decap-cms-widget-markdown 3.12.0 → 3.13.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/decap-cms-widget-markdown.js +6 -6
  3. package/dist/decap-cms-widget-markdown.js.map +1 -1
  4. package/dist/esm/MarkdownControl/Toolbar.js +6 -6
  5. package/dist/esm/MarkdownControl/VisualEditor.js +7 -3
  6. package/dist/esm/MarkdownControl/components/InlineShortcode.js +67 -0
  7. package/dist/esm/MarkdownControl/index.js +2 -2
  8. package/dist/esm/MarkdownControl/plugins/shortcodes/insertShortcode.js +53 -4
  9. package/dist/esm/MarkdownControl/plugins/shortcodes/withShortcodes.js +11 -1
  10. package/dist/esm/MarkdownControl/renderers.js +21 -16
  11. package/dist/esm/MarkdownPreview.js +37 -3
  12. package/dist/esm/serializers/remarkRehypeShortcodes.js +18 -15
  13. package/dist/esm/serializers/remarkShortcodes.js +118 -12
  14. package/dist/esm/serializers/remarkSlate.js +14 -1
  15. package/dist/esm/serializers/slateRemark.js +18 -2
  16. package/package.json +23 -13
  17. package/src/MarkdownControl/Toolbar.js +2 -2
  18. package/src/MarkdownControl/VisualEditor.js +5 -1
  19. package/src/MarkdownControl/components/InlineShortcode.js +77 -0
  20. package/src/MarkdownControl/components/__tests__/InlineShortcode.spec.js +81 -0
  21. package/src/MarkdownControl/index.js +2 -2
  22. package/src/MarkdownControl/plugins/shortcodes/__tests__/insertShortcode.spec.js +106 -0
  23. package/src/MarkdownControl/plugins/shortcodes/insertShortcode.js +59 -6
  24. package/src/MarkdownControl/plugins/shortcodes/withShortcodes.js +12 -2
  25. package/src/MarkdownControl/renderers.js +3 -0
  26. package/src/MarkdownPreview.js +38 -3
  27. package/src/__tests__/renderer.spec.js +68 -0
  28. package/src/serializers/__tests__/remarkShortcodes.spec.js +210 -1
  29. package/src/serializers/__tests__/slate.spec.js +80 -0
  30. package/src/serializers/remarkRehypeShortcodes.js +19 -9
  31. package/src/serializers/remarkShortcodes.js +140 -12
  32. package/src/serializers/remarkSlate.js +7 -0
  33. package/src/serializers/slateRemark.js +13 -2
  34. package/LICENSE +0 -22
  35. package/dist/esm/MarkdownControl/plugins/BreakToDefaultBlock.js +0 -26
  36. package/dist/esm/MarkdownControl/plugins/CloseBlock.js +0 -28
  37. package/dist/esm/MarkdownControl/plugins/CommandsAndQueries.js +0 -180
  38. package/dist/esm/MarkdownControl/plugins/ForceInsert.js +0 -42
  39. package/dist/esm/MarkdownControl/plugins/Hotkey.js +0 -26
  40. package/dist/esm/MarkdownControl/plugins/LineBreak.js +0 -13
  41. package/dist/esm/MarkdownControl/plugins/Link.js +0 -49
  42. package/dist/esm/MarkdownControl/plugins/lists/locations/isCursorInItemContainingNestedList.js +0 -6
  43. package/dist/esm/MarkdownControl/plugins/util.js +0 -11
  44. package/dist/esm/serializers/remarkImagesToText.js +0 -31
  45. package/dist/esm/types.js +0 -2
