Convert markdown-prosemirror parser/compiler to Remark
This commit is contained in:
parent
24c0a1bdb4
commit
0eb109cb73
@ -167,6 +167,7 @@
|
||||
"slate-drop-or-paste-images": "^0.2.0",
|
||||
"slug": "^0.9.1",
|
||||
"textarea-caret-position": "^0.1.1",
|
||||
"unist-util-visit": "^1.1.1",
|
||||
"uuid": "^2.0.3",
|
||||
"whatwg-fetch": "^1.0.0"
|
||||
},
|
||||
|
@ -23,8 +23,8 @@ export default class MarkupItReactRenderer extends React.Component {
|
||||
|
||||
render() {
|
||||
const { value } = this.props;
|
||||
const mast = remark.parse(value);
|
||||
const hast = toHAST(mast, { allowDangerousHTML: true });
|
||||
const mdast = remark.parse(value);
|
||||
const hast = toHAST(mdast, { allowDangerousHTML: true });
|
||||
const html = hastToHTML(hast, { allowDangerousHTML: true });
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />; // eslint-disable-line react/no-danger
|
||||
}
|
||||
|
@ -0,0 +1,324 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile a markdown ordered list 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 1,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"order": 1,
|
||||
"tight": true,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "yo",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "list_item",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "bro",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "list_item",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "fro",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "list_item",
|
||||
},
|
||||
],
|
||||
"type": "ordered_list",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile bulleted lists 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 1,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"tight": false,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "yo",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "list_item",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "bro",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "list_item",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "fro",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "list_item",
|
||||
},
|
||||
],
|
||||
"type": "bullet_list",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile hard breaks (double space) 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "blue moon",
|
||||
"type": "text",
|
||||
},
|
||||
Object {
|
||||
"type": "hard_break",
|
||||
},
|
||||
Object {
|
||||
"text": "footballs",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile horizontal rules 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 1,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"type": "horizontal_rule",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "blue moon",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile horizontal rules 2`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 1,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"type": "horizontal_rule",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "blue moon",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile images 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"alt": "super",
|
||||
"src": "duper.jpg",
|
||||
"title": null,
|
||||
},
|
||||
"type": "image",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile multiple header levels 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 1,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 2,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H2",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 3,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H3",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Compile markdown to Prosemirror document structure should compile simple markdown 1`] = `
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"attrs": Object {
|
||||
"level": 1,
|
||||
},
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "H1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
Object {
|
||||
"content": Array [
|
||||
Object {
|
||||
"text": "sweet body",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
@ -0,0 +1,91 @@
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { schema } from "prosemirror-markdown";
|
||||
|
||||
const makeParser = require("../parser");
|
||||
|
||||
const testSchema = new Schema({
|
||||
nodes: schema.nodeSpec,
|
||||
marks: schema.markSpec,
|
||||
});
|
||||
const parser = makeParser(testSchema);
|
||||
|
||||
describe("Compile markdown to Prosemirror document structure", () => {
|
||||
it("should compile simple markdown", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
sweet body
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile a markdown ordered list", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
1. yo
|
||||
2. bro
|
||||
3. fro
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile bulleted lists", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
* yo
|
||||
* bro
|
||||
* fro
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile multiple header levels", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
## H2
|
||||
|
||||
### H3
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile horizontal rules", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
---
|
||||
|
||||
blue moon
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile horizontal rules", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
---
|
||||
|
||||
blue moon
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile hard breaks (double space)", () => {
|
||||
const value = `
|
||||
blue moon
|
||||
footballs
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile images", () => {
|
||||
const value = `
|
||||
![super](duper.jpg)
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -5,8 +5,13 @@ import { EditorState } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import history from 'prosemirror-history';
|
||||
import {
|
||||
blockQuoteRule, orderedListRule, bulletListRule, codeBlockRule, headingRule,
|
||||
inputRules, allInputRules,
|
||||
blockQuoteRule,
|
||||
orderedListRule,
|
||||
bulletListRule,
|
||||
codeBlockRule,
|
||||
headingRule,
|
||||
inputRules,
|
||||
allInputRules,
|
||||
} from 'prosemirror-inputrules';
|
||||
import { keymap } from 'prosemirror-keymap';
|
||||
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
@ -56,20 +61,26 @@ function schemaWithPlugins(schema, plugins) {
|
||||
let nodeSpec = schema.nodeSpec;
|
||||
plugins.forEach((plugin) => {
|
||||
const attrs = {};
|
||||
plugin.get('fields').forEach((field) => {
|
||||
attrs[field.get('name')] = { default: null };
|
||||
plugin.get("fields").forEach((field) => {
|
||||
attrs[field.get("name")] = { default: null };
|
||||
});
|
||||
nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get('id') }`, {
|
||||
nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get("id") }`, {
|
||||
attrs,
|
||||
group: 'block',
|
||||
parseDOM: [{
|
||||
tag: 'div[data-plugin]',
|
||||
getAttrs(dom) {
|
||||
return JSON.parse(dom.getAttribute('data-plugin'));
|
||||
group: "block",
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "div[data-plugin]",
|
||||
getAttrs(dom) {
|
||||
return JSON.parse(dom.getAttribute("data-plugin"));
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
toDOM(node) {
|
||||
return ['div', { 'data-plugin': JSON.stringify(node.attrs) }, plugin.get('label')];
|
||||
return [
|
||||
"div",
|
||||
{ "data-plugin": JSON.stringify(node.attrs) },
|
||||
plugin.get("label"),
|
||||
];
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -83,8 +94,8 @@ function schemaWithPlugins(schema, plugins) {
|
||||
function createSerializer(schema, plugins) {
|
||||
const serializer = Object.create(defaultMarkdownSerializer);
|
||||
plugins.forEach((plugin) => {
|
||||
serializer.nodes[`plugin_${ plugin.get('id') }`] = (state, node) => {
|
||||
const toBlock = plugin.get('toBlock');
|
||||
serializer.nodes[`plugin_${ plugin.get("id") }`] = (state, node) => {
|
||||
const toBlock = plugin.get("toBlock");
|
||||
state.write(`${ toBlock.call(plugin, node.attrs) }\n\n`);
|
||||
};
|
||||
});
|
||||
@ -159,17 +170,31 @@ export default class Editor extends Component {
|
||||
const { schema, selection } = state;
|
||||
if (selection.from === selection.to) {
|
||||
const { $from } = selection;
|
||||
if ($from.parent && $from.parent.type === schema.nodes.paragraph && $from.parent.textContent === '') {
|
||||
if (
|
||||
$from.parent &&
|
||||
$from.parent.type === schema.nodes.paragraph &&
|
||||
$from.parent.textContent === ""
|
||||
) {
|
||||
const pos = this.view.coordsAtPos(selection.from);
|
||||
const editorPos = this.view.content.getBoundingClientRect();
|
||||
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
|
||||
const selectionPosition = {
|
||||
top: pos.top - editorPos.top,
|
||||
left: pos.left - editorPos.left,
|
||||
};
|
||||
this.setState({ selectionPosition });
|
||||
} else {
|
||||
this.setState({ showToolbar: false, showBlockMenu: false });
|
||||
}
|
||||
} else {
|
||||
const pos = this.view.coordsAtPos(selection.from);
|
||||
const editorPos = this.view.content.getBoundingClientRect();
|
||||
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
|
||||
this.setState({ selectionPosition });
|
||||
const selectionPosition = {
|
||||
top: pos.top - editorPos.top,
|
||||
left: pos.left - editorPos.left,
|
||||
};
|
||||
this.setState({ selectionPosition });
|
||||
}
|
||||
};
|
||||
|
||||
@ -177,26 +202,24 @@ export default class Editor extends Component {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
handleHeader = level => (
|
||||
() => {
|
||||
const { schema } = this.state;
|
||||
const state = this.view.state;
|
||||
const { $from, to, node } = state.selection;
|
||||
let nodeType = schema.nodes.heading;
|
||||
let attrs = { level };
|
||||
let inHeader = node && node.hasMarkup(nodeType, attrs);
|
||||
if (!inHeader) {
|
||||
inHeader = to <= $from.end() && $from.parent.hasMarkup(nodeType, attrs);
|
||||
}
|
||||
if (inHeader) {
|
||||
nodeType = schema.nodes.paragraph;
|
||||
attrs = {};
|
||||
}
|
||||
|
||||
const command = setBlockType(nodeType, { level });
|
||||
command(state, this.handleAction);
|
||||
handleHeader = level => () => {
|
||||
const { schema } = this.state;
|
||||
const state = this.view.state;
|
||||
const { $from, to, node } = state.selection;
|
||||
let nodeType = schema.nodes.heading;
|
||||
let attrs = { level };
|
||||
let inHeader = node && node.hasMarkup(nodeType, attrs);
|
||||
if (!inHeader) {
|
||||
inHeader = to <= $from.end() && $from.parent.hasMarkup(nodeType, attrs);
|
||||
}
|
||||
);
|
||||
if (inHeader) {
|
||||
nodeType = schema.nodes.paragraph;
|
||||
attrs = {};
|
||||
}
|
||||
|
||||
const command = setBlockType(nodeType, { level });
|
||||
command(state, this.handleAction);
|
||||
};
|
||||
|
||||
handleBold = () => {
|
||||
const command = toggleMark(this.state.schema.marks.strong);
|
||||
@ -213,14 +236,20 @@ export default class Editor extends Component {
|
||||
if (!markActive(this.view.state, this.state.schema.marks.link)) {
|
||||
url = prompt('Link URL:'); // eslint-disable-line no-alert
|
||||
}
|
||||
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
|
||||
const command = toggleMark(this.state.schema.marks.link, {
|
||||
href: url ? processUrl(url) : null,
|
||||
});
|
||||
command(this.view.state, this.handleAction);
|
||||
};
|
||||
|
||||
handlePluginSubmit = (plugin, data) => {
|
||||
const { schema } = this.state;
|
||||
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
|
||||
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
|
||||
const nodeType = schema.nodes[`plugin_${ plugin.get("id") }`];
|
||||
this.view.props.onAction(
|
||||
this.view.state.tr
|
||||
.replaceSelectionWith(nodeType.create(data.toJS()))
|
||||
.action()
|
||||
);
|
||||
};
|
||||
|
||||
handleDragEnter = (e) => {
|
||||
@ -248,31 +277,40 @@ export default class Editor extends Component {
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length) {
|
||||
Array.from(e.dataTransfer.files).forEach((file) => {
|
||||
createAssetProxy(file.name, file)
|
||||
.then((assetProxy) => {
|
||||
createAssetProxy(file.name, file).then((assetProxy) => {
|
||||
this.props.onAddAsset(assetProxy);
|
||||
if (file.type.split('/')[0] === 'image') {
|
||||
if (file.type.split("/")[0] === "image") {
|
||||
nodes.push(
|
||||
schema.nodes.image.create({ src: assetProxy.public_path, alt: file.name })
|
||||
schema.nodes.image.create({
|
||||
src: assetProxy.public_path,
|
||||
alt: file.name,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
nodes.push(
|
||||
schema.marks.link.create({ href: assetProxy.public_path, title: file.name })
|
||||
schema.marks.link.create({
|
||||
href: assetProxy.public_path,
|
||||
title: file.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
nodes.push(schema.nodes.paragraph.create({}, e.dataTransfer.getData('text/plain')));
|
||||
nodes.push(
|
||||
schema.nodes.paragraph.create({}, e.dataTransfer.getData("text/plain"))
|
||||
);
|
||||
}
|
||||
|
||||
nodes.forEach((node) => {
|
||||
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(node).action());
|
||||
this.view.props.onAction(
|
||||
this.view.state.tr.replaceSelectionWith(node).action()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
handleToggle = () => {
|
||||
this.props.onMode('raw');
|
||||
this.props.onMode("raw");
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -283,36 +321,38 @@ export default class Editor extends Component {
|
||||
classNames.push(styles.dragging);
|
||||
}
|
||||
|
||||
return (<div
|
||||
className={classNames.join(' ')}
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragLeave={this.handleDragLeave}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleDrop}
|
||||
>
|
||||
<Sticky
|
||||
className={styles.editorControlBar}
|
||||
classNameActive={styles.editorControlBarSticky}
|
||||
fillContainerWidth
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(' ')}
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragLeave={this.handleDragLeave}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleDrop}
|
||||
>
|
||||
<Toolbar
|
||||
selectionPosition={selectionPosition}
|
||||
onH1={this.handleHeader(1)}
|
||||
onH2={this.handleHeader(2)}
|
||||
onBold={this.handleBold}
|
||||
onItalic={this.handleItalic}
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
plugins={plugins}
|
||||
onSubmit={this.handlePluginSubmit}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
</Sticky>
|
||||
<div ref={this.handleRef} />
|
||||
<div className={styles.shim} />
|
||||
</div>);
|
||||
<Sticky
|
||||
className={styles.editorControlBar}
|
||||
classNameActive={styles.editorControlBarSticky}
|
||||
fillContainerWidth
|
||||
>
|
||||
<Toolbar
|
||||
selectionPosition={selectionPosition}
|
||||
onH1={this.handleHeader(1)}
|
||||
onH2={this.handleHeader(2)}
|
||||
onBold={this.handleBold}
|
||||
onItalic={this.handleItalic}
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
plugins={plugins}
|
||||
onSubmit={this.handlePluginSubmit}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
</Sticky>
|
||||
<div ref={this.handleRef} />
|
||||
<div className={styles.shim} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,256 +2,90 @@
|
||||
/*
|
||||
Based closely on
|
||||
https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/from_markdown.js
|
||||
|
||||
Adds a bit of logic allowing editor plugins to hook into the parsing.
|
||||
*/
|
||||
|
||||
const markdownit = require("markdown-it")
|
||||
import Remark from "remark";
|
||||
const visit = require('unist-util-visit')
|
||||
const {Mark} = require("prosemirror-model")
|
||||
|
||||
function maybeMerge(a, b) {
|
||||
if (a.isText && b.isText && Mark.sameSet(a.marks, b.marks))
|
||||
return a.copy(a.text + b.text)
|
||||
}
|
||||
let schema
|
||||
|
||||
function pluginHandler(schema, plugins) {
|
||||
return (type, attrs, content) => {
|
||||
if (type.name === 'paragraph' && content.length === 1 && content[0].type.name === 'text') {
|
||||
const text = content[0].text;
|
||||
const plugin = plugins.find(plugin => plugin.get('pattern').test(text));
|
||||
if (plugin) {
|
||||
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
|
||||
const data = plugin.get('fromBlock').call(plugin, text.match(plugin.get('pattern')));
|
||||
return nodeType.create(data);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
// Setup Remark.
|
||||
const remark = new Remark({
|
||||
commonmark: true,
|
||||
footnotes: true,
|
||||
pedantic: true,
|
||||
});
|
||||
|
||||
// Object used to track the context of a running parse.
|
||||
class MarkdownParseState {
|
||||
constructor(schema, plugins, tokenHandlers) {
|
||||
this.schema = schema
|
||||
this.stack = [{type: schema.nodes.doc, content: []}]
|
||||
this.marks = Mark.none
|
||||
this.tokenHandlers = tokenHandlers
|
||||
this.pluginHandler = pluginHandler(schema, plugins);
|
||||
const processMdastNode = (node) => {
|
||||
console.log('processMdastNode', node)
|
||||
if (node.type === 'root') {
|
||||
const content = node.children.map((childNode) => (
|
||||
processMdastNode(childNode)
|
||||
))
|
||||
return schema.node('doc', {}, content)
|
||||
}
|
||||
|
||||
top() {
|
||||
return this.stack[this.stack.length - 1]
|
||||
}
|
||||
|
||||
push(elt) {
|
||||
if (this.stack.length) this.top().content.push(elt)
|
||||
}
|
||||
|
||||
// : (string)
|
||||
// Adds the given text to the current position in the document,
|
||||
// using the current marks as styling.
|
||||
addText(text) {
|
||||
if (!text) return
|
||||
let nodes = this.top().content, last = nodes[nodes.length - 1]
|
||||
let node = this.schema.text(text, this.marks), merged
|
||||
if (last && (merged = maybeMerge(last, node))) nodes[nodes.length - 1] = merged
|
||||
else nodes.push(node)
|
||||
}
|
||||
|
||||
// : (Mark)
|
||||
// Adds the given mark to the set of active marks.
|
||||
openMark(mark) {
|
||||
this.marks = mark.addToSet(this.marks)
|
||||
}
|
||||
|
||||
// : (Mark)
|
||||
// Removes the given mark from the set of active marks.
|
||||
closeMark(mark) {
|
||||
this.marks = mark.removeFromSet(this.marks)
|
||||
}
|
||||
|
||||
parseTokens(toks) {
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
let tok = toks[i]
|
||||
let handler = this.tokenHandlers[tok.type]
|
||||
if (!handler)
|
||||
throw new Error("Token type `" + tok.type + "` not supported by Markdown parser")
|
||||
handler(this, tok)
|
||||
}
|
||||
}
|
||||
|
||||
// : (NodeType, ?Object, ?[Node]) → ?Node
|
||||
// Add a node at the current position.
|
||||
addNode(type, attrs, content) {
|
||||
const node = this.pluginHandler(type, attrs, content) || type.createAndFill(attrs, content, this.marks);
|
||||
if (!node) return null
|
||||
this.push(node)
|
||||
return node
|
||||
}
|
||||
|
||||
// : (NodeType, ?Object)
|
||||
// Wrap subsequent content in a node of the given type.
|
||||
openNode(type, attrs) {
|
||||
this.stack.push({type: type, attrs: attrs, content: []})
|
||||
}
|
||||
|
||||
// : () → ?Node
|
||||
// Close and return the node that is currently on top of the stack.
|
||||
closeNode() {
|
||||
if (this.marks.length) this.marks = Mark.none
|
||||
let info = this.stack.pop()
|
||||
return this.addNode(info.type, info.attrs, info.content)
|
||||
}
|
||||
}
|
||||
|
||||
function attrs(given, token) {
|
||||
return given instanceof Function ? given(token) : given
|
||||
}
|
||||
|
||||
// Code content is represented as a single token with a `content`
|
||||
// property in Markdown-it.
|
||||
function noOpenClose(type) {
|
||||
return type == "code_inline" || type == "code_block" || type == "fence"
|
||||
}
|
||||
|
||||
function withoutTrailingNewline(str) {
|
||||
return str[str.length - 1] == "\n" ? str.slice(0, str.length - 1) : str
|
||||
}
|
||||
|
||||
function tokenHandlers(schema, tokens) {
|
||||
let handlers = Object.create(null)
|
||||
for (let type in tokens) {
|
||||
let spec = tokens[type]
|
||||
if (spec.block) {
|
||||
let nodeType =schema.nodeType(spec.block);
|
||||
if (noOpenClose(type)) {
|
||||
handlers[type] = (state, tok) => {
|
||||
state.openNode(nodeType, attrs(spec.attrs, tok))
|
||||
state.addText(withoutTrailingNewline(tok.content))
|
||||
state.closeNode()
|
||||
}
|
||||
} else {
|
||||
handlers[type + "_open"] = (state, tok) => state.openNode(nodeType, attrs(spec.attrs, tok))
|
||||
handlers[type + "_close"] = state => state.closeNode()
|
||||
}
|
||||
} else if (spec.node) {
|
||||
let nodeType = schema.nodeType(spec.node)
|
||||
handlers[type] = (state, tok) => state.addNode(nodeType, attrs(spec.attrs, tok))
|
||||
} else if (spec.mark) {
|
||||
let markType = schema.marks[spec.mark]
|
||||
if (noOpenClose(type)) {
|
||||
handlers[type] = (state, tok) => {
|
||||
state.openMark(markType.create(attrs(spec.attrs, tok)))
|
||||
state.addText(withoutTrailingNewline(tok.content))
|
||||
state.closeMark(markType)
|
||||
}
|
||||
} else {
|
||||
handlers[type + "_open"] = (state, tok) => state.openMark(markType.create(attrs(spec.attrs, tok)))
|
||||
handlers[type + "_close"] = state => state.closeMark(markType)
|
||||
}
|
||||
/***
|
||||
* Block nodes
|
||||
***/
|
||||
if (node.type === 'heading') {
|
||||
const content = node.children.map((childNode) => (
|
||||
processMdastNode(childNode)
|
||||
))
|
||||
console.log(content)
|
||||
return schema.node('heading', { level: node.depth }, content)
|
||||
} else if (node.type === 'paragraph') {
|
||||
const content = node.children.map((childNode) => (
|
||||
processMdastNode(childNode)
|
||||
))
|
||||
return schema.node('paragraph', {}, content)
|
||||
} else if (node.type === 'list') {
|
||||
const content = node.children.map((childNode) => (
|
||||
processMdastNode(childNode)
|
||||
))
|
||||
if (node.ordered) {
|
||||
return schema.node('ordered_list', { tight: true, order: 1 }, content)
|
||||
} else {
|
||||
throw new RangeError("Unrecognized parsing spec " + JSON.stringify(spec))
|
||||
return schema.node('bullet_list', {}, content)
|
||||
}
|
||||
} else if (node.type === 'listItem') {
|
||||
const content = node.children.map((childNode) => (
|
||||
processMdastNode(childNode)
|
||||
))
|
||||
return schema.node('list_item', {}, content)
|
||||
} else if (node.type === 'thematicBreak') {
|
||||
return schema.node('horizontal_rule')
|
||||
} else if (node.type === 'break') {
|
||||
return schema.node('hard_break')
|
||||
} else if (node.type === 'image') {
|
||||
return schema.node('image', { src: node.url, alt: node.alt })
|
||||
}
|
||||
/***
|
||||
* end block items
|
||||
***/
|
||||
|
||||
// Inline
|
||||
if (node.type === 'text') {
|
||||
console.log('text value', node.value)
|
||||
return schema.text(node.value)
|
||||
}
|
||||
|
||||
handlers.text = (state, tok) => state.addText(tok.content)
|
||||
handlers.inline = (state, tok) => state.parseTokens(tok.children)
|
||||
handlers.softbreak = state => state.addText("\n")
|
||||
|
||||
return handlers
|
||||
return doc
|
||||
}
|
||||
|
||||
// ;; A configuration of a Markdown parser. Such a parser uses
|
||||
// [markdown-it](https://github.com/markdown-it/markdown-it) to
|
||||
// tokenize a file, and then runs the custom rules it is given over
|
||||
// the tokens to create a ProseMirror document tree.
|
||||
class MarkdownParser {
|
||||
// :: (Schema, MarkdownIt, Object)
|
||||
// Create a parser with the given configuration. You can configure
|
||||
// the markdown-it parser to parse the dialect you want, and provide
|
||||
// a description of the ProseMirror entities those tokens map to in
|
||||
// the `tokens` object, which maps token names to descriptions of
|
||||
// what to do with them. Such a description is an object, and may
|
||||
// have the following properties:
|
||||
//
|
||||
// **`node`**`: ?string`
|
||||
// : This token maps to a single node, whose type can be looked up
|
||||
// in the schema under the given name. Exactly one of `node`,
|
||||
// `block`, or `mark` must be set.
|
||||
//
|
||||
// **`block`**`: ?string`
|
||||
// : This token comes in `_open` and `_close` variants (which are
|
||||
// appended to the base token name provides a the object
|
||||
// property), and wraps a block of content. The block should be
|
||||
// wrapped in a node of the type named to by the property's
|
||||
// value.
|
||||
//
|
||||
// **`mark`**`: ?string`
|
||||
// : This token also comes in `_open` and `_close` variants, but
|
||||
// should add a mark (named by the value) to its content, rather
|
||||
// than wrapping it in a node.
|
||||
//
|
||||
// **`attrs`**`: ?union<Object, (MarkdownToken) → Object>`
|
||||
// : If the mark or node to be created needs attributes, they can
|
||||
// be either given directly, or as a function that takes a
|
||||
// [markdown-it
|
||||
// token](https://markdown-it.github.io/markdown-it/#Token) and
|
||||
// returns an attribute object.
|
||||
constructor(schema, plugins, tokenizer, tokens) {
|
||||
// :: Object The value of the `tokens` object used to construct
|
||||
// this parser. Can be useful to copy and modify to base other
|
||||
// parsers on.
|
||||
this.tokens = tokens
|
||||
this.schema = schema
|
||||
this.tokenizer = tokenizer
|
||||
this.plugins = plugins
|
||||
this.tokenHandlers = tokenHandlers(schema, tokens)
|
||||
}
|
||||
|
||||
// :: (string) → Node
|
||||
// Parse a string as [CommonMark](http://commonmark.org/) markup,
|
||||
// and create a ProseMirror document as prescribed by this parser's
|
||||
// rules.
|
||||
parse(text) {
|
||||
let state = new MarkdownParseState(this.schema, this.plugins, this.tokenHandlers), doc
|
||||
state.parseTokens(this.tokenizer.parse(text, {}))
|
||||
do { doc = state.closeNode() } while (state.stack.length)
|
||||
return doc
|
||||
}
|
||||
const compileMarkdownToProseMirror = (src) => {
|
||||
console.log(src)
|
||||
const mdast = remark.parse(src)
|
||||
console.log(mdast)
|
||||
const doc = processMdastNode(mdast)
|
||||
console.log(doc.content)
|
||||
return doc
|
||||
}
|
||||
|
||||
// :: MarkdownParser
|
||||
// A parser parsing unextended [CommonMark](http://commonmark.org/),
|
||||
// without inline HTML, and producing a document in the basic schema.
|
||||
export default function createMarkdownParser(schema, plugins) {
|
||||
const tokens = {
|
||||
blockquote: {block: "blockquote"},
|
||||
paragraph: {block: "paragraph"},
|
||||
list_item: {block: "list_item"},
|
||||
// Note - we force lists to be tight here, while that's not ProseMirror's default
|
||||
// The default behavior means list elements always have a `p` inside, and we want
|
||||
// to avoid tha.
|
||||
bullet_list: {block: "bullet_list", attrs: tok => ({tight: true})},
|
||||
ordered_list: {block: "ordered_list", attrs: tok => ({tight: true, order: +tok.attrGet("order") || 1})},
|
||||
heading: {block: "heading", attrs: tok => ({level: +tok.tag.slice(1)})},
|
||||
code_block: {block: "code_block"},
|
||||
fence: {block: "code_block"},
|
||||
hr: {node: "horizontal_rule"},
|
||||
image: {node: "image", attrs: tok => ({
|
||||
src: tok.attrGet("src"),
|
||||
title: tok.attrGet("title") || null,
|
||||
alt: tok.children[0] && tok.children[0].content || null
|
||||
})},
|
||||
hardbreak: {node: "hard_break"},
|
||||
|
||||
em: {mark: "em"},
|
||||
strong: {mark: "strong"},
|
||||
link: {mark: "link", attrs: tok => ({
|
||||
href: tok.attrGet("href"),
|
||||
title: tok.attrGet("title") || null
|
||||
})},
|
||||
code_inline: {mark: "code"}
|
||||
};
|
||||
|
||||
return new MarkdownParser(schema, plugins, markdownit("commonmark", {html: false}), tokens);
|
||||
module.exports = (s, plugins) => {
|
||||
//console.log(s)
|
||||
//console.log(s.nodes.code_block.create({ params: { language: 'javascript' } }))
|
||||
schema = s
|
||||
return compileMarkdownToProseMirror
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user