npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

brookmd

v0.30.2

Published

Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.

Readme

brookmd

Zero-dep streaming markdown for the browser. Rust→WASM core, one Web Worker per stream, incremental parse with speculative closure for mid-stream constructs.

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.

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; patches cross the worker boundary as verified splices (not full re-sends, so emitted bytes stay O(n) even for one giant growing block); and heavy renderers (math, mermaid) are deferred until a block closes — code fences highlight as they stream, re-tokenizing only the last line rather than the whole block per chunk. The result is low retained memory and a main thread that stays responsive while streaming. See the live demo.

Beyond the browser: the same Rust core also powers experimental React Native, Swift (iOS/macOS), Kotlin/Android, Flutter, and C-ABI bindings — every platform speaks the same versioned wire, byte-for-byte. See the platform matrix in the repository README.

Install

bun add brookmd     # or: npm i brookmd / pnpm add brookmd

brookmd ships compiled, non-minified ESM (dist/*.js + .d.ts types) plus the compiled WASM — no raw .ts/.tsx source. The worker and WASM asset are referenced with the web-standard new URL(asset, import.meta.url) pattern, so any bundler with asset-module support resolves them: Vite (the reference setup), webpack 5, Rollup (with asset modules), Parcel, and Next.js (App Router — Turbopack and webpack; verified on Next.js 16, see the Next.js callout below).

The streaming client (<BrookMarkdown> / BrookClient) is browser-only (it constructs Web Workers). For server-side / static rendering of finished content — SSR, React Server Components, build steps — use the worker-free, synchronous brookmd/server entry. The framework packages — react, vue, svelte, solid-js — are all optional peer dependencies; you only need the one whose binding you import. The framework-free entries (brookmd/client, brookmd/dom, brookmd/element, and brookmd/server) need none. (The bare brookmd entry re-exports the React component surface, so it pulls react — import from brookmd/client if you want a framework-free core.)

Vite — one-line config. Vite's dependency pre-bundling (esbuild) hoists the wasm-bindgen glue into .vite/deps/, which breaks the relative new URL("…_bg.wasm", import.meta.url) lookup so the worker can't load WASM (you'll see a 404 / "magic word" error). Exclude brookmd from pre-bundling:

// vite.config.ts
export default defineConfig({
  optimizeDeps: { exclude: ["brookmd"] },
});

No other bundler needs this — it's specific to Vite's optimizer.

Next.js (App Router) — one requirement. Works on Next.js with Turbopack (the default for both next dev and next build) or webpack. Since 0.17.0 brookmd ships compiled ESM, so no transpilePackages or other build config is needed — earlier versions required it only because the package shipped raw TypeScript, which Next does not compile inside node_modules. That no longer applies.

Use it from a Client Component. <BrookMarkdown> uses React hooks (and spawns a Web Worker on mount), so it must carry "use client" — it can't be a Server Component. (It is still SSR-safe: on the server it renders an empty shell and only starts streaming after hydration, so there's no SSR crash — the constraint is hooks, not the worker.)

"use client";
import { BrookMarkdown } from "brookmd/react";

export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
  return <BrookMarkdown stream={stream} />;
}

Create the stream in Client Component code, not in a Server Component. A Response / ReadableStream / AsyncIterable isn't serializable, so it can't be passed as a prop from a Server Component (e.g. page.tsx) — that throws "Only plain objects can be passed to Client Components." Pass a serializable prop (a URL, the chat messages) from the server and open the stream on the client — e.g. stream={await fetch("/api/chat")} from a client effect, or the useBrookStream hook (see Quick start).

That's it — Turbopack bundles the worker and emits the .wasm to _next/static/media itself, so no extra asset/loader config is needed (and the Vite optimizeDeps workaround above does not apply). Both next dev and next build && next start are verified to spawn the worker, load the WASM, and stream markdown. Dev tip: open the app on localhost — Next dev blocks cross-origin dev resources (HMR, chunks) from other hosts (e.g. 127.0.0.1) unless you add them to allowedDevOrigins in next.config.

Quick start

import { BrookClient, BrookMarkdown } from "brookmd";

// One client per stream. Spawns a Web Worker that owns a Rust parser.
const client = new BrookClient();

// Feed chunks as they arrive from your SSE / fetch reader.
for await (const delta of streamFromAi()) {
  client.append(delta);
}
client.finalize();

In React — pass the stream straight to <BrookMarkdown>. It owns the client, pipes the stream, supersedes it if it changes, and cleans up on unmount:

import { BrookMarkdown } from "brookmd/react";

export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
  return <BrookMarkdown stream={stream} />;
}

stream accepts an AsyncIterable<string> (e.g. SSE deltas), a Response, or a ReadableStream<Uint8Array> — so <BrookMarkdown stream={await fetch("/api/chat")} /> works too.

Need the client handle (for outline() / getMetrics() / a shared client)? Use the useBrookStream hook — same lifecycle, returns the owned client:

import { BrookMarkdown, useBrookStream } from "brookmd/react";

export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
  const client = useBrookStream(stream);
  return <BrookMarkdown client={client} />;
}

Chat UI defaults

Five flags an LLM chat UI almost always wants — each off by default because the library's default is strict CommonMark, not because it is the better choice here:

import { getDefaultPool } from "brookmd";
import { BrookMarkdown, useBrookStream } from "brookmd/react";
import { useEffect } from "react";

// Hoist the config and the overrides — a fresh object each render busts the
// per-block memo, so every block re-renders on every patch.
const chatConfig = {
  softBreaks: true, // a lone \n renders as <br> — models write chat prose, not CommonMark
  dirAuto: true,    // per-block dir="auto", so an Arabic answer renders RTL beside an English one
  a11y: true,       // task-list <label>s + <th scope="col">
  blockData: true,  // typed props.table / heading / code — toolbars from data, not HTML re-parsing
  gfmMath: true,    // $…$ / $$…$$ / \(…\) / \[…\] (only if your model emits LaTeX)
};

export function Answer({ stream }: { stream: AsyncIterable<string> }) {
  // Hide the one-time WASM init behind the user's typing, not the first token.
  useEffect(() => { getDefaultPool().warm(); }, []);
  const client = useBrookStream(stream, { config: chatConfig });
  return <BrookMarkdown client={client} className="brook-caret" stickToBottom />;
}

className="brook-caret" opts into the theme's streaming caret; drop it if you draw your own.

Already holding a growing string? — useBrookMarkdownString

Many apps keep the streaming message as a single growing string prop (it re-renders with the full text-so-far each token), not as a stream. Feed that string straight in — useBrookMarkdownString diffs it for you and forwards only the delta, so you don't hand-roll an append/reset bridge:

import { BrookMarkdown, useBrookMarkdownString } from "brookmd/react";

export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
  const client = useBrookMarkdownString(text, { streaming });
  return <BrookMarkdown client={client} />;
}

It handles the two shapes a controlled string takes: a prefix-extension (the common token-by-token growth) appends only the new suffix; a divergence (e.g. the finished text swapped for a re-processed final string — bolded numbers, wrapped tickers) resets and reparses. Pass streaming: false once the content is final so the last block commits (a finished code fence then highlights). The framework-neutral primitive is client.setContent(fullString, { done }) — use it from any binding.

Transforming streamed content? If the enrichment runs live per token (e.g. bold every number as it arrives), do it at render time via components — keep the markdown source append-only so parsing stays incremental. Re-transforming the whole string each token (so earlier bytes change) forces setContent to reparse every tick (O(n²)); that's what render-time overrides avoid. setContent's reset path is for the once-at-the-end reprocess swap, not per-token rewrites. That swap is seamless: the current view stays on screen while the new string reparses — the document never blanks, scroll never moves, and blocks whose rendered content is unchanged keep their identity (and React keys), so only genuinely changed blocks re-render. (setContent("") is an explicit clear and resets immediately.)

With the Vercel AI SDK (useChat)

useChat hands you each message as parts, and the assistant's text part grows token by token — exactly the controlled-string shape above. Join the text parts and pass the result straight in:

import { useChat } from "@ai-sdk/react";
import { BrookMarkdown, useBrookMarkdownString } from "brookmd/react";

// Hoisted — see the memoization note in `components`.
const components = { a: (p: any) => <a {...p} /> };

export function Thread() {
  const { messages, status } = useChat();
  // `status` describes the LAST message only — earlier ones are finished.
  return messages.map((m, i) => (
    <Answer key={m.id} message={m} streaming={status === "streaming" && i === messages.length - 1} />
  ));
}

function Answer(props: {
  message: { parts: Array<{ type: string; text?: string }> };
  streaming: boolean;
}) {
  const text = props.message.parts.map((p) => (p.type === "text" ? p.text ?? "" : "")).join("");
  const client = useBrookMarkdownString(text, { streaming: props.streaming });
  return <BrookMarkdown client={client} components={components} />;
}

Pass streaming: false when the message finishes — it is not inferred. Omit it (or leave it true) and the stream stays OPEN forever: the last block never commits, so a finished code fence never highlights and never shows its copy button, and a streaming caret never stops blinking. brookmd deliberately refuses to infer "done" from an unchanged string — a caller who grows the text without the flag would re-finalize on every token, an O(n²) reparse trap.

One client per message, so re-rendering the thread never re-parses history, and getDefaultPool().warm() in the chat shell (see Chat UI defaults) keeps WASM init off the first answer.

When you want to drive the stream yourself, pass a client you own — the component never destroys it:

import { useEffect, useState } from "react";
import { BrookClient, BrookMarkdown } from "brookmd";

export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
  const [client] = useState(() => new BrookClient());
  useEffect(() => () => client.destroy(), [client]);
  useEffect(() => {
    const ac = new AbortController();
    client.pipeFrom(stream, { signal: ac.signal }); // pipeFrom also accepts AsyncIterable
    return () => ac.abort();
  }, [client, stream]);
  return <BrookMarkdown client={client} />;
}

StrictMode note: a stream (SSE generator / Response) can be consumed only once, so React StrictMode's dev-only double-mount may truncate it in development. Production mounts once and is unaffected.

Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.

Framework bindings

BrookClient is framework-neutral — it owns the worker and exposes subscribe/getSnapshot. Pick a renderer to put its blocks on screen. Every binding below is thin glue over the same incremental DOM renderer, so they share one identity contract: a committed block's node is never recreated, only the streaming tail re-renders.

One ownership rule across all bindings: the renderer's teardown (React unmount, handle.destroy(), element disconnect, etc.) frees only the rendered DOM and the subscription — it never destroys the client. You call client.destroy() when you're done with the stream. (React's <BrookMarkdown>, documented below, is the same.)

Vanilla / any framework — brookmd/dom

import { BrookClient } from "brookmd/client";
import { mountBrookMarkdown } from "brookmd/dom";

const client = new BrookClient();
const handle = mountBrookMarkdown(client, document.getElementById("out")!, {
  stickToBottom: true,
});

// Feed it from a fetch/SSE reader:
const reader = (await fetch("/api/chat")).body!.getReader();
const dec = new TextDecoder();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  client.append(dec.decode(value, { stream: true })); // stream:true carries multibyte across chunks
}
client.append(dec.decode());
client.finalize();

// Teardown: destroy BOTH — the renderer and the client you created.
handle.destroy();
client.destroy();

Already holding a growing string? There's no framework reactivity to wrap, so just call client.setContent(fullString, { done }) instead of the append loop — it diffs internally (prefix → delta; divergence → reparse) and finalizes on done. That's the same primitive the React/Vue/Svelte/Solid controlled-string helpers wrap; in vanilla you call it directly.

mountBrookMarkdown(client, container, options?) returns { destroy(), refresh(), openBlockId() } — openBlockId() is the id of the streaming tail block (the only one that can still re-render), or null when nothing is open. Options: components, sanitize, virtualize, stickToBottom, className, id, role, ariaLive, ariaAtomic, decorators, urlTransform, onRenderMetrics, onLinkClick (since 0.30.0 — same delegated hook as React, called with the native MouseEvent; see Intercepting link clicks), highlightCode (default true), streamingHighlight (boolean | "wavefront" | "eager", default true — highlight a code fence while it is still streaming; see Streaming syntax highlighting), batch (default true — one DOM write per requestAnimationFrame), morphOpenBlocks (default false — morph a growing generic open block's subtree in place instead of rebuilding it via innerHTML, so only the changed parts repaint and focus/selection in the streaming tail survive; the rendered result is equivalent to the default rebuild path). Block-kind overrides use components keyed by block-kind (CodeBlock, Table, Alert, Component, …) with values (props) => HTMLElement | string. Tag-level (lowercase a/table/code) overrides are React-only — there's no virtual tree on the fast innerHTML path; a block-kind override can rewrite the html it's handed instead.

Web Component <brook-markdown> — brookmd/element

The universal binding — plain HTML, Angular, or any framework that renders DOM. Register once, then use the element:

import { defineBrookMarkdown } from "brookmd/element";
defineBrookMarkdown(); // defines <brook-markdown>; pass a custom tag name if you like
<!-- zero-JS streaming straight from a URL -->
<brook-markdown src="/api/post.md" gfm-math stick-to-bottom></brook-markdown>

<!-- one-shot from inline text -->
<brook-markdown># Hello **world**</brook-markdown>
// or caller-owned streaming — drive your own client:
const el = document.querySelector("brook-markdown");
el.client = myBrookClient;             // element subscribes; never destroys it
el.components = { Thinking: (p) => myNode(p) };
myBrookClient.append(delta);

Config flags are tri-state attributes: absent = library default; gfm-math / gfm-math="true" / ="1" = on; gfm-math="false" / ="0" = off (the only way to turn off a default-on flag such as gfm-alerts). It renders in light DOM so your markdown CSS applies, and defineBrookMarkdown is a no-op under SSR (no customElements). A self-owned element (src / markdown / inline text / append()) is torn down on disconnect; a caller-supplied client is left alone.

The full attribute surface:

| Attributes | | |---|---| | content | markdown, src | | renderer | stick-to-bottom, virtualize (both since 0.30.0) | | 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 | | config (lists) | component-tags, allow-schemes — comma- or space-separated |

Properties (set them in JS, not as attributes): client, components, sanitize, onLinkClick (since 0.30.0). Methods: append(), finalize(), reset(), getClient(). Config flags with no attribute of their own (inlineComponentTags, htmlAllowlist, dropHtmlTags, blockData) are set by assigning a caller-owned client you constructed with that config.

Angular consumes the same element — no separate package:

import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { defineBrookMarkdown } from "brookmd/element";
defineBrookMarkdown(); // once at bootstrap

@Component({
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<brook-markdown [attr.src]="url" stick-to-bottom></brook-markdown>`,
})
export class Answer { url = "/api/post.md"; }

Controlled growing string? Assign a caller-owned client and drive it with setContent — el.client = myClient; myClient.setContent(fullString, { done }) — the element subscribes and renders, you own the diffing. (The self-owned markdown attribute is one-shot — it re-parses the whole document on each change, so don't point it at a per-token-growing string; use a client + setContent for that.)

Vue 3 — brookmd/vue

<script setup lang="ts">
import { onBeforeUnmount } from "vue";
import { BrookClient } from "brookmd/client";
import { BrookMarkdown } from "brookmd/vue";

const client = new BrookClient();
// feed client.append(delta) from your stream, then client.finalize()
onBeforeUnmount(() => client.destroy());
</script>

<template>
  <BrookMarkdown :client="client" stick-to-bottom />
</template>

Props: client (required), components, sanitize, virtualize, stickToBottom. There's also a useBrookMarkdown composable returning a container ref if you'd rather mount into your own element.

Already holding a growing string? useBrookMarkdownString owns a client and diffs the string for you (the Vue analogue of the React hook — see Controlled strings):

<script setup lang="ts">
import { BrookMarkdown, useBrookMarkdownString } from "brookmd/vue";
const props = defineProps<{ text: string; streaming: boolean }>();
// Pass getters so the composable tracks the live values; it owns + destroys the client.
const client = useBrookMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
</script>
<template><BrookMarkdown :client="client" /></template>

Svelte (4 & 5) — brookmd/svelte

A Svelte action — works in both v4 and v5, no .svelte build step:

<script lang="ts">
  import { onDestroy } from "svelte";
  import { BrookClient } from "brookmd/client";
  import { brookMarkdown } from "brookmd/svelte";

  const client = new BrookClient();
  // feed client.append(delta) then client.finalize()
  onDestroy(() => client.destroy());
</script>

<div use:brookMarkdown={{ client, stickToBottom: true }} />

Growing string? The brookMarkdownString action owns a client and diffs the string — use:brookMarkdownString={{ content, streaming }} (it destroys its client on destroy, so no manual cleanup):

<script lang="ts">
  import { brookMarkdownString } from "brookmd/svelte";
  export let content: string;     // the growing message
  export let streaming: boolean;  // false once complete → finalizes
</script>

<div use:brookMarkdownString={{ content, streaming, stickToBottom: true }} />

Solid — brookmd/solid

import { onCleanup } from "solid-js";
import { BrookClient } from "brookmd/client";
import { BrookMarkdown } from "brookmd/solid";

const client = new BrookClient();
// feed client.append(delta) then client.finalize()
onCleanup(() => client.destroy());

<BrookMarkdown client={client} stickToBottom />;

Growing string? createBrookMarkdownString owns a client and diffs the string (the Solid analogue of the React hook), driving setContent from a createEffect and destroying the client on cleanup:

import { BrookMarkdown, createBrookMarkdownString } from "brookmd/solid";

function Message(props: { text: string; streaming: boolean }) {
  const client = createBrookMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
  return <BrookMarkdown client={client} />;
}

The Solid binding's mount/teardown logic is tested, but its JSX component shell has so far only been exercised through a real Solid (vite-plugin-solid) build in development, not in CI — treat it as the newest of the bindings and file an issue if your Solid setup trips on it. The component is a thin ref'd <div>; if you hit a transform edge, mountBrookMarkdown from brookmd/dom inside onMount/onCleanup is the zero-surprise fallback.

Server-side rendering

<BrookMarkdown> / BrookClient are browser-only (they spawn a Web Worker), but the Rust→WASM core is a plain synchronous parser. So brookmd/server renders finished markdown on the server with no worker and no async ceremony — Node SSR, React Server Components, or a build step:

import { initBrook, renderToString } from "brookmd/server";

await initBrook();                                       // once at startup (loads the WASM)
const html = renderToString("# Hello\n\n**world**");   // sync HTML string, no worker

For React server rendering (RSC, static generation, or SSR), use <BrookMarkdownStatic> from brookmd/server/react — a hookless, RSC-safe component that renders finished content with the same components overrides (inline/block component tags dispatch on the server too). It lives in its own subpath so the core brookmd/server above stays importable with no react installed:

import { initBrook } from "brookmd/server";
import { BrookMarkdownStatic } from "brookmd/server/react";

await initBrook();
export default function Doc({ md }: { md: string }) {
  return (
    <BrookMarkdownStatic
      content={md}
      config={{ inlineComponentTags: ["tik"] }}
      components={{ tik: ({ symbol }) => <span className="ticker">{symbol}</span> }}
    />
  );
}
  • initBrook() — async, idempotent. In Node it reads the package's .wasm off disk (Node's fetch can't load file://); on the web it fetches the bundler-resolved asset. On edge runtimes pass bytes yourself: initBrookSync(wasmBytes).
  • renderToString(md, { config }) — synchronous HTML string, zero React dependency (imports cleanly with no react installed).
  • parseToBlocks(md, { config }) — the block array, for custom rendering.

Document assembly. A Block.html never ends with a newline — the terminator that follows a top-level block belongs to the document, not the block. renderToString therefore joins blocks with cmark's cr() rule: insert \n before a block only when the output doesn't already end with one, and end the document with one \n. An unconditional "\n".join(...) would double the newline a raw HTML block serializes for itself. If you assemble parseToBlocks output into a document string yourself, use the same rule — it's what makes the output byte-identical to a reference CommonMark/GFM renderer (652/652 CommonMark 0.31 and 24/24 GFM extension examples, byte-for-byte). See WIRE.md §12.

  • <BrookMarkdownStatic content config components /> (from brookmd/server/react) — synchronous React tree for render-once contexts; render it with your framework's server renderer (renderToStaticMarkup, RSC, …). For live streaming, client-side code highlighting, or Mermaid, render <BrookMarkdown> on the client instead — it's a separate component. (If you SSR-then-hydrate, use the same component on both sides; the dedicated client renderers in <BrookMarkdown> don't hydrate <BrookMarkdownStatic>'s plainer markup.)

What it does

| Concern | brookmd | conventional main-thread renderer | |---|---|---| | Re-parse on each token | No — only the active tail | Yes, full string | | Where parse runs | Web Worker (off main thread) | Main thread | | Block identity across chunks | Stable monotonic IDs | New keys on every render | | Mid-stream unclosed ``` / * / ** | Speculatively closed in render, replaced cleanly | Often renders raw or breaks | | Half-streamed link [label](https://… | Label-only inert anchor (data-brook-pending), URL never leaks | Raw brackets + partial URL flash | | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk | | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |

Streaming links

A link's destination is the last thing a model emits — [Earnings Call](https://… often spans many tokens. While a link is still streaming, brookmd renders it as an inert, label-only anchor: the label text inside an <a> with no href (the half-typed URL never flashes on screen), marked so you can style it:

<a data-brook-pending="" target="_blank" rel="noopener noreferrer nofollow">Earnings Call</a>

An anchor without an href gets no default link styling from the browser, so without a rule for the marker the link would "pop" blue only when the URL completes. The bundled theme (import "brookmd/styles.css") already styles it; if you bring your own CSS, copy this:

.brook-md a[data-brook-pending] {
  color: var(--brook-accent, #0969da); /* match your settled link's resting style */
  cursor: default;                    /* not clickable yet */
}