@@ -0,0 +1,106 @@
1
+ import { createEditor, Editor, Transforms } from 'slate';
2
+ import { withReact } from 'slate-react';
3
+
4
+ import withShortcodes from '../withShortcodes';
5
+ import insertShortcode from '../insertShortcode';
6
+
7
+ function makeEditor(
8
+ initialChildren = [{ type: 'paragraph', children: [{ text: 'Sample text' }] }],
9
+ ) {
10
+ const editor = withReact(withShortcodes(createEditor()));
11
+ editor.children = initialChildren;
12
+ return editor;
13
+ }
14
+
15
+ describe('insertShortcode', () => {
16
+ it('should insert inline shortcode with onInsert resolving data', async () => {
17
+ const editor = makeEditor();
18
+ editor.selection = {
19
+ anchor: { path: [0, 0], offset: 0 },
20
+ focus: { path: [0, 0], offset: 6 },
21
+ }; // Selected "Sample"
22
+
23
+ const onInsertMock = jest.fn().mockResolvedValue({ target: 'doc-page', label: 'Sample' });
24
+
25
+ const pluginConfig = {
26
+ id: 'wikilink',
27
+ type: 'inline',
28
+ onInsert: onInsertMock,
29
+ };
30
+
31
+ await insertShortcode(editor, pluginConfig, { contextKey: 'val' });
32
+
33
+ expect(onInsertMock).toHaveBeenCalledWith({
34
+ selectedText: 'Sample',
35
+ cmsContext: { contextKey: 'val' },
36
+ });
37
+
38
+ const insertedNode = editor.children[0].children.find(
39
+ child => child.type === 'inline-shortcode',
40
+ );
41
+ expect(insertedNode).toBeDefined();
42
+ expect(insertedNode.data).toEqual({
43
+ shortcode: 'wikilink',
44
+ shortcodeNew: true,
45
+ shortcodeData: { target: 'doc-page', label: 'Sample' },
46
+ isVoid: true,
47
+ });
48
+ });
49
+
50
+ it('should cancel inline shortcode insertion when onInsert resolves null', async () => {
51
+ const editor = makeEditor();
52
+ editor.selection = {
53
+ anchor: { path: [0, 0], offset: 0 },
54
+ focus: { path: [0, 0], offset: 6 },
55
+ };
56
+
57
+ const onInsertMock = jest.fn().mockResolvedValue(null);
58
+
59
+ const pluginConfig = {
60
+ id: 'wikilink',
61
+ type: 'inline',
62
+ onInsert: onInsertMock,
63
+ };
64
+
65
+ await insertShortcode(editor, pluginConfig);
66
+
67
+ expect(onInsertMock).toHaveBeenCalled();
68
+ const insertedNode = editor.children[0].children.find(
69
+ child => child.type === 'inline-shortcode',
70
+ );
71
+ expect(insertedNode).toBeUndefined();
72
+ });
73
+
74
+ it('should insert at the captured selection after onInsert changes focus', async () => {
75
+ const editor = makeEditor();
76
+ Transforms.select(editor, {
77
+ anchor: { path: [0, 0], offset: 0 },
78
+ focus: { path: [0, 0], offset: 6 },
79
+ });
80
+ let resolveInsert;
81
+ const onInsert = jest.fn(
82
+ () =>
83
+ new Promise(resolve => {
84
+ resolveInsert = resolve;
85
+ }),
86
+ );
87
+
88
+ const insertion = insertShortcode(editor, {
89
+ id: 'wikilink',
90
+ type: 'inline',
91
+ onInsert,
92
+ });
93
+ Transforms.select(editor, { path: [0, 0], offset: 11 });
94
+ resolveInsert({ target: 'doc-page' });
95
+ await insertion;
96
+
97
+ expect(editor.children[0].children[1].type).toBe('inline-shortcode');
98
+ expect(Editor.string(editor, [0])).toBe(' text');
99
+ });
100
+
101
+ it('should keep inline shortcodes atomic when isVoid is false', () => {
102
+ const editor = makeEditor();
103
+
104
+ expect(editor.isVoid({ type: 'inline-shortcode', data: { isVoid: false } })).toBe(true);
105
+ });
106
+ });
@@ -1,12 +1,65 @@
1
- import { Transforms } from 'slate';
1
+ import { Editor, Range, Transforms } from 'slate';
2
2
 
