feat(mcp): serve embedded community MCP server at /mcp

Replace the removed enterprise EE MCP (private apps/server/src/ee submodule,
license-gated /mcp route) with our docmost-mcp, vendored as an isolated ESM
workspace package and served by the server over HTTP — no enterprise license.

Backend:
- Add packages/mcp (@docmost/mcp): vendored docmost-mcp refactored into a
  side-effect-free createDocmostMcpServer() factory (38 tools preserved),
  stdio entry kept in stdio.ts, Streamable-HTTP session manager in http.ts.
- Add apps/server McpModule: @Post/@Get/@Delete('mcp') (served at /mcp via the
  existing global-prefix exclude), @SkipTransform + reply.hijack to bridge raw
  Fastify req/res into the SDK transport. The module dynamically imports the
  ESM-only package from CommonJS via a Function-indirected import resolved with
  require.resolve + file:// URL. Gated by the workspace ai.mcp toggle, a
  service-account (MCP_DOCMOST_EMAIL/PASSWORD/API_URL) and optional MCP_TOKEN;
  per-session idle eviction (MCP_SESSION_IDLE_MS).
- Drop the enterprise license check on mcpEnabled in workspace.service.
- Dockerfile: copy packages/mcp into the production image.
- .env.example: document MCP_DOCMOST_*, MCP_TOKEN, MCP_SESSION_IDLE_MS.

Frontend:
- Recreate the community "AI & MCP" workspace-settings panel (mcp-settings.tsx):
  admin-only toggle on settings.ai.mcp with optimistic update, copyable
  ${APP_URL}/mcp URL; wired into workspace-settings page. Reuses existing i18n.

Fixes:
- Pin packages/mcp tiptap deps to 3.20.4 (matching the client) and inline
  getStyleProperty, preventing a duplicate @tiptap/core@3.26.1 from leaking into
  the client editor via pnpm shamefully-hoist (was breaking apps/client tsc).
This commit is contained in:
vvzvlad
2026-06-16 23:54:53 +03:00
parent 1b693edf2b
commit 1f5987d6b0
92 changed files with 21690 additions and 7 deletions

View File

@@ -0,0 +1,126 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildCollabWsUrl,
markdownToProseMirror,
} 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"],
);
});

View File

@@ -0,0 +1,136 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffDocs } from "../../build/lib/diff.js";
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
const para = (...children) => ({ type: "paragraph", content: children });
const doc = (...children) => ({ type: "doc", content: children });
// ---------------------------------------------------------------------------
// Core diff: one inserted word
// ---------------------------------------------------------------------------
test("diffDocs detects a single inserted word", () => {
const oldDoc = doc(para(t("Hello world")));
const newDoc = doc(para(t("Hello brave world")));
const r = diffDocs(oldDoc, newDoc);
assert.ok(r.summary.inserted > 0, "reports insertion length");
assert.equal(r.summary.deleted, 0, "no deletions");
const ins = r.changes.find((c) => c.op === "insert");
assert.ok(ins, "has an insert change");
assert.match(ins.text, /brave/);
assert.match(r.markdown, /inserted/);
});
// ---------------------------------------------------------------------------
// Core diff: one deleted block
// ---------------------------------------------------------------------------
test("diffDocs detects a deleted block", () => {
const oldDoc = doc(para(t("keep this")), para(t("remove this block")));
const newDoc = doc(para(t("keep this")));
const r = diffDocs(oldDoc, newDoc);
assert.ok(r.summary.deleted > 0, "reports deletion length");
const del = r.changes.find((c) => c.op === "delete");
assert.ok(del, "has a delete change");
assert.match(del.text, /remove this block/);
});
// ---------------------------------------------------------------------------
// Integrity counts
// ---------------------------------------------------------------------------
test("diffDocs reports integrity counts as [old,new] tuples", () => {
const link = [{ type: "link", attrs: { href: "http://x" } }];
const image = { type: "image", attrs: { src: "/api/files/a.png" } };
const callout = {
type: "callout",
attrs: { type: "info" },
content: [para(t("note"))],
};
const oldDoc = doc(
para(t("a link", link)),
image,
callout,
para(t("body with [1] and [2]")),
);
// new doc: drop the image, drop one footnote marker, keep link + callout.
const newDoc = doc(
para(t("a link", link)),
callout,
para(t("body with [1]")),
);
const r = diffDocs(oldDoc, newDoc);
assert.deepEqual(r.integrity.images, [1, 0]);
assert.deepEqual(r.integrity.links, [1, 1]);
assert.deepEqual(r.integrity.callouts, [1, 1]);
assert.deepEqual(r.integrity.tables, [0, 0]);
// footnote markers parsed in reading order from the body.
assert.deepEqual(r.integrity.footnoteMarkers, [[1, 2], [1]]);
});
// ---------------------------------------------------------------------------
// Footnote markers stop at the notes heading
// ---------------------------------------------------------------------------
test("diffDocs footnote markers ignore the notes section", () => {
const oldDoc = doc(
para(t("body [1]")),
{ type: "heading", attrs: { level: 2 }, content: [t("Примечания переводчика")] },
{
type: "orderedList",
content: [
{ type: "listItem", content: [para(t("note [1] inside list"))] },
],
},
);
const r = diffDocs(oldDoc, oldDoc);
// Only the body [1] is counted, not the [1] inside the notes list.
assert.deepEqual(r.integrity.footnoteMarkers, [[1], [1]]);
assert.equal(r.summary.inserted, 0);
assert.equal(r.summary.deleted, 0);
});
// ---------------------------------------------------------------------------
// Bug 3: links integrity counts UNIQUE links by href, not link-bearing runs.
// A single link split across two runs (link+bold, then link) is one link.
// ---------------------------------------------------------------------------
test("diffDocs counts a link split across two runs as one link", () => {
const link = [{ type: "link", attrs: { href: "http://x" } }];
const linkBold = [
{ type: "link", attrs: { href: "http://x" } },
{ type: "bold" },
];
// One logical link to http://x rendered as two adjacent runs.
const splitDoc = doc(para(t("see ", linkBold), t("the link", link), t(" here")));
// Same single href represented as a single run.
const wholeDoc = doc(para(t("see the link", link), t(" here")));
const r = diffDocs(splitDoc, wholeDoc);
// Unique-by-href: both sides have exactly one distinct link.
assert.deepEqual(r.integrity.links, [1, 1]);
});
test("diffDocs counts two distinct hrefs as two links", () => {
const a = [{ type: "link", attrs: { href: "http://a" } }];
const b = [{ type: "link", attrs: { href: "http://b" } }];
const oldDoc = doc(para(t("one", a), t(" two", b)));
// new doc drops the second link.
const newDoc = doc(para(t("one", a), t(" two")));
const r = diffDocs(oldDoc, newDoc);
assert.deepEqual(r.integrity.links, [2, 1]);
});
// ---------------------------------------------------------------------------
// Identical docs produce no changes
// ---------------------------------------------------------------------------
test("diffDocs on identical docs reports no changes", () => {
const d = doc(para(t("unchanged")));
const r = diffDocs(d, d);
assert.equal(r.changes.length, 0);
assert.equal(r.summary.blocksChanged, 0);
});

View File

@@ -0,0 +1,190 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
serializeDocmostMarkdown,
parseDocmostMarkdown,
} from "../../build/lib/markdown-document.js";
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
import { markdownToProseMirror } 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;
}
/** Find the first text node carrying a mark of the given type. */
function findTextWithMark(node, markType) {
for (const t of findAll(node, "text")) {
if (Array.isArray(t.marks) && t.marks.some((m) => m.type === markType)) {
return t;
}
}
return null;
}
test("serialize/parse: meta and comments survive a round-trip; body recovered", () => {
const meta = {
version: 1,
pageId: "p1",
slugId: "s1",
title: "Hello",
spaceId: "sp1",
parentPageId: null,
};
const body = "# Title\n\nSome **bold** body text.";
const comments = [
{ id: "c1", content: "a note", resolved: false },
{ id: "c2", content: "another", resolved: true },
];
const full = serializeDocmostMarkdown(meta, body, comments);
const parsed = parseDocmostMarkdown(full);
assert.deepEqual(parsed.meta, meta);
assert.deepEqual(parsed.comments, comments);
assert.equal(parsed.body, body);
});
test("serialize: a page with no comments still emits an empty comments block", () => {
const full = serializeDocmostMarkdown({ version: 1 }, "body", []);
assert.match(full, /<!--\s*docmost:comments\s*\n\[\]\n-->/);
const parsed = parseDocmostMarkdown(full);
assert.deepEqual(parsed.comments, []);
});
test("parse: plain markdown with no blocks -> meta=null, comments=null, body=input", () => {
const input = " # Just a heading\n\nplain body ";
const parsed = parseDocmostMarkdown(input);
assert.equal(parsed.meta, null);
assert.equal(parsed.comments, null);
assert.equal(parsed.body, input.trim());
});
test("parse: tolerant to CRLF line endings", () => {
const meta = { version: 1, pageId: "p9" };
const body = "line one\n\nline two";
const full = serializeDocmostMarkdown(meta, body, []).replace(/\n/g, "\r\n");
const parsed = parseDocmostMarkdown(full);
assert.deepEqual(parsed.meta, meta);
assert.deepEqual(parsed.comments, []);
assert.equal(parsed.body, body);
});
test("parse: a malformed present meta block throws a clear error", () => {
const bad = "<!-- docmost:meta\n{not valid json}\n-->\n\nbody\n";
assert.throws(() => parseDocmostMarkdown(bad), /docmost:meta JSON/);
});
test("parse: a literal comments-block in the body is left in the body when a real trailing block follows", () => {
// The body documents the format (e.g. inside a fenced code block) AND there is
// a real trailing comments block. Only the final, document-ending block is
// metadata; the literal stays in the body verbatim.
const meta = { version: 1, pageId: "p-literal" };
const literal = "```\n<!-- docmost:comments\n[1]\n-->\n```";
const body = `# Doc\n\nExample of the format:\n\n${literal}`;
const realComments = [{ id: "c1", content: "real" }];
const full = serializeDocmostMarkdown(meta, body, realComments);
const parsed = parseDocmostMarkdown(full);
// The REAL trailing comments are parsed.
assert.deepEqual(parsed.comments, realComments);
// The literal block text is still present in the recovered body.
assert.ok(
parsed.body.includes("<!-- docmost:comments\n[1]\n-->"),
"expected the literal comments block to remain in the body",
);
assert.equal(parsed.body, body.trim());
});
test("parse: a body-ending literal comments block (no real trailing block) is treated as the final block", () => {
// Hand-written file whose ONLY `docmost:comments` opener is a literal that
// also ends the document. Per the implementation, the final document-ending
// block IS treated as metadata, so it is parsed and stripped from the body.
const input = "# Doc\n\nsome text\n\n<!-- docmost:comments\n[1]\n-->\n";
const parsed = parseDocmostMarkdown(input);
assert.equal(parsed.meta, null);
assert.deepEqual(parsed.comments, [1]);
assert.equal(parsed.body, "# Doc\n\nsome text");
});
test("parse: a literal comments block NOT ending the document stays entirely in the body", () => {
// The literal opener/closer is followed by more body content, so it does not
// end the document and is therefore left untouched in the body.
const input =
"# Doc\n\n<!-- docmost:comments\n[1]\n-->\n\nmore body after it\n";
const parsed = parseDocmostMarkdown(input);
assert.equal(parsed.meta, null);
assert.equal(parsed.comments, null);
assert.equal(parsed.body, input.trim());
});
test("export emits comment anchors and they round-trip back to a comment mark", () => {
// A small ProseMirror doc with a text run carrying a `comment` mark.
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "before " },
{
type: "text",
text: "anchored",
marks: [{ type: "comment", attrs: { commentId: "cm-123" } }],
},
{ type: "text", text: " after" },
],
},
],
};
const body = convertProseMirrorToMarkdown(doc);
assert.match(body, /data-comment-id="cm-123"/);
return markdownToProseMirror(body).then((rebuilt) => {
const commented = findTextWithMark(rebuilt, "comment");
assert.ok(commented, "expected a text node with a comment mark");
const mark = commented.marks.find((m) => m.type === "comment");
assert.equal(mark.attrs.commentId, "cm-123");
});
});
test("drawio round-trips through export and import", () => {
const doc = {
type: "doc",
content: [
{
type: "drawio",
attrs: { src: "https://example/diagram.xml", attachmentId: "att-7" },
},
],
};
const body = convertProseMirrorToMarkdown(doc);
assert.match(body, /data-type="drawio"/);
assert.match(body, /data-src="https:\/\/example\/diagram\.xml"/);
return markdownToProseMirror(body).then((rebuilt) => {
const diagram = find(rebuilt, "drawio");
assert.ok(diagram, "expected a drawio node after import");
assert.equal(diagram.attrs.src, "https://example/diagram.xml");
});
});

