Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f9f6c7585 | |||
| 91c4cc925a |
@@ -729,35 +729,6 @@ export class AiChatToolsService {
|
||||
}),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// meta.hash in the result is the baseHash drawioUpdate requires.
|
||||
drawioGet: sharedTool(
|
||||
sharedToolSpecs.drawioGet,
|
||||
async ({ pageId, node, format }) =>
|
||||
await client.drawioGet(pageId, node, format ?? 'xml'),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// The flat schema fields are regrouped into the client's `where` object.
|
||||
drawioCreate: sharedTool(
|
||||
sharedToolSpecs.drawioCreate,
|
||||
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
|
||||
await client.drawioCreate(
|
||||
pageId,
|
||||
{ position, anchorNodeId, anchorText },
|
||||
xml,
|
||||
title,
|
||||
),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// baseHash is the optimistic lock: mismatch => structured conflict error.
|
||||
drawioUpdate: sharedTool(
|
||||
sharedToolSpecs.drawioUpdate,
|
||||
async ({ pageId, node, xml, baseHash }) =>
|
||||
await client.drawioUpdate(pageId, node, xml, baseHash),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||
tableInsertRow: sharedTool(
|
||||
|
||||
@@ -168,32 +168,6 @@ export interface DocmostClientLike {
|
||||
url: string,
|
||||
opts?: { align?: 'left' | 'center' | 'right'; alt?: string },
|
||||
): Promise<Record<string, unknown>>;
|
||||
// --- draw.io diagrams (#423, stage 1) ---
|
||||
// Read a diagram as decoded mxGraph XML (default) or the raw .drawio.svg.
|
||||
// meta.hash is the optimistic-lock key drawioUpdate expects as baseHash.
|
||||
drawioGet(
|
||||
pageId: string,
|
||||
node: string,
|
||||
format?: 'xml' | 'svg',
|
||||
): Promise<Record<string, unknown>>;
|
||||
// Lint mxGraph XML, build the .drawio.svg attachment and insert a drawio node.
|
||||
drawioCreate(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: 'before' | 'after' | 'append';
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
xml: string,
|
||||
title?: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
// Optimistic-locked full replacement of a diagram (baseHash from drawioGet).
|
||||
drawioUpdate(
|
||||
pageId: string,
|
||||
node: string,
|
||||
xml: string,
|
||||
baseHash: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
tableInsertRow(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
|
||||
+39
-117
@@ -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
|
||||
@@ -167,35 +168,6 @@ function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collab-token cache TTL in milliseconds (issue #435). Read fresh from the
|
||||
* environment on every mint — like collab-session.ts readConfig — so tests and a
|
||||
* live rollback can change it without reloading the module.
|
||||
*
|
||||
* Why a cache at all: the live CollabSession registry (#400/#431) keys sessions
|
||||
* on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH
|
||||
* collab-token sources mint a FRESH token per mutation — the in-app provider
|
||||
* re-signs a JWT whose iat/exp (seconds) changes every second, and the external
|
||||
* MCP POSTs /auth/collab-token each call — so the token in the key changed on
|
||||
* every op and the session was almost never reused (connect-storms, 25s
|
||||
* timeouts, zombie sessions). Caching the token per-client keeps the key stable
|
||||
* across a burst of mutations so ONE session is reused.
|
||||
*
|
||||
* Default 5 min: well under the 24h collab-token lifetime AND <= the collab
|
||||
* session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the
|
||||
* permission-staleness window is not widened beyond what #431 already accepted.
|
||||
* The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the
|
||||
* cache — an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables
|
||||
* the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls
|
||||
* back to the 5-min default with the cache ON — parseInt yields NaN, which is
|
||||
* treated as "not configured", not as "disabled". So to turn the cache off you
|
||||
* must set the value to exactly 0, not to garbage.
|
||||
*/
|
||||
function readCollabTokenTtlMs(): number {
|
||||
const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10);
|
||||
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
export class DocmostClient {
|
||||
private client: AxiosInstance;
|
||||
private token: string | null = null;
|
||||
@@ -234,15 +206,6 @@ export class DocmostClient {
|
||||
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||
private pageIdCache = new Map<string, string>();
|
||||
|
||||
// Collab-token cache (issue #435): the last minted collab token plus the
|
||||
// wall-clock time it was minted, so a burst of content mutations reuses ONE
|
||||
// token and therefore ONE live CollabSession (whose registry key includes the
|
||||
// token — #400 invariant 4). Per-instance: a DocmostClient is built per
|
||||
// user/per chat request, so a cached token can never leak across identities.
|
||||
// Reset whenever the client's identity changes (login() / this.token cleared);
|
||||
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
|
||||
private collabTokenCache: { token: string; mintedAt: number } | null = null;
|
||||
|
||||
// Two construction forms:
|
||||
// - new DocmostClient(config) // discriminated union (current)
|
||||
// - new DocmostClient(baseURL, email, password) // legacy positional creds
|
||||
@@ -311,11 +274,8 @@ export class DocmostClient {
|
||||
|
||||
if (config && isAuthError && !config._retry && !isLoginRequest) {
|
||||
config._retry = true;
|
||||
// Drop the stale token + Authorization header before re-login. Also
|
||||
// clear the collab-token cache (#435): a new identity/login must not
|
||||
// keep serving a collab token minted under the old one.
|
||||
// Drop the stale token + Authorization header before re-login.
|
||||
this.token = null;
|
||||
this.collabTokenCache = null;
|
||||
delete this.client.defaults.headers.common["Authorization"];
|
||||
try {
|
||||
await this.login();
|
||||
@@ -364,9 +324,6 @@ export class DocmostClient {
|
||||
throw new Error("getToken returned an empty token");
|
||||
}
|
||||
this.token = token;
|
||||
// Identity (re)established: drop any collab token minted under a
|
||||
// previous identity so the #435 cache can never outlive it.
|
||||
this.collabTokenCache = null;
|
||||
this.client.defaults.headers.common["Authorization"] =
|
||||
`Bearer ${token}`;
|
||||
})
|
||||
@@ -389,34 +346,8 @@ export class DocmostClient {
|
||||
* by this.client's response interceptor; this helper replicates that
|
||||
* behaviour for collab-token requests: ensure a token, try once, and on an
|
||||
* expired-token auth error perform a fresh login and retry exactly once.
|
||||
*
|
||||
* Collab-token cache (issue #435): both sources — the getCollabToken provider
|
||||
* (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) — mint
|
||||
* a FRESH token per call, whose string therefore changes every op. Since the
|
||||
* live CollabSession registry keys on the token string (#400/#431 invariant 4),
|
||||
* that churned the key and defeated session reuse. So we cache the last minted
|
||||
* token per-client for readCollabTokenTtlMs() and hand it back for a burst of
|
||||
* mutations, keeping the session key stable. `forceRefresh` bypasses the cache
|
||||
* (the 401/403 reauth retry uses it, so the retry cannot be handed the same
|
||||
* stale token that just failed — otherwise reauth would be a no-op). TTL 0
|
||||
* disables the cache: exact fetch-per-call legacy behaviour.
|
||||
*/
|
||||
private async getCollabTokenWithReauth(
|
||||
forceRefresh = false,
|
||||
): Promise<string> {
|
||||
const ttl = readCollabTokenTtlMs();
|
||||
// Serve the cached collab token while it is still fresh (identity isolation
|
||||
// is preserved: the cache is a per-instance field on a client built per
|
||||
// user/per chat request, and it is cleared on every identity change).
|
||||
if (
|
||||
!forceRefresh &&
|
||||
ttl > 0 &&
|
||||
this.collabTokenCache &&
|
||||
Date.now() - this.collabTokenCache.mintedAt < ttl
|
||||
) {
|
||||
return this.collabTokenCache.token;
|
||||
}
|
||||
|
||||
private async getCollabTokenWithReauth(): Promise<string> {
|
||||
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
|
||||
// (the internal agent's provenance collab token), use it instead of the
|
||||
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
|
||||
@@ -427,13 +358,23 @@ export class DocmostClient {
|
||||
if (typeof token !== "string" || token.length === 0) {
|
||||
throw new Error("getCollabToken returned an empty token");
|
||||
}
|
||||
return this.rememberCollabToken(token, ttl);
|
||||
return token;
|
||||
} catch (e) {
|
||||
// On an auth error retry EXACTLY once, forcing a refresh so the retry
|
||||
// re-invokes the provider (bypassing the cache) for a genuinely fresh
|
||||
// token. `!forceRefresh` bounds it to a single retry (no loop).
|
||||
if (this.isCollabAuthError(e) && !forceRefresh) {
|
||||
return this.getCollabTokenWithReauth(true);
|
||||
const axiosStatus = axios.isAxiosError(e)
|
||||
? e.response?.status
|
||||
: undefined;
|
||||
const attachedStatus = (e as any)?.status;
|
||||
const isAuthError =
|
||||
axiosStatus === 401 ||
|
||||
axiosStatus === 403 ||
|
||||
attachedStatus === 401 ||
|
||||
attachedStatus === 403;
|
||||
if (isAuthError) {
|
||||
const token = await this.getCollabTokenFn();
|
||||
if (typeof token !== "string" || token.length === 0) {
|
||||
throw new Error("getCollabToken returned an empty token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -441,51 +382,28 @@ export class DocmostClient {
|
||||
|
||||
await this.ensureAuthenticated();
|
||||
try {
|
||||
const token = await getCollabToken(this.apiUrl, this.token!);
|
||||
return this.rememberCollabToken(token, ttl);
|
||||
return await getCollabToken(this.apiUrl, this.token!);
|
||||
} catch (e) {
|
||||
// getCollabToken wraps the AxiosError in a plain Error but attaches the
|
||||
// HTTP status as `.status`, so isCollabAuthError detects an auth failure
|
||||
// via either the raw AxiosError shape OR the attached status.
|
||||
if (this.isCollabAuthError(e) && !forceRefresh) {
|
||||
// Fresh login (which clears this.token AND the collab-token cache), then
|
||||
// retry exactly once with the cache bypassed via forceRefresh.
|
||||
// HTTP status as `.status`, so detect an auth failure via either the raw
|
||||
// AxiosError shape OR the attached status.
|
||||
const axiosStatus = axios.isAxiosError(e)
|
||||
? e.response?.status
|
||||
: undefined;
|
||||
const attachedStatus = (e as any)?.status;
|
||||
const isAuthError =
|
||||
axiosStatus === 401 ||
|
||||
axiosStatus === 403 ||
|
||||
attachedStatus === 401 ||
|
||||
attachedStatus === 403;
|
||||
if (isAuthError) {
|
||||
await this.login();
|
||||
return this.getCollabTokenWithReauth(true);
|
||||
return await getCollabToken(this.apiUrl, this.token!);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a freshly minted collab token in the per-client cache (issue #435) and
|
||||
* return it unchanged. No-op write when the cache is disabled (ttl<=0) or the
|
||||
* token is empty, so a disabled cache is exact fetch-per-call legacy behaviour
|
||||
* and a bad token is never cached.
|
||||
*/
|
||||
private rememberCollabToken(token: string, ttl: number): string {
|
||||
if (ttl > 0 && typeof token === "string" && token.length > 0) {
|
||||
this.collabTokenCache = { token, mintedAt: Date.now() };
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error carries a 401/403 — either as a raw AxiosError
|
||||
* (`error.response.status`) or as the plain-Error `.status` that
|
||||
* lib/auth-utils.getCollabToken attaches after wrapping the AxiosError.
|
||||
*/
|
||||
private isCollabAuthError(e: unknown): boolean {
|
||||
const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined;
|
||||
const attachedStatus = (e as any)?.status;
|
||||
return (
|
||||
axiosStatus === 401 ||
|
||||
axiosStatus === 403 ||
|
||||
attachedStatus === 401 ||
|
||||
attachedStatus === 403
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the collaboration websocket, read the live doc, apply
|
||||
* `transform`, write the result, and wait for the server to persist it —
|
||||
@@ -1639,6 +1557,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
|
||||
@@ -1903,7 +1823,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).
|
||||
@@ -4158,7 +4079,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 "./node-ops.js";
|
||||
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` (footnote-authoring.ts) 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` (footnote-authoring.ts): 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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { blockPlainText } from "./node-ops.js";
|
||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
|
||||
import {
|
||||
footnoteContentKey,
|
||||
makeFootnoteDefinition,
|
||||
@@ -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,282 +0,0 @@
|
||||
// Unit tests for the collab-token cache (issue #435). The live CollabSession
|
||||
// registry (#400/#431) keys sessions on (wsUrl, pageId, collabToken), so a token
|
||||
// string that changes every op defeats reuse. This cache holds the last minted
|
||||
// token per DocmostClient for MCP_COLLAB_TOKEN_TTL_MS so a burst of mutations
|
||||
// reuses ONE token -> ONE session. These tests exercise both mint sources:
|
||||
// - the getCollabToken PROVIDER path (in-app agent), via a counting provider fn;
|
||||
// - the REST /auth/collab-token path (external MCP), via a mock http server.
|
||||
// getCollabTokenWithReauth is private in TS but a plain method on the compiled
|
||||
// build, so the tests call it directly (same convention as reauth.test.mjs).
|
||||
import { test, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
// Restore the env knob after each test so cases do not leak into one another.
|
||||
const ENV_KEY = "MCP_COLLAB_TOKEN_TTL_MS";
|
||||
afterEach(() => {
|
||||
delete process.env[ENV_KEY];
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small mock server for the REST /auth/collab-token path. Counts collab-token
|
||||
// mints and can be told to 401 the first N of them (to drive the reauth retry).
|
||||
// ---------------------------------------------------------------------------
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
function sendJson(res, status, obj, extra = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extra });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const openServers = [];
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
openServers.map((s) => new Promise((r) => s.close(r))),
|
||||
);
|
||||
});
|
||||
|
||||
// state: { collabCalls, loginCalls, unauthorizedCollabHits }
|
||||
function spawnCollabServer(state, { collabAuthFailsFor = 0 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
state.loginCalls++;
|
||||
// A fresh authToken per login so an identity change is observable.
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": `authToken=login-${state.loginCalls}; Path=/; HttpOnly`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/auth/collab-token") {
|
||||
state.collabCalls++;
|
||||
if (state.collabCalls <= collabAuthFailsFor) {
|
||||
sendJson(res, 401, { message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
// Unique token per mint so a stale cached value is distinguishable.
|
||||
sendJson(res, 200, { data: { token: `collab-${state.collabCalls}` } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
openServers.push(server);
|
||||
resolve(`http://127.0.0.1:${server.address().port}/api`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PROVIDER path (in-app agent getCollabToken fn)
|
||||
// ===========================================================================
|
||||
|
||||
// A counting provider that returns a distinct token each call so a cached
|
||||
// (reused) token is visibly the SAME string while a fresh mint is different.
|
||||
function countingProvider() {
|
||||
let n = 0;
|
||||
const fn = async () => {
|
||||
n++;
|
||||
return `provider-token-${n}`;
|
||||
};
|
||||
return {
|
||||
fn,
|
||||
get calls() {
|
||||
return n;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("within TTL, repeated calls return the SAME token and mint ONCE (provider path)", async () => {
|
||||
process.env[ENV_KEY] = "300000"; // 5 min
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
const a = await client.getCollabTokenWithReauth();
|
||||
const b = await client.getCollabTokenWithReauth();
|
||||
const c = await client.getCollabTokenWithReauth();
|
||||
|
||||
assert.equal(a, "provider-token-1");
|
||||
assert.equal(b, a, "second call reuses the cached token");
|
||||
assert.equal(c, a, "third call reuses the cached token");
|
||||
assert.equal(p.calls, 1, "the provider is invoked exactly once within the TTL");
|
||||
});
|
||||
|
||||
test("after TTL expiry a new token is minted (provider path)", async () => {
|
||||
process.env[ENV_KEY] = "20"; // 20ms TTL
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
const a = await client.getCollabTokenWithReauth();
|
||||
await new Promise((r) => setTimeout(r, 40)); // let the TTL lapse
|
||||
const b = await client.getCollabTokenWithReauth();
|
||||
|
||||
assert.equal(a, "provider-token-1");
|
||||
assert.equal(b, "provider-token-2", "a fresh token is minted after expiry");
|
||||
assert.equal(p.calls, 2);
|
||||
});
|
||||
|
||||
test("MCP_COLLAB_TOKEN_TTL_MS=0 disables the cache: mint on EVERY call (provider path)", async () => {
|
||||
process.env[ENV_KEY] = "0";
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
await client.getCollabTokenWithReauth();
|
||||
await client.getCollabTokenWithReauth();
|
||||
await client.getCollabTokenWithReauth();
|
||||
|
||||
assert.equal(p.calls, 3, "cache disabled -> exact fetch-per-call legacy path");
|
||||
});
|
||||
|
||||
test("a 401 triggers the internal reauth retry, which bypasses the cache and mints fresh (provider path)", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
let n = 0;
|
||||
const provider = async () => {
|
||||
n++;
|
||||
if (n === 1) {
|
||||
// The FIRST mint fails with an auth error; the internal reauth retry must
|
||||
// re-invoke the provider (bypassing the empty cache) for a fresh token.
|
||||
const err = new Error("collab token expired");
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return `provider-token-${n}`;
|
||||
};
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: provider,
|
||||
});
|
||||
|
||||
// Cache is empty: mint #1 401s -> the reauth retry mints #2 and caches it.
|
||||
const tok = await client.getCollabTokenWithReauth();
|
||||
assert.equal(tok, "provider-token-2", "the post-401 retry token wins");
|
||||
assert.equal(n, 2, "exactly one failed mint + one retry, no loop");
|
||||
|
||||
// The retried token is what got cached (no extra mint on a cache hit).
|
||||
const cached = await client.getCollabTokenWithReauth();
|
||||
assert.equal(cached, "provider-token-2");
|
||||
assert.equal(n, 2, "served from cache, provider not re-invoked");
|
||||
});
|
||||
|
||||
test("forceRefresh=true bypasses a warm cache and mints a fresh token (provider path)", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
const first = await client.getCollabTokenWithReauth(); // caches token-1
|
||||
assert.equal(first, "provider-token-1");
|
||||
|
||||
// A forced refresh (what the reauth path passes) must NOT return the cached
|
||||
// token-1; it mints a fresh token-2 and replaces the cache.
|
||||
const forced = await client.getCollabTokenWithReauth(true);
|
||||
assert.equal(forced, "provider-token-2", "cache bypassed on forceRefresh");
|
||||
assert.equal(p.calls, 2);
|
||||
|
||||
const cached = await client.getCollabTokenWithReauth();
|
||||
assert.equal(cached, "provider-token-2", "the fresh token replaced the cache");
|
||||
assert.equal(p.calls, 2);
|
||||
});
|
||||
|
||||
test("two consecutive mutations keep the SAME token, so the session key is stable (provider path)", async () => {
|
||||
// The whole point of #435: acquireCollabSession keys on the token, so two
|
||||
// acquire calls in a burst must be handed the identical token string.
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
const t1 = await client.getCollabTokenWithReauth();
|
||||
const t2 = await client.getCollabTokenWithReauth();
|
||||
assert.equal(t1, t2, "identical token across two mutations -> one session key");
|
||||
assert.equal(p.calls, 1);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// REST /auth/collab-token path (external MCP)
|
||||
// ===========================================================================
|
||||
|
||||
test("within TTL, the REST /auth/collab-token endpoint is hit ONCE", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const state = { collabCalls: 0, loginCalls: 0 };
|
||||
const baseURL = await spawnCollabServer(state);
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const a = await client.getCollabTokenWithReauth();
|
||||
const b = await client.getCollabTokenWithReauth();
|
||||
|
||||
assert.equal(a, "collab-1");
|
||||
assert.equal(b, a, "cached token reused");
|
||||
assert.equal(state.collabCalls, 1, "POST /auth/collab-token called once");
|
||||
});
|
||||
|
||||
test("TTL=0 hits the REST endpoint on every call", async () => {
|
||||
process.env[ENV_KEY] = "0";
|
||||
const state = { collabCalls: 0, loginCalls: 0 };
|
||||
const baseURL = await spawnCollabServer(state);
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await client.getCollabTokenWithReauth();
|
||||
await client.getCollabTokenWithReauth();
|
||||
|
||||
assert.equal(state.collabCalls, 2, "cache disabled -> fetch each call");
|
||||
});
|
||||
|
||||
test("401 on REST collab-token re-logs-in and refetches (cache bypassed)", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const state = { collabCalls: 0, loginCalls: 0 };
|
||||
// The first collab-token mint 401s; the reauth path logs in and retries.
|
||||
const baseURL = await spawnCollabServer(state, { collabAuthFailsFor: 1 });
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
// Pre-seed a token so the initial call does not perform an initial login.
|
||||
client.token = "seed";
|
||||
client.client.defaults.headers.common["Authorization"] = "Bearer seed";
|
||||
|
||||
const tok = await client.getCollabTokenWithReauth();
|
||||
assert.equal(tok, "collab-2", "the post-reauth mint wins, not the failed one");
|
||||
assert.equal(state.loginCalls, 1, "re-login happened exactly once");
|
||||
assert.equal(state.collabCalls, 2, "one failed mint + one successful retry");
|
||||
});
|
||||
|
||||
test("a fresh login clears the cache so a collab token cannot outlive the identity", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const state = { collabCalls: 0, loginCalls: 0 };
|
||||
const baseURL = await spawnCollabServer(state);
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const before = await client.getCollabTokenWithReauth();
|
||||
assert.equal(before, "collab-1");
|
||||
|
||||
// Simulate an identity change (the 401 interceptor / re-login path calls
|
||||
// login(), which must drop the cached collab token).
|
||||
await client.login();
|
||||
|
||||
const after = await client.getCollabTokenWithReauth();
|
||||
assert.equal(after, "collab-2", "cache was invalidated by login(); refetched");
|
||||
assert.equal(state.collabCalls, 2);
|
||||
});
|
||||
@@ -81,10 +81,6 @@ const HOST_CONTRACT_METHODS = [
|
||||
"insertImage",
|
||||
"replaceImage",
|
||||
"insertFootnote",
|
||||
// draw.io diagrams (#423, stage 1) — read + create + optimistic-locked update
|
||||
"drawioGet",
|
||||
"drawioCreate",
|
||||
"drawioUpdate",
|
||||
// write (comment)
|
||||
"createComment",
|
||||
"resolveComment",
|
||||
|
||||
@@ -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