feat(widget-markdown): allow registering remark plugins (#5633)

This commit is contained in:
stefanprobst
2021-07-25 15:03:35 +02:00
committed by GitHub
parent 82005d4a7d
commit 437f4bc634
12 changed files with 431 additions and 59 deletions

View File

@ -2,6 +2,7 @@
declare module 'netlify-cms-core' {
import type { ComponentType } from 'react';
import type { List, Map } from 'immutable';
import type { Pluggable } from 'unified';
export type CmsBackendType =
| 'azure'
@ -543,6 +544,7 @@ declare module 'netlify-cms-core' {
export interface CMS {
getBackend: (name: string) => CmsRegistryBackend | undefined;
getEditorComponents: () => Map<string, ComponentType<any>>;
getRemarkPlugins: () => Array<Pluggable>;
getLocale: (locale: string) => CmsLocalePhrases | undefined;
getMediaLibrary: (name: string) => CmsMediaLibrary | undefined;
getPreviewStyles: () => PreviewStyle[];
@ -552,6 +554,7 @@ declare module 'netlify-cms-core' {
init: (options?: InitOptions) => void;
registerBackend: (name: string, backendClass: CmsBackendClass) => void;
registerEditorComponent: (options: EditorComponentOptions) => void;
registerRemarkPlugin: (plugin: Pluggable) => void;
registerEventListener: (
eventListener: CmsEventListener,
options?: CmsEventListenerOptions,

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map, List } from 'immutable';
import { oneLine } from 'common-tags';
import { getRemarkPlugins } from '../../../lib/registry';
import ValidationErrorTypes from '../../../constants/validationErrorTypes';
function truthy() {
@ -326,6 +327,7 @@ export default class Widget extends Component {
resolveWidget,
widget,
getEditorComponents,
getRemarkPlugins,
query,
queryHits,
clearSearch,

View File

@ -7,7 +7,12 @@ import Frame, { FrameContextConsumer } from 'react-frame-component';
import { lengths } from 'netlify-cms-ui-default';
import { connect } from 'react-redux';
import { resolveWidget, getPreviewTemplate, getPreviewStyles } from '../../../lib/registry';
import {
resolveWidget,
getPreviewTemplate,
getPreviewStyles,
getRemarkPlugins,
} from '../../../lib/registry';
import { ErrorBoundary } from '../../UI';
import { selectTemplateName, selectInferedField, selectField } from '../../../reducers/collections';
import { boundGetAsset } from '../../../actions/media';
@ -45,6 +50,7 @@ export class PreviewPane extends React.Component {
entry={entry}
fieldsMetaData={metadata}
resolveWidget={resolveWidget}
getRemarkPlugins={getRemarkPlugins}
/>
);
};

View File

@ -26,6 +26,7 @@ const registry = {
previewStyles: [],
widgets: {},
editorComponents: Map(),
remarkPlugins: [],
widgetValueSerializers: {},
mediaLibraries: [],
locales: {},
@ -43,6 +44,8 @@ export default {
resolveWidget,
registerEditorComponent,
getEditorComponents,
registerRemarkPlugin,
getRemarkPlugins,
registerWidgetValueSerializer,
getWidgetValueSerializer,
registerBackend,
@ -163,6 +166,19 @@ export function getEditorComponents() {
return registry.editorComponents;
}
/**
* Remark plugins
*/
/** @typedef {import('unified').Pluggable} RemarkPlugin */
/** @type {(plugin: RemarkPlugin) => void} */
export function registerRemarkPlugin(plugin) {
registry.remarkPlugins.push(plugin);
}
/** @type {() => Array<RemarkPlugin>} */
export function getRemarkPlugins() {
return registry.remarkPlugins;
}
/**
* Widget Serializers
*/

View File

@ -45,8 +45,8 @@ function createEmptyRawDoc() {
return { nodes: [emptyBlock] };
}
function createSlateValue(rawValue, { voidCodeBlock }) {
const rawDoc = rawValue && markdownToSlate(rawValue, { voidCodeBlock });
function createSlateValue(rawValue, { voidCodeBlock, remarkPlugins }) {
const rawDoc = rawValue && markdownToSlate(rawValue, { voidCodeBlock, remarkPlugins });
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'));
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
return Value.create({ document });
@ -95,6 +95,8 @@ export default class Editor extends React.Component {
? editorComponents
: editorComponents.set('code-block', { label: 'Code Block', type: 'code-block' });
this.remarkPlugins = props.getRemarkPlugins();
mergeMediaConfig(this.editorComponents, this.props.field);
this.renderBlock = renderBlock({
classNameWrapper: props.className,
@ -108,9 +110,13 @@ export default class Editor extends React.Component {
getAsset: props.getAsset,
resolveWidget: props.resolveWidget,
t: props.t,
remarkPlugins: this.remarkPlugins,
});
this.state = {
value: createSlateValue(this.props.value, { voidCodeBlock: !!this.codeBlockComponent }),
value: createSlateValue(this.props.value, {
voidCodeBlock: !!this.codeBlockComponent,
remarkPlugins: this.remarkPlugins,
}),
};
}
@ -123,14 +129,20 @@ export default class Editor extends React.Component {
value: PropTypes.string,
field: ImmutablePropTypes.map.isRequired,
getEditorComponents: PropTypes.func.isRequired,
getRemarkPlugins: PropTypes.func.isRequired,
isShowModeToggle: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired,
};
shouldComponentUpdate(nextProps, nextState) {
if (!this.state.value.equals(nextState.value)) return true;
const raw = nextState.value.document.toJS();
const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent });
return !this.state.value.equals(nextState.value) || nextProps.value !== markdown;
const markdown = slateToMarkdown(raw, {
voidCodeBlock: this.codeBlockComponent,
remarkPlugins: this.remarkPlugins,
});
return nextProps.value !== markdown;
}
componentDidMount() {
@ -143,7 +155,10 @@ export default class Editor extends React.Component {
componentDidUpdate(prevProps) {
if (prevProps.value !== this.props.value) {
this.setState({
value: createSlateValue(this.props.value, { voidCodeBlock: !!this.codeBlockComponent }),
value: createSlateValue(this.props.value, {
voidCodeBlock: !!this.codeBlockComponent,
remarkPlugins: this.remarkPlugins,
}),
});
}
}
@ -183,7 +198,10 @@ export default class Editor extends React.Component {
handleDocumentChange = debounce(editor => {
const { onChange } = this.props;
const raw = editor.value.document.toJS();
const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent });
const markdown = slateToMarkdown(raw, {
voidCodeBlock: this.codeBlockComponent,
remarkPlugins: this.remarkPlugins,
});
onChange(markdown);
}, 150);

View File

@ -76,6 +76,7 @@ export default class MarkdownControl extends React.Component {
classNameWrapper,
field,
getEditorComponents,
getRemarkPlugins,
resolveWidget,
t,
isDisabled,
@ -95,6 +96,7 @@ export default class MarkdownControl extends React.Component {
value={value}
field={field}
getEditorComponents={getEditorComponents}
getRemarkPlugins={getRemarkPlugins}
resolveWidget={resolveWidget}
pendingFocus={pendingFocus && this.setFocusReceived}
t={t}

View File

@ -5,10 +5,10 @@ import isHotkey from 'is-hotkey';
import { slateToMarkdown, markdownToSlate, htmlToSlate, markdownToHtml } from '../../serializers';
function CopyPasteVisual({ getAsset, resolveWidget }) {
function CopyPasteVisual({ getAsset, resolveWidget, remarkPlugins }) {
function handleCopy(event, editor) {
const markdown = slateToMarkdown(editor.value.fragment.toJS());
const html = markdownToHtml(markdown, { getAsset, resolveWidget });
const markdown = slateToMarkdown(editor.value.fragment.toJS(), { remarkPlugins });
const html = markdownToHtml(markdown, { getAsset, resolveWidget, remarkPlugins });
setEventTransfer(event, 'text', markdown);
setEventTransfer(event, 'html', html);
setEventTransfer(event, 'fragment', base64.serializeNode(editor.value.fragment));
@ -28,7 +28,9 @@ function CopyPasteVisual({ getAsset, resolveWidget }) {
}
const html = data.types.includes('text/html') && data.getData('text/html');
const ast = html ? htmlToSlate(html) : markdownToSlate(data.getData('text/plain'));
const ast = html
? htmlToSlate(html)
: markdownToSlate(data.getData('text/plain'), { remarkPlugins });
const doc = Document.fromJSON(ast);
return editor.insertFragment(doc);
},

View File

@ -15,7 +15,7 @@ import Shortcode from './Shortcode';
import { SLATE_DEFAULT_BLOCK_TYPE as defaultType } from '../../types';
import Hotkey, { HOT_KEY_MAP } from './Hotkey';
function plugins({ getAsset, resolveWidget, t }) {
function plugins({ getAsset, resolveWidget, t, remarkPlugins }) {
return [
{
onKeyDown(event, editor, next) {
@ -51,7 +51,7 @@ function plugins({ getAsset, resolveWidget, t }) {
CloseBlock({ defaultType }),
SelectAll(),
ForceInsert({ defaultType }),
CopyPasteVisual({ getAsset, resolveWidget }),
CopyPasteVisual({ getAsset, resolveWidget, remarkPlugins }),
Shortcode({ defaultType }),
];
}

View File

@ -12,12 +12,12 @@ class MarkdownPreview extends React.Component {
};
render() {
const { value, getAsset, resolveWidget, field } = this.props;
const { value, getAsset, resolveWidget, field, getRemarkPlugins } = this.props;
if (value === null) {
return null;
}
const html = markdownToHtml(value, { getAsset, resolveWidget });
const html = markdownToHtml(value, { getAsset, resolveWidget }, getRemarkPlugins?.());
const toRender = field?.get('sanitize_preview', false) ? DOMPurify.sanitize(html) : html;
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: toRender }} />;

View File

@ -0,0 +1,299 @@
import visit from 'unist-util-visit';
import { markdownToRemark, remarkToMarkdown } from '..';
describe('registered remark plugins', () => {
function withNetlifyLinks() {
return function transformer(tree) {
visit(tree, 'link', function onLink(node) {
node.url = 'https://netlify.com';
});
};
}
it('should use remark transformer plugins when converting mdast to markdown', () => {
const plugins = [withNetlifyLinks];
const result = remarkToMarkdown(
{
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'Some ',
},
{
type: 'emphasis',
children: [
{
type: 'text',
value: 'important',
},
],
},
{
type: 'text',
value: ' text with ',
},
{
type: 'link',
title: null,
url: 'https://this-value-should-be-replaced.com',
children: [
{
type: 'text',
value: 'a link',
},
],
},
{
type: 'text',
value: ' in it.',
},
],
},
],
},
plugins,
);
expect(result).toMatchInlineSnapshot(
`"Some *important* text with [a link](https://netlify.com) in it."`,
);
});
it('should use remark transformer plugins when converting markdown to mdast', () => {
const plugins = [withNetlifyLinks];
const result = markdownToRemark(
'Some text with [a link](https://this-value-should-be-replaced.com) in it.',
plugins,
);
expect(result).toMatchInlineSnapshot(`
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [],
"position": Position {
"end": Object {
"column": 16,
"line": 1,
"offset": 15,
},
"indent": Array [],
"start": Object {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "text",
"value": "Some text with ",
},
Object {
"children": Array [
Object {
"children": Array [],
"position": Position {
"end": Object {
"column": 23,
"line": 1,
"offset": 22,
},
"indent": Array [],
"start": Object {
"column": 17,
"line": 1,
"offset": 16,
},
},
"type": "text",
"value": "a link",
},
],
"position": Position {
"end": Object {
"column": 67,
"line": 1,
"offset": 66,
},
"indent": Array [],
"start": Object {
"column": 16,
"line": 1,
"offset": 15,
},
},
"title": null,
"type": "link",
"url": "https://netlify.com",
},
Object {
"children": Array [],
"position": Position {
"end": Object {
"column": 74,
"line": 1,
"offset": 73,
},
"indent": Array [],
"start": Object {
"column": 67,
"line": 1,
"offset": 66,
},
},
"type": "text",
"value": " in it.",
},
],
"position": Position {
"end": Object {
"column": 74,
"line": 1,
"offset": 73,
},
"indent": Array [],
"start": Object {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "paragraph",
},
],
"position": Object {
"end": Object {
"column": 74,
"line": 1,
"offset": 73,
},
"start": Object {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
`);
});
it('should use remark serializer plugins when converting mdast to markdown', () => {
function withEscapedLessThanChar() {
if (this.Compiler) {
this.Compiler.prototype.visitors.text = node => {
return node.value.replace(/</g, '&lt;');
};
}
}
const plugins = [withEscapedLessThanChar];
const result = remarkToMarkdown(
{
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: '<3 Netlify',
},
],
},
],
},
plugins,
);
expect(result).toMatchInlineSnapshot(`"&lt;3 Netlify"`);
});
it('should use remark preset with settings when converting mdast to markdown', () => {
const settings = {
emphasis: '_',
bullet: '-',
};
const plugins = [{ settings }];
const result = remarkToMarkdown(
{
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'Some ',
},
{
type: 'emphasis',
children: [
{
type: 'text',
value: 'important',
},
],
},
{
type: 'text',
value: ' points:',
},
],
},
{
type: 'list',
ordered: false,
start: null,
spread: false,
children: [
{
type: 'listItem',
spread: false,
checked: null,
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'One',
},
],
},
],
},
{
type: 'listItem',
spread: false,
checked: null,
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'Two',
},
],
},
],
},
],
},
],
},
plugins,
);
expect(result).toMatchInlineSnapshot(`
"Some _important_ points:
- One
- Two"
`);
});
});

