Files
gitmost/packages/prosemirror-markdown/src/lib/markdown-converter.ts
T
agent_coder 35f2c06f42 test(converter): #391 review round — orderedList start guard + nightly hardening (#351)
DO-1 (regression): the orderedList start hardening `Number(x)||1` let a
negative/fractional start through into the marker ("-3.", "2.5.") — marked
can't tokenize it, so the whole list re-imported as a paragraph (structure
corruption); start=0 churned. Both the markdown and raw-HTML export paths
now use `Number.isInteger(raw) && raw > 1 ? raw : 1`, so any degenerate
start collapses to the default "1." markers / bare <ol> (list always valid).
New ordered-list-start-normalization test pins {0,-3,2.5} → valid start=1
list, byte-stable; the round-trip fuzz keeps to integers >=2 (num 2,3,5,42).

DO-2 (nightly was non-functional): NUM_RUNS=5000 OOM'd the worker and the
crash was misreported as a "counterexample" issue whose prefix-dedup then
locked out all future issues. Reworked to shard 8 fresh vitest processes
(600 runs each, distinct seeds, --max-old-space-size) so deep fuzzing
never OOMs; a failing shard's output is preserved, and issue creation
discriminates a real fast-check counterexample from an infra/OOM failure
(distinct titles + scoped dedup). The two issue steps use `always() &&`
so they actually run on the failure path.

DO-3: envInt extracted to test/generative/env-int.ts + unit-tested.
DO-4: nightly dispatch inputs go through env: (no ${{ }} in run:).
DO-5: attr-arbitraries.ts docblock synced (column.width/orderedList.start
are fixed+fuzzed, not pinned it.fails).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:32:29 +03:00

1641 lines
82 KiB
TypeScript

