diff --git a/packages/docmost-client/src/client.ts b/packages/docmost-client/src/client.ts index 54fd4df..0d88e84 100644 --- a/packages/docmost-client/src/client.ts +++ b/packages/docmost-client/src/client.ts @@ -20,7 +20,6 @@ import { markdownToProseMirror, mutatePageContent, buildCollabWsUrl, - assertYjsEncodable, } from "./lib/collaboration.js"; import { docmostExtensions } from "./lib/docmost-schema.js"; import { @@ -43,17 +42,6 @@ import { withPageLock } from "./lib/page-lock.js"; import { applyTextEdits, TextEdit, TextEditResult } from "./lib/json-edit.js"; import { getCollabToken, performLogin } from "./lib/auth-utils.js"; import { diffDocs } from "./lib/diff.js"; -import { - blockText, - walk, - getList, - insertMarkerAfter, - setCalloutRange, - noteItem, - mdToInlineNodes, - commentsToFootnotes, -} from "./lib/transforms.js"; -import vm from "node:vm"; export class DocmostClient { private client: AxiosInstance; @@ -2410,172 +2398,6 @@ export class DocmostClient { return { from: fromSide.meta, to: toSide.meta, diff }; } - /** - * Edit a page by running an arbitrary user-supplied JS transform against the - * live document, with a diff preview + page-history safety net. - * - * The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm - * sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals, - * a 5s timeout, and NO access to require/process/fs/network. It must return a - * `{ type: "doc" }` node, which is validated structurally before any write. - * - * `ctx` exposes: - * - comments: the page's comments (fetched before the live read); - * - log: an array the transform can push diagnostics to (via console.log); - * - consume(id): mark a comment id as consumed (for deleteComments); - * - helpers: the transforms.ts primitives + commentsToFootnotes. - * - * Footnote convention used by the helpers: footnote markers are plain "[N]" - * text in the body, and the notes are an orderedList under a heading whose - * text is "Примечания переводчика". - * - * dryRun (default true): read the page's current content, run the transform, - * and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket. - * Otherwise the transform runs atomically inside mutatePageContent, optionally - * deletes consumed comments, and returns the new historyId + diff + log. - */ - async transformPage( - pageId: string, - transformJs: string, - opts: { dryRun?: boolean; deleteComments?: boolean } = {}, - ) { - const dryRun = opts.dryRun ?? true; - const deleteComments = opts.deleteComments ?? false; - - await this.ensureAuthenticated(); - const comments = await this.listComments(pageId); - - // ctx handed to the sandbox. consume() records ids; helpers are the pure - // transform primitives. log is captured from console.log inside the sandbox. - const ctx = { - comments, - log: [] as string[], - consumed: new Set(), - consume(id: string) { - this.consumed.add(id); - }, - helpers: { - blockText, - walk, - getList, - insertMarkerAfter, - setCalloutRange, - noteItem, - mdToInlineNodes, - commentsToFootnotes, - }, - }; - - // Captured oldDoc / newDoc for the diff (set inside runTransform). - let oldDoc: any; - let newDoc: any; - - // SYNCHRONOUS transform runner — safe to call inside mutatePageContent's - // onSynced (no await between the live read and the write). - const runTransform = (liveDoc: any): any => { - oldDoc = structuredClone(liveDoc); - const sandbox: Record = { - doc: structuredClone(liveDoc), - ctx, - structuredClone, - console: { - log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")), - }, - }; - // Wrap the provided string in parentheses so both an expression-arrow - // (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a - // fresh context with no require/process/module so the transform cannot - // touch fs/network/process. 5s wall-clock timeout. - let fn: any; - try { - fn = vm.runInNewContext("(" + transformJs + ")", sandbox, { - timeout: 5000, - }); - } catch (e: any) { - throw new Error(`transform did not compile: ${e?.message ?? e}`); - } - if (typeof fn !== "function") { - throw new Error("transform must evaluate to a function (doc, ctx) => doc"); - } - const result = vm.runInNewContext( - "f(d, c)", - { f: fn, d: sandbox.doc, c: ctx }, - { timeout: 5000 }, - ); - if ( - !result || - typeof result !== "object" || - result.type !== "doc" || - !Array.isArray(result.content) - ) { - throw new Error( - 'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })', - ); - } - // Validate the returned doc before it can be written. - this.validateDocStructure(result); - this.validateDocUrls(result); - newDoc = result; - return result; - }; - - if (dryRun) { - // Preview only: run against the current REST snapshot, never open the - // socket. oldDoc/newDoc are captured by runTransform. - const raw = await this.getPageRaw(pageId); - const current = raw.content || { type: "doc", content: [] }; - runTransform(current); - // Exercise the same Yjs encoder the apply path uses, so the preview - // fails with the SAME descriptive error when the doc is not encodable - // instead of returning a misleadingly-green diff. - assertYjsEncodable(newDoc); - return { - pushed: false, - diff: diffDocs(oldDoc, newDoc), - log: ctx.log, - }; - } - - // Apply atomically against the live doc. - const collabToken = await this.getCollabTokenWithReauth(); - await mutatePageContent(pageId, collabToken, this.apiUrl, runTransform); - - // Optionally delete consumed comments (best-effort; a delete failure must - // not undo the successful write). - const deletedComments: string[] = []; - if (deleteComments) { - for (const id of ctx.consumed) { - try { - await this.deleteComment(id); - deletedComments.push(id); - } catch (e) { - if (process.env.DEBUG) { - console.error(`transform: failed to delete comment ${id}:`, e); - } - } - } - } - - // Fetch the newest historyId (Docmost snapshots on the write above). - let historyId: string | null = null; - try { - const hist = await this.listPageHistory(pageId); - historyId = hist.items?.[0]?.id ?? null; - } catch (e) { - if (process.env.DEBUG) { - console.error("transform: failed to fetch history id:", e); - } - } - - return { - pushed: true, - historyId, - diff: diffDocs(oldDoc, newDoc), - deletedComments, - log: ctx.log, - }; - } - // --- docmost-sync additions (backport target: docmost-mcp/src/client.ts) --- // // REST-only helpers added for the docmost-sync engine. They reuse the diff --git a/packages/docmost-client/src/lib/transforms.ts b/packages/docmost-client/src/lib/transforms.ts deleted file mode 100644 index d8fba09..0000000 --- a/packages/docmost-client/src/lib/transforms.ts +++ /dev/null @@ -1,477 +0,0 @@ -/** - * Pure, network-free transform primitives for a ProseMirror/TipTap document - * tree, plus one higher-level orchestration (commentsToFootnotes). - * - * A ProseMirror node here is a plain JSON object of the shape produced by - * Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the - * `content` array; callouts, tables, lists all hold their children in - * `content`, so a single recursive walk reaches them all. - * - * Conventions (matching node-ops.ts): - * - functions that produce a new document deep-clone their input and return a - * `{ doc, ... }` object; the caller's objects are never mutated. - * - functions are defensively null-safe. - * - `marks` arrays are preserved verbatim when fragments are split/reordered. - */ - -import { blockPlainText } from "./node-ops.js"; - -/** Deep-clone a JSON-serializable value without mutating the original. */ -function clone(value: T): T { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - // Fallback for environments without structuredClone. - return JSON.parse(JSON.stringify(value)) as T; -} - -/** True if `value` is a non-null object (and not an array). */ -function isObject(value: any): value is Record { - return value != null && typeof value === "object" && !Array.isArray(value); -} - -/** - * Plain text of a node (re-export of node-ops' blockPlainText so transform - * authors have a single import surface). Recurses through nested content. - */ -export function blockText(node: any): string { - return blockPlainText(node); -} - -/** - * Depth-first visit of every node in the tree, including the root and the - * nested content of callouts, tables, lists, etc. `fn` is called once per node. - * Null-safe: a nullish or non-object node is ignored. - */ -export function walk(node: any, fn: (node: any) => void): void { - if (!isObject(node)) return; - fn(node); - if (Array.isArray(node.content)) { - for (const child of node.content) { - walk(child, fn); - } - } -} - -/** - * Find the FIRST node (depth-first) matching `predicate`, anywhere in the tree. - * Works even when the node carries no `attrs.id` (it searches the raw tree, not - * an id index). Returns the live node reference inside `doc` (NOT a clone), or - * null when nothing matches. Typical use: `getList(doc, n => n.type === - * "orderedList")`. - */ -export function getList( - doc: any, - predicate: (node: any) => boolean, -): any | null { - let found: any | null = null; - walk(doc, (node) => { - if (found == null && predicate(node)) { - found = node; - } - }); - return found; -} - -/** Options for insertMarkerAfter. */ -export interface InsertMarkerOptions { - /** - * Limit the search to TOP-LEVEL blocks with index < beforeBlock. Used to keep - * footnote markers in the body and out of the notes section. - */ - beforeBlock?: number; -} - -/** - * Insert `marker` as a PLAIN (unmarked) text run right after the first - * occurrence of `anchor`. - * - * The text run that contains the END of the anchor is SPLIT at the anchor end, - * so all existing marks (links, bold, ...) on the surrounding text are - * preserved, while the inserted marker run carries NO marks. The marker is - * inserted as a leading-space-padded run (`" " + marker`) so it visually - * separates from the preceding word. - * - * The anchor is matched against the concatenated plain text of each top-level - * block (so an anchor that spans several text/mark runs still matches). The - * insertion happens inside the inline content array that holds the anchor's - * final character. - * - * Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is - * false when the anchor text was not found in any in-scope block. - */ -export function insertMarkerAfter( - doc: any, - anchor: string, - marker: string, - opts: InsertMarkerOptions = {}, -): { doc: any; inserted: boolean } { - const out = clone(doc); - if (!isObject(out) || !Array.isArray(out.content) || !anchor) { - return { doc: out, inserted: false }; - } - - const limit = - typeof opts.beforeBlock === "number" - ? Math.min(opts.beforeBlock, out.content.length) - : out.content.length; - - for (let b = 0; b < limit; b++) { - const block = out.content[b]; - if (!isObject(block)) continue; - // Quick reject: skip blocks whose plain text cannot contain the anchor. - if (!blockPlainText(block).includes(anchor)) continue; - - // Walk the inline content arrays inside this block, tracking a running - // character offset so we can locate the inline array + text run that holds - // the END of the anchor's first occurrence. - let inserted = false; - let offset = 0; // characters of plain text seen so far in this block - const anchorEnd = (() => blockPlainText(block).indexOf(anchor) + anchor.length)(); - - // Recurse into inline-bearing containers (paragraph, heading, table cell, - // callout child paragraphs, ...). We only split inside an array of inline - // nodes (text/inline atoms); the FIRST array whose cumulative range covers - // anchorEnd receives the split + marker. - const visit = (container: any): void => { - if (inserted || !isObject(container) || !Array.isArray(container.content)) { - return; - } - const inline = container.content; - // Detect whether this array is an inline array (contains text nodes). - const hasText = inline.some( - (n: any) => isObject(n) && n.type === "text", - ); - if (hasText) { - for (let i = 0; i < inline.length; i++) { - const n = inline[i]; - const len = isObject(n) ? blockPlainText(n).length : 0; - const runStart = offset; - const runEnd = offset + len; - // The run that contains the anchor end (anchorEnd lands inside this - // run, i.e. runStart < anchorEnd <= runEnd) is the split point. - if ( - !inserted && - isObject(n) && - n.type === "text" && - typeof n.text === "string" && - anchorEnd > runStart && - anchorEnd <= runEnd - ) { - const cut = anchorEnd - runStart; // split index within this text run - const before = n.text.slice(0, cut); - const after = n.text.slice(cut); - const marks = Array.isArray(n.marks) ? n.marks : []; - const parts: any[] = []; - if (before.length > 0) { - parts.push({ ...n, text: before, marks: [...marks] }); - } - // Marker is a PLAIN run: no marks copied. Leading space separates it. - parts.push({ type: "text", text: " " + marker }); - if (after.length > 0) { - parts.push({ ...n, text: after, marks: [...marks] }); - } - inline.splice(i, 1, ...parts); - inserted = true; - return; - } - offset = runEnd; - } - } else { - // Not an inline array: recurse into children (e.g. callout -> paragraph). - for (const child of inline) { - visit(child); - if (inserted) return; - } - } - }; - - visit(block); - if (inserted) { - return { doc: out, inserted: true }; - } - // If the block matched in plain text but we could not split (e.g. anchor - // lands inside an atom), fall through to the next block rather than failing. - } - - return { doc: out, inserted: false }; -} - -/** - * In the disclaimer callout, replace a `[1]…[K]` range marker with `[1]…[n]`. - * - * Docmost translations use a callout that states the footnote range, e.g. - * "[1]…[5]". When the number of notes changes, this rewrites the trailing - * number of any `[1]…[K]` (or `[1]...[K]`, ASCII ellipsis) occurrence found in a - * callout's text nodes to `[1]…[n]`. Operates on a clone; returns - * `{ doc, changed }` where `changed` is the number of text nodes rewritten. - */ -export function setCalloutRange( - doc: any, - n: number, -): { doc: any; changed: number } { - const out = clone(doc); - let changed = 0; - // Match "[1]" + (… or ...) + "[]"; rewrite the last number to n. - const rangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/g; - walk(out, (node) => { - if (node.type === "callout") { - walk(node, (inner) => { - if ( - inner.type === "text" && - typeof inner.text === "string" && - rangeRe.test(inner.text) - ) { - rangeRe.lastIndex = 0; - inner.text = inner.text.replace(rangeRe, `$1${n}$2`); - changed++; - } - rangeRe.lastIndex = 0; - }); - } - }); - return { doc: out, changed }; -} - -/** - * Generate a short random id for a new block's `attrs.id`. Docmost uses nanoid; - * a base36 random string is sufficient here (uniqueness within one document). - */ -function freshId(): string { - return ( - Math.random().toString(36).slice(2, 12) + - Math.random().toString(36).slice(2, 6) - ); -} - -/** - * Wrap inline ProseMirror nodes in a list item: - * { type:"listItem", content:[{ type:"paragraph", attrs:{id}, content: inlineNodes }] } - * with a fresh random block id on the paragraph. The inline nodes are cloned so - * the result shares no references with the caller's input. - */ -export function noteItem(inlineNodes: any[]): any { - const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; - return { - type: "listItem", - content: [ - { - type: "paragraph", - attrs: { id: freshId() }, - content, - }, - ], - }; -} - -/** - * Convert a comment's markdown (e.g. `**Lead.** body...`) into inline - * ProseMirror nodes. - * - * A leading `комментарий: ` (case-insensitive) or `N. ` numeric prefix is - * stripped first. Then a minimal bold-split is applied: a leading - * `**bold lead**` run becomes a text node with a bold mark, and the remainder - * becomes a plain text node. This keeps the conversion synchronous (the - * transform sandbox runs synchronously) and dependency-free; the existing - * async markdownToProseMirror is intentionally NOT used here. - */ -export function mdToInlineNodes(markdown: string): any[] { - let md = typeof markdown === "string" ? markdown : ""; - // Strip a leading "комментарий: " prefix (case-insensitive) or a "N. " prefix. - md = md.replace(/^\s*комментарий\s*:\s*/i, ""); - md = md.replace(/^\s*\d+\.\s+/, ""); - md = md.trim(); - - if (md === "") return []; - - const nodes: any[] = []; - // Leading bold lead: **...** at the very start. - const leadMatch = /^\*\*([^*]+)\*\*\s*/.exec(md); - if (leadMatch) { - const leadText = leadMatch[1]; - nodes.push({ - type: "text", - text: leadText, - marks: [{ type: "bold" }], - }); - const rest = md.slice(leadMatch[0].length); - if (rest.length > 0) { - // Preserve the separating space that followed the bold lead. - const sep = /^\*\*[^*]+\*\*(\s*)/.exec(md); - const spacing = sep ? sep[1] : ""; - nodes.push({ type: "text", text: spacing + rest }); - } - return nodes; - } - - // No bold lead: emit the whole thing as a single plain text node, with any - // remaining **bold** spans split out inline. - return splitInlineBold(md); -} - -/** - * Split a string with inline `**bold**` spans into text nodes, bolding the - * spans. Used as the no-lead fallback in mdToInlineNodes. - */ -function splitInlineBold(text: string): any[] { - const nodes: any[] = []; - const re = /\*\*([^*]+)\*\*/g; - let last = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(text)) !== null) { - if (m.index > last) { - nodes.push({ type: "text", text: text.slice(last, m.index) }); - } - nodes.push({ type: "text", text: m[1], marks: [{ type: "bold" }] }); - last = m.index + m[0].length; - } - if (last < text.length) { - nodes.push({ type: "text", text: text.slice(last) }); - } - return nodes.length > 0 ? nodes : [{ type: "text", text }]; -} - -/** Options for commentsToFootnotes. */ -export interface CommentsToFootnotesOptions { - /** Heading text under which the notes orderedList lives. */ - notesHeading?: string; -} - -/** A comment shape as returned by DocmostClient.listComments. */ -export interface FootnoteComment { - id: string; - content: string; - selection?: string | null; - [k: string]: any; -} - -/** - * Turn inline comments into numbered footnotes. - * - * For each inline comment that carries a `selection`: - * 1. insert a placeholder marker (a NUL-delimited "\u0000FN\u0000" - * sentinel) right after the selection text in the BODY (before the - * notes heading); - * 2. build a note list item from the comment's markdown content. - * - * Then RENUMBER every footnote marker in the body by reading order: existing - * `[N]` markers and the new "\u0000FN\u0000" placeholders are both replaced by a - * sequential `[seq]`, and the notes orderedList is reordered so each note lines - * up with its marker's reading-order position. Finally the disclaimer callout - * range is synced to the new note count. - * - * Returns `{ doc, consumed }` where `consumed` lists the ids of comments that - * were successfully anchored (their selection was found and a placeholder - * inserted). Operates on a clone of `doc`. - */ -export function commentsToFootnotes( - doc: any, - comments: FootnoteComment[], - opts: CommentsToFootnotesOptions = {}, -): { doc: any; consumed: string[] } { - let working = clone(doc); - const notesHeading = opts.notesHeading ?? "Примечания переводчика"; - - const top: any[] = Array.isArray(working.content) ? working.content : []; - const notesIdx = top.findIndex( - (n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading, - ); - if (notesIdx < 0) { - throw new Error(`heading "${notesHeading}" not found`); - } - // The notes orderedList lives at or after the heading. - const notesList = top - .slice(notesIdx) - .find((n) => isObject(n) && n.type === "orderedList"); - if (!notesList) { - throw new Error("notes orderedList not found"); - } - - const consumed: string[] = []; - const noteByPh = new Map(); - - (Array.isArray(comments) ? comments : []).forEach((c, i) => { - if (!c || !c.selection) return; - // Collision-proof sentinel delimited by NUL control chars, which never occur - // in real Docmost prose — so the renumber regex below cannot mistake any body - // text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is - // transient: the placeholder round-trips within this function (insertMarkerAfter - // inserts it, the renumber pass replaces it with "[N]"), so it never persists - // in a returned/pushed document. - const ph = `\u0000FN${i}\u0000`; - // insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh - // the `top` / `notesList` references that point into it. - const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, { - beforeBlock: notesIdx, - }); - if (!r.inserted) return; - working = r.doc; - noteByPh.set(ph, noteItem(mdToInlineNodes(c.content))); - consumed.push(c.id); - }); - - // Re-resolve references into the (possibly re-cloned) working doc. - const top2: any[] = Array.isArray(working.content) ? working.content : []; - const notesList2 = top2 - .slice(notesIdx) - .find((n) => isObject(n) && n.type === "orderedList"); - if (!notesList2) { - throw new Error("notes orderedList not found"); - } - - const oldNotes: any[] = Array.isArray(notesList2.content) - ? notesList2.content - : []; - const newNotes: any[] = []; - let seq = 0; - // Match either an existing "[N]" marker or a NUL-delimited "\u0000FN\u0000" - // placeholder, in reading order across the body (blocks before the notes heading). - const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; - // Same range regex setCalloutRange uses to detect the disclaimer callout's - // "[1]…[K]" range; used here to decide whether a top-level callout is the - // disclaimer (skip) or an ordinary callout (renumber normally). - const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/; - for (let i = 0; i < notesIdx; i++) { - // Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote - // marker and is synced separately by setCalloutRange. Renumbering it here - // would consume note slots and corrupt the sequence. Other top-level - // callouts may carry legitimate "[N]" body markers and are renumbered. - if ( - isObject(top2[i]) && - top2[i].type === "callout" && - disclaimerRangeRe.test(blockText(top2[i])) - ) { - continue; - } - walk(top2[i], (node) => { - if (node.type !== "text" || typeof node.text !== "string") return; - node.text = node.text.replace(re, (_m: string, oldNum: string, phIdx: string) => { - if (oldNum != null) { - const note = oldNotes[Number(oldNum) - 1]; - // Every existing body marker MUST map to a real note. An out-of-range - // marker means the document is internally inconsistent; fail loudly - // rather than silently dropping the note and desyncing the callout. - if (note === undefined) { - throw new Error( - `footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`, - ); - } - newNotes.push(note); - } else { - newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`)); - } - return `[${++seq}]`; - }); - }); - } - - // Reorder the notes list IN PLACE on `working` first, THEN sync the callout - // range. setCalloutRange clones `working`, so the reordered notes (mutated - // before the clone) are carried into its result automatically. No null-filter - // here: marker count and note count must stay exactly equal (the out-of-range - // guard above guarantees no undefined entry is ever pushed). - notesList2.content = newNotes; - const synced = setCalloutRange(working, notesList2.content.length); - - return { doc: synced.doc, consumed }; -} diff --git a/test/client-pure.test.ts b/test/client-pure.test.ts index d6015c8..a05e372 100644 --- a/test/client-pure.test.ts +++ b/test/client-pure.test.ts @@ -1,9 +1,7 @@ -// Unit tests for the PURE (private) helper methods of DocmostClient plus the -// transformPage vm sandbox. The constructor only calls axios.create (no -// network), so a real instance can be built and its private methods exercised -// via an `(client as any)` cast. Private methods are pure (no I/O) except for -// transformPage, whose network calls (ensureAuthenticated / listComments / -// getPageRaw) we stub on the instance so the dryRun path runs offline. +// Unit tests for the PURE (private) helper methods of DocmostClient. The +// constructor only calls axios.create (no network), so a real instance can be +// built and its private methods exercised via an `(client as any)` cast. These +// private methods are pure (no I/O). import { describe, it, expect } from 'vitest'; import { DocmostClient } from '../packages/docmost-client/src/client.js'; @@ -361,114 +359,3 @@ describe('DocmostClient.parseCommentContent', () => { expect(c.parseCommentContent('not json {')).toBe('not json {'); }); }); - -describe('DocmostClient.transformPage sandbox (dryRun, no socket)', () => { - // The dryRun path performs: ensureAuthenticated -> listComments -> getPageRaw - // -> runTransform (the vm sandbox) -> assertYjsEncodable -> diff. We stub the - // three network methods so the REAL sandbox runs offline. getPageRaw returns - // a minimal current doc; the transform operates on a structuredClone of it. - function stubbedClient(currentDoc: any = { type: 'doc', content: [{ type: 'paragraph' }] }): any { - const c = makeClient(); - c.ensureAuthenticated = async () => {}; - c.listComments = async () => []; - c.getPageRaw = async () => ({ content: currentDoc }); - return c; - } - - it('runs a transform that returns the doc and reports pushed:false in dryRun', async () => { - const c = stubbedClient(); - const res = await c.transformPage('pid', '(doc, ctx) => doc', { dryRun: true }); - expect(res.pushed).toBe(false); - expect(res).toHaveProperty('diff'); - expect(Array.isArray(res.log)).toBe(true); - }); - - it('runs a transform that mutates the doc and the change shows in the diff', async () => { - const c = stubbedClient({ type: 'doc', content: [{ type: 'paragraph' }] }); - const transform = `(doc) => { doc.content.push({ type: 'paragraph', content: [{ type: 'text', text: 'added' }] }); return doc; }`; - const res = await c.transformPage('pid', transform, { dryRun: true }); - expect(res.pushed).toBe(false); - // diffDocs returns a non-empty diff when content changed. - expect(res.diff).toBeTruthy(); - }); - - it('captures console.log output into the returned log array', async () => { - const c = stubbedClient(); - const res = await c.transformPage('pid', `(doc) => { console.log('hello', 1); return doc; }`, { dryRun: true }); - expect(res.log).toContain('hello 1'); - }); - - it('sandbox has NO access to require/process/module/fs (they are undefined)', async () => { - const c = stubbedClient(); - // The transform asserts these globals are undefined INSIDE the sandbox and - // throws if any leaked. If the transform completes, none leaked. - const transform = `(doc) => { - if (typeof require !== 'undefined') throw new Error('require leaked'); - if (typeof process !== 'undefined') throw new Error('process leaked'); - if (typeof module !== 'undefined') throw new Error('module leaked'); - if (typeof global !== 'undefined') throw new Error('global leaked'); - return doc; - }`; - await expect(c.transformPage('pid', transform, { dryRun: true })).resolves.toMatchObject({ pushed: false }); - }); - - it('a transform that tries to require("fs") throws (no module loader in the sandbox)', async () => { - const c = stubbedClient(); - const transform = `(doc) => { const fs = require('fs'); return doc; }`; - // require is not defined in the new context -> ReferenceError at run time. - await expect(c.transformPage('pid', transform, { dryRun: true })).rejects.toThrow(); - }); - - it('throws when the transform does not evaluate to a function', async () => { - const c = stubbedClient(); - await expect(c.transformPage('pid', '42', { dryRun: true })) - .rejects.toThrow(/must evaluate to a function/); - }); - - it('throws when the transform returns a non-doc value', async () => { - const c = stubbedClient(); - await expect(c.transformPage('pid', '(doc) => ({ type: "paragraph" })', { dryRun: true })) - .rejects.toThrow(/must return a ProseMirror doc node/); - await expect(c.transformPage('pid', '(doc) => null', { dryRun: true })) - .rejects.toThrow(/must return a ProseMirror doc node/); - }); - - it('throws on a transform that does not compile', async () => { - const c = stubbedClient(); - await expect(c.transformPage('pid', '(doc) => {{{', { dryRun: true })) - .rejects.toThrow(/did not compile/); - }); - - it('invokes validateDocStructure on the result (malformed node rejected)', async () => { - const c = stubbedClient(); - // Return a doc whose child has a non-string type -> validateDocStructure throws. - const transform = `(doc) => ({ type: 'doc', content: [{ type: 123 }] })`; - await expect(c.transformPage('pid', transform, { dryRun: true })) - .rejects.toThrow(/string `type`/); - }); - - it('invokes validateDocUrls on the result (unsafe href rejected)', async () => { - const c = stubbedClient(); - const transform = `(doc) => ({ type: 'doc', content: [ - { type: 'paragraph', content: [ - { type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }] } - ] } - ] })`; - await expect(c.transformPage('pid', transform, { dryRun: true })) - .rejects.toThrow(/unsafe link href rejected/); - }); - - it('enforces a vm wall-clock timeout (option present in source)', () => { - // Asserting the option rather than waiting 5s: the source sets - // { timeout: 5000 } on both runInNewContext calls. We verify the timeout - // literal exists in the compiled source so an accidental removal is caught. - // (Reading the source keeps this fast and avoids a real 5s busy loop.) - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require('node:fs'); - const url = require('node:url'); - const path = require('node:path'); - const here = path.dirname(url.fileURLToPath(import.meta.url)); - const src = fs.readFileSync(path.join(here, '..', 'packages', 'docmost-client', 'src', 'client.ts'), 'utf8'); - expect(src).toMatch(/timeout:\s*5000/); - }); -}); diff --git a/test/client-rest.test.ts b/test/client-rest.test.ts index 8002c07..8badf7c 100644 --- a/test/client-rest.test.ts +++ b/test/client-rest.test.ts @@ -715,3 +715,399 @@ describe('auth: getCollabTokenWithReauth', () => { expect(login.count).toBe(1); // only the initial ensureAuthenticated login }); }); + +// =========================================================================== +// REST-binding tests (test-strategy report §2 client-core "Integration add"). +// +// These exercise the request body / endpoint shape the client SENDS for the +// methods the report flags individually (listRecentSince, movePage, the SPEC §8 +// deletion-mirroring trio deletePage/restorePage/listTrash, getPage subpages, +// getPageJson defaults) plus the validateDocUrls unit cases. They deliberately +// do NOT re-cover the skip-list: the envelope (data?.data ?? data) and +// pagination are asserted ONCE above (paginateAll/search/listComments), the +// 401-reauth surface is above, and the security validators isSafeUrl / +// validateDocStructure / transformPage are covered in client-pure.test.ts. +// +// Seam: rather than mocking login, pre-authenticate by setting the private +// token + default Authorization header so ensureAuthenticated() is a no-op, and +// attach MockAdapter to the client's PRIVATE axios instance. No real network. +// =========================================================================== + +/** + * Build a client that is already authenticated (token + default header set) so + * ensureAuthenticated() short-circuits without ever hitting /auth/login. Mocks + * the client's own private axios instance and tracks it for afterEach cleanup. + */ +function preauthedClient(): { client: DocmostClient; mock: MockAdapter } { + const client = new DocmostClient(BASE_URL, 'e', 'p'); + (client as any).token = 'test-token'; + (client as any).client.defaults.headers.common['Authorization'] = + 'Bearer test-token'; + const mock = instanceMock(client); + return { client, mock }; +} + +// --------------------------------------------------------------------------- +// listRecentSince — REST binding to /pages/recent (report §2 client-core). +// The cursor walk itself lives in the pure collectRecentSince (covered in +// recent-since*.test.ts); here we assert ONLY the HTTP body the fetchPage +// closure sends and that the envelope is unwrapped correctly. +// --------------------------------------------------------------------------- +describe('listRecentSince (REST binding)', () => { + it('posts to /pages/recent with limit:100 and OMITS spaceId when undefined', async () => { + const { client, mock } = preauthedClient(); + + let body: any; + mock.onPost('/pages/recent').reply((config) => { + body = JSON.parse(config.data); + // Single short page, all newer than the cutoff -> no second fetch. + return [ + 200, + { data: { items: [{ id: 'r1', updatedAt: '2026-06-16T12:00:00.000Z' }], meta: { nextCursor: null } } }, + ]; + }); + + const out = await client.listRecentSince(undefined, '2026-06-16T00:00:00.000Z'); + expect(out.map((i: any) => i.id)).toEqual(['r1']); + expect(body.limit).toBe(100); + expect(body).not.toHaveProperty('spaceId'); // omitted when undefined + expect(body).not.toHaveProperty('cursor'); // omitted on the first page + }); + + it('INCLUDES spaceId in the body when one is given', async () => { + const { client, mock } = preauthedClient(); + + let body: any; + mock.onPost('/pages/recent').reply((config) => { + body = JSON.parse(config.data); + return [200, { data: { items: [], meta: { nextCursor: null } } }]; + }); + + await client.listRecentSince('space-7', '2026-06-16T00:00:00.000Z'); + expect(body.spaceId).toBe('space-7'); + expect(body.limit).toBe(100); + }); + + it('threads cursor across pages and unwraps both the data.data and meta.nextCursor envelope', async () => { + const { client, mock } = preauthedClient(); + + const seenCursors: (string | undefined)[] = []; + mock.onPost('/pages/recent').reply((config) => { + const body = JSON.parse(config.data); + seenCursors.push(body.cursor); + if (!body.cursor) { + // Page 1: a full page of newer-than-cutoff items + a nextCursor. + return [ + 200, + { + data: { + items: [ + { id: 'a', updatedAt: '2026-06-16T12:00:00.000Z' }, + { id: 'b', updatedAt: '2026-06-16T11:00:00.000Z' }, + ], + meta: { nextCursor: 'CUR2' }, + }, + }, + ]; + } + // Page 2 (cursor threaded): one more newer item, no further cursor. + return [ + 200, + { data: { items: [{ id: 'c', updatedAt: '2026-06-16T10:00:00.000Z' }], meta: { nextCursor: null } } }, + ]; + }); + + const out = await client.listRecentSince('space-1', '2026-06-16T00:00:00.000Z'); + expect(out.map((i: any) => i.id)).toEqual(['a', 'b', 'c']); + // First page sends no cursor; the second carries the server's nextCursor. + expect(seenCursors).toEqual([undefined, 'CUR2']); + }); + + it('STOPS at the first item at/below the cutoff (descending scan) and skips no-id pagination', async () => { + const { client, mock } = preauthedClient(); + const since = '2026-06-16T10:00:00.000Z'; + + let calls = 0; + mock.onPost('/pages/recent').reply(() => { + calls++; + return [ + 200, + { + data: { + items: [ + { id: 'newer', updatedAt: '2026-06-16T11:00:00.000Z' }, // > cutoff -> kept + { id: 'equal', updatedAt: since }, // <= cutoff -> stop here (boundary excluded) + { id: 'older', updatedAt: '2026-06-16T09:00:00.000Z' }, // never reached + ], + meta: { nextCursor: 'NEVER-USED' }, + }, + }, + ]; + }); + + const out = await client.listRecentSince('space-1', since); + expect(out.map((i: any) => i.id)).toEqual(['newer']); + // Hitting the cutoff terminates the walk even though a nextCursor exists. + expect(calls).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// movePage — REST binding to /pages/move (report §2 client-core). +// --------------------------------------------------------------------------- +describe('movePage (REST binding)', () => { + it('uses the default position "a00000" when none is supplied', async () => { + const { client, mock } = preauthedClient(); + + let body: any; + let url: string | undefined; + mock.onPost('/pages/move').reply((config) => { + body = JSON.parse(config.data); + url = config.url; + return [200, { success: true }]; + }); + + await client.movePage('page-1', 'parent-9'); + expect(url).toBe('/pages/move'); + expect(body).toEqual({ pageId: 'page-1', parentPageId: 'parent-9', position: 'a00000' }); + }); + + it('passes an explicit position through and accepts parentPageId:null (move to root)', async () => { + const { client, mock } = preauthedClient(); + + let body: any; + mock.onPost('/pages/move').reply((config) => { + body = JSON.parse(config.data); + return [200, { success: true }]; + }); + + await client.movePage('page-1', null, 'b50000'); + expect(body.position).toBe('b50000'); + expect(body.parentPageId).toBeNull(); // null is preserved, not coerced/dropped + expect(body.pageId).toBe('page-1'); + }); +}); + +// --------------------------------------------------------------------------- +// ⭐ deletePage / restorePage / listTrash — the SPEC §8 / §16 deletion-mirroring +// path. The body key is `pageId` (NOT `id`); trash is PER-SPACE (sends spaceId) +// and paginates. Assert the request bodies precisely. +// --------------------------------------------------------------------------- +describe('deletePage / restorePage / listTrash (SPEC §8 deletion mirroring)', () => { + it('deletePage posts to /pages/delete with body { pageId } (NOT id, no permanentlyDelete)', async () => { + const { client, mock } = preauthedClient(); + + let body: any; + let url: string | undefined; + mock.onPost('/pages/delete').reply((config) => { + url = config.url; + body = JSON.parse(config.data); + return [200, { success: true }]; + }); + + await client.deletePage('page-42'); + expect(url).toBe('/pages/delete'); + expect(body).toEqual({ pageId: 'page-42' }); // exact body: pageId only + expect(body).not.toHaveProperty('id'); + // Soft-delete by default: the destructive permanentlyDelete flag is never sent. + expect(body).not.toHaveProperty('permanentlyDelete'); + }); + + it('restorePage posts to /pages/restore with body { pageId } (NOT id)', async () => { + const { client, mock } = preauthedClient(); + + let body: any; + let url: string | undefined; + mock.onPost('/pages/restore').reply((config) => { + url = config.url; + body = JSON.parse(config.data); + return [200, { success: true }]; + }); + + await client.restorePage('page-7'); + expect(url).toBe('/pages/restore'); + expect(body).toEqual({ pageId: 'page-7' }); + expect(body).not.toHaveProperty('id'); + }); + + it('listTrash posts to /pages/trash PER-SPACE (sends spaceId) and paginates', async () => { + const { client, mock } = preauthedClient(); + + const bodies: any[] = []; + mock.onPost('/pages/trash').reply((config) => { + const body = JSON.parse(config.data); + bodies.push(body); + if (body.page === 1) { + // A full page (limit 100) with hasNextPage:true forces a second fetch. + const items = Array.from({ length: 100 }, (_, i) => ({ id: 't' + i, deletedAt: 'd', spaceId: 'space-3' })); + return [200, { data: { items, meta: { hasNextPage: true } } }]; + } + return [200, { data: { items: [{ id: 'last', deletedAt: 'd', spaceId: 'space-3' }], meta: { hasNextPage: false } } }]; + }); + + const trash = await client.listTrash('space-3'); + expect(trash).toHaveLength(101); + // Every request carried the per-space scope; no workspace-wide variant. + expect(bodies.every((b) => b.spaceId === 'space-3')).toBe(true); + // Paginated: page 1 then page 2. + expect(bodies.map((b) => b.page)).toEqual([1, 2]); + }); +}); + +// --------------------------------------------------------------------------- +// getPage — subpages fetch degrades gracefully; {{SUBPAGES}} placeholder +// resolution (report §2 client-core). +// --------------------------------------------------------------------------- +describe('getPage (subpages binding)', () => { + it('degrades gracefully when the subpages fetch fails (warn -> [] subpages, page still returned)', async () => { + const { client, mock } = preauthedClient(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + // /pages/info returns a page whose body has NO {{SUBPAGES}} placeholder. + mock.onPost('/pages/info').reply(200, { + data: { + id: 'p1', + slugId: 'slug-1', + title: 'Page 1', + spaceId: 'space-1', + content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body text only' }] }] }, + }, + }); + // The subpages enumeration (/pages/sidebar-pages) blows up. + mock.onPost('/pages/sidebar-pages').reply(500, {}); + + const res = await client.getPage('p1'); + expect(res.success).toBe(true); + expect(res.data.id).toBe('p1'); + expect(res.data.content).toBe('Body text only'); // body survives the subpages failure + // No subpages present -> filterPage omits the field entirely. + expect(res.data).not.toHaveProperty('subpages'); + expect(warn).toHaveBeenCalled(); + }); + + it('resolves the {{SUBPAGES}} placeholder into a list when subpages exist', async () => { + const { client, mock } = preauthedClient(); + + mock.onPost('/pages/info').reply(200, { + data: { + id: 'parent', + slugId: 'slug-parent', + title: 'Parent', + spaceId: 'space-1', + content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '{{SUBPAGES}}' }] }] }, + }, + }); + // Two children for the placeholder to expand into. + mock.onPost('/pages/sidebar-pages').reply(200, { + data: { + items: [ + { id: 'kid-1', title: 'Kid One', hasChildren: false }, + { id: 'kid-2', title: 'Kid Two', hasChildren: false }, + ], + }, + }); + + const res = await client.getPage('parent'); + // {{SUBPAGES}} expanded into a "### Subpages" markdown list. + expect(res.data.content).toContain('### Subpages'); + expect(res.data.content).toContain('- [Kid One](page:kid-1)'); + expect(res.data.content).toContain('- [Kid Two](page:kid-2)'); + expect(res.data.content).not.toContain('{{SUBPAGES}}'); + // filterPage surfaces the subpages as {id,title} pairs. + expect(res.data.subpages).toEqual([ + { id: 'kid-1', title: 'Kid One' }, + { id: 'kid-2', title: 'Kid Two' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// getPageJson — default content when the response omits it (report §2). +// --------------------------------------------------------------------------- +describe('getPageJson (default content)', () => { + it('defaults content to an empty doc when /pages/info returns none', async () => { + const { client, mock } = preauthedClient(); + + mock.onPost('/pages/info').reply(200, { + data: { + id: 'p1', + slugId: 'slug-1', + title: 'No Content Page', + parentPageId: null, + spaceId: 'space-1', + updatedAt: '2026-06-16T00:00:00.000Z', + // no `content` field + }, + }); + + const json = await client.getPageJson('p1'); + expect(json.content).toEqual({ type: 'doc', content: [] }); + expect(json.id).toBe('p1'); + expect(json.title).toBe('No Content Page'); + }); + + it('passes an existing content doc through untouched', async () => { + const { client, mock } = preauthedClient(); + + const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }] }; + mock.onPost('/pages/info').reply(200, { + data: { id: 'p2', slugId: 's2', title: 'T', parentPageId: 'par', spaceId: 'sp', updatedAt: 'u', content: doc }, + }); + + const json = await client.getPageJson('p2'); + expect(json.content).toEqual(doc); + }); +}); + +// --------------------------------------------------------------------------- +// validateDocUrls — bare/edge node-shape tolerance (report §2 client-core +// "Unit add"). Reached directly via the private validator (a pure, synchronous, +// network-free guard); no mock needed. The XSS allowlist of isSafeUrl AND the +// unsafe-href/src rejection are already covered in client-pure.test.ts and are +// NOT re-tested here — these cases target validateDocUrls' node-shape +// robustness (missing attrs / empty href / null mark) specifically. +// --------------------------------------------------------------------------- +describe('validateDocUrls (bare/edge nodes)', () => { + // A plain instance is enough; validateDocUrls never touches the network. + function validator() { + const client = new DocmostClient(BASE_URL, 'e', 'p'); + return (doc: any) => (client as any).validateDocUrls(doc); + } + + it('does NOT throw (no NPE) when a link mark has no attrs object at all', () => { + // The `mark.attrs` guard short-circuits before isSafeUrl, so a link mark + // without an attrs object is a no-op rather than a null-deref crash. + const check = validator(); + const doc = { + type: 'doc', + content: [{ type: 'text', text: 'x', marks: [{ type: 'link' }] }], // no attrs key + }; + expect(() => check(doc)).not.toThrow(); + }); + + it('does NOT throw when a link mark href is an empty string (safe-but-bare value)', () => { + // isSafeUrl treats a trimmed-empty href as harmless, so a bare empty href + // passes without being falsely rejected. + const check = validator(); + const doc = { + type: 'doc', + content: [{ type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: '' } }] }], + }; + expect(() => check(doc)).not.toThrow(); + }); + + it('does NOT throw on a media node with no attrs at all', () => { + const check = validator(); + const doc = { type: 'doc', content: [{ type: 'image' }] }; // no attrs object + expect(() => check(doc)).not.toThrow(); + }); + + it('tolerates a null entry inside the marks array', () => { + const check = validator(); + const doc = { + type: 'doc', + content: [{ type: 'text', text: 'x', marks: [null, { type: 'bold' }] }], + }; + expect(() => check(doc)).not.toThrow(); + }); +}); diff --git a/test/transforms-extra.test.ts b/test/transforms-extra.test.ts deleted file mode 100644 index c77d5dd..0000000 Binary files a/test/transforms-extra.test.ts and /dev/null differ diff --git a/test/transforms.test.ts b/test/transforms.test.ts deleted file mode 100644 index 1b91462..0000000 --- a/test/transforms.test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - walk, - getList, - insertMarkerAfter, - setCalloutRange, - noteItem, - mdToInlineNodes, - commentsToFootnotes, -} from '../packages/docmost-client/src/lib/transforms.js'; - -// --------------------------------------------------------------------------- -// Small inline fixture builders. A ProseMirror node is a plain JSON object of -// shape { type, attrs?, content?, text?, marks? }. -// --------------------------------------------------------------------------- - -/** A plain text run, optionally with marks. */ -function text(t: string, marks?: any[]): any { - return marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; -} - -/** A paragraph holding the given inline runs. */ -function para(...runs: any[]): any { - return { type: 'paragraph', attrs: { id: 'p' }, content: runs }; -} - -/** A callout holding the given child blocks. */ -function callout(...children: any[]): any { - return { type: 'callout', content: children }; -} - -/** A document with the given top-level blocks. */ -function doc(...blocks: any[]): any { - return { type: 'doc', content: blocks }; -} - -/** - * Recursively strip every `attrs.id` so docs containing freshId()-generated ids - * can be deep-compared structurally. Mutates a clone, returns it. - */ -function stripIds(value: T): T { - const v: any = structuredClone(value); - const recur = (n: any): void => { - if (Array.isArray(n)) { - n.forEach(recur); - return; - } - if (n && typeof n === 'object') { - if (n.attrs && typeof n.attrs === 'object' && 'id' in n.attrs) { - delete n.attrs.id; - } - for (const k of Object.keys(n)) recur(n[k]); - } - }; - recur(v); - return v; -} - -// =========================================================================== -describe('walk', () => { - it('is a no-op for a nullish or non-object root', () => { - const seen: any[] = []; - walk(null, (n) => seen.push(n)); - walk(undefined, (n) => seen.push(n)); - walk('string', (n) => seen.push(n)); - walk(42, (n) => seen.push(n)); - walk([1, 2, 3], (n) => seen.push(n)); // array is not an object root - expect(seen).toEqual([]); - }); - - it('visits the root itself and all nested nodes (callout/table/list)', () => { - const tree = { - type: 'doc', - content: [ - callout(para(text('a'))), - { - type: 'table', - content: [ - { type: 'tableRow', content: [{ type: 'tableCell', content: [para(text('b'))] }] }, - ], - }, - { - type: 'orderedList', - content: [{ type: 'listItem', content: [para(text('c'))] }], - }, - ], - }; - const types: string[] = []; - walk(tree, (n) => types.push(n.type)); - // Root first, then DFS into every nested container. - expect(types[0]).toBe('doc'); - expect(types).toContain('callout'); - expect(types).toContain('table'); - expect(types).toContain('tableRow'); - expect(types).toContain('tableCell'); - expect(types).toContain('orderedList'); - expect(types).toContain('listItem'); - expect(types).toContain('paragraph'); - expect(types).toContain('text'); - }); - - it('ignores a non-array content field', () => { - const node = { type: 'weird', content: { not: 'an array' } }; - const seen: any[] = []; - walk(node, (n) => seen.push(n)); - // Only the root is visited; the object content is never recursed into. - expect(seen).toEqual([node]); - }); -}); - -// =========================================================================== -describe('getList', () => { - it('returns the FIRST match in depth-first order', () => { - const first = { type: 'orderedList', attrs: { id: 'L1' }, content: [] }; - const second = { type: 'orderedList', attrs: { id: 'L2' }, content: [] }; - const tree = doc(callout(first), second); - const found = getList(tree, (n) => n.type === 'orderedList'); - expect(found).toBe(first); // DFS reaches the callout's child before the sibling - expect(found.attrs.id).toBe('L1'); - }); - - it('returns null when nothing matches', () => { - const tree = doc(para(text('x'))); - expect(getList(tree, (n) => n.type === 'orderedList')).toBeNull(); - }); - - it('returns a LIVE reference, not a clone', () => { - const list = { type: 'orderedList', content: [] }; - const tree = doc(list); - const found = getList(tree, (n) => n.type === 'orderedList'); - expect(found).toBe(list); // same object identity - found.marker = 'mutated'; - expect(list.marker).toBe('mutated'); // mutation visible on the original - }); - - it('matches a node lacking attrs.id', () => { - const noId = { type: 'orderedList', content: [] }; // no attrs at all - const tree = doc(para(text('x')), noId); - const found = getList(tree, (n) => n.type === 'orderedList'); - expect(found).toBe(noId); - }); -}); - -// =========================================================================== -describe('insertMarkerAfter', () => { - it('returns inserted:false when the anchor is not found', () => { - const d = doc(para(text('hello world'))); - const r = insertMarkerAfter(d, 'absent text', '[1]'); - expect(r.inserted).toBe(false); - // Returned doc is a clone of the unchanged input. - expect(r.doc).toEqual(d); - expect(r.doc).not.toBe(d); - }); - - it('inserts a plain marker run after the anchor in a single text run', () => { - const d = doc(para(text('see here for details'))); - const r = insertMarkerAfter(d, 'see here', '[1]'); - expect(r.inserted).toBe(true); - expect(r.doc.content[0].content).toEqual([ - { type: 'text', text: 'see here', marks: [] }, - { type: 'text', text: ' [1]' }, - { type: 'text', text: ' for details', marks: [] }, - ]); - }); - - it('preserves marks across runs and emits a PLAIN marker, no empty runs', () => { - // Anchor "foo bar" spans a plain run "foo " and a bold run "bar baz". - const d = doc( - para( - text('foo '), - text('bar baz', [{ type: 'bold' }]), - ), - ); - const r = insertMarkerAfter(d, 'foo bar', '[1]'); - expect(r.inserted).toBe(true); - // The bold run "bar baz" is split at the anchor end (after "bar"); the - // leading "foo " run is untouched, the marker is plain, surrounding marks - // are preserved verbatim, and no empty text run is emitted. - expect(r.doc.content[0].content).toEqual([ - { type: 'text', text: 'foo ' }, - { type: 'text', text: 'bar', marks: [{ type: 'bold' }] }, - { type: 'text', text: ' [1]' }, - { type: 'text', text: ' baz', marks: [{ type: 'bold' }] }, - ]); - }); - - it('splits exactly at a run boundary without emitting an empty run', () => { - // Anchor ends exactly at the end of the first run "alpha". - const d = doc(para(text('alpha'), text('beta'))); - const r = insertMarkerAfter(d, 'alpha', '[1]'); - expect(r.inserted).toBe(true); - // "before" == whole first run, "after" is empty -> no empty run pushed. - expect(r.doc.content[0].content).toEqual([ - { type: 'text', text: 'alpha', marks: [] }, - { type: 'text', text: ' [1]' }, - { type: 'text', text: 'beta' }, - ]); - }); - - it('beforeBlock scope excludes blocks at/after the boundary', () => { - const d = doc( - para(text('body anchor')), // index 0 (in scope when beforeBlock=1) - para(text('notes anchor')), // index 1 (out of scope) - ); - // Anchor only exists in the out-of-scope block -> not inserted. - const r = insertMarkerAfter(d, 'notes anchor', '[1]', { beforeBlock: 1 }); - expect(r.inserted).toBe(false); - // The in-scope anchor still inserts when limited. - const r2 = insertMarkerAfter(d, 'body anchor', '[1]', { beforeBlock: 1 }); - expect(r2.inserted).toBe(true); - }); - - it('does not mutate the input document', () => { - const d = doc(para(text('keep me intact please'))); - const snapshot = structuredClone(d); - insertMarkerAfter(d, 'keep me', '[1]'); - expect(d).toEqual(snapshot); - }); - - it('returns inserted:false for an empty anchor', () => { - const d = doc(para(text('anything'))); - const r = insertMarkerAfter(d, '', '[1]'); - expect(r.inserted).toBe(false); - expect(r.doc).toEqual(d); - }); -}); - -// =========================================================================== -describe('setCalloutRange', () => { - it('rewrites a Unicode-ellipsis [1]…[K] range inside a callout', () => { - const d = doc(callout(para(text('Footnotes [1]…[5] follow')))); - const r = setCalloutRange(d, 7); - expect(r.changed).toBe(1); - expect(r.doc.content[0].content[0].content[0].text).toBe('Footnotes [1]…[7] follow'); - }); - - it('rewrites an ASCII-ellipsis [1]...[K] range inside a callout', () => { - const d = doc(callout(para(text('range [1]...[3] here')))); - const r = setCalloutRange(d, 9); - expect(r.changed).toBe(1); - expect(r.doc.content[0].content[0].content[0].text).toBe('range [1]...[9] here'); - }); - - it('leaves a paragraph [1]…[K] (outside any callout) untouched', () => { - const d = doc(para(text('not a callout [1]…[5]'))); - const r = setCalloutRange(d, 9); - expect(r.changed).toBe(0); - expect(r.doc.content[0].content[0].text).toBe('not a callout [1]…[5]'); - }); - - it('rewrites across multiple callouts and reports the changed count', () => { - const d = doc( - callout(para(text('a [1]…[2] b'))), - para(text('skip [1]…[2]')), - callout(para(text('c [1]...[4] d'))), - ); - const r = setCalloutRange(d, 10); - expect(r.changed).toBe(2); - expect(r.doc.content[0].content[0].content[0].text).toBe('a [1]…[10] b'); - expect(r.doc.content[2].content[0].content[0].text).toBe('c [1]...[10] d'); - }); - - it('reports changed:0 when no range matches', () => { - const d = doc(callout(para(text('no range here')))); - const r = setCalloutRange(d, 4); - expect(r.changed).toBe(0); - }); - - it('does not mutate the input document', () => { - const d = doc(callout(para(text('x [1]…[5] y')))); - const snapshot = structuredClone(d); - setCalloutRange(d, 99); - expect(d).toEqual(snapshot); - }); - - it('handles TWO matching text nodes in one callout (regex lastIndex reset)', () => { - // Two separate text nodes, each carrying a range, inside one callout. - const d = doc( - callout( - para(text('first [1]…[2]')), - para(text('second [1]…[3]')), - ), - ); - const r = setCalloutRange(d, 6); - expect(r.changed).toBe(2); - expect(r.doc.content[0].content[0].content[0].text).toBe('first [1]…[6]'); - expect(r.doc.content[0].content[1].content[0].text).toBe('second [1]…[6]'); - }); -}); - -// =========================================================================== -describe('noteItem', () => { - it('wraps inline nodes in listItem > paragraph', () => { - const inline = [text('hello')]; - const item = noteItem(inline); - expect(item.type).toBe('listItem'); - expect(item.content).toHaveLength(1); - const p = item.content[0]; - expect(p.type).toBe('paragraph'); - expect(p.content).toEqual([text('hello')]); - // The paragraph carries a string id from Math.random()-based freshId(). - expect(typeof p.attrs.id).toBe('string'); - expect(p.attrs.id.length).toBeGreaterThan(0); - }); - - it('produces empty content for non-array input', () => { - expect(noteItem(undefined as any).content[0].content).toEqual([]); - expect(noteItem(null as any).content[0].content).toEqual([]); - expect(noteItem('nope' as any).content[0].content).toEqual([]); - }); - - it('clones the input so the result shares no references', () => { - const inline = [text('mutable')]; - const item = noteItem(inline); - inline[0].text = 'changed'; - expect(item.content[0].content[0].text).toBe('mutable'); // unaffected - expect(item.content[0].content[0]).not.toBe(inline[0]); - }); - - it('matches the expected structure (ignoring the random id)', () => { - const item = noteItem([text('body', [{ type: 'bold' }])]); - expect(stripIds(item)).toEqual({ - type: 'listItem', - content: [ - { - type: 'paragraph', - attrs: {}, - content: [{ type: 'text', text: 'body', marks: [{ type: 'bold' }] }], - }, - ], - }); - }); -}); - -// =========================================================================== -describe('mdToInlineNodes', () => { - it('returns [] for empty or non-string input', () => { - expect(mdToInlineNodes('')).toEqual([]); - expect(mdToInlineNodes(' ')).toEqual([]); - expect(mdToInlineNodes(undefined as any)).toEqual([]); - expect(mdToInlineNodes(null as any)).toEqual([]); - expect(mdToInlineNodes(123 as any)).toEqual([]); - }); - - it('strips a case-insensitive "комментарий:" prefix', () => { - expect(mdToInlineNodes('Комментарий: hello')).toEqual([{ type: 'text', text: 'hello' }]); - expect(mdToInlineNodes('комментарий : hi')).toEqual([{ type: 'text', text: 'hi' }]); - }); - - it('strips a leading "N. " numeric prefix', () => { - expect(mdToInlineNodes('3. some note')).toEqual([{ type: 'text', text: 'some note' }]); - }); - - it('turns a leading **bold lead** into a bold node + plain remainder, space preserved', () => { - const nodes = mdToInlineNodes('**Lead** rest of text'); - expect(nodes).toEqual([ - { type: 'text', text: 'Lead', marks: [{ type: 'bold' }] }, - { type: 'text', text: ' rest of text' }, // separating space preserved - ]); - }); - - it('splits an inline **bold** mid-text', () => { - const nodes = mdToInlineNodes('start **mid** end'); - expect(nodes).toEqual([ - { type: 'text', text: 'start ' }, - { type: 'text', text: 'mid', marks: [{ type: 'bold' }] }, - { type: 'text', text: ' end' }, - ]); - }); - - it('passes plain text through unchanged when there is no bold', () => { - expect(mdToInlineNodes('just plain text')).toEqual([{ type: 'text', text: 'just plain text' }]); - }); - - it('handles a bold-only string', () => { - // A bold-only string is treated as a leading bold lead with empty remainder. - expect(mdToInlineNodes('**only**')).toEqual([ - { type: 'text', text: 'only', marks: [{ type: 'bold' }] }, - ]); - }); -}); - -// =========================================================================== -describe('commentsToFootnotes', () => { - const HEADING = 'Примечания переводчика'; - - /** - * Build a realistic doc: body paragraphs, then the notes heading, then the - * notes orderedList. `notes` is an array of inline-text strings for existing - * list items. - */ - function buildDoc(opts: { - body: any[]; - notes?: string[]; - omitHeading?: boolean; - omitList?: boolean; - disclaimer?: any; - }): any { - const blocks: any[] = []; - if (opts.disclaimer) blocks.push(opts.disclaimer); - blocks.push(...opts.body); - if (!opts.omitHeading) { - blocks.push({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] }); - } - if (!opts.omitList) { - const items = (opts.notes ?? []).map((t, i) => ({ - type: 'listItem', - attrs: { id: `li${i}` }, - content: [para(text(t))], - })); - blocks.push({ type: 'orderedList', attrs: { id: 'ol' }, content: items }); - } - return doc(...blocks); - } - - function findNotesList(d: any): any { - return d.content.find((n: any) => n.type === 'orderedList'); - } - - it('is identity (renumber pass only) when there are zero comments', () => { - const d = buildDoc({ body: [para(text('plain body'))], notes: [] }); - const r = commentsToFootnotes(d, []); - expect(r.consumed).toEqual([]); - expect(r.doc.content[0]).toEqual(para(text('plain body'))); - }); - - it('inserts a marker and appends one note for one comment with a selection', () => { - const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] }); - const r = commentsToFootnotes(d, [ - { id: 'c1', content: 'A note', selection: 'quick brown' }, - ]); - expect(r.consumed).toEqual(['c1']); - // Body now carries "[1]" right after the selection. - const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); - expect(bodyText).toBe('the quick brown [1] fox'); - // The notes list holds exactly one note built from the comment content. - const list = findNotesList(r.doc); - expect(list.content).toHaveLength(1); - expect(stripIds(list.content[0])).toEqual( - stripIds(noteItem(mdToInlineNodes('A note'))), - ); - }); - - it('numbers many comments by BODY reading order, not comment-array order', () => { - // Body order: "alpha" then "omega". Comments are given out of order. - const d = buildDoc({ - body: [para(text('alpha then omega here'))], - notes: [], - }); - const r = commentsToFootnotes(d, [ - { id: 'cOmega', content: 'note for omega', selection: 'omega' }, - { id: 'cAlpha', content: 'note for alpha', selection: 'alpha' }, - ]); - // Both consumed, in comment-array processing order (NOT reading order, NOT sorted). - expect(r.consumed).toEqual(['cOmega', 'cAlpha']); - const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); - // "alpha" precedes "omega" in reading order => [1] then [2]. - expect(bodyText).toBe('alpha [1] then omega [2] here'); - const list = findNotesList(r.doc); - // Note list reordered to reading order: alpha-note first, omega-note second. - expect(list.content[0].content[0].content[0].text).toBe('note for alpha'); - expect(list.content[1].content[0].content[0].text).toBe('note for omega'); - }); - - it('skips a comment with no selection without consuming it', () => { - const d = buildDoc({ body: [para(text('body text here'))], notes: [] }); - const r = commentsToFootnotes(d, [ - { id: 'c1', content: 'no anchor', selection: null }, - { id: 'c2', content: 'anchored', selection: 'body text' }, - ]); - expect(r.consumed).toEqual(['c2']); - const list = findNotesList(r.doc); - expect(list.content).toHaveLength(1); - }); - - it('skips a comment whose selection is absent (no orphan note)', () => { - const d = buildDoc({ body: [para(text('present text'))], notes: [] }); - const r = commentsToFootnotes(d, [ - { id: 'c1', content: 'orphan', selection: 'this string is not in the body' }, - ]); - expect(r.consumed).toEqual([]); // nothing anchored - const list = findNotesList(r.doc); - expect(list.content).toHaveLength(0); // no orphan note appended - const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); - expect(bodyText).toBe('present text'); // body unchanged - }); - - it('renumbers existing [N] markers mixed with new placeholders by reading order', () => { - // Body already has an existing "[1]" marker after "first"; a new comment - // anchors before it in reading order at "intro". - const d = buildDoc({ - body: [para(text('intro and first [1] then more'))], - notes: ['existing note one'], - }); - const r = commentsToFootnotes(d, [ - { id: 'cNew', content: 'fresh note', selection: 'intro' }, - ]); - expect(r.consumed).toEqual(['cNew']); - const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); - // Reading order: "intro" placeholder -> [1]; existing "[1]" -> [2]. - expect(bodyText).toBe('intro [1] and first [2] then more'); - const list = findNotesList(r.doc); - // Notes reordered: the new note (for "intro") first, the existing note second. - expect(list.content).toHaveLength(2); - expect(list.content[0].content[0].content[0].text).toBe('fresh note'); - expect(list.content[1].content[0].content[0].text).toBe('existing note one'); - }); - - it('throws "document is inconsistent" when a body [N] has no matching note', () => { - // Body references [9] but the notes list has only 3 items. - const d = buildDoc({ - body: [para(text('see footnote [9] here'))], - notes: ['n1', 'n2', 'n3'], - }); - expect(() => commentsToFootnotes(d, [])).toThrow(/document is inconsistent/); - }); - - it('throws when the notes heading is missing', () => { - const d = buildDoc({ body: [para(text('x'))], notes: [], omitHeading: true }); - expect(() => commentsToFootnotes(d, [])).toThrow(/not found/); - }); - - it('throws when the notes orderedList is missing', () => { - const d = buildDoc({ body: [para(text('x'))], omitList: true }); - expect(() => commentsToFootnotes(d, [])).toThrow(/orderedList not found/); - }); - - it('does not mutate the input document', () => { - const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] }); - const snapshot = structuredClone(d); - commentsToFootnotes(d, [{ id: 'c1', content: 'A note', selection: 'quick brown' }]); - expect(d).toEqual(snapshot); - }); - - it('does not renumber a top-level disclaimer callout but syncs its range', () => { - // A disclaimer callout carries "[1]…[K]"; it must be preserved (not consumed - // as a footnote marker) and its range synced to the final note count. - const disclaimer = callout(para(text('Notes range [1]…[1] applies'))); - const d = buildDoc({ - body: [para(text('alpha and beta here'))], - notes: [], - disclaimer, - }); - const r = commentsToFootnotes(d, [ - { id: 'c1', content: 'note a', selection: 'alpha' }, - { id: 'c2', content: 'note b', selection: 'beta' }, - ]); - expect(r.consumed.sort()).toEqual(['c1', 'c2']); - // Disclaimer callout is at index 0; its body must NOT have been renumbered - // into [1][2], it remains a "[1]…[n]" range synced to 2 notes. - const calloutText = r.doc.content[0].content[0].content - .map((n: any) => n.text) - .join(''); - expect(calloutText).toBe('Notes range [1]…[2] applies'); - // Body markers (index 1) are the real footnotes. - const bodyText = r.doc.content[1].content.map((n: any) => n.text).join(''); - expect(bodyText).toBe('alpha [1] and beta [2] here'); - const list = findNotesList(r.doc); - expect(list.content).toHaveLength(2); - }); -});