3
3
  import isCursorInEmptyParagraph from './locations/isCursorInEmptyParagraph';
4
4
 
5
- function insertShortcode(editor, pluginConfig) {
5
+ async function insertShortcode(editor, pluginConfig, cmsContext = {}) {
6
+ if (pluginConfig.type === 'inline') {
7
+ const selectionRef = editor.selection ? Editor.rangeRef(editor, editor.selection) : null;
8
+ let selectedText = '';
9
+ if (editor.selection && Range.isRange(editor.selection)) {
10
+ selectedText = Editor.string(editor, editor.selection);
11
+ }
12
+
13
+ let shortcodeData = {};
14
+
15
+ if (typeof pluginConfig.onInsert === 'function') {
16
+ try {
17
+ const result = await pluginConfig.onInsert({ selectedText, cmsContext });
18
+ if (result === null || result === undefined) {
19
+ selectionRef?.unref();
20
+ return;
21
+ }
22
+ shortcodeData = result;
23
+ } catch (err) {
24
+ selectionRef?.unref();
25
+ console.error(`Error in onInsert for inline component '${pluginConfig.id}':`, err);
26
+ return;
27
+ }
28
+ } else if (pluginConfig.fields) {
29
+ const defaultValues = pluginConfig.fields
30
+ .toMap()
31
+ .mapKeys((_, field) => field.get('name'))
32
+ .map(field => field.get('default', ''));
33
+ shortcodeData = defaultValues.toJS();
34
+ }
35
+
36
+ const nodeData = {
37
+ type: 'inline-shortcode',
38
+ id: pluginConfig.id,
39
+ data: {
40
+ shortcode: pluginConfig.id,
41
+ shortcodeNew: true,
42
+ shortcodeData,
43
+ isVoid: true,
44
+ },
45
+ children: [{ text: '' }],
46
+ };
47
+
48
+ const at = selectionRef?.unref();
49
+ if (selectionRef && !at) {
50
+ return;
51
+ }
52
+ Transforms.insertNodes(editor, nodeData, at ? { at } : undefined);
53
+ return;
54
+ }
55
+
6
56
  const defaultValues = pluginConfig.fields
7
- .toMap()
8
- .mapKeys((_, field) => field.get('name'))
9
- .map(field => field.get('default', ''));
57
+ ? pluginConfig.fields
58
+ .toMap()
59
+ .mapKeys((_, field) => field.get('name'))
60
+ .map(field => field.get('default', ''))
61
+ .toJS()
62
+ : {};
10
63
 
11
64
  const nodeData = {
12
65
  type: 'shortcode',
@@ -14,7 +67,7 @@ function insertShortcode(editor, pluginConfig) {
14
67
  data: {
15
68
  shortcode: pluginConfig.id,
16
69
  shortcodeNew: true,
17
- shortcodeData: defaultValues.toJS(),
70
+ shortcodeData: defaultValues,
18
71
  },
19
72
  children: [{ text: '' }],
20
73
  };
@@ -3,10 +3,20 @@ import { Editor, Transforms } from 'slate';
3
3
  import defaultEmptyBlock from '../blocks/defaultEmptyBlock';
4
4
 
5
5
  function withShortcodes(editor) {
6
- const { isVoid, normalizeNode } = editor;
6
+ const { isVoid, isInline, normalizeNode } = editor;
7
7
 
8
8
  editor.isVoid = element => {
9
- return element.type === 'shortcode' ? true : isVoid(element);
9
+ if (element.type === 'shortcode') {
10
+ return true;
11
+ }
12
+ if (element.type === 'inline-shortcode') {
13
+ return true;
14
+ }
15
+ return isVoid(element);
16
+ };
17
+
18
+ editor.isInline = element => {
19
+ return element.type === 'inline-shortcode' ? true : isInline(element);
10
20
  };
11
21
 
12
22
  // Prevent empty editor after deleting shortcode theat was only child
@@ -6,6 +6,7 @@ import { useSelected } from 'slate-react';
6
6
 
7
7
  import VoidBlock from './components/VoidBlock';
8
8
  import Shortcode from './components/Shortcode';
9
+ import InlineShortcode from './components/InlineShortcode';
9
10
 
10
11
  const bottomMargin = '16px';
11
12
 
@@ -350,6 +351,8 @@ export function Element(props) {
350
351
  <Shortcode {...props}>{children}</Shortcode>
351
352
  </VoidBlock>
352
353
  );
354
+ case 'inline-shortcode':
355
+ return <InlineShortcode {...props} />;
353
356
  default:
354
357
  return <Paragraph style={style}>{children}</Paragraph>;
355
358
  }
@@ -1,11 +1,46 @@
1
- import React from 'react';
1
+ import { Component } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  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
+ // Editors preview a selected-but-not-yet-committed image via URL.createObjectURL(), which
9
+ // produces a blob: URL - DOMPurify's default ALLOWED_URI_REGEXP doesn't include that scheme,
10
+ // so it strips `src` here even though the image is entirely local and safe.
11
+ //
12
+ // Widening ALLOWED_URI_REGEXP itself (as an earlier version of this fix did) allows blob: on
13
+ // every URI-bearing attribute DOMPurify checks - not just <img src>, but also <a href>,
14
+ // <form action>, etc. - which is a materially larger relaxation than this bug needs. A
15
+ // uponSanitizeAttribute hook scopes the exception to exactly <img src>; every other
16
+ // attribute/tag keeps DOMPurify's default (blob:-excluding) behaviour. The hook is added and
17
+ // removed around a single sanitize() call so it can never affect any other consumer of the
18
+ // shared `dompurify` module import elsewhere in the app.
19
+ //
20
+ // The allowed value is further restricted to this document's own origin: a blob: URL embeds
21
+ // the origin of the tab that created it (`blob:<origin>/<uuid>`) and a browser already refuses
22
+ // to dereference one minted by a different origin, so this check cannot relax anything a
23
+ // browser wouldn't already block - it only means a value that could not possibly be one of
24
+ // *this* CMS instance's own not-yet-committed asset previews is rejected before ever reaching
25
+ // the DOM, rather than being handed to the browser to fail on.
26
+ function isSameOriginBlobUrl(value) {
27
+ return value.startsWith(`blob:${window.location.origin}/`);
28
+ }
29
+
30
+ function sanitizePreviewHtml(html) {
31
+ DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
32
+ if (node.nodeName === 'IMG' && data.attrName === 'src' && isSameOriginBlobUrl(data.attrValue)) {
33
+ data.forceKeepAttr = true;
34
+ }
35
+ });
36
+ try {
37
+ return DOMPurify.sanitize(html);
38
+ } finally {
39
+ DOMPurify.removeHook('uponSanitizeAttribute');
40
+ }
41
+ }
42
+
43
+ class MarkdownPreview extends Component {
9
44
  static propTypes = {
10
45
  getAsset: PropTypes.func.isRequired,
11
46
  resolveWidget: PropTypes.func.isRequired,
@@ -25,7 +60,7 @@ class MarkdownPreview extends React.Component {
25
60
 
26
61
  const html = markdownToHtml(value, { getAsset, resolveWidget }, getRemarkPlugins?.());
27
62
  const shouldSanitizePreview = field?.get('sanitize_preview') ?? true;
28
- const toRender = shouldSanitizePreview ? DOMPurify.sanitize(html) : html;
63
+ const toRender = shouldSanitizePreview ? sanitizePreviewHtml(html) : html;
29
64
 
30
65
  return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: toRender }} />;
31
66
  }
@@ -207,6 +207,74 @@ I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
207
207
  expect(img).not.toHaveAttribute('onerror');
208
208
  });
