@firecms/core
Version:
Awesome Firebase/Firestore-based headless open-source CMS
261 lines (240 loc) • 10 kB
text/typescript
import {
MarkdownParser,
MarkdownSerializer,
defaultMarkdownParser,
defaultMarkdownSerializer
} from "prosemirror-markdown";
import markdownIt from "markdown-it";
// @ts-ignore
import markdownItTaskLists from "markdown-it-task-lists";
// @ts-ignore
import markdownItMark from "markdown-it-mark";
// @ts-ignore
import markdownItIns from "markdown-it-ins";
import { schema } from "./schema";
const parserTokens: any = {
...defaultMarkdownParser.tokens,
em: { mark: "italic" },
strong: { mark: "bold" },
html_inline: { ignore: true, noCloseToken: true },
html_block: { ignore: true, noCloseToken: true },
s: {
mark: "strike",
},
task_list: {
block: "task_list",
},
task_item: {
block: "task_item",
getAttrs: (tok: any) => ({ checked: tok.attrGet("checked") === "true" }),
},
mark: {
mark: "highlight"
},
ins: {
mark: "underline"
},
table: { block: "table" },
thead: { ignore: true },
tbody: { ignore: true },
tr: { block: "table_row" },
th: { block: "table_header" },
td: { block: "table_cell" }
};
const md = markdownIt({ html: false })
.use(markdownItTaskLists)
.use(markdownItMark)
.use(markdownItIns);
// Override the escape rule so that `\` before a newline is kept as literal
// text instead of being silently consumed as a hardbreak. The default
// markdown-it behaviour strips the backslash and produces a <br>, which
// causes users to lose visible `\` characters in their content.
md.inline.ruler.at("escape", function escapeOverride(state: any, silent: boolean): boolean {
let pos = state.pos;
const max = state.posMax;
if (state.src.charCodeAt(pos) !== 0x5C /* \ */) return false;
pos++;
if (pos >= max) return false;
const ch1 = state.src.charCodeAt(pos);
// KEY CHANGE: when `\` is followed by a newline, output the backslash as
// literal text and let the normal softbreak handling deal with the newline.
if (ch1 === 0x0A) {
if (!silent) {
state.pending += "\\";
}
state.pos = pos; // leave the newline for softbreak to handle
return true;
}
// For escaped ASCII punctuation, output the character without the backslash
// (standard markdown escape behaviour: `\*` → `*`).
let escapedStr = state.src[pos];
// Handle surrogate pairs
if (ch1 >= 0xD800 && ch1 <= 0xDBFF && pos + 1 < max) {
const ch2 = state.src.charCodeAt(pos + 1);
if (ch2 >= 0xDC00 && ch2 <= 0xDFFF) {
escapedStr += state.src[pos + 1];
pos++;
}
}
const origStr = "\\" + escapedStr;
if (!silent) {
// Check if the character is an ASCII punctuation that
// markdown-it considers escapable (codes < 256 in its lookup table).
const isEscapable = ch1 < 256 && /[\\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-]/.test(String.fromCharCode(ch1));
const token = state.push("text_special", "", 0);
if (isEscapable) {
token.content = escapedStr;
} else {
token.content = origStr;
}
token.markup = origStr;
token.info = "escape";
}
state.pos = pos + 1;
return true;
});
// Also disable the newline rule which redundantly converts `\` + newline
// to hardbreaks via a separate code path.
md.inline.ruler.disable(["newline"]);
// Unwrap images from paragraphs so they can be parsed as block nodes by ProseMirror
md.core.ruler.after("inline", "image-to-block", (state: any) => {
const tokens = state.tokens;
for (let i = tokens.length - 2; i >= 1; i--) {
if (
tokens[i - 1] && tokens[i - 1].type === "paragraph_open" &&
tokens[i] && tokens[i].type === "inline" &&
tokens[i + 1] && tokens[i + 1].type === "paragraph_close"
) {
const inlineTokens = tokens[i].children || [];
if (inlineTokens.length === 1 && inlineTokens[0].type === "image") {
state.tokens.splice(i - 1, 3, inlineTokens[0]);
// No need to adjust index when looping backward!
}
}
}
});
// Wrap inline tokens inside table cells into paragraphs to satisfy ProseMirror table cell schema (block+)
md.core.ruler.after("inline", "tables-wrap-paragraphs", (state: any) => {
const tokens = state.tokens;
for (let i = tokens.length - 1; i >= 0; i--) {
if (tokens[i].type === "td_open" || tokens[i].type === "th_open") {
let closeIndex = i + 1;
while (closeIndex < tokens.length && tokens[closeIndex].type !== "td_close" && tokens[closeIndex].type !== "th_close") {
closeIndex++;
}
if (closeIndex < tokens.length) {
const pOpen = new state.Token("paragraph_open", "p", 1);
pOpen.block = true;
const pClose = new state.Token("paragraph_close", "p", -1);
pClose.block = true;
state.tokens.splice(closeIndex, 0, pClose);
state.tokens.splice(i + 1, 0, pOpen);
}
}
}
});
export const markdownParser = new MarkdownParser(schema, md, parserTokens);
export const markdownSerializer = new MarkdownSerializer(
{
...defaultMarkdownSerializer.nodes,
// Add custom serialization for task lists
task_list(state, node) {
state.renderList(node, " ", () => "- ");
},
task_item(state, node) {
state.write(`[${node.attrs.checked ? "x" : " "}] `);
state.renderContent(node);
},
horizontal_rule(state, node) {
state.write(node.attrs.markup || "---");
state.closeBlock(node);
},
image(state, node) {
const rawSrc = node.attrs.src || "";
const src = rawSrc.replace(/ /g, "%20");
state.write("]/g, "\\$&") +
(node.attrs.title ? ' "' + node.attrs.title.replace(/"/g, '\\"') + '"' : "") + ")");
state.closeBlock(node);
},
table(state, node) {
node.forEach((row, _, i) => {
row.forEach((cell, _, j) => {
state.write(j === 0 ? "| " : " ");
let cellContent = "";
const oldWrite = state.write.bind(state);
state.write = (s: string) => { cellContent += s; };
let first = true;
cell.forEach((block: any) => {
if (!first) cellContent += "<br>";
state.renderInline(block);
first = false;
});
state.write = oldWrite;
state.write(cellContent.replace(/\|/g, "\\|"));
state.write(" |");
});
state.write("\n");
if (i === 0) {
row.forEach((cell, _, j) => {
state.write(j === 0 ? "|---|" : "---|");
});
state.write("\n");
}
});
state.closeBlock(node);
},
table_row() {},
table_cell() {},
table_header() {},
// Custom text serializer: since our parser override keeps `\` as
// literal text (instead of consuming it), we must avoid the default
// esc() from double-escaping it. We escape all standard markdown
// specials *except* the backslash itself.
text(state: any, node: any) {
const escaped = node.text.replace(/[`*~\[\]_]/g, (m: string, i: number) => {
// Don't escape mid-word underscores (same logic as default esc)
if (m === "_" && i > 0 && i + 1 < node.text.length
&& /\w/.test(node.text[i - 1]) && /\w/.test(node.text[i + 1])) {
return m;
}
return "\\" + m;
});
// Handle start-of-line patterns that could be parsed as block syntax
const lines = escaped.split("\n");
for (let i = 0; i < lines.length; i++) {
state.write();
let line = lines[i];
if (state.atBlockStart || i > 0) {
line = line
.replace(/^(\+[ ]|[-*>])/, "\\$&")
.replace(/^(\s*)(#{1,6})(\s|$)/, '$1\\$2$3')
.replace(/^(\s*\d+)\.\s/, "$1\\. ");
}
state.out += line;
if (i !== lines.length - 1) state.out += "\n";
}
}
},
{
...defaultMarkdownSerializer.marks,
bold: defaultMarkdownSerializer.marks.strong,
italic: defaultMarkdownSerializer.marks.em,
strike: { open: "~~", close: "~~", mixable: true, expelEnclosingWhitespace: true },
highlight: { open: "==", close: "==", mixable: true, expelEnclosingWhitespace: true },
underline: { open: "++", close: "++", mixable: true, expelEnclosingWhitespace: true },
link: {
...defaultMarkdownSerializer.marks.link,
close(state: any, mark, parent, index) {
const inAutolink = state.inAutolink;
state.inAutolink = undefined;
const href = mark.attrs.href.replace(/ /g, "%20");
return inAutolink ? ">"
: "](" + href.replace(/[\(\)"]/g, "\\$&") + (mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : "") + ")";
}
},
// textStyle (colored text from HTML) has no markdown equivalent — emit content as-is
textStyle: { open: "", close: "", mixable: true, expelEnclosingWhitespace: true },
}
);
export const parser = markdownParser;
export const serializer = markdownSerializer;