The moment the closing ) arrives, the href appears and data-brook-pending is dropped — committed and finalized output never carry the marker, and the finished block is byte-identical to a one-shot parse. Two composition notes: urlTransform runs only on a real href, so it never sees a half-streamed URL prefix — only the complete one (it may run again on re-renders while the surrounding block is still open); decorators skip text inside <a> by default (skipInside), pending or not.

Styling

brookmd emits semantic HTML under a .brook-md root and ships no CSS by default — bring your own design system, or opt into the bundled theme:

import "brookmd/styles.css";

It gives good-looking output out of the box, including the built-in syntax highlighter's colors (without any CSS, highlight() renders uncolored). The theme is scoped to .brook-md, zero-runtime, and does not change the rendered HTML — skip the import and nothing is styled.

Next.js Pages Router: brookmd/styles.css is global CSS, which the Pages Router only allows importing from pages/_app. Import it there (App Router and other bundlers can import it from any component). Or skip it and bring your own .brook-md styles.

Re-theme by overriding a few CSS variables; it's light by default and switches to dark automatically via prefers-color-scheme (force a mode with class="brook-md brook-dark" or brook-light):

.brook-md {
  --brook-accent: #7c3aed;   /* links */
  --brook-bg-code: #faf7ff;  /* code background */
  --brook-t-kw: #c026d3;     /* syntax: keywords (also --brook-t-str/num/com/fn/ty/…) */
}

