brookmd 0.22.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +1229 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1265 -0
  4. package/dist/block-props.d.ts +18 -0
  5. package/dist/block-props.js +75 -0
  6. package/dist/client.d.ts +370 -0
  7. package/dist/client.js +754 -0
  8. package/dist/decorate.d.ts +24 -0
  9. package/dist/decorate.js +71 -0
  10. package/dist/dom.d.ts +130 -0
  11. package/dist/dom.js +627 -0
  12. package/dist/element.d.ts +20 -0
  13. package/dist/element.js +288 -0
  14. package/dist/hi.d.ts +12 -0
  15. package/dist/hi.js +215 -0
  16. package/dist/html-to-react.d.ts +61 -0
  17. package/dist/html-to-react.js +338 -0
  18. package/dist/index.d.ts +22 -0
  19. package/dist/index.js +18 -0
  20. package/dist/morph.d.ts +28 -0
  21. package/dist/morph.js +166 -0
  22. package/dist/react.d.ts +236 -0
  23. package/dist/react.js +539 -0
  24. package/dist/renderers/CodeBlock.d.ts +7 -0
  25. package/dist/renderers/CodeBlock.js +75 -0
  26. package/dist/renderers/Math.d.ts +14 -0
  27. package/dist/renderers/Math.js +15 -0
  28. package/dist/renderers/Mermaid.d.ts +13 -0
  29. package/dist/renderers/Mermaid.js +15 -0
  30. package/dist/server-react.d.ts +32 -0
  31. package/dist/server-react.js +48 -0
  32. package/dist/server.d.ts +31 -0
  33. package/dist/server.js +82 -0
  34. package/dist/solid.d.ts +104 -0
  35. package/dist/solid.js +54 -0
  36. package/dist/styles.css +188 -0
  37. package/dist/svelte.d.ts +80 -0
  38. package/dist/svelte.js +59 -0
  39. package/dist/types-core.d.ts +436 -0
  40. package/dist/types-core.js +0 -0
  41. package/dist/types-react.d.ts +13 -0
  42. package/dist/types-react.js +0 -0
  43. package/dist/types.d.ts +2 -0
  44. package/dist/types.js +2 -0
  45. package/dist/url-safety.d.ts +12 -0
  46. package/dist/url-safety.js +45 -0
  47. package/dist/vue.d.ts +94 -0
  48. package/dist/vue.js +79 -0
  49. package/dist/wasm/LICENSE +21 -0
  50. package/dist/wasm/README.md +71 -0
  51. package/dist/wasm/brook_md_core.d.ts +166 -0
  52. package/dist/wasm/brook_md_core.js +512 -0
  53. package/dist/wasm/brook_md_core_bg.wasm +0 -0
  54. package/dist/wasm/brook_md_core_bg.wasm.d.ts +26 -0
  55. package/dist/worker-core.d.ts +65 -0
  56. package/dist/worker-core.js +155 -0
  57. package/dist/worker.d.ts +1 -0
  58. package/dist/worker.js +49 -0
  59. package/package.json +87 -0
