flux-md 0.5.1 → 0.6.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 CHANGED
@@ -4,6 +4,268 @@ Notable changes to flux-md. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.6.0 — 2026-05-28
8
+
9
+ ### Added — flux-md is no longer React-only
10
+
11
+ The core (`FluxClient` + the WASM worker) was always framework-neutral; only
12
+ the renderer was React-bound. This release adds five new entry points, each
13
+ **thin lifecycle glue** over one new framework-agnostic DOM renderer — none
14
+ re-implements the subscribe/diff loop, and none destroys your client (you own
15
+ the worker/stream).
16
+
17
+ - **`flux-md/dom`** — the foundation. `mountFluxMarkdown(client, container,
18
+ options?) → { destroy(), refresh() }` incrementally patches a DOM subtree
19
+ using the parser's stable block IDs: a committed block's node is never
20
+ recreated (so one-shot work like syntax highlighting and the copy-button
21
+ listener runs exactly once), only the streaming tail re-renders. Reuses the
22
+ in-house highlighter for deferred code, applies your `sanitize` hook to the
23
+ open/speculative tail, and batches patches per `requestAnimationFrame`.
24
+ Block-kind overrides via `components` (`(props) => HTMLElement | string`);
25
+ tag-level overrides remain React-only.
26
+ - **`flux-md/element`** — `defineFluxMarkdown(tag = "flux-markdown")` defines a
27
+ `<flux-markdown>` custom element. Light DOM (your markdown CSS applies),
28
+ SSR-safe (no auto-register), and usable three ways: a caller-owned `client`
29
+ property, a self-owned client driven by `append()`/`finalize()`, or zero-JS
30
+ via a `src` URL it fetch-streams / inline text / a `markdown` attribute.
31
+ Config flags map to tri-state attributes (`gfm-math`, `dir-auto`, …). Covers
32
+ **Angular** with `CUSTOM_ELEMENTS_SCHEMA` — no separate package.
33
+ - **`flux-md/vue`** — a `<FluxMarkdown>` component + `useFluxMarkdown`
34
+ composable (Vue 3, optional peer dep).
35
+ - **`flux-md/svelte`** — a `fluxMarkdown` action, `use:fluxMarkdown={{ client }}`
36
+ (Svelte 4 and 5, optional peer dep).
37
+ - **`flux-md/solid`** — a `<FluxMarkdown>` component (Solid, optional peer dep).
38
+ Newest binding: its mount/teardown glue is tested, but the JSX component shell
39
+ has only been exercised via a real `vite-plugin-solid` build, not in CI — the
40
+ `flux-md/dom` mount inside `onMount`/`onCleanup` is the fallback if your Solid
41
+ toolchain trips on it.
42
+
43
+ Purely additive — existing `flux-md` / `flux-md/react` / `flux-md/client` users
44
+ are unaffected (the React renderer and core are byte-identical; the only change
45
+ to existing code was a type-only import repoint so the neutral entry points
46
+ typecheck without React). `vue`, `svelte`, and `solid-js` join `react` as
47
+ optional peer dependencies — import only the binding you need. See the new
48
+ "Framework bindings" section in the README. 65 → 85 tests.
49
+
50
+ ## 0.5.6 — 2026-05-28
51
+
52
+ ### Performance
53
+
54
+ - **`ContainerCache` now handles multi-paragraph inner content.** A blockquote
55
+ or GitHub alert with blank `>` lines inside (`> [!NOTE]\n> Para one.\n>\n>
56
+ Para two.\n`) used to drop the cache and fall back to the O(n²) full path
57
+ the moment the first blank arrived. The cache now closes the current
58
+ paragraph on a blank `>` and starts a new one, preserving the
59
+ streaming-O(new bytes) shape across multi-paragraph inner content. Each
60
+ completed inner paragraph is pre-rendered into a growing
61
+ `committed_paras_html` string; the single-paragraph fast path (the bench's
62
+ `big_blockquote` / `big_alert`) is unchanged within noise.
63
+
64
+ - **`ListCache` now handles loose lists.** A flat list with blank lines
65
+ between siblings (`- one\n\n- two\n\n- three\n`) is a CommonMark "loose"
66
+ list — every item body gets wrapped in `<p>…</p>` — and the cache used to
67
+ bail on the first blank. The cache now flips to loose on the first
68
+ blank-then-marker sequence, re-renders prior cached items with `<p>`
69
+ wrappers from stored source spans (one-time O(items)), and continues the
70
+ streaming-O(new bytes) shape from there. Tight→loose is sticky.
71
+
72
+ 50 KB loose-list bench, before-fix → after-fix:
73
+
74
+ | chunk | before | after | speedup |
75
+ |------:|---------:|--------:|--------:|
76
+ | 16 | 5593 ms | 21 ms | ~272× |
77
+ | 256 | 355 ms | 7 ms | ~49× |
78
+
79
+ Tight `big_list` perf is unchanged within bench noise.
80
+
81
+ ### Added
82
+
83
+ - **React `CodeBlock` default renderer ships a copy-to-clipboard button.**
84
+ Closed code blocks now show an icon + "Copy" in their header (the existing
85
+ "streaming" pill takes that slot until close, so streaming code is never
86
+ copy-clickable mid-arrival). Click → copies the decoded source via
87
+ `navigator.clipboard.writeText` → swaps to a checkmark + "Copied" for
88
+ 1.5 s → reverts. Native `<button>` (keyboard-reachable), `aria-label`
89
+ toggles between "Copy code" and "Copied" with `aria-live="polite"`,
90
+ guards against `navigator.clipboard` being absent (SSR / insecure context)
91
+ and rejected `writeText` promises (permission denied) — both leave the
92
+ button silently usable. No new dependency.
93
+
94
+ ### Documentation
95
+
96
+ - README quickstart now uses `useState(() => new FluxClient())` + an
97
+ unmount-only destroy effect instead of `useMemo(() => new FluxClient(),
98
+ [])` + cleanup-on-stream-change (which destroyed the client when the
99
+ `stream` prop changed, leaking a freed parser on the next append).
100
+ - New "when to enable each flag" guide for `ParserConfig` with concrete
101
+ LLM-output triggers (`gfmMath` when `$…$` arrives, `componentTags` for
102
+ `<Thinking>` blocks, etc.) — so a reader picks flags without reading the
103
+ full reference further down.
104
+ - `Alert` block-kind override example added to the `components` docs.
105
+ - `sanitize` example mirrors the realistic memoize-at-module-scope pattern
106
+ from the live demo (a fresh arrow each render busts the per-block memo).
107
+ - New "Performance" section pointing to CHANGELOG / `examples/bench.rs` for
108
+ numbers (no numbers baked into the README — those rot).
109
+
110
+ ## 0.5.5 — 2026-05-28
111
+
112
+ ### Performance
113
+
114
+ - 1× memcpy in the paragraph / container cache assembly (was 2×). Both caches
115
+ were building the block HTML in two stages — concatenate
116
+ `committed + active` into an intermediate `String`, then concatenate
117
+ `<p>` + that into the output — so a long open paragraph or container did two
118
+ memcpys of the committed inner per append. The fix builds directly into the
119
+ output buffer and trims trailing whitespace in-place; the container case
120
+ backs out a provisional `<p>` opener if the body content turns out to be
121
+ empty (preserving the empty-body fix from 0.5.4). Output is byte-identical.
122
+
123
+ 200 KB bench (best of 7), chunk=16:
124
+
125
+ | shape | 0.5.4 | 0.5.5 | speedup |
126
+ |-----------------|---------:|---------:|--------:|
127
+ | `long_paragraph`| 142 ms | **96 ms**| 1.48× |
128
+ | `emphasis_para` | 170 ms | **116 ms**| 1.47× |
129
+ | `big_blockquote`| 213 ms | **157 ms**| 1.36× |
130
+ | `big_alert` | 343 ms | **237 ms**| 1.45× |
131
+
132
+ Modest wins at every chunk size for the affected caches; the
133
+ table / list / fence caches are unchanged (they were already 1× memcpy).
134
+
135
+ ## 0.5.4 — 2026-05-28
136
+
137
+ ### Fixed (mid-stream rendering)
138
+
139
+ - **GFM tables now form during streaming, not just at finalize.** Streaming a
140
+ table char-by-char (or in any chunking where the delimiter row's `\n` lands
141
+ in a different chunk than the row's content) used to leave the block as a
142
+ `<p>` spanning both lines until `.finalize()` ran. The paragraph cache's
143
+ delimiter-detection walked from the line AFTER the cut and so missed a
144
+ delimiter row that completed inside the line the cut had advanced into. The
145
+ fix re-checks the line containing the cut whenever it has just completed,
146
+ guarded by a cheap `bytes[cut..].contains('\n')` so long open paragraphs
147
+ without interior `\n` still take the O(new bytes) per-call path.
148
+ - **Open alerts/blockquotes with an empty body no longer render an empty
149
+ `<p></p>`.** A `> [!NOTE]\n` shown mid-stream now matches the full renderer:
150
+ `<div class="markdown-alert ...">…<p class="...title">Note</p></div>` with
151
+ no empty body paragraph. The container cache was wrapping the body in
152
+ `<p>…</p>` unconditionally, even when the body was empty.
153
+
154
+ Both bugs only manifested *before* `finalize()`. The post-finalize output —
155
+ what every existing parity test checks — was already correct, which is why
156
+ neither was caught earlier. A new `tests/midstream_parity.rs` asserts that the
157
+ streamed view of an open block matches what one-shot parsing produces for the
158
+ same prefix (tables, alerts, blockquotes, lists, code fences, math fences).
159
+
160
+ ### Performance
161
+
162
+ - `big_table` at the artificial `chunk=16` stress case is ~280 ms (was ~145 ms
163
+ in 0.5.3). The 145 ms was the *incorrect* path: the paragraph cache treated
164
+ the whole 200 KB table as a single growing paragraph until finalize, never
165
+ engaging the table cache. The 280 ms is the cost of correctly emitting the
166
+ table mid-stream at the smallest chunk size. Every realistic LLM streaming
167
+ chunk size (≥64 bytes) is unchanged — `big_table` at chunk=64 is 73 ms,
168
+ chunk=256 is 38 ms, etc.
169
+
170
+ ## 0.5.3 — 2026-05-28
171
+
172
+ ### Performance
173
+
174
+ - **Streaming long open resumable containers is now O(n).** A long
175
+ `> [!NOTE]` alert, a `>`-quoted explanation, or a flat bullet/ordered list
176
+ used to re-run scan + inline render over the whole growing inner on every
177
+ append (O(n²)). Three new tail caches mirror the existing fence/table
178
+ pattern:
179
+
180
+ - `ContainerCache` — single-paragraph blockquote / GitHub alert. Wraps
181
+ the existing paragraph-cache (inline-boundary commit) with a
182
+ `>`-stripped inner buffer; the wrapper HTML (`<blockquote>` /
183
+ alert `<div>`) is built once at arm time, each new `> ` line is
184
+ stripped once into the inner buffer, only the unsettled inline tail is
185
+ re-rendered. Bails on a blank `>`-line (paragraph break inside the
186
+ container), lazy continuation, or `\r`.
187
+
188
+ - `ListCache` — tight, flat list (the LLM-emit shape: one sibling marker
189
+ per line, no blanks, no continuation, no nesting). Opener
190
+ (`<ul>` / `<ol start=N>`) pre-rendered at arm time; each new sibling
191
+ line renders directly into the cache as a tight `<li>…</li>` (GFM
192
+ task-list `[ ] `/`[x] ` supported). Bails on the first blank line
193
+ (loose-list signal), non-marker line, over-edge marker (nested), or
194
+ foreign-family marker — the full path handles those.
195
+
196
+ Measured at 50 KB (best of 7), before → after:
197
+
198
+ | shape | chunk=16 | chunk=256 |
199
+ |-----------------|-------------------|-----------------|
200
+ | `big_blockquote`| 5164 → **22 ms** | 332 → **8.5 ms**|
201
+ | `big_list` | 6141 → **18 ms** | 391 → **7.4 ms**|
202
+ | `big_alert` | 6298 → **28 ms** | 404 → **11 ms** |
203
+
204
+ At 200 KB, `big_list` chunk=256 was extrapolating to ~6.2 s before the
205
+ cache; now **36 ms** (~170×). Every realistic streaming shape now has a
206
+ flat chunk-size curve.
207
+
208
+ Output is byte-identical. Parity gated by `tests/container_cache.rs`
209
+ (blockquote + all five alert kinds, dir_auto, CRLF, lazy continuation,
210
+ multi-paragraph fallback, 400-line stress) and `tests/list_cache.rs` (5
211
+ marker families, ordered with non-default start, dir_auto, CRLF, loose /
212
+ nested / multi-line fallback, 400-item stress).
213
+
214
+ ### Documentation
215
+
216
+ - Reworded the "future plugin slot" comments in `renderers/Math.tsx` and
217
+ `renderers/Mermaid.tsx`. The actual extension path is the
218
+ `components.MathBlock` / `components.Mermaid` overrides, which already
219
+ works end-to-end.
220
+
221
+ ### Known limitations
222
+
223
+ - The three new caches disarm when `gfmFootnotes` is on, mirroring
224
+ `TableCache` from 0.5.2: cell-level `[^x]` occurrence ids would diverge
225
+ across the cache vs. full-reparse boundary. Footnotes + a long container
226
+ / table stays on the full O(n²) path — rare combination, may be lifted
227
+ in a later release by tracking per-cache footnote-occ deltas.
228
+ - The blockquote/alert cache covers the *single-paragraph* inner case (the
229
+ realistic LLM shape). A long open container with a multi-block inner
230
+ (lists inside, fenced code inside, etc.) still routes through the full
231
+ path. The bench's `big_blockquote` / `big_alert` are single-paragraph
232
+ shapes — what these caches were built for.
233
+
234
+ ## 0.5.2 — 2026-05-28
235
+
236
+ ### Performance
237
+
238
+ - **Streaming a long GFM table is now O(n) at every chunk size.** Tables already
239
+ rendered visually incrementally (header at the delimiter row, rows append as
240
+ they arrive) — but `render_table` re-walked every row on every append, so the
241
+ total work was O(n²) once chunks exceeded ~30 bytes (a row). The fix is an
242
+ incremental `TableCache` that mirrors the existing code/math `FenceCache`:
243
+ `<thead>` is pre-rendered once, each newly-complete `<tr>` is folded into the
244
+ cached prefix, and only the trailing partial row is re-rendered each append.
245
+ Output is byte-identical; parity gated by `tests/table_cache.rs` (every chunk
246
+ size 1..=9 × char-by-char against one-shot, with alignments, inline markdown,
247
+ link refs, CRLF fallback, and a 400-row stress case).
248
+
249
+ Measured on a 200 KB table (best of 7 — chunk varies on each row):
250
+
251
+ | chunk | before | after | speedup |
252
+ |------:|---------:|------:|--------:|
253
+ | 16 | 143 ms | 145 ms | ~1× (was already fast) |
254
+ | 64 | 20807 ms | 78 ms | **267×** |
255
+ | 128 | 10414 ms | 54 ms | **193×** |
256
+ | 256 | 5373 ms | 40 ms | **134×** |
257
+ | 512 | 2608 ms | 34 ms | **77×** |
258
+ | 1024 | 1322 ms | 31 ms | **43×** |
259
+
260
+ The pre-fix bench printed only chunks 16 and 256, which hid the regression
261
+ (16 was fine, 256 was the cliff floor). The bench now sweeps 16/64/128/256/
262
+ 512/1024 so the next regression in this shape can't slip in unnoticed.
263
+
264
+ Footnotes are the one combination still on the full O(n²) path: the
265
+ cell-level `[^x]` occurrence counter would diverge across the
266
+ cache/full-reparse boundary, so the cache disarms when `gfmFootnotes` is on
267
+ (rare enough to defer to a later release).
268
+
7
269
  ## 0.5.1 — 2026-05-27
8
270
 
9
271
  ### Performance
@@ -14,8 +276,9 @@ Notable changes to flux-md. Format based on
14
276
  two-level lookup (committed, then the uncommitted tail), and folded in place
15
277
  via `Rc::make_mut` once the render's clone is dropped. A 235 KB
16
278
  reference-definition stream at 16-byte chunks: **~1,395 ms → ~53 ms** (~26×).
17
- This was the last remaining O(n²) streaming shape every realistic shape is
18
- now O(n). Output is unchanged.
279
+ This was believed to be the last remaining O(n²) streaming shape; in fact a
280
+ long open GFM table was still O(n²) (fixed in 0.5.2 `big_table` at
281
+ chunk=256 went from ~5,400 ms to ~40 ms). Output is unchanged.
19
282
 
20
283
  ## 0.5.0 — 2026-05-27
21
284
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Zero-dep streaming markdown for the browser. Rust→WASM core, one Web Worker per stream, incremental parse with speculative closure for mid-stream constructs.
4
4
 
5
+ Drop in a streaming-aware renderer — **React, Vue, Svelte, Solid, a framework-agnostic `<flux-markdown>` Web Component, or the vanilla DOM mount** — wire each LLM stream to a `FluxClient`, and the markdown renders incrementally off the main thread, block by block, with stable identities so unchanged blocks never re-reconcile.
6
+
5
7
  Parsing runs entirely **off the main thread** — each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document, and heavy renderers (syntax highlighting, math, mermaid) are **deferred until a block closes**. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
6
8
 
7
9
  ## Install
@@ -16,8 +18,10 @@ import.meta.url)`** pattern, so any bundler with asset-module support resolves
16
18
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
17
19
  modules), and **Parcel**. Next.js (webpack/turbopack) should work but is
18
20
  untested — file an issue if it doesn't. It is **browser-only** (it constructs
19
- Web Workers); it does not run under SSR/RSC. `react` is an optional peer
20
- dependencyonly needed if you import `flux-md/react`.
21
+ Web Workers); it does not run under SSR/RSC. The framework packages `react`,
22
+ `vue`, `svelte`, `solid-js` are all **optional** peer dependencies; you only
23
+ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
24
+ `flux-md/dom`, `flux-md/element`) needs none.
21
25
 
22
26
  ## Quick start
23
27
 
@@ -37,11 +41,13 @@ client.finalize();
37
41
  In React:
38
42
 
39
43
  ```tsx