View File

@@ -0,0 +1,173 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { filterComment, filterPage } from "../../build/lib/filters.js";
test("filterComment includes resolvedAt/resolvedById as null when absent", () => {
const result = filterComment({
id: "c1",
pageId: "p1",
content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
});
assert.equal(result.resolvedAt, null);
assert.equal(result.resolvedById, null);
});
test("filterComment passes through resolvedAt/resolvedById when present", () => {
const result = filterComment({
id: "c1",
pageId: "p1",
content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
resolvedAt: "2026-02-02T10:00:00.000Z",
resolvedById: "user-42",
});
assert.equal(result.resolvedAt, "2026-02-02T10:00:00.000Z");
assert.equal(result.resolvedById, "user-42");
});
test("filterComment still includes id/content/createdAt", () => {
const result = filterComment({
id: "c-id",
pageId: "p1",
content: "the body",
createdAt: "2026-03-03T03:03:03.000Z",
});
assert.equal(result.id, "c-id");
assert.equal(result.content, "the body");
assert.equal(result.createdAt, "2026-03-03T03:03:03.000Z");
});
test("filterComment uses markdownContent override when provided", () => {
const result = filterComment(
{
id: "c1",
pageId: "p1",
content: "raw json content",
createdAt: "2026-01-01T00:00:00.000Z",
},
"**markdown** content",
);
assert.equal(result.content, "**markdown** content");
});
test("filterComment is null-safe on missing creator", () => {
const result = filterComment({
id: "c1",
pageId: "p1",
content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
creatorId: "u1",
// no `creator` object present
});
assert.equal(result.creatorName, null);
assert.equal(result.creatorId, "u1");
});
test("filterComment reads creator.name when creator present", () => {
const result = filterComment({
id: "c1",
pageId: "p1",
content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
creator: { name: "Alice" },
});
assert.equal(result.creatorName, "Alice");
});
test("filterComment defaults selection/type/parentCommentId/editedAt", () => {
const result = filterComment({
id: "c1",
pageId: "p1",
content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
});
assert.equal(result.selection, null);
assert.equal(result.type, "page");
assert.equal(result.parentCommentId, null);
assert.equal(result.editedAt, null);
});
test("filterPage selects expected fields", () => {
const result = filterPage({
id: "page-1",
slugId: "slug-1",
title: "My Page",
parentPageId: "parent-1",
spaceId: "space-1",
isLocked: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
deletedAt: null,
// extra fields that must be dropped
extraneous: "should not appear",
content: "should be ignored when not passed as arg",
});
assert.deepEqual(result, {
id: "page-1",
slugId: "slug-1",
title: "My Page",
parentPageId: "parent-1",
spaceId: "space-1",
isLocked: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
deletedAt: null,
});
});
test("filterPage omits content key when content arg is not a string", () => {
const result = filterPage({ id: "p1", title: "t" });
assert.equal("content" in result, false);
});
test("filterPage includes content when arg is a string", () => {
const result = filterPage({ id: "p1", title: "t" }, "# Heading");
assert.equal(result.content, "# Heading");
});
test("filterPage includes content when arg is an empty string", () => {
const result = filterPage({ id: "p1", title: "t" }, "");
assert.equal("content" in result, true);
assert.equal(result.content, "");
});
test("filterPage omits subpages when none provided", () => {
const result = filterPage({ id: "p1", title: "t" });
assert.equal("subpages" in result, false);
});
test("filterPage omits subpages when an empty array is provided", () => {
const result = filterPage({ id: "p1", title: "t" }, undefined, []);
assert.equal("subpages" in result, false);
});
test("filterPage maps subpages to id/title only", () => {
const result = filterPage({ id: "p1", title: "t" }, undefined, [
{ id: "s1", title: "Sub One", extra: "drop" },
{ id: "s2", title: "Sub Two" },
]);
assert.deepEqual(result.subpages, [
{ id: "s1", title: "Sub One" },
{ id: "s2", title: "Sub Two" },
]);
});
test("filterPage includes both content and subpages together", () => {
const result = filterPage({ id: "p1", title: "t" }, "body", [
{ id: "s1", title: "Sub" },
]);
assert.equal(result.content, "body");
assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]);
});

View File

@@ -0,0 +1,173 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyTextEdits } from "../../build/lib/json-edit.js";
// Helpers to build small ProseMirror docs.
const textNode = (text, extra = {}) => ({ type: "text", text, ...extra });
const paragraph = (...children) => ({ type: "paragraph", content: children });
const doc = (...children) => ({ type: "doc", content: children });
test("single-match replace preserves ids/marks and reports replacements===1", () => {
const input = doc({
type: "paragraph",
attrs: { id: "para-1" },
content: [
textNode("Hello world", { marks: [{ type: "bold" }] }),
],
});
const { doc: out, results } = applyTextEdits(input, [
{ find: "world", replace: "there" },
]);
assert.deepEqual(results, [{ find: "world", replacements: 1 }]);
const para = out.content[0];
// Paragraph id attribute is preserved.
assert.equal(para.attrs.id, "para-1");
const tnode = para.content[0];
// Text node marks are preserved.
assert.deepEqual(tnode.marks, [{ type: "bold" }]);
assert.equal(tnode.text, "Hello there");
});
test("zero match throws not found", () => {
const input = doc(paragraph(textNode("Hello world")));
assert.throws(
() => applyTextEdits(input, [{ find: "absent", replace: "x" }]),
/not found/,
);
});
test("text split across two text nodes (one bold) throws spans-multiple-runs", () => {
// "Hello world" is split: "Hello " (plain) + "world" (bold). No single text
// node contains "Hello world", but the collected document text does.
const input = doc(
paragraph(
textNode("Hello "),
textNode("world", { marks: [{ type: "bold" }] }),
),
);
assert.throws(
() => applyTextEdits(input, [{ find: "Hello world", replace: "x" }]),
/spans/,
);
});
test("multi-match without replaceAll throws matches", () => {
// "ab" appears twice inside a single text node.
const input = doc(paragraph(textNode("ab cd ab")));
assert.throws(
() => applyTextEdits(input, [{ find: "ab", replace: "x" }]),
/matches/,
);
});
test("replaceAll replaces all occurrences", () => {
const input = doc(
paragraph(textNode("foo and foo")),
paragraph(textNode("more foo")),
);
const { doc: out, results } = applyTextEdits(input, [
{ find: "foo", replace: "bar", replaceAll: true },
]);
// 2 in the first paragraph, 1 in the second = 3 total.
assert.deepEqual(results, [{ find: "foo", replacements: 3 }]);
assert.equal(out.content[0].content[0].text, "bar and bar");
assert.equal(out.content[1].content[0].text, "more bar");
});
test("replacement containing $&, $1, $$ is inserted LITERALLY (regression)", () => {
const input = doc(paragraph(textNode("token here")));
const literal = "price $& cost $1 dollars $$ end";
const { doc: out } = applyTextEdits(input, [
{ find: "token", replace: literal },
]);
// The replacement must appear verbatim, NOT regex-expanded.
assert.equal(out.content[0].content[0].text, `${literal} here`);
// Be explicit that the find text was not re-injected via $&.
assert.ok(out.content[0].content[0].text.includes("$&"));
assert.ok(!out.content[0].content[0].text.includes("token"));
});
test("$ patterns are inserted literally under replaceAll too", () => {
const input = doc(paragraph(textNode("x and x")));
const { doc: out } = applyTextEdits(input, [
{ find: "x", replace: "$&$1$$", replaceAll: true },
]);
assert.equal(out.content[0].content[0].text, "$&$1$$ and $&$1$$");
});
test("empty replacement prunes the emptied text node", () => {
// A paragraph whose only text node becomes empty: the node must be pruned.
const input = doc(
paragraph(
textNode("DELETE", { marks: [{ type: "italic" }] }),
textNode(" kept"),
),
);
const { doc: out, results } = applyTextEdits(input, [
{ find: "DELETE", replace: "" },
]);
assert.deepEqual(results, [{ find: "DELETE", replacements: 1 }]);
const para = out.content[0];
// The emptied first text node is gone; only the " kept" node remains.
assert.equal(para.content.length, 1);
assert.equal(para.content[0].text, " kept");
});
test("multi-edit array applied in order", () => {
const input = doc(paragraph(textNode("alpha beta")));
const { doc: out, results } = applyTextEdits(input, [
{ find: "alpha", replace: "ALPHA" },
{ find: "beta", replace: "BETA" },
]);
assert.deepEqual(results, [
{ find: "alpha", replacements: 1 },
{ find: "beta", replacements: 1 },
]);
assert.equal(out.content[0].content[0].text, "ALPHA BETA");
});
test("second edit can target text produced by the first (ordered application)", () => {
const input = doc(paragraph(textNode("one")));
const { doc: out, results } = applyTextEdits(input, [
{ find: "one", replace: "two" },
{ find: "two", replace: "three" },
]);
assert.deepEqual(results, [
{ find: "one", replacements: 1 },
{ find: "two", replacements: 1 },
]);
assert.equal(out.content[0].content[0].text, "three");
});
test("input doc is not mutated", () => {
const input = doc(paragraph(textNode("immutable source")));
const snapshot = JSON.parse(JSON.stringify(input));
const { doc: out } = applyTextEdits(input, [
{ find: "immutable", replace: "changed" },
]);
// Original is untouched; the returned doc is a distinct object.
assert.deepEqual(input, snapshot);
assert.notEqual(out, input);
assert.equal(out.content[0].content[0].text, "changed source");
});

