Footnotes were strict 1:1: a repeated `[^a]` reference was treated as a collision and re-id'd to `a__2`, and a reference with no definition synthesized its own empty one — so an agent-authored article with reused labels produced dozens of empty `kowiki__N` footnotes. Move to Pandoc REUSE semantics and add non-fatal import diagnostics. Reuse (core): - resolveCollisions (footnote-sync): repeated references sharing an id are REUSE (recorded once in document order, never re-id'd) — one number, one shared definition. Only a duplicate DEFINITION is re-id'd deterministically and, with no matching reference, dropped by the existing orphan policy (first-wins). CollisionPlan.refReids is now always empty (harmless no-op downstream). - extractFootnoteDefinitions (marked) and extractFootnotes (MCP): duplicate definition ids are FIRST-WINS (keep first, drop rest); reference markers are never rewritten. Removed the marker-rewriting and the now-dead deriveFootnoteId mirror + helpers from the MCP path. Import diagnostics: - New analyzeFootnotes() (MCP): fence-aware pure scan reporting dangling references, empty/duplicate definitions and `[^id]` markers inside table rows. - createPage / updatePage / importPageMarkdown now attach `footnoteWarnings` (only when non-empty) so an agent can fix its markup; the page is still created. Paste-reuse: - footnotePastePlugin remaps only ids the pasted slice DEFINES (a colliding definition); a pasted lone reference to an existing id keeps it (reuse). Tests: reuse/first-wins rewrites of footnote.test, footnote-markdown.test, footnote.marked.orphan.test and the MCP footnotes.test; new footnote-paste.test (editor-ext) and footnote-analyze.test (MCP). Deleted derive-id-parity.test.mjs (the MCP no longer derives ids; editor-ext's deriveFootnoteId keeps its own golden test). editor-ext 128, MCP 299, server roundtrip 2, client views 3, client+server tsc clean. Two review suggestions applied: corrected a stale "duplicated in MCP" comment and the dangling-reference warning wording. Note: the multi-backlink editor UI (a reused definition linking back to each of its references) is deferred to a follow-up — this PR delivers the data-integrity core (reuse + warnings + paste-reuse). Forward links and numbering already reuse correctly; the backlink currently targets the first reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.5 KiB
JavaScript
107 lines
3.5 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js";
|
|
|
|
test("clean footnotes produce no diagnostics", () => {
|
|
const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.danglingReferences, []);
|
|
assert.deepEqual(d.emptyDefinitions, []);
|
|
assert.deepEqual(d.duplicateDefinitions, []);
|
|
assert.deepEqual(d.referencesInTables, []);
|
|
assert.deepEqual(d.warnings, []);
|
|
});
|
|
|
|
test("reuse (repeated references to one definition) is NOT a warning", () => {
|
|
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.danglingReferences, []);
|
|
assert.deepEqual(d.warnings, []);
|
|
});
|
|
|
|
test("dangling reference (no definition) is reported", () => {
|
|
const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.danglingReferences, ["missing"]);
|
|
assert.equal(d.warnings.length, 1);
|
|
assert.match(d.warnings[0], /no matching definition/);
|
|
assert.match(d.warnings[0], /\[\^missing\]/);
|
|
});
|
|
|
|
test("empty definition text is reported", () => {
|
|
const md = ["See[^a].", "", "[^a]: "].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.emptyDefinitions, ["a"]);
|
|
assert.match(d.warnings.join("\n"), /empty text/);
|
|
});
|
|
|
|
test("duplicate definition id is reported (first-wins)", () => {
|
|
const md = ["See[^d].", "", "[^d]: first", "[^d]: second"].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.duplicateDefinitions, ["d"]);
|
|
assert.match(d.warnings.join("\n"), /defined more than once/);
|
|
});
|
|
|
|
test("reference inside a GFM table row is reported (heuristic)", () => {
|
|
const md = [
|
|
"| Col |",
|
|
"| --- |",
|
|
"| cell[^t] |",
|
|
"",
|
|
"[^t]: table note",
|
|
].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.referencesInTables, ["t"]);
|
|
assert.match(d.warnings.join("\n"), /table/);
|
|
// It is defined, so it is NOT also dangling.
|
|
assert.deepEqual(d.danglingReferences, []);
|
|
});
|
|
|
|
test("footnote syntax inside a code fence is ignored", () => {
|
|
const md = [
|
|
"Intro.",
|
|
"",
|
|
"```",
|
|
"Example[^demo]",
|
|
"[^demo]: not a real definition",
|
|
"```",
|
|
"",
|
|
"Outro[^a].",
|
|
"",
|
|
"[^a]: real",
|
|
].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
// `[^demo]` lives only in the fenced block, so it is neither a reference nor a
|
|
// dangling one, and `[^demo]:` is not counted as a definition.
|
|
assert.deepEqual(d.danglingReferences, []);
|
|
assert.deepEqual(d.duplicateDefinitions, []);
|
|
assert.deepEqual(d.warnings, []);
|
|
});
|
|
|
|
test("a reference that only appears inside a definition's text is not dangling", () => {
|
|
// `[^b]` is referenced from within [^a]'s text and has its own definition.
|
|
const md = ["See[^a].", "", "[^a]: see also [^b]", "[^b]: the other"].join(
|
|
"\n",
|
|
);
|
|
const d = analyzeFootnotes(md);
|
|
assert.deepEqual(d.danglingReferences, []);
|
|
});
|
|
|
|
test("multiple problem classes accumulate distinct warnings", () => {
|
|
const md = [
|
|
"Ref[^x] and[^dup].",
|
|
"",
|
|
"[^dup]: one",
|
|
"[^dup]: two",
|
|
"[^empty]:",
|
|
].join("\n");
|
|
const d = analyzeFootnotes(md);
|
|
// x has no definition; dup is defined twice; empty is empty AND has no ref.
|
|
assert.ok(d.danglingReferences.includes("x"));
|
|
assert.deepEqual(d.duplicateDefinitions, ["dup"]);
|
|
assert.deepEqual(d.emptyDefinitions, ["empty"]);
|
|
// One warning line per problem class present.
|
|
assert.ok(d.warnings.length >= 3);
|
|
});
|