What the theme covers

Document elements, the highlighter's token colours (.t-kw, .t-str, …), the pending-link marker — and (since 0.30.0) block spacing via .brook-block plus the chrome the renderers emit: the code-block header and its controls (.brook-code-header, .brook-code-lang, .brook-code-copy, .brook-code-streaming-pill, .brook-code-body), the math and mermaid slots (.brook-math-*, .brook-mermaid-*), GitHub-style alerts (.markdown-alert*), and the footnote section. Writing your own CSS instead? Those are the class names to target.

Block-state classes

Every block the generic renderer emits is wrapped in a state-carrying element, and these names are a stable styling contract — React, the DOM mount, and the server renderer all emit the same ones:

| Class | Where | Means | |---|---|---| | brook-md | root | always present; className is appended to it | | brook-block | every block wrapper | — | | brook-block-<kind> | every block wrapper | the lowercased block kind: brook-block-paragraph, brook-block-codeblock, brook-block-table, brook-block-mathblock, … | | brook-open | the streaming tail block | still growing — its HTML may change on the next patch | | brook-speculative | a block closed by inference | may still be revised | | brook-streaming | the code / math / mermaid slot | that renderer's own still-arriving state | | brook-bottom-anchor | the stickToBottom sentinel | — | | brook-deferred | root, while deferTail is deferring | — |