View File

@@ -0,0 +1,151 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
// ProseMirror builders.
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
const paragraph = (...content) => ({ type: "paragraph", content });
const doc = (...content) => ({ type: "doc", content });
const listItem = (...content) => ({ type: "listItem", content });
const bulletList = (...items) => ({ type: "bulletList", content: items });
const orderedList = (...items) => ({ type: "orderedList", content: items });
test("nested bulletList with 3 children keeps all children indented under the parent", () => {
const input = doc(
bulletList(
listItem(
paragraph(text("Parent")),
bulletList(
listItem(paragraph(text("A"))),
listItem(paragraph(text("B"))),
listItem(paragraph(text("C"))),
),
),
),
);
assert.equal(
convertProseMirrorToMarkdown(input),
"- Parent\n - A\n - B\n - C",
);
});
test("nested list under an ordered item indents 3 spaces", () => {
const input = doc(
orderedList(
listItem(
paragraph(text("Parent")),
bulletList(listItem(paragraph(text("Child")))),
),
),
);
assert.equal(
convertProseMirrorToMarkdown(input),
"1. Parent\n - Child",
);
});
test("link with title -> [t](url \"title\")", () => {
const input = doc(
paragraph(
text("click", [
{ type: "link", attrs: { href: "https://example.com", title: "the title" } },
]),
),
);
assert.equal(
convertProseMirrorToMarkdown(input),
'[click](https://example.com "the title")',
);
});
test("hardBreak -> trailing two-spaces+newline", () => {
const input = doc(
paragraph(text("line1"), { type: "hardBreak" }, text("line2")),
);
assert.equal(convertProseMirrorToMarkdown(input), "line1 \nline2");
});
test("table cell with two block children joined by a space (and a pipe escaped)", () => {
const input = doc({
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
content: [paragraph(text("a|b")), paragraph(text("c"))],
},
],
},
],
});
// Single-column header row + separator. The cell joins its two paragraphs
// with a space ("a|b c") then escapes the pipe -> "a\|b c".
assert.equal(
convertProseMirrorToMarkdown(input),
"| a\\|b c |\n| --- |",
);
});
test("code block trailing newline trimmed", () => {
const input = doc({
type: "codeBlock",
attrs: { language: "js" },
content: [text("const a = 1;\n")],
});
// The single trailing newline inside the code is trimmed; fences add one.
assert.equal(
convertProseMirrorToMarkdown(input),
"```js\nconst a = 1;\n```",
);
});
test("textAlign value: delimiting double-quote escaped (attribute-safe, idempotent; < > left literal/inert)", () => {
const input = doc({
type: "paragraph",
attrs: { textAlign: 'right"><b' },
content: [text("body")],
});
// Attribute values escape only & and " so the value cannot break out of the
// quoted attribute. < and > are left literal: parse5/jsdom does NOT decode
// &lt;/&gt; inside attribute values, so escaping them would corrupt the value
// and accumulate on every round-trip. The literal < > are inert inside quotes.
assert.equal(
convertProseMirrorToMarkdown(input),
'<div align="right&quot;><b">body</div>',
);
});
test("highlight color: delimiting double-quote escaped (attribute-safe; < > inert, and import sanitizes the color)", () => {
const input = doc(
paragraph(
text("hi", [{ type: "highlight", attrs: { color: 'red"><script' } }]),
),
);
assert.equal(
convertProseMirrorToMarkdown(input),
'<mark style="background-color: red&quot;><script">hi</mark>',
);
});
test("empty task item still emits its marker", () => {
const input = doc({
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: false }, content: [] },
{ type: "taskItem", attrs: { checked: true }, content: [] },
],
});
assert.equal(convertProseMirrorToMarkdown(input), "- [ ]\n- [x]");
});

View File

@@ -0,0 +1,301 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
insertNodeRelative,
sanitizeForYjs,
findUnstorableAttr,
} from "../../build/lib/node-ops.js";
// ProseMirror builders. Blocks carry a stable id in attrs.id.
const textNode = (text) => ({ type: "text", text });
const para = (id, ...children) => ({
type: "paragraph",
attrs: { id },
content: children,
});
const doc = (...children) => ({ type: "doc", content: children });
const snapshot = (v) => JSON.parse(JSON.stringify(v));
// A table cell holding a single paragraph.
const cell = (id, innerPara) => ({
type: "tableCell",
attrs: { id },
content: [innerPara],
});
const row = (id, ...cells) => ({
type: "tableRow",
attrs: { id },
content: cells,
});
const table = (id, ...rows) => ({
type: "table",
attrs: { id },
content: rows,
});
// A 2x2 table: rows r1/r2, cells c1..c4, each cell holds a paragraph p1..p4.
const make2x2Table = () =>
doc(
table(
"t1",
row("r1", cell("c1", para("p1", textNode("A1"))), cell("c2", para("p2", textNode("A2")))),
row("r2", cell("c3", para("p3", textNode("B1"))), cell("c4", para("p4", textNode("B2")))),
),
);
const freshRow = () => row("rNEW", cell("cNEW", para("pNEW", textNode("NEW"))));
const freshCell = () => cell("cNEW", para("pNEW", textNode("NEW")));
// ---------------------------------------------------------------------------
// sanitizeForYjs
// ---------------------------------------------------------------------------
test("sanitizeForYjs strips undefined node-attr keys, preserves null/false/0/''", () => {
const input = doc({
type: "paragraph",
attrs: {
id: "p-1",
gone: undefined,
keptNull: null,
keptFalse: false,
keptZero: 0,
keptEmpty: "",
},
content: [textNode("x")],
});
const out = sanitizeForYjs(input);
const attrs = out.content[0].attrs;
assert.equal("gone" in attrs, false);
assert.equal("keptNull" in attrs, true);
assert.equal(attrs.keptNull, null);
assert.equal(attrs.keptFalse, false);
assert.equal(attrs.keptZero, 0);
assert.equal(attrs.keptEmpty, "");
// Input must not be mutated.
assert.equal("gone" in input.content[0].attrs, true);
});
test("sanitizeForYjs strips undefined mark-attr keys, preserves falsy values", () => {
const input = doc({
type: "paragraph",
attrs: { id: "p-1" },
content: [
{
type: "text",
text: "x",
marks: [
{
type: "link",
attrs: { href: "", target: undefined, rel: null },
},
],
},
],
});
const out = sanitizeForYjs(input);
const markAttrs = out.content[0].content[0].marks[0].attrs;
assert.equal("target" in markAttrs, false);
assert.equal(markAttrs.href, "");
assert.equal(markAttrs.rel, null);
});
// ---------------------------------------------------------------------------
// findUnstorableAttr
// ---------------------------------------------------------------------------
test("findUnstorableAttr returns a path for an undefined node attr", () => {
const input = doc(
para("p-0", textNode("ok")),
{
type: "paragraph",
attrs: { id: "p-1", indent: undefined },
content: [textNode("y")],
},
);
const hit = findUnstorableAttr(input);
assert.equal(hit, "content[1].attrs.indent (undefined)");
});
test("findUnstorableAttr finds an unstorable mark attr", () => {
const input = doc({
type: "paragraph",
attrs: { id: "p-1" },
content: [
{
type: "text",
text: "x",
marks: [{ type: "link", attrs: { href: () => {} } }],
},
],
});
const hit = findUnstorableAttr(input);
assert.equal(hit, "content[0].content[0].marks[0].attrs.href (function)");
});
test("findUnstorableAttr returns null for a clean doc", () => {
const input = doc(para("p-1", textNode("clean")));
assert.equal(findUnstorableAttr(input), null);
});
// ---------------------------------------------------------------------------
// insertNodeRelative — table-structure-aware
// ---------------------------------------------------------------------------
test("insertNodeRelative inserting a tableRow anchored on a paragraph INSIDE a cell appends a sibling row to the table", () => {
const input = make2x2Table();
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "p4", // paragraph inside last cell of the last row
});
assert.equal(inserted, true);
const tbl = out.content[0];
// table.content length +1 (the row is a direct child of the table).
assert.equal(tbl.content.length, 3);
// The new row is a direct child of the table, NOT nested inside a cell.
const newRow = tbl.content[2];
assert.equal(newRow.type, "tableRow");
assert.equal(newRow.attrs.id, "rNEW");
// Existing rows' cells are intact.
assert.deepEqual(
tbl.content[0].content.map((c) => c.attrs.id),
["c1", "c2"],
);
assert.deepEqual(
tbl.content[1].content.map((c) => c.attrs.id),
["c3", "c4"],
);
// Assert the new row is NOT nested inside any existing cell.
for (const r of [tbl.content[0], tbl.content[1]]) {
for (const c of r.content) {
const ids = (c.content || []).map((n) => n.attrs?.id);
assert.equal(ids.includes("rNEW"), false);
}
}
});
test("insertNodeRelative before/after place the new row at the correct index relative to the enclosing row", () => {
// "before" the first row.
{
const input = make2x2Table();
const { doc: out } = insertNodeRelative(input, freshRow(), {
position: "before",
anchorNodeId: "p1", // paragraph in first row
});
assert.deepEqual(
out.content[0].content.map((r) => r.attrs.id),
["rNEW", "r1", "r2"],
);
}
// "after" the first row.
{
const input = make2x2Table();
const { doc: out } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "p1", // paragraph in first row
});
assert.deepEqual(
out.content[0].content.map((r) => r.attrs.id),
["r1", "rNEW", "r2"],
);
}
});
test("insertNodeRelative inserting a tableCell anchored inside a cell adds it to the enclosing row", () => {
const input = make2x2Table();
const { doc: out, inserted } = insertNodeRelative(input, freshCell(), {
position: "after",
anchorNodeId: "p1", // paragraph inside first cell of first row
});
assert.equal(inserted, true);
// The cell is spliced into the enclosing row (r1) after c1.
assert.deepEqual(
out.content[0].content[0].content.map((c) => c.attrs.id),
["c1", "cNEW", "c2"],
);
// The other row is untouched.
assert.deepEqual(
out.content[0].content[1].content.map((c) => c.attrs.id),
["c3", "c4"],
);
});
test("insertNodeRelative inserting a tableRow with an anchor NOT inside a table throws", () => {
const input = doc(para("p-1", textNode("plain")));
assert.throws(
() =>
insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "p-1",
}),
/not inside a table/,
);
});
test("insertNodeRelative append + tableRow throws", () => {
const input = make2x2Table();
assert.throws(
() => insertNodeRelative(input, freshRow(), { position: "append" }),
/cannot append a tableRow at the top level/,
);
});
test("insertNodeRelative structural insert with unresolved anchor returns inserted:false (no throw)", () => {
const input = make2x2Table();
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "does-not-exist",
});
assert.equal(inserted, false);
assert.deepEqual(out, input);
});
test("insertNodeRelative tableRow by anchorText resolving to the table block appends within the table", () => {
const input = make2x2Table();
// anchorText "A1" lives in the first cell; the matched top-level block is the
// table itself, so the row appends at the end of the table.
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorText: "A1",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content[0].content.map((r) => r.attrs.id),
["r1", "r2", "rNEW"],
);
});
// ---------------------------------------------------------------------------
// Regression: a normal (non-structural) paragraph insert is unchanged.
// ---------------------------------------------------------------------------
test("insertNodeRelative regression: normal paragraph before/after a top-level block behaves as before", () => {
const before = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
{
const { doc: out, inserted } = insertNodeRelative(
before,
para("new", textNode("NEW")),
{ position: "before", anchorNodeId: "p-2" },
);
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
}
{
const snap = snapshot(before);
const { doc: out, inserted } = insertNodeRelative(
before,
para("new", textNode("NEW")),
{ position: "after", anchorNodeId: "p-1" },
);
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
// Input not mutated.
assert.deepEqual(before, snap);
}
});

