Merge pull request 'feat(footnotes): author-inline footnotes + deterministic server canonicalization (#228)' (#232) from feat/228-inline-footnotes into develop

Reviewed-on: #232
This commit was merged in pull request #232.
This commit is contained in:
2026-06-28 02:23:27 +03:00
37 changed files with 6077 additions and 75 deletions

View File

@@ -0,0 +1,371 @@
import { describe, it, expect } from 'vitest';
import { Editor, getSchema } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { FootnoteReference } from './footnote-reference';
import { FootnotesList } from './footnotes-list';
import { FootnoteDefinition } from './footnote-definition';
import { canonicalizeFootnotes } from './footnote-canonicalize';
import { FOOTNOTE_CORPUS } from './footnote-corpus';
import {
collectReferenceIds,
computeFootnoteNumbers,
FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME,
FOOTNOTE_DEFINITION_NAME,
} from './footnote-util';
import { Node as PMNode } from '@tiptap/pm/model';
const extensions = [
Document,
Paragraph,
Text,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
];
const ref = (id: string) => ({ type: FOOTNOTE_REFERENCE_NAME, attrs: { id } });
const def = (id: string, text?: string) => ({
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id },
content: [
text
? { type: 'paragraph', content: [{ type: 'text', text }] }
: { type: 'paragraph' },
],
});
const list = (...defs: any[]) => ({ type: FOOTNOTES_LIST_NAME, content: defs });
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
/** Find every node of `type`, document order. */
function findAll(node: any, type: string, acc: any[] = []): any[] {
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;
}
/** Physical id order of the definitions in the (single) footnotesList. */
function defOrder(doc: any): string[] {
return findAll(doc, FOOTNOTE_DEFINITION_NAME).map((d) => d.attrs.id);
}
const schema = getSchema(extensions);
/** Reference order (distinct, document order) computed via the shared util. */
function refOrder(doc: any): string[] {
return collectReferenceIds(PMNode.fromJSON(schema, doc));
}
describe('canonicalizeFootnotes (pure JSON)', () => {
it('orders definitions by FIRST reference (out-of-order list -> 1..N)', () => {
// References appear b, a, d, c; the bottom list is in a different (import)
// order. The canonical list must follow reference order so reading it top to
// bottom yields numbers 1..N.
const doc = {
type: 'doc',
content: [
para(
{ type: 'text', text: 'x' },
ref('b'),
ref('a'),
ref('d'),
ref('c'),
),
list(def('a', 'A'), def('c', 'C'), def('b', 'B'), def('d', 'D')),
],
};
const out = canonicalizeFootnotes(doc);
expect(defOrder(out)).toEqual(['b', 'a', 'd', 'c']);
// The physical definition order now matches reference order, so the derived
// numbers (1..N) run sequentially down the list.
expect(refOrder(out)).toEqual(['b', 'a', 'd', 'c']);
const numbers = computeFootnoteNumbers(PMNode.fromJSON(schema, out));
expect(numbers.get('b')).toBe(1);
expect(numbers.get('a')).toBe(2);
expect(numbers.get('d')).toBe(3);
expect(numbers.get('c')).toBe(4);
});
it('numbers run 1..N down the canonical list', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'x' }, ref('b'), ref('a'), ref('c')),
list(def('a', 'A'), def('c', 'C'), def('b', 'B')),
],
};
const out = canonicalizeFootnotes(doc);
// Definition order == reference order == 1,2,3 reading down.
expect(defOrder(out)).toEqual(['b', 'a', 'c']);
});
it('drops an orphan definition (no matching reference)', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'x' }, ref('a')),
list(def('a', 'A'), def('orphan', 'O')),
],
};
const out = canonicalizeFootnotes(doc);
expect(defOrder(out)).toEqual(['a']);
expect(findAll(out, FOOTNOTE_DEFINITION_NAME)).toHaveLength(1);
});
it('with NO references, removes the footnotesList entirely', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'plain' }),
list(def('orphan', 'O')),
],
};
const out = canonicalizeFootnotes(doc);
expect(findAll(out, FOOTNOTES_LIST_NAME)).toHaveLength(0);
expect(findAll(out, FOOTNOTE_DEFINITION_NAME)).toHaveLength(0);
});
it('reuse: repeated references collapse to ONE definition/number', () => {
const doc = {
type: 'doc',
content: [
para(ref('d'), { type: 'text', text: ' a ' }, ref('d'), ref('d')),
list(def('d', 'shared')),
],
};
const out = canonicalizeFootnotes(doc);
// One definition; the three references keep id "d".
expect(defOrder(out)).toEqual(['d']);
expect(
findAll(out, FOOTNOTE_REFERENCE_NAME).map((r) => r.attrs.id),
).toEqual(['d', 'd', 'd']);
});
it('duplicate definitions: first wins, the rest are dropped (never resurface as orphans)', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'x' }, ref('d')),
list(def('d', 'first'), def('d', 'second'), def('d', 'third')),
],
};
const out = canonicalizeFootnotes(doc);
const defs = findAll(out, FOOTNOTE_DEFINITION_NAME);
expect(defs.map((d) => d.attrs.id)).toEqual(['d']);
expect(defs[0].content[0].content[0].text).toBe('first');
});
it('synthesizes an empty definition for a reference that has none', () => {
const doc = {
type: 'doc',
content: [para({ type: 'text', text: 'x' }, ref('missing'))],
};
const out = canonicalizeFootnotes(doc);
expect(defOrder(out)).toEqual(['missing']);
const list0 = findAll(out, FOOTNOTES_LIST_NAME);
expect(list0).toHaveLength(1);
});
it('merges multiple footnotesList nodes into one', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'a' }, ref('x'), ref('y')),
list(def('x', 'X')),
para({ type: 'text', text: 'tail' }),
list(def('y', 'Y')),
],
};
const out = canonicalizeFootnotes(doc);
expect(findAll(out, FOOTNOTES_LIST_NAME)).toHaveLength(1);
expect(defOrder(out)).toEqual(['x', 'y']);
});
it('places the single list before trailing empty paragraphs', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'x' }, ref('a')),
list(def('a', 'A')),
{ type: 'paragraph' },
],
};
const out = canonicalizeFootnotes(doc);
const last = out.content[out.content.length - 1];
expect(last.type).toBe('paragraph');
expect(out.content[out.content.length - 2].type).toBe(FOOTNOTES_LIST_NAME);
});
it('is idempotent: canonicalize(canonicalize(x)) === canonicalize(x)', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'x' }, ref('b'), ref('a')),
list(def('a', 'A'), def('b', 'B'), def('orphan', 'O')),
],
};
const once = canonicalizeFootnotes(doc);
const twice = canonicalizeFootnotes(once);
expect(twice).toEqual(once);
});
it('does not mutate its input', () => {
const doc = {
type: 'doc',
content: [
para({ type: 'text', text: 'x' }, ref('a')),
list(def('orphan', 'O')),
],
};
const snapshot = JSON.parse(JSON.stringify(doc));
canonicalizeFootnotes(doc);
expect(doc).toEqual(snapshot);
});
});
/**
* GOLDEN PARITY against the live `footnoteSyncPlugin`. The server canonicalizer
* must produce EXACTLY what the editor keeps. For every editor-reachable steady
* state (the list is already reference-ordered there), driving a real editor to
* convergence and then running `canonicalizeFootnotes` on its JSON must be a
* byte-for-byte no-op — proving the server output is identical to the editor's.
*/
describe('canonicalizeFootnotes golden parity with footnoteSyncPlugin', () => {
function makeEditor(content: any) {
return new Editor({ extensions, content });
}
/** Load `content`, fire one local edit so the sync plugin converges, return JSON. */
function pluginSteadyState(content: any): any {
const editor = makeEditor(content);
// A local doc change triggers footnoteSyncPlugin.appendTransaction.
editor.commands.insertContentAt(1, ' ');
const json = editor.state.doc.toJSON();
editor.destroy();
return json;
}
const corpus: Array<{ name: string; content: any }> = [
{
name: 'plain ref + def',
content: {
type: 'doc',
content: [para({ type: 'text', text: 'a' }, ref('x')), list(def('x', 'X'))],
},
},
{
name: 'two refs, two defs in reference order',
content: {
type: 'doc',
content: [
para({ type: 'text', text: 'a' }, ref('x'), { type: 'text', text: 'b' }, ref('y')),
list(def('x', 'X'), def('y', 'Y')),
],
},
},
{
name: 'orphan definition gets removed',
content: {
type: 'doc',
content: [para({ type: 'text', text: 'a' }, ref('x')), list(def('x', 'X'), def('orphan', 'O'))],
},
},
{
name: 'reference missing its definition (synth empty)',
content: {
type: 'doc',
content: [para({ type: 'text', text: 'a' }, ref('x'))],
},
},
{
name: 'reuse: repeated references, one definition',
content: {
type: 'doc',
content: [
para(ref('d'), { type: 'text', text: ' a ' }, ref('d'), ref('d')),
list(def('d', 'shared')),
],
},
},
{
name: 'no footnotes at all',
content: {
type: 'doc',
content: [para({ type: 'text', text: 'just text' })],
},
},
];
for (const { name, content } of corpus) {
it(`steady state is a canonicalize no-op: ${name}`, () => {
const steady = pluginSteadyState(content);
expect(canonicalizeFootnotes(steady)).toEqual(steady);
});
}
it('placement parity: the LIVE plugin leaves a list with NON-EMPTY content after it in place, and canonicalize agrees', () => {
// Drives the real footnoteSyncPlugin (not a hand-authored expected): a single
// canonical list with body content AFTER it must NOT be repositioned by the
// plugin, and the server canonicalizer must agree (step-6 placement parity).
const content = {
type: 'doc',
content: [
para({ type: 'text', text: 'a' }, ref('x')),
list(def('x', 'X')),
para({ type: 'text', text: 'epilogue' }),
],
};
const steady = pluginSteadyState(content);
// The plugin did NOT move the list to the end: a non-empty paragraph follows it.
const types = steady.content.map((n: any) => n.type);
const listPos = types.indexOf(FOOTNOTES_LIST_NAME);
expect(listPos).toBeGreaterThanOrEqual(0);
expect(listPos).toBeLessThan(types.length - 1);
const after = steady.content[listPos + 1];
expect(after.type).toBe('paragraph');
expect(JSON.stringify(after)).toContain('epilogue');
// The canonicalizer is a byte-for-byte no-op on that steady state (parity).
expect(canonicalizeFootnotes(steady)).toEqual(steady);
});
it('the canonicalizer and the editor agree on reference order and definition set', () => {
const content = {
type: 'doc',
content: [
para({ type: 'text', text: 'a' }, ref('x'), { type: 'text', text: 'b' }, ref('y')),
list(def('y', 'Y'), def('x', 'X')), // physically reversed
],
};
const steady = pluginSteadyState(content);
const canon = canonicalizeFootnotes(content);
// Same reference order and same DEFINITION SET (ids) in both, even though the
// physical list order may differ (the plugin preserves node identity, the
// canonicalizer reorders). Numbering — derived from reference order — matches.
expect(refOrder(steady)).toEqual(['x', 'y']);
expect(defOrder(canon)).toEqual(['x', 'y']);
expect(new Set(defOrder(steady))).toEqual(new Set(defOrder(canon)));
});
});
/**
* SHARED golden corpus: this editor-ext copy of `canonicalizeFootnotes` and the
* MCP mirror (`packages/mcp/src/lib/footnote-canonicalize.ts`) are BOTH run
* against the identical { input -> expected } corpus. Pinning the same expected
* outputs in both suites makes "the two pure copies behave identically" a
* checkable property without coupling the packages (architecture item A). The
* MCP mirror of these assertions lives in `test/unit/footnote-corpus.test.mjs`.
*/
describe('canonicalizeFootnotes shared golden corpus (editor-ext copy)', () => {
for (const { name, input, expected } of FOOTNOTE_CORPUS) {
it(`matches the corpus expected output: ${name}`, () => {
expect(canonicalizeFootnotes(input)).toEqual(expected);
// Idempotent on the corpus too.
expect(canonicalizeFootnotes(expected)).toEqual(expected);
});
}
});