Use them to gate anything that must not run on half-arrived content — a KaTeX or Mermaid pass skips .brook-open / .brook-streaming (see the KaTeX recipe) — and to style the tail differently from settled text.

Streaming caret

The theme ships an opt-in caret that follows the streaming tail. Add the brook-caret class to the root; nothing else changes, and the rendered HTML is identical with or without it:

<BrookMarkdown client={client} className="brook-caret" />
mountBrookMarkdown(client, el, { className: "brook-caret" });

It is a ::after bar on the last text element inside the brook-open block, so exactly one caret is on screen. Code, math, and mermaid fences don't get one — they already show a "streaming" pill. Retint it with --brook-caret, and it stops blinking under prefers-reduced-motion. Bringing your own CSS? The block-state classes are all it is built from.

Tailwind / design systems

Two hooks, no wrapper components: className on the root, and the element path of the components map for everything inside a block. Overrides apply to the OPEN (streaming) block too, so the tail is styled the whole way down instead of popping into place when it settles:

import { BrookMarkdown, type Components } from "brookmd";

// HOIST it (module scope) or memoize. A fresh object each render busts the
// per-block memo, so every block re-parses on every patch — the single most
// expensive mistake you can make with this API.
const components: Components = {
  p: (p) => <p className="my-3 leading-7" {...p} />,
  ul: (p) => <ul className="my-3 list-disc pl-6" {...p} />,
  ol: (p) => <ol className="my-3 list-decimal pl-6" {...p} />,
  li: (p) => <li className="my-1" {...p} />,
  a: (p) => <a className="text-sky-600 underline underline-offset-2" {...p} />,
  h1: (p) => <h1 className="mt-6 text-2xl font-semibold" {...p} />,
  h2: (p) => <h2 className="mt-5 text-xl font-semibold" {...p} />,
  h3: (p) => <h3 className="mt-4 text-lg font-semibold" {...p} />,
  table: (p) => <table className="w-full border-collapse text-sm" {...p} />,
  code: (p) => <code className="rounded bg-slate-100 px-1 py-0.5" {...p} />,
  blockquote: (p) => <blockquote className="border-l-4 pl-4 italic" {...p} />,
};