package/dist/client.js ADDED
@@ -0,0 +1,754 @@
1
+ function emptyBlockStore() {
2
+ return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
3
+ }
4
+ function htmlToText(html) {
5
+ return html.replace(/<[^>]*>/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim();
6
+ }
7
+ function applyPatch(store, patch) {
8
+ for (const b of patch.newly_committed) {
9
+ if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
10
+ store.committed.set(b.id, b);
11
+ }
12
+ store.active = patch.active;
13
+ const next = new Array(store.committedOrder.length + store.active.length);
14
+ for (let i = 0; i < store.committedOrder.length; i++) {
15
+ next[i] = store.committed.get(store.committedOrder[i]);
16
+ }
17
+ for (let i = 0; i < store.active.length; i++) {
18
+ next[store.committedOrder.length + i] = store.active[i];
19
+ }
20
+ store.snapshot = next;
21
+ }
22
+ class BrookPool {
23
+ constructor(factory, cap) {
24
+ this.factory = factory;
25
+ this.cap = cap;
26
+ }
27
+ factory;
28
+ cap;
29
+ workers = [];
30
+ handlers = /* @__PURE__ */ new Map();
31
+ nextStreamId = 1;
32
+ /** Reserve a stream id and assign a worker, registering its message handler. */
33
+ acquire(handler) {
34
+ const streamId = this.nextStreamId++;
35
+ const pw = this.pick();
36
+ pw.streamCount++;
37
+ pw.streamIds.add(streamId);
38
+ this.handlers.set(streamId, handler);
39
+ return { streamId, pw };
40
+ }
41
+ /** Free a stream's parser in its worker; keep the worker warm for siblings. */
42
+ release(streamId, pw) {
43
+ this.handlers.delete(streamId);
44
+ pw.streamIds.delete(streamId);
45
+ pw.streamCount = Math.max(0, pw.streamCount - 1);
46
+ try {
47
+ pw.worker.postMessage({ type: "dispose", streamId });
48
+ } catch {
49
+ }
50
+ }
51
+ /** Inverse of {@link release}: re-register a stream's handler so it receives
52
+ * patches again. For React StrictMode's dev double-mount, which destroys a
53
+ * client on the simulated unmount and remounts the SAME instance. The worker
54
+ * lazily recreates the disposed parser on the next append. */
55
+ reattach(streamId, pw, handler) {
56
+ if (!this.handlers.has(streamId)) {
57
+ pw.streamCount++;
58
+ pw.streamIds.add(streamId);
59
+ }
60
+ this.handlers.set(streamId, handler);
61
+ }
62
+ send(pw, msg) {
63
+ pw.worker.postMessage(msg);
64
+ }
65
+ /** Resolves when the given worker has finished WASM init; rejects if it failed. */
66
+ whenWorkerReady(pw) {
67
+ if (pw.ready) return Promise.resolve();
68
+ if (pw.failed) return Promise.reject(pw.failed);
69
+ return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
70
+ }
71
+ /**
72
+ * Eagerly spin up one worker so WASM init starts BEFORE the first stream —
73
+ * taking the one-time init off the first-token critical path (e.g. call
74
+ * `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
75
+ * if one exists; the warm worker is the one the first stream attaches to (it
76
+ * has spare capacity), so the work is not wasted. Resolves when that worker has
77
+ * finished initializing WASM; rejects if init fails fatally. Browser-only (it
78
+ * constructs a `Worker`).
79
+ */
80
+ warm() {
81
+ const live = this.workers.filter((w) => !w.failed);
82
+ const pw = live[0] ?? this.create();
83
+ return this.whenWorkerReady(pw);
84
+ }
85
+ /** Terminate every worker (test teardown / full shutdown). */
86
+ disposeAll() {
87
+ for (const pw of this.workers) {
88
+ try {
89
+ pw.worker.terminate();
90
+ } catch {
91
+ }
92
+ }
93
+ this.workers = [];
94
+ this.handlers.clear();
95
+ }
96
+ get workerCount() {
97
+ return this.workers.length;
98
+ }
99
+ // Create a new worker while under cap and every live worker is busy; otherwise
100
+ // attach to the least-loaded LIVE worker. A fatally-failed worker is never
101
+ // handed out (a stream on it would post into a dead worker and hang) — it is
102
+ // retained only to reject outstanding whenWorkerReady waiters.
103
+ pick() {
104
+ const live = this.workers.filter((w) => !w.failed);
105
+ if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
106
+ return this.create();
107
+ }
108
+ if (live.length === 0) return this.create();
109
+ return live.reduce((a, b) => b.streamCount < a.streamCount ? b : a);
110
+ }
111
+ create() {
112
+ const pw = {
113
+ worker: this.factory(),
114
+ ready: false,
115
+ failed: null,
116
+ streamCount: 0,
117
+ streamIds: /* @__PURE__ */ new Set(),
118
+ readyWaiters: []
119
+ };
120
+ pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
121
+ this.workers.push(pw);
122
+ return pw;
123
+ }
124
+ onMessage(pw, msg) {
125
+ if (msg.type === "ready") {
126
+ pw.ready = true;
127
+ const waiters = pw.readyWaiters;
128
+ pw.readyWaiters = [];
129
+ for (const w of waiters) w.resolve();
130
+ return;
131
+ }
132
+ if (msg.type === "error" && msg.fatal) {
133
+ const err = new Error(msg.message);
134
+ pw.failed = err;
135
+ const waiters = pw.readyWaiters;
136
+ pw.readyWaiters = [];
137
+ for (const w of waiters) {
138
+ try {
139
+ w.reject(err);
140
+ } catch {
141
+ }
142
+ }
143
+ for (const sid of pw.streamIds) this.dispatch(sid, msg);
144
+ try {
145
+ pw.worker.terminate();
146
+ } catch {
147
+ }
148
+ const idx = this.workers.indexOf(pw);
149
+ if (idx !== -1) this.workers.splice(idx, 1);
150
+ return;
151
+ }
152
+ this.dispatch(msg.streamId, msg);
153
+ }
154
+ // Route a message to a stream's handler, isolating a throwing client callback
155
+ // (e.g. a user-supplied onError) so it can neither break the worker message
156
+ // loop nor starve sibling streams sharing this worker.
157
+ dispatch(streamId, msg) {
158
+ try {
159
+ this.handlers.get(streamId)?.(msg);
160
+ } catch (e) {
161
+ console.error("brookmd: stream message handler threw", e);
162
+ }
163
+ }
164
+ }
165
+ const ID_NAMESPACE_STRIDE = 4294967296;
166
+ function poolCap() {
167
+ const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
168
+ return Math.min(hc || 4, 8);
169
+ }
170
+ let defaultPool = null;
171
+ function getDefaultPool() {
172
+ if (!defaultPool) {
173
+ defaultPool = new BrookPool(
174
+ () => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
175
+ poolCap()
176
+ );
177
+ }
178
+ return defaultPool;
179
+ }
180
+ function __resetDefaultPool() {
181
+ defaultPool = null;
182
+ }
183
+ class BrookClient {
184
+ pool;
185
+ pw = null;
186
+ streamId = 0;
187
+ config;
188
+ configSent = false;
189
+ listeners = /* @__PURE__ */ new Set();
190
+ store = emptyBlockStore();
191
+ onError;
192
+ onBlock;
193
+ attached = true;
194
+ // Diff baseline for setContent(): the full string fed in so far, and whether
195
+ // it has been finalized. Cleared by reset()/reattach() (the worker drops the
196
+ // parser there, so the baseline is stale and the document must be re-fed).
197
+ lastContent = "";
198
+ contentDone = false;
199
+ // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
200
+ // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
201
+ // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
202
+ // renders once per frame instead of once per patch (the DOM renderer already
203
+ // batches to 1/frame independently). Lossless: committed blocks are
204
+ // reference-stable, so a dropped intermediate notify only skips a tail-only
205
+ // render that the next flush supersedes. The finalize/done patch is exempt and
206
+ // flushes synchronously, never deferred to the next frame.
207
+ coalesce = false;
208
+ rafHandle = null;
209
+ // Set by finalize(); the next patch's emit flushes synchronously (a 'done'
210
+ // notification must not be deferred a frame) and clears it. Belt-and-suspenders
211
+ // alongside the per-patch `final` flag, which is the authoritative signal.
212
+ finalizePending = false;
213
+ // Stream generation, bumped on reset(). Stamped on every worker message and
214
+ // echoed back on each patch; a patch whose epoch is older than this is a
215
+ // pre-reset straggler and is dropped before it can repopulate the cleared store.
216
+ epoch = 0;
217
+ // --- Preserved-view divergence swap (setContent's reset+reparse path) ---
218
+ // The displayed snapshot captured by softReset(). While set, getSnapshot()
219
+ // returns a positional merge of this view over the rebuilding store, so the
220
+ // document never blanks out during a divergence reparse and unchanged blocks
221
+ // keep their object identity AND id (React key / DOM node key) across the
222
+ // swap. It persists after the reparse completes — dropping it would revert
223
+ // the adopted ids and remount every block one notify later.
224
+ staleSnapshot = null;
225
+ // Set when the reparse's terminal (final) patch lands: the merge stops
226
+ // padding with the old document's tail, so a shorter replacement trims to the
227
+ // new length on that very notify.
228
+ staleTrimmed = false;
229
+ // Id offset applied to non-adopted new blocks in a merged view. A divergence
230
+ // reparse restarts core block ids at 0 (fresh parser), and streamed ids are
231
+ // chunk-dependent (tail reparses burn ids) — so a changed block's new id can
232
+ // collide with a retained old block's id in the same merged snapshot.
233
+ // Bumped by ID_NAMESPACE_STRIDE per generation, which keeps every merged id
234
+ // provably unique: adopted ids are all below the current namespace.
235
+ idNamespace = 0;
236
+ // getSnapshot() cache: (store.snapshot ref, trimmed) → merged array. Repeated
237
+ // reads between notifies must return the SAME reference — the
238
+ // useSyncExternalStore cached-snapshot contract.
239
+ mergeCache = null;
240
+ // Perf
241
+ appendedBytes = 0;
242
+ patchCount = 0;
243
+ totalParseMicros = 0;
244
+ lastPatchMs = 0;
245
+ firstAppendMs = 0;
246
+ retainedBytes = 0;
247
+ wasmMemoryBytes = 0;
248
+ // Render-path observability (advanced ONLY when an onRenderMetrics hook is
249
+ // wired into a renderer; zero-cost otherwise). renderCount = React BlockView
250
+ // body renders; rebuildCount = DOM node rebuilds.
251
+ renderCount = 0;
252
+ rebuildCount = 0;
253
+ /**
254
+ * @param options.pool worker pool to join (defaults to the shared
255
+ * process-wide pool — pass a dedicated `BrookPool` only for isolation).
256
+ * @param options.config per-stream parser flags (see {@link ParserConfig});
257
+ * omitted fields use library defaults. Applied once, immutable thereafter.
258
+ * @param options.onError invoked on a worker/parse error or a fatal WASM-init
259
+ * failure (`fatal: true`). Without it, errors are only `console.error`d and
260
+ * a load failure surfaces solely as a rejected {@link BrookClient.whenReady}.
261
+ * @param options.onBlock invoked once per block as it commits (in document
262
+ * order, after the store updates) — for side effects like lazily
263
+ * highlighting a finished code block or analytics. A committed block never
264
+ * re-fires; the streaming tail does not (subscribe for live tail updates).
265
+ * NOTE: this is a PARSER-commit hook — the block carries the parser's raw
266
+ * id. During a setContent divergence swap the rendered view may show that
267
+ * block under a different id (an adopted old id, or a namespaced one), so
268
+ * correlate with rendered blocks via subscribe()+getSnapshot(), not this id.
269
+ * @param options.coalesce opt-in (default `false`): collapse multiple
270
+ * intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
271
+ * flush to subscribers, so a React `useSyncExternalStore` consumer renders at
272
+ * most once per frame instead of once per patch. Lossless — committed blocks
273
+ * are reference-stable, so only superseded tail-only renders are skipped. The
274
+ * stream-completion (finalize) patch always flushes synchronously, and a
275
+ * pending frame is cancelled on `reset()`/`destroy()`. No effect when
276
+ * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
277
+ */
278
+ constructor(options = {}) {
279
+ this.pool = options.pool ?? getDefaultPool();
280
+ this.config = options.config;
281
+ this.onError = options.onError;
282
+ this.onBlock = options.onBlock;
283
+ this.coalesce = options.coalesce ?? false;
284
+ }
285
+ /**
286
+ * Lazily reserve this client's stream id and bind it to a pool worker. The
287
+ * SOLE place that calls pool.acquire() — so the worker is created on the FIRST
288
+ * worker-bound operation (append/finalize/reset/pipeFrom/whenReady), never at
289
+ * construct time. This is what makes `new BrookClient()` SSR-safe: nothing here
290
+ * runs during an SSR render (which only subscribes + reads the snapshot).
291
+ *
292
+ * Idempotent: once this.pw is set it returns it immediately and never
293
+ * re-acquires — this.pw is never nulled (destroy() deliberately keeps it so
294
+ * StrictMode's destroy()→reattach() on the SAME instance re-registers the same
295
+ * slot). Note: streamId/worker assignment now follows first-worker-bound-op
296
+ * order, not construction order — a client constructed first no longer
297
+ * necessarily owns the lowest streamId. This affects neither the pool cap nor
298
+ * multiplexing (pick() is unchanged and remains the only path to create()).
299
+ */
300
+ ensureAcquired() {
301
+ if (this.pw && !this.pw.failed) return this.pw;
302
+ this.pw = null;
303
+ const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
304
+ this.streamId = streamId;
305
+ this.pw = pw;
306
+ return pw;
307
+ }
308
+ get ready() {
309
+ return this.pw?.ready ?? false;
310
+ }
311
+ whenReady() {
312
+ const pw = this.ensureAcquired();
313
+ return this.pool.whenWorkerReady(pw);
314
+ }
315
+ // The config rides on the first message a stream sends; the worker applies it
316
+ // when it creates the parser. postMessage is FIFO per worker, so it always
317
+ // lands before any append is processed. Returns undefined after the first use.
318
+ firstConfig() {
319
+ if (this.configSent || !this.config) return void 0;
320
+ this.configSent = true;
321
+ return this.config;
322
+ }
323
+ append(chunk) {
324
+ const pw = this.ensureAcquired();
325
+ if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
326
+ this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
327
+ }
328
+ finalize() {
329
+ const pw = this.ensureAcquired();
330
+ this.finalizePending = true;
331
+ this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
332
+ }
333
+ /**
334
+ * Pipe a source straight in: read it to completion, `append()` each chunk,
335
+ * then `finalize()`. The LLM-native path — e.g.
336
+ * `await client.pipeFrom(await fetch("/api/chat"))`. Accepts:
337
+ * - a `Response` or its `ReadableStream<Uint8Array>` body (bytes; decoded
338
+ * with `TextDecoder({ stream: true })` so a multibyte sequence straddling
339
+ * a chunk boundary carries into the next read), or
340
+ * - an `AsyncIterable<string>` (e.g. an SSE delta generator) — string chunks
341
+ * appended verbatim.
342
+ *
343
+ * Pass `opts.signal` to supersede/cancel: the signal is checked on every
344
+ * iteration, so once aborted no further chunk is appended and **finalize is
345
+ * skipped** (a superseded stream must not finalize). For a byte source the
346
+ * reader is also `cancel()`'d to tear down the upstream. Resolves once
347
+ * finalized (or cleanly on abort); rejects if the source itself errors.
348
+ * Browser-only for byte sources (uses `TextDecoder`).
349
+ */
350
+ async pipeFrom(source, opts) {
351
+ const signal = opts?.signal;
352
+ if (signal?.aborted) return;
353
+ if (!("getReader" in source) && !("body" in source)) {
354
+ for await (const chunk of source) {
355
+ if (signal?.aborted) return;
356
+ this.append(chunk);
357
+ }
358
+ if (!signal?.aborted) this.finalize();
359
+ return;
360
+ }
361
+ const body = "body" in source ? source.body : source;
362
+ if (!body) {
363
+ this.finalize();
364
+ return;
365
+ }
366
+ const reader = body.getReader();
367
+ const onAbort = () => {
368
+ reader.cancel().catch(() => {
369
+ });
370
+ };
371
+ signal?.addEventListener("abort", onAbort, { once: true });
372
+ const decoder = new TextDecoder();
373
+ try {
374
+ for (; ; ) {
375
+ const { done, value } = await reader.read();
376
+ if (signal?.aborted) return;
377
+ if (done) break;
378
+ if (value) this.append(decoder.decode(value, { stream: true }));
379
+ }
380
+ this.append(decoder.decode());
381
+ this.finalize();
382
+ } finally {
383
+ signal?.removeEventListener("abort", onAbort);
384
+ try {
385
+ reader.releaseLock();
386
+ } catch {
387
+ }
388
+ }
389
+ }
390
+ /**
391
+ * Drive the parser from a CONTROLLED full string instead of manual appends.
392
+ * Pass the whole document-so-far each time; setContent diffs it against the
393
+ * last value and does the minimal work:
394
+ * - **prefix-extension** (the streaming-growth case) → append only the new
395
+ * suffix, so committed blocks stay put and only the active tail re-parses;
396
+ * - **any other change** (e.g. a finished stream swapped for a re-processed
397
+ * final string) → reset + reparse the whole new string, keeping the
398
+ * current view on screen until the reparse lands: the document never
399
+ * blanks, scroll never moves, and blocks whose rendered content is
400
+ * unchanged keep their identity (and React keys) so only genuinely
401
+ * changed blocks re-render. An empty new string is an explicit clear and
402
+ * hard-resets immediately.
403
+ *
404
+ * This is the first-class bridge for UIs that hold a streaming message as a
405
+ * single growing string prop (the common React shape) — no hand-rolled diff,
406
+ * no readiness gate (appends before WASM is ready are buffered). Pass
407
+ * `{ done: true }` once the content is final to `finalize()` (idempotent within
408
+ * a generation; a content change *after* done reopens the stream via a fresh
409
+ * reparse, since a finalized parser is terminal and can't be appended to).
410
+ * Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
411
+ * not both — they share the internal diff baseline.
412
+ *
413
+ * v1 note: the non-prefix path is a full reparse, not a partial rewind —
414
+ * committed blocks are frozen, so there is no truncate-to-offset. For the
415
+ * common case (append-growth + one end-of-stream swap) that is optimal. A
416
+ * transform that rewrites *earlier* bytes on every update is an anti-pattern
417
+ * here (it forces a reparse each tick); do that enrichment at render time via
418
+ * `components` instead, keeping the source append-only.
419
+ */
420
+ setContent(content, opts) {
421
+ if (content !== this.lastContent) {
422
+ if (!this.contentDone && content.startsWith(this.lastContent)) {
423
+ if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
424
+ this.softReset(this.getSnapshot());
425
+ }
426
+ this.append(content.slice(this.lastContent.length));
427
+ } else {
428
+ const displayed = this.getSnapshot();
429
+ if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
430
+ else this.reset();
431
+ this.append(content);
432
+ }
433
+ this.lastContent = content;
434
+ this.contentDone = false;
435
+ }
436
+ if (opts?.done && !this.contentDone) {
437
+ this.finalize();
438
+ this.contentDone = true;
439
+ }
440
+ }
441
+ reset() {
442
+ const hadContent = this.getSnapshot().length > 0;
443
+ this.staleSnapshot = null;
444
+ this.staleTrimmed = false;
445
+ this.mergeCache = null;
446
+ this.resetParser();
447
+ if (hadContent) this.emit(true);
448
+ }
449
+ /**
450
+ * setContent's divergence reset: rebuild the parser exactly like {@link reset},
451
+ * but keep `preserve` (the currently displayed view) on screen while the new
452
+ * content reparses. No notify fires here — subscribers keep reading the same
453
+ * snapshot reference until the first new-generation patch merges over it, so
454
+ * the swap is seamless: no empty frame, no container collapse, no scroll
455
+ * clamp, and blocks whose content survives the reprocess never re-render.
456
+ */
457
+ softReset(preserve) {
458
+ this.staleSnapshot = preserve;
459
+ this.staleTrimmed = false;
460
+ this.idNamespace += ID_NAMESPACE_STRIDE;
461
+ this.resetParser();
462
+ this.mergeCache = { base: this.store.snapshot, trimmed: false, view: preserve };
463
+ }
464
+ /**
465
+ * Finish a preserved-view divergence swap by making the merged view THE
466
+ * store: adopted ids become the committed keys, the merged array becomes the
467
+ * snapshot, and every scrap of merge state drops. From here getSnapshot() is
468
+ * a plain field read again (zero steady-state overhead) and the superseded
469
+ * generation's blocks are garbage — only one document stays in memory.
470
+ * Runs on the terminal (final) patch, which commits everything — if anything
471
+ * is somehow still open, the lazy merge simply stays live instead (the
472
+ * incremental reuse keeps it linear).
473
+ */
474
+ collapseStale() {
475
+ if (this.store.active.length > 0) return;
476
+ const view = this.getSnapshot();
477
+ const committed = /* @__PURE__ */ new Map();
478
+ const committedOrder = new Array(view.length);
479
+ for (let i = 0; i < view.length; i++) {
480
+ committedOrder[i] = view[i].id;
481
+ committed.set(view[i].id, view[i]);
482
+ }
483
+ this.store = { committed, committedOrder, active: [], snapshot: view };
484
+ this.staleSnapshot = null;
485
+ this.staleTrimmed = false;
486
+ this.mergeCache = null;
487
+ }
488
+ // Shared generation teardown: swap in an empty store, zero the metrics and
489
+ // the setContent baseline, invalidate any coalesced frame, bump the epoch,
490
+ // and tell the worker to drop the parser. View concerns (stale preservation,
491
+ // subscriber notify) belong to the callers — reset() and softReset().
492
+ resetParser() {
493
+ this.store = emptyBlockStore();
494
+ this.appendedBytes = 0;
495
+ this.patchCount = 0;
496
+ this.totalParseMicros = 0;
497
+ this.lastPatchMs = 0;
498
+ this.firstAppendMs = 0;
499
+ this.retainedBytes = 0;
500
+ this.wasmMemoryBytes = 0;
501
+ this.lastContent = "";
502
+ this.contentDone = false;
503
+ this.cancelFrame();
504
+ this.finalizePending = false;
505
+ this.epoch += 1;
506
+ const pw = this.ensureAcquired();
507
+ this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
508
+ }
509
+ destroy() {
510
+ if (!this.attached) return;
511
+ if (this.pw) this.pool.release(this.streamId, this.pw);
512
+ this.cancelFrame();
513
+ this.finalizePending = false;
514
+ this.listeners.clear();
515
+ this.attached = false;
516
+ }
517
+ /**
518
+ * Re-register with the pool after {@link destroy} so the client receives
519
+ * patches again. Needed only for React StrictMode's dev double-mount, where
520
+ * the renderer destroys on the simulated unmount then remounts the SAME
521
+ * client instance; apps don't normally call this. No-op if still attached.
522
+ */
523
+ reattach() {
524
+ if (this.attached) return;
525
+ this.lastContent = "";
526
+ this.contentDone = false;
527
+ if (!this.pw) {
528
+ this.attached = true;
529
+ return;
530
+ }
531
+ this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
532
+ this.attached = true;
533
+ this.configSent = false;
534
+ }
535
+ subscribe = (fn) => {
536
+ this.listeners.add(fn);
537
+ return () => this.listeners.delete(fn);
538
+ };
539
+ getSnapshot = () => {
540
+ const base = this.store.snapshot;
541
+ if (!this.staleSnapshot) return base;
542
+ const cache = this.mergeCache;
543
+ if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
544
+ const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
545
+ this.mergeCache = { base, trimmed: this.staleTrimmed, view };
546
+ return view;
547
+ };
548
+ /**
549
+ * Positional merge of the preserved pre-divergence view over the rebuilding
550
+ * store (see {@link softReset}). Per position:
551
+ * - identical committed block (html + kind + open + speculative) → the OLD
552
+ * block object, so its id and reference survive the swap and the block
553
+ * never re-renders (blocksEqual / the DOM keyed reconcile hold);
554
+ * - changed committed block → the new block CARRYING THE OLD BLOCK'S id, so
555
+ * the same keyed component re-renders in place and its state (pagination,
556
+ * expansion…) survives the swap; only a NET-NEW position (past the old
557
+ * document's end) takes a namespace-offset id, where a raw parser id
558
+ * could genuinely collide with a retained old id;
559
+ * - still-open block over old content → the old block (never a shrinking
560
+ * partial where complete content was already on screen); past the old
561
+ * document's end the live tail streams in as-is;
562
+ * - position the reparse hasn't reached → the old block, until the terminal
563
+ * patch sets `staleTrimmed` and the view clamps to the new length.
564
+ *
565
+ * LINEARITY: committed blocks are reference-stable across patches (the store
566
+ * contract), so a base entry pointer-equal to the previous merge's reproduces
567
+ * its previous decision without re-running the O(html) equality compare. Each
568
+ * block is string-compared exactly once — when it first commits — keeping a
569
+ * long post-divergence stream linear instead of quadratic. Only the active
570
+ * tail (fresh references each patch, small by design) re-compares per patch.
571
+ */
572
+ mergeStale(base, prev) {
573
+ const stale = this.staleSnapshot;
574
+ const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
575
+ const view = new Array(len);
576
+ for (let i = 0; i < len; i++) {
577
+ const nb = i < base.length ? base[i] : void 0;
578
+ if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
579
+ view[i] = prev.view[i];
580
+ continue;
581
+ }
582
+ const ob = i < stale.length ? stale[i] : void 0;
583
+ if (!nb) {
584
+ view[i] = ob;
585
+ continue;
586
+ }
587
+ if (ob) {
588
+ if (nb.open && !this.staleTrimmed) {
589
+ view[i] = ob;
590
+ continue;
591
+ }
592
+ if (nb.html === ob.html && nb.kind.type === ob.kind.type && nb.open === ob.open && nb.speculative === ob.speculative) {
593
+ view[i] = ob;
594
+ continue;
595
+ }
596
+ view[i] = { ...nb, id: ob.id };
597
+ continue;
598
+ }
599
+ view[i] = { ...nb, id: this.idNamespace + nb.id };
600
+ }
601
+ return view;
602
+ }
603
+ /**
604
+ * Internal: a renderer with an `onRenderMetrics` hook calls this once per
605
+ * actual React block render so `getMetrics().renderCount` aggregates churn.
606
+ * No-op cost when no hook is wired (it is simply never called). Not part of
607
+ * the public API surface — the underscore marks it renderer-internal.
608
+ */
609
+ __noteRender() {
610
+ this.renderCount++;
611
+ }
612
+ /**
613
+ * Internal: the DOM renderer calls this once per actual node rebuild (the
614
+ * changed-block branch) when an `onRenderMetrics` hook is wired, so
615
+ * `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
616
+ */
617
+ __noteRebuild() {
618
+ this.rebuildCount++;
619
+ }
620
+ getMetrics() {
621
+ const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
622
+ return {
623
+ bytes: this.appendedBytes,
624
+ patches: this.patchCount,
625
+ meanParseMicros: this.patchCount > 0 ? this.totalParseMicros / this.patchCount : 0,
626
+ totalParseMs: this.totalParseMicros / 1e3,
627
+ throughputKBs: this.appendedBytes / 1024 / (elapsed / 1e3),
628
+ committedBlocks: this.store.committed.size,
629
+ activeBlocks: this.store.active.length,
630
+ lastPatchAgoMs: this.lastPatchMs === 0 ? 0 : performance.now() - this.lastPatchMs,
631
+ retainedBytes: this.retainedBytes,
632
+ // NOTE: with the worker pool, this is the *shared* worker's WASM heap —
633
+ // clients on the same worker report the same number. Use Math.max (not
634
+ // sum) when aggregating across clients; summing double-counts.
635
+ wasmMemoryBytes: this.wasmMemoryBytes,
636
+ // Render-path churn (0 unless an onRenderMetrics hook is wired into a
637
+ // renderer): renderCount = React block-body renders, rebuildCount = DOM
638
+ // node rebuilds. Committed blocks memo-skip, so they contribute once.
639
+ renderCount: this.renderCount,
640
+ rebuildCount: this.rebuildCount
641
+ };
642
+ }
643
+ /**
644
+ * A heading outline of the current snapshot (committed + active), in document
645
+ * order — for a table of contents. Works mid-stream; entries appear as their
646
+ * headings stream in. The `id` is stable, so a built ToC won't re-key.
647
+ */
648
+ outline() {
649
+ const out = [];
650
+ for (const b of this.getSnapshot()) {
651
+ if (b.kind.type === "Heading") {
652
+ const d = b.kind.data;
653
+ const level = typeof d === "number" ? d : d?.level ?? 1;
654
+ out.push({ level, text: htmlToText(b.html), id: b.id });
655
+ }
656
+ }
657
+ return out;
658
+ }
659
+ /**
660
+ * The rendered document as plain text — tags stripped, entities decoded,
661
+ * blocks separated by blank lines. Derived from the rendered HTML (the source
662
+ * markdown is parsed away in WASM and not retained client-side), so it is a
663
+ * readable approximation for search indexing / summaries, not a round-trip of
664
+ * the original source.
665
+ */
666
+ toPlaintext() {
667
+ const parts = [];
668
+ for (const b of this.getSnapshot()) {
669
+ const t = htmlToText(b.html);
670
+ if (t) parts.push(t);
671
+ }
672
+ return parts.join("\n\n");
673
+ }
674
+ onMessage(msg) {
675
+ switch (msg.type) {
676
+ case "patch": {
677
+ if (msg.epoch !== void 0 && msg.epoch < this.epoch) break;
678
+ let patch;
679
+ try {
680
+ patch = JSON.parse(msg.patch);
681
+ } catch (e) {
682
+ const message = e instanceof Error ? e.message : String(e);
683
+ if (this.onError) this.onError({ message: `brookmd: malformed patch (${message})` });
684
+ else console.error("brookmd: malformed patch:", message);
685
+ break;
686
+ }
687
+ applyPatch(this.store, patch);
688
+ if (msg.final === true && this.staleSnapshot) {
689
+ this.staleTrimmed = true;
690
+ this.mergeCache = null;
691
+ this.collapseStale();
692
+ }
693
+ this.appendedBytes = msg.appendedBytes;
694
+ this.totalParseMicros += msg.parseMicros;
695
+ this.retainedBytes = msg.retainedBytes;
696
+ this.wasmMemoryBytes = msg.wasmMemoryBytes;
697
+ this.patchCount += 1;
698
+ this.lastPatchMs = performance.now();
699
+ const sync = this.finalizePending || msg.final === true;
700
+ this.finalizePending = false;
701
+ this.emit(sync);
702
+ if (this.onBlock) {
703
+ for (const b of patch.newly_committed) this.onBlock(b);
704
+ }
705
+ break;
706
+ }
707
+ case "error":
708
+ if (this.onError) {
709
+ this.onError({ message: msg.message, fatal: msg.fatal });
710
+ } else {
711
+ console.error("brookmd worker error:", msg.message);
712
+ }
713
+ break;
714
+ }
715
+ }
716
+ /**
717
+ * Notify subscribers of a new snapshot.
718
+ *
719
+ * With `coalesce` off (default) this is fully synchronous, exactly as before.
720
+ * With it on and `requestAnimationFrame` available, a normal emit only
721
+ * *schedules* a single per-frame flush — repeated intra-frame emits collapse
722
+ * into one notify. `sync` forces an immediate flush (stream completion / reset)
723
+ * and cancels any frame already pending so the snapshot is delivered once.
724
+ */
725
+ emit(sync = false) {
726
+ if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
727
+ if (this.rafHandle !== null) return;
728
+ this.rafHandle = requestAnimationFrame(() => {
729
+ this.rafHandle = null;
730
+ this.flushNow();
731
+ });
732
+ return;
733
+ }
734
+ this.cancelFrame();
735
+ this.flushNow();
736
+ }
737
+ flushNow() {
738
+ for (const fn of this.listeners) fn();
739
+ }
740
+ cancelFrame() {
741
+ if (this.rafHandle !== null) {
742
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
743
+ this.rafHandle = null;
744
+ }
745
+ }
746
+ }
747
+ export {
748
+ BrookClient,
749
+ BrookPool,
750
+ __resetDefaultPool,
751
+ applyPatch,
752
+ emptyBlockStore,
753
+ getDefaultPool
754
+ };