brookmd 0.27.0 → 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,63 @@ 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
+
7
64
  ## 0.27.0 — 2026-07-31
8
65
 
9
66
  ### Added
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
@@ -129,7 +129,24 @@ export interface MountOptions {
129
129
  * `client.getMetrics().rebuildCount`.
130
130
  */
131
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;
132
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;
133
150
  export declare function mountBrookMarkdown(client: BrookClient, container: HTMLElement, options?: MountOptions): MountHandle;
134
151
  /**
135
152
  * Derive the streaming tail's block id from an ordered snapshot: the id of the
package/dist/dom.js CHANGED
@@ -1,9 +1,19 @@
1
1
  import { highlightDeferred } from "./hi-defer.js";
2
2
  import { createInc, incHighlight, incSeed } from "./hi-inc.js";
3
3
  import { morph } from "./morph.js";
4
+ import { newIncCode, paintIncCode, spliceHtml, spliceKeep } from "./splice.js";
4
5
  import { blockProps, extractLang } from "./block-props.js";
5
6
  import { decorateSegments } from "./decorate.js";
6
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
+ }
7
17
  const INTRINSIC_PX = {
8
18
  Paragraph: 80,
9
19
  Heading: 44,
@@ -32,6 +42,7 @@ function mountBrookMarkdown(client, container, options = {}) {
32
42
  const streamingHighlight = options.streamingHighlight !== false;
33
43
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
34
44
  const morphOpenBlocks = options.morphOpenBlocks === true;
45
+ const fullRebuild = options.__fullRebuild === true;
35
46
  const root = document.createElement("div");
36
47
  root.className = options.className ? `brook-md ${options.className}` : "brook-md";
37
48
  if (options.id) root.id = options.id;
@@ -70,6 +81,7 @@ function mountBrookMarkdown(client, container, options = {}) {
70
81
  id: b.id,
71
82
  node: void 0,
72
83
  html: b.html,
84
+ block: b,
73
85
  open: b.open,
74
86
  speculative: b.speculative,
75
87
  kind: b.kind.type,
@@ -87,6 +99,13 @@ function mountBrookMarkdown(client, container, options = {}) {
87
99
  continue;
88
100
  }
89
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
+ }
90
109
  if (existing.table && b.open && b.kind.type === "Table") {
91
110
  const data = tableData(b);
92
111
  if (data) {
@@ -96,15 +115,38 @@ function mountBrookMarkdown(client, container, options = {}) {
96
115
  existing.node.classList.toggle("brook-speculative", b.speculative);
97
116
  }
98
117
  existing.html = b.html;
118
+ existing.block = b;
99
119
  existing.open = b.open;
100
120
  existing.speculative = b.speculative;
101
121
  continue;
102
122
  }
103
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;
131
+ existing.open = b.open;
132
+ existing.speculative = b.speculative;
133
+ continue;
134
+ }
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
+ }
104
145
  if (morphOpenBlocks && b.open && existing.open && existing.generic && existing.kind === b.kind.type && usesGenericPath(b)) {
105
146
  morph(existing.node, sanitize ? sanitize(b.html) : b.html);
106
147
  if (onRenderMetrics) noteRender(existing, b, t0);
107
148
  existing.html = b.html;
149
+ existing.block = b;
108
150
  existing.speculative = b.speculative;
109
151
  existing.node.className = genericClassName(b);
110
152
  existing.generic = !sanitize;
@@ -114,9 +156,35 @@ function mountBrookMarkdown(client, container, options = {}) {
114
156
  existing.node.insertAdjacentHTML("beforeend", b.html.slice(existing.html.length));
115
157
  if (onRenderMetrics) noteRender(existing, b, t0);
116
158
  existing.html = b.html;
159
+ existing.block = b;
117
160
  continue;
118
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
+ }
119
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;
120
188
  if (existing.inc && b.kind.type !== "CodeBlock") existing.inc = void 0;
121
189
  if (existing.highlight) {
122
190
  existing.highlight.cancel();
@@ -127,6 +195,7 @@ function mountBrookMarkdown(client, container, options = {}) {
127
195
  existing.node = node;
128
196
  if (onRenderMetrics) noteRender(existing, b, t0);
129
197
  existing.html = b.html;
198
+ existing.block = b;
130
199
  existing.open = b.open;
131
200
  existing.speculative = b.speculative;
132
201
  existing.kind = b.kind.type;
@@ -137,6 +206,8 @@ function mountBrookMarkdown(client, container, options = {}) {
137
206
  if (!seen.has(id)) {
138
207
  if (mb.highlight) mb.highlight.cancel();
139
208
  mb.inc = void 0;
209
+ mb.codeInc = void 0;
210
+ mb.plainCode = void 0;
140
211
  mb.node.remove();
141
212
  mounted.delete(id);
142
213
  }
@@ -206,13 +277,13 @@ function mountBrookMarkdown(client, container, options = {}) {
206
277
  if (data) return buildKeyedTable(b, data, mb);
207
278
  }
208
279
  if (b.open && kind === "List" && !hasInlineTransforms) {
209
- const keyed = renderKeyedList(b);
280
+ const keyed = renderKeyedList(b, mb);
210
281
  if (keyed) return keyed;
211
282
  }
212
283
  const node = document.createElement("div");
213
284
  node.className = genericClassName(b);
214
285
  if (b.open && !sanitize && !hasInlineTransforms && (kind === "Blockquote" || kind === "Alert")) {
215
- const wrapper = renderKeyedContainer(b);
286
+ const wrapper = renderKeyedContainer(b, mb);
216
287
  if (wrapper) {
217
288
  node.appendChild(wrapper);
218
289
  return node;
@@ -224,47 +295,122 @@ function mountBrookMarkdown(client, container, options = {}) {
224
295
  lastRenderGeneric = !sanitize && !hasInlineTransforms;
225
296
  return node;
226
297
  }
227
- function renderKeyedList(b) {
298
+ function renderKeyedList(b, mb) {
228
299
  const ld = b.kind.data;
229
300
  const items = ld?.items;
230
301
  if (!Array.isArray(items) || items.length === 0) return null;
231
302
  const node = document.createElement("div");
232
- node.className = "brook-block brook-block-list" + (b.open ? " brook-open" : "") + (b.speculative ? " brook-speculative" : "");
233
- const list = document.createElement(ld?.ordered ? "ol" : "ul");
234
- 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) {
235
307
  list.setAttribute("start", String(ld.start));
236
308
  }
237
- for (const it of items) {
309
+ const rendered = new Array(items.length);
310
+ for (let i = 0; i < items.length; i++) {
238
311
  const li = document.createElement("li");
239
- li.innerHTML = sanitize ? sanitize(it.html) : it.html;
312
+ li.innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
240
313
  list.appendChild(li);
314
+ rendered[i] = items[i].html;
241
315
  }
242
316
  node.appendChild(list);
317
+ mb.list = { list, ordered, start: ld?.start, items: rendered };
243
318
  return node;
244
319
  }
245
- 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) {
246
356
  const nested = b.kind.data?.nested;
247
357
  if (!Array.isArray(nested)) return null;
248
358
  const tagName = b.kind.type === "Alert" ? "div" : "blockquote";
249
359
  const wrapper = document.createElement(tagName);
250
360
  applyOpenTagAttrs(wrapper, b.html);
361
+ let offset = 0;
362
+ let title = "";
251
363
  if (b.kind.type === "Alert") {
252
- const title = alertTitleHtml(b.html);
364
+ title = alertTitleHtml(b.html);
253
365
  if (title) {
254
366
  const t = document.createElement("div");
255
367
  t.innerHTML = title;
256
368
  const titleNode = t.firstElementChild;
257
- if (titleNode) wrapper.appendChild(titleNode);
369
+ if (titleNode) {
370
+ wrapper.appendChild(titleNode);
371
+ offset = 1;
372
+ }
258
373
  }
259
374
  }
375
+ const rendered = new Array(nested.length);
260
376
  for (let i = 0; i < nested.length; i++) {
261
- const child = document.createElement("div");
262
- child.innerHTML = nested[i].html;
263
- const inner = child.firstElementChild;
264
- wrapper.appendChild(inner ?? child);
377
+ wrapper.appendChild(nestedChild(nested[i].html));
378
+ rendered[i] = nested[i].html;
265
379
  }
380
+ mb.container = { wrapper, offset, openTag: openTagOf(b.html), title, nested: rendered };
266
381
  return wrapper;
267
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
+ }
268
414
  function buildKeyedTable(b, data, mb) {
269
415
  const node = document.createElement("div");
270
416
  node.className = "brook-block brook-block-table brook-open" + (b.speculative ? " brook-speculative" : "");
@@ -352,6 +498,8 @@ function mountBrookMarkdown(client, container, options = {}) {
352
498
  }
353
499
  function renderCodeBlock(b, mb) {
354
500
  const lang = extractLang(b.html) || "text";
501
+ mb.codeInc = void 0;
502
+ mb.plainCode = void 0;
355
503
  const text = b.open ? "" : codeText(b);
356
504
  let openMarkup = null;
357
505
  if (b.open && streamingHighlight) {
@@ -386,7 +534,9 @@ function mountBrookMarkdown(client, container, options = {}) {
386
534
  const body = document.createElement("div");
387
535
  body.className = "brook-code-body";
388
536
  if (highlighted !== null) {
389
- 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
+ );
390
540
  } else {
391
541
  const div = document.createElement("div");
392
542
  div.tabIndex = 0;
@@ -394,12 +544,14 @@ function mountBrookMarkdown(client, container, options = {}) {
394
544
  div.setAttribute("aria-label", `${lang} code`);
395
545
  div.innerHTML = b.html;
396
546
  body.appendChild(div);
547
+ if (b.open) mb.plainCode = div;
397
548
  if (run !== null && run.rest !== null) {
398
549
  mb.highlight = run;
399
550
  run.rest.then((markup) => {
400
551
  if (markup === null || dead) return;
401
552
  if (mb.highlight !== run || mb.node !== block) return;
402
553
  mb.highlight = void 0;
554
+ mb.plainCode = void 0;
403
555
  body.replaceChild(highlightedPre(lang, markup), div);
404
556
  });
405
557
  }
@@ -407,6 +559,25 @@ function mountBrookMarkdown(client, container, options = {}) {
407
559
  block.appendChild(body);
408
560
  return block;
409
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
+ }
410
581
  function highlightedPre(lang, markup) {
411
582
  const pre = document.createElement("pre");
412
583
  pre.tabIndex = 0;
@@ -515,6 +686,8 @@ function mountBrookMarkdown(client, container, options = {}) {
515
686
  mb.highlight = void 0;
516
687
  }
517
688
  mb.inc = void 0;
689
+ mb.codeInc = void 0;
690
+ mb.plainCode = void 0;
518
691
  }
519
692
  root.remove();
520
693
  },
@@ -608,9 +781,12 @@ function decodeCodeText(html) {
608
781
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
609
782
  }
610
783
  const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
611
- function applyOpenTagAttrs(el, html) {
784
+ function openTagOf(html) {
612
785
  const gt = html.indexOf(">");
613
- 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);
614
790
  let m;
615
791
  CONTAINER_ATTR_RE.lastIndex = 0;
616
792
  while (m = CONTAINER_ATTR_RE.exec(open)) {
@@ -669,6 +845,8 @@ function applyUrlTransformDom(root, urlTransform) {
669
845
  });
670
846
  }
671
847
  export {
848
+ __keyedStats,
849
+ __resetKeyedStats,
672
850
  mountBrookMarkdown,
673
851
  tailOpenBlockId
674
852
  };
package/dist/hi-inc.d.ts CHANGED
@@ -53,6 +53,31 @@ export interface IncState {
53
53
  c: number;
54
54
  /** Markup for `[0, c)`. Always a byte-prefix of the block's final markup. */
55
55
  frozenHtml: string;
56
+ /**
57
+ * Bumped whenever `frozenHtml` is TRUNCATED or cleared — the one checkpoint of
58
+ * rewind {@link adopt} performs, or a restart. It never moves while the prefix
59
+ * merely GROWS.
60
+ *
61
+ * A renderer that mirrors the frozen prefix into the DOM append-only reads it
62
+ * as the "may I splice?" token: same rev ⇒ the prefix only grew, so appending
63
+ * `frozenHtml.slice(alreadyWritten)` is exact; a changed rev means the prefix
64
+ * was rewritten underneath and the mirror must be re-seeded. Length alone is
65
+ * NOT enough — one call can rewind to `c0` and then re-freeze past the old
66
+ * length, which looks like a plain append but is not one.
67
+ */
68
+ frozenRev: number;
69
+ /**
70
+ * The length `frozenHtml` was TRUNCATED to at the last {@link frozenRev} bump:
71
+ * `frozenLen0` for {@link adopt}'s one-checkpoint rewind, `0` for a restart.
72
+ *
73
+ * The rev alone says "rewritten"; this says *from where*, which is what lets a
74
+ * DOM mirror rewind to a boundary it already holds instead of re-seeding the
75
+ * whole run. It is load-bearing that this is reported rather than inferred:
76
+ * `adopt` frequently rewinds and then re-freezes PAST the old length within
77
+ * the same call, so the observable `frozenHtml.length` can come back unchanged
78
+ * while its bytes have moved.
79
+ */
80
+ frozenCut: number;
56
81
  /** The checkpoint BEFORE `c`, and the `frozenHtml` length that went with it —
57
82
  * the one step of rewind a tail revision needs (see {@link adopt}). */
58
83
  c0: number;
package/dist/hi-inc.js CHANGED
@@ -72,6 +72,8 @@ function createInc(lang) {
72
72
  lang: key,
73
73
  c: 0,
74
74
  frozenHtml: "",
75
+ frozenRev: 0,
76
+ frozenCut: 0,
75
77
  c0: 0,
76
78
  frozenLen0: 0,
77
79
  opener: null,
@@ -97,6 +99,8 @@ function adopt(st, d) {
97
99
  }
98
100
  if (st.c0 > 0 && d >= st.c0 + GAP) {
99
101
  st.frozenHtml = st.frozenHtml.slice(0, st.frozenLen0);
102
+ st.frozenRev++;
103
+ st.frozenCut = st.frozenLen0;
100
104
  st.c = st.c0;
101
105
  st.c0 = 0;
102
106
  st.frozenLen0 = 0;
@@ -117,6 +121,8 @@ function dropTail(st) {
117
121
  function reset(st) {
118
122
  st.c = 0;
119
123
  st.frozenHtml = "";
124
+ st.frozenRev++;
125
+ st.frozenCut = 0;
120
126
  st.c0 = 0;
121
127
  st.frozenLen0 = 0;
122
128
  st.opener = null;
@@ -19,6 +19,8 @@ type HNode = {
19
19
  */
20
20
  export declare function parseStyle(css: string): Record<string, string>;
21
21
  export declare function getParseCount(): number;
22
+ /** @internal Test-only. Characters of markup handed to the tokenizer so far. */
23
+ export declare function getParseChars(): number;
22
24
  export declare function resetParseCount(): void;
23
25
  export declare function parseTrustedHtml(html: string): HNode[];
24
26
  /**
@@ -131,14 +131,20 @@ function parseOpenTag(html, start) {
131
131
  return { tag, attrs, selfClose: false, next: i };
132
132
  }
133
133
  let parseCount = 0;
134
+ let parseChars = 0;
134
135
  function getParseCount() {
135
136
  return parseCount;
136
137
  }
138
+ function getParseChars() {
139
+ return parseChars;
140
+ }
137
141
  function resetParseCount() {
138
142
  parseCount = 0;
143
+ parseChars = 0;
139
144
  }
140
145
  function parseTrustedHtml(html) {
141
146
  parseCount++;
147
+ parseChars += html.length;
142
148
  const root = [];
143
149
  const stack = [];
144
150
  let i = 0;
@@ -357,6 +363,7 @@ function wrapLink(text, attrs) {
357
363
  }
358
364
  export {
359
365
  decodeEntities,
366
+ getParseChars,
360
367
  getParseCount,
361
368
  htmlToReact,
362
369
  parseStyle,
@@ -0,0 +1,32 @@
1
+ import { type MutableRefObject } from "react";
2
+ import type { Block } from "./types-core.js";
3
+ /**
4
+ * Let a layout effect own an element's children so an OPEN block's patch is
5
+ * applied incrementally (see splice.ts) instead of re-setting the whole
6
+ * `dangerouslySetInnerHTML` every time it grows.
7
+ *
8
+ * ## How React and the effect share the node
9
+ *
10
+ * The returned string is the html captured on the FIRST render and never
11
+ * changes again. Render it as the node's `__html` and React writes the element
12
+ * exactly once, at mount — its own `lastHtml !== nextHtml` check then keeps it
13
+ * from touching the children on any later commit, and the effect owns them from
14
+ * there.
15
+ *
16
+ * That is what makes this safe under concurrent rendering: a render that is
17
+ * thrown away commits nothing and runs no effect, and the effect's own
18
+ * bookkeeping makes a repeat run (StrictMode's double-invoke) a no-op. It also
19
+ * leaves SSR and hydration byte-identical, because the first markup React
20
+ * produces is still the block's full html.
21
+ *
22
+ * The caller hands the node BACK to React by rendering a different element
23
+ * (a closed block's plain `<div dangerouslySetInnerHTML>`), which remounts the
24
+ * subtree and re-renders the settled html in one pass.
25
+ *
26
+ * @param hostRef ref attached to the element whose children are managed
27
+ * @param block the block's CURRENT version (identity matters — `spliceKeep`
28
+ * is keyed on it)
29
+ * @param enabled false to stay entirely out of the way
30
+ * @returns the html to render as `__html`, or `null` when not managing
31
+ */
32
+ export declare function useHtmlSplice(hostRef: MutableRefObject<HTMLElement | null>, block: Block | undefined, enabled: boolean): string | null;
@@ -0,0 +1,33 @@
1
+ import { useEffect, useLayoutEffect, useRef } from "react";
2
+ import { spliceHtml, spliceKeep } from "./splice.js";
3
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
4
+ function useHtmlSplice(hostRef, block, enabled) {
5
+ const seed = useRef(block);
6
+ const applied = useRef(null);
7
+ useIsoLayoutEffect(() => {
8
+ const base = seed.current;
9
+ if (!enabled || block === void 0 || base === void 0) {
10
+ applied.current = null;
11
+ return;
12
+ }
13
+ const node = hostRef.current;
14
+ if (node === null) return;
15
+ let prev = applied.current;
16
+ if (prev === null || prev.node !== node) prev = { node, block: base };
17
+ if (prev.block === block) {
18
+ applied.current = prev;
19
+ return;
20
+ }
21
+ const keep = spliceKeep(prev.block, block);
22
+ if (keep !== void 0 && spliceHtml(node, prev.block.html, block.html, keep)) {
23
+ applied.current = { node, block };
24
+ return;
25
+ }
26
+ node.innerHTML = block.html;
27
+ applied.current = { node, block };
28
+ });
29
+ return enabled && seed.current !== void 0 ? seed.current.html : null;
30
+ }
31
+ export {
32
+ useHtmlSplice
33
+ };
package/dist/react.d.ts CHANGED
@@ -144,6 +144,15 @@ interface BrookMarkdownProps {
144
144
  * the block — an override bypasses the built-in highlighter entirely.
145
145
  */
146
146
  streamingHighlight?: boolean;
147
+ /**
148
+ * @internal TEST-ONLY. Turn off the incremental apply paths (the open code
149
+ * block's frozen/tail mirror and the open generic block's delta splice) so
150
+ * every patch re-renders the whole block through React, exactly as it did
151
+ * before they existed. The DOM-parity fuzz renders one tree with it on and one
152
+ * with it off and asserts their markup matches after every commit. Not part of
153
+ * the supported API.
154
+ */
155
+ __fullRebuild?: boolean;
147
156
  /** Appended to the root's `className` (the `brook-md` class is always present). */
148
157
  className?: string;
149
158
  /** Set on the root element. */
@@ -281,6 +290,8 @@ interface BlockViewProps {
281
290
  sanitize?: (html: string) => string;
282
291
  childMemo?: boolean;
283
292
  streamingHighlight?: boolean;
293
+ /** @internal TEST-ONLY — see BrookMarkdownProps.__fullRebuild. */
294
+ __fullRebuild?: boolean;
284
295
  onRenderMetrics?: RenderMetricsHook;
285
296
  decorators?: Decorator[];
286
297
  urlTransform?: UrlTransform;
package/dist/react.js CHANGED
@@ -15,6 +15,7 @@ import { CodeBlock } from "./renderers/CodeBlock.js";
15
15
  import { MathBlock } from "./renderers/Math.js";
16
16
  import { Mermaid } from "./renderers/Mermaid.js";
17
17
  import { htmlToReact } from "./html-to-react.js";
18
+ import { useHtmlSplice } from "./react-splice.js";
18
19
  import { warnOnce } from "./warn.js";
19
20
  const NO_DEFER_BLOCKS = [];
20
21
  const EMPTY_KEYS = [];
@@ -53,6 +54,7 @@ function BrookMarkdownFromClient({
53
54
  sanitize,
54
55
  childMemo,
55
56
  streamingHighlight,
57
+ __fullRebuild,
56
58
  className,
57
59
  id,
58
60
  role,
@@ -106,6 +108,7 @@ function BrookMarkdownFromClient({
106
108
  sanitize,
107
109
  childMemo,
108
110
  streamingHighlight,
111
+ __fullRebuild,
109
112
  onRenderMetrics: onMetrics,
110
113
  decorators,
111
114
  urlTransform,
@@ -314,6 +317,18 @@ function SafeHtml({
314
317
  return htmlToReact(html, components, map, opts);
315
318
  }, [html, components, childMemo, decorators, urlTransform]);
316
319
  }
320
+ function SplicedBlock({ className, block }) {
321
+ const host = useRef(null);
322
+ const seedHtml = useHtmlSplice(host, block, true);
323
+ return /* @__PURE__ */ jsx(
324
+ "div",
325
+ {
326
+ className,
327
+ ref: host,
328
+ dangerouslySetInnerHTML: { __html: seedHtml ?? block.html }
329
+ }
330
+ );
331
+ }
317
332
  function KeyedListItemImpl({
318
333
  html,
319
334
  components,
@@ -489,6 +504,7 @@ function renderBlockContent({
489
504
  sanitize,
490
505
  childMemo,
491
506
  streamingHighlight,
507
+ __fullRebuild,
492
508
  decorators,
493
509
  urlTransform
494
510
  }) {
@@ -519,7 +535,9 @@ function renderBlockContent({
519
535
  html: block.html,
520
536
  open: block.open,
521
537
  code: typeof source === "string" ? source : void 0,
522
- streamingHighlight
538
+ streamingHighlight,
539
+ block,
540
+ __fullRebuild
523
541
  }
524
542
  );
525
543
  }
@@ -555,13 +573,13 @@ function renderBlockContent({
555
573
  );
556
574
  }
557
575
  }
558
- if (components || hasInlineTransforms) {
559
- if (components && !hasInlineTransforms && block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
560
- const nested = block.kind.data?.nested;
561
- if (Array.isArray(nested)) {
562
- return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components }) });
563
- }
576
+ if (block.open && !sanitize && !hasInlineTransforms && (kind === "Blockquote" || kind === "Alert")) {
577
+ const nested = block.kind.data?.nested;
578
+ if (Array.isArray(nested)) {
579
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components: components ?? NO_COMPONENTS }) });
564
580
  }
581
+ }
582
+ if (components || hasInlineTransforms) {
565
583
  const safe = sanitize ? sanitize(block.html) : block.html;
566
584
  return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
567
585
  SafeHtml,
@@ -574,6 +592,9 @@ function renderBlockContent({
574
592
  }
575
593
  ) });
576
594
  }
595
+ if (block.open && !sanitize && !__fullRebuild) {
596
+ return /* @__PURE__ */ jsx(SplicedBlock, { className, block });
597
+ }
577
598
  return /* @__PURE__ */ jsx(
578
599
  "div",
579
600
  {
@@ -584,7 +605,7 @@ function renderBlockContent({
584
605
  }
585
606
  function blocksEqual(prev, next) {
586
607
  if (prev.block == null || next.block == null) return prev.block === next.block;
587
- return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.streamingHighlight === next.streamingHighlight && prev.onRenderMetrics === next.onRenderMetrics && // Identity compare: an unstable decorators/urlTransform (fresh each render)
608
+ return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.streamingHighlight === next.streamingHighlight && prev.__fullRebuild === next.__fullRebuild && prev.onRenderMetrics === next.onRenderMetrics && // Identity compare: an unstable decorators/urlTransform (fresh each render)
588
609
  // busts the memo so every committed block re-decorates — the O(n²) footgun
589
610
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
590
611
  prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
@@ -1,3 +1,4 @@
1
+ import type { Block } from "../types-core.js";
1
2
  interface Props {
2
3
  html: string;
3
4
  open: boolean;
@@ -10,7 +11,18 @@ interface Props {
10
11
  code?: string;
11
12
  /** Highlight the block while it is still open. Default true. */
12
13
  streamingHighlight?: boolean;
14
+ /**
15
+ * The block this markup came from, when the renderer is driven by the stream.
16
+ * Only used to apply the wire's `html_delta` to the PLAIN escaped body of an
17
+ * open fence (the `streamingHighlight: false` / no-language path) instead of
18
+ * re-setting its whole innerHTML each patch. Absent → that body rebuilds, as
19
+ * it always did.
20
+ */
21
+ block?: Block;
22
+ /** @internal TEST-ONLY: force the pre-mirror path (a full `innerHTML` set of
23
+ * the whole markup on every patch) so the parity fuzz has a reference. */
24
+ __fullRebuild?: boolean;
13
25
  }
14
- declare function CodeBlockImpl({ html, open, code, streamingHighlight }: Props): import("react/jsx-runtime").JSX.Element;
26
+ declare function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }: Props): import("react/jsx-runtime").JSX.Element;
15
27
  export declare const CodeBlock: import("react").MemoExoticComponent<typeof CodeBlockImpl>;
16
28
  export {};
@@ -1,15 +1,18 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { highlight } from "../hi.js";
4
4
  import { highlightDeferred, highlightWithin } from "../hi-defer.js";
5
5
  import { createInc, incHighlight, incSeed } from "../hi-inc.js";
6
+ import { newIncCode, paintIncCode } from "../splice.js";
7
+ import { useHtmlSplice } from "../react-splice.js";
6
8
  import { extractLang } from "../block-props.js";
7
9
  function decodeText(html) {
8
10
  const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
9
11
  if (!m) return "";
10
12
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
11
13
  }
12
- function CodeBlockImpl({ html, open, code, streamingHighlight }) {
14
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
15
+ function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }) {
13
16
  const lang = extractLang(html) || "text";
14
17
  const text = useMemo(() => open ? "" : code ?? decodeText(html), [html, open, code]);
15
18
  const streaming = open && streamingHighlight !== false;
@@ -19,6 +22,9 @@ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
19
22
  );
20
23
  const incRef = useRef(null);
21
24
  const [inc, setInc] = useState(null);
25
+ const codeRef = useRef(null);
26
+ const mirrorRef = useRef(null);
27
+ const plainRef = useRef(null);
22
28
  const sync = useMemo(() => {
23
29
  if (!text) return null;
24
30
  if (typeof window === "undefined") return highlight(text, lang);
@@ -62,10 +68,30 @@ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
62
68
  run.cancel();
63
69
  };
64
70
  }, [text, lang, sync]);
65
- const highlighted = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null) ?? // The streaming tail. Not gated on `openText` identity: the markup lags the
66
- // props by one commit, and showing last patch's spans beats flashing the
67
- // whole block back to plain every tick. A language change does invalidate it.
68
- (streaming && inc !== null && inc.lang === lang ? inc.html : null);
71
+ const settled = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null);
72
+ const streamed = streaming && inc !== null && inc.lang === lang ? inc.html : null;
73
+ const highlighted = settled ?? streamed;
74
+ const mirrored = settled === null && streamed !== null && !__fullRebuild;
75
+ useIsoLayoutEffect(() => {
76
+ if (!mirrored) {
77
+ mirrorRef.current = null;
78
+ return;
79
+ }
80
+ const node = codeRef.current;
81
+ const st = incRef.current;
82
+ if (node === null || st === null || streamed === null) return;
83
+ let m = mirrorRef.current;
84
+ if (m === null || m.code !== node || m.lang !== lang) {
85
+ node.innerHTML = "";
86
+ m = newIncCode(node, lang, st);
87
+ mirrorRef.current = m;
88
+ }
89
+ if (!paintIncCode(m, st, streamed)) {
90
+ node.innerHTML = streamed;
91
+ mirrorRef.current = null;
92
+ }
93
+ });
94
+ const plainSeed = useHtmlSplice(plainRef, block, open && highlighted === null && !__fullRebuild);
69
95
  const [copied, setCopied] = useState(false);
70
96
  const timerRef = useRef(null);
71
97
  useEffect(() => {
@@ -117,8 +143,25 @@ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
117
143
  /* @__PURE__ */ jsx("div", { className: "brook-code-body", children: highlighted ? (
118
144
  // tabIndex=0 + role/label so keyboard users can scroll long code and
119
145
  // screen readers announce the region with its language.
120
- /* @__PURE__ */ jsx("pre", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, children: /* @__PURE__ */ jsx("code", { dangerouslySetInnerHTML: { __html: highlighted } }) })
121
- ) : /* @__PURE__ */ jsx("div", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, dangerouslySetInnerHTML: { __html: html } }) })
146
+ /* @__PURE__ */ jsx("pre", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, children: mirrored ? (
147
+ // Rendered with NO children and NO dangerouslySetInnerHTML, so
148
+ // React never writes into it; the layout effect above owns it.
149
+ // Same element type and position as the settled form below, so
150
+ // the close-time swap updates this node in place rather than
151
+ // remounting it — and React's own innerHTML write at that point
152
+ // is what discards the mirror's nodes.
153
+ /* @__PURE__ */ jsx("code", { ref: codeRef })
154
+ ) : /* @__PURE__ */ jsx("code", { dangerouslySetInnerHTML: { __html: highlighted } }) })
155
+ ) : /* @__PURE__ */ jsx(
156
+ "div",
157
+ {
158
+ tabIndex: 0,
159
+ role: "region",
160
+ "aria-label": `${lang} code`,
161
+ ref: plainRef,
162
+ dangerouslySetInnerHTML: { __html: plainSeed ?? html }
163
+ }
164
+ ) })
122
165
  ] });
