Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2af3d34a |
@@ -49,14 +49,19 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
{/* #146: contentDOM MUST be the first child — non-editable chrome before
|
||||
{/* #146: contentDOM MUST be the first child — a non-editable marker before
|
||||
it makes click hit-testing snap the caret above. Content first; the
|
||||
back-link follows in DOM and is placed on the right via CSS flex. The
|
||||
decorative "N." number is rendered inline via the .definitionContent
|
||||
::before rule (from the --footnote-number var), so no marker element
|
||||
precedes the content. The second #146 mitigation lives in
|
||||
marker + back-link follow in DOM and are placed left/right via CSS
|
||||
flex `order`. The second #146 mitigation lives in
|
||||
editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.definitionMarker}
|
||||
contentEditable={false}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}.
|
||||
</span>
|
||||
{refCount > 1 ? (
|
||||
// Multiple references -> ↩ followed by one lettered link per occurrence.
|
||||
<span
|
||||
|
||||
@@ -81,34 +81,34 @@
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Tight spacing between the content and the trailing ↩ back-link. */
|
||||
gap: 0.3em;
|
||||
/* Tight number→text spacing (~one space) so it reads like "1. text"
|
||||
instead of leaving a wide gap after the period. */
|
||||
gap: 0.4em;
|
||||
padding: 2px 0;
|
||||
/* Footnotes read smaller than body text (16px). Matches .listHeading. */
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
/* The "N." number is decorative (from the --footnote-number CSS var on the
|
||||
wrapper, never in the document model) and is rendered inline at the start of
|
||||
the first content line via ::before. This keeps text and wrapped lines flush
|
||||
to the left margin — no hanging indent — while the editable contentDOM stays
|
||||
the FIRST DOM child (#146). */
|
||||
.definitionMarker {
|
||||
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.definitionContent > :first-child::before {
|
||||
content: var(--footnote-number, "?") ". ";
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
|
||||
Drop the outer margins so the definition sits tight to the heading above and
|
||||
the ::before number aligns with the top of the row — same approach used for
|
||||
callouts in core.css. */
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
|
||||
which pushes the first text line ~0.5em below the "N." marker (aligned to
|
||||
flex-start), making the number float above the text. Drop the outer margins
|
||||
so the marker and the first line share the same top edge — same approach
|
||||
used for callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
const referencesInTables = new Set<string>();
|
||||
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<string, string[]>();
|
||||
|
||||
// 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] }
|
||||
: {};
|
||||
}
|
||||
|
||||
@@ -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<T>(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)
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<T>(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<string, any> {
|
||||
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 `#<n>` (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;
|
||||
|
||||
// "#<n>": 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<string, string> = {
|
||||
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<string>): 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>): 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.
|
||||
*
|
||||
* - `#<n>`: 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;
|
||||
|
||||
// "#<n>": 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<string>();
|
||||
collectIds(out, used);
|
||||
|
||||
const newCells: any[] = [];
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
|
||||
const attrs: Record<string, any> = { 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<string>();
|
||||
collectIds(out, used);
|
||||
id = makeFreshId(used);
|
||||
}
|
||||
|
||||
cellNode.content = [makeCellParagraph(id, text)];
|
||||
return { doc: out, updated: true };
|
||||
}
|
||||
@@ -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<typeof RE2>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<T>(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<string, string> = {
|
||||
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;
|
||||
|
||||
// "#<n>": 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) ||
|
||||
|
||||
+5
@@ -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",
|
||||
@@ -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 ``. */
|
||||
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`.
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user