diff --git a/packages/mcp/src/lib/footnote-normalize-merge.ts b/packages/mcp/src/lib/footnote-normalize-merge.ts index 4e2e414f..ac5a121e 100644 --- a/packages/mcp/src/lib/footnote-normalize-merge.ts +++ b/packages/mcp/src/lib/footnote-normalize-merge.ts @@ -137,14 +137,29 @@ function collectDefinitions(node: any, out: any[]): void { function normalizeDefinitionText(def: any): void { const textNodes: any[] = []; collectTextNodes(def, textNodes); - for (const t of textNodes) t.text = normalizeAndCollapse(t.text); + for (const t of textNodes) { + // Skip text carrying a `code` mark: inline code is a verbatim literal, not + // prose typography. Rewriting quotes/dashes/special-spaces there would + // corrupt the literal's meaning (a string literal, an em-dash flag, i18n). + // Leaving it untouched also makes it contribute its RAW text to + // `footnoteMergeKey`, so two notes differing only by glyphs inside code + // stay distinct (while prose glyph-forks still merge). See #419. + if ((t.marks || []).some((m: any) => m?.type === "code")) continue; + t.text = normalizeAndCollapse(t.text); + } if (textNodes.length === 0) return; + const hasCodeMark = (t: any): boolean => + (t.marks || []).some((m: any) => m?.type === "code"); const first = textNodes[0]; - const startTrimmed = first.text.replace(/^ +/, ""); - if (startTrimmed !== "") first.text = startTrimmed; + if (!hasCodeMark(first)) { + 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; + if (!hasCodeMark(last)) { + const endTrimmed = last.text.replace(/ +$/, ""); + if (endTrimmed !== "") last.text = endTrimmed; + } } /** Rewrite `footnoteReference` ids IN PLACE using `defIdToCanon` (deep). */ diff --git a/packages/mcp/test/unit/footnote-normalize-merge.test.mjs b/packages/mcp/test/unit/footnote-normalize-merge.test.mjs index 740a1410..a977af8a 100644 --- a/packages/mcp/test/unit/footnote-normalize-merge.test.mjs +++ b/packages/mcp/test/unit/footnote-normalize-merge.test.mjs @@ -149,6 +149,52 @@ test("marks are kept on merged (surviving) definition text", () => { assert.equal(defText(defs(out)[0]), '"x"'); }); +// --- Inline code is verbatim (not typography) ------------------------------ + +test("text inside a code mark is left verbatim; prose in the same def is normalized", () => { + const d = doc( + para(ref("A")), + list({ + type: "footnoteDefinition", + attrs: { id: "A" }, + content: [ + para( + txt("a—b «x»", [{ type: "code" }]), + txt(" prose «y» — z"), + ), + ], + }), + ); + const out = normalizeAndMergeFootnotes(d); + const nodes = findAll(defs(out)[0], "text"); + // Code node: byte-for-byte unchanged (typography preserved). + assert.equal(nodes[0].text, "a—b «x»"); + // Prose node: dashes/quotes normalized to ASCII. + assert.equal(nodes[1].text, ' prose "y" - z'); +}); + +test("two notes differing ONLY by glyphs inside a code mark do NOT merge", () => { + const d = doc( + para(ref("A"), ref("B")), + list( + def("A", txt("«x»", [{ type: "code" }]), txt(" same prose «q»")), + def("B", txt('"x"', [{ type: "code" }]), txt(" same prose «q»")), + ), + ); + const out = normalizeAndMergeFootnotes(d); + // Prose is identical after normalization, but the code literals differ raw + // -> the merge key diverges -> both definitions survive, no re-hang. + assert.deepEqual(refIds(out), ["A", "B"]); + assert.deepEqual(defIds(out), ["A", "B"]); + // Each code literal stays verbatim. + assert.equal(defs(out)[0].content[0].content[0].text, "«x»"); + assert.equal(defs(out)[1].content[0].content[0].text, '"x"'); + // Both survive canonicalization (neither is an orphan). + const canon = canonicalizeFootnotes(out); + assert.deepEqual(defIds(canon), ["A", "B"]); + assert.deepEqual(refIds(canon), ["A", "B"]); +}); + // --- Composition with the canonicalizer ------------------------------------ test("pass + canonicalize: single tail list and sequential numbering", () => {