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 {alt}; + const title = data && data.get('title') || props.title; + return
    {alt}
    ; }, + 'table': props => {props.children}
    , + '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);