import { encodeHtmlEmbedSource } from "./docmost-schema.js";
import {
attachedCommentFor,
standaloneCommentFor,
} from "./attached-comment.js";
import {
encodeInlineMathLatex,
inlineMathGlobalRe,
inlineMathSerializable,
} from "./math-inline.js";
import {
attachmentToHtml,
audioToHtml,
diagramToHtml,
embedToHtml,
pageEmbedToHtml,
pdfToHtml,
transclusionReferenceToHtml,
videoToHtml,
youtubeToHtml,
} from "./media-html.js";
/**
* Hard cap on processNode recursion depth (see the depth guard below).
*
* Chosen well above any realistic document (the deepest legitimate nesting the
* editor can produce is far shallower) yet far below the point where the
* converter's own call stack overflows. The heaviest shape (deeply nested
* lists) costs ~5 JS frames per level and the runtime stack holds ~10k frames,
* so the measured overflow is around level ~650 (deeply nested lists); 400
* leaves a comfortable margin while still rendering pathological-but-bounded
* docs in full (the 200-level stress fixture reaches depth ~204).
*/
const MAX_NODE_DEPTH = 400;
/**
* Options for {@link convertProseMirrorToMarkdown}.
*/
export interface ConvertProseMirrorToMarkdownOptions {
/**
* When true, an inline comment anchor whose Comment mark is `resolved`
* emits its BARE text (no `<span data-comment-id …>` wrapper), so an agent
* reading the page never sees resolved-comment anchors. ACTIVE (unresolved)
* anchors still emit their wrapper. Defaults to false — a zero-behavior
* change for every existing caller, including the lossless git-sync export
* path where resolved anchors MUST be preserved for round-tripping.
*/
dropResolvedCommentAnchors?: boolean;
}
/**
* Adjacent sibling lists that share a markdown MARKER FAMILY re-parse as ONE
* merged list — bulletList and taskList both emit `- ` markers (→ a single
* `<ul>`), and two orderedLists both emit `1.` markers (→ a single `<ol>`). The
* cross-type case is real data loss the editor CAN produce (e.g. a taskList
* followed by a bulletList: the merged `<ul>` has a mix of checkbox and plain
* items, so `bridgeTaskLists` refuses to convert it and every taskItem loses its
* checkbox). Between two such adjacent list children we emit an empty HTML comment
* `<!-- -->`: marked renders it as its own HTML block that interrupts the list, so
* the two lists stay distinct; on import the comment is inert (parseAttachedComment
* → null) and dropped by generateJSON, and re-export re-inserts it, so the marker
* is byte-stable. It fires ONLY between two adjacent same-family list nodes — no
* separator is emitted for any other join, so non-list output is unchanged.
*/
const LIST_MARKER_SEPARATOR = "<!-- -->";
function listMarkerFamily(type: string | undefined): "ul" | "ol" | null {
if (type === "bulletList" || type === "taskList") return "ul";
if (type === "orderedList") return "ol";
return null;
}
function adjacentListsMerge(
prevType: string | undefined,
curType: string | undefined,
): boolean {
const a = listMarkerFamily(prevType);
return a !== null && a === listMarkerFamily(curType);
}
/**
* Render each block child, inserting a `<!-- -->` separator entry between any two
* adjacent same-marker-family list nodes (see LIST_MARKER_SEPARATOR). Callers
* join the returned strings with their own context separator.
*/
function renderBlockChildren(
children: any[],
render: (n: any) => string,
): string[] {
const out: string[] = [];
let prevType: string | undefined;
for (const child of children) {
if (adjacentListsMerge(prevType, child?.type)) out.push(LIST_MARKER_SEPARATOR);
out.push(render(child));
prevType = child?.type;
}
return out;
}
/**
* Convert ProseMirror/TipTap JSON content to Markdown
* Supports all Docmost-specific node types and extensions
*/
export function convertProseMirrorToMarkdown(
content: any,
options: ConvertProseMirrorToMarkdownOptions = {},
): string {
if (!content || !content.content) return "";
// Closure flag read by both `case "comment"` emitters (the top-level marks
// loop and the raw-HTML inlineToHtml path). Off by default; the agent-read
// callers (mcp getPage / in-app AI chat) pass it true.
const dropResolvedCommentAnchors = options.dropResolvedCommentAnchors === true;
// Escape a value interpolated into an HTML double-quoted attribute value
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
// ATTRIBUTE context only the quote that delimits the value and the ampersand
// that starts an entity are special, so we escape ONLY & " (and ' for safety
// when single-quoted delimiters are used). We deliberately do NOT escape < or
// >: the HTML re-parser (parse5/jsdom via @tiptap/html) does NOT decode
// &lt;/&gt; back inside attribute values, so escaping them would corrupt the
// stored data (e.g. a math node's LaTeX `a < b`) and ACCUMULATE escapes on
// every round-trip (`a < b` -> `a &lt; b` -> `a &amp;lt; b`). Escaping & "
// keeps the value inert against attribute-injection while staying idempotent.
// NOTE: escape ONLY & and " here. The value is always wrapped in double
// quotes, so " is the only delimiter; ' is NOT special in a double-quoted
// value, and parse5 does not decode &#39; back inside attribute values, so
// escaping ' would (like < >) corrupt the value and accumulate &amp; on every
// round-trip. Escaping & and " is idempotent (parse5 decodes them back).
const escapeAttr = (value: unknown): string =>
String(value)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
// Escape a value placed as HTML element TEXT content (between tags), where
// <, >, and & are all significant. Used for text rendered inside raw-HTML
// blocks (table cells / columns) so stored characters cannot inject markup.
const escapeHtmlText = (value: unknown): string =>
String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Percent-encode characters that would break out of a markdown URL target
// (...) — whitespace/newlines and parentheses — so a stored src stays a
// single inert token (used for image/video/youtube srcs).
const encodeMdUrl = (value: unknown): string =>
String(value || "")
.replace(/\s/g, (c: string) => (c === " " ? "%20" : encodeURIComponent(c)))
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
// Backslash-escape every character that would be INTERPRETED inside a markdown
// label re-parsed as inline content — used for a link-form media node's visible
// text (`attrs.name`/`attrs.provider`, #293 canon #8) AND for an image `![alt]`
// (canon #4) — so the value round-trips byte-exact. Two overlapping trigger
// sets must be escaped:
// 1. Stock CommonMark inline: emphasis (`* _`), code (`` ` ``), strikethrough
// (`~`), autolinks/raw-HTML (`<`), HTML entities (`&`), image markers (`!`),
// brackets (`[ ]`), and `( )` — even with `[ ]` escaped, an unescaped
// `](x)` forms a false nested-link destination and fragments the parse.
// 2. The Docmost inline EXTENSIONS this package registers on its marked
// instance: highlight `==x==` (canon #7), math `$x$` (canon #6), and
// footnote `^[x]` (canon #2). Their triggers `= $ ^` are NOT CommonMark
// punctuation the stock lexer would treat specially, but the extension
// tokenizers fire on them — so an alt/name like `x $A$ y`, `use ==b==`, or
// `^[fn]` would be silently turned into a math/highlight/footnote node on
// import unless the trigger is escaped. `\= \$ \^` decode back to literals
// (all ASCII punctuation) and, being escape tokens, stop the extension
// tokenizer from matching — verified lossless round-trip.
const escapeLinkText = (value: unknown): string =>
String(value ?? "").replace(/[\\`*_~[\]<&!()=$^]/g, (c: string) => `\\${c}`);
// #293 canon #6: the schema-HTML forms for math. These are the LOSSLESS forms
// the raw-HTML path (columns/cells) and the mathInline fallback emit, and the
// SAME shape the importer's schema parseHTML rebuilds (span/div carrying the
// LaTeX in a `text="…"` attribute). Kept as small helpers so the readable
// `$…$`/`$$…$$` markdown forms and the raw-HTML forms cannot drift apart.
const mathInlineHtml = (latex: string): string =>
`<span data-type="mathInline" data-katex="true" text="${escapeAttr(latex)}"></span>`;
const mathBlockHtml = (latex: string): string =>
`<div data-type="mathBlock" data-katex="true" text="${escapeAttr(latex)}"></div>`;
// #293 canon #6: neutralize a would-be inline-math `$…$` span sitting in PROSE
// text so it re-imports as literal text (never a phantom math node). We escape
// ONLY the two delimiting `$` of a span the inline-math tokenizer WOULD match
// (shared rule, math-inline.ts), leaving the inner untouched. A currency `$`
// (`$5`, `$5 and $10`) has no VALID closing under the rule, so it never
// matches and is emitted CLEAN (no backslash churn). On re-import marked's
// escape tokenizer turns each `\$` back into a literal `$`, so `the set $A$`
// round-trips as text while `$5 and $10` stays exactly as written.
const escapeProseMath = (value: string): string =>
value.replace(inlineMathGlobalRe(), (_m, inner) => `\\$${inner}\\$`);
// #293 canon #2 inline footnotes. PRE-SCAN the whole tree once: map every
// footnote DEFINITION by its id (first-wins) and collect every REFERENCED id.
// The canonical markdown form inlines a note's body AT its reference point as
// `^[body]`, so the serializer needs the body keyed by id when it reaches a
// `footnoteReference`, and needs to know which definitions are orphaned (no
// ref) so their bodies are not silently dropped (appended at the doc end).
const footnoteDefs = new Map<string, any>();
const referencedFootnoteIds = new Set<string>();
// ITERATIVE walk (explicit stack, not recursion): a pathologically deep
// document must not overflow the call stack here — the same reason processNode
// has its depth guard. This scan is unbounded but stack-safe.
const scanStack: any[] = [content];
while (scanStack.length) {
const n = scanStack.pop();
if (!n || typeof n !== "object") continue;
if (
n.type === "footnoteDefinition" &&
n.attrs?.id &&
!footnoteDefs.has(n.attrs.id)
) {
footnoteDefs.set(n.attrs.id, n);
}
if (n.type === "footnoteReference" && n.attrs?.id) {
referencedFootnoteIds.add(n.attrs.id);
}
if (Array.isArray(n.content)) {
for (const child of n.content) scanStack.push(child);
}
}
// Balance `[`/`]` in a rendered footnote body so the whole thing stays a
// parseable `^[…]` capture (the importer's tokenizer counts brackets from `^[`
// to the matching `]`). A markdown link's `[text](url)` is already balanced
// and is left intact; only a STRAY unmatched `]` (or `[`) — e.g. a literal
// bracket in prose — is backslash-escaped so it cannot open/close the footnote
// early. Already backslash-escaped pairs are passed through untouched.
const balanceBrackets = (s: string): string => {
const out: string[] = [];
const openIndices: number[] = []; // positions in `out` of unmatched `[`
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === "\\" && i + 1 < s.length) {
out.push(c + s[i + 1]); // keep an existing escaped pair verbatim
i += 2;
continue;
}
if (c === "[") {
openIndices.push(out.length);
out.push("[");
i++;
continue;
}
if (c === "]") {
if (openIndices.length > 0) {
openIndices.pop();
out.push("]");
} else {
out.push("\\]"); // stray close -> escape
}
i++;
continue;
}
out.push(c);
i++;
}
for (const idx of openIndices) out[idx] = "\\["; // stray opens -> escape
return out.join("");
};
// While TRUE, `case "text"` DOUBLES every RAW user backslash (`\` -> `\\`) in a
// text run BEFORE the intentional markdown/balance escapes are layered on (see
// the text case). This is the F2 fix: a body ending in `\` (Windows path,
// LaTeX, regex) must survive `^[…]`. Without it a trailing `\` serialized to
// `^[…\]`, whose `\]` the import tokenizer reads as an ESCAPED `]`, so the
// balance never closed and the whole footnote degraded to literal prose (and
// the `\` was lost). Doubling raw backslashes makes `parseInline` restore each
// (`\\`->`\`) on import while the serializer's OWN single escapes (`\[` `\]`
// `\=` `\$`, the `\n` paragraph separator) stay intact. It is a closure flag
// (per-conversion, not module state), so concurrent conversions never share it.
let inFootnoteBody = false;
// Render a footnote DEFINITION's `paragraph+` body to INLINE markdown suitable
// to sit inside `^[…]`. Each paragraph is rendered inline (so links/marks in
// the note round-trip) with raw backslashes doubled (via inFootnoteBody) and
// its brackets balanced; paragraphs are then joined with the literal two-char
// separator `\n`. Because raw backslashes are already doubled, a real
// backslash-n in the text is `\\n` and never mistaken for the separator (the
// only unescaped `\n` is the join inserted here). Footnotes are inline (single
// line) in markdown, so an embedded hard break collapses to a space.
const renderFootnoteBody = (def: any): string => {
const paras = (def?.content || []).filter(
(p: any) => p?.type === "paragraph",
);
// A definition with no paragraph still yields one (empty) segment so an
// empty note serializes as `^[]` and round-trips.
const segments = (paras.length ? paras : [{ content: [] }]).map(
(p: any) => {
const prev = inFootnoteBody;
inFootnoteBody = true;
let s: string;
try {
s = renderInlineChildren(p.content || []);
} finally {
inFootnoteBody = prev;
}
// Collapse any real newline (e.g. a hard break) so `^[…]` stays on one
// logical line for the inline tokenizer.
s = s.replace(/\r?\n/g, " ");
s = balanceBrackets(s);
return s;
},
);
return segments.join("\\n");
};
// Recursion depth guard. processNode is mutually recursive (directly and via
// processListItem/processTaskItem/blockToHtml), and a pathologically nested
// document (e.g. tens of thousands of nested blockquotes) would otherwise
// overflow the call stack and throw a RangeError, which would abort the sync
// and prevent the page from ever being written. We track the live nesting
// depth in a closure counter (the wrapper below) so we NEVER throw: past the
// limit we stop recursing and emit the node's own text (or nothing) instead.
// Normal documents never approach MAX_NODE_DEPTH, so their output is byte-
// identical. NOTE: the wrapper signature is (node) only — several callers use
// `.map(processNode)`, which would otherwise pass the array index as a second
// argument; the wrapper ignores extra arguments so that is harmless.
let nodeDepth = 0;
// A table cell whose content is NOT a single plain paragraph — a list, code
// block, blockquote, multiple paragraphs, etc. A GFM pipe cell can only hold
// inline content on one line, so such a cell must force the HTML <table> form
// or its structure is flattened/lost on round trip (review #8).
const cellIsMultiBlock = (cell: any): boolean => {
const blocks = cell.content || [];
if (blocks.length > 1) return true;
const only = blocks[0];
return only != null && only.type !== "paragraph";
};
// Render a whole table as raw HTML `<table>` (round-trips via the schema's
// table-family parseHTML). Used when a GFM pipe table would be wrong: merged
// cells (colspan/rowspan), multi-block cells (#8), OR the table sits inside a
// raw-HTML container like a column (marked does not parse markdown inside raw
// HTML, so a GFM pipe table there becomes literal "| a | b |" text — #7).
// `blockToHtml` is referenced lazily (defined below; only called at runtime).
const tableToHtml = (tableRows: any[]): string => {
const renderHtmlCell = (cell: any): string => {
const tag = cell.type === "tableHeader" ? "th" : "td";
const a = cell.attrs || {};
const cellParts: string[] = [];
if ((a.colspan ?? 1) > 1)
cellParts.push(`colspan="${escapeAttr(a.colspan)}"`);
if ((a.rowspan ?? 1) > 1)
cellParts.push(`rowspan="${escapeAttr(a.rowspan)}"`);
if (a.align) cellParts.push(`align="${escapeAttr(a.align)}"`);
const open = cellParts.length
? `<${tag} ${cellParts.join(" ")}>`
: `<${tag}>`;
const inner = (cell.content || [])
.map((block: any) => blockToHtml(block))
.join("");
return `${open}${inner}</${tag}>`;
};
const htmlRows = tableRows
.map(
(row: any) =>
`<tr>${(row.content || []).map(renderHtmlCell).join("")}</tr>`,
)
.join("");
return `<table><tbody>${htmlRows}</tbody></table>`;
};
const processNode = (node: any): string => {
if (nodeDepth >= MAX_NODE_DEPTH) {
// Bail out of deeper recursion without throwing. A text node still has
// its own content worth keeping; a container at the limit collapses to
// "" (its already-too-deep subtree is dropped) rather than overflowing.
return typeof node?.text === "string" ? node.text : "";
}
nodeDepth++;
try {
return processNodeInner(node);
} finally {
nodeDepth--;
}
};
const processNodeInner = (node: any): string => {
const type = node.type;
const nodeContent = node.content || [];
switch (type) {
case "doc": {
// #293 canon #2: the `footnotesList` is NOT emitted in markdown — every
// note body is inlined at its `^[…]` reference. Skip the list entirely
// (emitting "" would inject a phantom blank gap via the "\n\n" join),
// then append any ORPHAN definition (one no reference points at) as its
// own `^[body]` line so its body is never silently lost.
// F3 (accepted, intentional): on re-import that orphan `^[body]` line
// becomes a reference + definition (an orphan def gains a ref). This is
// lossless (the body survives) and byte-stable (it re-exports identically),
// so it is deliberately not treated as data loss.
const parts: string[] = [];
let prevDocType: string | undefined;
for (const child of nodeContent) {
if (child?.type === "footnotesList") continue;
// Keep adjacent same-family sibling lists distinct (see renderBlockChildren).
if (adjacentListsMerge(prevDocType, child?.type)) {
parts.push(LIST_MARKER_SEPARATOR);
}
parts.push(processNode(child));
prevDocType = child?.type;
}
for (const [id, def] of footnoteDefs) {
if (!referencedFootnoteIds.has(id)) {
parts.push(`^[${renderFootnoteBody(def)}]`);
}
}
return parts.join("\n\n");
}
case "paragraph": {
const text = renderInlineChildren(nodeContent);
const align = node.attrs?.textAlign;
// Non-default alignment round-trips as an ATTACHED HTML comment at the
// END of the block line (#293 canon #9):
// `some text <!--attrs {"textAlign":"center"}-->`
// This replaces the old `<p style="text-align:…">` wrapper (review #10,
// itself a replacement for the never-parsed `<div align>`). The schema's
// textAlign default is `null`; "left" is the visual default the editor
// renders identically to null — neither emits a marker (no churn). The
// importer's applyAttachedComments step reads the comment back before the
// DOM stage drops it. We only attach when there is text to attach to: a
// lone comment on a blank line has no block to bind to on re-import.
if (align && align !== "left" && text) {
return `${text} ${attachedCommentFor("attrs", { textAlign: align })}`;
}
return text || "";
}
case "heading": {
const level = node.attrs?.level || 1;
const headingText = renderInlineChildren(nodeContent);
const headingLine = "#".repeat(level) + " " + headingText;
const headingAlign = node.attrs?.textAlign;
// A non-default heading alignment attaches the same trailing comment
// (#293 canon #9), keeping the readable `## text` markdown form:
// `## Title <!--attrs {"textAlign":"center"}-->`
// Bare `## text` carries no alignment, so without this an aligned heading
// would silently drop textAlign on export. Replaces the old
// `<hN style="text-align:…">` HTML form. "left"/null stay bare (no churn).
// Require headingText so an empty aligned heading stays bare `##` rather
// than emitting a comment with no visible element to attach to (matches
// the paragraph guard's `text` check — a lone comment has no block to
// bind on re-import).
if (headingAlign && headingAlign !== "left" && headingText) {
return `${headingLine} ${attachedCommentFor("attrs", { textAlign: headingAlign })}`;
}
return headingLine;
}
case "text":
let textContent = node.text || "";
// #293 canon #7: `==` is now a LIVE inline highlight syntax on import (a
// marked inline extension turns `==text==` into a color-less highlight
// mark). A LITERAL `==` in a text run would therefore be misparsed as a
// highlight on the next import, so backslash-escape each `=` of a `==`
// pair; marked's escape tokenizer decodes `\=` back to a literal `=`, so
// a literal `==` round-trips as text (never materializes a phantom mark).
// This runs for BOTH unmarked text and marked non-code runs, but NOT for
// an inline code span (a run carrying the `code` mark returns a backtick
// span below with `==` verbatim, matching `` `a == b` `` staying code).
// A highlight run's own `==` delimiters are appended AFTER this in the
// marks loop, so they are never escaped; only the run's inner text is.
if (!(node.marks || []).some((m: any) => m.type === "code")) {
// #293 canon #2 (F2): inside a footnote body, DOUBLE every RAW user
// backslash FIRST, so it survives `^[…]` (the import tokenizer treats
// `\<char>` as an escape when balancing brackets, and `parseInline`
// decodes escapes). Doing it before the intentional escapes below keeps
// the serializer's own single escapes (`\=` `\$` `^\[`, and the `\[`/
// `\]` balanceBrackets adds) single; only genuine user backslashes are
// doubled. Skipped for code runs (a code span's content is NOT decoded
// by parseInline, so its backslashes must stay verbatim).
if (inFootnoteBody) {
textContent = textContent.replace(/\\/g, "\\\\");
}
textContent = textContent.replace(/==/g, "\\=\\=");
// #293 canon #6: escape a would-be inline-math `$…$` span so it stays
// literal text on re-import (currency `$5` is left clean — see
// escapeProseMath). Runs on the SAME non-code runs as the `==` escape
// above; an inline `code` run returns verbatim below, matching the
// codeBlock path (a `$…$` inside code must stay code, never math).
textContent = escapeProseMath(textContent);
// #293 canon #2: `^[` opens a LIVE inline-footnote span on import
// (`^[text]` -> a footnote reference). A LITERAL `^[` in prose text
// would therefore materialize a phantom footnote on the next import, so
// backslash-escape the bracket (`^[` -> `^\[`); marked's escape
// tokenizer decodes `\[` back to `[`, so a literal `^[…]` round-trips
// as text and never opens a footnote. Only the OPENING `^[` needs
// breaking (the tokenizer requires it), so this is a minimal, idempotent
// escape. A real footnoteReference node emits `^[body]` from its own
// case, never through here.
textContent = textContent.replace(/\^\[/g, "^\\[");
}
// Apply marks (bold, italic, code, etc.)
if (node.marks) {
// The schema's `code` mark declares `excludes: "_"` — it excludes every
// other inline mark — so the editor can NEVER produce a text run that
// carries `code` together with another mark, and on import any
// co-occurring mark is always dropped (the run comes back as code-only).
// The lossless, byte-stable behavior is therefore: when a run has the
// `code` mark, emit ONLY the backtick code span and ignore every other
// mark, so md1 is already code-only and md2 === md1. Runs WITHOUT a code
// mark are rendered exactly as before.
const markTypes = node.marks.map((m: any) => m.type);
const hasCode = markTypes.includes("code");
if (hasCode) {
textContent = `\`${textContent}\``;
return textContent;
}
for (const mark of node.marks) {
switch (mark.type) {
case "bold":
textContent = `**${textContent}**`;
break;
case "italic":
textContent = `*${textContent}*`;
break;
case "code":
// A `code` run already returned above (hasCode early return), so
// this branch is only reached for a non-code run that somehow
// still lists `code`; emit the plain backtick span.
textContent = `\`${textContent}\``;
break;
case "link": {
const href = mark.attrs?.href || "";
const title = mark.attrs?.title;
if (title) {
// Emit the optional markdown link title; escape an embedded
// double-quote so it cannot terminate the title string early.
const safeTitle = String(title).replace(/"/g, '\\"');
textContent = `[${textContent}](${href} "${safeTitle}")`;
} else {
textContent = `[${textContent}](${href})`;
}
break;
}
case "strike":
textContent = `~~${textContent}~~`;
break;
case "underline":
textContent = `<u>${textContent}</u>`;
break;
case "subscript":
textContent = `<sub>${textContent}</sub>`;
break;
case "superscript":
textContent = `<sup>${textContent}</sup>`;
break;
case "highlight": {
// #293 canon #7: a highlight WITHOUT a color serializes as the
// Obsidian/GFM `==text==` syntax (the importer's marked inline
// `==` extension parses it back to a color-less highlight mark).
// A highlight WITH a color keeps the `<mark style="background-
// color: …">` HTML form (the condition is deterministic on the
// `color` attr), so a colored highlight is not flattened. The
// inner textContent already had any literal `==` backslash-
// escaped above, so a highlight over text containing `==` still
// round-trips.
const color = mark.attrs?.color;
textContent = color
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
: `==${textContent}==`;
break;
}
case "textStyle":
if (mark.attrs?.color) {
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
}
break;
case "spoiler":
// Markdown has no native spoiler syntax, so emit the same raw
// inline HTML the editor-ext/MCP stack uses. The schema's Spoiler
// mark parses span[data-spoiler] back on import, so the mark
// survives the PM -> MD -> PM round-trip.
textContent = `<span data-spoiler="true">${textContent}</span>`;
break;
case "comment": {
// Emit the inline comment anchor so highlights round-trip. The
// schema's Comment mark parses span[data-comment-id] (attrs
// commentId/resolved).
const cid = mark.attrs?.commentId;
if (cid) {
// Hide resolved anchors from agent reads: drop the wrapper and
// keep only the bare text. Active anchors keep their wrapper.
if (mark.attrs?.resolved && dropResolvedCommentAnchors) {
break;
}
const resolvedAttr = mark.attrs?.resolved
? ` data-resolved="true"`
: "";
textContent = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
}
break;
}
}
}
}
return textContent;
case "codeBlock":
const language = node.attrs?.language || "";
// Strip ALL trailing newlines so the export is idempotent: marked
// re-adds exactly one trailing "\n" on import, so trimming only one
// here would let the text grow by "\n" on each round-trip. Removing
// every trailing newline makes repeated cycles stable.
//
// Read the child text RAW (schema codeBlock is `content: "text*"`), NOT
// through processNode: code-fence content is literal and markdown escapes
// do not apply inside a fence. In particular the canon #7 `==` -> `\=\=`
// escape (in `case "text"`) must NOT reach code — marked leaves `\=`
// verbatim inside a fence, so routing code through `case "text"` would
// permanently stamp backslashes into any `==` (a `==` comparison is
// extremely common in source), corrupting the block on the git-sync data
// path.
const code = nodeContent
.map((child: any) => (typeof child?.text === "string" ? child.text : ""))
.join("")
.replace(/\n+$/, "");
// CommonMark: an inner ``` run inside the code would prematurely close
// a 3-backtick fence (corrupting the block on re-import). Use an outer
// fence one backtick longer than the longest backtick run in the code
// (minimum 3) so the inner fence is always content.
const longestBacktickRun = (code.match(/`+/g) || []).reduce(
(max: number, run: string) => Math.max(max, run.length),
0,
);
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
return fence + language + "\n" + code + "\n" + fence;
case "bulletList":
return nodeContent
.map((item: any) => processListItem(item, "-"))
.join("\n");
case "orderedList": {
// Honor a non-1 `start`: CommonMark expresses it as the first marker
// ("5." starts the list at 5), and marked parses that back into
// <ol start="5">. Emitting `${start + index}.` keeps a start=5 list as
// "5.","6.",… while a default (start=1) list stays "1.","2.",… See #351.
// Only an INTEGER ≥ 2 is a valid explicit start; collapse everything else
// (absent, non-number, 0, negative, fractional) to 1. A negative/fractional
// start would otherwise emit an untokenizable marker (e.g. "-3.", "2.5.")
// that marked cannot parse, re-importing the whole list as a PARAGRAPH.
const raw = node.attrs?.start;
const start = Number.isInteger(raw) && (raw as number) > 1 ? (raw as number) : 1;
return nodeContent
.map((item: any, index: number) =>
processListItem(item, `${start + index}.`),
)
.join("\n");
}
case "taskList":
return nodeContent.map((item: any) => processTaskItem(item)).join("\n");
case "taskItem":
// Delegate to the same helper used by taskList so multi-block and
// nested task items render and indent consistently.
return processTaskItem(node);
case "listItem":
// Direct-listItem path (lists normally render via processListItem, which
// handles the marker + indentation). Blank line between block children so
// multiple paragraphs do not merge on re-parse; a `<!-- -->` entry
// (renderBlockChildren) separates adjacent sibling lists.
return renderBlockChildren(nodeContent, processNode).join("\n\n");
case "blockquote": {
// Prefix EVERY line of EVERY child with "> " and separate block-level
// children with a blank ">" line so code blocks / multi-paragraph
// quotes round-trip correctly. A `> <!-- -->` separator line is inserted
// between two adjacent same-family sibling lists (renderBlockChildren)
// so they stay distinct inside the quote.
const bqParts: string[] = [];
let prevBqType: string | undefined;
for (const n of nodeContent) {
if (adjacentListsMerge(prevBqType, n?.type)) {
bqParts.push(`> ${LIST_MARKER_SEPARATOR}`);
}
bqParts.push(
processNode(n)
.split("\n")
.map((line: string) => (line.length ? `> ${line}` : ">"))
.join("\n"),
);
prevBqType = n?.type;
}
return bqParts.join("\n>\n");
}
case "horizontalRule":
return "---";
case "hardBreak":
// Two trailing spaces before the newline encode a markdown hard break;
// a bare "\n" would be reimported as a soft break and lost.
return " \n";
case "image": {
const imgAttrs = node.attrs || {};
// #293 canon #4: a top-level image ALWAYS serializes as `![alt](src)`.
// Non-default layout/identity attrs (which markdown `![](src)` cannot
// express — width/height/align/size/attachmentId/aspectRatio/caption/
// title) are carried in an attached `<!--img {…}-->` comment on the SAME
// line, materialized back onto the <img> by markdown-to-prosemirror's
// applyCommentDirectives before generateJSON drops the comment.
// Escape the alt text: it sits in the `![alt]` label, which the importer
// re-parses as CommonMark inline content, so a markdown-active char in a
// realistic description ("Figure [1]", "the *new* logo") would break the
// round-trip — the image node vanishes / emphasis collapses. Same reason
// the link-form media (attachment/pdf/embed) escape their visible text.
const imgAlt = escapeLinkText(imgAttrs.alt ?? "");
// Neutralize characters that could break out of the markdown image
// URL: spaces/newlines and parentheses would terminate the (...) target
// and let a stored src inject following markdown/HTML. Percent-encode
// them so the URL stays a single inert token.
const imgSrc = encodeMdUrl(imgAttrs.src);
const base = `![${imgAlt}](${imgSrc})`;
// Build the img-comment JSON from the NON-DEFAULT attrs only, in a
// STABLE key order so the output is deterministic. An attr equal to its
// schema default is NOT emitted (predicate over the canonicalized attrs).
const json: Record<string, unknown> = {};
// Numeric sizing attrs are coerced to string in the payload: the import
// side (applyCommentDirectives) writes them as DOM attributes and the
// schema's parseHTML reads them back as strings, so a numeric value would
// otherwise round-trip as `420 -> "420"` and produce a one-time spurious
// git diff. Emitting the string form up front keeps the round-trip
// byte-stable whether the source attr was a number or a string (mirrors
// the legacy imageToHtml, which stringified via `width="…"`).
if (imgAttrs.width != null) json.width = String(imgAttrs.width);
if (imgAttrs.height != null) json.height = String(imgAttrs.height);
// align default is unified to "center" (#293 canon #4): treat BOTH null
// and "center" as the default and omit them, so a bare/center image stays
// clean `![](src)` and only left/right emits `"align"`.
if (imgAttrs.align != null && imgAttrs.align !== "center")
json.align = imgAttrs.align;
if (imgAttrs.size != null) json.size = String(imgAttrs.size);
if (imgAttrs.aspectRatio != null)
json.aspectRatio = String(imgAttrs.aspectRatio);
if (imgAttrs.attachmentId) json.attachmentId = imgAttrs.attachmentId;
if (imgAttrs.caption) json.caption = imgAttrs.caption;
if (imgAttrs.title) json.title = imgAttrs.title;
// No non-default attrs -> bare image, no trailing space, no comment.
if (Object.keys(json).length === 0) return base;
// attachedCommentFor defuses any `--` in a value (e.g. a caption that
// contains the comment-closing `-->`) so the payload can never close the
// HTML comment early; JSON.parse on the import side restores it verbatim.
return `${base} ${attachedCommentFor("img", json)}`;
}
case "video": {
// #293 canon #8 (image-form): a top-level video serializes as
// `![](src)<!--video {…}-->`. The bare `![](src)` is ALWAYS followed by
// the `video` discriminator comment — a bare image with NO comment is an
// `image`, never sniffed by URL — so the comment is REQUIRED even when
// there are no extra attrs (emitted name-only as `<!--video-->`). src
// lives in the markdown target; every OTHER non-default attr rides in the
// comment JSON (stable key order, numerics stringified for byte-stability
// exactly like canon #4). align default "center" is omitted. The schema
// `<div><video>` form is still emitted on the raw-HTML path (blockToHtml)
// via videoToHtml, since comment nodes are dropped inside columns/cells.
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.src);
const json: Record<string, unknown> = {};
if (attrs.alt) json.alt = attrs.alt;
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
if (attrs.width != null) json.width = String(attrs.width);
if (attrs.height != null) json.height = String(attrs.height);
if (attrs.size != null) json.size = String(attrs.size);
if (attrs.align != null && attrs.align !== "center")
json.align = attrs.align;
if (attrs.aspectRatio != null)
json.aspectRatio = String(attrs.aspectRatio);
return `![](${src})${standaloneCommentFor("video", json)}`;
}
case "youtube": {
// #293 canon #8 (image-form): `![](url)<!--youtube {…}-->`. Same rules as
// video — src in the target, other non-default attrs (width/height/align,
// align!=center) in the ALWAYS-emitted discriminator comment. The
// div[data-type="youtube"] form stays on the raw-HTML path (youtubeToHtml).
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.src);
const json: Record<string, unknown> = {};
if (attrs.width != null) json.width = String(attrs.width);
if (attrs.height != null) json.height = String(attrs.height);
if (attrs.align != null && attrs.align !== "center")
json.align = attrs.align;
return `![](${src})${standaloneCommentFor("youtube", json)}`;
}
case "table": {
// A GFM pipe table cannot represent merged cells. If ANY cell carries
// colspan>1 or rowspan>1, a pipe table would corrupt the grid on
// re-import, so emit the WHOLE table as raw HTML <table> instead: the
// schema's table family parseHTML (tag table/tr/td/th, with colspan/
// rowspan read from the same-named HTML attrs and align via parseHTML)
// round-trips it faithfully. Otherwise keep the lighter GFM pipe table.
const tableRows: any[] = nodeContent;
if (tableRows.length === 0) return "";
const hasSpan = tableRows.some((row: any) =>
(row.content || []).some(
(cell: any) =>
(cell.attrs?.colspan ?? 1) > 1 || (cell.attrs?.rowspan ?? 1) > 1,
),
);
// A GFM pipe table also cannot hold a cell with block content (a list,
// code block, paragraphs) — it would be flattened to one line and lost
// (review #8). Force the HTML form for those too.
const hasMultiBlockCell = tableRows.some((row: any) =>
(row.content || []).some((cell: any) => cellIsMultiBlock(cell)),
);
if (hasSpan || hasMultiBlockCell) {
return tableToHtml(tableRows);
}
// No merged cells: emit a GFM table (header row + separator) so the
// markdown can be parsed back into a table on re-import.
const rows = tableRows.map(processNode);
const headerCells = tableRows[0]?.content || [];
const columns = headerCells.length || 1;
// Derive alignment markers (:--, :-:, --:) from each header cell.
const markers = Array.from({ length: columns }, (_, i) => {
const align = headerCells[i]?.attrs?.align;
switch (align) {
case "left":
return ":--";
case "center":
return ":-:";
case "right":
return "--:";
default:
return "---";
}
});
const separator = "| " + markers.join(" | ") + " |";
return [rows[0], separator, ...rows.slice(1)].join("\n");
}
case "tableRow":
return "| " + nodeContent.map(processNode).join(" | ") + " |";
case "tableCell":
case "tableHeader": {
// Join multiple block children with a space (not "") so adjacent blocks
// like a paragraph followed by a list don't collide into "line1- a".
// Then collapse newlines and escape pipes so a cell containing "|" or a
// line break cannot corrupt the surrounding GFM row.
return nodeContent
.map(processNode)
.join(" ")
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|");
}
case "callout": {
// Obsidian-native callout: `> [!type]` opener + a blockquote (`>`-prefixed)
// body, so it renders as a callout in Obsidian. The importer parses both
// this and the legacy `:::type` fence (existing vaults). Each body line is
// blockquote-prefixed; a blank line becomes a bare `>` so the callout is
// not split.
const calloutType = (node.attrs?.type || "info").toLowerCase();
const calloutBody = renderBlockChildren(nodeContent, processNode)
// Blank line between block children (rendered as a bare `>` after the
// prefix pass below) so multiple paragraphs stay separate nodes instead
// of merging on re-parse — same rule blockquote already uses. A
// `<!-- -->` entry (renderBlockChildren) separates adjacent sibling lists.
.join("\n\n")
.split("\n")
.map((l: string) => (l.length ? `> ${l}` : ">"))
.join("\n");
return `> [!${calloutType}]\n${calloutBody}`;
}
case "details": {
// The `open` (collapsed/expanded) state lives on the details node, NOT on
// the summary, so emit the <details> wrapper HERE carrying it — otherwise
// the open state is dropped on a round trip. The schema's details node
// parses `open` back from the attribute.
const open = node.attrs?.open ? " open" : "";
return `<details${open}>\n${nodeContent.map(processNode).join("")}</details>`;
}
case "detailsSummary":
return `<summary>${renderInlineChildren(nodeContent)}</summary>\n\n`;
case "detailsContent":
// Blank line between block children so multiple paragraphs in a details
// body survive as separate nodes (a single "\n" merges them on re-parse);
// a `<!-- -->` entry (renderBlockChildren) separates adjacent sibling lists.
return `${renderBlockChildren(nodeContent, processNode).join("\n\n")}\n`;
case "mathInline": {
// #293 canon #6: inline math serializes as Obsidian-native `$LaTeX$`
// (readable, re-parsed by the importer's marked inline extension). A
// literal `$` inside the LaTeX is escaped `\$` so it cannot close the
// span early (the importer decodes `\$`→`$`). When the LaTeX cannot be
// safely fenced (empty, whitespace-edged, multi-line, or an ambiguous
// backslash-before-`$`), fall back to the LOSSLESS schema-HTML `<span>`
// form (inlineMathSerializable). A following-sibling digit — which would
// also break the pandoc closing rule — is handled by renderInlineChildren
// (this case cannot see siblings).
const inlineMath = node.attrs?.text || "";
if (!inlineMathSerializable(inlineMath)) {
return mathInlineHtml(inlineMath);
}
return `$${encodeInlineMathLatex(inlineMath)}$`;
}
case "mathBlock": {
// #293 canon #6: block math serializes as a `$$` fence on its own lines
// (`$$\n<latex>\n$$`), so multi-line LaTeX is preserved. If the LaTeX
// itself contains a `$$` (which would close the fence early — essentially
// never valid inside a single math node), fall back to the lossless
// schema-HTML `<div>` form.
const blockMath = node.attrs?.text || "";
if (blockMath.includes("$$")) {
return mathBlockHtml(blockMath);
}
return `$$\n${blockMath}\n$$`;
}
case "mention": {
// Emit span[data-type="mention"] with the schema's data-* attributes so
// generateJSON rebuilds the mention node instead of leaving "@label"
// plain text that cannot re-parse.
const attrs = node.attrs || {};
const parts: string[] = [`data-type="mention"`];
if (attrs.id) parts.push(`data-id="${escapeAttr(attrs.id)}"`);
if (attrs.label)
parts.push(`data-label="${escapeAttr(attrs.label)}"`);
if (attrs.entityType)
parts.push(`data-entity-type="${escapeAttr(attrs.entityType)}"`);
if (attrs.entityId)
parts.push(`data-entity-id="${escapeAttr(attrs.entityId)}"`);
if (attrs.slugId)
parts.push(`data-slug-id="${escapeAttr(attrs.slugId)}"`);
if (attrs.creatorId)
parts.push(`data-creator-id="${escapeAttr(attrs.creatorId)}"`);
if (attrs.anchorId)
parts.push(`data-anchor-id="${escapeAttr(attrs.anchorId)}"`);
// Keep the label as visible text content too; the schema reads attrs
// from data-*, so the inner text is purely cosmetic and harmless.
const mentionLabel = attrs.label || attrs.id || "";
// The label is visible element TEXT content here (the data-* attrs above
// carry the real values), so escape it for the text context, not attrs.
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
}
case "attachment": {
// #293 canon #8 (link-form): `[filename](src)<!--attachment {…}-->`. The
// schema's `url` is the markdown target; the VISIBLE text is the filename
// (`attrs.name`, escaped so `[`/`]` cannot break the label). Every OTHER
// non-default attr (mime/size/attachmentId) rides in the ALWAYS-emitted
// discriminator comment — a bare `[text](src)` with no comment is a plain
// link, never an attachment. The div[data-type="attachment"] form stays
// on the raw-HTML path (attachmentToHtml) for columns/cells.
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.url);
const text = escapeLinkText(attrs.name ?? "");
const json: Record<string, unknown> = {};
if (attrs.mime) json.mime = attrs.mime;
if (attrs.size != null) json.size = String(attrs.size);
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
return `[${text}](${src})${standaloneCommentFor("attachment", json)}`;
}
case "drawio":
case "excalidraw": {
// #293 canon #8 (image-form): `![](src)<!--drawio|excalidraw {…}-->`. src
// in the target; title/alt/width/height/size/aspectRatio/align(!=center)/
// attachmentId in the ALWAYS-emitted discriminator comment (the NAME
// selects drawio vs excalidraw). The div[data-type=…] form stays on the
// raw-HTML path (diagramToHtml).
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.src);
const json: Record<string, unknown> = {};
if (attrs.title != null) json.title = attrs.title;
if (attrs.alt != null) json.alt = attrs.alt;
if (attrs.width != null) json.width = String(attrs.width);
if (attrs.height != null) json.height = String(attrs.height);
if (attrs.size != null) json.size = String(attrs.size);
if (attrs.aspectRatio != null)
json.aspectRatio = String(attrs.aspectRatio);
if (attrs.align != null && attrs.align !== "center")
json.align = attrs.align;
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
return `![](${src})${standaloneCommentFor(type, json)}`;
}
case "embed": {
// #293 canon #8 (link-form): `[provider](src)<!--embed {…}-->`. src in
// the target; the VISIBLE text is the provider (`attrs.provider`). align/
// width/height ride in the discriminator comment only when non-default
// (align "center", width 800, height 600 are the schema defaults). The
// div[data-type="embed"] form stays on the raw-HTML path (embedToHtml).
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.src);
const text = escapeLinkText(attrs.provider ?? "");
const json: Record<string, unknown> = {};
if (attrs.align != null && attrs.align !== "center")
json.align = attrs.align;
if (attrs.width != null && attrs.width !== 800)
json.width = String(attrs.width);
if (attrs.height != null && attrs.height !== 600)
json.height = String(attrs.height);
return `[${text}](${src})${standaloneCommentFor("embed", json)}`;
}
case "audio": {
// #293 canon #8 (image-form): `![](src)<!--audio {…}-->`. src in the
// target; attachmentId/size in the ALWAYS-emitted discriminator comment.
// The <div><audio> form stays on the raw-HTML path (audioToHtml).
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.src);
const json: Record<string, unknown> = {};
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
if (attrs.size != null) json.size = String(attrs.size);
return `![](${src})${standaloneCommentFor("audio", json)}`;
}
case "pdf": {
// #293 canon #8 (link-form): `[filename](src)<!--pdf {…}-->`. src in the
// target; the VISIBLE text is the filename (`attrs.name`). attachmentId/
// size/width/height ride in the ALWAYS-emitted discriminator comment. The
// div[data-type="pdf"] form stays on the raw-HTML path (pdfToHtml).
const attrs = node.attrs || {};
const src = encodeMdUrl(attrs.src);
const text = escapeLinkText(attrs.name ?? "");
const json: Record<string, unknown> = {};
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
if (attrs.size != null) json.size = String(attrs.size);
if (attrs.width != null) json.width = String(attrs.width);
if (attrs.height != null) json.height = String(attrs.height);
return `[${text}](${src})${standaloneCommentFor("pdf", json)}`;
}
case "columns": {
// Emit the schema-matching div[data-type="columns"] wrapper so the
// multi-column layout survives. Without a case the children were
// concatenated with no separator and the text merged. The schema reads
// layout from data-layout and widthMode from data-width-mode. The whole
// block is raw HTML, so render children via blockToHtml (NOT markdown,
// which marked would not re-parse inside a raw HTML block).
const attrs = node.attrs || {};
const parts: string[] = [`data-type="columns"`];
if (attrs.layout)
parts.push(`data-layout="${escapeAttr(attrs.layout)}"`);
if (attrs.widthMode && attrs.widthMode !== "normal")
parts.push(`data-width-mode="${escapeAttr(attrs.widthMode)}"`);
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
return `<div ${parts.join(" ")}>${inner}</div>`;
}
case "column": {
// Emit the schema-matching div[data-type="column"]; the schema reads the
// column width from data-width. Children are rendered as HTML so their
// formatting survives inside this raw HTML block.
const attrs = node.attrs || {};
const parts: string[] = [`data-type="column"`];
if (attrs.width)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
return `<div ${parts.join(" ")}>${inner}</div>`;
}
case "pageBreak":
// #293 canon #5: a pageBreak is a STANDALONE machinery comment on its
// own line — `<!--pagebreak-->` — which is invisible in any markdown
// renderer yet round-trips (the importer materializes it back into the
// pageBreak atom; see markdown-to-prosemirror's applyCommentDirectives).
// This keeps the markdown readable instead of leaking a raw <div>. The
// schema div-form is still emitted on the raw-HTML path (blockToHtml),
// because DOM parsers drop comment nodes inside columns/cells.
return standaloneCommentFor("pagebreak");
case "subpages":
// #293 canon #5: a subpages block serializes as a STANDALONE comment —
// `<!--subpages-->` by default, or `<!--subpages {"recursive":true}-->`
// when the recursive toggle is set. Same rationale as pageBreak: readable
// markdown, invisible in renderers, re-materialized on import. The div
// form (`<div data-type="subpages">`) is retained on the raw-HTML path
// (blockToHtml) since comments cannot survive a DOM parse inside columns.
return node.attrs?.recursive
? standaloneCommentFor("subpages", { recursive: true })
: standaloneCommentFor("subpages");
case "status": {
// Inline status pill. The schema reads the label from the element's
// TEXT content and the color from data-color, so emit both; without a
// case this inline atom fell through to `default` and collapsed to "".
const attrs = node.attrs || {};
const statusColor = attrs.color || "gray";
return `<span data-type="status" data-color="${escapeAttr(statusColor)}">${escapeHtmlText(attrs.text ?? "")}</span>`;
}
case "htmlEmbed": {
// Block atom; the schema reads the raw source from a base64-encoded
// data-source attribute (and an optional fixed height from data-height).
// Encode with the shared helper so it decodes symmetrically on import.
const attrs = node.attrs || {};
const parts: string[] = [
`data-type="htmlEmbed"`,
`data-source="${escapeAttr(encodeHtmlEmbedSource(attrs.source ?? ""))}"`,
];
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "footnoteReference": {
// #293 canon #2: on the top-level/inline markdown path a footnote is
// written AT its reference as `^[body]` (Pandoc/Obsidian inline
// footnote). Look the body up by id in the pre-scanned definitions map
// and render it inline. An ORPHAN reference (no matching definition) has
// no body anywhere, so it emits `^[]` — a footnote with an empty body,
// the lossless choice (the id was already derived, never authored). The
// separate `<section data-footnotes>` list is NOT emitted (see the
// footnotesList/doc cases); the raw-HTML path carries the body on the
// <sup> instead (inlineToHtml).
const def = footnoteDefs.get(node.attrs?.id);
return `^[${def ? renderFootnoteBody(def) : ""}]`;
}
case "footnotesList":
// Bodies are inlined at their references (`^[…]`), so the bottom list
// emits NOTHING in markdown. The doc case skips it outright to avoid a
// phantom blank line; this case guards any non-doc-level occurrence.
return "";
case "footnoteDefinition":
// Reached only via a footnotesList (handled above) — never rendered on
// its own on the markdown path; its body rides at the reference.
return "";
case "pageEmbed": {
// #293 canon #8 (standalone): a whole-page live embed serializes as a
// lone discriminator comment on its own line — `<!--pageembed-->` or
// `<!--pageembed {"sourcePageId":…}-->`. Readable markdown, invisible in
// renderers, re-materialized on import. The div[data-type="pageEmbed"]
// form stays on the raw-HTML path (pageEmbedToHtml) for columns/cells.
const attrs = node.attrs || {};
const json: Record<string, unknown> = {};
if (attrs.sourcePageId) json.sourcePageId = attrs.sourcePageId;
return standaloneCommentFor("pageembed", json);
}
case "transclusionReference": {
// #293 canon #8 (standalone): a live block/page reference serializes as a
// lone `<!--transclusion {…}-->` comment carrying sourcePageId +
// transclusionId (the data-loss-critical id links). The
// div[data-type="transclusionReference"] form stays on the raw-HTML path
// (transclusionReferenceToHtml). (transclusionSource is unchanged — it has
// block children and keeps recursing through processNode.)
const attrs = node.attrs || {};
const json: Record<string, unknown> = {};
if (attrs.sourcePageId) json.sourcePageId = attrs.sourcePageId;
if (attrs.transclusionId) json.transclusionId = attrs.transclusionId;
return standaloneCommentFor("transclusion", json);
}
case "transclusionSource": {
// Sync-source container; the schema reads data-id and re-parses its
// block children, so render them as schema-matching HTML.
const attrs = node.attrs || {};
const idAttr = attrs.id ? ` data-id="${escapeAttr(attrs.id)}"` : "";
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
return `<div data-type="transclusionSource"${idAttr}>${inner}</div>`;
}
default:
// Fallback: process children
return nodeContent.map(processNode).join("");
}
};
// Render a run of inline children to MARKDOWN, with the #293 canon #6
// inline-math guard. A `mathInline` serialized as `$…$` whose FOLLOWING
// sibling renders starting with a DIGIT would put a digit right after the
// closing `$`, which the pandoc inline rule refuses to parse as math (the
// currency guard) — so the node would re-import as literal text (data loss).
// For that node ONLY we fall back to the lossless schema-HTML `<span>` form.
// Every other inline node is rendered exactly as processNode would, so output
// is unchanged whenever no math sits directly before a digit.
const renderInlineChildren = (nodes: any[]): string => {
const parts = nodes.map(processNode);
for (let i = 0; i < nodes.length - 1; i++) {
if (
nodes[i]?.type === "mathInline" &&
parts[i].startsWith("$") &&
/^[0-9]/.test(parts[i + 1] || "")
) {
parts[i] = mathInlineHtml(nodes[i].attrs?.text || "");
}
}
return parts.join("");
};
// Render inline content (text runs + their marks) to HTML. Used by the raw
// HTML fallbacks (spanned tables, columns) where marked will NOT re-parse
// markdown, so backtick/asterisk/bracket syntax would otherwise leak as
// literal characters. Each mark is mirrored to the HTML the schema's parseHTML
// accepts so it re-imports as the matching ProseMirror mark.
const inlineToHtml = (inlineNodes: any[]): string =>
(inlineNodes || [])
.map((n: any) => {
if (n.type === "hardBreak") return "<br>";
// #293 canon #6: on the raw-HTML path (columns/spanned cells) marked does
// NOT re-parse markdown, so inline math MUST stay the schema-HTML `<span>`
// form here — a `$…$` fence would land as literal text on re-import.
if (n.type === "mathInline") return mathInlineHtml(n.attrs?.text || "");
if (n.type === "footnoteReference") {
// #293 canon #2 raw-HTML path (columns/spanned cells): marked does NOT
// re-parse `^[…]` inside raw HTML, so the note text rides ON the <sup>
// in a `data-fn-text` attribute (encoded exactly like the `^[…]`
// inner). NO id is emitted here (F1): the importer's footnote post-pass
// assigns ids by dedup-ing on the EXACT body text, so a column footnote
// and an inline `^[…]` with the same body merge to one definition and
// DIFFERENT bodies can never collide. The post-pass reads data-fn-text,
// sets data-id, builds the doc-level definition, and strips the attr.
const def = footnoteDefs.get(n.attrs?.id);
const body = def ? renderFootnoteBody(def) : "";
return `<sup data-footnote-ref data-fn-text="${escapeAttr(body)}"></sup>`;
}
if (n.type !== "text") {
// Other inline atoms (mention, status) already emit schema HTML from
// processNode.
return processNode(n);
}
let t = escapeHtmlText(n.text || "");
for (const mark of n.marks || []) {
switch (mark.type) {
case "bold":
t = `<strong>${t}</strong>`;
break;
case "italic":
t = `<em>${t}</em>`;
break;
case "code":
t = `<code>${t}</code>`;
break;
case "strike":
t = `<s>${t}</s>`;
break;
case "underline":
t = `<u>${t}</u>`;
break;
case "subscript":
t = `<sub>${t}</sub>`;
break;
case "superscript":
t = `<sup>${t}</sup>`;
break;
case "link": {
// Mirror the top-level link path: emit the optional `title` too
// (the schema's link mark carries a `title` attr — see
// DocmostAttributes link globals — so <a title> round-trips). A
// link with a title inside a column/spanned cell would otherwise
// drop it on re-import.
const linkTitle = mark.attrs?.title;
const titleAttr = linkTitle
? ` title="${escapeAttr(String(linkTitle))}"`
: "";
t = `<a href="${escapeAttr(mark.attrs?.href || "")}"${titleAttr}>${t}</a>`;
break;
}
case "highlight":
t = mark.attrs?.color
? `<mark style="background-color: ${escapeAttr(mark.attrs.color)}">${t}</mark>`
: `<mark>${t}</mark>`;
break;
case "textStyle":
if (mark.attrs?.color)
t = `<span style="color: ${escapeAttr(mark.attrs.color)}">${t}</span>`;
break;
case "spoiler":
// Emit the same raw inline HTML the top-level path uses. The
// schema's Spoiler mark parses span[data-spoiler] back on import,
// so a spoiler inside a column/spanned cell survives the round
// trip (without this case the mark was silently lost here).
t = `<span data-spoiler="true">${t}</span>`;
break;
case "comment":
// Inline comment anchor inside a raw-HTML container (columns /
// spanned table cells), so commented text there also round-trips.
if (mark.attrs?.commentId) {
// Hide resolved anchors from agent reads: drop the wrapper and
// keep only the bare text. Active anchors keep their wrapper.
if (mark.attrs?.resolved && dropResolvedCommentAnchors) {
break;
}
const r = mark.attrs?.resolved ? ` data-resolved="true"` : "";
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
}
break;
}
}
return t;
})
.join("");
// Emit the schema-matching <img> for an image node. Shared so the image is
// emitted as real HTML wherever a raw-HTML container needs it (inside a column
// or a spanned table cell), where markdown `![](...)` would NOT be re-parsed
// and would survive as literal text. The Image extension reads src/alt from
// the standard attributes; the Docmost extra attrs (width/height/align/size/
// attachmentId/aspectRatio) are global attributes read from same-named DOM
// attributes, so emit them by name.
const imageToHtml = (node: any): string => {
const attrs = node.attrs || {};
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.alt) parts.push(`alt="${escapeAttr(attrs.alt)}"`);
if (attrs.title) parts.push(`title="${escapeAttr(attrs.title)}"`);
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
// #293 canon #4: image align default is unified to "center", so a center
// (or unset) image no longer emits a redundant align="center" here — only a
// genuinely non-default alignment (left/right) is written.
if (attrs.align && attrs.align !== "center")
parts.push(`align="${escapeAttr(attrs.align)}"`);
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
// Plain-text caption (issue #221). Markdown `![](src)` cannot carry it, so
// emit it as data-caption; the schema's image `caption` attr parses it back.
if (attrs.caption) parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
return `<img ${parts.join(" ")}>`;
};
// Emit the schema-matching div[data-type="callout"] for a callout node. The
// schema reads the banner type from data-callout-type. Children are rendered
// as HTML so they survive inside a raw-HTML container.
const calloutToHtml = (node: any): string => {
const type = (node.attrs?.type || "info").toLowerCase();
const inner = (node.content || []).map(blockToHtml).join("");
return `<div data-type="callout" data-callout-type="${escapeAttr(type)}">${inner}</div>`;
};
// Emit a schema-matching <details> tree. The schema parses <details>,
// summary[data-type="detailsSummary"], and div[data-type="detailsContent"].
// The `open` (collapsed/expanded) state lives on the details node and the
// schema parses it back from the attribute, so emit it here too — mirroring
// the top-level `details` case — or a NESTED details (inside columns/cells)
// would silently drop `open:true` every round trip.
const detailsToHtml = (node: any): string => {
const open = node.attrs?.open ? " open" : "";
const inner = (node.content || []).map(blockToHtml).join("");
return `<details${open}>${inner}</details>`;
};
const detailsSummaryToHtml = (node: any): string =>
`<summary data-type="detailsSummary">${inlineToHtml(node.content || [])}</summary>`;
const detailsContentToHtml = (node: any): string => {
const inner = (node.content || []).map(blockToHtml).join("");
return `<div data-type="detailsContent">${inner}</div>`;
};
// Emit the schema-matching taskList/taskItem HTML. bridgeTaskLists (in
// collaboration.ts) recognizes ul[data-type="taskList"] with
// li[data-type="taskItem"][data-checked]; emitting that directly here keeps
// task lists inside columns/cells from degrading to literal "- [ ]" text.
const taskListToHtml = (node: any): string => {
const items = (node.content || [])
.map((it: any) => {
const checked = it.attrs?.checked ? "true" : "false";
return `<li data-type="taskItem" data-checked="${checked}">${blockChildrenToHtml(it)}</li>`;
})
.join("");
return `<ul data-type="taskList">${items}</ul>`;
};
// Render a block node to HTML for the raw-HTML containers (spanned tables,
// columns). marked does NOT re-parse markdown inside a raw-HTML block, so
// EVERY block type that can appear inside a column or a spanned cell must be
// emitted as schema-matching HTML here — never as markdown, or it would land
// as literal text on re-import. Nodes whose processNode case already produces
// schema-matching HTML (math/media/embed/attachment/nested columns/spanned
// table) are delegated to processNode; the markdown-emitting cases
// (image/blockquote/callout/details/hr/taskList) get explicit HTML here.
const blockToHtml = (block: any): string => {
const children = block.content || [];
switch (block.type) {
case "paragraph": {
// Carry textAlign here too (symmetric with the processNode paragraph
// case): a paragraph nested inside an HTML container (column/table/
// callout) would otherwise drop its alignment on the round trip.
const pAlign = block.attrs?.textAlign;
const pStyle =
pAlign && pAlign !== "left"
? ` style="text-align:${escapeAttr(pAlign)}"`
: "";
return `<p${pStyle}>${inlineToHtml(children)}</p>`;
}
case "heading": {
// Same for a heading nested in an HTML container: emit the alignment as
// an inline style (symmetric with the processNode heading case) so it is
// not silently dropped. Clamp the level to a valid HTML heading tag.
const level = Math.min(6, Math.max(1, block.attrs?.level || 1));
const hAlign = block.attrs?.textAlign;
const hStyle =
hAlign && hAlign !== "left"
? ` style="text-align:${escapeAttr(hAlign)}"`
: "";
return `<h${level}${hStyle}>${inlineToHtml(children)}</h${level}>`;
}
case "bulletList":
return `<ul>${children
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
.join("")}</ul>`;
case "orderedList": {
// Carry a non-1 `start` on the raw-HTML path (columns/spanned cells) via
// the <ol start="N"> attribute, which the tiptap parser reads back into
// attrs.start. A default (start=1) list stays a bare <ol>. See #351.
// Use the SAME integer-≥-2 guard as the markdown path: a fractional start
// would emit `start="2.5"` → parseInt→2 on import (path divergence), and a
// 0/negative start is not a valid explicit start either.
const raw = block.attrs?.start;
const start = Number.isInteger(raw) && (raw as number) > 1 ? (raw as number) : 1;
const startAttr = start > 1 ? ` start="${start}"` : "";
return `<ol${startAttr}>${children
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
.join("")}</ol>`;
}
case "codeBlock": {
const lang = block.attrs?.language || "";
// The code itself is element TEXT content (between <code> tags), so it
// must escape < > & — NOT the attribute escaper. The language rides in
// a class ATTRIBUTE, so it uses escapeAttr.
//
// Read the child text RAW (as `case "codeBlock"` does) and keep it
// VERBATIM — do NOT strip the trailing newline. Unlike the markdown fence
// path (which strips then relies on marked re-adding one `\n`), the schema
// codeBlock parseHTML reads the `<code>` text content back byte-for-byte,
// so stripping here would drop a trailing newline the node legitimately
// carries and break the round trip inside a column/cell.
const code = escapeHtmlText(
children
.map((child: any) =>
typeof child?.text === "string" ? child.text : "",
)
.join(""),
);
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : "";
return `<pre><code${cls}>${code}</code></pre>`;
}
case "image":
return imageToHtml(block);
case "blockquote":
return `<blockquote>${children.map(blockToHtml).join("")}</blockquote>`;
case "horizontalRule":
return "<hr>";
case "callout":
return calloutToHtml(block);
case "details":
return detailsToHtml(block);
case "detailsSummary":
return detailsSummaryToHtml(block);
case "detailsContent":
return detailsContentToHtml(block);
case "taskList":
return taskListToHtml(block);
case "taskItem":
// A bare taskItem (outside a taskList) still needs a wrapping list so
// the schema parses it; wrap it in a single-item taskList.
return taskListToHtml({ content: [block] });
// A table nested in a raw-HTML block (e.g. inside a column) MUST be the
// HTML <table> form — a GFM pipe table here would not be re-parsed by
// marked and would round-trip as literal "| a | b |" text (review #7).
case "table":
return tableToHtml(block.content || []);
// #293 canon #5: on the TOP-LEVEL path (processNode) subpages/pageBreak
// serialize as standalone `<!--...-->` comments, but a comment node is
// discarded by the DOM parse stage (jsdom/parse5) that reads back a raw-
// HTML block — so inside a column/cell the comment form would silently
// vanish (latent data loss; these atoms previously fell through to the
// `default` <div></div>). Here we KEEP the schema-matching div-form so the
// node survives the raw-HTML round trip.
case "pageBreak":
return `<div data-type="pageBreak"></div>`;
case "subpages": {
const recursive = block.attrs?.recursive ? ` data-recursive="true"` : "";
return `<div data-type="subpages"${recursive}></div>`;
}
// #293 canon #8: the media/discriminator family now serializes at TOP LEVEL
// (processNode) as md-target + `<!--name-->` comment. A comment node is
// DROPPED by the DOM parse stage that reads a raw-HTML block back, so inside
// a column/cell the comment form would silently vanish (data loss). Give
// each an EXPLICIT schema-HTML case here (via the shared media-html builders
// — the SAME output processNode used to emit, and the same the importer
// rebuilds) instead of delegating to processNode's md+comment form.
case "video":
return videoToHtml(block.attrs || {});
case "audio":
return audioToHtml(block.attrs || {});
case "pdf":
return pdfToHtml(block.attrs || {});
case "youtube":
return youtubeToHtml(block.attrs || {});
case "embed":
return embedToHtml(block.attrs || {});
case "attachment":
return attachmentToHtml(block.attrs || {});
case "drawio":
case "excalidraw":
return diagramToHtml(block.type, block.attrs || {});
case "pageEmbed":
return pageEmbedToHtml(block.attrs || {});
case "transclusionReference":
return transclusionReferenceToHtml(block.attrs || {});
// #293 canon #6: on the TOP-LEVEL path (processNode) mathBlock now
// serializes as a `$$…$$` fence, but marked does NOT re-parse markdown
// inside a raw-HTML block, so inside a column/cell it MUST stay the
// schema-HTML `<div>` form or it would land as literal `$$…$$` text on
// re-import. Give it an EXPLICIT case here (the same form the importer
// rebuilds) instead of delegating to processNode's fence form.
case "mathBlock":
return mathBlockHtml(block.attrs?.text || "");
// #293 canon #2: on the markdown path a footnotesList emits nothing (the
// note body rides at each `^[…]` reference). But a raw-HTML container
// drops comment/markdown reconstruction, so KEEP the schema-matching
// <section>/<div> HTML here so a footnotesList that ever lands inside a
// column/cell still round-trips via the schema's parseHTML instead of
// vanishing. (Normally the list is doc-level and never nests here.)
case "footnotesList": {
const inner = (block.content || []).map(blockToHtml).join("");
return `<section data-footnotes>${inner}</section>`;
}
case "footnoteDefinition": {
const idAttr = block.attrs?.id
? ` data-id="${escapeAttr(block.attrs.id)}"`
: "";
const inner = (block.content || []).map(blockToHtml).join("");
return `<div data-footnote-def${idAttr}>${inner}</div>`;
}
// columns/column, htmlEmbed, transclusionSource already emit
// schema-matching HTML from processNode.
case "columns":
case "column":
case "htmlEmbed":
case "transclusionSource":
return processNode(block);
default:
// Any still-unhandled block type: NEVER fall back to markdown inside a
// raw-HTML block (it would become literal text). Wrap its rendered
// children in a <div> so their content is preserved; if it has no block
// children, render its inline content instead.
if (children.length && children.some((c: any) => c.type !== "text")) {
return `<div>${children.map(blockToHtml).join("")}</div>`;
}
return `<div>${inlineToHtml(children)}</div>`;
}
};
// Render the block children of a list item to HTML (a listItem holds block+
// content). Mirrors processListItem but for the HTML fallback path.
const blockChildrenToHtml = (item: any): string =>
(item.content || []).map((b: any) => blockToHtml(b)).join("");
// Indent the rendered children of a list item under a marker prefix.
// Each child block is a (possibly multi-line) string. The very first physical
// line of the first child carries the marker (e.g. "- " or "1. "); EVERY
// other line — the remaining lines of the first child AND all lines of every
// subsequent child (nested lists, code blocks, extra paragraphs) — is indented
// to align under the marker. Without indenting these continuation lines, the
// 2nd/3rd line of a nested child collapses to column 0 and escapes the list.
//
// The continuation indent MUST equal the LIST marker width, which is not the
// same as the visible prefix width:
// - bullet "- " -> 2 columns
// - task "- [ ] " -> marker is still "- " (the "[ ] " is content), 2
// - ordered "1. "/"10. " -> 3/4 columns, scaling with the number's digits
// CommonMark anchors nested content to the marker column, so an ordered item
// indented to only 2 columns would be re-parsed as a sibling/loose content on
// re-import. Callers therefore pass the exact indent width to use.
const indentItemChildren = (
childStrings: string[],
prefix: string,
indentWidth: number,
): string => {
const indent = " ".repeat(indentWidth);
const lines: string[] = [];
childStrings.forEach((child, childIndex) => {
// Separate consecutive block children with a BLANK line so the item is a
// CommonMark "loose" list item and each block stays its own node. Without
// it, a second paragraph (`- a\n b`) is re-parsed as a lazy continuation
// of the first and the two merge into one paragraph — silent data loss.
// The blank line still sits INSIDE the item (the following block keeps the
// continuation indent), so nested lists/code blocks remain nested.
if (childIndex > 0) lines.push("");
child.split("\n").forEach((line, lineIndex) => {
if (childIndex === 0 && lineIndex === 0) {
// First physical line of the first block gets the marker.
lines.push(`${prefix} ${line}`);
} else {
// Indent every continuation line by the marker width; keep blank
// lines blank rather than emitting trailing whitespace.
lines.push(line.length ? `${indent}${line}` : "");
}
});
});
return lines.join("\n");
};
const processListItem = (item: any, prefix: string): string => {
const itemContent = item.content || [];
// A `<!-- -->` entry separates two adjacent same-family sublists inside the
// item so they do not merge on re-parse (see renderBlockChildren).
const childStrings = renderBlockChildren(itemContent, processNode);
if (childStrings.length === 0) return prefix;
// The rendered marker is `${prefix} ` (prefix + one space), so its width —
// and thus the continuation indent — is prefix.length + 1. This is correct
// for both bullet ("-" -> 2) and ordered ("1." -> 3, "10." -> 4) markers,
// since for those the visible prefix IS the list marker.
return indentItemChildren(childStrings, prefix, prefix.length + 1);
};
const processTaskItem = (item: any): string => {
const checked = item.attrs?.checked || false;
const checkbox = checked ? "[x]" : "[ ]";
const prefix = `- ${checkbox}`;
const itemContent = item.content || [];
// A `<!-- -->` entry separates two adjacent same-family sublists inside the
// item so they do not merge on re-parse (see renderBlockChildren).
const childStrings = renderBlockChildren(itemContent, processNode);
// An empty task item still needs its checkbox marker; without this guard
// the indent below produces "" and the "- [ ]"/"- [x]" row disappears.
if (childStrings.length === 0) return prefix;
// The list marker for a task item is just "- " (2 columns); the "[ ] "/"[x] "
// checkbox is item content, NOT part of the marker. So the continuation
// indent is a fixed 2 — do NOT derive it from the wider prefix.length.
return indentItemChildren(childStrings, prefix, 2);
};
return processNode(content).trim();
}