use mdast instead of html for rte local model

markdown is currently serialized to html at load time,
which makes it near impossible to support arbitrary html
in the markdown. This also means we're stringifying to
html on every change.

This commit moves to Remark's MDAST for local serialization,
including parsing from MDAST to Slates's Raw AST. It brings
much more control over the editing experience and full
support for processing unescaped HTML.
This commit is contained in:
Shawn Erquhart 2017-07-18 19:14:40 -04:00
parent 93687d9157
commit 842c2935e9
12 changed files with 465 additions and 150 deletions

View File

@ -116,6 +116,7 @@
"lodash": "^4.13.1",
"markup-it": "^2.0.0",
"material-design-icons": "^3.0.1",
"mdast-util-definitions": "^1.2.2",
"moment": "^2.11.2",
"netlify-auth-js": "^0.5.5",
"normalize.css": "^4.2.0",
@ -165,9 +166,12 @@
"slate": "^0.20.6",
"slate-drop-or-paste-images": "^0.2.0",
"slate-edit-list": "^0.7.1",
"slate-edit-table": "^0.10.1",
"slug": "^0.9.1",
"textarea-caret-position": "^0.1.1",
"unified": "^6.1.4",
"unist-builder": "^1.0.2",
"unist-util-modify-children": "^1.1.1",
"uuid": "^2.0.3",
"whatwg-fetch": "^1.0.0"
},

View File