209
209
 
210
+ it('should preserve same-origin blob: URLs used for not-yet-committed asset previews', () => {
211
+ // Editors preview a selected-but-not-yet-committed image via URL.createObjectURL(),
212
+ // which produces a blob: URL - DOMPurify's default ALLOWED_URI_REGEXP doesn't include
213
+ // that scheme, so it silently stripped `src` here once sanitize_preview defaulted to
214
+ // true, even though the image itself is entirely local and safe. A real
215
+ // URL.createObjectURL() call always embeds the current document's own origin, so the
216
+ // fixture must too - a cross-origin value isn't something this exception should (or, per
217
+ // the test below, does) preserve.
218
+ const blobUrl = `blob:${window.location.origin}/1234-5678-90ab`;
219
+ const value = `<img src="${blobUrl}">`;
220
+ const field = Map({ sanitize_preview: true });
221
+
222
+ const { container } = render(
223
+ <MarkdownPreview
224
+ value={value}
225
+ getAsset={jest.fn()}
226
+ resolveWidget={jest.fn()}
227
+ field={field}
228
+ />,
229
+ );
230
+ const img = container.querySelector('img');
231
+ expect(img).toHaveAttribute('src', blobUrl);
232
+ });
233
+
234
+ it('should NOT preserve blob: URLs on non-<img> attributes', () => {
235
+ // The blob: exception above must be scoped to <img src>. Widening DOMPurify's global
236
+ // ALLOWED_URI_REGEXP instead of using a node-scoped hook would let blob: through on any
237
+ // URI-bearing attribute - including <a href> and <form action>, both of which could be
238
+ // used to smuggle attacker-controlled blob content (see PR review discussion). Uses a
239
+ // same-origin blob: URL so this test isolates the tag/attribute scoping specifically,
240
+ // independent of the separate cross-origin check below.
241
+ const blobUrl = `blob:${window.location.origin}/should-be-stripped`;
242
+ const value = [`<a href="${blobUrl}">click</a>`, `<form action="${blobUrl}"></form>`].join(
243
+ '',
244
+ );
245
+ const field = Map({ sanitize_preview: true });
246
+
247
+ const { container } = render(
248
+ <MarkdownPreview
249
+ value={value}
250
+ getAsset={jest.fn()}
251
+ resolveWidget={jest.fn()}
252
+ field={field}
253
+ />,
254
+ );
255
+ expect(container.querySelector('a')).not.toHaveAttribute('href');
256
+ expect(container.querySelector('form')).not.toHaveAttribute('action');
257
+ });
258
+
259
+ it('should NOT preserve a cross-origin blob: URL even on <img src>', () => {
260
+ // A blob: URL embeds the origin that created it and a browser already refuses to
261
+ // dereference one from a different origin, but the sanitizer should reject it outright
262
+ // rather than pass through a value that could not be one of this CMS instance's own
263
+ // asset previews.
264
+ const value = '<img src="blob:https://attacker.example/1234-5678-90ab">';
265
+ const field = Map({ sanitize_preview: true });
266
+
267
+ const { container } = render(
268
+ <MarkdownPreview
269
+ value={value}
270
+ getAsset={jest.fn()}
271
+ resolveWidget={jest.fn()}
272
+ field={field}
273
+ />,
274
+ );
275
+ expect(container.querySelector('img')).not.toHaveAttribute('src');
276
+ });
277
+
210
278
  it('should sanitize dangerous link protocols', () => {
211
279
  const value = '<a href="javascript:alert(1)">click</a>';
212
280
 
@@ -1,8 +1,9 @@
1
1
  import { Map, OrderedMap } from 'immutable';
2
2
  import unified from 'unified';
3
3
  import markdownToRemarkPlugin from 'remark-parse';
4
+ import remarkToMarkdownPlugin from 'remark-stringify';
4
5
 
5
- import { remarkParseShortcodes } from '../remarkShortcodes';
6
+ import { remarkParseShortcodes, createRemarkShortcodeStringifier } from '../remarkShortcodes';
6
7
 
7
8
  function process(value, plugins) {
8
9
  return unified()
@@ -11,6 +12,14 @@ function process(value, plugins) {
11
12
  .parse(value);
12
13
  }
13
14
 
15
+ function stringify(mdast, plugins) {
16
+ return unified()
17
+ .use(remarkToMarkdownPlugin, { commonmark: true })
18
+ .use(createRemarkShortcodeStringifier({ plugins }))
19
+ .stringify(mdast)
20
+ .trim();
21
+ }
22
+
14
23
  function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) {
15
24
  return {
16
25
  id,
@@ -103,6 +112,206 @@ describe('remarkParseShortcodes', () => {
103
112
  expect(removePositions(mdast)).toMatchSnapshot();
104
113
  });
105
114
  });
115
+ describe('inline shortcodes', () => {
116
+ it('should parse inline shortcode inside paragraph without breaking paragraph into blocks', () => {
117
+ const inlineComponent = {
118
+ id: 'ref',
119
+ type: 'inline',
120
+ pattern: /\{\{<\s*ref\s+"(?<target>[^"]+)"\s*>\}\}/,
121
+ fromInline: match => ({ target: match.groups.target }),
122
+ toInline: data => `{{< ref "${data.target}" >}}`,
123
+ };
124
+
125
+ const mdast = process(
126
+ 'Hello {{< ref "about" >}} world',
127
+ Map({ [inlineComponent.id]: inlineComponent }),
128
+ );
129
+
130
+ const stripped = removePositions(mdast);
131
+ expect(stripped).toEqual({
132
+ type: 'root',
133
+ children: [
134
+ {
135
+ type: 'paragraph',
136
+ children: [
137
+ { type: 'text', value: 'Hello ' },
138
+ {
139
+ type: 'inline-shortcode',
140
+ data: {
141
+ shortcode: 'ref',
142
+ shortcodeData: { target: 'about' },
143
+ isVoid: true,
144
+ },
145
+ },
146
+ { type: 'text', value: ' world' },
147
+ ],
148
+ },
149
+ ],
150
+ });
151
+ });
152
+
153
+ it('should parse adjacent CJK characters and punctuation correctly', () => {
154
+ const wikilinkComponent = {
155
+ id: 'wikilink',
156
+ type: 'inline',
157
+ trigger: '[',
158
+ pattern: /\[\[(?<target>[^\]]+)\]\]/,
159
+ fromInline: match => ({ target: match.groups.target }),
160
+ toInline: data => `[[${data.target}]]`,
161
+ };
162
+
163
+ const mdast = process(
164
+ '這是一個[[測試頁面]],請點擊!',
165
+ Map({ [wikilinkComponent.id]: wikilinkComponent }),
166
+ );
167
+
168
+ const stripped = removePositions(mdast);
169
+ expect(stripped).toEqual({
170
+ type: 'root',
171
+ children: [
172
+ {
173
+ type: 'paragraph',
174
+ children: [
175
+ { type: 'text', value: '這是一個' },
176
+ {
177
+ type: 'inline-shortcode',
178
+ data: {
179
+ shortcode: 'wikilink',
180
+ shortcodeData: { target: '測試頁面' },
181
+ isVoid: true,
182
+ },
183
+ },
184
+ { type: 'text', value: ',請點擊!' },
185
+ ],
186
+ },
187
+ ],
188
+ });
189
+ });
190
+
191
+ it('should parse multiple inline shortcodes within single paragraph', () => {
192
+ const tagComponent = {
193
+ id: 'tag',
194
+ type: 'inline',
195
+ trigger: '#',
196
+ pattern: /#(?<name>[a-zA-Z0-9_-]+)/,
197
+ fromInline: match => ({ name: match.groups.name }),
198
+ toInline: data => `#${data.name}`,
199
+ };
200
+
201
+ const mdast = process(
202
+ 'Tags: #react and #decap are cool',
203
+ Map({ [tagComponent.id]: tagComponent }),
204
+ );
205
+
206
+ const stripped = removePositions(mdast);
207
+ expect(stripped.children[0].children).toHaveLength(5);
208
+ expect(stripped.children[0].children[1]).toEqual({
209
+ type: 'inline-shortcode',
210
+ data: {
211
+ shortcode: 'tag',
212
+ shortcodeData: { name: 'react' },
213
+ isVoid: true,
214
+ },
215
+ });
216
+ expect(stripped.children[0].children[3]).toEqual({
217
+ type: 'inline-shortcode',
218
+ data: {
219
+ shortcode: 'tag',
220
+ shortcodeData: { name: 'decap' },
221
+ isVoid: true,
222
+ },
223
+ });
224
+ });
225
+
226
+ it.each([undefined, '['])(
227
+ 'should preserve captures for global patterns with trigger %s',
228
+ trigger => {
229
+ const wikilinkComponent = {
230
+ id: 'wikilink',
231
+ type: 'inline',
232
+ trigger,
233
+ pattern: /\[\[(?<target>[^\]]+)\]\]/g,
234
+ fromInline: match => ({ target: match.groups.target }),
235
+ };
236
+
237
+ const mdast = process(
238
+ 'Before [[target]] after',
239
+ Map({ [wikilinkComponent.id]: wikilinkComponent }),
240
+ );
241
+
242
+ expect(removePositions(mdast).children[0].children[1]).toEqual({
243
+ type: 'inline-shortcode',
244
+ data: {
245
+ shortcode: 'wikilink',
246
+ shortcodeData: { target: 'target' },
247
+ isVoid: true,
248
+ },
249
+ });
250
+ },
251
+ );
252
+
253
+ it('should stringify inline shortcodes correctly in round-trip', () => {
254
+ const inlineComponent = {
255
+ id: 'ref',
256
+ type: 'inline',
257
+ pattern: /\{\{<\s*ref\s+"(?<target>[^"]+)"\s*>\}\}/,
258
+ fromInline: match => ({ target: match.groups.target }),
259
+ toInline: data => `{{< ref "${data.target}" >}}`,
260
+ };
261
+
262
+ const input = 'Hello {{< ref "about" >}} world';
263
+ const plugins = Map({ [inlineComponent.id]: inlineComponent });
264
+ const mdast = process(input, plugins);
265
+ const output = stringify(mdast, plugins);
266
+
267
+ expect(output).toEqual(input);
268
+ });
269
+
270
+ it('should warn when inline component pattern has greedy quantifier', () => {
271
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
272
+ const inlineComponent = {
273
+ id: 'greedy-ref',
274
+ type: 'inline',
275
+ pattern: /\{\{< ref (.+) >\}\}/,
276
+ fromInline: match => ({ target: match[1] }),
277
+ toInline: data => `{{< ref ${data.target} >}}`,
278
+ };
279
+
280
+ process('text', Map({ [inlineComponent.id]: inlineComponent }));
281
+ expect(warnSpy).toHaveBeenCalledWith(
282
+ expect.stringContaining('Potentially greedy RegExp in inline component'),
283
+ );
284
+ warnSpy.mockRestore();
285
+ });
286
+
287
+ it('should handle inline shortcodes nested inside bold text', () => {
288
+ const badgeComponent = {
289
+ id: 'badge',
290
+ type: 'inline',
291
+ pattern: /\[badge:(?<text>[^\]]+)\]/,
292
+ fromInline: match => ({ text: match.groups.text }),
293
+ toInline: data => `[badge:${data.text}]`,
294
+ };
295
+
296
+ const input = '**Important [badge:NEW] Note**';
297
+ const plugins = Map({ [badgeComponent.id]: badgeComponent });
298
+ const mdast = process(input, plugins);
299
+ const stripped = removePositions(mdast);
300
+
301
+ expect(stripped.children[0].children[0].type).toBe('strong');
302
+ const strongChildren = stripped.children[0].children[0].children;
303
+ expect(strongChildren[0]).toEqual({ type: 'text', value: 'Important ' });
304
+ expect(strongChildren[1]).toEqual({
305
+ type: 'inline-shortcode',
306
+ data: {
307
+ shortcode: 'badge',
308
+ shortcodeData: { text: 'NEW' },
309
+ isVoid: true,
310
+ },
311
+ });
312
+ expect(strongChildren[2]).toEqual({ type: 'text', value: ' Note' });
313
+ });
314
+ });
106
315
  });
