diff --git a/prose/markdownParser.js b/prose/markdownParser.js index 8b2b0c1..a5e2b3c 100644 --- a/prose/markdownParser.js +++ b/prose/markdownParser.js @@ -1,72 +1,79 @@ import { MarkdownParser } from "prosemirror-markdown"; import markdownit from "markdown-it"; import { writeFreelySchema } from "./schema"; const md = markdownit("commonmark", { html: true }); -// Re-type html_block tokens so they can be handled distinctly -// from other HTML blocks. -md.core.ruler.push("readmore", (state) => { +// Map HTML comment shortcodes to their own token types so they are handled +// as special blocks rather than generic HTML. +const SHORTCODE_TOKENS = { + "": "readmore_block", + "": "emailsub_block", +}; + +md.core.ruler.push("shortcodes", (state) => { for (let i = 0; i < state.tokens.length; i++) { const token = state.tokens[i]; - if (token.type === "html_block" && token.content.trim() === "") { - token.type = "readmore_block"; + if (token.type === "html_block") { + const mapped = SHORTCODE_TOKENS[token.content.trim()]; + if (mapped) token.type = mapped; } } }); export const writeFreelyMarkdownParser = new MarkdownParser( writeFreelySchema, md, { blockquote: { block: "blockquote" }, paragraph: { block: "paragraph" }, list_item: { block: "list_item" }, bullet_list: { block: "bullet_list" }, ordered_list: { block: "ordered_list", getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }), }, heading: { block: "heading", getAttrs: (tok) => ({ level: +tok.tag.slice(1) }), }, code_block: { block: "code_block", noCloseToken: true }, fence: { block: "code_block", getAttrs: (tok) => ({ params: tok.info || "" }), noCloseToken: true, }, hr: { node: "horizontal_rule" }, image: { node: "image", getAttrs: (tok) => ({ src: tok.attrGet("src"), title: tok.attrGet("title") || null, alt: (tok.children !== null && typeof tok.children[0] !== 'undefined' ? tok.children[0].content : null), }), }, hardbreak: { node: "hard_break" }, em: { mark: "em" }, strong: { mark: "strong" }, link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href"), title: tok.attrGet("title") || null, }), }, code_inline: { mark: "code", noCloseToken: true }, readmore_block: { node: "readmore" }, + emailsub_block: { node: "emailsub" }, html_block: { node: "html_block", getAttrs: (tok) => ({ content: tok.content }), }, html_inline: { node: "html_inline", getAttrs: (tok) => ({ content: tok.content }), }, } ); diff --git a/prose/markdownSerializer.js b/prose/markdownSerializer.js index 61f54ee..3e0175b 100644 --- a/prose/markdownSerializer.js +++ b/prose/markdownSerializer.js @@ -1,135 +1,139 @@ import { MarkdownSerializer } from "prosemirror-markdown"; function backticksFor(node, side) { const ticks = /`+/g; let m; let len = 0; if (node.isText) while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length); let result = len > 0 && side > 0 ? " `" : "`"; for (let i = 0; i < len; i++) result += "`"; if (len > 0 && side < 0) result += " "; return result; } function isPlainURL(link, parent, index, side) { if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false; const content = parent.child(index + (side < 0 ? -1 : 0)); if ( !content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link ) return false; if (index == (side < 0 ? 1 : parent.childCount - 1)) return true; const next = parent.child(index + (side < 0 ? -2 : 1)); return !link.isInSet(next.marks); } export const writeFreelyMarkdownSerializer = new MarkdownSerializer( { readmore(state, node) { state.write("\n"); state.closeBlock(node); }, + emailsub(state, node) { + state.write("\n"); + state.closeBlock(node); + }, html_block(state, node) { state.write(node.attrs.content); state.closeBlock(node); }, html_inline(state, node) { state.write(node.attrs.content); }, blockquote(state, node) { state.wrapBlock("> ", null, node, () => state.renderContent(node)); }, code_block(state, node) { state.write(`\`\`\`${node.attrs.params || ""}\n`); state.text(node.textContent, false); state.ensureNewLine(); state.write("```"); state.closeBlock(node); }, heading(state, node) { state.write(`${state.repeat("#", node.attrs.level)} `); state.renderInline(node); state.closeBlock(node); }, horizontal_rule: function horizontal_rule(state, node) { state.write(node.attrs.markup || "---"); state.closeBlock(node); }, bullet_list(state, node) { node.attrs.tight = true; state.renderList(node, " ", () => `${node.attrs.bullet || "*"} `); }, ordered_list(state, node) { const start = node.attrs.order || 1; const maxW = String(start + node.childCount - 1).length; const space = state.repeat(" ", maxW + 2); state.renderList(node, space, (i) => { const nStr = String(start + i); return `${state.repeat(" ", maxW - nStr.length) + nStr}. `; }); }, list_item(state, node) { state.renderContent(node); }, paragraph(state, node) { state.renderInline(node); state.closeBlock(node); }, image(state, node) { state.write( `![${state.esc(node.attrs.alt || "")}](${state.esc(node.attrs.src)}${ node.attrs.title ? ` ${state.quote(node.attrs.title)}` : "" })` ); }, hard_break(state, node, parent, index) { for (let i = index + 1; i < parent.childCount; i += 1) if (parent.child(i).type !== node.type) { state.write("\\\n"); return; } }, text(state, node) { state.text(node.text || ""); }, }, { em: { open: "_", close: "_", mixable: true, expelEnclosingWhitespace: true, }, strong: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true, }, link: { open(_state, mark, parent, index) { return isPlainURL(mark, parent, index, 1) ? "<" : "["; }, close(state, mark, parent, index) { return isPlainURL(mark, parent, index, -1) ? ">" : `](${state.esc(mark.attrs.href)}${ mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : "" })`; }, }, code: { open(_state, _mark, parent, index) { return backticksFor(parent.child(index), -1); }, close(_state, _mark, parent, index) { return backticksFor(parent.child(index - 1), 1); }, escape: false, }, } ); diff --git a/prose/schema.js b/prose/schema.js index bb32112..1d66123 100644 --- a/prose/schema.js +++ b/prose/schema.js @@ -1,40 +1,55 @@ import { schema } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; export const writeFreelySchema = new Schema({ nodes: schema.spec.nodes .addToEnd("readmore", { inline: false, content: "", group: "block", draggable: true, toDOM: (node) => [ "div", { class: "editorreadmore" }, "Read more...", ], parseDOM: [{ tag: "div.editorreadmore" }], }) + .addToEnd("emailsub", { + inline: false, + content: "", + group: "block", + draggable: true, + toDOM: () => [ + "div", { id: "emailsub", contenteditable: "false" }, + ["form", {}, + ["p", {}, "Enter your email to subscribe to updates."], + ["input", { type: "email", name: "email", placeholder: "me@example.com" }], + ["input", { type: "submit", id: "subscribe-btn", value: "Subscribe" }], + ], + ], + parseDOM: [{ tag: "div#emailsub" }], + }) .addToEnd("html_block", { attrs: { content: { default: "" } }, content: "", group: "block", marks: "", draggable: true, toDOM: (node) => [ "div", { class: "editor-html-block", contenteditable: "false" }, node.attrs.content, ], parseDOM: [{ tag: "div.editor-html-block", getAttrs: (dom) => ({ content: dom.textContent }) }], }) .addToEnd("html_inline", { attrs: { content: { default: "" } }, inline: true, content: "", group: "inline", toDOM: (node) => ["code", { class: "editor-html-inline" }, node.attrs.content], parseDOM: [{ tag: "code.editor-html-inline", getAttrs: (dom) => ({ content: dom.textContent }) }], }), marks: schema.spec.marks, });