View File

@ -59,21 +59,24 @@ import { getEditorComponents } from '../MarkdownControl';
/**
* Deserialize a Markdown string to an MDAST.
*/
export function markdownToRemark(markdown) {
/**
* Parse the Markdown string input to an MDAST.
*/
const parsed = unified()
export function markdownToRemark(markdown, remarkPlugins) {
const processor = unified()
.use(markdownToRemarkPlugin, { fences: true, commonmark: true })
.use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] })
.use(remarkParseShortcodes, { plugins: getEditorComponents() })
.use(remarkAllowHtmlEntities)
.parse(markdown);
.use(remarkSquashReferences)
.use(remarkPlugins);
/**
* Parse the Markdown string input to an MDAST.
*/
const parsed = processor.parse(markdown);
/**
* Further transform the MDAST with plugins.
*/
const result = unified().use(remarkSquashReferences).runSync(parsed);
const result = processor.runSync(parsed);
return result;
}
@ -91,7 +94,7 @@ function markdownToRemarkRemoveTokenizers({ inlineTokenizers }) {
/**
* Serialize an MDAST to a Markdown string.
*/
export function remarkToMarkdown(obj) {
export function remarkToMarkdown(obj, remarkPlugins) {
/**
* Rewrite the remark-stringify text visitor to simply return the text value,
* without encoding or escaping any characters. This means we're completely
@ -123,20 +126,24 @@ export function remarkToMarkdown(obj) {
rule: '-',
};
const processor = unified()
.use({ settings: remarkToMarkdownPluginOpts })
.use(remarkEscapeMarkdownEntities)
.use(remarkStripTrailingBreaks)
.use(remarkToMarkdownPlugin)
.use(remarkAllowAllText)
.use(createRemarkShortcodeStringifier({ plugins: getEditorComponents() }))
.use(remarkPlugins);
/**
* Transform the MDAST with plugins.
*/
const processedMdast = unified()
.use(remarkEscapeMarkdownEntities)
.use(remarkStripTrailingBreaks)
.runSync(mdast);
const processedMdast = processor.runSync(mdast);
const markdown = unified()
.use(remarkToMarkdownPlugin, remarkToMarkdownPluginOpts)
.use(remarkAllowAllText)
.use(createRemarkShortcodeStringifier({ plugins: getEditorComponents() }))
.stringify(processedMdast)
.replace(/\r?/g, '');
/**
* Serialize the MDAST to markdown.
*/
const markdown = processor.stringify(processedMdast).replace(/\r?/g, '');
/**
* Return markdown with trailing whitespace removed.
@ -147,8 +154,8 @@ export function remarkToMarkdown(obj) {
/**
* Convert Markdown to HTML.
*/
export function markdownToHtml(markdown, { getAsset, resolveWidget } = {}) {
const mdast = markdownToRemark(markdown);
export function markdownToHtml(markdown, { getAsset, resolveWidget, remarkPlugins = [] } = {}) {
const mdast = markdownToRemark(markdown, remarkPlugins);
const hast = unified()
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset, resolveWidget })
@ -192,8 +199,8 @@ export function htmlToSlate(html) {
/**
* Convert Markdown to Slate's Raw AST.
*/
export function markdownToSlate(markdown, { voidCodeBlock } = {}) {
const mdast = markdownToRemark(markdown);
export function markdownToSlate(markdown, { voidCodeBlock, remarkPlugins = [] } = {}) {
const mdast = markdownToRemark(markdown, remarkPlugins);
const slateRaw = unified()
.use(remarkWrapHtml)
@ -212,8 +219,8 @@ export function markdownToSlate(markdown, { voidCodeBlock } = {}) {
* MDAST. The conversion is manual because Unified can only operate on Unist
* trees.
*/
export function slateToMarkdown(raw, { voidCodeBlock } = {}) {
export function slateToMarkdown(raw, { voidCodeBlock, remarkPlugins = [] } = {}) {
const mdast = slateToRemark(raw, { voidCodeBlock });
const markdown = remarkToMarkdown(mdast);
const markdown = remarkToMarkdown(mdast, remarkPlugins);
return markdown;
}