107
316
 
108
317
  function removePositions(obj) {
@@ -6,6 +6,7 @@ import flow from 'lodash/flow';
6
6
  // eslint-disable-next-line no-unused-vars
7
7
  import h from '../../../test-helpers/h';
8
8
  import { markdownToSlate, slateToMarkdown } from '../index';
9
+ import slateToRemark from '../slateRemark';
9
10
 
10
11
  const process = flow([markdownToSlate, slateToMarkdown]);
11
12
 
@@ -55,6 +56,44 @@ describe('slate', () => {
55
56
  expect(process('*a \nb*')).toEqual('*a\\\nb*');
56
57
  });
57
58
 
59
+ it('should preserve marks around inline shortcodes', () => {
60
+ const marks = [{ type: 'bold' }];
61
+ const mdast = slateToRemark(
62
+ [
63
+ {
64
+ type: 'paragraph',
65
+ children: [
66
+ { text: 'Important ', bold: true, marks },
67
+ {
68
+ type: 'inline-shortcode',
69
+ data: {
70
+ shortcode: 'badge',
71
+ shortcodeData: { text: 'NEW' },
72
+ marks,
73
+ },
74
+ children: [{ text: '' }],
75
+ },
76
+ { text: ' Note', bold: true, marks },
77
+ ],
78
+ },
79
+ ],
80
+ {},
81
+ );
82
+
83
+ expect(mdast.children[0].children).toHaveLength(1);
84
+ expect(mdast.children[0].children[0]).toMatchObject({
85
+ type: 'strong',
86
+ children: [
87
+ { type: 'html', value: 'Important ' },
88
+ {
89
+ type: 'inline-shortcode',
90
+ data: { shortcode: 'badge', shortcodeData: { text: 'NEW' } },
91
+ },
92
+ { type: 'html', value: ' Note' },
93
+ ],
94
+ });
95
+ });
96
+
58
97
  // slateAst no longer valid
59
98
 
60
99
  it('should not output empty headers in markdown', () => {
@@ -299,4 +338,45 @@ describe('slate', () => {
299
338
  expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"*h~~e**l**l~~o*"`);
300
339
  });
301
340
  });
341
+
342
+ describe('inline-shortcode', () => {
343
+ it('should convert inline-shortcode between Slate and MDAST', () => {
344
+ const slateAst = (
345
+ <editor>
346
+ <element type="paragraph">
347
+ <text>Hello </text>
348
+ <element
349
+ type="inline-shortcode"
350
+ data={{
351
+ shortcode: 'ref',
352
+ shortcodeData: { target: 'about' },
353
+ }}
354
+ >
355
+ <text></text>
356
+ </element>
357
+ <text> world</text>
358
+ </element>
359
+ </editor>
360
+ );
361
+
362
+ const refPlugin = {
363
+ id: 'ref',
364
+ type: 'inline',
365
+ pattern: /\{\{<\s*ref\s+"(?<target>[^"]+)"\s*>\}\}/,
366
+ fromInline: match => ({ target: match.groups.target }),
367
+ toInline: data => `{{< ref "${data.target}" >}}`,
368
+ };
369
+
370
+ const markdown = slateToMarkdown(slateAst.children, {
371
+ remarkPlugins: [
372
+ function () {
373
+ this.Compiler.prototype.visitors['inline-shortcode'] = node =>
374
+ refPlugin.toInline(node.data.shortcodeData);
375
+ },
376
+ ],
377
+ });
378
+
379
+ expect(markdown).toEqual('Hello {{< ref "about" >}} world');
380
+ });
381
+ });
302
382
  });