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 +265 -2
- package/README.md +230 -19
- package/package.json +20 -5
- package/src/block-props.ts +96 -0
- package/src/client.ts +1 -1
- package/src/dom.ts +430 -0
- package/src/element.ts +339 -0
- package/src/renderers/CodeBlock.tsx +62 -5
- package/src/renderers/Math.tsx +5 -3
- package/src/renderers/Mermaid.tsx +4 -3
- package/src/solid.tsx +70 -0
- package/src/svelte.ts +55 -0
- package/src/types-core.ts +138 -0
- package/src/types-react.ts +14 -0
- package/src/types.ts +7 -150
- package/src/vue.ts +100 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
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
|
|
18
|
-
|
|
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.
|
|
20
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
164
|
-
|
|
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
|
-
//
|
|
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.
|
|
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 `
|
|
335
|
-
|
|
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
|
-
|
|
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
|