diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 40a079c9..f384f7eb 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -17,7 +17,7 @@ import { resolveCurrentPageResult, type SelectionContext, } from './current-page.util'; -import { parseNodeArg } from './parse-node-arg'; +import { parseNodeArg } from '@docmost/prosemirror-markdown'; import { modelFriendlyInput } from './model-friendly-input'; import { SandboxStore } from '../../../integrations/sandbox/sandbox.store'; import { diff --git a/apps/server/src/core/ai-chat/tools/parse-node-arg.spec.ts b/apps/server/src/core/ai-chat/tools/parse-node-arg.spec.ts index 729c065d..a0bedf57 100644 --- a/apps/server/src/core/ai-chat/tools/parse-node-arg.spec.ts +++ b/apps/server/src/core/ai-chat/tools/parse-node-arg.spec.ts @@ -1,10 +1,10 @@ -import { parseNodeArg } from './parse-node-arg'; +import { parseNodeArg } from '@docmost/prosemirror-markdown'; /** - * Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone - * MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the - * patchNode / insertNode / updatePageJson tool adapters. Behavior must be - * byte-identical: object passthrough, valid-string parse, invalid-string throw. + * Unit tests for the shared `parseNodeArg` helper (#414: now the single copy in + * `@docmost/prosemirror-markdown`, imported by both the server tool adapters and + * `@docmost/mcp`). Used by the patchNode / insertNode / updatePageJson adapters. + * Behavior: object passthrough, valid-string parse, invalid-string throw. */ describe('parseNodeArg', () => { it('passes an object through unchanged', () => { diff --git a/apps/server/src/core/ai-chat/tools/parse-node-arg.ts b/apps/server/src/core/ai-chat/tools/parse-node-arg.ts deleted file mode 100644 index e4495c45..00000000 --- a/apps/server/src/core/ai-chat/tools/parse-node-arg.ts +++ /dev/null @@ -1,26 +0,0 @@ -// The model sometimes serializes a ProseMirror node arg as a JSON string -// instead of an object. Normalize: parse a string to an object (throwing on -// invalid JSON), pass an object through unchanged. Shared by patchNode / -// insertNode (and the analogous updatePageJson content parsing). -// -// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts` -// (the function logic, default/explicit throw messages and branch order match; -// only comments and quote style differ). We cannot import that helper here: -// `@docmost/mcp` is ESM-only and this server -// compiles with module:commonjs, so it is loaded at runtime via the -// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing -// runtime code across that ESM/CJS boundary by a normal import is impossible, -// hence the mirrored copy. -export function parseNodeArg( - node: unknown, - errMsg = 'node was a string but not valid JSON', -): unknown { - if (typeof node === 'string') { - try { - return JSON.parse(node); - } catch { - throw new Error(errMsg); - } - } - return node; -} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index d370a2b6..531e013e 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -46,7 +46,7 @@ import { insertTableRow, deleteTableRow, updateTableCell, -} from "./lib/node-ops.js"; +} from "@docmost/prosemirror-markdown"; import { searchInDoc, SearchOptions } from "./lib/page-search.js"; import { withPageLock } from "./lib/page-lock.js"; import { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 45bb397a..17c4a145 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -4,7 +4,7 @@ import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { DocmostClient, DocmostMcpConfig } from "./client.js"; -import { parseNodeArg } from "./lib/parse-node-arg.js"; +import { parseNodeArg } from "@docmost/prosemirror-markdown"; import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; // Re-export the client and its config type so embedding hosts (e.g. the gitmost diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index c14afbf8..c61d93ee 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -14,7 +14,7 @@ import { JSDOM } from "jsdom"; import { markdownToProseMirror } from "@docmost/prosemirror-markdown"; import { docmostExtensions, docmostSchema } from "./docmost-schema.js"; import { withPageLock } from "./page-lock.js"; -import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; +import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown"; import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; import { summarizeChange, VerifyReport } from "./diff.js"; diff --git a/packages/mcp/src/lib/footnote-analyze.ts b/packages/mcp/src/lib/footnote-analyze.ts index 11178d25..dba92eef 100644 --- a/packages/mcp/src/lib/footnote-analyze.ts +++ b/packages/mcp/src/lib/footnote-analyze.ts @@ -1,136 +1,62 @@ /** - * Legacy footnote diagnostics for imported Markdown (issue #166). + * Legacy footnote advisory for imported Markdown (issue #166, reduced in #414). * - * A PURE, fence-aware text scan (independent of the Markdown->ProseMirror - * conversion path, so it reports the same problems for `create_page`, - * `update_page` and `import_page_markdown`). It never changes the document — the - * importer still creates the page; this only surfaces footnote problems to the - * caller so an agent can fix its own markup instead of shipping broken footnotes. + * Since #293 STEP 5 the canonical import form is inline `^[body]` footnotes + * (handled by `@docmost/prosemirror-markdown`). LEGACY reference-style + * `[^id]: …` definition markup is now INERT on import — the importer leaves it as + * literal text — so authoring it silently produces broken footnotes (the #410 + * incident class). Rather than the old, elaborate diagnostics of every problem + * SHAPE (dangling/duplicate/empty/in-table) that no longer describe what the + * importer builds, this module surfaces ONE advisory warning whenever legacy + * reference-style definition syntax is present, nudging the author to the inline + * form. It never changes the document — the importer still creates the page. * - * SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]` - * footnotes (handled by `@docmost/prosemirror-markdown`), where these problems - * cannot arise. This scan therefore targets the LEGACY reference-style - * (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal - * text). The warnings remain useful as an advisory nudge when an agent still - * authors the old syntax, but they no longer describe what the importer builds. - * - * Detected problems: - * - danglingReferences: a `[^id]` reference with no `[^id]:` definition. - * - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace. - * - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the - * first would have been kept under the old first-wins import). - * - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic: - * the line, trimmed, starts with `|`) — footnotes in table cells often do not - * render as expected. + * The scan is fence-aware: a `[^id]:` line inside a ``` / ~~~ code block is + * example text, not markup, so it never triggers the warning. */ -import { - lexFootnoteLines, - forEachFootnoteReference, -} from "./footnote-lex.js"; +/** A legacy footnote DEFINITION line: `[^id]:` at the start of a (non-fenced) line. */ +const FOOTNOTE_DEF_RE = /^\[\^[^\]\s]+\]:/; +/** Opening/closing code fence marker (``` or ~~~). */ +const FENCE_RE = /^\s*(`{3,}|~{3,})/; -export interface FootnoteDiagnostics { - /** Reference ids (distinct, document order) with no matching definition. */ - danglingReferences: string[]; - /** Definition ids whose first (kept) text is empty/whitespace. */ - emptyDefinitions: string[]; - /** Ids defined by two or more `[^id]:` lines (only the first is kept). */ - duplicateDefinitions: string[]; - /** Reference ids found inside a GFM table row (heuristic). */ - referencesInTables: string[]; - /** Human-readable warning lines for the tool result (one per problem class). */ - warnings: string[]; -} +/** The single advisory shown when legacy reference-style footnotes are present. */ +export const LEGACY_FOOTNOTE_WARNING = + "Reference-style footnotes (`[^id]: …`) are not parsed on import and will " + + "appear as literal text. Use inline footnotes instead: `^[footnote text]`."; /** - * Analyze the footnotes in a Markdown string. Pure; safe to call on any body. + * True when `markdown` contains a legacy `[^id]:` definition line OUTSIDE any + * code fence. Pure; safe to call on any body. */ -export function analyzeFootnotes(markdown: string): FootnoteDiagnostics { - // Distinct reference ids in first-appearance order, plus the set of ids seen - // inside a table row. - const refIds: string[] = []; - const refIdSet = new Set(); - const referencesInTables = new Set(); - const addRef = (id: string, inTable: boolean) => { - if (!refIdSet.has(id)) { - refIdSet.add(id); - refIds.push(id); - } - if (inTable) referencesInTables.add(id); - }; - - // Definition texts per id, in first-appearance order of the id. - const defTextsById = new Map(); - - // Same lexer the importer uses, so the analysis matches exactly what import - // keeps/strips (#166): fenced lines are inert, definition lines are pulled. - for (const tok of lexFootnoteLines(markdown)) { - if (tok.inFence) continue; - if (tok.definition) { - const { id, text } = tok.definition; - const arr = defTextsById.get(id); - if (arr) arr.push(text); - else defTextsById.set(id, [text]); - // A definition's TEXT can itself reference another footnote (`[^a]: see - // [^b]`); count those so such a `[^b]` is not falsely reported dangling. - forEachFootnoteReference(text, (rid) => addRef(rid, false)); +export function hasLegacyFootnoteDefinition(markdown: string): boolean { + if (typeof markdown !== "string" || !markdown.includes("[^")) return false; + let fence: string | null = null; + for (const line of markdown.split("\n")) { + const fenceMatch = FENCE_RE.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1][0]; + if (fence === null) fence = marker; // opening fence + else if (marker === fence) fence = null; // matching closing fence continue; } - const inTable = tok.line.trimStart().startsWith("|"); - forEachFootnoteReference(tok.line, (id) => addRef(id, inTable)); + if (fence !== null) continue; // inside a fence: inert example text + if (FOOTNOTE_DEF_RE.test(line)) return true; } - - const danglingReferences = refIds.filter((id) => !defTextsById.has(id)); - const duplicateDefinitions: string[] = []; - const emptyDefinitions: string[] = []; - for (const [id, texts] of defTextsById) { - if (texts.length >= 2) duplicateDefinitions.push(id); - // First-wins: the kept definition is the first one; flag it if it is blank. - if ((texts[0] ?? "").trim().length === 0) emptyDefinitions.push(id); - } - const tableRefs = [...referencesInTables]; - - const warnings: string[] = []; - const list = (ids: string[]) => ids.map((id) => `[^${id}]`).join(", "); - if (danglingReferences.length > 0) { - warnings.push( - `Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`, - ); - } - if (emptyDefinitions.length > 0) { - warnings.push( - `Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`, - ); - } - if (duplicateDefinitions.length > 0) { - warnings.push( - `Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`, - ); - } - if (tableRefs.length > 0) { - warnings.push( - `Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`, - ); - } - - return { - danglingReferences, - emptyDefinitions, - duplicateDefinitions, - referencesInTables: tableRefs, - warnings, - }; + return false; } /** * The optional `footnoteWarnings` field for a page-write tool result: present - * (with the warning lines) only when `markdown` has footnote problems, omitted - * otherwise. One helper so all three call sites (create/update/import) attach the - * field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`. + * (with the single advisory) only when `markdown` uses legacy reference-style + * footnote syntax, omitted otherwise. One helper so all three call sites + * (create/update/import) attach the field identically. Spread into the result: + * `{ ...result, ...footnoteWarningsField(text) }`. */ export function footnoteWarningsField(markdown: string): { footnoteWarnings?: string[]; } { - const { warnings } = analyzeFootnotes(markdown); - return warnings.length > 0 ? { footnoteWarnings: warnings } : {}; + return hasLegacyFootnoteDefinition(markdown) + ? { footnoteWarnings: [LEGACY_FOOTNOTE_WARNING] } + : {}; } diff --git a/packages/mcp/src/lib/footnote-authoring.ts b/packages/mcp/src/lib/footnote-authoring.ts deleted file mode 100644 index 9dfcd7fa..00000000 --- a/packages/mcp/src/lib/footnote-authoring.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Inline-authoring helpers for footnotes (MCP). - * - * These build/identify footnote DEFINITION nodes for the author-inline tool - * (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes - * by text, a definition-node factory, and a fresh uuidv7-style id generator. - * - * Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of - * the editor-ext canonicalizer (compositionally symmetric to the editor-ext - * copy, which keeps its authoring helpers in `footnote-util.ts`). The pure - * canonicalizer has no dependency on these. - */ - -const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; - -function cloneJson(v: T): T { - if (typeof structuredClone === "function") return structuredClone(v); - return JSON.parse(JSON.stringify(v)) as T; -} - -/** - * Normalized content key for de-duplicating footnote DEFINITIONS by their text. - * - * Two definitions with the same key are the SAME footnote — so the inline - * authoring tool reuses one id (one number, one definition, several references) - * instead of minting a second definition. Key = plaintext (whitespace-collapsed, - * trimmed) PLUS a signature of the inline mark types in order, so two notes that - * read the same but differ in formatting (one bold, one plain) are NOT merged. - * Conservative: only an exact match merges. - */ -export function footnoteContentKey(defNode: any): string { - const parts: string[] = []; - const visit = (n: any): void => { - if (!n || typeof n !== "object") return; - if (n.type === "text" && typeof n.text === "string") { - const marks = Array.isArray(n.marks) - ? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",") - : ""; - parts.push(`${n.text}${marks}`); - } - if (Array.isArray(n.content)) for (const c of n.content) visit(c); - }; - visit(defNode); - // Collapse the assembled text's whitespace and trim, keeping the mark - // signature attached so formatting differences still distinguish notes. - return parts - .join("") - .replace(/[ \t\r\n]+/g, " ") - .trim(); -} - -/** - * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. - */ -export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any { - const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; - return { - type: FOOTNOTE_DEFINITION_NAME, - attrs: { id }, - content: [{ type: "paragraph", content }], - }; -} - -/** - * Generate a uuidv7-style id (time-ordered), matching editor-ext's - * `generateFootnoteId`. Used for a genuinely-new inline footnote id. - */ -export function generateFootnoteId(): string { - const now = Date.now(); - const timeHex = now.toString(16).padStart(12, "0"); - const rand = (length: number) => { - let s = ""; - for (let i = 0; i < length; i++) - s += Math.floor(Math.random() * 16).toString(16); - return s; - }; - const versioned = "7" + rand(3); - const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); - const variant = variantNibble + rand(3); - return ( - timeHex.slice(0, 8) + - "-" + - timeHex.slice(8, 12) + - "-" + - versioned + - "-" + - variant + - "-" + - rand(12) - ); -} diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts index c83d41e4..a483134b 100644 --- a/packages/mcp/src/lib/footnote-canonicalize.ts +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -4,8 +4,8 @@ * `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's * `footnoteSyncPlugin` end-state, identical in behaviour to * `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather - * than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the - * `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately + * than imported from editor-ext — for the SAME reason the `docmost-schema.ts` + * nodes are mirrored: the MCP package is deliberately * decoupled from the browser/React-heavy editor barrel and operates on plain * JSON. The editor-ext copy owns the golden test against the live plugin; this * copy must stay behaviourally identical (a SHARED golden corpus, exercised by @@ -13,8 +13,8 @@ * * This module is the pure MIRROR only. The inline-authoring helpers * (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by - * `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this - * file is compositionally symmetric to the editor-ext copy. + * `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the + * importer's `assembleFootnotes`, #414), so this file stays a pure mirror. * * Why it exists: every NON-editor write path (markdown import, update_page_json, * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the diff --git a/packages/mcp/src/lib/footnote-lex.ts b/packages/mcp/src/lib/footnote-lex.ts deleted file mode 100644 index 4a684e86..00000000 --- a/packages/mcp/src/lib/footnote-lex.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal). - * - * Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared - * `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this - * lexer no longer backs an mcp importer. It now backs ONLY the import-time - * diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the - * raw markdown for legacy reference-style `[^id]:` definition lines and surface - * advisory warnings (duplicate/orphan definitions) about content that is now - * inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is - * NOT a definition) is the property the analyzer relies on. - * - * NOTE: this is deliberately NOT shared with editor-ext's - * `extractFootnoteDefinitions` — that lives in a different package and the - * decoupling between the editor and the MCP mirror is intentional. - */ - -/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */ -export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; -/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */ -export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g; -/** Opening/closing code fence marker (``` or ~~~). */ -const FENCE_RE = /^(\s*)(`{3,}|~{3,})/; - -export interface FootnoteLine { - /** The raw line, verbatim. */ - line: string; - /** - * True for a code-fence marker line AND every line inside a fence — footnote - * syntax on such lines is inert (example text, not real markup). The importer - * keeps these in the body; the analyzer skips them. - */ - inFence: boolean; - /** The parsed definition, when this is a `[^id]: text` line OUTSIDE any fence. */ - definition: { id: string; text: string } | null; -} - -/** Classify every line of `markdown`, tracking fenced-code state. Pure. */ -export function lexFootnoteLines(markdown: string): FootnoteLine[] { - const out: FootnoteLine[] = []; - let fence: string | null = null; - for (const line of markdown.split("\n")) { - const fenceMatch = FENCE_RE.exec(line); - if (fenceMatch) { - const marker = fenceMatch[2][0]; - if (fence === null) fence = marker; // opening fence - else if (marker === fence) fence = null; // matching closing fence - out.push({ line, inFence: true, definition: null }); - continue; - } - if (fence !== null) { - out.push({ line, inFence: true, definition: null }); - continue; - } - const m = FOOTNOTE_DEF_RE.exec(line); - out.push({ - line, - inFence: false, - definition: m ? { id: m[1], text: m[2] } : null, - }); - } - return out; -} - -/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */ -export function forEachFootnoteReference( - line: string, - onRef: (id: string) => void, -): void { - FOOTNOTE_REF_RE_G.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null) onRef(m[1]); -} diff --git a/packages/mcp/src/lib/node-ops.ts b/packages/mcp/src/lib/node-ops.ts deleted file mode 100644 index cdb67902..00000000 --- a/packages/mcp/src/lib/node-ops.ts +++ /dev/null @@ -1,963 +0,0 @@ -/** - * Pure, network-free helpers for manipulating a ProseMirror/TipTap document - * tree by node id. - * - * 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; a node carries a stable id in `attrs.id`. Callouts and - * table cells hold their children in `content` just like any other block, so a - * single recursive walk reaches them all. - * - * Every exported function operates on a DEEP CLONE of the input document and - * returns the new document. The input doc and any `newNode`/`node` argument are - * never mutated. All functions are defensively null-safe: missing/!Array - * `content`, non-object nodes, and absent `attrs` are tolerated. - */ - -import { stripInlineMarkdown } from "./text-normalize.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); -} - -/** True if `node` carries the given id in `node.attrs.id`. */ -function matchesId(node: any, nodeId: string): boolean { - return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId; -} - -/** - * Recursively concatenate all text contained in a node. - * - * Text nodes contribute their `text` string; container nodes contribute the - * joined `blockPlainText` of their `content` children. Returns "" for nullish - * or non-object inputs. - */ -export function blockPlainText(node: any): string { - if (!isObject(node)) return ""; - let out = ""; - if (typeof node.text === "string") { - out += node.text; - } - if (Array.isArray(node.content)) { - for (const child of node.content) { - out += blockPlainText(child); - } - } - return out; -} - -/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */ -function truncate(text: string, n: number): string { - return text.length > n ? text.slice(0, n) + "…" : text; -} - -/** One compact outline entry for a single top-level block. */ -export interface OutlineEntry { - index: number; - type: string | undefined; - id: string | null; - firstText: string; - /** Present for headings only. */ - level?: number | null; - /** Present for tables only. */ - rows?: number; - cols?: number; - header?: string[]; - /** Present for list blocks only (bulletList/orderedList/taskList). */ - items?: number; -} - -/** - * Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in - * `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or - * table cells — compactness is the point; use `getNodeByRef` to drill into a - * specific block. - * - * Each entry carries `{ index, type, id, firstText }`, plus type-specific - * extras: headings add `level`; tables add `rows`/`cols` and the first row's - * cell texts as `header`; list blocks (types ending in "List") add `items`. - * `firstText` is the block's plain text truncated to 100 chars. Null-safe: - * a missing or non-object doc/content yields `[]`. - */ -export function buildOutline(doc: any): OutlineEntry[] { - if (!isObject(doc) || !Array.isArray(doc.content)) return []; - - const out: OutlineEntry[] = []; - for (let i = 0; i < doc.content.length; i++) { - const block = doc.content[i]; - const type = isObject(block) ? block.type : undefined; - const entry: OutlineEntry = { - index: i, - type, - id: - isObject(block) && isObject(block.attrs) - ? (block.attrs.id ?? null) - : null, - firstText: truncate(blockPlainText(block), 100), - }; - - if (type === "heading") { - entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null; - } else if (type === "table") { - const headerRow = block.content?.[0]?.content ?? []; - entry.rows = block.content?.length ?? 0; - entry.cols = block.content?.[0]?.content?.length ?? 0; - entry.header = headerRow.map((cell: any) => - truncate(blockPlainText(cell), 40), - ); - } else if (typeof type === "string" && type.endsWith("List")) { - entry.items = block.content?.length ?? 0; - } - - out.push(entry); - } - return out; -} - -/** - * Resolve a single node by reference and return `{ node, path, type }`, or - * `null` when nothing matches. - * - * - `ref` of the form `#` (e.g. `#2`) selects the TOP-LEVEL block at index - * `n` in `doc.content`. This is the only way to address table/tableRow/ - * tableCell nodes, which carry no `attrs.id`. - * - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the - * tree with `attrs.id === ref` is returned. - * - * `path` is the array of child indices from the doc root down to the node - * (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE, - * so callers can mutate it without touching the input doc. Null-safe. - */ -export function getNodeByRef( - doc: any, - ref: string, -): { node: any; path: number[]; type: string | undefined } | null { - if (!isObject(doc)) return null; - - // "#": index into the top-level content array. - const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null; - if (indexMatch) { - const index = Number(indexMatch[1]); - const block = Array.isArray(doc.content) ? doc.content[index] : undefined; - if (!isObject(block)) return null; - return { node: clone(block), path: [index], type: block.type }; - } - - // Otherwise: depth-first search for the first node with attrs.id === ref. - const search = ( - node: any, - trail: number[], - ): { node: any; path: number[]; type: string } | null => { - if (!isObject(node)) return null; - if (Array.isArray(node.content)) { - for (let i = 0; i < node.content.length; i++) { - const child = node.content[i]; - const path = [...trail, i]; - if (matchesId(child, ref)) { - return { node: clone(child), path, type: child.type }; - } - const hit = search(child, path); - if (hit != null) return hit; - } - } - return null; - }; - - return search(doc, []); -} - -/** - * Replace EVERY node whose `attrs.id === nodeId` with a deep clone of - * `newNode`, anywhere in the tree (including inside callouts and table cells). - * - * Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced` - * is the number of nodes substituted. A fresh clone of `newNode` is used for - * each match so they do not share references. - */ -export function replaceNodeById( - doc: any, - nodeId: string, - newNode: any, -): { doc: any; replaced: number } { - const out = clone(doc); - let replaced = 0; - - // Walk a content array, replacing direct matches and recursing into the - // (possibly new) children of non-matching nodes. - const walkContent = (content: any[]): void => { - for (let i = 0; i < content.length; i++) { - const child = content[i]; - if (matchesId(child, nodeId)) { - content[i] = clone(newNode); - replaced++; - // Do not recurse into a freshly substituted node. - continue; - } - if (isObject(child) && Array.isArray(child.content)) { - walkContent(child.content); - } - } - }; - - if (isObject(out) && Array.isArray(out.content)) { - walkContent(out.content); - } - return { doc: out, replaced }; -} - -/** - * Remove EVERY node whose `attrs.id === nodeId` from its parent `content` - * array, anywhere in the tree (recursive, including callouts and tables). - * - * Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is - * the number of nodes removed. - */ -export function deleteNodeById( - doc: any, - nodeId: string, -): { doc: any; deleted: number } { - const out = clone(doc); - let deleted = 0; - - // Filter a content array in place, dropping matches and recursing into the - // surviving children. - const walkContent = (content: any[]): any[] => { - const kept: any[] = []; - for (const child of content) { - if (matchesId(child, nodeId)) { - deleted++; - continue; - } - if (isObject(child) && Array.isArray(child.content)) { - child.content = walkContent(child.content); - } - kept.push(child); - } - return kept; - }; - - if (isObject(out) && Array.isArray(out.content)) { - out.content = walkContent(out.content); - } - return { doc: out, deleted }; -} - -/** - * Throw a clear, model-actionable error when a node-id write op did NOT match - * exactly one node (#159). `count === 0` -> "no node found"; `count > 1` -> - * "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write - * by id could clobber/remove EVERY duplicate. The caller skips the write for any - * `count !== 1` (the transform returns null), so this only REPORTS; nothing was - * changed. No-op for the unambiguous single-match case. - */ -export function assertUnambiguousMatch( - op: "patch_node" | "delete_node", - verb: "replace" | "delete", - count: number, - nodeId: string, - pageId: string, -): void { - if (count === 0) { - throw new Error( - `${op}: no node with id "${nodeId}" found on page ${pageId}`, - ); - } - if (count > 1) { - throw new Error( - `${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`, - ); - } -} - -/** - * Deep-clone `doc` and strip every node/mark attribute whose value is strictly - * `undefined`, so the result is safe to hand to Yjs (which throws an opaque - * "Unexpected content type" when asked to store an `undefined` attribute value). - * - * Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all - * legitimate JSON-storable values and are preserved. Operates on a clone and - * returns it; the input is never mutated. Defensively null-safe like the rest - * of the file. - */ -export function sanitizeForYjs(doc: any): any { - const out = clone(doc); - - // Drop every key whose value is strictly `undefined` from an attrs object. - const stripUndefined = (attrs: any): void => { - if (!isObject(attrs)) return; - for (const key of Object.keys(attrs)) { - if (attrs[key] === undefined) { - delete attrs[key]; - } - } - }; - - const walk = (node: any): void => { - if (!isObject(node)) return; - stripUndefined(node.attrs); - if (Array.isArray(node.marks)) { - for (const mark of node.marks) { - if (isObject(mark)) stripUndefined(mark.attrs); - } - } - if (Array.isArray(node.content)) { - for (const child of node.content) { - walk(child); - } - } - }; - - walk(out); - return out; -} - -/** - * Diagnostics helper: walk the tree and return a human-readable path string for - * the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs - * cannot store — i.e. `undefined`, a `function`, a `symbol`, or a `bigint` - * (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when - * every attribute is storable. Null-safe. - */ -export function findUnstorableAttr(doc: any): string | null { - const isUnstorable = (value: any): string | null => { - if (value === undefined) return "undefined"; - const t = typeof value; - if (t === "function") return "function"; - if (t === "symbol") return "symbol"; - if (t === "bigint") return "bigint"; - return null; - }; - - // Check an attrs object; return the offending sub-path or null. - const checkAttrs = (attrs: any, basePath: string): string | null => { - if (!isObject(attrs)) return null; - for (const key of Object.keys(attrs)) { - const kind = isUnstorable(attrs[key]); - if (kind != null) return `${basePath}.${key} (${kind})`; - } - return null; - }; - - const walk = (node: any, path: string): string | null => { - if (!isObject(node)) return null; - const attrHit = checkAttrs(node.attrs, `${path}.attrs`); - if (attrHit != null) return attrHit; - if (Array.isArray(node.marks)) { - for (let i = 0; i < node.marks.length; i++) { - const markHit = checkAttrs( - node.marks[i]?.attrs, - `${path}.marks[${i}].attrs`, - ); - if (markHit != null) return markHit; - } - } - if (Array.isArray(node.content)) { - for (let i = 0; i < node.content.length; i++) { - const childHit = walk(node.content[i], `${path}.content[${i}]`); - if (childHit != null) return childHit; - } - } - return null; - }; - - // The root doc node carries no useful index, so start the path at "doc". - if (!isObject(doc)) return null; - const attrHit = checkAttrs(doc.attrs, "attrs"); - if (attrHit != null) return attrHit; - if (Array.isArray(doc.content)) { - for (let i = 0; i < doc.content.length; i++) { - const childHit = walk(doc.content[i], `content[${i}]`); - if (childHit != null) return childHit; - } - } - return null; -} - -/** - * Table structural node types and the container each must live directly inside. - * Used by `insertNodeRelative` to splice rows/cells into the correct ancestor - * rather than blindly into the anchor's direct parent (which would corrupt the - * table's nesting). - */ -const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]); -const REQUIRED_CONTAINER: Record = { - tableRow: "table", - tableCell: "tableRow", - tableHeader: "tableRow", -}; - -/** - * Find the index of the first TOP-LEVEL block whose plain text includes the - * anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches. - * - * Two passes preserve "exact wins globally": - * - Pass 1: first block containing the verbatim `anchorText`. - * - Pass 2 (only if pass 1 found nothing): first block containing the - * markdown-stripped anchor, when stripping actually changed it. - */ -function findAnchorTextIndex(content: any[], anchorText: string): number { - if (!Array.isArray(content)) return -1; - // Pass 1: exact. - for (let i = 0; i < content.length; i++) { - if (blockPlainText(content[i]).includes(anchorText)) return i; - } - // Pass 2: markdown-stripped fallback. - const a = stripInlineMarkdown(anchorText); - if (a !== anchorText && a.length > 0) { - for (let i = 0; i < content.length; i++) { - if (blockPlainText(content[i]).includes(a)) return i; - } - } - return -1; -} - -/** - * Locate an anchor and return its ancestor chain (from `doc` down to and - * including the matched node). Each chain entry is `{ node, index }` where - * `index` is the node's position inside its parent's `content` array (the root - * doc has index -1). Returns `null` when the anchor cannot be resolved. - */ -function findAnchorChain( - doc: any, - opts: InsertOptions, -): { node: any; index: number }[] | null { - if (!isObject(doc)) return null; - - // DFS by id anywhere in the tree, accumulating the path. - if (opts.anchorNodeId != null) { - const targetId = opts.anchorNodeId; - const search = ( - node: any, - index: number, - trail: { node: any; index: number }[], - ): { node: any; index: number }[] | null => { - if (!isObject(node)) return null; - const here = [...trail, { node, index }]; - if (matchesId(node, targetId)) return here; - if (Array.isArray(node.content)) { - for (let i = 0; i < node.content.length; i++) { - const hit = search(node.content[i], i, here); - if (hit != null) return hit; - } - } - return null; - }; - return search(doc, -1, []); - } - - // By text: only top-level blocks are scanned (same rule as the JSON path). - // Exact match wins; a markdown-stripped fallback is tried only on a miss. - if (opts.anchorText != null && Array.isArray(doc.content)) { - const i = findAnchorTextIndex(doc.content, opts.anchorText); - if (i !== -1) { - return [ - { node: doc, index: -1 }, - { node: doc.content[i], index: i }, - ]; - } - } - - return null; -} - -/** Options controlling where `insertNodeRelative` places the new node. */ -export interface InsertOptions { - position: "before" | "after" | "append"; - /** Resolve the anchor by node id anywhere in the tree (preferred). */ - anchorNodeId?: string; - /** Fallback: first TOP-LEVEL block whose plain text includes this string. */ - anchorText?: string; -} - -/** - * Insert a deep clone of `node` relative to an anchor. - * - * - position "append": push the node onto the top-level `doc.content`. - * - position "before"/"after": locate the anchor and splice the node into the - * anchor's parent `content` array immediately before / after it. - * - * Anchor resolution for before/after: - * - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId` - * anywhere in the tree (recursive); - * - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content` - * blocks and pick the first whose `blockPlainText` includes `anchorText`. - * - * Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is - * false when the anchor could not be resolved (the doc is returned unchanged - * apart from being cloned). - */ -export function insertNodeRelative( - doc: any, - node: any, - opts: InsertOptions, -): { doc: any; inserted: boolean } { - const out = clone(doc); - const fresh = clone(node); - - // Defensive: stay null-safe like the other exports — a missing opts means - // there is nothing actionable to do. - if (!isObject(opts)) return { doc: out, inserted: false }; - - const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type); - - // "append": top-level push. - if (opts.position === "append") { - // Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the - // top level — appending one would produce invalid nesting. - if (isStructural) { - throw new Error( - `insert_node: cannot append a ${node.type} at the top level; use ` + - `position before/after with an anchor inside the target table`, - ); - } - if (isObject(out)) { - if (!Array.isArray(out.content)) out.content = []; - out.content.push(fresh); - return { doc: out, inserted: true }; - } - return { doc: out, inserted: false }; - } - - const offset = opts.position === "after" ? 1 : 0; - - // Structural insert (before/after a tableRow/tableCell/tableHeader): splice - // into the nearest enclosing table/tableRow rather than the anchor's direct - // parent, so the row/cell lands at the correct level of the table. - if (isStructural) { - const containerType = REQUIRED_CONTAINER[node.type]; - const chain = findAnchorChain(out, opts); - // Anchor not resolved at all — keep the existing "anchor not found" path. - if (chain == null) return { doc: out, inserted: false }; - - // Find the DEEPEST ancestor (including the anchor itself) of the required - // container type. - let containerIdx = -1; - for (let i = chain.length - 1; i >= 0; i--) { - if (isObject(chain[i].node) && chain[i].node.type === containerType) { - containerIdx = i; - break; - } - } - - if (containerIdx === -1) { - throw new Error( - `insert_node: cannot insert a ${node.type} here — the anchor is not ` + - `inside a ${containerType}. Anchor on a cell's text or a block id ` + - `that lives inside the target table.`, - ); - } - - const container = chain[containerIdx].node; - if (!Array.isArray(container.content)) container.content = []; - - if (containerIdx === chain.length - 1) { - // The matched container IS the anchor node itself (e.g. anchorText - // resolved to the table block): append/prepend within it. - const at = opts.position === "after" ? container.content.length : 0; - container.content.splice(at, 0, fresh); - } else { - // The immediate child on the path leading to the anchor is the row/cell - // to splice next to. - const enclosingChildIndex = chain[containerIdx + 1].index; - container.content.splice(enclosingChildIndex + offset, 0, fresh); - } - return { doc: out, inserted: true }; - } - - // Resolve by id anywhere in the tree: splice into the parent content array. - if (opts.anchorNodeId != null) { - let inserted = false; - const walkContent = (content: any[]): void => { - for (let i = 0; i < content.length; i++) { - const child = content[i]; - if (matchesId(child, opts.anchorNodeId as string)) { - content.splice(i + offset, 0, fresh); - inserted = true; - return; - } - if (isObject(child) && Array.isArray(child.content)) { - walkContent(child.content); - if (inserted) return; - } - } - }; - if (isObject(out) && Array.isArray(out.content)) { - walkContent(out.content); - } - return { doc: out, inserted }; - } - - // Resolve by text: only top-level doc.content blocks are scanned. Exact - // match wins; a markdown-stripped fallback is tried only on a miss. - if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) { - const i = findAnchorTextIndex(out.content, opts.anchorText); - if (i !== -1) { - out.content.splice(i + offset, 0, fresh); - return { doc: out, inserted: true }; - } - } - - return { doc: out, inserted: false }; -} - -// =========================================================================== -// Table editing helpers -// -// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes: -// table -> { type:"table", content:[tableRow...] } -// row -> { type:"tableRow", content:[tableCell|tableHeader...] } -// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth}, -// content:[paragraph...] } -// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] } -// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the -// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE -// of the input doc (via `clone`) and never mutate their inputs. -// =========================================================================== - -/** - * Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed - * `makeFreshId` so generated paragraph ids never collide with existing ones. - */ -function collectIds(node: any, used: Set): void { - if (!isObject(node)) return; - if (isObject(node.attrs) && typeof node.attrs.id === "string") { - used.add(node.attrs.id); - } - if (Array.isArray(node.content)) { - for (const child of node.content) collectIds(child, used); - } -} - -/** - * Fresh-id generator: returns a random Docmost-style id (12 chars from - * lowercase `a-z0-9`) that is not already in `used`, and records it. On the - * rare collision the id is regenerated. Callers rely on uniqueness, not on the - * exact string, so randomness is fine — and unlike a module-local counter it - * needs no reset and cannot become predictable across calls. - */ -function makeFreshId(used: Set): string { - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; - let id: string; - do { - id = ""; - for (let i = 0; i < 12; i++) { - id += alphabet[Math.floor(Math.random() * alphabet.length)]; - } - } while (used.has(id) || id === ""); - used.add(id); - return id; -} - -/** - * Resolve a table reference against an ALREADY-CLONED doc and return the LIVE - * table node (a reference inside `rootClone`, so the caller may mutate it) plus - * its index path. Returns null when no table matches. - * - * - `#`: the top-level block at index `n`, only if its `type === "table"`. - * - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its - * ancestor chain to the nearest `type === "table"` ancestor. - */ -function locateTable( - rootClone: any, - tableRef: string, -): { table: any; path: number[] } | null { - if (!isObject(rootClone)) return null; - - // "#": index into the top-level content array; must be a table. - const indexMatch = - typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null; - if (indexMatch) { - const index = Number(indexMatch[1]); - const block = Array.isArray(rootClone.content) - ? rootClone.content[index] - : undefined; - if (isObject(block) && block.type === "table") { - return { table: block, path: [index] }; - } - return null; - } - - // Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then - // climb to the nearest enclosing table. - const search = ( - node: any, - trail: { node: any; index: number }[], - ): { table: any; path: number[] } | null => { - if (!isObject(node)) return null; - if (Array.isArray(node.content)) { - for (let i = 0; i < node.content.length; i++) { - const child = node.content[i]; - const here = [...trail, { node: child, index: i }]; - if (matchesId(child, tableRef)) { - // Walk UP to the nearest table ancestor (including the match itself). - for (let j = here.length - 1; j >= 0; j--) { - if (isObject(here[j].node) && here[j].node.type === "table") { - return { - table: here[j].node, - path: here.slice(0, j + 1).map((e) => e.index), - }; - } - } - return null; // id found but no enclosing table - } - const hit = search(child, here); - if (hit != null) return hit; - } - } - return null; - }; - - return search(rootClone, []); -} - -/** Build the plain-text → single-paragraph cell content used by all writers. */ -function makeCellParagraph(id: string, text: string): any { - return { - type: "paragraph", - attrs: { id, indent: 0 }, - // Empty string → a paragraph with an empty content array. - content: text ? [{ type: "text", text }] : [], - }; -} - -/** - * Read a table as a matrix. Returns null when `tableRef` resolves to no table. - * - * - `rows`/`cols`: the table's row count and the column count of its FIRST row. - * Tables may be ragged (rows of differing length), so `cols` reflects only - * row 0; use the per-row length of `cells`/`cellIds` for each row's actual - * width. - * - `cells`: `string[][]` of each cell's `blockPlainText`. - * - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null), - * so callers can `patch_node` a cell for rich-formatted edits. - * - `path`: index path of the table within the doc. - */ -export function readTable( - doc: any, - tableRef: string, -): { - rows: number; - cols: number; - cells: string[][]; - cellIds: (string | null)[][]; - path: number[]; -} | null { - const root = clone(doc); - const located = locateTable(root, tableRef); - if (located == null) return null; - const { table, path } = located; - - const rowNodes = Array.isArray(table.content) ? table.content : []; - const rows = rowNodes.length; - const cols = rowNodes[0]?.content?.length ?? 0; - - const cells: string[][] = []; - const cellIds: (string | null)[][] = []; - for (const rowNode of rowNodes) { - const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : []; - const rowText: string[] = []; - const rowIds: (string | null)[] = []; - for (const cellNode of cellNodes) { - rowText.push(blockPlainText(cellNode)); - // The cell's first paragraph carries the id used for patch_node. - const firstPara = Array.isArray(cellNode?.content) - ? cellNode.content[0] - : undefined; - const id = - isObject(firstPara) && isObject(firstPara.attrs) - ? (firstPara.attrs.id ?? null) - : null; - rowIds.push(id); - } - cells.push(rowText); - cellIds.push(rowIds); - } - - return { rows, cols, cells, cellIds, path }; -} - -/** - * Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`. - * - * The row is padded to the table's column count (`cells[i] ?? ""`); supplying - * MORE cells than columns throws. Each new cell copies `colwidth` for its - * column from the header row when present, gets a fresh-id paragraph, and a - * `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices - * the row there; otherwise the row is appended at the end. - */ -export function insertTableRow( - doc: any, - tableRef: string, - cells: string[], - index?: number, -): { doc: any; inserted: boolean } { - const out = clone(doc); - const located = locateTable(out, tableRef); - if (located == null) return { doc: out, inserted: false }; - const { table } = located; - - if (!Array.isArray(table.content)) table.content = []; - const rows = table.content.length; - const headerRow = table.content[0]; - const headerCells = Array.isArray(headerRow?.content) - ? headerRow.content - : []; - - // Column count is the WIDEST existing row, so the guard below stays - // meaningful for ragged tables and the new row matches the table's width. - // Fall back to the supplied cell count only when the table has no rows. - let colCount = 0; - for (const r of table.content) { - if (isObject(r) && Array.isArray(r.content)) - colCount = Math.max(colCount, r.content.length); - } - if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0; - - if (Array.isArray(cells) && cells.length > colCount) { - throw new Error( - `table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`, - ); - } - - // Resolve the landing index up front so the cell-type decision and the splice - // below agree: a valid integer in [0, rows] splices there, else we append. - const landingIndex = - typeof index === "number" && - Number.isInteger(index) && - index >= 0 && - index <= rows - ? index - : rows; - - // Seed the id generator with every id already in the doc so the new cell - // paragraph ids are unique within the whole document. - const used = new Set(); - collectIds(out, used); - - const newCells: any[] = []; - for (let i = 0; i < colCount; i++) { - const text = (Array.isArray(cells) ? cells[i] : undefined) ?? ""; - const attrs: Record = { colspan: 1, rowspan: 1 }; - // Copy this column's colwidth from the header row's cell when present. - const colwidth = headerCells[i]?.attrs?.colwidth; - if (colwidth !== undefined) attrs.colwidth = colwidth; - // A row landing at index 0 becomes the new header row, so inherit the - // current header cell's type per column (Docmost uses "tableHeader" there); - // every other position is a plain data cell. - const cellType = - landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell"; - newCells.push({ - type: cellType, - attrs, - content: [makeCellParagraph(makeFreshId(used), text)], - }); - } - - const newRow = { type: "tableRow", content: newCells }; - - // Splice at the resolved landing index (append when index was omitted/invalid). - table.content.splice(landingIndex, 0, newRow); - - return { doc: out, inserted: true }; -} - -/** - * Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`. - * `deleted` is false only when the table cannot be located. Throws on an - * out-of-range index, and refuses to delete the table's only row. - */ -export function deleteTableRow( - doc: any, - tableRef: string, - index: number, -): { doc: any; deleted: boolean } { - const out = clone(doc); - const located = locateTable(out, tableRef); - if (located == null) return { doc: out, deleted: false }; - const { table } = located; - - if (!Array.isArray(table.content)) table.content = []; - const rows = table.content.length; - - if (!Number.isInteger(index) || index < 0 || index >= rows) { - throw new Error( - `table_delete_row: row index ${index} out of range (table has ${rows} row(s))`, - ); - } - if (rows <= 1) { - throw new Error( - "table_delete_row: refusing to delete the only row of the table", - ); - } - - table.content.splice(index, 1); - return { doc: out, deleted: true }; -} - -/** - * Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns - * `{ doc, updated }`; `updated` is false only when the table cannot be located. - * Throws when `row`/`col` is out of range. The cell's own attrs (colspan/ - * rowspan/colwidth) are preserved; its content becomes a single text paragraph - * that reuses the cell's existing first-paragraph id when present, else a fresh - * one. - */ -export function updateTableCell( - doc: any, - tableRef: string, - row: number, - col: number, - text: string, -): { doc: any; updated: boolean } { - const out = clone(doc); - const located = locateTable(out, tableRef); - if (located == null) return { doc: out, updated: false }; - const { table } = located; - - const rowNodes = Array.isArray(table.content) ? table.content : []; - const rows = rowNodes.length; - const rowNode = rowNodes[row]; - const cols = - isObject(rowNode) && Array.isArray(rowNode.content) - ? rowNode.content.length - : 0; - - if ( - !Number.isInteger(row) || - row < 0 || - row >= rows || - !Number.isInteger(col) || - col < 0 || - col >= cols - ) { - throw new Error(`table_update_cell: cell [${row},${col}] out of range`); - } - - const cellNode = rowNode.content[col]; - // Reuse the cell's existing first-paragraph id, or mint a fresh unique one. - const existingPara = Array.isArray(cellNode?.content) - ? cellNode.content[0] - : undefined; - let id = - isObject(existingPara) && isObject(existingPara.attrs) - ? existingPara.attrs.id - : undefined; - if (typeof id !== "string" || id.length === 0) { - const used = new Set(); - collectIds(out, used); - id = makeFreshId(used); - } - - cellNode.content = [makeCellParagraph(id, text)]; - return { doc: out, updated: true }; -} diff --git a/packages/mcp/src/lib/page-search.ts b/packages/mcp/src/lib/page-search.ts index 7f95b2a5..f4c49ac3 100644 --- a/packages/mcp/src/lib/page-search.ts +++ b/packages/mcp/src/lib/page-search.ts @@ -33,7 +33,7 @@ import RE2 from "re2"; -import { blockPlainText } from "./node-ops.js"; +import { blockPlainText } from "@docmost/prosemirror-markdown"; /** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */ type Re2Regex = InstanceType; diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index e3ab0cff..fadc71db 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -14,13 +14,13 @@ * - `marks` arrays are preserved verbatim when fragments are split/reordered. */ -import { blockPlainText } from "./node-ops.js"; -import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; import { + blockPlainText, footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, -} from "./footnote-authoring.js"; +} from "@docmost/prosemirror-markdown"; +import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; export { canonicalizeFootnotes } from "./footnote-canonicalize.js"; @@ -365,7 +365,7 @@ export function noteItem(inlineNodes: any[]): any { * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). * - * Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts); + * Built on the shared `makeFootnoteDefinition` factory (`@docmost/prosemirror-markdown`); * the only extra is a fresh block id on the inner paragraph (Docmost stamps one, * and the canonicalizer preserves attrs as-is). Single factory, one place to * change the definition shape. diff --git a/packages/mcp/test/mock/footnote-warnings.test.mjs b/packages/mcp/test/mock/footnote-warnings.test.mjs index 2f8e0b7d..94b43416 100644 --- a/packages/mcp/test/mock/footnote-warnings.test.mjs +++ b/packages/mcp/test/mock/footnote-warnings.test.mjs @@ -1,7 +1,7 @@ // Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the // representative path that is fully plain-HTTP (import + getPage) and so is // mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the -// IDENTICAL wiring (`analyzeFootnotes(...)` + spread-when-non-empty) but run their +// IDENTICAL wiring (`footnoteWarningsField(...)` spread-when-non-empty) but run their // mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness // does not stand up. The analyzer itself is unit-tested in footnote-analyze.test. import { test, after } from "node:test"; @@ -76,35 +76,29 @@ function pageHandler() { }; } -test("createPage attaches footnoteWarnings when the content has footnote problems", async () => { +test("createPage attaches footnoteWarnings when the content uses legacy footnote syntax", async () => { const baseURL = await spawn(pageHandler()); const client = new DocmostClient(baseURL, "user@example.com", "pw"); - // A dangling reference + a duplicate definition + a table marker. - const content = [ - "Intro[^missing] and| cell[^t] |.", - "", - "[^d]: one", - "[^d]: two", - "[^t]: in table", - ].join("\n"); + // Legacy reference-style `[^id]:` definitions — inert on import since #293. + const content = ["Intro[^a].", "", "[^a]: a definition"].join("\n"); const result = await client.createPage("T", content, "sp-1"); assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present"); const joined = result.footnoteWarnings.join("\n"); - assert.match(joined, /no matching definition/); // dangling [^missing] - assert.match(joined, /defined more than once/); // duplicate [^d] + assert.match(joined, /reference-style footnotes/i); + assert.match(joined, /\^\[footnote text\]/); // nudge to the inline form // The page itself is still returned. assert.equal(result.success, true); }); -test("createPage omits footnoteWarnings when the content is clean", async () => { +test("createPage omits footnoteWarnings when the content uses the inline form", async () => { const baseURL = await spawn(pageHandler()); const client = new DocmostClient(baseURL, "user@example.com", "pw"); - const content = ["A[^a] and reuse[^a].", "", "[^a]: fine"].join("\n"); + const content = "A note.^[the body] and reuse.^[the body]"; const result = await client.createPage("T", content, "sp-1"); assert.equal( "footnoteWarnings" in result, false, - "no footnoteWarnings field on clean input", + "no footnoteWarnings field on inline-footnote input", ); assert.equal(result.success, true); }); diff --git a/packages/mcp/test/unit/footnote-analyze.test.mjs b/packages/mcp/test/unit/footnote-analyze.test.mjs index b2de1787..67e9f2ff 100644 --- a/packages/mcp/test/unit/footnote-analyze.test.mjs +++ b/packages/mcp/test/unit/footnote-analyze.test.mjs @@ -1,64 +1,45 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js"; +import { + footnoteWarningsField, + hasLegacyFootnoteDefinition, +} from "../../build/lib/footnote-analyze.js"; -test("clean footnotes produce no diagnostics", () => { - const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n"); - const d = analyzeFootnotes(md); - assert.deepEqual(d.danglingReferences, []); - assert.deepEqual(d.emptyDefinitions, []); - assert.deepEqual(d.duplicateDefinitions, []); - assert.deepEqual(d.referencesInTables, []); - assert.deepEqual(d.warnings, []); +// #414: the legacy footnote diagnostics were reduced to ONE advisory that fires +// on the PRESENCE of legacy reference-style `[^id]:` definition syntax (inert on +// import since #293), nudging the author to inline `^[...]` footnotes. + +test("inline `^[...]` footnotes produce no warning", () => { + const md = "A note here.^[the body] and reuse elsewhere.^[the body]"; + assert.equal(hasLegacyFootnoteDefinition(md), false); + assert.deepEqual(footnoteWarningsField(md), {}); }); -test("reuse (repeated references to one definition) is NOT a warning", () => { - const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n"); - const d = analyzeFootnotes(md); - assert.deepEqual(d.danglingReferences, []); - assert.deepEqual(d.warnings, []); +test("no footnotes at all produce no warning", () => { + const md = "Just a paragraph with [a link](https://x) and no footnotes."; + assert.equal(hasLegacyFootnoteDefinition(md), false); + assert.deepEqual(footnoteWarningsField(md), {}); }); -test("dangling reference (no definition) is reported", () => { - const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n"); - const d = analyzeFootnotes(md); - assert.deepEqual(d.danglingReferences, ["missing"]); - assert.equal(d.warnings.length, 1); - assert.match(d.warnings[0], /no matching definition/); - assert.match(d.warnings[0], /\[\^missing\]/); +test("a legacy `[^id]:` definition triggers the single advisory", () => { + const md = ["See[^a].", "", "[^a]: defined"].join("\n"); + assert.equal(hasLegacyFootnoteDefinition(md), true); + const field = footnoteWarningsField(md); + assert.equal(field.footnoteWarnings.length, 1); + assert.match(field.footnoteWarnings[0], /reference-style footnotes/i); + assert.match(field.footnoteWarnings[0], /\^\[footnote text\]/); }); -test("empty definition text is reported", () => { - const md = ["See[^a].", "", "[^a]: "].join("\n"); - const d = analyzeFootnotes(md); - assert.deepEqual(d.emptyDefinitions, ["a"]); - assert.match(d.warnings.join("\n"), /empty text/); +test("a bare `[^id]` reference (no definition line) is not flagged", () => { + // Only the definition syntax `[^id]:` is a reliable signal of legacy authoring; + // a lone `[^x]` in prose is too ambiguous to warn on. + const md = "A sentence mentioning [^x] with no definition."; + assert.equal(hasLegacyFootnoteDefinition(md), false); + assert.deepEqual(footnoteWarningsField(md), {}); }); -test("duplicate definition id is reported (first-wins)", () => { - const md = ["See[^d].", "", "[^d]: first", "[^d]: second"].join("\n"); - const d = analyzeFootnotes(md); - assert.deepEqual(d.duplicateDefinitions, ["d"]); - assert.match(d.warnings.join("\n"), /defined more than once/); -}); - -test("reference inside a GFM table row is reported (heuristic)", () => { - const md = [ - "| Col |", - "| --- |", - "| cell[^t] |", - "", - "[^t]: table note", - ].join("\n"); - const d = analyzeFootnotes(md); - assert.deepEqual(d.referencesInTables, ["t"]); - assert.match(d.warnings.join("\n"), /table/); - // It is defined, so it is NOT also dangling. - assert.deepEqual(d.danglingReferences, []); -}); - -test("footnote syntax inside a code fence is ignored", () => { +test("legacy syntax inside a code fence is ignored (fence-aware)", () => { const md = [ "Intro.", "", @@ -67,40 +48,22 @@ test("footnote syntax inside a code fence is ignored", () => { "[^demo]: not a real definition", "```", "", - "Outro[^a].", - "", - "[^a]: real", + "Outro with an inline note.^[real]", ].join("\n"); - const d = analyzeFootnotes(md); - // `[^demo]` lives only in the fenced block, so it is neither a reference nor a - // dangling one, and `[^demo]:` is not counted as a definition. - assert.deepEqual(d.danglingReferences, []); - assert.deepEqual(d.duplicateDefinitions, []); - assert.deepEqual(d.warnings, []); + assert.equal(hasLegacyFootnoteDefinition(md), false); + assert.deepEqual(footnoteWarningsField(md), {}); }); -test("a reference that only appears inside a definition's text is not dangling", () => { - // `[^b]` is referenced from within [^a]'s text and has its own definition. - const md = ["See[^a].", "", "[^a]: see also [^b]", "[^b]: the other"].join( - "\n", - ); - const d = analyzeFootnotes(md); - assert.deepEqual(d.danglingReferences, []); -}); - -test("multiple problem classes accumulate distinct warnings", () => { +test("a legacy definition OUTSIDE a fence still warns even with a fenced sample", () => { const md = [ - "Ref[^x] and[^dup].", + "```", + "[^demo]: example inside a fence", + "```", "", - "[^dup]: one", - "[^dup]: two", - "[^empty]:", + "See[^a].", + "", + "[^a]: real definition outside the fence", ].join("\n"); - const d = analyzeFootnotes(md); - // x has no definition; dup is defined twice; empty is empty AND has no ref. - assert.ok(d.danglingReferences.includes("x")); - assert.deepEqual(d.duplicateDefinitions, ["dup"]); - assert.deepEqual(d.emptyDefinitions, ["empty"]); - // One warning line per problem class present. - assert.ok(d.warnings.length >= 3); + assert.equal(hasLegacyFootnoteDefinition(md), true); + assert.equal(footnoteWarningsField(md).footnoteWarnings.length, 1); }); diff --git a/packages/mcp/test/unit/footnote-canonicalize.test.mjs b/packages/mcp/test/unit/footnote-canonicalize.test.mjs index d7af244e..e510e80f 100644 --- a/packages/mcp/test/unit/footnote-canonicalize.test.mjs +++ b/packages/mcp/test/unit/footnote-canonicalize.test.mjs @@ -5,7 +5,7 @@ import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js" import { footnoteContentKey, generateFootnoteId, -} from "../../build/lib/footnote-authoring.js"; +} from "@docmost/prosemirror-markdown"; import { insertInlineFootnote } from "../../build/lib/transforms.js"; import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js"; diff --git a/packages/mcp/test/unit/footnote-warnings-import.test.mjs b/packages/mcp/test/unit/footnote-warnings-import.test.mjs index e9abab52..60d4571d 100644 --- a/packages/mcp/test/unit/footnote-warnings-import.test.mjs +++ b/packages/mcp/test/unit/footnote-warnings-import.test.mjs @@ -1,39 +1,37 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - analyzeFootnotes, - footnoteWarningsField, -} from "../../build/lib/footnote-analyze.js"; +import { footnoteWarningsField } from "../../build/lib/footnote-analyze.js"; import { serializeDocmostMarkdown, parseDocmostMarkdown, } from "../../build/lib/markdown-document.js"; -// Pins the footnoteWarnings PLUMBING contract (#169 review): the field is -// present only on problems and omitted on clean input, AND `import_page_markdown` -// analyzes the BODY (after the docmost:meta / docmost:comments blocks) — so a -// footnote-like token inside those JSON blocks never warns, while a real marker -// in the body does. importPageMarkdown does exactly -// `footnoteWarningsField(parseDocmostMarkdown(full).body)` over a collab socket -// this harness does not stand up, so we test the same pure composition directly. +// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the +// field is present only when legacy reference-style `[^id]:` syntax is used and +// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the +// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those +// JSON blocks never warns, while a real definition in the body does. +// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)` +// over a collab socket this harness does not stand up, so we test the same pure +// composition directly. -test("footnoteWarningsField is present on problems and omitted on clean input", () => { - const problem = footnoteWarningsField("See[^missing].\n\n[^a]: defined"); - assert.ok(Array.isArray(problem.footnoteWarnings)); - assert.match(problem.footnoteWarnings.join("\n"), /no matching definition/); +test("footnoteWarningsField is present on legacy syntax and omitted on the inline form", () => { + const legacy = footnoteWarningsField("See[^a].\n\n[^a]: defined"); + assert.ok(Array.isArray(legacy.footnoteWarnings)); + assert.match(legacy.footnoteWarnings.join("\n"), /reference-style footnotes/i); - const clean = footnoteWarningsField("A[^a] and reuse[^a].\n\n[^a]: fine"); - assert.deepEqual(clean, {}); // no key at all on clean input + const inline = footnoteWarningsField("A note.^[the body] reused.^[the body]"); + assert.deepEqual(inline, {}); // no key at all on inline-footnote input }); test("import analyzes the BODY only — tokens inside meta/comments never warn", () => { - // meta + comments JSON carry `[^metaonly]` / `[^commentonly]`-looking text; the - // BODY has a genuinely dangling `[^bodyref]`. + // meta + comments JSON carry `[^metaonly]:` / `[^commentonly]:`-looking text; + // the BODY has a genuine legacy `[^bodyref]:` definition. const full = serializeDocmostMarkdown( - { pageId: "p1", note: "front-matter mentions [^metaonly] in text" }, - "Body with a dangling[^bodyref] marker.", - [{ id: "c1", content: "a comment that says [^commentonly]" }], + { pageId: "p1", note: "front-matter mentions [^metaonly]: in text" }, + "Body with a legacy[^bodyref] marker.\n\n[^bodyref]: the definition", + [{ id: "c1", content: "a comment that says [^commentonly]: text" }], ); const { body } = parseDocmostMarkdown(full); @@ -42,20 +40,19 @@ test("import analyzes the BODY only — tokens inside meta/comments never warn", assert.ok(!body.includes("[^commentonly]")); const field = footnoteWarningsField(body); - const joined = (field.footnoteWarnings ?? []).join("\n"); - // ONLY the body's dangling reference is flagged. - assert.match(joined, /\[\^bodyref\]/); - assert.ok(!joined.includes("metaonly")); - assert.ok(!joined.includes("commentonly")); + // ONLY the body's legacy definition triggers the advisory. + assert.ok(Array.isArray(field.footnoteWarnings)); + assert.match(field.footnoteWarnings.join("\n"), /reference-style footnotes/i); - // Cross-check against analyzeFootnotes directly (same composition the importer uses). - assert.deepEqual(analyzeFootnotes(body).danglingReferences, ["bodyref"]); + // The meta/comments tokens, analyzed on their own, would NOT have warned in a + // way that leaks here — the field is computed over the body only. + assert.deepEqual(footnoteWarningsField("front-matter mentions text"), {}); }); -test("import on a clean body yields no footnoteWarnings field", () => { +test("import on an inline-footnote body yields no footnoteWarnings field", () => { const full = serializeDocmostMarkdown( { pageId: "p1" }, - "Clean body[^a] reusing[^a].\n\n[^a]: ok", + "Clean body.^[a note] reusing.^[a note]", [], ); const { body } = parseDocmostMarkdown(full); diff --git a/packages/mcp/test/unit/node-ops-table.test.mjs b/packages/mcp/test/unit/node-ops-table.test.mjs index 2feb0d9a..164985c7 100644 --- a/packages/mcp/test/unit/node-ops-table.test.mjs +++ b/packages/mcp/test/unit/node-ops-table.test.mjs @@ -5,7 +5,7 @@ import { insertNodeRelative, sanitizeForYjs, findUnstorableAttr, -} from "../../build/lib/node-ops.js"; +} from "@docmost/prosemirror-markdown"; // ProseMirror builders. Blocks carry a stable id in attrs.id. const textNode = (text) => ({ type: "text", text }); diff --git a/packages/mcp/test/unit/node-ops.test.mjs b/packages/mcp/test/unit/node-ops.test.mjs index 694ac93e..b616a52f 100644 --- a/packages/mcp/test/unit/node-ops.test.mjs +++ b/packages/mcp/test/unit/node-ops.test.mjs @@ -7,7 +7,7 @@ import { deleteNodeById, assertUnambiguousMatch, insertNodeRelative, -} from "../../build/lib/node-ops.js"; +} from "@docmost/prosemirror-markdown"; // ProseMirror builders. Blocks carry a stable id in attrs.id. const textNode = (text) => ({ type: "text", text }); diff --git a/packages/mcp/test/unit/outline.test.mjs b/packages/mcp/test/unit/outline.test.mjs index 8aa45958..184c17f5 100644 --- a/packages/mcp/test/unit/outline.test.mjs +++ b/packages/mcp/test/unit/outline.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js"; +import { buildOutline, getNodeByRef } from "@docmost/prosemirror-markdown"; // Helpers to build the small fixture doc. const textNode = (text) => ({ type: "text", text }); diff --git a/packages/mcp/test/unit/page-search.test.mjs b/packages/mcp/test/unit/page-search.test.mjs index 5add544d..931e855b 100644 --- a/packages/mcp/test/unit/page-search.test.mjs +++ b/packages/mcp/test/unit/page-search.test.mjs @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { searchInDoc } from "../../build/lib/page-search.js"; -import { getNodeByRef } from "../../build/lib/node-ops.js"; +import { getNodeByRef } from "@docmost/prosemirror-markdown"; // --------------------------------------------------------------------------- // Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings diff --git a/packages/mcp/test/unit/parse-node-arg.test.mjs b/packages/mcp/test/unit/parse-node-arg.test.mjs index c24cdd54..25255c59 100644 --- a/packages/mcp/test/unit/parse-node-arg.test.mjs +++ b/packages/mcp/test/unit/parse-node-arg.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { parseNodeArg } from "../../build/lib/parse-node-arg.js"; +import { parseNodeArg } from "@docmost/prosemirror-markdown"; test("parseNodeArg passes an object through unchanged", () => { const obj = { type: "paragraph", content: [] }; diff --git a/packages/mcp/test/unit/table-ops.test.mjs b/packages/mcp/test/unit/table-ops.test.mjs index de2208d9..280845c6 100644 --- a/packages/mcp/test/unit/table-ops.test.mjs +++ b/packages/mcp/test/unit/table-ops.test.mjs @@ -6,7 +6,7 @@ import { insertTableRow, deleteTableRow, updateTableCell, -} from "../../build/lib/node-ops.js"; +} from "@docmost/prosemirror-markdown"; // --------------------------------------------------------------------------- // Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a diff --git a/packages/prosemirror-markdown/src/lib/footnote.ts b/packages/prosemirror-markdown/src/lib/footnote.ts index d1732734..67b45b0e 100644 --- a/packages/prosemirror-markdown/src/lib/footnote.ts +++ b/packages/prosemirror-markdown/src/lib/footnote.ts @@ -59,3 +59,89 @@ export function splitFootnoteParagraphs(encoded: string): string[] { paragraphs.push(current); return paragraphs; } + +// --------------------------------------------------------------------------- +// Inline-authoring helpers (#414: moved here from the mcp `footnote-authoring.ts` +// fork so the dedup convention — content-key + definition factory + id gen — +// has ONE home next to the importer that shares the convention). Used by the +// mcp author-inline tool (`insertInlineFootnote` in transforms.ts). +// --------------------------------------------------------------------------- + +const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; + +function cloneJson(v: T): T { + if (typeof structuredClone === "function") return structuredClone(v); + return JSON.parse(JSON.stringify(v)) as T; +} + +/** + * Normalized content key for de-duplicating footnote DEFINITIONS by their text. + * + * Two definitions with the same key are the SAME footnote — so the inline + * authoring tool reuses one id (one number, one definition, several references) + * instead of minting a second definition. Key = plaintext (whitespace-collapsed, + * trimmed) PLUS a signature of the inline mark types in order, so two notes that + * read the same but differ in formatting (one bold, one plain) are NOT merged. + * Conservative: only an exact match merges. + */ +export function footnoteContentKey(defNode: any): string { + const parts: string[] = []; + const visit = (n: any): void => { + if (!n || typeof n !== "object") return; + if (n.type === "text" && typeof n.text === "string") { + const marks = Array.isArray(n.marks) + ? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",") + : ""; + parts.push(`${n.text}${marks}`); + } + if (Array.isArray(n.content)) for (const c of n.content) visit(c); + }; + visit(defNode); + // Collapse the assembled text's whitespace and trim, keeping the mark + // signature attached so formatting differences still distinguish notes. + return parts + .join("") + .replace(/[ \t\r\n]+/g, " ") + .trim(); +} + +/** + * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. + */ +export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any { + const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph", content }], + }; +} + +/** + * Generate a uuidv7-style id (time-ordered), matching editor-ext's + * `generateFootnoteId`. Used for a genuinely-new inline footnote id. + */ +export function generateFootnoteId(): string { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + const rand = (length: number) => { + let s = ""; + for (let i = 0; i < length; i++) + s += Math.floor(Math.random() * 16).toString(16); + return s; + }; + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + return ( + timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12) + ); +} diff --git a/packages/prosemirror-markdown/src/lib/index.ts b/packages/prosemirror-markdown/src/lib/index.ts index f95e6a6d..3279dfc6 100644 --- a/packages/prosemirror-markdown/src/lib/index.ts +++ b/packages/prosemirror-markdown/src/lib/index.ts @@ -44,3 +44,35 @@ export { docsCanonicallyEqual, } from "./canonicalize.js"; export { parsePageFile, serializePageFile } from "./page-file.js"; + +// Pure, network-free helpers for manipulating a ProseMirror/TipTap document +// tree by node id (#414: the single canonical copy, formerly forked into mcp). +// Consumed by `@docmost/mcp` (patch/insert/delete node, table tools, outline). +export { + blockPlainText, + buildOutline, + getNodeByRef, + replaceNodeById, + deleteNodeById, + sanitizeForYjs, + findUnstorableAttr, + insertNodeRelative, + readTable, + insertTableRow, + deleteTableRow, + updateTableCell, + assertUnambiguousMatch, +} from "./node-ops.js"; +export type { OutlineEntry } from "./node-ops.js"; + +// Normalize a ProseMirror node arg that the model may have serialized as a JSON +// string (#414: single copy shared by mcp and the CommonJS server app). +export { parseNodeArg } from "./parse-node-arg.js"; + +// Inline-footnote authoring convention (#414: single copy, formerly the mcp +// `footnote-authoring.ts` fork), shared with the importer's `assembleFootnotes`. +export { + footnoteContentKey, + makeFootnoteDefinition, + generateFootnoteId, +} from "./footnote.js"; diff --git a/packages/prosemirror-markdown/src/lib/node-ops.ts b/packages/prosemirror-markdown/src/lib/node-ops.ts index 4934b216..cdb67902 100644 --- a/packages/prosemirror-markdown/src/lib/node-ops.ts +++ b/packages/prosemirror-markdown/src/lib/node-ops.ts @@ -14,6 +14,8 @@ * `content`, non-object nodes, and absent `attrs` are tolerated. */ +import { stripInlineMarkdown } from "./text-normalize.js"; + /** Deep-clone a JSON-serializable value without mutating the original. */ function clone(value: T): T { if (typeof structuredClone === "function") { @@ -97,12 +99,15 @@ export function buildOutline(doc: any): OutlineEntry[] { const entry: OutlineEntry = { index: i, type, - id: isObject(block) && isObject(block.attrs) ? block.attrs.id ?? null : null, + id: + isObject(block) && isObject(block.attrs) + ? (block.attrs.id ?? null) + : null, firstText: truncate(blockPlainText(block), 100), }; if (type === "heading") { - entry.level = isObject(block.attrs) ? block.attrs.level ?? null : null; + entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null; } else if (type === "table") { const headerRow = block.content?.[0]?.content ?? []; entry.rows = block.content?.length ?? 0; @@ -247,6 +252,33 @@ export function deleteNodeById( return { doc: out, deleted }; } +/** + * Throw a clear, model-actionable error when a node-id write op did NOT match + * exactly one node (#159). `count === 0` -> "no node found"; `count > 1` -> + * "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write + * by id could clobber/remove EVERY duplicate. The caller skips the write for any + * `count !== 1` (the transform returns null), so this only REPORTS; nothing was + * changed. No-op for the unambiguous single-match case. + */ +export function assertUnambiguousMatch( + op: "patch_node" | "delete_node", + verb: "replace" | "delete", + count: number, + nodeId: string, + pageId: string, +): void { + if (count === 0) { + throw new Error( + `${op}: no node with id "${nodeId}" found on page ${pageId}`, + ); + } + if (count > 1) { + throw new Error( + `${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`, + ); + } +} + /** * Deep-clone `doc` and strip every node/mark attribute whose value is strictly * `undefined`, so the result is safe to hand to Yjs (which throws an opaque @@ -364,6 +396,31 @@ const REQUIRED_CONTAINER: Record = { tableHeader: "tableRow", }; +/** + * Find the index of the first TOP-LEVEL block whose plain text includes the + * anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches. + * + * Two passes preserve "exact wins globally": + * - Pass 1: first block containing the verbatim `anchorText`. + * - Pass 2 (only if pass 1 found nothing): first block containing the + * markdown-stripped anchor, when stripping actually changed it. + */ +function findAnchorTextIndex(content: any[], anchorText: string): number { + if (!Array.isArray(content)) return -1; + // Pass 1: exact. + for (let i = 0; i < content.length; i++) { + if (blockPlainText(content[i]).includes(anchorText)) return i; + } + // Pass 2: markdown-stripped fallback. + const a = stripInlineMarkdown(anchorText); + if (a !== anchorText && a.length > 0) { + for (let i = 0; i < content.length; i++) { + if (blockPlainText(content[i]).includes(a)) return i; + } + } + return -1; +} + /** * Locate an anchor and return its ancestor chain (from `doc` down to and * including the matched node). Each chain entry is `{ node, index }` where @@ -399,14 +456,14 @@ function findAnchorChain( } // By text: only top-level blocks are scanned (same rule as the JSON path). + // Exact match wins; a markdown-stripped fallback is tried only on a miss. if (opts.anchorText != null && Array.isArray(doc.content)) { - for (let i = 0; i < doc.content.length; i++) { - if (blockPlainText(doc.content[i]).includes(opts.anchorText)) { - return [ - { node: doc, index: -1 }, - { node: doc.content[i], index: i }, - ]; - } + const i = findAnchorTextIndex(doc.content, opts.anchorText); + if (i !== -1) { + return [ + { node: doc, index: -1 }, + { node: doc.content[i], index: i }, + ]; } } @@ -540,13 +597,13 @@ export function insertNodeRelative( return { doc: out, inserted }; } - // Resolve by text: only top-level doc.content blocks are scanned. + // Resolve by text: only top-level doc.content blocks are scanned. Exact + // match wins; a markdown-stripped fallback is tried only on a miss. if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) { - for (let i = 0; i < out.content.length; i++) { - if (blockPlainText(out.content[i]).includes(opts.anchorText)) { - out.content.splice(i + offset, 0, fresh); - return { doc: out, inserted: true }; - } + const i = findAnchorTextIndex(out.content, opts.anchorText); + if (i !== -1) { + out.content.splice(i + offset, 0, fresh); + return { doc: out, inserted: true }; } } @@ -617,7 +674,8 @@ function locateTable( if (!isObject(rootClone)) return null; // "#": index into the top-level content array; must be a table. - const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null; + const indexMatch = + typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null; if (indexMatch) { const index = Number(indexMatch[1]); const block = Array.isArray(rootClone.content) @@ -717,7 +775,7 @@ export function readTable( : undefined; const id = isObject(firstPara) && isObject(firstPara.attrs) - ? firstPara.attrs.id ?? null + ? (firstPara.attrs.id ?? null) : null; rowIds.push(id); } @@ -751,14 +809,17 @@ export function insertTableRow( if (!Array.isArray(table.content)) table.content = []; const rows = table.content.length; const headerRow = table.content[0]; - const headerCells = Array.isArray(headerRow?.content) ? headerRow.content : []; + const headerCells = Array.isArray(headerRow?.content) + ? headerRow.content + : []; // Column count is the WIDEST existing row, so the guard below stays // meaningful for ragged tables and the new row matches the table's width. // Fall back to the supplied cell count only when the table has no rows. let colCount = 0; for (const r of table.content) { - if (isObject(r) && Array.isArray(r.content)) colCount = Math.max(colCount, r.content.length); + if (isObject(r) && Array.isArray(r.content)) + colCount = Math.max(colCount, r.content.length); } if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0; @@ -771,7 +832,10 @@ export function insertTableRow( // Resolve the landing index up front so the cell-type decision and the splice // below agree: a valid integer in [0, rows] splices there, else we append. const landingIndex = - typeof index === "number" && Number.isInteger(index) && index >= 0 && index <= rows + typeof index === "number" && + Number.isInteger(index) && + index >= 0 && + index <= rows ? index : rows; @@ -790,7 +854,8 @@ export function insertTableRow( // A row landing at index 0 becomes the new header row, so inherit the // current header cell's type per column (Docmost uses "tableHeader" there); // every other position is a plain data cell. - const cellType = landingIndex === 0 ? headerCells[i]?.type ?? "tableCell" : "tableCell"; + const cellType = + landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell"; newCells.push({ type: cellType, attrs, @@ -862,9 +927,10 @@ export function updateTableCell( const rowNodes = Array.isArray(table.content) ? table.content : []; const rows = rowNodes.length; const rowNode = rowNodes[row]; - const cols = isObject(rowNode) && Array.isArray(rowNode.content) - ? rowNode.content.length - : 0; + const cols = + isObject(rowNode) && Array.isArray(rowNode.content) + ? rowNode.content.length + : 0; if ( !Number.isInteger(row) || diff --git a/packages/mcp/src/lib/parse-node-arg.ts b/packages/prosemirror-markdown/src/lib/parse-node-arg.ts similarity index 63% rename from packages/mcp/src/lib/parse-node-arg.ts rename to packages/prosemirror-markdown/src/lib/parse-node-arg.ts index 2e97da42..5ba764fb 100644 --- a/packages/mcp/src/lib/parse-node-arg.ts +++ b/packages/prosemirror-markdown/src/lib/parse-node-arg.ts @@ -2,6 +2,11 @@ // instead of an object. Normalize: parse a string to an object (throwing on // invalid JSON), pass an object through unchanged. Shared by patch_node / // insert_node (and the analogous update_page_json content parsing). +// +// This lives in the converter package (#414) so BOTH consumers import the ONE +// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot +// import `@docmost/mcp` directly (ESM-only, no declaration files), but it does +// import `@docmost/prosemirror-markdown` natively — so this is the shared home. export function parseNodeArg( node: unknown, errMsg = "node was a string but not valid JSON", diff --git a/packages/prosemirror-markdown/src/lib/text-normalize.ts b/packages/prosemirror-markdown/src/lib/text-normalize.ts new file mode 100644 index 00000000..ea84d648 --- /dev/null +++ b/packages/prosemirror-markdown/src/lib/text-normalize.ts @@ -0,0 +1,99 @@ +/** + * Locator normalization: strip inline markdown wrappers and trailing + * decoration from a LOCATOR string so a find/anchor that the model wrote with + * markdown (or a stray emoji) can still match the document's plain text. + * + * This is used ONLY as a fallback for LOCATING (after an exact match fails); + * it is never applied to replacement text or inserted node content, so no + * formatting is ever lost. + * + * Scope note (#414): this package-local copy exists so `node-ops.ts` — which + * lives here now (the single canonical copy) — can resolve its markdown-tolerant + * anchor fallback without a circular dependency back on `@docmost/mcp`. It + * intentionally carries ONLY `stripInlineMarkdown` (the primitive `node-ops` + * needs); the mcp-side `text-normalize.ts` (which additionally serves + * `json-edit.ts` via `stripBalancedWrappers`) is the subject of a separate + * dedup task and is left untouched here. + */ + +/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */ +const MAX_PASSES = 8; + +/** + * Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so + * `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is + * non-greedy and capture group 1 is the inner text. Applied repeatedly until + * the string stops changing (nested wrappers like `**_x_**`). + */ +const WRAPPER_PATTERNS: RegExp[] = [ + /\*\*([^*]+?)\*\*/g, // **x** + /__([^_]+?)__/g, // __x__ + /~~([^~]+?)~~/g, // ~~x~~ + /\*([^*]+?)\*/g, // *x* + /_([^_]+?)_/g, // _x_ + /``([^`]+?)``/g, // ``x`` + /`([^`]+?)`/g, // `x` +]; + +/** Links/images -> their visible text. `!?` covers both `[t](u)` and `![a](s)`. */ +const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g; + +/** + * Apply the two balanced/link passes: first collapse links/images to their + * visible text, then collapse balanced inline wrappers repeatedly until stable. + * Does NOT trim decoration, does NOT guard against an empty result — it returns + * exactly the transformed string. + */ +function stripWrappersAndLinks(s: string): string { + // 1. Links/images -> their visible text. + let out = s.replace(LINK_IMAGE_RE, "$1"); + + // 2. Strip balanced wrappers, repeating until the string is stable so nested + // wrappers (`**_x_**`) and adjacent runs both collapse. + for (let pass = 0; pass < MAX_PASSES; pass++) { + const before = out; + for (const re of WRAPPER_PATTERNS) { + out = out.replace(re, "$1"); + } + if (out === before) break; + } + return out; +} + +/** + * Conservatively strip inline markdown from a locator string. + * + * Deterministic, order-fixed steps: + * 1. Links/images: `[text](url)` -> `text`, `![alt](src)` -> `alt`. + * 2. Balanced inline wrappers (strong before emphasis, code, strikethrough), + * applied repeatedly until stable for nested cases. + * 3. Trim leading/trailing decoration only: whitespace, leftover marker chars + * (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,` + * etc.) are NEVER trimmed. + * + * If the result is empty (e.g. the input was only markers like `***`), the + * ORIGINAL string is returned so a locator can never normalize down to "" and + * match everything. + */ +export function stripInlineMarkdown(s: string): string { + if (typeof s !== "string" || s.length === 0) return s; + + // 1 + 2. Shared link/image and balanced-wrapper passes. + let out = stripWrappersAndLinks(s); + + // 3. Trim leading/trailing decoration: whitespace, leftover markdown markers, + // and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the + // regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT + // Extended_Pictographic). The `u` flag enables the Unicode property escape. + // Anchored runs only — interior text and sentence punctuation are untouched. + const DECORATION = + "[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+"; + out = out + .replace(new RegExp("^" + DECORATION, "u"), "") + .replace(new RegExp(DECORATION + "$", "u"), ""); + + // 4. Never normalize a locator down to nothing. + if (out.length === 0) return s; + + return out; +}