feat(editor-components): match any characters with shortcodes (#2268)

This commit is contained in:
Shawn Erquhart 2019-04-10 15:23:53 -04:00 committed by GitHub
parent 8867c5acb6
commit 14b6292eab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 78 additions and 155 deletions

View File

@ -313,13 +313,14 @@ Object {
Object { Object {
"nodes": Array [ "nodes": Array [
Object { Object {
"data": undefined, "data": Object {
"leaves": Array [ "alt": "super",
Object { "title": null,
"text": "![super](duper.jpg)", "url": "duper.jpg",
}, },
], "isVoid": true,
"object": "text", "object": "inline",
"type": "image",
}, },
], ],
"object": "block", "object": "block",
@ -1520,13 +1521,14 @@ Object {
Object { Object {
"nodes": Array [ "nodes": Array [
Object { Object {
"data": undefined, "data": Object {
"leaves": Array [ "alt": "test",
Object { "title": null,
"text": "![test](test.png)", "url": "test.png",
}, },
], "isVoid": true,
"object": "text", "object": "inline",
"type": "image",
}, },
], ],
"object": "block", "object": "block",

View File

@ -70,9 +70,9 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
<h4>H4</h4> <h4>H4</h4>
<p><a href=\\"http://google.com\\">link title</a></p> <p><a href=\\"http://google.com\\">link title</a></p>
<h5>H5</h5> <h5>H5</h5>
<p>![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)</p> <p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" alt=\\"alt text\\"></p>
<h6>H6</h6> <h6>H6</h6>
<p>![](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)</p>", <p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\"></p>",
} }
} }
/> />

View File

@ -14,8 +14,7 @@ import remarkPaddedLinks from './remarkPaddedLinks';
import remarkWrapHtml from './remarkWrapHtml'; import remarkWrapHtml from './remarkWrapHtml';
import remarkToSlate from './remarkSlate'; import remarkToSlate from './remarkSlate';
import remarkSquashReferences from './remarkSquashReferences'; import remarkSquashReferences from './remarkSquashReferences';
import remarkImagesToText from './remarkImagesToText'; import { remarkParseShortcodes, createRemarkShortcodeStringifier } from './remarkShortcodes';
import remarkShortcodes from './remarkShortcodes';
import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities'; import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities';
import remarkStripTrailingBreaks from './remarkStripTrailingBreaks'; import remarkStripTrailingBreaks from './remarkStripTrailingBreaks';
import remarkAllowHtmlEntities from './remarkAllowHtmlEntities'; import remarkAllowHtmlEntities from './remarkAllowHtmlEntities';
@ -66,6 +65,7 @@ export const markdownToRemark = markdown => {
const parsed = unified() const parsed = unified()
.use(markdownToRemarkPlugin, { fences: true, commonmark: true }) .use(markdownToRemarkPlugin, { fences: true, commonmark: true })
.use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] }) .use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] })
.use(remarkParseShortcodes, { plugins: getEditorComponents() })
.use(remarkAllowHtmlEntities) .use(remarkAllowHtmlEntities)
.parse(markdown); .parse(markdown);
@ -74,8 +74,6 @@ export const markdownToRemark = markdown => {
*/ */
const result = unified() const result = unified()
.use(remarkSquashReferences) .use(remarkSquashReferences)
.use(remarkImagesToText)
.use(remarkShortcodes, { plugins: getEditorComponents() })
.runSync(parsed); .runSync(parsed);
return result; return result;
@ -136,6 +134,7 @@ export const remarkToMarkdown = obj => {
const markdown = unified() const markdown = unified()
.use(remarkToMarkdownPlugin, remarkToMarkdownPluginOpts) .use(remarkToMarkdownPlugin, remarkToMarkdownPluginOpts)
.use(remarkAllowAllText) .use(remarkAllowAllText)
.use(createRemarkShortcodeStringifier({ plugins: getEditorComponents() }))
.stringify(processedMdast); .stringify(processedMdast);
/** /**
@ -179,8 +178,6 @@ export const htmlToSlate = html => {
const slateRaw = unified() const slateRaw = unified()
.use(remarkAssertParents) .use(remarkAssertParents)
.use(remarkPaddedLinks) .use(remarkPaddedLinks)
.use(remarkImagesToText)
.use(remarkShortcodes, { plugins: getEditorComponents() })
.use(remarkWrapHtml) .use(remarkWrapHtml)
.use(remarkToSlate) .use(remarkToSlate)
.runSync(mdast); .runSync(mdast);

View File

@ -1,99 +1,48 @@
import { map, every } from 'lodash'; export function remarkParseShortcodes({ plugins }) {
import u from 'unist-builder'; const Parser = this.Parser;
import mdastToString from 'mdast-util-to-string'; const tokenizers = Parser.prototype.blockTokenizers;
const methods = Parser.prototype.blockMethods;
/** tokenizers.shortcode = createShortcodeTokenizer({ plugins });
* Parse shortcodes from an MDAST.
*
* Shortcodes are plain text, and must be the lone content of a paragraph. The
* paragraph must also be a direct child of the root node. When a shortcode is
* found, we just need to add data to the node so the shortcode can be
* identified and processed when serializing to a new format. The paragraph
* containing the node is also recreated to ensure normalization.
*/
export default function remarkShortcodes({ plugins }) {
return transform;
/** methods.unshift('shortcode');
* Map over children of the root node and convert any found shortcode nodes. }
*/
function transform(root) {
const transformedChildren = map(root.children, processShortcodes);
return { ...root, children: transformedChildren };
}
/** function createShortcodeTokenizer({ plugins }) {
* Mapping function to transform nodes that contain shortcodes. return function tokenizeShortcode(eat, value, silent) {
*/ const potentialMatchValue = value.split('\n\n')[0];
function processShortcodes(node) {
/**
* If the node is not eligible to contain a shortcode, return the original
* node unchanged.
*/
if (!nodeMayContainShortcode(node)) return node;
/**
* Combine the text values of all children to a single string, check the
* string for a shortcode pattern match, and validate the match.
*/
const text = mdastToString(node).trim();
const { plugin, match } = matchTextToPlugin(text);
const matchIsValid = validateMatch(text, match);
/**
* If a valid match is found, return a new node with shortcode data
* included. Otherwise, return the original node.
*/
return matchIsValid ? createShortcodeNode(text, plugin, match) : node;
}
/**
* Ensure that the node and it's children are acceptable types to contain
* shortcodes. Currently, only a paragraph containing text and/or html nodes
* may contain shortcodes.
*/
function nodeMayContainShortcode(node) {
const validNodeTypes = ['paragraph'];
const validChildTypes = ['text', 'html'];
if (validNodeTypes.includes(node.type)) {
return every(node.children, child => {
return validChildTypes.includes(child.type);
});
}
}
/**
* Return the plugin and RegExp.match result from the first plugin with a
* pattern that matches the given text.
*/
function matchTextToPlugin(text) {
let match; let match;
const plugin = plugins.find(p => { const plugin = plugins.find(plugin => {
match = text.match(p.pattern); match = potentialMatchValue.trim().match(plugin.pattern);
return !!match; return !!match;
}); });
return { plugin, match };
}
/** if (match) {
* A match is only valid if it takes up the entire paragraph. if (silent) {
*/ return true;
function validateMatch(text, match) { }
return match && match[0].length === text.length;
}
/** const shortcodeData = plugin.fromBlock(match);
* Create a new node with shortcode data included. Use an 'html' node instead
* of a 'text' node as the child to ensure the node content is not parsed by return eat(match[0])({
* Remark or Rehype. Include the child as an array because an MDAST paragraph type: 'shortcode',
* node must have it's children in an array. data: { shortcode: plugin.id, shortcodeData },
*/ });
function createShortcodeNode(text, plugin, match) { }
const shortcode = plugin.id; };
const shortcodeData = plugin.fromBlock(match); }
const data = { shortcode, shortcodeData };
const textNode = u('html', text); export function createRemarkShortcodeStringifier({ plugins }) {
return u('paragraph', { data }, [textNode]); return function remarkStringifyShortcodes() {
} const Compiler = this.Compiler;
const visitors = Compiler.prototype.visitors;
visitors.shortcode = shortcode;
function shortcode(node) {
const { data } = node;
const plugin = plugins.find(plugin => data.shortcode === plugin.id);
return plugin.toBlock(data.shortcodeData);
}
};
} }

