Skip to content

Commit 999ba68

Browse files
authored
fix(YfmHtmlBlock): added YfmHtmlBlockOptions, added getSanitizeYfmHtmlBlock (#332)
1 parent 2d1afd5 commit 999ba68

File tree

7 files changed

+349
-15
lines changed

7 files changed

+349
-15
lines changed

demo/Playground.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, {CSSProperties, useCallback, useEffect, useState} from 'react';
22

3-
import sanitize from '@diplodoc/transform/lib/sanitize';
3+
import {defaultOptions} from '@diplodoc/transform/lib/sanitize';
44
import {Button, DropdownMenu} from '@gravity-ui/uikit';
55
import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18';
66

@@ -20,6 +20,7 @@ import {FoldingHeading} from '../src/extensions/yfm/FoldingHeading';
2020
import {Math} from '../src/extensions/yfm/Math';
2121
import {Mermaid} from '../src/extensions/yfm/Mermaid';
2222
import {YfmHtmlBlock} from '../src/extensions/yfm/YfmHtmlBlock';
23+
import {getSanitizeYfmHtmlBlock} from '../src/extensions/yfm/YfmHtmlBlock/utils';
2324
import {cloneDeep} from '../src/lodash';
2425
import type {FileUploadHandler} from '../src/utils/upload';
2526
import {VERSION} from '../src/version';
@@ -164,7 +165,13 @@ export const Playground = React.memo<PlaygroundProps>((props) => {
164165
})
165166
.use(YfmHtmlBlock, {
166167
useConfig: useYfmHtmlBlockStyles,
167-
sanitize,
168+
sanitize: getSanitizeYfmHtmlBlock({options: defaultOptions}),
169+
baseTarget: '_blank',
170+
styles: {
171+
body: {
172+
margin: 0,
173+
},
174+
},
168175
})
169176
.use(FoldingHeading),
170177
});

package-lock.json

