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.
- package/CHANGELOG.md +1229 -0
- package/LICENSE +21 -0
- package/README.md +1265 -0
- package/dist/block-props.d.ts +18 -0
- package/dist/block-props.js +75 -0
- package/dist/client.d.ts +370 -0
- package/dist/client.js +754 -0
- package/dist/decorate.d.ts +24 -0
- package/dist/decorate.js +71 -0
- package/dist/dom.d.ts +130 -0
- package/dist/dom.js +627 -0
- package/dist/element.d.ts +20 -0
- package/dist/element.js +288 -0
- package/dist/hi.d.ts +12 -0
- package/dist/hi.js +215 -0
- package/dist/html-to-react.d.ts +61 -0
- package/dist/html-to-react.js +338 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +18 -0
- package/dist/morph.d.ts +28 -0
- package/dist/morph.js +166 -0
- package/dist/react.d.ts +236 -0
- package/dist/react.js +539 -0
- package/dist/renderers/CodeBlock.d.ts +7 -0
- package/dist/renderers/CodeBlock.js +75 -0
- package/dist/renderers/Math.d.ts +14 -0
- package/dist/renderers/Math.js +15 -0
- package/dist/renderers/Mermaid.d.ts +13 -0
- package/dist/renderers/Mermaid.js +15 -0
- package/dist/server-react.d.ts +32 -0
- package/dist/server-react.js +48 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +82 -0
- package/dist/solid.d.ts +104 -0
- package/dist/solid.js +54 -0
- package/dist/styles.css +188 -0
- package/dist/svelte.d.ts +80 -0
- package/dist/svelte.js +59 -0
- package/dist/types-core.d.ts +436 -0
- package/dist/types-core.js +0 -0
- package/dist/types-react.d.ts +13 -0
- package/dist/types-react.js +0 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/dist/url-safety.d.ts +12 -0
- package/dist/url-safety.js +45 -0
- package/dist/vue.d.ts +94 -0
- package/dist/vue.js +79 -0
- package/dist/wasm/LICENSE +21 -0
- package/dist/wasm/README.md +71 -0
- package/dist/wasm/brook_md_core.d.ts +166 -0
- package/dist/wasm/brook_md_core.js +512 -0
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/dist/wasm/brook_md_core_bg.wasm.d.ts +26 -0
- package/dist/worker-core.d.ts +65 -0
- package/dist/worker-core.js +155 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +49 -0
- 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:…`,
|
|
1052
|
+
`javascript\:…`, `javascript:…`, 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.
|