From fbfab7cda54aba68c948188d0ad5660431d275fc Mon Sep 17 00:00:00 2001 From: SMores <5354254+SMores@users.noreply.github.com> Date: Tue, 17 Aug 2021 06:01:52 -0400 Subject: [PATCH] fix(markdown-widget): support arbitrary component order (#5597) --- .../src/valueObjects/EditorComponent.js | 1 - .../src/MarkdownControl/index.js | 4 +- .../__tests__/remarkShortcodes.spec.js | 66 +++++++++++++++--- .../src/serializers/remarkShortcodes.js | 64 ++++++++++++++--- website/content/docs/custom-widgets.md | 68 +++++++++++++++---- 5 files changed, 168 insertions(+), 35 deletions(-) diff --git a/packages/netlify-cms-core/src/valueObjects/EditorComponent.js b/packages/netlify-cms-core/src/valueObjects/EditorComponent.js index cc7a99f2..710f3470 100644 --- a/packages/netlify-cms-core/src/valueObjects/EditorComponent.js +++ b/packages/netlify-cms-core/src/valueObjects/EditorComponent.js @@ -28,7 +28,6 @@ export default function createEditorComponent(config) { type, icon, widget, - // enforce multiline flag, exclude others pattern, fromBlock: bind(fromBlock) || (() => ({})), toBlock: bind(toBlock) || (() => 'Plugin'), diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/index.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/index.js index f8b1f247..d89b1998 100644 --- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/index.js +++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { List } from 'immutable'; +import { List, Map } from 'immutable'; import RawEditor from './RawEditor'; import VisualEditor from './VisualEditor'; @@ -12,7 +12,7 @@ const MODE_STORAGE_KEY = 'cms.md-mode'; // be handled through Redux and a separate registry store for instances let editorControl; // eslint-disable-next-line func-style -let _getEditorComponents = () => []; +let _getEditorComponents = () => Map(); export function getEditorControl() { return editorControl; diff --git a/packages/netlify-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js b/packages/netlify-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js index d5dede15..53d4ea04 100644 --- a/packages/netlify-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js +++ b/packages/netlify-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js @@ -1,4 +1,6 @@ -import { remarkParseShortcodes } from '../remarkShortcodes'; +import { Map, OrderedMap } from 'immutable'; + +import { remarkParseShortcodes, getLinesWithOffsets } from '../remarkShortcodes'; // Stub of Remark Parser function process(value, plugins, processEat = () => {}) { @@ -14,34 +16,61 @@ function process(value, plugins, processEat = () => {}) { } function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) { - // initialize pattern as RegExp as done in the EditorComponent value object - return { id, fromBlock, pattern: new RegExp(pattern, 'm') }; + return { + id, + fromBlock, + pattern, + }; } describe('remarkParseShortcodes', () => { describe('pattern matching', () => { it('should work', () => { const editorComponent = EditorComponent({ pattern: /bar/ }); - process('foo bar', [editorComponent]); + process('foo bar', Map({ [editorComponent.id]: editorComponent })); expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar'])); }); it('should match value surrounded in newlines', () => { const editorComponent = EditorComponent({ pattern: /^bar$/ }); - process('foo\n\nbar\n', [editorComponent]); + process('foo\n\nbar\n', Map({ [editorComponent.id]: editorComponent })); expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar'])); }); it('should match multiline shortcodes', () => { const editorComponent = EditorComponent({ pattern: /^foo\nbar$/ }); - process('foo\nbar', [editorComponent]); + process('foo\nbar', Map({ [editorComponent.id]: editorComponent })); expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo\nbar'])); }); it('should match multiline shortcodes with empty lines', () => { const editorComponent = EditorComponent({ pattern: /^foo\n\nbar$/ }); - process('foo\n\nbar', [editorComponent]); + process('foo\n\nbar', Map({ [editorComponent.id]: editorComponent })); expect(editorComponent.fromBlock).toHaveBeenCalledWith( expect.arrayContaining(['foo\n\nbar']), ); }); + it('should match shortcodes based on order of occurrence in value', () => { + const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /foo/ }); + const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ }); + process( + 'foo\n\nbar', + OrderedMap([ + [barEditorComponent.id, barEditorComponent], + [fooEditorComponent.id, fooEditorComponent], + ]), + ); + expect(fooEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo'])); + }); + it('should match shortcodes based on order of occurrence in value even when some use line anchors', () => { + const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ }); + const bazEditorComponent = EditorComponent({ id: 'baz', pattern: /^baz$/ }); + process( + 'foo\n\nbar\n\nbaz', + OrderedMap([ + [bazEditorComponent.id, bazEditorComponent], + [barEditorComponent.id, barEditorComponent], + ]), + ); + expect(barEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar'])); + }); }); describe('output', () => { it('should be a remark shortcode node', () => { @@ -49,8 +78,29 @@ describe('remarkParseShortcodes', () => { const shortcodeData = { bar: 'baz' }; const expectedNode = { type: 'shortcode', data: { shortcode: 'foo', shortcodeData } }; const editorComponent = EditorComponent({ pattern: /bar/, fromBlock: () => shortcodeData }); - process('foo bar', [editorComponent], processEat); + process('foo bar', Map({ [editorComponent.id]: editorComponent }), processEat); expect(processEat).toHaveBeenCalledWith(expectedNode); }); }); }); + +describe('getLinesWithOffsets', () => { + test('should split into lines', () => { + const value = ' line1\n\nline2 \n\n line3 \n\n'; + + const lines = getLinesWithOffsets(value); + expect(lines).toEqual([ + { line: ' line1', start: 0 }, + { line: 'line2', start: 8 }, + { line: ' line3', start: 16 }, + { line: '', start: 30 }, + ]); + }); + + test('should return single item on no match', () => { + const value = ' line1 '; + + const lines = getLinesWithOffsets(value); + expect(lines).toEqual([{ line: ' line1', start: 0 }]); + }); +}); diff --git a/packages/netlify-cms-widget-markdown/src/serializers/remarkShortcodes.js b/packages/netlify-cms-widget-markdown/src/serializers/remarkShortcodes.js index f14a8b5e..6a31ace3 100644 --- a/packages/netlify-cms-widget-markdown/src/serializers/remarkShortcodes.js +++ b/packages/netlify-cms-widget-markdown/src/serializers/remarkShortcodes.js @@ -8,19 +8,63 @@ export function remarkParseShortcodes({ plugins }) { methods.unshift('shortcode'); } +export function getLinesWithOffsets(value) { + const SEPARATOR = '\n\n'; + const splitted = value.split(SEPARATOR); + const trimmedLines = splitted + .reduce( + (acc, line) => { + const { start: previousLineStart, originalLength: previousLineOriginalLength } = + acc[acc.length - 1]; + + return [ + ...acc, + { + line: line.trimEnd(), + start: previousLineStart + previousLineOriginalLength + SEPARATOR.length, + originalLength: line.length, + }, + ]; + }, + [{ start: -SEPARATOR.length, originalLength: 0 }], + ) + .slice(1) + .map(({ line, start }) => ({ line, start })); + return trimmedLines; +} + +function matchFromLines({ trimmedLines, plugin }) { + for (const { line, start } of trimmedLines) { + const match = line.match(plugin.pattern); + if (match) { + match.index += start; + return match; + } + } +} + function createShortcodeTokenizer({ plugins }) { return function tokenizeShortcode(eat, value, silent) { - let match; - const potentialMatchValue = value.split('\n\n')[0].trimEnd(); - const plugin = plugins.find(plugin => { - match = value.match(plugin.pattern); + // Plugin patterns may rely on `^` and `$` tokens, even if they don't + // use the multiline flag. To support this, we fall back to searching + // through each line individually, trimming trailing whitespace and + // newlines, if we don't initially match on a pattern. We keep track of + // the starting position of each line so that we can sort correctly + // across the full multiline matches. + const trimmedLines = getLinesWithOffsets(value); - if (!match) { - match = potentialMatchValue.match(plugin.pattern); - } - - return !!match; - }); + // Attempt to find a regex match for each plugin's pattern, and then + // select the first by its occurrence in `value`. This ensures we won't + // skip a plugin that occurs later in the plugin registry, but earlier + // in the `value`. + const [{ plugin, match } = {}] = plugins + .toArray() + .map(plugin => ({ + match: value.match(plugin.pattern) || matchFromLines({ trimmedLines, plugin }), + plugin, + })) + .filter(({ match }) => !!match) + .sort((a, b) => a.match.index - b.match.index); if (match) { if (silent) { diff --git a/website/content/docs/custom-widgets.md b/website/content/docs/custom-widgets.md index 2f32bfc9..e58354c7 100644 --- a/website/content/docs/custom-widgets.md +++ b/website/content/docs/custom-widgets.md @@ -118,29 +118,69 @@ CMS.registerEditorComponent(definition)