/**
* Notion-Flavored Markdown (NFM) ⇄ ProseMirror JSON.
*
* This is the single, deterministic bridge between Notion's canonical
* Notion-flavored Markdown (the exact bytes Notion's `/pages/{id}/markdown`
* API emits and accepts, Notion-Version 2026-03-11) and the TipTap/ProseMirror
* document used by the editor.
*
* Design goals (the whole reason this exists):
* 1. Idempotency / no drift. `docToNfm(nfmToDoc(x)) === x` for every piece of
* canonical NFM `x`. A document authored in Notion, pulled, opened in the
* editor, and saved back with no edits produces byte-identical NFM — so
* pulling and pushing never mutates content.
* 2. Lossless fidelity. Every Notion block/inline type round-trips, including
* quotes, toggle headings, block colors, tables (with header rows/columns
* and cell colors), equations, callouts, columns, synced blocks, mentions,
* and inline color/underline/background — matching the ground-truth spec at
* the `notion://docs/enhanced-markdown-spec` MCP resource.
* 3. Shared. Pure functions with no editor/React/DOM dependency, usable by the
* server (pull canonicalization + content hashing) and the editor
* (`setContent(nfmToDoc(x))` / `docToNfm(editor.getJSON())`) alike.
*
* Canonical NFM form (what `docToNfm` emits):
* - One block per line; children indented one extra TAB. No blank separator
* lines (Notion strips them). Intentional blank blocks are ``.
* - Block attributes as a trailing `{toggle="true" color="red"}` list.
* - Tables, toggles, callouts, columns, synced blocks, media, mentions use the
* HTML-ish tags from the spec. Tables are `
` HTML, never pipe tables.
* - Inline text backslash-escapes the spec's special characters outside code.
*
* Registry blocks (the dev-doc / OpenAPI library shared with plan) are encoded
* INLINE as PascalCase MDX elements (``, ``). On READ
* a registered tag becomes a `registryBlock` atom carrying the verbatim element
* source in `__raw`; on WRITE it emits that `__raw` back (or re-serializes from
* the editor's typed data). This module stays React-free — it only consults the
* content registry's tag set (`@agent-native/core/blocks/server` config) to tell
* a registered PascalCase block tag from a lowercase Notion container tag.
*
* Local-file content can also use repo-local MDX components. Unknown PascalCase
* tags become `localMdxComponent` atoms that preserve their exact source while
* exposing simple string props for the editor preview layer.
*/
import { matchInlineMathAt } from "./inline-math.js";
import { isRegistryBlockTag, registryBlockSpecByTag } from "./nfm-registry.js";
// ── Shared PM JSON types ────────────────────────────────────────────
export interface PMMark {
type: string;
attrs?: Record;
}
export interface PMNode {
type: string;
attrs?: Record;
content?: PMNode[];
marks?: PMMark[];
text?: string;
}
export interface PMDoc {
type: "doc";
content: PMNode[];
}
// ── Colors (from the NFM spec) ──────────────────────────────────────
const BASE_COLORS = [
"gray",
"brown",
"orange",
"yellow",
"green",
"blue",
"purple",
"pink",
"red",
];
export const NFM_COLORS = new Set([
"default",
...BASE_COLORS,
...BASE_COLORS.map((c) => `${c}_bg`),
]);
function isColor(value: string | null | undefined): value is string {
return !!value && NFM_COLORS.has(value) && value !== "default";
}
// ── Inline escaping ─────────────────────────────────────────────────
// The spec escapes these characters OUTSIDE code: \ * ~ ` $ [ ] < > { } | ^
const ESCAPABLE = new Set("\\*~`$[]<>{}|^".split(""));
export function escapeInlineText(text: string): string {
let out = "";
for (const ch of text) {
if (ESCAPABLE.has(ch)) out += "\\" + ch;
else out += ch;
}
return out;
}
function unescapeInlineText(text: string): string {
let out = "";
for (let i = 0; i < text.length; i++) {
if (text[i] === "\\" && i + 1 < text.length && ESCAPABLE.has(text[i + 1])) {
out += text[i + 1];
i++;
} else {
out += text[i];
}
}
return out;
}
// Patterns that, at the START of a serialized paragraph line, are
// indistinguishable from real block structure (list items, headings,
// task items) — none of whose marker characters ('#', '-', digits, '.', ')')
// are in the spec's ESCAPABLE set. A paragraph whose text happens to start
// with one of these needs a single leading backslash so it round-trips as a
// paragraph instead of being reparsed as that block type. These are matched
// against the raw (untrimmed) text because the corresponding block parsers
// (heading/list/task) also match against the untrimmed dedented line.
const LEADING_BLOCK_MARKER = /^(#{1,6} |[-*+] |\d+[.)] |\[[ xX]\] )/;
// Divider lookalikes ("---", "***", "___", 3+ repeats) — matched separately
// because the divider parser trims the line before testing
// (`/^(---+|\*\*\*+|___+)$/.test(dedent.trim())`), so a paragraph like
// "--- " or " ---" must be escape-checked against the same trimmed form or
// it silently reparses as a horizontalRule and loses its text.
const DIVIDER_LOOKALIKE = /^(-{3,}|\*{3,}|_{3,})$/;
// Escape a leading block-marker pattern in a serialized paragraph's inline
// text by inserting one backslash before the first character. Only ever
// applied at the very start of the line, so it can't perturb Notion-parity
// bytes anywhere else in the text.
function escapeLeadingBlockMarker(text: string): string {
if (LEADING_BLOCK_MARKER.test(text) || DIVIDER_LOOKALIKE.test(text.trim())) {
return "\\" + text;
}
return text;
}
// Inverse of escapeLeadingBlockMarker: drop one leading backslash that was
// inserted purely to keep a literal marker-like paragraph from being
// reparsed as structure.
function unescapeLeadingBlockMarker(text: string): string {
if (
text[0] === "\\" &&
(LEADING_BLOCK_MARKER.test(text.slice(1)) ||
DIVIDER_LOOKALIKE.test(text.slice(1).trim()))
) {
return text.slice(1);
}
return text;
}
// ── Attribute helpers (for the HTML-ish tags) ───────────────────────
function escapeAttr(value: string): string {
return String(value)
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(//g, ">");
}
function unescapeAttr(value: string): string {
return value
.replace(/"/g, '"')
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&");
}
function serializeAttrs(
attrs: Array<[string, string | number | boolean | null | undefined]>,
): string {
const parts = attrs
.filter(([, v]) => v !== undefined && v !== null && v !== "" && v !== false)
.map(([k, v]) =>
v === true ? `${k}="true"` : `${k}="${escapeAttr(String(v))}"`,
);
return parts.length ? " " + parts.join(" ") : "";
}
function parseAttrs(raw: string): Record {
const attrs: Record = {};
const re = /([a-zA-Z_:][\w:-]*)\s*=\s*"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw))) attrs[m[1]] = unescapeAttr(m[2]);
return attrs;
}
function hasUnsupportedJsxProps(raw: string): boolean {
const withoutDoubleQuotedStrings = raw.replace(/"[^"]*"/g, '""');
return (
/\s[a-zA-Z_:][\w:-]*\s*=\s*(?:\{|`|')/.test(withoutDoubleQuotedStrings) ||
/\s[a-zA-Z_:][\w:-]*(?=\s*\/?>)/.test(withoutDoubleQuotedStrings)
);
}
// Trailing `{toggle="true" color="red"}` attribute list on a block line.
function blockAttrSuffix(opts: {
toggle?: boolean;
color?: string | null;
}): string {
const parts: string[] = [];
if (opts.toggle) parts.push('toggle="true"');
if (isColor(opts.color)) parts.push(`color="${opts.color}"`);
return parts.length ? ` {${parts.join(" ")}}` : "";
}
// True when `s` ends in an odd number of backslashes, meaning the character
// immediately after it (a `{` or `}` at the call sites below) is escaped.
function oddTrailingBackslashes(s: string): boolean {
return (s.match(/\\+$/)?.[0].length ?? 0) % 2 === 1;
}
// A toggle-heading line is `${summary}${blockAttrSuffix(...)}` — `summary`
// and the real trailing `{toggle="true" ...}` attrs end up concatenated on
// one line, then re-split by splitBlockAttrs. `summary` is stored as raw NFM
// (see serializeToggle), which is fine for Notion-emitted summaries (Notion's
// own inline spec already backslash-escapes a literal trailing backslash, so
// valid raw-NFM summary text never ends in an odd backslash run). But the
// editor's plain summary writes untouched plain text into the same
// attr, and a plain-text summary ending in an odd number of backslashes
// (e.g. "C:\path\") makes splitBlockAttrs's oddTrailingBackslashes guard
// think the real trailing `{toggle="true"}` attrs are themselves escaped
// literal text, degrading the toggle into a heading containing the literal
// attrs string. Doubling (not just parity-flipping) the trailing backslash
// run is safe to apply unconditionally — it's a no-op with no trailing
// backslash, and unlike "add one backslash when odd", doubling is exactly
// reversible for every run length (k -> 2k -> k) with no ambiguity between
// an original even run and an escaped former-odd run.
function escapeTrailingBackslashRun(s: string): string {
const run = s.match(/\\+$/)?.[0];
if (!run) return s;
return s.slice(0, -run.length) + run + run;
}
// Inverse of escapeTrailingBackslashRun: halve a trailing backslash run.
// Safe to apply unconditionally to text extracted from a toggle-heading line
// (see escapeTrailingBackslashRun) since the run length is always even there.
function unescapeTrailingBackslashRun(s: string): string {
const run = s.match(/\\+$/)?.[0];
if (!run) return s;
return s.slice(0, -run.length) + "\\".repeat(run.length / 2);
}
// Strip + read a trailing `{...}` attribute list from a block line.
function splitBlockAttrs(line: string): {
text: string;
toggle: boolean;
color: string | null;
} {
const m = line.match(/^(.*?)\s*\{([^{}]*)\}\s*$/);
if (!m) return { text: line, toggle: false, color: null };
const body = m[2];
// Escape-aware: a backslash-escaped `\{...\}` is literal text, not a block
// attribute list. `\s*` in the regex above cannot consume a backslash, so
// an escaped opening brace leaves its backslash at the end of m[1]; an
// escaped closing brace leaves its backslash at the end of the body.
if (oddTrailingBackslashes(m[1]) || oddTrailingBackslashes(body)) {
return { text: line, toggle: false, color: null };
}
const toggle = /\btoggle\s*=\s*"true"/.test(body);
const colorMatch = body.match(/\bcolor\s*=\s*"([^"]+)"/);
const color = colorMatch && isColor(colorMatch[1]) ? colorMatch[1] : null;
// Only treat as an attribute list if it actually contained known attrs;
// otherwise it was literal braces (which would have been escaped anyway).
if (!toggle && !color) return { text: line, toggle: false, color: null };
return { text: m[1], toggle, color };
}
// ════════════════════════════════════════════════════════════════════
// INLINE: serialize
// ════════════════════════════════════════════════════════════════════
function markOf(node: PMNode, type: string): PMMark | undefined {
return node.marks?.find((m) => m.type === type);
}
function serializeInlineAtom(node: PMNode): string {
const tagName = (node.attrs?.tagName as string) || "mention";
const label = (node.attrs?.label as string) || "";
let attrs: Record = {};
try {
attrs = JSON.parse((node.attrs?.attrsJson as string) || "{}");
} catch {
attrs = {};
}
if (tagName === "math") {
return "$" + (label || attrs.latex || "") + "$";
}
const attrEntries = Object.entries(attrs).filter(([k]) => k !== "latex");
const attrStr = serializeAttrs(attrEntries);
const selfClosing = !label.trim();
if (selfClosing) return `<${tagName}${attrStr}/>`;
return `<${tagName}${attrStr}>${escapeAttr(label)}${tagName}>`;
}
function serializeInline(nodes: PMNode[] | undefined): string {
if (!nodes || !nodes.length) return "";
return nodes.map(serializeInlineNode).join("");
}
function serializeInlineNode(node: PMNode): string {
if (node.type === "hardBreak") return " ";
if (node.type === "notionInlineAtom") return serializeInlineAtom(node);
if (node.type !== "text") {
// Unknown inline node — best-effort textContent.
return node.text ? escapeInlineText(node.text) : "";
}
const raw = node.text ?? "";
const code = markOf(node, "code");
let out: string;
if (code) {
const codeText = raw.replace(/\n/g, " ");
// CommonMark-style variable-length code span delimiter: use a backtick
// run one longer than the longest run inside the text, so a code span
// containing its own backtick(s) (e.g. "a`b") can't be split apart by a
// naive single-backtick delimiter. Pad with a single space on each side
// when the text starts OR ends with a backtick OR a space, so the
// delimiter run doesn't visually merge with the content's own backtick
// and a leading/trailing space in the content isn't mistaken for our own
// padding on the next parse — see the matching strip rule in parseInline.
// Content that is entirely spaces (including empty) is exempt: there is
// no backtick to visually separate from the delimiter, and padding it
// would be indistinguishable from un-padded all-space content on parse.
const isAllSpaces = /^ *$/.test(codeText);
const longestRun = Math.max(
0,
...(codeText.match(/`+/g) || []).map((r) => r.length),
);
const delim = "`".repeat(Math.max(1, longestRun + 1));
const needsPadding =
!isAllSpaces &&
(codeText.startsWith("`") ||
codeText.endsWith("`") ||
codeText.startsWith(" ") ||
codeText.endsWith(" "));
const body = needsPadding ? ` ${codeText} ` : codeText;
out = delim + body + delim;
} else {
out = escapeInlineText(raw);
}
const bold = markOf(node, "bold");
const italic = markOf(node, "italic");
if (bold && italic) {
out = "***" + out + "***";
} else {
if (markOf(node, "strike")) out = "~~" + out + "~~";
if (italic) out = "*" + out + "*";
if (bold) out = "**" + out + "**";
}
if (!(bold && italic) && markOf(node, "strike") && (bold || italic)) {
// strike already applied above; nothing to do
}
// strike for the bold+italic branch
if (bold && italic && markOf(node, "strike")) {
out = "~~" + out + "~~";
}
const span = markOf(node, "notionSpan");
// StarterKit registers a plain "underline" mark (Cmd+U) that nfm never
// otherwise serializes. Fold it into the notionSpan
// form nfmToDoc already parses, so Cmd+U formatting survives a save instead
// of silently vanishing.
const plainUnderline = !!markOf(node, "underline");
if (span || plainUnderline) {
const a = span?.attrs || {};
const underlined =
a.underline === "true" || a.underline === true || plainUnderline;
const attrStr = serializeAttrs([
["color", a.color || a.bgColor || null],
["underline", underlined ? "true" : null],
]);
if (attrStr) out = `${out}`;
}
const link = markOf(node, "link");
if (link?.attrs?.href) {
out = `[${out}](${serializeUrlForParens(link.attrs.href)})`;
}
return out;
}
// ════════════════════════════════════════════════════════════════════
// INLINE: parse
// ════════════════════════════════════════════════════════════════════
function textNode(text: string, marks: PMMark[]): PMNode {
return marks.length ? { type: "text", text, marks } : { type: "text", text };
}
function addMark(nodes: PMNode[], mark: PMMark): void {
for (const n of nodes) {
if (n.type === "text") {
n.marks = n.marks || [];
if (!n.marks.some((m) => m.type === mark.type)) n.marks.push(mark);
}
}
}
function mergeSpanMark(nodes: PMNode[], attrs: Record): void {
const color = attrs.color;
const isBg = color ? color.endsWith("_bg") : false;
const spanAttrs: Record = {
color: color && !isBg ? color : null,
bgColor: color && isBg ? color : null,
underline: attrs.underline === "true" ? "true" : null,
href: attrs.href || null,
attrsJson: "{}",
};
for (const n of nodes) {
if (n.type === "text") {
n.marks = n.marks || [];
if (!n.marks.some((m) => m.type === "notionSpan")) {
n.marks.push({ type: "notionSpan", attrs: spanAttrs });
}
}
}
}
// Find the index of the next unescaped occurrence of `token` starting at `from`.
function findToken(s: string, token: string, from: number): number {
for (let i = from; i <= s.length - token.length; i++) {
if (s[i] === "\\") {
i++;
continue;
}
if (s.startsWith(token, i)) return i;
}
return -1;
}
function parseInline(input: string): PMNode[] {
const out: PMNode[] = [];
let buf = "";
let i = 0;
const flush = () => {
if (buf) {
out.push(textNode(buf, []));
buf = "";
}
};
while (i < input.length) {
const ch = input[i];
// Escape
if (ch === "\\" && i + 1 < input.length && ESCAPABLE.has(input[i + 1])) {
buf += input[i + 1];
i += 2;
continue;
}
// Canonical inline math is $...$; GitHub's $`...`$ form remains a
// backwards-compatible input alias and canonicalizes on the next write.
const inlineMath = ch === "$" ? matchInlineMathAt(input, i) : null;
if (inlineMath) {
flush();
out.push({
type: "notionInlineAtom",
attrs: {
tagName: "math",
attrsJson: "{}",
label: inlineMath.latex,
},
});
i = inlineMath.to;
continue;
}
// Inline code `...` — CommonMark-style variable-length delimiter: the
// opening run of N backticks closes only at the next run of exactly N
// backticks (a longer or shorter run is just more code content), so a
// code span can itself contain shorter backtick runs (e.g. ``a`b``).
if (ch === "`") {
const openRun = /^`+/.exec(input.slice(i))?.[0].length ?? 0;
const delim = "`".repeat(openRun);
let searchFrom = i + openRun;
let close = -1;
while (searchFrom <= input.length - openRun) {
const idx = input.indexOf(delim, searchFrom);
if (idx === -1) break;
// Reject if this run is actually longer than `openRun` (part of a
// longer backtick sequence) — advance past the whole run.
const runLen = /^`+/.exec(input.slice(idx))?.[0].length ?? 0;
if (runLen === openRun) {
close = idx;
break;
}
searchFrom = idx + runLen;
}
if (close !== -1) {
flush();
let codeText = input
.slice(i + openRun, close)
.replace(/ /g, "\n");
// A single leading+trailing space is padding the serializer adds
// whenever the content itself starts/ends with a backtick OR a
// space (see serializeInlineNode) — strip exactly one on each side
// in that case. Content that is entirely spaces is never padding
// (there is no unpadded content to disambiguate from), so it is
// left untouched.
if (
codeText.startsWith(" ") &&
codeText.endsWith(" ") &&
codeText.trim().length > 0
) {
codeText = codeText.slice(1, -1);
}
out.push(textNode(codeText, [{ type: "code" }]));
i = close + openRun;
continue;
}
}
// Hard break
if (input.startsWith(" ", i) || input.startsWith(" ", i)) {
flush();
out.push({ type: "hardBreak" });
i += input.startsWith(" ", i) ? 5 : 4;
continue;
}
// ...
if (input.startsWith("", i);
const close = input.indexOf("", open);
if (open !== -1 && close !== -1) {
flush();
const attrs = parseAttrs(input.slice(i + 5, open));
const inner = parseInline(input.slice(open + 1, close));
mergeSpanMark(inner, attrs);
out.push(...inner);
i = close + "".length;
continue;
}
}
// Inline mention / atom tags: or
if (input.startsWith("", i);
const open = input.indexOf(">", i);
// self-closing form
if (selfClose !== -1 && (open === -1 || selfClose <= open)) {
flush();
const tagMatch = input.slice(i).match(/^<(mention-[\w-]+)([^>]*?)\/>/);
if (tagMatch) {
out.push(makeInlineAtom(tagMatch[1], tagMatch[2], ""));
i += tagMatch[0].length;
continue;
}
}
const tagMatch = input
.slice(i)
.match(/^<(mention-[\w-]+)([^>]*)>([\s\S]*?)<\/\1>/);
if (tagMatch) {
flush();
out.push(makeInlineAtom(tagMatch[1], tagMatch[2], tagMatch[3]));
i += tagMatch[0].length;
continue;
}
}
// Bold+italic ***...***
if (input.startsWith("***", i)) {
const close = findToken(input, "***", i + 3);
if (close !== -1) {
flush();
const inner = parseInline(input.slice(i + 3, close));
addMark(inner, { type: "bold" });
addMark(inner, { type: "italic" });
out.push(...inner);
i = close + 3;
continue;
}
}
// Bold **...**
if (input.startsWith("**", i)) {
const close = findToken(input, "**", i + 2);
if (close !== -1) {
flush();
const inner = parseInline(input.slice(i + 2, close));
addMark(inner, { type: "bold" });
out.push(...inner);
i = close + 2;
continue;
}
}
// Strike ~~...~~
if (input.startsWith("~~", i)) {
const close = findToken(input, "~~", i + 2);
if (close !== -1) {
flush();
const inner = parseInline(input.slice(i + 2, close));
addMark(inner, { type: "strike" });
out.push(...inner);
i = close + 2;
continue;
}
}
// Italic *...*
if (ch === "*") {
const close = findToken(input, "*", i + 1);
if (close !== -1) {
flush();
const inner = parseInline(input.slice(i + 1, close));
addMark(inner, { type: "italic" });
out.push(...inner);
i = close + 1;
continue;
}
}
// Link [text](url)
if (ch === "[") {
const link = matchLink(input, i);
if (link) {
flush();
const inner = parseInline(link.text);
addMark(inner, { type: "link", attrs: { href: link.href } });
out.push(...inner);
i = link.end;
continue;
}
}
buf += ch;
i++;
}
flush();
return out;
}
function makeInlineAtom(
tagName: string,
rawAttrs: string,
label: string,
): PMNode {
const attrs = parseAttrs(rawAttrs);
return {
type: "notionInlineAtom",
attrs: {
tagName,
attrsJson: JSON.stringify(attrs),
// serializeInlineAtom escapes the label with escapeAttr on write; mirror
// that here (like parseLeafTag does for block atoms) so a round trip
// doesn't accumulate an extra "amp;" layer every cycle.
label: unescapeAttr(label).trim(),
},
};
}
// Find the index of the matching unescaped `]` for a `[` at `start`,
// respecting nested (escaped-aware) brackets. Returns -1 if none found.
function findMatchingBracketClose(s: string, start: number): number {
let depth = 0;
for (let i = start; i < s.length; i++) {
if (s[i] === "\\") {
i++;
continue;
}
if (s[i] === "[") depth++;
else if (s[i] === "]") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
// Find the index of the matching unescaped `)` for a `(` at `openParenIdx`,
// respecting nested (escaped-aware) parens — links/images can carry URLs
// with literal parens, e.g. Wikipedia disambiguation links. Returns -1 if
// none found.
function findMatchingParenClose(s: string, openParenIdx: number): number {
let depth = 0;
for (let i = openParenIdx; i < s.length; i++) {
if (s[i] === "\\") {
i++;
continue;
}
if (s[i] === "(") depth++;
else if (s[i] === ")") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
// True when every `(`/`)` in a raw URL is already paren-balanced on its own
// (depth never goes negative and ends at zero). `findMatchingParenClose`
// treats a link/image destination as everything up to the matching close
// paren, so a balanced URL placed verbatim inside `(...)` parses back to
// itself byte-for-byte — this is what lets canonical Notion emissions like
// `https://en.wikipedia.org/wiki/Foo_(bar)` stay untouched (Notion never
// escapes those parens). An UNBALANCED URL (e.g. a single stray `)` or `(`)
// would either truncate the destination early or fail to find a close paren
// at all, so those need escaping instead — see escapeUrlParens.
function hasBalancedParens(url: string): boolean {
let depth = 0;
for (let i = 0; i < url.length; i++) {
if (url[i] === "(") depth++;
else if (url[i] === ")") {
depth--;
if (depth < 0) return false;
}
}
return depth === 0;
}
// Serialize a URL (link href or image src) for use inside `(...)`. Verbatim
// when its parens are already balanced (preserves Notion's byte-exact
// fixpoint for canonical URLs like Wikipedia disambiguation links). When
// unbalanced, backslash-escape every paren so findMatchingParenClose (which
// already understands `\(`/`\)`) can find the true end of the destination
// and reparse the exact original URL instead of truncating or overrunning.
function serializeUrlForParens(url: string): string {
if (hasBalancedParens(url)) return url;
return url.replace(/[()]/g, (ch) => "\\" + ch);
}
// Inverse of serializeUrlForParens: a raw destination slice extracted by
// findMatchingParenClose keeps any `\(`/`\)` literally (that function only
// uses the backslash to skip past the character for depth-counting — it
// doesn't strip it). A balanced canonical URL never contains a backslash
// immediately before a paren, so unescaping unconditionally is safe and
// keeps the Notion fixpoint intact while undoing our own escaping.
function unescapeUrlParens(url: string): string {
return url.replace(/\\([()])/g, "$1");
}
function matchLink(
s: string,
start: number,
): { text: string; href: string; end: number } | null {
// Find matching unescaped ] then immediately ( ... )
const closeBracket = findMatchingBracketClose(s, start);
if (closeBracket === -1 || s[closeBracket + 1] !== "(") return null;
// findMatchingParenClose expects to start AT the opening '(' itself so its
// depth counter begins at 1; closeBracket + 1 is that '('.
const closeParen = findMatchingParenClose(s, closeBracket + 1);
if (closeParen === -1) return null;
return {
text: s.slice(start + 1, closeBracket),
href: unescapeUrlParens(s.slice(closeBracket + 2, closeParen)),
end: closeParen + 1,
};
}
// ════════════════════════════════════════════════════════════════════
// BLOCK: serialize (docToNfm)
// ════════════════════════════════════════════════════════════════════
const TAB = "\t";
function indentStr(n: number): string {
return TAB.repeat(Math.max(0, n));
}
/**
* Optional serialize-side context for registry blocks. When the editor has live
* typed `data` for a `registryBlock` node (from the side-map), it supplies
* `serializeRegistryBlock(blockId)` so an EDITED block re-serializes from its
* current data. An UNTOUCHED block (server pull, content hashing, or any
* block the context can't resolve) falls back to the node's preserved `__raw`,
* keeping the round-trip byte-exact with no registry/React dependency.
*
* It is held in a module-scoped guard for the duration of the synchronous
* `docToNfm` call rather than threaded through every serialize helper — JS is
* single-threaded and `docToNfm` is synchronous and non-reentrant, so the guard
* is set on entry and always cleared in `finally`.
*/
export interface NfmSerializeContext {
/**
* Re-serialize a registry block to its exact MDX string from the editor's
* current typed `data`, or return `undefined`/`null` to fall back to `__raw`.
*/
serializeRegistryBlock?: (
blockId: string,
node: PMNode,
) => string | undefined | null;
}
let activeSerializeContext: NfmSerializeContext | null = null;
// Suppresses the top-level editor-terminal-filler trim (see
// trimEditorTerminalFiller) for the duration of a canonicalizeNfm call, so
// server pull canonicalization and content hashing never delete a Notion-
// authored ``. Only docToNfm's direct callers (the editor save
// paths) want that heuristic; canonicalization must be lossless.
let suppressTerminalFillerTrim = false;
export function docToNfm(
doc: PMDoc | PMNode | null | undefined,
context?: NfmSerializeContext,
): string {
const content = (doc?.content as PMNode[]) || [];
const previous = activeSerializeContext;
activeSerializeContext = context ?? null;
try {
const lines = serializeBlocks(content, 0, /* isTopLevel */ true);
return lines.join("\n");
} finally {
activeSerializeContext = previous;
}
}
function isEmptyParagraphNode(node: PMNode | undefined): boolean {
if (!node || node.type !== "paragraph") return false;
if (node.content?.length) return false;
if (isColor(node.attrs?.color)) return false;
return (Number(node.attrs?.indent) || 0) === 0;
}
function trimEditorTerminalFiller(blocks: PMNode[]): PMNode[] {
if (suppressTerminalFillerTrim) return blocks;
if (blocks.length < 2) return blocks;
const last = blocks[blocks.length - 1];
const previous = blocks[blocks.length - 2];
if (!isEmptyParagraphNode(last) || previous?.type === "paragraph") {
return blocks;
}
return blocks.slice(0, -1);
}
function serializeBlocks(
blocks: PMNode[],
indent: number,
isTopLevel = false,
): string[] {
const out: string[] = [];
// The editor only ever appends its terminal filler paragraph at the very
// top level of the document, never inside a nested callout/toggle/column —
// so only trim there. Trimming at every nesting level (the original bug)
// deleted intentional Notion `` spacers nested inside
// containers.
const serializableBlocks = isTopLevel
? trimEditorTerminalFiller(blocks)
: blocks;
for (let i = 0; i < serializableBlocks.length; i++) {
const block = serializableBlocks[i];
if (block.type === "bulletList" || block.type === "orderedList") {
out.push(...serializeList(block, indent));
} else if (block.type === "taskList") {
out.push(...serializeTaskList(block, indent));
} else {
out.push(...serializeBlock(block, indent));
}
}
return out;
}
function firstParagraph(node: PMNode): PMNode | null {
const first = node.content?.[0];
return first && first.type === "paragraph" ? first : null;
}
function serializeBlock(node: PMNode, indent: number): string[] {
const extra = Number(node.attrs?.indent) || 0;
const ind = indent + extra;
switch (node.type) {
case "paragraph": {
const inline = serializeInline(node.content);
if (!inline) return [indentStr(ind) + ""];
return [
indentStr(ind) +
escapeLeadingBlockMarker(inline) +
blockAttrSuffix({ color: node.attrs?.color }),
];
}
case "heading": {
const level = Math.min(6, Math.max(1, Number(node.attrs?.level) || 1));
const inline = serializeInline(node.content);
return [
indentStr(ind) +
"#".repeat(level) +
" " +
inline +
blockAttrSuffix({ color: node.attrs?.color }),
];
}
case "horizontalRule":
return [indentStr(ind) + "---"];
case "codeBlock": {
const lang = (node.attrs?.language as string) || "";
const text = (node.content || []).map((t) => t.text || "").join("");
const body = text.split("\n").map((l) => indentStr(ind) + l);
// CommonMark-style variable-length fence: if the body itself contains a
// backtick run, the fence must be longer than the longest such run so
// the body's own backticks can never be mistaken for the closing fence.
const longestRun = Math.max(
0,
...(text.match(/`+/g) || []).map((r) => r.length),
);
const fence = "`".repeat(Math.max(3, longestRun + 1));
return [indentStr(ind) + fence + lang, ...body, indentStr(ind) + fence];
}
case "blockquote":
return serializeQuote(node, ind);
case "notionToggle":
return serializeToggle(node, ind);
case "notionCallout":
return serializeCallout(node, ind);
case "notionColumns":
return serializeColumns(node, ind);
case "notionColumn": {
const out = [indentStr(ind) + ""];
out.push(...serializeBlocks(node.content || [], ind + 1));
out.push(indentStr(ind) + "");
return out;
}
case "notionSyncedBlock":
return serializeSynced(node, ind);
case "table":
return serializeTable(node, ind);
case "image":
return serializeImage(node, ind);
case "video":
case "audio":
return serializeMedia(node, ind);
case "notionBlockAtom":
return serializeBlockAtom(node, ind);
case "registryBlock":
return serializeRegistryBlock(node, ind);
case "contentReference":
case "localMdxComponent":
return serializeRawSourceBlock(node, ind);
default: {
// Unknown block: preserve its raw text if present so nothing is lost.
const raw = serializeRawSourceBlock(node, ind);
if (raw.length > 0) return raw;
const inline = serializeInline(node.content);
return inline ? [indentStr(ind) + inline] : [];
}
}
}
function serializeChildrenAfterFirst(node: PMNode, indent: number): string[] {
const children = (node.content || []).slice(1);
return serializeBlocks(children, indent);
}
function serializeQuote(node: PMNode, ind: number): string[] {
const textPara = firstParagraph(node);
const out: string[] = [];
const inline = textPara ? serializeInline(textPara.content) : "";
out.push(
indentStr(ind) +
"> " +
inline +
blockAttrSuffix({ color: node.attrs?.color }),
);
// Children (blocks after the text paragraph) are nested one tab deeper.
const children = textPara
? (node.content || []).slice(1)
: node.content || [];
out.push(...serializeBlocks(children, ind + 1));
return out;
}
function serializeToggle(node: PMNode, ind: number): string[] {
// `summary` is stored as raw NFM source (see the parse side, which keeps it
// verbatim rather than unescaping it) and must be emitted verbatim too —
// escaping it here would double-escape already-escaped literals like
// "\*not bold\*" and mangle real inline formatting like "**bold**", both of
// which broke the pull/push fixpoint and corrupted Notion toggle titles.
const summary = (node.attrs?.summary as string) || "";
const headingLevel = Number(node.attrs?.headingLevel) || 0;
const color = node.attrs?.color;
const out: string[] = [];
if (headingLevel >= 1 && headingLevel <= 4) {
// A heading-toggle's summary shares one line with the real trailing
// `{toggle="true" ...}` attrs (unlike , which keeps
// summary on its own line) — escapeTrailingBackslashRun keeps a
// plain-text summary ending in an odd backslash run (e.g. an
// editor-typed "C:\path\") from being misread as escaping that suffix
// away; see its doc comment for why this is a no-op for every valid
// Notion-emitted summary.
out.push(
indentStr(ind) +
"#".repeat(headingLevel) +
" " +
escapeTrailingBackslashRun(summary) +
blockAttrSuffix({ toggle: true, color }),
);
out.push(...serializeBlocks(node.content || [], ind + 1));
return out;
}
const attrStr = serializeAttrs([["color", isColor(color) ? color : null]]);
out.push(indentStr(ind) + ``);
out.push(indentStr(ind) + `${summary}`);
out.push(...serializeBlocks(node.content || [], ind + 1));
out.push(indentStr(ind) + "");
return out;
}
function serializeCallout(node: PMNode, ind: number): string[] {
const icon = (node.attrs?.icon as string) ?? "";
const color = node.attrs?.color;
const attrStr = serializeAttrs([
["icon", icon || null],
["color", isColor(color) ? color : null],
]);
const out = [indentStr(ind) + ``];
out.push(...serializeBlocks(node.content || [], ind + 1));
out.push(indentStr(ind) + "");
return out;
}
function serializeColumns(node: PMNode, ind: number): string[] {
const out = [indentStr(ind) + ""];
out.push(...serializeBlocks(node.content || [], ind + 1));
out.push(indentStr(ind) + "");
return out;
}
function serializeSynced(node: PMNode, ind: number): string[] {
const tag = node.attrs?.isReference
? "synced_block_reference"
: "synced_block";
const attrStr = serializeAttrs([
["url", node.attrs?.url || null],
["notice", node.attrs?.notice || null],
]);
const out = [indentStr(ind) + `<${tag}${attrStr}>`];
out.push(...serializeBlocks(node.content || [], ind + 1));
out.push(indentStr(ind) + `${tag}>`);
return out;
}
function serializeImage(node: PMNode, ind: number): string[] {
const src = (node.attrs?.src as string) || "";
const alt = (node.attrs?.alt as string) || "";
const color = node.attrs?.color;
const suffix = isColor(color) ? ` {color="${color}"}` : "";
return [
indentStr(ind) +
`})${suffix}`,
];
}
function serializeMedia(node: PMNode, ind: number): string[] {
const tag = node.type; // video | audio
const src = (node.attrs?.src as string) || "";
const caption = (node.attrs?.title as string) || "";
const color = node.attrs?.color;
const attrStr = serializeAttrs([
["src", src],
["color", isColor(color) ? color : null],
]);
return [
indentStr(ind) +
`<${tag}${attrStr}>${caption ? escapeAttr(caption) : ""}${tag}>`,
];
}
function serializeBlockAtom(node: PMNode, ind: number): string[] {
// Raw containers (e.g. ) parsed by parseRawContainer carry
// their exact source in __raw and must be emitted verbatim — the tagName/
// label/attrsJson below are only a summary for display, never the content.
const raw = serializeRawSourceBlock(node, ind);
if (raw.length > 0) return raw;
const tagName = (node.attrs?.tagName as string) || "unknown";
const label = (node.attrs?.label as string) || "";
let attrs: Record = {};
try {
attrs = JSON.parse((node.attrs?.attrsJson as string) || "{}");
} catch {
attrs = {};
}
if (tagName === "equation") {
const latex = label || attrs.latex || "";
return [
indentStr(ind) + "$$",
...latex.split("\n").map((l) => indentStr(ind) + l),
indentStr(ind) + "$$",
];
}
const rawEntries = Object.entries(attrs);
const attrStr = serializeAttrs(rawEntries);
if (label.trim()) {
return [
indentStr(ind) +
`<${tagName}${attrStr}>${escapeAttr(label)}${tagName}>`,
];
}
return [indentStr(ind) + `<${tagName}${attrStr}/>`];
}
/**
* Serialize a `registryBlock` atom node back to its inline MDX element lines.
*
* Order of precedence:
* 1. An EDITED block: the active serialize context resolves the node by its
* `blockId` to a fresh MDX string re-serialized from the editor's typed
* `data` (via core `serializeSpecBlock`).
* 2. An UNTOUCHED block: emit the node's preserved `__raw` verbatim — the
* exact bytes captured on parse. This is the default path for server pull,
* content hashing, and any block the context can't (or chooses not to)
* re-serialize, so the round-trip stays byte-exact with no React/registry.
*
* Either way the resulting MDX is split on newlines and each line is indented to
* the block's structural depth, exactly like every other block.
*/
function serializeRegistryBlock(node: PMNode, ind: number): string[] {
const blockId = (node.attrs?.blockId as string) || "";
const fromContext =
activeSerializeContext?.serializeRegistryBlock?.(blockId, node) ?? null;
const mdx =
typeof fromContext === "string" && fromContext.length > 0
? fromContext
: typeof node.attrs?.__raw === "string"
? (node.attrs.__raw as string)
: "";
if (!mdx) return [];
return mdx.split("\n").map((l) => (l.length ? indentStr(ind) + l : l));
}
function serializeRawSourceBlock(node: PMNode, ind: number): string[] {
if (typeof node.attrs?.__raw !== "string" || !node.attrs.__raw) return [];
return (node.attrs.__raw as string)
.split("\n")
.map((l) => (l.length ? indentStr(ind) + l : l));
}
function serializeList(node: PMNode, indent: number): string[] {
const ordered = node.type === "orderedList";
const start = ordered ? Number(node.attrs?.start) || 1 : 0;
const out: string[] = [];
let n = start;
for (const item of node.content || []) {
const textPara = firstParagraph(item);
const inline = textPara ? serializeInline(textPara.content) : "";
const color = textPara?.attrs?.color;
const marker = ordered ? `${n}. ` : "- ";
out.push(indentStr(indent) + marker + inline + blockAttrSuffix({ color }));
const children = textPara
? (item.content || []).slice(1)
: item.content || [];
out.push(...serializeBlocks(children, indent + 1));
n++;
}
return out;
}
function serializeTaskList(node: PMNode, indent: number): string[] {
const out: string[] = [];
for (const item of node.content || []) {
const checked = !!item.attrs?.checked;
const textPara = firstParagraph(item);
const inline = textPara ? serializeInline(textPara.content) : "";
const color = textPara?.attrs?.color;
out.push(
indentStr(indent) +
`- [${checked ? "x" : " "}] ` +
inline +
blockAttrSuffix({ color }),
);
const children = textPara
? (item.content || []).slice(1)
: item.content || [];
out.push(...serializeBlocks(children, indent + 1));
}
return out;
}
// Table cells (
/
) are inline-only in NFM. Editor cells are
// block+ (@tiptap/extension-table-cell), so Enter or a pasted list can put
// multiple blocks — or a non-paragraph block — into a cell. Flatten every
// child to inline text joined by " " instead of keeping only the first
// paragraph, so nothing the user typed is silently discarded. " " round
// trips because the inline parser already maps / to hardBreak.
function serializeCellInline(cell: PMNode): string {
const parts: string[] = [];
for (const child of cell.content || []) {
const part = serializeCellChildInline(child);
if (part) parts.push(part);
}
return parts.join(" ");
}
function serializeCellChildInline(node: PMNode): string {
switch (node.type) {
case "paragraph":
case "heading":
return serializeInline(node.content);
case "bulletList":
case "orderedList":
case "taskList": {
const items: string[] = [];
for (const item of node.content || []) {
const textPara = firstParagraph(item);
const inline = textPara ? serializeInline(textPara.content) : "";
if (inline) items.push(inline);
}
return items.join(" ");
}
case "blockquote": {
const parts: string[] = [];
for (const child of node.content || []) {
const part = serializeCellChildInline(child);
if (part) parts.push(part);
}
return parts.join(" ");
}
default:
return serializeInline(node.content);
}
}
function serializeTable(node: PMNode, ind: number): string[] {
const attrs = node.attrs || {};
const tableAttrStr = serializeAttrs([
["fit-page-width", attrs.fitPageWidth ? "true" : null],
["header-row", attrs.headerRow ? "true" : null],
["header-column", attrs.headerColumn ? "true" : null],
]);
const out = [indentStr(ind) + `