);
+ * - FootnoteDefinition: one editable note keyed by id ().
+ * The visible number is not stored; it is derived from reference order.
+ *
+ * priority 101 so this node's parse rule beats the Superscript mark's
+ * rule (otherwise an empty reference is parsed as an empty superscript
+ * mark and dropped). Keep in sync with editor-ext.
+ */
+const FootnoteReference = Node.create({
+ name: "footnoteReference",
+ priority: 101,
+ group: "inline",
+ inline: true,
+ atom: true,
+ selectable: true,
+ draggable: false,
+ addAttributes() {
+ return {
+ id: {
+ default: null,
+ parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
+ renderHTML: (attrs: Record) =>
+ attrs.id ? { "data-id": attrs.id } : {},
+ },
+ };
+ },
+ parseHTML() {
+ return [{ tag: "sup[data-footnote-ref]", priority: 100 }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }];
+ },
+});
+
+const FootnotesList = Node.create({
+ name: "footnotesList",
+ group: "block",
+ content: "footnoteDefinition+",
+ isolating: true,
+ selectable: false,
+ defining: true,
+ parseHTML() {
+ return [{ tag: "section[data-footnotes]" }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0];
+ },
+});
+
+const FootnoteDefinition = Node.create({
+ name: "footnoteDefinition",
+ content: "paragraph+",
+ defining: true,
+ isolating: true,
+ selectable: false,
+ addAttributes() {
+ return {
+ id: {
+ default: null,
+ parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
+ renderHTML: (attrs: Record) =>
+ attrs.id ? { "data-id": attrs.id } : {},
+ },
+ };
+ },
+ parseHTML() {
+ return [{ tag: "div[data-footnote-def]" }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0];
+ },
+});
+
/** Inline KaTeX expression. Carries the LaTeX source in `text`. */
const MathInline = Node.create({
name: "mathInline",
@@ -1069,6 +1146,9 @@ export const docmostExtensions = [
TableCell,
TableHeader,
Mention,
+ FootnoteReference,
+ FootnotesList,
+ FootnoteDefinition,
MathInline,
MathBlock,
Details,
diff --git a/packages/mcp/src/lib/markdown-converter.ts b/packages/mcp/src/lib/markdown-converter.ts
index cbaa7042..4e35c995 100644
--- a/packages/mcp/src/lib/markdown-converter.ts
+++ b/packages/mcp/src/lib/markdown-converter.ts
@@ -430,6 +430,30 @@ export function convertProseMirrorToMarkdown(content: any): string {
return `@${escapeHtmlText(mentionLabel)}`;
}
+ case "footnoteReference": {
+ // Pandoc/GFM inline marker. The number is derived (not stored), so the
+ // id is the stable anchor.
+ const fnId = node.attrs?.id || "";
+ return fnId ? `[^${fnId}]` : "";
+ }
+
+ case "footnotesList":
+ // The container renders its definitions, each on its own `[^id]: ...`
+ // line. A blank line separates the body from the notes block.
+ return nodeContent.map(processNode).join("\n");
+
+ case "footnoteDefinition": {
+ const defId = node.attrs?.id || "";
+ // Collapse the definition's paragraphs into a single line; multi-line
+ // footnotes are a v2 refinement.
+ const defText = nodeContent
+ .map(processNode)
+ .join(" ")
+ .replace(/\s*\n+\s*/g, " ")
+ .trim();
+ return defId ? `[^${defId}]: ${defText}` : "";
+ }
+
case "attachment": {
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
// the schema stores name/url (plus mime/size/attachmentId). Emit the
diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts
index d8fba091..98269aff 100644
--- a/packages/mcp/src/lib/transforms.ts
+++ b/packages/mcp/src/lib/transforms.ts
@@ -264,6 +264,66 @@ export function noteItem(inlineNodes: any[]): any {
};
}
+/**
+ * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id:
+ * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
+ * (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
+ */
+export function footnoteDefinition(id: string, inlineNodes: any[]): any {
+ const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : [];
+ return {
+ type: "footnoteDefinition",
+ attrs: { id },
+ content: [{ type: "paragraph", attrs: { id: freshId() }, content }],
+ };
+}
+
+/**
+ * Replace every `[N]` body marker and `\u0000FN\u0000` comment placeholder in
+ * an inline content array with a real `footnoteReference` node, in reading
+ * order. `onMarker` is called for each replaced marker (with the original `[N]`
+ * number or the placeholder index) and returns the fresh footnote id to attach
+ * to the inserted node. Mutates `inline` in place.
+ */
+function replaceMarkersWithReferences(
+ inline: any[],
+ onMarker: (info: { oldNum?: number; phIdx?: number }) => string,
+): void {
+ const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
+ for (let i = 0; i < inline.length; i++) {
+ const n = inline[i];
+ if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") {
+ continue;
+ }
+ if (!re.test(n.text)) continue;
+ re.lastIndex = 0;
+
+ const marks = Array.isArray(n.marks) ? n.marks : [];
+ const parts: any[] = [];
+ let last = 0;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(n.text)) !== null) {
+ if (m.index > last) {
+ parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] });
+ }
+ const oldNum = m[1] != null ? Number(m[1]) : undefined;
+ const phIdx = m[2] != null ? Number(m[2]) : undefined;
+ const fnId = onMarker({ oldNum, phIdx });
+ parts.push({ type: "footnoteReference", attrs: { id: fnId } });
+ last = m.index + m[0].length;
+ }
+ if (last < n.text.length) {
+ parts.push({ ...n, text: n.text.slice(last), marks: [...marks] });
+ }
+ // Drop any zero-length text runs the slicing may have produced.
+ const cleaned = parts.filter(
+ (p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0),
+ );
+ inline.splice(i, 1, ...cleaned);
+ i += cleaned.length - 1;
+ }
+}
+
/**
* Convert a comment's markdown (e.g. `**Lead.** body...`) into inline
* ProseMirror nodes.
@@ -388,54 +448,91 @@ export function commentsToFootnotes(
}
const consumed: string[] = [];
- const noteByPh = new Map();
+ const noteInlineByPh = new Map();
(Array.isArray(comments) ? comments : []).forEach((c, i) => {
if (!c || !c.selection) return;
// Collision-proof sentinel delimited by NUL control chars, which never occur
- // in real Docmost prose — so the renumber regex below cannot mistake any body
- // text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
- // transient: the placeholder round-trips within this function (insertMarkerAfter
- // inserts it, the renumber pass replaces it with "[N]"), so it never persists
- // in a returned/pushed document.
+ // in real Docmost prose - so the marker regex cannot mistake any body text
+ // (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
+ // transient: the placeholder is inserted here and replaced by a
+ // footnoteReference node below; it never persists in a returned document.
const ph = `\u0000FN${i}\u0000`;
- // insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh
- // the `top` / `notesList` references that point into it.
+ // insertMarkerAfter returns a NEW cloned doc; reassign `working`.
const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, {
beforeBlock: notesIdx,
});
if (!r.inserted) return;
working = r.doc;
- noteByPh.set(ph, noteItem(mdToInlineNodes(c.content)));
+ noteInlineByPh.set(ph, mdToInlineNodes(c.content));
consumed.push(c.id);
});
// Re-resolve references into the (possibly re-cloned) working doc.
const top2: any[] = Array.isArray(working.content) ? working.content : [];
- const notesList2 = top2
- .slice(notesIdx)
- .find((n) => isObject(n) && n.type === "orderedList");
+ const notesIdx2 = top2.findIndex(
+ (n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading,
+ );
+ const oldListIndex = top2.findIndex(
+ (n) => isObject(n) && n.type === "orderedList",
+ );
+ const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null;
if (!notesList2) {
throw new Error("notes orderedList not found");
}
- const oldNotes: any[] = Array.isArray(notesList2.content)
+ // Inline content of each existing note (listItem -> paragraph -> inline).
+ const oldNoteInline = (Array.isArray(notesList2.content)
? notesList2.content
- : [];
- const newNotes: any[] = [];
- let seq = 0;
- // Match either an existing "[N]" marker or a NUL-delimited "\u0000FN\u0000"
- // placeholder, in reading order across the body (blocks before the notes heading).
- const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
- // Same range regex setCalloutRange uses to detect the disclaimer callout's
- // "[1]…[K]" range; used here to decide whether a top-level callout is the
- // disclaimer (skip) or an ordinary callout (renumber normally).
+ : []
+ ).map((item: any) => {
+ const para =
+ isObject(item) && Array.isArray(item.content)
+ ? item.content.find((c: any) => isObject(c) && c.type === "paragraph")
+ : null;
+ return para && Array.isArray(para.content) ? para.content : [];
+ });
+
+ // Walk the body in reading order, turning each "[N]" / placeholder marker into
+ // a real footnoteReference node and collecting its definition inline content.
+ const definitions: any[] = [];
const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/;
- for (let i = 0; i < notesIdx; i++) {
- // Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote
- // marker and is synced separately by setCalloutRange. Renumbering it here
- // would consume note slots and corrupt the sequence. Other top-level
- // callouts may carry legitimate "[N]" body markers and are renumbered.
+
+ // Recursively visit inline arrays inside a block (paragraph, heading, callout
+ // child paragraphs, table cells, ...), preserving document reading order.
+ const visitInlineArrays = (container: any): void => {
+ if (!isObject(container) || !Array.isArray(container.content)) return;
+ const hasText = container.content.some(
+ (n: any) => isObject(n) && n.type === "text",
+ );
+ if (hasText) {
+ replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => {
+ const fnId = freshId();
+ if (oldNum != null) {
+ const inline = oldNoteInline[oldNum - 1];
+ // Every existing body marker MUST map to a real note. An out-of-range
+ // marker means the document is internally inconsistent; fail loudly.
+ if (inline === undefined) {
+ throw new Error(
+ `footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`,
+ );
+ }
+ definitions.push(footnoteDefinition(fnId, inline));
+ } else {
+ const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || [];
+ definitions.push(footnoteDefinition(fnId, inline));
+ }
+ return fnId;
+ });
+ } else {
+ for (const child of container.content) visitInlineArrays(child);
+ }
+ };
+
+ const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex;
+ for (let i = 0; i < notesBoundary; i++) {
+ // Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote
+ // marker and is synced separately by setCalloutRange.
if (
isObject(top2[i]) &&
top2[i].type === "callout" &&
@@ -443,35 +540,22 @@ export function commentsToFootnotes(
) {
continue;
}
- walk(top2[i], (node) => {
- if (node.type !== "text" || typeof node.text !== "string") return;
- node.text = node.text.replace(re, (_m: string, oldNum: string, phIdx: string) => {
- if (oldNum != null) {
- const note = oldNotes[Number(oldNum) - 1];
- // Every existing body marker MUST map to a real note. An out-of-range
- // marker means the document is internally inconsistent; fail loudly
- // rather than silently dropping the note and desyncing the callout.
- if (note === undefined) {
- throw new Error(
- `footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`,
- );
- }
- newNotes.push(note);
- } else {
- newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`));
- }
- return `[${++seq}]`;
- });
- });
+ visitInlineArrays(top2[i]);
}
- // Reorder the notes list IN PLACE on `working` first, THEN sync the callout
- // range. setCalloutRange clones `working`, so the reordered notes (mutated
- // before the clone) are carried into its result automatically. No null-filter
- // here: marker count and note count must stay exactly equal (the out-of-range
- // guard above guarantees no undefined entry is ever pushed).
- notesList2.content = newNotes;
- const synced = setCalloutRange(working, notesList2.content.length);
+ // Replace the old orderedList with a real footnotesList of the collected
+ // definitions (reading order). If there are no definitions, drop the list.
+ if (definitions.length > 0) {
+ top2[oldListIndex] = {
+ type: "footnotesList",
+ content: definitions,
+ };
+ } else {
+ top2.splice(oldListIndex, 1);
+ }
+
+ // Sync the disclaimer callout range to the new note count.
+ const synced = setCalloutRange(working, definitions.length);
return { doc: synced.doc, consumed };
}
diff --git a/packages/mcp/test/unit/footnotes.test.mjs b/packages/mcp/test/unit/footnotes.test.mjs
new file mode 100644
index 00000000..df45a7b9
--- /dev/null
+++ b/packages/mcp/test/unit/footnotes.test.mjs
@@ -0,0 +1,153 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
+import { markdownToProseMirror } from "../../build/lib/collaboration.js";
+
+/** Recursively collect every node of `type`. */
+function findAll(node, type, acc = []) {
+ if (!node || typeof node !== "object") return acc;
+ if (node.type === type) acc.push(node);
+ if (Array.isArray(node.content)) {
+ for (const c of node.content) findAll(c, type, acc);
+ }
+ return acc;
+}
+
+const footnoteDoc = {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "Water" },
+ { type: "footnoteReference", attrs: { id: "fn1" } },
+ { type: "text", text: " and clay" },
+ { type: "footnoteReference", attrs: { id: "fn2" } },
+ { type: "text", text: "." },
+ ],
+ },
+ {
+ type: "footnotesList",
+ content: [
+ {
+ type: "footnoteDefinition",
+ attrs: { id: "fn1" },
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: "First note." }] },
+ ],
+ },
+ {
+ type: "footnoteDefinition",
+ attrs: { id: "fn2" },
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: "Second note." }] },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+test("JSON -> Markdown emits pandoc footnote syntax", () => {
+ const md = convertProseMirrorToMarkdown(footnoteDoc);
+ assert.match(md, /\[\^fn1\]/);
+ assert.match(md, /\[\^fn2\]/);
+ assert.match(md, /\[\^fn1\]: First note\./);
+ assert.match(md, /\[\^fn2\]: Second note\./);
+});
+
+test("Markdown -> JSON rebuilds footnote nodes", async () => {
+ const md = convertProseMirrorToMarkdown(footnoteDoc);
+ const json = await markdownToProseMirror(md);
+
+ const refs = findAll(json, "footnoteReference");
+ const list = findAll(json, "footnotesList");
+ const defs = findAll(json, "footnoteDefinition");
+
+ assert.equal(refs.length, 2);
+ assert.deepEqual(
+ refs.map((r) => r.attrs.id),
+ ["fn1", "fn2"],
+ );
+ assert.equal(list.length, 1);
+ assert.equal(defs.length, 2);
+ assert.deepEqual(
+ defs.map((d) => d.attrs.id),
+ ["fn1", "fn2"],
+ );
+});
+
+test("JSON -> MD -> JSON preserves footnote ids and text", async () => {
+ const md = convertProseMirrorToMarkdown(footnoteDoc);
+ const json = await markdownToProseMirror(md);
+ const md2 = convertProseMirrorToMarkdown(json);
+
+ // The second markdown serialization carries the same markers + definitions.
+ assert.match(md2, /\[\^fn1\]/);
+ assert.match(md2, /\[\^fn2\]/);
+ assert.match(md2, /\[\^fn1\]: First note\./);
+ assert.match(md2, /\[\^fn2\]: Second note\./);
+});
+
+test("duplicate-id markdown dedups DETERMINISTICALLY (same input -> same ids)", async () => {
+ // The MCP import must derive duplicate ids deterministically (NOT random) so
+ // the same markdown imported here and via the editor produces identical ids,
+ // and re-importing is stable. This is the test that would FAIL on the old
+ // Math.random()/Date.now() implementation.
+ const md = [
+ "See[^d] one[^d] two[^d].",
+ "",
+ "[^d]: first",
+ "[^d]: second",
+ "[^d]: third",
+ ].join("\n");
+
+ const idsOf = async () => {
+ const json = await markdownToProseMirror(md);
+ const refs = findAll(json, "footnoteReference").map((r) => r.attrs.id);
+ const defs = findAll(json, "footnoteDefinition").map((d) => d.attrs.id);
+ return { refs, defs };
+ };
+
+ const a = await idsOf();
+ const b = await idsOf();
+
+ // Identical across runs.
+ assert.deepEqual(a.refs, b.refs);
+ assert.deepEqual(a.defs, b.defs);
+ // Deterministic derived scheme: keeper "d", duplicates "d__2", "d__3".
+ assert.deepEqual([...a.defs].sort(), ["d", "d__2", "d__3"]);
+ // 1:1 reference <-> definition pairing, all distinct.
+ assert.equal(new Set(a.defs).size, 3);
+ assert.deepEqual([...a.refs].sort(), [...a.defs].sort());
+});
+
+test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => {
+ // Markdown that DOCUMENTS footnote syntax inside a code fence. The example
+ // definition line must be preserved verbatim inside the code block and not
+ // pulled out into a real footnotesList / footnoteDefinition.
+ const md = [
+ "Intro text.",
+ "",
+ "```markdown",
+ "Body[^demo]",
+ "",
+ "[^demo]: example definition",
+ "```",
+ "",
+ "Outro.",
+ ].join("\n");
+
+ const json = await markdownToProseMirror(md);
+
+ // No real footnote nodes were extracted from the code block.
+ assert.equal(findAll(json, "footnotesList").length, 0);
+ assert.equal(findAll(json, "footnoteDefinition").length, 0);
+
+ // The example definition line survives somewhere in the code block text.
+ const codeBlocks = findAll(json, "codeBlock");
+ assert.ok(codeBlocks.length >= 1, "code block present");
+ const codeText = JSON.stringify(json);
+ assert.match(codeText, /\[\^demo\]: example definition/);
+});
diff --git a/packages/mcp/test/unit/transforms.test.mjs b/packages/mcp/test/unit/transforms.test.mjs
index 3f66593c..f7999113 100644
--- a/packages/mcp/test/unit/transforms.test.mjs
+++ b/packages/mcp/test/unit/transforms.test.mjs
@@ -34,6 +34,18 @@ const li = (text) => ({
const doc = (...children) => ({ type: "doc", content: children });
const snapshot = (v) => JSON.parse(JSON.stringify(v));
+// Collect every footnoteReference id under a node, in reading order.
+const collectRefIds = (node, acc = []) => {
+ if (!node || typeof node !== "object") return acc;
+ if (node.type === "footnoteReference") acc.push(node.attrs?.id);
+ if (Array.isArray(node.content)) {
+ for (const c of node.content) collectRefIds(c, acc);
+ }
+ return acc;
+};
+// Plain text of a footnoteDefinition.
+const defText = (def) => blockText(def);
+
// ---------------------------------------------------------------------------
// blockText / walk / getList
// ---------------------------------------------------------------------------
@@ -173,21 +185,30 @@ test("commentsToFootnotes anchors comments and renumbers by position", () => {
const { doc: out, consumed } = commentsToFootnotes(d, comments);
assert.deepEqual(consumed.sort(), ["cA", "cB"]);
- // Markers in reading order: p1 "apple"->[1], p2 existing->[2], p3 "banana"->[3]
- assert.match(blockText(out.content[1]), /\[1\]/);
- assert.match(blockText(out.content[2]), /\[2\]/);
- assert.match(blockText(out.content[3]), /\[3\]/);
+ // Real footnoteReference nodes were inserted at p1 (apple), p2 (existing),
+ // p3 (banana), in reading order — the old `[N]` text markers are gone.
+ const refIds = collectRefIds(out);
+ assert.equal(refIds.length, 3);
+ // Body paragraphs p1..p3 no longer carry literal [N] text markers.
+ assert.doesNotMatch(blockText(out.content[1]), /\[\d+\]/);
+ assert.doesNotMatch(blockText(out.content[2]), /\[\d+\]/);
+ assert.doesNotMatch(blockText(out.content[3]), /\[\d+\]/);
- // No stray placeholders remain.
- const allText = blockText(out);
- assert.doesNotMatch(allText, / F\d+ /);
+ // No stray NUL placeholders remain.
+ assert.doesNotMatch(blockText(out), /\u0000/);
- // Notes list reordered to [apple, existing, banana] (reading order).
- const list = out.content.find((n) => n.type === "orderedList");
+ // The bottom footnotesList holds the definitions in reading order, each keyed
+ // by the matching reference id.
+ const list = out.content.find((n) => n.type === "footnotesList");
+ assert.ok(list, "footnotesList present");
assert.equal(list.content.length, 3);
- assert.equal(blockText(list.content[0]), "apple note");
- assert.equal(blockText(list.content[1]), "existing note one");
- assert.equal(blockText(list.content[2]), "banana note");
+ assert.deepEqual(
+ list.content.map((d) => d.attrs.id),
+ refIds,
+ );
+ assert.equal(defText(list.content[0]), "apple note");
+ assert.equal(defText(list.content[1]), "existing note one");
+ assert.equal(defText(list.content[2]), "banana note");
// Callout range synced to 3 notes.
assert.match(blockText(out.content[0]), /\[1\]…\[3\]/);
@@ -224,15 +245,16 @@ test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched",
// The literal "F1"/"FN2"/"F12" prose is preserved verbatim (no bogus
// footnotes, no eaten spaces around them).
assert.match(bodyText, /Press F1 for help, model FN2 and F12 for tools/);
- // Exactly one real footnote marker was produced, at the anchored word.
- const markerCount = (bodyText.match(/\[\d+\]/g) || []).length;
- assert.equal(markerCount, 1);
- assert.match(bodyText, /apple \[1\]/);
+ // Exactly one real footnoteReference node was produced, at the anchored word.
+ const refIds = collectRefIds(out);
+ assert.equal(refIds.length, 1);
// Exactly one note in the list — "F1"/"FN2"/"F12" did not spawn extra notes.
- const list = out.content.find((n) => n.type === "orderedList");
+ const list = out.content.find((n) => n.type === "footnotesList");
+ assert.ok(list, "footnotesList present");
assert.equal(list.content.length, 1);
- assert.equal(blockText(list.content[0]), "apple note");
+ assert.equal(list.content[0].attrs.id, refIds[0]);
+ assert.equal(defText(list.content[0]), "apple note");
// No stray placeholder sentinel remains anywhere: the NUL-delimited sentinel
// is fully consumed by the renumber pass, so no raw NUL control char persists
@@ -287,17 +309,25 @@ test("commentsToFootnotes renumbers body callouts but skips the disclaimer range
assert.deepEqual(consumed, []);
// The disclaimer's "[1]…[K]" range is NOT treated as body markers: it stays
- // a range and is synced to the note count (2), not renumbered into [1],[2].
+ // a range and is synced to the note count (2), not turned into references.
assert.match(blockText(out.content[0]), /\[1\]…\[2\]/);
- // The body callout's [1] is renumbered as a real reading-order marker.
- assert.match(blockText(out.content[1]), /noted \[1\] above/);
- // The following paragraph's [2] keeps reading order.
- assert.match(blockText(out.content[2]), /with \[2\] too/);
+ // The body callout's [1] and the paragraph's [2] became footnoteReference
+ // nodes in reading order (the literal text markers are gone).
+ const refIds = collectRefIds(out);
+ assert.equal(refIds.length, 2);
+ assert.match(blockText(out.content[1]), /noted +above/); // [1] -> node, no text
+ assert.match(blockText(out.content[2]), /with +too/); // [2] -> node, no text
- // Notes list still has the two original notes in order.
- const list = out.content.find((n) => n.type === "orderedList");
+ // The footnotesList holds the two original notes in reading order, keyed to
+ // the new reference ids.
+ const list = out.content.find((n) => n.type === "footnotesList");
+ assert.ok(list, "footnotesList present");
assert.equal(list.content.length, 2);
- assert.equal(blockText(list.content[0]), "first note");
- assert.equal(blockText(list.content[1]), "second note");
+ assert.deepEqual(
+ list.content.map((d) => d.attrs.id),
+ refIds,
+ );
+ assert.equal(defText(list.content[0]), "first note");
+ assert.equal(defText(list.content[1]), "second note");
});