<BrookMarkdown client={client} components={components} className="text-slate-900" />;

Using @tailwindcss/typography? Skip the theme import. .brook-md is a plain <div>, so class="prose brook-md" works — but brookmd/styles.css resets .brook-md > * margins and sets its own type scale, which fights prose's spacing. Pick one: the theme, or prose plus the token-colour variables (--brook-t-kw, …) if you still want the built-in highlighter's colours.

Public API

All entry points

Every subpath is independently importable; you pay only for what you import.

| Entry | What it is | |---|---| | 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) | | brookmd/client | framework-free core — BrookClient, BrookPool, getDefaultPool, applyPatch, emptyBlockStore | | brookmd/react | BrookMarkdown, useBrookStream, useBrookMarkdownString, blockKindProps | | brookmd/server | worker-free, React-free one-shot: initBrook, initBrookSync, isBrookReady, renderToString, parseToBlocks | | brookmd/server/react | BrookMarkdownStatic — hookless, RSC-safe | | brookmd/dom | mountBrookMarkdown, tailOpenBlockId | | brookmd/element | defineBrookMarkdown (the <brook-markdown> Web Component) | | brookmd/vue · /svelte · /solid | the framework bindings | | brookmd/highlight | highlight, supportedLangs, registerLanguage (since 0.30.0) | | brookmd/html-to-react | htmlToReact, parseTrustedHtml, wrapLink, safeUrl — render one block's HTML to a React tree yourself | | brookmd/block-props | blockProps, extractLang, htmlAttrs — the framework-neutral block→props mapping the DOM renderer uses | | brookmd/worker-core | WorkerCore — the worker's state machine, for hosting the parser in your own worker/runtime | | brookmd/types | every type, value-free (Block, ParserConfig, RenderMetrics, ListItemData, LinkClickInfo, the wire types, …) | | brookmd/styles.css | the optional theme |

BrookClient

class BrookClient {
  constructor(options?: {
    pool?: BrookPool;
    config?: ParserConfig;
    onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
    onBlock?: (block: Block) => void;                 // fires once per block as it commits
    coalesce?: boolean;                               // one rAF-scheduled notify per frame (default false)
    recovery?: boolean;                               // auto-heal a transient worker death (default true)
  });
  get failed(): Error | null;                       // terminal worker failure, else null (null through heals)
  append(chunk: string): void;                      // queue text for parsing
  pipeFrom(                                         // read → append → finalize
    src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
    opts?: { signal?: AbortSignal },                // abort to supersede (no finalize)
  ): Promise<void>;
  finalize(): void;                                 // mark stream complete
  setContent(                                       // drive from a controlled full string
    full: string,                                   // diffs vs last: prefix → append delta; else seamless
    opts?: { done?: boolean },                      //   reset+reparse (view held, unchanged blocks keep identity)
  ): void;                                          // done:true → finalize
  reset(): void;                                    // wipe and reuse
  destroy(): void;                                  // free this stream's parser
  reattach(): void;                                 // re-register after destroy() (StrictMode double-mount)
  whenReady(): Promise<void>;                       // resolves once WASM loaded; rejects on init failure
  subscribe(listener: () => void): () => void;      // React-friendly store
  getSnapshot(): Block[];                           // ordered current blocks
  getPersistable(source?: string): PersistableSnapshot;     // capture the rendered doc as JSON
  hydrate(                                                  // restore it: no worker, no parse
    snapshot: PersistableSnapshot,
    opts?: { source?: string },                             // source ⇒ a live thread can resume
  ): void;
  outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
  toPlaintext(): string;                            // rendered document as plain text (search / summaries)
  getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
                   retainedBytes, wasmMemoryBytes, ... };
}

pipeFrom is the LLM-native shortcut — hand it a fetch response and it reads, appends, and finalizes for you:

const client = new BrookClient();
await client.pipeFrom(await fetch("/api/chat")); // streams the body in, then finalizes

Pass onError to be notified of worker/parse errors and a fatal WASM-init failure ({ fatal: true }); without it, errors are only console.error'd and a load failure surfaces as a rejected whenReady(). Pass onBlock to run a side effect each time a block commits (e.g. lazy-highlight a finished code block).

Pass coalesce: true to collapse every patch that lands inside one frame into a single requestAnimationFrame-scheduled notification, so a useSyncExternalStore consumer renders at most once per frame instead of once per patch. It is lossless (committed blocks are reference-stable, so only superseded tail renders are skipped), the finalize patch always flushes synchronously, and it degrades to synchronous emits where requestAnimationFrame is unavailable (SSR, tests). The React hooks that own a client (useBrookStream / useBrookMarkdownString) already set it; a client you construct yourself defaults to false.

reattach() re-registers a client with the pool after destroy(). It exists for React StrictMode's dev double-mount (destroy on the simulated unmount, then the SAME instance remounts) — apps don't normally call it, and it is a no-op while still attached.

A transient worker death heals invisibly by default: if a worker dies mid-stream (e.g. a stale hashed worker URL 404s after a redeploy), the client buffers the driven document, re-acquires a fresh worker, and re-feeds it once — the view stays on screen and onError does not fire. Only if the replacement also dies is the failure terminal (onError with { fatal: true }, and client.failed becomes the Error; it is null while healthy and through a successful heal). Set recovery: false to disable the buffer and auto-recovery (a fatal death is then immediately terminal) — worth it for memory-sensitive, very large documents where retaining the full source is undesirable.

Per-stream config