View File

@ -1,4 +1,4 @@
import { get, isEmpty, isArray, last, flatMap } from 'lodash'; import { isEmpty, isArray, last, flatMap } from 'lodash';
/** /**
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins * A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
@ -169,14 +169,7 @@ function convertMarkNode(node) {
* transformer. * transformer.
*/ */
function convertNode(node, nodes) { function convertNode(node, nodes) {
/** switch (node.type) {
* Unified/Remark processors use mutable operations, so we don't want to
* change a node's type directly for conversion purposes, as that tends to
* unexpected errors.
*/
const type = get(node, ['data', 'shortcode']) ? 'shortcode' : node.type;
switch (type) {
/** /**
* General * General
* *
@ -189,7 +182,7 @@ function convertNode(node, nodes) {
case 'blockquote': case 'blockquote':
case 'tableRow': case 'tableRow':
case 'tableCell': { case 'tableCell': {
return createBlock(typeMap[type], nodes); return createBlock(typeMap[node.type], nodes);
} }
/** /**
@ -202,7 +195,7 @@ function convertNode(node, nodes) {
case 'shortcode': { case 'shortcode': {
const { data } = node; const { data } = node;
const nodes = [createText('')]; const nodes = [createText('')];
return createBlock(typeMap[type], nodes, { data, isVoid: true }); return createBlock(typeMap[node.type], nodes, { data, isVoid: true });
} }
/** /**
@ -272,7 +265,7 @@ function convertNode(node, nodes) {
const data = { lang: node.lang }; const data = { lang: node.lang };
const text = createText(node.value); const text = createText(node.value);
const nodes = [text]; const nodes = [text];
return createBlock(typeMap[type], nodes, { data }); return createBlock(typeMap[node.type], nodes, { data });
} }
/** /**
@ -307,7 +300,7 @@ function convertNode(node, nodes) {
* Thematic breaks are void nodes in the Slate schema. * Thematic breaks are void nodes in the Slate schema.
*/ */
case 'thematicBreak': { case 'thematicBreak': {
return createBlock(typeMap[type], { isVoid: true }); return createBlock(typeMap[node.type], { isVoid: true });
} }
/** /**
@ -319,7 +312,7 @@ function convertNode(node, nodes) {
case 'link': { case 'link': {
const { title, url, data } = node; const { title, url, data } = node;
const newData = { ...data, title, url }; const newData = { ...data, title, url };
return createInline(typeMap[type], { data: newData }, nodes); return createInline(typeMap[node.type], { data: newData }, nodes);
} }
/** /**
@ -332,7 +325,7 @@ function convertNode(node, nodes) {
case 'image': { case 'image': {
const { title, url, alt, data } = node; const { title, url, alt, data } = node;
const newData = { ...data, title, alt, url }; const newData = { ...data, title, alt, url };
return createInline(typeMap[type], { isVoid: true, data: newData }); return createInline(typeMap[node.type], { isVoid: true, data: newData });
} }
/** /**
@ -343,7 +336,7 @@ function convertNode(node, nodes) {
*/ */
case 'table': { case 'table': {
const data = { align: node.align }; const data = { align: node.align };
return createBlock(typeMap[type], nodes, { data }); return createBlock(typeMap[node.type], nodes, { data });
} }
} }
} }

