124f5a45a2
mcp had its OWN drifted copy of the converter (markdown-converter.ts ~900 lines, docmost-schema.ts ~1270 lines, markdown-document.ts) — older than the shared package, missing the git-sync fixes AND the #293 canon. This switches mcp's converter CORE to @docmost/prosemirror-markdown, so mcp jumps straight to the canonical format and the drift-generating second copy is gone. - markdown-converter.ts / markdown-document.ts / docmost-schema.ts become thin re-export shims of the package (convertProseMirrorToMarkdown, the docmost:meta envelope, docmostExtensions + docmostSchema=getSchema(docmostExtensions)). The mcp-only helpers clampCalloutType/sanitizeCssColor are preserved verbatim in the schema shim (the package doesn't expose them via its barrel). ~2170 lines of the drifted converter/schema bodies deleted. - collaboration.ts drops its own ~360-line marked pipeline (preprocessCallouts, bridgeTaskLists, extractFootnotes, the footnoteRef extension) and re-points to the package's markdownToProseMirror, keeping markdownToProseMirrorCanonical and all the yjs/collab write glue. footnote-lex/analyze doc comments updated (they now describe advisory legacy-syntax diagnostics, not an importer). Schema parity verified: the package schema is a strict SUPERSET of mcp's old schema — every node and attr mcp declared is present (the package only adds status/pageEmbed/transclusion*/subpages.recursive/etc.), so nothing is silently dropped on the switch. The switch actually FIXES two pre-existing mcp data-loss bugs its own tests documented: htmlEmbed and pageBreak now round-trip (were dropped by the old mcp converter). Footnotes: the package assembles inline ^[body] footnotes on import (sequential fn-N ids, identical bodies merged), so mcp's canonicalizeFootnotes is now an idempotent no-op after it (verified). Legacy reference footnotes [^id]/[^id]: are inert literal text (canon #2 no-backward-compat) — lossless, the text survives verbatim. Build hygiene: packages/mcp/build/ is now gitignored and untracked, matching the git-sync/prosemirror-markdown convention (private package, rebuilt in CI/Docker, so src and prod can never silently diverge). This also removes a dead untracked build/_vendored_editor_ext/ artifact that a broad `git add` would otherwise commit. Dependency: packages/mcp/package.json gains @docmost/prosemirror-markdown (workspace:*); pnpm-lock.yaml gets the matching link importer (mirrors git-sync). mcp tests updated deliberately to the canonical forms (highlight ==, math $…$, image <!--img-->, drawio/media discriminators, subpages/pageBreak comments, textAlign, inline ^[…] footnotes) with strict assertions; 4 structural safety-net round-trip tests added. mcp: node --test 454 passed; tsc clean. package: 657 passed. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js";
|
|
import {
|
|
footnoteContentKey,
|
|
generateFootnoteId,
|
|
} from "../../build/lib/footnote-authoring.js";
|
|
import { insertInlineFootnote } from "../../build/lib/transforms.js";
|
|
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.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 defIds = (doc) =>
|
|
findAll(doc, "footnoteDefinition").map((d) => d.attrs.id);
|
|
const refIds = (doc) =>
|
|
findAll(doc, "footnoteReference").map((r) => r.attrs.id);
|
|
|
|
const ref = (id) => ({ type: "footnoteReference", attrs: { id } });
|
|
const def = (id, text) => ({
|
|
type: "footnoteDefinition",
|
|
attrs: { id },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
});
|
|
const para = (...inline) => ({ type: "paragraph", content: inline });
|
|
const list = (...defs) => ({ type: "footnotesList", content: defs });
|
|
|
|
// The ordering / orphan-drop / no-refs / duplicate-first-wins cases are covered
|
|
// (with full deepEqual on input -> expected) by the shared golden corpus in
|
|
// footnote-corpus.test.mjs; only the input-immutability and idempotence
|
|
// properties — which the corpus does not assert — are kept here.
|
|
|
|
test("canonicalize is idempotent", () => {
|
|
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);
|
|
assert.deepEqual(twice, once);
|
|
});
|
|
|
|
test("canonicalize does not mutate its input", () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "x" }, ref("a")), list(def("o", "O"))],
|
|
};
|
|
const snap = JSON.parse(JSON.stringify(doc));
|
|
canonicalizeFootnotes(doc);
|
|
assert.deepEqual(doc, snap);
|
|
});
|
|
|
|
test("footnoteContentKey: same text -> same key; formatting differs -> different key", () => {
|
|
const plain = def("x", "hello world");
|
|
const sameText = def("y", "hello world"); // whitespace-collapsed match
|
|
const bold = {
|
|
type: "footnoteDefinition",
|
|
attrs: { id: "z" },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "hello world", marks: [{ type: "bold" }] },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
assert.equal(footnoteContentKey(plain), footnoteContentKey(sameText));
|
|
assert.notEqual(footnoteContentKey(plain), footnoteContentKey(bold));
|
|
});
|
|
|
|
test("insertInlineFootnote: places a reference at the anchor and derives the list", () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "The sky is blue today." })],
|
|
};
|
|
const r = insertInlineFootnote(doc, {
|
|
anchorText: "blue",
|
|
text: "Rayleigh scattering.",
|
|
});
|
|
assert.equal(r.inserted, true);
|
|
assert.equal(r.reused, false);
|
|
assert.equal(refIds(r.doc).length, 1);
|
|
assert.deepEqual(defIds(r.doc), [r.footnoteId]);
|
|
// The marker hugs the anchor word (no leading space text run before the ref).
|
|
assert.equal(findAll(r.doc, "footnotesList").length, 1);
|
|
});
|
|
|
|
test("insertInlineFootnote: content dedup -> same text reuses one definition, two refs", () => {
|
|
let doc = {
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "Alpha and beta and gamma." })],
|
|
};
|
|
const r1 = insertInlineFootnote(doc, {
|
|
anchorText: "Alpha",
|
|
text: "shared note",
|
|
});
|
|
const r2 = insertInlineFootnote(r1.doc, {
|
|
anchorText: "beta",
|
|
text: "shared note",
|
|
});
|
|
assert.equal(r2.reused, true);
|
|
assert.equal(r2.footnoteId, r1.footnoteId);
|
|
// One definition, two references both pointing at it.
|
|
assert.deepEqual(defIds(r2.doc), [r1.footnoteId]);
|
|
assert.deepEqual(refIds(r2.doc), [r1.footnoteId, r1.footnoteId]);
|
|
});
|
|
|
|
test("insertInlineFootnote: distinct text -> two definitions numbered by reference order", () => {
|
|
let doc = {
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "First point, second point." })],
|
|
};
|
|
const r1 = insertInlineFootnote(doc, { anchorText: "First", text: "note one" });
|
|
const r2 = insertInlineFootnote(r1.doc, {
|
|
anchorText: "second",
|
|
text: "note two",
|
|
});
|
|
assert.equal(r2.reused, false);
|
|
// Reference order in the body is [First-ref, second-ref]; the derived list
|
|
// matches that order.
|
|
assert.deepEqual(defIds(r2.doc), refIds(r2.doc));
|
|
assert.equal(defIds(r2.doc).length, 2);
|
|
});
|
|
|
|
test("insertInlineFootnote: anchor not found -> inserted:false, no write", () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "nothing to anchor on" })],
|
|
};
|
|
const r = insertInlineFootnote(doc, { anchorText: "ZZZ", text: "x" });
|
|
assert.equal(r.inserted, false);
|
|
assert.equal(findAll(r.doc, "footnoteReference").length, 0);
|
|
});
|
|
|
|
test("insertInlineFootnote: anchor ONLY inside a codeBlock -> refused (no invalid doc)", () => {
|
|
// A footnoteReference is an inline atom; codeBlock content is text-only, so
|
|
// splicing one in would persist a schema-invalid doc. The insert must refuse.
|
|
const doc = {
|
|
type: "doc",
|
|
content: [{ type: "codeBlock", content: [{ type: "text", text: "const blue = 1;" }] }],
|
|
};
|
|
const r = insertInlineFootnote(doc, { anchorText: "blue", text: "Rayleigh." });
|
|
assert.equal(r.inserted, false);
|
|
assert.equal(findAll(r.doc, "footnoteReference").length, 0);
|
|
assert.equal(findAll(r.doc, "footnotesList").length, 0);
|
|
// The codeBlock text is untouched.
|
|
assert.deepEqual(r.doc, doc);
|
|
});
|
|
|
|
test("insertInlineFootnote: anchor ONLY inside an existing footnote definition -> refused", () => {
|
|
// The anchor text lives in a definition (inside the footnotesList). The search
|
|
// is bounded to the BODY (before the first list), so it is not matched there
|
|
// and the insert is refused rather than nesting a reference in a definition.
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
para({ type: "text", text: "Hello world." }, ref("a")),
|
|
list(def("a", "the sky is blue")),
|
|
],
|
|
};
|
|
const r = insertInlineFootnote(doc, { anchorText: "sky", text: "note" });
|
|
assert.equal(r.inserted, false);
|
|
// No EXTRA reference and still exactly one (the pre-existing) list/definition.
|
|
assert.equal(findAll(r.doc, "footnoteReference").length, 1);
|
|
assert.deepEqual(defIds(r.doc), ["a"]);
|
|
});
|
|
|
|
test("insertInlineFootnote: codeBlock match is skipped, a later body paragraph still anchors", () => {
|
|
// The anchor first appears in a codeBlock (refused) but also in a normal
|
|
// paragraph after it; the insert falls through to the valid block.
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
{ type: "codeBlock", content: [{ type: "text", text: "let token = 1;" }] },
|
|
para({ type: "text", text: "The token is rotated daily." }),
|
|
],
|
|
};
|
|
const r = insertInlineFootnote(doc, { anchorText: "token", text: "secret" });
|
|
assert.equal(r.inserted, true);
|
|
// The reference landed in the paragraph, NOT the codeBlock.
|
|
const code = findAll(r.doc, "codeBlock")[0];
|
|
assert.equal(findAll(code, "footnoteReference").length, 0);
|
|
assert.equal(findAll(r.doc, "footnoteReference").length, 1);
|
|
});
|
|
|
|
test("insertInlineFootnote: anchor only inside a NESTED definition -> refused, definition preserved", () => {
|
|
// The footnotesList is nested in a callout (not top level) and the anchor text
|
|
// appears ONLY inside that definition. The search must be bounded past the
|
|
// notes subtree (recursive boundary) AND refuse to descend into the definition,
|
|
// so it aborts cleanly instead of gluing a reference into the definition (which
|
|
// canonicalize would then drop as an orphan, losing the definition's prose).
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
para({ type: "text", text: "Body text here." }, ref("a")),
|
|
{
|
|
type: "callout",
|
|
content: [list(def("a", "the unique anchor lives here"))],
|
|
},
|
|
],
|
|
};
|
|
const r = insertInlineFootnote(doc, {
|
|
anchorText: "unique anchor",
|
|
text: "new note",
|
|
});
|
|
assert.equal(r.inserted, false);
|
|
// The existing definition (and its text) is preserved untouched.
|
|
assert.equal(findAll(r.doc, "footnoteDefinition").length, 1);
|
|
assert.match(JSON.stringify(r.doc), /the unique anchor lives here/);
|
|
assert.equal(findAll(r.doc, "footnoteReference").length, 1); // only the original
|
|
});
|
|
|
|
test("insertInlineFootnote: anchor only inside a BARE definition (no list wrapper) -> refused", () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
para({ type: "text", text: "Some body." }),
|
|
{
|
|
type: "footnoteDefinition",
|
|
attrs: { id: "a" },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "orphan anchor text" }] }],
|
|
},
|
|
],
|
|
};
|
|
const r = insertInlineFootnote(doc, { anchorText: "orphan anchor", text: "x" });
|
|
assert.equal(r.inserted, false);
|
|
assert.equal(findAll(r.doc, "footnoteDefinition").length, 1);
|
|
assert.match(JSON.stringify(r.doc), /orphan anchor text/);
|
|
});
|
|
|
|
test("insertInlineFootnote: anchor in body BEFORE a nested list still inserts", () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
para({ type: "text", text: "The sky is blue." }, ref("a")),
|
|
{ type: "callout", content: [list(def("a", "note a"))] },
|
|
],
|
|
};
|
|
const r = insertInlineFootnote(doc, { anchorText: "blue", text: "Rayleigh." });
|
|
assert.equal(r.inserted, true);
|
|
// The new reference plus the original = two references; a single canonical list.
|
|
assert.equal(findAll(r.doc, "footnoteReference").length, 2);
|
|
assert.equal(findAll(r.doc, "footnotesList").length, 1);
|
|
});
|
|
|
|
test("markdown import (page path): inline footnotes render as a reference-ordered list", async () => {
|
|
// Inline `^[body]` footnotes carry their body at the reference point, so the
|
|
// PAGE import path (markdownToProseMirrorCanonical) materializes the bottom
|
|
// list in REFERENCE order — numbers read 1, 2, 3 down the list — with ids
|
|
// assigned sequentially (fn-1, fn-2, fn-3).
|
|
const md = "See^[bravo] then^[alpha] then^[charlie].";
|
|
const json = await markdownToProseMirrorCanonical(md);
|
|
assert.deepEqual(defIds(json), ["fn-1", "fn-2", "fn-3"]);
|
|
assert.equal(findAll(json, "footnotesList").length, 1);
|
|
// Bodies materialize in reference order (bravo, alpha, charlie).
|
|
const defsJson = JSON.stringify(findAll(json, "footnoteDefinition"));
|
|
assert.ok(
|
|
defsJson.indexOf("bravo") <
|
|
defsJson.indexOf("alpha") &&
|
|
defsJson.indexOf("alpha") < defsJson.indexOf("charlie"),
|
|
"definitions follow reference order",
|
|
);
|
|
});
|
|
|
|
test("generateFootnoteId: valid uuidv7 shape (version 7, variant 8..b) and unique", () => {
|
|
// version nibble = 7; variant nibble in [8,9,a,b]; otherwise lowercase hex.
|
|
const re =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
const ids = new Set();
|
|
for (let i = 0; i < 50; i++) {
|
|
const id = generateFootnoteId();
|
|
assert.match(id, re, `not a uuidv7: ${id}`);
|
|
ids.add(id);
|
|
}
|
|
// Distinct across calls (random component makes collisions astronomically rare).
|
|
assert.equal(ids.size, 50, "generated ids must be unique");
|
|
});
|