organize serializers
This commit is contained in:
parent
dd51f6365c
commit
9dcda7b0b9
@ -1,6 +1,6 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes } from 'react';
|
||||||
import { Editor as Slate, Plain } from 'slate';
|
import { Editor as Slate, Plain } from 'slate';
|
||||||
import { markdownToRemark, remarkToMarkdown } from '../../unified';
|
import { markdownToRemark, remarkToMarkdown } from '../../serializers';
|
||||||
import Toolbar from '../Toolbar/Toolbar';
|
import Toolbar from '../Toolbar/Toolbar';
|
||||||
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
||||||
import styles from './index.css';
|
import styles from './index.css';
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
import { get, isEmpty } from 'lodash';
|
import { get, isEmpty } from 'lodash';
|
||||||
import { Editor as Slate, Raw, Block, Text } from 'slate';
|
import { Editor as Slate, Raw, Block, Text } from 'slate';
|
||||||
import { slateToRemark, remarkToSlate, htmlToSlate } from '../../unified';
|
import { slateToRemark, remarkToSlate, htmlToSlate } from '../../serializers';
|
||||||
import registry from '../../../../../lib/registry';
|
import registry from '../../../../../lib/registry';
|
||||||
import Toolbar from '../Toolbar/Toolbar';
|
import Toolbar from '../Toolbar/Toolbar';
|
||||||
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes } from 'react';
|
||||||
import registry from '../../../../lib/registry';
|
import registry from '../../../../lib/registry';
|
||||||
import { markdownToRemark, remarkToMarkdown } from '../unified';
|
import { markdownToRemark, remarkToMarkdown } from '../serializers'
|
||||||
import RawEditor from './RawEditor';
|
import RawEditor from './RawEditor';
|
||||||
import VisualEditor from './VisualEditor';
|
import VisualEditor from './VisualEditor';
|
||||||
import { StickyContainer } from '../../../UI/Sticky/Sticky';
|
import { StickyContainer } from '../../../UI/Sticky/Sticky';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes } from 'react';
|
||||||
import { remarkToHtml } from '../unified';
|
import { remarkToHtml } from '../serializers';
|
||||||
import previewStyle from '../../defaultPreviewStyle';
|
import previewStyle from '../../defaultPreviewStyle';
|
||||||
|
|
||||||
const MarkdownPreview = ({ value, getAsset }) => {
|
const MarkdownPreview = ({ value, getAsset }) => {
|
||||||
|
203
src/components/Widgets/Markdown/serializers/index.js
Normal file
203
src/components/Widgets/Markdown/serializers/index.js
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { get, isEmpty, reduce } from 'lodash';
|
||||||
|
import unified from 'unified';
|
||||||
|
import u from 'unist-builder';
|
||||||
|
import markdownToRemarkPlugin from 'remark-parse';
|
||||||
|
import remarkToMarkdownPlugin from 'remark-stringify';
|
||||||
|
import remarkToRehype from 'remark-rehype';
|
||||||
|
import rehypeToHtml from 'rehype-stringify';
|
||||||
|
import htmlToRehype from 'rehype-parse';
|
||||||
|
import rehypeToRemark from 'rehype-remark';
|
||||||
|
import rehypeMinifyWhitespace from 'rehype-minify-whitespace';
|
||||||
|
import remarkToRehypeShortcodes from './remark-rehype-shortcodes';
|
||||||
|
import rehypeRemoveEmpty from './rehype-remove-empty';
|
||||||
|
import rehypePaperEmoji from './rehype-paper-emoji';
|
||||||
|
import remarkNestedList from './remark-nested-list';
|
||||||
|
import remarkToSlatePlugin from './remark-slate';
|
||||||
|
import remarkImagesToText from './remark-images-to-text';
|
||||||
|
import remarkShortcodes from './remark-shortcodes';
|
||||||
|
import registry from '../../../../lib/registry';
|
||||||
|
|
||||||
|
export const remarkToHtml = (mdast, getAsset) => {
|
||||||
|
const result = unified()
|
||||||
|
.use(remarkToRehypeShortcodes, { plugins: registry.getEditorComponents(), getAsset })
|
||||||
|
.use(remarkToRehype, { allowDangerousHTML: true })
|
||||||
|
.runSync(mdast);
|
||||||
|
|
||||||
|
const output = unified()
|
||||||
|
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true })
|
||||||
|
.stringify(result);
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export const htmlToSlate = html => {
|
||||||
|
const hast = unified()
|
||||||
|
.use(htmlToRehype, { fragment: true })
|
||||||
|
.parse(html);
|
||||||
|
|
||||||
|
const result = unified()
|
||||||
|
.use(rehypeRemoveEmpty)
|
||||||
|
.use(rehypeMinifyWhitespace)
|
||||||
|
.use(rehypePaperEmoji)
|
||||||
|
.use(rehypeToRemark)
|
||||||
|
.use(remarkNestedList)
|
||||||
|
.use(remarkToSlatePlugin)
|
||||||
|
.runSync(hast);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markdownToRemark = markdown => {
|
||||||
|
const parsed = unified()
|
||||||
|
.use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true })
|
||||||
|
.parse(markdown);
|
||||||
|
|
||||||
|
const result = unified()
|
||||||
|
.use(remarkImagesToText)
|
||||||
|
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
|
||||||
|
.runSync(parsed);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remarkToMarkdown = obj => {
|
||||||
|
/**
|
||||||
|
* Rewrite the remark-stringify text visitor to simply return the text value,
|
||||||
|
* without encoding or escaping any characters. This means we're completely
|
||||||
|
* trusting the markdown that we receive.
|
||||||
|
*/
|
||||||
|
function remarkAllowAllText() {
|
||||||
|
const Compiler = this.Compiler;
|
||||||
|
const visitors = Compiler.prototype.visitors;
|
||||||
|
visitors.text = node => node.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mdast = obj || u('root', [u('paragraph', [u('text', '')])]);
|
||||||
|
const result = unified()
|
||||||
|
.use(remarkToMarkdownPlugin, { listItemIndent: '1', fences: true, pedantic: true, commonmark: true })
|
||||||
|
.use(remarkAllowAllText)
|
||||||
|
.stringify(mdast);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remarkToSlate = mdast => {
|
||||||
|
const result = unified()
|
||||||
|
.use(remarkToSlatePlugin)
|
||||||
|
.runSync(mdast);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slateToRemark = (raw, shortcodePlugins) => {
|
||||||
|
const typeMap = {
|
||||||
|
'paragraph': 'paragraph',
|
||||||
|
'heading-one': 'heading',
|
||||||
|
'heading-two': 'heading',
|
||||||
|
'heading-three': 'heading',
|
||||||
|
'heading-four': 'heading',
|
||||||
|
'heading-five': 'heading',
|
||||||
|
'heading-six': 'heading',
|
||||||
|
'quote': 'blockquote',
|
||||||
|
'code': 'code',
|
||||||
|
'numbered-list': 'list',
|
||||||
|
'bulleted-list': 'list',
|
||||||
|
'list-item': 'listItem',
|
||||||
|
'table': 'table',
|
||||||
|
'table-row': 'tableRow',
|
||||||
|
'table-cell': 'tableCell',
|
||||||
|
'thematic-break': 'thematicBreak',
|
||||||
|
'link': 'link',
|
||||||
|
'image': 'image',
|
||||||
|
};
|
||||||
|
const markMap = {
|
||||||
|
bold: 'strong',
|
||||||
|
italic: 'emphasis',
|
||||||
|
strikethrough: 'delete',
|
||||||
|
code: 'inlineCode',
|
||||||
|
};
|
||||||
|
const transform = node => {
|
||||||
|
const children = isEmpty(node.nodes) ? node.nodes : node.nodes.reduce((acc, childNode) => {
|
||||||
|
if (childNode.kind !== 'text') {
|
||||||
|
acc.push(transform(childNode));
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (childNode.ranges) {
|
||||||
|
childNode.ranges.forEach(range => {
|
||||||
|
const { marks = [], text } = range;
|
||||||
|
const markTypes = marks.map(mark => markMap[mark.type]);
|
||||||
|
if (markTypes.includes('inlineCode')) {
|
||||||
|
acc.push(u('inlineCode', text));
|
||||||
|
} else {
|
||||||
|
const textNode = u('html', text);
|
||||||
|
const nestedText = !markTypes.length ? textNode : markTypes.reduce((acc, markType) => {
|
||||||
|
const nested = u(markType, [acc]);
|
||||||
|
return nested;
|
||||||
|
}, textNode);
|
||||||
|
acc.push(nestedText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
|
||||||
|
acc.push(u('html', childNode.text));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (node.type === 'root') {
|
||||||
|
return u('root', children);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'shortcode') {
|
||||||
|
const { data } = node;
|
||||||
|
const plugin = shortcodePlugins.get(data.shortcode);
|
||||||
|
const text = plugin.toBlock(data.shortcodeData);
|
||||||
|
const textNode = u('html', text);
|
||||||
|
return u('paragraph', { data }, [ textNode ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type.startsWith('heading')) {
|
||||||
|
const depths = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
||||||
|
const depth = node.type.split('-')[1];
|
||||||
|
const props = { depth: depths[depth] };
|
||||||
|
return u(typeMap[node.type], props, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['paragraph', 'quote', 'list-item', 'table', 'table-row', 'table-cell'].includes(node.type)) {
|
||||||
|
return u(typeMap[node.type], children);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'code') {
|
||||||
|
const value = get(node.nodes, [0, 'text']);
|
||||||
|
const props = { lang: get(node.data, 'lang') };
|
||||||
|
return u(typeMap[node.type], props, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['numbered-list', 'bulleted-list'].includes(node.type)) {
|
||||||
|
const ordered = node.type === 'numbered-list';
|
||||||
|
const props = { ordered, start: get(node.data, 'start') || 1 };
|
||||||
|
return u(typeMap[node.type], props, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'thematic-break') {
|
||||||
|
return u(typeMap[node.type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'link') {
|
||||||
|
const data = get(node, 'data', {});
|
||||||
|
const { url, title } = data;
|
||||||
|
return u(typeMap[node.type], data, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'image') {
|
||||||
|
const data = get(node, 'data', {});
|
||||||
|
const { url, title, alt } = data;
|
||||||
|
return u(typeMap[node.type], data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw.type = 'root';
|
||||||
|
const mdast = transform(raw);
|
||||||
|
|
||||||
|
const result = unified()
|
||||||
|
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
|
||||||
|
.runSync(mdast);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Dropbox Paper outputs emoji characters as images, and stores the actual
|
||||||
|
* emoji character in a `data-emoji-ch` attribute on the image. This plugin
|
||||||
|
* replaces the images with the emoji characters.
|
||||||
|
*/
|
||||||
|
export default function rehypePaperEmoji() {
|
||||||
|
const transform = node => {
|
||||||
|
if (node.tagName === 'img' && node.properties.dataEmojiCh) {
|
||||||
|
return { type: 'text', value: node.properties.dataEmojiCh };
|
||||||
|
}
|
||||||
|
node.children = node.children ? node.children.map(transform) : node.children;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
return transform;
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
import { find, capitalize } from 'lodash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove empty nodes, including the top level parents of deeply nested empty nodes.
|
||||||
|
*/
|
||||||
|
export default function rehypeRemoveEmpty() {
|
||||||
|
const isVoidElement = node => ['img', 'hr', 'br'].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) || isShortcode(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
node.children = node.children.reduce((acc, childNode) => {
|
||||||
|
if (isVoidElement(childNode) || isNonEmptyLeaf(childNode) || isShortcode(node)) {
|
||||||
|
return acc.concat(childNode);
|
||||||
|
}
|
||||||
|
return find(childNode.children, isNonEmptyNode) ? acc.concat(transform(childNode)) : acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
return transform;
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Images must be parsed as shortcodes for asset proxying. This plugin converts
|
||||||
|
* MDAST image nodes back to text to allow shortcode pattern matching.
|
||||||
|
*/
|
||||||
|
export default function remarkImagesToText() {
|
||||||
|
return transform;
|
||||||
|
|
||||||
|
function transform(node) {
|
||||||
|
const children = node.children ? node.children.map(transform) : node.children;
|
||||||
|
if (node.type === 'image') {
|
||||||
|
const alt = node.alt || '';
|
||||||
|
const url = node.url || '';
|
||||||
|
const title = node.title ? ` "${node.title}"` : '';
|
||||||
|
return { type: 'text', value: `` };
|
||||||
|
}
|
||||||
|
return { ...node, children };
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* If the first child of a list item is a list, include it in the previous list
|
||||||
|
* item. Otherwise it translates to markdown as having two bullets. When
|
||||||
|
* rehype-remark processes a list and finds children that are not list items, it
|
||||||
|
* wraps them in list items, which leads to the condition this plugin addresses.
|
||||||
|
* Dropbox Paper currently outputs this kind of HTML, which is invalid. We have
|
||||||
|
* a support issue open for it, and this plugin can potentially be removed when
|
||||||
|
* that's resolved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function remarkNestedList() {
|
||||||
|
const transform = node => {
|
||||||
|
if (node.type === 'list' && node.children && node.children.length > 1) {
|
||||||
|
node.children = node.children.reduce((acc, childNode, index) => {
|
||||||
|
if (index && childNode.children && childNode.children[0].type === 'list') {
|
||||||
|
acc[acc.length - 1].children.push(transform(childNode.children.shift()))
|
||||||
|
if (childNode.children.length) {
|
||||||
|
acc.push(transform(childNode));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acc.push(transform(childNode));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
node.children = node.children.map(transform);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
return transform;
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
import { map, has } from 'lodash';
|
||||||
|
import { renderToString } from 'react-dom/server';
|
||||||
|
import u from 'unist-builder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This plugin doesn't actually transform Remark (MDAST) nodes to Rehype
|
||||||
|
* (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST
|
||||||
|
* conversion by replacing the shortcode text with stringified HTML for
|
||||||
|
* previewing the shortcode output.
|
||||||
|
*/
|
||||||
|
export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
|
||||||
|
return transform;
|
||||||
|
|
||||||
|
function transform(root) {
|
||||||
|
const transformedChildren = map(root.children, processShortcodes);
|
||||||
|
return { ...root, children: transformedChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping function to transform nodes that contain shortcodes.
|
||||||
|
*/
|
||||||
|
function processShortcodes(node) {
|
||||||
|
/**
|
||||||
|
* If the node doesn't contain shortcode data, return the original node.
|
||||||
|
*/
|
||||||
|
if (!has(node, ['data', 'shortcode'])) return node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shortcode data from the node, and retrieve the matching plugin by
|
||||||
|
* key.
|
||||||
|
*/
|
||||||
|
const { shortcode, shortcodeData } = node.data;
|
||||||
|
const plugin = plugins.get(shortcode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the shortcode plugin's `toPreview` method, which will return either
|
||||||
|
* an HTML string or a React component. If a React component is returned,
|
||||||
|
* render it to an HTML string.
|
||||||
|
*/
|
||||||
|
const value = plugin.toPreview(shortcodeData, getAsset);
|
||||||
|
const valueHtml = typeof value === 'string' ? value : renderToString(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a new 'html' type node containing the shortcode preview markup.
|
||||||
|
*/
|
||||||
|
const textNode = u('html', valueHtml);
|
||||||
|
const children = [ textNode ];
|
||||||
|
return { ...node, children };
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
import { map, every } from 'lodash';
|
||||||
|
import u from 'unist-builder';
|
||||||
|
import mdastToString from 'mdast-util-to-string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping function to transform nodes that contain shortcodes.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
const plugin = plugins.find(p => {
|
||||||
|
match = text.match(p.pattern);
|
||||||
|
return !!match;
|
||||||
|
});
|
||||||
|
return { plugin, match };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A match is only valid if it takes up the entire paragraph.
|
||||||
|
*/
|
||||||
|
function validateMatch(text, match) {
|
||||||
|
return match && match[0].length === text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* Remark or Rehype. Include the child as an array because an MDAST paragraph
|
||||||
|
* node must have it's children in an array.
|
||||||
|
*/
|
||||||
|
function createShortcodeNode(text, plugin, match) {
|
||||||
|
const shortcode = plugin.id;
|
||||||
|
const shortcodeData = plugin.fromBlock(match);
|
||||||
|
const data = { shortcode, shortcodeData };
|
||||||
|
const textNode = u('html', text);
|
||||||
|
return u('paragraph', { data }, [textNode]);
|
||||||
|
}
|
||||||
|
}
|
172
src/components/Widgets/Markdown/serializers/remark-slate.js
Normal file
172
src/components/Widgets/Markdown/serializers/remark-slate.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { get, isEmpty } from 'lodash';
|
||||||
|
import u from 'unist-builder';
|
||||||
|
import mdastDefinitions from 'mdast-util-definitions';
|
||||||
|
import modifyChildren from 'unist-util-modify-children';
|
||||||
|
|
||||||
|
export default function remarkToSlatePlugin() {
|
||||||
|
const typeMap = {
|
||||||
|
paragraph: 'paragraph',
|
||||||
|
blockquote: 'quote',
|
||||||
|
code: 'code',
|
||||||
|
listItem: 'list-item',
|
||||||
|
table: 'table',
|
||||||
|
tableRow: 'table-row',
|
||||||
|
tableCell: 'table-cell',
|
||||||
|
thematicBreak: 'thematic-break',
|
||||||
|
link: 'link',
|
||||||
|
image: 'image',
|
||||||
|
};
|
||||||
|
const markMap = {
|
||||||
|
strong: 'bold',
|
||||||
|
emphasis: 'italic',
|
||||||
|
delete: 'strikethrough',
|
||||||
|
inlineCode: 'code',
|
||||||
|
};
|
||||||
|
const toTextNode = (text, data) => ({ kind: 'text', text, data });
|
||||||
|
const wrapText = (node, index, parent) => {
|
||||||
|
if (['text', 'html'].includes(node.type)) {
|
||||||
|
parent.children.splice(index, 1, u('paragraph', [node]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let getDefinition;
|
||||||
|
const transform = (node, index, siblings, parent) => {
|
||||||
|
let nodes;
|
||||||
|
|
||||||
|
if (node.type === 'root') {
|
||||||
|
// Create definition getter for link and image references
|
||||||
|
getDefinition = mdastDefinitions(node);
|
||||||
|
// Ensure top level text nodes are wrapped in paragraphs
|
||||||
|
modifyChildren(wrapText)(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(node.children)) {
|
||||||
|
nodes = node.children;
|
||||||
|
} else {
|
||||||
|
// If a node returns a falsey value, exclude it. Some nodes do not
|
||||||
|
// translate from MDAST to Slate, such as definitions for link/image
|
||||||
|
// references or footnotes.
|
||||||
|
//
|
||||||
|
// Consider using unist-util-remove instead for this.
|
||||||
|
nodes = node.children.reduce((acc, childNode, idx, sibs) => {
|
||||||
|
const transformed = transform(childNode, idx, sibs, node);
|
||||||
|
if (transformed) {
|
||||||
|
acc.push(transformed);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'root') {
|
||||||
|
return { nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert MDAST shortcode nodes to Slate 'shortcode' type nodes.
|
||||||
|
*/
|
||||||
|
if (get(node, ['data', 'shortcode'])) {
|
||||||
|
const { data } = node;
|
||||||
|
const nodes = [ toTextNode('') ];
|
||||||
|
return { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process raw html as text, since it's valid markdown
|
||||||
|
if (['text', 'html'].includes(node.type)) {
|
||||||
|
return toTextNode(node.value, node.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'inlineCode') {
|
||||||
|
return { kind: 'text', ranges: [{ text: node.value, marks: [{ type: 'code' }] }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['strong', 'emphasis', 'delete'].includes(node.type)) {
|
||||||
|
const remarkToSlateMarks = (markNode, parentMarks = []) => {
|
||||||
|
const marks = [...parentMarks, { type: markMap[markNode.type] }];
|
||||||
|
const ranges = [];
|
||||||
|
markNode.children.forEach(childNode => {
|
||||||
|
if (['html', 'text'].includes(childNode.type)) {
|
||||||
|
ranges.push({ text: childNode.value, marks });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nestedRanges = remarkToSlateMarks(childNode, marks);
|
||||||
|
ranges.push(...nestedRanges);
|
||||||
|
});
|
||||||
|
return ranges;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { kind: 'text', ranges: remarkToSlateMarks(node) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
const depths = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
|
||||||
|
return { kind: 'block', type: `heading-${depths[node.depth]}`, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['paragraph', 'blockquote', 'tableRow', 'tableCell'].includes(node.type)) {
|
||||||
|
return { kind: 'block', type: typeMap[node.type], nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'code') {
|
||||||
|
const data = { lang: node.lang };
|
||||||
|
const text = toTextNode(node.value);
|
||||||
|
const nodes = [text];
|
||||||
|
return { kind: 'block', type: typeMap[node.type], data, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'list') {
|
||||||
|
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
|
||||||
|
const data = { start: node.start };
|
||||||
|
return { kind: 'block', type: slateType, data, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'listItem') {
|
||||||
|
const data = { checked: node.checked };
|
||||||
|
return { kind: 'block', type: typeMap[node.type], data, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'table') {
|
||||||
|
const data = { align: node.align };
|
||||||
|
return { kind: 'block', type: typeMap[node.type], data, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'thematicBreak') {
|
||||||
|
return { kind: 'block', type: typeMap[node.type], isVoid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'link') {
|
||||||
|
const { title, url } = node;
|
||||||
|
const data = { title, url };
|
||||||
|
return { kind: 'inline', type: typeMap[node.type], data, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'linkReference') {
|
||||||
|
const definition = getDefinition(node.identifier);
|
||||||
|
const data = {};
|
||||||
|
if (definition) {
|
||||||
|
data.title = definition.title;
|
||||||
|
data.url = definition.url;
|
||||||
|
}
|
||||||
|
return { kind: 'inline', type: typeMap['link'], data, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'image') {
|
||||||
|
const { title, url, alt } = node;
|
||||||
|
const data = { title, url, alt };
|
||||||
|
return { kind: 'block', type: typeMap[node.type], data };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'imageReference') {
|
||||||
|
const definition = getDefinition(node.identifier);
|
||||||
|
const data = {};
|
||||||
|
if (definition) {
|
||||||
|
data.title = definition.title;
|
||||||
|
data.url = definition.url;
|
||||||
|
}
|
||||||
|
return { kind: 'block', type: typeMap['image'], data };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Since `transform` is used for recursive child mapping, ensure that only the
|
||||||
|
// first argument is supplied on the initial call.
|
||||||
|
return node => transform(node);
|
||||||
|
}
|
@ -1,627 +0,0 @@
|
|||||||
import { get, has, find, isEmpty, every, map } from 'lodash';
|
|
||||||
import { renderToString } from 'react-dom/server';
|
|
||||||
import unified from 'unified';
|
|
||||||
import u from 'unist-builder';
|
|
||||||
import markdownToRemarkPlugin from 'remark-parse';
|
|
||||||
import remarkToMarkdownPlugin from 'remark-stringify';
|
|
||||||
import mdastDefinitions from 'mdast-util-definitions';
|
|
||||||
import mdastToString from 'mdast-util-to-string';
|
|
||||||
import modifyChildren from 'unist-util-modify-children';
|
|
||||||
import remarkToRehype from 'remark-rehype';
|
|
||||||
import rehypeToHtml from 'rehype-stringify';
|
|
||||||
import htmlToRehype from 'rehype-parse';
|
|
||||||
import rehypeToRemark from 'rehype-remark';
|
|
||||||
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 hastFromString from 'hast-util-from-string';
|
|
||||||
import hastToMdastHandlerAll from 'hast-util-to-mdast/all';
|
|
||||||
import { reduce, capitalize } from 'lodash';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove empty nodes, including the top level parents of deeply nested empty nodes.
|
|
||||||
*/
|
|
||||||
const rehypeRemoveEmpty = () => {
|
|
||||||
const isVoidElement = node => ['img', 'hr', 'br'].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) || isShortcode(node)) {
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
node.children = node.children.reduce((acc, childNode) => {
|
|
||||||
if (isVoidElement(childNode) || isNonEmptyLeaf(childNode) || isShortcode(node)) {
|
|
||||||
return acc.concat(childNode);
|
|
||||||
}
|
|
||||||
return find(childNode.children, isNonEmptyNode) ? acc.concat(transform(childNode)) : acc;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
return transform;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the first child of a list item is a list, include it in the previous list
|
|
||||||
* item. Otherwise it translates to markdown as having two bullets. When
|
|
||||||
* rehype-remark processes a list and finds children that are not list items, it
|
|
||||||
* wraps them in list items, which leads to the condition this plugin addresses.
|
|
||||||
* Dropbox Paper currently outputs this kind of HTML, which is invalid. We have
|
|
||||||
* a support issue open for it, and this plugin can potentially be removed when
|
|
||||||
* that's resolved.
|
|
||||||
*/
|
|
||||||
const remarkNestedList = () => {
|
|
||||||
const transform = node => {
|
|
||||||
if (node.type === 'list' && node.children && node.children.length > 1) {
|
|
||||||
node.children = node.children.reduce((acc, childNode, index) => {
|
|
||||||
if (index && childNode.children && childNode.children[0].type === 'list') {
|
|
||||||
acc[acc.length - 1].children.push(transform(childNode.children.shift()))
|
|
||||||
if (childNode.children.length) {
|
|
||||||
acc.push(transform(childNode));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
acc.push(transform(childNode));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
node.children = node.children.map(transform);
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
return transform;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dropbox Paper outputs emoji characters as images, and stores the actual
|
|
||||||
* emoji character in a `data-emoji-ch` attribute on the image. This plugin
|
|
||||||
* replaces the images with the emoji characters.
|
|
||||||
*/
|
|
||||||
const rehypePaperEmoji = () => {
|
|
||||||
const transform = node => {
|
|
||||||
if (node.tagName === 'img' && node.properties.dataEmojiCh) {
|
|
||||||
return { type: 'text', value: node.properties.dataEmojiCh };
|
|
||||||
}
|
|
||||||
node.children = node.children ? node.children.map(transform) : node.children;
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
return transform;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite the remark-stringify text visitor to simply return the text value,
|
|
||||||
* without encoding or escaping any characters. This means we're completely
|
|
||||||
* trusting the markdown that we receive.
|
|
||||||
*/
|
|
||||||
function remarkPrecompileShortcodes() {
|
|
||||||
const Compiler = this.Compiler;
|
|
||||||
const visitors = Compiler.prototype.visitors;
|
|
||||||
visitors.text = node => node.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
const remarkShortcodes = ({ plugins }) => {
|
|
||||||
return transform;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping function to transform nodes that contain shortcodes.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
const plugin = plugins.find(p => {
|
|
||||||
match = text.match(p.pattern);
|
|
||||||
return !!match;
|
|
||||||
});
|
|
||||||
return { plugin, match };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A match is only valid if it takes up the entire paragraph.
|
|
||||||
*/
|
|
||||||
function validateMatch(text, match) {
|
|
||||||
return match && match[0].length === text.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* Remark or Rehype. Include the child as an array because an MDAST paragraph
|
|
||||||
* node must have it's children in an array.
|
|
||||||
*/
|
|
||||||
function createShortcodeNode(text, plugin, match) {
|
|
||||||
const shortcode = plugin.id;
|
|
||||||
const shortcodeData = plugin.fromBlock(match);
|
|
||||||
const data = { shortcode, shortcodeData };
|
|
||||||
const textNode = u('html', text);
|
|
||||||
return u('paragraph', { data }, [textNode]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This plugin doesn't actually transform Remark (MDAST) nodes to Rehype
|
|
||||||
* (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST
|
|
||||||
* conversion by replacing the shortcode text with stringified HTML for
|
|
||||||
* previewing the shortcode output.
|
|
||||||
*/
|
|
||||||
const remarkToRehypeShortcodes = ({ plugins, getAsset }) => {
|
|
||||||
return transform;
|
|
||||||
|
|
||||||
function transform(root) {
|
|
||||||
const transformedChildren = map(root.children, processShortcodes);
|
|
||||||
return { ...root, children: transformedChildren };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping function to transform nodes that contain shortcodes.
|
|
||||||
*/
|
|
||||||
function processShortcodes(node) {
|
|
||||||
/**
|
|
||||||
* If the node doesn't contain shortcode data, return the original node.
|
|
||||||
*/
|
|
||||||
if (!has(node, ['data', 'shortcode'])) return node;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get shortcode data from the node, and retrieve the matching plugin by
|
|
||||||
* key.
|
|
||||||
*/
|
|
||||||
const { shortcode, shortcodeData } = node.data;
|
|
||||||
const plugin = plugins.get(shortcode);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the shortcode plugin's `toPreview` method, which will return either
|
|
||||||
* an HTML string or a React component. If a React component is returned,
|
|
||||||
* render it to an HTML string.
|
|
||||||
*/
|
|
||||||
const value = plugin.toPreview(shortcodeData, getAsset);
|
|
||||||
const valueHtml = typeof value === 'string' ? value : renderToString(value);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a new 'html' type node containing the shortcode preview markup.
|
|
||||||
*/
|
|
||||||
const textNode = u('html', valueHtml);
|
|
||||||
const children = [ textNode ];
|
|
||||||
return { ...node, children };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remarkToSlatePlugin = () => {
|
|
||||||
const typeMap = {
|
|
||||||
paragraph: 'paragraph',
|
|
||||||
blockquote: 'quote',
|
|
||||||
code: 'code',
|
|
||||||
listItem: 'list-item',
|
|
||||||
table: 'table',
|
|
||||||
tableRow: 'table-row',
|
|
||||||
tableCell: 'table-cell',
|
|
||||||
thematicBreak: 'thematic-break',
|
|
||||||
link: 'link',
|
|
||||||
image: 'image',
|
|
||||||
};
|
|
||||||
const markMap = {
|
|
||||||
strong: 'bold',
|
|
||||||
emphasis: 'italic',
|
|
||||||
delete: 'strikethrough',
|
|
||||||
inlineCode: 'code',
|
|
||||||
};
|
|
||||||
const toTextNode = (text, data) => ({ kind: 'text', text, data });
|
|
||||||
const wrapText = (node, index, parent) => {
|
|
||||||
if (['text', 'html'].includes(node.type)) {
|
|
||||||
parent.children.splice(index, 1, u('paragraph', [node]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let getDefinition;
|
|
||||||
const transform = (node, index, siblings, parent) => {
|
|
||||||
let nodes;
|
|
||||||
|
|
||||||
if (node.type === 'root') {
|
|
||||||
// Create definition getter for link and image references
|
|
||||||
getDefinition = mdastDefinitions(node);
|
|
||||||
// Ensure top level text nodes are wrapped in paragraphs
|
|
||||||
modifyChildren(wrapText)(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmpty(node.children)) {
|
|
||||||
nodes = node.children;
|
|
||||||
} else {
|
|
||||||
// If a node returns a falsey value, exclude it. Some nodes do not
|
|
||||||
// translate from MDAST to Slate, such as definitions for link/image
|
|
||||||
// references or footnotes.
|
|
||||||
//
|
|
||||||
// Consider using unist-util-remove instead for this.
|
|
||||||
nodes = node.children.reduce((acc, childNode, idx, sibs) => {
|
|
||||||
const transformed = transform(childNode, idx, sibs, node);
|
|
||||||
if (transformed) {
|
|
||||||
acc.push(transformed);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'root') {
|
|
||||||
return { nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert MDAST shortcode nodes to Slate 'shortcode' type nodes.
|
|
||||||
*/
|
|
||||||
if (get(node, ['data', 'shortcode'])) {
|
|
||||||
const { data } = node;
|
|
||||||
const nodes = [ toTextNode('') ];
|
|
||||||
return { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process raw html as text, since it's valid markdown
|
|
||||||
if (['text', 'html'].includes(node.type)) {
|
|
||||||
return toTextNode(node.value, node.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'inlineCode') {
|
|
||||||
return { kind: 'text', ranges: [{ text: node.value, marks: [{ type: 'code' }] }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['strong', 'emphasis', 'delete'].includes(node.type)) {
|
|
||||||
const remarkToSlateMarks = (markNode, parentMarks = []) => {
|
|
||||||
const marks = [...parentMarks, { type: markMap[markNode.type] }];
|
|
||||||
const ranges = [];
|
|
||||||
markNode.children.forEach(childNode => {
|
|
||||||
if (['html', 'text'].includes(childNode.type)) {
|
|
||||||
ranges.push({ text: childNode.value, marks });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nestedRanges = remarkToSlateMarks(childNode, marks);
|
|
||||||
ranges.push(...nestedRanges);
|
|
||||||
});
|
|
||||||
return ranges;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { kind: 'text', ranges: remarkToSlateMarks(node) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'heading') {
|
|
||||||
const depths = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
|
|
||||||
return { kind: 'block', type: `heading-${depths[node.depth]}`, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['paragraph', 'blockquote', 'tableRow', 'tableCell'].includes(node.type)) {
|
|
||||||
return { kind: 'block', type: typeMap[node.type], nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'code') {
|
|
||||||
const data = { lang: node.lang };
|
|
||||||
const text = toTextNode(node.value);
|
|
||||||
const nodes = [text];
|
|
||||||
return { kind: 'block', type: typeMap[node.type], data, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'list') {
|
|
||||||
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
|
|
||||||
const data = { start: node.start };
|
|
||||||
return { kind: 'block', type: slateType, data, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'listItem') {
|
|
||||||
const data = { checked: node.checked };
|
|
||||||
return { kind: 'block', type: typeMap[node.type], data, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'table') {
|
|
||||||
const data = { align: node.align };
|
|
||||||
return { kind: 'block', type: typeMap[node.type], data, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'thematicBreak') {
|
|
||||||
return { kind: 'block', type: typeMap[node.type], isVoid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'link') {
|
|
||||||
const { title, url } = node;
|
|
||||||
const data = { title, url };
|
|
||||||
return { kind: 'inline', type: typeMap[node.type], data, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'linkReference') {
|
|
||||||
const definition = getDefinition(node.identifier);
|
|
||||||
const data = {};
|
|
||||||
if (definition) {
|
|
||||||
data.title = definition.title;
|
|
||||||
data.url = definition.url;
|
|
||||||
}
|
|
||||||
return { kind: 'inline', type: typeMap['link'], data, nodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'image') {
|
|
||||||
const { title, url, alt } = node;
|
|
||||||
const data = { title, url, alt };
|
|
||||||
return { kind: 'block', type: typeMap[node.type], data };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'imageReference') {
|
|
||||||
const definition = getDefinition(node.identifier);
|
|
||||||
const data = {};
|
|
||||||
if (definition) {
|
|
||||||
data.title = definition.title;
|
|
||||||
data.url = definition.url;
|
|
||||||
}
|
|
||||||
return { kind: 'block', type: typeMap['image'], data };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Since `transform` is used for recursive child mapping, ensure that only the
|
|
||||||
// first argument is supplied on the initial call.
|
|
||||||
return node => transform(node);
|
|
||||||
};
|
|
||||||
|
|
||||||
const slateToRemarkPlugin = () => {
|
|
||||||
const transform = node => {
|
|
||||||
console.log(node);
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
return transform;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Images must be parsed as shortcodes for asset proxying. This plugin converts
|
|
||||||
* MDAST image nodes back to text to allow shortcode pattern matching.
|
|
||||||
*/
|
|
||||||
const remarkImagesToText = () => {
|
|
||||||
return transform;
|
|
||||||
|
|
||||||
function transform(node) {
|
|
||||||
const children = node.children ? node.children.map(transform) : node.children;
|
|
||||||
if (node.type === 'image') {
|
|
||||||
const alt = node.alt || '';
|
|
||||||
const url = node.url || '';
|
|
||||||
const title = node.title ? ` "${node.title}"` : '';
|
|
||||||
return { type: 'text', value: `` };
|
|
||||||
}
|
|
||||||
return { ...node, children };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const markdownToRemark = markdown => {
|
|
||||||
const parsed = unified()
|
|
||||||
.use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true })
|
|
||||||
.parse(markdown);
|
|
||||||
|
|
||||||
const result = unified()
|
|
||||||
.use(remarkImagesToText)
|
|
||||||
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
|
|
||||||
.runSync(parsed);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remarkToMarkdown = obj => {
|
|
||||||
const mdast = obj || u('root', [u('paragraph', [u('text', '')])]);
|
|
||||||
|
|
||||||
const result = unified()
|
|
||||||
.use(remarkToMarkdownPlugin, { listItemIndent: '1', fences: true, pedantic: true, commonmark: true })
|
|
||||||
.use(remarkPrecompileShortcodes)
|
|
||||||
.stringify(mdast);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remarkToSlate = mdast => {
|
|
||||||
const result = unified()
|
|
||||||
.use(remarkToSlatePlugin)
|
|
||||||
.runSync(mdast);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const slateToRemark = (raw, shortcodePlugins) => {
|
|
||||||
const typeMap = {
|
|
||||||
'paragraph': 'paragraph',
|
|
||||||
'heading-one': 'heading',
|
|
||||||
'heading-two': 'heading',
|
|
||||||
'heading-three': 'heading',
|
|
||||||
'heading-four': 'heading',
|
|
||||||
'heading-five': 'heading',
|
|
||||||
'heading-six': 'heading',
|
|
||||||
'quote': 'blockquote',
|
|
||||||
'code': 'code',
|
|
||||||
'numbered-list': 'list',
|
|
||||||
'bulleted-list': 'list',
|
|
||||||
'list-item': 'listItem',
|
|
||||||
'table': 'table',
|
|
||||||
'table-row': 'tableRow',
|
|
||||||
'table-cell': 'tableCell',
|
|
||||||
'thematic-break': 'thematicBreak',
|
|
||||||
'link': 'link',
|
|
||||||
'image': 'image',
|
|
||||||
};
|
|
||||||
const markMap = {
|
|
||||||
bold: 'strong',
|
|
||||||
italic: 'emphasis',
|
|
||||||
strikethrough: 'delete',
|
|
||||||
code: 'inlineCode',
|
|
||||||
};
|
|
||||||
const transform = node => {
|
|
||||||
const children = isEmpty(node.nodes) ? node.nodes : node.nodes.reduce((acc, childNode) => {
|
|
||||||
if (childNode.kind !== 'text') {
|
|
||||||
acc.push(transform(childNode));
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
if (childNode.ranges) {
|
|
||||||
childNode.ranges.forEach(range => {
|
|
||||||
const { marks = [], text } = range;
|
|
||||||
const markTypes = marks.map(mark => markMap[mark.type]);
|
|
||||||
if (markTypes.includes('inlineCode')) {
|
|
||||||
acc.push(u('inlineCode', text));
|
|
||||||
} else {
|
|
||||||
const textNode = u('html', text);
|
|
||||||
const nestedText = !markTypes.length ? textNode : markTypes.reduce((acc, markType) => {
|
|
||||||
const nested = u(markType, [acc]);
|
|
||||||
return nested;
|
|
||||||
}, textNode);
|
|
||||||
acc.push(nestedText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
|
|
||||||
acc.push(u('html', childNode.text));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (node.type === 'root') {
|
|
||||||
return u('root', children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'shortcode') {
|
|
||||||
const { data } = node;
|
|
||||||
const plugin = shortcodePlugins.get(data.shortcode);
|
|
||||||
const text = plugin.toBlock(data.shortcodeData);
|
|
||||||
const textNode = u('html', text);
|
|
||||||
return u('paragraph', { data }, [ textNode ]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type.startsWith('heading')) {
|
|
||||||
const depths = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
|
||||||
const depth = node.type.split('-')[1];
|
|
||||||
const props = { depth: depths[depth] };
|
|
||||||
return u(typeMap[node.type], props, children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['paragraph', 'quote', 'list-item', 'table', 'table-row', 'table-cell'].includes(node.type)) {
|
|
||||||
return u(typeMap[node.type], children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'code') {
|
|
||||||
const value = get(node.nodes, [0, 'text']);
|
|
||||||
const props = { lang: get(node.data, 'lang') };
|
|
||||||
return u(typeMap[node.type], props, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['numbered-list', 'bulleted-list'].includes(node.type)) {
|
|
||||||
const ordered = node.type === 'numbered-list';
|
|
||||||
const props = { ordered, start: get(node.data, 'start') || 1 };
|
|
||||||
return u(typeMap[node.type], props, children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'thematic-break') {
|
|
||||||
return u(typeMap[node.type]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'link') {
|
|
||||||
const data = get(node, 'data', {});
|
|
||||||
const { url, title } = data;
|
|
||||||
return u(typeMap[node.type], data, children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'image') {
|
|
||||||
const data = get(node, 'data', {});
|
|
||||||
const { url, title, alt } = data;
|
|
||||||
return u(typeMap[node.type], data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raw.type = 'root';
|
|
||||||
const mdast = transform(raw);
|
|
||||||
|
|
||||||
const result = unified()
|
|
||||||
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
|
|
||||||
.runSync(mdast);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remarkToHtml = (mdast, getAsset) => {
|
|
||||||
const result = unified()
|
|
||||||
.use(remarkToRehypeShortcodes, { plugins: registry.getEditorComponents(), getAsset })
|
|
||||||
.use(remarkToRehype, { allowDangerousHTML: true })
|
|
||||||
.runSync(mdast);
|
|
||||||
|
|
||||||
const output = unified()
|
|
||||||
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true, entities: { subset: [] } })
|
|
||||||
.stringify(result);
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
export const htmlToSlate = html => {
|
|
||||||
const hast = unified()
|
|
||||||
.use(htmlToRehype, { fragment: true })
|
|
||||||
.parse(html);
|
|
||||||
|
|
||||||
const result = unified()
|
|
||||||
.use(rehypeRemoveEmpty)
|
|
||||||
.use(rehypeMinifyWhitespace)
|
|
||||||
.use(rehypePaperEmoji)
|
|
||||||
.use(rehypeToRemark)
|
|
||||||
.use(remarkNestedList)
|
|
||||||
.use(remarkToSlatePlugin)
|
|
||||||
.runSync(hast);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
Loading…
x
Reference in New Issue
Block a user