40
- import { useEffect, useMemo } from "react";
44
+ import { useEffect, useState } from "react";
41
45
  import { FluxClient, FluxMarkdown } from "flux-md";
42
46
 
43
47
  export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
44
- const client = useMemo(() => new FluxClient(), []);
48
+ // One client per component instance. Destroy on unmount, not on stream change.
49
+ const [client] = useState(() => new FluxClient());
50
+ useEffect(() => () => client.destroy(), [client]);
45
51
 
46
52
  useEffect(() => {
47
53
  let cancelled = false;
@@ -52,11 +58,8 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
52
58
  }
53
59
  if (!cancelled) client.finalize();
54
60
  })();
55
- return () => {
56
- cancelled = true;
57
- client.destroy();
58
- };
59
- }, [stream]);
61
+ return () => { cancelled = true; };
62
+ }, [client, stream]);
60
63
 
61
64
  return <FluxMarkdown client={client} />;
62
65
  }
@@ -64,6 +67,166 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
64
67
 
65
68
  Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
66
69
 
70
+ ## Framework bindings
71
+
72
+ `FluxClient` is framework-neutral — it owns the worker and exposes
73
+ `subscribe`/`getSnapshot`. Pick a renderer to put its blocks on screen. Every
74
+ binding below is thin glue over the same incremental DOM renderer, so they
75
+ share one identity contract: a committed block's node is never recreated, only
76
+ the streaming tail re-renders.
77
+
78
+ **One ownership rule across all bindings:** the renderer's teardown (React
79
+ unmount, `handle.destroy()`, element disconnect, etc.) frees only the rendered
80
+ DOM and the subscription — it **never** destroys the client. You call
81
+ `client.destroy()` when you're done with the stream. (React's `<FluxMarkdown>`,
82
+ documented [below](#fluxmarkdown-react), is the same.)
83
+
84
+ ### Vanilla / any framework — `flux-md/dom`
85
+
86
+ ```ts
87
+ import { FluxClient } from "flux-md/client";
88
+ import { mountFluxMarkdown } from "flux-md/dom";
89
+
90
+ const client = new FluxClient();
91
+ const handle = mountFluxMarkdown(client, document.getElementById("out")!, {
92
+ stickToBottom: true,
93
+ });
94
+
95
+ // Feed it from a fetch/SSE reader:
96
+ const reader = (await fetch("/api/chat")).body!.getReader();
97
+ const dec = new TextDecoder();
98
+ for (;;) {
99
+ const { value, done } = await reader.read();
100
+ if (done) break;
101
+ client.append(dec.decode(value, { stream: true })); // stream:true carries multibyte across chunks
102
+ }
103
+ client.append(dec.decode());
104
+ client.finalize();
105
+
106
+ // Teardown: destroy BOTH — the renderer and the client you created.
107
+ handle.destroy();
108
+ client.destroy();
109
+ ```
110
+
111
+ `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
112
+ Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
113
+ (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
114
+ Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
115
+ `Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
116
+ (lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
117
+ tree on the fast `innerHTML` path; a block-kind override can rewrite the `html`
118
+ it's handed instead.
119
+
120
+ ### Web Component `<flux-markdown>` — `flux-md/element`
121
+
122
+ The universal binding — plain HTML, Angular, or any framework that renders DOM.
123
+ Register once, then use the element:
124
+
125
+ ```ts
126
+ import { defineFluxMarkdown } from "flux-md/element";
127
+ defineFluxMarkdown(); // defines <flux-markdown>; pass a custom tag name if you like
128
+ ```
129
+
130
+ ```html
131
+ <!-- zero-JS streaming straight from a URL -->
132
+ <flux-markdown src="/api/post.md" gfm-math stick-to-bottom></flux-markdown>
133
+
134
+ <!-- one-shot from inline text -->
135
+ <flux-markdown># Hello **world**</flux-markdown>
136
+ ```
137
+
138
+ ```js
139
+ // or caller-owned streaming — drive your own client:
140
+ const el = document.querySelector("flux-markdown");
141
+ el.client = myFluxClient; // element subscribes; never destroys it
142
+ el.components = { Thinking: (p) => myNode(p) };
143
+ myFluxClient.append(delta);
144
+ ```
145
+
146
+ Config flags are **tri-state attributes**: absent = library default;
147
+ `gfm-math` / `gfm-math="true"` / `="1"` = on; `gfm-math="false"` / `="0"` = off
148
+ (the only way to turn off a default-on flag such as `gfm-alerts`). It renders in
149
+ light DOM so your markdown CSS applies, and `defineFluxMarkdown` is a no-op under
150
+ SSR (no `customElements`). A self-owned element (`src` / `markdown` / inline
151
+ text / `append()`) is torn down on disconnect; a caller-supplied `client` is left
152
+ alone.
153
+
154
+ **Angular** consumes the same element — no separate package:
155
+
156
+ ```ts
157
+ import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
158
+ import { defineFluxMarkdown } from "flux-md/element";
159
+ defineFluxMarkdown(); // once at bootstrap
160
+
161
+ @Component({
162
+ standalone: true,
163
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
164
+ template: `<flux-markdown [attr.src]="url" stick-to-bottom></flux-markdown>`,
165
+ })
166
+ export class Answer { url = "/api/post.md"; }
167
+ ```
168
+
169
+ ### Vue 3 — `flux-md/vue`
170
+
171
+ ```vue
172
+ <script setup lang="ts">
173
+ import { onBeforeUnmount } from "vue";
174
+ import { FluxClient } from "flux-md/client";
175
+ import { FluxMarkdown } from "flux-md/vue";
176
+
177
+ const client = new FluxClient();
178
+ // feed client.append(delta) from your stream, then client.finalize()
179
+ onBeforeUnmount(() => client.destroy());
180
+ </script>
181
+
182
+ <template>
183
+ <FluxMarkdown :client="client" stick-to-bottom />
184
+ </template>
185
+ ```
186
+
187
+ Props: `client` (required), `components`, `sanitize`, `virtualize`,
188
+ `stickToBottom`. There's also a `useFluxMarkdown` composable returning a
189
+ `container` ref if you'd rather mount into your own element.
190
+
191
+ ### Svelte (4 & 5) — `flux-md/svelte`
192
+
193
+ A Svelte action — works in both v4 and v5, no `.svelte` build step:
194
+
195
+ ```svelte
196
+ <script lang="ts">
197
+ import { onDestroy } from "svelte";
198
+ import { FluxClient } from "flux-md/client";
199
+ import { fluxMarkdown } from "flux-md/svelte";
200
+
201
+ const client = new FluxClient();
202
+ // feed client.append(delta) then client.finalize()
203
+ onDestroy(() => client.destroy());
204
+ </script>
205
+
206
+ <div use:fluxMarkdown={{ client, stickToBottom: true }} />
207
+ ```
208
+
209
+ ### Solid — `flux-md/solid`
210
+
211
+ ```tsx
212
+ import { onCleanup } from "solid-js";
213
+ import { FluxClient } from "flux-md/client";
214
+ import { FluxMarkdown } from "flux-md/solid";
215
+
216
+ const client = new FluxClient();
217
+ // feed client.append(delta) then client.finalize()
218
+ onCleanup(() => client.destroy());
219
+
220
+ <FluxMarkdown client={client} stickToBottom />;
221
+ ```
222
+
223
+ The Solid binding's mount/teardown logic is tested, but its JSX component shell
224
+ has so far only been exercised through a real Solid (`vite-plugin-solid`) build
225
+ in development, not in CI — treat it as the newest of the bindings and file an
226
+ issue if your Solid setup trips on it. The component is a thin `ref`'d `<div>`;
227
+ if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
228
+ `onMount`/`onCleanup` is the zero-surprise fallback.
229
+
67
230
  ## What it does
68
231
 
69
232
  | Concern | flux-md | conventional main-thread renderer |
@@ -73,7 +236,7 @@ Multiple concurrent streams just need multiple clients — each runs in its own
73
236
  | Block identity across chunks | Stable monotonic IDs | New keys on every render |
74
237
  | Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
75
238
  | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
76
- | XSS sanitization | Allowlist in Rust + URL scheme check | rehype-sanitize on JS thread |
239
+ | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
77
240
 
78
241
  ## Public API
79
242
 
@@ -114,6 +277,25 @@ Omitted fields use the defaults above, so `new FluxClient()` is unchanged.
114
277
  Config is applied when the stream's parser is created and is **immutable** for
115
278
  that stream (`reset()` keeps it; use a new client for different flags).
116
279
 
280
+ When to enable each flag:
281
+
282
+ - `gfmAutolinks` — on by default. Leave it on unless you want strict CommonMark.
283
+ - `gfmAlerts` — on by default. Leave it on unless you want strict CommonMark.
284
+ - `gfmMath: true` — when your LLM emits `$…$` or `$$…$$` (or LaTeX `\(…\)` /
285
+ `\[…\]`). flux-md emits KaTeX-ready markup; you bring the KaTeX pass (or
286
+ `components.MathBlock`).
287
+ - `gfmFootnotes: true` — when your input uses `[^1]` references and `[^1]:`
288
+ definitions. Off by default; see the footnote streaming caveat above.
289
+ - `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
290
+ `dir="auto"` so the browser detects direction independently per block.
291
+ - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
292
+ LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
293
+ similar — see [Security](#security)).
294
+ - `componentTags: ["Thinking", …]` — when your LLM emits custom tags like
295
+ `<Thinking>…</Thinking>` and you want their inner content parsed as markdown
296
+ and dispatched to a React component. Safe without `unsafeHtml` (attributes are
297
+ sanitized; allowlisted tags only).
298
+
117
299
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
118
300
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
119
301
  can't re-render), and the footnote **section is emitted at finalize**. So a
@@ -160,15 +342,15 @@ Subscribes to a `FluxClient`, renders each block keyed by its stable parser-assi
160
342
 
161
343
  #### Custom components / overrides
162
344
 
163
- Pass a `components` map to replace how elements render the same idea as
164
- react-markdown's `components` prop, but the keys come in **two namespaces**:
345
+ Pass a `components` map to replace how elements render. Keys come in **two
346
+ namespaces**:
165
347
 
166
348
  ```tsx
167
349
  import { useMemo } from "react";
168
350
  import { FluxClient, FluxMarkdown, type Components } from "flux-md";
169
351
 
170
352
  function Message({ client }: { client: FluxClient }) {
171
- // ⚠️ Memoize (or hoist to module scope). A fresh object every render busts
353
+ // Memoize (or hoist to module scope). A fresh object every render busts
172
354
  // FluxMarkdown's block memo, so every block re-parses on every patch.
173
355
  const components: Components = useMemo(
174
356
  () => ({
@@ -181,6 +363,15 @@ function Message({ client }: { client: FluxClient }) {
181
363
  CodeBlock: ({ text, language, open }) => (
182
364
  <MyCodeBlockWithCopyButton code={text} lang={language} streaming={open} />
183
365
  ),
366
+
367
+ // GitHub alerts (`> [!NOTE]` / `[!TIP]` / `[!WARNING]` / `[!CAUTION]` /
368
+ // `[!IMPORTANT]`) — swap in your own callout component. The alert kind
369
+ // is on `block.kind.data.kind`; `html` is the rendered inner body.
370
+ Alert: ({ block, html }) => (
371
+ <MyCallout kind={(block.kind.data as { kind: string }).kind}>
372
+ <div dangerouslySetInnerHTML={{ __html: html }} />
373
+ </MyCallout>
374
+ ),
184
375
  }),
185
376
  [],
186
377
  );
@@ -313,8 +504,8 @@ styles them, and they're overridable as a block kind via `components.Alert`.
313
504
  By design, not yet, or only partially:
314
505
 
315
506
  - **Raw HTML in markdown** — escaped by default, not passed through. (Security
316
- default. A `setUnsafeHtml(true)` opt-in exists but must never be enabled for
317
- untrusted input.)
507
+ default. The `unsafeHtml: true` config flag disables the escape but must never
508
+ be enabled for untrusted input without a `sanitize` hook.)
318
509
  - **Forward link references when streaming** — a `[ref]` used *before* its later
319
510
  `[ref]: url` definition can't resolve until the definition arrives; one-shot
320
511
  parsing handles it fully, streaming converges once the definition streams in.
@@ -326,13 +517,28 @@ By design, not yet, or only partially:
326
517
  - **Syntax highlighting on open code blocks** — deferred until close. This is a
327
518
  deliberate perf choice.
328
519
 
520
+ ## Performance
521
+
522
+ Every realistic streaming shape (long paragraph, fenced code block, GFM table,
523
+ blockquote/alert, flat list, math fence, reference-heavy document) parses in
524
+ **O(n) total work**, not O(n²) — at every chunk size from 16 bytes (char-by-char)
525
+ up. Each shape has an incremental cache that mirrors the structure of the block
526
+ so that an append only does work proportional to the *newly arrived* bytes, not
527
+ the growing tail. See [CHANGELOG.md](./CHANGELOG.md) for per-shape numbers and
528
+ the regression that prompted each cache; the canonical bench is
529
+ `crates/flux-md-core/examples/bench.rs` (`cargo run --release --example bench`).
530
+
531
+ Headline numbers are not durable across machines, but the curve is: chunk size
532
+ shouldn't change the order of magnitude for any shape. If you hit one that does,
533
+ file an issue with the input and chunking — that's the next bench scenario.
534
+
329
535
  ## Security
330
536
 
331
537
  flux-md is XSS-safe by default — its HTML output is meant to be injected via
332
538
  `innerHTML` without a downstream sanitizer:
333
539
 
334
- - **Raw HTML is escaped** (the `unsafe_html` / `setUnsafeHtml(true)` opt-in
335
- disables this; **never enable it for untrusted input**).
540
+ - **Raw HTML is escaped** (the `unsafeHtml: true` config flag disables this;
541
+ **never enable it for untrusted input without a `sanitize` hook**).
336
542
  - **Dangerous URL schemes are neutralized** in `<a href>` and `<img src>` —
337
543
  `javascript:`, `vbscript:`, `data:text/html`, `data:text/javascript` become
338
544
  `#`. The check runs on the *decoded* URL and strips characters browsers
@@ -352,12 +558,17 @@ that returns raw HTML), **bring a real sanitizer** and pass it via
352
558
  `<FluxMarkdown sanitize={…} />`. flux-md applies it to every block's HTML before
353
559
  injection — **including the streaming (open) tail**, which the raw-`innerHTML`
354
560
  fast path would otherwise expose. flux-md stays zero-dep; you choose the
355
- sanitizer:
561
+ sanitizer. The realistic pattern (matches the live demo):
356
562
 
357
563
  ```tsx
358
564
  import DOMPurify from "dompurify";
359
565
 
360
- <FluxMarkdown client={client} sanitize={(html) => DOMPurify.sanitize(html)} />
566
+ // Hoist to module scope (or wrap in useCallback). A fresh arrow each render
567
+ // busts FluxMarkdown's per-block memo and re-runs every block through sanitize.
568
+ const sanitize = (html: string) => DOMPurify.sanitize(html);
569
+
570
+ // …then in your component:
571
+ <FluxMarkdown client={client} sanitize={sanitize} />
361
572
  ```
362
573
 
363
574
  The built-in code/math renderers operate on already-escaped content and are not