View File

@@ -0,0 +1,402 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
blockPlainText,
replaceNodeById,
deleteNodeById,
insertNodeRelative,
} from "../../build/lib/node-ops.js";
// ProseMirror builders. Blocks carry a stable id in attrs.id.
const textNode = (text) => ({ type: "text", text });
const para = (id, ...children) => ({
type: "paragraph",
attrs: { id },
content: children,
});
const doc = (...children) => ({ type: "doc", content: children });
const snapshot = (v) => JSON.parse(JSON.stringify(v));
// A callout / table-cell wraps its children in `content`, just like any other
// block, so recursion reaches a paragraph nested inside it.
const callout = (id, ...children) => ({
type: "callout",
attrs: { id, type: "info" },
content: children,
});
const tableDoc = (innerPara) =>
doc({
type: "table",
attrs: { id: "table-1" },
content: [
{
type: "tableRow",
attrs: { id: "row-1" },
content: [
{
type: "tableCell",
attrs: { id: "cell-1" },
content: [innerPara],
},
],
},
],
});
// ---------------------------------------------------------------------------
// blockPlainText
// ---------------------------------------------------------------------------
test("blockPlainText concatenates nested text", () => {
const node = {
type: "callout",
content: [
para("p-1", textNode("Hello "), textNode("world")),
para("p-2", textNode("!")),
],
};
assert.equal(blockPlainText(node), "Hello world!");
});
test("blockPlainText returns '' for nullish / non-object", () => {
assert.equal(blockPlainText(null), "");
assert.equal(blockPlainText(undefined), "");
assert.equal(blockPlainText("just a string"), "");
});
test("blockPlainText reads a bare text node", () => {
assert.equal(blockPlainText(textNode("solo")), "solo");
});
// ---------------------------------------------------------------------------
// replaceNodeById
// ---------------------------------------------------------------------------
test("replaceNodeById replaces the matching block and leaves others, count===1", () => {
const input = doc(
para("p-1", textNode("one")),
para("p-2", textNode("two")),
para("p-3", textNode("three")),
);
const newNode = para("p-2", textNode("REPLACED"));
const { doc: out, replaced } = replaceNodeById(input, "p-2", newNode);
assert.equal(replaced, 1);
// Target replaced.
assert.equal(out.content[1].content[0].text, "REPLACED");
// Siblings untouched (text and ids).
assert.equal(out.content[0].content[0].text, "one");
assert.equal(out.content[2].content[0].text, "three");
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "p-2", "p-3"],
);
});
test("replaceNodeById on no-match returns replaced===0 and does not throw", () => {
const input = doc(para("p-1", textNode("one")));
const { doc: out, replaced } = replaceNodeById(
input,
"missing",
para("x", textNode("x")),
);
assert.equal(replaced, 0);
// Document content is preserved.
assert.equal(out.content[0].content[0].text, "one");
});
test("replaceNodeById replaces EVERY node sharing the id (count reflects all)", () => {
const input = doc(
para("dup", textNode("a")),
para("dup", textNode("b")),
para("keep", textNode("c")),
);
const { doc: out, replaced } = replaceNodeById(
input,
"dup",
para("dup", textNode("NEW")),
);
assert.equal(replaced, 2);
assert.equal(out.content[0].content[0].text, "NEW");
assert.equal(out.content[1].content[0].text, "NEW");
assert.equal(out.content[2].content[0].text, "c");
// The two replacements must not share a reference (deep clone per match).
assert.notEqual(out.content[0], out.content[1]);
});
test("replaceNodeById reaches a node nested inside a callout", () => {
const input = doc(callout("c-1", para("inner", textNode("old"))));
const { doc: out, replaced } = replaceNodeById(
input,
"inner",
para("inner", textNode("new")),
);
assert.equal(replaced, 1);
assert.equal(out.content[0].content[0].content[0].text, "new");
});
test("replaceNodeById reaches a node nested inside a table cell", () => {
const input = tableDoc(para("deep", textNode("before")));
const { doc: out, replaced } = replaceNodeById(
input,
"deep",
para("deep", textNode("after")),
);
assert.equal(replaced, 1);
const cellPara = out.content[0].content[0].content[0].content[0];
assert.equal(cellPara.content[0].text, "after");
});
test("replaceNodeById does NOT mutate input (deep-equal snapshot)", () => {
const input = doc(
para("p-1", textNode("one")),
callout("c-1", para("inner", textNode("old"))),
);
const snap = snapshot(input);
const { doc: out } = replaceNodeById(
input,
"inner",
para("inner", textNode("changed")),
);
assert.deepEqual(input, snap);
assert.notEqual(out, input);
});
// ---------------------------------------------------------------------------
// deleteNodeById
// ---------------------------------------------------------------------------
test("deleteNodeById removes the block and reports deleted===1", () => {
const input = doc(
para("p-1", textNode("one")),
para("p-2", textNode("two")),
para("p-3", textNode("three")),
);
const { doc: out, deleted } = deleteNodeById(input, "p-2");
assert.equal(deleted, 1);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "p-3"],
);
});
test("deleteNodeById on no-match returns deleted===0 and leaves content", () => {
const input = doc(para("p-1", textNode("one")));
const { doc: out, deleted } = deleteNodeById(input, "missing");
assert.equal(deleted, 0);
assert.equal(out.content.length, 1);
});
test("deleteNodeById removes a node nested inside a callout", () => {
const input = doc(
callout("c-1", para("inner", textNode("x")), para("keep", textNode("y"))),
);
const { doc: out, deleted } = deleteNodeById(input, "inner");
assert.equal(deleted, 1);
assert.deepEqual(
out.content[0].content.map((n) => n.attrs.id),
["keep"],
);
});
test("deleteNodeById removes EVERY node sharing the id", () => {
const input = doc(
para("dup", textNode("a")),
para("keep", textNode("b")),
para("dup", textNode("c")),
);
const { doc: out, deleted } = deleteNodeById(input, "dup");
assert.equal(deleted, 2);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["keep"],
);
});
test("deleteNodeById does NOT mutate input (deep-equal snapshot)", () => {
const input = doc(
para("p-1", textNode("one")),
para("p-2", textNode("two")),
);
const snap = snapshot(input);
const { doc: out } = deleteNodeById(input, "p-2");
assert.deepEqual(input, snap);
assert.notEqual(out, input);
});
// ---------------------------------------------------------------------------
// insertNodeRelative
// ---------------------------------------------------------------------------
test("insertNodeRelative before by anchorNodeId", () => {
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "before",
anchorNodeId: "p-2",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
});
test("insertNodeRelative after by anchorNodeId", () => {
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "after",
anchorNodeId: "p-1",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
});
test("insertNodeRelative before/after by anchorNodeId reaches a nested sibling", () => {
const input = doc(
callout("c-1", para("a", textNode("a")), para("b", textNode("b"))),
);
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "after",
anchorNodeId: "a",
});
assert.equal(inserted, true);
// Inserted as a sibling inside the callout's content array.
assert.deepEqual(
out.content[0].content.map((n) => n.attrs.id),
["a", "new", "b"],
);
});
test("insertNodeRelative before by anchorText (top-level)", () => {
const input = doc(
para("p-1", textNode("alpha")),
para("p-2", textNode("beta")),
);
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "before",
anchorText: "beta",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
});
test("insertNodeRelative after by anchorText (top-level)", () => {
const input = doc(
para("p-1", textNode("alpha")),
para("p-2", textNode("beta")),
);
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "after",
anchorText: "alpha",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
});
test("insertNodeRelative anchorText scans TOP-LEVEL blocks via recursive plain text", () => {
// anchorText matches the FIRST top-level block whose (recursive) blockPlainText
// includes the string. "deeptext" lives nested in a top-level callout, so the
// callout itself is the matched top-level block and the node lands as its
// sibling at the top level (not inside the callout).
const input = doc(
callout("c-1", para("inner", textNode("deeptext"))),
para("p-2", textNode("tail")),
);
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "after",
anchorText: "deeptext",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["c-1", "new", "p-2"],
);
});
test("insertNodeRelative anchorText does NOT match text only present below top level when no top-level block contains it", () => {
// The only block whose plain text includes "lonely" is a paragraph nested two
// levels deep, but the top-level scan still sees it through the callout's
// recursive plain text. To prove the scan is TOP-LEVEL (parent-array) only,
// assert the insertion happens at the top level beside the callout, never
// inside it.
const input = doc(callout("c-1", para("inner", textNode("lonely word"))));
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "before",
anchorText: "lonely",
});
assert.equal(inserted, true);
// Inserted at the top level (siblings of the callout), not into the callout.
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["new", "c-1"],
);
// The callout's own children are untouched.
assert.deepEqual(
out.content[1].content.map((n) => n.attrs.id),
["inner"],
);
});
test("insertNodeRelative append pushes the node at the end of top-level content", () => {
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "append",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "p-2", "new"],
);
});
test("insertNodeRelative inserted===false when anchorNodeId missing", () => {
const input = doc(para("p-1", textNode("one")));
const node = para("new", textNode("NEW"));
const { doc: out, inserted } = insertNodeRelative(input, node, {
position: "after",
anchorNodeId: "nope",
});
assert.equal(inserted, false);
assert.deepEqual(out, input);
});
test("insertNodeRelative inserted===false when anchorText missing", () => {
const input = doc(para("p-1", textNode("one")));
const node = para("new", textNode("NEW"));
const { inserted } = insertNodeRelative(input, node, {
position: "before",
anchorText: "nomatch",
});
assert.equal(inserted, false);
});
test("insertNodeRelative does NOT mutate input (deep-equal snapshot)", () => {
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
const snap = snapshot(input);
const node = para("new", textNode("NEW"));
const { doc: out } = insertNodeRelative(input, node, {
position: "after",
anchorNodeId: "p-1",
});
assert.deepEqual(input, snap);
assert.notEqual(out, input);
});

