import MarkdownIt from "markdown-it";
import {
defaultMarkdownParser,
defaultMarkdownSerializer,
MarkdownParser,
MarkdownSerializer,
} from "prosemirror-markdown";
import type { Node as PMNode } from "prosemirror-model";
import type Token from "markdown-it/lib/token.mjs";
import { customEmojiSrc } from "./emoji";
import { schema } from "../components/rich-text/schema";
import {
DOCUMENT_LITERAL_DOT,
type DocumentCandidate,
isPlan,
isPlanBoundary,
isPlanHref,
PLAN_LITERAL_DOT,
protectDocumentReferences,
resolveDocument,
restoreDocumentReferences,
} from "../components/rich-text/references";
import type { Mention } from "../components/rich-text/rich-text";
/**
* Markdown serializer for the rich-text schema. Extends the default ProseMirror
* markdown serializer with our custom mention/emoji nodes and strike/underline
* marks. The wire format is designed to roundtrip through `blocks/parse.ts`:
*
* - `mention` → `@name`
* - `document` → `&name.md`
* - `plan` → `&plan.md`
* - `emoji` → `:name:`
* - `strike` → `~~text~~`
* - `underline` → `text` (HTML escape hatch — markdown has no native underline)
*/
const serializer = new MarkdownSerializer(
{
...defaultMarkdownSerializer.nodes,
mention(state, node, parent, index) {
state.write(`@${node.attrs.name}`);
// Guard against the mention gluing onto following text: "@ace" + "can"
// serializes to "@acecan", which re-parses as a single mention named
// "acecan" and silently drops the Ace invocation. Insert a separating
// space when the next inline node would extend the mention token.
let next = index + 1 < parent.childCount ? parent.child(index + 1) : null;
if (next?.isText && /^[\w-]/.test(next.text ?? "")) state.write(" ");
},
document(state, node, parent, index) {
state.write(`&${node.attrs.name}`);
let next = index + 1 < parent.childCount ? parent.child(index + 1) : null;
if (next?.isText && /^[\w-]/.test(next.text ?? "")) state.write(" ");
},
plan(state, _node, parent, index) {
state.write("&plan.md");
let next = index + 1 < parent.childCount ? parent.child(index + 1) : null;
if (next?.isText && /^[\w-]/.test(next.text ?? "")) state.write(" ");
},
emoji(state, node) {
state.write(`:${node.attrs.name}:`);
},
},
{
...defaultMarkdownSerializer.marks,
strike: { open: "~~", close: "~~", mixable: true, expelEnclosingWhitespace: true },
underline: { open: "", close: "", mixable: true, expelEnclosingWhitespace: true },
},
{ escapeExtraCharacters: /&(?=[a-z0-9]+(?:-[a-z0-9]+)*\.md\b)/gi },
);
let tokenizer = new MarkdownIt({ html: true, linkify: true, breaks: true });
let parser = new MarkdownParser(schema, {
...tokenizer,
parse(text, env) {
let tokens = tokenizer.parse(text, env);
normalize(tokens);
return tokens;
},
} as MarkdownIt, {
...defaultMarkdownParser.tokens,
html_block: { block: "paragraph", noCloseToken: true },
softbreak: { node: "hard_break" },
s: { mark: "strike" },
u: { mark: "underline" },
});
const DOCUMENT_NAME = "[a-z0-9]+(?:-[a-z0-9]+)*\\.md";
const CODE_OR_REFERENCE = new RegExp(
`\`\`\`[\\s\\S]*?(?:\`\`\`|$)|(\`+)[\\s\\S]*?\\1|(^|[\\s([{])((?:\\\\)*)&(${DOCUMENT_NAME})|(^|[\\s([{])(plan\\.md)`,
"gi",
);
/** Serialize a ProseMirror doc to markdown, trimmed. */
function serialize(doc: PMNode): string {
return serializer.serialize(doc).trim();
}
function normalize(tokens: Token[]) {
for (let token of tokens) {
if (token.children) normalize(token.children);
if (token.type !== "html_inline") continue;
let html = token.content.toLowerCase().trim();
if (html === "") token.type = "u_open";
else if (html === "") token.type = "u_close";
else token.type = "text";
}
}
function protectLinks(text: string, plan: boolean): string {
return text.replace(
CODE_OR_REFERENCE,
(
match,
_ticks: string,
prefix: string,
slashes: string,
name: string,
planPrefix: string,
planName: string,
offset: number,
input: string,
) => {
if (name) {
return prefix + slashes + "&"
+ name.replace(/\.md$/i, `${DOCUMENT_LITERAL_DOT}md`);
}
if (!plan || !planName) return match;
let dot = isPlanBoundary(input.slice(offset + match.length))
? DOCUMENT_LITERAL_DOT
: PLAN_LITERAL_DOT;
return planPrefix + planName.slice(0, -3) + dot + planName.slice(-2);
},
);
}
function restore(text: string): string {
return restoreDocumentReferences(text)
.replaceAll(DOCUMENT_LITERAL_DOT, ".")
.replaceAll(PLAN_LITERAL_DOT, ".");
}
function inline(
text: string,
marks: PMNode["marks"],
mentions?: Map,
documents?: readonly DocumentCandidate[],
plan = false,
): PMNode[] {
let link = schema.marks.link.isInSet(marks);
if (link) {
if (plan && isPlan(restore(text)) && isPlanHref(link.attrs.href)) {
return [schema.nodes.plan.create()];
}
return [schema.text(restore(text), marks)];
}
let out: PMNode[] = [];
let re =
/(^|[\s([{\uFFFC])(\\*)&([a-z0-9]+(?:-[a-z0-9]+)*(?:\.|\uE001)md)|(^|[\s([{\uFFFC])(plan(?:\.|\uE001)md)|(^|[\s([{\uFFFC])@([A-Za-z0-9-]+)|:([\w+-]+):/gi;
let last = 0;
let match;
function push(start: number, end: number) {
if (end > start) {
out.push(schema.text(restore(text.slice(start, end)), marks));
}
}
while ((match = re.exec(text)) !== null) {
if (match[3]) {
let name = restore(match[3]);
let start = match.index + match[1]!.length + match[2]!.length;
let end = start + match[3].length + 1;
if (plan && isPlan(name) && isPlanBoundary(text.slice(end))) {
push(last, start);
out.push(schema.nodes.plan.create());
last = end;
continue;
}
let document = resolveDocument(documents, name);
if (!document) continue;
push(last, start);
out.push(schema.node("document", { uid: document.uid, name: document.name }));
last = end;
continue;
}
if (match[5]) {
if (!plan) continue;
let start = match.index + match[4]!.length;
let end = start + match[5].length;
if (!isPlanBoundary(text.slice(end))) continue;
push(last, start);
out.push(schema.nodes.plan.create());
last = end;
continue;
}
if (match[7]) {
let name = match[7];
let entry = mentions?.get(name);
if (!entry) continue;
let start = match.index + match[6]!.length;
let end = start + name.length + 1;
push(last, start);
out.push(schema.node("mention", {
id: entry.id ?? null,
name: entry.name,
avatar: entry.avatar ?? null,
}));
last = end;
continue;
}
let name = match[8]!;
let src = customEmojiSrc(name);
if (!src) continue;
push(last, match.index);
out.push(schema.node("emoji", { id: name, name, src }));
last = match.index + match[0]!.length;
}
push(last, text.length);
return out;
}
function expand(
node: PMNode,
mentions?: Map,
documents?: readonly DocumentCandidate[],
plan = false,
): PMNode[] {
if (node.type === schema.nodes.code_block) return [node];
if (node.isText) {
if (!node.text || schema.marks.code.isInSet(node.marks)) return [node];
return inline(node.text, node.marks, mentions, documents, plan);
}
if (node.isLeaf) return [node];
let children: PMNode[] = [];
node.forEach(child => {
children.push(...expand(child, mentions, documents, plan));
});
return [node.type.create(node.attrs, children, node.marks)];
}
function enrich(
doc: PMNode,
mentions?: Mention[],
documents?: readonly DocumentCandidate[],
plan = false,
): PMNode {
let map = mentions?.length ? new Map(mentions.map(item => [item.name, item])) : undefined;
return expand(doc, map, documents, plan)[0]!;
}
/** Parse stored markdown into the rich-text editor schema. */
function parse(
raw = "",
mentions?: Mention[],
documents?: readonly DocumentCandidate[],
plan = false,
): PMNode {
let text = protectLinks(protectDocumentReferences(raw), plan);
return enrich(parser.parse(text), mentions, documents, plan);
}
export { parse, serialize, serializer };