remove prosemirror, reuse unified pipelines

This commit is contained in:
Shawn Erquhart 2017-06-27 12:39:23 -04:00
parent cba631ba1a
commit 5a664f8be1
9 changed files with 41 additions and 456 deletions

View File

@ -1,24 +1,10 @@
import React, { PropTypes } from 'react';
import get from 'lodash/get';
import unified from 'unified';
import markdownToRemark from 'remark-parse';
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 rehypeMinifyWhitespace from 'rehype-minify-whitespace';
import rehypeReparse from 'rehype-raw';
import CaretPosition from 'textarea-caret-position';
import TextareaAutosize from 'react-textarea-autosize';
import registry from '../../../../../lib/registry';
import {
remarkParseConfig,
remarkStringifyConfig,
rehypeParseConfig,
rehypeStringifyConfig,
} from '../../unifiedConfig';
import { markdownToHtml, htmlToMarkdown } from '../../unified';
import { createAssetProxy } from '../../../../../valueObjects/AssetProxy';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
@ -36,18 +22,6 @@ function processUrl(url) {
return `/${ url }`;
}
function cleanupPaste(paste) {
return unified()
.use(htmlToRehype, rehypeParseConfig)
.use(rehypeSanitize)
.use(rehypeReparse)
.use(rehypeToRemark)
.use(rehypeSanitize)
.use(rehypeMinifyWhitespace)
.use(remarkToMarkdown, remarkStringifyConfig)
.process(paste);
}
function getCleanPaste(e) {
const transfer = e.clipboardData;
return new Promise((resolve) => {
@ -58,7 +32,7 @@ function getCleanPaste(e) {
// Avoid trying to clean up full HTML documents with head/body/etc
if (!data.match(/^\s*<!doctype/i)) {
e.preventDefault();
resolve(cleanupPaste(data));
resolve(htmlToMarkdown(data));
} else {
// Handle complex pastes by stealing focus with a contenteditable div
const div = document.createElement('div');
@ -69,7 +43,7 @@ function getCleanPaste(e) {
document.body.appendChild(div);
div.focus();
setTimeout(() => {
resolve(cleanupPaste(div.innerHTML));
resolve(htmlToMarkdown(div.innerHTML));
document.body.removeChild(div);
}, 50);
return null;
@ -86,12 +60,7 @@ export default class RawEditor extends React.Component {
super(props);
const plugins = registry.getEditorComponents();
this.state = {
value: unified()
.use(htmlToRehype)
.use(rehypeToRemark)
.use(remarkToMarkdown, remarkStringifyConfig)
.processSync(this.props.value)
.contents,
value: htmlToMarkdown(this.props.value),
plugins,
};
this.shortcuts = {
@ -259,16 +228,7 @@ export default class RawEditor extends React.Component {
handleChange = (e) => {
// handleChange may receive an event or a value
const value = typeof e === 'object' ? e.target.value : e;
const html = unified()
.use(markdownToRemark, remarkParseConfig)
.use(remarkToRehype)
.use(rehypeSanitize)
.use(rehypeMinifyWhitespace)
.use(rehypeToHtml, rehypeStringifyConfig)
.processSync(value)
.contents;
console.log(html);
const html = markdownToHtml(value);
this.props.onChange(html);
this.updateHeight();
this.setState({ value });

View File

@ -1,23 +1,9 @@
import React, { Component, PropTypes } from 'react';
import { Map, List } from 'immutable';
import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw} from 'slate';
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify';
import remarkToMarkdown from 'remark-stringify';
import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark';
import { markdownToHtml, htmlToMarkdown } from '../../unified';
import registry from '../../../../../lib/registry';
import { createAssetProxy } from '../../../../../valueObjects/AssetProxy';
import {
remarkParseConfig,
remarkStringifyConfig,
rehypeParseConfig,
rehypeStringifyConfig,
} from '../../unifiedConfig';
import { buildKeymap } from './keymap';
import createMarkdownParser from './parser';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import styles from './index.css';
@ -29,19 +15,8 @@ import styles from './index.css';
* and before persisting.
*/
registry.registerWidgetValueSerializer('markdown', {
serialize: value => unified()
.use(htmlToRehype, rehypeParseConfig)
.use(htmlToRehype)
.use(rehypeToRemark)
.use(remarkToMarkdown, remarkStringifyConfig)
.processSync(value)
.contents,
deserialize: value => unified()
.use(markdownToRemark, remarkParseConfig)
.use(remarkToRehype)
.use(rehypeToHtml, rehypeStringifyConfig)
.processSync(value)
.contents
serialize: htmlToMarkdown,
deserialize: markdownToHtml,
});
function processUrl(url) {
@ -281,7 +256,6 @@ export default class Editor extends Component {
constructor(props) {
super(props);
const plugins = registry.getEditorComponents();
console.log(this.props.value);
this.state = {
editorState: serializer.deserialize(this.props.value || '<p></p>'),
schema: {

View File

@ -1,92 +0,0 @@
const { wrapIn, setBlockType, chainCommands, newlineInCode, toggleMark } = require('prosemirror-commands');
const { selectNextCell, selectPreviousCell } = require('prosemirror-schema-table');
const { wrapInList, splitListItem, liftListItem, sinkListItem } = require('prosemirror-schema-list');
const { undo, redo } = require('prosemirror-history');
const mac = typeof navigator != 'undefined' ? /Mac/.test(navigator.platform) : false;
// :: (Schema, ?Object) → Object
// Inspect the given schema looking for marks and nodes from the
// basic schema, and if found, add key bindings related to them.
// This will add:
//
// * **Mod-b** for toggling [strong](#schema-basic.StrongMark)
// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark)
// * **Mod-`** for toggling [code font](#schema-basic.CodeMark)
// * **Ctrl-Shift-0** for making the current textblock a paragraph
// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current
// textblock a heading of the corresponding level
// * **Ctrl-Shift-Backslash** to make the current textblock a code block
// * **Ctrl-Shift-8** to wrap the selection in an ordered list
// * **Ctrl-Shift-9** to wrap the selection in a bullet list
// * **Ctrl->** to wrap the selection in a block quote
// * **Enter** to split a non-empty textblock in a list item while at
// the same time splitting the list item
// * **Mod-Enter** to insert a hard break
// * **Mod-_** to insert a horizontal rule
//
// You can suppress or map these bindings by passing a `mapKeys`
// argument, which maps key names (say `"Mod-B"` to either `false`, to
// remove the binding, or a new key name string.
function buildKeymap(schema, mapKeys) {
let keys = {}, type;
function bind(key, cmd) {
if (mapKeys) {
const mapped = mapKeys[key];
if (mapped === false) return;
if (mapped) key = mapped;
}
keys[key] = cmd;
}
bind('Mod-z', undo);
bind('Mod-y', redo);
if (type = schema.marks.strong)
bind('Mod-b', toggleMark(type));
if (type = schema.marks.em)
bind('Mod-i', toggleMark(type));
if (type = schema.marks.code)
bind('Mod-`', toggleMark(type));
if (type = schema.nodes.bullet_list)
bind('Shift-Ctrl-8', wrapInList(type));
if (type = schema.nodes.ordered_list)
bind('Shift-Ctrl-9', wrapInList(type));
if (type = schema.nodes.blockquote)
bind('Ctrl->', wrapIn(type));
if (type = schema.nodes.hard_break) {
let br = type, cmd = chainCommands(newlineInCode, (state, onAction) => {
onAction(state.tr.replaceSelection(br.create()).scrollAction());
return true;
});
bind('Mod-Enter', cmd);
bind('Shift-Enter', cmd);
if (mac) bind('Ctrl-Enter', cmd);
}
if (type = schema.nodes.list_item) {
bind('Enter', splitListItem(type));
bind('Mod-[', liftListItem(type));
bind('Mod-]', sinkListItem(type));
}
if (type = schema.nodes.paragraph)
bind('Shift-Ctrl-0', setBlockType(type));
if (type = schema.nodes.code_block)
bind('Shift-Ctrl-\\', setBlockType(type));
if (type = schema.nodes.heading)
for (let i = 1; i <= 6; i++) bind(`Shift-Ctrl-${ i }`, setBlockType(type, { level: i }));
if (type = schema.nodes.horizontal_rule) {
const hr = type;
bind('Mod-_', (state, onAction) => {
onAction(state.tr.replaceSelection(hr.create()).scrollAction());
return true;
});
}
if (schema.nodes.table_row) {
bind('Tab', selectNextCell);
bind('Shift-Tab', selectPreviousCell);
}
return keys;
}
exports.buildKeymap = buildKeymap;

View File

@ -1,187 +0,0 @@
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
/**
* A remark plugin for converting an MDAST to a ProseMirror tree.
* @param {state} information to be shared across ProseMirror actions
* @returns {function} a transformer function
*/
export default function markdownToProseMirror({ state }) {
// The state object also contains `activeMarks` and `textsArray`, but we
// may change those values from here to be shared across ProseMirror actions
// (this plugin is run for each action), so we always access them directly
// on the state object.
const { schema, plugins } = state;
// return transform;
return node => {
const result = transform(node);
return result;
};
/**
* The MDAST transformer function.
* @param {object} node an MDAST node
* @returns {Node} a ProseMirror Node
*/
function transform(node) {
if (node.type === 'text') {
processText(node.value);
return;
}
const nodeDef = getNodeDef(node);
const processor = get(nodeDef, 'block') ? processBlock : processInline;
return nodeDef ? processor(nodeDef, node.children, node.value) : node;
}
/**
* Provides required information for converting an MDAST node into a ProseMirror
* Node.
*
* @param {object} node - an MDAST node
* @returns {object} conversion data node with the following shape:
* {string} pmType - the equivalent node type in the ProseMirror schema
* {boolean} block - true if the node is block level, otherwise false
* {object} attrs - passed to ProseMirror's schema mark/node creation methods
* {object} content - overrides `node.children` as node content
* {Node} defaultContent - content to use if node has no content (default: null)
* {boolean} canContainPlugins true for nodes that may contain plugins
*/
function getNodeDef({ type, ordered, lang, value, depth, url, alt }) {
switch (type) {
case 'root':
return { pmType: 'doc', block: true, defaultContent: schema.node('paragraph') };
case 'heading':
return { pmType: type, attrs: { level: depth }, hasText: true, block: true };
case 'paragraph':
return { pmType: type, hasText: true, block: true, canContainPlugins: true };
case 'blockquote':
return { pmType: type, block: true };
case 'list':
return { pmType: ordered ? 'ordered_list' : 'bullet_list', attrs: { tight: true }, block: true };
case 'listItem':
return { pmType: 'list_item', block: true };
case 'thematicBreak':
return { pmType: 'horizontal_rule', block: true };
case 'break':
return { pmType: 'hard_break', block: true };
case 'image':
return { pmType: type, block: true, attrs: { src: url, alt } };
case 'code':
return { pmType: 'code_block', attrs: { params: lang }, content: schema.text(value), block: true };
case 'emphasis':
return { pmType: 'em' };
case 'strong':
return { pmType: type };
case 'link':
return { pmType: type, attrs: { href: url } };
case 'inlineCode':
return { pmType: 'code' };
}
}
/**
* Derives content from block nodes. Block nodes containing raw text, such as
* headings and paragraphs, are processed differently than block nodes
* containing other node types.
* @param {array} children child nodes
* @param {boolean} hasText if true, the node contains raw text nodes
* @returns {array} processed child nodes
*/
function getBlockContent(children, hasText) {
// children.map will return undefined for text nodes, so we filter those out
const processedChildren = children.map(transform).filter(val => val);
if (hasText) {
const content = state.textsArray;
state.textsArray = [];
return content;
}
return processedChildren;
}
/**
* Processes text nodes.
* @param {string} value the node's text content
* @returns {undefined}
*/
function processText(value) {
state.textsArray.push(schema.text(value, state.activeMarks));
return;
}
/**
* Processes block nodes.
* @param {object} nodeModel the nodeModel for this node type via nodeModelGetters
* @param {array} children the node's child nodes
* @return {Node} a ProseMirror node
*/
function processBlock({ pmType, attrs, content, defaultContent = null, hasText, canContainPlugins }, children) {
// Plugins are just text shortcodes, so they're rendered as a text node within
// a paragraph node in the MDAST. We use a regex to determine if the text
// represents a plugin, so for performance reasons we only test text nodes that
// are the only child of a node that can contain plugins. Currently, only
// paragraphs may contain plugins.
//
// Additionally, images are handled via plugin. Because images already have a
// markdown pattern, they're represented as 'image' type in the MDAST. We
// check for those here, too.
if (canContainPlugins && children.length === 1 && ['text', 'image'].includes(children[0].type)) {
const processedPlugin = processPlugin(children[0]);
if (processedPlugin) {
return processedPlugin;
}
}
const nodeContent = content || (isEmpty(children) ? defaultContent : getBlockContent(children, hasText));
return schema.node(pmType, attrs, nodeContent);
}
/**
* Processes inline nodes.
* @param {object} nodeModel the nodeModel for this node type via nodeModelGetters
* @param {array} children the node's child nodes
* @return {undefined}
*/
function processInline({ pmType, attrs }, children, value) {
const mark = schema.marks[pmType].create(attrs);
state.activeMarks = mark.addToSet(state.activeMarks);
if (isEmpty(children)) {
state.textsArray.push(schema.text(value, state.activeMarks));
} else {
children.forEach(childNode => transform(childNode));
}
state.activeMarks = mark.removeFromSet(state.activeMarks);
return;
}
/**
* Processes plugins, which are represented as user-defined text shortcodes.
*
* The built in image plugin is handled differently because it overrides
* remark/rehype's handling of a recognized markdown/html entity. Ideally, would
* stop remark from parsing images at all, so that no special logic would be
* required, but overriding this way would require a plugin to indicate what
* entity it's overriding.
*
* @param {object} a remark node representing a user defined plugin
* @return {Node} a ProseMirror Node
*/
function processPlugin({ type, value, alt, url }) {
const isImage = type === 'image';
const plugin = isImage ? plugins.get('image') : plugins.find(plugin => plugin.get('pattern').test(value));
if (plugin) {
const matches = isImage ? [ , alt, url ] : value.match(plugin.get('pattern'));
const nodeType = schema.nodes[`plugin_${plugin.get('id')}`];
const data = plugin.get('fromBlock').call(plugin, matches);
return nodeType.create(data);
}
}
}

View File

@ -1,34 +0,0 @@
import unified from 'unified';
import remarkToMarkdown from 'remark-parse';
import { Mark } from 'prosemirror-model';
import markdownToProseMirror from './markdownToProseMirror';
const state = { activeMarks: Mark.none, textsArray: [] };
/**
* Uses unified to parse markdown and apply plugins.
* @param {string} src raw markdown
* @returns {Node} a ProseMirror Node
*/
function parser(src) {
const result = unified()
.use(remarkToMarkdown, { fences: true, footnotes: true, pedantic: true })
.parse(src);
return unified()
.use(markdownToProseMirror, { state })
.runSync(result);
}
/**
* Gets the parser and makes schema and plugins available at top scope.
* @param {Schema} schema - a ProseMirror schema
* @param {Map} plugins - Immutable Map of registered plugins
*/
function parserGetter(schema, plugins) {
state.schema = schema;
state.plugins = plugins;
return parser;
}
export default parserGetter;

View File

@ -1,59 +0,0 @@
import React, { PropTypes } from "react";
import { renderToStaticMarkup } from 'react-dom/server';
import { Map } from 'immutable';
import isString from 'lodash/isString';
import isEmpty from 'lodash/isEmpty';
import unified from 'unified';
import htmlToRehype from 'rehype-parse';
import registry from "../../../../lib/registry";
const cmsPluginRehype = ({ getAsset }) => {
const plugins = registry.getEditorComponents();
return transform;
function transform(node) {
// Handle externally defined plugins (they'll be wrapped in paragraphs)
if (node.tagName === 'p' && node.children.length === 1) {
if (node.children[0].type === 'text') {
const value = node.children[0].value;
const plugin = plugins.find(plugin => plugin.get('pattern').test(value));
if (plugin) {
const data = plugin.get('fromBlock')(value.match(plugin.get('pattern')));
const preview = plugin.get('toPreview')(data);
const output = `<div>${isString(preview) ? preview : renderToStaticMarkup(preview)}</div>`;
return unified().use(htmlToRehype, { fragment: true }).parse(output).children[0];
}
}
// Handle the internally defined image plugin. At this point the token has
// already been parsed as an image by Remark, so we have to catch it by
// checking for the 'image' type.
if (node.children[0].tagName === 'img') {
const { src, alt } = node.children[0].properties;
// Until we improve the editor components API for built in components,
// we'll mock the result of String.prototype.match to pass in to the image
// plugin's fromBlock method.
const plugin = plugins.get('image');
if (plugin) {
const matches = [ , alt, src ];
const data = plugin.get('fromBlock')(matches);
const extendedData = { ...data, image: getAsset(data.image).toString() };
const preview = plugin.get('toPreview')(extendedData);
const output = `<div>${isString(preview) ? preview : renderToStaticMarkup(preview)}</div>`;
return unified().use(htmlToRehype, { fragment: true }).parse(output).children[0];
}
}
}
if (!isEmpty(node.children)) {
node.children = node.children.map(childNode => transform(childNode, getAsset));
}
return node;
}
};
export default cmsPluginRehype;

View File

@ -1,10 +1,4 @@
import React, { PropTypes } from 'react';
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToRehype from 'remark-rehype';
import htmlToRehype from 'rehype-parse';
import rehypeToReact from 'rehype-react';
import cmsPluginToRehype from './cmsPluginRehype';
import previewStyle from '../../defaultPreviewStyle';
const MarkdownPreview = ({ value, getAsset }) => {

View File

@ -0,0 +1,33 @@
import unified from 'unified';
import markdownToRemark from 'remark-parse';
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 rehypeMinifyWhitespace from 'rehype-minify-whitespace';
const remarkParseConfig = { fences: true };
const remarkStringifyConfig = { listItemIndent: '1', fences: true };
const rehypeParseConfig = { fragment: true };
export const markdownToHtml = markdown =>
unified()
.use(markdownToRemark, remarkParseConfig)
.use(remarkToRehype)
.use(rehypeSanitize)
.use(rehypeMinifyWhitespace)
.use(rehypeToHtml)
.processSync(markdown)
.contents;
export const htmlToMarkdown = html =>
unified()
.use(htmlToRehype, rehypeParseConfig)
.use(rehypeSanitize)
.use(rehypeMinifyWhitespace)
.use(rehypeToRemark)
.use(remarkToMarkdown, remarkStringifyConfig)
.processSync(html)
.contents;

View File

@ -1,4 +0,0 @@
export const remarkParseConfig = { fences: true };
export const remarkStringifyConfig = { listItemIndent: '1', fences: true };
export const rehypeParseConfig = { fragment: true };
export const rehypeStringifyConfig = {};