From d4581a096f6fbcc4920fb7a6f6306e5d5fdfa8e1 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Thu, 9 Jul 2026 23:24:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=D0=B4=D0=B5=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BD=D0=BE=D1=81=D0=BE=D0=BA=20+=20=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=BC=D1=83=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый чистый модуль footnote-normalize-merge.ts (normalizeAndMergeFootnotes): нормализует текст определений сносок (типографские кавычки->ASCII, тире->'-', NBSP/спецпробелы->пробел, схлопывание пробелов, trim) и сливает определения с совпавшим нормализованным текстом, перевешивая ссылки на канонический id; дубли-сироты добивает canonicalizeFootnotes. Ключ слияния attrs-aware (footnoteMergeKey/stableAttrs) — сноски с одинаковым текстом, но разными attrs марок (напр. link.href) НЕ сливаются (защита от потери target). Пасс вызывается строго ПЕРЕД canonicalizeFootnotes на 5 write-путях MCP (markdown-импорт, updatePageJson, copyPageContent, docmost_transform, insertInlineFootnote). Глиф-карты продублированы из comment-anchor.ts (там private+завязаны на golden). Идемпотентен, чистый (deep-clone), scope строго внутри footnoteDefinition. closes #419 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/client.ts | 9 +- packages/mcp/src/lib/collaboration.ts | 8 +- .../mcp/src/lib/footnote-normalize-merge.ts | 265 +++++++++++++++++ packages/mcp/src/lib/transforms.ts | 3 + .../unit/footnote-normalize-merge.test.mjs | 271 ++++++++++++++++++ 5 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 packages/mcp/src/lib/footnote-normalize-merge.ts create mode 100644 packages/mcp/test/unit/footnote-normalize-merge.test.mjs diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 20040432..cba14720 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -82,6 +82,7 @@ import { canonicalizeFootnotes, insertInlineFootnote, } from "./lib/transforms.js"; +import { normalizeAndMergeFootnotes } from "./lib/footnote-normalize-merge.js"; import vm from "node:vm"; // Supported image types, kept as two lookup tables so both a local file @@ -1639,6 +1640,8 @@ export class DocmostClient { // leave footnotes out of order, orphaned, or in multiple lists — the bottom // list + numbering are always derived from reference order. No-op when the // footnotes are already canonical. + // #419: normalize + merge glyph-forked definitions before canonicalizing. + doc = normalizeAndMergeFootnotes(doc); doc = canonicalizeFootnotes(doc); // Write the BODY first, then the title (#159 split-brain): a failed body @@ -1903,7 +1906,8 @@ export class DocmostClient { // footnotes before copying — a no-op on already-canonical source content, but // it guarantees a copy can never propagate a non-canonical footnote topology // to the target (parity with the other full-doc write paths). - const canonical = canonicalizeFootnotes(content); + // #419: normalize + merge glyph-forked definitions before canonicalizing. + const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content)); const collabToken = await this.getCollabTokenWithReauth(); // Open the TARGET collab doc by its canonical UUID, never the slugId (#260). @@ -4158,7 +4162,8 @@ export class DocmostClient { // path can leave footnotes out of order / orphaned / in a raw `[^id]` // block. In a dryRun preview this may surface footnote edits the script // author did not write (the canonicalizer tidied them) — that is expected. - const result = canonicalizeFootnotes(raw); + // #419: normalize + merge glyph-forked definitions before canonicalizing. + const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw)); newDoc = result; return result; }; diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index 9cd2d62c..bbd805b2 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -15,6 +15,7 @@ import { docmostExtensions, docmostSchema } from "./docmost-schema.js"; import { withPageLock } from "./page-lock.js"; import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown"; import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; +import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js"; import { VerifyReport } from "./diff.js"; import { acquireCollabSession } from "./collab-session.js"; @@ -82,7 +83,12 @@ global.WebSocket = WebSocket; export async function markdownToProseMirrorCanonical( markdownContent: string, ): Promise { - return canonicalizeFootnotes(await markdownToProseMirror(markdownContent)); + // #419: normalize + merge glyph-forked footnote definitions BEFORE + // canonicalizing, so the canonicalizer re-hangs references and drops the + // now-orphaned duplicate definitions. + return canonicalizeFootnotes( + normalizeAndMergeFootnotes(await markdownToProseMirror(markdownContent)), + ); } /** diff --git a/packages/mcp/src/lib/footnote-normalize-merge.ts b/packages/mcp/src/lib/footnote-normalize-merge.ts new file mode 100644 index 00000000..4e2e414f --- /dev/null +++ b/packages/mcp/src/lib/footnote-normalize-merge.ts @@ -0,0 +1,265 @@ +/** + * Deterministic server-side NORMALIZATION + MERGE of footnote DEFINITIONS + * (MCP, PURE). + * + * Problem (#419): footnotes with the same meaning but different GLYPHS — + * typographic quotes («…»/“…”) vs ASCII "…", em/en-dash vs `-`, non-breaking + * space vs normal space, differing space counts — are not recognized as equal + * and "fork": two definitions appear where the author meant one. The existing + * de-dup paths miss this: `footnoteContentKey` (footnote-authoring.ts) only + * collapses ASCII whitespace (quotes/dashes/NBSP untouched), and + * `canonicalizeFootnotes` keys purely by `attrs.id` (the two forks have + * different ids), so neither glues the forks together. + * + * This pass fixes that DETERMINISTICALLY on the MCP write-paths (an LLM + * instruction gives no glue guarantee). It: + * 1. Normalizes the TEXT of every `footnoteDefinition`'s text nodes IN PLACE + * (typographic quotes -> ASCII "/', dashes -> `-`, NBSP & friends -> + * normal space, whitespace runs collapsed, whole-definition edges + * trimmed) — unconditionally, for ALL definitions, KEEPING their marks. + * 2. Computes a MERGE KEY per definition (normalized text + an ATTRS-AWARE + * inline-mark signature, via the local `footnoteMergeKey`), so notes that + * read the same but differ in formatting (bold vs plain) OR in a mark + * attribute (a `link` with a different `href`, differing `code`/`highlight` + * attrs) are NOT merged. See `footnoteMergeKey` for why this diverges from + * the shared type-only `footnoteContentKey`. + * 3. Maps every duplicate definition id to the FIRST (document-order) + * definition's id and re-hangs `footnoteReference` nodes onto it. + * + * Duplicate definitions keep their original ids but now have NO references, so + * the canonicalizer that runs immediately after this pass removes them as + * orphans and derives the single tail list + numbering. This pass therefore + * MUST run BEFORE `canonicalizeFootnotes(doc)` at every write-path call-site + * (see the enforcement rule in `footnote-canonicalize.ts`). + * + * Accepted tradeoff: the exact typographic glyphs of the SURVIVING footnote are + * rewritten to ASCII, in exchange for a GUARANTEED merge. Scope is strictly + * INSIDE `footnoteDefinition` — body text (normal paragraphs) is never touched. + * + * Pure: deep-clones its input, deterministic, idempotent (a re-run is a no-op — + * text is already normalized and references already point at the canonical id, + * so no spurious mutations / git-sync churn). + */ + +const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; +const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; + +/** + * Typographic glyph maps. DUPLICATED from `comment-anchor.ts` (the source of + * truth, `normalizeForMatch`) on purpose: those constants are private there and + * bound to that module's anchor-matching golden tests, so extracting them would + * risk changing anchor behaviour. Keeping a local copy makes this pass fully + * self-contained. If the anchor maps grow, mirror the change here. + */ +/** Typographic double-quote variants mapped to ASCII `"`. */ +const DOUBLE_QUOTES = "«»„“”‟〝〞""; +/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */ +const SINGLE_QUOTES = "‘’‚‛"; +/** Dash variants mapped to ASCII `-`. */ +const DASHES = "–—―−‐‑‒"; + +function cloneJson(v: T): T { + if (typeof structuredClone === "function") return structuredClone(v); + return JSON.parse(JSON.stringify(v)) as T; +} + +/** + * True for any character we collapse/replace with a single normal space. + * Mirrors `comment-anchor.ts`'s `isWhitespaceChar`: ASCII whitespace (`\s` + * covers tab/newline) plus the non-breaking / special spaces listed explicitly + * for determinism across engines. + */ +function isWhitespaceChar(ch: string): boolean { + return ( + /\s/.test(ch) || + ch === " " || // no-break space + ch === " " || // figure space + ch === " " || // narrow no-break space + ch === " " || // thin space + ch === " " || // hair space + ch === " " || // en space + ch === " " // em space + ); +} + +/** + * Map typographic quotes/dashes to ASCII and collapse every whitespace run + * (including NBSP & friends) to a SINGLE normal space. Does NOT trim — the + * whole-definition edge trim is applied separately so inter-node spacing across + * a multi-text-node definition is preserved. + */ +function normalizeAndCollapse(s: string): string { + let out = ""; + let i = 0; + while (i < s.length) { + const ch = s[i]; + if (isWhitespaceChar(ch)) { + while (i < s.length && isWhitespaceChar(s[i])) i++; + out += " "; + continue; + } + let mapped = ch; + if (DOUBLE_QUOTES.indexOf(ch) !== -1) mapped = '"'; + else if (SINGLE_QUOTES.indexOf(ch) !== -1) mapped = "'"; + else if (DASHES.indexOf(ch) !== -1) mapped = "-"; + out += mapped; + i++; + } + return out; +} + +/** Collect every text node inside `def`, in document order (deep). */ +function collectTextNodes(node: any, out: any[]): void { + if (!node || typeof node !== "object") return; + if (node.type === "text" && typeof node.text === "string") out.push(node); + if (Array.isArray(node.content)) { + for (const child of node.content) collectTextNodes(child, out); + } +} + +/** Collect every `footnoteDefinition` node in document order (deep). */ +function collectDefinitions(node: any, out: any[]): void { + if (!node || typeof node !== "object") return; + if (node.type === FOOTNOTE_DEFINITION_NAME) out.push(node); + if (Array.isArray(node.content)) { + for (const child of node.content) collectDefinitions(child, out); + } +} + +/** + * Normalize the text of one definition's text nodes IN PLACE: map glyphs + + * collapse whitespace on every node (marks untouched), then trim the leading + * edge of the first text node and the trailing edge of the last so the + * definition as a whole is trimmed WITHOUT dropping the spacing between two + * adjacent text nodes. The edge trims are guarded so an all-whitespace edge + * node is never emptied into a schema-invalid empty text node. + */ +function normalizeDefinitionText(def: any): void { + const textNodes: any[] = []; + collectTextNodes(def, textNodes); + for (const t of textNodes) t.text = normalizeAndCollapse(t.text); + if (textNodes.length === 0) return; + const first = textNodes[0]; + const startTrimmed = first.text.replace(/^ +/, ""); + if (startTrimmed !== "") first.text = startTrimmed; + const last = textNodes[textNodes.length - 1]; + const endTrimmed = last.text.replace(/ +$/, ""); + if (endTrimmed !== "") last.text = endTrimmed; +} + +/** Rewrite `footnoteReference` ids IN PLACE using `defIdToCanon` (deep). */ +function rehangReferences( + node: any, + defIdToCanon: Map, +): void { + if (!node || typeof node !== "object") return; + if (node.type === FOOTNOTE_REFERENCE_NAME) { + const id = node?.attrs?.id; + if (typeof id === "string") { + const canon = defIdToCanon.get(id); + if (canon && canon !== id) node.attrs.id = canon; + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) rehangReferences(child, defIdToCanon); + } +} + +/** + * Stable, order-independent serialization of a mark's `attrs`: sort keys so the + * same attrs always yield the same string regardless of authoring order. Empty / + * missing attrs -> "" (so an attr-less mark keys identically to a type-only mark + * signature, preserving bold-vs-plain parity). + */ +function stableAttrs(attrs: any): string { + if (!attrs || typeof attrs !== "object") return ""; + const sorted: Record = {}; + for (const k of Object.keys(attrs).sort()) sorted[k] = attrs[k]; + return JSON.stringify(sorted); +} + +/** + * ATTRS-AWARE merge key for a footnote definition. Deliberately DIVERGES from + * the shared `footnoteContentKey` (footnote-authoring.ts): that key's mark + * signature is TYPE-ONLY (`m.type`), so two definitions with identical visible + * text but marks differing only in ATTRIBUTES — most importantly a `link` with a + * different `href` (footnotes are usually citations/links), also `code` / + * `highlight` with differing attrs — collapse to the SAME key and get merged; + * one definition then loses its references and the canonicalizer deletes it as an + * orphan, silently dropping a distinct link target (data loss, #419). + * + * This key folds each mark's `attrs` (stable, sorted-key serialization) into the + * signature, so different-href / different-attr notes stay separate. We do NOT + * change `footnoteContentKey` itself: it is shared with the live + * `insertInlineFootnote` / `commentsToFootnotes` dedup and altering it there + * would change their behaviour — out of scope here. + * + * The TEXT portion mirrors `footnoteContentKey` exactly (per text node + * `text + mark-signature`, concatenated, whitespace-collapsed, trimmed) over the + * already-in-place-normalized text, so empty text still yields "" (empties never + * collapse) and merge parity with the rest of the pass is preserved. + */ +function footnoteMergeKey(defNode: any): string { + const parts: string[] = []; + const visit = (n: any): void => { + if (!n || typeof n !== "object") return; + if (n.type === "text" && typeof n.text === "string") { + const marks = Array.isArray(n.marks) + ? n.marks + .filter((m: any) => m && m.type) + .map((m: any) => `${m.type}${stableAttrs(m.attrs)}`) + .sort() + .join(",") + : ""; + parts.push(`${n.text}${marks}`); + } + if (Array.isArray(n.content)) for (const c of n.content) visit(c); + }; + visit(defNode); + return parts + .join("") + .replace(/[ \t\r\n]+/g, " ") + .trim(); +} + +/** + * Normalize footnote-definition text and merge definitions whose normalized + * text (+ mark signature) matches. See the file header for the full contract. + * Pure (deep-clones input, deterministic, idempotent). Intended to run + * immediately BEFORE `canonicalizeFootnotes(doc)`. + */ +export function normalizeAndMergeFootnotes(doc: T): T { + if (doc == null || typeof doc !== "object") return doc; + const out = cloneJson(doc) as any; + + // 1) All definitions in document order; normalize each one's text in place. + const defNodes: any[] = []; + collectDefinitions(out, defNodes); + for (const def of defNodes) normalizeDefinitionText(def); + + // 2) Merge key per definition (normalized text + inline-mark signature). The + // first definition in document order per key wins; later ones map onto it. + // Empty-text definitions (key === "") are NOT merged — otherwise every + // empty footnote would collapse into one (parity with insertInlineFootnote). + const keyToCanon = new Map(); + const defIdToCanon = new Map(); + for (const def of defNodes) { + const id = def?.attrs?.id; + if (typeof id !== "string" || id === "") continue; + const key = footnoteMergeKey(def); + if (key === "") continue; + const canon = keyToCanon.get(key); + if (canon === undefined) { + keyToCanon.set(key, id); + } else if (canon !== id) { + defIdToCanon.set(id, canon); + } + } + + // 3) Re-hang references from duplicate ids onto the canonical id. Duplicate + // definitions keep their ids but now have no references -> the following + // canonicalizer pass drops them as orphans. + if (defIdToCanon.size > 0) rehangReferences(out, defIdToCanon); + + return out; +} diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index fadc71db..37b4b118 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -14,6 +14,7 @@ * - `marks` arrays are preserved verbatim when fragments are split/reordered. */ +import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js"; import { blockPlainText, footnoteContentKey, @@ -766,6 +767,8 @@ export function insertInlineFootnote( appendDefinition(working, makeFootnoteDefinition(footnoteId, inline)); } + // #419: normalize + merge glyph-forked definitions before canonicalizing. + working = normalizeAndMergeFootnotes(working); // Derive numbering + the single bottom list deterministically. working = canonicalizeFootnotes(working); return { doc: working, inserted: true, footnoteId, reused }; diff --git a/packages/mcp/test/unit/footnote-normalize-merge.test.mjs b/packages/mcp/test/unit/footnote-normalize-merge.test.mjs new file mode 100644 index 00000000..740a1410 --- /dev/null +++ b/packages/mcp/test/unit/footnote-normalize-merge.test.mjs @@ -0,0 +1,271 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeAndMergeFootnotes } from "../../build/lib/footnote-normalize-merge.js"; +import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"; + +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 defs = (doc) => findAll(doc, "footnoteDefinition"); +const defIds = (doc) => defs(doc).map((d) => d.attrs.id); +const refIds = (doc) => findAll(doc, "footnoteReference").map((r) => r.attrs.id); +const defText = (d) => + findAll(d, "text") + .map((t) => t.text) + .join(""); + +const ref = (id) => ({ type: "footnoteReference", attrs: { id } }); +const para = (...inline) => ({ type: "paragraph", content: inline }); +const txt = (text, marks) => + marks ? { type: "text", text, marks } : { type: "text", text }; +const def = (id, ...inline) => ({ + type: "footnoteDefinition", + attrs: { id }, + content: [para(...inline)], +}); +const list = (...defs) => ({ type: "footnotesList", content: defs }); +const doc = (...content) => ({ type: "doc", content }); + +// --- Normalization + merge of glyph forks ---------------------------------- + +test("typographic double quotes «…» vs \"…\" merge into one", () => { + const d = doc( + para(txt("a"), ref("A"), txt(" b"), ref("B")), + list(def("A", txt("«word»")), def("B", txt('"word"'))), + ); + const out = normalizeAndMergeFootnotes(d); + // Both references now point at the first definition's id. + assert.deepEqual(refIds(out), ["A", "A"]); + // Surviving text is ASCII-normalized. + assert.equal(defText(defs(out)[0]), '"word"'); + // Duplicate def kept its id (canonicalizer removes it as an orphan later). + const canon = canonicalizeFootnotes(out); + assert.deepEqual(defIds(canon), ["A"]); + assert.equal(findAll(canon, "footnotesList").length, 1); +}); + +test("em/en dash and hyphen merge", () => { + const d = doc( + para(ref("A"), ref("B"), ref("C")), + list( + def("A", txt("see — here")), + def("B", txt("see – here")), + def("C", txt("see - here")), + ), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A", "A"]); + assert.equal(defText(defs(out)[0]), "see - here"); +}); + +test("NBSP and extra spaces merge with normal spacing", () => { + const d = doc( + para(ref("A"), ref("B")), + list( + def("A", txt("foo bar")), // NBSP + def("B", txt("foo bar")), // collapsed spaces + ), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A"]); + assert.equal(defText(defs(out)[0]), "foo bar"); +}); + +test("same text but different styling (bold vs plain) does NOT merge", () => { + const d = doc( + para(ref("A"), ref("B")), + list( + def("A", txt("word", [{ type: "bold" }])), + def("B", txt("word")), + ), + ); + const out = normalizeAndMergeFootnotes(d); + // No re-hang: references keep their own ids. + assert.deepEqual(refIds(out), ["A", "B"]); + assert.deepEqual(defIds(out), ["A", "B"]); + // Marks preserved on the surviving text node. + assert.deepEqual(defs(out)[0].content[0].content[0].marks, [ + { type: "bold" }, + ]); +}); + +test("same text but a link mark with different href does NOT merge (data-loss guard)", () => { + const d = doc( + para(ref("A"), ref("B")), + list( + def("A", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])), + def("B", txt("source", [{ type: "link", attrs: { href: "https://b.example/2" } }])), + ), + ); + const out = normalizeAndMergeFootnotes(d); + // No re-hang: each reference keeps its own definition (distinct link target). + assert.deepEqual(refIds(out), ["A", "B"]); + assert.deepEqual(defIds(out), ["A", "B"]); + // Both distinct hrefs survive. + assert.deepEqual( + defs(out).map((dn) => dn.content[0].content[0].marks[0].attrs.href), + ["https://a.example/1", "https://b.example/2"], + ); + // Canonicalize keeps both as two tail entries (neither is an orphan). + const canon = canonicalizeFootnotes(out); + assert.deepEqual(defIds(canon), ["A", "B"]); + assert.deepEqual(refIds(canon), ["A", "B"]); +}); + +test("same text and SAME link href still merges (attrs-aware key doesn't over-separate)", () => { + const d = doc( + para(ref("A"), ref("B")), + list( + def("A", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])), + def("B", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])), + ), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A"]); + const canon = canonicalizeFootnotes(out); + assert.deepEqual(defIds(canon), ["A"]); + assert.equal(findAll(canon, "footnotesList").length, 1); +}); + +test("marks are kept on merged (surviving) definition text", () => { + const d = doc( + para(ref("A"), ref("B")), + list( + def("A", txt("«x»", [{ type: "italic" }])), + def("B", txt("«x»", [{ type: "italic" }])), + ), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A"]); + assert.deepEqual(defs(out)[0].content[0].content[0].marks, [ + { type: "italic" }, + ]); + assert.equal(defText(defs(out)[0]), '"x"'); +}); + +// --- Composition with the canonicalizer ------------------------------------ + +test("pass + canonicalize: single tail list and sequential numbering", () => { + const d = doc( + para(txt("intro "), ref("A"), txt(" middle "), ref("B")), + list(def("A", txt("«note»")), def("B", txt('"note"'))), + ); + const canon = canonicalizeFootnotes(normalizeAndMergeFootnotes(d)); + assert.equal(findAll(canon, "footnotesList").length, 1); + assert.deepEqual(defIds(canon), ["A"]); + assert.deepEqual(refIds(canon), ["A", "A"]); +}); + +// --- Idempotency ----------------------------------------------------------- + +test("idempotent: a second run is a no-op", () => { + const d = doc( + para(ref("A"), ref("B")), + list(def("A", txt("«word»")), def("B", txt('"word"'))), + ); + const once = normalizeAndMergeFootnotes(d); + const twice = normalizeAndMergeFootnotes(once); + assert.deepEqual(twice, once); +}); + +test("input document is not mutated (pure)", () => { + const d = doc( + para(ref("A"), ref("B")), + list(def("A", txt("«word»")), def("B", txt('"word"'))), + ); + const snapshot = JSON.parse(JSON.stringify(d)); + normalizeAndMergeFootnotes(d); + assert.deepEqual(d, snapshot); +}); + +// --- Nested definitions ---------------------------------------------------- + +test("definitions nested in a callout are normalized and merged", () => { + const callout = (...content) => ({ + type: "callout", + attrs: { type: "info" }, + content, + }); + const d = doc( + para(ref("A"), ref("B")), + callout(list(def("A", txt("«c»")), def("B", txt('"c"')))), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A"]); + assert.equal(defText(defs(out)[0]), '"c"'); +}); + +// --- Empty footnotes ------------------------------------------------------- + +test("empty footnotes do NOT collapse into each other", () => { + const d = doc( + para(ref("A"), ref("B")), + list(def("A", txt("")), { type: "footnoteDefinition", attrs: { id: "B" }, content: [{ type: "paragraph" }] }), + ); + const out = normalizeAndMergeFootnotes(d); + // Both empty definitions keep distinct ids; references unchanged. + assert.deepEqual(refIds(out), ["A", "B"]); + assert.deepEqual(defIds(out), ["A", "B"]); +}); + +// --- Body text left untouched ---------------------------------------------- + +test("body text (outside footnotes) is NOT normalized", () => { + const d = doc( + para(txt("body «quoted» — dash"), ref("A")), + list(def("A", txt("«note»"))), + ); + const out = normalizeAndMergeFootnotes(d); + // Body paragraph keeps its typographic glyphs verbatim. + assert.equal(out.content[0].content[0].text, "body «quoted» — dash"); + // Footnote text IS normalized. + assert.equal(defText(defs(out)[0]), '"note"'); +}); + +// --- Multi-paragraph structure preserved ----------------------------------- + +test("multi-paragraph definition: text normalized, structure preserved", () => { + const d = doc( + para(ref("A")), + list({ + type: "footnoteDefinition", + attrs: { id: "A" }, + content: [para(txt("«p1»")), para(txt("p2 — end"))], + }), + ); + const out = normalizeAndMergeFootnotes(d); + const def0 = defs(out)[0]; + assert.equal(def0.content.length, 2); + assert.equal(def0.content[0].content[0].text, '"p1"'); + assert.equal(def0.content[1].content[0].text, "p2 - end"); +}); + +// --- Multi-reference footnote not broken ----------------------------------- + +test("one id shared by multiple references is preserved", () => { + const d = doc( + para(ref("A"), txt(" x "), ref("A")), + list(def("A", txt("note"))), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A"]); + assert.deepEqual(defIds(out), ["A"]); +}); + +// --- Whole-definition edge trim -------------------------------------------- + +test("leading/trailing whitespace is trimmed for the merge and stored text", () => { + const d = doc( + para(ref("A"), ref("B")), + list(def("A", txt(" hello ")), def("B", txt("hello"))), + ); + const out = normalizeAndMergeFootnotes(d); + assert.deepEqual(refIds(out), ["A", "A"]); + assert.equal(defText(defs(out)[0]), "hello"); +});