diff --git a/package.json b/package.json
index 3ba523ce..a2405c4c 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js
index d04903a8..8127eee6 100644
--- a/src/actions/editorialWorkflow.js
+++ b/src/actions/editorialWorkflow.js
@@ -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({
diff --git a/src/actions/entries.js b/src/actions/entries.js
index 6cae3137..6bdfbf7c 100644
--- a/src/actions/entries.js
+++ b/src/actions/entries.js
@@ -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',
diff --git a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js b/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js
index b421691e..e6cd1571 100644
--- a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js
+++ b/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js
@@ -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,
};
diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css
index 81ab24db..b7a3aafb 100644
--- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css
+++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css
@@ -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 {
diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js
index f0871fd3..f6b5d1ca 100644
--- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js
+++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js
@@ -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 =>
{props.children}
,
'paragraph': props => {props.children}
,
'list-item': props => {props.children},
+ 'numbered-list': props => {
+ const { data } = props.node;
+ const start = data.get('start') || 1;
+ return {props.children}
;
+ },
'bulleted-list': props => ,
- 'numbered-list': props => {props.children}
,
'quote': props => {props.children}
,
- 'code': props => {props.children}
,
+ 'code': props => {props.children}
,
'heading-one': props => {props.children}
,
'heading-two': props => {props.children}
,
'heading-three': props => {props.children}
,
@@ -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 ;
+ const title = data && data.get('title') || props.title;
+ return ;
},
+ 'table': props => ,
+ 'table-row': props => {props.children}
,
+ 'table-cell': props => {props.children} | ,
+ 'thematic-break': props =>
,
};
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 {props.children};
+ const data = props.node.get('data');
+ const href = data && data.get('url') || props.href;
+ const title = data && data.get('title') || props.title;
+ return {props.children};
},
'shortcode': props => {
const { attributes, node, state: editorState } = props;
@@ -153,7 +155,6 @@ const NODE_COMPONENTS = {
const MARK_COMPONENTS = {
bold: props => {props.children},
italic: props => {props.children},
- underlined: props => {props.children},
strikethrough: props => {props.children},
code: props => {props.children}
,
};
@@ -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 ? `${this.props.value}
` : '';
+ 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,
};
diff --git a/src/components/Widgets/Markdown/MarkdownControl/index.js b/src/components/Widgets/Markdown/MarkdownControl/index.js
index 41d79763..6ed3df10 100644
--- a/src/components/Widgets/Markdown/MarkdownControl/index.js
+++ b/src/components/Widgets/Markdown/MarkdownControl/index.js
@@ -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) {
diff --git a/src/components/Widgets/Markdown/MarkdownPreview/index.js b/src/components/Widgets/Markdown/MarkdownPreview/index.js
index b7927dfd..c3127f6a 100644
--- a/src/components/Widgets/Markdown/MarkdownPreview/index.js
+++ b/src/components/Widgets/Markdown/MarkdownPreview/index.js
@@ -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 : ;
+ if (value === null) {
+ return null;
+ }
+ const html = remarkToHtml(value);
+ return ;
};
MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired,
- value: PropTypes.string,
+ value: PropTypes.object,
};
export default MarkdownPreview;
diff --git a/src/components/Widgets/Markdown/unified.js b/src/components/Widgets/Markdown/unified.js
index ba011474..434f2b34 100644
--- a/src/components/Widgets/Markdown/unified.js
+++ b/src/components/Widgets/Markdown/unified.js
@@ -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 {{}} from ?
- * 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 => {
diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js
index 059fa947..5bc3a944 100644
--- a/src/containers/EntryPage.js
+++ b/src/containers/EntryPage.js
@@ -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);
}
}
diff --git a/src/lib/serializeEntryValues.js b/src/lib/serializeEntryValues.js
new file mode 100644
index 00000000..f6f9ae05
--- /dev/null
+++ b/src/lib/serializeEntryValues.js
@@ -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');
+};
diff --git a/src/reducers/entries.js b/src/reducers/entries.js
index acd891bf..49c20bf5 100644
--- a/src/reducers/entries.js
+++ b/src/reducers/entries.js
@@ -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);