View File

@@ -0,0 +1,109 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js";
// Helpers to build the small fixture doc.
const textNode = (text) => ({ type: "text", text });
const paragraph = (id, text) => ({
type: "paragraph",
attrs: { id },
content: [textNode(text)],
});
// A table cell holds a paragraph; cells/rows/table carry NO attrs.id.
const cell = (text) => ({
type: "tableCell",
content: [{ type: "paragraph", content: [textNode(text)] }],
});
const row = (...texts) => ({
type: "tableRow",
content: texts.map(cell),
});
const listItem = (text) => ({
type: "listItem",
content: [{ type: "paragraph", content: [textNode(text)] }],
});
// A long paragraph to exercise truncation (>100 chars).
const longText = "x".repeat(150);
const buildDoc = () => ({
type: "doc",
content: [
{ type: "heading", attrs: { id: "h1", level: 2 }, content: [textNode("Title")] },
paragraph("p1", longText),
{
type: "table",
content: [row("A", "B", "C"), row("1", "2", "3")],
},
{
type: "bulletList",
attrs: { id: "list1" },
content: [listItem("one"), listItem("two")],
},
],
});
test("buildOutline returns one compact entry per top-level block", () => {
const outline = buildOutline(buildDoc());
assert.equal(outline.length, 4);
// Heading: level + id + firstText.
assert.equal(outline[0].type, "heading");
assert.equal(outline[0].level, 2);
assert.equal(outline[0].id, "h1");
assert.equal(outline[0].firstText, "Title");
// Long paragraph text is truncated to 100 chars + ellipsis.
assert.equal(outline[1].id, "p1");
assert.equal(outline[1].firstText, "x".repeat(100) + "…");
assert.equal(outline[1].firstText.length, 101);
// Table: rows/cols/header from the first row; no id on the table itself.
assert.equal(outline[2].type, "table");
assert.equal(outline[2].rows, 2);
assert.equal(outline[2].cols, 3);
assert.deepEqual(outline[2].header, ["A", "B", "C"]);
assert.equal(outline[2].id, null);
// List: item count.
assert.equal(outline[3].type, "bulletList");
assert.equal(outline[3].items, 2);
});
test("buildOutline is null-safe", () => {
assert.deepEqual(buildOutline(undefined), []);
assert.deepEqual(buildOutline({ type: "doc" }), []);
assert.deepEqual(buildOutline(42), []);
});
test("getNodeByRef resolves a block id to its node and path", () => {
const doc = buildDoc();
const hit = getNodeByRef(doc, "h1");
assert.ok(hit);
assert.equal(hit.type, "heading");
assert.deepEqual(hit.path, [0]);
assert.equal(hit.node.attrs.id, "h1");
});
test("getNodeByRef resolves #<index> to a top-level block (table)", () => {
const doc = buildDoc();
const hit = getNodeByRef(doc, "#2");
assert.ok(hit);
assert.equal(hit.type, "table");
assert.deepEqual(hit.path, [2]);
});
test("getNodeByRef returns null for an unknown ref", () => {
assert.equal(getNodeByRef(buildDoc(), "nope"), null);
});
test("getNodeByRef returns a clone (mutating it does not change the input)", () => {
const doc = buildDoc();
const hit = getNodeByRef(doc, "h1");
hit.node.attrs.id = "MUTATED";
hit.node.content[0].text = "changed";
// Original doc is untouched.
assert.equal(doc.content[0].attrs.id, "h1");
assert.equal(doc.content[0].content[0].text, "Title");
});

View File

@@ -0,0 +1,153 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { withPageLock } from "../../build/lib/page-lock.js";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test("two ops on the same pageId run strictly sequentially (no overlap)", async () => {
const events = [];
const pageId = "same-page";
const p1 = withPageLock(pageId, async () => {
events.push("start-1");
await delay(40);
events.push("end-1");
return "r1";
});
// Queue the second op while the first is still running.
const p2 = withPageLock(pageId, async () => {
events.push("start-2");
await delay(10);
events.push("end-2");
return "r2";
});
const [r1, r2] = await Promise.all([p1, p2]);
assert.equal(r1, "r1");
assert.equal(r2, "r2");
// First op must fully finish before the second one begins.
assert.deepEqual(events, ["start-1", "end-1", "start-2", "end-2"]);
});
test("same pageId ordering holds for many queued ops", async () => {
const pageId = "ordered-page";
const order = [];
const active = { count: 0, maxConcurrent: 0 };
const ops = [];
for (let i = 0; i < 6; i++) {
ops.push(
withPageLock(pageId, async () => {
active.count += 1;
active.maxConcurrent = Math.max(active.maxConcurrent, active.count);
order.push(i);
await delay(5);
active.count -= 1;
return i;
}),
);
}
const results = await Promise.all(ops);
assert.deepEqual(results, [0, 1, 2, 3, 4, 5]);
assert.deepEqual(order, [0, 1, 2, 3, 4, 5]);
// Strictly sequential: never more than one op running at a time.
assert.equal(active.maxConcurrent, 1);
});
test("a rejecting op does not poison the chain for the same page", async () => {
const pageId = "poison-page";
const events = [];
const failing = withPageLock(pageId, async () => {
events.push("fail-start");
await delay(20);
events.push("fail-throw");
throw new Error("boom");
});
// The caller of the failing op must still see the rejection.
await assert.rejects(failing, /boom/);
const following = withPageLock(pageId, async () => {
events.push("next-run");
await delay(5);
return "ok";
});
const result = await following;
assert.equal(result, "ok");
// The next op ran after the failing one settled and was not blocked by it.
assert.deepEqual(events, ["fail-start", "fail-throw", "next-run"]);
});
test("failing op queued before a success both resolve/reject correctly", async () => {
const pageId = "poison-page-2";
const order = [];
const failing = withPageLock(pageId, async () => {
order.push("fail");
await delay(20);
throw new Error("nope");
});
const ok = withPageLock(pageId, async () => {
order.push("ok");
await delay(5);
return 123;
});
await assert.rejects(failing, /nope/);
assert.equal(await ok, 123);
// The failing op still ran first (it was queued first), then the success.
assert.deepEqual(order, ["fail", "ok"]);
});
test("ops on different pageIds run concurrently (overlap)", async () => {
const events = [];
const pA = withPageLock("page-A", async () => {
events.push("A-start");
await delay(40);
events.push("A-end");
return "A";
});
const pB = withPageLock("page-B", async () => {
events.push("B-start");
await delay(10);
events.push("B-end");
return "B";
});
const [rA, rB] = await Promise.all([pA, pB]);
assert.equal(rA, "A");
assert.equal(rB, "B");
// B starts before A finishes (concurrent), and B finishes before A.
assert.deepEqual(events, ["A-start", "B-start", "B-end", "A-end"]);
});
test("no functional leak: many sequential ops on same page keep working", async () => {
const pageId = "leak-page";
// Run a long series of fully sequential ops (each awaited before the next is
// queued) so the internal map entry is created and dropped repeatedly.
for (let i = 0; i < 50; i++) {
const value = await withPageLock(pageId, async () => {
await delay(1);
return i;
});
assert.equal(value, i);
}
// After the chain has drained, a brand new op on the same page still works,
// confirming the entry was not left in a broken state.
const final = await withPageLock(pageId, async () => "still-works");
assert.equal(final, "still-works");
});