@ -228,20 +228,23 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.resolve();
const backend = currentBackend(state.config);
const transactionID = uuid.v4();
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');
const transactionID = uuid.v4();
const transformedData = serializeValues(entryDraft.getIn(['entry', 'data']), collection.get('fields'));
const transformedEntry = entry.set('data', transformedData);
const transformedEntryDraft = entryDraft.set('entry', transformedEntry);
dispatch(unpublishedEntryPersisting(collection, entry, transactionID));
dispatch(unpublishedEntryPersisting(collection, transformedEntry, transactionID));
const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry;
return persistAction.call(backend, state.config, collection, entryDraft, assetProxies.toJS())
return persistAction.call(backend, state.config, collection, transformedEntryDraft, assetProxies.toJS())
.then(() => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
return dispatch(unpublishedEntryPersisted(collection, entry, transactionID));
return dispatch(unpublishedEntryPersisted(collection, transformedEntry, transactionID));
})
.catch((error) => {
dispatch(notifSend({

View File

@ -1,12 +1,11 @@
import { List, Map } from 'immutable';
import { isArray, isObject, isEmpty, isNil } from 'lodash';
import { List } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from '../lib/serializeEntryValues';
import { closeEntry } from './editor';
import { currentBackend } from '../backends/backend';
import { getIntegrationProvider } from '../integrations';
import { getAsset, selectIntegration } from '../reducers';
import { createEntry } from '../valueObjects/Entry';
import registry from '../lib/registry';
const { notifSend } = notifActions;
@ -219,27 +218,10 @@ export function loadEntry(collection, slug) {
dispatch(entryLoading(collection, slug));
return backend.getEntry(collection, slug)
.then(loadedEntry => {
const deserializeValues = (values, fields) => {
return fields.reduce((acc, field) => {
const fieldName = field.get('name');
const value = values[fieldName];
const serializer = registry.getWidgetValueSerializer(field.get('widget'));
if (isArray(value) && !isEmpty(value)) {
acc[fieldName] = value.map(val => deserializeValues(val, field.get('fields')));
} else if (isObject(value) && !isEmpty(value)) {
acc[fieldName] = deserializeValues(value, field.get('fields'));
} else if (serializer && !isNil(value)) {
acc[fieldName] = serializer.deserialize(value);
} else if (!isNil(value)) {
acc[fieldName] = value;
}
return acc;
}, {});
};
loadedEntry.data = deserializeValues(loadedEntry.data, collection.get('fields'));
return dispatch(entryLoaded(collection, loadedEntry))
})
.catch((error) => {
console.error(error);
dispatch(notifSend({
message: `Failed to load entry: ${ error.message }`,
kind: 'danger',
@ -289,23 +271,6 @@ export function persistEntry(collection) {
const backend = currentBackend(state.config);
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');
const serializeValues = (values, fields) => {
return fields.reduce((acc, field) => {
const fieldName = field.get('name');
const value = values.get(fieldName);
const serializer = registry.getWidgetValueSerializer(field.get('widget'));
if (List.isList(value)) {
return acc.set(fieldName, value.map(val => serializeValues(val, field.get('fields'))));
} else if (Map.isMap(value)) {
return acc.set(fieldName, serializeValues(value, field.get('fields')));
} else if (serializer && !isNil(value)) {
return acc.set(fieldName, serializer.serialize(value));
} else if (!isNil(value)) {
return acc.set(fieldName, value);
}
return acc;
}, Map());
};
const transformedData = serializeValues(entryDraft.getIn(['entry', 'data']), collection.get('fields'));
const transformedEntry = entry.set('data', transformedData);
const transformedEntryDraft = entryDraft.set('entry', transformedEntry);
@ -321,6 +286,7 @@ export function persistEntry(collection) {
return dispatch(entryPersisted(collection, transformedEntry));
})
.catch((error) => {
console.error(error);
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',

View File

@ -1,6 +1,6 @@
import React, { PropTypes } from 'react';
import { Editor as SlateEditor, Plain as SlatePlain } from 'slate';
import { markdownToHtml, htmlToMarkdown } from '../../unified';
import { markdownToRemark, remarkToMarkdown } from '../../unified';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import styles from './index.css';
@ -8,7 +8,7 @@ import styles from './index.css';
export default class RawEditor extends React.Component {
constructor(props) {
super(props);
const value = htmlToMarkdown(this.props.value);
const value = remarkToMarkdown(this.props.value);
this.state = {
editorState: SlatePlain.deserialize(value || ''),
};
@ -20,7 +20,7 @@ export default class RawEditor extends React.Component {
handleDocumentChange = (doc, editorState) => {
const value = SlatePlain.serialize(editorState);
const html = markdownToHtml(value);
const html = markdownToRemark(value);
this.props.onChange(html);
};
@ -60,5 +60,5 @@ export default class RawEditor extends React.Component {
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.node,
value: PropTypes.object,
};

View File

@ -99,6 +99,17 @@
border-left: 3px solid #eee;
margin-left: 0; margin-right: 0;
}
& table {
border-collapse: collapse;
}
& td,
& th {
border: 2px solid black;
padding: 8px;
text-align: left;
}
}
.shortcode {

View File

@ -1,27 +1,18 @@
import React, { Component, PropTypes } from 'react';
import ReactDOMServer from 'react-dom/server';
import { Map, List, fromJS } from 'immutable';
import { reduce, mapValues } from 'lodash';
import { get, reduce, mapValues } from 'lodash';
import cn from 'classnames';
import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw, Text as SlateText, Block as SlateBlock, Selection as SlateSelection} from 'slate';
import EditList from 'slate-edit-list';
import { markdownToHtml, htmlToMarkdown } from '../../unified';
import EditTable from 'slate-edit-table';
import { markdownToRemark, remarkToMarkdown, slateToRemark, remarkToSlate, markdownToHtml, htmlToMarkdown } from '../../unified';
import registry from '../../../../../lib/registry';
import { createAssetProxy } from '../../../../../valueObjects/AssetProxy';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import styles from './index.css';
/**
* Slate can serialize to html, but we persist the value as markdown. Serializing
* the html to markdown on every keystroke is a big perf hit, so we'll register
* functions to perform those actions only when necessary, such as after loading
* and before persisting.
*/
registry.registerWidgetValueSerializer('markdown', {
serialize: htmlToMarkdown,
deserialize: markdownToHtml,
});
function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
@ -102,10 +93,14 @@ const BLOCK_COMPONENTS = {
'container': props => <div {...props.attributes}>{props.children}</div>,
'paragraph': props => <p {...props.attributes}>{props.children}</p>,
'list-item': props => <li {...props.attributes}>{props.children}</li>,
'numbered-list': props => {
const { data } = props.node;
const start = data.get('start') || 1;
return <ol {...props.attributes} start={start}>{props.children}</ol>;
},
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
'quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
'code': props => <pre {...props.attributes}><code>{props.children}</code></pre>,
'code': props => <pre><code {...props.attributes}>{props.children}</code></pre>,
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>,
@ -116,8 +111,13 @@ const BLOCK_COMPONENTS = {
const data = props.node && props.node.get('data');
const src = data && data.get('src') || props.src;
const alt = data && data.get('alt') || props.alt;
return <img src={src} alt={alt} {...props.attributes}/>;
const title = data && data.get('title') || props.title;
return <div><img src={src} alt={alt} title={title}{...props.attributes}/></div>;
},
'table': props => <table><tbody {...props.attributes}>{props.children}</tbody></table>,
'table-row': props => <tr {...props.attributes}>{props.children}</tr>,
'table-cell': props => <td {...props.attributes}>{props.children}</td>,
'thematic-break': props => <hr {...props.attributes}/>,
};
const getShortcodeId = props => {
if (props.node) {
@ -132,8 +132,10 @@ const shortcodeStyles = {border: '2px solid black', padding: '8px', margin: '2px
const NODE_COMPONENTS = {
...BLOCK_COMPONENTS,
'link': props => {
const href = props.node && props.node.getIn(['data', 'href']) || props.href;
return <a href={href} {...props.attributes}>{props.children}</a>;
const data = props.node.get('data');
const href = data && data.get('url') || props.href;
const title = data && data.get('title') || props.title;
return <a href={href} title={title} {...props.attributes}>{props.children}</a>;
},
'shortcode': props => {
const { attributes, node, state: editorState } = props;
@ -153,7 +155,6 @@ const NODE_COMPONENTS = {
const MARK_COMPONENTS = {
bold: props => <strong>{props.children}</strong>,
italic: props => <em>{props.children}</em>,
underlined: props => <u>{props.children}</u>,
strikethrough: props => <s>{props.children}</s>,
code: props => <code>{props.children}</code>,
};
@ -217,9 +218,6 @@ const RULES = [
if (['bulleted-list', 'numbered-list'].includes(entity.type)) {
return;
}
if (entity.kind !== 'block') {
return;
}
const component = BLOCK_COMPONENTS[entity.type]
if (!component) {
return;
@ -242,9 +240,6 @@ const RULES = [
return;
}
const component = MARK_COMPONENTS[entity.type]
if (!component) {
return;
}
return component({ children });
}
},
@ -268,13 +263,14 @@ const RULES = [
deserialize(el, next) {
if (el.tagName != 'img') return
return {
kind: 'inline',
kind: 'block',
type: 'image',
isVoid: true,
nodes: [],
data: {
src: el.attribs.src,
alt: el.attribs.alt,
title: el.attribs.title,
}
}
},
@ -286,6 +282,7 @@ const RULES = [
const props = {
src: data.get('src'),
alt: data.get('alt'),
title: data.get('title'),
};
const result = NODE_COMPONENTS.image(props);
return result;
@ -300,7 +297,8 @@ const RULES = [
type: 'link',
nodes: next(el.children),
data: {
href: el.attribs.href
href: el.attribs.href,
title: el.attribs.title,
}
}
},
@ -311,6 +309,7 @@ const RULES = [
const data = entity.get('data');
const props = {
href: data.get('href'),
title: data.get('title'),
attributes: data.get('attributes'),
children,
};
@ -328,7 +327,7 @@ const RULES = [
]
const serializer = new SlateHtml({ rules: RULES });
const htmlSerializer = new SlateHtml({ rules: RULES });
const SoftBreak = (options = {}) => ({
onKeyDown(e, data, state) {
@ -374,53 +373,29 @@ const BackspaceCloseBlock = (options = {}) => ({
});
const slatePlugins = [
SoftBreak({ ignoreIn: ['paragraph', 'list-item', 'numbered-list', 'bulleted-list'], closeAfter: 1 }),
BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list'] }),
SoftBreak({ ignoreIn: ['list-item', 'numbered-list', 'bulleted-list', 'table', 'table-row', 'table-cell'], closeAfter: 1 }),
BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list', 'table', 'table-row', 'table-cell'] }),
EditList({ types: ['bulleted-list', 'numbered-list'], typeItem: 'list-item' }),
EditTable({ typeTable: 'table', typeRow: 'table-row', typeCell: 'table-cell' }),
];
export default class Editor extends Component {
constructor(props) {
super(props);
const plugins = registry.getEditorComponents();
// Wrap value in div to ensure against trailing text outside of top level html element
const initialValue = this.props.value ? `<div>${this.props.value}</div>` : '<p></p>';
const emptyRaw = {
nodes: [{ kind: 'block', type: 'paragraph', nodes: [
{ kind: 'text', ranges: [{ text: '' }] }
]}],
};
const remark = this.props.value && remarkToSlate(this.props.value);
const initialValue = get(remark, ['nodes', 'length']) ? remark : emptyRaw;
const editorState = SlateRaw.deserialize(initialValue, { terse: true });
this.state = {
editorState: serializer.deserialize(initialValue),
editorState,
schema: {
nodes: NODE_COMPONENTS,
marks: MARK_COMPONENTS,
rules: [
{
match: object => object.kind === 'document',
validate: doc => {
const blocks = doc.getBlocks();
const firstBlock = blocks.first();
const lastBlock = blocks.last();
const firstBlockIsVoid = firstBlock.isVoid;
const lastBlockIsVoid = lastBlock.isVoid;
if (firstBlockIsVoid || lastBlockIsVoid) {
return { blocks, firstBlock, lastBlock, firstBlockIsVoid, lastBlockIsVoid };
}
},
normalize: (transform, doc, { blocks, firstBlock, lastBlock, firstBlockIsVoid, lastBlockIsVoid }) => {
const block = SlateBlock.create({
type: 'paragraph',
nodes: [SlateText.createFromString('')],
});
if (firstBlockIsVoid) {
const { key } = transform.state.document;
transform.insertNodeByKey(key, 0, block);
}
if (lastBlockIsVoid) {
const { key, nodes } = transform.state.document;
transform.insertNodeByKey(key, nodes.size, block);
}
return transform;
},
}
],
},
plugins,
};
@ -437,8 +412,9 @@ export default class Editor extends Component {
}
handleDocumentChange = (doc, editorState) => {
const html = serializer.serialize(editorState);
this.props.onChange(html);
const raw = SlateRaw.serialize(editorState, { terse: true });
const mdast = slateToRemark(raw);
this.props.onChange(mdast);
};
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
@ -602,5 +578,5 @@ Editor.propTypes = {
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.node,
value: PropTypes.object,
};

View File

@ -1,18 +1,30 @@
import React, { PropTypes } from 'react';
import registry from '../../../../lib/registry';
import { markdownToRemark, remarkToMarkdown } from '../unified';
import RawEditor from './RawEditor';
import VisualEditor from './VisualEditor';
import { StickyContainer } from '../../../UI/Sticky/Sticky';
const MODE_STORAGE_KEY = 'cms.md-mode';
/**
* Slate can serialize to html, but we persist the value as markdown. Serializing
* the html to markdown on every keystroke is a big perf hit, so we'll register
* functions to perform those actions only when necessary, such as after loading
* and before persisting.
*/
registry.registerWidgetValueSerializer('markdown', {
serialize: remarkToMarkdown,
deserialize: markdownToRemark,
});
export default class MarkdownControl extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
value: PropTypes.node,
value: PropTypes.object,
};
constructor(props) {

View File

@ -1,13 +1,18 @@
import React, { PropTypes } from 'react';
import { remarkToHtml } from '../unified';
import previewStyle from '../../defaultPreviewStyle';
const MarkdownPreview = ({ value, getAsset }) => {
return value === null ? null : <div style={previewStyle} dangerouslySetInnerHTML={{__html: value}}></div>;
if (value === null) {
return null;
}
const html = remarkToHtml(value);
return <div style={previewStyle} dangerouslySetInnerHTML={{__html: html}}></div>;
};
MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired,
value: PropTypes.string,
value: PropTypes.object,
};
export default MarkdownPreview;

View File

@ -1,29 +1,34 @@
import find from 'lodash/find';
import { get, find, isEmpty } from 'lodash';
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import u from 'unist-builder';
import markdownToRemarkPlugin from 'remark-parse';
import remarkToMarkdownPlugin from 'remark-stringify';
import mdastDefinitions from 'mdast-util-definitions';
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 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';
// Remove the yaml tokenizer, as the rich text editor doesn't support frontmatter
delete markdownToRemarkPlugin.Parser.prototype.blockTokenizers.yamlFrontMatter;
console.log(markdownToRemarkPlugin.Parser.prototype.blockTokenizers);
const shortcodeAttributePrefix = 'ncp';
/**
* Remove empty nodes, including the top level parents of deeply nested empty nodes.
*/
const rehypeRemoveEmpty = () => {
const isVoidElement = node => ['img', 'hr'].includes(node.tagName);
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 => {
@ -135,28 +140,15 @@ const rehypeShortcodes = () => {
}
/**
* we can't escape the less than symbol
* which means how do we know {{<thing attr>}} from <tag attr> ?
* maybe we escape nothing
* then we can check for shortcodes in a unified plugin
* and only check against text nodes
* and maybe narrow the target text nodes even further somehow
* and make shortcode parsing faster
* 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;
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);
}
}
visitors.text = node => node.value;
};
const parseShortcodesFromMarkdown = markdown => {
const plugins = registry.getEditorComponents();
@ -180,7 +172,302 @@ const parseShortcodesFromMarkdown = markdown => {
return markdownLinesParsed.join('\n');
};
const rehypeSanitizeSchema = merge(rehypeSanitizeSchemaDefault, { attributes: { '*': [ 'data*' ] } });
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 => ({ kind: 'text', text });
const wrapText = (node, index, parent) => {
if (['text', 'html'].includes(node.type)) {
parent.children.splice(index, 1, u('paragraph', [node]));
}
};
let getDefinition;
const transform = node => {
let nodes;
if (node.type === 'root') {
getDefinition = mdastDefinitions(node);
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.
nodes = node.children.reduce((acc, childNode) => {
const transformed = transform(childNode);
if (transformed) {
acc.push(transformed);
}
return acc;
}, []);
}
if (node.type === 'root') {
return { nodes };
}
// Process raw html as text, since it's valid markdown
if (['text', 'html'].includes(node.type)) {
return toTextNode(node.value);
}
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 { title, url } = definition;
const data = { title, 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 { title, url } = definition;
const data = { title, url };
return { kind: 'block', type: typeMap['image'], data };
}
};
return transform;
};
const slateToRemarkPlugin = () => {
const transform = node => {
console.log(node);
return node;
};
return transform;
};
export const markdownToRemark = markdown => {
const result = unified()
.use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true })
.parse(markdown);
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 => {
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.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 result = transform(raw);
return result;
};
export const remarkToHtml = mdast => {
const result = unified()
.use(remarkToRehype, { allowDangerousHTML: true })
.use(rehypeReparse)
.use(rehypeRemoveEmpty)
.use(rehypeMinifyWhitespace)
.use(() => node => {
return node;
})
.runSync(mdast);
const output = unified()
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true, entities: { subset: [] } })
.stringify(result);
return output
}
export const markdownToHtml = markdown => {
// Parse shortcodes from the raw markdown rather than via Unified plugin.
@ -188,11 +475,9 @@ export const markdownToHtml = markdown => {
// parsing rules.
const markdownWithParsedShortcodes = parseShortcodesFromMarkdown(markdown);
const result = unified()
.use(markdownToRemark, { fences: true })
.use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true })
.use(remarkToRehype, { allowDangerousHTML: true })
.use(rehypeReparse)
.use(rehypeRemoveEmpty)
.use(rehypeSanitize, rehypeSanitizeSchema)
.use(rehypeMinifyWhitespace)
.use(rehypeToHtml, { allowDangerousHTML: true })
.processSync(markdownWithParsedShortcodes)
@ -203,7 +488,6 @@ export const markdownToHtml = markdown => {
export const htmlToMarkdown = html => {
const result = unified()
.use(htmlToRehype, { fragment: true })
.use(rehypeSanitize, rehypeSanitizeSchema)
.use(rehypeRemoveEmpty)
.use(rehypeMinifyWhitespace)
.use(rehypePaperEmoji)
@ -222,7 +506,7 @@ export const htmlToMarkdown = html => {
return node;
})
.use(remarkNestedList)
.use(remarkToMarkdown, { listItemIndent: '1', fences: true })
.use(remarkToMarkdownPlugin, { listItemIndent: '1', fences: true, pedantic: true, commonmark: true })
.use(remarkPrecompileShortcodes)
/*
.use(() => node => {

View File

@ -13,6 +13,7 @@ import {
deleteEntry,
} from '../actions/entries';
import { closeEntry } from '../actions/editor';
import { deserializeValues } from '../lib/serializeEntryValues';
import { addAsset, removeAsset } from '../actions/media';
import { openSidebar } from '../actions/globalUI';
import { selectEntry, getAsset } from '../reducers';
@ -64,11 +65,14 @@ class EntryPage extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.entry === nextProps.entry) return;
const { entry, newEntry, fields, collection } = nextProps;
if (nextProps.entry && !nextProps.entry.get('isFetching') && !nextProps.entry.get('error')) {
this.createDraft(nextProps.entry);
} else if (nextProps.newEntry) {
this.props.createEmptyDraft(nextProps.collection);
if (entry && !entry.get('isFetching') && !entry.get('error')) {
const values = deserializeValues(entry.get('data'), fields);
const deserializedEntry = entry.set('data', values);
this.createDraft(deserializedEntry);
} else if (newEntry) {
this.props.createEmptyDraft(collection);
}
}

View File

@ -0,0 +1,49 @@
import { isArray, isObject, isEmpty, isNil } from 'lodash';
import { Map, List } from 'immutable';
import registry from './registry';
/**
* Methods for serializing/deserializing entry field values. Most widgets don't
* require this for their values, and those that do can typically serialize/
* deserialize on every change from within the widget. The serialization
* handlers here are for widgets whose values require heavy serialization that
* would hurt performance if run for every change.
* An example of this is the markdown widget, whose value is stored as a
* markdown string. Instead of stringifying on every change of that field, a
* deserialization method is registered from the widget's control module that
* converts the stored markdown string to an AST, and that AST serves as the
* widget model during editing.
*
* Serialization handlers should be registered for each widget that requires
* them, and the registration method is exposed through the registry. Any
* registered deserialization handlers run on entry load, and serialization
* handlers run on persist.
*/
const runSerializer = (values, fields, method) => {
return fields.reduce((acc, field) => {
const fieldName = field.get('name');
const value = values.get(fieldName);
const serializer = registry.getWidgetValueSerializer(field.get('widget'));
const nestedFields = field.get('fields');
if (nestedFields && List.isList(value)) {
return acc.set(fieldName, value.map(val => runSerializer(val, nestedFields, method)));
} else if (nestedFields && Map.isMap(value)) {
return acc.set(fieldName, runSerializer(value, nestedFields, method));
} else if (serializer && !isNil(value)) {
return acc.set(fieldName, serializer[method](value));
} else if (!isNil(value)) {
return acc.set(fieldName, value);
}
return acc;
}, Map());
};
export const serializeValues = (values, fields) => {
return runSerializer(values, fields, 'serialize');
};
export const deserializeValues = (values, fields) => {
return runSerializer(values, fields, 'deserialize');
};

View File

@ -21,10 +21,11 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], true);
case ENTRY_SUCCESS:
return state.setIn(
const result = state.setIn(
['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`],
fromJS(action.payload.entry)
);
return result;
case ENTRIES_REQUEST:
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);