Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76af4f692e | |||
| 0108dec0e6 | |||
| f750a509c2 | |||
| d4581a096f |
+85
-171
@@ -82,6 +82,7 @@ import {
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
} from "./lib/transforms.js";
|
||||
import { normalizeAndMergeFootnotes } from "./lib/footnote-normalize-merge.js";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Supported image types, kept as two lookup tables so both a local file
|
||||
@@ -552,18 +553,16 @@ export class DocmostClient {
|
||||
// forever and accumulate duplicates).
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
let cursor: string | undefined;
|
||||
let page = 1;
|
||||
let allItems: T[] = [];
|
||||
let truncated = false;
|
||||
let hasNextPage = true;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const payload: Record<string, any> = {
|
||||
while (hasNextPage && page <= MAX_PAGES) {
|
||||
const response = await this.client.post(endpoint, {
|
||||
...basePayload,
|
||||
limit: clampedLimit,
|
||||
};
|
||||
if (cursor) payload.cursor = cursor;
|
||||
|
||||
const response = await this.client.post(endpoint, payload);
|
||||
page,
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const items = data.data?.items || data.items || [];
|
||||
@@ -571,28 +570,22 @@ export class DocmostClient {
|
||||
|
||||
allItems = allItems.concat(items);
|
||||
|
||||
// Advance strictly via the server-issued cursor. A missing nextCursor (or
|
||||
// hasNextPage false) means we reached the end. A cursor identical to the
|
||||
// one we just sent means the server did not understand our pagination
|
||||
// param — stop instead of re-fetching page one forever and duplicating.
|
||||
const next = meta?.hasNextPage ? meta?.nextCursor : null;
|
||||
if (!next || next === cursor) {
|
||||
// If the server still reports more pages but stopped issuing a usable
|
||||
// cursor at the ceiling, flag the result as truncated below.
|
||||
if (page === MAX_PAGES - 1 && meta?.hasNextPage) truncated = true;
|
||||
// Stop if the page is empty or shorter than the requested size: a full
|
||||
// page worth of items is the only situation where another page can exist,
|
||||
// so this defends against a stuck hasNextPage flag in addition to it.
|
||||
if (items.length === 0 || items.length < clampedLimit) {
|
||||
break;
|
||||
}
|
||||
cursor = next;
|
||||
|
||||
// Reaching the ceiling with more pages still available means the result
|
||||
// set is truncated.
|
||||
if (page === MAX_PAGES - 1) truncated = true;
|
||||
hasNextPage = meta?.hasNextPage || false;
|
||||
page++;
|
||||
}
|
||||
|
||||
// If the loop stopped because it hit the MAX_PAGES ceiling while the server
|
||||
// still reported more results, the result set is truncated — warn so the
|
||||
// caller is not silently handed an incomplete list.
|
||||
if (truncated) {
|
||||
// still reported more results (hasNextPage true and the last page was
|
||||
// full), the result set is truncated — warn so the caller is not silently
|
||||
// handed an incomplete list.
|
||||
if (hasNextPage && page > MAX_PAGES) {
|
||||
console.warn(
|
||||
`paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||
);
|
||||
@@ -626,10 +619,9 @@ export class DocmostClient {
|
||||
* Tree (`tree` true): the space's FULL page hierarchy as a nested tree (each
|
||||
* node has a `children` array). This mode REQUIRES `spaceId` (a page tree is
|
||||
* scoped to one space) and IGNORES `limit` — the whole hierarchy is returned.
|
||||
* It fetches the tree via `enumerateSpacePages`, which on the fork server
|
||||
* resolves to a single `/pages/tree` request returning the whole
|
||||
* permission-filtered flat page set (soft-deleted pages excluded
|
||||
* server-side).
|
||||
* It walks the sidebar tree via `enumerateSpacePages`, which performs N
|
||||
* sidebar requests and is bounded by that method's 10000-node cap (and skips
|
||||
* soft-deleted pages server-side).
|
||||
*/
|
||||
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
|
||||
await this.ensureAuthenticated();
|
||||
@@ -640,8 +632,8 @@ export class DocmostClient {
|
||||
"list_pages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.",
|
||||
);
|
||||
}
|
||||
const { pages } = await this.enumerateSpacePages(spaceId);
|
||||
return buildPageTree(pages);
|
||||
const nodes = await this.enumerateSpacePages(spaceId);
|
||||
return buildPageTree(nodes);
|
||||
}
|
||||
|
||||
const clampedLimit = Math.max(1, Math.min(100, limit));
|
||||
@@ -663,123 +655,57 @@ export class DocmostClient {
|
||||
async listSidebarPages(spaceId: string, pageId?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Paginate via the server-issued cursor. The server switched from OFFSET
|
||||
// (`page`) to CURSOR (`cursor`/`nextCursor`) pagination, and the global
|
||||
// ValidationPipe(whitelist:true) SILENTLY STRIPS the obsolete `page` field
|
||||
// — so the old offset loop got the SAME first page every time (with
|
||||
// hasNextPage stuck true) and dropped every child beyond the first page.
|
||||
// Paginate: the endpoint returns server-paged children, so posting only
|
||||
// { page: 1 } silently dropped every child beyond the first page. Loop on
|
||||
// meta.hasNextPage (with a MAX_PAGES ceiling like paginateAll, guarding
|
||||
// against a stuck hasNextPage flag) and accumulate all children.
|
||||
const MAX_PAGES = 50;
|
||||
let cursor: string | undefined;
|
||||
let page = 1;
|
||||
let allItems: any[] = [];
|
||||
let truncated = false;
|
||||
let hasNextPage = true;
|
||||
|
||||
for (let i = 0; i < MAX_PAGES; i++) {
|
||||
// limit: 100 is the server-side Max; cuts request count 5x vs the default 20.
|
||||
const payload: Record<string, any> = { spaceId, limit: 100 };
|
||||
while (hasNextPage && page <= MAX_PAGES) {
|
||||
// Only send pageId when scoping to a page's children; omit it for roots.
|
||||
const payload: Record<string, any> = { spaceId, page };
|
||||
if (pageId) payload.pageId = pageId;
|
||||
if (cursor) payload.cursor = cursor;
|
||||
|
||||
const data = (await this.client.post("/pages/sidebar-pages", payload)).data
|
||||
?.data;
|
||||
allItems = allItems.concat(data?.items ?? []);
|
||||
const response = await this.client.post("/pages/sidebar-pages", payload);
|
||||
const data = response.data?.data ?? response.data;
|
||||
const items = data?.items || [];
|
||||
allItems = allItems.concat(items);
|
||||
|
||||
// Advance strictly via the server-issued cursor; a missing/repeated cursor
|
||||
// means the protocol drifted again — stop instead of looping on page one.
|
||||
const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null;
|
||||
if (!next || next === cursor) break;
|
||||
cursor = next;
|
||||
|
||||
// Reaching the ceiling with more pages still available means the child
|
||||
// list is truncated (mirrors paginateAll).
|
||||
if (i === MAX_PAGES - 1) truncated = true;
|
||||
}
|
||||
|
||||
// Warn on real truncation (ceiling hit while the server still had pages) so
|
||||
// the caller is not silently handed an incomplete child list.
|
||||
if (truncated) {
|
||||
console.warn(
|
||||
`listSidebarPages: children of "${pageId ?? spaceId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||
);
|
||||
hasNextPage = data?.meta?.hasNextPage || false;
|
||||
page++;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate EVERY page in a space (or in a subtree, when rootPageId is given).
|
||||
* Enumerate EVERY page in a space (or in a subtree, when rootPageId is given)
|
||||
* by walking the sidebar-pages tree.
|
||||
*
|
||||
* Primary path (fork server): a SINGLE `POST /pages/tree` returns the whole
|
||||
* space (or a subtree) as a flat, permission-filtered list in one request, in
|
||||
* the exact node shape buildPageTree consumes. This replaces the old
|
||||
* per-node BFS, which issued N sidebar requests and — after the server moved
|
||||
* to cursor pagination — silently lost every child past the first sidebar
|
||||
* page (the obsolete `page` param was stripped by ValidationPipe).
|
||||
* Starting set: the children of rootPageId when provided, otherwise the
|
||||
* space root pages. From there it does an iterative breadth-first walk: each
|
||||
* node is collected, and when node.hasChildren is true its direct children
|
||||
* are fetched via listSidebarPages(spaceId, node.id) and enqueued.
|
||||
*
|
||||
* The subtree variant (rootPageId given) INCLUDES the root node itself
|
||||
* (getPageAndDescendants seeds with id = rootPageId), unlike the old BFS
|
||||
* which started from the root's children.
|
||||
* This replaces the old "/pages/recent" enumeration, which is a bounded
|
||||
* recent-activity feed (~5000 cap) and therefore misses comments on older
|
||||
* pages that were never recently touched.
|
||||
*
|
||||
* Fallback path (stdio mode may target STOCK upstream Docmost, which lacks
|
||||
* `/pages/tree`): on a 404/405 it falls back to the cursor-based BFS below,
|
||||
* walking direct children via the fixed cursor listSidebarPages. Safeguards:
|
||||
* a `visited` Set of page ids prevents re-processing a node (cycles /
|
||||
* duplicate references), and a hard node cap bounds pathological trees so the
|
||||
* walk always terminates.
|
||||
*
|
||||
* Returns `{ pages, truncated }`. `truncated` is true ONLY when the fallback
|
||||
* BFS stopped at its MAX_NODES cap — the primary /pages/tree path is uncapped
|
||||
* and always returns the complete set, so it never reports truncation.
|
||||
* Safeguards: a `visited` Set of page ids prevents re-processing a node
|
||||
* (cycles / duplicate references), and a hard node cap bounds pathological
|
||||
* trees so the walk always terminates.
|
||||
*/
|
||||
private async enumerateSpacePages(
|
||||
spaceId: string,
|
||||
rootPageId?: string,
|
||||
): Promise<{ pages: any[]; truncated: boolean }> {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Single request replaces the whole BFS: /pages/tree returns the full
|
||||
// permission-filtered flat page set of a space (or a subtree) at once. This
|
||||
// path is uncapped, so it is never truncated.
|
||||
const payload = rootPageId ? { pageId: rootPageId } : { spaceId };
|
||||
try {
|
||||
const response = await this.client.post("/pages/tree", payload);
|
||||
const pages = (response.data?.data ?? response.data)?.items ?? [];
|
||||
return { pages, truncated: false };
|
||||
} catch (e: any) {
|
||||
// Only fall back when the endpoint is absent (stock upstream Docmost);
|
||||
// any other error is a genuine failure and must propagate.
|
||||
if (
|
||||
!axios.isAxiosError(e) ||
|
||||
(e.response?.status !== 404 && e.response?.status !== 405)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: cursor-based breadth-first walk via listSidebarPages.
|
||||
): Promise<any[]> {
|
||||
const MAX_NODES = 10000;
|
||||
const result: any[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Seed with the root node itself when scoping to a subtree, so its own
|
||||
// comments aren't dropped: the primary /pages/tree seeds
|
||||
// getPageAndDescendants with id = rootPageId (root included), but
|
||||
// listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN.
|
||||
// The `visited` set below prevents a double-add if the root also appears
|
||||
// among the children. getPageRaw returns a page whose id/title/spaceId are
|
||||
// exactly what buildPageTree and check_new_comments consume.
|
||||
if (rootPageId) {
|
||||
try {
|
||||
const root = await this.getPageRaw(rootPageId);
|
||||
if (root?.id) {
|
||||
result.push(root);
|
||||
visited.add(root.id);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if the root can't be read, fall through to children-only.
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the queue with the starting level (subtree children or roots).
|
||||
const queue: any[] = await this.listSidebarPages(spaceId, rootPageId);
|
||||
|
||||
@@ -804,12 +730,7 @@ export class DocmostClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Truncated only when the cap was hit with the queue still non-empty (real
|
||||
// truncation, not a natural end at exactly MAX_NODES).
|
||||
return {
|
||||
pages: result,
|
||||
truncated: result.length >= MAX_NODES && queue.length > 0,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Raw page info including the ProseMirror JSON content and slugId. */
|
||||
@@ -1719,6 +1640,8 @@ export class DocmostClient {
|
||||
// leave footnotes out of order, orphaned, or in multiple lists — the bottom
|
||||
// list + numbering are always derived from reference order. No-op when the
|
||||
// footnotes are already canonical.
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
doc = normalizeAndMergeFootnotes(doc);
|
||||
doc = canonicalizeFootnotes(doc);
|
||||
|
||||
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||
@@ -1983,7 +1906,8 @@ export class DocmostClient {
|
||||
// footnotes before copying — a no-op on already-canonical source content, but
|
||||
// it guarantees a copy can never propagate a non-canonical footnote topology
|
||||
// to the target (parity with the other full-doc write paths).
|
||||
const canonical = canonicalizeFootnotes(content);
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content));
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||
@@ -2440,13 +2364,7 @@ export class DocmostClient {
|
||||
let allComments: any[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
// Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments
|
||||
// ever stops advancing the cursor (the exact #442 drift scenario) this loop
|
||||
// would otherwise spin forever accumulating duplicates.
|
||||
const MAX_PAGES = 50;
|
||||
let truncated = false;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
do {
|
||||
const payload: Record<string, any> = { pageId, limit: 100 };
|
||||
if (cursor) payload.cursor = cursor;
|
||||
|
||||
@@ -2454,23 +2372,8 @@ export class DocmostClient {
|
||||
const data = response.data.data || response.data;
|
||||
const items = data.items || [];
|
||||
allComments = allComments.concat(items);
|
||||
|
||||
// Advance strictly via the server-issued cursor. A missing nextCursor or a
|
||||
// cursor identical to the one we just sent means the end (or a server that
|
||||
// ignores our pagination param) — stop instead of re-fetching page one.
|
||||
const next: string | null = data.meta?.nextCursor || null;
|
||||
if (!next || next === cursor) break;
|
||||
cursor = next;
|
||||
|
||||
// Reaching the ceiling with a still-advancing cursor means truncation.
|
||||
if (page === MAX_PAGES - 1) truncated = true;
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
console.warn(
|
||||
`listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||
);
|
||||
}
|
||||
cursor = data.meta?.nextCursor || null;
|
||||
} while (cursor);
|
||||
|
||||
const mapped = allComments.map((comment: any) => {
|
||||
const markdown = comment.content
|
||||
@@ -2943,27 +2846,36 @@ export class DocmostClient {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Enumerate the FULL set of pages in scope via the page tree (a complete
|
||||
// page index), NOT the bounded "/pages/recent" feed which caps at ~5000
|
||||
// recent items and silently misses comments on older pages.
|
||||
// 1. Enumerate the FULL set of pages in scope by walking the sidebar-pages
|
||||
// tree (a complete page index), NOT the bounded "/pages/recent" feed which
|
||||
// caps at ~5000 recent items and silently misses comments on older pages.
|
||||
//
|
||||
// Subtree scope: when parentPageId is given, the scope is that page ITSELF
|
||||
// plus every descendant. Otherwise the scope is the whole space (all roots
|
||||
// and their descendants).
|
||||
// plus every descendant (enumerateSpacePages walks its children). Otherwise
|
||||
// the scope is the whole space (all roots and their descendants).
|
||||
//
|
||||
// NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not
|
||||
// bump it (verified on a live server), so such a filter silently misses
|
||||
// comments on pages that were not otherwise edited. The complete tree walk
|
||||
// already restricts the scope correctly, so no recent-feed allow-list is
|
||||
// needed any more.
|
||||
//
|
||||
// The subtree scope (parentPageId given) already INCLUDES the root node
|
||||
// itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so
|
||||
// no separate getPageRaw fetch for the parent is needed.
|
||||
const { pages: pagesInScope, truncated } = await this.enumerateSpacePages(
|
||||
spaceId,
|
||||
parentPageId,
|
||||
);
|
||||
let pagesInScope: any[];
|
||||
if (parentPageId) {
|
||||
const subtree = await this.enumerateSpacePages(spaceId, parentPageId);
|
||||
// Include the parent page node itself alongside its descendants. Fetch it
|
||||
// so its title/id are available even though it is not returned by its own
|
||||
// children listing.
|
||||
let parentNode: any = { id: parentPageId };
|
||||
try {
|
||||
parentNode = await this.getPageRaw(parentPageId);
|
||||
} catch (e: any) {
|
||||
// Fall back to a minimal node if the parent can't be fetched; its
|
||||
// comments are still attempted below (the fetch there is non-fatal).
|
||||
}
|
||||
pagesInScope = [parentNode, ...subtree];
|
||||
} else {
|
||||
pagesInScope = await this.enumerateSpacePages(spaceId);
|
||||
}
|
||||
|
||||
// 2. Fetch comments for each page, keep ones created after since
|
||||
const results: any[] = [];
|
||||
@@ -2992,9 +2904,10 @@ export class DocmostClient {
|
||||
0,
|
||||
);
|
||||
|
||||
// `truncated` is reported by enumerateSpacePages: it is true ONLY when the
|
||||
// stdio fallback BFS hit its node cap. The primary /pages/tree path is
|
||||
// uncapped, so a space with legitimately many pages is not falsely flagged.
|
||||
// enumerateSpacePages caps traversal at 10000 nodes; flag when that cap was
|
||||
// hit so the caller knows the scan may be incomplete (some pages skipped).
|
||||
const truncated = pagesInScope.length >= 10000;
|
||||
|
||||
return {
|
||||
since,
|
||||
scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`,
|
||||
@@ -4249,7 +4162,8 @@ export class DocmostClient {
|
||||
// path can leave footnotes out of order / orphaned / in a raw `[^id]`
|
||||
// block. In a dryRun preview this may surface footnote edits the script
|
||||
// author did not write (the canonicalizer tidied them) — that is expected.
|
||||
const result = canonicalizeFootnotes(raw);
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw));
|
||||
newDoc = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||
import { withPageLock } from "./page-lock.js";
|
||||
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
|
||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
|
||||
import { VerifyReport } from "./diff.js";
|
||||
import { acquireCollabSession } from "./collab-session.js";
|
||||
|
||||
@@ -82,7 +83,12 @@ global.WebSocket = WebSocket;
|
||||
export async function markdownToProseMirrorCanonical(
|
||||
markdownContent: string,
|
||||
): Promise<any> {
|
||||
return canonicalizeFootnotes(await markdownToProseMirror(markdownContent));
|
||||
// #419: normalize + merge glyph-forked footnote definitions BEFORE
|
||||
// canonicalizing, so the canonicalizer re-hangs references and drops the
|
||||
// now-orphaned duplicate definitions.
|
||||
return canonicalizeFootnotes(
|
||||
normalizeAndMergeFootnotes(await markdownToProseMirror(markdownContent)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Deterministic server-side NORMALIZATION + MERGE of footnote DEFINITIONS
|
||||
* (MCP, PURE).
|
||||
*
|
||||
* Problem (#419): footnotes with the same meaning but different GLYPHS —
|
||||
* typographic quotes («…»/“…”) vs ASCII "…", em/en-dash vs `-`, non-breaking
|
||||
* space vs normal space, differing space counts — are not recognized as equal
|
||||
* and "fork": two definitions appear where the author meant one. The existing
|
||||
* de-dup paths miss this: `footnoteContentKey` (@docmost/prosemirror-markdown) only
|
||||
* collapses ASCII whitespace (quotes/dashes/NBSP untouched), and
|
||||
* `canonicalizeFootnotes` keys purely by `attrs.id` (the two forks have
|
||||
* different ids), so neither glues the forks together.
|
||||
*
|
||||
* This pass fixes that DETERMINISTICALLY on the MCP write-paths (an LLM
|
||||
* instruction gives no glue guarantee). It:
|
||||
* 1. Normalizes the TEXT of every `footnoteDefinition`'s text nodes IN PLACE
|
||||
* (typographic quotes -> ASCII "/', dashes -> `-`, NBSP & friends ->
|
||||
* normal space, whitespace runs collapsed, whole-definition edges
|
||||
* trimmed) — unconditionally, for ALL definitions, KEEPING their marks.
|
||||
* 2. Computes a MERGE KEY per definition (normalized text + an ATTRS-AWARE
|
||||
* inline-mark signature, via the local `footnoteMergeKey`), so notes that
|
||||
* read the same but differ in formatting (bold vs plain) OR in a mark
|
||||
* attribute (a `link` with a different `href`, differing `code`/`highlight`
|
||||
* attrs) are NOT merged. See `footnoteMergeKey` for why this diverges from
|
||||
* the shared type-only `footnoteContentKey`.
|
||||
* 3. Maps every duplicate definition id to the FIRST (document-order)
|
||||
* definition's id and re-hangs `footnoteReference` nodes onto it.
|
||||
*
|
||||
* Duplicate definitions keep their original ids but now have NO references, so
|
||||
* the canonicalizer that runs immediately after this pass removes them as
|
||||
* orphans and derives the single tail list + numbering. This pass therefore
|
||||
* MUST run BEFORE `canonicalizeFootnotes(doc)` at every write-path call-site
|
||||
* (see the enforcement rule in `footnote-canonicalize.ts`).
|
||||
*
|
||||
* Accepted tradeoff: the exact typographic glyphs of the SURVIVING footnote are
|
||||
* rewritten to ASCII, in exchange for a GUARANTEED merge. Scope is strictly
|
||||
* INSIDE `footnoteDefinition` — body text (normal paragraphs) is never touched.
|
||||
*
|
||||
* Pure: deep-clones its input, deterministic, idempotent (a re-run is a no-op —
|
||||
* text is already normalized and references already point at the canonical id,
|
||||
* so no spurious mutations / git-sync churn).
|
||||
*/
|
||||
|
||||
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||
const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
|
||||
|
||||
/**
|
||||
* Typographic glyph maps. DUPLICATED from `comment-anchor.ts` (the source of
|
||||
* truth, `normalizeForMatch`) on purpose: those constants are private there and
|
||||
* bound to that module's anchor-matching golden tests, so extracting them would
|
||||
* risk changing anchor behaviour. Keeping a local copy makes this pass fully
|
||||
* self-contained. If the anchor maps grow, mirror the change here.
|
||||
*/
|
||||
/** Typographic double-quote variants mapped to ASCII `"`. */
|
||||
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
|
||||
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
|
||||
const SINGLE_QUOTES = "‘’‚‛";
|
||||
/** Dash variants mapped to ASCII `-`. */
|
||||
const DASHES = "–—―−‐‑‒";
|
||||
|
||||
function cloneJson<T>(v: T): T {
|
||||
if (typeof structuredClone === "function") return structuredClone(v);
|
||||
return JSON.parse(JSON.stringify(v)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for any character we collapse/replace with a single normal space.
|
||||
* Mirrors `comment-anchor.ts`'s `isWhitespaceChar`: ASCII whitespace (`\s`
|
||||
* covers tab/newline) plus the non-breaking / special spaces listed explicitly
|
||||
* for determinism across engines.
|
||||
*/
|
||||
function isWhitespaceChar(ch: string): boolean {
|
||||
return (
|
||||
/\s/.test(ch) ||
|
||||
ch === " " || // no-break space
|
||||
ch === " " || // figure space
|
||||
ch === " " || // narrow no-break space
|
||||
ch === " " || // thin space
|
||||
ch === " " || // hair space
|
||||
ch === " " || // en space
|
||||
ch === " " // em space
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map typographic quotes/dashes to ASCII and collapse every whitespace run
|
||||
* (including NBSP & friends) to a SINGLE normal space. Does NOT trim — the
|
||||
* whole-definition edge trim is applied separately so inter-node spacing across
|
||||
* a multi-text-node definition is preserved.
|
||||
*/
|
||||
function normalizeAndCollapse(s: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const ch = s[i];
|
||||
if (isWhitespaceChar(ch)) {
|
||||
while (i < s.length && isWhitespaceChar(s[i])) i++;
|
||||
out += " ";
|
||||
continue;
|
||||
}
|
||||
let mapped = ch;
|
||||
if (DOUBLE_QUOTES.indexOf(ch) !== -1) mapped = '"';
|
||||
else if (SINGLE_QUOTES.indexOf(ch) !== -1) mapped = "'";
|
||||
else if (DASHES.indexOf(ch) !== -1) mapped = "-";
|
||||
out += mapped;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collect every text node inside `def`, in document order (deep). */
|
||||
function collectTextNodes(node: any, out: any[]): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.type === "text" && typeof node.text === "string") out.push(node);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) collectTextNodes(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every `footnoteDefinition` node in document order (deep). */
|
||||
function collectDefinitions(node: any, out: any[]): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.type === FOOTNOTE_DEFINITION_NAME) out.push(node);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) collectDefinitions(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the text of one definition's text nodes IN PLACE: map glyphs +
|
||||
* collapse whitespace on every node (marks untouched), then trim the leading
|
||||
* edge of the first text node and the trailing edge of the last so the
|
||||
* definition as a whole is trimmed WITHOUT dropping the spacing between two
|
||||
* adjacent text nodes. The edge trims are guarded so an all-whitespace edge
|
||||
* node is never emptied into a schema-invalid empty text node.
|
||||
*/
|
||||
function normalizeDefinitionText(def: any): void {
|
||||
const textNodes: any[] = [];
|
||||
collectTextNodes(def, textNodes);
|
||||
for (const t of textNodes) {
|
||||
// Skip text carrying a `code` mark: inline code is a verbatim literal, not
|
||||
// prose typography. Rewriting quotes/dashes/special-spaces there would
|
||||
// corrupt the literal's meaning (a string literal, an em-dash flag, i18n).
|
||||
// Leaving it untouched also makes it contribute its RAW text to
|
||||
// `footnoteMergeKey`, so two notes differing only by glyphs inside code
|
||||
// stay distinct (while prose glyph-forks still merge). See #419.
|
||||
if ((t.marks || []).some((m: any) => m?.type === "code")) continue;
|
||||
t.text = normalizeAndCollapse(t.text);
|
||||
}
|
||||
if (textNodes.length === 0) return;
|
||||
const hasCodeMark = (t: any): boolean =>
|
||||
(t.marks || []).some((m: any) => m?.type === "code");
|
||||
const first = textNodes[0];
|
||||
if (!hasCodeMark(first)) {
|
||||
const startTrimmed = first.text.replace(/^ +/, "");
|
||||
if (startTrimmed !== "") first.text = startTrimmed;
|
||||
}
|
||||
const last = textNodes[textNodes.length - 1];
|
||||
if (!hasCodeMark(last)) {
|
||||
const endTrimmed = last.text.replace(/ +$/, "");
|
||||
if (endTrimmed !== "") last.text = endTrimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrite `footnoteReference` ids IN PLACE using `defIdToCanon` (deep). */
|
||||
function rehangReferences(
|
||||
node: any,
|
||||
defIdToCanon: Map<string, string>,
|
||||
): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.type === FOOTNOTE_REFERENCE_NAME) {
|
||||
const id = node?.attrs?.id;
|
||||
if (typeof id === "string") {
|
||||
const canon = defIdToCanon.get(id);
|
||||
if (canon && canon !== id) node.attrs.id = canon;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) rehangReferences(child, defIdToCanon);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, order-independent serialization of a mark's `attrs`: sort keys so the
|
||||
* same attrs always yield the same string regardless of authoring order. Empty /
|
||||
* missing attrs -> "" (so an attr-less mark keys identically to a type-only mark
|
||||
* signature, preserving bold-vs-plain parity).
|
||||
*/
|
||||
function stableAttrs(attrs: any): string {
|
||||
if (!attrs || typeof attrs !== "object") return "";
|
||||
const sorted: Record<string, any> = {};
|
||||
for (const k of Object.keys(attrs).sort()) sorted[k] = attrs[k];
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* ATTRS-AWARE merge key for a footnote definition. Deliberately DIVERGES from
|
||||
* the shared `footnoteContentKey` (@docmost/prosemirror-markdown): that key's mark
|
||||
* signature is TYPE-ONLY (`m.type`), so two definitions with identical visible
|
||||
* text but marks differing only in ATTRIBUTES — most importantly a `link` with a
|
||||
* different `href` (footnotes are usually citations/links), also `code` /
|
||||
* `highlight` with differing attrs — collapse to the SAME key and get merged;
|
||||
* one definition then loses its references and the canonicalizer deletes it as an
|
||||
* orphan, silently dropping a distinct link target (data loss, #419).
|
||||
*
|
||||
* This key folds each mark's `attrs` (stable, sorted-key serialization) into the
|
||||
* signature, so different-href / different-attr notes stay separate. We do NOT
|
||||
* change `footnoteContentKey` itself: it is shared with the live
|
||||
* `insertInlineFootnote` / `commentsToFootnotes` dedup and altering it there
|
||||
* would change their behaviour — out of scope here.
|
||||
*
|
||||
* The TEXT portion mirrors `footnoteContentKey` exactly (per text node
|
||||
* `text + mark-signature`, concatenated, whitespace-collapsed, trimmed) over the
|
||||
* already-in-place-normalized text, so empty text still yields "" (empties never
|
||||
* collapse) and merge parity with the rest of the pass is preserved.
|
||||
*/
|
||||
function footnoteMergeKey(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
|
||||
.filter((m: any) => m && m.type)
|
||||
.map((m: any) => `${m.type}${stableAttrs(m.attrs)}`)
|
||||
.sort()
|
||||
.join(",")
|
||||
: "";
|
||||
parts.push(`${n.text}${marks}`);
|
||||
}
|
||||
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
|
||||
};
|
||||
visit(defNode);
|
||||
return parts
|
||||
.join("")
|
||||
.replace(/[ \t\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize footnote-definition text and merge definitions whose normalized
|
||||
* text (+ mark signature) matches. See the file header for the full contract.
|
||||
* Pure (deep-clones input, deterministic, idempotent). Intended to run
|
||||
* immediately BEFORE `canonicalizeFootnotes(doc)`.
|
||||
*/
|
||||
export function normalizeAndMergeFootnotes<T = any>(doc: T): T {
|
||||
if (doc == null || typeof doc !== "object") return doc;
|
||||
const out = cloneJson(doc) as any;
|
||||
|
||||
// 1) All definitions in document order; normalize each one's text in place.
|
||||
const defNodes: any[] = [];
|
||||
collectDefinitions(out, defNodes);
|
||||
for (const def of defNodes) normalizeDefinitionText(def);
|
||||
|
||||
// 2) Merge key per definition (normalized text + inline-mark signature). The
|
||||
// first definition in document order per key wins; later ones map onto it.
|
||||
// Empty-text definitions (key === "") are NOT merged — otherwise every
|
||||
// empty footnote would collapse into one (parity with insertInlineFootnote).
|
||||
const keyToCanon = new Map<string, string>();
|
||||
const defIdToCanon = new Map<string, string>();
|
||||
for (const def of defNodes) {
|
||||
const id = def?.attrs?.id;
|
||||
if (typeof id !== "string" || id === "") continue;
|
||||
const key = footnoteMergeKey(def);
|
||||
if (key === "") continue;
|
||||
const canon = keyToCanon.get(key);
|
||||
if (canon === undefined) {
|
||||
keyToCanon.set(key, id);
|
||||
} else if (canon !== id) {
|
||||
defIdToCanon.set(id, canon);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Re-hang references from duplicate ids onto the canonical id. Duplicate
|
||||
// definitions keep their ids but now have no references -> the following
|
||||
// canonicalizer pass drops them as orphans.
|
||||
if (defIdToCanon.size > 0) rehangReferences(out, defIdToCanon);
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
||||
*/
|
||||
|
||||
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
|
||||
import {
|
||||
blockPlainText,
|
||||
footnoteContentKey,
|
||||
@@ -766,6 +767,8 @@ export function insertInlineFootnote(
|
||||
appendDefinition(working, makeFootnoteDefinition(footnoteId, inline));
|
||||
}
|
||||
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
working = normalizeAndMergeFootnotes(working);
|
||||
// Derive numbering + the single bottom list deterministically.
|
||||
working = canonicalizeFootnotes(working);
|
||||
return { doc: working, inserted: true, footnoteId, reused };
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
// Mock-HTTP tests for the cursor-pagination migration in DocmostClient (#442).
|
||||
//
|
||||
// The server switched its list endpoints from OFFSET (`page`) to CURSOR
|
||||
// (`cursor`/`nextCursor`) pagination, and the global ValidationPipe silently
|
||||
// strips the obsolete `page` field — so the old offset loops re-fetched page
|
||||
// one forever (hasNextPage stuck true), dropping every item past the first
|
||||
// page. These tests pin the new cursor behaviour and the immovable-cursor
|
||||
// guard that prevents a silent spin/duplication if the protocol drifts again.
|
||||
//
|
||||
// A local http.createServer stands in for Docmost so everything stays
|
||||
// deterministic and offline (same harness style as reauth.test.mjs).
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const openServers = [];
|
||||
async function spawn(handler) {
|
||||
const { server, baseURL } = await startServer(handler);
|
||||
openServers.push(server);
|
||||
return { server, baseURL };
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
await Promise.all(openServers.map((s) => closeServer(s)));
|
||||
});
|
||||
|
||||
// A login handler shared by every server below.
|
||||
function handleLogin(req, res) {
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1) listSidebarPages: collects every cursor page; #requests == #pages.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("listSidebarPages walks all cursor pages and collects every item", async () => {
|
||||
// Three pages keyed by the cursor the client sends back.
|
||||
const PAGES = {
|
||||
"": { items: [{ id: "a" }, { id: "b" }], nextCursor: "c1" },
|
||||
c1: { items: [{ id: "c" }, { id: "d" }], nextCursor: "c2" },
|
||||
c2: { items: [{ id: "e" }], nextCursor: null },
|
||||
};
|
||||
let requests = 0;
|
||||
const sentLimits = [];
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
requests++;
|
||||
const body = JSON.parse(raw || "{}");
|
||||
sentLimits.push(body.limit);
|
||||
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: page.items,
|
||||
meta: {
|
||||
hasNextPage: page.nextCursor != null,
|
||||
nextCursor: page.nextCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const all = await client.listSidebarPages("space-1");
|
||||
|
||||
assert.equal(requests, 3, "one request per cursor page");
|
||||
assert.deepEqual(
|
||||
all.map((p) => p.id),
|
||||
["a", "b", "c", "d", "e"],
|
||||
"all items across all pages collected in order",
|
||||
);
|
||||
assert.ok(
|
||||
sentLimits.every((l) => l === 100),
|
||||
"requests limit:100 (server-side max)",
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2) REGRESSION on the bug class: server IGNORES the cursor param and always
|
||||
// returns page one with hasNextPage:true -> the immovable-cursor guard must
|
||||
// terminate the loop with no duplicates, NOT spin to MAX_PAGES.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("listSidebarPages terminates (no dups) when the server ignores the cursor", async () => {
|
||||
let requests = 0;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
requests++;
|
||||
// Always the SAME first page with hasNextPage:true and the SAME cursor,
|
||||
// exactly as a server that no longer understands our pagination param.
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ id: "x1" }, { id: "x2" }],
|
||||
meta: { hasNextPage: true, nextCursor: "stuck" },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const all = await client.listSidebarPages("space-1");
|
||||
|
||||
// Request 1 (no cursor) gets "stuck"; request 2 (cursor "stuck") gets "stuck"
|
||||
// again -> guard trips. Far below the MAX_PAGES=50 ceiling; no runaway dups.
|
||||
assert.equal(requests, 2, "stops as soon as the cursor stops moving");
|
||||
assert.equal(all.length, 4, "no runaway accumulation / duplication");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", async () => {
|
||||
let treeRequests = 0;
|
||||
let sidebarRequests = 0;
|
||||
let treeBody = null;
|
||||
|
||||
const NODES = [
|
||||
{ id: "r1", slugId: "r1s", title: "Root 1", parentPageId: null, hasChildren: true, spaceId: "space-1", position: "a", icon: null, canEdit: true },
|
||||
{ id: "c1", slugId: "c1s", title: "Child 1", parentPageId: "r1", hasChildren: false, spaceId: "space-1", position: "a", icon: null, canEdit: true },
|
||||
{ id: "r2", slugId: "r2s", title: "Root 2", parentPageId: null, hasChildren: false, spaceId: "space-1", position: "b", icon: null, canEdit: true },
|
||||
];
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/tree") {
|
||||
treeRequests++;
|
||||
treeBody = JSON.parse(raw || "{}");
|
||||
sendJson(res, 200, { success: true, data: { items: NODES } });
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sidebarRequests++;
|
||||
sendJson(res, 200, { success: true, data: { items: [], meta: {} } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
// list_pages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
|
||||
const tree = await client.listPages("space-1", 50, true);
|
||||
|
||||
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
|
||||
assert.equal(sidebarRequests, 0, "no per-node sidebar BFS requests");
|
||||
assert.deepEqual(treeBody, { spaceId: "space-1" }, "space scope posts spaceId only");
|
||||
// buildPageTree nests c1 under r1; two roots at the top level.
|
||||
assert.equal(tree.length, 2, "two root nodes");
|
||||
const r1 = tree.find((n) => n.id === "r1");
|
||||
assert.equal(r1.children.length, 1, "child nested under its root");
|
||||
assert.equal(r1.children[0].id, "c1");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3b) enumerateSpacePages fallback: /pages/tree 404 -> cursor BFS via sidebar.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("enumerateSpacePages falls back to the cursor BFS on /pages/tree 404", async () => {
|
||||
let treeRequests = 0;
|
||||
const sidebarCalls = [];
|
||||
|
||||
// Root level: one root with children. Child level (pageId=r1): one leaf.
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/tree") {
|
||||
treeRequests++;
|
||||
// Stock upstream Docmost has no /pages/tree.
|
||||
sendJson(res, 404, { message: "Not Found" });
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
sidebarCalls.push(body.pageId ?? "<root>");
|
||||
if (!body.pageId) {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: "r1", title: "Root", parentPageId: null, hasChildren: true },
|
||||
],
|
||||
meta: { hasNextPage: false, nextCursor: null },
|
||||
},
|
||||
});
|
||||
} else if (body.pageId === "r1") {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: "c1", title: "Leaf", parentPageId: "r1", hasChildren: false },
|
||||
],
|
||||
meta: { hasNextPage: false, nextCursor: null },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const tree = await client.listPages("space-1", 50, true);
|
||||
|
||||
assert.ok(treeRequests >= 1, "the tree endpoint was attempted first");
|
||||
assert.deepEqual(
|
||||
sidebarCalls,
|
||||
["<root>", "r1"],
|
||||
"fell back to the sidebar BFS: roots then the root's children",
|
||||
);
|
||||
assert.equal(tree.length, 1, "one root in the built tree");
|
||||
assert.equal(tree[0].children[0].id, "c1", "leaf nested via the BFS");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3c) enumerateSpacePages fallback SUBTREE: /pages/tree 404 + a rootPageId ->
|
||||
// the ROOT page itself must be seeded (via getPageRaw) so its own comments
|
||||
// aren't dropped. listSidebarPages(spaceId, root) returns only the root's
|
||||
// CHILDREN, so without the seed the root would be absent. (Finding 1.)
|
||||
// -----------------------------------------------------------------------------
|
||||
test("enumerateSpacePages fallback subtree seeds the ROOT page itself", async () => {
|
||||
const sidebarCalls = [];
|
||||
let infoRequests = 0;
|
||||
const commentedPages = [];
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/tree") {
|
||||
// Stock upstream Docmost -> fall back to the BFS.
|
||||
sendJson(res, 404, { message: "Not Found" });
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
// getPageRaw for the root seed. Shape mirrors a real page-info response.
|
||||
infoRequests++;
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { id: "root", title: "Root", spaceId: "space-1", hasChildren: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
sidebarCalls.push(body.pageId ?? "<root>");
|
||||
// Children of the root: one leaf. (Root itself is NOT in this list.)
|
||||
const items =
|
||||
body.pageId === "root"
|
||||
? [{ id: "leaf", title: "Leaf", parentPageId: "root", hasChildren: false }]
|
||||
: [];
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items, meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
commentedPages.push(body.pageId);
|
||||
const items =
|
||||
body.pageId === "root"
|
||||
? [{ id: "cm1", createdAt: "2030-01-01T00:00:00.000Z", content: null }]
|
||||
: [];
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items, meta: { nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
// checkNewComments(space, since, parentPageId) exercises the subtree fallback.
|
||||
const result = await client.checkNewComments(
|
||||
"space-1",
|
||||
"2020-01-01T00:00:00.000Z",
|
||||
"root",
|
||||
);
|
||||
|
||||
assert.equal(infoRequests, 1, "root was seeded via one getPageRaw");
|
||||
assert.equal(sidebarCalls[0], "root", "BFS walked the root's children");
|
||||
assert.ok(
|
||||
commentedPages.includes("root"),
|
||||
"the ROOT page is in scope (its comments were fetched) — not dropped",
|
||||
);
|
||||
assert.ok(commentedPages.includes("leaf"), "the descendant is in scope too");
|
||||
assert.equal(result.checkedPages, 2, "root + one descendant scanned");
|
||||
assert.equal(result.totalNewComments, 1, "the root's fresh comment found");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5) listComments immovable-cursor guard: the server IGNORES the cursor and
|
||||
// keeps returning the same nextCursor -> the loop must terminate (no
|
||||
// infinite loop, no duplicates), not spin forever. (Finding 4.)
|
||||
// -----------------------------------------------------------------------------
|
||||
test("listComments terminates (no dups) when the server ignores the cursor", async () => {
|
||||
let requests = 0;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/comments") {
|
||||
requests++;
|
||||
// Always the SAME page with the SAME nextCursor, as a server that no
|
||||
// longer advances the cursor would.
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ id: "cm1", createdAt: "2030-01-01T00:00:00.000Z", content: null }],
|
||||
meta: { nextCursor: "stuck" },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const { items } = await client.listComments("page-1", true);
|
||||
|
||||
// Request 1 (no cursor) gets "stuck"; request 2 (cursor "stuck") gets "stuck"
|
||||
// again -> guard trips. Bounded far below MAX_PAGES=50, no runaway dups.
|
||||
assert.equal(requests, 2, "stops as soon as the cursor stops moving");
|
||||
assert.equal(items.length, 2, "no runaway accumulation / duplication");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4) check_new_comments subtree: the root is included in scope WITHOUT a
|
||||
// separate getPageRaw (/pages/info) request for the parent.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("checkNewComments subtree includes the root without a separate getPageRaw", async () => {
|
||||
let pageInfoRequests = 0;
|
||||
let treeBody = null;
|
||||
const commentedPages = [];
|
||||
|
||||
// /pages/tree (subtree) returns the parent itself plus a descendant, exactly
|
||||
// as getPageAndDescendants seeds with id = parentPageId.
|
||||
const NODES = [
|
||||
{ id: "parent", title: "Parent", parentPageId: null, hasChildren: true },
|
||||
{ id: "kid", title: "Kid", parentPageId: "parent", hasChildren: false },
|
||||
];
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/tree") {
|
||||
treeBody = JSON.parse(raw || "{}");
|
||||
sendJson(res, 200, { success: true, data: { items: NODES } });
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
// If checkNewComments still fetched the parent separately this would fire.
|
||||
pageInfoRequests++;
|
||||
sendJson(res, 200, { success: true, data: { id: "parent" } });
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
commentedPages.push(body.pageId);
|
||||
// One fresh comment on the parent, none elsewhere.
|
||||
const items =
|
||||
body.pageId === "parent"
|
||||
? [{ id: "cm1", createdAt: "2030-01-01T00:00:00.000Z", content: null }]
|
||||
: [];
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items, meta: { nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.checkNewComments(
|
||||
"space-1",
|
||||
"2020-01-01T00:00:00.000Z",
|
||||
"parent",
|
||||
);
|
||||
|
||||
assert.equal(pageInfoRequests, 0, "no separate getPageRaw for the root");
|
||||
assert.deepEqual(treeBody, { pageId: "parent" }, "subtree scope posts pageId");
|
||||
assert.ok(
|
||||
commentedPages.includes("parent"),
|
||||
"the root itself is in scope (comments fetched for it)",
|
||||
);
|
||||
assert.ok(commentedPages.includes("kid"), "descendants are in scope too");
|
||||
assert.equal(result.checkedPages, 2, "root + one descendant scanned");
|
||||
assert.equal(result.totalNewComments, 1, "the root's fresh comment found");
|
||||
});
|
||||
@@ -297,12 +297,12 @@ test("a response with ONLY authTokenRefresh (no authToken) rejects login", async
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5) paginateAll loop guards.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("paginateAll stops at the MAX_PAGES cap when the server always issues a fresh cursor", async () => {
|
||||
test("paginateAll stops at the MAX_PAGES cap when hasNextPage is always true", async () => {
|
||||
let pageRequests = 0;
|
||||
const LIMIT = 100;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const body = JSON.parse((await readBody(req)) || "{}");
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
@@ -311,18 +311,15 @@ test("paginateAll stops at the MAX_PAGES cap when the server always issues a fre
|
||||
}
|
||||
if (req.url === "/api/spaces") {
|
||||
pageRequests++;
|
||||
// Always return a FULL page AND hasNextPage:true with a FRESH nextCursor
|
||||
// that differs from the one the client just sent, so the immovable-cursor
|
||||
// guard never trips — only the MAX_PAGES ceiling can stop the loop.
|
||||
// Always return a FULL page (== requested limit) AND hasNextPage:true.
|
||||
// Both the page-length check and the hasNextPage flag say "keep going",
|
||||
// so only the MAX_PAGES ceiling can stop the loop.
|
||||
const items = Array.from({ length: LIMIT }, (_, i) => ({
|
||||
id: `s-${pageRequests}-${i}`,
|
||||
}));
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
meta: { hasNextPage: true, nextCursor: `cursor-${pageRequests}` },
|
||||
},
|
||||
data: { items, meta: { hasNextPage: true } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -341,7 +338,7 @@ test("paginateAll stops at the MAX_PAGES cap when the server always issues a fre
|
||||
assert.equal(all.length, 50 * LIMIT, "accumulates one full page per request");
|
||||
});
|
||||
|
||||
test("paginateAll stops on the immovable-cursor guard when the server ignores the cursor param", async () => {
|
||||
test("paginateAll stops early on a short page even if hasNextPage is true", async () => {
|
||||
let pageRequests = 0;
|
||||
const LIMIT = 100;
|
||||
|
||||
@@ -355,17 +352,15 @@ test("paginateAll stops on the immovable-cursor guard when the server ignores th
|
||||
}
|
||||
if (req.url === "/api/spaces") {
|
||||
pageRequests++;
|
||||
// The bug class: the server IGNORES the pagination param and keeps
|
||||
// returning page one with hasNextPage:true and the SAME nextCursor. The
|
||||
// immovable-cursor guard must stop the loop instead of spinning to
|
||||
// MAX_PAGES and duplicating items.
|
||||
const items = Array.from({ length: LIMIT }, (_, i) => ({ id: `s-${i}` }));
|
||||
// First page is full; second page is SHORT (fewer than limit). The short
|
||||
// page must stop the loop immediately even though hasNextPage stays true.
|
||||
const count = pageRequests === 1 ? LIMIT : 3;
|
||||
const items = Array.from({ length: count }, (_, i) => ({
|
||||
id: `s-${pageRequests}-${i}`,
|
||||
}));
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
meta: { hasNextPage: true, nextCursor: "stuck" },
|
||||
},
|
||||
data: { items, meta: { hasNextPage: true } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -375,10 +370,8 @@ test("paginateAll stops on the immovable-cursor guard when the server ignores th
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const all = await client.paginateAll("/spaces", {}, LIMIT);
|
||||
|
||||
// Request 1 sends no cursor and receives "stuck"; request 2 sends "stuck" and
|
||||
// receives "stuck" again -> guard trips after exactly two requests, no dups.
|
||||
assert.equal(pageRequests, 2, "stops once the cursor stops moving");
|
||||
assert.equal(all.length, 2 * LIMIT, "no runaway accumulation past the guard");
|
||||
assert.equal(pageRequests, 2, "stops right after the first short page");
|
||||
assert.equal(all.length, LIMIT + 3, "full page + short page accumulated");
|
||||
});
|
||||
|
||||
test("paginateAll handles both {data:{items,meta}} and {items,meta} envelopes", async () => {
|
||||
@@ -394,16 +387,16 @@ test("paginateAll handles both {data:{items,meta}} and {items,meta} envelopes",
|
||||
}
|
||||
if (req.url === "/api/groups") {
|
||||
bareRequests.push(1);
|
||||
// Page 1: hasNextPage true with a next cursor. Page 2: no next -> stop.
|
||||
// Page 1: full page, hasNextPage true. Page 2: short page -> stop.
|
||||
if (bareRequests.length === 1) {
|
||||
sendJson(res, 200, {
|
||||
items: Array.from({ length: 100 }, (_, i) => ({ id: `g${i}` })),
|
||||
meta: { hasNextPage: true, nextCursor: "c2" },
|
||||
meta: { hasNextPage: true },
|
||||
});
|
||||
} else {
|
||||
sendJson(res, 200, {
|
||||
items: [{ id: "tail" }],
|
||||
meta: { hasNextPage: false, nextCursor: null },
|
||||
meta: { hasNextPage: false },
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeAndMergeFootnotes } from "../../build/lib/footnote-normalize-merge.js";
|
||||
import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js";
|
||||
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const c of node.content) findAll(c, type, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
const defs = (doc) => findAll(doc, "footnoteDefinition");
|
||||
const defIds = (doc) => defs(doc).map((d) => d.attrs.id);
|
||||
const refIds = (doc) => findAll(doc, "footnoteReference").map((r) => r.attrs.id);
|
||||
const defText = (d) =>
|
||||
findAll(d, "text")
|
||||
.map((t) => t.text)
|
||||
.join("");
|
||||
|
||||
const ref = (id) => ({ type: "footnoteReference", attrs: { id } });
|
||||
const para = (...inline) => ({ type: "paragraph", content: inline });
|
||||
const txt = (text, marks) =>
|
||||
marks ? { type: "text", text, marks } : { type: "text", text };
|
||||
const def = (id, ...inline) => ({
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id },
|
||||
content: [para(...inline)],
|
||||
});
|
||||
const list = (...defs) => ({ type: "footnotesList", content: defs });
|
||||
const doc = (...content) => ({ type: "doc", content });
|
||||
|
||||
// --- Normalization + merge of glyph forks ----------------------------------
|
||||
|
||||
test("typographic double quotes «…» vs \"…\" merge into one", () => {
|
||||
const d = doc(
|
||||
para(txt("a"), ref("A"), txt(" b"), ref("B")),
|
||||
list(def("A", txt("«word»")), def("B", txt('"word"'))),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
// Both references now point at the first definition's id.
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
// Surviving text is ASCII-normalized.
|
||||
assert.equal(defText(defs(out)[0]), '"word"');
|
||||
// Duplicate def kept its id (canonicalizer removes it as an orphan later).
|
||||
const canon = canonicalizeFootnotes(out);
|
||||
assert.deepEqual(defIds(canon), ["A"]);
|
||||
assert.equal(findAll(canon, "footnotesList").length, 1);
|
||||
});
|
||||
|
||||
test("em/en dash and hyphen merge", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B"), ref("C")),
|
||||
list(
|
||||
def("A", txt("see — here")),
|
||||
def("B", txt("see – here")),
|
||||
def("C", txt("see - here")),
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A", "A"]);
|
||||
assert.equal(defText(defs(out)[0]), "see - here");
|
||||
});
|
||||
|
||||
test("NBSP and extra spaces merge with normal spacing", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(
|
||||
def("A", txt("foo bar")), // NBSP
|
||||
def("B", txt("foo bar")), // collapsed spaces
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
assert.equal(defText(defs(out)[0]), "foo bar");
|
||||
});
|
||||
|
||||
test("same text but different styling (bold vs plain) does NOT merge", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(
|
||||
def("A", txt("word", [{ type: "bold" }])),
|
||||
def("B", txt("word")),
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
// No re-hang: references keep their own ids.
|
||||
assert.deepEqual(refIds(out), ["A", "B"]);
|
||||
assert.deepEqual(defIds(out), ["A", "B"]);
|
||||
// Marks preserved on the surviving text node.
|
||||
assert.deepEqual(defs(out)[0].content[0].content[0].marks, [
|
||||
{ type: "bold" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("same text but a link mark with different href does NOT merge (data-loss guard)", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(
|
||||
def("A", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])),
|
||||
def("B", txt("source", [{ type: "link", attrs: { href: "https://b.example/2" } }])),
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
// No re-hang: each reference keeps its own definition (distinct link target).
|
||||
assert.deepEqual(refIds(out), ["A", "B"]);
|
||||
assert.deepEqual(defIds(out), ["A", "B"]);
|
||||
// Both distinct hrefs survive.
|
||||
assert.deepEqual(
|
||||
defs(out).map((dn) => dn.content[0].content[0].marks[0].attrs.href),
|
||||
["https://a.example/1", "https://b.example/2"],
|
||||
);
|
||||
// Canonicalize keeps both as two tail entries (neither is an orphan).
|
||||
const canon = canonicalizeFootnotes(out);
|
||||
assert.deepEqual(defIds(canon), ["A", "B"]);
|
||||
assert.deepEqual(refIds(canon), ["A", "B"]);
|
||||
});
|
||||
|
||||
test("same text and SAME link href still merges (attrs-aware key doesn't over-separate)", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(
|
||||
def("A", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])),
|
||||
def("B", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])),
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
const canon = canonicalizeFootnotes(out);
|
||||
assert.deepEqual(defIds(canon), ["A"]);
|
||||
assert.equal(findAll(canon, "footnotesList").length, 1);
|
||||
});
|
||||
|
||||
test("marks are kept on merged (surviving) definition text", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(
|
||||
def("A", txt("«x»", [{ type: "italic" }])),
|
||||
def("B", txt("«x»", [{ type: "italic" }])),
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
assert.deepEqual(defs(out)[0].content[0].content[0].marks, [
|
||||
{ type: "italic" },
|
||||
]);
|
||||
assert.equal(defText(defs(out)[0]), '"x"');
|
||||
});
|
||||
|
||||
// --- Inline code is verbatim (not typography) ------------------------------
|
||||
|
||||
test("text inside a code mark is left verbatim; prose in the same def is normalized", () => {
|
||||
const d = doc(
|
||||
para(ref("A")),
|
||||
list({
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id: "A" },
|
||||
content: [
|
||||
para(
|
||||
txt("a—b «x»", [{ type: "code" }]),
|
||||
txt(" prose «y» — z"),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
const nodes = findAll(defs(out)[0], "text");
|
||||
// Code node: byte-for-byte unchanged (typography preserved).
|
||||
assert.equal(nodes[0].text, "a—b «x»");
|
||||
// Prose node: dashes/quotes normalized to ASCII.
|
||||
assert.equal(nodes[1].text, ' prose "y" - z');
|
||||
});
|
||||
|
||||
test("two notes differing ONLY by glyphs inside a code mark do NOT merge", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(
|
||||
def("A", txt("«x»", [{ type: "code" }]), txt(" same prose «q»")),
|
||||
def("B", txt('"x"', [{ type: "code" }]), txt(" same prose «q»")),
|
||||
),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
// Prose is identical after normalization, but the code literals differ raw
|
||||
// -> the merge key diverges -> both definitions survive, no re-hang.
|
||||
assert.deepEqual(refIds(out), ["A", "B"]);
|
||||
assert.deepEqual(defIds(out), ["A", "B"]);
|
||||
// Each code literal stays verbatim.
|
||||
assert.equal(defs(out)[0].content[0].content[0].text, "«x»");
|
||||
assert.equal(defs(out)[1].content[0].content[0].text, '"x"');
|
||||
// Both survive canonicalization (neither is an orphan).
|
||||
const canon = canonicalizeFootnotes(out);
|
||||
assert.deepEqual(defIds(canon), ["A", "B"]);
|
||||
assert.deepEqual(refIds(canon), ["A", "B"]);
|
||||
});
|
||||
|
||||
// --- Composition with the canonicalizer ------------------------------------
|
||||
|
||||
test("pass + canonicalize: single tail list and sequential numbering", () => {
|
||||
const d = doc(
|
||||
para(txt("intro "), ref("A"), txt(" middle "), ref("B")),
|
||||
list(def("A", txt("«note»")), def("B", txt('"note"'))),
|
||||
);
|
||||
const canon = canonicalizeFootnotes(normalizeAndMergeFootnotes(d));
|
||||
assert.equal(findAll(canon, "footnotesList").length, 1);
|
||||
assert.deepEqual(defIds(canon), ["A"]);
|
||||
assert.deepEqual(refIds(canon), ["A", "A"]);
|
||||
});
|
||||
|
||||
// --- Idempotency -----------------------------------------------------------
|
||||
|
||||
test("idempotent: a second run is a no-op", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(def("A", txt("«word»")), def("B", txt('"word"'))),
|
||||
);
|
||||
const once = normalizeAndMergeFootnotes(d);
|
||||
const twice = normalizeAndMergeFootnotes(once);
|
||||
assert.deepEqual(twice, once);
|
||||
});
|
||||
|
||||
test("input document is not mutated (pure)", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(def("A", txt("«word»")), def("B", txt('"word"'))),
|
||||
);
|
||||
const snapshot = JSON.parse(JSON.stringify(d));
|
||||
normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(d, snapshot);
|
||||
});
|
||||
|
||||
// --- Nested definitions ----------------------------------------------------
|
||||
|
||||
test("definitions nested in a callout are normalized and merged", () => {
|
||||
const callout = (...content) => ({
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content,
|
||||
});
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
callout(list(def("A", txt("«c»")), def("B", txt('"c"')))),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
assert.equal(defText(defs(out)[0]), '"c"');
|
||||
});
|
||||
|
||||
// --- Empty footnotes -------------------------------------------------------
|
||||
|
||||
test("empty footnotes do NOT collapse into each other", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(def("A", txt("")), { type: "footnoteDefinition", attrs: { id: "B" }, content: [{ type: "paragraph" }] }),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
// Both empty definitions keep distinct ids; references unchanged.
|
||||
assert.deepEqual(refIds(out), ["A", "B"]);
|
||||
assert.deepEqual(defIds(out), ["A", "B"]);
|
||||
});
|
||||
|
||||
// --- Body text left untouched ----------------------------------------------
|
||||
|
||||
test("body text (outside footnotes) is NOT normalized", () => {
|
||||
const d = doc(
|
||||
para(txt("body «quoted» — dash"), ref("A")),
|
||||
list(def("A", txt("«note»"))),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
// Body paragraph keeps its typographic glyphs verbatim.
|
||||
assert.equal(out.content[0].content[0].text, "body «quoted» — dash");
|
||||
// Footnote text IS normalized.
|
||||
assert.equal(defText(defs(out)[0]), '"note"');
|
||||
});
|
||||
|
||||
// --- Multi-paragraph structure preserved -----------------------------------
|
||||
|
||||
test("multi-paragraph definition: text normalized, structure preserved", () => {
|
||||
const d = doc(
|
||||
para(ref("A")),
|
||||
list({
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id: "A" },
|
||||
content: [para(txt("«p1»")), para(txt("p2 — end"))],
|
||||
}),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
const def0 = defs(out)[0];
|
||||
assert.equal(def0.content.length, 2);
|
||||
assert.equal(def0.content[0].content[0].text, '"p1"');
|
||||
assert.equal(def0.content[1].content[0].text, "p2 - end");
|
||||
});
|
||||
|
||||
// --- Multi-reference footnote not broken -----------------------------------
|
||||
|
||||
test("one id shared by multiple references is preserved", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), txt(" x "), ref("A")),
|
||||
list(def("A", txt("note"))),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
assert.deepEqual(defIds(out), ["A"]);
|
||||
});
|
||||
|
||||
// --- Whole-definition edge trim --------------------------------------------
|
||||
|
||||
test("leading/trailing whitespace is trimmed for the merge and stored text", () => {
|
||||
const d = doc(
|
||||
para(ref("A"), ref("B")),
|
||||
list(def("A", txt(" hello ")), def("B", txt("hello"))),
|
||||
);
|
||||
const out = normalizeAndMergeFootnotes(d);
|
||||
assert.deepEqual(refIds(out), ["A", "A"]);
|
||||
assert.equal(defText(defs(out)[0]), "hello");
|
||||
});
|
||||
Reference in New Issue
Block a user