View File

@@ -0,0 +1,272 @@
import {
FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME,
FOOTNOTE_DEFINITION_NAME,
} from './footnote-util';
/**
* Server-side, EditorView-free port of the footnote integrity invariant that
* `footnoteSyncPlugin` maintains in the live editor. Where the plugin is an
* `appendTransaction` that only runs inside a ProseMirror `EditorView`, this is
* a PURE function over ProseMirror JSON: `canonicalizeFootnotes(doc) -> doc`.
*
* It exists because the NON-editor write paths served by THIS copy build
* ProseMirror JSON directly (never running the editor's plugins), so the
* canonical footnote topology was never enforced on those writes. The consumers
* of this editor-ext copy are: the server markdown/HTML import
* (`markdownToHtml -> htmlToJson` in import.service / file-import-task.service),
* `PageService` create/update (`parseProsemirrorContent` for the JSON/markdown/
* HTML REST write paths), and the client markdown PASTE path
* (`markdown-clipboard.ts`). (The MCP package mirrors this canonicalizer in
* `packages/mcp/src/lib/footnote-canonicalize.ts` for its own FULL-document write
* paths — `markdownToProseMirrorCanonical` (the page markdown-import path; the
* plain `markdownToProseMirror` primitive used for COMMENT bodies does NOT
* canonicalize), `update_page_json`, `docmost_transform`, `insert_footnote`,
* `copy_page_content` — see that file's header.) All of these are the root cause
* of the symptom in the issue: footnotes rendered out of order (`1, 4, 2, 3, …`),
* a raw trailing `[^id]: …` block, and orphan definitions, all of which are
* simply the result of content written PAST the canonicalizer.
*
* The desired end-state (identical to the plugin's) is:
*
* 1. Reference ids in DOCUMENT ORDER are the single source of truth for which
* definitions exist and in what order (numbering is derived from this, see
* `computeFootnoteNumbers`). Repeated references that share an id are REUSE
* (one footnote, one number, one definition) — never re-id'd.
* 2. Exactly ONE `footnotesList`, holding one definition per referenced id in
* REFERENCE order, reusing the existing definition node (content preserved)
* or synthesizing an empty one when missing. The list sits after the last
* meaningful block (only trailing empty paragraphs may follow it).
* 3. Orphan definitions (no matching reference) are dropped.
* 4. Duplicate DEFINITIONS (two nodes sharing an id) are resolved first-wins:
* the first definition for an id is kept; later duplicates carry the SAME
* id, so they can never be referenced separately and are simply dropped.
* This matches the importer's first-wins rule ("one definition per id").
* (The LIVE editor instead re-id's a duplicate definition so a paste/collab
* merge cannot silently lose live user data; the artifacts this copy
* sanitizes are agent/import-authored, so first-wins is the right policy —
* see footnote-sync.ts `resolveCollisions`.)
* 5. Idempotent: a document that already satisfies the invariant is returned
* structurally unchanged (the existing definition/list nodes are reused
* verbatim), so re-running the canonicalizer — or running it on a write that
* the editor already canonicalized — is a no-op. This is what makes it safe
* to wire into EVERY write path without spurious mutations / git-sync churn.
*
* Divergence from the live plugin (intentional): the plugin preserves the
* PHYSICAL order of existing definition nodes to keep their Yjs/CRDT subtree
* identity stable across collaborators (numbering is decoration-derived, so the
* displayed numbers are correct regardless of physical order). This function has
* no live CRDT to protect, so when a REPAIR is needed it physically REORDERS the
* list into reference order — which is exactly the fix the out-of-order import
* needs.
*
* Placement PARITY with the plugin: when the document is already in the canonical
* single-list state, this function leaves that list EXACTLY where it sits (it
* does not move it to the end). The plugin behaves the same — it treats one
* footnotesList holding the canonical definition set as canonical regardless of
* whether content follows it (footnote-sync.ts: `primaryList` falls back to the
* last list and `noChangeNeeded` stays true). So on every editor-reachable steady
* state the two agree byte-for-byte, including when non-empty content follows the
* list; see the golden parity test and the shared corpus.
*
* Pure: deep-clones its input, never mutates the caller's object, and is
* deterministic (no `Math.random`/`Date.now`).
*/
export function canonicalizeFootnotes<T = any>(doc: T): T {
if (
doc == null ||
typeof doc !== 'object' ||
!Array.isArray((doc as any).content)
) {
return doc;
}
const out = cloneJson(doc) as any;
// 1) Distinct reference ids in document order (deep — references can live in
// callouts, tables, list items, ...). This is the ordering/numbering truth.
const referenceIds: string[] = [];
const seenRefIds = new Set<string>();
collectReferenceIds(out, referenceIds, seenRefIds);
// 2) Every definition node in document order (deep — defs normally live inside
// one or more `footnotesList` blocks, but we tolerate stray placements).
const defNodes: any[] = [];
collectDefinitions(out, defNodes);
// 3) First definition per id wins. Later duplicates carry the SAME id, so they
// can never be referenced separately and would be orphans — they are simply
// dropped (first-wins; see the file header, item 4).
const defById = new Map<string, any>();
for (const d of defNodes) {
const id = d?.attrs?.id;
if (id && !defById.has(id)) defById.set(id, d);
}
// 4) Build the ordered definition list: one per referenced id, in REFERENCE
// order, reusing the existing node (content preserved, id normalized) or
// synthesizing an empty definition. Definitions whose id is NOT referenced
// are orphans and are simply never added. The reused node is SHALLOW-copied
// (id normalized): `out` is already a deep clone and the old lists are cut,
// so a second per-definition deep clone is needless.
const orderedDefs: any[] = [];
for (const id of referenceIds) {
const existing = defById.get(id);
if (existing) {
orderedDefs.push({
...existing,
attrs: { ...(existing.attrs ?? {}), id },
});
} else {
orderedDefs.push(emptyDefinition(id));
}
}
// 5) No references -> there must be NO list at all (at any depth).
if (referenceIds.length === 0) {
stripFootnotesListsDeep(out);
return out;
}
// 6) Placement parity with the live plugin: when the document is ALREADY in the
// canonical single-list state, leave that list exactly where it sits instead
// of cutting and re-inserting it at the end. The plugin never repositions a
// sole correct list (footnote-sync.ts), so moving it here would silently
// reorder any user content that follows the list on the first write. The doc
// is in that state when there is exactly one top-level footnotesList, every
// definition in the doc is referenced (no orphans / duplicates: the def count
// equals the canonical count), and the list already holds exactly the
// canonical definitions in reference order.
const topLevelLists = out.content.filter(
(n: any) => n && n.type === FOOTNOTES_LIST_NAME,
);
if (
topLevelLists.length === 1 &&
defNodes.length === orderedDefs.length &&
deepEqualJson(topLevelLists[0].content, orderedDefs)
) {
return out;
}
// 7) Otherwise rebuild: strip every footnotesList AND every bare
// footnoteDefinition at ANY depth (collectDefinitions gathers defs
// recursively, so a list nested in a callout/blockquote — or a bare
// definition outside any list — would otherwise have its defs copied into the
// rebuilt list while the original survives in place → duplicates) and
// re-insert exactly one list after the last meaningful (non-empty paragraph)
// top-level block, so it coexists with a trailing-node empty paragraph. This
// both repairs a non-canonical doc and (in the import case) physically
// reorders the list into reference order.
stripFootnotesListsDeep(out);
stripFootnoteDefinitionsDeep(out);
const top: any[] = out.content;
let insertAt = top.length;
while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--;
top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs });
out.content = top;
return out;
}
/** Remove every `footnotesList` node at ANY depth (mutates the given clone). */
function stripFootnotesListsDeep(node: any): void {
if (!node || typeof node !== 'object' || !Array.isArray(node.content)) return;
node.content = node.content.filter(
(c: any) => !(c && c.type === FOOTNOTES_LIST_NAME),
);
for (const child of node.content) stripFootnotesListsDeep(child);
}
/**
* Remove every BARE `footnoteDefinition` node at ANY depth (mutates the given
* clone). Runs only in the rebuild path AFTER the lists are stripped, so it
* targets definitions that were sitting outside a list (e.g. hand-authored via a
* raw-JSON write path and nested in a callout); their content was already copied
* into the rebuilt list, so leaving the originals would duplicate them.
*/
function stripFootnoteDefinitionsDeep(node: any): void {
if (!node || typeof node !== 'object' || !Array.isArray(node.content)) return;
node.content = node.content.filter(
(c: any) => !(c && c.type === FOOTNOTE_DEFINITION_NAME),
);
for (const child of node.content) stripFootnoteDefinitionsDeep(child);
}
/**
* Deep equality over plain JSON: arrays are compared POSITIONALLY
* (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity
* is required for correctness here — a reordered `footnotesList.content` must
* compare UNEQUAL so the canonical rebuild fires instead of leaving it in place.
*/
function deepEqualJson(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null || typeof a !== typeof b) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqualJson(a[i], b[i])) return false;
}
return true;
}
if (typeof a === 'object') {
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqualJson(a[k], b[k])) return false;
}
return true;
}
return false;
}
/** A fresh empty definition node for a referenced id with no definition. */
function emptyDefinition(id: string): any {
return {
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id },
content: [{ type: 'paragraph' }],
};
}
function isEmptyParagraph(node: any): boolean {
return (
!!node &&
node.type === 'paragraph' &&
(!Array.isArray(node.content) || node.content.length === 0)
);
}
/** Collect DISTINCT footnoteReference ids in document order (first appearance). */
function collectReferenceIds(
node: any,
out: string[],
seen: Set<string>,
): void {
if (!node || typeof node !== 'object') return;
if (node.type === FOOTNOTE_REFERENCE_NAME) {
const id = node?.attrs?.id;
if (id && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) collectReferenceIds(child, out, seen);
}
}
/** Collect every footnoteDefinition node in document order. */
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);
}
}
function cloneJson<T>(v: T): T {
if (typeof structuredClone === 'function') return structuredClone(v);
return JSON.parse(JSON.stringify(v)) as T;
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,3 +4,4 @@ export * from "./footnotes-list";
export * from "./footnote-definition";
export * from "./footnote-numbering";
export * from "./footnote-sync";
export * from "./footnote-canonicalize";