View File

@@ -0,0 +1,149 @@
// Round-trip regression tests: PM -> markdown -> PM must preserve rich nodes.
// These lock in the converter/schema fixes (math, mention, attachment, columns,
// nested blocks, text color) and the attribute-escaping idempotency fix.
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";
const doc = (...content) => ({ type: "doc", content });
const para = (...content) => ({ type: "paragraph", content });
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
// Recursively collect nodes of a given type.
const findNodes = (node, type, acc = []) => {
if (!node) return acc;
if (node.type === type) acc.push(node);
for (const c of node.content || []) findNodes(c, type, acc);
return acc;
};
// Recursively collect the set of mark types present.
const markTypes = (node, acc = new Set()) => {
if (!node) return acc;
for (const m of node.marks || []) acc.add(m.type);
for (const c of node.content || []) markTypes(c, acc);
return acc;
};
const roundtrip = async (pmDoc) => markdownToProseMirror(convertProseMirrorToMarkdown(pmDoc));
test("round-trip: text color (textStyle mark) survives", async () => {
const input = doc(para(text("colored", [{ type: "textStyle", attrs: { color: "red" } }])));
const out = await roundtrip(input);
const ts = findNodes(out, "text").flatMap((n) => n.marks || []).filter((m) => m.type === "textStyle");
assert.ok(ts.length >= 1, "textStyle mark should survive");
assert.equal(ts[0].attrs?.color, "red");
});
test("round-trip: mathInline with '<' survives and is idempotent", async () => {
const input = doc(para(text("x"), { type: "mathInline", attrs: { text: "a < b \\leq c" } }));
const md1 = convertProseMirrorToMarkdown(input);
const md2 = convertProseMirrorToMarkdown(await markdownToProseMirror(md1));
assert.equal(md1, md2, "markdown must be idempotent across a round-trip (no escape accumulation)");
const out = await markdownToProseMirror(md1);
const math = findNodes(out, "mathInline");
assert.equal(math.length, 1, "mathInline node should survive");
assert.equal(math[0].attrs?.text, "a < b \\leq c", "LaTeX (incl. '<') preserved exactly");
});
test("round-trip: mathBlock survives", async () => {
const input = doc({ type: "mathBlock", attrs: { text: "E = mc^2" } });
const out = await roundtrip(input);
const math = findNodes(out, "mathBlock");
assert.equal(math.length, 1);
assert.equal(math[0].attrs?.text, "E = mc^2");
});
test("round-trip: mention node survives (not flattened to @text)", async () => {
const input = doc(para(text("hi "), { type: "mention", attrs: { id: "u1", label: "Alice", entityType: "user", entityId: "u1" } }));
const out = await roundtrip(input);
assert.equal(findNodes(out, "mention").length, 1, "mention node should survive");
});
test("round-trip: attachment node survives with url + name", async () => {
const input = doc({ type: "attachment", attrs: { url: "/api/files/x/report.pdf", name: "report.pdf", mime: "application/pdf" } });
const out = await roundtrip(input);
const att = findNodes(out, "attachment");
assert.equal(att.length, 1, "attachment node should survive");
assert.equal(att[0].attrs?.url, "/api/files/x/report.pdf");
assert.equal(att[0].attrs?.name, "report.pdf");
});
test("round-trip: image inside a column survives as an image node (not literal markdown)", async () => {
const input = doc({
type: "columns",
content: [
{ type: "column", content: [para(text("left")), { type: "image", attrs: { src: "/api/files/a/p.png", alt: "pic" } }] },
{ type: "column", content: [para(text("right"))] },
],
});
const out = await roundtrip(input);
assert.equal(findNodes(out, "image").length, 1, "image inside a column must survive");
// and it must NOT leak as literal markdown text
assert.ok(!JSON.stringify(out).includes("![pic]"), "image must not become literal markdown text");
});
test("round-trip: blockquote inside a column survives as a blockquote node", async () => {
const input = doc({
type: "columns",
content: [
{ type: "column", content: [{ type: "blockquote", content: [para(text("quoted"))] }] },
{ type: "column", content: [para(text("r"))] },
],
});
const out = await roundtrip(input);
assert.equal(findNodes(out, "blockquote").length, 1, "blockquote inside a column must survive");
});
test("round-trip: table cell with colspan>1 keeps the grid (HTML fallback)", async () => {
const cell = (t, attrs = {}) => ({ type: "tableCell", attrs, content: [para(text(t))] });
const header = (t) => ({ type: "tableHeader", attrs: {}, content: [para(text(t))] });
const input = doc({
type: "table",
content: [
{ type: "tableRow", content: [header("A"), header("B")] },
{ type: "tableRow", content: [cell("wide", { colspan: 2 })] },
],
});
const out = await roundtrip(input);
const tables = findNodes(out, "table");
assert.equal(tables.length, 1, "table should survive");
const spanned = findNodes(out, "tableCell").find((c) => (c.attrs?.colspan ?? 1) > 1);
assert.ok(spanned, "colspan>1 cell should be preserved via the HTML fallback");
});
test("import: an unsafe highlight color (raw data-color) is sanitized to null (no style breakout)", async () => {
// data-color is read verbatim (no CSSOM isolation), so it is the real
// injection surface; a value with quotes/semicolons must be clamped to null.
const out = await markdownToProseMirror('<mark data-color="red&quot;; background:url(x)">hi</mark>');
const hl = findNodes(out, "text").flatMap((n) => n.marks || []).filter((m) => m.type === "highlight");
assert.ok(hl.length >= 1, "highlight mark present");
assert.equal(hl[0].attrs?.color ?? null, null, "unsafe color must be clamped to null");
});
test("import: a safe highlight color is preserved", async () => {
const out = await markdownToProseMirror('<mark style="background-color: #ff0000">hi</mark>');
const hl = findNodes(out, "text").flatMap((n) => n.marks || []).filter((m) => m.type === "highlight");
assert.ok(hl.length >= 1);
assert.equal(hl[0].attrs?.color, "#ff0000");
});
test("round-trip: attribute value with an apostrophe is idempotent (no &amp; accumulation)", async () => {
const input = doc({ type: "attachment", attrs: { url: "/api/files/x/o'brien's file.pdf", name: "o'brien's file.pdf" } });
const md1 = convertProseMirrorToMarkdown(input);
const md2 = convertProseMirrorToMarkdown(await markdownToProseMirror(md1));
assert.equal(md1, md2, "apostrophe in an attribute value must not accumulate escapes across round-trips");
const att = findNodes(await markdownToProseMirror(md1), "attachment");
assert.equal(att.length, 1);
assert.equal(att[0].attrs?.name, "o'brien's file.pdf", "apostrophe preserved verbatim");
});
test("import: a colored span that is also a comment keeps the comment mark", async () => {
const out = await markdownToProseMirror('<span data-comment-id="c1" style="color: red">x</span>');
const marks = findNodes(out, "text").flatMap((n) => n.marks || []).map((m) => m.type);
assert.ok(marks.includes("comment"), "comment mark must survive (textStyle must not steal the span)");
});
test("import: a colored mention span keeps the mention node", async () => {
const out = await markdownToProseMirror('<span data-type="mention" data-id="u1" data-label="Alice" style="color: blue">@Alice</span>');
assert.equal(findNodes(out, "mention").length, 1, "mention node must survive a colored span");
});

View File

@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
docmostExtensions,
clampCalloutType,
} from "../../build/lib/docmost-schema.js";
import { TiptapTransformer } from "@hocuspocus/transformer";
test("clampCalloutType: a known type passes through", () => {
assert.equal(clampCalloutType("warning"), "warning");
});
test("clampCalloutType: an uppercase known type folds to lower case", () => {
assert.equal(clampCalloutType("WARNING"), "warning");
assert.equal(clampCalloutType("Info"), "info");
});
test("clampCalloutType: an unknown type falls back to info", () => {
assert.equal(clampCalloutType("bogus"), "info");
});
test("clampCalloutType: null and undefined fall back to info", () => {
assert.equal(clampCalloutType(null), "info");
assert.equal(clampCalloutType(undefined), "info");
});
// Minimal-doc builders for the toYdoc acceptance loop.
const text = (t) => ({ type: "text", text: t });
const paragraph = (inline) => ({ type: "paragraph", content: inline });
const docOf = (...content) => ({ type: "doc", content });
// Each entry is a minimal valid doc for one Docmost node type. Inline atoms
// (mention, mathInline) and inline-capable nodes go inside a paragraph; block
// atoms and block containers go at the top level.
const cases = {
mention: docOf(
paragraph([{ type: "mention", attrs: { id: "u1", label: "Bob" } }]),
),
mathInline: docOf(paragraph([{ type: "mathInline", attrs: { text: "x^2" } }])),
mathBlock: docOf({ type: "mathBlock", attrs: { text: "x^2" } }),
details: docOf({
type: "details",
content: [
{ type: "detailsSummary", content: [text("Summary")] },
{ type: "detailsContent", content: [paragraph([text("body")])] },
],
}),
attachment: docOf({
type: "attachment",
attrs: { url: "http://x/f.zip", name: "f.zip" },
}),
video: docOf({ type: "video", attrs: { src: "http://x/v.mp4" } }),
youtube: docOf({ type: "youtube", attrs: { src: "http://y/watch" } }),
embed: docOf({ type: "embed", attrs: { src: "http://e", provider: "iframe" } }),
drawio: docOf({ type: "drawio", attrs: { src: "http://d" } }),
excalidraw: docOf({ type: "excalidraw", attrs: { src: "http://e" } }),
columns: docOf({
type: "columns",
content: [
{ type: "column", content: [paragraph([text("c1")])] },
{ type: "column", content: [paragraph([text("c2")])] },
],
}),
subpages: docOf({ type: "subpages" }),
audio: docOf({ type: "audio", attrs: { src: "http://a.mp3" } }),
pdf: docOf({ type: "pdf", attrs: { src: "http://p.pdf" } }),
pageBreak: docOf({ type: "pageBreak" }),
};
for (const [name, doc] of Object.entries(cases)) {
test(`toYdoc accepts a ${name} node without throwing`, () => {
assert.doesNotThrow(() => {
TiptapTransformer.toYdoc(doc, "default", docmostExtensions);
});
});
}