123
166
  }
124
167
  const CodeBlock = memo(CodeBlockImpl);
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Incremental DOM application for a streaming block — the two shapes of
3
+ * "apply this patch without rewriting everything before it".
4
+ *
5
+ * 1. {@link paintIncCode} mirrors hi-inc's frozen-prefix / speculative-tail split
6
+ * into an open code fence's live `<code>`.
7
+ * 2. {@link spliceHtml} applies the wire's `html_delta` to a generic block's
8
+ * subtree, guided by {@link spliceKeep}.
9
+ *
10
+ * Both are shared by the DOM and React renderers so the invariants below have
11
+ * exactly one implementation.
12
+ *
13
+ * ## The generic splice
14
+ *
15
+ * A streaming block's html grows at its END: the core appends bytes and then
16
+ * SPECULATIVELY CLOSES whatever is open, so patch N's html is patch N+1's html
17
+ * with a different run of closing tags stitched on. The wire already computes
18
+ * and verifies that boundary (`html_delta.keep_units`, WIRE.md §11), and
19
+ * `applyPatch` publishes it to renderers as {@link spliceKeep}. What is left is
20
+ * to apply it to the DOM without re-parsing everything before it.
21
+ *
22
+ * ## Why "top-level children" is not enough
23
+ *
24
+ * A block's html is usually ONE top-level element — `<p>…</p>`, `<ul>…</ul>`,
25
+ * `<blockquote>…</blockquote>` — so splicing at that level degenerates to a full
26
+ * rebuild. The growth point is at the bottom of the chain of elements still OPEN
27
+ * at the splice offset, and that is where this splices: it walks down that chain
28
+ * in the live DOM, appends the new markup in the right context, and never
29
+ * touches a node before the boundary. Everything earlier — including a user's
30
+ * text selection and a `<pre>`'s scroll offset — survives untouched.
31
+ *
32
+ * ## The precondition, and why it is the honest one
33
+ *
34
+ * The old html's discarded suffix (`prevHtml.slice(keep)`) must be **pure
35
+ * structure**: closing tags and inter-tag whitespace, nothing that contributed
36
+ * real content. That is exactly the speculative-closure shape, and it is what
37
+ * makes "the DOM built from `prevHtml[0, keep)`" recoverable from the live tree
38
+ * by removing a bounded amount of trailing whitespace. Anything else — a link
39
+ * losing its `data-brook-pending` attribute, a literal `**b` becoming
40
+ * `<strong>b</strong>` — rewrites bytes the old DOM already committed to, and
41
+ * this bails so the caller rebuilds. Correctness never depends on the fast path
42
+ * firing; every check below returns `false` rather than guessing.
43
+ *
44
+ * The result is byte-identical to `host.innerHTML = nextHtml` when serialized.
45
+ * The node COUNT can differ (a splice may leave two adjacent text nodes where a
46
+ * one-shot parse makes one), which is what any streaming DOM append does and
47
+ * what `innerHTML` parity is checked against.
48
+ */
49
+ import type { IncState } from "./hi-inc.js";
50
+ import type { Block } from "./types-core.js";
51
+ /** @internal Called by `applyPatch` for every delta-reconstructed active block. */
52
+ export declare function noteSplice(next: Block, prev: Block, keep: number): void;
53
+ /**
54
+ * The longest common prefix, in UTF-16 units, that `from.html` and `to.html`
55
+ * provably share — or `undefined` when the wire did not establish one (delta
56
+ * mode off, a full re-emit, or `from` is further back than {@link SPLICE_DEPTH}).
57
+ *
58
+ * The value is the MINIMUM `keep_units` across the patches between them: each
59
+ * one guarantees its own prefix, so their minimum is a prefix of all of them.
60
+ * That is conservative — it can be shorter than the true common prefix — and
61
+ * never wrong, which is the right side to err on when a caller splices at it.
62
+ *
63
+ * @internal Renderer-only; not part of the public API.
64
+ */
65
+ export declare function spliceKeep(from: Block, to: Block): number | undefined;
66
+ /**
67
+ * The live `<code>` of an OPEN code block, split the way hi-inc splits its
68
+ * markup: a **frozen** run of children (proven immutable — appended once and
69
+ * never touched again) followed by a **speculative tail** (rewritten per patch,
70
+ * bounded by hi-inc's CAP).
71
+ *
72
+ * The two regions are NOT wrapped in elements — `frozenEnd` is simply the last
73
+ * child that belongs to the frozen run — so the resulting `innerHTML` is
74
+ * byte-identical to the `code.innerHTML = markup` this replaces. Only the node
75
+ * *count* differs (a splice can leave two adjacent text nodes where a one-shot
76
+ * parse would have made one), which serializes the same and is exactly what a
77
+ * browser does for any streamed append.
78
+ */
79
+ export interface IncCode {
80
+ code: Element;
81
+ /** The language the mirror was built for; a change invalidates it. */
82
+ lang: string;
83
+ /** Last child of the frozen run — everything after it is the tail. */
84
+ frozenEnd: ChildNode | null;
85
+ /** Chars of `IncState.frozenHtml` already mirrored into the DOM. */
86
+ frozenLen: number;
87
+ /** The `IncState.frozenRev` that `frozenLen` belongs to. */
88
+ frozenRev: number;
89
+ /** The boundary BEFORE `frozenEnd`, and the length that went with it — one
90
+ * step of history mirroring hi-inc's own `c0`/`frozenLen0`, which is exactly
91
+ * how far hi-inc's `adopt` can rewind. Without it a rewind would have to re-seed
92
+ * the whole run, and 18 of those over a 20 KB fence cost more than everything
93
+ * else on the streaming path combined. */
94
+ frozenEnd0: ChildNode | null;
95
+ frozenLen0: number;
96
+ /** The tail markup currently in the DOM, so an unchanged tail is not rewritten. */
97
+ tail: string;
98
+ }
99
+ /** A fresh, empty mirror for a `<code>` that has nothing painted into it yet. */
100
+ export declare function newIncCode(code: Element, lang: string, st: IncState): IncCode;
101
+ /**
102
+ * Mirror hi-inc's frozen/tail split into a live `<code>`: append whatever the
103
+ * frozen prefix settled since the last patch, then replace the speculative
104
+ * tail. Returns false when the mirror cannot be trusted (see the length
105
+ * invariant below) so the caller falls back to a full node rebuild.
106
+ *
107
+ * Cost per patch is |newly frozen| + |tail|. The frozen term sums, across the
108
+ * whole stream, to one pass over the final markup; the tail is bounded by
109
+ * hi-inc's CAP. That is what makes an open fence linear at the DOM, not just
110
+ * at the tokenizer.
111
+ */
112
+ export declare function paintIncCode(ic: IncCode, st: IncState, markup: string): boolean;
113
+ /**
114
+ * Apply `prevHtml → nextHtml` to `host`, whose `innerHTML` is exactly
115
+ * `prevHtml`, given the wire-verified common-prefix length `keep`. Returns
116
+ * `false` (having changed NOTHING) when the shape is not one it can prove; the
117
+ * caller then rebuilds as it always did.
118
+ */
119
+ export declare function spliceHtml(host: Element, prevHtml: string, nextHtml: string, keep: number): boolean;
120
+ /** @internal Test-only. */
121
+ export declare function __spliceStats(): {
122
+ attempts: number;
123
+ hits: number;
124
+ };
125
+ /** @internal Test-only. */
126
+ export declare function __resetSpliceStats(): void;
package/dist/splice.js ADDED
@@ -0,0 +1,212 @@
1
+ const SPLICE = /* @__PURE__ */ new WeakMap();
2
+ const SPLICE_DEPTH = 8;
3
+ function noteSplice(next, prev, keep) {
4
+ let old = prev;
5
+ for (let d = 1; d < SPLICE_DEPTH && old !== void 0; d++) old = SPLICE.get(old)?.prev;
6
+ if (old !== void 0) SPLICE.delete(old);
7
+ SPLICE.set(next, { prev, keep });
8
+ }
9
+ function spliceKeep(from, to) {
10
+ let keep = Infinity;
11
+ let cur = to;
12
+ for (let i = 0; i < SPLICE_DEPTH; i++) {
13
+ const link = SPLICE.get(cur);
14
+ if (link === void 0) return void 0;
15
+ if (link.keep < keep) keep = link.keep;
16
+ if (link.prev === from) return keep;
17
+ cur = link.prev;
18
+ }
19
+ return void 0;
20
+ }
21
+ function newIncCode(code, lang, st) {
22
+ return {
23
+ code,
24
+ lang,
25
+ frozenEnd: null,
26
+ frozenLen: 0,
27
+ frozenRev: st.frozenRev,
28
+ frozenEnd0: null,
29
+ frozenLen0: 0,
30
+ tail: ""
31
+ };
32
+ }
33
+ function paintIncCode(ic, st, markup) {
34
+ const frozen = st.frozenHtml;
35
+ if (markup.length < frozen.length) return false;
36
+ let rewound = ic.frozenRev !== st.frozenRev || frozen.length < ic.frozenLen;
37
+ const tail = markup.slice(frozen.length);
38
+ if (!rewound && frozen.length === ic.frozenLen && tail === ic.tail) return true;
39
+ if (rewound && st.frozenRev === ic.frozenRev + 1 && st.frozenCut === ic.frozenLen0) {
40
+ ic.frozenEnd = ic.frozenEnd0;
41
+ ic.frozenLen = ic.frozenLen0;
42
+ ic.frozenRev = st.frozenRev;
43
+ ic.frozenEnd0 = null;
44
+ ic.frozenLen0 = 0;
45
+ rewound = false;
46
+ }
47
+ const keep = rewound ? null : ic.frozenEnd;
48
+ while (ic.code.lastChild !== keep) ic.code.removeChild(ic.code.lastChild);
49
+ if (rewound) {
50
+ ic.frozenEnd = null;
51
+ ic.frozenLen = 0;
52
+ ic.frozenEnd0 = null;
53
+ ic.frozenLen0 = 0;
54
+ ic.frozenRev = st.frozenRev;
55
+ }
56
+ if (frozen.length > ic.frozenLen) {
57
+ ic.frozenEnd0 = ic.frozenEnd;
58
+ ic.frozenLen0 = ic.frozenLen;
59
+ ic.code.insertAdjacentHTML("beforeend", frozen.slice(ic.frozenLen));
60
+ ic.frozenEnd = ic.code.lastChild;
61
+ ic.frozenLen = frozen.length;
62
+ }
63
+ if (tail) ic.code.insertAdjacentHTML("beforeend", tail);
64
+ ic.tail = tail;
65
+ return true;
66
+ }
67
+ const UNSAFE_CHAIN = /* @__PURE__ */ new Set([
68
+ // Content models the fragment parser treats specially (raw text, escapable
69
+ // raw text, foreign content, or a separate document fragment).
70
+ "template",
71
+ "svg",
72
+ "math",
73
+ "script",
74
+ "style",
75
+ "textarea",
76
+ "title",
77
+ "noscript",
78
+ "noframes",
79
+ "iframe",
80
+ "xmp",
81
+ "plaintext",
82
+ "listing",
83
+ // Foster parenting relocates non-table content out of these, so a scaffold
84
+ // parse would not place the appended nodes where a whole parse does.
85
+ "table",
86
+ "thead",
87
+ "tbody",
88
+ "tfoot",
89
+ "tr",
90
+ "select",
91
+ "optgroup"
92
+ ]);
93
+ const UNSAFE_TIP = /* @__PURE__ */ new Set(["pre", "listing", "textarea"]);
94
+ const CLOSE_TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
95
+ function scanTail(t) {
96
+ const ops = [];
97
+ let i = 0;
98
+ let closes = 0;
99
+ while (i < t.length) {
100
+ if (t.charCodeAt(i) === 60) {
101
+ if (t.charCodeAt(i + 1) !== 47) return null;
102
+ const gt = t.indexOf(">", i + 2);
103
+ if (gt === -1) return null;
104
+ const name = t.slice(i + 2, gt);
105
+ if (!CLOSE_TAG_NAME.test(name)) return null;
106
+ ops.push({ close: name.toLowerCase() });
107
+ closes++;
108
+ i = gt + 1;
109
+ continue;
110
+ }
111
+ let j = i;
112
+ while (j < t.length && t.charCodeAt(j) !== 60) j++;
113
+ const run = t.slice(i, j);
114
+ if (/\S/.test(run)) return null;
115
+ ops.push({ ws: run });
116
+ i = j;
117
+ }
118
+ return closes > 0 ? ops : null;
119
+ }
120
+ function spliceHtml(host, prevHtml, nextHtml, keep) {
121
+ attempts++;
122
+ if (keep <= 0 || keep >= prevHtml.length || keep > nextHtml.length) return false;
123
+ const ops = scanTail(prevHtml.slice(keep));
124
+ if (ops === null) return false;
125
+ const closes = [];
126
+ for (const op of ops) if ("close" in op) closes.push(op.close);
127
+ const n = closes.length;
128
+ const chain = new Array(n + 1);
129
+ chain[0] = host;
130
+ for (let d2 = 1; d2 <= n; d2++) {
131
+ const want = closes[n - d2];
132
+ if (UNSAFE_CHAIN.has(want)) return false;
133
+ if (d2 === n && UNSAFE_TIP.has(want)) return false;
134
+ const el = chain[d2 - 1].lastElementChild;
135
+ if (el === null || el.tagName.toLowerCase() !== want) return false;
136
+ chain[d2] = el;
137
+ }
138
+ const strips = [];
139
+ const ws = new Array(n + 1);
140
+ let d = n;
141
+ for (const op of ops) {
142
+ if ("close" in op) {
143
+ d--;
144
+ continue;
145
+ }
146
+ if (ws[d] !== void 0) return false;
147
+ ws[d] = op.ws;
148
+ }
149
+ if (d !== 0) return false;
150
+ for (let i = 0; i <= n; i++) {
151
+ const w = ws[i];
152
+ const last = chain[i].lastChild;
153
+ if (w === void 0 || w === "") {
154
+ if (i < n && last !== chain[i + 1]) return false;
155
+ continue;
156
+ }
157
+ if (last === null || last.nodeType !== 3) return false;
158
+ const data = last.nodeValue ?? "";
159
+ if (i < n) {
160
+ if (data !== w || last.previousSibling !== chain[i + 1]) return false;
161
+ } else if (!data.endsWith(w)) {
162
+ return false;
163
+ }
164
+ strips.push({ text: last, ws: w });
165
+ }
166
+ let scaffold = "";
167
+ for (let i = 1; i <= n; i++) scaffold += `<${chain[i].tagName.toLowerCase()}>`;
168
+ const tmp = host.ownerDocument.createElement("div");
169
+ tmp.innerHTML = scaffold + nextHtml.slice(keep);
170
+ const sc = new Array(n + 1);
171
+ sc[0] = tmp;
172
+ for (let i = 1; i <= n; i++) {
173
+ const el = sc[i - 1].firstChild;
174
+ if (el === null || el.nodeType !== 1) return false;
175
+ const e = el;
176
+ if (e.tagName !== chain[i].tagName) return false;
177
+ sc[i] = e;
178
+ }
179
+ for (const strip of strips) {
180
+ const data = strip.text.nodeValue ?? "";
181
+ if (data.length === strip.ws.length) strip.text.parentNode?.removeChild(strip.text);
182
+ else strip.text.nodeValue = data.slice(0, data.length - strip.ws.length);
183
+ }
184
+ for (let i = n; i >= 0; i--) {
185
+ let node = i === n ? sc[i].firstChild : sc[i + 1].nextSibling;
186
+ while (node !== null) {
187
+ const next = node.nextSibling;
188
+ chain[i].appendChild(node);
189
+ node = next;
190
+ }
191
+ }
192
+ hits++;
193
+ return true;
194
+ }
195
+ let attempts = 0;
196
+ let hits = 0;
197
+ function __spliceStats() {
198
+ return { attempts, hits };
199
+ }
200
+ function __resetSpliceStats() {
201
+ attempts = 0;
202
+ hits = 0;
203
+ }
204
+ export {
205
+ __resetSpliceStats,
206
+ __spliceStats,
207
+ newIncCode,
208
+ noteSplice,
209
+ paintIncCode,
210
+ spliceHtml,
211
+ spliceKeep
212
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./dist/worker.js", "./dist/styles.css"],