From 469a50afa4b6cd3f10f123bbe8d112be6ee13e0f Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Wed, 12 Jul 2017 23:15:42 -0400 Subject: [PATCH] add idempotent markdown/html shortcode handling --- package.json | 4 + src/components/Widgets/Markdown/unified.js | 118 +++++++++++++++++++-- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4a51414d..5ba48323 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,11 @@ "classnames": "^2.2.5", "dateformat": "^1.0.12", "deep-equal": "^1.0.1", + "deepmerge": "^1.5.0", "fuzzy": "^0.1.1", + "hast-util-from-string": "^1.0.0", + "hast-util-sanitize": "^1.1.1", + "hast-util-to-mdast": "^1.2.0", "history": "^2.1.2", "immutability-helper": "^2.0.0", "immutable": "^3.7.6", diff --git a/src/components/Widgets/Markdown/unified.js b/src/components/Widgets/Markdown/unified.js index b7e2b467..046ee2c5 100644 --- a/src/components/Widgets/Markdown/unified.js +++ b/src/components/Widgets/Markdown/unified.js @@ -9,7 +9,15 @@ import remarkToMarkdown from 'remark-stringify'; import rehypeSanitize from 'rehype-sanitize'; import rehypeReparse from 'rehype-raw'; import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; +import ReactDOMServer from 'react-dom/server'; +import registry from '../../../lib/registry'; +import merge from 'deepmerge'; +import rehypeSanitizeSchemaDefault from 'hast-util-sanitize/lib/github'; +import hastFromString from 'hast-util-from-string'; +import hastToMdastHandlerAll from 'hast-util-to-mdast/all'; +import { reduce, capitalize } from 'lodash'; +const shortcodeAttributePrefix = 'ncp'; /** * Remove empty nodes, including the top level parents of deeply nested empty nodes. @@ -17,19 +25,21 @@ import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; const rehypeRemoveEmpty = () => { const isVoidElement = node => ['img', 'hr'].includes(node.tagName); const isNonEmptyLeaf = node => ['text', 'raw'].includes(node.type) && node.value; + const isShortcode = node => node.properties && node.properties[`data${capitalize(shortcodeAttributePrefix)}`]; const isNonEmptyNode = node => { return isVoidElement(node) || isNonEmptyLeaf(node) + || isShortcode(node) || find(node.children, isNonEmptyNode); }; const transform = node => { - if (isVoidElement(node) || isNonEmptyLeaf(node)) { + if (isVoidElement(node) || isNonEmptyLeaf(node) || isShortcode(node)) { return node; } if (node.children) { node.children = node.children.reduce((acc, childNode) => { - if (isVoidElement(childNode) || isNonEmptyLeaf(childNode)) { + if (isVoidElement(childNode) || isNonEmptyLeaf(childNode) || isShortcode(node)) { return acc.concat(childNode); } return find(childNode.children, isNonEmptyNode) ? acc.concat(transform(childNode)) : acc; @@ -89,16 +99,91 @@ const rehypePaperEmoji = () => { return transform; }; +const rehypeShortcodes = () => { + const plugins = registry.getEditorComponents(); + const transform = node => { + const { properties } = node; + const dataPrefix = `data${capitalize(shortcodeAttributePrefix)}`; + const pluginId = properties && properties[dataPrefix]; + const plugin = plugins.get(pluginId); + + if (plugin) { + const data = reduce(properties, (acc, value, key) => { + if (key.startsWith(dataPrefix)) { + const dataKey = key.slice(dataPrefix.length).toLowerCase(); + if (dataKey) { + acc[dataKey] = value; + } + } + return acc; + }, {}); + + node.data = node.data || {}; + node.data[shortcodeAttributePrefix] = true; + + return hastFromString(node, plugin.toBlock(data)); + } + + node.children = node.children ? node.children.map(transform) : node.children; + + return node; + }; + return transform; +} + +function remarkPrecompileShortcodes() { + const Compiler = this.Compiler; + const visitors = Compiler.prototype.visitors; + const textVisitor = visitors.text; + + visitors.text = newTextVisitor; + + function newTextVisitor(node, parent) { + if (parent.data && parent.data[shortcodeAttributePrefix]) { + return node.value; + } + return textVisitor.call(this, node, parent); + } +} + +const parseShortcodesFromMarkdown = markdown => { + const plugins = registry.getEditorComponents(); + const markdownLines = markdown.split('\n'); + const markdownLinesParsed = plugins.reduce((lines, plugin) => { + const result = lines.map(line => { + return line.replace(plugin.pattern, (...match) => { + const data = plugin.fromBlock(match); + const preview = plugin.toPreview(data); + const html = typeof preview === 'string' ? preview : ReactDOMServer.renderToStaticMarkup(preview); + const dataAttrs = reduce(data, (attrs, val, key) => { + attrs.push(`data-${shortcodeAttributePrefix}-${key}="${val}"`); + return attrs; + }, [`data-${shortcodeAttributePrefix}="${plugin.id}"`]); + const result = `
${html}
`; + return result; + }); + }); + return result; + }, markdownLines); + return markdownLinesParsed.join('\n'); +}; + +const rehypeSanitizeSchema = merge(rehypeSanitizeSchemaDefault, { attributes: { '*': [ 'data*' ] } }); + export const markdownToHtml = markdown => { + // Parse shortcodes from the raw markdown rather than via Unified plugin. + // This ensures against conflicts between shortcode syntax and Unified + // parsing rules. + const markdownWithParsedShortcodes = parseShortcodesFromMarkdown(markdown); const result = unified() .use(markdownToRemark, { fences: true }) .use(remarkToRehype, { allowDangerousHTML: true }) .use(rehypeReparse) .use(rehypeRemoveEmpty) - .use(rehypeSanitize) + .use(rehypeSanitize, rehypeSanitizeSchema) .use(rehypeMinifyWhitespace) .use(rehypeToHtml, { allowDangerousHTML: true }) - .processSync(markdown) + .processSync(markdownWithParsedShortcodes) .contents; return result; } @@ -106,13 +191,32 @@ export const markdownToHtml = markdown => { export const htmlToMarkdown = html => { const result = unified() .use(htmlToRehype, { fragment: true }) - .use(rehypePaperEmoji) - .use(rehypeSanitize) + .use(rehypeSanitize, rehypeSanitizeSchema) .use(rehypeRemoveEmpty) .use(rehypeMinifyWhitespace) - .use(rehypeToRemark) + .use(rehypePaperEmoji) + .use(rehypeShortcodes) + .use(rehypeToRemark, { handlers: { div: (h, node) => { + const dataPrefix = `data${capitalize(shortcodeAttributePrefix)}`; + const isShortcode = node.properties[dataPrefix]; + if (isShortcode) { + const paragraph = h(node, 'paragraph', hastToMdastHandlerAll(h, node)); + paragraph.data = paragraph.data || {}; + paragraph.data[shortcodeAttributePrefix] = true; + return paragraph; + } + }}}) + .use(() => node => { + return node; + }) .use(remarkNestedList) .use(remarkToMarkdown, { listItemIndent: '1', fences: true }) + .use(remarkPrecompileShortcodes) + /* + .use(() => node => { + return node; + }) + */ .processSync(html) .contents; return result;