brookmd 0.26.1 → 0.28.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/CHANGELOG.md CHANGED
@@ -4,6 +4,106 @@ Notable changes to brookmd (formerly `flux-md`). Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.28.0 — 2026-07-31
8
+
9
+ **Browser-side linearity.** The parser and wire have been O(new bytes) per
10
+ append for a while; this release makes the *DOM application* match. Before, an
11
+ open (streaming) block was fully rebuilt on every animation frame — a 20 KB
12
+ highlighted code block wrote ~44 million characters of `innerHTML` over its
13
+ lifetime (≈2,150× the wire bytes), and a per-frame `replaceWith` destroyed any
14
+ text selection or `<pre>` scroll position inside the block. Every open-block
15
+ path now applies patches incrementally, measured at **2–3.4× the block's final
16
+ markup, flat across sizes** (1× is the write-it-once floor), and enforced by a
17
+ chars-written regression gate so browser-side linearity is CI-pinned like the
18
+ parser's scaling shapes.
19
+
20
+ ### Performance
21
+
22
+ - **Open blocks apply the wire's splice instead of rebuilding.** The delta
23
+ signal (`keep_units`) the wire already computed was being discarded at the
24
+ client; it now reaches both renderers, which rebuild only the element the
25
+ splice lands in (fast path fires ~94% of syncs; any ambiguity falls back to
26
+ a full rebuild, so correctness never depends on the fast path).
27
+ - **Open code blocks reuse the streaming highlighter's frozen prefix**: the
28
+ frozen markup is appended once and never rewritten; only the bounded
29
+ speculative tail repaints. 20 KB highlighted stream: ~44 MB written → ~370 KB
30
+ (**~120× less**).
31
+ - **Keyed list/container sync in the DOM renderer** (`blockData` on): settled
32
+ `<li>`s / nested container children are never re-rendered — new items append,
33
+ only the open last item repaints; a tight→loose flip resyncs once, keyed by
34
+ `(index, html)` so it cannot be silently missed. Streamed 20 KB list: 284× →
35
+ **2.9×**; blockquote: 318× → **3.4×**.
36
+ - **React's keyed container path now engages by default.** It was accidentally
37
+ gated behind a `components` map (git history shows the gate was incidental);
38
+ a default-config streaming blockquote/alert now renders keyed — 22.6× and
39
+ growing → **3.4× flat**. React and DOM implementations were cross-checked to
40
+ byte-identical work counts.
41
+ - Known documented fallback: a streamed table with `blockData: false` still
42
+ takes the full-rebuild path (the splice refuses table tag chains — foster
43
+ parenting). `blockData: true` tables were already incremental (1.7×). A
44
+ table-scoped splice was measured (15% improvement) and rejected as not worth
45
+ the parser-divergence risk.
46
+
47
+ ### Fixed
48
+
49
+ - **Text selection and `<pre>` scroll survive streaming.** Selecting text in
50
+ the already-settled part of a streaming block no longer collapses on the
51
+ next token; a code block's horizontal scroll position is preserved. Both are
52
+ pinned by tests with negative controls.
53
+
54
+ ### Changed
55
+
56
+ - The DOM inside an open, streaming `<code>` element is now two spans (frozen +
57
+ speculative tail) while streaming; it settles to the same single-markup form
58
+ as before when the block closes. CSS that targets `code > *` structurally
59
+ may observe the difference mid-stream only. Settled output is unchanged.
60
+ - React's default open blockquote/alert omits inter-block whitespace text
61
+ nodes mid-stream (matching the DOM renderer's long-standing keyed behavior);
62
+ layout is unaffected and settle output is byte-identical.
63
+
64
+ ## 0.27.0 — 2026-07-31
65
+
66
+ ### Added
67
+
68
+ - **Streaming syntax highlighting — open code blocks now highlight live, on by
69
+ default.** Since 0.19 the built-in highlighter deliberately waited for a
70
+ fence to close; a streaming block showed plain text. Open blocks now render
71
+ highlighted as they grow, and the mechanism preserves every guarantee the
72
+ deferred design existed to protect:
73
+
74
+ - **Byte-identical at settle.** All committed markup comes from seeded runs
75
+ of the same resumable tokenizer that `highlight()` uses — the frozen
76
+ prefix is only ever extended at *checkpoints* (positions provably outside
77
+ any future token: after a newline in whitespace, ≥ 3 bytes behind the
78
+ stream head, with no unterminated string/comment/template live; HTML
79
+ checkpoints after `>` instead). When the block closes, one seeded run
80
+ finishes the tail, so the final bytes equal a one-shot `highlight()` —
81
+ pinned by a ~44,000-case fuzz (all languages, chunk sizes down to 1 byte,
82
+ plus speculative-revision streams) asserting settle-identity and that
83
+ every intermediate frozen prefix is a byte-prefix of the final output.
84
+ - **Bounded work, independent of block size.** Between checkpoints, an
85
+ unterminated string/comment advances via an O(1)-state scanner over only
86
+ the new bytes; the tail re-scan is capped at 8 KB (past the cap the tail
87
+ renders plain until the next checkpoint — correctness unaffected). Total
88
+ tokenization work measures ≈ 5.7× the streamed bytes end-to-end against
89
+ the real parser, flat from 288 B to 23 KB blocks (naive re-highlighting
90
+ measures 812× more at 23 KB); a work-bound test enforces < 6×. The
91
+ 50 000-char plain-escape guard still applies and discards streaming state.
92
+ - **The tail is speculative.** The few tokens nearest the stream head may
93
+ change color as bytes arrive (an unterminated `"` reads as plain until its
94
+ closer lands) — the same speculative-tail behavior brookmd's links and
95
+ emphasis already have. Frozen output never changes.
96
+
97
+ Opt out with `streamingHighlight={false}` on `BrookMarkdown` (React) or
98
+ `streamingHighlight: false` in `mountBrookMarkdown` options (DOM, and via it
99
+ the Web Component and Vue/Svelte/Solid adapters) — that restores 0.26.1's
100
+ plain-until-close exactly. Not a `ParserConfig` field: highlighting is a
101
+ renderer concern; the parser, wire, and WASM are untouched. SSR is
102
+ unchanged (closed blocks, synchronous), and `components.CodeBlock` /
103
+ `pre` / `code` overrides still bypass the built-in highlighter entirely.
104
+ Closing a block that streamed in now also highlights near-instantly: the
105
+ close-time pass reuses the accumulated prefix instead of starting over.
106
+
7
107
  ## 0.26.1 — 2026-07-30
8
108
 
9
109
  Two fixes found by benchmarking 0.26.0 against real chat traffic. Requires
package/README.md CHANGED
@@ -4,7 +4,7 @@ Zero-dep streaming markdown for the browser. Rust→WASM core, one Web Worker pe
4
4
 
5
5
  Drop in a streaming-aware renderer — **React, Vue, Svelte, Solid, a framework-agnostic `<brook-markdown>` Web Component, or the vanilla DOM mount** — wire each LLM stream to a `BrookClient`, and the markdown renders incrementally off the main thread, block by block, with stable identities so unchanged blocks never re-reconcile.
6
6
 
7
- Parsing runs entirely **off the main thread** — each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document; patches cross the worker boundary as **verified splices** (not full re-sends, so emitted bytes stay O(n) even for one giant growing block); and heavy renderers (syntax highlighting, math, mermaid) are **deferred until a block closes**. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
7
+ Parsing runs entirely **off the main thread** — each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document; patches cross the worker boundary as **verified splices** (not full re-sends, so emitted bytes stay O(n) even for one giant growing block); and heavy renderers (math, mermaid) are **deferred until a block closes** — code fences highlight as they stream, re-tokenizing only the last line rather than the whole block per chunk. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
8
8
 
9
9
  > **Beyond the browser:** the same Rust core also powers experimental React
10
10
  > Native, Swift (iOS/macOS), Kotlin/Android, Flutter, and C-ABI bindings —
@@ -248,7 +248,9 @@ controlled-string helpers wrap; in vanilla you call it directly.
248
248
 
249
249
  `mountBrookMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
250
250
  Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
251
- (default true), `batch` (default true — one DOM write per `requestAnimationFrame`),
251
+ (default true), `streamingHighlight` (default true — highlight a code fence while
252
+ it is still streaming; see [Streaming syntax highlighting](#streaming-syntax-highlighting)),
253
+ `batch` (default true — one DOM write per `requestAnimationFrame`),
252
254
  `morphOpenBlocks` (default false — morph a growing generic open block's subtree in
253
255
  place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
254
256
  and focus/selection in the streaming tail survive; the rendered result is
@@ -1167,6 +1169,34 @@ import { highlight } from "brookmd/highlight";
1167
1169
  const html = highlight("const x = 1;", "ts");
1168
1170
  ```
1169
1171
 
1172
+ ### Streaming syntax highlighting
1173
+
1174
+ A code fence is highlighted **while it streams**, not only once it closes. On by
1175
+ default; turn it off with `streamingHighlight={false}` (React) or
1176
+ `{ streamingHighlight: false }` (the DOM mount options / Vue / Svelte / Solid),
1177
+ which restores the plain-until-close behaviour.
1178
+
1179
+ It stays O(n) over the whole block rather than re-highlighting the fence on every
1180
+ chunk. An open block keeps a **frozen prefix** — markup for everything behind a
1181
+ checkpoint, which later bytes provably cannot rewrite — and re-tokenizes only the
1182
+ **tail** after it, about one source line's worth per patch. When the fence closes,
1183
+ the tokenizer resumes from that checkpoint instead of starting over, so a block
1184
+ that streamed in highlights near-instantly.
1185
+
1186
+ Two things worth knowing:
1187
+
1188
+ - **The tail is speculative.** A prefix of source does not tokenize like the same
1189
+ prefix of a longer source: `const s = "hello` is a stray quote plus an
1190
+ identifier until its closing quote lands, and `123.456e` is `123` plus loose
1191
+ fragments until a digit arrives. So the last line's colours can shift as bytes
1192
+ come in. Nothing behind the checkpoint ever changes.
1193
+ - **The settled markup is byte-identical** to `highlight(text, lang)` either way.
1194
+ Turning this on or off changes when colour appears, never what it is.
1195
+
1196
+ Blocks past the highlighter's 50 000-character guard, unknown languages, and any
1197
+ fence taken over by a `components.CodeBlock` / `pre` / `code` override are
1198
+ unaffected — they behave exactly as before.
1199
+
1170
1200
  ## Coverage
1171
1201
 
1172
1202
  **CommonMark 0.31: 100% (652/652 spec examples), byte-exact** — every section,
@@ -1218,8 +1248,6 @@ By design, not yet, or only partially:
1218
1248
  (`<span>`/`<div class="math …">` with `gfmMath` on) and a `Mermaid` slot, but
1219
1249
  stays zero-dep: bring your own KaTeX / mermaid pass (or a `components.MathBlock`
1220
1250
  / `components.Mermaid` override) for the actual SVG/MathML output.
1221
- - **Syntax highlighting on open code blocks** — deferred until close. This is a
1222
- deliberate perf choice.
1223
1251
 
1224
1252
  ## Performance
1225
1253
 
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { warnOnce } from "./warn.js";
2
2
  import { createWorker } from "./asset-urls.js";
3
+ import { noteSplice } from "./splice.js";
3
4
  function emptyBlockStore() {
4
5
  return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
5
6
  }
@@ -18,7 +19,9 @@ function applyPatch(store, patch) {
18
19
  const { html_delta, ...rest } = entry;
19
20
  const prev = store.active.find((b) => b.id === entry.id);
20
21
  if (!prev) throw new Error(`brookmd: html_delta for block ${entry.id} without a base`);
21
- active[i] = { ...rest, html: prev.html.slice(0, html_delta.keep_units) + html_delta.append };
22
+ const next2 = { ...rest, html: prev.html.slice(0, html_delta.keep_units) + html_delta.append };
23
+ active[i] = next2;
24
+ noteSplice(next2, prev, html_delta.keep_units);
22
25
  } else {
23
26
  active[i] = entry;
24
27
  }
package/dist/dom.d.ts CHANGED
@@ -64,6 +64,17 @@ export interface MountOptions {
64
64
  /** Use the built-in code highlighter. Default true; suppressed when a
65
65
  * `components.CodeBlock` override is supplied. */
66
66
  highlightCode?: boolean;
67
+ /**
68
+ * Highlight a code fence **while it is still streaming**, instead of showing
69
+ * plain escaped text until it closes. On by default (parity with the React
70
+ * renderer's `streamingHighlight` prop).
71
+ *
72
+ * An open block keeps a frozen prefix and re-tokenizes only its tail on each
73
+ * patch, so this stays linear in the block's size. The settled markup is
74
+ * byte-identical either way — only the tail's colours are provisional, and
75
+ * they may shift as bytes arrive. Set `false` for the pre-0.27 behaviour.
76
+ */
77
+ streamingHighlight?: boolean;
67
78
  /** Coalesce patches into one DOM write per animation frame. Default true. */
68
79
  batch?: boolean;
69
80
  /**
@@ -118,7 +129,24 @@ export interface MountOptions {
118
129
  * `client.getMetrics().rebuildCount`.
119
130
  */
120
131
  onRenderMetrics?: RenderMetricsHook;
132
+ /**
133
+ * @internal TEST-ONLY. Turn off the incremental apply fast paths — the open
134
+ * code block's frozen/tail mirror and the delta-driven child splice — so every
135
+ * changed open block rebuilds its whole node, exactly as it did before those
136
+ * existed. The DOM-parity fuzz mounts one renderer with this on and one with it
137
+ * off over the same patch stream and asserts their `innerHTML` matches after
138
+ * every sync: correctness must never depend on a fast path firing. Not part of
139
+ * the supported API and never useful in an app.
140
+ */
141
+ __fullRebuild?: boolean;
121
142
  }
143
+ /** @internal Test-only. */
144
+ export declare function __keyedStats(): {
145
+ attempts: number;
146
+ hits: number;
147
+ };
148
+ /** @internal Test-only. */
149
+ export declare function __resetKeyedStats(): void;
122
150
  export declare function mountBrookMarkdown(client: BrookClient, container: HTMLElement, options?: MountOptions): MountHandle;
123
151
  /**
124
152
  * Derive the streaming tail's block id from an ordered snapshot: the id of the
package/dist/dom.js CHANGED
@@ -1,8 +1,19 @@
1
1
  import { highlightDeferred } from "./hi-defer.js";
2
+ import { createInc, incHighlight, incSeed } from "./hi-inc.js";
2
3
  import { morph } from "./morph.js";
4
+ import { newIncCode, paintIncCode, spliceHtml, spliceKeep } from "./splice.js";
3
5
  import { blockProps, extractLang } from "./block-props.js";
4
6
  import { decorateSegments } from "./decorate.js";
5
7
  import { safeUrl } from "./url-safety.js";
8
+ let keyedAttempts = 0;
9
+ let keyedHits = 0;
10
+ function __keyedStats() {
11
+ return { attempts: keyedAttempts, hits: keyedHits };
12
+ }
13
+ function __resetKeyedStats() {
14
+ keyedAttempts = 0;
15
+ keyedHits = 0;
16
+ }
6
17
  const INTRINSIC_PX = {
7
18
  Paragraph: 80,
8
19
  Heading: 44,
@@ -28,8 +39,10 @@ function mountBrookMarkdown(client, container, options = {}) {
28
39
  const hasInlineTransforms = !!decorators || !!urlTransform;
29
40
  const hasPerf = typeof performance !== "undefined";
30
41
  const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
42
+ const streamingHighlight = options.streamingHighlight !== false;
31
43
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
32
44
  const morphOpenBlocks = options.morphOpenBlocks === true;
45
+ const fullRebuild = options.__fullRebuild === true;
33
46
  const root = document.createElement("div");
34
47
  root.className = options.className ? `brook-md ${options.className}` : "brook-md";
35
48
  if (options.id) root.id = options.id;
@@ -68,6 +81,7 @@ function mountBrookMarkdown(client, container, options = {}) {
68
81
  id: b.id,
69
82
  node: void 0,
70
83
  html: b.html,
84
+ block: b,
71
85
  open: b.open,
72
86
  speculative: b.speculative,
73
87
  kind: b.kind.type,
@@ -85,6 +99,13 @@ function mountBrookMarkdown(client, container, options = {}) {
85
99
  continue;
86
100
  }
87
101
  const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
102
+ if (existing.codeInc && existing.inc && b.open && existing.open && b.kind.type === "CodeBlock" && existing.kind === "CodeBlock" && syncIncCode(existing, b)) {
103
+ if (onRenderMetrics) noteRender(existing, b, t0);
104
+ existing.html = b.html;
105
+ existing.block = b;
106
+ existing.speculative = b.speculative;
107
+ continue;
108
+ }
88
109
  if (existing.table && b.open && b.kind.type === "Table") {
89
110
  const data = tableData(b);
90
111
  if (data) {
@@ -94,15 +115,38 @@ function mountBrookMarkdown(client, container, options = {}) {
94
115
  existing.node.classList.toggle("brook-speculative", b.speculative);
95
116
  }
96
117
  existing.html = b.html;
118
+ existing.block = b;
119
+ existing.open = b.open;
120
+ existing.speculative = b.speculative;
121
+ continue;
122
+ }
123
+ }
124
+ if (!fullRebuild && existing.list && b.open && b.kind.type === "List" && existing.kind === "List") {
125
+ const ld = b.kind.data;
126
+ if (ld && syncKeyedList(existing.list, ld)) {
127
+ if (onRenderMetrics) noteRender(existing, b, t0);
128
+ existing.node.className = genericClassName(b);
129
+ existing.html = b.html;
130
+ existing.block = b;
97
131
  existing.open = b.open;
98
132
  existing.speculative = b.speculative;
99
133
  continue;
100
134
  }
101
135
  }
136
+ if (!fullRebuild && existing.container && b.open && existing.kind === b.kind.type && (b.kind.type === "Blockquote" || b.kind.type === "Alert") && syncKeyedContainer(existing.container, b)) {
137
+ if (onRenderMetrics) noteRender(existing, b, t0);
138
+ existing.node.className = genericClassName(b);
139
+ existing.html = b.html;
140
+ existing.block = b;
141
+ existing.open = b.open;
142
+ existing.speculative = b.speculative;
143
+ continue;
144
+ }
102
145
  if (morphOpenBlocks && b.open && existing.open && existing.generic && existing.kind === b.kind.type && usesGenericPath(b)) {
103
146
  morph(existing.node, sanitize ? sanitize(b.html) : b.html);
104
147
  if (onRenderMetrics) noteRender(existing, b, t0);
105
148
  existing.html = b.html;
149
+ existing.block = b;
106
150
  existing.speculative = b.speculative;
107
151
  existing.node.className = genericClassName(b);
108
152
  existing.generic = !sanitize;
@@ -112,9 +156,36 @@ function mountBrookMarkdown(client, container, options = {}) {
112
156
  existing.node.insertAdjacentHTML("beforeend", b.html.slice(existing.html.length));
113
157
  if (onRenderMetrics) noteRender(existing, b, t0);
114
158
  existing.html = b.html;
159
+ existing.block = b;
115
160
  continue;
116
161
  }
162
+ if (!fullRebuild && !sanitize && !hasInlineTransforms && existing.generic && b.open && existing.open && existing.kind === b.kind.type && usesGenericPath(b)) {
163
+ const keep = spliceKeep(existing.block, b);
164
+ if (keep !== void 0 && spliceHtml(existing.node, existing.html, b.html, keep)) {
165
+ if (onRenderMetrics) noteRender(existing, b, t0);
166
+ existing.html = b.html;
167
+ existing.block = b;
168
+ existing.speculative = b.speculative;
169
+ existing.node.className = genericClassName(b);
170
+ continue;
171
+ }
172
+ }
173
+ if (!fullRebuild && existing.plainCode && b.open && existing.open && b.kind.type === "CodeBlock" && existing.kind === "CodeBlock") {
174
+ const keep = spliceKeep(existing.block, b);
175
+ if (keep !== void 0 && spliceHtml(existing.plainCode, existing.html, b.html, keep)) {
176
+ if (onRenderMetrics) noteRender(existing, b, t0);
177
+ existing.html = b.html;
178
+ existing.block = b;
179
+ existing.speculative = b.speculative;
180
+ continue;
181
+ }
182
+ }
117
183
  existing.table = void 0;
184
+ existing.list = void 0;
185
+ existing.container = void 0;
186
+ existing.codeInc = void 0;
187
+ existing.plainCode = void 0;
188
+ if (existing.inc && b.kind.type !== "CodeBlock") existing.inc = void 0;
118
189
  if (existing.highlight) {
119
190
  existing.highlight.cancel();
120
191
  existing.highlight = void 0;
@@ -124,6 +195,7 @@ function mountBrookMarkdown(client, container, options = {}) {
124
195
  existing.node = node;
125
196
  if (onRenderMetrics) noteRender(existing, b, t0);
126
197
  existing.html = b.html;
198
+ existing.block = b;
127
199
  existing.open = b.open;
128
200
  existing.speculative = b.speculative;
129
201
  existing.kind = b.kind.type;
@@ -133,6 +205,9 @@ function mountBrookMarkdown(client, container, options = {}) {
133
205
  for (const [id, mb] of mounted) {
134
206
  if (!seen.has(id)) {
135
207
  if (mb.highlight) mb.highlight.cancel();
208
+ mb.inc = void 0;
209
+ mb.codeInc = void 0;
210
+ mb.plainCode = void 0;
136
211
  mb.node.remove();
137
212
  mounted.delete(id);
138
213
  }
@@ -202,13 +277,13 @@ function mountBrookMarkdown(client, container, options = {}) {
202
277
  if (data) return buildKeyedTable(b, data, mb);
203
278
  }
204
279
  if (b.open && kind === "List" && !hasInlineTransforms) {
205
- const keyed = renderKeyedList(b);
280
+ const keyed = renderKeyedList(b, mb);
206
281
  if (keyed) return keyed;
207
282
  }
208
283
  const node = document.createElement("div");
209
284
  node.className = genericClassName(b);
210
285
  if (b.open && !sanitize && !hasInlineTransforms && (kind === "Blockquote" || kind === "Alert")) {
211
- const wrapper = renderKeyedContainer(b);
286
+ const wrapper = renderKeyedContainer(b, mb);
212
287
  if (wrapper) {
213
288
  node.appendChild(wrapper);
214
289
  return node;
@@ -220,47 +295,122 @@ function mountBrookMarkdown(client, container, options = {}) {
220
295
  lastRenderGeneric = !sanitize && !hasInlineTransforms;
221
296
  return node;
222
297
  }
223
- function renderKeyedList(b) {
298
+ function renderKeyedList(b, mb) {
224
299
  const ld = b.kind.data;
225
300
  const items = ld?.items;
226
301
  if (!Array.isArray(items) || items.length === 0) return null;
227
302
  const node = document.createElement("div");
228
- node.className = "brook-block brook-block-list" + (b.open ? " brook-open" : "") + (b.speculative ? " brook-speculative" : "");
229
- const list = document.createElement(ld?.ordered ? "ol" : "ul");
230
- if (ld?.ordered && ld.start !== void 0 && ld.start !== 1) {
303
+ node.className = genericClassName(b);
304
+ const ordered = !!ld?.ordered;
305
+ const list = document.createElement(ordered ? "ol" : "ul");
306
+ if (ordered && ld.start !== void 0 && ld.start !== 1) {
231
307
  list.setAttribute("start", String(ld.start));
232
308
  }
233
- for (const it of items) {
309
+ const rendered = new Array(items.length);
310
+ for (let i = 0; i < items.length; i++) {
234
311
  const li = document.createElement("li");
235
- li.innerHTML = sanitize ? sanitize(it.html) : it.html;
312
+ li.innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
236
313
  list.appendChild(li);
314
+ rendered[i] = items[i].html;
237
315
  }
238
316
  node.appendChild(list);
317
+ mb.list = { list, ordered, start: ld?.start, items: rendered };
239
318
  return node;
240
319
  }
241
- function renderKeyedContainer(b) {
320
+ function syncKeyedList(km, ld) {
321
+ keyedAttempts++;
322
+ const items = ld.items;
323
+ if (!Array.isArray(items) || items.length === 0) return false;
324
+ if (!!ld.ordered !== km.ordered) return false;
325
+ const cur = km.items;
326
+ const kids = km.list.children;
327
+ if (kids.length !== cur.length) return false;
328
+ if (ld.start !== km.start) {
329
+ if (km.ordered && ld.start !== void 0 && ld.start !== 1) {
330
+ km.list.setAttribute("start", String(ld.start));
331
+ } else {
332
+ km.list.removeAttribute("start");
333
+ }
334
+ km.start = ld.start;
335
+ }
336
+ const n = items.length;
337
+ while (cur.length > n) {
338
+ km.list.removeChild(km.list.lastElementChild);
339
+ cur.pop();
340
+ }
341
+ for (let i = 0; i < cur.length; i++) {
342
+ if (cur[i] === items[i].html) continue;
343
+ kids[i].innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
344
+ cur[i] = items[i].html;
345
+ }
346
+ for (let i = cur.length; i < n; i++) {
347
+ const li = document.createElement("li");
348
+ li.innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
349
+ km.list.appendChild(li);
350
+ cur.push(items[i].html);
351
+ }
352
+ keyedHits++;
353
+ return true;
354
+ }
355
+ function renderKeyedContainer(b, mb) {
242
356
  const nested = b.kind.data?.nested;
243
357
  if (!Array.isArray(nested)) return null;
244
358
  const tagName = b.kind.type === "Alert" ? "div" : "blockquote";
245
359
  const wrapper = document.createElement(tagName);
246
360
  applyOpenTagAttrs(wrapper, b.html);
361
+ let offset = 0;
362
+ let title = "";
247
363
  if (b.kind.type === "Alert") {
248
- const title = alertTitleHtml(b.html);
364
+ title = alertTitleHtml(b.html);
249
365
  if (title) {
250
366
  const t = document.createElement("div");
251
367
  t.innerHTML = title;
252
368
  const titleNode = t.firstElementChild;
253
- if (titleNode) wrapper.appendChild(titleNode);
369
+ if (titleNode) {
370
+ wrapper.appendChild(titleNode);
371
+ offset = 1;
372
+ }
254
373
  }
255
374
  }
375
+ const rendered = new Array(nested.length);
256
376
  for (let i = 0; i < nested.length; i++) {
257
- const child = document.createElement("div");
258
- child.innerHTML = nested[i].html;
259
- const inner = child.firstElementChild;
260
- wrapper.appendChild(inner ?? child);
377
+ wrapper.appendChild(nestedChild(nested[i].html));
378
+ rendered[i] = nested[i].html;
261
379
  }
380
+ mb.container = { wrapper, offset, openTag: openTagOf(b.html), title, nested: rendered };
262
381
  return wrapper;
263
382
  }
383
+ function nestedChild(html) {
384
+ const child = document.createElement("div");
385
+ child.innerHTML = html;
386
+ return child.firstElementChild ?? child;
387
+ }
388
+ function syncKeyedContainer(kc, b) {
389
+ keyedAttempts++;
390
+ const nested = b.kind.data?.nested;
391
+ if (!Array.isArray(nested)) return false;
392
+ if (openTagOf(b.html) !== kc.openTag) return false;
393
+ if (b.kind.type === "Alert" && alertTitleHtml(b.html) !== kc.title) return false;
394
+ const cur = kc.nested;
395
+ const kids = kc.wrapper.children;
396
+ if (kids.length !== kc.offset + cur.length) return false;
397
+ const n = nested.length;
398
+ while (cur.length > n) {
399
+ kc.wrapper.removeChild(kc.wrapper.lastElementChild);
400
+ cur.pop();
401
+ }
402
+ for (let i = 0; i < cur.length; i++) {
403
+ if (cur[i] === nested[i].html) continue;
404
+ kc.wrapper.replaceChild(nestedChild(nested[i].html), kids[kc.offset + i]);
405
+ cur[i] = nested[i].html;
406
+ }
407
+ for (let i = cur.length; i < n; i++) {
408
+ kc.wrapper.appendChild(nestedChild(nested[i].html));
409
+ cur.push(nested[i].html);
410
+ }
411
+ keyedHits++;
412
+ return true;
413
+ }
264
414
  function buildKeyedTable(b, data, mb) {
265
415
  const node = document.createElement("div");
266
416
  node.className = "brook-block brook-block-table brook-open" + (b.speculative ? " brook-speculative" : "");
@@ -348,9 +498,22 @@ function mountBrookMarkdown(client, container, options = {}) {
348
498
  }
349
499
  function renderCodeBlock(b, mb) {
350
500
  const lang = extractLang(b.html) || "text";
501
+ mb.codeInc = void 0;
502
+ mb.plainCode = void 0;
351
503
  const text = b.open ? "" : codeText(b);
352
- const run = text ? highlightDeferred(text, lang) : null;
353
- const highlighted = run ? run.html : null;
504
+ let openMarkup = null;
505
+ if (b.open && streamingHighlight) {
506
+ let inc = mb.inc;
507
+ if (inc === void 0 || inc.lang !== lang.toLowerCase()) {
508
+ inc = createInc(lang) ?? void 0;
509
+ mb.inc = inc;
510
+ }
511
+ if (inc !== void 0) openMarkup = incHighlight(inc, codeText(b));
512
+ }
513
+ const seed = !b.open && mb.inc !== void 0 ? incSeed(mb.inc, text, lang) : void 0;
514
+ const run = text ? highlightDeferred(text, lang, seed) : null;
515
+ if (!b.open) mb.inc = void 0;
516
+ const highlighted = openMarkup ?? (run ? run.html : null);
354
517
  const block = document.createElement("div");
355
518
  block.className = "brook-code-block" + (b.open ? " brook-streaming" : "");
356
519
  const header = document.createElement("div");
@@ -371,7 +534,9 @@ function mountBrookMarkdown(client, container, options = {}) {
371
534
  const body = document.createElement("div");
372
535
  body.className = "brook-code-body";
373
536
  if (highlighted !== null) {
374
- body.appendChild(highlightedPre(lang, highlighted));
537
+ body.appendChild(
538
+ openMarkup !== null && mb.inc !== void 0 && !fullRebuild ? incPre(lang, mb, mb.inc, openMarkup) : highlightedPre(lang, highlighted)
539
+ );
375
540
  } else {
376
541
  const div = document.createElement("div");
377
542
  div.tabIndex = 0;
@@ -379,12 +544,14 @@ function mountBrookMarkdown(client, container, options = {}) {
379
544
  div.setAttribute("aria-label", `${lang} code`);
380
545
  div.innerHTML = b.html;
381
546
  body.appendChild(div);
547
+ if (b.open) mb.plainCode = div;
382
548
  if (run !== null && run.rest !== null) {
383
549
  mb.highlight = run;
384
550
  run.rest.then((markup) => {
385
551
  if (markup === null || dead) return;
386
552
  if (mb.highlight !== run || mb.node !== block) return;
387
553
  mb.highlight = void 0;
554
+ mb.plainCode = void 0;
388
555
  body.replaceChild(highlightedPre(lang, markup), div);
389
556
  });
390
557
  }
@@ -392,6 +559,25 @@ function mountBrookMarkdown(client, container, options = {}) {
392
559
  block.appendChild(body);
393
560
  return block;
394
561
  }
562
+ function syncIncCode(mb, b) {
563
+ const ic = mb.codeInc;
564
+ if ((extractLang(b.html) || "text") !== ic.lang) return false;
565
+ const markup = incHighlight(mb.inc, codeText(b));
566
+ if (markup === null) return false;
567
+ return paintIncCode(ic, mb.inc, markup);
568
+ }
569
+ function incPre(lang, mb, st, markup) {
570
+ const pre = document.createElement("pre");
571
+ pre.tabIndex = 0;
572
+ pre.setAttribute("role", "region");
573
+ pre.setAttribute("aria-label", `${lang} code`);
574
+ const code = document.createElement("code");
575
+ const ic = newIncCode(code, lang, st);
576
+ if (paintIncCode(ic, st, markup)) mb.codeInc = ic;
577
+ else code.innerHTML = markup;
578
+ pre.appendChild(code);
579
+ return pre;
580
+ }
395
581
  function highlightedPre(lang, markup) {
396
582
  const pre = document.createElement("pre");
397
583
  pre.tabIndex = 0;
@@ -499,6 +685,9 @@ function mountBrookMarkdown(client, container, options = {}) {
499
685
  mb.highlight.cancel();
500
686
  mb.highlight = void 0;
501
687
  }
688
+ mb.inc = void 0;
689
+ mb.codeInc = void 0;
690
+ mb.plainCode = void 0;
502
691
  }
503
692
  root.remove();
504
693
  },
@@ -592,9 +781,12 @@ function decodeCodeText(html) {
592
781
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
593
782
  }
594
783
  const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
595
- function applyOpenTagAttrs(el, html) {
784
+ function openTagOf(html) {
596
785
  const gt = html.indexOf(">");
597
- const open = gt < 0 ? html : html.slice(0, gt);
786
+ return gt < 0 ? html : html.slice(0, gt);
787
+ }
788
+ function applyOpenTagAttrs(el, html) {
789
+ const open = openTagOf(html);
598
790
  let m;
599
791
  CONTAINER_ATTR_RE.lastIndex = 0;
600
792
  while (m = CONTAINER_ATTR_RE.exec(open)) {
@@ -653,6 +845,8 @@ function applyUrlTransformDom(root, urlTransform) {
653
845
  });
654
846
  }
655
847
  export {
848
+ __keyedStats,
849
+ __resetKeyedStats,
656
850
  mountBrookMarkdown,
657
851
  tailOpenBlockId
658
852
  };
@@ -1,3 +1,4 @@
1
+ import { type HighlightState } from "./hi.js";
1
2
  /**
2
3
  * Test-only: shrink the per-slice budget so a suite can force the deferred path
3
4
  * deterministically instead of betting on how fast the machine is. Call with no
@@ -27,9 +28,9 @@ export interface DeferredHighlight {
27
28
  * renderer can call it from a render pass and only reach for
28
29
  * {@link highlightDeferred} when this comes back empty.
29
30
  */
30
- export declare function highlightWithin(code: string, lang: string): string | null;
31
+ export declare function highlightWithin(code: string, lang: string, seed?: HighlightState): string | null;
31
32
  /**
32
33
  * Highlight `code` without blocking: the first slice runs here, synchronously,
33
34
  * and the rest (if any) continues on later tasks. See {@link DeferredHighlight}.
34
35
  */
35
- export declare function highlightDeferred(code: string, lang: string): DeferredHighlight;
36
+ export declare function highlightDeferred(code: string, lang: string, seed?: HighlightState): DeferredHighlight;