@ai-react-markdown/engine 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1863 @@
1
+ import { Element, Parents, Root as Root$1, ElementContent } from 'hast';
2
+ import { Root } from 'mdast';
3
+ import { Options } from 'remark-rehype';
4
+ import { PluggableList, Processor, Plugin } from 'unified';
5
+ import { VFile } from 'vfile';
6
+ import { BuildVisitor } from 'unist-util-visit';
7
+ import { defaultSchema } from 'rehype-sanitize';
8
+ import { Handlers } from 'mdast-util-to-hast';
9
+ import { RemendOptions } from 'remend';
10
+
11
+ /**
12
+ * Streaming stress-payload fixtures, extracted verbatim from core's
13
+ * stories/streaming/scenarios.ts (boundary action ⑦): the engine's
14
+ * equivalence/sensitivity test batteries consume DEFAULT_PAYLOAD/withDefs,
15
+ * and stories keep using them through the engine entry — one source, no
16
+ * drifting copies.
17
+ *
18
+ * @module fixtures/scenarios
19
+ */
20
+ declare const DEFAULT_PAYLOAD: string;
21
+ /**
22
+ * Append the definitions tail to a payload (any payload — including the
23
+ * ALREADY-SCALED one; apply after `.repeat()` so one tail serves the whole
24
+ * document and no repeat seam glues a def line onto the next repetition).
25
+ *
26
+ * What this exercises — and what it does NOT: the default payload contains
27
+ * zero definitions, so without this tail the def-label scanner runs on a
28
+ * best-case input and the aggregate footnote footer never renders. With
29
+ * it, def lines stream through the scanner's active region (its full-parse
30
+ * slow path) and the footer assembles. It does NOT exercise the
31
+ * cross-chunk PHANTOM path: each benchmark side is a single chunk, so no
32
+ * label is ever defined "elsewhere" and the phantom candidate set stays
33
+ * empty — measuring that needs a second chunk contributing definitions
34
+ * the first one references.
35
+ */
36
+ declare function withDefs(payload: string): string;
37
+
38
+ /**
39
+ * Pipeline-facing types for the Markdown processing stages. The pure half of
40
+ * core's `components/markdown/types.ts`, split out in boundary action ③:
41
+ * every field here is consumed by the framework-agnostic pipeline
42
+ * (processor/transform/stages); the React-only `components` field stays in
43
+ * core, whose `Options` extends {@link PipelineOptions} with it.
44
+ *
45
+ * Ported 1:1 from react-markdown v10's lib/index.js JSDoc.
46
+ *
47
+ * @module components/markdown/types
48
+ */
49
+
50
+ /** Filter callback for elements. Return falsy to drop. */
51
+ type AllowElement = (element: Readonly<Element>, index: number, parent: Readonly<Parents> | undefined) => boolean | null | undefined;
52
+ /** Transform every URL on every element attribute. Return null/empty to strip. */
53
+ type UrlTransform = (url: string, key: string, node: Readonly<Element>) => string | null | undefined;
54
+ /** Configuration consumed by the pipeline stages (parse/transform). */
55
+ interface PipelineOptions {
56
+ allowElement?: AllowElement | null | undefined;
57
+ allowedElements?: ReadonlyArray<string> | null | undefined;
58
+ children?: string | null | undefined;
59
+ disallowedElements?: ReadonlyArray<string> | null | undefined;
60
+ rehypePlugins?: PluggableList | null | undefined;
61
+ remarkPlugins?: PluggableList | null | undefined;
62
+ remarkRehypeOptions?: Readonly<Options> | null | undefined;
63
+ skipHtml?: boolean | null | undefined;
64
+ unwrapDisallowed?: boolean | null | undefined;
65
+ urlTransform?: UrlTransform | null | undefined;
66
+ }
67
+ /**
68
+ * Internal: deprecated prop entry. `to` was `keyof Options` before the ③
69
+ * bisection; `'components'` is the one Options key that lives React-side,
70
+ * kept in the union so the deprecation table stays byte-identical.
71
+ */
72
+ interface Deprecation {
73
+ from: string;
74
+ id: string;
75
+ to?: keyof PipelineOptions | 'components';
76
+ }
77
+
78
+ /**
79
+ * Unified processor and VFile setup. Ported 1:1 from react-markdown
80
+ * v10 `createProcessor` and `createFile`.
81
+ *
82
+ * @module components/markdown/processor
83
+ */
84
+
85
+ /**
86
+ * Build the unified processor: remark-parse → remarkPlugins → remark-rehype →
87
+ * rehypePlugins. Returns an unfrozen processor — caller is expected to call
88
+ * `.parse()` and `.runSync()` (or `.run()`) on it.
89
+ */
90
+ declare function createProcessor(options: Readonly<PipelineOptions>): Processor<Root, Root, Root$1, undefined, undefined>;
91
+ /**
92
+ * Wrap the markdown string in a VFile so plugins that consume `file.value`
93
+ * work. Mirrors react-markdown: in dev `unreachable` throws an AssertionError
94
+ * for non-string `children`; in prod it silently no-ops, leaving `file.value`
95
+ * undefined and unified treating the input as empty.
96
+ */
97
+ declare function createFile(options: Readonly<PipelineOptions>): VFile;
98
+
99
+ /**
100
+ * The pure pipeline stages, lifted verbatim from core's Markdown.tsx
101
+ * (boundary action ②): parseStage / transformStage are framework-agnostic
102
+ * (mdast/hast in, hast out) and are consumed by the incremental-parse
103
+ * engine, so they live engine-side; renderHastSubtree and the `<Markdown>`
104
+ * component stay in core.
105
+ *
106
+ * The deprecation table and validation travel with parseStage — every
107
+ * caller (React or not) must get identical validation semantics.
108
+ *
109
+ * @module components/markdown/stages
110
+ */
111
+
112
+ /** Bundled processor + parsed mdast + VFile, ready to feed `transformStage`. */
113
+ interface ParsedMarkdown {
114
+ processor: Processor<Root, Root, Root$1, undefined, undefined>;
115
+ file: VFile;
116
+ mdast: Root;
117
+ }
118
+ /**
119
+ * Stage 1: validate options, build the unified processor, parse the markdown
120
+ * source into raw (pre-transform) mdast. The returned `mdast` is mutated in
121
+ * place by remark plugins during {@link transformStage}, but its top-level
122
+ * `position` offsets remain valid keys for hast→mdast lookup.
123
+ */
124
+ declare function parseStage(options: Readonly<PipelineOptions>): ParsedMarkdown;
125
+ /**
126
+ * Stage 2: run remark transformers, remark-rehype, and rehype plugins. Returns
127
+ * the final hast Root. The mdast in {@link ParsedMarkdown} may be mutated
128
+ * by remark transformers as a side effect of this call.
129
+ */
130
+ declare function transformStage(parsed: ParsedMarkdown): Root$1;
131
+
132
+ /**
133
+ * Single-pass hast tree transform: rewrites raw HTML, runs `urlTransform` on
134
+ * URL attributes, and applies `allowedElements` / `disallowedElements` /
135
+ * `allowElement` filters. Ported 1:1 from react-markdown v10.
136
+ *
137
+ * @module components/markdown/transform
138
+ */
139
+
140
+ interface TransformContext {
141
+ allowedElements: ReadonlyArray<string> | null | undefined;
142
+ allowElement: AllowElement | null | undefined;
143
+ disallowedElements: ReadonlyArray<string> | null | undefined;
144
+ skipHtml: boolean | null | undefined;
145
+ unwrapDisallowed: boolean | null | undefined;
146
+ urlTransform: UrlTransform;
147
+ }
148
+ declare function buildTransform(ctx: TransformContext): BuildVisitor<Root$1>;
149
+
150
+ /**
151
+ * Default URL transform — same allowlist as react-markdown / GitHub.
152
+ *
153
+ * @module components/markdown/urlTransform
154
+ */
155
+
156
+ /**
157
+ * Make a URL safe.
158
+ *
159
+ * Allows `http`, `https`, `irc`, `ircs`, `mailto`, and `xmpp` protocols, plus
160
+ * URLs relative to the current protocol (e.g. `/foo`). Other protocols are
161
+ * stripped to the empty string. Mirrors GitHub's behaviour and matches
162
+ * `micromark-util-sanitize-uri` minus the URL-encoding pass.
163
+ */
164
+ declare const defaultUrlTransform: UrlTransform;
165
+
166
+ /**
167
+ * Freeze-boundary detector for incremental (prefix-freeze) parsing.
168
+ *
169
+ * Production port of the "L4" rule validated by the measurement study in
170
+ * `src/experiments/prefixFreeze/` (see its README for the ablation ladder,
171
+ * the falsification results, and the intentional two-way divergence note —
172
+ * stricter blockers AND looser code-span masking; the corpus-scoped
173
+ * directional pin lives in detectorConsistency.test.ts).
174
+ *
175
+ * The boundary is the largest source offset `b` such that, for ANY future
176
+ * append to `text`, the markdown blocks that begin before `b` parse
177
+ * byte-identically. Candidates are confirmed blank lines outside fenced
178
+ * code and flow math; a candidate survives only if every blocker below
179
+ * clears:
180
+ *
181
+ * 1. **Raw-HTML balance** — an unclosed container tag (or `<!--` comment)
182
+ * before the candidate lets rehype-raw reparent later top-level siblings
183
+ * into it (the v1.5.1 swallow bug, commit a8e89ec). Tag balance is
184
+ * tracked outside fences; while any tag, comment, or raw block
185
+ * (`<?…?>` / `<!DECL…>` / `<![CDATA[…]]>` — CommonMark html block types
186
+ * 3–5) is open, candidates are blocked. Line-truncated tag starts
187
+ * (`<div` at EOL, attributes wrapping) count as opens.
188
+ * 2. **`$$` flow math** — remark-math's flow math swallows blank lines and
189
+ * runs to EOF when unclosed (verified empirically); its closing fence
190
+ * must sit at LINE START (a mid-line `$$` does not close it). Math
191
+ * interiors are treated exactly like fence interiors: no candidates.
192
+ * 3. **Continuation context** — CommonMark lists, footnote definitions, and
193
+ * indented code blocks are NOT terminated by blank lines; later indented
194
+ * lines can extend a block that "ended" before the candidate. With the
195
+ * definition-list extension enabled, `: description` bodies behave the
196
+ * same way.
197
+ * 4. **Definition-list term claim** (`options.defListEnabled`) — the
198
+ * micromark definition-list extension scans BACKWARD across exactly one
199
+ * blank line to claim a preceding paragraph as a `<dt>`. A candidate
200
+ * whose blank run is 1 is only safe once the next line is confirmed to
201
+ * never match `^ {0,3}:[ \t]`; runs of ≥ 2 blanks are immune.
202
+ * 5. **Reference taint** — micromark decides reference-ness at parse time,
203
+ * so a later `[label]:` definition retargets earlier literal `[text]`.
204
+ * Every reference-style candidate before the boundary must resolve
205
+ * against a SETTLED definition (one followed by a confirmed blank line).
206
+ * Labels are matched with micromark's own `normalizeIdentifier`
207
+ * (Unicode case folding — `toLowerCase` is the unsafe direction).
208
+ * Definitions must START a block (or chain a valid definition line) —
209
+ * a def-shaped paragraph continuation line is literal text.
210
+ * 6. **Raw-remnant seam** — an html FLOW run can swallow non-tag lines
211
+ * (e.g. a `$$` math fence glued under `</details>`); once tag balance
212
+ * returns to zero, that remnant becomes FLOATING text that parse5/
213
+ * rehype-raw attaches at the root, and its hast shape (position vs
214
+ * seam-owned position-less, trailing-newline ownership) depends on
215
+ * whether a sibling node FOLLOWS it. A tail block that flips between
216
+ * def (no hast output) and paragraph therefore reshapes the frozen
217
+ * region retroactively (2026-07-31 direction-battery counterexample,
218
+ * reproduced on v1.8.0). The candidate adjacent to such a run is
219
+ * rejected until a later confirmed content line pins the seam from the
220
+ * frozen side; dropping candidates only over-blocks (safe direction).
221
+ * 7. **Phase poison** (`phasePoisonedAt`) — points where this line-level
222
+ * model may have DIVERGED from micromark and provably cannot resync:
223
+ * a fence/math open suppressed by `htmlFlowSinceBlank` (only certainly
224
+ * swallowed at top level — in a container it really opens and the
225
+ * open/close phase inverts permanently), and a paragraph-inline `<!--`
226
+ * that fails to close by end of line (literal text to micromark, but
227
+ * the comment scan would skip real markup as comment interior). Every
228
+ * candidate past the first such point is rejected, sticky; candidates
229
+ * at or before it stay valid — the ambiguous region then re-parses
230
+ * inside the tail (pure over-block).
231
+ *
232
+ * ## Incremental scanning (checkpoint resume)
233
+ *
234
+ * The hot path calls this once per streamed frame. Appends leave every
235
+ * previously-CONFIRMED line byte-identical, so the scan checkpoints its
236
+ * entire per-line state after the last confirmed line (one whose
237
+ * terminating `\n` exists) and, given `resume`, re-lexes only from there.
238
+ * The trailing PARTIAL line is never baked into the checkpoint: it cannot
239
+ * emit candidates (unconfirmed lines are never blank), its tag/ref effects
240
+ * cannot affect candidates that all precede it, and the next frame re-lexes
241
+ * it from scratch. Resume MUTATES the checkpoint monotonically and is
242
+ * idempotent for identical input — but a checkpoint belongs to exactly one
243
+ * advancing state lineage (advanceIncrementalParse's), never share it.
244
+ *
245
+ * Continuation hazards (blocker 3) are a forward-rolling verdict updated at
246
+ * each decisive block start — equivalent to the previous per-candidate
247
+ * upward walk ("nearest decisive block start above") at O(1) per candidate.
248
+ * Reference taint (blocker 5) maintains defs and an unresolved-ref list
249
+ * incrementally; settling is monotone, so resolved entries only ever leave.
250
+ *
251
+ * ## Inline code-span masking
252
+ *
253
+ * `` `<div>` ``, `` `[x]` `` and `` `[^n]` `` in prose are code, not
254
+ * markup. Before HTML/ref/footnote extraction each line is masked using
255
+ * micromark's own pairing rule (equal-length backtick runs, leftmost
256
+ * first) — but ONLY when the pairing is provably intra-line: if any run on
257
+ * a line is left unpaired, or an earlier line of the same paragraph left
258
+ * one unpaired, masking is disabled for the rest of the paragraph. A
259
+ * cross-line span can therefore never cause an unmask mismatch: every
260
+ * masked span is one micromark would pair identically. Skipped masking
261
+ * only over-blocks (safe direction).
262
+ *
263
+ * A line only counts as blank once its terminating newline exists: the
264
+ * trailing partial line is UNCONFIRMED (the next chunk may append content
265
+ * to it) and treating it as blank breaks boundary monotonicity.
266
+ *
267
+ * Footnote refs/defs participate in blockers 3 and 5 like their link
268
+ * counterparts (separate label namespace); the engine splices across them
269
+ * via injection replay (v2).
270
+ */
271
+ interface FreezeBoundaryOptions {
272
+ /** Whether remark-definition-list is in the active plugin chain (the
273
+ * `enginePlugins` selection includes `definitionList`). Enables blockers 3b/4. */
274
+ defListEnabled: boolean;
275
+ /**
276
+ * Whether `$$` flow math is in the grammar (remark-math). Default `true`
277
+ * (the engine's own profile). The def-label scanner runs a PINNED
278
+ * remark-parse+gfm subset where `$$` is ordinary paragraph text — under
279
+ * that grammar the math branch is a MASKING hole: `inMath` returns early
280
+ * without comment/fence scanning, so `$$\n<!--\n$$` reads as a closed
281
+ * math block here while the subset grammar sees an OPEN type-2 HTML
282
+ * comment running to `-->`/EOF (a candidate after it would let a
283
+ * standalone tail parse invent ghost defs). With `false`, `$$` lines take
284
+ * the ordinary text path and comments/fences inside are scanned.
285
+ */
286
+ mathFlow?: boolean;
287
+ /**
288
+ * Whether blocker 5 (reference taint) applies. Default `true`: the
289
+ * engine must reject candidates past an unresolved `[label]` because a
290
+ * later definition retargets the reference's PARSE. The def-label
291
+ * scanner only extracts definition IDENTITIES — a pure block-level fact
292
+ * unaffected by how inline references resolve — and under taint a
293
+ * streaming citation footer (defs with no settling blank line yet)
294
+ * collapses the boundary to the body's first reference, zeroing the
295
+ * caching this profile exists for. `false` skips ref tracking entirely.
296
+ */
297
+ referenceTaint?: boolean;
298
+ }
299
+ interface FreezeScanResult {
300
+ /** Largest freeze-safe boundary, or 0 when nothing can be frozen. */
301
+ boundary: number;
302
+ /** Opaque resume state — pass back on the next APPEND-ONLY call to skip
303
+ * re-lexing the confirmed prefix. Single-consumer; see module docs. */
304
+ checkpoint: FreezeScanCheckpoint;
305
+ }
306
+ interface Candidate {
307
+ /** Freeze boundary: start of the line after this blank line. */
308
+ offset: number;
309
+ /** Consecutive confirmed blank lines (outside fences/math) ending here. */
310
+ blankRun: number;
311
+ /** No unbalanced HTML container / comment / raw block before this point. */
312
+ htmlBalanced: boolean;
313
+ /** Rolling continuation-hazard verdict at emission (blocker 3). */
314
+ hazard: boolean;
315
+ /** Blocker-6: the run ending at this blank left balanced FLOATING raw
316
+ * remnant whose hast seam is tail-dependent; reject this candidate. */
317
+ seamRisk: boolean;
318
+ /** Blocker-4 settle verdict, decided by the NEXT confirmed line (`null`
319
+ * while that line hasn't confirmed — only the newest candidate can be
320
+ * pending). Storing the verdict instead of the line lets the checkpoint
321
+ * drop its lines array, which retained a full copy of the document
322
+ * (round-2 review: ~2-3× doc size per mounted instance). */
323
+ defListSettled: boolean | null;
324
+ }
325
+ interface UnresolvedRef {
326
+ offset: number;
327
+ label: string;
328
+ footnote: boolean;
329
+ }
330
+ /** Mutable resume state. All fields describe the scan strictly BEFORE the
331
+ * first unconfirmed character (`confirmedOffset`). */
332
+ interface FreezeScanCheckpoint {
333
+ defListEnabled: boolean;
334
+ /** Grammar-profile switches baked at creation — a checkpoint is only
335
+ * resumable under the exact profile that built it. */
336
+ mathFlow: boolean;
337
+ referenceTaint: boolean;
338
+ /** Start offset of the first line NOT yet baked into this checkpoint. */
339
+ confirmedOffset: number;
340
+ candidates: Candidate[];
341
+ defs: Map<string, number>;
342
+ footnoteDefs: Map<string, number>;
343
+ unresolvedRefs: UnresolvedRef[];
344
+ tagBalance: Map<string, number>;
345
+ openTotal: number;
346
+ commentOpen: boolean;
347
+ piOpen: boolean;
348
+ declOpen: boolean;
349
+ cdataOpen: boolean;
350
+ inFence: boolean;
351
+ fenceChar: string;
352
+ fenceLen: number;
353
+ inMath: boolean;
354
+ /** Opening dollar-run length while inMath — the close run must match it. */
355
+ mathFenceLen: number;
356
+ blankRun: number;
357
+ lastBlankStart: number;
358
+ /** Rolling blocker-3 verdict ("nearest decisive block start so far"). */
359
+ hazardVerdict: boolean;
360
+ /** Previous confirmed line was blank (block-start detection). */
361
+ prevLineBlank: boolean;
362
+ /** Previous confirmed line was a plain text line (def-chain detection). */
363
+ prevLineWasText: boolean;
364
+ /** Previous confirmed line registered a VALID definition (def chains). */
365
+ prevLineWasValidDef: boolean;
366
+ /** An earlier line of the current paragraph left an unpaired backtick
367
+ * run — masking is disabled until the paragraph ends (safety gate). */
368
+ paragraphHasUnpairedRun: boolean;
369
+ /** Blocker-6 pending flag: a confirmed html-flow line left balanced
370
+ * floating raw remnant, and no later content line has pinned the seam
371
+ * yet. Persists across blank lines (every candidate emitted while set
372
+ * has the remnant as its last frozen child); cleared by the next
373
+ * non-blank line that starts OUTSIDE an html-flow run. */
374
+ htmlSeamPending: boolean;
375
+ /** A line since the last blank started with `<` at block indent — an html
376
+ * FLOW block is (approximately) running, and it only ends at a blank
377
+ * line. micromark does no inline parsing there: backtick runs are
378
+ * literal text, so code-span masking would hide REAL tags from the
379
+ * balance scan (under-block — fuzz counterexample: `</details>` followed
380
+ * by an unblanked `` `<div>` `` line). While set, masking is skipped —
381
+ * which can only over-block (safe direction). */
382
+ htmlFlowSinceBlank: boolean;
383
+ /** Offset of the first fence/math OPEN suppressed by `htmlFlowSinceBlank`
384
+ * (Infinity = none). Whether the run really swallowed that line depends
385
+ * on container context the line scan cannot see (`<embed` inside a list
386
+ * item is a lazy paragraph line, and the glued `$$` a REAL math open —
387
+ * seed-20260757 under-block: the tracker's fence phase INVERTS from that
388
+ * line on, every later close reads as an open, and the corruption never
389
+ * resyncs). Candidates past this offset are rejected outright — sticky,
390
+ * pure over-block; candidates before it are untouched (the ambiguous
391
+ * region then re-parses inside the tail). The rolling hazard poison for
392
+ * ambiguous tag names stays, but it decays at the next decisive block
393
+ * start — this field is the phase-corruption backstop that does not. */
394
+ phasePoisonedAt: number;
395
+ }
396
+ declare function computeFreezeBoundary(text: string, options: FreezeBoundaryOptions, resume?: FreezeScanCheckpoint | null): FreezeScanResult;
397
+
398
+ /**
399
+ * Pure splice helpers for incremental (prefix-freeze) parsing: cut the
400
+ * frozen prefix out of the previous frame's trees, re-base a tail-only
401
+ * parse into document coordinates, and join the two into fresh roots.
402
+ *
403
+ * Invariant these helpers exist to uphold (enforced by the
404
+ * splice-equivalence arbiter test, NOT asserted at runtime): the spliced
405
+ * `{mdast, hast}` is deep-equal — positions included — to a full pipeline
406
+ * run over the whole content. Defined on UNRENDERED trees; after a render,
407
+ * `renderHastSubtree` mutates hast in place (convergent `data.originalUrls`
408
+ * stash, raw→text rewrite — see Markdown.tsx), so no in-component
409
+ * deep-equal check is possible or attempted.
410
+ *
411
+ * Prefix link/image definitions are PREPENDED to the tail source (not
412
+ * appended: a streaming tail routinely ends inside an unclosed fence or
413
+ * `$$` block that would swallow appended lines, and appended defs would
414
+ * invert CommonMark first-def-wins against a same-label tail def).
415
+ * Definitions emit zero hast (mdast-util-to-hast has no `definition`
416
+ * handler), so their only trace is `definition` mdast nodes inside the
417
+ * injected region, which are dropped before the join. Definition text is
418
+ * sliced verbatim from the previous content via node positions — exact
419
+ * source roundtrip preserves escapes and multi-line titles.
420
+ *
421
+ * Sanitize-STRIPPED prefix nodes (HTML comments, `<?…?>` bogus comments,
422
+ * `<script>`) are modeled explicitly: their wrap separators survive as
423
+ * orphans, and `alignPrefixCut` re-derives the mdast↔hast pairing from
424
+ * separator-run lengths (B0-probe-verified: one orphan '\n' per stripped
425
+ * child's gap slot; separators MERGED into raw trailing literals count via
426
+ * their trailing newlines — `literalCredit`). Layouts outside the model
427
+ * return null → full-parse fallback for the frame.
428
+ */
429
+
430
+ /** Prefix footnote/definition events in DOCUMENT ORDER. Order is the whole
431
+ * point: mdast-util-to-hast's footnoteOrder/footnoteCounts are built from
432
+ * encounter order, and the replay must reproduce it exactly. */
433
+ type InjectionEvent =
434
+ /** Link/image definition — parse-time resolution only, zero hast. */
435
+ {
436
+ kind: 'def';
437
+ source: string;
438
+ }
439
+ /** Footnote definition — sliced from the PHYSICAL LINE START (column
440
+ * invariance for the footer position rebase). */
441
+ | {
442
+ kind: 'footnoteDef';
443
+ source: string;
444
+ origStart: number;
445
+ origLine: number;
446
+ }
447
+ /** Consecutive footnote references, verbatim source tokens. Seeds
448
+ * footnoteOrder (first-encounter) and footnoteCounts (backref -N ids). */
449
+ | {
450
+ kind: 'refs';
451
+ tokens: string[];
452
+ };
453
+ interface PrefixInjectionPlan {
454
+ events: InjectionEvent[];
455
+ /** True when an event exists that CANNOT be injected reliably — the caller
456
+ * must fall back to a full parse for this frame (safe, one-frame cost). */
457
+ uninjectable: boolean;
458
+ /** False when the plan must NOT be cached for resume: a position-less
459
+ * top-level child cannot be partitioned by the resume offset, so a
460
+ * resumed walk would re-visit it and duplicate its events (round-2
461
+ * review). Fresh walks stay correct — they just can't be incremental. */
462
+ cacheable?: boolean;
463
+ }
464
+ /** A plan cached in engine state: valid for any LATER boundary of the same
465
+ * append lineage, because events derive from (content, positions) alone
466
+ * and the boundary is monotone under appends — new frames only APPEND
467
+ * events for children in [cached.boundary, newBoundary). */
468
+ interface CachedInjectionPlan extends PrefixInjectionPlan {
469
+ boundary: number;
470
+ }
471
+
472
+ /**
473
+ * React-free state machine for incremental (prefix-freeze) parsing.
474
+ *
475
+ * Given the previous frame's state and the current content, either splice
476
+ * a frozen prefix with a tail-only parse or run the full pipeline. Every
477
+ * gate failure degrades to the full path — the caller cannot observe a
478
+ * difference except through `usedIncremental` and the dev-only stage
479
+ * timings. The splice output is deep-equal to a full parse (the
480
+ * splice-equivalence arbiter test enforces this; see spliceParse.ts).
481
+ *
482
+ * Gate order (first failure wins):
483
+ * - G0 depsKey identity — the parse inputs beyond `content` (plugin
484
+ * arrays, remark-rehype options, handlers, documentId, …) must be
485
+ * identical to the previous frame's. This intentionally covers MORE
486
+ * than the component's G3 12-dep flush (e.g. `preserveOrphanReferences`
487
+ * flips reach the handlers without touching any G3 field).
488
+ * - G1 append — `content.startsWith(prev.content)`; equal content returns
489
+ * the previous trees unchanged. Non-append rewrites (including Stage-A
490
+ * preprocessor rewrites near the stream end) land here.
491
+ * - G3 boundary — `b = min(computeFreezeBoundary(content), prev.stableBoundary)`
492
+ * must be > 0. The `min` with the PREVIOUS frame's boundary is
493
+ * load-bearing, not defensive: the freshly computed boundary proves
494
+ * stability of the CURRENT parse's prefix, but the splice reuses the
495
+ * PREVIOUS parse's nodes — e.g. a shortcut ref rendered literal last
496
+ * frame must not be frozen the moment its definition arrives and the
497
+ * fresh boundary jumps past it. `prev.stableBoundary` is exactly the
498
+ * "stable under all future appends" property for prev's nodes.
499
+ * - G4 straddle (defensive) — no prev top-level mdast child may cross the
500
+ * boundary; the detector's blockers should already prevent this.
501
+ *
502
+ * (v1's G2 footnote bypass is GONE. Footnotes splice via INJECTION REPLAY:
503
+ * the prefix's footnote event sequence — defs and refs ×count, in document
504
+ * order, collected from prev.mdast — is prepended to the tail source, so
505
+ * the tail run's mdast-util-to-hast state (footnoteOrder / footnoteCounts /
506
+ * footnoteById) is seeded exactly and its footer regenerates the WHOLE
507
+ * document's section; the injected nodes are stripped from both trees and
508
+ * the footer's positions are rewritten by the dual rule in spliceParse.
509
+ * Prefix inline hast is naturally stable — numbering is first-reference
510
+ * order, which appends cannot change for the prefix. The detector's
511
+ * reference taint (footnote namespace) keeps unresolved `[^x]` out of the
512
+ * frozen prefix, C0-probe + arbiter verified.)
513
+ *
514
+ * `nextState.stableBoundary` is written on BOTH paths from the same single
515
+ * boundary computation.
516
+ */
517
+
518
+ interface IncrementalParseState {
519
+ /** The CHUNK's own text — excludes the phantom suffix. */
520
+ content: string;
521
+ /** The phantom suffix this state's trees were parsed with ('' standalone). */
522
+ phantomSuffix: string;
523
+ /** Post-transform trees (transformStage mutates mdast in place; these are
524
+ * the settled shapes) — of `content + phantomSuffix`. */
525
+ mdast: Root;
526
+ hast: Root$1;
527
+ /** Scan boundary at the frame that produced these trees. */
528
+ stableBoundary: number;
529
+ /** Detector resume state: append frames re-lex only past the confirmed
530
+ * prefix instead of the whole document (E2). Single-consumer mutable —
531
+ * owned by this state lineage. */
532
+ scanCheckpoint: FreezeScanCheckpoint | null;
533
+ /** Injection-plan resume state: events derive from (content, positions)
534
+ * alone, so within an append lineage only children past the cached
535
+ * boundary need visiting (final-review R3 — without this the plan walk
536
+ * re-visits the entire frozen prefix every splice frame). Null until the
537
+ * first splice frame; carried verbatim across full-path append frames. */
538
+ injectionPlan: CachedInjectionPlan | null;
539
+ /** Identity tuple of every parse input beyond `content` (G0). */
540
+ depsKey: readonly unknown[];
541
+ }
542
+ type IncrementalStage = 'scan' | 'parse' | 'transform';
543
+ interface AdvanceOptions {
544
+ remarkPlugins: PipelineOptions['remarkPlugins'];
545
+ rehypePlugins: PipelineOptions['rehypePlugins'];
546
+ /** The FULLY-MERGED remark-rehype options (handlers, clobberPrefix, …) —
547
+ * exactly what the full path would pass to parseStage. */
548
+ remarkRehypeOptions: PipelineOptions['remarkRehypeOptions'];
549
+ depsKey: readonly unknown[];
550
+ /** Whether remark-definition-list is active (`enginePlugins` includes `definitionList`). */
551
+ defListEnabled: boolean;
552
+ /** Cross-chunk phantom-definition suffix (coordinated mode) — appended to
553
+ * the parse input but NEVER frozen: the append gate, boundary scan, and
554
+ * prefix cut all see `content` alone, and the suffix re-parses with the
555
+ * tail every frame. It may shrink/grow/reorder between frames (registry
556
+ * label churn) without invalidating the frozen prefix — the reference
557
+ * taint keeps every phantom-resolved ref in the tail (a phantom's def is
558
+ * never IN `content`, so such refs never settle). '' when standalone. */
559
+ phantomSuffix?: string;
560
+ /** Optional stage-timing wrapper (the component passes measureStage). */
561
+ measure?: <T>(stage: IncrementalStage, fn: () => T) => T;
562
+ }
563
+ interface AdvanceResult {
564
+ mdast: Root;
565
+ hast: Root$1;
566
+ usedIncremental: boolean;
567
+ /** The boundary the splice used (0 on the full path). */
568
+ boundary: number;
569
+ nextState: IncrementalParseState;
570
+ }
571
+ declare function advanceIncrementalParse(prev: IncrementalParseState | null, content: string, options: AdvanceOptions): AdvanceResult;
572
+
573
+ /**
574
+ * Source-offset attribution for top-level hast children.
575
+ *
576
+ * hast positions alone cannot delimit a frozen prefix: rehype-katex
577
+ * replaces a math block with a position-less span (and other plugins can
578
+ * do the same), and mdast-util-to-hast's root `wrap()` interleaves
579
+ * position-less `'\n'` text separators. Each top-level hast child is
580
+ * therefore attributed a source offset:
581
+ *
582
+ * - its own `position.start.offset` when present;
583
+ * - otherwise the start of the first top-level MDAST child at or after the
584
+ * running cursor (top-level mdast children always carry positions — this
585
+ * mirrors blockMemo's source-offset lookup);
586
+ * - the synthetic footnote section is attributed `Infinity` — it is never
587
+ * freeze-eligible (production handles it via `FootnoteSectionEntry` /
588
+ * `aggregateFootnotesIfLast`, not positional identity).
589
+ *
590
+ * The returned array is non-decreasing except for `Infinity` entries, so
591
+ * "children attributed before offset b" is always a prefix of the child
592
+ * list.
593
+ *
594
+ * Extracted from the prefixFreeze experiment's falsification harness
595
+ * (which now imports this module) — the production splice and the
596
+ * experiment must cut prefixes identically or the experiment stops being
597
+ * evidence.
598
+ */
599
+
600
+ declare function attributeHastChildren(mdast: Root, hast: Root$1, stopAt?: number): number[];
601
+
602
+ /**
603
+ * TEST/STORY helper (not exported from the package barrel): prefix
604
+ * snapshots sliced at CODE-POINT granularity, so a frame boundary never
605
+ * splits a surrogate pair. Every streaming verifier in the repo — the
606
+ * splice-equivalence arbiter, the prefixFreeze experiment harness, and the
607
+ * Storybook smoke/playground streams — derives its frame sequence from
608
+ * this one implementation; a change here (e.g. moving to grapheme
609
+ * clusters) changes them all together instead of leaving four copies to
610
+ * drift (review finding R4).
611
+ */
612
+ declare function codePointSnapshots(payload: string, chunkSize: number): string[];
613
+
614
+ interface DefLabels {
615
+ footnoteLabels: Set<string>;
616
+ linkLabels: Set<string>;
617
+ }
618
+ declare function collectDefLabels(source: string): DefLabels;
619
+ /** Index just past the LAST blank line of `source`, or 0 if none.
620
+ * Plain non-overlapping scan: for runs of blanks ("\n\n\n") this can land
621
+ * a newline or two early, but the slack is whitespace-only and whitespace
622
+ * can never satisfy DEF_LINE_START_RE, so the decision is identical.
623
+ * @internal exported for tests only — the fast path is otherwise
624
+ * indistinguishable from a full parse whose sets came out equal. */
625
+ declare function lastRegionStart(source: string): number;
626
+ /** A line that can START a definition, matched by the FULL def signature:
627
+ * container prefixes (blockquote `>`, list bullets, ordered-list digits),
628
+ * then `[label]` with the closing bracket IMMEDIATELY followed by `:` —
629
+ * remark accepts a definition only with that adjacency (grammar-verified:
630
+ * `[x]\n: url` and `[x] : url` are paragraphs, and `[a][b]: url` is a
631
+ * reference because the label's first unescaped `]` isn't followed by
632
+ * `:`). The label alternation admits escape pairs (`\]` stays inside the
633
+ * label) and spans newlines (labels may soft-wrap; they cannot cross the
634
+ * blank line that bounds the region). Both alternatives are disjoint, so
635
+ * the scan is linear — no backtracking blowup on bracket-dense regions.
636
+ *
637
+ * Requiring the signature (not just a line-start `[`) is what keeps the
638
+ * streaming-heavy shapes — bulleted link lists `- [t](u)`, task boxes
639
+ * `- [x]`, reference lists `- [a][b]` — on the fast path; a bracket-only
640
+ * probe made every append inside a blank-line-free link list pay a full
641
+ * reparse (the measured Documents+smooth cliff). An INCOMPLETE def line
642
+ * (`[x` with `]:` still in flight) correctly stays on the fast path too:
643
+ * the parser sees no definition in it either, and the region re-check on
644
+ * the completing append flips to the full parse exactly when the answer
645
+ * can change. The `m` flag also matches at index 0, which is a true line
646
+ * start (the region begins just past a blank line or at the document
647
+ * start). Residual over-matching (e.g. `[x]:` inside an open code fence)
648
+ * is safe: it costs a redundant full parse, never a wrong result.
649
+ * @internal exported for tests only. */
650
+ declare const DEF_LINE_START_RE: RegExp;
651
+ interface DefLabelScanner {
652
+ /** Equivalent to `collectDefLabels(source)` at every call, but cheap for
653
+ * the streaming common case. Returns a REFERENCE-STABLE result while the
654
+ * label set is unchanged. */
655
+ scan(source: string): DefLabels;
656
+ }
657
+ /**
658
+ * Append-aware wrapper around {@link collectDefLabels} for the streaming
659
+ * hot path: PASS 0 re-runs on every token, but its result — the def label
660
+ * set — almost never changes while prose streams in.
661
+ *
662
+ * Fast path: when the new source merely APPENDS to the previous one, the
663
+ * label set can only differ if the affected region contains a line-start
664
+ * `[label]:` def signature (see DEF_LINE_START_RE — mid-line brackets,
665
+ * bulleted links, task boxes and reference lists all lack the adjacent
666
+ * `]:` and stay on the fast path). That region is the previous source's
667
+ * text SINCE ITS LAST BLANK LINE plus the appended text — not just the
668
+ * appended text, because CommonMark definitions span lines (`[x]:` with
669
+ * the destination on the next line) and a trailing append can re-type an
670
+ * entire paragraph (setext `===`). No construct that produces or destroys
671
+ * a definition crosses a blank line (labels, destinations and titles all
672
+ * forbid them), so text before that boundary is settled. When the region
673
+ * has no def-capable line, the previous result is returned AS-IS;
674
+ * otherwise (and for any non-append change) a full re-parse runs, and the
675
+ * previous result object is kept whenever the recomputed sets are equal.
676
+ *
677
+ * Misjudging conservatively (an unnecessary `[` hit — e.g. inside an open
678
+ * code fence) only costs a redundant full parse, never a wrong result.
679
+ *
680
+ * The reference stability doubles as churn control: consumers that list
681
+ * the result in effect deps (chunk re-registration) stop firing per token.
682
+ *
683
+ * @param parse Full-parse fallback — injectable so tests can COUNT parses
684
+ * and assert the fast path actually fires (from the outside, a skipped
685
+ * parse is indistinguishable from a parse whose sets came out equal).
686
+ * Production callers never pass it.
687
+ */
688
+ declare function createDefLabelScanner(parse?: (source: string) => DefLabels): DefLabelScanner;
689
+
690
+ /**
691
+ * Extract the SOURCE identifier from a footnote `<li>` id, covering both id
692
+ * shapes in the codebase — standalone (mdast-util-to-hast `fn-` +
693
+ * `normalizeUri`, then the sanitize clobber + rehypeRebaseHashLinks linkage)
694
+ * and aggregate (`${clobberPrefix}fn-${sourceIdentifier}`, raw). Exported as
695
+ * the single source of truth for li-id parsing: the streaming cursor's
696
+ * anchor targeting (`detectAnchor`) reuses it so a clobber-linkage change
697
+ * can never drift the two consumers apart.
698
+ */
699
+ declare function sourceIdFromFootnoteLiId(idProp: string, clobberPrefix?: string): string | null;
700
+ declare function extractDefBodiesFromHast(hast: Root$1, clobberPrefix?: string): Map<string, ElementContent[]>;
701
+
702
+ type Contribution = {
703
+ kind: 'ref';
704
+ refKind: 'footnote' | 'link' | 'image';
705
+ label: string;
706
+ referenceType?: 'full' | 'collapsed' | 'shortcut';
707
+ } | {
708
+ kind: 'fnDef';
709
+ label: string;
710
+ sourceIdentifier: string;
711
+ content: string;
712
+ } | {
713
+ kind: 'linkDef';
714
+ label: string;
715
+ url: string;
716
+ title?: string;
717
+ };
718
+ interface ExtractContributionsOptions {
719
+ /** Already-normalized labels that were phantom-injected at PASS 0.5.
720
+ * Defs matching these are skipped to avoid leaking sentinel rows into
721
+ * registry.chunkData. */
722
+ phantomFootnoteLabels?: Set<string>;
723
+ /**
724
+ * Caller's resolved URL transform (typically `props.urlTransform ??
725
+ * defaultUrlTransform`). Applied to every emitted `linkDef.url` so the
726
+ * registry stores already-sanitized URLs.
727
+ *
728
+ * Cross-chunk link/image references render through the registry rather
729
+ * than the in-tree hast (which is where react-markdown's transform pass
730
+ * normally enforces `urlTransform`). Without this, a chunk defining
731
+ * `[evil]: javascript:alert(1)` could XSS a sibling chunk that uses
732
+ * `[click][evil]` — the standalone path strips the protocol; the cross-
733
+ * chunk path would have rendered `<a href="javascript:…">`. Sanitizing at
734
+ * contribute time also benefits any future consumer that reads
735
+ * `Registry.resolveLinkDef` directly.
736
+ *
737
+ * Invocation contract mirrors react-markdown's hast-pass call site
738
+ * (`buildTransform` in `./markdown/transform.ts`): a synthetic
739
+ * `<a href={url}>` element stands in for the node argument since
740
+ * mdast `definition` nodes have no hast counterpart. The key is `'href'`
741
+ * — link defs are far more common than image defs, and protocol-allowlist
742
+ * transforms (including `defaultUrlTransform`) are key-agnostic anyway.
743
+ * A `null` return collapses to the empty string, matching how
744
+ * `transform.ts` would render a blocked attribute.
745
+ *
746
+ * Omitting this option preserves v1 behavior (URLs stored raw). Library
747
+ * callers should always supply it; the option stays optional so unit-test
748
+ * fixtures that don't care about URL safety can construct minimal calls.
749
+ */
750
+ urlTransform?: UrlTransform;
751
+ }
752
+ declare function extractContributions(mdast: Root, options?: ExtractContributionsOptions): Generator<Contribution>;
753
+
754
+ /**
755
+ * Cross-chunk shared state. Holds per-chunk contributions (refs, defs,
756
+ * linkDefs) keyed by Symbol identity allocated via useId reactId, with
757
+ * refcount + microtask-deferred reclamation for React Strict Mode safety.
758
+ *
759
+ * @module components/documentRegistry
760
+ */
761
+
762
+ interface FootnoteDef {
763
+ /** Already-normalized identifier (uppercase). Used as dictionary key for
764
+ * case-insensitive cross-chunk lookups. */
765
+ identifier: string;
766
+ /** mdast's case-folded identifier — the exact string mdast-util-to-hast
767
+ * emits in `<li id="${clobberPrefix}fn-${sourceIdentifier}">` and that
768
+ * `FootnoteSupNumber` mirrors in its anchor href. Needed so the aggregate
769
+ * footer's `<li id>` and backref href match the inline `<sup>`'s href
770
+ * exactly (otherwise hash navigation breaks). Optional so unit-test
771
+ * fixtures don't need to fabricate it; production data always supplies it. */
772
+ sourceIdentifier?: string;
773
+ /** Content extracted from the source markdown footnote definition. */
774
+ contentSource: string;
775
+ /** Per-def hast body (the def's mdast children after mdast-util-to-hast
776
+ * conversion). Drives AggregateFootnotesIfLast to render the consolidated
777
+ * footer at the end of each document's last chunk. Optional so unit-test
778
+ * fixtures can build minimal FootnoteDef objects without producing hast. */
779
+ bodyHast?: ElementContent[];
780
+ }
781
+ interface LinkDef {
782
+ /** Already-normalized identifier (uppercase). */
783
+ identifier: string;
784
+ url: string;
785
+ title?: string;
786
+ }
787
+ type RefKind = 'footnote' | 'link' | 'image';
788
+ interface RefRecord {
789
+ /** Already-normalized identifier (uppercase). */
790
+ label: string;
791
+ /** Which markdown reference space this entry belongs to. Footnote refs,
792
+ * link refs, and image refs occupy disjoint namespaces in GFM, so they
793
+ * must be filtered separately when computing footnote numbers / refcounts. */
794
+ kind: RefKind;
795
+ referenceType?: 'full' | 'collapsed' | 'shortcut';
796
+ }
797
+ interface ChunkData {
798
+ refs: RefRecord[];
799
+ defs: Map<string, FootnoteDef>;
800
+ linkDefs: Map<string, LinkDef>;
801
+ ownFootnoteLabels: Set<string>;
802
+ ownLinkLabels: Set<string>;
803
+ }
804
+ /**
805
+ * Public, read-only view of the cross-chunk registry. This is the type
806
+ * surfaced by `useDocumentRegistry` and re-exported from the package
807
+ * barrel. Consumers can:
808
+ *
809
+ * - read the registry's current state (`chunkOrder`, `chunkData`,
810
+ * `labelSet`, `version`)
811
+ * - observe changes (`subscribe`)
812
+ * - run selectors (`globalNumber`, `resolveLinkDef`, …)
813
+ *
814
+ * Mutators — `registerChunk`, `allocateSymbol`, `releaseSymbol`,
815
+ * `contributeLabels`, `contributeChunkData` — are intentionally off this
816
+ * interface. Driving the registry directly is reserved for internal
817
+ * coordinators (the package's own `MarkdownContent` renderer) and tests,
818
+ * which import the wider `RegistryInternal` type from this module.
819
+ * `RegistryInternal` is exported here but NOT re-exported from the package
820
+ * barrel — keeping mutators off the public surface prevents a misbehaving
821
+ * consumer-component from corrupting refcounts, skipping version bumps, or
822
+ * otherwise breaking the invariants the renderer relies on.
823
+ */
824
+ interface Registry {
825
+ /** Chunk mount-order Symbol list. **Read-only.** Direct mutation
826
+ * (`.push`, `.splice`, index assignment) corrupts footnote numbering,
827
+ * "last chunk" detection, and eviction. */
828
+ readonly chunkOrder: readonly symbol[];
829
+ /** Chunk Symbol → contribution payload. **Read-only.** Direct `.set` /
830
+ * `.delete` bypasses version bumps and subscriber wake-ups. */
831
+ readonly chunkData: ReadonlyMap<symbol, ChunkData>;
832
+ /** Union of own-def labels across all chunks. PASS 0.5 phantom-injection
833
+ * driver. **Read-only.** The registry derives this from per-chunk
834
+ * contributions; direct mutation breaks the derivation. */
835
+ readonly labelSet: {
836
+ readonly footnoteLabels: ReadonlySet<string>;
837
+ readonly linkLabels: ReadonlySet<string>;
838
+ };
839
+ /** Monotonic version counter bumped by every mutation. **Read-only** —
840
+ * consumers should observe via `subscribe`, not by writing. */
841
+ readonly version: number;
842
+ subscribe(cb: () => void): () => void;
843
+ canonicalFootnoteFor(label: string): symbol | null;
844
+ canonicalLinkFor(label: string): symbol | null;
845
+ globalNumber(label: string): number | null;
846
+ /**
847
+ * Resolve a cross-chunk link definition by label. The returned `url` is the
848
+ * value the contributing chunk's `urlTransform` produced — cross-chunk
849
+ * link/image references run a second, per-attribute sanitization pass
850
+ * (`urlTransform` + `sanitizeSchema.protocols`) at render time, so the
851
+ * placeholder components themselves never trust this value blindly.
852
+ *
853
+ * Consumers reading `def.url` directly (custom backlink panels, analytics,
854
+ * dev tooling) receive a defense-in-depth-filtered string but should still
855
+ * pipe it through their own `urlTransform` if they intend to render it as
856
+ * an `href`/`src` — the contribute-time pass uses the `'href'` key and a
857
+ * synthetic `<a>` node, so a key-aware policy may treat the value
858
+ * differently when used as an `<img src>`.
859
+ */
860
+ resolveLinkDef(label: string): LinkDef | null;
861
+ getRefsForLabel(label: string): number;
862
+ /** Map a chunk-local footnote-ref occurrence index (1-based, as emitted by
863
+ * `customMdastHandlers`) to the corresponding document-wide occurrence
864
+ * index across all chunks. Used by `FootnoteSupNumber` to build a unique
865
+ * `id="fnref-X-N"` for each ref instance and by `AggregateFootnotesIfLast`
866
+ * to enumerate per-occurrence backrefs. Returns `null` if the ref isn't
867
+ * registered yet (registry mid-flight). */
868
+ globalOccurrenceForRef(chunkSym: symbol, label: string, localOccurrence: number): number | null;
869
+ }
870
+ /**
871
+ * Internal registry surface — extends {@link Registry} with the mutator
872
+ * methods and implementation-private fields (reactId-keyed refcount table,
873
+ * subscriber set, microtask-coalesce flag, `_notify` itself).
874
+ *
875
+ * Exported from this module so internal coordinators (`MarkdownContent`)
876
+ * and tests can hold a strongly-typed reference, but **not** re-exported
877
+ * from the package barrel — a consumer flipping `_notifyScheduled = true`
878
+ * or pushing into `chunkOrder` directly would silently break the
879
+ * coalesce / numbering invariants. The runtime value returned by
880
+ * {@link createRegistry} always satisfies this wider shape; public consumers
881
+ * just see the narrowed {@link Registry} view.
882
+ */
883
+ interface RegistryInternal extends Registry {
884
+ /** Allocate (or reuse, for Strict Mode remount) the chunk Symbol for
885
+ * `reactId` AND publish this chunk's own def labels (footnotes + links)
886
+ * in one call. Canonical pair API used by `MarkdownContent`'s allocate
887
+ * effect — combining the two reduces the pair to a single registry
888
+ * version step, which downstream consumers see as one wake-up rather
889
+ * than two (the second was already coalesced by microtask, but this
890
+ * keeps the version monotonic-by-1-per-mount which makes debugging
891
+ * easier). The granular `allocateSymbol` / `contributeLabels` methods
892
+ * remain available for tests that need to exercise each step. */
893
+ registerChunk(reactId: string, footnotes: Set<string>, links: Set<string>): symbol;
894
+ allocateSymbol(reactId: string): symbol;
895
+ releaseSymbol(reactId: string): void;
896
+ contributeLabels(symbol: symbol, footnotes: Set<string>, links: Set<string>): void;
897
+ contributeChunkData(symbol: symbol, data: ChunkData): void;
898
+ _reactIdMap: Map<string, {
899
+ symbol: symbol;
900
+ refcount: number;
901
+ }>;
902
+ _subscribers: Set<() => void>;
903
+ _notifyScheduled: boolean;
904
+ _notify(): void;
905
+ }
906
+ /**
907
+ * Construct a new Registry. `onEmpty`, if supplied, is invoked once each
908
+ * time the registry transitions to "no chunks alive" — i.e. the last
909
+ * tracked chunk's deferred `releaseSymbol` cleanup just removed its
910
+ * entry, leaving `chunkOrder` and `chunkData` both empty. The container
911
+ * uses this to evict the registry from its `documentId → Registry` map
912
+ * so long-lived SPAs that cycle through many `documentId` values don't
913
+ * accumulate empty shells.
914
+ *
915
+ * `onEmpty` fires synchronously from inside the releaseSymbol microtask,
916
+ * so the registry's state is guaranteed quiescent during the callback —
917
+ * no other code can interleave between the empty-state check and the
918
+ * caller's eviction logic.
919
+ */
920
+ declare function createRegistry(onEmpty?: () => void): RegistryInternal;
921
+
922
+ /**
923
+ * Builds a `rehype-sanitize` schema by handing the caller a deep clone of
924
+ * the library's internal default schema to mutate or replace.
925
+ *
926
+ * The mutate-and-return pattern matches the ergonomics of Next.js's
927
+ * `webpack(config)` and Express middleware: a callback receives a draft,
928
+ * either modifies it in place (returning nothing) or returns a fresh object
929
+ * to replace it. The library guarantees the draft is a deep clone, so direct
930
+ * mutation never leaks into the singleton — and the singleton itself is not
931
+ * exported, so consumers cannot accidentally hand-roll a schema that drops
932
+ * the library's cross-chunk tag allowlist or KaTeX className additions.
933
+ *
934
+ * @module components/extendSanitizeSchema
935
+ */
936
+
937
+ /**
938
+ * The full `rehype-sanitize` schema type. Re-exported as the canonical
939
+ * library-internal alias so other modules don't each redeclare
940
+ * `typeof defaultSchema`.
941
+ *
942
+ * The shape is owned by `rehype-sanitize`; consumers should treat it as
943
+ * tracking that upstream type — it may evolve across rehype-sanitize major
944
+ * versions.
945
+ */
946
+ type SanitizeSchema = typeof defaultSchema;
947
+ /**
948
+ * Build a sanitize schema by mutating (or replacing) a deep clone of the
949
+ * library default.
950
+ *
951
+ * Designed to be called ONCE at module scope so the returned object has a
952
+ * stable identity across renders — passing it directly into
953
+ * `<AIMarkdown sanitizeSchema={…}>` keeps the block-memo cache warm.
954
+ *
955
+ * @example Append a custom URL protocol via mutation:
956
+ * ```ts
957
+ * const SCHEMA = extendSanitizeSchema((s) => {
958
+ * s.protocols!.href!.push('myapp');
959
+ * s.protocols!.src!.push('myapp');
960
+ * });
961
+ *
962
+ * function App() {
963
+ * return <AIMarkdown content={…} sanitizeSchema={SCHEMA} />;
964
+ * }
965
+ * ```
966
+ *
967
+ * @example Return-style replacement for wider edits:
968
+ * ```ts
969
+ * const SCHEMA = extendSanitizeSchema((s) => ({
970
+ * ...s,
971
+ * tagNames: [...(s.tagNames ?? []), 'my-widget'],
972
+ * }));
973
+ * ```
974
+ *
975
+ * @example Inspect the library default (e.g. to learn what's already allowed):
976
+ * ```ts
977
+ * // The draft handed to the modifier IS the library default, deep-cloned.
978
+ * // Logging it once at module load surfaces every default field — protocols,
979
+ * // attributes, tagNames, etc. — without ever exposing the singleton itself.
980
+ * extendSanitizeSchema((s) => {
981
+ * console.log('default sanitize schema:', s);
982
+ * });
983
+ * ```
984
+ *
985
+ * @remarks Allowing a protocol on `protocols.href` lets the URL through
986
+ * Gate 1 (the schema-level per-protocol allowlist, which runs inside the
987
+ * rehype plugin chain). Gate 2 (`urlTransform`, the per-attribute rewriter
988
+ * that runs later at render time in `renderHastSubtree`) must permit the
989
+ * same protocol independently — see the `urlTransform` prop on
990
+ * `<AIMarkdown>` and the exported {@link defaultUrlTransform} for
991
+ * composition. Keep the two protocol lists in sync.
992
+ *
993
+ * ### Footguns
994
+ *
995
+ * - **Reassigning the local parameter** (`(s) => { s = { …new schema… }; }`)
996
+ * does NOT replace the draft — JS only rebinds the local variable. Either
997
+ * mutate the original draft or `return` the new object explicitly.
998
+ * - **Returning `null`** is treated the same as returning nothing (the
999
+ * modified draft is used). The TypeScript signature does not permit
1000
+ * `null`, but JS callers or `as`-casted code paths could silently hit
1001
+ * this. Prefer `return` with no value, or an explicit `return draft;`.
1002
+ * - **Throwing inside the modifier** propagates to the call site
1003
+ * uncaught — there is no try/catch. Callers usually invoke this once at
1004
+ * module load, where a thrown error surfaces as a startup-time crash and
1005
+ * is the correct failure mode.
1006
+ *
1007
+ * @param modifier - Receives a deep clone of the library default. Mutate it
1008
+ * freely; either return the (possibly different) result, or return
1009
+ * nothing to use the mutated draft. Returning `undefined` (and, by
1010
+ * convention, `null`) is treated the same as a mutate-only call.
1011
+ * @returns A new `Schema` object — never the library default singleton.
1012
+ */
1013
+ declare function extendSanitizeSchema(modifier: (draft: SanitizeSchema) => SanitizeSchema | void): SanitizeSchema;
1014
+
1015
+ /**
1016
+ * Type definitions for the sealed engine plugin system (v2 input surface).
1017
+ *
1018
+ * An "engine plugin" is a first-class, core-exported description of one
1019
+ * configurable entry in the unified plugin chain. The five shipped plugins
1020
+ * replace the two v1.x enums (`AIMarkdownRenderExtraSyntax` /
1021
+ * `AIMarkdownRenderDisplayOptimizeAbility`) as the public way to select
1022
+ * optional parse-level capability.
1023
+ *
1024
+ * ## Why the set is sealed
1025
+ *
1026
+ * The incremental (prefix-freeze) parse engine's boundary scanner is
1027
+ * syntax-aware — it must know the boundary rules of every multiline
1028
+ * construct in the chain (see `computeFreezeBoundary`'s `defListEnabled`
1029
+ * option). Open plugin injection would void the engine's verification
1030
+ * record (50k-sample fuzz, direction batteries, byte equivalence), so
1031
+ * plugins are born where the certification rig lives: `packages/core`.
1032
+ * Wrappers curate (bundle default sets, filter, facade sugar); consumers
1033
+ * select. New parse-level capability lands via an upstream PR into core.
1034
+ * Third-party *content* extension stays open through `contentPreprocessors`
1035
+ * and `customComponents`.
1036
+ *
1037
+ * ## Seal mechanics
1038
+ *
1039
+ * The seal is a type-level contract and design declaration, not runtime
1040
+ * tamper-proofing — the same guarantee class as `Object.freeze`. The
1041
+ * `'~sealed'` marker key makes accidental construction a type error while
1042
+ * keeping the type structural, which is required for the type to remain
1043
+ * assignable across the package's two build entries (the root entry and
1044
+ * the `/plugins` subpath must stay mutually assignable whether or not the
1045
+ * build duplicates this declaration per entry; a `unique symbol` brand
1046
+ * would break the duplicated case. Current tsup output happens to share
1047
+ * one declaration chunk between the entries — an implementation detail
1048
+ * the seal deliberately does not lean on). A second, runtime gate
1049
+ * (`getEnginePluginInternals`, enforced in `sanitizeEnginePlugins`)
1050
+ * additionally rejects any object that lacks EITHER the marker or the
1051
+ * internal stage metadata, so a type-level forge is dropped at runtime
1052
+ * with a dev warning. Deliberately forging both keys voids the engine's
1053
+ * verification record.
1054
+ *
1055
+ * @module plugins/defs
1056
+ */
1057
+ /**
1058
+ * Names of the shipped engine plugins. Closed to third-party EXTENSION (see
1059
+ * module docs), but an OPEN set across 2.x versions: new plugin names may be
1060
+ * added in minor releases. Do not write exhaustive `switch` statements or
1061
+ * `Record<AIMarkdownEnginePluginName, …>` maps over this union — both break
1062
+ * on the next addition. Feature-test with `Set`/`includes` instead.
1063
+ */
1064
+ type AIMarkdownEnginePluginName = 'highlight' | 'definitionList' | 'smartypants' | 'pangu' | 'removeComments';
1065
+ /**
1066
+ * A sealed engine plugin. Values are core-exported singletons from
1067
+ * `@ai-react-markdown/core/plugins`; pass them to the `enginePlugins` prop
1068
+ * of `<AIMarkdown>`.
1069
+ *
1070
+ * - Passing an array replaces the default set wholesale (array-atomic
1071
+ * semantics); omitting the prop means `defaultEnginePlugins` (all five).
1072
+ * - The produced chain position of each plugin comes from canonical
1073
+ * per-stage tables keyed by name (`pluginChain.ts`) — the order of the
1074
+ * user-supplied array is irrelevant.
1075
+ * - Duplicate members are deduplicated with a dev warning.
1076
+ * - Serializing plugin objects is unsupported; transport
1077
+ * {@link AIMarkdownEnginePlugin.name} instead and map names back to the
1078
+ * exported singletons at the edge (remote-config scenarios).
1079
+ */
1080
+ interface AIMarkdownEnginePlugin {
1081
+ /** Stable identifier; the serialization escape hatch. */
1082
+ readonly name: AIMarkdownEnginePluginName;
1083
+ /**
1084
+ * @internal Type-level seal — constructible only inside core. Third-party
1085
+ * construction (including deliberately forging this marker) voids the
1086
+ * incremental engine's verification record.
1087
+ */
1088
+ readonly '~sealed': 'ai-react-markdown/engine-plugin';
1089
+ }
1090
+ /**
1091
+ * Chain stage a plugin belongs to. Determines the plugin's splice position
1092
+ * in the produced remark chain — user array order never does.
1093
+ * @internal
1094
+ */
1095
+ type EnginePluginStage = 'extraSyntax' | 'displayOptimize';
1096
+ /**
1097
+ * Runtime metadata carried by every sealed plugin object. Not part of the
1098
+ * public type — internal consumers read it through
1099
+ * {@link getEnginePluginInternals}.
1100
+ * @internal
1101
+ */
1102
+ interface EnginePluginInternals {
1103
+ readonly stage: EnginePluginStage;
1104
+ }
1105
+ /**
1106
+ * Read a sealed plugin's internal metadata. Returns `null` for objects that
1107
+ * do not carry it (a forged or foreign object) so callers can reject them
1108
+ * defensively instead of crashing.
1109
+ * @internal
1110
+ */
1111
+ declare function getEnginePluginInternals(plugin: AIMarkdownEnginePlugin): EnginePluginInternals | null;
1112
+
1113
+ /**
1114
+ * THE single source of the production plugin chain.
1115
+ *
1116
+ * `MarkdownContent`'s memos, the splice-equivalence arbiter's option
1117
+ * catalog (`incrementalParse/testPluginCatalog.ts`), and the prefixFreeze
1118
+ * experiment harness all build their chains HERE — plugin-order drift
1119
+ * between the renderer and its verification suites previously required
1120
+ * hand-synchronized copies, and a missed copy would silently leave the
1121
+ * arbiter testing a non-production pipeline.
1122
+ *
1123
+ * Two deliberate NON-consumers, kept as independent mirrors on purpose:
1124
+ * - `byteEquivalence.test.tsx`'s `legacyPlugins()` — it is the REFERENCE
1125
+ * implementation the new pipeline is compared against; importing this
1126
+ * module would make that comparison circular.
1127
+ * - `positionPropagation.test.ts` — it pins the position-retention
1128
+ * contract of the exact stack and must fail loudly when the stack
1129
+ * changes, not silently follow it.
1130
+ *
1131
+ * @module components/pluginChain
1132
+ */
1133
+
1134
+ type RemarkPlugins = NonNullable<PipelineOptions['remarkPlugins']>;
1135
+ type RehypePlugins = NonNullable<PipelineOptions['rehypePlugins']>;
1136
+ type RemarkRehypeOptions = NonNullable<PipelineOptions['remarkRehypeOptions']>;
1137
+ /** The always-on remark chain with plugin-gated extras spliced at their
1138
+ * contractual positions. ORDER IS LOAD-BEARING — see the arbiter suite. */
1139
+ declare function buildCoreRemarkPlugins(enginePlugins: readonly AIMarkdownEnginePlugin[]): RemarkPlugins;
1140
+ /** The rehype chain. `clobberPrefix` namespaces ids per instance; pass ''
1141
+ * for unprefixed output (test harnesses). */
1142
+ declare function buildCoreRehypePlugins(sanitizeSchema: SanitizeSchema, clobberPrefix: string): RehypePlugins;
1143
+ /** Base remark-rehype options (before the standalone/coordinated handler
1144
+ * merge that `MarkdownContent`'s pipeline memo layers on top). */
1145
+ declare function buildCoreRemarkRehypeOptions(enableDefinitionList: boolean): RemarkRehypeOptions;
1146
+
1147
+ /**
1148
+ * `rehypeRebaseHashLinks` — restore intra-document hash navigation after
1149
+ * `rehype-sanitize` clobbers `id` attributes.
1150
+ *
1151
+ * ### Why this exists
1152
+ *
1153
+ * `rehype-sanitize` defends against ID-clobbering attacks (e.g. a malicious
1154
+ * `<div id="window">` shadowing `window.window`) by prefixing every clobberable
1155
+ * attribute (`id`, `name`, `aria-describedby`, `aria-labelledby`) with
1156
+ * `clobberPrefix` (default `'user-content-'`). It does **not** rewrite `href`
1157
+ * values, since hashes are not themselves clobbering vectors. As a result,
1158
+ * any intra-document link — `[ref](#section)`, GFM footnote anchors, or raw
1159
+ * `<a id="x"><a href="#x">` pairs — points at an unprefixed hash while its
1160
+ * target id has been prefixed: navigation breaks.
1161
+ *
1162
+ * Pair this plugin with `remarkRehypeOptions: { clobberPrefix: '' }` so that
1163
+ * `mdast-util-to-hast` does not also prefix (avoiding `user-content-user-content-`
1164
+ * double prefixes), and place it **after** `rehype-sanitize` in the rehype
1165
+ * pipeline. The result mirrors GitHub's rendering: a single, consistent
1166
+ * `user-content-` prefix on every id and matching hash href.
1167
+ *
1168
+ * @module components/rehypeRebaseHashLinks
1169
+ */
1170
+
1171
+ interface RehypeRebaseHashLinksOptions {
1172
+ /** Prefix to apply. Must match the `clobberPrefix` used by `rehype-sanitize`. */
1173
+ prefix?: string;
1174
+ }
1175
+ declare const rehypeRebaseHashLinks: Plugin<[RehypeRebaseHashLinksOptions?], Root$1>;
1176
+
1177
+ declare function rehypeFooterAdorn(): (tree: Root$1) => void;
1178
+
1179
+ /**
1180
+ * Direction B: source-level phantom-definition injection helpers.
1181
+ *
1182
+ * @module components/remarkInjectPhantomDefs
1183
+ */
1184
+ declare const SENTINEL_LINK_URL = "__aimd_sentinel_link__";
1185
+ declare const SENTINEL_FN_CONTENT = "__aimd_sentinel_fn__";
1186
+ interface PhantomLabels {
1187
+ missingFootnotes: Set<string>;
1188
+ missingLinks: Set<string>;
1189
+ }
1190
+ /**
1191
+ * The phantom-definition SUFFIX for labels not locally defined ('' when
1192
+ * there are none). Kept separate from the join so the incremental engine
1193
+ * can treat it as always-tail: the suffix may shrink/grow/reorder between
1194
+ * frames (registry label churn) without breaking the append gate.
1195
+ *
1196
+ * Labels are expected to already be normalized via normalizeId (uppercase).
1197
+ */
1198
+ declare function buildPhantomSuffix(phantoms: PhantomLabels): string;
1199
+
1200
+ /**
1201
+ * Custom mdast-util-to-hast handlers for cross-chunk label resolution.
1202
+ *
1203
+ * Direction A: `footnoteDefinitionHandler` mutates `state.footnoteOrder` so
1204
+ * `state.footer()` emits a `<section data-footnotes>` even when no
1205
+ * `footnoteReference` exists locally (orphan-def protection).
1206
+ *
1207
+ * Direction B: `linkReferenceHandler` / `imageReferenceHandler` /
1208
+ * `footnoteReferenceHandler` short-circuit the default to-hast output and
1209
+ * emit custom hast tags (`cross-chunk-link` / `cross-chunk-image` /
1210
+ * `footnote-sup`) carrying `label` + `referenceType` properties. These tags
1211
+ * map to React placeholder components in Phase 11. The sentinel URL is never
1212
+ * read — the handlers only need to know that the def is resolvable
1213
+ * (i.e. present in state.definitionById).
1214
+ *
1215
+ * All identifier comparisons normalize via {@link normalizeId} to match
1216
+ * mdast-util-to-hast's internal uppercase keying convention.
1217
+ *
1218
+ * @module components/customMdastHandlers
1219
+ */
1220
+
1221
+ interface CrossChunkHandlerOptions {
1222
+ /** Set of labels (already normalized) that this chunk phantom-injected
1223
+ * into its source for parser acceptance. Handlers use this to short-circuit
1224
+ * state writes that would otherwise leak sentinel content into the footer. */
1225
+ phantomFootnoteLabels: Set<string>;
1226
+ /** Same role for link/image defs. */
1227
+ phantomLinkLabels: Set<string>;
1228
+ /** When true, footnoteDefinitionHandler proactively registers orphan defs
1229
+ * to state.footnoteOrder (Direction A). */
1230
+ preserveOrphan: boolean;
1231
+ /** Passed through to placeholder hast properties so React components can
1232
+ * partition by document. */
1233
+ documentId: string;
1234
+ }
1235
+ declare function buildCrossChunkHandlers(): Handlers;
1236
+
1237
+ /**
1238
+ * Two-gate URL sanitization for cross-chunk link/image placeholders.
1239
+ *
1240
+ * In the standalone path, every `<a href>` and `<img src>` element passes
1241
+ * through TWO gates before render:
1242
+ *
1243
+ * 1. `urlTransform(url, key, node)` — the caller's allowlist (default:
1244
+ * `defaultUrlTransform`, which mirrors GitHub's protocol allowlist).
1245
+ * Runs in `markdown/transform.ts` during the hast visit pass.
1246
+ * 2. `rehype-sanitize` `protocols.<attr>` allowlist — drops the attribute
1247
+ * entirely if the URL's protocol isn't permitted for that attribute.
1248
+ *
1249
+ * Cross-chunk references skip BOTH passes naturally: the placeholder hast
1250
+ * tag (`<cross-chunk-link>` / `<cross-chunk-image>`) carries only a `label`
1251
+ * attribute; the real URL is looked up from the registry at React render
1252
+ * time, AFTER both passes have run. Without an explicit re-sanitization
1253
+ * here, cross-chunk renders would observably diverge from standalone:
1254
+ *
1255
+ * - A `[evil]: javascript:…` def in chunk A used by chunk B would render
1256
+ * a live `javascript:` link, even though the same source in standalone
1257
+ * mode is stripped.
1258
+ * - A consumer who allows a custom scheme via `urlTransform` but did NOT
1259
+ * add it to `sanitizeSchema.protocols` would see standalone strip the
1260
+ * `href`, but cross-chunk render it — silently breaking the "two gates,
1261
+ * defense in depth" contract documented in README.
1262
+ * - A key-aware `urlTransform` (e.g. one that allows a scheme only on
1263
+ * `href` but not `src`) would behave correctly in standalone but be
1264
+ * bypassed in the cross-chunk image path if we sanitized once at
1265
+ * contribute time with a fixed key.
1266
+ *
1267
+ * This helper makes the cross-chunk path observably identical to the
1268
+ * standalone two-gate pipeline, parameterised by the correct `key` for
1269
+ * each tag (`'href'` for `<a>`, `'src'` for `<img>`).
1270
+ *
1271
+ * @module components/crossChunkUrlSanitize
1272
+ */
1273
+
1274
+ type UrlAttrKey = 'href' | 'src';
1275
+ type UrlAttrTag = 'a' | 'img';
1276
+ /**
1277
+ * Run a cross-chunk-resolved URL through both standalone gates.
1278
+ *
1279
+ * @returns Sanitized URL string. Empty string when either gate strips the
1280
+ * URL — matches `defaultUrlTransform`'s and `rehype-sanitize`'s observable
1281
+ * behavior of replacing a blocked URL with `''`.
1282
+ */
1283
+ declare function sanitizeCrossChunkUrl(rawUrl: string, key: UrlAttrKey, tagName: UrlAttrTag, urlTransform: UrlTransform, schema: SanitizeSchema): string;
1284
+
1285
+ /**
1286
+ * The five shipped engine plugins and the default set.
1287
+ *
1288
+ * Construction happens ONLY here (principle P7: capability construction
1289
+ * follows the verification harness — the certification rig lives in this
1290
+ * package). Each object is frozen and carries internal stage metadata that
1291
+ * fixes its position in the produced chain; see `plugins/defs.ts` for the
1292
+ * seal rationale.
1293
+ *
1294
+ * @module plugins/catalog
1295
+ */
1296
+
1297
+ /** `==Highlight==` syntax support (v1.x: `AIMarkdownRenderExtraSyntax.HIGHLIGHT`). */
1298
+ declare const highlight: AIMarkdownEnginePlugin;
1299
+ /**
1300
+ * Definition list syntax (v1.x: `AIMarkdownRenderExtraSyntax.DEFINITION_LIST`).
1301
+ * @see https://michelf.ca/projects/php-markdown/extra/#def-list
1302
+ */
1303
+ declare const definitionList: AIMarkdownEnginePlugin;
1304
+ /** Strip HTML comments from the content (v1.x: `REMOVE_COMMENTS`). */
1305
+ declare const removeComments: AIMarkdownEnginePlugin;
1306
+ /**
1307
+ * Typographic enhancements via SmartyPants — curly quotes, em-dashes, etc.
1308
+ * (v1.x: `SMARTYPANTS`). @see https://www.npmjs.com/package/smartypants
1309
+ */
1310
+ declare const smartypants: AIMarkdownEnginePlugin;
1311
+ /** Automatic spacing between CJK and half-width characters (v1.x: `PANGU`). */
1312
+ declare const pangu: AIMarkdownEnginePlugin;
1313
+ /**
1314
+ * The default engine plugin set — all five, in canonical chain order.
1315
+ * Parity with the v1.x shipped defaults (`defaultAIMarkdownRenderConfig`).
1316
+ *
1317
+ * The recommended "turn one off" idiom:
1318
+ * ```ts
1319
+ * <AIMarkdown enginePlugins={defaultEnginePlugins.filter((p) => p !== pangu)} />
1320
+ * ```
1321
+ */
1322
+ declare const defaultEnginePlugins: readonly AIMarkdownEnginePlugin[];
1323
+
1324
+ /**
1325
+ * Shared hast predicates — engine-side home for detection helpers consumed
1326
+ * by both the incremental-parse engine (attributeHastChildren's seam
1327
+ * attribution) and core's block-memo renderer.
1328
+ *
1329
+ * Lifted verbatim from core's blockMemo.ts (boundary action ①): blockMemo
1330
+ * stays React-side while incrementalParse moves into the engine, and this
1331
+ * predicate was the one value-level edge between them.
1332
+ *
1333
+ * @module components/hastPredicates
1334
+ */
1335
+
1336
+ /**
1337
+ * Detect mdast-util-to-hast's synthesized footnote `<section data-footnotes>`.
1338
+ * Position-based detection alone would be too broad — any future rehype plugin
1339
+ * that appends a position-less node would be misclassified. We assert by
1340
+ * `tagName === 'section'` AND presence of the `dataFootnotes` property.
1341
+ */
1342
+ declare function isFootnoteSection(node: Element): boolean;
1343
+
1344
+ /**
1345
+ * CommonMark §4.7 label normalization. Used as the single canonical form
1346
+ * for all label-keyed structures (registry maps, phantomFootnoteLabels Set,
1347
+ * labelSet, etc.) and for handler comparisons against mdast-util-to-hast's
1348
+ * internal `state.definitionById` / `state.footnoteById` keys.
1349
+ *
1350
+ * Direction is uppercase to align with mdast-util-to-hast internals
1351
+ * (`String(identifier).toUpperCase()`). Direction is irrelevant once both
1352
+ * sides agree; uppercase chosen to match the upstream library to minimize
1353
+ * adapter calls.
1354
+ *
1355
+ * @module components/normalizeId
1356
+ */
1357
+ declare function normalizeId(s: string): string;
1358
+ /**
1359
+ * Same as {@link normalizeId} plus resolution of backslash escapes.
1360
+ * Used by PASS 0.5 substring pre-check against raw chunk source text:
1361
+ * sources may write `[foo\]bar]` but the resulting label identifier is
1362
+ * `foo]bar`, so we must unescape source before substring matching.
1363
+ */
1364
+ declare function normalizeForMatch(s: string): string;
1365
+
1366
+ /**
1367
+ * Shorten long consumer-supplied `documentId` values down to a fixed-length
1368
+ * Base62 hash so they don't bloat every rendered `id="…"` / `href="#…"`.
1369
+ *
1370
+ * Trade-offs:
1371
+ * - Non-cryptographic hash (MurmurHash3 x86 32-bit) — pure speed, excellent
1372
+ * avalanche on the structured inputs we actually receive (UUIDs, nanoids,
1373
+ * `useId()` outputs, opaque chat-message ids). Collision domain is 2^32;
1374
+ * at ~77,000 active document ids the birthday-paradox collision rate hits
1375
+ * ~50%, which is far beyond any realistic single-page chat workload.
1376
+ * - Only kicks in past a length threshold (default 16) so short, hand-picked
1377
+ * ids stay readable and existing test fixtures (`'tst'`, `'doc-a'`, …)
1378
+ * plus `useId()`'s `'_r_0_'`-style output (≤7 chars) pass through untouched.
1379
+ * - Pure function: same input always yields the same output. Two chunks
1380
+ * sharing one logical `documentId` therefore still produce identical
1381
+ * prefixes, so cross-chunk anchor and footnote coordination is preserved.
1382
+ *
1383
+ * Why MurmurHash3 specifically (vs the simpler FNV-1a):
1384
+ * - FNV-1a does NOT pass SMHasher's avalanche test. For *structured* inputs
1385
+ * like UUIDs (fixed hyphen positions, hex-only alphabet) its lower bits
1386
+ * show measurable bias, which translates to a higher *practical* collision
1387
+ * rate than the theoretical 2^32 figure suggests.
1388
+ * - MurmurHash3's finalizer (`fmix32`) — `h ^= h>>>16; h = imul(h, M1);
1389
+ * h ^= h>>>13; h = imul(h, M2); h ^= h>>>16` — is purpose-built to
1390
+ * flatten any local pattern that survives the body, which is what gets
1391
+ * it through SMHasher. That's the entire engineering reason for the
1392
+ * ~30-line code-size delta vs FNV-1a.
1393
+ *
1394
+ * @module components/shortenDocumentId
1395
+ */
1396
+ /**
1397
+ * Shorten a `documentId` for use inside an HTML id prefix.
1398
+ *
1399
+ * @param id - The raw documentId (consumer-supplied or `useId()` fallback).
1400
+ * @param threshold - Only ids strictly longer than this get hashed.
1401
+ * Defaults to `16`, which leaves short hand-picked ids and React's
1402
+ * `useId()` outputs unchanged but catches UUIDs (36 chars) and nanoids.
1403
+ * @returns Either the original `id` (when short) or a 1–6 char Base62 hash.
1404
+ */
1405
+ declare function shortenDocumentId(id: string, threshold?: number): string;
1406
+
1407
+ /**
1408
+ * Dev-only pipeline stage timing.
1409
+ *
1410
+ * The block-memo render path runs four distinct stages per content change
1411
+ * (parse → transform → build → render). Which one dominates depends on the
1412
+ * workload — plugin mix, document length, math density — and every
1413
+ * optimization decision should start from that split, not from a guess.
1414
+ *
1415
+ * Two delivery channels, deliberately separate:
1416
+ *
1417
+ * - **Programmatic consumers** (the BlockMemoCompare story's profiler)
1418
+ * use {@link subscribeStageTimings} — a private, direct callback
1419
+ * channel. It must NOT go through the User Timing API: React 19 dev
1420
+ * builds emit one `performance.measure` per component render
1421
+ * ("component tracks"), so a page-global 'measure' observer receives a
1422
+ * flood of foreign entries it has to filter, and — measured — anything
1423
+ * that scans or clears the buffer becomes O(buffer) with MILLIONS of
1424
+ * entries in a long session.
1425
+ * - **DevTools timeline visibility**: {@link measureStage} still emits
1426
+ * one `performance.measure` per stage, named
1427
+ * `ai-markdown:stage:<stage>`. Emission is an append — cheap. It does
1428
+ * NOT clear per name afterwards: Chromium's `clearMeasures(name)` scans
1429
+ * the whole buffer, and under the React-dev flood that per-call scan
1430
+ * cost ~35% of main-thread time ON THE INSTRUMENTED SIDE ONLY —
1431
+ * inverting the very benchmark this instrumentation serves. Our own
1432
+ * growth is ~4 entries per streamed token; the benchmark harness
1433
+ * bulk-clears the buffer between runs (see useRenderProfiler.reset).
1434
+ *
1435
+ * Production builds fold {@link ENABLED} to `false`: the dual dev/prod
1436
+ * build resolves `process.env.NODE_ENV` at build time (tsup `env`), so
1437
+ * the published dist carries no env read at all; in-repo src consumers
1438
+ * (storybook, vitest) run under Vite/Node where the text is substituted
1439
+ * or `process` exists. The whole thing costs one boolean check per stage.
1440
+ *
1441
+ * @module components/devStageTimings
1442
+ */
1443
+ /** The stages of the block-memo render pipeline, in execution order.
1444
+ * Single source of truth: the union type, the emitted measure names, and
1445
+ * any display ordering (ProfilerPanel) all derive from this tuple.
1446
+ * `scan` is the incremental-parse boundary detector (emitted only when
1447
+ * `incrementalParse` routes through the incremental engine);
1448
+ * when incremental parsing splices, `parse`/`transform` cover the
1449
+ * TAIL-ONLY work — the honest per-frame cost, not the full-document one. */
1450
+ declare const PIPELINE_STAGES: readonly ["scan", "parse", "transform", "build", "render"];
1451
+ type PipelineStage = (typeof PIPELINE_STAGES)[number];
1452
+ /** Prefix for the emitted `performance.measure` entry names. */
1453
+ declare const STAGE_MEASURE_PREFIX = "ai-markdown:stage:";
1454
+ /** `instanceId` is the emitting `<AIMarkdown>`'s documentId — listeners on a
1455
+ * page with several instances (e.g. an A/B comparison where BOTH sides run
1456
+ * the block-memo path) filter by it; a page-wide aggregate ignores it. */
1457
+ type StageListener = (stage: PipelineStage, durationMs: number, instanceId?: string) => void;
1458
+ /**
1459
+ * Subscribe to stage timings over the private callback channel (dev-only;
1460
+ * in production builds {@link measureStage} never emits, so listeners
1461
+ * simply stay silent). Returns an unsubscribe function.
1462
+ *
1463
+ * This exists so the benchmark profiler does NOT have to observe the
1464
+ * page-global 'measure' entry type: React 19 dev floods that channel with
1465
+ * one measure per component render, which would have to be filtered per
1466
+ * batch — and the module-level listener set is still page-scoped, so the
1467
+ * one-consumer-per-page discipline from the story docs continues to apply.
1468
+ */
1469
+ declare function subscribeStageTimings(listener: StageListener): () => void;
1470
+ /**
1471
+ * Run `fn` as the named pipeline stage. In dev: notifies
1472
+ * {@link subscribeStageTimings} listeners and emits one
1473
+ * `performance.measure` for DevTools timeline visibility. The
1474
+ * single-callable shape (instead of a start/end pair) makes the stage
1475
+ * name a typed argument and the pairing unforgettable: a call site cannot
1476
+ * mismatch or drop an end call. If `fn` throws, nothing is emitted for
1477
+ * the aborted stage.
1478
+ *
1479
+ * NOTE: emission is append-only — never clear per name from here. See the
1480
+ * module docs: `clearMeasures(name)` is O(page buffer), and React 19
1481
+ * dev's component tracks grow that buffer without bound.
1482
+ */
1483
+ declare function measureStage<T>(stage: PipelineStage, fn: () => T, instanceId?: string): T;
1484
+
1485
+ /**
1486
+ * Builds the `rehype-sanitize` schema used by the internal markdown renderer.
1487
+ *
1488
+ * Extracted into its own module so the merge logic can be unit-tested in
1489
+ * isolation without pulling in React or the full markdown pipeline.
1490
+ *
1491
+ * @module components/sanitizeSchema
1492
+ */
1493
+
1494
+ type Schema = typeof defaultSchema;
1495
+ type AttributeEntry = NonNullable<NonNullable<Schema['attributes']>[string]>[number];
1496
+ /**
1497
+ * Extend the allowlist for a tag's `className` attribute with extra class
1498
+ * names while preserving all other default entries.
1499
+ *
1500
+ * `findDefinition` in hast-util-sanitize returns the *first* matching entry
1501
+ * for a given property name, so appending a second `className` entry would be
1502
+ * ignored. Instead, merge the allowed values into the existing entry.
1503
+ *
1504
+ * Edge cases:
1505
+ * - `existing` is `undefined` → returns a single new `['className', ...extra]`
1506
+ * - `existing` has no `className` entry → appends one with just the extras
1507
+ * - `existing` has a bare-string `'className'` entry (hast-util-sanitize's
1508
+ * "allow all values" form) → would be narrowed to an allow-list. This is a
1509
+ * semantics change, but the current `defaultSchema.attributes.code` entry
1510
+ * is always tuple-form, so this branch is defensive only.
1511
+ */
1512
+ declare function mergeClassNameAllowlist(existing: ReadonlyArray<AttributeEntry> | undefined, extraClassNames: readonly string[]): AttributeEntry[];
1513
+ /**
1514
+ * The full sanitize schema used by the markdown renderer: extends
1515
+ * `defaultSchema` to allow `<mark>`, the KaTeX math class names, and the
1516
+ * three custom hast tags emitted by cross-chunk coordination handlers.
1517
+ *
1518
+ * **Owns its arrays and objects.** The shallow spread of `defaultSchema`
1519
+ * alone would leave `attributes.a`, `attributes.img`, `protocols`,
1520
+ * `ancestors`, and similar nested fields aliased to `rehype-sanitize`'s
1521
+ * default singleton. A consumer who reasonably (but mistakenly) writes
1522
+ * `sanitizeSchema.protocols.href.push('myapp')` would then poison
1523
+ * `rehype-sanitize`'s `defaultSchema` for every other consumer in the
1524
+ * process — a cross-package side-effect that's near-impossible to debug.
1525
+ *
1526
+ * One `cloneDeep` at module init breaks that aliasing without measurable
1527
+ * cost (init-time only, single small object graph). The recommended
1528
+ * extension API is still {@link extendSanitizeSchema}, which clones again
1529
+ * per call; this layer just makes the exported singleton safe if someone
1530
+ * skips the helper.
1531
+ */
1532
+ declare const sanitizeSchema: Schema;
1533
+
1534
+ /**
1535
+ * Smooth-stream controller — framework-free typewriter pacing.
1536
+ *
1537
+ * Reveals an accumulated source string as a gradually growing prefix
1538
+ * (`getVisible()`), stepping by grapheme cluster at a rate that adapts to
1539
+ * backlog. The revealed prefix is append-only between snaps, which is
1540
+ * exactly the incremental-parse engine's fast path — the controller sits
1541
+ * upstream of the renderer and never touches the parse pipeline.
1542
+ *
1543
+ * Pacing model — an adaptive jitter buffer (audio-playout style):
1544
+ * - The controller estimates the source's arrival rate and burst interval
1545
+ * with irregular-sampling EMAs (recorded on every append), and derives a
1546
+ * target buffer B* ≈ bufferFactor × one burst's worth of text — the
1547
+ * causality floor for smoothing bursts of that period.
1548
+ * - Streaming: rate = max(floor, rateEma + (backlog − B*) / correctionTau).
1549
+ * Feedforward tracks the source speed (bounded lag at any model speed);
1550
+ * the feedback term pins the backlog near B*, dipping BELOW the source
1551
+ * rate on purpose when the buffer runs low so it can refill instead of
1552
+ * running dry between bursts. The floor is a tiny anti-freeze trickle,
1553
+ * deliberately smaller than any realistic arrival rate.
1554
+ * - Pre-stats (first burst after construction/snap): no estimate exists
1555
+ * yet, so the backlog reveals over roughly one correction window.
1556
+ * - Finished: rate = backlog / time-to-deadline, deadline stamped at
1557
+ * `finish() + drainMs` — the backlog empties BY the deadline instead of
1558
+ * decaying toward it forever.
1559
+ * - A deadline credit accumulator converts elapsed time (injectable
1560
+ * `now()`) into whole-grapheme reveals per scheduled frame — timers
1561
+ * carry no state between frames, so throttled/paused schedulers
1562
+ * self-correct on the next tick instead of drifting.
1563
+ * - The public tuning surface is three named presets ({@link SmoothStreamPacing});
1564
+ * the numeric parameters stay controller-level for advanced hosts.
1565
+ *
1566
+ * Grapheme discipline: stepping uses `Intl.Segmenter` (code-point fallback)
1567
+ * and the trailing grapheme of the source is held back until it is
1568
+ * confirmed — by more text arriving or by `finish()` — so a surrogate
1569
+ * half or a still-growing emoji ZWJ sequence is never revealed to the
1570
+ * parser mid-cluster.
1571
+ *
1572
+ * @module components/smoothStream/controller
1573
+ */
1574
+ /**
1575
+ * The public pacing surface: three named trade-off points on the
1576
+ * latency-vs-smoothness axis (the audio-plugin buffer-preset convention —
1577
+ * perceptual parameters resist meaningful numeric tuning).
1578
+ *
1579
+ * - `'smooth'` — target ~1.7 bursts of buffer: almost never runs dry
1580
+ * between server flushes, at the cost of a little extra lag.
1581
+ * - `'balanced'` (default) — ~1 burst of buffer, the causality floor for
1582
+ * smoothing: minimal lag that can still bridge a typical gap.
1583
+ * - `'responsive'` — sub-burst buffer: lowest lag, accepts an occasional
1584
+ * visible pause between bursts.
1585
+ */
1586
+ type SmoothStreamPacing = 'smooth' | 'balanced' | 'responsive';
1587
+ /** The numeric parameter bundle a {@link SmoothStreamPacing} preset names. */
1588
+ interface SmoothStreamPacingParams {
1589
+ /** Target buffer as a multiple of one estimated burst's worth of text. */
1590
+ bufferFactor: number;
1591
+ /**
1592
+ * Feedback time constant (ms): how fast the backlog is steered toward
1593
+ * the target buffer. Also the pre-stats reveal window for the first
1594
+ * burst, before any arrival estimate exists.
1595
+ */
1596
+ correctionTauMs: number;
1597
+ /** Smoothing horizon (ms) for the arrival-rate / burst-interval EMAs. */
1598
+ emaTauMs: number;
1599
+ /**
1600
+ * Anti-freeze floor (grapheme clusters/s). Deliberately tiny — smaller
1601
+ * than any realistic arrival rate — so the feedback term can slow the
1602
+ * reveal below the source rate to refill the buffer, without ever
1603
+ * freezing visible progress entirely.
1604
+ */
1605
+ minCharsPerSecond: number;
1606
+ /**
1607
+ * Hard drain budget after {@link SmoothStreamController.finish}: a
1608
+ * deadline is stamped at `finish() + drainMs` and the rate scales with
1609
+ * remaining-backlog / remaining-time, so the backlog empties BY the
1610
+ * deadline (not asymptotically). Consumed when the deadline is stamped —
1611
+ * changing it affects the next drain, not one in progress.
1612
+ */
1613
+ drainMs: number;
1614
+ }
1615
+ interface SmoothStreamOptions extends Partial<SmoothStreamPacingParams> {
1616
+ /**
1617
+ * Named pacing preset, the intended tuning surface. Individual
1618
+ * {@link SmoothStreamPacingParams} fields act as advanced per-field
1619
+ * overrides on top of the chosen preset. Default `'balanced'`.
1620
+ */
1621
+ pacing?: SmoothStreamPacing;
1622
+ /**
1623
+ * Injectable clock (milliseconds, monotonic preferred). Defaults to
1624
+ * `performance.now`, falling back to `Date.now`. Tests inject a manual
1625
+ * clock — pacing must never race the wall clock.
1626
+ */
1627
+ now?: () => number;
1628
+ /**
1629
+ * Injectable frame scheduler: schedules `cb` once, ASYNCHRONOUSLY, and
1630
+ * returns a cancel function. A scheduler that invokes `cb` synchronously
1631
+ * violates the contract (the returned cancel handle would be recorded
1632
+ * after the tick already cleared it, stranding the controller). Defaults
1633
+ * to `requestAnimationFrame` with a `setTimeout` fallback so the
1634
+ * controller also runs under node.
1635
+ */
1636
+ schedule?: (cb: () => void) => () => void;
1637
+ }
1638
+ interface SmoothStreamController {
1639
+ /**
1640
+ * Sets the full accumulated source. An append-extension of the current
1641
+ * source animates; anything else — the FIRST update after construction
1642
+ * included — snaps (content replacement is not a stream). An identical
1643
+ * string is a no-op, so replayed effects (StrictMode) are safe.
1644
+ * Calling `update` after {@link finish} re-enters streaming: multi-round
1645
+ * LLM flows (stream → tool call → stream) keep one controller.
1646
+ */
1647
+ update(source: string): void;
1648
+ /**
1649
+ * Signals end of stream: confirms the held-back trailing grapheme and
1650
+ * switches the control law to the `drainMs` window. Not terminal —
1651
+ * a later {@link update} resumes animation.
1652
+ */
1653
+ finish(): void;
1654
+ /** Jumps to `source` instantly, no animation, and clears any backlog. */
1655
+ snap(source: string): void;
1656
+ /** Reveals everything pending right now (skip-animation affordance). */
1657
+ flush(): void;
1658
+ /** Subscribes to visible-prefix changes. Returns the unsubscribe. */
1659
+ subscribe(listener: () => void): () => void;
1660
+ /**
1661
+ * The currently revealed prefix. Reference-stable between changes —
1662
+ * safe as a `useSyncExternalStore` snapshot.
1663
+ */
1664
+ getVisible(): string;
1665
+ /** True when the visible prefix has caught up with the full source. */
1666
+ isDrained(): boolean;
1667
+ /**
1668
+ * Cancels any scheduled frame and drops all subscribers. NOT terminal:
1669
+ * any subsequent call ({@link update}, {@link subscribe}, …) revives the
1670
+ * controller. React StrictMode's dev-only effect replay (mount → cleanup
1671
+ * → re-run) disposes and then reuses the same state-held instance — a
1672
+ * permanently-latching dispose would kill the reveal in dev forever.
1673
+ * After a REAL unmount nothing calls back in, so disposal sticks.
1674
+ */
1675
+ dispose(): void;
1676
+ }
1677
+ /**
1678
+ * The three preset bundles. Calibrated against the burst patterns in the
1679
+ * SmoothStream stories (server-buffer-like flushes at slow and fast model
1680
+ * speeds); frozen so a shared reference can't be mutated by a consumer.
1681
+ */
1682
+ declare const SMOOTH_STREAM_PACING_PRESETS: Readonly<Record<SmoothStreamPacing, Readonly<SmoothStreamPacingParams>>>;
1683
+ declare const createSmoothStreamController: (options?: SmoothStreamOptions) => SmoothStreamController;
1684
+
1685
+ /**
1686
+ * Type definitions for the content preprocessor pipeline.
1687
+ *
1688
+ * @module preprocessors/defs
1689
+ */
1690
+ /**
1691
+ * A synchronous function that transforms raw markdown content before it is
1692
+ * passed to the remark/rehype rendering pipeline.
1693
+ *
1694
+ * Preprocessors run in sequence -- each receives the output of the previous one.
1695
+ *
1696
+ * @param content - The raw (or partially processed) markdown string.
1697
+ * @returns The transformed markdown string.
1698
+ *
1699
+ * @example
1700
+ * ```ts
1701
+ * const stripFrontmatter: AIMDContentPreprocessor = (content) =>
1702
+ * content.replace(/^---[\s\S]*?---\n/, '');
1703
+ * ```
1704
+ */
1705
+ type AIMDContentPreprocessor = (content: string) => string;
1706
+
1707
+ /**
1708
+ * Content preprocessing pipeline.
1709
+ *
1710
+ * Runs all preprocessors (built-in + user-supplied) in sequence before
1711
+ * the markdown string is handed to react-markdown. The built-in LaTeX
1712
+ * preprocessor always runs first, followed by any extra preprocessors
1713
+ * provided by the consumer.
1714
+ *
1715
+ * @module preprocessors
1716
+ */
1717
+
1718
+ /**
1719
+ * Run the full preprocessing pipeline on raw markdown content.
1720
+ *
1721
+ * @param content - Raw markdown string.
1722
+ * @param extraPreprocessors - Optional user-supplied preprocessors appended after the built-in ones.
1723
+ * @param latexPreprocessor - The LaTeX stage. Defaults to the stateless
1724
+ * {@link preprocessLaTeX}; the renderer passes a per-instance
1725
+ * append-aware wrapper (byte-identical output, O(active tail) on
1726
+ * streaming appends) so per-frame reveals stop paying O(document).
1727
+ * @returns The preprocessed markdown string ready for rendering.
1728
+ */
1729
+ declare function preprocessAIMDContent(content: string, extraPreprocessors?: AIMDContentPreprocessor[], latexPreprocessor?: AIMDContentPreprocessor): string;
1730
+
1731
+ /**
1732
+ * LaTeX preprocessing pipeline.
1733
+ *
1734
+ * Normalizes raw markdown so that LaTeX expressions survive the remark/rehype
1735
+ * rendering pipeline intact. The main entry point is {@link preprocessLaTeX},
1736
+ * which splits content into protected regions (code blocks, inline code, HTML
1737
+ * tags) and applies a sequence of transformations to the unprotected text:
1738
+ *
1739
+ * 1. Escape mhchem commands (`\ce`, `\pu`)
1740
+ * 2. Escape currency dollar signs (e.g. `$100`, `$1,000.50`)
1741
+ * 3. Convert bracket delimiters (`\[...\]`, `\(...\)`) to dollar delimiters
1742
+ * 4. Escape pipes inside closed LaTeX blocks to prevent GFM table interference
1743
+ * 5. Escape pipes inside unclosed LaTeX blocks (streaming partial content)
1744
+ * 6. Escape underscores inside `\text{...}` commands
1745
+ * 7. Convert single-dollar delimiters to double-dollar delimiters
1746
+ * 8. Truncate trailing unclosed LaTeX blocks (streaming protection)
1747
+ *
1748
+ * Thanks to the implementations from the following repositories:
1749
+ * - https://github.com/lobehub/lobe-ui/blob/master/src/hooks/useMarkdown/latex.ts
1750
+ * - https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
1751
+ *
1752
+ * @module preprocessors/latex
1753
+ */
1754
+ interface Segment {
1755
+ text: string;
1756
+ isCode: boolean;
1757
+ }
1758
+ /**
1759
+ * Split content into alternating text and protected segments.
1760
+ * Protected segments (isCode: true) are excluded from LaTeX processing:
1761
+ * - fenced multiline code blocks: 3+ backticks or tildes at the *start of a
1762
+ * line* (≤3 space indent). Mid-line runs are never fence openers.
1763
+ * - inline code spans: a run of N backticks closed by another run of exactly
1764
+ * N backticks. May span newlines. Multi-backtick forms (e.g. `` `` `x` ``)
1765
+ * are supported so literal backtick characters can appear inside.
1766
+ * - HTML tags (e.g. `<span>$</span>` where `$` should not be treated as LaTeX).
1767
+ */
1768
+ declare function splitByProtectedRegions(content: string): Segment[];
1769
+ /**
1770
+ * Main LaTeX preprocessor entry point.
1771
+ *
1772
+ * Splits the input into protected regions (code blocks, inline code, HTML tags)
1773
+ * and applies the full normalization pipeline to unprotected text segments.
1774
+ * Returns the input unchanged when no LaTeX-related characters (`$`, `\[`, `\(`)
1775
+ * are detected.
1776
+ *
1777
+ * @param str - Raw markdown string.
1778
+ * @returns The preprocessed string with normalized LaTeX delimiters.
1779
+ */
1780
+ declare function preprocessLaTeX(str: string): string;
1781
+ /**
1782
+ * Append-aware `preprocessLaTeX`: one instance per streaming lineage (the
1783
+ * component holds it like the smooth-stream controller). Byte-identical to
1784
+ * `preprocessLaTeX(full)` on every call; non-append input resets all state;
1785
+ * identical input replays the cached output (StrictMode/idempotence).
1786
+ *
1787
+ * @param options.freezeThreshold Active-region size below which no freeze
1788
+ * is attempted. Tests pass `0` so SHORT pinned counterexamples actually
1789
+ * exercise the freeze path instead of passing vacuously through the
1790
+ * full-reprocess fallback.
1791
+ * @internal Wired by the renderer; not part of the public API.
1792
+ */
1793
+ declare function createIncrementalLatexPreprocessor(options?: {
1794
+ freezeThreshold?: number;
1795
+ }): (content: string) => string;
1796
+
1797
+ /**
1798
+ * Optional streaming tail-repair preprocessor built on `remend` (the
1799
+ * markdown-termination engine extracted from Vercel's Streamdown).
1800
+ *
1801
+ * While a response streams, the tail of the markdown source is frequently
1802
+ * mid-construct — `**bold` without its closer, an unterminated `` `code ``
1803
+ * span, a half-typed `[link](url`. The stock pipeline renders those frames
1804
+ * literally (asterisks and all) until the closing bytes arrive. Wrapping the
1805
+ * content with this preprocessor completes the unterminated syntax so every
1806
+ * frame renders styled.
1807
+ *
1808
+ * NOT enabled by default — opt in per instance:
1809
+ *
1810
+ * ```tsx
1811
+ * const remend = createRemendPreprocessor();
1812
+ * <AIMarkdown content={content} contentPreprocessors={[remend]} />
1813
+ * ```
1814
+ *
1815
+ * Create the preprocessor ONCE (module scope or `useMemo`) — a fresh function
1816
+ * identity per render would defeat `contentPreprocessors`' stable-value
1817
+ * memoization and re-run the whole pipeline every frame.
1818
+ *
1819
+ * Interactions (see docs/content-preprocessors.md for the full discussion):
1820
+ *
1821
+ * - **block-memo**: zero conflict. Repairs only append/adjust the tail;
1822
+ * earlier blocks' bytes are untouched, so their hast digests still hit.
1823
+ * - **incremental parse** (`incrementalParse`): frames whose tail was
1824
+ * repaired are not byte-appends of the previous frame, so the engine's
1825
+ * append gate falls back to a full parse for exactly those frames (not
1826
+ * sticky — splicing resumes once the construct closes in the real bytes).
1827
+ * - **`preprocessLaTeX`**: user preprocessors run AFTER the built-in LaTeX
1828
+ * pass. Math repair is therefore disabled here (`katex`/`inlineKatex`
1829
+ * forced off) — the LaTeX preprocessor already owns `$` handling,
1830
+ * including truncating unclosed `$$` tails.
1831
+ * - **complete documents**: on well-formed text remend is a no-op, so the
1832
+ * final frame renders identically with or without it. A document that
1833
+ * legitimately ends inside an unterminated marker (a trailing lone `*`)
1834
+ * will get it closed — acceptable for streaming UIs, but don't apply this
1835
+ * preprocessor to static content.
1836
+ *
1837
+ * @module preprocessors/remend
1838
+ */
1839
+
1840
+ /** Options accepted by {@link createRemendPreprocessor} — everything remend
1841
+ * supports except the math toggles, which this pipeline reserves for the
1842
+ * built-in LaTeX preprocessor. */
1843
+ type RemendPreprocessorOptions = Omit<RemendOptions, 'katex' | 'inlineKatex'>;
1844
+ /**
1845
+ * Build an {@link AIMDContentPreprocessor} that completes unterminated
1846
+ * markdown syntax at the streaming tail.
1847
+ *
1848
+ * Deviations from remend's own defaults:
1849
+ *
1850
+ * - `linkMode: 'text-only'` (overridable) — remend's default (`'protocol'`)
1851
+ * substitutes a `streamdown:incomplete-link` placeholder URL, but this
1852
+ * pipeline's URL sanitizer strips unknown protocols, which would leave a
1853
+ * dead `<a>` for the duration of the stream. Rendering the link text
1854
+ * plainly until the real URL arrives looks better under our sanitize
1855
+ * defaults.
1856
+ * - `katex`/`inlineKatex` forced off (NOT overridable — removed from the
1857
+ * option type) — the built-in LaTeX preprocessor (which runs first)
1858
+ * already rewrites and truncates `$`/`$$` constructs; two writers on the
1859
+ * same delimiters would fight.
1860
+ */
1861
+ declare function createRemendPreprocessor(options?: RemendPreprocessorOptions): AIMDContentPreprocessor;
1862
+
1863
+ export { type AIMDContentPreprocessor, type AIMarkdownEnginePlugin, type AIMarkdownEnginePluginName, type AdvanceOptions, type AdvanceResult, type AllowElement, type ChunkData, type Contribution, type CrossChunkHandlerOptions, DEFAULT_PAYLOAD, DEF_LINE_START_RE, type DefLabelScanner, type DefLabels, type Deprecation, type EnginePluginInternals, type EnginePluginStage, type ExtractContributionsOptions, type FootnoteDef, type FreezeBoundaryOptions, type IncrementalParseState, type IncrementalStage, type LinkDef, PIPELINE_STAGES, type ParsedMarkdown, type PhantomLabels, type PipelineOptions, type PipelineStage, type RefKind, type RefRecord, type Registry, type RegistryInternal, type RehypePlugins, type RehypeRebaseHashLinksOptions, type RemarkPlugins, type RemarkRehypeOptions, type RemendPreprocessorOptions, SENTINEL_FN_CONTENT, SENTINEL_LINK_URL, SMOOTH_STREAM_PACING_PRESETS, STAGE_MEASURE_PREFIX, type SanitizeSchema, type SmoothStreamController, type SmoothStreamOptions, type SmoothStreamPacing, type SmoothStreamPacingParams, type TransformContext, type UrlAttrKey, type UrlAttrTag, type UrlTransform, advanceIncrementalParse, attributeHastChildren, buildCoreRehypePlugins, buildCoreRemarkPlugins, buildCoreRemarkRehypeOptions, buildCrossChunkHandlers, buildPhantomSuffix, buildTransform, codePointSnapshots, collectDefLabels, computeFreezeBoundary, createDefLabelScanner, createFile, createIncrementalLatexPreprocessor, createProcessor, createRegistry, createRemendPreprocessor, createSmoothStreamController, defaultEnginePlugins, defaultUrlTransform, definitionList, extendSanitizeSchema, extractContributions, extractDefBodiesFromHast, getEnginePluginInternals, highlight, isFootnoteSection, lastRegionStart, measureStage, mergeClassNameAllowlist, normalizeForMatch, normalizeId, pangu, parseStage, preprocessAIMDContent, preprocessLaTeX, rehypeFooterAdorn, rehypeRebaseHashLinks, removeComments, sanitizeCrossChunkUrl, sanitizeSchema, shortenDocumentId, smartypants, sourceIdFromFootnoteLiId, splitByProtectedRegions, subscribeStageTimings, transformStage, withDefs };