@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.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/index.cjs +4138 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1863 -0
- package/dist/index.d.ts +1863 -0
- package/dist/index.dev.cjs +4150 -0
- package/dist/index.dev.cjs.map +1 -0
- package/dist/index.dev.js +4059 -0
- package/dist/index.dev.js.map +1 -0
- package/dist/index.js +4047 -0
- package/dist/index.js.map +1 -0
- package/package.json +108 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|