Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions cypress/e2e/markdown_widget_inline_component_spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
18 changes: 18 additions & 0 deletions dev-test/backends/test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,24 @@
);
}
});
CMS.registerEditorComponent({
id: 'wikilink',
label: 'Wikilink',
type: 'inline',
trigger: '[',
pattern: /\[\[(?<target>[^\]]+)\]\]/,
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;
},
});
</script>
</body>
</html>
532 changes: 295 additions & 237 deletions dev-test/index.html

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions docs/specs/inline-editor-components-rfc.md
Original file line number Diff line number Diff line change
@@ -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<string, any>; // Parse matched regex into pure data object
toInline: (data: Record<string, any>) => string; // Serialize data object back to Markdown string

// 2. Rich Text Visual Editor Rendering
toPreview: (data: Record<string, any>) => React.ReactNode;

// 3. Interactive & Async Lifecycles (Optional)
onInsert?: (context: {
selectedText: string;
cmsContext: any;
}) => Promise<Record<string, any> | null>; // Returns data object, or null to cancel insertion

onEdit?: (context: {
data: Record<string, any>;
}) => Promise<Record<string, any> | 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.

22 changes: 21 additions & 1 deletion packages/decap-cms-core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, any>;
toInline: (data: Record<string, any>) => string;
toPreview: (data: Record<string, any>) => React.ReactNode;
onInsert?: (context: {
selectedText: string;
cmsContext: any;
}) => Promise<Record<string, any> | null>;
onEdit?: (context: { data: Record<string, any> }) => Promise<Record<string, any> | null>;
}

export type EditorComponentOptions = BlockEditorComponentOptions | InlineEditorComponentOptions;

export interface PreviewStyleOptions {
raw: boolean;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<span {...attributes} css={inlineStyles} onClick={handleClick}>
<span contentEditable={isVoid ? false : undefined} style={{ userSelect: 'none' }}>
{previewContent}
</span>
{children}
</span>
);
}

export default InlineShortcode;
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading