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 +57 -0
- package/dist/client.js +4 -1
- package/dist/dom.d.ts +17 -0
- package/dist/dom.js +196 -18
- package/dist/hi-inc.d.ts +25 -0
- package/dist/hi-inc.js +6 -0
- package/dist/html-to-react.d.ts +2 -0
- package/dist/html-to-react.js +7 -0
- package/dist/react-splice.d.ts +32 -0
- package/dist/react-splice.js +33 -0
- package/dist/react.d.ts +11 -0
- package/dist/react.js +29 -8
- package/dist/renderers/CodeBlock.d.ts +13 -1
- package/dist/renderers/CodeBlock.js +51 -8
- package/dist/splice.d.ts +126 -0
- package/dist/splice.js +212 -0
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
233
|
-
const
|
|
234
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
262
|
-
|
|
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(
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
609
782
|
}
|
|
610
783
|
const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
|
|
611
|
-
function
|
|
784
|
+
function openTagOf(html) {
|
|
612
785
|
const gt = html.indexOf(">");
|
|
613
|
-
|
|
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;
|
package/dist/html-to-react.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/html-to-react.js
CHANGED
|
@@ -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 (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
11
13
|
}
|
|
12
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
121
|
-
|
|
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);
|
package/dist/splice.d.ts
ADDED
|
@@ -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.
|
|
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"],
|