diff --git a/cypress/e2e/markdown_widget_inline_component_spec.js b/cypress/e2e/markdown_widget_inline_component_spec.js new file mode 100644 index 000000000000..ce4f8c772e63 --- /dev/null +++ b/cypress/e2e/markdown_widget_inline_component_spec.js @@ -0,0 +1,38 @@ +describe('Markdown widget inline component', () => { + it('loads the post entry and tests the Wikilink inline editor component full round-trip', () => { + cy.visit('/#/collections/posts/entries/2026-08-16-post-number-20'); + + // Click Login on test-repo login screen + cy.get('button').contains('Login').click(); + + // Wait for the CMS editor to load + cy.get('[data-slate-editor="true"]', { timeout: 15000 }).should('exist'); + + // Stub prompt window + cy.window().then(win => { + cy.stub(win, 'prompt').callsFake(() => 'MyWikiPage'); + }); + + // Focus the Markdown editor + cy.get('[data-slate-editor="true"]').last().click(); + + // Click the last "Add Component" toolbar button (on the Markdown widget) + cy.get('button[title="Add Component"]').last().click({ force: true }); + + // Click the "Wikilink" option in the dropdown + cy.contains('Wikilink').click({ force: true }); + + // Verify the preview element is rendered in the Slate Markdown editor + cy.get('[data-slate-editor="true"]').last().should('contain', 'MyWikiPage'); + + // Toggle to Markdown raw mode using switch + cy.get('button[role="switch"]').last().click(); + + // Verify raw editor contains the serialized [[MyWikiPage]] + cy.get('[data-slate-editor="true"]').last().should('contain', '[[MyWikiPage]]'); + + // Toggle back to Rich Text mode + cy.get('button[role="switch"]').last().click(); + cy.get('[data-slate-editor="true"]').last().should('contain', 'MyWikiPage'); + }); +}); diff --git a/dev-test/backends/test/index.html b/dev-test/backends/test/index.html index b8c058da0277..f0b086431334 100644 --- a/dev-test/backends/test/index.html +++ b/dev-test/backends/test/index.html @@ -237,6 +237,24 @@ ); } }); + CMS.registerEditorComponent({ + id: 'wikilink', + label: 'Wikilink', + type: 'inline', + trigger: '[', + pattern: /\[\[(?[^\]]+)\]\]/, + fromInline: match => ({ target: match.groups.target }), + toInline: data => `[[${data.target}]]`, + toPreview: data => `🔗 [[${data.target || 'link'}]]`, + onInsert: async ({ selectedText }) => { + const target = prompt('Enter link target:', selectedText); + return target ? { target } : null; + }, + onEdit: async ({ data }) => { + const target = prompt('Edit link target:', data.target); + return target ? { target } : null; + }, + }); diff --git a/dev-test/index.html b/dev-test/index.html index 0d8819c3f960..69c9d933ccc8 100644 --- a/dev-test/index.html +++ b/dev-test/index.html @@ -1,203 +1,239 @@ - + - - + + - Decap CMS Development Test - + + + + - - - - - - + CMS.registerPreviewTemplate('posts', PostPreview); + CMS.registerPreviewTemplate('general', GeneralPreview); + CMS.registerPreviewTemplate('authors', AuthorsPreview); + CMS.registerPreviewStyle(previewStyles, { raw: true }); + // Pass the name of a registered control to reuse with a new widget preview. + CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview); + CMS.registerEditorComponent({ + id: 'youtube', + label: 'Youtube', + fields: [{ name: 'id', label: 'Youtube Video ID' }], + pattern: /^{{<\s?youtube (\S+)\s?>}}/, + fromBlock: function (match) { + return { + id: match[1], + }; + }, + toBlock: function (obj) { + return '{{< youtube ' + obj.id + ' >}}'; + }, + toPreview: function (obj) { + return ( + 'Youtube Video' + ); + }, + }); + CMS.registerEditorComponent({ + id: 'container-markdown', + label: 'Container Markdown', + fields: [{ name: 'inner', label: 'Body', widget: 'markdown', editor_components: [] }], + pattern: /^{{< container-markdown >}}(.*?){{< \/container-markdown >}}/s, + fromBlock(match) { + return { + inner: match[1] || '', + }; + }, + toBlock(obj) { + return `{{< container-markdown >}}${obj.inner}{{< /container-markdown >}}`; + }, + }); + CMS.registerEditorComponent({ + id: 'container-richtext', + label: 'Container Richtext', + fields: [ + { + name: 'inner', + label: 'Body', + widget: 'richtext', + editor_components: ['container-richtext'], + }, + ], + pattern: /^{{< container-richtext >}}(.*?){{< \/container-richtext >}}/s, + fromBlock(match) { + return { + inner: match[1] || '', + }; + }, + toBlock(obj) { + return `{{< container-richtext >}}${obj.inner}{{< /container-richtext >}}`; + }, + }); + CMS.registerEditorComponent({ + id: 'wikilink', + label: 'Wikilink', + type: 'inline', + trigger: '[', + pattern: /\[\[(?[^\]]+)\]\]/, + fromInline: match => ({ target: match.groups.target }), + toInline: data => `[[${data.target}]]`, + toPreview: data => `🔗 [[${data.target || 'link'}]]`, + onInsert: async ({ selectedText }) => { + const target = prompt('Enter link target:', selectedText); + return target ? { target } : null; + }, + onEdit: async ({ data }) => { + const target = prompt('Edit link target:', data.target); + return target ? { target } : null; + }, + }); + + diff --git a/docs/specs/inline-editor-components-rfc.md b/docs/specs/inline-editor-components-rfc.md new file mode 100644 index 000000000000..cbeb88346166 --- /dev/null +++ b/docs/specs/inline-editor-components-rfc.md @@ -0,0 +1,91 @@ +# RFC: Inline Custom Components for Markdown Editor + +- **Target Package**: `packages/decap-cms-widget-markdown` +- **Feature Type**: New Feature / Architecture Enhancement +- **Related Issues**: #5065 (Inline custom widgets), #2064 (MDX requirements) + +--- + +## 1. Problem Statement + +Currently, Decap CMS's `CMS.registerEditorComponent` assumes all registered components are **block-level**. When developers attempt to register inline syntaxes (such as Hugo Shortcodes `{{< ref >}}`, Obsidian `[[wikilink]]`, custom badges, or tags): + +1. **Rich Text Formatting Breakdown**: The rich text editor (Slate) splits paragraphs and isolates inline elements into block nodes. +2. **Round-trip Serialization Errors**: Reverse serialization (`Slate -> MDAST -> Markdown`) causes `Sent invalid data to remark` errors or unintended line breaks and lost leading/trailing whitespace. +3. **Lack of Selection & Async Lifecycle**: No native mechanism to wrap selected text into an inline element asynchronously. + +--- + +## 2. API Specification (`CMS.registerEditorComponent`) + +Extend existing component registration with `type: 'inline'` and associated lifecycle functions: + +```typescript +interface InlineEditorComponentOptions { + id: string; // Unique identifier + label: string; // Toolbar button label / tooltip + type: 'inline'; // Explicitly marks component as inline + isVoid?: boolean; // Atomicity flag (default: true; non-editable content) + trigger?: string; // Prefix character for Remark tokenizer optimization (e.g. '@', '[') + + // 1. Markdown Regex Parsing & Stringification + pattern: RegExp; // Regular expression matching inline syntax (non-greedy recommended) + fromInline: (match: RegExpExecArray) => Record; // Parse matched regex into pure data object + toInline: (data: Record) => string; // Serialize data object back to Markdown string + + // 2. Rich Text Visual Editor Rendering + toPreview: (data: Record) => React.ReactNode; + + // 3. Interactive & Async Lifecycles (Optional) + onInsert?: (context: { + selectedText: string; + cmsContext: any; + }) => Promise | null>; // Returns data object, or null to cancel insertion + + onEdit?: (context: { + data: Record; + }) => Promise | null>; // Triggered when existing node is clicked +} +``` + +--- + +## 3. Data Flow & Architecture + +``` +Markdown (Raw Source) + │ ▲ + ▼ │ [Remark inlineTokenizer / MDAST Stringifier] +MDAST Inline Node (`type: 'inline-shortcode'`) + │ ▲ + ▼ │ [remarkToSlate / slateToRemark] +Slate Inline Element (`inline: true`, `void: isVoid`) + │ ▲ + ▼ │ [Slate Element Component] +Rich Text Visual DOM (Rendered via `toPreview`) +``` + +--- + +## 4. Implementation Phases + +- [x] **Phase 1: Tokenizer & MDAST** + - [x] Implement Remark `inlineTokenizer` supporting custom patterns. + - [x] Validate bidirectional round-trip conversions (`Markdown <-> MDAST`) without character escaping or whitespace loss. +- [x] **Phase 2: Slate Integration** + - [x] Register Slate Inline Node types (`isVoid: true/false`). + - [x] Implement Slate element renderer with `contentEditable={false}` for void nodes. +- [x] **Phase 3: Interactive Events & Toolbar** + - [x] Toolbar button click -> capture selection -> invoke `onInsert`. + - [x] Support double-click/click on existing inline nodes to trigger `onEdit`. +- [x] **Phase 4: Tests & Documentation** + - [x] Add unit tests for CJK characters and punctuation adjacent to inline elements. + - [x] Add regex validation warning in development mode. + +--- + +## 5. Scope & Future Work + +- **Scope of this PR**: This implementation specifically targets `packages/decap-cms-widget-markdown`, the primary and default Markdown editor across Decap CMS. +- **Future Work**: Support for `packages/decap-cms-widget-richtext` (based on Plate.js) will be tracked and implemented in a separate follow-up PR to keep PR review focused and risk-contained. + diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index 87e24f85842a..7e1207d0d71d 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -486,9 +486,10 @@ declare module 'decap-cms-core' { fields?: EditorComponentField[]; }; - export interface EditorComponentOptions { + export interface BlockEditorComponentOptions { id: string; label: string; + type?: 'block'; fields?: EditorComponentField[]; pattern: RegExp; allow_add?: boolean; @@ -497,6 +498,25 @@ declare module 'decap-cms-core' { toPreview: (data: any) => string | JSX.Element; } + export interface InlineEditorComponentOptions { + id: string; + label: string; + type: 'inline'; + isVoid?: boolean; + trigger?: string; + pattern: RegExp; + fromInline: (match: RegExpExecArray) => Record; + toInline: (data: Record) => string; + toPreview: (data: Record) => React.ReactNode; + onInsert?: (context: { + selectedText: string; + cmsContext: any; + }) => Promise | null>; + onEdit?: (context: { data: Record }) => Promise | null>; + } + + export type EditorComponentOptions = BlockEditorComponentOptions | InlineEditorComponentOptions; + export interface PreviewStyleOptions { raw: boolean; } diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js index efe10c3dc131..3a8a98dcaaf4 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js @@ -164,7 +164,11 @@ function Editor(props) { } function handleInsertShortcode(pluginConfig) { - insertShortcode(editor, pluginConfig); + insertShortcode(editor, pluginConfig, { + getAsset: props.getAsset, + resolveWidget: props.resolveWidget, + t: props.t, + }); } function handleKeyDown(event) { diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/components/InlineShortcode.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/components/InlineShortcode.js new file mode 100644 index 000000000000..87256ed73d53 --- /dev/null +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/components/InlineShortcode.js @@ -0,0 +1,75 @@ +/* eslint-disable react/prop-types */ +import { css } from '@emotion/react'; +import { useSelected, ReactEditor, useSlate } from 'slate-react'; +import { Transforms } from 'slate'; +import { colors, lengths } from 'decap-cms-ui-default'; + +import { getEditorComponents } from '../index'; + +function InlineShortcode(props) { + const { attributes, children, element } = props; + const editor = useSlate(); + const isSelected = useSelected(); + const plugin = getEditorComponents().get(element.data?.shortcode); + const isVoid = element.data?.isVoid !== false; + const shortcodeData = element.data?.shortcodeData || {}; + + async function handleClick(e) { + if (plugin && typeof plugin.onEdit === 'function') { + e.preventDefault(); + e.stopPropagation(); + try { + const updatedData = await plugin.onEdit({ data: shortcodeData }); + if (updatedData) { + const path = ReactEditor.findPath(editor, element); + Transforms.setNodes( + editor, + { + data: { + ...element.data, + shortcodeData: updatedData, + }, + }, + { at: path }, + ); + } + } catch (err) { + console.error( + `Error executing onEdit for inline component '${element.data?.shortcode}':`, + err, + ); + } + } + } + + let previewContent; + if (plugin && typeof plugin.toPreview === 'function') { + previewContent = plugin.toPreview(shortcodeData); + } else if (plugin && typeof plugin.toInline === 'function') { + previewContent = plugin.toInline(shortcodeData); + } else { + previewContent = `[${element.data?.shortcode || 'inline'}]`; + } + + const inlineStyles = css` + display: inline-flex; + align-items: center; + vertical-align: baseline; + cursor: ${plugin?.onEdit ? 'pointer' : 'default'}; + border-radius: ${lengths.borderRadius || '3px'}; + padding: 0 2px; + background-color: ${isSelected ? 'rgba(30, 144, 255, 0.15)' : 'transparent'}; + box-shadow: ${isSelected ? `0 0 0 1px ${colors.active || '#3a69c7'}` : 'none'}; + `; + + return ( + + + {previewContent} + + {children} + + ); +} + +export default InlineShortcode; diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/__tests__/insertShortcode.spec.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/__tests__/insertShortcode.spec.js new file mode 100644 index 000000000000..495d30ab1bf2 --- /dev/null +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/__tests__/insertShortcode.spec.js @@ -0,0 +1,73 @@ +import { createEditor } from 'slate'; +import { withReact } from 'slate-react'; + +import withShortcodes from '../withShortcodes'; +import insertShortcode from '../insertShortcode'; + +function makeEditor( + initialChildren = [{ type: 'paragraph', children: [{ text: 'Sample text' }] }], +) { + const editor = withReact(withShortcodes(createEditor())); + editor.children = initialChildren; + return editor; +} + +describe('insertShortcode', () => { + it('should insert inline shortcode with onInsert resolving data', async () => { + const editor = makeEditor(); + editor.selection = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 6 }, + }; // Selected "Sample" + + const onInsertMock = jest.fn().mockResolvedValue({ target: 'doc-page', label: 'Sample' }); + + const pluginConfig = { + id: 'wikilink', + type: 'inline', + onInsert: onInsertMock, + }; + + await insertShortcode(editor, pluginConfig, { contextKey: 'val' }); + + expect(onInsertMock).toHaveBeenCalledWith({ + selectedText: 'Sample', + cmsContext: { contextKey: 'val' }, + }); + + const insertedNode = editor.children[0].children.find( + child => child.type === 'inline-shortcode', + ); + expect(insertedNode).toBeDefined(); + expect(insertedNode.data).toEqual({ + shortcode: 'wikilink', + shortcodeNew: true, + shortcodeData: { target: 'doc-page', label: 'Sample' }, + isVoid: true, + }); + }); + + it('should cancel inline shortcode insertion when onInsert resolves null', async () => { + const editor = makeEditor(); + editor.selection = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 6 }, + }; + + const onInsertMock = jest.fn().mockResolvedValue(null); + + const pluginConfig = { + id: 'wikilink', + type: 'inline', + onInsert: onInsertMock, + }; + + await insertShortcode(editor, pluginConfig); + + expect(onInsertMock).toHaveBeenCalled(); + const insertedNode = editor.children[0].children.find( + child => child.type === 'inline-shortcode', + ); + expect(insertedNode).toBeUndefined(); + }); +}); diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/insertShortcode.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/insertShortcode.js index 9ce850a80fbc..b1fab3b7351c 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/insertShortcode.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/insertShortcode.js @@ -1,12 +1,58 @@ -import { Transforms } from 'slate'; +import { Editor, Range, Transforms } from 'slate'; import isCursorInEmptyParagraph from './locations/isCursorInEmptyParagraph'; -function insertShortcode(editor, pluginConfig) { +async function insertShortcode(editor, pluginConfig, cmsContext = {}) { + if (pluginConfig.type === 'inline') { + let selectedText = ''; + if (editor.selection && Range.isRange(editor.selection)) { + selectedText = Editor.string(editor, editor.selection); + } + + let shortcodeData = {}; + + if (typeof pluginConfig.onInsert === 'function') { + try { + const result = await pluginConfig.onInsert({ selectedText, cmsContext }); + if (result === null || result === undefined) { + return; + } + shortcodeData = result; + } catch (err) { + console.error(`Error in onInsert for inline component '${pluginConfig.id}':`, err); + return; + } + } else if (pluginConfig.fields) { + const defaultValues = pluginConfig.fields + .toMap() + .mapKeys((_, field) => field.get('name')) + .map(field => field.get('default', '')); + shortcodeData = defaultValues.toJS(); + } + + const nodeData = { + type: 'inline-shortcode', + id: pluginConfig.id, + data: { + shortcode: pluginConfig.id, + shortcodeNew: true, + shortcodeData, + isVoid: pluginConfig.isVoid !== false, + }, + children: [{ text: '' }], + }; + + Transforms.insertNodes(editor, nodeData); + return; + } + const defaultValues = pluginConfig.fields - .toMap() - .mapKeys((_, field) => field.get('name')) - .map(field => field.get('default', '')); + ? pluginConfig.fields + .toMap() + .mapKeys((_, field) => field.get('name')) + .map(field => field.get('default', '')) + .toJS() + : {}; const nodeData = { type: 'shortcode', @@ -14,7 +60,7 @@ function insertShortcode(editor, pluginConfig) { data: { shortcode: pluginConfig.id, shortcodeNew: true, - shortcodeData: defaultValues.toJS(), + shortcodeData: defaultValues, }, children: [{ text: '' }], }; diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/withShortcodes.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/withShortcodes.js index 9d2601274a7c..03a2e41a3d59 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/withShortcodes.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/plugins/shortcodes/withShortcodes.js @@ -3,10 +3,20 @@ import { Editor, Transforms } from 'slate'; import defaultEmptyBlock from '../blocks/defaultEmptyBlock'; function withShortcodes(editor) { - const { isVoid, normalizeNode } = editor; + const { isVoid, isInline, normalizeNode } = editor; editor.isVoid = element => { - return element.type === 'shortcode' ? true : isVoid(element); + if (element.type === 'shortcode') { + return true; + } + if (element.type === 'inline-shortcode') { + return element.data && element.data.isVoid !== undefined ? element.data.isVoid : true; + } + return isVoid(element); + }; + + editor.isInline = element => { + return element.type === 'inline-shortcode' ? true : isInline(element); }; // Prevent empty editor after deleting shortcode theat was only child diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js index fbddd3842675..ec95ea32d470 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js @@ -6,6 +6,7 @@ import { useSelected } from 'slate-react'; import VoidBlock from './components/VoidBlock'; import Shortcode from './components/Shortcode'; +import InlineShortcode from './components/InlineShortcode'; const bottomMargin = '16px'; @@ -350,6 +351,8 @@ export function Element(props) { {children} ); + case 'inline-shortcode': + return ; default: return {children}; } diff --git a/packages/decap-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js b/packages/decap-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js index 5104abadc152..4c5412532722 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js +++ b/packages/decap-cms-widget-markdown/src/serializers/__tests__/remarkShortcodes.spec.js @@ -1,8 +1,9 @@ import { Map, OrderedMap } from 'immutable'; import unified from 'unified'; import markdownToRemarkPlugin from 'remark-parse'; +import remarkToMarkdownPlugin from 'remark-stringify'; -import { remarkParseShortcodes } from '../remarkShortcodes'; +import { remarkParseShortcodes, createRemarkShortcodeStringifier } from '../remarkShortcodes'; function process(value, plugins) { return unified() @@ -11,6 +12,14 @@ function process(value, plugins) { .parse(value); } +function stringify(mdast, plugins) { + return unified() + .use(remarkToMarkdownPlugin, { commonmark: true }) + .use(createRemarkShortcodeStringifier({ plugins })) + .stringify(mdast) + .trim(); +} + function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) { return { id, @@ -103,6 +112,179 @@ describe('remarkParseShortcodes', () => { expect(removePositions(mdast)).toMatchSnapshot(); }); }); + describe('inline shortcodes', () => { + it('should parse inline shortcode inside paragraph without breaking paragraph into blocks', () => { + const inlineComponent = { + id: 'ref', + type: 'inline', + pattern: /\{\{<\s*ref\s+"(?[^"]+)"\s*>\}\}/, + fromInline: match => ({ target: match.groups.target }), + toInline: data => `{{< ref "${data.target}" >}}`, + }; + + const mdast = process( + 'Hello {{< ref "about" >}} world', + Map({ [inlineComponent.id]: inlineComponent }), + ); + + const stripped = removePositions(mdast); + expect(stripped).toEqual({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { type: 'text', value: 'Hello ' }, + { + type: 'inline-shortcode', + data: { + shortcode: 'ref', + shortcodeData: { target: 'about' }, + isVoid: true, + }, + }, + { type: 'text', value: ' world' }, + ], + }, + ], + }); + }); + + it('should parse adjacent CJK characters and punctuation correctly', () => { + const wikilinkComponent = { + id: 'wikilink', + type: 'inline', + trigger: '[', + pattern: /\[\[(?[^\]]+)\]\]/, + fromInline: match => ({ target: match.groups.target }), + toInline: data => `[[${data.target}]]`, + }; + + const mdast = process( + '這是一個[[測試頁面]],請點擊!', + Map({ [wikilinkComponent.id]: wikilinkComponent }), + ); + + const stripped = removePositions(mdast); + expect(stripped).toEqual({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { type: 'text', value: '這是一個' }, + { + type: 'inline-shortcode', + data: { + shortcode: 'wikilink', + shortcodeData: { target: '測試頁面' }, + isVoid: true, + }, + }, + { type: 'text', value: ',請點擊!' }, + ], + }, + ], + }); + }); + + it('should parse multiple inline shortcodes within single paragraph', () => { + const tagComponent = { + id: 'tag', + type: 'inline', + trigger: '#', + pattern: /#(?[a-zA-Z0-9_-]+)/, + fromInline: match => ({ name: match.groups.name }), + toInline: data => `#${data.name}`, + }; + + const mdast = process( + 'Tags: #react and #decap are cool', + Map({ [tagComponent.id]: tagComponent }), + ); + + const stripped = removePositions(mdast); + expect(stripped.children[0].children).toHaveLength(5); + expect(stripped.children[0].children[1]).toEqual({ + type: 'inline-shortcode', + data: { + shortcode: 'tag', + shortcodeData: { name: 'react' }, + isVoid: true, + }, + }); + expect(stripped.children[0].children[3]).toEqual({ + type: 'inline-shortcode', + data: { + shortcode: 'tag', + shortcodeData: { name: 'decap' }, + isVoid: true, + }, + }); + }); + + it('should stringify inline shortcodes correctly in round-trip', () => { + const inlineComponent = { + id: 'ref', + type: 'inline', + pattern: /\{\{<\s*ref\s+"(?[^"]+)"\s*>\}\}/, + fromInline: match => ({ target: match.groups.target }), + toInline: data => `{{< ref "${data.target}" >}}`, + }; + + const input = 'Hello {{< ref "about" >}} world'; + const plugins = Map({ [inlineComponent.id]: inlineComponent }); + const mdast = process(input, plugins); + const output = stringify(mdast, plugins); + + expect(output).toEqual(input); + }); + + it('should warn when inline component pattern has greedy quantifier', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const inlineComponent = { + id: 'greedy-ref', + type: 'inline', + pattern: /\{\{< ref (.+) >\}\}/, + fromInline: match => ({ target: match[1] }), + toInline: data => `{{< ref ${data.target} >}}`, + }; + + process('text', Map({ [inlineComponent.id]: inlineComponent })); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Potentially greedy RegExp in inline component'), + ); + warnSpy.mockRestore(); + }); + + it('should handle inline shortcodes nested inside bold text', () => { + const badgeComponent = { + id: 'badge', + type: 'inline', + pattern: /\[badge:(?[^\]]+)\]/, + fromInline: match => ({ text: match.groups.text }), + toInline: data => `[badge:${data.text}]`, + }; + + const input = '**Important [badge:NEW] Note**'; + const plugins = Map({ [badgeComponent.id]: badgeComponent }); + const mdast = process(input, plugins); + const stripped = removePositions(mdast); + + expect(stripped.children[0].children[0].type).toBe('strong'); + const strongChildren = stripped.children[0].children[0].children; + expect(strongChildren[0]).toEqual({ type: 'text', value: 'Important ' }); + expect(strongChildren[1]).toEqual({ + type: 'inline-shortcode', + data: { + shortcode: 'badge', + shortcodeData: { text: 'NEW' }, + isVoid: true, + }, + }); + expect(strongChildren[2]).toEqual({ type: 'text', value: ' Note' }); + }); + }); }); function removePositions(obj) { diff --git a/packages/decap-cms-widget-markdown/src/serializers/__tests__/slate.spec.js b/packages/decap-cms-widget-markdown/src/serializers/__tests__/slate.spec.js index ccfd5acb72cd..5149b7d962a1 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/__tests__/slate.spec.js +++ b/packages/decap-cms-widget-markdown/src/serializers/__tests__/slate.spec.js @@ -299,4 +299,45 @@ describe('slate', () => { expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"*h~~e**l**l~~o*"`); }); }); + + describe('inline-shortcode', () => { + it('should convert inline-shortcode between Slate and MDAST', () => { + const slateAst = ( + + + Hello + + + + world + + + ); + + const refPlugin = { + id: 'ref', + type: 'inline', + pattern: /\{\{<\s*ref\s+"(?[^"]+)"\s*>\}\}/, + fromInline: match => ({ target: match.groups.target }), + toInline: data => `{{< ref "${data.target}" >}}`, + }; + + const markdown = slateToMarkdown(slateAst.children, { + remarkPlugins: [ + function () { + this.Compiler.prototype.visitors['inline-shortcode'] = node => + refPlugin.toInline(node.data.shortcodeData); + }, + ], + }); + + expect(markdown).toEqual('Hello {{< ref "about" >}} world'); + }); + }); }); diff --git a/packages/decap-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js b/packages/decap-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js index 311bdf98ef89..a5044100faae 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js +++ b/packages/decap-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js @@ -1,5 +1,4 @@ import React from 'react'; -import map from 'lodash/map'; import has from 'lodash/has'; import { renderToString } from 'react-dom/server'; import u from 'unist-builder'; @@ -14,8 +13,21 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid return transform; function transform(root) { - const transformedChildren = map(root.children, processShortcodes); - return { ...root, children: transformedChildren }; + function walk(node) { + if (!node) return node; + if (has(node, ['data', 'shortcode'])) { + return processShortcodes(node); + } + if (Array.isArray(node.children)) { + return { + ...node, + children: node.children.map(walk), + }; + } + return node; + } + + return walk(root); } /** @@ -33,6 +45,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid */ const { shortcode, shortcodeData } = node.data; const plugin = plugins.get(shortcode); + if (!plugin) return node; /** * Run the shortcode plugin's `toPreview` method, which will return either @@ -45,9 +58,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid /** * Return a new 'html' type node containing the shortcode preview markup. */ - const textNode = u('html', valueHtml); - const children = [textNode]; - return { ...node, children }; + return u('html', valueHtml); } /** diff --git a/packages/decap-cms-widget-markdown/src/serializers/remarkShortcodes.js b/packages/decap-cms-widget-markdown/src/serializers/remarkShortcodes.js index bef1dd3bdd6e..8857e23fe2af 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/remarkShortcodes.js +++ b/packages/decap-cms-widget-markdown/src/serializers/remarkShortcodes.js @@ -1,16 +1,20 @@ export function remarkParseShortcodes({ plugins }) { const Parser = this.Parser; - const tokenizers = Parser.prototype.blockTokenizers; - const methods = Parser.prototype.blockMethods; + const blockTokenizers = Parser.prototype.blockTokenizers; + const blockMethods = Parser.prototype.blockMethods; + const inlineTokenizers = Parser.prototype.inlineTokenizers; + const inlineMethods = Parser.prototype.inlineMethods; - tokenizers.shortcode = createShortcodeTokenizer({ plugins }); + blockTokenizers.shortcode = createShortcodeTokenizer({ plugins }); + blockMethods.unshift('shortcode'); - methods.unshift('shortcode'); + inlineTokenizers.inlineShortcode = createInlineShortcodeTokenizer({ plugins }); + inlineMethods.unshift('inlineShortcode'); } function createShortcodeTokenizer({ plugins }) { plugins.forEach(plugin => { - if (plugin.pattern.flags.includes('m')) { + if (plugin.pattern && plugin.pattern.flags.includes('m')) { console.warn( `Invalid RegExp: editor component '${plugin.id}' must not use the multiline flag in its pattern.`, ); @@ -20,6 +24,9 @@ function createShortcodeTokenizer({ plugins }) { let match; const potentialMatchValue = value.split('\n\n')[0].trimEnd(); const plugin = plugins.find(plugin => { + if (plugin.type === 'inline') { + return false; + } let { pattern } = plugin; // Plugin patterns must start with a caret (^) to match the beginning of the block. // If the pattern does not start with a caret, we add it @@ -46,7 +53,11 @@ function createShortcodeTokenizer({ plugins }) { return true; } - const shortcodeData = plugin.fromBlock(match); + const shortcodeData = plugin.fromBlock + ? plugin.fromBlock(match) + : plugin.fromInline + ? plugin.fromInline(match) + : match; try { return eat(match[0])({ @@ -65,17 +76,130 @@ function createShortcodeTokenizer({ plugins }) { }; } +function createInlineShortcodeTokenizer({ plugins }) { + plugins.forEach(plugin => { + if (plugin.type === 'inline' && plugin.pattern) { + if (plugin.pattern.flags.includes('m')) { + console.warn( + `Invalid RegExp: inline editor component '${plugin.id}' must not use the multiline flag in its pattern.`, + ); + } + if (/(\.\*|\.\+)(?!\?)/.test(plugin.pattern.source)) { + console.warn( + `Potentially greedy RegExp in inline component '${plugin.id}': consider using non-greedy quantifier (e.g. .*? or .+?) or specific character classes to prevent overmatching within paragraphs.`, + ); + } + } + }); + + function tokenizeInlineShortcode(eat, value, silent) { + let match; + const plugin = plugins.find(plugin => { + if (plugin.type !== 'inline') { + return false; + } + let { pattern } = plugin; + // Inline patterns must match at the current offset (leading ^) + if (!pattern.source.startsWith('^')) { + pattern = new RegExp(`^${pattern.source}`, pattern.flags); + } + + match = value.match(pattern); + return !!match; + }); + + if (match) { + if (silent) { + return true; + } + + const shortcodeData = plugin.fromInline + ? plugin.fromInline(match) + : plugin.fromBlock + ? plugin.fromBlock(match) + : match; + + try { + return eat(match[0])({ + type: 'inline-shortcode', + data: { + shortcode: plugin.id, + shortcodeData, + isVoid: plugin.isVoid !== false, + }, + }); + } catch (e) { + console.warn( + `Sent invalid data to remark. Inline plugin: ${plugin.id}. Value: ${ + match[0] + }. Data: ${JSON.stringify(shortcodeData)}`, + ); + return false; + } + } + } + + tokenizeInlineShortcode.locator = function locateInlineShortcode(value, fromIndex) { + let minIndex = -1; + plugins.forEach(plugin => { + if (plugin.type !== 'inline') { + return; + } + + if (plugin.trigger) { + const triggerIndex = value.indexOf(plugin.trigger, fromIndex); + if (triggerIndex !== -1 && (minIndex === -1 || triggerIndex < minIndex)) { + minIndex = triggerIndex; + } + } else { + let searchPattern = plugin.pattern; + if (searchPattern.source.startsWith('^')) { + searchPattern = new RegExp(searchPattern.source.slice(1), searchPattern.flags); + } + const slice = value.slice(fromIndex); + const match = slice.match(searchPattern); + if (match && typeof match.index === 'number') { + const foundIndex = fromIndex + match.index; + if (minIndex === -1 || foundIndex < minIndex) { + minIndex = foundIndex; + } + } + } + }); + return minIndex; + }; + + return tokenizeInlineShortcode; +} + export function createRemarkShortcodeStringifier({ plugins }) { return function remarkStringifyShortcodes() { const Compiler = this.Compiler; const visitors = Compiler.prototype.visitors; visitors.shortcode = shortcode; + visitors['inline-shortcode'] = inlineShortcode; function shortcode(node) { const { data } = node; const plugin = plugins.find(plugin => data.shortcode === plugin.id); - return plugin.toBlock(data.shortcodeData); + if (!plugin) return ''; + return plugin.toBlock + ? plugin.toBlock(data.shortcodeData) + : plugin.toInline + ? plugin.toInline(data.shortcodeData) + : ''; + } + + function inlineShortcode(node) { + const { data } = node; + const plugin = plugins.find(plugin => data.shortcode === plugin.id); + if (!plugin) return ''; + return plugin.toInline + ? plugin.toInline(data.shortcodeData) + : plugin.toBlock + ? plugin.toBlock(data.shortcodeData) + : ''; } }; } diff --git a/packages/decap-cms-widget-markdown/src/serializers/remarkSlate.js b/packages/decap-cms-widget-markdown/src/serializers/remarkSlate.js index 2104d2f05a4a..7135f66c6209 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/remarkSlate.js +++ b/packages/decap-cms-widget-markdown/src/serializers/remarkSlate.js @@ -21,6 +21,7 @@ const typeMap = { link: 'link', image: 'image', shortcode: 'shortcode', + 'inline-shortcode': 'inline-shortcode', }; /** @@ -279,6 +280,12 @@ export default function remarkToSlate({ voidCodeBlock } = {}) { return createBlock(typeMap[node.type], nodes, { data }); } + case 'inline-shortcode': { + const nodes = [createText('')]; + const data = { ...node.data, id: node.data.shortcode, shortcodeNew: true }; + return createInline(typeMap[node.type], { data }, nodes); + } + case 'text': { const text = node.value; return createText(text); diff --git a/packages/decap-cms-widget-markdown/src/serializers/slateRemark.js b/packages/decap-cms-widget-markdown/src/serializers/slateRemark.js index b6cc34f0afd0..f9edc3d2c3be 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/slateRemark.js +++ b/packages/decap-cms-widget-markdown/src/serializers/slateRemark.js @@ -32,6 +32,7 @@ const typeMap = { link: 'link', image: 'image', shortcode: 'shortcode', + 'inline-shortcode': 'inline-shortcode', }; /** @@ -62,7 +63,7 @@ const blockTypes = [ 'table-cell', ]; -const inlineTypes = ['link', 'image', 'break']; +const inlineTypes = ['link', 'image', 'break', 'inline-shortcode']; const leadingWhitespaceExp = /^\s+\S/; const trailingWhitespaceExp = /(?!\S)\s+$/; @@ -468,6 +469,14 @@ export default function slateToRemark(value, { voidCodeBlock }) { const { url, title, alt, ...data } = get(node, 'data', {}); return u(typeMap[node.type], { url, title, alt, data }); } + + /** + * Inline Shortcodes + */ + case 'inline-shortcode': { + const { data } = node; + return u(typeMap[node.type], { data }); + } } } }