const client = new BrookClient({
  config: {
    gfmAutolinks: true,   // bare www./http(s):// URLs + emails → links (default true)
    gfmAlerts: true,      // > [!NOTE] → callouts (default true)
    gfmTagfilter: false,  // GFM disallowed raw HTML: escape <script>/<title>/… under unsafeHtml (default false)
    gfmFootnotes: true,   // [^1] + [^1]: → footnote section (default false)
    gfmMath: true,        // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
    dirAuto: true,        // per-block dir="auto" for RTL/bidi text (default false)
    softBreaks: true,     // a single \n renders as <br> (the chat convention; default false)
    lenientLists: true,   // marker + 6+ SPACES → item text, not indented code (default false)
    a11y: true,           // task-list <label> + <th scope="col"> a11y markup (default false)
    unsafeHtml: false,    // pass raw HTML through (default false — keep it false for untrusted input)
    componentTags: ["Thinking", "Callout"], // BLOCK custom tags w/ markdown inside (default none)
    inlineComponentTags: ["tik", "cite"],   // INLINE custom tags (chips/citations) w/ markdown inside (default none)
    htmlAllowlist: ["br", "sub", "sup"],    // safe raw-HTML sanitizer: [] = allow all but dangerous; list = only those (default off)
    dropHtmlTags: [],                        // tags removed entirely (comments always dropped when sanitizing; default off)
    blockHtml: true,                         // extend the sanitizer to BLOCK raw HTML (<details>…); needs a list above (default false)
    allowSchemes: ["file"],                  // un-block a default-blocked URL scheme (default none — see "Security")
    blockData: true,      // opt-in structured kind.data per block (default false — see "Structured block data")
    retainCommittedHtml: false, // keep committed HTML inside the parser too (default false on the streaming path)
  },
});

Omitted fields use the defaults above, so new BrookClient() is unchanged. Config is applied when the stream's parser is created and is immutable for that stream (reset() keeps it; use a new client for different flags).