View File

@@ -0,0 +1,338 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
} from "../../build/lib/node-ops.js";
// ---------------------------------------------------------------------------
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
// cell does. A cell holds a single plain-text paragraph.
// ---------------------------------------------------------------------------
const textNode = (text) => ({ type: "text", text });
const para = (id, text) => ({
type: "paragraph",
attrs: { id, indent: 0 },
content: text ? [textNode(text)] : [],
});
const cell = (paraId, text, colwidth) => ({
type: "tableCell",
attrs: { colspan: 1, rowspan: 1, ...(colwidth ? { colwidth } : {}) },
content: [para(paraId, text)],
});
const row = (...cells) => ({ type: "tableRow", content: cells });
const doc = (...children) => ({ type: "doc", content: children });
const snapshot = (v) => JSON.parse(JSON.stringify(v));
// Heading at index 0, a 3x3 table at index 1.
// Header row "A"/"B"/"C" with colwidths [120]/[200]/[150]; two data rows.
const makeDoc = () =>
doc(
{ type: "heading", attrs: { id: "h1", level: 1 }, content: [textNode("Title")] },
{
type: "table",
content: [
row(
cell("hpA", "A", [120]),
cell("hpB", "B", [200]),
cell("hpC", "C", [150]),
),
row(cell("p10", "r1c0"), cell("p11", "r1c1"), cell("p12", "r1c2")),
row(cell("p20", "r2c0"), cell("p21", "r2c1"), cell("p22", "r2c2")),
],
},
);
// Gather every attrs.id present anywhere in a doc.
const allIds = (node, acc = new Set()) => {
if (node && typeof node === "object" && !Array.isArray(node)) {
if (node.attrs && typeof node.attrs.id === "string") acc.add(node.attrs.id);
if (Array.isArray(node.content)) node.content.forEach((c) => allIds(c, acc));
}
return acc;
};
// ---------------------------------------------------------------------------
// readTable
// ---------------------------------------------------------------------------
test("readTable('#1') returns the 3x3 matrix, cell ids, and path", () => {
const t = readTable(makeDoc(), "#1");
assert.ok(t);
assert.equal(t.rows, 3);
assert.equal(t.cols, 3);
assert.deepEqual(t.cells, [
["A", "B", "C"],
["r1c0", "r1c1", "r1c2"],
["r2c0", "r2c1", "r2c2"],
]);
assert.deepEqual(t.cellIds, [
["hpA", "hpB", "hpC"],
["p10", "p11", "p12"],
["p20", "p21", "p22"],
]);
assert.deepEqual(t.path, [1]);
});
test("readTable(<cell paragraph id>) resolves the enclosing table", () => {
const t = readTable(makeDoc(), "p21"); // a paragraph inside a data cell
assert.ok(t);
assert.equal(t.rows, 3);
assert.equal(t.cols, 3);
assert.deepEqual(t.path, [1]);
});
test("readTable on a non-table block / unknown ref returns null", () => {
assert.equal(readTable(makeDoc(), "#0"), null); // heading, not a table
assert.equal(readTable(makeDoc(), "nope"), null); // no such id
});
// ---------------------------------------------------------------------------
// insertTableRow
// ---------------------------------------------------------------------------
test("insertTableRow appends a 4th row, copies header colwidths, fresh unique ids", () => {
const input = makeDoc();
const snap = snapshot(input);
const existingIds = allIds(input);
const { doc: out, inserted } = insertTableRow(input, "#1", ["x", "y", "z"]);
assert.equal(inserted, true);
// Input not mutated.
assert.deepEqual(input, snap);
const tbl = out.content[1];
assert.equal(tbl.content.length, 4);
const newRow = tbl.content[3];
assert.equal(newRow.type, "tableRow");
assert.equal(newRow.content.length, 3);
// Cell texts.
assert.deepEqual(
newRow.content.map((c) => c.content[0].content[0]?.text),
["x", "y", "z"],
);
// Colwidths copied from the header row.
assert.deepEqual(
newRow.content.map((c) => c.attrs.colwidth),
[[120], [200], [150]],
);
// colspan/rowspan present.
for (const c of newRow.content) {
assert.equal(c.attrs.colspan, 1);
assert.equal(c.attrs.rowspan, 1);
}
// New paragraph ids are unique and not equal to any existing id.
const newIds = newRow.content.map((c) => c.content[0].attrs.id);
assert.equal(new Set(newIds).size, 3);
for (const id of newIds) {
assert.ok(typeof id === "string" && id.length > 0);
assert.equal(existingIds.has(id), false);
}
});
test("insertTableRow at index 0 inserts before the header and pads to 3 cells", () => {
const { doc: out, inserted } = insertTableRow(makeDoc(), "#1", ["x"], 0);
assert.equal(inserted, true);
const tbl = out.content[1];
assert.equal(tbl.content.length, 4);
const newRow = tbl.content[0]; // inserted at the front
assert.equal(newRow.content.length, 3);
// First cell "x", remaining two empty.
assert.deepEqual(
newRow.content.map((c) => c.content[0].content.length),
[1, 0, 0],
);
assert.equal(newRow.content[0].content[0].content[0].text, "x");
});
test("insertTableRow throws when given more cells than columns", () => {
assert.throws(
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
);
});
test("insertTableRow on a missing table returns inserted:false", () => {
const { inserted } = insertTableRow(makeDoc(), "#0", ["x"]);
assert.equal(inserted, false);
});
// A header cell uses type "tableHeader" (vs. "tableCell" for data cells).
const headerCell = (paraId, text, colwidth) => ({
type: "tableHeader",
attrs: { colspan: 1, rowspan: 1, ...(colwidth ? { colwidth } : {}) },
content: [para(paraId, text)],
});
// Table whose first row uses tableHeader cells.
const makeHeaderDoc = () =>
doc({
type: "table",
content: [
row(headerCell("hA", "A"), headerCell("hB", "B")),
row(cell("p10", "r1c0"), cell("p11", "r1c1")),
],
});
test("insertTableRow at index 0 inherits the header cell type (tableHeader)", () => {
const { doc: out, inserted } = insertTableRow(makeHeaderDoc(), "#0", ["x", "y"], 0);
assert.equal(inserted, true);
const tbl = out.content[0];
const newRow = tbl.content[0]; // landed at index 0
// The new row's cells inherit the header type.
assert.deepEqual(
newRow.content.map((c) => c.type),
["tableHeader", "tableHeader"],
);
assert.equal(newRow.content[0].content[0].content[0].text, "x");
});
test("insertTableRow append produces data cells (tableCell), not header cells", () => {
const { doc: out, inserted } = insertTableRow(makeHeaderDoc(), "#0", ["x", "y"]);
assert.equal(inserted, true);
const tbl = out.content[0];
const newRow = tbl.content[tbl.content.length - 1]; // appended last
assert.deepEqual(
newRow.content.map((c) => c.type),
["tableCell", "tableCell"],
);
});
// Ragged table: row 0 has 2 cols, a later row has 3.
const makeRaggedDoc = () =>
doc({
type: "table",
content: [
row(cell("a0", "a0"), cell("a1", "a1")),
row(cell("b0", "b0"), cell("b1", "b1"), cell("b2", "b2")),
],
});
test("insertTableRow uses the max column count across all rows (ragged table)", () => {
// colCount is 3 (the widest row), so 3 cells are accepted...
const { doc: out, inserted } = insertTableRow(makeRaggedDoc(), "#0", ["x", "y", "z"]);
assert.equal(inserted, true);
const tbl = out.content[0];
const newRow = tbl.content[tbl.content.length - 1];
assert.equal(newRow.content.length, 3);
assert.deepEqual(
newRow.content.map((c) => c.content[0].content[0]?.text),
["x", "y", "z"],
);
// ...but 4 cells exceed the widest row and throw.
assert.throws(
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
);
});
test("insertTableRow into an empty table uses colCount = supplied cells", () => {
const empty = doc({ type: "table", content: [] });
const { doc: out, inserted } = insertTableRow(empty, "#0", ["x", "y", "z"]);
assert.equal(inserted, true);
const tbl = out.content[0];
assert.equal(tbl.content.length, 1);
assert.equal(tbl.content[0].content.length, 3);
assert.deepEqual(
tbl.content[0].content.map((c) => c.content[0].content[0]?.text),
["x", "y", "z"],
);
});
test("insertTableRow mints 12-char [a-z0-9] ids that are unique and non-colliding", () => {
const input = makeDoc();
const existingIds = allIds(input);
const { doc: out } = insertTableRow(input, "#1", ["x", "y", "z"]);
const tbl = out.content[1];
const newRow = tbl.content[tbl.content.length - 1];
const newIds = newRow.content.map((c) => c.content[0].attrs.id);
// Docmost-style: exactly 12 chars from lowercase a-z0-9.
for (const id of newIds) {
assert.match(id, /^[a-z0-9]{12}$/);
assert.equal(existingIds.has(id), false); // no collision with the doc
}
// All distinct within the new row.
assert.equal(new Set(newIds).size, newIds.length);
});
// ---------------------------------------------------------------------------
// deleteTableRow
// ---------------------------------------------------------------------------
test("deleteTableRow removes the 3rd row -> rows:2", () => {
const { doc: out, deleted } = deleteTableRow(makeDoc(), "#1", 2);
assert.equal(deleted, true);
const tbl = out.content[1];
assert.equal(tbl.content.length, 2);
// The removed row was the second data row (r2*).
assert.deepEqual(
tbl.content.map((r) => r.content[0].content[0].content[0]?.text ?? ""),
["A", "r1c0"],
);
});
test("deleteTableRow out-of-range index throws", () => {
assert.throws(
() => deleteTableRow(makeDoc(), "#1", 9),
/table_delete_row: row index 9 out of range \(table has 3 row\(s\)\)/,
);
});
test("deleteTableRow refuses to delete the only row", () => {
const single = doc({
type: "table",
content: [row(cell("only", "x"))],
});
assert.throws(
() => deleteTableRow(single, "#0", 0),
/refusing to delete the only row of the table/,
);
});
// ---------------------------------------------------------------------------
// updateTableCell
// ---------------------------------------------------------------------------
test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", () => {
const input = makeDoc();
const snap = snapshot(input);
const { doc: out, updated } = updateTableCell(input, "#1", 1, 1, "Z");
assert.equal(updated, true);
// Input not mutated.
assert.deepEqual(input, snap);
const targetCell = out.content[1].content[1].content[1];
assert.equal(targetCell.content.length, 1);
const p = targetCell.content[0];
assert.equal(p.type, "paragraph");
assert.equal(p.attrs.id, "p11"); // preserved
assert.equal(p.content[0].text, "Z");
// Cell attrs preserved.
assert.equal(targetCell.attrs.colspan, 1);
assert.equal(targetCell.attrs.rowspan, 1);
});
test("updateTableCell out-of-range row/col throws", () => {
assert.throws(
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
/table_update_cell: cell \[9,0\] out of range/,
);
assert.throws(
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
/table_update_cell: cell \[0,9\] out of range/,
);
});

