d269bd9efe
Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412). - getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка {type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря (ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage). format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/ Header по #<index>) в JSON через canBeDocChild = docmostSchema.nodes.doc. contentMatch.matchType (не рукописный список); поле format на каждом ответе. - patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса; соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON. insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell JSON-only. - Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1 фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→ canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты. - node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в prosemirror-markdown/node-ops (канон после #414) + barrel. - ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG (getNode default→markdown, breaking для внешних клиентов, в окне #411/#412). Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback; сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация; guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273. Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
5.1 KiB
JavaScript
158 lines
5.1 KiB
JavaScript
// #413: getNode's markdown-default format, its JSON opt-in, the non-top-level
|
|
// AUTO fallback to JSON, and comment-anchor preservation (incl. resolved) on the
|
|
// markdown read. getNode only reads (getPageRaw), so a lightweight subclass that
|
|
// stubs auth + the page fetch is enough — no collab socket needed.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { DocmostClient } from "../../build/client.js";
|
|
|
|
function makeClient(doc) {
|
|
class TestClient extends DocmostClient {
|
|
async ensureAuthenticated() {}
|
|
async getPageRaw(pageId) {
|
|
return { id: pageId, slugId: "s", title: "P", spaceId: "sp", content: doc };
|
|
}
|
|
}
|
|
return new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
|
|
}
|
|
|
|
const P = "p1";
|
|
|
|
test("getNode defaults to markdown for a paragraph", async () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
attrs: { id: "b1" },
|
|
content: [{ type: "text", text: "hello world" }],
|
|
},
|
|
],
|
|
};
|
|
const res = await makeClient(doc).getNode(P, "b1");
|
|
assert.equal(res.format, "markdown");
|
|
assert.equal(typeof res.markdown, "string");
|
|
assert.match(res.markdown, /hello world/);
|
|
assert.equal(res.node, undefined, "markdown result carries no raw node");
|
|
});
|
|
|
|
test("getNode format:'json' returns the raw subtree verbatim", async () => {
|
|
const target = {
|
|
type: "paragraph",
|
|
attrs: { id: "b1" },
|
|
content: [{ type: "text", text: "hello" }],
|
|
};
|
|
const doc = { type: "doc", content: [target] };
|
|
const res = await makeClient(doc).getNode(P, "b1", "json");
|
|
assert.equal(res.format, "json");
|
|
assert.deepEqual(res.node, target);
|
|
assert.equal(res.markdown, undefined);
|
|
});
|
|
|
|
test("getNode AUTO-falls back to JSON for a non-top-level type (tableRow via #index)", async () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "table",
|
|
content: [
|
|
{
|
|
type: "tableRow",
|
|
content: [
|
|
{
|
|
type: "tableCell",
|
|
attrs: { colspan: 1, rowspan: 1 },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
attrs: { id: "cp" },
|
|
content: [{ type: "text", text: "x" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
// "#0.0"-style refs are not supported; the whole table is "#0", a row is only
|
|
// reachable by drilling — but a tableRow IS a non-doc-child type. Address the
|
|
// table itself as "#0": a table CAN be a doc child, so markdown is fine there.
|
|
// To hit the fallback, address the row by walking: getNode resolves "#0" to the
|
|
// table (doc child -> markdown). Instead we verify the schema gate directly by
|
|
// asking for the table (markdown) and a row is exercised via the unit test on
|
|
// canBeDocChild; here confirm a table renders as markdown.
|
|
const tableRes = await makeClient(doc).getNode(P, "#0");
|
|
assert.equal(tableRes.format, "markdown", "a table is a doc child -> markdown");
|
|
|
|
// Now build a doc whose top-level block IS a tableRow (schematically invalid but
|
|
// exercises the getNode fallback branch): getNode("#0") resolves it and, because
|
|
// tableRow cannot be a doc child, must fall back to JSON.
|
|
const rowDoc = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "tableRow",
|
|
content: [
|
|
{
|
|
type: "tableCell",
|
|
attrs: { colspan: 1, rowspan: 1 },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "y" }] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const rowRes = await makeClient(rowDoc).getNode(P, "#0");
|
|
assert.equal(rowRes.format, "json", "a tableRow cannot be a doc child -> JSON fallback");
|
|
assert.equal(rowRes.type, "tableRow");
|
|
assert.ok(rowRes.node, "the JSON fallback returns the raw subtree");
|
|
});
|
|
|
|
test("getNode(markdown) PRESERVES comment anchors — active and resolved", async () => {
|
|
// A paragraph with two comment marks: one active, one resolved. get_page strips
|
|
// resolved anchors; getNode must NOT (a read for editing/write-back).
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
attrs: { id: "b1" },
|
|
content: [
|
|
{ type: "text", text: "start " },
|
|
{
|
|
type: "text",
|
|
text: "active",
|
|
marks: [{ type: "comment", attrs: { commentId: "cid-active" } }],
|
|
},
|
|
{ type: "text", text: " mid " },
|
|
{
|
|
type: "text",
|
|
text: "resolved",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { commentId: "cid-resolved", resolved: true },
|
|
},
|
|
],
|
|
},
|
|
{ type: "text", text: " end" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const res = await makeClient(doc).getNode(P, "b1");
|
|
assert.equal(res.format, "markdown");
|
|
assert.match(
|
|
res.markdown,
|
|
/data-comment-id="cid-active"/,
|
|
"the active comment anchor is preserved",
|
|
);
|
|
assert.match(
|
|
res.markdown,
|
|
/data-comment-id="cid-resolved"/,
|
|
"the RESOLVED comment anchor is ALSO preserved (unlike get_page)",
|
|
);
|
|
});
|