When to enable each flag:

  • gfmAutolinks — on by default. Leave it on unless you want strict CommonMark.

  • gfmAlerts — on by default. Leave it on unless you want strict CommonMark.

  • gfmMath: true — when your LLM emits $…$ or $$…$$ (or LaTeX \(…\) / \[…\]). brookmd emits KaTeX-ready markup; you bring the KaTeX pass (or components.MathBlock).

  • gfmFootnotes: true — when your input uses [^1] references and [^1]: definitions. Off by default; see the footnote streaming caveat above.

  • dirAuto: true — when content can be RTL / mixed-direction. Emits per-block dir="auto" so the browser detects direction independently per block.

  • lenientLists: true — when your LLM over-indents after a list marker. Strict CommonMark (§5.2) says a marker followed by 5 or more columns of whitespace starts an indented code block, so a model writing - const value = 1; renders as a <pre><code> block instead of a list item. This flag raises that cutoff to 6 columns of literal spaces: at 6+ the padding is absorbed into the item's content column and the text renders as the item's own markdown (inline formatting, links, and nested lists all parse normally). Off by default, so strict-CommonMark output is unchanged.

    Four cases stay strictly conformant by design — the flag is deliberately narrow, not a general "fix my indentation" pass:

    | Input | Stays | Why | | --- | --- | --- | | - + exactly 5 spaces | code block | 5 columns is the §5.2 boundary itself; relaxing it would swallow genuine one-space-past-the-minimum code | | - ```js (fence on the marker line) | fenced code | the marker line opens a real fence — there is no over-indentation to undo | | - then code indented on a later line | code block | the decision reads only the marker's own line; a later-line indent is unambiguous authored code | | -\t\tfoo (tab padding) | code block | tab padding is a deliberate authoring choice, unlike model over-indentation which is always literal spaces |

    Excluding tabs is what keeps the divergence from CommonMark down to a single spec example (274, 1. + 6 spaces). Everything else in the 652-example suite renders identically with the flag on or off; the conformance suites themselves run in strict mode and are unaffected.

    The rule is a pure per-line comparison made when the marker is first scanned, so it costs no lookahead and no re-parse while streaming.

  • a11y: true — opt-in accessibility markup that deviates from strict GFM byte-output: wraps task-list checkboxes in a <label> (screen-reader association) and adds scope="col" to table headers. Off by default so conformance output stays exact.

  • unsafeHtml: true — only when rendering trusted HTML. For untrusted / LLM-produced HTML, pair this with <BrookMarkdown sanitize={…} /> (DOMPurify or similar — see Security).

  • gfmTagfilter: true — the GFM "Disallowed Raw HTML" extension, for use with unsafeHtml: the nine disallowed tags (<title>, <textarea>, <style>, <xmp>, <iframe>, <noembed>, <noframes>, <script>, <plaintext>) get their leading < escaped so they display as text instead of taking effect — opening and closing forms, any case, in blocks and inline. Off by default (strict CommonMark passes them through under unsafeHtml); it's a tag denylist, not a sanitizer — untrusted input still wants sanitize.

  • componentTags: ["Thinking", …] — when your LLM emits block custom tags like <Thinking>…</Thinking> (on their own line) and you want their inner content parsed as markdown and dispatched to a React component. Safe without unsafeHtml (attributes are sanitized; allowlisted tags only).

  • inlineComponentTags: ["tik", …] — same idea for inline custom elements that sit inside a paragraph, heading, list item, or table cell (ticker chips, citations, @mentions). See Inline component tags.

  • htmlAllowlist / dropHtmlTags — render a safe subset of raw HTML (e.g. <br>, <sub>, <sup>) natively without unsafeHtml, drop specific tags, and drop HTML comments. See Safe raw HTML.

  • blockHtml: true — extend that sanitizer to block-level raw HTML, so a <details><summary>…</summary>…</details> block renders as real elements instead of an escaped code block. Needs one of the two lists above to be set; <script>/<pre>/<style>/<textarea> blocks stay escaped. See Block-level raw HTML.

  • allowSchemes: ["file"] — un-block a URL scheme brookmd blocks by default, for privileged hosts (Electron, extensions) that intercept link clicks instead of navigating. Script-executing schemes can never be re-enabled. See Un-blocking a scheme.

  • retainCommittedHtml: true — keep every committed block's rendered HTML inside the parser as well. Off on the streaming path, which is what you want: the client receives each committed block exactly once and stores it itself, so a second copy in WASM serves nobody — dropping it roughly halves a long stream's getMetrics().retainedBytes, and the wire is byte-identical either way. Turn it on only if something reads the whole rendered document back out of the parser. The server renderers (renderToString / parseToBlocks) do exactly that and pin it on regardless of what you pass.

Footnotes (gfmFootnotes) work in streaming with one honest caveat: a [^1] reference renders speculatively the moment it's seen (committed blocks can't re-render), and the footnote section is emitted at finalize. So a reference whose definition never arrives leaves a dangling link — the same forward-reference cost as link reference definitions. Multiple references to the same footnote each get a unique id (fnref-N, fnref-N-2, …) and the definition lists one backref per reference. Remaining v1 limits: single-block definitions (no continuation-indent / multi-paragraph) and no nested footnotes. The section uses GitHub-style markup (<section class="footnotes">, <sup class="footnote-ref">).

Math (gfmMath) recognizes both delimiter families LLMs emit — $…$ / $$…$$ and LaTeX \(…\) / \[…\]. Inline math renders to <span class="math math-inline">…</span>, display math to <div class="math math-display">…</div> (and inline display to a math-display span), each carrying the HTML-escaped LaTeX as its text content — exactly what KaTeX's auto-render expects. brookmd stays zero-dep: it produces the KaTeX-ready markup and never processes the body as markdown; you bring the KaTeX pass (or override components.MathBlock, which receives the raw LaTeX as text). Single $ uses the pandoc rule so prose and currency stay literal — the opener needs a non-space to its right, the closer a non-space to its left and no digit after it, so $5 and $10 is not math. A $$/\[ block is blank-line tolerant (multi-line \begin{aligned}… stays one block) and renders incrementally while streaming, like a code fence. Off by default (so $ in plain prose is untouched) — enable it per stream when your model emits LaTeX.

Bidirectional text (dirAuto) emits dir="auto" on each block-level text element (p, h1–h6, blockquote, ul/ol/li, table), so the browser runs the Unicode bidi algorithm per block — an Arabic/Hebrew paragraph renders RTL while the English one beside it stays LTR, with no JS direction detection. Code blocks never get it (code is always LTR). This is the per-block model GitHub uses; it's the right fix for the common failure mode of detecting one direction for a whole mixed-language document. Off by default (strict CommonMark output is unchanged); turn it on for RTL or mixed-direction content.

BrookMarkdown (React)

Subscribes to a BrookClient, renders each block keyed by its stable parser-assigned ID. Memoized so unchanged blocks never re-reconcile.

<BrookMarkdown client={client} />

The root element accepts opt-in className (appended to the always-present brook-md root class), id, role, and aria-live / aria-atomic. Set aria-live="polite" to make the output a live region so screen readers announce streamed content as it settles — polite coalesces rapid updates and does not read every token. The same options exist on the DOM mount (mountBrookMarkdown(client, el, { ariaLive: "polite" })), covering the Web Component and the Vue/Svelte/Solid adapters.

Props

| Prop | Type | Default | What it does | |---|---|---|---| | client | BrookClient | — | A client you own and drive; the component never destroys it. | | 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. | | streamConfig | ParserConfig | — | Per-stream config for that internally created client (stream mode only). | | onStreamError | (err: Error) => void | — | The stream source rejected. Worker/parse errors go to the client's onError instead. | | components | Components | — | Overrides. Hoist it. | | decorators | Decorator[] | — | Inline text decorators. Hoist it. | | urlTransform | UrlTransform | — | Rewrite href/src/poster; the output is re-sanitized. Hoist it. | | sanitize | (html: string) => string | — | Runs on every block's HTML including the open tail. Hoist it. | | streamingHighlight | boolean \| "wavefront" \| "eager" | "wavefront" | Where the colour front sits. | | virtualize | boolean | false | content-visibility: auto on closed blocks — long documents. | | stickToBottom | boolean | false | Emits the scroll-snap anchor — stick to bottom. | | 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. | | 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. | | className / id / role | string | — | Set on the root; className is appended to brook-md. | | aria-live / aria-atomic | "off" \| "polite" \| "assertive" / boolean | off | Live-region attributes — see Accessible chat. | | onLinkClick | (event, link) => void | — | Delegated link clicks — see Intercepting link clicks. (since 0.30.0) | | 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. | | onBlockError | (error, info) => void | — | Per-block error boundary hook — see onBlockError. Hoist it. |

Accessible chat

Make the assistant's message a polite live region and mark it busy while it streams:

<div role="log" aria-live="polite" aria-busy={streaming}>
  <BrookMarkdown client={client} />
</div>
  • aria-live="polite" announces content as it settles rather than reading every token. Put it on the container you own (as above) or on the root via the aria-live prop — one live region, not both.
  • Turn on a11y: true in the per-stream config: task-list checkboxes get a <label> (so the checkbox and its text are associated) and table headers get scope="col".
  • Flip aria-busy back to false when the stream finalizes, so assistive tech knows the message is complete. Where focus goes when a message lands is your app shell's decision, not the renderer's.

Custom components / overrides

Pass a components map to replace how elements render. Keys come in two namespaces:

import { useMemo } from "react";
import { BrookClient, BrookMarkdown, type Components } from "brookmd";

function Message({ client }: { client: BrookClient }) {
  // Memoize (or hoist to module scope). A fresh object every render busts
  // BrookMarkdown's block memo, so every block re-parses on every patch.
  const components: Components = useMemo(
    () => ({
      // tag-level (lowercase HTML names) — applied inside a block's HTML
      table: (props) => <table className="rounded border" {...props} />,
      a: (props) => <a target="_blank" rel="noreferrer" {...props} />,
      h1: "h2", // a string value just swaps the tag

      // block-kind (capitalized BlockKindTag) — replaces the whole block
      CodeBlock: ({ text, language, open }) => (
        <MyCodeBlockWithCopyButton code={text} lang={language} streaming={open} />
      ),

      // GitHub alerts (`> [!NOTE]` / `[!TIP]` / `[!WARNING]` / `[!CAUTION]` /
      // `[!IMPORTANT]`) — swap in your own callout component. The alert kind
      // is on `block.kind.data.kind`; `html` is the rendered inner body.
      Alert: ({ block, html }) => (
        <MyCallout kind={(block.kind.data as { kind: string }).kind}>
          <div dangerouslySetInnerHTML={{ __html: html }} />
        </MyCallout>
      ),
    }),
    [],
  );
  return <BrookMarkdown client={client} components={components} />;
}

Tag-level keys (table, thead, tr, td, a, code, pre, h1–h6, ul, ol, li, blockquote, p, img, del, input, hr, …) replace that element wherever it appears. The component receives the element's parsed attributes (with class→className and style as an object) plus children.

Block-kind keys (CodeBlock, Mermaid, MathBlock, Alert, Paragraph, Heading, List, Blockquote, Table, Rule, Html) replace the entire block. The component receives BlockComponentProps: { block, html, open, speculative }, plus text/language for code/math blocks — and meta, the rest of a fence's info string (```ts title="src/main.ts"), for a filename header (the alert type is at block.kind.data.kind).