View File

@@ -0,0 +1,303 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
} from "../../build/lib/transforms.js";
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
const para = (id, ...children) => ({
type: "paragraph",
attrs: { id },
content: children,
});
const heading = (id, text) => ({
type: "heading",
attrs: { id, level: 2 },
content: [t(text)],
});
const olist = (...items) => ({ type: "orderedList", content: items });
const li = (text) => ({
type: "listItem",
content: [{ type: "paragraph", content: [t(text)] }],
});
const doc = (...children) => ({ type: "doc", content: children });
const snapshot = (v) => JSON.parse(JSON.stringify(v));
// ---------------------------------------------------------------------------
// blockText / walk / getList
// ---------------------------------------------------------------------------
test("blockText concatenates nested inline text", () => {
assert.equal(blockText(para("p", t("a"), t("b"), t("c"))), "abc");
});
test("walk visits every node depth-first", () => {
const d = doc(para("p1", t("x")), olist(li("y")));
const types = [];
walk(d, (n) => types.push(n.type));
assert.deepEqual(types, [
"doc",
"paragraph",
"text",
"orderedList",
"listItem",
"paragraph",
"text",
]);
});
test("getList finds an orderedList without an id", () => {
const d = doc(para("p", t("x")), olist(li("one")));
const found = getList(d, (n) => n.type === "orderedList");
assert.ok(found);
assert.equal(found.type, "orderedList");
});
// ---------------------------------------------------------------------------
// insertMarkerAfter — mark-safe split
// ---------------------------------------------------------------------------
test("insertMarkerAfter splits a marked run and inserts an UNMARKED marker", () => {
// A paragraph: "see " (plain) + "the link" (link mark) + " here" (plain).
const link = [{ type: "link", attrs: { href: "http://x" } }];
const original = doc(
para("p1", t("see "), t("the link", link), t(" here")),
);
const before = snapshot(original);
const { doc: out, inserted } = insertMarkerAfter(
original,
"the link",
"[1]",
);
assert.equal(inserted, true);
// The caller's object is untouched (deep clone).
assert.deepEqual(original, before);
const inline = out.content[0].content;
// Expect: "see "(plain), "the link"(link), " [1]"(NO marks), " here"(plain).
const marker = inline.find((n) => n.text === " [1]");
assert.ok(marker, "marker run present");
assert.equal(marker.marks, undefined, "marker carries no marks");
// The link run kept its mark verbatim.
const linkRun = inline.find((n) => n.text === "the link");
assert.deepEqual(linkRun.marks, link);
// Plain text reads correctly with the marker placed right after the anchor.
assert.equal(blockText(out.content[0]), "see the link [1] here");
});
test("insertMarkerAfter respects beforeBlock and reports not-found", () => {
const d = doc(para("p1", t("alpha")), para("p2", t("beta")));
// anchor only in block index 1, but search limited to blocks < 1
const r = insertMarkerAfter(d, "beta", "[1]", { beforeBlock: 1 });
assert.equal(r.inserted, false);
});
// ---------------------------------------------------------------------------
// setCalloutRange
// ---------------------------------------------------------------------------
test("setCalloutRange rewrites [1]…[K] to [1]…[n]", () => {
const d = doc({
type: "callout",
attrs: { type: "info" },
content: [para("c", t("Footnotes [1]…[3] are translator notes."))],
});
const { doc: out, changed } = setCalloutRange(d, 7);
assert.equal(changed, 1);
assert.equal(blockText(out), "Footnotes [1]…[7] are translator notes.");
});
// ---------------------------------------------------------------------------
// noteItem / mdToInlineNodes
// ---------------------------------------------------------------------------
test("noteItem wraps inline nodes in a listItem with a fresh paragraph id", () => {
const item = noteItem([t("hello")]);
assert.equal(item.type, "listItem");
assert.equal(item.content[0].type, "paragraph");
assert.ok(item.content[0].attrs.id, "has a fresh id");
assert.deepEqual(item.content[0].content, [t("hello")]);
});
test("mdToInlineNodes splits a bold lead and strips a prefix", () => {
const nodes = mdToInlineNodes("комментарий: **Lead.** body text");
// bold lead node + plain remainder
assert.equal(nodes[0].text, "Lead.");
assert.deepEqual(nodes[0].marks, [{ type: "bold" }]);
assert.ok(nodes[1].text.includes("body text"));
assert.equal(nodes[1].marks, undefined);
});
test("mdToInlineNodes strips a 'N. ' numeric prefix", () => {
const nodes = mdToInlineNodes("3. plain note");
assert.equal(nodes.map((n) => n.text).join(""), "plain note");
});
// ---------------------------------------------------------------------------
// commentsToFootnotes — renumber by reading position on a small fixture
// ---------------------------------------------------------------------------
test("commentsToFootnotes anchors comments and renumbers by position", () => {
// Body has an EXISTING footnote [1] in the second paragraph; we add two
// inline comments anchored to text in the first and third paragraphs. After
// running, markers must be renumbered 1,2,3 in reading order and the notes
// list reordered to match.
const callout = {
type: "callout",
attrs: { type: "info" },
content: [para("c", t("Notes [1]…[1] follow."))],
};
const d = doc(
callout,
para("p1", t("First mentions apple.")),
para("p2", t("Second already has a note [1] here.")),
para("p3", t("Third mentions banana.")),
heading("h", "Примечания переводчика"),
olist(li("existing note one")), // matches the existing [1]
);
const comments = [
{ id: "cA", content: "apple note", selection: "apple" },
{ id: "cB", content: "banana note", selection: "banana" },
];
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\]/);
// No stray placeholders remain.
const allText = blockText(out);
assert.doesNotMatch(allText, / F\d+ /);
// Notes list reordered to [apple, existing, banana] (reading order).
const list = out.content.find((n) => n.type === "orderedList");
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");
// Callout range synced to 3 notes.
assert.match(blockText(out.content[0]), /\[1\]…\[3\]/);
});
test("commentsToFootnotes throws when the notes heading is missing", () => {
const d = doc(para("p", t("no notes section")));
assert.throws(
() => commentsToFootnotes(d, [{ id: "x", content: "y", selection: "no" }]),
/heading .* not found/,
);
});
// ---------------------------------------------------------------------------
// Bug 1: the placeholder sentinel must not collide with real "F<digits>" /
// "FN<digits>" text. Body text "F1"/"FN2"/"F12" near a real comment anchor must
// be left untouched; only the real comment becomes a footnote. "FN2" is the key
// case: the old printable " FN<i> " sentinel could collide with prose like "FN2",
// which the NUL-delimited "\u0000FN<i>\u0000" sentinel makes impossible.
// ---------------------------------------------------------------------------
test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched", () => {
const d = doc(
para("p1", t("Press F1 for help, model FN2 and F12 for tools near apple here.")),
heading("h", "Примечания переводчика"),
olist(), // empty notes list; the single comment supplies the only note
);
const comments = [{ id: "cA", content: "apple note", selection: "apple" }];
const { doc: out, consumed } = commentsToFootnotes(d, comments);
assert.deepEqual(consumed, ["cA"]);
const bodyText = blockText(out.content[0]);
// 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 note in the list — "F1"/"FN2"/"F12" did not spawn extra notes.
const list = out.content.find((n) => n.type === "orderedList");
assert.equal(list.content.length, 1);
assert.equal(blockText(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
// in the returned doc. We deliberately do NOT assert absence of the printable
// " FN<i> " shape: the body intentionally contains real prose "model FN2 and",
// which must survive verbatim (see the match assertion above) - that is exactly
// why the old printable sentinel was unsafe and the NUL sentinel is not.
assert.doesNotMatch(blockText(out), /\u0000/);
});
// ---------------------------------------------------------------------------
// Bug 2: an out-of-range body marker must throw, not silently drop the note.
// ---------------------------------------------------------------------------
test("commentsToFootnotes throws on an out-of-range body marker", () => {
// Body marker [9] but the notes list has only 1 item -> inconsistent doc.
const d = doc(
para("p1", t("Some text with a dangling marker [9] here.")),
heading("h", "Примечания переводчика"),
olist(li("the only note")),
);
assert.throws(
() => commentsToFootnotes(d, []),
/footnote \[9\] has no matching note \(notes list has 1 items\); document is inconsistent/,
);
});
// ---------------------------------------------------------------------------
// Bug 4: a non-disclaimer callout in the body gets its [N] markers renumbered;
// a disclaimer callout carrying a "[1]…[K]" range is left out of renumbering.
// ---------------------------------------------------------------------------
test("commentsToFootnotes renumbers body callouts but skips the disclaimer range", () => {
const disclaimer = {
type: "callout",
attrs: { type: "info" },
content: [para("d", t("Notes [1]…[2] follow."))],
};
const bodyCallout = {
type: "callout",
attrs: { type: "warning" },
content: [para("bc", t("Important point already noted [1] above."))],
};
const d = doc(
disclaimer,
bodyCallout,
para("p2", t("Then a second mention with [2] too.")),
heading("h", "Примечания переводчика"),
olist(li("first note"), li("second note")),
);
const { doc: out, consumed } = commentsToFootnotes(d, []);
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].
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/);
// Notes list still has the two original notes in order.
const list = out.content.find((n) => n.type === "orderedList");
assert.equal(list.content.length, 2);
assert.equal(blockText(list.content[0]), "first note");
assert.equal(blockText(list.content[1]), "second note");
});