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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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;
}

View File

@ -3,13 +3,14 @@ group: Configuration
weight: 200
title: Beta Features!
---
We run new functionality in an open beta format from time to time. That means that this functionality is totally available for use, and we *think* it might be ready for primetime, but it could break or change without notice.
We run new functionality in an open beta format from time to time. That means that this functionality is totally available for use, and we _think_ it might be ready for primetime, but it could break or change without notice.
**Use these features at your own risk.**
## Working with a Local Git Repository
***added in netlify-cms@2.10.17 / netlify-cms-app@2.11.14***
**_added in netlify-cms@2.10.17 / netlify-cms-app@2.11.14_**
You can connect Netlify CMS to a local Git repository, instead of working with a live repo.
@ -26,7 +27,8 @@ local_backend: true
3. Run `npx netlify-cms-proxy-server` from the root directory of the above repository.
* If the default port (8081) is in use, the proxy server won't start and you will see an error message. In this case, follow [these steps](#configure-the-netlify-cms-proxy-server-port-number) before proceeding.
- If the default port (8081) is in use, the proxy server won't start and you will see an error message. In this case, follow [these steps](#configure-the-netlify-cms-proxy-server-port-number) before proceeding.
4. Start your local development server (e.g. run `gatsby develop`).
5. Open <http://localhost:8000/admin> to verify that your can administer your content locally.
@ -55,7 +57,7 @@ local_backend:
## GitLab and BitBucket Editorial Workflow Support
***added in netlify-cms@2.10.6 / netlify-cms-app@2.11.3***
**_added in netlify-cms@2.10.6 / netlify-cms-app@2.11.3_**
You can enable the Editorial Workflow with the following line in your Netlify CMS `config.yml` file:
@ -212,7 +214,7 @@ Learn more about the benefits of GraphQL in the [GraphQL docs](https://graphql.o
When using the [GitHub backend](/docs/github-backend), you can use Netlify CMS to accept contributions from GitHub users without giving them access to your repository. When they make changes in the CMS, the CMS forks your repository for them behind the scenes, and all the changes are made to the fork. When the contributor is ready to submit their changes, they can set their draft as ready for review in the CMS. This triggers a pull request to your repository, which you can merge using the GitHub UI.
At the same time, any contributors who *do* have write access to the repository can continue to use Netlify CMS normally.
At the same time, any contributors who _do_ have write access to the repository can continue to use Netlify CMS normally.
More details and setup instructions can be found on [the Open Authoring docs page](/docs/open-authoring).
@ -285,11 +287,11 @@ And for the image field being populated with a value of `image.png`.
Supports all of the [`slug` templates](/docs/configuration-options#slug) and:
* `{{dirname}}` The path to the file's parent directory, relative to the collection's `folder`.
* `{{filename}}` The file name without the extension part.
* `{{extension}}` The file extension.
* `{{media_folder}}` The global `media_folder`.
* `{{public_folder}}` The global `public_folder`.
- `{{dirname}}` The path to the file's parent directory, relative to the collection's `folder`.
- `{{filename}}` The file name without the extension part.
- `{{extension}}` The file extension.
- `{{media_folder}}` The global `media_folder`.
- `{{public_folder}}` The global `public_folder`.
## List Widget: Variable Types
@ -305,9 +307,9 @@ To use variable types in the list widget, update your field configuration as fol
### Additional list widget options
* `types`: a nested list of object widgets. All widgets must be of type `object`. Every object widget may define different set of fields.
* `typeKey`: the name of the field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined. Default is `type`.
* `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
- `types`: a nested list of object widgets. All widgets must be of type `object`. Every object widget may define different set of fields.
- `typeKey`: the name of the field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined. Default is `type`.
- `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
### Example Configuration
@ -502,12 +504,12 @@ Netlify CMS generates the following commit types:
Template tags produce the following output:
* `{{slug}}`: the url-safe filename of the entry changed
* `{{collection}}`: the name of the collection containing the entry changed
* `{{path}}`: the full path to the file changed
* `{{message}}`: the relevant message based on the current change (e.g. the `create` message when an entry is created)
* `{{author-login}}`: the login/username of the author
* `{{author-name}}`: the full name of the author (might be empty based on the user's profile)
- `{{slug}}`: the url-safe filename of the entry changed
- `{{collection}}`: the name of the collection containing the entry changed
- `{{path}}`: the full path to the file changed
- `{{message}}`: the relevant message based on the current change (e.g. the `create` message when an entry is created)
- `{{author-login}}`: the login/username of the author
- `{{author-name}}`: the full name of the author (might be empty based on the user's profile)
## Image widget file size limit
@ -634,6 +636,7 @@ collections:
```
Nested collections expect the following directory structure:
```bash
content
└── pages
@ -647,3 +650,17 @@ content
│ └── index.md
└── index.md
```
## Remark plugins
You can register plugins to customize [`remark`](https://github.com/remarkjs/remark), the library used by the richtext editor for serializing and deserializing markdown.
```js
// register a plugin
CMS.registerRemarkPlugin(plugin);
// provide global settings to all plugins, e.g. for customizing `remark-stringify`
CMS.registerRemarkPlugin({ settings: { bullet: '-' } });
```
Note that `netlify-widget-markdown` currently uses `remark@10`, so you should check a plugin's compatibility first.