Lines changed: 88 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@
200200
},
201201
"devDependencies": {
202202
"@diplodoc/folding-headings-extension": "0.1.0",
203-
"@diplodoc/html-extension": "1.3.1",
203+
"@diplodoc/html-extension": "1.3.3",
204204
"@diplodoc/latex-extension": "1.0.3",
205205
"@diplodoc/mermaid-extension": "1.2.1",
206206
"@diplodoc/transform": "4.22.0",
@@ -223,6 +223,7 @@
223223
"@types/react": "18.0.28",
224224
"@types/react-dom": "18.0.11",
225225
"@types/rimraf": "3.0.2",
226+
"@types/sanitize-html": "2.11.0",
226227
"bem-cn-lite": "4.1.0",
227228
"esbuild-sass-plugin": "2.15.0",
228229
"eslint": "8.56.0",

src/extensions/yfm/YfmHtmlBlock/YfmHtmlBlockNodeView/NodeView.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ export class WYfmHtmlBlockNodeView implements NodeView {
9898
getPos={this.getPos}
9999
node={this.node}
100100
onChange={this.onChange.bind(this)}
101-
sanitize={this.options.sanitize}
102-
useConfig={this.options.useConfig}
101+
options={this.options}
103102
view={this.view}
104103
/>,
105104
this.dom,

src/extensions/yfm/YfmHtmlBlock/YfmHtmlBlockNodeView/YfmHtmlBlockView.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, {useEffect, useRef, useState} from 'react';
22

3+
import {getStyles} from '@diplodoc/html-extension';
34
import type {IHTMLIFrameElementConfig} from '@diplodoc/html-extension/runtime';
45
import {Ellipsis as DotsIcon, Eye} from '@gravity-ui/icons';
56
import {Button, Icon, Label, Menu, Popup} from '@gravity-ui/uikit';
@@ -13,12 +14,13 @@ import {i18n} from '../../../../i18n/common';
1314
import {useBooleanState} from '../../../../react-utils/hooks';
1415
import {removeNode} from '../../../../utils/remove-node';
1516
import {YfmHtmlBlockConsts} from '../YfmHtmlBlockSpecs/const';
17+
import {YfmHtmlBlockOptions} from '../index';
18+
19+
import './YfmHtmlBlock.scss';
1620

1721
export const cnYfmHtmlBlock = cn('yfm-html-block');
1822
export const cnHelper = cn('yfm-html-block-helper');
1923

20-
import './YfmHtmlBlock.scss';
21-
2224
const b = cnYfmHtmlBlock;
2325

2426
interface YfmHtmlBlockViewProps {
@@ -202,10 +204,9 @@ export const YfmHtmlBlockView: React.FC<{
202204
getPos: () => number | undefined;
203205
node: Node;
204206
onChange: (attrs: {[YfmHtmlBlockConsts.NodeAttrs.srcdoc]: string}) => void;
205-
sanitize?: (dirtyHtml: string) => string;
206-
useConfig?: () => IHTMLIFrameElementConfig | undefined;
207+
options: YfmHtmlBlockOptions;
207208
view: EditorView;
208-
}> = ({onChange, node, getPos, view, useConfig, sanitize}) => {
209+
}> = ({onChange, node, getPos, view, options: {useConfig, sanitize, styles, baseTarget = '_'}}) => {
209210
const [editing, setEditing, unsetEditing, toggleEditing] = useBooleanState(
210211
Boolean(node.attrs[YfmHtmlBlockConsts.NodeAttrs.newCreated]),
211212
);
@@ -232,7 +233,17 @@ export const YfmHtmlBlockView: React.FC<{
232233
);
233234
}
234235

235-
const dirtyHtml = node.attrs[YfmHtmlBlockConsts.NodeAttrs.srcdoc];
236+
let dirtyHtml =
237+
`<base target="${baseTarget}">` + node.attrs[YfmHtmlBlockConsts.NodeAttrs.srcdoc];
238+
239+
if (styles) {
240+
const stylesContent =
241+
typeof styles === 'string'
242+
? `<link rel="stylesheet" href="${styles}" />`
243+
: `<style>${getStyles(styles)}</style>`;
244+
dirtyHtml = stylesContent + dirtyHtml;
245+
}
246+
236247
const html = sanitize ? sanitize(dirtyHtml) : dirtyHtml;
237248

238249
return (
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {SanitizeOptions} from '@diplodoc/transform/lib/sanitize';
2+
3+
import {getSanitizeYfmHtmlBlock, getYfmHtmlBlockOptions} from './utils'; // update the path accordingly
4+
5+
// remove all whitespaces and newline characters
6+
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim();
7+
8+
describe('sanitize options functions', () => {
9+
const defaultOptions: SanitizeOptions = {
10+
allowedTags: ['b', 'i', 'strong', 'em'],
11+
allowedAttributes: {
12+
a: ['href', 'name', 'target'],
13+
},
14+
cssWhiteList: {
15+
color: true,
16+
'font-weight': true,
17+
},
18+
};
19+
20+
it('should merge additional tags into default options', () => {
21+
const options = getYfmHtmlBlockOptions(defaultOptions);
22+
expect(options.allowedTags).toEqual(expect.arrayContaining(['link', 'base', 'style']));
23+
});
24+
25+
it('should merge additional attributes into default options', () => {
26+
const options = getYfmHtmlBlockOptions(defaultOptions);
27+
expect(options.allowedAttributes).toEqual({
28+
...defaultOptions.allowedAttributes,
29+
link: ['rel', 'href'],
30+
base: ['target'],
31+
style: [],
32+
});
33+
});
34+
35+
it('should merge additional css properties into default options', () => {
36+
const options = getYfmHtmlBlockOptions(defaultOptions);
37+
expect(options.cssWhiteList).toEqual({
38+
...defaultOptions.cssWhiteList,
39+
'align-content': true,
40+
'align-items': true,
41+
'align-self': true,
42+
color: true,
43+
'column-count': true,
44+
'column-fill': true,
45+
'column-gap': true,
46+
'column-rule': true,
47+
'column-rule-color': true,
48+
'column-rule-style': true,
49+
'column-rule-width': true,
50+
'column-span': true,
51+
'column-width': true,
52+
columns: true,
53+
flex: true,
54+
'flex-basis': true,
55+
'flex-direction': true,
56+
'flex-flow': true,
57+
'flex-grow': true,
58+
'flex-shrink': true,
59+
'flex-wrap': true,
60+
'font-weight': true,
61+
gap: true,
62+
grid: true,
63+
'grid-area': true,
64+
'grid-auto-columns': true,
65+
'grid-auto-flow': true,
66+
'grid-auto-rows': true,
67+
'grid-column': true,
68+
'grid-column-end': true,
69+
'grid-column-start': true,
70+
'grid-row': true,
71+
'grid-row-end': true,
72+
'grid-row-start': true,
73+
'grid-template': true,
74+
'grid-template-areas': true,
75+
'grid-template-columns': true,
76+
'grid-template-rows': true,
77+
'justify-content': true,
78+
'justify-items': true,
79+
'justify-self': true,
80+
'line-height': true,
81+
'object-fit': true,
82+
'object-position': true,
83+
order: true,
84+
orphans: true,
85+
'row-gap': true,
86+
});
87+
});
88+
});
89+
90+
describe('sanitize HTML function', () => {
91+
const options: SanitizeOptions = {
92+
allowedTags: ['b', 'i', 'strong', 'em'],
93+
allowedAttributes: {
94+
a: ['href', 'name', 'target'],
95+
},
96+
cssWhiteList: {
97+
color: true,
98+
'font-weight': true,
99+
},
100+
};
101+
102+
it('should sanitize HTML content with additional options', () => {
103+
const htmlContent = `
104+
<b>Bold</b>
105+
<i>Italic</i>
106+
<link href="styles.css" rel="stylesheet" />
107+
<base target="_blank" />
108+
<style>.example { flex: 1; columns: 1; }</style>
109+
`;
110+
111+
const sanitizeYfmHtmlBlock = getSanitizeYfmHtmlBlock({options});
112+
const sanitizedContent = normalizeWhitespace(sanitizeYfmHtmlBlock(htmlContent));
113+
114+
expect(sanitizedContent).toContain('<link href="styles.css" rel="stylesheet" />');
115+
expect(sanitizedContent).toContain('<base target="_blank" />');
116+
expect(sanitizedContent).toContain(
117+
normalizeWhitespace('<style>.example { flex: 1; columns: 1; }</style>'),
118+
);
119+
});
120+
121+
it('should sanitize HTML content using a custom sanitize function', () => {
122+
// example of custom sanitize logic
123+
const customSanitize = (html: string, _?: SanitizeOptions): string => {
124+
return html.replace(/<style.*<\/style>/, '<style></style>');
125+
};
126+
127+
const htmlContent = '<style>.example { flex: 1; columns: 1; }</style>';
128+
const expectedSanitizedContent = '<style></style>';
129+
130+
const sanitizeYfmHtmlBlock = getSanitizeYfmHtmlBlock({options, sanitize: customSanitize});
131+
const sanitizedContent = sanitizeYfmHtmlBlock(htmlContent);
132+
133+
expect(sanitizedContent).toEqual(expectedSanitizedContent);
134+
});
135+
});

0 commit comments

Comments
 (0)