View File

@ -25,6 +25,7 @@ const typeMap = {
'thematic-break': 'thematicBreak', 'thematic-break': 'thematicBreak',
link: 'link', link: 'link',
image: 'image', image: 'image',
shortcode: 'shortcode',
}; };
/** /**
@ -37,14 +38,7 @@ const markMap = {
code: 'inlineCode', code: 'inlineCode',
}; };
let shortcodePlugins; export default function slateToRemark(raw) {
export default function slateToRemark(raw, opts) {
/**
* Set shortcode plugins in outer scope.
*/
({ shortcodePlugins } = opts);
/** /**
* The Slate Raw AST generally won't have a top level type, so we set it to * The Slate Raw AST generally won't have a top level type, so we set it to
* "root" for clarity. * "root" for clarity.
@ -76,9 +70,7 @@ function transform(node) {
/** /**
* Run individual nodes through conversion factories. * Run individual nodes through conversion factories.
*/ */
return ['text'].includes(node.object) return ['text'].includes(node.object) ? convertTextNode(node) : convertNode(node, children);
? convertTextNode(node)
: convertNode(node, children, shortcodePlugins);
} }
/** /**
@ -388,9 +380,9 @@ function getMarkLength(markType, nodes) {
/** /**
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u` * Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u`
* function to create MDAST nodes and parses shortcodes. * function to create MDAST nodes.
*/ */
function convertNode(node, children, shortcodePlugins) { function convertNode(node, children) {
switch (node.type) { switch (node.type) {
/** /**
* General * General
@ -418,19 +410,12 @@ function convertNode(node, children, shortcodePlugins) {
* property contains the data received from the shortcode plugin's * property contains the data received from the shortcode plugin's
* `fromBlock` method when the shortcode node was created. * `fromBlock` method when the shortcode node was created.
* *
* Here we get the shortcode plugin from the registry and use it's * Here we create a `shortcode` MDAST node that contains only the shortcode
* `toBlock` method to recreate the original markdown shortcode. We then * data.
* insert that text into a new "html" type node (a "text" type node
* might get encoded or escaped by remark-stringify). Finally, we wrap
* the "html" node in a "paragraph" type node, as shortcode nodes must
* be alone in their own paragraph.
*/ */
case 'shortcode': { case 'shortcode': {
const { data } = node; const { data } = node;
const plugin = shortcodePlugins.get(data.shortcode); return u(typeMap[node.type], { data });
const text = plugin.toBlock(data.shortcodeData);
const textNode = u('html', text);
return u('paragraph', { data }, [textNode]);
} }
/** /**
@ -510,10 +495,7 @@ function convertNode(node, children, shortcodePlugins) {
* Images * Images
* *
* This transformation is almost identical to that of links, except for the * This transformation is almost identical to that of links, except for the
* lack of child nodes and addition of `alt` attribute data. Currently the * lack of child nodes and addition of `alt` attribute data.
* CMS handles block images by shortcode, so this case will only apply to
* inline images, which currently can only occur through raw markdown
* insertion.
*/ */
case 'image': { case 'image': {
const { url, title, alt, ...data } = get(node, 'data', {}); const { url, title, alt, ...data } = get(node, 'data', {});