decap-cms-widget-markdown 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/decap-cms-widget-markdown.js +4 -4
- package/dist/decap-cms-widget-markdown.js.LICENSE.txt +0 -2
- package/dist/decap-cms-widget-markdown.js.map +1 -1
- package/dist/esm/MarkdownControl/index.js +14 -2
- package/dist/esm/MarkdownControl/plugins/html/withHtml.js +45 -10
- package/dist/esm/MarkdownControl/plugins/shortcodes/insertShortcode.js +1 -1
- package/dist/esm/MarkdownControl/renderers.js +19 -19
- package/dist/esm/MarkdownPreview.js +2 -1
- package/dist/esm/serializers/index.js +19 -3
- package/dist/esm/serializers/remarkRehypeShortcodes.js +26 -3
- package/dist/esm/serializers/remarkShortcodes.js +18 -41
- package/package.json +4 -3
- package/src/MarkdownControl/index.js +13 -2
- package/src/MarkdownControl/plugins/html/__tests__/withHtml.spec.js +67 -0
- package/src/MarkdownControl/plugins/html/withHtml.js +51 -4
- package/src/MarkdownControl/plugins/shortcodes/insertShortcode.js +1 -2
- package/src/MarkdownControl/renderers.js +1 -1
- package/src/MarkdownPreview.js +3 -1
- package/src/__tests__/renderer.spec.js +10 -0
- package/src/serializers/__tests__/index.spec.js +15 -0
- package/src/serializers/__tests__/remarkShortcodes.spec.js +16 -38
- package/src/serializers/index.js +19 -1
- package/src/serializers/remarkRehypeShortcodes.js +32 -3
- package/src/serializers/remarkShortcodes.js +29 -46
|
@@ -29,7 +29,8 @@ class MarkdownPreview extends React.Component {
|
|
|
29
29
|
getAsset,
|
|
30
30
|
resolveWidget
|
|
31
31
|
}, getRemarkPlugins?.());
|
|
32
|
-
const
|
|
32
|
+
const shouldSanitizePreview = field?.get('sanitize_preview') ?? true;
|
|
33
|
+
const toRender = shouldSanitizePreview ? DOMPurify.sanitize(html) : html;
|
|
33
34
|
return ___EmotionJSX(WidgetPreviewContainer, {
|
|
34
35
|
dangerouslySetInnerHTML: {
|
|
35
36
|
__html: toRender
|
|
@@ -7,6 +7,7 @@ import remarkToRehype from 'remark-rehype';
|
|
|
7
7
|
import rehypeToHtml from 'rehype-stringify';
|
|
8
8
|
import htmlToRehype from 'rehype-parse';
|
|
9
9
|
import rehypeToRemark from 'rehype-remark';
|
|
10
|
+
import rehypeRemoveComments from 'rehype-remove-comments';
|
|
10
11
|
import remarkToRehypeShortcodes from './remarkRehypeShortcodes';
|
|
11
12
|
import rehypePaperEmoji from './rehypePaperEmoji';
|
|
12
13
|
import remarkAssertParents from './remarkAssertParents';
|
|
@@ -154,10 +155,23 @@ export function markdownToHtml(markdown, {
|
|
|
154
155
|
remarkPlugins = []
|
|
155
156
|
} = {}) {
|
|
156
157
|
const mdast = markdownToRemark(markdown, remarkPlugins);
|
|
158
|
+
const editorComponents = getEditorComponents();
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Provide a `toHtml` callback so `remarkToRehypeShortcodes` can recursively
|
|
162
|
+
* render markdown/richtext sub-fields of container editor components.
|
|
163
|
+
*/
|
|
164
|
+
function toHtml(md) {
|
|
165
|
+
return markdownToHtml(md, {
|
|
166
|
+
getAsset,
|
|
167
|
+
resolveWidget
|
|
168
|
+
});
|
|
169
|
+
}
|
|
157
170
|
const hast = unified().use(remarkToRehypeShortcodes, {
|
|
158
|
-
plugins:
|
|
171
|
+
plugins: editorComponents,
|
|
159
172
|
getAsset,
|
|
160
|
-
resolveWidget
|
|
173
|
+
resolveWidget,
|
|
174
|
+
toHtml
|
|
161
175
|
}).use(remarkToRehype, {
|
|
162
176
|
allowDangerousHTML: true
|
|
163
177
|
}).runSync(mdast);
|
|
@@ -180,7 +194,9 @@ export function htmlToSlate(html) {
|
|
|
180
194
|
const hast = unified().use(htmlToRehype, {
|
|
181
195
|
fragment: true
|
|
182
196
|
}).parse(html);
|
|
183
|
-
const mdast = unified().use(rehypePaperEmoji).use(
|
|
197
|
+
const mdast = unified().use(rehypePaperEmoji).use(rehypeRemoveComments, {
|
|
198
|
+
removeConditional: true
|
|
199
|
+
}).use(rehypeToRemark, {
|
|
184
200
|
minify: false
|
|
185
201
|
}).runSync(hast);
|
|
186
202
|
const slateRaw = unified().use(remarkAssertParents).use(remarkPaddedLinks).use(remarkWrapHtml).use(remarkToSlate).runSync(mdast);
|
|
@@ -13,7 +13,8 @@ import u from 'unist-builder';
|
|
|
13
13
|
export default function remarkToRehypeShortcodes({
|
|
14
14
|
plugins,
|
|
15
15
|
getAsset,
|
|
16
|
-
resolveWidget
|
|
16
|
+
resolveWidget,
|
|
17
|
+
toHtml
|
|
17
18
|
}) {
|
|
18
19
|
return transform;
|
|
19
20
|
function transform(root) {
|
|
@@ -68,13 +69,35 @@ export default function remarkToRehypeShortcodes({
|
|
|
68
69
|
function getPreview(plugin, shortcodeData) {
|
|
69
70
|
const {
|
|
70
71
|
toPreview,
|
|
71
|
-
widget,
|
|
72
72
|
fields
|
|
73
73
|
} = plugin;
|
|
74
74
|
if (toPreview) {
|
|
75
75
|
return toPreview(shortcodeData, getAsset, fields);
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* For editor components without a custom `toPreview` (e.g. container
|
|
80
|
+
* components with nested markdown/richtext fields), render each sub-field
|
|
81
|
+
* value using the appropriate widget preview.
|
|
82
|
+
*/
|
|
83
|
+
if (fields && fields.size > 0 && toHtml) {
|
|
84
|
+
const htmlParts = fields.map(field => {
|
|
85
|
+
const name = field.get('name');
|
|
86
|
+
const widget = field.get('widget') || 'string';
|
|
87
|
+
const fieldValue = shortcodeData ? shortcodeData[name] : '';
|
|
88
|
+
if (!fieldValue) return '';
|
|
89
|
+
if (widget === 'markdown' || widget === 'richtext') {
|
|
90
|
+
return toHtml(fieldValue);
|
|
91
|
+
}
|
|
92
|
+
return `<p>${fieldValue}</p>`;
|
|
93
|
+
}).toArray();
|
|
94
|
+
return htmlParts.join('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Last resort fallback: try resolving the widget and rendering its preview.
|
|
99
|
+
*/
|
|
100
|
+
const preview = resolveWidget(plugin.widget);
|
|
78
101
|
return /*#__PURE__*/React.createElement(preview.preview, {
|
|
79
102
|
value: shortcodeData,
|
|
80
103
|
field: plugin,
|
|
@@ -9,60 +9,37 @@ export function remarkParseShortcodes({
|
|
|
9
9
|
});
|
|
10
10
|
methods.unshift('shortcode');
|
|
11
11
|
}
|
|
12
|
-
export function getLinesWithOffsets(value) {
|
|
13
|
-
const SEPARATOR = '\n\n';
|
|
14
|
-
const splitted = value.split(SEPARATOR);
|
|
15
|
-
const trimmedLines = splitted.reduce((acc, line) => {
|
|
16
|
-
const {
|
|
17
|
-
start: previousLineStart,
|
|
18
|
-
originalLength: previousLineOriginalLength
|
|
19
|
-
} = acc[acc.length - 1];
|
|
20
|
-
return [...acc, {
|
|
21
|
-
line: line.trimEnd(),
|
|
22
|
-
start: previousLineStart + previousLineOriginalLength + SEPARATOR.length,
|
|
23
|
-
originalLength: line.length
|
|
24
|
-
}];
|
|
25
|
-
}, [{
|
|
26
|
-
start: -SEPARATOR.length,
|
|
27
|
-
originalLength: 0
|
|
28
|
-
}]).slice(1).map(({
|
|
29
|
-
line,
|
|
30
|
-
start
|
|
31
|
-
}) => ({
|
|
32
|
-
line,
|
|
33
|
-
start
|
|
34
|
-
}));
|
|
35
|
-
return trimmedLines;
|
|
36
|
-
}
|
|
37
12
|
function createShortcodeTokenizer({
|
|
38
13
|
plugins
|
|
39
14
|
}) {
|
|
15
|
+
plugins.forEach(plugin => {
|
|
16
|
+
if (plugin.pattern.flags.includes('m')) {
|
|
17
|
+
console.warn(`Invalid RegExp: editor component '${plugin.id}' must not use the multiline flag in its pattern.`);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
40
20
|
return function tokenizeShortcode(eat, value, silent) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// in the `value`.
|
|
45
|
-
const [{
|
|
46
|
-
plugin,
|
|
47
|
-
match
|
|
48
|
-
} = {}] = plugins.toArray().map(plugin => {
|
|
21
|
+
let match;
|
|
22
|
+
const potentialMatchValue = value.split('\n\n')[0].trimEnd();
|
|
23
|
+
const plugin = plugins.find(plugin => {
|
|
49
24
|
let {
|
|
50
25
|
pattern
|
|
51
26
|
} = plugin;
|
|
52
|
-
// Plugin patterns must start with a caret (^) to match the beginning of the
|
|
27
|
+
// Plugin patterns must start with a caret (^) to match the beginning of the block.
|
|
53
28
|
// If the pattern does not start with a caret, we add it
|
|
54
29
|
// to ensure that remark consumes only the shortcode, without any leading text.
|
|
55
30
|
if (!pattern.source.startsWith('^')) {
|
|
56
31
|
pattern = new RegExp(`^${pattern.source}`, pattern.flags);
|
|
57
32
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}) => !!match).sort((a, b) => a.match.index - b.match.index);
|
|
33
|
+
match = value.match(pattern);
|
|
34
|
+
if (!match) {
|
|
35
|
+
match = potentialMatchValue.match(pattern);
|
|
36
|
+
}
|
|
37
|
+
return !!match;
|
|
38
|
+
});
|
|
65
39
|
if (match) {
|
|
40
|
+
if (match.index > 0) {
|
|
41
|
+
console.warn(`Invalid RegExp: editor component '${plugin.id}' must match from the beginning of the block.`);
|
|
42
|
+
}
|
|
66
43
|
if (silent) {
|
|
67
44
|
return true;
|
|
68
45
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decap-cms-widget-markdown",
|
|
3
3
|
"description": "Widget for editing markdown in Decap CMS.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.9.0",
|
|
5
5
|
"homepage": "https://www.decapcms.org/docs/widgets/#markdown",
|
|
6
6
|
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown",
|
|
7
7
|
"bugs": "https://github.com/decaporg/decap-cms/issues",
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"detab": "^2.0.4",
|
|
25
|
-
"dompurify": "^3.
|
|
25
|
+
"dompurify": "^3.4.0",
|
|
26
26
|
"is-hotkey": "^0.2.0",
|
|
27
27
|
"is-url": "^1.2.4",
|
|
28
28
|
"mdast-util-definitions": "^1.2.3",
|
|
29
29
|
"mdast-util-to-string": "^1.0.5",
|
|
30
30
|
"rehype-parse": "^6.0.0",
|
|
31
31
|
"rehype-remark": "^8.0.0",
|
|
32
|
+
"rehype-remove-comments": "^4.0.2",
|
|
32
33
|
"rehype-stringify": "^7.0.0",
|
|
33
34
|
"remark-parse": "^6.0.3",
|
|
34
35
|
"remark-rehype": "^4.0.0",
|
|
@@ -63,5 +64,5 @@
|
|
|
63
64
|
"commonmark": "^0.30.0",
|
|
64
65
|
"commonmark-spec": "^0.30.0"
|
|
65
66
|
},
|
|
66
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "ae01ad6f2b139f93472c8f0d049d35277d8583ca"
|
|
67
68
|
}
|
|
@@ -47,7 +47,11 @@ export default class MarkdownControl extends React.Component {
|
|
|
47
47
|
_getEditorComponents = props.getEditorComponents;
|
|
48
48
|
this.state = {
|
|
49
49
|
mode:
|
|
50
|
-
|
|
50
|
+
// When used inside a container/shortcode editor component, default to
|
|
51
|
+
// raw mode — the widget type already implies the editing surface.
|
|
52
|
+
props.isEditorComponent
|
|
53
|
+
? 'raw'
|
|
54
|
+
: this.getAllowedModes().indexOf(preferredMode) !== -1
|
|
51
55
|
? preferredMode
|
|
52
56
|
: this.getAllowedModes()[0],
|
|
53
57
|
pendingFocus: false,
|
|
@@ -57,6 +61,12 @@ export default class MarkdownControl extends React.Component {
|
|
|
57
61
|
componentDidMount() {
|
|
58
62
|
// Manually validate PropTypes - React 19 breaking change
|
|
59
63
|
PropTypes.checkPropTypes(MarkdownControl.propTypes, this.props, 'prop', 'MarkdownControl');
|
|
64
|
+
|
|
65
|
+
// Ensure containerised widgets start in the correct mode even if the
|
|
66
|
+
// constructor ran before the prop was available (e.g. HMR / late prop).
|
|
67
|
+
if (this.props.isEditorComponent && this.state.mode !== 'raw') {
|
|
68
|
+
this.setState({ mode: 'raw' });
|
|
69
|
+
}
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
handleMode = mode => {
|
|
@@ -92,7 +102,8 @@ export default class MarkdownControl extends React.Component {
|
|
|
92
102
|
} = this.props;
|
|
93
103
|
|
|
94
104
|
const { mode, pendingFocus } = this.state;
|
|
95
|
-
const
|
|
105
|
+
const isEditorComponent = this.props.isEditorComponent;
|
|
106
|
+
const isShowModeToggle = this.getAllowedModes().length > 1 && !isEditorComponent;
|
|
96
107
|
const visualEditor = (
|
|
97
108
|
<div className="cms-editor-visual" ref={this.processRef}>
|
|
98
109
|
<VisualEditor
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Transforms } from 'slate';
|
|
2
|
+
|
|
3
|
+
import withHtml from '../withHtml';
|
|
4
|
+
|
|
5
|
+
describe('withHtml', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
jest.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function createEditor() {
|
|
11
|
+
return {
|
|
12
|
+
insertData: jest.fn(),
|
|
13
|
+
isInline: jest.fn(() => false),
|
|
14
|
+
isVoid: jest.fn(() => false),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createDataTransfer(html) {
|
|
19
|
+
return {
|
|
20
|
+
getData: jest.fn(type => (type === 'text/html' ? html : '')),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('should unwrap links with dangerous protocols', () => {
|
|
25
|
+
const editor = withHtml(createEditor());
|
|
26
|
+
const insertFragmentSpy = jest.spyOn(Transforms, 'insertFragment').mockImplementation(() => {});
|
|
27
|
+
|
|
28
|
+
editor.insertData(createDataTransfer('<p><a href="javascript:alert(1)">click me</a></p>'));
|
|
29
|
+
|
|
30
|
+
expect(insertFragmentSpy).toHaveBeenCalledWith(editor, [
|
|
31
|
+
{
|
|
32
|
+
type: 'paragraph',
|
|
33
|
+
children: [{ text: 'click me' }],
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should drop images with dangerous protocols', () => {
|
|
39
|
+
const editor = withHtml(createEditor());
|
|
40
|
+
const insertFragmentSpy = jest.spyOn(Transforms, 'insertFragment').mockImplementation(() => {});
|
|
41
|
+
|
|
42
|
+
editor.insertData(createDataTransfer('<p>before<img src="javascript:alert(1)">after</p>'));
|
|
43
|
+
|
|
44
|
+
expect(insertFragmentSpy).toHaveBeenCalledWith(editor, [
|
|
45
|
+
{
|
|
46
|
+
type: 'paragraph',
|
|
47
|
+
children: [{ text: 'beforeafter' }],
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should keep safe image URLs', () => {
|
|
53
|
+
const editor = withHtml(createEditor());
|
|
54
|
+
const insertFragmentSpy = jest.spyOn(Transforms, 'insertFragment').mockImplementation(() => {});
|
|
55
|
+
|
|
56
|
+
editor.insertData(createDataTransfer('<p><img src="https://example.com/image.png"></p>'));
|
|
57
|
+
|
|
58
|
+
expect(insertFragmentSpy).toHaveBeenCalledWith(editor, [
|
|
59
|
+
{
|
|
60
|
+
type: 'paragraph',
|
|
61
|
+
children: [
|
|
62
|
+
{ type: 'image', url: 'https://example.com/image.png', children: [{ text: '' }] },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
// source: https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/paste-html.tsx
|
|
2
|
+
import DOMPurify from 'dompurify';
|
|
2
3
|
import { jsx } from 'slate-hyperscript';
|
|
3
4
|
import { Transforms } from 'slate';
|
|
4
5
|
|
|
6
|
+
function sanitizeElementUrl(url, { allowDataImage = false } = {}) {
|
|
7
|
+
if (!url) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const trimmed = url.trim();
|
|
12
|
+
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const normalized = trimmed.replace(/\s+/g, '').toLowerCase();
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
normalized.startsWith('javascript:') ||
|
|
21
|
+
normalized.startsWith('vbscript:') ||
|
|
22
|
+
normalized.startsWith('file:')
|
|
23
|
+
) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (normalized.startsWith('data:')) {
|
|
28
|
+
return allowDataImage && /^data:image\/(?!svg\+xml)[a-z0-9.+-]+[;,]/i.test(normalized)
|
|
29
|
+
? trimmed
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
|
35
|
+
|
|
5
36
|
const ELEMENT_TAGS = {
|
|
6
|
-
A: el =>
|
|
37
|
+
A: el => {
|
|
38
|
+
const url = sanitizeElementUrl(el.getAttribute('href'));
|
|
39
|
+
return url ? { type: 'link', url } : undefined;
|
|
40
|
+
},
|
|
7
41
|
BLOCKQUOTE: () => ({ type: 'quote' }),
|
|
8
42
|
H1: () => ({ type: 'heading-one' }),
|
|
9
43
|
H2: () => ({ type: 'heading-two' }),
|
|
@@ -11,7 +45,10 @@ const ELEMENT_TAGS = {
|
|
|
11
45
|
H4: () => ({ type: 'heading-four' }),
|
|
12
46
|
H5: () => ({ type: 'heading-five' }),
|
|
13
47
|
H6: () => ({ type: 'heading-six' }),
|
|
14
|
-
IMG: el =>
|
|
48
|
+
IMG: el => {
|
|
49
|
+
const url = sanitizeElementUrl(el.getAttribute('src'), { allowDataImage: true });
|
|
50
|
+
return url ? { type: 'image', url } : null;
|
|
51
|
+
},
|
|
15
52
|
LI: () => ({ type: 'list-item' }),
|
|
16
53
|
OL: () => ({ type: 'numbered-list' }),
|
|
17
54
|
P: () => ({ type: 'paragraph' }),
|
|
@@ -37,7 +74,7 @@ const INLINE_STYLES = {
|
|
|
37
74
|
|
|
38
75
|
function deserialize(el) {
|
|
39
76
|
if (el.nodeType === 3) {
|
|
40
|
-
return el.textContent;
|
|
77
|
+
return el.textContent.replace(/(\r)?\n/g, '');
|
|
41
78
|
} else if (el.nodeType !== 1) {
|
|
42
79
|
return null;
|
|
43
80
|
} else if (el.nodeName === 'BR') {
|
|
@@ -62,6 +99,15 @@ function deserialize(el) {
|
|
|
62
99
|
|
|
63
100
|
if (ELEMENT_TAGS[nodeName]) {
|
|
64
101
|
const attrs = ELEMENT_TAGS[nodeName](el);
|
|
102
|
+
|
|
103
|
+
if (attrs === undefined) {
|
|
104
|
+
return children;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (attrs === null) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
65
111
|
return jsx('element', attrs, children);
|
|
66
112
|
}
|
|
67
113
|
|
|
@@ -102,7 +148,8 @@ function withHtml(editor) {
|
|
|
102
148
|
const html = data.getData('text/html');
|
|
103
149
|
|
|
104
150
|
if (html) {
|
|
105
|
-
const
|
|
151
|
+
const sanitizedHtml = DOMPurify.sanitize(html);
|
|
152
|
+
const parsed = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
|
|
106
153
|
const fragment = deserialize(parsed.body);
|
|
107
154
|
Transforms.insertFragment(editor, fragment);
|
|
108
155
|
return;
|
|
@@ -6,8 +6,7 @@ function insertShortcode(editor, pluginConfig) {
|
|
|
6
6
|
const defaultValues = pluginConfig.fields
|
|
7
7
|
.toMap()
|
|
8
8
|
.mapKeys((_, field) => field.get('name'))
|
|
9
|
-
.
|
|
10
|
-
.map(field => field.get('default'));
|
|
9
|
+
.map(field => field.get('default', ''));
|
|
11
10
|
|
|
12
11
|
const nodeData = {
|
|
13
12
|
type: 'shortcode',
|
package/src/MarkdownPreview.js
CHANGED
|
@@ -4,6 +4,7 @@ import { WidgetPreviewContainer } from 'decap-cms-ui-default';
|
|
|
4
4
|
import DOMPurify from 'dompurify';
|
|
5
5
|
|
|
6
6
|
import { markdownToHtml } from './serializers';
|
|
7
|
+
|
|
7
8
|
class MarkdownPreview extends React.Component {
|
|
8
9
|
static propTypes = {
|
|
9
10
|
getAsset: PropTypes.func.isRequired,
|
|
@@ -23,7 +24,8 @@ class MarkdownPreview extends React.Component {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const html = markdownToHtml(value, { getAsset, resolveWidget }, getRemarkPlugins?.());
|
|
26
|
-
const
|
|
27
|
+
const shouldSanitizePreview = field?.get('sanitize_preview') ?? true;
|
|
28
|
+
const toRender = shouldSanitizePreview ? DOMPurify.sanitize(html) : html;
|
|
27
29
|
|
|
28
30
|
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: toRender }} />;
|
|
29
31
|
}
|
|
@@ -208,6 +208,16 @@ I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
|
|
|
208
208
|
expect(img).not.toHaveAttribute('onerror');
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
it('should sanitize dangerous link protocols', () => {
|
|
212
|
+
const value = '<a href="javascript:alert(1)">click</a>';
|
|
213
|
+
|
|
214
|
+
const { container } = render(
|
|
215
|
+
<MarkdownPreview value={value} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
|
|
216
|
+
);
|
|
217
|
+
const link = container.querySelector('a');
|
|
218
|
+
expect(link).not.toHaveAttribute('href');
|
|
219
|
+
});
|
|
220
|
+
|
|
211
221
|
it('should not sanitize HTML', async () => {
|
|
212
222
|
const value = `<img src="foobar.png" onerror="alert('hello')">`;
|
|
213
223
|
const field = Map({ sanitize_preview: false });
|
|
@@ -49,4 +49,19 @@ describe('htmlToSlate', () => {
|
|
|
49
49
|
],
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
it('should remove HTML comments', () => {
|
|
54
|
+
const html = `<!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]--><span>regular text</span>`;
|
|
55
|
+
|
|
56
|
+
const actual = htmlToSlate(html);
|
|
57
|
+
expect(actual).toEqual({
|
|
58
|
+
type: 'root',
|
|
59
|
+
children: [
|
|
60
|
+
{
|
|
61
|
+
type: 'paragraph',
|
|
62
|
+
children: [{ text: 'regular text' }],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
});
|
|
52
67
|
});
|
|
@@ -2,7 +2,7 @@ import { Map, OrderedMap } from 'immutable';
|
|
|
2
2
|
import unified from 'unified';
|
|
3
3
|
import markdownToRemarkPlugin from 'remark-parse';
|
|
4
4
|
|
|
5
|
-
import { remarkParseShortcodes
|
|
5
|
+
import { remarkParseShortcodes } from '../remarkShortcodes';
|
|
6
6
|
|
|
7
7
|
function process(value, plugins) {
|
|
8
8
|
return unified()
|
|
@@ -33,30 +33,29 @@ describe('remarkParseShortcodes', () => {
|
|
|
33
33
|
expect.arrayContaining(['foo\n\nbar']),
|
|
34
34
|
);
|
|
35
35
|
});
|
|
36
|
-
it('should match shortcodes
|
|
37
|
-
const fooEditorComponent = EditorComponent({ id: 'foo', pattern:
|
|
38
|
-
const barEditorComponent = EditorComponent({ id: 'bar', pattern:
|
|
36
|
+
it('should match shortcodes by first matching plugin', () => {
|
|
37
|
+
const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /^foo/ });
|
|
38
|
+
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /^bar/ });
|
|
39
39
|
process(
|
|
40
|
-
'
|
|
40
|
+
'bar\n\nfoo',
|
|
41
41
|
OrderedMap([
|
|
42
|
-
[barEditorComponent.id, barEditorComponent],
|
|
43
42
|
[fooEditorComponent.id, fooEditorComponent],
|
|
44
|
-
]),
|
|
45
|
-
);
|
|
46
|
-
expect(fooEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo']));
|
|
47
|
-
});
|
|
48
|
-
it('should match shortcodes based on order of occurrence in value even when some use line anchors', () => {
|
|
49
|
-
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
|
|
50
|
-
const bazEditorComponent = EditorComponent({ id: 'baz', pattern: /^baz$/ });
|
|
51
|
-
process(
|
|
52
|
-
'foo\n\nbar\n\nbaz',
|
|
53
|
-
OrderedMap([
|
|
54
|
-
[bazEditorComponent.id, bazEditorComponent],
|
|
55
43
|
[barEditorComponent.id, barEditorComponent],
|
|
56
44
|
]),
|
|
57
45
|
);
|
|
46
|
+
// 'bar' is the first block, but 'foo' plugin is first in registry,
|
|
47
|
+
// so 'foo' doesn't match 'bar'. 'bar' plugin matches 'bar'.
|
|
58
48
|
expect(barEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
|
|
59
49
|
});
|
|
50
|
+
it('should warn when pattern uses multiline flag', () => {
|
|
51
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
52
|
+
const editorComponent = EditorComponent({ pattern: /^foo$/m });
|
|
53
|
+
process('foo', Map({ [editorComponent.id]: editorComponent }));
|
|
54
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
55
|
+
expect.stringContaining('must not use the multiline flag'),
|
|
56
|
+
);
|
|
57
|
+
warnSpy.mockRestore();
|
|
58
|
+
});
|
|
60
59
|
});
|
|
61
60
|
describe('parse', () => {
|
|
62
61
|
describe('pattern with leading caret', () => {
|
|
@@ -122,24 +121,3 @@ describe('remarkParseShortcodes', () => {
|
|
|
122
121
|
return obj;
|
|
123
122
|
}
|
|
124
123
|
});
|
|
125
|
-
|
|
126
|
-
describe('getLinesWithOffsets', () => {
|
|
127
|
-
test('should split into lines', () => {
|
|
128
|
-
const value = ' line1\n\nline2 \n\n line3 \n\n';
|
|
129
|
-
|
|
130
|
-
const lines = getLinesWithOffsets(value);
|
|
131
|
-
expect(lines).toEqual([
|
|
132
|
-
{ line: ' line1', start: 0 },
|
|
133
|
-
{ line: 'line2', start: 8 },
|
|
134
|
-
{ line: ' line3', start: 16 },
|
|
135
|
-
{ line: '', start: 30 },
|
|
136
|
-
]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test('should return single item on no match', () => {
|
|
140
|
-
const value = ' line1 ';
|
|
141
|
-
|
|
142
|
-
const lines = getLinesWithOffsets(value);
|
|
143
|
-
expect(lines).toEqual([{ line: ' line1', start: 0 }]);
|
|
144
|
-
});
|
|
145
|
-
});
|
package/src/serializers/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import remarkToRehype from 'remark-rehype';
|
|
|
7
7
|
import rehypeToHtml from 'rehype-stringify';
|
|
8
8
|
import htmlToRehype from 'rehype-parse';
|
|
9
9
|
import rehypeToRemark from 'rehype-remark';
|
|
10
|
+
import rehypeRemoveComments from 'rehype-remove-comments';
|
|
10
11
|
|
|
11
12
|
import remarkToRehypeShortcodes from './remarkRehypeShortcodes';
|
|
12
13
|
import rehypePaperEmoji from './rehypePaperEmoji';
|
|
@@ -157,8 +158,23 @@ export function remarkToMarkdown(obj, remarkPlugins) {
|
|
|
157
158
|
export function markdownToHtml(markdown, { getAsset, resolveWidget, remarkPlugins = [] } = {}) {
|
|
158
159
|
const mdast = markdownToRemark(markdown, remarkPlugins);
|
|
159
160
|
|
|
161
|
+
const editorComponents = getEditorComponents();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Provide a `toHtml` callback so `remarkToRehypeShortcodes` can recursively
|
|
165
|
+
* render markdown/richtext sub-fields of container editor components.
|
|
166
|
+
*/
|
|
167
|
+
function toHtml(md) {
|
|
168
|
+
return markdownToHtml(md, { getAsset, resolveWidget });
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
const hast = unified()
|
|
161
|
-
.use(remarkToRehypeShortcodes, {
|
|
172
|
+
.use(remarkToRehypeShortcodes, {
|
|
173
|
+
plugins: editorComponents,
|
|
174
|
+
getAsset,
|
|
175
|
+
resolveWidget,
|
|
176
|
+
toHtml,
|
|
177
|
+
})
|
|
162
178
|
.use(remarkToRehype, { allowDangerousHTML: true })
|
|
163
179
|
.runSync(mdast);
|
|
164
180
|
|
|
@@ -183,6 +199,7 @@ export function htmlToSlate(html) {
|
|
|
183
199
|
|
|
184
200
|
const mdast = unified()
|
|
185
201
|
.use(rehypePaperEmoji)
|
|
202
|
+
.use(rehypeRemoveComments, { removeConditional: true })
|
|
186
203
|
.use(rehypeToRemark, { minify: false })
|
|
187
204
|
.runSync(hast);
|
|
188
205
|
|
|
@@ -222,5 +239,6 @@ export function markdownToSlate(markdown, { voidCodeBlock, remarkPlugins = [] }
|
|
|
222
239
|
export function slateToMarkdown(raw, { voidCodeBlock, remarkPlugins = [] } = {}) {
|
|
223
240
|
const mdast = slateToRemark(raw, { voidCodeBlock });
|
|
224
241
|
const markdown = remarkToMarkdown(mdast, remarkPlugins);
|
|
242
|
+
|
|
225
243
|
return markdown;
|
|
226
244
|
}
|
|
@@ -10,7 +10,7 @@ import u from 'unist-builder';
|
|
|
10
10
|
* conversion by replacing the shortcode text with stringified HTML for
|
|
11
11
|
* previewing the shortcode output.
|
|
12
12
|
*/
|
|
13
|
-
export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) {
|
|
13
|
+
export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget, toHtml }) {
|
|
14
14
|
return transform;
|
|
15
15
|
|
|
16
16
|
function transform(root) {
|
|
@@ -54,11 +54,40 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid
|
|
|
54
54
|
* Retrieve the shortcode preview component.
|
|
55
55
|
*/
|
|
56
56
|
function getPreview(plugin, shortcodeData) {
|
|
57
|
-
const { toPreview,
|
|
57
|
+
const { toPreview, fields } = plugin;
|
|
58
58
|
if (toPreview) {
|
|
59
59
|
return toPreview(shortcodeData, getAsset, fields);
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* For editor components without a custom `toPreview` (e.g. container
|
|
64
|
+
* components with nested markdown/richtext fields), render each sub-field
|
|
65
|
+
* value using the appropriate widget preview.
|
|
66
|
+
*/
|
|
67
|
+
if (fields && fields.size > 0 && toHtml) {
|
|
68
|
+
const htmlParts = fields
|
|
69
|
+
.map(field => {
|
|
70
|
+
const name = field.get('name');
|
|
71
|
+
const widget = field.get('widget') || 'string';
|
|
72
|
+
const fieldValue = shortcodeData ? shortcodeData[name] : '';
|
|
73
|
+
|
|
74
|
+
if (!fieldValue) return '';
|
|
75
|
+
|
|
76
|
+
if (widget === 'markdown' || widget === 'richtext') {
|
|
77
|
+
return toHtml(fieldValue);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `<p>${fieldValue}</p>`;
|
|
81
|
+
})
|
|
82
|
+
.toArray();
|
|
83
|
+
|
|
84
|
+
return htmlParts.join('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Last resort fallback: try resolving the widget and rendering its preview.
|
|
89
|
+
*/
|
|
90
|
+
const preview = resolveWidget(plugin.widget);
|
|
62
91
|
return React.createElement(preview.preview, {
|
|
63
92
|
value: shortcodeData,
|
|
64
93
|
field: plugin,
|