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/README.md ADDED
@@ -0,0 +1,1265 @@
1
+ # brookmd
2
+
3
+ Zero-dep streaming markdown for the browser. Rust→WASM core, one Web Worker per stream, incremental parse with speculative closure for mid-stream constructs.
4
+
5
+ Drop in a streaming-aware renderer — **React, Vue, Svelte, Solid, a framework-agnostic `<brook-markdown>` Web Component, or the vanilla DOM mount** — wire each LLM stream to a `BrookClient`, and the markdown renders incrementally off the main thread, block by block, with stable identities so unchanged blocks never re-reconcile.
6
+
7
+ Parsing runs entirely **off the main thread** — each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document, and heavy renderers (syntax highlighting, math, mermaid) are **deferred until a block closes**. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bun add brookmd # or: npm i brookmd / pnpm add brookmd
13
+ ```
14
+
15
+ brookmd ships **compiled, non-minified ESM** (`dist/*.js` + `.d.ts` types) plus
16
+ the compiled WASM — no raw `.ts`/`.tsx` source. The worker and WASM asset are
17
+ referenced with the **web-standard `new URL(asset,
18
+ import.meta.url)`** pattern, so any bundler with asset-module support resolves
19
+ them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
20
+ modules), **Parcel**, and **Next.js** (App Router — Turbopack *and* webpack;
21
+ **verified on Next.js 16**, see the [Next.js callout](#nextjs) below).
22
+
23
+ The streaming client (`<BrookMarkdown>` / `BrookClient`) is **browser-only** (it
24
+ constructs Web Workers). For **server-side / static rendering of finished
25
+ content** — SSR, React Server Components, build steps — use the worker-free,
26
+ synchronous [`brookmd/server`](#server-side-rendering) entry. The framework packages — `react`,
27
+ `vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
28
+ need the one whose binding you import. The framework-free entries
29
+ (`brookmd/client`, `brookmd/dom`, `brookmd/element`, and `brookmd/server`) need
30
+ none. (The bare `brookmd` entry re-exports the React component surface, so it
31
+ pulls `react` — import from `brookmd/client` if you want a framework-free core.)
32
+
33
+ > **Vite — one-line config.** Vite's dependency pre-bundling (esbuild) hoists
34
+ > the wasm-bindgen glue into `.vite/deps/`, which breaks the relative
35
+ > `new URL("…_bg.wasm", import.meta.url)` lookup so the worker can't load WASM
36
+ > (you'll see a 404 / "magic word" error). Exclude brookmd from pre-bundling:
37
+ >
38
+ > ```ts
39
+ > // vite.config.ts
40
+ > export default defineConfig({
41
+ > optimizeDeps: { exclude: ["brookmd"] },
42
+ > });
43
+ > ```
44
+ >
45
+ > No other bundler needs this — it's specific to Vite's optimizer.
46
+
47
+ <a id="nextjs"></a>
48
+
49
+ > **Next.js (App Router) — one requirement.** Works on **Next.js** with
50
+ > **Turbopack** (the default for both `next dev` and `next build`) or webpack.
51
+ > Since 0.17.0 brookmd ships **compiled ESM**, so **no `transpilePackages` or
52
+ > other build config is needed** — earlier versions required it only because the
53
+ > package shipped raw TypeScript, which Next does not compile inside
54
+ > `node_modules`. That no longer applies.
55
+ >
56
+ > **Use it from a Client Component.** `<BrookMarkdown>` uses React hooks (and
57
+ > spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
58
+ > a Server Component. (It is still SSR-safe: on the server it renders an empty
59
+ > shell and only starts streaming after hydration, so there's no SSR crash —
60
+ > the constraint is hooks, not the worker.)
61
+ >
62
+ > ```tsx
63
+ > "use client";
64
+ > import { BrookMarkdown } from "brookmd/react";
65
+ >
66
+ > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
67
+ > return <BrookMarkdown stream={stream} />;
68
+ > }
69
+ > ```
70
+ >
71
+ > **Create the `stream` in Client Component code, not in a Server Component.**
72
+ > A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
73
+ > can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
74
+ > throws *"Only plain objects can be passed to Client Components."* Pass a
75
+ > serializable prop (a URL, the chat messages) from the server and open the
76
+ > stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
77
+ > effect, or the `useBrookStream` hook (see [Quick start](#quick-start)).
78
+ >
79
+ > That's it — Turbopack bundles the worker and emits the `.wasm` to
80
+ > `_next/static/media` itself, so no extra asset/loader config is needed (and the
81
+ > Vite `optimizeDeps` workaround above does **not** apply). Both `next dev` and
82
+ > `next build && next start` are verified to spawn the worker, load the WASM, and
83
+ > stream markdown. _Dev tip:_ open the app on `localhost` — Next dev blocks
84
+ > cross-origin dev resources (HMR, chunks) from other hosts (e.g. `127.0.0.1`)
85
+ > unless you add them to `allowedDevOrigins` in `next.config`.
86
+
87
+ ## Quick start
88
+
89
+ ```ts
90
+ import { BrookClient, BrookMarkdown } from "brookmd";
91
+
92
+ // One client per stream. Spawns a Web Worker that owns a Rust parser.
93
+ const client = new BrookClient();
94
+
95
+ // Feed chunks as they arrive from your SSE / fetch reader.
96
+ for await (const delta of streamFromAi()) {
97
+ client.append(delta);
98
+ }
99
+ client.finalize();
100
+ ```
101
+
102
+ In React — pass the stream straight to `<BrookMarkdown>`. It owns the client,
103
+ pipes the stream, supersedes it if it changes, and cleans up on unmount:
104
+
105
+ ```tsx
106
+ import { BrookMarkdown } from "brookmd/react";
107
+
108
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
109
+ return <BrookMarkdown stream={stream} />;
110
+ }
111
+ ```
112
+
113
+ `stream` accepts an `AsyncIterable<string>` (e.g. SSE deltas), a `Response`, or
114
+ a `ReadableStream<Uint8Array>` — so `<BrookMarkdown stream={await fetch("/api/chat")} />`
115
+ works too.
116
+
117
+ Need the client handle (for `outline()` / `getMetrics()` / a shared client)? Use
118
+ the `useBrookStream` hook — same lifecycle, returns the owned client:
119
+
120
+ ```tsx
121
+ import { BrookMarkdown, useBrookStream } from "brookmd/react";
122
+
123
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
124
+ const client = useBrookStream(stream);
125
+ return <BrookMarkdown client={client} />;
126
+ }
127
+ ```
128
+
129
+ ### Already holding a growing string? — `useBrookMarkdownString`
130
+
131
+ Many apps keep the streaming message as a **single growing string prop** (it
132
+ re-renders with the full text-so-far each token), not as a stream. Feed that
133
+ string straight in — `useBrookMarkdownString` diffs it for you and forwards only
134
+ the delta, so you don't hand-roll an append/reset bridge:
135
+
136
+ ```tsx
137
+ import { BrookMarkdown, useBrookMarkdownString } from "brookmd/react";
138
+
139
+ export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
140
+ const client = useBrookMarkdownString(text, { streaming });
141
+ return <BrookMarkdown client={client} />;
142
+ }
143
+ ```
144
+
145
+ It handles the two shapes a controlled string takes: a **prefix-extension** (the
146
+ common token-by-token growth) appends only the new suffix; a **divergence** (e.g.
147
+ the finished text swapped for a re-processed final string — bolded numbers,
148
+ wrapped tickers) resets and reparses. Pass `streaming: false` once the content is
149
+ final so the last block commits (a finished code fence then highlights). The
150
+ framework-neutral primitive is **`client.setContent(fullString, { done })`** —
151
+ use it from any binding.
152
+
153
+ > **Transforming streamed content?** If the enrichment runs **live per token**
154
+ > (e.g. bold every number as it arrives), do it at **render time** via
155
+ > [`components`](#custom-components--overrides) — keep the markdown source
156
+ > append-only so parsing stays incremental. Re-transforming the *whole* string
157
+ > each token (so earlier bytes change) forces `setContent` to reparse every tick
158
+ > (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
159
+ > for the **once**-at-the-end reprocess swap, not per-token rewrites. That swap
160
+ > is seamless: the current view stays on screen while the new string reparses —
161
+ > the document never blanks, scroll never moves, and blocks whose rendered
162
+ > content is unchanged keep their identity (and React keys), so only genuinely
163
+ > changed blocks re-render. (`setContent("")` is an explicit clear and resets
164
+ > immediately.)
165
+
166
+ <details>
167
+ <summary>Full manual control (caller-owned client)</summary>
168
+
169
+ When you want to drive the stream yourself, pass a `client` you own — the
170
+ component never destroys it:
171
+
172
+ ```tsx
173
+ import { useEffect, useState } from "react";
174
+ import { BrookClient, BrookMarkdown } from "brookmd";
175
+
176
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
177
+ const [client] = useState(() => new BrookClient());
178
+ useEffect(() => () => client.destroy(), [client]);
179
+ useEffect(() => {
180
+ const ac = new AbortController();
181
+ client.pipeFrom(stream, { signal: ac.signal }); // pipeFrom also accepts AsyncIterable
182
+ return () => ac.abort();
183
+ }, [client, stream]);
184
+ return <BrookMarkdown client={client} />;
185
+ }
186
+ ```
187
+
188
+ </details>
189
+
190
+ > **StrictMode note:** a stream (SSE generator / `Response`) can be consumed only
191
+ > once, so React StrictMode's dev-only double-mount may truncate it in
192
+ > development. Production mounts once and is unaffected.
193
+
194
+ Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
195
+
196
+ ## Framework bindings
197
+
198
+ `BrookClient` is framework-neutral — it owns the worker and exposes
199
+ `subscribe`/`getSnapshot`. Pick a renderer to put its blocks on screen. Every
200
+ binding below is thin glue over the same incremental DOM renderer, so they
201
+ share one identity contract: a committed block's node is never recreated, only
202
+ the streaming tail re-renders.
203
+
204
+ **One ownership rule across all bindings:** the renderer's teardown (React
205
+ unmount, `handle.destroy()`, element disconnect, etc.) frees only the rendered
206
+ DOM and the subscription — it **never** destroys the client. You call
207
+ `client.destroy()` when you're done with the stream. (React's `<BrookMarkdown>`,
208
+ documented [below](#brookmarkdown-react), is the same.)
209
+
210
+ ### Vanilla / any framework — `brookmd/dom`
211
+
212
+ ```ts
213
+ import { BrookClient } from "brookmd/client";
214
+ import { mountBrookMarkdown } from "brookmd/dom";
215
+
216
+ const client = new BrookClient();
217
+ const handle = mountBrookMarkdown(client, document.getElementById("out")!, {
218
+ stickToBottom: true,
219
+ });
220
+
221
+ // Feed it from a fetch/SSE reader:
222
+ const reader = (await fetch("/api/chat")).body!.getReader();
223
+ const dec = new TextDecoder();
224
+ for (;;) {
225
+ const { value, done } = await reader.read();
226
+ if (done) break;
227
+ client.append(dec.decode(value, { stream: true })); // stream:true carries multibyte across chunks
228
+ }
229
+ client.append(dec.decode());
230
+ client.finalize();
231
+
232
+ // Teardown: destroy BOTH — the renderer and the client you created.
233
+ handle.destroy();
234
+ client.destroy();
235
+ ```
236
+
237
+ **Already holding a growing string?** There's no framework reactivity to wrap,
238
+ so just call **`client.setContent(fullString, { done })`** instead of the
239
+ `append` loop — it diffs internally (prefix → delta; divergence → reparse) and
240
+ finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
241
+ controlled-string helpers wrap; in vanilla you call it directly.
242
+
243
+ `mountBrookMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
244
+ Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
245
+ (default true), `batch` (default true — one DOM write per `requestAnimationFrame`),
246
+ `morphOpenBlocks` (default false — morph a growing generic open block's subtree in
247
+ place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
248
+ and focus/selection in the streaming tail survive; the rendered result is
249
+ equivalent to the default rebuild path).
250
+ Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
251
+ `Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
252
+ (lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
253
+ tree on the fast `innerHTML` path; a block-kind override can rewrite the `html`
254
+ it's handed instead.
255
+
256
+ ### Web Component `<brook-markdown>` — `brookmd/element`
257
+
258
+ The universal binding — plain HTML, Angular, or any framework that renders DOM.
259
+ Register once, then use the element:
260
+
261
+ ```ts
262
+ import { defineBrookMarkdown } from "brookmd/element";
263
+ defineBrookMarkdown(); // defines <brook-markdown>; pass a custom tag name if you like
264
+ ```
265
+
266
+ ```html
267
+ <!-- zero-JS streaming straight from a URL -->
268
+ <brook-markdown src="/api/post.md" gfm-math stick-to-bottom></brook-markdown>
269
+
270
+ <!-- one-shot from inline text -->
271
+ <brook-markdown># Hello **world**</brook-markdown>
272
+ ```
273
+
274
+ ```js
275
+ // or caller-owned streaming — drive your own client:
276
+ const el = document.querySelector("brook-markdown");
277
+ el.client = myBrookClient; // element subscribes; never destroys it
278
+ el.components = { Thinking: (p) => myNode(p) };
279
+ myBrookClient.append(delta);
280
+ ```
281
+
282
+ Config flags are **tri-state attributes**: absent = library default;
283
+ `gfm-math` / `gfm-math="true"` / `="1"` = on; `gfm-math="false"` / `="0"` = off
284
+ (the only way to turn off a default-on flag such as `gfm-alerts`). It renders in
285
+ light DOM so your markdown CSS applies, and `defineBrookMarkdown` is a no-op under
286
+ SSR (no `customElements`). A self-owned element (`src` / `markdown` / inline
287
+ text / `append()`) is torn down on disconnect; a caller-supplied `client` is left
288
+ alone.
289
+
290
+ **Angular** consumes the same element — no separate package:
291
+
292
+ ```ts
293
+ import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
294
+ import { defineBrookMarkdown } from "brookmd/element";
295
+ defineBrookMarkdown(); // once at bootstrap
296
+
297
+ @Component({
298
+ standalone: true,
299
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
300
+ template: `<brook-markdown [attr.src]="url" stick-to-bottom></brook-markdown>`,
301
+ })
302
+ export class Answer { url = "/api/post.md"; }
303
+ ```
304
+
305
+ **Controlled growing string?** Assign a caller-owned client and drive it with
306
+ `setContent` — `el.client = myClient; myClient.setContent(fullString, { done })`
307
+ — the element subscribes and renders, you own the diffing. (The self-owned
308
+ `markdown` attribute is **one-shot** — it re-parses the whole document on each
309
+ change, so don't point it at a per-token-growing string; use a client +
310
+ `setContent` for that.)
311
+
312
+ ### Vue 3 — `brookmd/vue`
313
+
314
+ ```vue
315
+ <script setup lang="ts">
316
+ import { onBeforeUnmount } from "vue";
317
+ import { BrookClient } from "brookmd/client";
318
+ import { BrookMarkdown } from "brookmd/vue";
319
+
320
+ const client = new BrookClient();
321
+ // feed client.append(delta) from your stream, then client.finalize()
322
+ onBeforeUnmount(() => client.destroy());
323
+ </script>
324
+
325
+ <template>
326
+ <BrookMarkdown :client="client" stick-to-bottom />
327
+ </template>
328
+ ```
329
+
330
+ Props: `client` (required), `components`, `sanitize`, `virtualize`,
331
+ `stickToBottom`. There's also a `useBrookMarkdown` composable returning a
332
+ `container` ref if you'd rather mount into your own element.
333
+
334
+ **Already holding a growing string?** `useBrookMarkdownString` owns a client and
335
+ diffs the string for you (the Vue analogue of the React hook — see
336
+ [Controlled strings](#already-holding-a-growing-string--usebrookmarkdownstring)):
337
+
338
+ ```vue
339
+ <script setup lang="ts">
340
+ import { BrookMarkdown, useBrookMarkdownString } from "brookmd/vue";
341
+ const props = defineProps<{ text: string; streaming: boolean }>();
342
+ // Pass getters so the composable tracks the live values; it owns + destroys the client.
343
+ const client = useBrookMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
344
+ </script>
345
+ <template><BrookMarkdown :client="client" /></template>
346
+ ```
347
+
348
+ ### Svelte (4 & 5) — `brookmd/svelte`
349
+
350
+ A Svelte action — works in both v4 and v5, no `.svelte` build step:
351
+
352
+ ```svelte
353
+ <script lang="ts">
354
+ import { onDestroy } from "svelte";
355
+ import { BrookClient } from "brookmd/client";
356
+ import { brookMarkdown } from "brookmd/svelte";
357
+
358
+ const client = new BrookClient();
359
+ // feed client.append(delta) then client.finalize()
360
+ onDestroy(() => client.destroy());
361
+ </script>
362
+
363
+ <div use:brookMarkdown={{ client, stickToBottom: true }} />
364
+ ```
365
+
366
+ **Growing string?** The `brookMarkdownString` action owns a client and diffs the
367
+ string — `use:brookMarkdownString={{ content, streaming }}` (it destroys its
368
+ client on `destroy`, so no manual cleanup):
369
+
370
+ ```svelte
371
+ <script lang="ts">
372
+ import { brookMarkdownString } from "brookmd/svelte";
373
+ export let content: string; // the growing message
374
+ export let streaming: boolean; // false once complete → finalizes
375
+ </script>
376
+
377
+ <div use:brookMarkdownString={{ content, streaming, stickToBottom: true }} />
378
+ ```
379
+
380
+ ### Solid — `brookmd/solid`
381
+
382
+ ```tsx
383
+ import { onCleanup } from "solid-js";
384
+ import { BrookClient } from "brookmd/client";
385
+ import { BrookMarkdown } from "brookmd/solid";
386
+
387
+ const client = new BrookClient();
388
+ // feed client.append(delta) then client.finalize()
389
+ onCleanup(() => client.destroy());
390
+
391
+ <BrookMarkdown client={client} stickToBottom />;
392
+ ```
393
+
394
+ **Growing string?** `createBrookMarkdownString` owns a client and diffs the string
395
+ (the Solid analogue of the React hook), driving `setContent` from a
396
+ `createEffect` and destroying the client on cleanup:
397
+
398
+ ```tsx
399
+ import { BrookMarkdown, createBrookMarkdownString } from "brookmd/solid";
400
+
401
+ function Message(props: { text: string; streaming: boolean }) {
402
+ const client = createBrookMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
403
+ return <BrookMarkdown client={client} />;
404
+ }
405
+ ```
406
+
407
+ The Solid binding's mount/teardown logic is tested, but its JSX component shell
408
+ has so far only been exercised through a real Solid (`vite-plugin-solid`) build
409
+ in development, not in CI — treat it as the newest of the bindings and file an
410
+ issue if your Solid setup trips on it. The component is a thin `ref`'d `<div>`;
411
+ if you hit a transform edge, `mountBrookMarkdown` from `brookmd/dom` inside
412
+ `onMount`/`onCleanup` is the zero-surprise fallback.
413
+
414
+ ## Server-side rendering
415
+
416
+ `<BrookMarkdown>` / `BrookClient` are browser-only (they spawn a Web Worker), but
417
+ the Rust→WASM core is a plain **synchronous** parser. So `brookmd/server` renders
418
+ **finished** markdown on the server with no worker and no async ceremony — Node
419
+ SSR, React Server Components, or a build step:
420
+
421
+ ```ts
422
+ import { initBrook, renderToString } from "brookmd/server";
423
+
424
+ await initBrook(); // once at startup (loads the WASM)
425
+ const html = renderToString("# Hello\n\n**world**"); // sync HTML string, no worker
426
+ ```
427
+
428
+ For React server rendering (RSC, static generation, or SSR), use
429
+ `<BrookMarkdownStatic>` from **`brookmd/server/react`** — a hookless, RSC-safe
430
+ component that renders finished content with the same `components` overrides
431
+ (inline/block component tags dispatch on the server too). It lives in its own
432
+ subpath so the core `brookmd/server` above stays importable with no `react`
433
+ installed:
434
+
435
+ ```tsx
436
+ import { initBrook } from "brookmd/server";
437
+ import { BrookMarkdownStatic } from "brookmd/server/react";
438
+
439
+ await initBrook();
440
+ export default function Doc({ md }: { md: string }) {
441
+ return (
442
+ <BrookMarkdownStatic
443
+ content={md}
444
+ config={{ inlineComponentTags: ["tik"] }}
445
+ components={{ tik: ({ symbol }) => <span className="ticker">{symbol}</span> }}
446
+ />
447
+ );
448
+ }
449
+ ```
450
+
451
+ - **`initBrook()`** — async, idempotent. In Node it reads the package's `.wasm` off
452
+ disk (Node's `fetch` can't load `file://`); on the web it fetches the
453
+ bundler-resolved asset. On edge runtimes pass bytes yourself:
454
+ `initBrookSync(wasmBytes)`.
455
+ - **`renderToString(md, { config })`** — synchronous HTML string, **zero React
456
+ dependency** (imports cleanly with no `react` installed).
457
+ - **`parseToBlocks(md, { config })`** — the block array, for custom rendering.
458
+ - **`<BrookMarkdownStatic content config components />`** (from
459
+ `brookmd/server/react`) — synchronous React tree for **render-once** contexts;
460
+ render it with your framework's server renderer
461
+ (`renderToStaticMarkup`, RSC, …). For live streaming, client-side code
462
+ highlighting, or Mermaid, render `<BrookMarkdown>` on the client instead — it's a
463
+ separate component. (If you SSR-then-hydrate, use the *same* component on both
464
+ sides; the dedicated client renderers in `<BrookMarkdown>` don't hydrate
465
+ `<BrookMarkdownStatic>`'s plainer markup.)
466
+
467
+ ## What it does
468
+
469
+ | Concern | brookmd | conventional main-thread renderer |
470
+ |---|---|---|
471
+ | Re-parse on each token | No — only the active tail | Yes, full string |
472
+ | Where parse runs | Web Worker (off main thread) | Main thread |
473
+ | Block identity across chunks | Stable monotonic IDs | New keys on every render |
474
+ | Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
475
+ | Half-streamed link `[label](https://…` | Label-only inert anchor (`data-brook-pending`), URL never leaks | Raw brackets + partial URL flash |
476
+ | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
477
+ | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
478
+
479
+ ### Streaming links
480
+
481
+ A link's destination is the last thing a model emits — `[Earnings Call](https://…`
482
+ often spans many tokens. While a link is still streaming, brookmd renders it as
483
+ an **inert, label-only anchor**: the label text inside an `<a>` with no `href`
484
+ (the half-typed URL never flashes on screen), marked so you can style it:
485
+
486
+ ```html
487
+ <a data-brook-pending="" target="_blank" rel="noopener noreferrer nofollow">Earnings Call</a>
488
+ ```
489
+
490
+ An anchor without an `href` gets **no default link styling** from the browser, so
491
+ without a rule for the marker the link would "pop" blue only when the URL
492
+ completes. The bundled theme (`import "brookmd/styles.css"`) already styles it;
493
+ if you bring your own CSS, copy this:
494
+
495
+ ```css
496
+ .brook-md a[data-brook-pending] {
497
+ color: var(--brook-accent, #0969da); /* match your settled link's resting style */
498
+ cursor: default; /* not clickable yet */
499
+ }
500
+ ```
501
+
502
+ The moment the closing `)` arrives, the `href` appears and `data-brook-pending`
503
+ is dropped — committed and finalized output never carry the marker, and the
504
+ finished block is byte-identical to a one-shot parse. Two composition notes:
505
+ `urlTransform` runs only on a real `href`, so it never sees a half-streamed URL
506
+ prefix — only the complete one (it may run again on re-renders while the
507
+ surrounding block is still open); `decorators` skip text
508
+ inside `<a>` by default (`skipInside`), pending or not.
509
+
510
+ ## Styling
511
+
512
+ brookmd emits semantic HTML under a `.brook-md` root and **ships no CSS by
513
+ default** — bring your own design system, or opt into the bundled theme:
514
+
515
+ ```ts
516
+ import "brookmd/styles.css";
517
+ ```
518
+
519
+ It gives good-looking output out of the box, **including the built-in syntax
520
+ highlighter's colors** (without any CSS, `highlight()` renders uncolored). The
521
+ theme is scoped to `.brook-md`, zero-runtime, and **does not change the rendered
522
+ HTML** — skip the import and nothing is styled.
523
+
524
+ > **Next.js Pages Router:** `brookmd/styles.css` is global CSS, which the Pages
525
+ > Router only allows importing from `pages/_app`. Import it there (App Router and
526
+ > other bundlers can import it from any component). Or skip it and bring your own
527
+ > `.brook-md` styles.
528
+
529
+ Re-theme by overriding a few CSS variables; it's light by default and switches to
530
+ dark automatically via `prefers-color-scheme` (force a mode with
531
+ `class="brook-md brook-dark"` or `brook-light`):
532
+
533
+ ```css
534
+ .brook-md {
535
+ --brook-accent: #7c3aed; /* links */
536
+ --brook-bg-code: #faf7ff; /* code background */
537
+ --brook-t-kw: #c026d3; /* syntax: keywords (also --brook-t-str/num/com/fn/ty/…) */
538
+ }
539
+ ```
540
+
541
+ ## Public API
542
+
543
+ ### `BrookClient`
544
+
545
+ ```ts
546
+ class BrookClient {
547
+ constructor(options?: {
548
+ pool?: BrookPool;
549
+ config?: ParserConfig;
550
+ onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
551
+ onBlock?: (block: Block) => void; // fires once per block as it commits
552
+ });
553
+ append(chunk: string): void; // queue text for parsing
554
+ pipeFrom( // read → append → finalize
555
+ src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
556
+ opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
557
+ ): Promise<void>;
558
+ finalize(): void; // mark stream complete
559
+ setContent( // drive from a controlled full string
560
+ full: string, // diffs vs last: prefix → append delta; else seamless
561
+ opts?: { done?: boolean }, // reset+reparse (view held, unchanged blocks keep identity)
562
+ ): void; // done:true → finalize
563
+ reset(): void; // wipe and reuse
564
+ destroy(): void; // free this stream's parser
565
+ whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
566
+ subscribe(listener: () => void): () => void; // React-friendly store
567
+ getSnapshot(): Block[]; // ordered current blocks
568
+ outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
569
+ toPlaintext(): string; // rendered document as plain text (search / summaries)
570
+ getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
571
+ retainedBytes, wasmMemoryBytes, ... };
572
+ }
573
+ ```
574
+
575
+ `pipeFrom` is the LLM-native shortcut — hand it a `fetch` response and it
576
+ reads, appends, and finalizes for you:
577
+
578
+ ```ts
579
+ const client = new BrookClient();
580
+ await client.pipeFrom(await fetch("/api/chat")); // streams the body in, then finalizes
581
+ ```
582
+
583
+ Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
584
+ failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
585
+ load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
586
+ effect each time a block commits (e.g. lazy-highlight a finished code block).
587
+
588
+ #### Per-stream config
589
+
590
+ ```ts
591
+ const client = new BrookClient({
592
+ config: {
593
+ gfmAutolinks: true, // bare www./http(s):// URLs + emails → links (default true)
594
+ gfmAlerts: true, // > [!NOTE] → callouts (default true)
595
+ gfmTagfilter: false, // GFM disallowed raw HTML: escape <script>/<title>/… under unsafeHtml (default false)
596
+ gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
597
+ gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
598
+ dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
599
+ a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
600
+ unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
601
+ componentTags: ["Thinking", "Callout"], // BLOCK custom tags w/ markdown inside (default none)
602
+ inlineComponentTags: ["tik", "cite"], // INLINE custom tags (chips/citations) w/ markdown inside (default none)
603
+ htmlAllowlist: ["br", "sub", "sup"], // safe raw-HTML sanitizer: [] = allow all but dangerous; list = only those (default off)
604
+ dropHtmlTags: [], // tags removed entirely (comments always dropped when sanitizing; default off)
605
+ blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
606
+ },
607
+ });
608
+ ```
609
+
610
+ Omitted fields use the defaults above, so `new BrookClient()` is unchanged.
611
+ Config is applied when the stream's parser is created and is **immutable** for
612
+ that stream (`reset()` keeps it; use a new client for different flags).
613
+
614
+ When to enable each flag:
615
+
616
+ - `gfmAutolinks` — on by default. Leave it on unless you want strict CommonMark.
617
+ - `gfmAlerts` — on by default. Leave it on unless you want strict CommonMark.
618
+ - `gfmMath: true` — when your LLM emits `$…$` or `$$…$$` (or LaTeX `\(…\)` /
619
+ `\[…\]`). brookmd emits KaTeX-ready markup; you bring the KaTeX pass (or
620
+ `components.MathBlock`).
621
+ - `gfmFootnotes: true` — when your input uses `[^1]` references and `[^1]:`
622
+ definitions. Off by default; see the footnote streaming caveat above.
623
+ - `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
624
+ `dir="auto"` so the browser detects direction independently per block.
625
+ - `a11y: true` — opt-in accessibility markup that deviates from strict GFM
626
+ byte-output: wraps task-list checkboxes in a `<label>` (screen-reader
627
+ association) and adds `scope="col"` to table headers. Off by default so
628
+ conformance output stays exact.
629
+ - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
630
+ LLM-produced HTML, pair this with `<BrookMarkdown sanitize={…} />` (DOMPurify or
631
+ similar — see [Security](#security)).
632
+ - `gfmTagfilter: true` — the GFM "Disallowed Raw HTML" extension, for use with
633
+ `unsafeHtml`: the nine disallowed tags (`<title>`, `<textarea>`, `<style>`,
634
+ `<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get
635
+ their leading `<` escaped so they display as text instead of taking effect —
636
+ opening and closing forms, any case, in blocks and inline. Off by default
637
+ (strict CommonMark passes them through under `unsafeHtml`); it's a tag
638
+ denylist, **not** a sanitizer — untrusted input still wants `sanitize`.
639
+ - `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
640
+ like `<Thinking>…</Thinking>` (on their own line) and you want their inner
641
+ content parsed as markdown and dispatched to a React component. Safe without
642
+ `unsafeHtml` (attributes are sanitized; allowlisted tags only).
643
+ - `inlineComponentTags: ["tik", …]` — same idea for **inline** custom elements
644
+ that sit inside a paragraph, heading, list item, or **table cell** (ticker
645
+ chips, citations, `@mentions`). See [Inline component tags](#inline-component-tags).
646
+ - `htmlAllowlist` / `dropHtmlTags` — render a **safe subset of raw HTML** (e.g.
647
+ `<br>`, `<sub>`, `<sup>`) natively without `unsafeHtml`, drop specific tags, and
648
+ drop HTML comments. See [Safe raw HTML](#safe-raw-html).
649
+
650
+ **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
651
+ `[^1]` reference renders speculatively the moment it's seen (committed blocks
652
+ can't re-render), and the footnote **section is emitted at finalize**. So a
653
+ reference whose definition never arrives leaves a dangling link — the same
654
+ forward-reference cost as link reference definitions. Multiple references to
655
+ the same footnote each get a **unique id** (`fnref-N`, `fnref-N-2`, …) and the
656
+ definition lists **one backref per reference**. Remaining v1 limits:
657
+ single-block definitions (no continuation-indent / multi-paragraph) and no
658
+ nested footnotes. The section uses GitHub-style markup
659
+ (`<section class="footnotes">`, `<sup class="footnote-ref">`).
660
+
661
+ **Math** (`gfmMath`) recognizes both delimiter families LLMs emit — `$…$` /
662
+ `$$…$$` and LaTeX `\(…\)` / `\[…\]`. Inline math renders to
663
+ `<span class="math math-inline">…</span>`, display math to
664
+ `<div class="math math-display">…</div>` (and inline display to a `math-display`
665
+ span), each carrying the **HTML-escaped LaTeX as its text content** — exactly
666
+ what [KaTeX](https://katex.org)'s auto-render / `rehype-katex` consume. brookmd
667
+ stays **zero-dep**: it produces the KaTeX-ready markup and never processes the
668
+ body as markdown; you bring the KaTeX pass (or override `components.MathBlock`,
669
+ which receives the raw LaTeX as `text`). Single `$` uses the **pandoc rule** so
670
+ prose and currency stay literal — the opener needs a non-space to its right, the
671
+ closer a non-space to its left and no digit after it, so `$5 and $10` is **not**
672
+ math. A `$$`/`\[` block is **blank-line tolerant** (multi-line `\begin{aligned}…`
673
+ stays one block) and renders incrementally while streaming, like a code fence.
674
+ Off by default (so `$` in plain prose is untouched) — enable it per stream when
675
+ your model emits LaTeX.
676
+
677
+ **Bidirectional text** (`dirAuto`) emits `dir="auto"` on each block-level text
678
+ element (`p`, `h1`–`h6`, `blockquote`, `ul`/`ol`/`li`, `table`), so the browser
679
+ runs the Unicode bidi algorithm **per block** — an Arabic/Hebrew paragraph
680
+ renders RTL while the English one beside it stays LTR, with no JS direction
681
+ detection. Code blocks never get it (code is always LTR). This is the per-block
682
+ model GitHub uses; it's the right fix for the common failure mode of detecting
683
+ one direction for a whole mixed-language document. Off by default (strict
684
+ CommonMark output is unchanged); turn it on for RTL or mixed-direction content.
685
+
686
+ ### `BrookMarkdown` (React)
687
+
688
+ Subscribes to a `BrookClient`, renders each block keyed by its stable parser-assigned ID. Memoized so unchanged blocks never re-reconcile.
689
+
690
+ ```tsx
691
+ <BrookMarkdown client={client} />
692
+ ```
693
+
694
+ The root element accepts opt-in `className` (appended to `brookmd`), `id`,
695
+ `role`, and `aria-live` / `aria-atomic`. Set `aria-live="polite"` to make the
696
+ output a live region so screen readers announce streamed content as it settles —
697
+ `polite` coalesces rapid updates and does **not** read every token. The same
698
+ options exist on the DOM mount (`mountBrookMarkdown(client, el, { ariaLive: "polite" })`),
699
+ covering the Web Component and the Vue/Svelte/Solid adapters.
700
+
701
+ #### Custom components / overrides
702
+
703
+ Pass a `components` map to replace how elements render. Keys come in **two
704
+ namespaces**:
705
+
706
+ ```tsx
707
+ import { useMemo } from "react";
708
+ import { BrookClient, BrookMarkdown, type Components } from "brookmd";
709
+
710
+ function Message({ client }: { client: BrookClient }) {
711
+ // Memoize (or hoist to module scope). A fresh object every render busts
712
+ // BrookMarkdown's block memo, so every block re-parses on every patch.
713
+ const components: Components = useMemo(
714
+ () => ({
715
+ // tag-level (lowercase HTML names) — applied inside a block's HTML
716
+ table: (props) => <table className="rounded border" {...props} />,
717
+ a: (props) => <a target="_blank" rel="noreferrer" {...props} />,
718
+ h1: "h2", // a string value just swaps the tag
719
+
720
+ // block-kind (capitalized BlockKindTag) — replaces the whole block
721
+ CodeBlock: ({ text, language, open }) => (
722
+ <MyCodeBlockWithCopyButton code={text} lang={language} streaming={open} />
723
+ ),
724
+
725
+ // GitHub alerts (`> [!NOTE]` / `[!TIP]` / `[!WARNING]` / `[!CAUTION]` /
726
+ // `[!IMPORTANT]`) — swap in your own callout component. The alert kind
727
+ // is on `block.kind.data.kind`; `html` is the rendered inner body.
728
+ Alert: ({ block, html }) => (
729
+ <MyCallout kind={(block.kind.data as { kind: string }).kind}>
730
+ <div dangerouslySetInnerHTML={{ __html: html }} />
731
+ </MyCallout>
732
+ ),
733
+ }),
734
+ [],
735
+ );
736
+ return <BrookMarkdown client={client} components={components} />;
737
+ }
738
+ ```
739
+
740
+ **Tag-level** keys (`table`, `thead`, `tr`, `td`, `a`, `code`, `pre`, `h1`–`h6`,
741
+ `ul`, `ol`, `li`, `blockquote`, `p`, `img`, `del`, `input`, `hr`, …) replace that
742
+ element wherever it appears. The component receives the element's parsed
743
+ attributes (with `class`→`className` and `style` as an object) plus `children`.
744
+
745
+ **Block-kind** keys (`CodeBlock`, `Mermaid`, `MathBlock`, `Alert`, `Paragraph`,
746
+ `Heading`, `List`, `Blockquote`, `Table`, `Rule`, `Html`) replace the entire
747
+ block. The component receives [`BlockComponentProps`](#types): `{ block, html,
748
+ open, speculative }`, plus `text`/`language` for code/math blocks (the alert
749
+ type is at `block.kind.data.kind`).
750
+
751
+ Rules worth knowing:
752
+
753
+ - **There is no `node` prop / no hast tree.** Introspect via `className` /
754
+ `data-*`, or — better — opt into the typed **[structured-data
755
+ channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
756
+ `block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
757
+ `list` fields) directly — no HTML re-parsing.
758
+ - **Overrides apply to the OPEN (streaming) block too**, not just settled ones —
759
+ so a design-system renderer (Tailwind classes on `p`/`ul`/`li`, inline
760
+ `<a>`/`<code>` overrides) stays styled mid-stream. The tail's HTML is always
761
+ well-formed (the parser speculatively closes it). If a `sanitize` is supplied
762
+ it runs first, on every block.
763
+ - **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
764
+ output). The HTML→React conversion runs only when you actually supply
765
+ overrides, and is memoized per `(block id, html)` so committed blocks don't
766
+ re-parse as the stream grows.
767
+ - For **code blocks** the built-in highlighter is the default; it is bypassed
768
+ (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
769
+ or `components.code`.
770
+
771
+ #### Inline text decorators
772
+
773
+ Wrap or replace matched inline **text** while streaming — e.g. bold financial
774
+ figures — without writing your own HTML re-parser. A `decorators` entry runs
775
+ POST-parse on real inline **text nodes only** (never URLs, code, or markup), once
776
+ per committed block, so a long document stays **O(n)**.
777
+
778
+ ```tsx
779
+ import { BrookMarkdown, wrapLink } from "brookmd";
780
+
781
+ // HOIST it (module scope) or memoize — a fresh identity each render busts the
782
+ // per-block memo and re-decorates every block on every patch (a dev warning fires).
783
+ const decorators = [
784
+ { match: /\$[\d.]+[BMK]|FY\d{4}|\d+(?:[-–]\d+)?%/g, replace: (t) => <mark>{t}</mark> },
785
+ // Linkify a ticker — route the href through the safe helper (see below):
786
+ { match: /\$[A-Z]{1,5}\b/g, replace: (t) => wrapLink(t, { href: `/sym/${t.slice(1)}` }) },
787
+ ];
788
+
789
+ <BrookMarkdown client={client} decorators={decorators} />;
790
+ ```
791
+
792
+ - **Trusted surface — not sanitized.** A decorator's `replace` output is spliced
793
+ straight into the tree and does **not** pass through brookmd's attribute sanitizer
794
+ (React renders a `javascript:` href without complaint). Treat `decorators`
795
+ exactly like `components`: build only trusted nodes, and route any link href
796
+ through `wrapLink` or the exported `safeUrl`.
797
+ - **`skipInside`** defaults to `['a','code','pre','kbd']`; override per decorator.
798
+ - **Per-text-node.** A value split by inline markup (e.g. `$2.<em>5</em>B`) is two
799
+ text nodes and won't match across them — match against settled, contiguous text.
800
+ - Matching is pure and stateless, so a value streamed char-by-char decorates
801
+ **identically** to a one-shot render. Same API on `brookmd/dom`
802
+ (`mountBrookMarkdown(client, el, { decorators })`); a decorator there returns a
803
+ `Node` or string.
804
+
805
+ `urlTransform?: (url, { tag, attr }) => string` rewrites `href`/`src`/`poster`
806
+ URLs as blocks render (proxy images, add UTM params). Its output is re-sanitized
807
+ (`safeUrl(urlTransform(safeUrl(value)))`), so a buggy transform can never emit a
808
+ `javascript:` / `data:text/html` URL. Hoist/memoize it for the same reason as
809
+ `decorators`.
810
+
811
+ ### Structured block data (`setBlockData`)
812
+
813
+ Set `blockData: true` in the per-stream config and each block carries typed
814
+ structured data on `block.kind.data`, also surfaced as typed fields on the
815
+ component props — so you build toolbars, tables of contents, charts, copy
816
+ buttons, etc. from **data**, never by re-parsing the rendered HTML (no hast tree,
817
+ no rehype). Off by default; when off, output and CommonMark/GFM conformance are
818
+ byte-identical, so non-users pay nothing.
819
+
820
+ | Kind | `block.kind.data` | prop | use |
821
+ |------|-------------------|------|-----|
822
+ | `Table` | `{ headers, rows, aligns }`, cells `{ text, html }` | `props.table` | sort / filter / transpose / CSV / chart |
823
+ | `Heading` | `{ level, text, id }` | `props.heading` | table of contents with anchors |
824
+ | `CodeBlock` | `{ lang, code }` | `props.code` | decoded source (copy / run) |
825
+ | `MathBlock` | `{ latex }` | `props.math` | LaTeX source (re-render) |
826
+ | `List` | `{ ordered, start }` | `props.list` | ordered-list numbering |
827
+
828
+ Each cell's `text` is inline-stripped plaintext (for sort/filter/CSV/logic);
829
+ `html` is the inline-rendered display HTML. The data **streams** with the
830
+ document — a growing table or a heading carries its structured data on every
831
+ patch, in lock-step with the HTML — something a batch HTML-AST cannot do.
832
+
833
+ ```tsx
834
+ // Table of contents from heading data — no DOM, works mid-stream:
835
+ const toc = client.getSnapshot()
836
+ .filter((b) => b.kind.type === "Heading" && b.kind.data)
837
+ .map((b) => b.kind.data as { level: number; text: string; id: string });
838
+ ```
839
+
840
+ ### Component tags
841
+
842
+ LLMs increasingly emit custom component tags like `<Thinking>…</Thinking>`. By
843
+ default these are inert (escaped, or — with `unsafeHtml` — raw HTML whose body
844
+ is *not* markdown). Opt in by allowlisting the tag names:
845
+
846
+ ```tsx
847
+ const client = new BrookClient({ config: { componentTags: ["Thinking", "Callout"] } });
848
+ ```
849
+
850
+ Now a listed tag is a **markdown container**: its inner content is parsed as
851
+ markdown, it spans blank lines up to its matching close tag (not split like a
852
+ raw HTML block), it nests, and a `</Tag>` inside a code fence stays content. It's
853
+ **safe without `unsafeHtml`** — the tag is allowlisted and its attributes are
854
+ sanitized (event handlers dropped, dangerous URL schemes → `#`).
855
+
856
+ Each renders as a `Component` block. Override it in React by tag name (or with
857
+ the generic `Component` fallback). The override receives `tag`, the sanitized
858
+ `attrs`, the inner content as ready-to-render **`children`** (the easy path), and
859
+ also `html` (the inner already-rendered markdown string, for
860
+ `dangerouslySetInnerHTML`):
861
+
862
+ ```tsx
863
+ <BrookMarkdown
864
+ client={client}
865
+ components={{
866
+ Thinking: ({ children }) => (
867
+ <details className="thinking">
868
+ <summary>Reasoning</summary>
869
+ {children}
870
+ </details>
871
+ ),
872
+ }}
873
+ />
874
+ ```
875
+
876
+ > **`children` vs `html`.** A `Component` override that renders *neither* shows
877
+ > **empty** (a common first-try gotcha). Prefer **`children`** — a parsed React
878
+ > tree with nested overrides applied; reach for `dangerouslySetInnerHTML={{ __html:
879
+ > html }}` only when you need the raw string. `attrs` keys are React-form
880
+ > (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While
881
+ > streaming, both reflect the partial inner content and re-render as more arrives.
882
+ > With no override the block renders as `<thinking …>…</thinking>`. Tag names
883
+ > match case-sensitively; off unless `componentTags` is set.
884
+
885
+ <a id="inline-component-tags"></a>
886
+
887
+ #### Inline component tags
888
+
889
+ `componentTags` handles **block** containers (a `<Thinking>` on its own line). For
890
+ **inline** custom elements — ticker chips, citations, `@mentions`, inline tooltips
891
+ that sit *inside* a paragraph, heading, list item, or **table cell** — use
892
+ `inlineComponentTags`:
893
+
894
+ ```tsx
895
+ const client = new BrookClient({ config: { inlineComponentTags: ["tik"] } });
896
+
897
+ <BrookMarkdown
898
+ client={client}
899
+ components={{
900
+ tik: ({ symbol, children }) => <span className="ticker">{children ?? symbol}</span>,
901
+ }}
902
+ />;
903
+ ```
904
+
905
+ Now `Apple <tik symbol="AAPL">AAPL</tik> rose 2%` (or self-closing
906
+ `<tik symbol="AAPL"/>`) dispatches the inline `<tik>` to `components.tik`: its
907
+ inner is parsed as **inline markdown** (the `children`), its attributes become
908
+ props, and it's **safe without `unsafeHtml`** (attributes sanitized, allowlisted
909
+ tags only). It works everywhere inline content does — **including table cells**.
910
+ Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
911
+ (`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
912
+ two lists are independent: list a tag under `componentTags` for blocks,
913
+ `inlineComponentTags` for inline, or both for both. An allowlisted tag used in an
914
+ unsupported position degrades **inertly** (escaped) — it never consumes
915
+ surrounding content.
916
+
917
+ > **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
918
+ > inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
919
+ > override `a` to render a chip when the href scheme matches. It's XSS-safe and
920
+ > renders inline-in-cells too — `inlineComponentTags` simply replaces that
921
+ > workaround with first-class inline elements.
922
+
923
+ ### Safe raw HTML
924
+
925
+ LLMs emit a little raw HTML — `<br>`, `<sub>`/`<sup>`, `<mark>`, and HTML comments
926
+ as markers (`<!--mk:id-->`). `unsafeHtml` is all-or-nothing; instead opt into a
927
+ **sanitizer** that renders a safe subset natively. Setting `htmlAllowlist` and/or
928
+ `dropHtmlTags` (even to `[]`) engages it:
929
+
930
+ ```ts
931
+ // Render only these inline tags; escape everything else:
932
+ new BrookClient({ config: { htmlAllowlist: ["br", "sub", "sup", "mark"] } });
933
+
934
+ // Or allow everything except a built-in dangerous set:
935
+ new BrookClient({ config: { htmlAllowlist: [] } });
936
+ ```
937
+
938
+ - **HTML comments are dropped** — no more `<!--mk:id-->` surfacing as escaped text
939
+ — in every mode except bare `unsafeHtml` pass-through.
940
+ - **`htmlAllowlist: ["br", …]`** renders only those inline tags; everything else is
941
+ escaped. **`htmlAllowlist: []`** (empty) allows *all* tags **except a built-in
942
+ dangerous set** (`script`, `style`, `iframe`, `object`, `embed`, `form`, `svg`,
943
+ `xmp`, `plaintext`, … — **non-overridable**: allowlisting one still drops it).
944
+ - **`dropHtmlTags: ["mk", …]`** removes those tags entirely (markup gone; inner
945
+ text stays as inert text).
946
+ - Every rendered tag's **attributes are sanitized**: `on*` handlers and `style`
947
+ (a CSS beacon / clickjacking vector) are dropped, and dangerous URL schemes
948
+ (`javascript:`, …, including multi-encoded) become `#`.
949
+ - **Scope:** *inline* raw HTML. Block-level raw HTML stays escaped for now (use
950
+ `unsafeHtml` **without** the sanitizer to render block HTML — when the sanitizer
951
+ is engaged, block HTML stays escaped even if `unsafeHtml` is also on). Tag
952
+ matching is case-insensitive.
953
+
954
+ ### Types
955
+
956
+ ```ts
957
+ interface Block {
958
+ id: number;
959
+ kind: { type: "Paragraph" | "Heading" | "CodeBlock" | "List" | ...; data?: unknown };
960
+ html: string; // safe to inject via dangerouslySetInnerHTML
961
+ open: boolean; // still being built (last block in active tail)
962
+ speculative: boolean; // closed by inference, may be revised
963
+ start: number;
964
+ end: number;
965
+ }
966
+
967
+ // Override map for <BrookMarkdown components={...} />
968
+ type Components = Record<string, React.ComponentType<any> | string>;
969
+
970
+ // Props a block-kind override receives (e.g. components.CodeBlock)
971
+ interface BlockComponentProps {
972
+ block: Block;
973
+ html: string;
974
+ open: boolean;
975
+ speculative: boolean;
976
+ text?: string; // decoded source — CodeBlock / MathBlock
977
+ language?: string; // info string — CodeBlock
978
+ }
979
+ ```
980
+
981
+ `htmlToReact(html, components)` and `parseTrustedHtml(html)` are also exported
982
+ for advanced use (e.g. rendering a single block's HTML to a React tree yourself).
983
+
984
+ ### `highlight(code, lang)`
985
+
986
+ Optional. Tiny native-RegExp tokenizer covering js/ts/tsx/jsx, rust, python, go, bash, sql, json, html, css. Unknown languages fall through to plain escaped text.
987
+
988
+ ```ts
989
+ import { highlight } from "brookmd/highlight";
990
+ const html = highlight("const x = 1;", "ts");
991
+ ```
992
+
993
+ ## Coverage
994
+
995
+ **CommonMark 0.31: 100% (652/652 spec examples)** — every section, including
996
+ the hard ones (nested/loose lists, link reference definitions, link precedence,
997
+ lazy blockquote continuation). Plus GFM extensions: tables, strikethrough, task
998
+ lists, extended autolinks, GitHub alerts (`> [!NOTE]` → styled callouts),
999
+ footnotes (`[^1]` + `[^1]:`), and math (`$…$`, `$$…$$`, `\(…\)`, `\[…\]`).
1000
+ Autolinks and alerts are on by default; footnotes and math are opt-in per stream
1001
+ (see [Per-stream config](#per-stream-config)). See
1002
+ `crates/brookmd-core/tests/{cmark_spec,gfm_spec,footnotes,math}.rs` for runners and floors.
1003
+
1004
+ GitHub alerts render to GitHub-compatible markup
1005
+ (`<div class="markdown-alert markdown-alert-note">…`), so existing markdown CSS
1006
+ styles them, and they're overridable as a block kind via `components.Alert`.
1007
+
1008
+ ## What it doesn't do
1009
+
1010
+ By design, not yet, or only partially:
1011
+
1012
+ - **Raw HTML in markdown** — escaped by default, not passed through. (Security
1013
+ default. The `unsafeHtml: true` config flag disables the escape but must never
1014
+ be enabled for untrusted input without a `sanitize` hook.)
1015
+ - **Forward link references when streaming** — a `[ref]` used *before* its later
1016
+ `[ref]: url` definition can't resolve until the definition arrives; one-shot
1017
+ parsing handles it fully, streaming converges once the definition streams in.
1018
+ - **Definition lists** — out of scope for v1.
1019
+ - **KaTeX / Mermaid rendering** — brookmd emits KaTeX-ready math markup
1020
+ (`<span>`/`<div class="math …">` with `gfmMath` on) and a `Mermaid` slot, but
1021
+ stays zero-dep: bring your own KaTeX / mermaid pass (or a `components.MathBlock`
1022
+ / `components.Mermaid` override) for the actual SVG/MathML output.
1023
+ - **Syntax highlighting on open code blocks** — deferred until close. This is a
1024
+ deliberate perf choice.
1025
+
1026
+ ## Performance
1027
+
1028
+ Every realistic streaming shape (long paragraph, fenced code block, GFM table,
1029
+ blockquote/alert, flat list, math fence, reference-heavy document) parses in
1030
+ **O(n) total work**, not O(n²) — at every chunk size from 16 bytes (char-by-char)
1031
+ up. Each shape has an incremental cache that mirrors the structure of the block
1032
+ so that an append only does work proportional to the *newly arrived* bytes, not
1033
+ the growing tail. See [CHANGELOG.md](./CHANGELOG.md) for per-shape numbers and
1034
+ the regression that prompted each cache; the canonical bench is
1035
+ `crates/brookmd-core/examples/bench.rs` (`cargo run --release --example bench`).
1036
+
1037
+ Headline numbers are not durable across machines, but the curve is: chunk size
1038
+ shouldn't change the order of magnitude for any shape. If you hit one that does,
1039
+ file an issue with the input and chunking — that's the next bench scenario.
1040
+
1041
+ ## Security
1042
+
1043
+ brookmd is XSS-safe by default — its HTML output is meant to be injected via
1044
+ `innerHTML` without a downstream sanitizer:
1045
+
1046
+ - **Raw HTML is escaped** (the `unsafeHtml: true` config flag disables this;
1047
+ **never enable it for untrusted input without a `sanitize` hook**).
1048
+ - **Dangerous URL schemes are neutralized** in `<a href>` and `<img src>` —
1049
+ `javascript:`, `vbscript:`, `data:text/html`, `data:text/javascript` become
1050
+ `#`. The check runs on the *decoded* URL and strips characters browsers
1051
+ ignore in the scheme, so obfuscations like `javascript&#58;…`,
1052
+ `javascript\:…`, `&#106;avascript:…`, and embedded tabs/newlines are caught,
1053
+ not just the literal form. (See `crates/brookmd-core/tests/security.rs`.)
1054
+ - **`htmlToReact` defends in depth**: it drops inline `on*` event-handler
1055
+ attributes and runs URL attributes through the same scheme filter. It's
1056
+ intended for brookmd's own (already-sanitized) HTML; if you hand it arbitrary
1057
+ third-party HTML, these guards are your only line of defense — prefer a
1058
+ dedicated HTML sanitizer for genuinely hostile input.
1059
+
1060
+ ### Rendering untrusted / LLM HTML safely
1061
+
1062
+ If you enable `unsafeHtml` to render HTML from an untrusted source (e.g. an LLM
1063
+ that returns raw HTML), **bring a real sanitizer** and pass it via
1064
+ `<BrookMarkdown sanitize={…} />`. brookmd applies it to every block's HTML before
1065
+ injection — **including the streaming (open) tail**, which the raw-`innerHTML`
1066
+ fast path would otherwise expose. brookmd stays zero-dep; you choose the
1067
+ sanitizer. The realistic pattern (matches the live demo):
1068
+
1069
+ ```tsx
1070
+ import DOMPurify from "dompurify";
1071
+
1072
+ // Hoist to module scope (or wrap in useCallback). A fresh arrow each render
1073
+ // busts BrookMarkdown's per-block memo and re-runs every block through sanitize.
1074
+ const sanitize = (html: string) => DOMPurify.sanitize(html);
1075
+
1076
+ // …then in your component:
1077
+ <BrookMarkdown client={client} sanitize={sanitize} />
1078
+ ```
1079
+
1080
+ The built-in code/math renderers operate on already-escaped content and are not
1081
+ run through `sanitize`, so syntax highlighting and math markup are preserved.
1082
+ With no `sanitize` prop, rendering is byte-identical and zero-cost. For
1083
+ genuinely hostile content where CSS-overlay/clickjacking matters, render inside
1084
+ a sandboxed `<iframe>` instead — sanitization stops injection, not every
1085
+ visual-overlay trick.
1086
+
1087
+ ### Supply chain & security posture
1088
+
1089
+ brookmd ships **zero runtime dependencies** — `dependencies` and
1090
+ `optionalDependencies` in `package.json` are both empty. The parsing core is Rust
1091
+ compiled to WebAssembly, reproducibly buildable from `crates/brookmd-core/` via
1092
+ `bun run build:wasm`. The package publishes **compiled, non-minified ESM**
1093
+ (`dist/*.js` + `.d.ts`); it does not ship raw `.ts`/`.tsx` source.
1094
+
1095
+ **Frameworks are optional peers, by design.** `react`, `vue`, `svelte`, and
1096
+ `solid-js` are declared as `peerDependencies` with
1097
+ `peerDependenciesMeta.optional: true`. You install only the one you use — or none
1098
+ (the `brookmd/dom` and `brookmd/element` entries need no framework at all). This
1099
+ is the most important supply-chain property of the package: **a React-only
1100
+ consumer never installs `vue` or `solid-js`, so those frameworks' transitive
1101
+ internals never enter that consumer's lockfile.** `npm i brookmd` on its own pulls
1102
+ in nothing else.
1103
+
1104
+ **Why a registry scan may flag `seroval` and `@vue/compiler-*`.** When a scanner
1105
+ resolves *all* declared peers, it surfaces alerts on framework internals reachable
1106
+ only through the optional peers — **none of which is brookmd code, and none of
1107
+ which is installed unless you opt into that framework:**
1108
+
1109
+ - `seroval` (transitive of **solid-js**) — its `deserialize()` uses
1110
+ `(0, eval)(source)` and touches the network. This is Solid's SSR serialization
1111
+ layer; it is also the package some scanners label a "potential vulnerability".
1112
+ - `@vue/compiler-core` (transitive of **vue**) — uses the `Function` constructor
1113
+ for template codegen.
1114
+ - `@vue/compiler-sfc` (transitive of **vue**) — references `globalThis["fetch"]`.
1115
+ - minified esm-bundler builds of those compilers read as "obfuscated code".
1116
+
1117
+ brookmd's own source contains **no `eval` and no `Function(...)` constructor**
1118
+ (`grep -rnE '\beval\s*\(|\bnew Function\b|\bFunction\s*\(' packages/brookmd/src`
1119
+ returns nothing). The
1120
+ repository's [`socket.yml`](https://github.com/siinghd/brookmd/blob/main/socket.yml)
1121
+ documents this and disables those upstream-framework alert types for brookmd's own
1122
+ CI (which installs every framework as a devDependency for cross-framework tests).
1123
+ If you prefer surgical handling, ignore the specific transitive packages instead
1124
+ (e.g. `@SocketSecurity ignore seroval@<version>`).
1125
+
1126
+ **brookmd is browser-oriented (Web Worker + WASM).** The default path runs the
1127
+ WASM parser inside a Web Worker — ideal for browsers and modern Node
1128
+ (`worker_threads`), but **not** intended for non-browser or older environments
1129
+ that lack Workers/WASM. If you need a worker-free, synchronous path (Node SSR /
1130
+ React Server Components), use **`brookmd/server`** — it loads the same WASM
1131
+ synchronously off disk and renders to a string without spawning a worker.
1132
+
1133
+ **First-party signals a scanner will (correctly) show.** These describe brookmd
1134
+ itself and are kept *visible* rather than silenced:
1135
+
1136
+ - **Native code (`hasNativeCode`).** The first-party `dist/wasm/brook_md_core_bg.wasm`
1137
+ (~180 KB) is built from the Rust source in this repo and runs inside a sandboxed
1138
+ Web Worker (browser) or Node worker thread. It is reproducible from source, not a
1139
+ vendored third-party binary.
1140
+ - **Network access (`networkAccess`).** Only `<brook-markdown src="URL">` (the URL
1141
+ *you* supply) and the wasm-bindgen glue loading the co-located `.wasm` via
1142
+ `fetch(new URL("…_bg.wasm", import.meta.url))` — which bundlers resolve to a
1143
+ local build artifact. No telemetry, no analytics, no first-party remote
1144
+ endpoints. In privileged contexts (browser extensions, Electron) treat the `src`
1145
+ value as any external URL and allowlist it in your CSP.
1146
+ - **Filesystem access (`filesystemAccess`).** Node/SSR only: `brookmd/server` reads
1147
+ the package's own `.wasm` off disk (Node's `fetch` cannot load `file://` URLs).
1148
+ It reads only the package-internal asset, never a caller-supplied path.
1149
+
1150
+ The `socket.yml` at the repository root documents every signal with its
1151
+ justification for Socket's GitHub app.
1152
+
1153
+ ## Scaling
1154
+
1155
+ `BrookClient`s share a **worker pool** (`getDefaultPool()`), so concurrency
1156
+ doesn't oversubscribe OS threads. Worker creation is lazy and load-aware:
1157
+
1158
+ - **1 stream → 1 worker**, and each new stream gets its own worker until the cap
1159
+ (`Math.min(navigator.hardwareConcurrency || 4, 8)`) — identical to the
1160
+ per-worker behavior for small stream counts.
1161
+ - **Past the cap**, new streams attach to the least-loaded worker, which
1162
+ multiplexes them (a `BrookParser` per stream id). So **50 concurrent streams
1163
+ run on ≤8 workers (~6 each)**, not 50 threads.
1164
+
1165
+ `destroy()` frees a stream's parser and keeps the worker warm for its siblings;
1166
+ the workers persist for the life of the page. Need isolation or manual
1167
+ teardown? Construct your own `new BrookPool(factory, cap)` and pass it to
1168
+ `new BrookClient(pool)`, or call `pool.disposeAll()`.
1169
+
1170
+ `getDefaultPool()` is **browser-only** (it constructs `Worker`s) and is a
1171
+ **per-page singleton** — don't rely on it in SSR/RSC. For isolation between
1172
+ independent feature areas, give each its own `new BrookPool()`.
1173
+
1174
+ **Warm the pool to hide WASM init.** The one-time WASM load happens on the first
1175
+ worker-bound op, which lands on the first-token critical path. Call
1176
+ `getDefaultPool().warm()` on app load / route entry to start it early — the warm
1177
+ worker is the one the first stream attaches to, so the init isn't wasted:
1178
+
1179
+ ```ts
1180
+ import { getDefaultPool } from "brookmd";
1181
+ useEffect(() => { getDefaultPool().warm(); }, []); // (or your framework's mount hook)
1182
+ ```
1183
+
1184
+ ### Long documents — `virtualize`
1185
+
1186
+ For very long documents (hundreds+ of blocks), pass `virtualize` to apply CSS
1187
+ `content-visibility: auto` (+ a per-kind `contain-intrinsic-size`) to **closed**
1188
+ blocks, so the browser skips style/layout/paint for off-screen content:
1189
+
1190
+ ```tsx
1191
+ <BrookMarkdown client={client} virtualize />
1192
+ ```
1193
+
1194
+ It's opt-in (off by default — short docs gain nothing) and never defers the
1195
+ streaming tail (open/speculative blocks always render fully, so no flicker
1196
+ where you're looking). It cuts **rendering cost, not DOM node count** — nodes
1197
+ stay in the document (search, anchors, and a11y all keep working), they just
1198
+ don't lay out while off-screen. Measured on a ~1800-block demo, an off-screen
1199
+ **layout pass is ~7× cheaper** (≈1980ms → ≈284ms over 30 forced relayouts),
1200
+ identical node count — i.e. whenever the browser would otherwise lay out
1201
+ off-screen blocks (initial paint, resize, font load, scroll), that work is
1202
+ skipped. No JS windowing, no scroll math, no dep — the browser does it natively.
1203
+
1204
+ Works best when `<BrookMarkdown>`'s parent uses normal block flow; a `flex`/`grid`
1205
+ parent can interact with `contain-intrinsic-size` in surprising ways.
1206
+
1207
+ ### Stick to bottom while streaming — `stickToBottom`
1208
+
1209
+ Pass `stickToBottom` and the view **follows the streaming tail, releasing when
1210
+ the user scrolls up** (and re-locking when they scroll back near the bottom) —
1211
+ the behavior every chat UI wants. It's **CSS-only** (CSS Scroll Snap, no JS, no
1212
+ scroll listeners): brookmd emits a bottom snap target; you add one line to your
1213
+ scroll container:
1214
+
1215
+ ```tsx
1216
+ <div className="chat-scroller"> {/* your existing scroll container */}
1217
+ <BrookMarkdown client={client} stickToBottom />
1218
+ </div>
1219
+ ```
1220
+ ```css
1221
+ .chat-scroller { overflow-y: auto; scroll-snap-type: y proximity; }
1222
+ ```
1223
+
1224
+ That's the whole feature. `proximity` (not `mandatory`) is what lets the user
1225
+ scroll up freely. Note it **follows** the bottom — during very fast streaming
1226
+ the lock can lag by a few px between snaps; it doesn't *hard-pin*. Re-snap on
1227
+ content growth is solid in Chromium/Firefox; **Safari is weaker** at
1228
+ re-snapping during streaming, so treat smooth following there as best-effort.
1229
+
1230
+ > **Metrics note:** because workers are shared, `getMetrics().wasmMemoryBytes`
1231
+ > is the *shared* worker's heap — clients on the same worker report the same
1232
+ > value. Aggregate with `Math.max`, not a sum.
1233
+
1234
+ ## Architecture
1235
+
1236
+ ```
1237
+ ┌── main thread ────────────────────────┐
1238
+ │ BrookMarkdown — React, useSyncStore │
1239
+ │ BrookClient — message routing │
1240
+ └──┬──── postMessage(chunk) ────────────┘
1241
+
1242
+ ┌── Web Worker ─────────────────────────┐
1243
+ │ worker.ts — coalesces chunks per │
1244
+ │ microtask, calls WASM │
1245
+ └──┬──── ffi ───────────────────────────┘
1246
+
1247
+ ┌── Rust → WASM (~170 KB after opt) ────┐
1248
+ │ StreamParser: │
1249
+ │ buffer: append-only │
1250
+ │ committed_offset │
1251
+ │ [committed_blocks] │
1252
+ │ [active_blocks] (re-parsed tail) │
1253
+ │ │
1254
+ │ scanner.rs → raw blocks │
1255
+ │ inline.rs → emphasis stack + safe │
1256
+ │ link/code rendering │
1257
+ │ render.rs → HTML with URL sanitize │
1258
+ └───────────────────────────────────────┘
1259
+ ```
1260
+
1261
+ Active tail re-parses on each chunk; committed blocks are frozen forever. Each block's ID is monotonic and is *preserved* across re-parses when its start offset and kind match a previously-seen active block — so React's keyed reconciliation reuses the DOM instead of remounting.
1262
+
1263
+ ## License
1264
+
1265
+ MIT.