feat(editor): footnotes (reference + definitions model)
Adds footnotes: a superscript marker in the text linked to an editable definition in a Footnotes section at the end of the page, with auto-numbering and a read-only hover popover. Chose the reference+definitions model (3 plain nodes) over an inline atom with a sub-editor specifically for collaboration safety. editor-ext (packages/editor-ext/src/lib/footnote/): - footnoteReference (inline atom, id), footnotesList (block, last child), footnoteDefinition (paragraph+, id). renderHTML emits sup[data-footnote-ref] / section[data-footnotes] / div[data-footnote-def]; parse-rule priority makes the empty reference win over the Superscript mark (else it is dropped on the server save). - numbering: a decoration-only plugin (pure function of doc order) -> every client computes identical numbers, no document mutation, Yjs-safe. - sync plugin: single-pass, always SYNC_META-tagged and skipping remote txns (terminates, no loop), idempotent; canonicalizes to one trailing footnotesList (merging duplicates), creates missing definitions, drops orphans, and coexists with TrailingNode. Disabled in read-only. - commands setFootnote (one tx: reference + definition at the matching index + focus) / removeFootnote (cascade, one undo) / scrollTo*. slash /footnote. client: superscript NodeView + floating-ui read-only popover; bottom-list and definition NodeViews; registered in mainExtensions. server: the three nodes registered in tiptapExtensions so collab/save/export keep them. Round-trip regression spec guards the Superscript parse-priority. markdown: turndown/marked round-trip to pandoc/GFM [^id] (+ a code-fence guard so footnote-like lines inside code blocks are not extracted). MCP mirror: schema + markdown-converter + commentsToFootnotes rewritten to real footnote nodes + diff marker counting; NUL sentinels written as \u0000 escapes. v2 follow-ups (per plan): definition reordering on reference move, id-collision regeneration on paste, multiple references to one footnote. Implements docs/footnotes-plan.md (variant B). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
120
packages/mcp/test/unit/footnotes.test.mjs
Normal file
120
packages/mcp/test/unit/footnotes.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
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("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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user