Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72c2d1687e | |||
| c9293e316b |
@@ -17,7 +17,7 @@ import {
|
|||||||
resolveCurrentPageResult,
|
resolveCurrentPageResult,
|
||||||
type SelectionContext,
|
type SelectionContext,
|
||||||
} from './current-page.util';
|
} from './current-page.util';
|
||||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the shared `parseNodeArg` helper (#414: now the single copy in
|
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
|
||||||
* `@docmost/prosemirror-markdown`, imported by both the server tool adapters and
|
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
|
||||||
* `@docmost/mcp`). Used by the patchNode / insertNode / updatePageJson adapters.
|
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
|
||||||
* Behavior: object passthrough, valid-string parse, invalid-string throw.
|
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
|
||||||
*/
|
*/
|
||||||
describe('parseNodeArg', () => {
|
describe('parseNodeArg', () => {
|
||||||
it('passes an object through unchanged', () => {
|
it('passes an object through unchanged', () => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
+119
-17
@@ -40,13 +40,14 @@ import {
|
|||||||
deleteNodeById,
|
deleteNodeById,
|
||||||
assertUnambiguousMatch,
|
assertUnambiguousMatch,
|
||||||
insertNodeRelative,
|
insertNodeRelative,
|
||||||
|
blockPlainText,
|
||||||
buildOutline,
|
buildOutline,
|
||||||
getNodeByRef,
|
getNodeByRef,
|
||||||
readTable,
|
readTable,
|
||||||
insertTableRow,
|
insertTableRow,
|
||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "./lib/node-ops.js";
|
||||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||||
import { withPageLock } from "./lib/page-lock.js";
|
import { withPageLock } from "./lib/page-lock.js";
|
||||||
import {
|
import {
|
||||||
@@ -59,10 +60,12 @@ import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
|||||||
import { diffDocs, summarizeChange } from "./lib/diff.js";
|
import { diffDocs, summarizeChange } from "./lib/diff.js";
|
||||||
import {
|
import {
|
||||||
applyAnchorInDoc,
|
applyAnchorInDoc,
|
||||||
canAnchorInDoc,
|
|
||||||
countAnchorMatches,
|
countAnchorMatches,
|
||||||
getAnchoredText,
|
getAnchoredText,
|
||||||
|
resolveAnchorSelection,
|
||||||
|
normalizeForMatch,
|
||||||
} from "./lib/comment-anchor.js";
|
} from "./lib/comment-anchor.js";
|
||||||
|
import { closestBlockHint } from "./lib/text-normalize.js";
|
||||||
import {
|
import {
|
||||||
blockText,
|
blockText,
|
||||||
walk,
|
walk,
|
||||||
@@ -2474,6 +2477,64 @@ export class DocmostClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
|
||||||
|
private topLevelBlockTexts(doc: any): string[] {
|
||||||
|
const content = doc && Array.isArray(doc.content) ? doc.content : [];
|
||||||
|
return content
|
||||||
|
.map((b: any) => blockPlainText(b))
|
||||||
|
.filter((t: string) => t.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when per-block anchoring failed but the (normalized) selection DOES
|
||||||
|
* appear in the blocks' joined plain text — i.e. it straddles a block
|
||||||
|
* boundary. Blocks are joined with a newline (collapsed to one space by
|
||||||
|
* normalizeForMatch) so a selection whose parts are separated by a paragraph
|
||||||
|
* break still matches. Callers only reach here after single-block anchoring
|
||||||
|
* (incl. the markdown-strip fallback) has already failed.
|
||||||
|
*/
|
||||||
|
private selectionSpansMultipleBlocks(
|
||||||
|
blockTexts: string[],
|
||||||
|
selection: string,
|
||||||
|
): boolean {
|
||||||
|
const normSel = normalizeForMatch(selection).norm.trim();
|
||||||
|
if (normSel.length === 0) return false;
|
||||||
|
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
|
||||||
|
return joined.indexOf(normSel) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the actionable error for a create_comment anchor MISS, porting
|
||||||
|
* edit_page_text's self-correction affordances: an explicit "spans multiple
|
||||||
|
* blocks" message when the selection straddles a block boundary, otherwise a
|
||||||
|
* "closest block text" hint quoting the block that holds the selection's
|
||||||
|
* longest token. `live` switches the wording between the pre-check (reading the
|
||||||
|
* persisted page) and the post-create live-anchor failure (which rolls back).
|
||||||
|
*/
|
||||||
|
private anchorNotFoundError(
|
||||||
|
doc: any,
|
||||||
|
selection: string,
|
||||||
|
live: boolean,
|
||||||
|
): Error {
|
||||||
|
const blockTexts = this.topLevelBlockTexts(doc);
|
||||||
|
const rolled = live ? " The comment was rolled back." : "";
|
||||||
|
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
|
||||||
|
return new Error(
|
||||||
|
"create_comment: the selection spans multiple blocks; anchor on a " +
|
||||||
|
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
|
||||||
|
rolled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = live ? "in the live document" : "in the page";
|
||||||
|
return new Error(
|
||||||
|
`create_comment: could not find the selection text ${where} to anchor ` +
|
||||||
|
"the comment. Provide the EXACT contiguous text from a single " +
|
||||||
|
"paragraph/block (<=250 chars)." +
|
||||||
|
closestBlockHint(blockTexts, selection) +
|
||||||
|
rolled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an inline comment anchored to its `selection` text, or a reply.
|
* Create an inline comment anchored to its `selection` text, or a reply.
|
||||||
*
|
*
|
||||||
@@ -2535,6 +2596,10 @@ export class DocmostClient {
|
|||||||
// Captured in the pre-check below (which already reads the page) and used as
|
// Captured in the pre-check below (which already reads the page) and used as
|
||||||
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
||||||
let anchoredSelection: string | null = null;
|
let anchoredSelection: string | null = null;
|
||||||
|
// Set when the anchor matched only after stripping markdown from the
|
||||||
|
// selection (the strip fallback); surfaced as a soft warning like
|
||||||
|
// edit_page_text does, so a stale-markdown selection is flagged.
|
||||||
|
let anchorNormalized = false;
|
||||||
|
|
||||||
// For a top-level comment, fail BEFORE creating anything when the selection
|
// For a top-level comment, fail BEFORE creating anything when the selection
|
||||||
// is not present in the persisted document — this avoids leaving an orphan
|
// is not present in the persisted document — this avoids leaving an orphan
|
||||||
@@ -2550,10 +2615,7 @@ export class DocmostClient {
|
|||||||
// rejected BEFORE creating the comment.
|
// rejected BEFORE creating the comment.
|
||||||
const matches = countAnchorMatches(page.content, selection);
|
const matches = countAnchorMatches(page.content, selection);
|
||||||
if (matches === 0) {
|
if (matches === 0) {
|
||||||
throw new Error(
|
throw this.anchorNotFoundError(page.content, selection, false);
|
||||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
|
||||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (matches >= 2) {
|
if (matches >= 2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -2567,18 +2629,27 @@ export class DocmostClient {
|
|||||||
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
||||||
// the raw agent selection below rather than crash.
|
// the raw agent selection below rather than crash.
|
||||||
anchoredSelection = getAnchoredText(page.content, selection);
|
anchoredSelection = getAnchoredText(page.content, selection);
|
||||||
} else if (!canAnchorInDoc(page.content, selection)) {
|
anchorNormalized = resolveAnchorSelection(
|
||||||
throw new Error(
|
page.content,
|
||||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
selection,
|
||||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
).normalized;
|
||||||
);
|
} else {
|
||||||
|
const resolved = resolveAnchorSelection(page.content, selection);
|
||||||
|
if (!resolved.found) {
|
||||||
|
throw this.anchorNotFoundError(page.content, selection, false);
|
||||||
|
}
|
||||||
|
anchorNormalized = resolved.normalized;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
|
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
|
||||||
// errors so the live anchor step can still try (and enforce) anchoring.
|
// swallow read/network errors so the live anchor step can still try (and
|
||||||
|
// enforce) anchoring.
|
||||||
if (
|
if (
|
||||||
e instanceof Error &&
|
e instanceof Error &&
|
||||||
(e.message.startsWith("create_comment: could not find the selection") ||
|
(e.message.startsWith("create_comment: could not find the selection") ||
|
||||||
|
e.message.startsWith(
|
||||||
|
"create_comment: the selection spans multiple blocks",
|
||||||
|
) ||
|
||||||
e.message.startsWith(
|
e.message.startsWith(
|
||||||
"create_comment: the suggestion's selection is ambiguous",
|
"create_comment: the suggestion's selection is ambiguous",
|
||||||
))
|
))
|
||||||
@@ -2650,6 +2721,10 @@ export class DocmostClient {
|
|||||||
// Set inside the transform when a suggestion's live anchor is ambiguous
|
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||||
// (>=2 occurrences), so the rollback path can surface the right error.
|
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||||
let ambiguousInLiveDoc = false;
|
let ambiguousInLiveDoc = false;
|
||||||
|
// Captured inside the transform on a not-found abort, so the rollback path
|
||||||
|
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||||
|
// LIVE document (the pre-check page is not in scope there).
|
||||||
|
let liveNotFoundError: Error | null = null;
|
||||||
try {
|
try {
|
||||||
const collabToken = await this.getCollabTokenWithReauth();
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||||
@@ -2677,6 +2752,13 @@ export class DocmostClient {
|
|||||||
const liveCount = countAnchorMatches(doc, selection as string);
|
const liveCount = countAnchorMatches(doc, selection as string);
|
||||||
if (liveCount !== 1) {
|
if (liveCount !== 1) {
|
||||||
ambiguousInLiveDoc = liveCount >= 2;
|
ambiguousInLiveDoc = liveCount >= 2;
|
||||||
|
if (liveCount === 0) {
|
||||||
|
liveNotFoundError = this.anchorNotFoundError(
|
||||||
|
doc,
|
||||||
|
selection as string,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2686,6 +2768,11 @@ export class DocmostClient {
|
|||||||
}
|
}
|
||||||
// Selection text not found in the LIVE document: abort the write. The
|
// Selection text not found in the LIVE document: abort the write. The
|
||||||
// rollback + throw below turns this into a hard error.
|
// rollback + throw below turns this into a hard error.
|
||||||
|
liveNotFoundError = this.anchorNotFoundError(
|
||||||
|
doc,
|
||||||
|
selection as string,
|
||||||
|
true,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2702,13 +2789,28 @@ export class DocmostClient {
|
|||||||
// suggestion, was ambiguous) in the live document. Roll back the comment
|
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||||
// and surface a hard error.
|
// and surface a hard error.
|
||||||
await this.safeDeleteComment(newCommentId);
|
await this.safeDeleteComment(newCommentId);
|
||||||
throw new Error(
|
if (ambiguousInLiveDoc) {
|
||||||
ambiguousInLiveDoc
|
throw new Error(
|
||||||
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
|
"create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
|
||||||
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
);
|
||||||
|
}
|
||||||
|
throw (
|
||||||
|
liveNotFoundError ??
|
||||||
|
new Error(
|
||||||
|
"create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft warning (like edit_page_text): the selection only matched after
|
||||||
|
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||||
|
if (anchorNormalized) {
|
||||||
|
result.warning =
|
||||||
|
"The selection matched only after stripping markdown syntax; the comment " +
|
||||||
|
"was anchored on the document's plain text. Copy the selection verbatim " +
|
||||||
|
"from get_page / search_in_page output to avoid this.";
|
||||||
|
}
|
||||||
|
|
||||||
result.anchored = true;
|
result.anchored = true;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||||
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||||
|
|
||||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
// 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 { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||||
import { withPageLock } from "./page-lock.js";
|
import { withPageLock } from "./page-lock.js";
|
||||||
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
|
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
import { summarizeChange, VerifyReport } from "./diff.js";
|
import { summarizeChange, VerifyReport } from "./diff.js";
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,23 @@
|
|||||||
* comparing and match across maximal runs of consecutive text nodes within a
|
* comparing and match across maximal runs of consecutive text nodes within a
|
||||||
* single block, while mapping every normalized character back to its raw index
|
* single block, while mapping every normalized character back to its raw index
|
||||||
* so the mark lands on the exact original characters.
|
* so the mark lands on the exact original characters.
|
||||||
|
*
|
||||||
|
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
|
||||||
|
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
|
||||||
|
* match the document's plain text. Exactly like edit_page_text's json-edit
|
||||||
|
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
|
||||||
|
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
|
||||||
|
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
|
||||||
|
* `resolveAnchorSelection`. `countAnchorMatches` keeps its OWN parallel exact-wins
|
||||||
|
* implementation (it needs a raw match COUNT, not a single resolved locator), kept
|
||||||
|
* deliberately in sync with `resolveAnchorSelection`: raw match ⇒ use raw, else fall
|
||||||
|
* back to the stripped count. All four therefore agree on which locator matched —
|
||||||
|
* the suggestion-uniqueness gate depends on count and can/get never disagreeing, so
|
||||||
|
* these two exact-wins implementations MUST stay in sync if either is changed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stripInlineMarkdown } from "./text-normalize.js";
|
||||||
|
|
||||||
/** Typographic double-quote variants mapped to ASCII `"`. */
|
/** Typographic double-quote variants mapped to ASCII `"`. */
|
||||||
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
|
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
|
||||||
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
|
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
|
||||||
@@ -214,15 +229,17 @@ function reconstructRawText(blockContent: any[], match: AnchorMatch): string {
|
|||||||
* un-appliable (spurious 409).
|
* un-appliable (spurious 409).
|
||||||
*/
|
*/
|
||||||
export function getAnchoredText(doc: any, selection: string): string | null {
|
export function getAnchoredText(doc: any, selection: string): string | null {
|
||||||
|
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
|
||||||
|
if (!found) return null;
|
||||||
const visit = (node: any, depth: number): string | null => {
|
const visit = (node: any, depth: number): string | null => {
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return null;
|
if (depth > MAX_DEPTH || !node || typeof node !== "object") return null;
|
||||||
if (!Array.isArray(node.content)) return null;
|
if (!Array.isArray(node.content)) return null;
|
||||||
const match = findAnchorInBlock(node.content, selection);
|
const match = findAnchorInBlock(node.content, effective);
|
||||||
if (match) return reconstructRawText(node.content, match);
|
if (match) return reconstructRawText(node.content, match);
|
||||||
for (const child of node.content) {
|
for (const child of node.content) {
|
||||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
||||||
const found = visit(child, depth + 1);
|
const foundText = visit(child, depth + 1);
|
||||||
if (found !== null) return found;
|
if (foundText !== null) return foundText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -231,12 +248,11 @@ export function getAnchoredText(doc: any, selection: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Depth-first, document-order check for whether `selection` can be anchored
|
* RAW (no markdown-strip fallback) depth-first check that `selection` anchors
|
||||||
* anywhere in `doc`. At each node with an array `content`, first try to match
|
* somewhere in `doc`. This is the primitive `resolveAnchorSelection` builds on;
|
||||||
* within that node's own content, then recurse into children that themselves
|
* public callers should use `canAnchorInDoc`, which adds the strip fallback.
|
||||||
* have a `content` array.
|
|
||||||
*/
|
*/
|
||||||
export function canAnchorInDoc(doc: any, selection: string): boolean {
|
function rawCanAnchorInDoc(doc: any, selection: string): boolean {
|
||||||
const visit = (node: any, depth: number): boolean => {
|
const visit = (node: any, depth: number): boolean => {
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
|
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
|
||||||
if (!Array.isArray(node.content)) return false;
|
if (!Array.isArray(node.content)) return false;
|
||||||
@@ -251,6 +267,43 @@ export function canAnchorInDoc(doc: any, selection: string): boolean {
|
|||||||
return visit(doc, 0);
|
return visit(doc, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the locator that ACTUALLY anchors `selection` in `doc`, applying the
|
||||||
|
* markdown-strip fallback once (so every public entry point agrees):
|
||||||
|
* - EXACT WINS: if the verbatim selection anchors anywhere, use it as-is.
|
||||||
|
* - FALLBACK: only if the verbatim selection anchors nowhere, and the
|
||||||
|
* markdown-stripped form differs and DOES anchor, use the stripped form and
|
||||||
|
* flag `normalized` so callers can surface a soft warning.
|
||||||
|
* - otherwise `found` is false and `selection` is returned unchanged.
|
||||||
|
*
|
||||||
|
* The stripped form is used ONLY to LOCATE the anchor; getAnchoredText still
|
||||||
|
* reconstructs and stores the RAW document substring, so the strip never leaks
|
||||||
|
* into what gets persisted.
|
||||||
|
*/
|
||||||
|
export function resolveAnchorSelection(
|
||||||
|
doc: any,
|
||||||
|
selection: string,
|
||||||
|
): { selection: string; found: boolean; normalized: boolean } {
|
||||||
|
if (rawCanAnchorInDoc(doc, selection)) {
|
||||||
|
return { selection, found: true, normalized: false };
|
||||||
|
}
|
||||||
|
const stripped = stripInlineMarkdown(selection);
|
||||||
|
if (stripped !== selection && rawCanAnchorInDoc(doc, stripped)) {
|
||||||
|
return { selection: stripped, found: true, normalized: true };
|
||||||
|
}
|
||||||
|
return { selection, found: false, normalized: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth-first, document-order check for whether `selection` can be anchored
|
||||||
|
* anywhere in `doc` (with the markdown-strip fallback). At each node with an
|
||||||
|
* array `content`, first try to match within that node's own content, then
|
||||||
|
* recurse into children that themselves have a `content` array.
|
||||||
|
*/
|
||||||
|
export function canAnchorInDoc(doc: any, selection: string): boolean {
|
||||||
|
return resolveAnchorSelection(doc, selection).found;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split the matched text nodes and splice the comment mark across the range.
|
* Split the matched text nodes and splice the comment mark across the range.
|
||||||
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
|
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
|
||||||
@@ -315,7 +368,7 @@ function spliceCommentMark(
|
|||||||
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
|
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
|
||||||
* occurrences inside one block are correctly reported as 2.)
|
* occurrences inside one block are correctly reported as 2.)
|
||||||
*/
|
*/
|
||||||
export function countAnchorMatches(doc: any, selection: string): number {
|
function rawCountAnchorMatches(doc: any, selection: string): number {
|
||||||
const normSel = normalizeForMatch(selection).norm.trim();
|
const normSel = normalizeForMatch(selection).norm.trim();
|
||||||
if (normSel.length === 0) return 0;
|
if (normSel.length === 0) return 0;
|
||||||
|
|
||||||
@@ -369,6 +422,25 @@ export function countAnchorMatches(doc: any, selection: string): number {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniqueness gate for suggestions, with the SAME markdown-strip fallback as the
|
||||||
|
* other entry points so count never disagrees with can/get/apply. EXACT WINS: if
|
||||||
|
* the verbatim selection occurs at all, return its raw occurrence count (so a
|
||||||
|
* selection that is unique raw stays unique — the fallback never runs and cannot
|
||||||
|
* introduce a spurious second match). Only when the verbatim selection is absent
|
||||||
|
* do we count occurrences of the markdown-stripped form.
|
||||||
|
*/
|
||||||
|
export function countAnchorMatches(doc: any, selection: string): number {
|
||||||
|
const raw = rawCountAnchorMatches(doc, selection);
|
||||||
|
if (raw > 0) return raw;
|
||||||
|
const stripped = stripInlineMarkdown(selection);
|
||||||
|
if (stripped !== selection) {
|
||||||
|
const strippedCount = rawCountAnchorMatches(doc, stripped);
|
||||||
|
if (strippedCount > 0) return strippedCount;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
|
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
|
||||||
* whose content matches `selection`, splice the comment mark across the matched
|
* whose content matches `selection`, splice the comment mark across the matched
|
||||||
@@ -380,10 +452,12 @@ export function applyAnchorInDoc(
|
|||||||
selection: string,
|
selection: string,
|
||||||
commentId: string,
|
commentId: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
|
||||||
|
if (!found) return false;
|
||||||
const visit = (node: any, depth: number): boolean => {
|
const visit = (node: any, depth: number): boolean => {
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
|
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
|
||||||
if (!Array.isArray(node.content)) return false;
|
if (!Array.isArray(node.content)) return false;
|
||||||
const match = findAnchorInBlock(node.content, selection);
|
const match = findAnchorInBlock(node.content, effective);
|
||||||
if (match) {
|
if (match) {
|
||||||
spliceCommentMark(node.content, match, commentId);
|
spliceCommentMark(node.content, match, commentId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,62 +1,136 @@
|
|||||||
/**
|
/**
|
||||||
* Legacy footnote advisory for imported Markdown (issue #166, reduced in #414).
|
* Legacy footnote diagnostics for imported Markdown (issue #166).
|
||||||
*
|
*
|
||||||
* Since #293 STEP 5 the canonical import form is inline `^[body]` footnotes
|
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
||||||
* (handled by `@docmost/prosemirror-markdown`). LEGACY reference-style
|
* conversion path, so it reports the same problems for `create_page`,
|
||||||
* `[^id]: …` definition markup is now INERT on import — the importer leaves it as
|
* `update_page` and `import_page_markdown`). It never changes the document — the
|
||||||
* literal text — so authoring it silently produces broken footnotes (the #410
|
* importer still creates the page; this only surfaces footnote problems to the
|
||||||
* incident class). Rather than the old, elaborate diagnostics of every problem
|
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* The scan is fence-aware: a `[^id]:` line inside a ``` / ~~~ code block is
|
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
|
||||||
* example text, not markup, so it never triggers the warning.
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A legacy footnote DEFINITION line: `[^id]:` at the start of a (non-fenced) line. */
|
import {
|
||||||
const FOOTNOTE_DEF_RE = /^\[\^[^\]\s]+\]:/;
|
lexFootnoteLines,
|
||||||
/** Opening/closing code fence marker (``` or ~~~). */
|
forEachFootnoteReference,
|
||||||
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
} from "./footnote-lex.js";
|
||||||
|
|
||||||
/** The single advisory shown when legacy reference-style footnotes are present. */
|
export interface FootnoteDiagnostics {
|
||||||
export const LEGACY_FOOTNOTE_WARNING =
|
/** Reference ids (distinct, document order) with no matching definition. */
|
||||||
"Reference-style footnotes (`[^id]: …`) are not parsed on import and will " +
|
danglingReferences: string[];
|
||||||
"appear as literal text. Use inline footnotes instead: `^[footnote text]`.";
|
/** 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[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `markdown` contains a legacy `[^id]:` definition line OUTSIDE any
|
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
|
||||||
* code fence. Pure; safe to call on any body.
|
|
||||||
*/
|
*/
|
||||||
export function hasLegacyFootnoteDefinition(markdown: string): boolean {
|
export function analyzeFootnotes(markdown: string): FootnoteDiagnostics {
|
||||||
if (typeof markdown !== "string" || !markdown.includes("[^")) return false;
|
// Distinct reference ids in first-appearance order, plus the set of ids seen
|
||||||
let fence: string | null = null;
|
// inside a table row.
|
||||||
for (const line of markdown.split("\n")) {
|
const refIds: string[] = [];
|
||||||
const fenceMatch = FENCE_RE.exec(line);
|
const refIdSet = new Set<string>();
|
||||||
if (fenceMatch) {
|
const referencesInTables = new Set<string>();
|
||||||
const marker = fenceMatch[1][0];
|
const addRef = (id: string, inTable: boolean) => {
|
||||||
if (fence === null) fence = marker; // opening fence
|
if (!refIdSet.has(id)) {
|
||||||
else if (marker === fence) fence = null; // matching closing fence
|
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));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (fence !== null) continue; // inside a fence: inert example text
|
const inTable = tok.line.trimStart().startsWith("|");
|
||||||
if (FOOTNOTE_DEF_RE.test(line)) return true;
|
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The optional `footnoteWarnings` field for a page-write tool result: present
|
* The optional `footnoteWarnings` field for a page-write tool result: present
|
||||||
* (with the single advisory) only when `markdown` uses legacy reference-style
|
* (with the warning lines) only when `markdown` has footnote problems, omitted
|
||||||
* footnote syntax, omitted otherwise. One helper so all three call sites
|
* otherwise. One helper so all three call sites (create/update/import) attach the
|
||||||
* (create/update/import) attach the field identically. Spread into the result:
|
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
|
||||||
* `{ ...result, ...footnoteWarningsField(text) }`.
|
|
||||||
*/
|
*/
|
||||||
export function footnoteWarningsField(markdown: string): {
|
export function footnoteWarningsField(markdown: string): {
|
||||||
footnoteWarnings?: string[];
|
footnoteWarnings?: string[];
|
||||||
} {
|
} {
|
||||||
return hasLegacyFootnoteDefinition(markdown)
|
const { warnings } = analyzeFootnotes(markdown);
|
||||||
? { footnoteWarnings: [LEGACY_FOOTNOTE_WARNING] }
|
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
|
||||||
: {};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
||||||
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
||||||
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
||||||
* than imported from editor-ext — for the SAME reason the `docmost-schema.ts`
|
* than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the
|
||||||
* nodes are mirrored: the MCP package is deliberately
|
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately
|
||||||
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
* 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
|
* 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
|
* 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
|
* This module is the pure MIRROR only. The inline-authoring helpers
|
||||||
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
||||||
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
|
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this
|
||||||
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
|
* file is compositionally symmetric to the editor-ext copy.
|
||||||
*
|
*
|
||||||
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
||||||
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 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]);
|
||||||
|
}
|
||||||
@@ -12,7 +12,11 @@
|
|||||||
* re-import for small wording fixes.
|
* re-import for small wording fixes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stripInlineMarkdown, stripBalancedWrappers } from "./text-normalize.js";
|
import {
|
||||||
|
stripInlineMarkdown,
|
||||||
|
stripBalancedWrappers,
|
||||||
|
closestBlockHint,
|
||||||
|
} from "./text-normalize.js";
|
||||||
|
|
||||||
export interface TextEdit {
|
export interface TextEdit {
|
||||||
find: string;
|
find: string;
|
||||||
@@ -381,29 +385,9 @@ export function applyTextEdits(
|
|||||||
} else {
|
} else {
|
||||||
// Append a bounded "closest text" hint: find the FIRST block that
|
// Append a bounded "closest text" hint: find the FIRST block that
|
||||||
// contains the longest whitespace-delimited token (>= 3 chars) of the
|
// contains the longest whitespace-delimited token (>= 3 chars) of the
|
||||||
// (stripped, then raw) locator, and quote that block's plain text.
|
// (stripped, then raw) locator, and quote that block's plain text. Shared
|
||||||
reason = "text not found in the document.";
|
// with create_comment via closestBlockHint so both give the same hint.
|
||||||
const tokenSource = stripped.length > 0 ? stripped : edit.find;
|
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
|
||||||
const longestToken = tokenSource
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((t) => t.length >= 3)
|
|
||||||
.sort((a, b) => b.length - a.length)[0];
|
|
||||||
if (longestToken) {
|
|
||||||
const hitBlock = blockPlain.find((plain) =>
|
|
||||||
plain.includes(longestToken),
|
|
||||||
);
|
|
||||||
if (hitBlock) {
|
|
||||||
// Truncate by code point (spread iterates by code point) so a
|
|
||||||
// surrogate pair is never split; append the ellipsis only when the
|
|
||||||
// text was actually longer than the limit.
|
|
||||||
const points = [...hitBlock];
|
|
||||||
const snippet =
|
|
||||||
points.length > 120
|
|
||||||
? points.slice(0, 120).join("") + "…"
|
|
||||||
: hitBlock;
|
|
||||||
reason += ` Closest block text: "${snippet}".`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
failed.push({ find: edit.find, reason });
|
failed.push({ find: edit.find, reason });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -0,0 +1,963 @@
|
|||||||
|
/**
|
||||||
|
* 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 RE2 from "re2";
|
||||||
|
|
||||||
import { blockPlainText } from "@docmost/prosemirror-markdown";
|
import { blockPlainText } from "./node-ops.js";
|
||||||
|
|
||||||
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
||||||
type Re2Regex = InstanceType<typeof RE2>;
|
type Re2Regex = InstanceType<typeof RE2>;
|
||||||
|
|||||||
-5
@@ -2,11 +2,6 @@
|
|||||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
||||||
// insert_node (and the analogous update_page_json content parsing).
|
// 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(
|
export function parseNodeArg(
|
||||||
node: unknown,
|
node: unknown,
|
||||||
errMsg = "node was a string but not valid JSON",
|
errMsg = "node was a string but not valid JSON",
|
||||||
@@ -114,3 +114,37 @@ export function stripInlineMarkdown(s: string): string {
|
|||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
|
||||||
|
* edit_page_text (json-edit) and create_comment (client) so both surface the
|
||||||
|
* same self-correction affordance.
|
||||||
|
*
|
||||||
|
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
|
||||||
|
* (markdown-stripped first, so `**bold**` contributes `bold`), find the FIRST
|
||||||
|
* of `blockTexts` that contains it, and return ` Closest block text: "…".` with
|
||||||
|
* the block quoted (truncated to 120 code points + ellipsis). Returns "" when
|
||||||
|
* no token qualifies or no block contains it, so the caller can append it
|
||||||
|
* unconditionally.
|
||||||
|
*/
|
||||||
|
export function closestBlockHint(
|
||||||
|
blockTexts: string[],
|
||||||
|
locator: string,
|
||||||
|
): string {
|
||||||
|
if (typeof locator !== "string" || locator.length === 0) return "";
|
||||||
|
const stripped = stripInlineMarkdown(locator);
|
||||||
|
const tokenSource = stripped.length > 0 ? stripped : locator;
|
||||||
|
const longestToken = tokenSource
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t.length >= 3)
|
||||||
|
.sort((a, b) => b.length - a.length)[0];
|
||||||
|
if (!longestToken) return "";
|
||||||
|
const hitBlock = blockTexts.find((plain) => plain.includes(longestToken));
|
||||||
|
if (!hitBlock) return "";
|
||||||
|
// Truncate by code point (spread iterates by code point) so a surrogate pair
|
||||||
|
// is never split; append the ellipsis only when the text was actually longer.
|
||||||
|
const points = [...hitBlock];
|
||||||
|
const snippet =
|
||||||
|
points.length > 120 ? points.slice(0, 120).join("") + "…" : hitBlock;
|
||||||
|
return ` Closest block text: "${snippet}".`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { blockPlainText } from "./node-ops.js";
|
||||||
|
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
import {
|
import {
|
||||||
blockPlainText,
|
|
||||||
footnoteContentKey,
|
footnoteContentKey,
|
||||||
makeFootnoteDefinition,
|
makeFootnoteDefinition,
|
||||||
generateFootnoteId,
|
generateFootnoteId,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "./footnote-authoring.js";
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
|
||||||
|
|
||||||
export { 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 }] }
|
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
||||||
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
||||||
*
|
*
|
||||||
* Built on the shared `makeFootnoteDefinition` factory (`@docmost/prosemirror-markdown`);
|
* Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts);
|
||||||
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
|
* 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
|
* and the canonicalizer preserves attrs as-is). Single factory, one place to
|
||||||
* change the definition shape.
|
* change the definition shape.
|
||||||
|
|||||||
@@ -771,9 +771,13 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'The comment is anchored inline to the given exact `selection` text ' +
|
'The comment is anchored inline to the given exact `selection` text ' +
|
||||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||||
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
||||||
"parent's anchor and take no selection. If the call fails with a " +
|
"parent's anchor and take no selection. Always COPY the `selection` " +
|
||||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
'VERBATIM from get_page / search_in_page output — do NOT quote it from ' +
|
||||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
'memory (stale-memory quoting is the top cause of anchor misses). If the ' +
|
||||||
|
'call fails with a "selection not found" error, the error quotes the ' +
|
||||||
|
"closest block text (or says the selection spans multiple blocks); retry " +
|
||||||
|
"with a corrected EXACT selection copied verbatim from a single " +
|
||||||
|
'paragraph/block. You may also attach a ' +
|
||||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||||
'once in the page. Reversible via the comment UI.',
|
'once in the page. Reversible via the comment UI.',
|
||||||
|
|||||||
@@ -548,3 +548,94 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
|
|||||||
);
|
);
|
||||||
assert.equal(createPayload.suggestedText, "goodbye");
|
assert.equal(createPayload.suggestedText, "goodbye");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 8) #408: a not-found selection error QUOTES the closest block text so the
|
||||||
|
// model can self-correct instead of blind-retrying.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("a not-found selection error includes a 'Closest block text' hint", async () => {
|
||||||
|
let createCalls = 0;
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
sendJson(res, 200, { success: true }, {
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
data: {
|
||||||
|
id: "page-1",
|
||||||
|
content: {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "The quick brown fox jumps" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/comments/create") {
|
||||||
|
createCalls++;
|
||||||
|
sendJson(res, 200, { data: { id: "should-not-happen" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, { message: "not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.createComment("page-1", "body", "inline", "quick brown cat"),
|
||||||
|
/Closest block text: "The quick brown fox jumps"/,
|
||||||
|
"a not-found selection must quote the closest block text",
|
||||||
|
);
|
||||||
|
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 9) #408: a selection that straddles two blocks gets the explicit
|
||||||
|
// "spans multiple blocks" message instead of a bare not-found.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("a selection spanning multiple blocks gets the explicit spans-multiple-blocks message", async () => {
|
||||||
|
let createCalls = 0;
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
sendJson(res, 200, { success: true }, {
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
data: {
|
||||||
|
id: "page-1",
|
||||||
|
content: {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "the quick brown" }] },
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "fox jumps over" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/comments/create") {
|
||||||
|
createCalls++;
|
||||||
|
sendJson(res, 200, { data: { id: "should-not-happen" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, { message: "not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.createComment("page-1", "body", "inline", "brown fox"),
|
||||||
|
/spans multiple blocks/,
|
||||||
|
"a cross-block selection must report the spans-multiple-blocks hint",
|
||||||
|
);
|
||||||
|
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the
|
// Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the
|
||||||
// representative path that is fully plain-HTTP (import + getPage) and so is
|
// representative path that is fully plain-HTTP (import + getPage) and so is
|
||||||
// mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the
|
// mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the
|
||||||
// IDENTICAL wiring (`footnoteWarningsField(...)` spread-when-non-empty) but run their
|
// IDENTICAL wiring (`analyzeFootnotes(...)` + spread-when-non-empty) but run their
|
||||||
// mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness
|
// 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.
|
// does not stand up. The analyzer itself is unit-tested in footnote-analyze.test.
|
||||||
import { test, after } from "node:test";
|
import { test, after } from "node:test";
|
||||||
@@ -76,29 +76,35 @@ function pageHandler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("createPage attaches footnoteWarnings when the content uses legacy footnote syntax", async () => {
|
test("createPage attaches footnoteWarnings when the content has footnote problems", async () => {
|
||||||
const baseURL = await spawn(pageHandler());
|
const baseURL = await spawn(pageHandler());
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
// Legacy reference-style `[^id]:` definitions — inert on import since #293.
|
// A dangling reference + a duplicate definition + a table marker.
|
||||||
const content = ["Intro[^a].", "", "[^a]: a definition"].join("\n");
|
const content = [
|
||||||
|
"Intro[^missing] and| cell[^t] |.",
|
||||||
|
"",
|
||||||
|
"[^d]: one",
|
||||||
|
"[^d]: two",
|
||||||
|
"[^t]: in table",
|
||||||
|
].join("\n");
|
||||||
const result = await client.createPage("T", content, "sp-1");
|
const result = await client.createPage("T", content, "sp-1");
|
||||||
assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present");
|
assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present");
|
||||||
const joined = result.footnoteWarnings.join("\n");
|
const joined = result.footnoteWarnings.join("\n");
|
||||||
assert.match(joined, /reference-style footnotes/i);
|
assert.match(joined, /no matching definition/); // dangling [^missing]
|
||||||
assert.match(joined, /\^\[footnote text\]/); // nudge to the inline form
|
assert.match(joined, /defined more than once/); // duplicate [^d]
|
||||||
// The page itself is still returned.
|
// The page itself is still returned.
|
||||||
assert.equal(result.success, true);
|
assert.equal(result.success, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createPage omits footnoteWarnings when the content uses the inline form", async () => {
|
test("createPage omits footnoteWarnings when the content is clean", async () => {
|
||||||
const baseURL = await spawn(pageHandler());
|
const baseURL = await spawn(pageHandler());
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
const content = "A note.^[the body] and reuse.^[the body]";
|
const content = ["A[^a] and reuse[^a].", "", "[^a]: fine"].join("\n");
|
||||||
const result = await client.createPage("T", content, "sp-1");
|
const result = await client.createPage("T", content, "sp-1");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
"footnoteWarnings" in result,
|
"footnoteWarnings" in result,
|
||||||
false,
|
false,
|
||||||
"no footnoteWarnings field on inline-footnote input",
|
"no footnoteWarnings field on clean input",
|
||||||
);
|
);
|
||||||
assert.equal(result.success, true);
|
assert.equal(result.success, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
applyAnchorInDoc,
|
applyAnchorInDoc,
|
||||||
countAnchorMatches,
|
countAnchorMatches,
|
||||||
getAnchoredText,
|
getAnchoredText,
|
||||||
|
resolveAnchorSelection,
|
||||||
} from "../../build/lib/comment-anchor.js";
|
} from "../../build/lib/comment-anchor.js";
|
||||||
|
|
||||||
const COMMENT_ID = "cmt-123";
|
const COMMENT_ID = "cmt-123";
|
||||||
@@ -308,3 +309,70 @@ test("getAnchoredText returns null when the selection does not anchor", () => {
|
|||||||
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
|
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
|
||||||
assert.equal(getAnchoredText(doc, "not present"), null);
|
assert.equal(getAnchoredText(doc, "not present"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #408 MARKDOWN-STRIP FALLBACK. A selection copied with inline markdown still
|
||||||
|
// carries `**`/`` ` ``/`[t](u)` markers the plain document text lacks. When the
|
||||||
|
// verbatim selection anchors nowhere, all four entry points retry with the
|
||||||
|
// markdown stripped — consistently, so the suggestion-uniqueness gate stays
|
||||||
|
// coherent — while what gets STORED remains the raw document substring.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("a markdown-styled selection anchors against plain doc text via the strip fallback", () => {
|
||||||
|
const doc = paragraphDoc([{ type: "text", text: "a bold word here" }]);
|
||||||
|
// The agent quoted "**bold** word" from a styled view; the doc is plain text.
|
||||||
|
const sel = "**bold** word";
|
||||||
|
const resolved = resolveAnchorSelection(doc, sel);
|
||||||
|
assert.equal(resolved.found, true, "strip fallback finds the anchor");
|
||||||
|
assert.equal(resolved.normalized, true, "reports the soft-warning flag");
|
||||||
|
assert.equal(canAnchorInDoc(doc, sel), true);
|
||||||
|
assert.equal(countAnchorMatches(doc, sel), 1);
|
||||||
|
|
||||||
|
const ok = applyAnchorInDoc(doc, sel, COMMENT_ID);
|
||||||
|
assert.equal(ok, true);
|
||||||
|
const marked = doc.content[0].content.filter((p) => commentMark(p));
|
||||||
|
assert.equal(marked.map((m) => m.text).join(""), "bold word",
|
||||||
|
"the mark lands on the plain-text span");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnchoredText stores the RAW doc substring even when matched via the strip fallback", () => {
|
||||||
|
// Doc uses a smart apostrophe; the agent typed ASCII + markdown emphasis.
|
||||||
|
const doc = paragraphDoc([{ type: "text", text: "it’s bold now" }]);
|
||||||
|
const stored = getAnchoredText(doc, "it's **bold**");
|
||||||
|
assert.equal(stored, "it’s bold",
|
||||||
|
"stored selection is the raw document text, not the stripped/ASCII locator");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the strip fallback does not flip a raw-unique selection to ambiguous", () => {
|
||||||
|
// "config" appears twice, but the raw phrase "config value" appears once.
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "the config value here" }] },
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "another config here" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Raw phrase is unique -> exactly 1, and no strip happens (nothing to strip).
|
||||||
|
assert.equal(countAnchorMatches(doc, "config value"), 1);
|
||||||
|
assert.equal(resolveAnchorSelection(doc, "config value").normalized, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EXACT WINS: a raw match short-circuits the strip fallback (count reflects raw)", () => {
|
||||||
|
// A literal "**" run exists raw once; its stripped form would also appear.
|
||||||
|
const doc = paragraphDoc([{ type: "text", text: "use **stars** and stars" }]);
|
||||||
|
// Raw "**stars**" occurs once -> count 1 from the verbatim locator; the
|
||||||
|
// fallback (which would find two "stars") never runs.
|
||||||
|
assert.equal(countAnchorMatches(doc, "**stars**"), 1);
|
||||||
|
assert.equal(resolveAnchorSelection(doc, "**stars**").normalized, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a markdown selection whose stripped form is ambiguous is counted as ambiguous", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "first config here" }] },
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "second config here" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Verbatim "**config**" matches nothing; stripped "config" matches twice.
|
||||||
|
assert.equal(countAnchorMatches(doc, "**config**"), 2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,45 +1,64 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import {
|
import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js";
|
||||||
footnoteWarningsField,
|
|
||||||
hasLegacyFootnoteDefinition,
|
|
||||||
} from "../../build/lib/footnote-analyze.js";
|
|
||||||
|
|
||||||
// #414: the legacy footnote diagnostics were reduced to ONE advisory that fires
|
test("clean footnotes produce no diagnostics", () => {
|
||||||
// on the PRESENCE of legacy reference-style `[^id]:` definition syntax (inert on
|
const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n");
|
||||||
// import since #293), nudging the author to inline `^[...]` footnotes.
|
const d = analyzeFootnotes(md);
|
||||||
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
test("inline `^[...]` footnotes produce no warning", () => {
|
assert.deepEqual(d.emptyDefinitions, []);
|
||||||
const md = "A note here.^[the body] and reuse elsewhere.^[the body]";
|
assert.deepEqual(d.duplicateDefinitions, []);
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
assert.deepEqual(d.referencesInTables, []);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
assert.deepEqual(d.warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no footnotes at all produce no warning", () => {
|
test("reuse (repeated references to one definition) is NOT a warning", () => {
|
||||||
const md = "Just a paragraph with [a link](https://x) and no footnotes.";
|
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n");
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
const d = analyzeFootnotes(md);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
|
assert.deepEqual(d.warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a legacy `[^id]:` definition triggers the single advisory", () => {
|
test("dangling reference (no definition) is reported", () => {
|
||||||
const md = ["See[^a].", "", "[^a]: defined"].join("\n");
|
const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n");
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), true);
|
const d = analyzeFootnotes(md);
|
||||||
const field = footnoteWarningsField(md);
|
assert.deepEqual(d.danglingReferences, ["missing"]);
|
||||||
assert.equal(field.footnoteWarnings.length, 1);
|
assert.equal(d.warnings.length, 1);
|
||||||
assert.match(field.footnoteWarnings[0], /reference-style footnotes/i);
|
assert.match(d.warnings[0], /no matching definition/);
|
||||||
assert.match(field.footnoteWarnings[0], /\^\[footnote text\]/);
|
assert.match(d.warnings[0], /\[\^missing\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a bare `[^id]` reference (no definition line) is not flagged", () => {
|
test("empty definition text is reported", () => {
|
||||||
// Only the definition syntax `[^id]:` is a reliable signal of legacy authoring;
|
const md = ["See[^a].", "", "[^a]: "].join("\n");
|
||||||
// a lone `[^x]` in prose is too ambiguous to warn on.
|
const d = analyzeFootnotes(md);
|
||||||
const md = "A sentence mentioning [^x] with no definition.";
|
assert.deepEqual(d.emptyDefinitions, ["a"]);
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
assert.match(d.warnings.join("\n"), /empty text/);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy syntax inside a code fence is ignored (fence-aware)", () => {
|
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", () => {
|
||||||
const md = [
|
const md = [
|
||||||
"Intro.",
|
"Intro.",
|
||||||
"",
|
"",
|
||||||
@@ -48,22 +67,40 @@ test("legacy syntax inside a code fence is ignored (fence-aware)", () => {
|
|||||||
"[^demo]: not a real definition",
|
"[^demo]: not a real definition",
|
||||||
"```",
|
"```",
|
||||||
"",
|
"",
|
||||||
"Outro with an inline note.^[real]",
|
"Outro[^a].",
|
||||||
|
"",
|
||||||
|
"[^a]: real",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
const d = analyzeFootnotes(md);
|
||||||
assert.deepEqual(footnoteWarningsField(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, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a legacy definition OUTSIDE a fence still warns even with a fenced sample", () => {
|
test("a reference that only appears inside a definition's text is not dangling", () => {
|
||||||
const md = [
|
// `[^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(
|
||||||
"[^demo]: example inside a fence",
|
"\n",
|
||||||
"```",
|
);
|
||||||
"",
|
const d = analyzeFootnotes(md);
|
||||||
"See[^a].",
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
"",
|
});
|
||||||
"[^a]: real definition outside the fence",
|
|
||||||
].join("\n");
|
test("multiple problem classes accumulate distinct warnings", () => {
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), true);
|
const md = [
|
||||||
assert.equal(footnoteWarningsField(md).footnoteWarnings.length, 1);
|
"Ref[^x] and[^dup].",
|
||||||
|
"",
|
||||||
|
"[^dup]: one",
|
||||||
|
"[^dup]: two",
|
||||||
|
"[^empty]:",
|
||||||
|
].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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"
|
|||||||
import {
|
import {
|
||||||
footnoteContentKey,
|
footnoteContentKey,
|
||||||
generateFootnoteId,
|
generateFootnoteId,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/footnote-authoring.js";
|
||||||
import { insertInlineFootnote } from "../../build/lib/transforms.js";
|
import { insertInlineFootnote } from "../../build/lib/transforms.js";
|
||||||
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
|
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { footnoteWarningsField } from "../../build/lib/footnote-analyze.js";
|
import {
|
||||||
|
analyzeFootnotes,
|
||||||
|
footnoteWarningsField,
|
||||||
|
} from "../../build/lib/footnote-analyze.js";
|
||||||
import {
|
import {
|
||||||
serializeDocmostMarkdown,
|
serializeDocmostMarkdown,
|
||||||
parseDocmostMarkdown,
|
parseDocmostMarkdown,
|
||||||
} from "../../build/lib/markdown-document.js";
|
} from "../../build/lib/markdown-document.js";
|
||||||
|
|
||||||
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
|
// Pins the footnoteWarnings PLUMBING contract (#169 review): the field is
|
||||||
// field is present only when legacy reference-style `[^id]:` syntax is used and
|
// present only on problems and omitted on clean input, AND `import_page_markdown`
|
||||||
// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the
|
// analyzes the BODY (after the docmost:meta / docmost:comments blocks) — so a
|
||||||
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
|
// footnote-like token inside those JSON blocks never warns, while a real marker
|
||||||
// JSON blocks never warns, while a real definition in the body does.
|
// in the body does. importPageMarkdown does exactly
|
||||||
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
|
// `footnoteWarningsField(parseDocmostMarkdown(full).body)` over a collab socket
|
||||||
// over a collab socket this harness does not stand up, so we test the same pure
|
// this harness does not stand up, so we test the same pure composition directly.
|
||||||
// composition directly.
|
|
||||||
|
|
||||||
test("footnoteWarningsField is present on legacy syntax and omitted on the inline form", () => {
|
test("footnoteWarningsField is present on problems and omitted on clean input", () => {
|
||||||
const legacy = footnoteWarningsField("See[^a].\n\n[^a]: defined");
|
const problem = footnoteWarningsField("See[^missing].\n\n[^a]: defined");
|
||||||
assert.ok(Array.isArray(legacy.footnoteWarnings));
|
assert.ok(Array.isArray(problem.footnoteWarnings));
|
||||||
assert.match(legacy.footnoteWarnings.join("\n"), /reference-style footnotes/i);
|
assert.match(problem.footnoteWarnings.join("\n"), /no matching definition/);
|
||||||
|
|
||||||
const inline = footnoteWarningsField("A note.^[the body] reused.^[the body]");
|
const clean = footnoteWarningsField("A[^a] and reuse[^a].\n\n[^a]: fine");
|
||||||
assert.deepEqual(inline, {}); // no key at all on inline-footnote input
|
assert.deepEqual(clean, {}); // no key at all on clean input
|
||||||
});
|
});
|
||||||
|
|
||||||
test("import analyzes the BODY only — tokens inside meta/comments never warn", () => {
|
test("import analyzes the BODY only — tokens inside meta/comments never warn", () => {
|
||||||
// meta + comments JSON carry `[^metaonly]:` / `[^commentonly]:`-looking text;
|
// meta + comments JSON carry `[^metaonly]` / `[^commentonly]`-looking text; the
|
||||||
// the BODY has a genuine legacy `[^bodyref]:` definition.
|
// BODY has a genuinely dangling `[^bodyref]`.
|
||||||
const full = serializeDocmostMarkdown(
|
const full = serializeDocmostMarkdown(
|
||||||
{ pageId: "p1", note: "front-matter mentions [^metaonly]: in text" },
|
{ pageId: "p1", note: "front-matter mentions [^metaonly] in text" },
|
||||||
"Body with a legacy[^bodyref] marker.\n\n[^bodyref]: the definition",
|
"Body with a dangling[^bodyref] marker.",
|
||||||
[{ id: "c1", content: "a comment that says [^commentonly]: text" }],
|
[{ id: "c1", content: "a comment that says [^commentonly]" }],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { body } = parseDocmostMarkdown(full);
|
const { body } = parseDocmostMarkdown(full);
|
||||||
@@ -40,19 +42,20 @@ test("import analyzes the BODY only — tokens inside meta/comments never warn",
|
|||||||
assert.ok(!body.includes("[^commentonly]"));
|
assert.ok(!body.includes("[^commentonly]"));
|
||||||
|
|
||||||
const field = footnoteWarningsField(body);
|
const field = footnoteWarningsField(body);
|
||||||
// ONLY the body's legacy definition triggers the advisory.
|
const joined = (field.footnoteWarnings ?? []).join("\n");
|
||||||
assert.ok(Array.isArray(field.footnoteWarnings));
|
// ONLY the body's dangling reference is flagged.
|
||||||
assert.match(field.footnoteWarnings.join("\n"), /reference-style footnotes/i);
|
assert.match(joined, /\[\^bodyref\]/);
|
||||||
|
assert.ok(!joined.includes("metaonly"));
|
||||||
|
assert.ok(!joined.includes("commentonly"));
|
||||||
|
|
||||||
// The meta/comments tokens, analyzed on their own, would NOT have warned in a
|
// Cross-check against analyzeFootnotes directly (same composition the importer uses).
|
||||||
// way that leaks here — the field is computed over the body only.
|
assert.deepEqual(analyzeFootnotes(body).danglingReferences, ["bodyref"]);
|
||||||
assert.deepEqual(footnoteWarningsField("front-matter mentions text"), {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("import on an inline-footnote body yields no footnoteWarnings field", () => {
|
test("import on a clean body yields no footnoteWarnings field", () => {
|
||||||
const full = serializeDocmostMarkdown(
|
const full = serializeDocmostMarkdown(
|
||||||
{ pageId: "p1" },
|
{ pageId: "p1" },
|
||||||
"Clean body.^[a note] reusing.^[a note]",
|
"Clean body[^a] reusing[^a].\n\n[^a]: ok",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const { body } = parseDocmostMarkdown(full);
|
const { body } = parseDocmostMarkdown(full);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
insertNodeRelative,
|
insertNodeRelative,
|
||||||
sanitizeForYjs,
|
sanitizeForYjs,
|
||||||
findUnstorableAttr,
|
findUnstorableAttr,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||||
const textNode = (text) => ({ type: "text", text });
|
const textNode = (text) => ({ type: "text", text });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteNodeById,
|
deleteNodeById,
|
||||||
assertUnambiguousMatch,
|
assertUnambiguousMatch,
|
||||||
insertNodeRelative,
|
insertNodeRelative,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||||
const textNode = (text) => ({ type: "text", text });
|
const textNode = (text) => ({ type: "text", text });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { buildOutline, getNodeByRef } from "@docmost/prosemirror-markdown";
|
import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// Helpers to build the small fixture doc.
|
// Helpers to build the small fixture doc.
|
||||||
const textNode = (text) => ({ type: "text", text });
|
const textNode = (text) => ({ type: "text", text });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test } from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { searchInDoc } from "../../build/lib/page-search.js";
|
import { searchInDoc } from "../../build/lib/page-search.js";
|
||||||
import { getNodeByRef } from "@docmost/prosemirror-markdown";
|
import { getNodeByRef } from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
import { parseNodeArg } from "../../build/lib/parse-node-arg.js";
|
||||||
|
|
||||||
test("parseNodeArg passes an object through unchanged", () => {
|
test("parseNodeArg passes an object through unchanged", () => {
|
||||||
const obj = { type: "paragraph", content: [] };
|
const obj = { type: "paragraph", content: [] };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
insertTableRow,
|
insertTableRow,
|
||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
||||||
|
|||||||
@@ -59,89 +59,3 @@ export function splitFootnoteParagraphs(encoded: string): string[] {
|
|||||||
paragraphs.push(current);
|
paragraphs.push(current);
|
||||||
return paragraphs;
|
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,35 +44,3 @@ export {
|
|||||||
docsCanonicallyEqual,
|
docsCanonicallyEqual,
|
||||||
} from "./canonicalize.js";
|
} from "./canonicalize.js";
|
||||||
export { parsePageFile, serializePageFile } from "./page-file.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,8 +14,6 @@
|
|||||||
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
* `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. */
|
/** Deep-clone a JSON-serializable value without mutating the original. */
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
if (typeof structuredClone === "function") {
|
if (typeof structuredClone === "function") {
|
||||||
@@ -99,15 +97,12 @@ export function buildOutline(doc: any): OutlineEntry[] {
|
|||||||
const entry: OutlineEntry = {
|
const entry: OutlineEntry = {
|
||||||
index: i,
|
index: i,
|
||||||
type,
|
type,
|
||||||
id:
|
id: isObject(block) && isObject(block.attrs) ? block.attrs.id ?? null : null,
|
||||||
isObject(block) && isObject(block.attrs)
|
|
||||||
? (block.attrs.id ?? null)
|
|
||||||
: null,
|
|
||||||
firstText: truncate(blockPlainText(block), 100),
|
firstText: truncate(blockPlainText(block), 100),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === "heading") {
|
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") {
|
} else if (type === "table") {
|
||||||
const headerRow = block.content?.[0]?.content ?? [];
|
const headerRow = block.content?.[0]?.content ?? [];
|
||||||
entry.rows = block.content?.length ?? 0;
|
entry.rows = block.content?.length ?? 0;
|
||||||
@@ -252,33 +247,6 @@ export function deleteNodeById(
|
|||||||
return { doc: out, deleted };
|
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
|
* 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
|
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
||||||
@@ -396,31 +364,6 @@ const REQUIRED_CONTAINER: Record<string, string> = {
|
|||||||
tableHeader: "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
|
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
||||||
* including the matched node). Each chain entry is `{ node, index }` where
|
* including the matched node). Each chain entry is `{ node, index }` where
|
||||||
@@ -456,14 +399,14 @@ function findAnchorChain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
// 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)) {
|
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
||||||
const i = findAnchorTextIndex(doc.content, opts.anchorText);
|
for (let i = 0; i < doc.content.length; i++) {
|
||||||
if (i !== -1) {
|
if (blockPlainText(doc.content[i]).includes(opts.anchorText)) {
|
||||||
return [
|
return [
|
||||||
{ node: doc, index: -1 },
|
{ node: doc, index: -1 },
|
||||||
{ node: doc.content[i], index: i },
|
{ node: doc.content[i], index: i },
|
||||||
];
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,13 +540,13 @@ export function insertNodeRelative(
|
|||||||
return { doc: out, inserted };
|
return { doc: out, inserted };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
// Resolve by text: only top-level doc.content blocks are scanned.
|
||||||
// match wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
||||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
for (let i = 0; i < out.content.length; i++) {
|
||||||
if (i !== -1) {
|
if (blockPlainText(out.content[i]).includes(opts.anchorText)) {
|
||||||
out.content.splice(i + offset, 0, fresh);
|
out.content.splice(i + offset, 0, fresh);
|
||||||
return { doc: out, inserted: true };
|
return { doc: out, inserted: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,8 +617,7 @@ function locateTable(
|
|||||||
if (!isObject(rootClone)) return null;
|
if (!isObject(rootClone)) return null;
|
||||||
|
|
||||||
// "#<n>": index into the top-level content array; must be a table.
|
// "#<n>": index into the top-level content array; must be a table.
|
||||||
const indexMatch =
|
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
||||||
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
|
||||||
if (indexMatch) {
|
if (indexMatch) {
|
||||||
const index = Number(indexMatch[1]);
|
const index = Number(indexMatch[1]);
|
||||||
const block = Array.isArray(rootClone.content)
|
const block = Array.isArray(rootClone.content)
|
||||||
@@ -775,7 +717,7 @@ export function readTable(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const id =
|
const id =
|
||||||
isObject(firstPara) && isObject(firstPara.attrs)
|
isObject(firstPara) && isObject(firstPara.attrs)
|
||||||
? (firstPara.attrs.id ?? null)
|
? firstPara.attrs.id ?? null
|
||||||
: null;
|
: null;
|
||||||
rowIds.push(id);
|
rowIds.push(id);
|
||||||
}
|
}
|
||||||
@@ -809,17 +751,14 @@ export function insertTableRow(
|
|||||||
if (!Array.isArray(table.content)) table.content = [];
|
if (!Array.isArray(table.content)) table.content = [];
|
||||||
const rows = table.content.length;
|
const rows = table.content.length;
|
||||||
const headerRow = table.content[0];
|
const headerRow = table.content[0];
|
||||||
const headerCells = Array.isArray(headerRow?.content)
|
const headerCells = Array.isArray(headerRow?.content) ? headerRow.content : [];
|
||||||
? headerRow.content
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Column count is the WIDEST existing row, so the guard below stays
|
// 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.
|
// 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.
|
// Fall back to the supplied cell count only when the table has no rows.
|
||||||
let colCount = 0;
|
let colCount = 0;
|
||||||
for (const r of table.content) {
|
for (const r of table.content) {
|
||||||
if (isObject(r) && Array.isArray(r.content))
|
if (isObject(r) && Array.isArray(r.content)) colCount = Math.max(colCount, r.content.length);
|
||||||
colCount = Math.max(colCount, r.content.length);
|
|
||||||
}
|
}
|
||||||
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
||||||
|
|
||||||
@@ -832,10 +771,7 @@ export function insertTableRow(
|
|||||||
// Resolve the landing index up front so the cell-type decision and the splice
|
// 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.
|
// below agree: a valid integer in [0, rows] splices there, else we append.
|
||||||
const landingIndex =
|
const landingIndex =
|
||||||
typeof index === "number" &&
|
typeof index === "number" && Number.isInteger(index) && index >= 0 && index <= rows
|
||||||
Number.isInteger(index) &&
|
|
||||||
index >= 0 &&
|
|
||||||
index <= rows
|
|
||||||
? index
|
? index
|
||||||
: rows;
|
: rows;
|
||||||
|
|
||||||
@@ -854,8 +790,7 @@ export function insertTableRow(
|
|||||||
// A row landing at index 0 becomes the new header row, so inherit the
|
// 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);
|
// current header cell's type per column (Docmost uses "tableHeader" there);
|
||||||
// every other position is a plain data cell.
|
// every other position is a plain data cell.
|
||||||
const cellType =
|
const cellType = landingIndex === 0 ? headerCells[i]?.type ?? "tableCell" : "tableCell";
|
||||||
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
|
|
||||||
newCells.push({
|
newCells.push({
|
||||||
type: cellType,
|
type: cellType,
|
||||||
attrs,
|
attrs,
|
||||||
@@ -927,10 +862,9 @@ export function updateTableCell(
|
|||||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||||
const rows = rowNodes.length;
|
const rows = rowNodes.length;
|
||||||
const rowNode = rowNodes[row];
|
const rowNode = rowNodes[row];
|
||||||
const cols =
|
const cols = isObject(rowNode) && Array.isArray(rowNode.content)
|
||||||
isObject(rowNode) && Array.isArray(rowNode.content)
|
? rowNode.content.length
|
||||||
? rowNode.content.length
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(row) ||
|
!Number.isInteger(row) ||
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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