One map, two prop contracts — the single biggest footgun. The keys above are looked up by TWO dispatchers. The block-kind dispatcher passes BlockComponentProps (with block); the element dispatcher, which is what makes a / code / table overrides work, passes the element's attributes and children only — no block. The same name can hit both: an inlineComponentTags chip, or a componentTags tag that lands inside a list item or blockquote (where it is a real nested Component block, rendered as an element inside its container's HTML), takes the element path. So an override that reads props.block.… throws can't access property "kind", block is undefined for those occurrences — intermittently, because it depends on where the model happened to put the tag.

Write any override for a name that can appear in both positions defensively:

const Thinking = ({ block, children }) =>
  block ? <Panel data={block.kind.data}>{children}</Panel> : <span>{children}</span>;

Three things make this survivable rather than fatal: block-kind keys are typed to BlockComponentProps, so a mismatched override is a compile error; a raw element whose name collides with a block-kind key (<Table>, <Alert>… — only reachable with raw-HTML passthrough on) is never dispatched to that override; and every block renders inside its own error boundary, so a throwing override costs that one block instead of unmounting the document. Wire onBlockError to see them.

Rules worth knowing:

  • There is no node prop / no syntax tree. Introspect via className / data-*, or — better — opt into the typed structured-data channel (blockData: true) and read block.kind.data (and the typed props.table / heading / code / math / list fields) directly — no HTML re-parsing.
  • Overrides apply to the OPEN (streaming) block too, not just settled ones — so a design-system renderer (Tailwind classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream. The tail's HTML is always well-formed (the parser speculatively closes it). If a sanitize is supplied it runs first, on every block.
  • No components prop ⇒ the original fast path (innerHTML, byte-identical output). The HTML→React conversion runs only when you actually supply overrides, and is memoized per (block id, html) so committed blocks don't re-parse as the stream grows.
  • For code blocks the built-in highlighter is the default; it is bypassed (so your override wins) when you pass components.CodeBlock, components.pre, or components.code.

Inline text decorators

Wrap or replace matched inline text while streaming — e.g. bold financial figures — without writing your own HTML re-parser. A decorators entry runs POST-parse on real inline text nodes only (never URLs, code, or markup), once per committed block, so a long document stays O(n).

import { BrookMarkdown, wrapLink } from "brookmd";

// HOIST it (module scope) or memoize — a fresh identity each render busts the
// per-block memo and re-decorates every block on every patch (a dev warning fires).
const decorators = [
  { match: /\$[\d.]+[BMK]|FY\d{4}|\d+(?:[-–]\d+)?%/g, replace: (t) => <mark>{t}</mark> },
  // Linkify a ticker — route the href through the safe helper (see below):
  { match: /\$[A-Z]{1,5}\b/g, replace: (t) => wrapLink(t, { href: `/sym/${t.slice(1)}` }) },
];

<BrookMarkdown client={client} decorators={decorators} />;
  • Trusted surface — not sanitized. A decorator's replace output is spliced straight into the tree and does not pass through brookmd's attribute sanitizer (React renders a javascript: href without complaint). Treat decorators exactly like components: build only trusted nodes, and route any link href through wrapLink or the exported safeUrl.
  • skipInside defaults to ['a','code','pre','kbd']; override per decorator.
  • Per-text-node. A value split by inline markup (e.g. $2.<em>5</em>B) is two text nodes and won't match across them — match against settled, contiguous text.
  • Matching is pure and stateless, so a value streamed char-by-char decorates identically to a one-shot render. Same API on brookmd/dom (mountBrookMarkdown(client, el, { decorators })); a decorator there returns a Node or string.

urlTransform?: (url, { tag, attr }) => string rewrites href/src/poster URLs as blocks render (proxy images, add UTM params). Its output is re-sanitized (safeUrl(urlTransform(safeUrl(value)))), so a buggy transform can never emit a javascript: / data:text/html URL. Hoist/memoize it for the same reason as decorators.

Math with KaTeX

With gfmMath: true brookmd emits KaTeX-ready markup and stays zero-dep — you run the typesetting pass. Typeset each .math element once its block has closed: an open block holds partial LaTeX, and feeding KaTeX a half-typed formula throws on every patch. One observer over the scroller covers every message in the thread:

import { useEffect, useRef, type ReactNode } from "react";
import katex from "katex";

export function MathPass({ children }: { children: ReactNode }) {
  const root = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const pass = () => {
      el.querySelectorAll<HTMLElement>(".math:not([data-tex])").forEach((node) => {
        if (node.closest(".brook-streaming, .brook-open")) return; // still streaming
        node.setAttribute("data-tex", "1");                        // idempotence marker
        try {
          katex.render(node.textContent ?? "", node, {
            displayMode: node.classList.contains("math-display"),
            throwOnError: false,
          });
        } catch {
          /* leave the raw LaTeX in place */
        }
      });
    };
    const obs = new MutationObserver(pass);
    obs.observe(el, { childList: true, subtree: true });
    pass();
    return () => obs.disconnect();
  }, []);
  return <div ref={root}>{children}</div>;
}

The two non-obvious parts are the .brook-streaming, .brook-open skip (see block-state classes) and the data-tex marker, which stops the observer re-typesetting what it already rendered. Prefer per-block control? components.MathBlock receives the decoded LaTeX as props.text.

Mermaid diagrams

Mermaid is a block slot: brookmd renders the diagram source and never calls a renderer. Render it as plain text while the fence is open and call mermaid.render only once it closes — a half-arrived diagram throws on every patch. The slot carries the fence's rendered <pre><code>, so the source is its text content:

import { useEffect, useState } from "react";
import mermaid from "mermaid";
import type { BlockComponentProps, Components } from "brookmd";

// brookmd's own escaped output — the text content IS the diagram source.
function mermaidSource(html: string): string {
  const el = document.createElement("div");
  el.innerHTML = html;
  return el.textContent ?? "";
}

const components: Components = {
  Mermaid: ({ html, open, block }: BlockComponentProps) => {
    const [svg, setSvg] = useState("");
    const source = mermaidSource(html);
    useEffect(() => {
      if (open || !source) return;
      let live = true;
      mermaid
        .render("brook-mermaid-" + block.id, source)
        .then((r: { svg: string }) => { if (live) setSvg(r.svg); })
        .catch(() => { /* keep the source on screen */ });
      return () => { live = false; };
    }, [open, source, block.id]);
    if (open || !svg) return <pre className="brook-mermaid-body">{source}</pre>;
    return <div dangerouslySetInnerHTML={{ __html: svg }} />;
  },
};

Bring your own highlighter

components.CodeBlock (or components.pre / components.code) bypasses the built-in highlighter entirely. If yours is async, render plain while the fence is open and highlight once on close — never per patch:

import { useEffect, useState } from "react";
import type { BlockComponentProps, Components } from "brookmd";
import { highl