brookmd 0.29.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,37 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
132
132
  }
133
133
  ```
134
134
 
135
+ ### Chat UI defaults
136
+
137
+ Five flags an LLM chat UI almost always wants — each off by default because the
138
+ library's default is strict CommonMark, not because it is the better choice here:
139
+
140
+ ```tsx
141
+ import { getDefaultPool } from "brookmd";
142
+ import { BrookMarkdown, useBrookStream } from "brookmd/react";
143
+ import { useEffect } from "react";
144
+
145
+ // Hoist the config and the overrides — a fresh object each render busts the
146
+ // per-block memo, so every block re-renders on every patch.
147
+ const chatConfig = {
148
+ softBreaks: true, // a lone \n renders as <br> — models write chat prose, not CommonMark
149
+ dirAuto: true, // per-block dir="auto", so an Arabic answer renders RTL beside an English one
150
+ a11y: true, // task-list <label>s + <th scope="col">
151
+ blockData: true, // typed props.table / heading / code — toolbars from data, not HTML re-parsing
152
+ gfmMath: true, // $…$ / $$…$$ / \(…\) / \[…\] (only if your model emits LaTeX)
153
+ };
154
+
155
+ export function Answer({ stream }: { stream: AsyncIterable<string> }) {
156
+ // Hide the one-time WASM init behind the user's typing, not the first token.
157
+ useEffect(() => { getDefaultPool().warm(); }, []);
158
+ const client = useBrookStream(stream, { config: chatConfig });
159
+ return <BrookMarkdown client={client} className="brook-caret" stickToBottom />;
160
+ }
161
+ ```
162
+
163
+ `className="brook-caret"` opts into the theme's [streaming
164
+ caret](#streaming-caret); drop it if you draw your own.
165
+
135
166
  ### Already holding a growing string? — `useBrookMarkdownString`
136
167
 
137
168
  Many apps keep the streaming message as a **single growing string prop** (it
@@ -169,6 +200,48 @@ use it from any binding.
169
200
  > changed blocks re-render. (`setContent("")` is an explicit clear and resets
170
201
  > immediately.)
171
202
 
203
+ ### With the Vercel AI SDK (`useChat`)
204
+
205
+ `useChat` hands you each message as `parts`, and the assistant's text part grows
206
+ token by token — exactly the controlled-string shape above. Join the text parts
207
+ and pass the result straight in:
208
+
209
+ ```tsx
210
+ import { useChat } from "@ai-sdk/react";
211
+ import { BrookMarkdown, useBrookMarkdownString } from "brookmd/react";
212
+
213
+ // Hoisted — see the memoization note in `components`.
214
+ const components = { a: (p: any) => <a {...p} /> };
215
+
216
+ export function Thread() {
217
+ const { messages, status } = useChat();
218
+ // `status` describes the LAST message only — earlier ones are finished.
219
+ return messages.map((m, i) => (
220
+ <Answer key={m.id} message={m} streaming={status === "streaming" && i === messages.length - 1} />
221
+ ));
222
+ }
223
+
224
+ function Answer(props: {
225
+ message: { parts: Array<{ type: string; text?: string }> };
226
+ streaming: boolean;
227
+ }) {
228
+ const text = props.message.parts.map((p) => (p.type === "text" ? p.text ?? "" : "")).join("");
229
+ const client = useBrookMarkdownString(text, { streaming: props.streaming });
230
+ return <BrookMarkdown client={client} components={components} />;
231
+ }
232
+ ```
233
+
234
+ > **Pass `streaming: false` when the message finishes — it is not inferred.**
235
+ > Omit it (or leave it `true`) and the stream stays OPEN forever: the last block
236
+ > never commits, so a finished code fence never highlights and never shows its
237
+ > copy button, and a streaming caret never stops blinking. brookmd deliberately
238
+ > refuses to infer "done" from an unchanged string — a caller who grows the text
239
+ > without the flag would re-finalize on every token, an O(n²) reparse trap.
240
+
241
+ One client per message, so re-rendering the thread never re-parses history, and
242
+ `getDefaultPool().warm()` in the chat shell (see [Chat UI
243
+ defaults](#chat-ui-defaults)) keeps WASM init off the first answer.
244
+
172
245
  <details>
173
246
  <summary>Full manual control (caller-owned client)</summary>
174
247
 
@@ -246,10 +319,18 @@ so just call **`client.setContent(fullString, { done })`** instead of the
246
319
  finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
247
320
  controlled-string helpers wrap; in vanilla you call it directly.
248
321
 
249
- `mountBrookMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
250
- Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
251
- (default true), `streamingHighlight` (default true highlight a code fence while
252
- it is still streaming; see [Streaming syntax highlighting](#streaming-syntax-highlighting)),
322
+ `mountBrookMarkdown(client, container, options?)` returns
323
+ `{ destroy(), refresh(), openBlockId() }` `openBlockId()` is the id of the
324
+ streaming **tail** block (the only one that can still re-render), or `null` when
325
+ nothing is open.
326
+ Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `className`,
327
+ `id`, `role`, `ariaLive`, `ariaAtomic`, `decorators`, `urlTransform`,
328
+ `onRenderMetrics`, `onLinkClick` (*since 0.30.0* — same delegated hook as React,
329
+ called with the native `MouseEvent`; see [Intercepting link
330
+ clicks](#intercepting-link-clicks--onlinkclick)), `highlightCode`
331
+ (default true), `streamingHighlight` (`boolean | "wavefront" | "eager"`, default
332
+ true — highlight a code fence while it is still streaming; see
333
+ [Streaming syntax highlighting](#streaming-syntax-highlighting)),
253
334
  `batch` (default true — one DOM write per `requestAnimationFrame`),
254
335
  `morphOpenBlocks` (default false — morph a growing generic open block's subtree in
255
336
  place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
@@ -295,6 +376,21 @@ SSR (no `customElements`). A self-owned element (`src` / `markdown` / inline
295
376
  text / `append()`) is torn down on disconnect; a caller-supplied `client` is left
296
377
  alone.
297
378
 
379
+ **The full attribute surface:**
380
+
381
+ | Attributes | |
382
+ |---|---|
383
+ | content | `markdown`, `src` |
384
+ | renderer | `stick-to-bottom`, `virtualize` *(both since 0.30.0)* |
385
+ | config (tri-state) | `gfm-autolinks`, `gfm-alerts`, `gfm-tagfilter`, `gfm-footnotes`, `gfm-math`, `dir-auto`, `lenient-lists`, `soft-breaks`, `a11y`, `unsafe-html`, `block-html`, `retain-committed-html` |
386
+ | config (lists) | `component-tags`, `allow-schemes` — comma- or space-separated |
387
+
388
+ Properties (set them in JS, not as attributes): `client`, `components`,
389
+ `sanitize`, `onLinkClick` *(since 0.30.0)*. Methods: `append()`, `finalize()`,
390
+ `reset()`, `getClient()`. Config flags with no attribute of their own
391
+ (`inlineComponentTags`, `htmlAllowlist`, `dropHtmlTags`, `blockData`) are set by
392
+ assigning a caller-owned `client` you constructed with that config.
393
+
298
394
  **Angular** consumes the same element — no separate package:
299
395
 
300
396
  ```ts
@@ -557,8 +653,118 @@ dark automatically via `prefers-color-scheme` (force a mode with
557
653
  }
558
654
  ```
559
655
 
656
+ ### What the theme covers
657
+
658
+ Document elements, the highlighter's token colours (`.t-kw`, `.t-str`, …), the
659
+ pending-link marker — and *(since 0.30.0)* block spacing via `.brook-block` plus
660
+ the chrome the renderers emit: the code-block header and its controls
661
+ (`.brook-code-header`, `.brook-code-lang`, `.brook-code-copy`,
662
+ `.brook-code-streaming-pill`, `.brook-code-body`), the math and mermaid slots
663
+ (`.brook-math-*`, `.brook-mermaid-*`), GitHub-style alerts (`.markdown-alert*`),
664
+ and the footnote section. Writing your own CSS instead? Those are the class names
665
+ to target.
666
+
667
+ ### Block-state classes
668
+
669
+ Every block the generic renderer emits is wrapped in a state-carrying element,
670
+ and these names are a **stable styling contract** — React, the DOM mount, and
671
+ the server renderer all emit the same ones:
672
+
673
+ | Class | Where | Means |
674
+ |---|---|---|
675
+ | `brook-md` | root | always present; `className` is appended to it |
676
+ | `brook-block` | every block wrapper | — |
677
+ | `brook-block-<kind>` | every block wrapper | the lowercased block kind: `brook-block-paragraph`, `brook-block-codeblock`, `brook-block-table`, `brook-block-mathblock`, … |
678
+ | `brook-open` | the streaming tail block | still growing — its HTML may change on the next patch |
679
+ | `brook-speculative` | a block closed by inference | may still be revised |
680
+ | `brook-streaming` | the code / math / mermaid slot | that renderer's own still-arriving state |
681
+ | `brook-bottom-anchor` | the `stickToBottom` sentinel | — |
682
+ | `brook-deferred` | root, while `deferTail` is deferring | — |
683
+
684
+ Use them to gate anything that must not run on half-arrived content — a KaTeX or
685
+ Mermaid pass skips `.brook-open` / `.brook-streaming` (see the [KaTeX
686
+ recipe](#math-with-katex)) — and to style the tail differently from settled text.
687
+
688
+ ### Streaming caret
689
+
690
+ The theme ships an **opt-in** caret that follows the streaming tail. Add the
691
+ `brook-caret` class to the root; nothing else changes, and the rendered HTML is
692
+ identical with or without it:
693
+
694
+ ```tsx
695
+ <BrookMarkdown client={client} className="brook-caret" />
696
+ ```
697
+
698
+ ```ts
699
+ mountBrookMarkdown(client, el, { className: "brook-caret" });
700
+ ```
701
+
702
+ It is a `::after` bar on the last text element inside the `brook-open` block, so
703
+ exactly one caret is on screen. Code, math, and mermaid fences don't get one —
704
+ they already show a "streaming" pill. Retint it with `--brook-caret`, and it
705
+ stops blinking under `prefers-reduced-motion`. Bringing your own CSS? The
706
+ [block-state classes](#block-state-classes) are all it is built from.
707
+
708
+ ### Tailwind / design systems
709
+
710
+ Two hooks, no wrapper components: `className` on the root, and the **element
711
+ path** of the [`components`](#custom-components--overrides) map for everything
712
+ inside a block. Overrides apply to the OPEN (streaming) block too, so the tail is
713
+ styled the whole way down instead of popping into place when it settles:
714
+
715
+ ```tsx
716
+ import { BrookMarkdown, type Components } from "brookmd";
717
+
718
+ // HOIST it (module scope) or memoize. A fresh object each render busts the
719
+ // per-block memo, so every block re-parses on every patch — the single most
720
+ // expensive mistake you can make with this API.
721
+ const components: Components = {
722
+ p: (p) => <p className="my-3 leading-7" {...p} />,
723
+ ul: (p) => <ul className="my-3 list-disc pl-6" {...p} />,
724
+ ol: (p) => <ol className="my-3 list-decimal pl-6" {...p} />,
725
+ li: (p) => <li className="my-1" {...p} />,
726
+ a: (p) => <a className="text-sky-600 underline underline-offset-2" {...p} />,
727
+ h1: (p) => <h1 className="mt-6 text-2xl font-semibold" {...p} />,
728
+ h2: (p) => <h2 className="mt-5 text-xl font-semibold" {...p} />,
729
+ h3: (p) => <h3 className="mt-4 text-lg font-semibold" {...p} />,
730
+ table: (p) => <table className="w-full border-collapse text-sm" {...p} />,
731
+ code: (p) => <code className="rounded bg-slate-100 px-1 py-0.5" {...p} />,
732
+ blockquote: (p) => <blockquote className="border-l-4 pl-4 italic" {...p} />,
733
+ };
734
+
735
+ <BrookMarkdown client={client} components={components} className="text-slate-900" />;
736
+ ```
737
+
738
+ > **Using `@tailwindcss/typography`? Skip the theme import.** `.brook-md` is a
739
+ > plain `<div>`, so `class="prose brook-md"` works — but `brookmd/styles.css`
740
+ > resets `.brook-md > *` margins and sets its own type scale, which fights
741
+ > `prose`'s spacing. Pick one: the theme, or `prose` plus the token-colour
742
+ > variables (`--brook-t-kw`, …) if you still want the built-in highlighter's
743
+ > colours.
744
+
560
745
  ## Public API
561
746
 
747
+ ### All entry points
748
+
749
+ Every subpath is independently importable; you pay only for what you import.
750
+
751
+ | Entry | What it is |
752
+ |---|---|
753
+ | `brookmd` | the common surface: `BrookClient`, `BrookPool`, `getDefaultPool`, `sourceFingerprint`, `BrookMarkdown`, `useBrookStream`, `useBrookMarkdownString`, `highlight`, `supportedLangs`, `htmlToReact`, `parseTrustedHtml`, `safeUrl`, `wrapLink` + the types (re-exports React, so it pulls `react`) |
754
+ | `brookmd/client` | framework-free core — `BrookClient`, `BrookPool`, `getDefaultPool`, `applyPatch`, `emptyBlockStore` |
755
+ | `brookmd/react` | `BrookMarkdown`, `useBrookStream`, `useBrookMarkdownString`, `blockKindProps` |
756
+ | `brookmd/server` | worker-free, **React-free** one-shot: `initBrook`, `initBrookSync`, `isBrookReady`, `renderToString`, `parseToBlocks` |
757
+ | `brookmd/server/react` | `BrookMarkdownStatic` — hookless, RSC-safe |
758
+ | `brookmd/dom` | `mountBrookMarkdown`, `tailOpenBlockId` |
759
+ | `brookmd/element` | `defineBrookMarkdown` (the `<brook-markdown>` Web Component) |
760
+ | `brookmd/vue` · `/svelte` · `/solid` | the framework bindings |
761
+ | `brookmd/highlight` | `highlight`, `supportedLangs`, `registerLanguage` *(since 0.30.0)* |
762
+ | `brookmd/html-to-react` | `htmlToReact`, `parseTrustedHtml`, `wrapLink`, `safeUrl` — render one block's HTML to a React tree yourself |
763
+ | `brookmd/block-props` | `blockProps`, `extractLang`, `htmlAttrs` — the framework-neutral block→props mapping the DOM renderer uses |
764
+ | `brookmd/worker-core` | `WorkerCore` — the worker's state machine, for hosting the parser in your own worker/runtime |
765
+ | `brookmd/types` | every type, value-free (`Block`, `ParserConfig`, `RenderMetrics`, `ListItemData`, `LinkClickInfo`, the wire types, …) |
766
+ | `brookmd/styles.css` | the optional theme |
767
+
562
768
  ### `BrookClient`
563
769
 
564
770
  ```ts
@@ -568,6 +774,7 @@ class BrookClient {
568
774
  config?: ParserConfig;
569
775
  onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
570
776
  onBlock?: (block: Block) => void; // fires once per block as it commits
777
+ coalesce?: boolean; // one rAF-scheduled notify per frame (default false)
571
778
  recovery?: boolean; // auto-heal a transient worker death (default true)
572
779
  });
573
780
  get failed(): Error | null; // terminal worker failure, else null (null through heals)
@@ -583,6 +790,7 @@ class BrookClient {
583
790
  ): void; // done:true → finalize
584
791
  reset(): void; // wipe and reuse
585
792
  destroy(): void; // free this stream's parser
793
+ reattach(): void; // re-register after destroy() (StrictMode double-mount)
586
794
  whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
587
795
  subscribe(listener: () => void): () => void; // React-friendly store
588
796
  getSnapshot(): Block[]; // ordered current blocks
@@ -611,6 +819,21 @@ failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
611
819
  load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
612
820
  effect each time a block commits (e.g. lazy-highlight a finished code block).
613
821
 
822
+ Pass **`coalesce: true`** to collapse every patch that lands inside one frame
823
+ into a single `requestAnimationFrame`-scheduled notification, so a
824
+ `useSyncExternalStore` consumer renders at most once per frame instead of once
825
+ per patch. It is lossless (committed blocks are reference-stable, so only
826
+ superseded tail renders are skipped), the finalize patch always flushes
827
+ synchronously, and it degrades to synchronous emits where `requestAnimationFrame`
828
+ is unavailable (SSR, tests). The React hooks that own a client
829
+ (`useBrookStream` / `useBrookMarkdownString`) already set it; a client you
830
+ construct yourself defaults to `false`.
831
+
832
+ **`reattach()`** re-registers a client with the pool after `destroy()`. It exists
833
+ for React StrictMode's dev double-mount (destroy on the simulated unmount, then
834
+ the SAME instance remounts) — apps don't normally call it, and it is a no-op
835
+ while still attached.
836
+
614
837
  A **transient worker death** heals invisibly by default: if a worker dies
615
838
  mid-stream (e.g. a stale hashed worker URL 404s after a redeploy), the client
616
839
  buffers the driven document, re-acquires a fresh worker, and re-feeds it once —
@@ -632,7 +855,7 @@ const client = new BrookClient({
632
855
  gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
633
856
  gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
634
857
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
635
- softBreaks: true, // a single \n renders as <br> (remark-breaks / chat convention; default false)
858
+ softBreaks: true, // a single \n renders as <br> (the chat convention; default false)
636
859
  lenientLists: true, // marker + 6+ SPACES → item text, not indented code (default false)
637
860
  a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
638
861
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
@@ -643,6 +866,7 @@ const client = new BrookClient({
643
866
  blockHtml: true, // extend the sanitizer to BLOCK raw HTML (<details>…); needs a list above (default false)
644
867
  allowSchemes: ["file"], // un-block a default-blocked URL scheme (default none — see "Security")
645
868
  blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
869
+ retainCommittedHtml: false, // keep committed HTML inside the parser too (default false on the streaming path)
646
870
  },
647
871
  });
648
872
  ```
@@ -721,6 +945,14 @@ When to enable each flag:
721
945
  for privileged hosts (Electron, extensions) that intercept link clicks instead
722
946
  of navigating. Script-executing schemes can never be re-enabled. See
723
947
  [Un-blocking a scheme](#un-blocking-a-scheme--allowschemes).
948
+ - `retainCommittedHtml: true` — keep every committed block's rendered HTML
949
+ **inside the parser** as well. Off on the streaming path, which is what you
950
+ want: the client receives each committed block exactly once and stores it
951
+ itself, so a second copy in WASM serves nobody — dropping it roughly halves a
952
+ long stream's `getMetrics().retainedBytes`, and the wire is byte-identical
953
+ either way. Turn it on only if something reads the whole rendered document back
954
+ out of the parser. The server renderers (`renderToString` / `parseToBlocks`) do
955
+ exactly that and pin it on regardless of what you pass.
724
956
 
725
957
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
726
958
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
@@ -738,7 +970,7 @@ nested footnotes. The section uses GitHub-style markup
738
970
  `<span class="math math-inline">…</span>`, display math to
739
971
  `<div class="math math-display">…</div>` (and inline display to a `math-display`
740
972
  span), each carrying the **HTML-escaped LaTeX as its text content** — exactly
741
- what [KaTeX](https://katex.org)'s auto-render / `rehype-katex` consume. brookmd
973
+ what [KaTeX](https://katex.org)'s auto-render expects. brookmd
742
974
  stays **zero-dep**: it produces the KaTeX-ready markup and never processes the
743
975
  body as markdown; you bring the KaTeX pass (or override `components.MathBlock`,
744
976
  which receives the raw LaTeX as `text`). Single `$` uses the **pandoc rule** so
@@ -766,12 +998,57 @@ Subscribes to a `BrookClient`, renders each block keyed by its stable parser-ass
766
998
  <BrookMarkdown client={client} />
767
999
  ```
768
1000
 
769
- The root element accepts opt-in `className` (appended to `brookmd`), `id`,
770
- `role`, and `aria-live` / `aria-atomic`. Set `aria-live="polite"` to make the
771
- output a live region so screen readers announce streamed content as it settles —
772
- `polite` coalesces rapid updates and does **not** read every token. The same
773
- options exist on the DOM mount (`mountBrookMarkdown(client, el, { ariaLive: "polite" })`),
774
- covering the Web Component and the Vue/Svelte/Solid adapters.
1001
+ The root element accepts opt-in `className` (appended to the always-present
1002
+ `brook-md` root class), `id`, `role`, and `aria-live` / `aria-atomic`. Set
1003
+ `aria-live="polite"` to make the output a live region so screen readers announce
1004
+ streamed content as it settles — `polite` coalesces rapid updates and does **not**
1005
+ read every token. The same options exist on the DOM mount
1006
+ (`mountBrookMarkdown(client, el, { ariaLive: "polite" })`), covering the Web
1007
+ Component and the Vue/Svelte/Solid adapters.
1008
+
1009
+ #### Props
1010
+
1011
+ | Prop | Type | Default | What it does |
1012
+ |---|---|---|---|
1013
+ | `client` | `BrookClient` | — | A client you own and drive; the component never destroys it. |
1014
+ | `stream` | `AsyncIterable<string> \| ReadableStream<Uint8Array> \| Response` | — | 1-line mode: the component owns an internal client. Exactly one of `client` / `stream` is required (neither → throws); `client` wins if both are given. |
1015
+ | `streamConfig` | `ParserConfig` | — | [Per-stream config](#per-stream-config) for that internally created client (stream mode only). |
1016
+ | `onStreamError` | `(err: Error) => void` | — | The `stream` source rejected. Worker/parse errors go to the client's `onError` instead. |
1017
+ | `components` | `Components` | — | [Overrides](#custom-components--overrides). **Hoist it.** |
1018
+ | `decorators` | `Decorator[]` | — | [Inline text decorators](#inline-text-decorators). **Hoist it.** |
1019
+ | `urlTransform` | `UrlTransform` | — | Rewrite `href`/`src`/`poster`; the output is re-sanitized. **Hoist it.** |
1020
+ | `sanitize` | `(html: string) => string` | — | Runs on every block's HTML **including the open tail**. **Hoist it.** |
1021
+ | `streamingHighlight` | `boolean \| "wavefront" \| "eager"` | `"wavefront"` | [Where the colour front sits](#where-the-colour-front-sits-wavefront-default-vs-eager). |
1022
+ | `virtualize` | `boolean` | `false` | `content-visibility: auto` on closed blocks — [long documents](#long-documents--virtualize). |
1023
+ | `stickToBottom` | `boolean` | `false` | Emits the scroll-snap anchor — [stick to bottom](#stick-to-bottom-while-streaming--sticktobottom). |
1024
+ | `deferTail` | `boolean` | `false` | Route the block list through React's `useDeferredValue`, so a burst of patches can yield to higher-priority updates; the root carries `brook-deferred` while a deferred render is in flight. Commit timing only — output is unchanged. rAF coalescing (`coalesce`) is the preferred way to absorb patch bursts. |
1025
+ | `childMemo` | `boolean` | `false` | On an OPEN block, reuse the React nodes of top-level children whose HTML hasn't changed and re-parse only the new trailing content. Applies only when `components`/`sanitize` route the block off the `innerHTML` fast path; byte-identical either way. Worth it for a long, slowly growing streamed block under a custom map. |
1026
+ | `className` / `id` / `role` | `string` | — | Set on the root; `className` is appended to `brook-md`. |
1027
+ | `aria-live` / `aria-atomic` | `"off" \| "polite" \| "assertive"` / `boolean` | off | Live-region attributes — see [Accessible chat](#accessible-chat). |
1028
+ | `onLinkClick` | `(event, link) => void` | — | Delegated link clicks — see [Intercepting link clicks](#intercepting-link-clicks--onlinkclick). *(since 0.30.0)* |
1029
+ | `onRenderMetrics` | `RenderMetricsHook` | — | Fires once per ACTUAL block render with `{ renderCount, speculativeToggleCount, lastRenderMs, kind }` — render-churn instrumentation; a committed block that memo-skips never fires. Zero cost when omitted. **Hoist it.** |
1030
+ | `onBlockError` | `(error, info) => void` | — | Per-block error boundary hook — see [`onBlockError`](#onblockerror). **Hoist it.** |
1031
+
1032
+ #### Accessible chat
1033
+
1034
+ Make the assistant's message a polite live region and mark it busy while it
1035
+ streams:
1036
+
1037
+ ```tsx
1038
+ <div role="log" aria-live="polite" aria-busy={streaming}>
1039
+ <BrookMarkdown client={client} />
1040
+ </div>
1041
+ ```
1042
+
1043
+ - `aria-live="polite"` announces content as it settles rather than reading every
1044
+ token. Put it on the container you own (as above) or on the root via the
1045
+ `aria-live` prop — one live region, not both.
1046
+ - Turn on `a11y: true` in the [per-stream config](#per-stream-config): task-list
1047
+ checkboxes get a `<label>` (so the checkbox and its text are associated) and
1048
+ table headers get `scope="col"`.
1049
+ - Flip `aria-busy` back to `false` when the stream finalizes, so assistive tech
1050
+ knows the message is complete. Where focus goes when a message lands is your
1051
+ app shell's decision, not the renderer's.
775
1052
 
776
1053
  #### Custom components / overrides
777
1054
 
@@ -853,7 +1130,7 @@ filename header (the alert type is at `block.kind.data.kind`).
853
1130
 
854
1131
  Rules worth knowing:
855
1132
 
856
- - **There is no `node` prop / no hast tree.** Introspect via `className` /
1133
+ - **There is no `node` prop / no syntax tree.** Introspect via `className` /
857
1134
  `data-*`, or — better — opt into the typed **[structured-data
858
1135
  channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
859
1136
  `block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
@@ -911,13 +1188,160 @@ URLs as blocks render (proxy images, add UTM params). Its output is re-sanitized
911
1188
  `javascript:` / `data:text/html` URL. Hoist/memoize it for the same reason as
912
1189
  `decorators`.
913
1190
 
1191
+ #### Math with KaTeX
1192
+
1193
+ With `gfmMath: true` brookmd emits KaTeX-ready markup and stays zero-dep — you
1194
+ run the typesetting pass. Typeset each `.math` element **once its block has
1195
+ closed**: an open block holds partial LaTeX, and feeding KaTeX a half-typed
1196
+ formula throws on every patch. One observer over the scroller covers every
1197
+ message in the thread:
1198
+
1199
+ ```tsx
1200
+ import { useEffect, useRef, type ReactNode } from "react";
1201
+ import katex from "katex";
1202
+
1203
+ export function MathPass({ children }: { children: ReactNode }) {
1204
+ const root = useRef<HTMLDivElement>(null);
1205
+ useEffect(() => {
1206
+ const el = root.current;
1207
+ if (!el) return;
1208
+ const pass = () => {
1209
+ el.querySelectorAll<HTMLElement>(".math:not([data-tex])").forEach((node) => {
1210
+ if (node.closest(".brook-streaming, .brook-open")) return; // still streaming
1211
+ node.setAttribute("data-tex", "1"); // idempotence marker
1212
+ try {
1213
+ katex.render(node.textContent ?? "", node, {
1214
+ displayMode: node.classList.contains("math-display"),
1215
+ throwOnError: false,
1216
+ });
1217
+ } catch {
1218
+ /* leave the raw LaTeX in place */
1219
+ }
1220
+ });
1221
+ };
1222
+ const obs = new MutationObserver(pass);
1223
+ obs.observe(el, { childList: true, subtree: true });
1224
+ pass();
1225
+ return () => obs.disconnect();
1226
+ }, []);
1227
+ return <div ref={root}>{children}</div>;
1228
+ }
1229
+ ```
1230
+
1231
+ The two non-obvious parts are the `.brook-streaming, .brook-open` skip (see
1232
+ [block-state classes](#block-state-classes)) and the `data-tex` marker, which
1233
+ stops the observer re-typesetting what it already rendered. Prefer per-block
1234
+ control? `components.MathBlock` receives the decoded LaTeX as `props.text`.
1235
+
1236
+ #### Mermaid diagrams
1237
+
1238
+ `Mermaid` is a block **slot**: brookmd renders the diagram source and never calls
1239
+ a renderer. Render it as plain text while the fence is open and call
1240
+ `mermaid.render` only once it closes — a half-arrived diagram throws on every
1241
+ patch. The slot carries the fence's rendered `<pre><code>`, so the source is its
1242
+ text content:
1243
+
1244
+ ```tsx
1245
+ import { useEffect, useState } from "react";
1246
+ import mermaid from "mermaid";
1247
+ import type { BlockComponentProps, Components } from "brookmd";
1248
+
1249
+ // brookmd's own escaped output — the text content IS the diagram source.
1250
+ function mermaidSource(html: string): string {
1251
+ const el = document.createElement("div");
1252
+ el.innerHTML = html;
1253
+ return el.textContent ?? "";
1254
+ }
1255
+
1256
+ const components: Components = {
1257
+ Mermaid: ({ html, open, block }: BlockComponentProps) => {
1258
+ const [svg, setSvg] = useState("");
1259
+ const source = mermaidSource(html);
1260
+ useEffect(() => {
1261
+ if (open || !source) return;
1262
+ let live = true;
1263
+ mermaid
1264
+ .render("brook-mermaid-" + block.id, source)
1265
+ .then((r: { svg: string }) => { if (live) setSvg(r.svg); })
1266
+ .catch(() => { /* keep the source on screen */ });
1267
+ return () => { live = false; };
1268
+ }, [open, source, block.id]);
1269
+ if (open || !svg) return <pre className="brook-mermaid-body">{source}</pre>;
1270
+ return <div dangerouslySetInnerHTML={{ __html: svg }} />;
1271
+ },
1272
+ };
1273
+ ```
1274
+
1275
+ #### Bring your own highlighter
1276
+
1277
+ `components.CodeBlock` (or `components.pre` / `components.code`) bypasses the
1278
+ built-in highlighter entirely. If yours is async, render plain while the fence is
1279
+ open and highlight once on close — never per patch:
1280
+
1281
+ ```tsx
1282
+ import { useEffect, useState } from "react";
1283
+ import type { BlockComponentProps, Components } from "brookmd";
1284
+ import { highlightToHtml } from "./my-highlighter"; // (code, lang) => Promise<string>
1285
+
1286
+ const components: Components = {
1287
+ CodeBlock: ({ text = "", language, open }: BlockComponentProps) => {
1288
+ const [html, setHtml] = useState<string | null>(null);
1289
+ useEffect(() => {
1290
+ if (open) return;
1291
+ let live = true;
1292
+ highlightToHtml(text, language || "text").then((h) => { if (live) setHtml(h); });
1293
+ return () => { live = false; };
1294
+ }, [open, text, language]);
1295
+ if (open || html === null) return <pre><code>{text}</code></pre>;
1296
+ return <pre dangerouslySetInnerHTML={{ __html: html }} />;
1297
+ },
1298
+ };
1299
+ ```
1300
+
1301
+ An override gives up the incremental streaming highlighter (the fence stays plain
1302
+ until it closes). If all you need is one more language, keep the fast path and
1303
+ register it instead — see [`registerLanguage`](#highlightcode-lang).
1304
+
1305
+ #### Interactive task lists
1306
+
1307
+ GFM task-list checkboxes are emitted `disabled` (`<input checked="" disabled=""
1308
+ type="checkbox">`) because that is GFM's byte-exact output. Override the `input`
1309
+ tag to make them live:
1310
+
1311
+ ```tsx
1312
+ import type { Components } from "brookmd";
1313
+
1314
+ const components: Components = {
1315
+ input: (p: { type?: string; checked?: boolean }) =>
1316
+ p.type === "checkbox" ? (
1317
+ <input type="checkbox" checked={!!p.checked} onChange={onToggle} />
1318
+ ) : (
1319
+ <input {...p} />
1320
+ ),
1321
+ };
1322
+ ```
1323
+
1324
+ The rendered markup is a **view** of the markdown: to persist a toggle, flip
1325
+ `[ ]` ⇄ `[x]` in your source string and re-feed it with `setContent`. With
1326
+ `blockData: true`, a `List` block's `props.list.items[i].start` is the
1327
+ document-absolute offset of that item's marker, which is enough to locate the
1328
+ checkbox to rewrite (nested items carry no offset).
1329
+
1330
+ #### Lazy images
1331
+
1332
+ ```tsx
1333
+ const components = { img: (p: any) => <img loading="lazy" decoding="async" {...p} /> };
1334
+ ```
1335
+
1336
+ Pair it with `urlTransform` if you also proxy or resize remote image URLs.
1337
+
914
1338
  ### Structured block data (`setBlockData`)
915
1339
 
916
1340
  Set `blockData: true` in the per-stream config and each block carries typed
917
1341
  structured data on `block.kind.data`, also surfaced as typed fields on the
918
1342
  component props — so you build toolbars, tables of contents, charts, copy
919
- buttons, etc. from **data**, never by re-parsing the rendered HTML (no hast tree,
920
- no rehype). Off by default; when off, output and CommonMark/GFM conformance are
1343
+ buttons, etc. from **data**, never by re-parsing the rendered HTML or walking
1344
+ a syntax tree. Off by default; when off, output and CommonMark/GFM conformance are
921
1345
  byte-identical, so non-users pay nothing.
922
1346
 
923
1347
  | Kind | `block.kind.data` | prop | use |
@@ -926,7 +1350,8 @@ byte-identical, so non-users pay nothing.
926
1350
  | `Heading` | `{ level, text, id }` | `props.heading` | table of contents with anchors |
927
1351
  | `CodeBlock` | `{ lang, meta?, code }` | `props.code` | decoded source (copy / run) |
928
1352
  | `MathBlock` | `{ latex }` | `props.math` | LaTeX source (re-render) |
929
- | `List` | `{ ordered, start }` | `props.list` | ordered-list numbering |
1353
+ | `List` | `{ ordered, start?, items? }`, items `{ html, start? }` | `props.list` | ordered-list numbering; `items[i].start` is the document-absolute offset of that top-level item's marker (absent for nested items) |
1354
+ | `Blockquote` / `Alert` | `{ nested }` (Alert also `{ kind }`), each `{ html }` | `props.container` | render inner sub-blocks KEYED, so a streaming quote re-renders only its open last child |
930
1355
 
931
1356
  Each cell's `text` is inline-stripped plaintext (for sort/filter/CSV/logic);
932
1357
  `html` is the inline-rendered display HTML. The data **streams** with the
@@ -940,6 +1365,41 @@ const toc = client.getSnapshot()
940
1365
  .map((b) => b.kind.data as { level: number; text: string; id: string });
941
1366
  ```
942
1367
 
1368
+ #### Table toolbar (CSV / copy)
1369
+
1370
+ `props.table` is the whole table as data, so a toolbar is a pure function of it —
1371
+ no HTML re-parse — and it keeps working **while the table streams**, because the
1372
+ override is invoked on open blocks too and `rows` grows as they arrive:
1373
+
1374
+ ```tsx
1375
+ import type { BlockComponentProps, Components, TableData } from "brookmd";
1376
+
1377
+ // RFC 4180: quote any field containing a comma, quote, or newline, and double
1378
+ // internal quotes. Built from each cell's plaintext `text` — never the display HTML.
1379
+ function toCsv(table: TableData): string {
1380
+ const quote = (v: string) => (/[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v);
1381
+ const line = (cells: { text: string }[]) => cells.map((c) => quote(c.text)).join(",");
1382
+ return [line(table.headers), ...table.rows.map(line)].join("\n");
1383
+ }
1384
+
1385
+ const components: Components = {
1386
+ Table: ({ table, html, open }: BlockComponentProps) => (
1387
+ <figure className="table-card">
1388
+ {table && (
1389
+ <button onClick={() => navigator.clipboard.writeText(toCsv(table))}>
1390
+ {open ? "Copy CSV (so far)" : "Copy CSV"}
1391
+ </button>
1392
+ )}
1393
+ <div dangerouslySetInnerHTML={{ __html: html }} />
1394
+ </figure>
1395
+ ),
1396
+ };
1397
+ ```
1398
+
1399
+ Needs `blockData: true` (with it off `props.table` is `undefined` — guard as
1400
+ above). The same data drives sort, filter, transpose and charts: `text` is the
1401
+ plaintext to compute on, `html` the markup to display.
1402
+
943
1403
  ### Component tags
944
1404
 
945
1405
  LLMs increasingly emit custom component tags like `<Thinking>…</Thinking>`. By
@@ -1131,8 +1591,8 @@ new BrookClient({ config: { htmlAllowlist: ["details", "summary"], blockHtml: tr
1131
1591
  propagated: `<b><i></b>` emits `<b><i></i></b>`, and a close tag matching
1132
1592
  nothing open is dropped. A type-6/7 block ends at a blank line even with tags
1133
1593
  open — the closers land there.
1134
- - **Markdown inside the HTML is not parsed** (the body is text + tags). That —
1135
- full `rehype-raw` semantics is a later stage.
1594
+ - **Markdown inside the HTML is not parsed** (the body is text + tags).
1595
+ Re-entering markdown inside a raw block is a later stage.
1136
1596
 
1137
1597
  ### Types
1138
1598
 
@@ -1147,33 +1607,111 @@ interface Block {
1147
1607
  end: number;
1148
1608
  }
1149
1609
 
1150
- // Override map for <BrookMarkdown components={...} />
1151
- type Components = Record<string, React.ComponentType<any> | string>;
1610
+ type BlockKindTag =
1611
+ | "Paragraph" | "Heading" | "CodeBlock" | "MathBlock" | "Mermaid" | "List"
1612
+ | "Blockquote" | "Alert" | "Table" | "Rule" | "Html" | "Component";
1613
+
1614
+ // Override map for <BrookMarkdown components={...} />. Block-kind keys are typed
1615
+ // to BlockComponentProps; every other key is an ELEMENT override and receives
1616
+ // that element's attributes + children — no `block`. (See "two prop contracts".)
1617
+ type Components = {
1618
+ [K in BlockKindTag]?: React.ComponentType<BlockComponentProps> | string;
1619
+ } & {
1620
+ [tag: string]: React.ComponentType<any> | string | undefined;
1621
+ };
1152
1622
 
1153
1623
  // Props a block-kind override receives (e.g. components.CodeBlock)
1154
1624
  interface BlockComponentProps {
1155
1625
  block: Block;
1156
- html: string;
1626
+ html: string; // this block's rendered HTML (for `Component`: the INNER html)
1627
+ children?: unknown; // React only: that inner content already parsed to a node tree
1157
1628
  open: boolean;
1158
1629
  speculative: boolean;
1159
- text?: string; // decoded source — CodeBlock / MathBlock
1160
- language?: string; // info string, first word — CodeBlock
1161
- meta?: string; // info string, the rest (`title="src/main.ts"`) — CodeBlock
1630
+ text?: string; // decoded source — CodeBlock / MathBlock
1631
+ language?: string; // info string, first word — CodeBlock
1632
+ meta?: string; // info string, the rest (`title="src/main.ts"`) — CodeBlock
1633
+ tag?: string; // component-tag name — Component
1634
+ attrs?: Record<string, string>; // sanitized attrs, React name-form — Component
1635
+ // Typed structured data — present only with `blockData: true`:
1636
+ table?: TableData; // { headers, rows, aligns }, cells { text, html }
1637
+ heading?: HeadingData; // { level, text, id }
1638
+ code?: CodeBlockData; // { lang, meta?, code }
1639
+ math?: MathBlockData; // { latex }
1640
+ list?: ListData; // { ordered, start?, items? }
1641
+ container?: ContainerData; // { nested } — Blockquote / Alert
1162
1642
  }
1163
1643
  ```
1164
1644
 
1165
1645
  `htmlToReact(html, components)` and `parseTrustedHtml(html)` are also exported
1166
1646
  for advanced use (e.g. rendering a single block's HTML to a React tree yourself).
1647
+ Every type above also lives in the value-free `brookmd/types` entry — see [all
1648
+ entry points](#all-entry-points).
1167
1649
 
1168
1650
  ### `highlight(code, lang)`
1169
1651
 
1170
- 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.
1652
+ Optional. A tiny native-RegExp tokenizer that emits `<span class="t-…">` spans
1653
+ (coloured by `brookmd/styles.css`, or by your own rules for those classes). The
1654
+ language name is matched case-insensitively; an unknown language — or a block over
1655
+ 50 000 characters — falls through to plain escaped text.
1171
1656
 
1172
1657
  ```ts
1173
- import { highlight } from "brookmd/highlight";
1658
+ import { highlight, supportedLangs } from "brookmd/highlight";
1174
1659
  const html = highlight("const x = 1;", "ts");
1660
+ supportedLangs(); // every info-string name currently registered
1661
+ ```
1662
+
1663
+ | Language | Info-string names |
1664
+ |---|---|
1665
+ | JavaScript / TypeScript | `js`, `javascript`, `jsx`, `ts`, `typescript`, `tsx` |
1666
+ | Rust | `rust`, `rs` |
1667
+ | Python | `python`, `py` |
1668
+ | Go | `go` |
1669
+ | Shell | `bash`, `sh`, `shell` |
1670
+ | JSON / SQL | `json`, `sql` |
1671
+ | HTML / XML / CSS | `html`, `xml`, `css` |
1672
+ | *(since 0.30.0)* YAML, TOML, diff, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dockerfile | `yaml`, `yml`, `toml`, `diff`, `java`, `c`, `cpp`, `c++`, `cs`, `csharp`, `php`, `rb`, `ruby`, `swift`, `kt`, `kotlin`, `dockerfile` |
1673
+
1674
+ #### Adding a language — `registerLanguage` *(since 0.30.0)*
1675
+
1676
+ Register your own and it joins the **incremental streaming** highlighter — a
1677
+ `components.CodeBlock` override would replace that machinery, this extends it:
1678
+
1679
+ ```ts
1680
+ import { registerLanguage } from "brookmd/highlight";
1681
+
1682
+ registerLanguage(["ini", "conf"], {
1683
+ // Tried in order at the cursor; every regex MUST be sticky (`/…/y`).
1684
+ pats: [
1685
+ ["com", /[#;][^\n]*/y],
1686
+ ["sel", /\[[^\]\n]*\]/y],
1687
+ ["attr", /[A-Za-z_][\w.-]*(?=\s*=)/y],
1688
+ ["str", /"(?:[^"\\]|\\.)*"/y],
1689
+ ["num", /-?\d+(?:\.\d+)?/y],
1690
+ ["pun", /[=,]/y],
1691
+ ["ws", /\s+/y], // catch-all: unmatched characters emit as plain text
1692
+ ],
1693
+ kw: ["true", "false", "null"],
1694
+ });
1695
+ ```
1696
+
1697
+ ```ts
1698
+ registerLanguage(names: string | string[], def: {
1699
+ pats: Array<[token: string, re: RegExp]>;
1700
+ kw?: Iterable<string>;
1701
+ }): void
1175
1702
  ```
1176
1703
 
1704
+ - **Token names** are the classes the theme already colours: `kw`, `str`, `rx`,
1705
+ `num`, `lt`, `com`, `fn`, `ty`, `mac`, `dec`, `attr`, `sel`, `tag`, `var`,
1706
+ `pun`, `txt`, plus `ws` (emitted verbatim, no span).
1707
+ - **`ident` is the keyword hook.** A pattern classed `ident` (e.g.
1708
+ `["ident", /[A-Za-z_$][\w$]*/y]`) resolves per match: a name in `kw` becomes
1709
+ `kw`, a name followed by `(` becomes `fn`, a Capitalized name becomes `ty`, and
1710
+ anything else is emitted as plain text. `kw` on its own does nothing without an
1711
+ `ident` pattern.
1712
+ - Registering an existing name **replaces** it, built-ins included. Names are
1713
+ lowercased; register at module scope, before the first fence renders.
1714
+
1177
1715
  ### Streaming syntax highlighting
1178
1716
 
1179
1717
  A code fence is highlighted **while it streams**, not only once it closes. On by
@@ -1198,6 +1736,39 @@ Two things worth knowing:
1198
1736
  - **The settled markup is byte-identical** to `highlight(text, lang)` either way.
1199
1737
  Turning this on or off changes when colour appears, never what it is.
1200
1738
 
1739
+ #### Where the colour front sits: `"wavefront"` (default) vs `"eager"`
1740
+
1741
+ `streamingHighlight` takes `boolean | "wavefront" | "eager"`; `true` means
1742
+ `"wavefront"`.
1743
+
1744
+ | | frozen prefix | speculative tail |
1745
+ | --------------- | ------------- | ------------------------------------------- |
1746
+ | `"wavefront"` | coloured | plain text until its line completes |
1747
+ | `"eager"` | coloured | coloured, rebuilt every patch |
1748
+ | `false` | plain | plain (whole fence stays plain until close) |
1749
+
1750
+ The default paints the tail as a **single text node** and updates its character
1751
+ data per patch. Colour therefore follows a wavefront one checkpoint — in practice
1752
+ one source line — behind the stream head, and at an LLM's token rate that is a
1753
+ sub-second lag on one line.
1754
+
1755
+ That is a deliberate trade, and the tail is the right place to make it. The tail's
1756
+ colours are *already* provisional by contract (see above): they may change on the
1757
+ next byte, so they are the least trustworthy pixels on screen. What they cost is
1758
+ not: highlighting them means building a span-dense subtree, throwing it away, and
1759
+ building it again on every frame. Measured in a real browser on a streamed 32 KB
1760
+ fence, that was **~758 ms** of extra work over the same fence with highlighting
1761
+ off — and ~96% of it was style/layout/paint, not script. The final DOM is
1762
+ identical either way (same 5,748 nodes); it was pure churn. Painting the tail as
1763
+ text removes essentially all of it: element creations under the open `<code>` drop
1764
+ from 7.5× the settled block's spans to 2.25× (i.e. the two unavoidable passes),
1765
+ and trips through the HTML parser from ~1 per patch to ~0.13.
1766
+
1767
+ Pass `"eager"` to put the colour back on the tail per patch — worth it for short
1768
+ fences, or when the fence is the whole point of the page and the cost is
1769
+ acceptable. Nothing else changes: every mode streams the same TEXT at every patch
1770
+ and settles to byte-identical markup.
1771
+
1201
1772
  Blocks past the highlighter's 50 000-character guard, unknown languages, and any
1202
1773
  fence taken over by a `components.CodeBlock` / `pre` / `code` override are
1203
1774
  unaffected — they behave exactly as before.
@@ -1252,7 +1823,9 @@ By design, not yet, or only partially:
1252
1823
  - **KaTeX / Mermaid rendering** — brookmd emits KaTeX-ready math markup
1253
1824
  (`<span>`/`<div class="math …">` with `gfmMath` on) and a `Mermaid` slot, but
1254
1825
  stays zero-dep: bring your own KaTeX / mermaid pass (or a `components.MathBlock`
1255
- / `components.Mermaid` override) for the actual SVG/MathML output.
1826
+ / `components.Mermaid` override) for the actual SVG/MathML output. Both are
1827
+ ~20 lines — see the [KaTeX](#math-with-katex) and
1828
+ [Mermaid](#mermaid-diagrams) recipes.
1256
1829
 
1257
1830
  ## Performance
1258
1831
 
@@ -1447,6 +2020,44 @@ navigation happen**, and treat the path as untrusted input at the point you act
1447
2020
  on it. In a privileged host, whether a model-authored `file:///…` is allowed to
1448
2021
  reach a real file is the embedder's decision, not brookmd's.
1449
2022
 
2023
+ ### Intercepting link clicks — `onLinkClick`
2024
+
2025
+ *(since 0.30.0)* Model-authored links go wherever the model decided. If your
2026
+ product wants an interstitial, an allowlist prompt, or in-app routing instead of
2027
+ a bare new tab, take the click:
2028
+
2029
+ ```tsx
2030
+ <BrookMarkdown
2031
+ client={client}
2032
+ onLinkClick={(event, link) => {
2033
+ if (isInternal(link.href)) {
2034
+ event.preventDefault(); // cancel the navigation
2035
+ router.push(new URL(link.href).pathname); // route in-app instead
2036
+ } else if (!isTrusted(link.href)) {
2037
+ event.preventDefault();
2038
+ showInterstitial(link.href, link.text);
2039
+ }
2040
+ }}
2041
+ />
2042
+ ```
2043
+
2044
+ - **Delegated:** exactly one listener sits on the `.brook-md` root and resolves
2045
+ the anchor from the event target. No per-anchor prop, so links cost nothing
2046
+ extra and no block leaves its fast path.
2047
+ - `link` is `{ href, text, element }` — `element` is the `<a>` itself.
2048
+ - `event.preventDefault()` cancels navigation; do nothing and the default
2049
+ `target="_blank" rel="noopener noreferrer nofollow"` behaviour stands.
2050
+ - A **streaming link with no URL yet** (`<a data-brook-pending>`, see [Streaming
2051
+ links](#streaming-links)) never fires it — there is no `href` to hand you.
2052
+ - Hoist / memoize the handler, as with the other callbacks.
2053
+ - Same hook on the DOM mount (`{ onLinkClick }`, with the native `MouseEvent`),
2054
+ forwarded by the Vue / Svelte / Solid bindings, and available as the
2055
+ `<brook-markdown>` element's `.onLinkClick` property.
2056
+
2057
+ This is also the piece that makes [`allowSchemes`](#un-blocking-a-scheme--allowschemes)
2058
+ usable: a privileged host that un-blocks `file:` should intercept the click and
2059
+ decide for itself, rather than letting navigation happen.
2060
+
1450
2061
  ### Rendering untrusted / LLM HTML safely
1451
2062
 
1452
2063
  If you enable `unsafeHtml` to render HTML from an untrusted source (e.g. an LLM
@@ -1553,9 +2164,11 @@ doesn't oversubscribe OS threads. Worker creation is lazy and load-aware:
1553
2164
  run on ≤8 workers (~6 each)**, not 50 threads.
1554
2165
 
1555
2166
  `destroy()` frees a stream's parser and keeps the worker warm for its siblings;
1556
- the workers persist for the life of the page. Need isolation or manual
1557
- teardown? Construct your own `new BrookPool(factory, cap)` and pass it to
1558
- `new BrookClient(pool)`, or call `pool.disposeAll()`.
2167
+ the workers persist for the life of the page. Need isolation or manual teardown?
2168
+ Construct your own `new BrookPool(factory, cap, { bootTimeoutMs })` and pass it as
2169
+ an **option object** — `new BrookClient({ pool })` or call `pool.disposeAll()`.
2170
+ `bootTimeoutMs` (default 20 000) is how long a freshly spawned worker has to
2171
+ report ready before it is failed; `0` disables the deadline.
1559
2172
 
1560
2173
  `getDefaultPool()` is **browser-only** (it constructs `Worker`s) and is a
1561
2174
  **per-page singleton** — don't rely on it in SSR/RSC. For isolation between