4b2af3d34a
Аудит при подготовке #413 нашёл дрейфующие дубли между packages/mcp и packages/prosemirror-markdown. Четыре дедупа (поведение тулов не меняется): 1. node-ops: форк ~960 строк сведён в ОДНУ копию в prosemirror-markdown (живая mcp-версия — строгое надмножество замороженного #293-seed'а пакета; сверено по git-истории, новая пакет-копия байт-в-байт == прежней mcp-копии). Barrel-экспорт полной поверхности; mcp/client.ts/page-search.ts/transforms.ts/collaboration.ts импортируют из пакета; тесты переехали. node-ops тянет stripInlineMarkdown -> пакет-локальная text-normalize.ts несёт только этот примитив (mcp-версия — домен #408; заголовок документирует дубликацию + источник истины). 2. footnote-lex/footnote-analyze (vestigial legacy [^id]: диагностика): сведены к одному fence-aware предупреждению 'reference-style footnotes -> use ^[...]' (полезно для класса #410); footnote-lex удалён. 3. footnote-authoring -> примитивы (footnoteContentKey/makeFootnoteDefinition/ generateFootnoteId) перенесены в пакетный footnote.ts, одна реализация конвенции. 4. parse-node-arg -> перенесён в prosemirror-markdown (не mcp: сервер CommonJS не импортирует ESM-only @docmost/mcp, но нативно импортирует пакет), обе копии удалены, консьюмеры перенаправлены. canonicalizeFootnotes/ENFORCEMENT RULE #228 и comment-anchor/json-edit/text-normalize (mcp) не тронуты. API-поверхность node-ops оставлена чистой для #409/#413. closes #414 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
302 lines
9.2 KiB
JavaScript
302 lines
9.2 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
insertNodeRelative,
|
|
sanitizeForYjs,
|
|
findUnstorableAttr,
|
|
} from "@docmost/prosemirror-markdown";
|
|
|
|
// 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);
|
|
}
|
|
});
|