Skip to content

Commit fbfab7c

Browse files
authored
fix(markdown-widget): support arbitrary component order (#5597)
1 parent f7f07c5 commit fbfab7c

File tree

5 files changed

+168
-35
lines changed

5 files changed

+168
-35
lines changed

packages/netlify-cms-core/src/valueObjects/EditorComponent.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export default function createEditorComponent(config) {
2828
type,
2929
icon,
3030
widget,
31-
// enforce multiline flag, exclude others
3231
pattern,
3332
fromBlock: bind(fromBlock) || (() => ({})),
3433
toBlock: bind(toBlock) || (() => 'Plugin'),

packages/netlify-cms-widget-markdown/src/MarkdownControl/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
33
import ImmutablePropTypes from 'react-immutable-proptypes';
4-
import { List } from 'immutable';
4+
import { List, Map } from 'immutable';
55

66
import RawEditor from './RawEditor';
77
import VisualEditor from './VisualEditor';
@@ -12,7 +12,7 @@ const MODE_STORAGE_KEY = 'cms.md-mode';
1212
// be handled through Redux and a separate registry store for instances
1313
let editorControl;
1414
// eslint-disable-next-line func-style
15-
let _getEditorComponents = () => [];
15+
let _getEditorComponents = () => Map();
1616

1717
export function getEditorControl() {
1818
return editorControl;
Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { remarkParseShortcodes } from '../remarkShortcodes';
1+
import { Map, OrderedMap } from 'immutable';
2+
3+
import { remarkParseShortcodes, getLinesWithOffsets } from '../remarkShortcodes';
24

35
// Stub of Remark Parser
46
function process(value, plugins, processEat = () => {}) {
@@ -14,43 +16,91 @@ function process(value, plugins, processEat = () => {}) {
1416
}
1517

1618
function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) {
17-
// initialize pattern as RegExp as done in the EditorComponent value object
18-
return { id, fromBlock, pattern: new RegExp(pattern, 'm') };
19+
return {
20+
id,
21+
fromBlock,
22+
pattern,
23+
};
1924
}
2025

2126
describe('remarkParseShortcodes', () => {
2227
describe('pattern matching', () => {
2328
it('should work', () => {
2429
const editorComponent = EditorComponent({ pattern: /bar/ });
25-
process('foo bar', [editorComponent]);
30+
process('foo bar', Map({ [editorComponent.id]: editorComponent }));
2631
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
2732
});
2833
it('should match value surrounded in newlines', () => {
2934
const editorComponent = EditorComponent({ pattern: /^bar$/ });
30-
process('foo\n\nbar\n', [editorComponent]);
35+
process('foo\n\nbar\n', Map({ [editorComponent.id]: editorComponent }));
3136
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
3237
});
3338
it('should match multiline shortcodes', () => {
3439
const editorComponent = EditorComponent({ pattern: /^foo\nbar$/ });
35-
process('foo\nbar', [editorComponent]);
40+
process('foo\nbar', Map({ [editorComponent.id]: editorComponent }));
3641
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo\nbar']));
3742
});
3843
it('should match multiline shortcodes with empty lines', () => {
3944
const editorComponent = EditorComponent({ pattern: /^foo\n\nbar$/ });
40-
process('foo\n\nbar', [editorComponent]);
45+
process('foo\n\nbar', Map({ [editorComponent.id]: editorComponent }));
4146
expect(editorComponent.fromBlock).toHaveBeenCalledWith(
4247
expect.arrayContaining(['foo\n\nbar']),
4348
);
4449
});
50+
it('should match shortcodes based on order of occurrence in value', () => {
51+
const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /foo/ });
52+
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
53+
process(
54+
'foo\n\nbar',
55+
OrderedMap([
56+
[barEditorComponent.id, barEditorComponent],
57+
[fooEditorComponent.id, fooEditorComponent],
58+
]),
59+
);
60+
expect(fooEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo']));
61+
});
62+
it('should match shortcodes based on order of occurrence in value even when some use line anchors', () => {
63+
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
64+
const bazEditorComponent = EditorComponent({ id: 'baz', pattern: /^baz$/ });
65+
process(
66+
'foo\n\nbar\n\nbaz',
67+
OrderedMap([
68+
[bazEditorComponent.id, bazEditorComponent],
69+
[barEditorComponent.id, barEditorComponent],
70+
]),
71+
);
72+
expect(barEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
73+
});
4574
});
4675
describe('output', () => {
4776
it('should be a remark shortcode node', () => {
4877
const processEat = jest.fn();
4978
const shortcodeData = { bar: 'baz' };
5079
const expectedNode = { type: 'shortcode', data: { shortcode: 'foo', shortcodeData } };
5180
const editorComponent = EditorComponent({ pattern: /bar/, fromBlock: () => shortcodeData });
52-
process('foo bar', [editorComponent], processEat);
81+
process('foo bar', Map({ [editorComponent.id]: editorComponent }), processEat);
5382
expect(processEat).toHaveBeenCalledWith(expectedNode);
5483
});
5584
});
5685
});
86+
87+
describe('getLinesWithOffsets', () => {
88+
test('should split into lines', () => {
89+
const value = ' line1\n\nline2 \n\n line3 \n\n';
90+
91+
const lines = getLinesWithOffsets(value);
92+
expect(lines).toEqual([
93+
{ line: ' line1', start: 0 },
94+
{ line: 'line2', start: 8 },
95+
{ line: ' line3', start: 16 },
96+
{ line: '', start: 30 },
97+
]);
98+
});
99+
100+
test('should return single item on no match', () => {
101+
const value = ' line1 ';
102+
103+
const lines = getLinesWithOffsets(value);
104+
expect(lines).toEqual([{ line: ' line1', start: 0 }]);
105+
});
106+
});

packages/netlify-cms-widget-markdown/src/serializers/remarkShortcodes.js

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,63 @@ export function remarkParseShortcodes({ plugins }) {
88
methods.unshift('shortcode');
99
}
1010

11+
export function getLinesWithOffsets(value) {
12+
const SEPARATOR = '\n\n';
13+
const splitted = value.split(SEPARATOR);
14+
const trimmedLines = splitted
15+
.reduce(
16+
(acc, line) => {
17+
const { start: previousLineStart, originalLength: previousLineOriginalLength } =
18+
acc[acc.length - 1];
19+
20+
return [
21+
...acc,
22+
{
23+
line: line.trimEnd(),
24+
start: previousLineStart + previousLineOriginalLength + SEPARATOR.length,
25+
originalLength: line.length,
26+
},
27+
];
28+
},
29+
[{ start: -SEPARATOR.length, originalLength: 0 }],
30+
)
31+
.slice(1)
32+
.map(({ line, start }) => ({ line, start }));
33+
return trimmedLines;
34+
}
35+
36+
function matchFromLines({ trimmedLines, plugin }) {
37+
for (const { line, start } of trimmedLines) {
38+
const match = line.match(plugin.pattern);
39+
if (match) {
40+
match.index += start;
41+
return match;
42+
}
43+
}
44+
}
45+
1146
function createShortcodeTokenizer({ plugins }) {
1247
return function tokenizeShortcode(eat, value, silent) {
13-
let match;
14-
const potentialMatchValue = value.split('\n\n')[0].trimEnd();
15-
const plugin = plugins.find(plugin => {
16-
match = value.match(plugin.pattern);
17-
18-
if (!match) {
19-
match = potentialMatchValue.match(plugin.pattern);
20-
}
48+
// Plugin patterns may rely on `^` and `$` tokens, even if they don't
49+
// use the multiline flag. To support this, we fall back to searching
50+
// through each line individually, trimming trailing whitespace and
51+
// newlines, if we don't initially match on a pattern. We keep track of
52+
// the starting position of each line so that we can sort correctly
53+
// across the full multiline matches.
54+
const trimmedLines = getLinesWithOffsets(value);
2155

22-
return !!match;
23-
});
56+
// Attempt to find a regex match for each plugin's pattern, and then
57+
// select the first by its occurrence in `value`. This ensures we won't
58+
// skip a plugin that occurs later in the plugin registry, but earlier
59+
// in the `value`.
60+
const [{ plugin, match } = {}] = plugins
61+
.toArray()
62+
.map(plugin => ({
63+
match: value.match(plugin.pattern) || matchFromLines({ trimmedLines, plugin }),
64+
plugin,
65+
}))
66+
.filter(({ match }) => !!match)
67+
.sort((a, b) => a.match.index - b.match.index);
2468

2569
if (match) {
2670
if (silent) {

website/content/docs/custom-widgets.md

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -118,29 +118,69 @@ CMS.registerEditorComponent(definition)
118118
<script>
119119
CMS.registerEditorComponent({
120120
// Internal id of the component
121-
id: "youtube",
121+
id: "collapsible-note",
122122
// Visible label
123-
label: "Youtube",
123+
label: "Collapsible Note",
124124
// Fields the user need to fill out when adding an instance of the component
125-
fields: [{name: 'id', label: 'Youtube Video ID', widget: 'string'}],
126-
// Pattern to identify a block as being an instance of this component
127-
pattern: /^youtube (\S+)$/,
128-
// Function to extract data elements from the regexp match
125+
fields: [
126+
{
127+
name: 'summary',
128+
label: 'Summary',
129+
widget: 'string'
130+
},
131+
{
132+
name: 'details',
133+
label: 'Details',
134+
widget: 'markdown'
135+
}
136+
],
137+
// Regex pattern used to search for instances of this block in the markdown document.
138+
// Patterns are run in a multline environment (against the entire markdown document),
139+
// and so generally should make use of the multiline flag (`m`). If you need to capture
140+
// newlines in your capturing groups, you can either use something like
141+
// `([\S\s]*)`, or you can additionally enable the "dot all" flag (`s`),
142+
// which will cause `(.*)` to match newlines as well.
143+
//
144+
// Additionally, it's recommended that you use non-greedy capturing groups (e.g.
145+
// `(.*?)` vs `(.*)`), especially if matching against newline characters.
146+
pattern: /^<details>$\s*?<summary>(.*?)<\/summary>\n\n(.*?)\n^<\/details>$/ms,
147+
// Given a RegExp Match object
148+
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match#return_value),
149+
// return an object with one property for each field defined in `fields`.
150+
//
151+
// This is used to populate the custom widget in the markdown editor in the CMS.
129152
fromBlock: function(match) {
130153
return {
131-
id: match[1]
154+
summary: match[1],
155+
detail: match[2]
132156
};
133157
},
134-
// Function to create a text block from an instance of this component
135-
toBlock: function(obj) {
136-
return 'youtube ' + obj.id;
158+
// Given an object with one property for each field defined in `fields`,
159+
// return the string you wish to be inserted into your markdown.
160+
//
161+
// This is used to serialize the data from the custom widget to the
162+
// markdown document
163+
toBlock: function(data) {
164+
return `
165+
<details>
166+
<summary>${data.summary}</summary>
167+
168+
${data.detail}
169+
170+
</details>
171+
`;
137172
},
138173
// Preview output for this component. Can either be a string or a React component
139174
// (component gives better render performance)
140-
toPreview: function(obj) {
141-
return (
142-
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
143-
);
175+
toPreview: function(data) {
176+
return `
177+
<details>
178+
<summary>${data.summary}</summary>
179+
180+
${data.detail}
181+
182+
</details>
183+
`;
144184
}
145185
});
146186
</script>

0 commit comments

Comments
 (0)