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>
181 lines
7.4 KiB
JavaScript
181 lines
7.4 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
buildCollabWsUrl,
|
|
markdownToProseMirror,
|
|
markdownToProseMirrorCanonical,
|
|
} from "../../build/lib/collaboration.js";
|
|
|
|
/** Recursively find the first descendant node (or self) of the given type. */
|
|
function find(node, type) {
|
|
if (!node || typeof node !== "object") return null;
|
|
if (node.type === type) return node;
|
|
const kids = Array.isArray(node.content) ? node.content : [];
|
|
for (const k of kids) {
|
|
const r = find(k, type);
|
|
if (r) return r;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Recursively collect every descendant node (and self) of the given type. */
|
|
function findAll(node, type, acc = []) {
|
|
if (!node || typeof node !== "object") return acc;
|
|
if (node.type === type) acc.push(node);
|
|
const kids = Array.isArray(node.content) ? node.content : [];
|
|
for (const k of kids) findAll(k, type, acc);
|
|
return acc;
|
|
}
|
|
|
|
/** Collect the set of mark types present anywhere in the document tree. */
|
|
function collectMarkTypes(node, set = new Set()) {
|
|
if (!node || typeof node !== "object") return set;
|
|
if (Array.isArray(node.marks)) {
|
|
for (const m of node.marks) set.add(m.type);
|
|
}
|
|
const kids = Array.isArray(node.content) ? node.content : [];
|
|
for (const k of kids) collectMarkTypes(k, set);
|
|
return set;
|
|
}
|
|
|
|
test("buildCollabWsUrl: https + /api -> wss + /collab", () => {
|
|
assert.equal(buildCollabWsUrl("https://h/api"), "wss://h/collab");
|
|
});
|
|
|
|
test("buildCollabWsUrl: http (no /api) -> ws + /collab", () => {
|
|
assert.equal(buildCollabWsUrl("http://h"), "ws://h/collab");
|
|
});
|
|
|
|
test("buildCollabWsUrl: trailing slash on /api/ is handled", () => {
|
|
assert.equal(buildCollabWsUrl("https://h/api/"), "wss://h/collab");
|
|
});
|
|
|
|
test("buildCollabWsUrl: a base with trailing slash maps to /collab", () => {
|
|
assert.equal(buildCollabWsUrl("https://h/"), "wss://h/collab");
|
|
});
|
|
|
|
test("buildCollabWsUrl: query and hash on the base are dropped", () => {
|
|
assert.equal(buildCollabWsUrl("https://h/api?foo=1#bar"), "wss://h/collab");
|
|
});
|
|
|
|
test("markdownToProseMirror: :::warning::: becomes a callout node typed warning", async () => {
|
|
const doc = await markdownToProseMirror(":::warning\nhello\n:::");
|
|
const callout = find(doc, "callout");
|
|
assert.ok(callout, "expected a callout node");
|
|
assert.equal(callout.attrs.type, "warning");
|
|
});
|
|
|
|
test("markdownToProseMirror: a ::: line inside a fenced code block is not a callout delimiter", async () => {
|
|
const doc = await markdownToProseMirror("```\n:::warning\nx\n:::\n```");
|
|
assert.equal(find(doc, "callout"), null, "code-fenced ::: must not open a callout");
|
|
assert.ok(find(doc, "codeBlock"), "the fenced block should stay a codeBlock");
|
|
});
|
|
|
|
test("markdownToProseMirror: GFM checkbox list -> one taskList, two taskItems, no bulletList", async () => {
|
|
const doc = await markdownToProseMirror("- [x] a\n- [ ] b");
|
|
const taskLists = findAll(doc, "taskList");
|
|
assert.equal(taskLists.length, 1, "expected exactly one taskList");
|
|
const items = findAll(doc, "taskItem");
|
|
assert.equal(items.length, 2, "expected two taskItems");
|
|
assert.deepEqual(
|
|
items.map((i) => i.attrs.checked),
|
|
[true, false],
|
|
);
|
|
assert.equal(find(doc, "bulletList"), null, "no bulletList should remain");
|
|
});
|
|
|
|
test("markdownToProseMirror: numbered checklist -> one taskList, no orderedList (ol phantom regression)", async () => {
|
|
const doc = await markdownToProseMirror("1. [x] a\n2. [ ] b");
|
|
const taskLists = findAll(doc, "taskList");
|
|
assert.equal(taskLists.length, 1, "expected exactly one taskList");
|
|
assert.equal(
|
|
find(doc, "orderedList"),
|
|
null,
|
|
"a numbered checklist must not leave a phantom orderedList",
|
|
);
|
|
assert.deepEqual(
|
|
findAll(doc, "taskItem").map((i) => i.attrs.checked),
|
|
[true, false],
|
|
);
|
|
});
|
|
|
|
test("markdownToProseMirror: a plain numbered list stays an orderedList", async () => {
|
|
const doc = await markdownToProseMirror("1. a\n2. b");
|
|
assert.ok(find(doc, "orderedList"), "plain numbered list should be an orderedList");
|
|
assert.equal(find(doc, "taskList"), null, "plain numbered list must not become a taskList");
|
|
});
|
|
|
|
test("markdownToProseMirror: mark/sub/sup produce highlight, subscript, superscript marks", async () => {
|
|
const doc = await markdownToProseMirror("<mark>h</mark> <sub>x</sub> <sup>y</sup>");
|
|
const marks = collectMarkTypes(doc);
|
|
assert.ok(marks.has("highlight"), "expected a highlight mark");
|
|
assert.ok(marks.has("subscript"), "expected a subscript mark");
|
|
assert.ok(marks.has("superscript"), "expected a superscript mark");
|
|
});
|
|
|
|
test("markdownToProseMirror: an aligned GFM table maps header alignment", async () => {
|
|
const doc = await markdownToProseMirror(
|
|
"| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |",
|
|
);
|
|
const headers = findAll(doc, "tableHeader");
|
|
assert.equal(headers.length, 3, "expected three header cells");
|
|
assert.deepEqual(
|
|
headers.map((h) => h.attrs.align),
|
|
["left", "center", "right"],
|
|
);
|
|
});
|
|
|
|
// Comment-body data-loss guard (#228 review #4): markdownToProseMirror is reused
|
|
// for COMMENT bodies (createComment/updateComment), so it must NOT canonicalize.
|
|
// Under the #293 canon, footnotes are INLINE (`^[body]`), so a comment can no
|
|
// longer carry a reference-less definition to be dropped — but the comment path
|
|
// must still (a) leave a legacy reference-style `[^id]:` line as harmless literal
|
|
// TEXT (never silently deleted) and (b) preserve an inline footnote it does
|
|
// contain (no canonicalization stripping it). The page-write variant canonicalizes.
|
|
test("markdownToProseMirror (comment path) keeps a legacy `[^id]:` line as literal text", async () => {
|
|
// A reference-style `[^1]:` line is not canonical footnote syntax anymore, so it
|
|
// is not parsed into a footnote node — but its TEXT must survive verbatim (no
|
|
// data loss on the comment write path).
|
|
const md = "A comment.\n\n[^1]: a standalone footnote definition";
|
|
const doc = await markdownToProseMirror(md);
|
|
assert.equal(
|
|
findAll(doc, "footnoteDefinition").length,
|
|
0,
|
|
"reference-style line is not a footnote node",
|
|
);
|
|
assert.match(
|
|
JSON.stringify(doc),
|
|
/a standalone footnote definition/,
|
|
"the text must survive the comment write path",
|
|
);
|
|
});
|
|
|
|
test("markdownToProseMirror (comment path) PRESERVES an inline footnote (no canonicalization)", async () => {
|
|
// An inline `^[body]` footnote in a comment imports to a real footnote node and
|
|
// is NOT dropped: the comment path must never canonicalize away content.
|
|
const md = "A comment.\n\n^[an inline footnote]";
|
|
const doc = await markdownToProseMirror(md);
|
|
assert.equal(findAll(doc, "footnoteDefinition").length, 1);
|
|
assert.equal(findAll(doc, "footnotesList").length, 1);
|
|
assert.match(JSON.stringify(doc), /an inline footnote/);
|
|
});
|
|
|
|
test("markdownToProseMirrorCanonical (page path) yields a single reference-ordered list", async () => {
|
|
// Page path produces the canonical footnote topology: one trailing
|
|
// `footnotesList`, definitions in FIRST-REFERENCE order, ids assigned
|
|
// sequentially. Inline `^[body]` footnotes carry the body at the reference
|
|
// point, so the bottom list is inherently reference-ordered.
|
|
const md = "See^[bravo] then^[alpha].";
|
|
const doc = await markdownToProseMirrorCanonical(md);
|
|
const defs = findAll(doc, "footnoteDefinition");
|
|
assert.deepEqual(
|
|
defs.map((d) => d.attrs.id),
|
|
["fn-1", "fn-2"],
|
|
);
|
|
assert.equal(findAll(doc, "footnotesList").length, 1);
|
|
// Bodies stay in reference order (bravo referenced before alpha).
|
|
assert.match(JSON.stringify(defs[0]), /bravo/);
|
|
assert.match(JSON.stringify(defs[1]), /alpha/);
|
|
});
|