brookmd 0.29.0 → 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/CHANGELOG.md +170 -1
- package/README.md +643 -30
- package/dist/block-props.js +8 -2
- package/dist/dom.d.ts +25 -3
- package/dist/dom.js +33 -8
- package/dist/element.d.ts +3 -0
- package/dist/element.js +22 -2
- package/dist/hi-inc.d.ts +28 -0
- package/dist/hi-inc.js +40 -16
- package/dist/hi.d.ts +62 -4
- package/dist/hi.js +243 -22
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/react.d.ts +30 -4
- package/dist/react.js +25 -3
- package/dist/renderers/CodeBlock.d.ts +3 -2
- package/dist/renderers/CodeBlock.js +29 -11
- package/dist/solid.js +1 -0
- package/dist/splice.d.ts +58 -11
- package/dist/splice.js +35 -6
- package/dist/styles.css +295 -0
- package/dist/svelte.d.ts +6 -2
- package/dist/svelte.js +6 -3
- package/dist/types-core.d.ts +21 -1
- package/dist/types-react.d.ts +1 -1
- package/dist/vue.d.ts +8 -6
- package/dist/vue.js +11 -3
- package/dist/wasm/brook_md_core.d.ts +1 -1
- package/dist/wasm/brook_md_core.js +1 -1
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/package.json +1 -1
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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> (
|
|
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
|
|
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
|
|
770
|
-
`role`, and `aria-live` / `aria-atomic`. Set
|
|
771
|
-
output a live region so screen readers announce
|
|
772
|
-
`polite` coalesces rapid updates and does **not**
|
|
773
|
-
options exist on the DOM mount
|
|
774
|
-
|
|
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
|
|
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
|
|
920
|
-
|
|
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).
|
|
1135
|
-
|
|
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
|
-
|
|
1151
|
-
|
|
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;
|
|
1160
|
-
language?: string;
|
|
1161
|
-
meta?: string;
|
|
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.
|
|
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
|
-
|
|
1558
|
-
`new BrookClient(pool)
|
|
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
|