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

View File

@ -228,20 +228,23 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.resolve(); if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.resolve();
const backend = currentBackend(state.config); const backend = currentBackend(state.config);
const transactionID = uuid.v4();
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path)); const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry'); 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; 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(() => { .then(() => {
dispatch(notifSend({ dispatch(notifSend({
message: 'Entry saved', message: 'Entry saved',
kind: 'success', kind: 'success',
dismissAfter: 4000, dismissAfter: 4000,
})); }));
return dispatch(unpublishedEntryPersisted(collection, entry, transactionID)); return dispatch(unpublishedEntryPersisted(collection, transformedEntry, transactionID));
}) })
.catch((error) => { .catch((error) => {
dispatch(notifSend({ dispatch(notifSend({

View File

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

View File

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

View File

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

View File

@ -1,27 +1,18 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
import { Map, List, fromJS } from 'immutable'; import { Map, List, fromJS } from 'immutable';
import { reduce, mapValues } from 'lodash'; import { get, reduce, mapValues } from 'lodash';
import cn from 'classnames'; 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 { 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 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 registry from '../../../../../lib/registry';
import { createAssetProxy } from '../../../../../valueObjects/AssetProxy'; import { createAssetProxy } from '../../../../../valueObjects/AssetProxy';
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';
/**
* 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) { function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) { if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
@ -102,10 +93,14 @@ const BLOCK_COMPONENTS = {
'container': props => <div {...props.attributes}>{props.children}</div>, 'container': props => <div {...props.attributes}>{props.children}</div>,
'paragraph': props => <p {...props.attributes}>{props.children}</p>, 'paragraph': props => <p {...props.attributes}>{props.children}</p>,
'list-item': props => <li {...props.attributes}>{props.children}</li>, '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>, '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>, '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-one': props => <h1 {...props.attributes}>{props.children}</h1>,
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>, 'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>, '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 data = props.node && props.node.get('data');
const src = data && data.get('src') || props.src; const src = data && data.get('src') || props.src;
const alt = data && data.get('alt') || props.alt; 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 => { const getShortcodeId = props => {
if (props.node) { if (props.node) {
@ -132,8 +132,10 @@ const shortcodeStyles = {border: '2px solid black', padding: '8px', margin: '2px
const NODE_COMPONENTS = { const NODE_COMPONENTS = {
...BLOCK_COMPONENTS, ...BLOCK_COMPONENTS,
'link': props => { 'link': props => {
const href = props.node && props.node.getIn(['data', 'href']) || props.href; const data = props.node.get('data');
return <a href={href} {...props.attributes}>{props.children}</a>; 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 => { 'shortcode': props => {
const { attributes, node, state: editorState } = props; const { attributes, node, state: editorState } = props;
@ -153,7 +155,6 @@ const NODE_COMPONENTS = {
const MARK_COMPONENTS = { const MARK_COMPONENTS = {
bold: props => <strong>{props.children}</strong>, bold: props => <strong>{props.children}</strong>,
italic: props => <em>{props.children}</em>, italic: props => <em>{props.children}</em>,
underlined: props => <u>{props.children}</u>,
strikethrough: props => <s>{props.children}</s>, strikethrough: props => <s>{props.children}</s>,
code: props => <code>{props.children}</code>, code: props => <code>{props.children}</code>,
}; };
@ -217,9 +218,6 @@ const RULES = [
if (['bulleted-list', 'numbered-list'].includes(entity.type)) { if (['bulleted-list', 'numbered-list'].includes(entity.type)) {
return; return;
} }
if (entity.kind !== 'block') {
return;
}
const component = BLOCK_COMPONENTS[entity.type] const component = BLOCK_COMPONENTS[entity.type]
if (!component) { if (!component) {
return; return;
@ -242,9 +240,6 @@ const RULES = [
return; return;
} }
const component = MARK_COMPONENTS[entity.type] const component = MARK_COMPONENTS[entity.type]
if (!component) {
return;
}
return component({ children }); return component({ children });
} }
}, },
@ -268,13 +263,14 @@ const RULES = [
deserialize(el, next) { deserialize(el, next) {
if (el.tagName != 'img') return if (el.tagName != 'img') return
return { return {
kind: 'inline', kind: 'block',
type: 'image', type: 'image',
isVoid: true, isVoid: true,
nodes: [], nodes: [],
data: { data: {
src: el.attribs.src, src: el.attribs.src,
alt: el.attribs.alt, alt: el.attribs.alt,
title: el.attribs.title,
} }
} }
}, },
@ -286,6 +282,7 @@ const RULES = [
const props = { const props = {
src: data.get('src'), src: data.get('src'),
alt: data.get('alt'), alt: data.get('alt'),
title: data.get('title'),
}; };
const result = NODE_COMPONENTS.image(props); const result = NODE_COMPONENTS.image(props);
return result; return result;
@ -300,7 +297,8 @@ const RULES = [
type: 'link', type: 'link',
nodes: next(el.children), nodes: next(el.children),
data: { 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 data = entity.get('data');
const props = { const props = {
href: data.get('href'), href: data.get('href'),
title: data.get('title'),
attributes: data.get('attributes'), attributes: data.get('attributes'),
children, children,
}; };
@ -328,7 +327,7 @@ const RULES = [
] ]
const serializer = new SlateHtml({ rules: RULES }); const htmlSerializer = new SlateHtml({ rules: RULES });
const SoftBreak = (options = {}) => ({ const SoftBreak = (options = {}) => ({
onKeyDown(e, data, state) { onKeyDown(e, data, state) {
@ -374,53 +373,29 @@ const BackspaceCloseBlock = (options = {}) => ({
}); });
const slatePlugins = [ const slatePlugins = [
SoftBreak({ ignoreIn: ['paragraph', 'list-item', 'numbered-list', 'bulleted-list'], closeAfter: 1 }), SoftBreak({ ignoreIn: ['list-item', 'numbered-list', 'bulleted-list', 'table', 'table-row', 'table-cell'], closeAfter: 1 }),
BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list'] }), BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list', 'table', 'table-row', 'table-cell'] }),
EditList({ types: ['bulleted-list', 'numbered-list'], typeItem: 'list-item' }), EditList({ types: ['bulleted-list', 'numbered-list'], typeItem: 'list-item' }),
EditTable({ typeTable: 'table', typeRow: 'table-row', typeCell: 'table-cell' }),
]; ];
export default class Editor extends Component { export default class Editor extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
const plugins = registry.getEditorComponents(); const plugins = registry.getEditorComponents();
// Wrap value in div to ensure against trailing text outside of top level html element const emptyRaw = {
const initialValue = this.props.value ? `<div>${this.props.value}</div>` : '<p></p>'; 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 = { this.state = {
editorState: serializer.deserialize(initialValue), editorState,
schema: { schema: {
nodes: NODE_COMPONENTS, nodes: NODE_COMPONENTS,
marks: MARK_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, plugins,
}; };
@ -437,8 +412,9 @@ export default class Editor extends Component {
} }
handleDocumentChange = (doc, editorState) => { handleDocumentChange = (doc, editorState) => {
const html = serializer.serialize(editorState); const raw = SlateRaw.serialize(editorState, { terse: true });
this.props.onChange(html); const mdast = slateToRemark(raw);
this.props.onChange(mdast);
}; };
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type); hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
@ -602,5 +578,5 @@ Editor.propTypes = {
getAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onMode: 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 React, { PropTypes } from 'react';
import registry from '../../../../lib/registry'; import registry from '../../../../lib/registry';
import { markdownToRemark, remarkToMarkdown } from '../unified';
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';
const MODE_STORAGE_KEY = 'cms.md-mode'; 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 { export default class MarkdownControl extends React.Component {
static propTypes = { static propTypes = {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired,
value: PropTypes.node, value: PropTypes.object,
}; };
constructor(props) { constructor(props) {

View File

@ -1,13 +1,18 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { remarkToHtml } from '../unified';
import previewStyle from '../../defaultPreviewStyle'; import previewStyle from '../../defaultPreviewStyle';
const MarkdownPreview = ({ value, getAsset }) => { 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 = { MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired,
value: PropTypes.string, value: PropTypes.object,
}; };
export default MarkdownPreview; 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 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 remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify'; import rehypeToHtml from 'rehype-stringify';
import htmlToRehype from 'rehype-parse'; import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark'; import rehypeToRemark from 'rehype-remark';
import remarkToMarkdown from 'remark-stringify';
import rehypeSanitize from 'rehype-sanitize';
import rehypeReparse from 'rehype-raw'; import rehypeReparse from 'rehype-raw';
import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; import rehypeMinifyWhitespace from 'rehype-minify-whitespace';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
import registry from '../../../lib/registry'; import registry from '../../../lib/registry';
import merge from 'deepmerge'; import merge from 'deepmerge';
import rehypeSanitizeSchemaDefault from 'hast-util-sanitize/lib/github';
import hastFromString from 'hast-util-from-string'; import hastFromString from 'hast-util-from-string';
import hastToMdastHandlerAll from 'hast-util-to-mdast/all'; import hastToMdastHandlerAll from 'hast-util-to-mdast/all';
import { reduce, capitalize } from 'lodash'; 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'; const shortcodeAttributePrefix = 'ncp';
/** /**
* Remove empty nodes, including the top level parents of deeply nested empty nodes. * Remove empty nodes, including the top level parents of deeply nested empty nodes.
*/ */
const rehypeRemoveEmpty = () => { 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 isNonEmptyLeaf = node => ['text', 'raw'].includes(node.type) && node.value;
const isShortcode = node => node.properties && node.properties[`data${capitalize(shortcodeAttributePrefix)}`]; const isShortcode = node => node.properties && node.properties[`data${capitalize(shortcodeAttributePrefix)}`];
const isNonEmptyNode = node => { const isNonEmptyNode = node => {
@ -135,28 +140,15 @@ const rehypeShortcodes = () => {
} }
/** /**
* we can't escape the less than symbol * Rewrite the remark-stringify text visitor to simply return the text value,
* which means how do we know {{<thing attr>}} from <tag attr> ? * without encoding or escaping any characters. This means we're completely
* maybe we escape nothing * trusting the markdown that we receive.
* 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
*/ */
function remarkPrecompileShortcodes() { function remarkPrecompileShortcodes() {
const Compiler = this.Compiler; const Compiler = this.Compiler;
const visitors = Compiler.prototype.visitors; const visitors = Compiler.prototype.visitors;
const textVisitor = visitors.text; visitors.text = node => node.value;
};
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 parseShortcodesFromMarkdown = markdown => {
const plugins = registry.getEditorComponents(); const plugins = registry.getEditorComponents();
@ -180,7 +172,302 @@ const parseShortcodesFromMarkdown = markdown => {
return markdownLinesParsed.join('\n'); 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 => { export const markdownToHtml = markdown => {
// Parse shortcodes from the raw markdown rather than via Unified plugin. // Parse shortcodes from the raw markdown rather than via Unified plugin.
@ -188,11 +475,9 @@ export const markdownToHtml = markdown => {
// parsing rules. // parsing rules.
const markdownWithParsedShortcodes = parseShortcodesFromMarkdown(markdown); const markdownWithParsedShortcodes = parseShortcodesFromMarkdown(markdown);
const result = unified() const result = unified()
.use(markdownToRemark, { fences: true }) .use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true })
.use(remarkToRehype, { allowDangerousHTML: true }) .use(remarkToRehype, { allowDangerousHTML: true })
.use(rehypeReparse)
.use(rehypeRemoveEmpty) .use(rehypeRemoveEmpty)
.use(rehypeSanitize, rehypeSanitizeSchema)
.use(rehypeMinifyWhitespace) .use(rehypeMinifyWhitespace)
.use(rehypeToHtml, { allowDangerousHTML: true }) .use(rehypeToHtml, { allowDangerousHTML: true })
.processSync(markdownWithParsedShortcodes) .processSync(markdownWithParsedShortcodes)
@ -203,7 +488,6 @@ export const markdownToHtml = markdown => {
export const htmlToMarkdown = html => { export const htmlToMarkdown = html => {
const result = unified() const result = unified()
.use(htmlToRehype, { fragment: true }) .use(htmlToRehype, { fragment: true })
.use(rehypeSanitize, rehypeSanitizeSchema)
.use(rehypeRemoveEmpty) .use(rehypeRemoveEmpty)
.use(rehypeMinifyWhitespace) .use(rehypeMinifyWhitespace)
.use(rehypePaperEmoji) .use(rehypePaperEmoji)
@ -222,7 +506,7 @@ export const htmlToMarkdown = html => {
return node; return node;
}) })
.use(remarkNestedList) .use(remarkNestedList)
.use(remarkToMarkdown, { listItemIndent: '1', fences: true }) .use(remarkToMarkdownPlugin, { listItemIndent: '1', fences: true, pedantic: true, commonmark: true })
.use(remarkPrecompileShortcodes) .use(remarkPrecompileShortcodes)
/* /*
.use(() => node => { .use(() => node => {

View File

@ -13,6 +13,7 @@ import {
deleteEntry, deleteEntry,
} from '../actions/entries'; } from '../actions/entries';
import { closeEntry } from '../actions/editor'; import { closeEntry } from '../actions/editor';
import { deserializeValues } from '../lib/serializeEntryValues';
import { addAsset, removeAsset } from '../actions/media'; import { addAsset, removeAsset } from '../actions/media';
import { openSidebar } from '../actions/globalUI'; import { openSidebar } from '../actions/globalUI';
import { selectEntry, getAsset } from '../reducers'; import { selectEntry, getAsset } from '../reducers';
@ -64,11 +65,14 @@ class EntryPage extends React.Component {
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (this.props.entry === nextProps.entry) return; if (this.props.entry === nextProps.entry) return;
const { entry, newEntry, fields, collection } = nextProps;
if (nextProps.entry && !nextProps.entry.get('isFetching') && !nextProps.entry.get('error')) { if (entry && !entry.get('isFetching') && !entry.get('error')) {
this.createDraft(nextProps.entry); const values = deserializeValues(entry.get('data'), fields);
} else if (nextProps.newEntry) { const deserializedEntry = entry.set('data', values);
this.props.createEmptyDraft(nextProps.collection); 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); return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], true);
case ENTRY_SUCCESS: case ENTRY_SUCCESS:
return state.setIn( const result = state.setIn(
['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`], ['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`],
fromJS(action.payload.entry) fromJS(action.payload.entry)
); );
return result;
case ENTRIES_REQUEST: case ENTRIES_REQUEST:
return state.setIn(['pages', action.payload.collection, 'isFetching'], true); return state.setIn(['pages', action.payload.collection, 'isFetching'], true);