flux-md 0.16.2 → 0.18.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 +950 -0
- package/README.md +111 -82
- package/dist/block-props.d.ts +18 -0
- package/dist/block-props.js +75 -0
- package/dist/client.d.ts +311 -0
- package/{src/client.ts → dist/client.js} +143 -325
- package/dist/dom.d.ts +110 -0
- package/dist/dom.js +576 -0
- package/dist/element.d.ts +20 -0
- package/dist/element.js +287 -0
- package/dist/hi.d.ts +12 -0
- package/{src/hi.ts → dist/hi.js} +42 -74
- package/dist/html-to-react.d.ts +40 -0
- package/dist/html-to-react.js +344 -0
- package/{src/index.ts → dist/index.d.ts} +5 -25
- package/dist/index.js +16 -0
- package/dist/morph.d.ts +28 -0
- package/dist/morph.js +166 -0
- package/dist/react.d.ts +204 -0
- package/dist/react.js +490 -0
- package/dist/renderers/CodeBlock.d.ts +7 -0
- package/dist/renderers/CodeBlock.js +75 -0
- package/dist/renderers/Math.d.ts +14 -0
- package/dist/renderers/Math.js +15 -0
- package/dist/renderers/Mermaid.d.ts +13 -0
- package/dist/renderers/Mermaid.js +15 -0
- package/dist/server-react.d.ts +32 -0
- package/dist/server-react.js +48 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +81 -0
- package/{src/solid.tsx → dist/solid.d.ts} +19 -95
- package/dist/solid.js +54 -0
- package/dist/svelte.d.ts +80 -0
- package/dist/svelte.js +59 -0
- package/dist/types-core.d.ts +382 -0
- package/dist/types-core.js +0 -0
- package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
- package/dist/types-react.js +0 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/dist/vue.d.ts +94 -0
- package/dist/vue.js +79 -0
- package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
- package/{src → dist}/wasm/flux_md_core.js +50 -55
- package/dist/wasm/flux_md_core_bg.wasm +0 -0
- package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker-core.d.ts +65 -0
- package/dist/worker-core.js +154 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +48 -0
- package/package.json +25 -19
- package/src/block-props.ts +0 -141
- package/src/dom.ts +0 -946
- package/src/element.ts +0 -400
- package/src/html-to-react.ts +0 -455
- package/src/morph.ts +0 -253
- package/src/react.tsx +0 -1020
- package/src/renderers/CodeBlock.tsx +0 -116
- package/src/renderers/Math.tsx +0 -28
- package/src/renderers/Mermaid.tsx +0 -27
- package/src/server.tsx +0 -221
- package/src/svelte.ts +0 -179
- package/src/types-core.ts +0 -398
- package/src/types.ts +0 -7
- package/src/vue.ts +0 -184
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/worker-core.ts +0 -174
- package/src/worker.ts +0 -72
- /package/{src → dist}/styles.css +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to flux-md. Format based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/); this project aims to follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## 0.18.0 — 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`flux-md/server/react` subpath.** Exports `FluxMarkdownStatic` (the hookless
|
|
12
|
+
RSC / SSR React component), moved here from `flux-md/server` so that the core
|
|
13
|
+
server entry stays React-free (see Changed).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **`FluxMarkdownStatic` moved from `flux-md/server` to `flux-md/server/react`.**
|
|
18
|
+
`flux-md/server` (`initFlux` / `initFluxSync` / `isFluxReady` / `parseToBlocks`
|
|
19
|
+
/ `renderToString`) is now genuinely **React-free**: it imports no framework, so
|
|
20
|
+
a non-React build step or a Vue/Svelte SSR app can
|
|
21
|
+
`import { renderToString } from "flux-md/server"` even when `react` is not
|
|
22
|
+
installed. (Previously the entry failed to load without `react`, because the
|
|
23
|
+
component pulled it in eagerly — contradicting the "zero React dependency"
|
|
24
|
+
promise.) Update RSC/SSR imports to
|
|
25
|
+
`import { FluxMarkdownStatic } from "flux-md/server/react"`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Streaming finalize divergence (correctness).** A document streamed
|
|
30
|
+
char-by-char could finalize to different HTML than the same bytes parsed in one
|
|
31
|
+
shot, when the still-growing final line transiently looked like a block start
|
|
32
|
+
(`#…`, `</p…`, a lone `*` / `-`) and then completed into a lazy continuation of
|
|
33
|
+
the previous block (`#hashtag`, `</pre>`, `*emph*`). The penultimate block was
|
|
34
|
+
committed too early and frozen, permanently splitting a block the one-shot parse
|
|
35
|
+
keeps whole. The streaming commit boundary now keeps the penultimate block
|
|
36
|
+
speculative across such a provisional final line.
|
|
37
|
+
- **Coalesced completion deferred a frame.** Under the React hooks' rAF
|
|
38
|
+
coalescing (default since 0.17.0), the terminal `finalize()` patch could be
|
|
39
|
+
delivered one frame late — its synchronous-flush signal was consumed by an
|
|
40
|
+
earlier in-flight append patch — briefly showing a finished code block without
|
|
41
|
+
its highlight / copy button. The terminal patch is now tagged `final` at the
|
|
42
|
+
worker, so the sync flush binds to it regardless of how many append patches
|
|
43
|
+
precede it.
|
|
44
|
+
- **`reset()` ghost blocks.** Swapping a streaming source mid-flight (e.g. a React
|
|
45
|
+
"regenerate") could leave stale blocks from the previous content in the store,
|
|
46
|
+
because an in-flight patch raced the `reset()`. A per-stream generation counter
|
|
47
|
+
now drops pre-reset patches before they reach the cleared store.
|
|
48
|
+
- **Worker-pool robustness.** A fatally-failed worker (WASM-init failure, or a
|
|
49
|
+
trap that poisoned the shared instance) is now terminated and removed from the
|
|
50
|
+
pool — previously it lingered and could bypass the pool cap, leaking a worker
|
|
51
|
+
per stream. A WASM trap is escalated to a fatal worker error (the stream then
|
|
52
|
+
recovers onto a fresh worker) instead of being mishandled as a recoverable
|
|
53
|
+
per-stream error, and `free()` on a poisoned instance is guarded so teardown
|
|
54
|
+
can't throw out of the message loop.
|
|
55
|
+
|
|
56
|
+
### Security
|
|
57
|
+
|
|
58
|
+
- **O(n²) entity-decode DoS.** The numeric character-reference scan (`&#…`) was
|
|
59
|
+
unbounded; input like `&#&#&#…` (no terminator) re-scanned to end-of-input on
|
|
60
|
+
every `&`, freezing the single-threaded parser for seconds on a few hundred KB.
|
|
61
|
+
The scan is now bounded to the longest valid reference (7 decimal / 6 hex
|
|
62
|
+
digits), matching the already-bounded named-entity branch.
|
|
63
|
+
- **Incomplete `data:` link blocklist.** Script-capable `data:` media types
|
|
64
|
+
(`image/svg+xml`, `application/xhtml+xml`, `text/xml`, `application/xml`,
|
|
65
|
+
`application/javascript`, …) could render as a live link / autolink /
|
|
66
|
+
component-attribute `href` — a browser navigating to one runs its script. They
|
|
67
|
+
are now blocked on the href path. Inert `data:image/…` raster images via
|
|
68
|
+
`![]()` are unaffected (an `<img>`-loaded SVG cannot run script).
|
|
69
|
+
|
|
70
|
+
## 0.17.0 — 2026-06-27
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- **Compiled `dist/`.** The package now ships compiled, non-minified ESM
|
|
75
|
+
(`dist/*.js` + `.d.ts`) instead of raw TypeScript source — fixing consumers that
|
|
76
|
+
don't transpile `node_modules` (e.g. Next.js no longer needs
|
|
77
|
+
`transpilePackages`) and the Socket "unusual packaging" signal. The worker and
|
|
78
|
+
WASM remain separate assets so a consumer bundler still re-emits the worker
|
|
79
|
+
chunk and fetches the `.wasm`.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
- WASM shadow stack reduced from 1 MB to 256 KB, cutting the WASM initial-memory
|
|
84
|
+
floor from ~1088 KB to ~320 KB (memory stays growable for large documents).
|
|
85
|
+
- Worker→main wire format is now a JSON string (a string structured-clones far
|
|
86
|
+
cheaper than an object graph); dropped `serde-wasm-bindgen` (smaller binary).
|
|
87
|
+
- React `useFluxStream` / `useFluxMarkdownString` default to rAF coalescing (one
|
|
88
|
+
render per frame), matching the framework-neutral DOM adapter.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- Bounded three recursive descents in the parser (block render, link-reference
|
|
93
|
+
sweep, inline-component tags) at depth 100. With the smaller shadow stack an
|
|
94
|
+
unbounded descent on deeply nested input could trap and poison the worker;
|
|
95
|
+
beyond the cap, content is preserved as escaped text.
|
|
96
|
+
|
|
97
|
+
## 0.16.2 — 2026-06-26
|
|
98
|
+
|
|
99
|
+
### Fixed
|
|
100
|
+
|
|
101
|
+
- **Retryable WASM init.** A transient failure fetching the `.wasm` asset (web
|
|
102
|
+
path) no longer poisons every subsequent `initFlux()` / `renderToString()` —
|
|
103
|
+
the cached rejected promise is dropped so the next call retries.
|
|
104
|
+
- **Defensive `blockData` guards.** A malformed/drifted keyed-list `items` field
|
|
105
|
+
or table `rows`/`aligns`/`headers` now falls back to the full-HTML render path
|
|
106
|
+
instead of crashing the streaming render. The start-only ordered-list
|
|
107
|
+
renumber path is unaffected.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- `<flux-markdown>` stream-failure logging now logs only the error *message*,
|
|
112
|
+
not the raw `src` URL or the full error object (avoids a console forwarder
|
|
113
|
+
shipping a tokenized URL / bulky error body to monitoring).
|
|
114
|
+
- Micro-perf: memoized the components normalization and hoisted `parseOpenTag`'s
|
|
115
|
+
single-char regexes to module scope on the React render path.
|
|
116
|
+
|
|
117
|
+
## 0.16.1 — 2026-06-25
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
|
|
121
|
+
- **Streaming flash for incomplete inline links, code, and math.** While an
|
|
122
|
+
inline construct is still streaming in (no closing delimiter yet), it no
|
|
123
|
+
longer flashes its raw markdown source. A half-typed link renders just its
|
|
124
|
+
label as an inert (non-navigable) `<a>` with the destination hidden until the
|
|
125
|
+
closing `)` lands (then only `href` is added — the element is reused, not
|
|
126
|
+
remounted); inline code shows `<code>…</code>` with the backtick hidden;
|
|
127
|
+
inline math (`$…$`, `\(…\)`, `\[…\]`) shows the rendered `<span class="math
|
|
128
|
+
…">` with the `$`/`\(` hidden. Previously these showed `[label](https://… `,
|
|
129
|
+
`` `code… ``, and `$x^2 +…` as raw text until the closer arrived.
|
|
130
|
+
Final output is unchanged, and an inline construct that never closes still
|
|
131
|
+
finalizes to literal text, byte-identical to a one-shot parse (pinned by
|
|
132
|
+
truncate-at-every-offset streaming-parity fuzz). Images, emphasis/strong, and
|
|
133
|
+
reference links intentionally still render literally while open.
|
|
134
|
+
|
|
135
|
+
## 0.16.0 — 2026-06-25
|
|
136
|
+
|
|
137
|
+
### Added
|
|
138
|
+
|
|
139
|
+
- **Keyed streaming renderers (opt-in via `blockData`).** Tables, lists, and
|
|
140
|
+
blockquote/alert containers now render keyed sub-blocks (`<tr>` / `<li>` /
|
|
141
|
+
inner blocks), so while a block streams only the growing tail row/item
|
|
142
|
+
re-renders instead of the whole block — committed rows keep their DOM
|
|
143
|
+
identity (scroll/selection survive). React + vanilla DOM. Backed by new
|
|
144
|
+
`ListData.items` and `ContainerData` block-data channels.
|
|
145
|
+
- **`onRenderMetrics` hook + render counters.** Opt-in per-block render-churn
|
|
146
|
+
probe; `getMetrics()` gains `renderCount` / `rebuildCount`. Zero cost when
|
|
147
|
+
unused.
|
|
148
|
+
- **Opt-in render/scheduling knobs (all default off):** `coalesce` (rAF patch
|
|
149
|
+
coalescing for the React/store path), `deferTail` (`useDeferredValue`),
|
|
150
|
+
`childMemo` (fine-grained `htmlToReact` reuse), `morphOpenBlocks` (in-place
|
|
151
|
+
DOM morph of open blocks), a DOM prefix-extension tail-append fast path, and
|
|
152
|
+
fine-grained tail-block signals for Solid/Vue/Svelte.
|
|
153
|
+
|
|
154
|
+
### Performance
|
|
155
|
+
|
|
156
|
+
- **Footnotes no longer disable the streaming caches.** The paragraph, list,
|
|
157
|
+
table, and blockquote/alert caches now stay armed when `gfm_footnotes` is on,
|
|
158
|
+
via placeholder occurrence-id tokens resolved on commit — closing the O(n²)
|
|
159
|
+
tail re-scan for footnote-bearing streamed blocks. Output is byte-identical
|
|
160
|
+
to a one-shot render.
|
|
161
|
+
- **Huge unclosed blocks stream in O(new bytes).** New incremental caches for
|
|
162
|
+
open indented-code and raw-HTML blocks remove their O(n²) tail re-scan.
|
|
163
|
+
- Single-pass URL scheme probe and memoized keyed-table header sniffs trim two
|
|
164
|
+
hot paths.
|
|
165
|
+
|
|
166
|
+
### Build & size
|
|
167
|
+
|
|
168
|
+
- The published tarball is ~32 KB gzip smaller. The WASM core is rebuilt with
|
|
169
|
+
`-Z build-std` + `panic=immediate-abort` (~219 → ~178 KB), and `CHANGELOG.md`
|
|
170
|
+
+ a stray wasm-pack `package.json` no longer ship. **Note:** building the
|
|
171
|
+
WASM now requires the nightly Rust toolchain + `rust-src`; consumers are
|
|
172
|
+
unaffected (the prebuilt binary ships), and `build:wasm:stable` remains for
|
|
173
|
+
stable toolchains.
|
|
174
|
+
|
|
175
|
+
### Security
|
|
176
|
+
|
|
177
|
+
- Footnote occurrence-id placeholder tokens can never leak into rendered HTML
|
|
178
|
+
(defensive guard + a debug assertion exercised by the streaming fuzz corpus).
|
|
179
|
+
|
|
180
|
+
## 0.15.1 — 2026-06-22
|
|
181
|
+
|
|
182
|
+
### Security
|
|
183
|
+
|
|
184
|
+
- **XSS — dangerous-scheme autolinks are neutralized.** A CommonMark URI autolink
|
|
185
|
+
(`<javascript:alert(1)>`, `<vbscript:…>`, `<file:…>`) previously emitted a live
|
|
186
|
+
`href`, because autolinks bypassed the scheme allowlist that regular links go
|
|
187
|
+
through. They now route through the same decode-stable dangerous-scheme filter:
|
|
188
|
+
the `href` becomes `#` while the visible link text is unchanged. `file:` is now
|
|
189
|
+
blocked everywhere (links, autolinks, URL attributes) — it has no legitimate use
|
|
190
|
+
in rendered untrusted markdown and is a local-resource / phishing vector in
|
|
191
|
+
privileged contexts (Electron, extensions, `file://` origins).
|
|
192
|
+
- **Component-tag / `htmlToReact` attribute hardening.** Sanitized attributes now
|
|
193
|
+
also drop React-meaningful names (`dangerouslySetInnerHTML`, `ref`, `key`,
|
|
194
|
+
`defaultValue`, `defaultChecked`, `suppressHydrationWarning`, …) so a hostile
|
|
195
|
+
attribute can't crash the render tree or smuggle in a prop. Attribute→prop
|
|
196
|
+
lookup maps are prototype-free (`Object.create(null)`), and only HTML / `data-`
|
|
197
|
+
/ `aria-` attribute names are forwarded to React.
|
|
198
|
+
|
|
199
|
+
### Fixed
|
|
200
|
+
|
|
201
|
+
- **ReDoS / quadratic blow-ups on untrusted input.**
|
|
202
|
+
- Highlighter (`hi.ts`): the JS/TS regex-literal and bash double-quoted-string
|
|
203
|
+
patterns could backtrack quadratically on crafted code blocks; both rewritten
|
|
204
|
+
to linear forms, plus a 50 KB per-block size guard.
|
|
205
|
+
- URL scheme check: the decode-to-fixpoint loop (Rust `is_dangerous_scheme` and
|
|
206
|
+
JS `safeUrl`) is capped at 8 passes — still catches multi-encoded
|
|
207
|
+
`javascript&amp;#58;` payloads, no longer O(n²) on `&`-spam.
|
|
208
|
+
- Inline parser: nested / unbalanced link-bracket scanning is bounded
|
|
209
|
+
(depth + length caps); GFM extended-autolink trailing-paren trimming is now
|
|
210
|
+
linear instead of recounting the span each iteration.
|
|
211
|
+
|
|
212
|
+
### Changed
|
|
213
|
+
|
|
214
|
+
- **`flux-md/server` uses a literal `import("node:fs/promises")`** instead of a
|
|
215
|
+
variable specifier, resolving the `dynamicRequire` supply-chain signal. Behavior
|
|
216
|
+
is unchanged — still a Node-only, `file:`-guarded branch.
|
|
217
|
+
- Added a **`## Security`** / supply-chain-transparency section to the README and a
|
|
218
|
+
documented **`socket.yml`** covering the inherent `nativeCode` / `networkAccess`
|
|
219
|
+
/ `filesystemAccess` signals (the WebAssembly core and the opt-in
|
|
220
|
+
`<flux-markdown src>` fetch).
|
|
221
|
+
|
|
222
|
+
### Performance
|
|
223
|
+
|
|
224
|
+
- **No redundant re-renders / rebuilds on no-op updates.**
|
|
225
|
+
- `<flux-markdown>` ignores a `setAttribute` whose value didn't change (a host
|
|
226
|
+
framework re-applying identical attributes no longer tears down the self-owned
|
|
227
|
+
client and reparses the whole document), and the `components` / `sanitize`
|
|
228
|
+
property setters skip the remount when assigned the same identity.
|
|
229
|
+
- `FluxClient.reset()` no longer notifies subscribers when the store was already
|
|
230
|
+
empty — skips a wasted, output-identical render pass.
|
|
231
|
+
- Documented that `sanitize` (like `components`) should be memoized/hoisted in
|
|
232
|
+
React, so a fresh closure each render doesn't bust the per-block memo.
|
|
233
|
+
- Added render-count / node-reuse / no-remount regression tests across the React,
|
|
234
|
+
DOM, store, custom-element, and Vue bindings, locking in that committed blocks
|
|
235
|
+
never re-render or rebuild as the stream grows (only the streaming tail does).
|
|
236
|
+
|
|
237
|
+
### Known limitations
|
|
238
|
+
|
|
239
|
+
- Streaming a single very large **unclosed** block (a multi-megabyte indented code
|
|
240
|
+
block, open HTML block, or footnote-disarmed list delivered across many chunks)
|
|
241
|
+
is still O(n²) in the uncommitted-tail length. A bounded incremental cache for
|
|
242
|
+
these resumable containers is tracked as follow-up; finalized / closed blocks and
|
|
243
|
+
all other inputs are unaffected.
|
|
244
|
+
|
|
245
|
+
## 0.15.0 — 2026-06-17
|
|
246
|
+
|
|
247
|
+
### Added
|
|
248
|
+
|
|
249
|
+
- **Safe raw-HTML sanitizer (`htmlAllowlist` / `dropHtmlTags`)** — render a safe
|
|
250
|
+
subset of *inline* raw HTML (`<br>`, `<sub>`, `<sup>`, `<mark>`, …) **without**
|
|
251
|
+
`unsafeHtml`. Setting either list (even to `[]`) engages it: `htmlAllowlist`
|
|
252
|
+
non-empty renders only those tags (others escaped); **empty allows all tags
|
|
253
|
+
except a built-in, non-overridable dangerous set** (`script`, `style`,
|
|
254
|
+
`iframe`, `object`, `embed`, `form`, `svg`, `xmp`, `plaintext`, …);
|
|
255
|
+
`dropHtmlTags` removes tags entirely. Every rendered tag's attributes are
|
|
256
|
+
sanitized — `on*` handlers and `style` (a CSS beacon / clickjacking vector)
|
|
257
|
+
dropped, dangerous URL schemes (incl. multi-encoded) → `#`. Inline-scoped;
|
|
258
|
+
block-level raw HTML stays escaped. Matching is case-insensitive.
|
|
259
|
+
|
|
260
|
+
### Fixed
|
|
261
|
+
|
|
262
|
+
- **HTML comments are dropped instead of escaped to visible text.** `<!--mk:id-->`
|
|
263
|
+
(a common LLM marker) previously rendered as a literal `<!--…-->` run or a
|
|
264
|
+
`<pre><code>` block; it now has no visible representation, in every mode except
|
|
265
|
+
bare `unsafeHtml` pass-through (which keeps it verbatim for CommonMark fidelity —
|
|
266
|
+
the browser ignores it either way). A comment-led block with trailing content
|
|
267
|
+
keeps that content (only comment-*only* blocks are dropped).
|
|
268
|
+
|
|
269
|
+
### Security
|
|
270
|
+
|
|
271
|
+
- The dangerous-tag set is **non-overridable** (allowlisting `script`/`iframe`/`svg`
|
|
272
|
+
still drops them), `style` is stripped from every sanitized/component tag, and
|
|
273
|
+
raw-text elements (`xmp`/`plaintext`/`noembed`/`noframes`/`listing`) are blocked
|
|
274
|
+
in allow-all mode — closing CSS-exfiltration / clickjacking / DOM-corruption
|
|
275
|
+
vectors found in adversarial review. The React `htmlToReact` path mirrors the
|
|
276
|
+
`style` value-filter as defense-in-depth (safe declarations like `text-align`
|
|
277
|
+
still pass).
|
|
278
|
+
|
|
279
|
+
Feature-off output is byte-identical except HTML comments now drop (the
|
|
280
|
+
CommonMark/GFM suites run with `unsafeHtml` on, so the 652/GFM floors are
|
|
281
|
+
unaffected).
|
|
282
|
+
|
|
283
|
+
## 0.14.0 — 2026-06-17
|
|
284
|
+
|
|
285
|
+
### Added
|
|
286
|
+
|
|
287
|
+
- **Inline custom component tags (`inlineComponentTags`)** — the headline gap for
|
|
288
|
+
rich apps. An allowlisted inline tag like `<tik symbol="AAPL">AAPL</tik>` (or
|
|
289
|
+
self-closing `<tik/>`) **anywhere inline** — paragraphs, headings, list items,
|
|
290
|
+
and **table cells** — renders as a real custom element with its inner parsed as
|
|
291
|
+
**inline markdown** and its attributes sanitized (event handlers dropped,
|
|
292
|
+
dangerous URL schemes → `#`). The React renderer dispatches it to
|
|
293
|
+
`components[tag]` with the inner markdown as `children` and the attributes as
|
|
294
|
+
props — **XSS-safe without `unsafeHtml`**. Independent of `componentTags`
|
|
295
|
+
(block containers): list a tag under either or both. Use lowercase tag names.
|
|
296
|
+
- **`children` on `Component` block overrides** — a `Component` override now also
|
|
297
|
+
receives the inner content pre-parsed to a React tree (`children`), so you can
|
|
298
|
+
`return <Chip {...attrs}>{children}</Chip>` instead of
|
|
299
|
+
`dangerouslySetInnerHTML`-ing `html`. The html-vs-children contract is now loud
|
|
300
|
+
in the types and docs (an override that renders neither shows empty).
|
|
301
|
+
- **`flux-md/server` — worker-free synchronous SSR / RSC rendering.** The Rust→
|
|
302
|
+
WASM core is a plain synchronous parser, so finished markdown renders on the
|
|
303
|
+
server with no worker: `initFlux()` (async, idempotent — reads the co-located
|
|
304
|
+
`.wasm` in Node, or `initFluxSync(bytes)` on edge), `renderToString(md, {
|
|
305
|
+
config })` (sync HTML string, zero React dep), `parseToBlocks(md, { config })`,
|
|
306
|
+
and `<FluxMarkdownStatic content config components />` — a hookless, RSC-safe
|
|
307
|
+
React component that emits the same `flux-md` tree a client `<FluxMarkdown>`
|
|
308
|
+
hydrates, with the same overrides (inline/block component tags dispatch on the
|
|
309
|
+
server too).
|
|
310
|
+
- **`FluxParser.allBlocks()` (WASM)** — returns the whole parsed document as a
|
|
311
|
+
block array, the one-shot render primitive used by `flux-md/server`.
|
|
312
|
+
|
|
313
|
+
### Fixed
|
|
314
|
+
|
|
315
|
+
- **Data-loss: a block component tag used inline swallowed sibling blocks.** With
|
|
316
|
+
e.g. `componentTags: ["tik"]`, an inline occurrence such as
|
|
317
|
+
`<tik>AAPL</tik> is up.` on a line with following content opened a block
|
|
318
|
+
container that consumed the rest of the document (the paragraph and a following
|
|
319
|
+
table vanished). A block component open tag must now be the **whole line** (only
|
|
320
|
+
trailing whitespace after `>`); otherwise it's treated as inline and degrades
|
|
321
|
+
inertly — it never eats surrounding content.
|
|
322
|
+
|
|
323
|
+
### Changed
|
|
324
|
+
|
|
325
|
+
- The React HTML→tree converter (`htmlToReact` / `parseTrustedHtml`) now preserves
|
|
326
|
+
a tag's original **case** for component dispatch (so a capitalized inline tag
|
|
327
|
+
like `<Cite>` maps to `components.Cite`); HTML semantics (void elements, `input`,
|
|
328
|
+
close-tag matching) still compare case-insensitively, so standard output is
|
|
329
|
+
unchanged.
|
|
330
|
+
|
|
331
|
+
Feature-off output is byte-identical (CommonMark 652 + GFM floors hold); both
|
|
332
|
+
allowlists are empty by default.
|
|
333
|
+
|
|
334
|
+
## 0.13.0 — 2026-06-04
|
|
335
|
+
|
|
336
|
+
### Added
|
|
337
|
+
|
|
338
|
+
- **`FluxClient.setContent(content, { done })` + controlled-string helpers for
|
|
339
|
+
every binding** — a first-class bridge for UIs that hold a streaming message as
|
|
340
|
+
a single growing/controlled string prop (rather than a stream). setContent diffs
|
|
341
|
+
against the last value: a **prefix-extension** appends only the delta (committed
|
|
342
|
+
blocks stay put); any **divergence** (e.g. a finished message swapped for a
|
|
343
|
+
re-processed final string) resets and reparses. No hand-rolled diff, no
|
|
344
|
+
readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
|
|
345
|
+
framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
|
|
346
|
+
per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
|
|
347
|
+
(composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
|
|
348
|
+
(action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
|
|
349
|
+
/ `<flux-markdown>` use a caller-owned client + `setContent` directly.
|
|
350
|
+
- **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
|
|
351
|
+
on app load) so the one-time WASM init is off the first-token critical path; the
|
|
352
|
+
warm worker is the one the first stream attaches to, so the work isn't wasted.
|
|
353
|
+
- **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
|
|
354
|
+
block**, not just settled ones — a design-system renderer (Tailwind classes on
|
|
355
|
+
`p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
|
|
356
|
+
of only after a block commits. This also closes a gap where a supplied
|
|
357
|
+
`sanitize` previously bypassed component-rendered blocks; it now runs on every
|
|
358
|
+
block. The no-`components` path is unchanged (byte-identical `innerHTML`).
|
|
359
|
+
|
|
360
|
+
### Fixed
|
|
361
|
+
|
|
362
|
+
- **Worker no longer drops the first chunk(s) under a slow WASM load.** The
|
|
363
|
+
worker buffered appends but did not gate parser creation on WASM readiness, so
|
|
364
|
+
an append that arrived before `init()` resolved would call `new FluxParser()`
|
|
365
|
+
against an uninitialized module — throwing `fluxparser_new of undefined` and
|
|
366
|
+
silently losing that chunk. Appends now accumulate (and `finalize` defers)
|
|
367
|
+
until init completes, then drain in order. Surfaced on a fresh Next.js /
|
|
368
|
+
Turbopack production load, where the worker+WASM fetch is slow enough to lose
|
|
369
|
+
the race; the fix is bundler-agnostic. The worker's message/readiness state
|
|
370
|
+
machine was extracted to `worker-core.ts` (dependency-injected, like
|
|
371
|
+
`FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
|
|
372
|
+
covering the gate — buffer-until-ready, drain order, finalize/reset before
|
|
373
|
+
ready — so the regression can't silently return.
|
|
374
|
+
- **React 19 / Next.js type compatibility.** The shipped source used the global
|
|
375
|
+
`JSX.Element`, which React 19's `@types/react` removed — a consumer's
|
|
376
|
+
`next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
|
|
377
|
+
*"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
|
|
378
|
+
under `@types/react` 18 **and** 19.
|
|
379
|
+
|
|
380
|
+
### Docs
|
|
381
|
+
|
|
382
|
+
- **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
|
|
383
|
+
Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
|
|
384
|
+
and use it from a `"use client"` component. See the README's Next.js callout.
|
|
385
|
+
|
|
386
|
+
## 0.12.0 — 2026-05-30
|
|
387
|
+
|
|
388
|
+
### Added
|
|
389
|
+
|
|
390
|
+
- **Optional default theme — `import "flux-md/styles.css"`.** A drop-in stylesheet
|
|
391
|
+
for good-looking output out of the box, **including the built-in syntax
|
|
392
|
+
highlighter's colors** (without any CSS, `highlight()` output is uncolored).
|
|
393
|
+
Scoped to `.flux-md`, driven by `--flux-*` CSS variables (re-theme by overriding
|
|
394
|
+
a few), light by default with automatic dark via `prefers-color-scheme` (force
|
|
395
|
+
with `class="flux-md flux-dark"` / `flux-light`). Opt-in and zero-runtime — the
|
|
396
|
+
rendered HTML is unchanged; skip the import to bring your own CSS.
|
|
397
|
+
|
|
398
|
+
## 0.11.0 — 2026-05-30
|
|
399
|
+
|
|
400
|
+
### Added
|
|
401
|
+
|
|
402
|
+
- **Opt-in live region + root attributes** on `<FluxMarkdown>` and
|
|
403
|
+
`mountFluxMarkdown`. The root accepts `className` (appended to `flux-md`),
|
|
404
|
+
`id`, `role`, and `aria-live` / `aria-atomic`. Set `aria-live="polite"` to
|
|
405
|
+
announce streamed content to screen readers — `polite` coalesces rapid updates
|
|
406
|
+
and does **not** read every token. Off by default; covers React and the DOM
|
|
407
|
+
mount (so the Web Component and the Vue/Svelte/Solid adapters too).
|
|
408
|
+
|
|
409
|
+
### Docs
|
|
410
|
+
|
|
411
|
+
- A repository root README, a "Structured block data" guide in the package
|
|
412
|
+
README, and a runnable **Data Studio** demo in the playground — a
|
|
413
|
+
sort/filter/CSV table and a live table of contents built entirely from
|
|
414
|
+
`block.data`, mid-stream.
|
|
415
|
+
|
|
416
|
+
## 0.10.0 — 2026-05-30
|
|
417
|
+
|
|
418
|
+
Server-side rendering safety, plus an opt-in structured-data channel so consumers
|
|
419
|
+
build toolbars / tables of contents / charts from **data** instead of re-parsing
|
|
420
|
+
rendered HTML (no hast tree, no rehype).
|
|
421
|
+
|
|
422
|
+
### Added
|
|
423
|
+
|
|
424
|
+
- **SSR-safe.** `new FluxClient()` and `renderToString(<FluxMarkdown …/>)` no
|
|
425
|
+
longer touch a Web Worker during construction or server render — worker
|
|
426
|
+
creation is deferred to the first `append`/`pipeFrom` (client-side) — so the
|
|
427
|
+
library imports and server-renders cleanly across React / Vue / Solid / Svelte.
|
|
428
|
+
A fresh-process SSR cold-import check guards it in CI.
|
|
429
|
+
- **Structured block data — `blockData: true`** (per-stream config; opt-in,
|
|
430
|
+
default off — output and CommonMark/GFM conformance are **byte-identical** when
|
|
431
|
+
off). When on, `block.kind.data` carries typed structured data per kind, also
|
|
432
|
+
surfaced as typed `BlockComponentProps` fields, and it **streams** in lock-step
|
|
433
|
+
with the HTML:
|
|
434
|
+
- **Table** → `{ headers, rows, aligns }`, cells `{ text, html }` (`props.table`)
|
|
435
|
+
— sort / filter / transpose / CSV / chart.
|
|
436
|
+
- **Heading** → `{ level, text, id }` (`props.heading`) — TOC with anchors.
|
|
437
|
+
- **CodeBlock** → `{ lang, code }` (`props.code`) — decoded source.
|
|
438
|
+
- **MathBlock** → `{ latex }` (`props.math`) — LaTeX source.
|
|
439
|
+
- **List** → `{ ordered, start }` (`props.list`).
|
|
440
|
+
|
|
441
|
+
### Fixed
|
|
442
|
+
|
|
443
|
+
- Packaging: the published tarball ships the WASM deterministically on every npm
|
|
444
|
+
version (build removes wasm-pack's nested `.gitignore`), with a tarball tripwire
|
|
445
|
+
in CI and the publish workflow.
|
|
446
|
+
|
|
447
|
+
## 0.9.0 — 2026-05-29
|
|
448
|
+
|
|
449
|
+
Kills the React streaming boilerplate. The common case — render an LLM stream —
|
|
450
|
+
goes from ~17 lines of hand-rolled lifecycle to one:
|
|
451
|
+
|
|
452
|
+
```tsx
|
|
453
|
+
<FluxMarkdown stream={stream} />
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Added
|
|
457
|
+
|
|
458
|
+
- **`stream` prop on React `<FluxMarkdown>`** — pass an `AsyncIterable<string>`
|
|
459
|
+
(SSE deltas), a `Response`, or a `ReadableStream<Uint8Array>` and the
|
|
460
|
+
component owns an internal client, pipes the stream, supersedes it on change,
|
|
461
|
+
and destroys it on unmount. The `client` prop is unchanged (now optional);
|
|
462
|
+
passing a `client` keeps the existing caller-owned behavior.
|
|
463
|
+
- **`useFluxStream(stream, options?)` hook (React)** — same lifecycle, returns
|
|
464
|
+
the owned `FluxClient` (so you can read `outline()` / `getMetrics()` or pass it
|
|
465
|
+
to `<FluxMarkdown client={…} />`).
|
|
466
|
+
- **`pipeFrom` now also accepts an `AsyncIterable<string>`** and an optional
|
|
467
|
+
`{ signal }` — the signal is checked every iteration, so an aborted stream
|
|
468
|
+
appends no further chunks and is **not** finalized (and a byte reader is
|
|
469
|
+
`cancel()`'d). Existing `pipeFrom(Response | ReadableStream)` calls are
|
|
470
|
+
unchanged.
|
|
471
|
+
|
|
472
|
+
### Notes
|
|
473
|
+
|
|
474
|
+
- A stream is single-use, so React StrictMode's dev-only double-mount may
|
|
475
|
+
truncate it in development; production mounts once and is unaffected (the
|
|
476
|
+
prior manual `useEffect` form had the same caveat).
|
|
477
|
+
- Rules of Hooks are respected — `<FluxMarkdown>` dispatches to one of two
|
|
478
|
+
sibling components, never a conditional hook.
|
|
479
|
+
|
|
480
|
+
## 0.8.0 — 2026-05-29
|
|
481
|
+
|
|
482
|
+
A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
|
|
483
|
+
in the worker pool and added two small, streaming-native conveniences.
|
|
484
|
+
|
|
485
|
+
### Added
|
|
486
|
+
|
|
487
|
+
- **`FluxClient.pipeFrom(src)`** — hand it a `Response` or a
|
|
488
|
+
`ReadableStream<Uint8Array>` and it reads the body, `append()`s each decoded
|
|
489
|
+
chunk, and `finalize()`s. The LLM-native one-liner:
|
|
490
|
+
`await client.pipeFrom(await fetch("/api/chat"))`.
|
|
491
|
+
- **`onBlock` option** — `new FluxClient({ onBlock })` fires once per block as it
|
|
492
|
+
commits (document order), for side effects like lazily highlighting a finished
|
|
493
|
+
code block or analytics. Committed blocks never re-fire.
|
|
494
|
+
|
|
495
|
+
### Fixed
|
|
496
|
+
|
|
497
|
+
- **Worker pool: a throwing stream handler no longer breaks sibling streams.** A
|
|
498
|
+
user `onError` (or any handler) that threw could abort the fatal-error fan-out
|
|
499
|
+
mid-loop and escape the worker message listener; dispatch is now isolated.
|
|
500
|
+
- **Worker pool: a fatally-failed worker is no longer re-assigned.** `pick()`
|
|
501
|
+
skipped the `failed` flag, so after a WASM-init failure a new stream could be
|
|
502
|
+
routed onto the dead worker and hang (a client that didn't `await whenReady()`
|
|
503
|
+
had no safety net). Failed workers are now excluded from selection.
|
|
504
|
+
- **`<flux-markdown>`: manual `append()`/`finalize()` supersede an in-flight
|
|
505
|
+
`src` fetch** (mirroring `reset()`), so mixing the two can't interleave.
|
|
506
|
+
- Hardened the CI/publish tarball check (explicit failure if `npm pack` yields
|
|
507
|
+
no tarball) and documented the `htmlToText` core-HTML-only invariant.
|
|
508
|
+
|
|
509
|
+
## 0.7.0 — 2026-05-29
|
|
510
|
+
|
|
511
|
+
DX, robustness, and accessibility round — the streaming core (perf, CommonMark
|
|
512
|
+
652/652, GFM) was already comprehensive, so this release sharpens the surface
|
|
513
|
+
around it.
|
|
514
|
+
|
|
515
|
+
### Added
|
|
516
|
+
|
|
517
|
+
- **`onError` on `FluxClient`** — `new FluxClient({ onError })` receives worker
|
|
518
|
+
and parse errors (previously only `console.error`'d). A **WASM-init failure**
|
|
519
|
+
now also surfaces: `whenReady()` **rejects** instead of hanging forever, and
|
|
520
|
+
`onError` fires with `{ fatal: true }`.
|
|
521
|
+
- **`a11y` parser option** (`ParserConfig.a11y` / `setA11y` / `<flux-markdown
|
|
522
|
+
a11y>`) — opt-in accessibility markup that intentionally deviates from strict
|
|
523
|
+
GFM byte-output: wraps a task-list checkbox + its text in a `<label>` (so the
|
|
524
|
+
box is programmatically associated for screen readers), and adds
|
|
525
|
+
`scope="col"` to table header cells. **Off by default** (conformance output
|
|
526
|
+
unchanged). Streaming output stays byte-identical to one-shot.
|
|
527
|
+
- **`FluxClient.outline()`** — a heading table-of-contents (level / text /
|
|
528
|
+
stable id) from the current snapshot, in document order; works mid-stream.
|
|
529
|
+
- **`FluxClient.toPlaintext()`** — the rendered document as plain text (tags
|
|
530
|
+
stripped, entities decoded, blocks blank-line separated) for search indexing
|
|
531
|
+
/ summaries.
|
|
532
|
+
|
|
533
|
+
### Fixed
|
|
534
|
+
|
|
535
|
+
- **`<flux-markdown>` `src` race** — rapidly changing `src` (or switching
|
|
536
|
+
between a `src` URL and inline `markdown`/`textContent`) could interleave two
|
|
537
|
+
fetch streams into one parser, corrupting the parse tree. The element now
|
|
538
|
+
supersedes any in-flight fetch (monotonic token + `AbortController`) at a
|
|
539
|
+
single chokepoint.
|
|
540
|
+
|
|
541
|
+
### Docs / packaging
|
|
542
|
+
|
|
543
|
+
- README documents the one-line Vite `optimizeDeps.exclude` requirement.
|
|
544
|
+
- `"sideEffects": ["./src/worker.ts"]` so bundlers can drop unused framework
|
|
545
|
+
adapters from the export surface.
|
|
546
|
+
- CI now publishes via a tag-triggered workflow with `npm publish --provenance`,
|
|
547
|
+
and asserts every published tarball ships a non-empty WASM artifact.
|
|
548
|
+
|
|
549
|
+
## 0.6.0 — 2026-05-28
|
|
550
|
+
|
|
551
|
+
### Added — flux-md is no longer React-only
|
|
552
|
+
|
|
553
|
+
The core (`FluxClient` + the WASM worker) was always framework-neutral; only
|
|
554
|
+
the renderer was React-bound. This release adds five new entry points, each
|
|
555
|
+
**thin lifecycle glue** over one new framework-agnostic DOM renderer — none
|
|
556
|
+
re-implements the subscribe/diff loop, and none destroys your client (you own
|
|
557
|
+
the worker/stream).
|
|
558
|
+
|
|
559
|
+
- **`flux-md/dom`** — the foundation. `mountFluxMarkdown(client, container,
|
|
560
|
+
options?) → { destroy(), refresh() }` incrementally patches a DOM subtree
|
|
561
|
+
using the parser's stable block IDs: a committed block's node is never
|
|
562
|
+
recreated (so one-shot work like syntax highlighting and the copy-button
|
|
563
|
+
listener runs exactly once), only the streaming tail re-renders. Reuses the
|
|
564
|
+
in-house highlighter for deferred code, applies your `sanitize` hook to the
|
|
565
|
+
open/speculative tail, and batches patches per `requestAnimationFrame`.
|
|
566
|
+
Block-kind overrides via `components` (`(props) => HTMLElement | string`);
|
|
567
|
+
tag-level overrides remain React-only.
|
|
568
|
+
- **`flux-md/element`** — `defineFluxMarkdown(tag = "flux-markdown")` defines a
|
|
569
|
+
`<flux-markdown>` custom element. Light DOM (your markdown CSS applies),
|
|
570
|
+
SSR-safe (no auto-register), and usable three ways: a caller-owned `client`
|
|
571
|
+
property, a self-owned client driven by `append()`/`finalize()`, or zero-JS
|
|
572
|
+
via a `src` URL it fetch-streams / inline text / a `markdown` attribute.
|
|
573
|
+
Config flags map to tri-state attributes (`gfm-math`, `dir-auto`, …). Covers
|
|
574
|
+
**Angular** with `CUSTOM_ELEMENTS_SCHEMA` — no separate package.
|
|
575
|
+
- **`flux-md/vue`** — a `<FluxMarkdown>` component + `useFluxMarkdown`
|
|
576
|
+
composable (Vue 3, optional peer dep).
|
|
577
|
+
- **`flux-md/svelte`** — a `fluxMarkdown` action, `use:fluxMarkdown={{ client }}`
|
|
578
|
+
(Svelte 4 and 5, optional peer dep).
|
|
579
|
+
- **`flux-md/solid`** — a `<FluxMarkdown>` component (Solid, optional peer dep).
|
|
580
|
+
Newest binding: its mount/teardown glue is tested, but the JSX component shell
|
|
581
|
+
has only been exercised via a real `vite-plugin-solid` build, not in CI — the
|
|
582
|
+
`flux-md/dom` mount inside `onMount`/`onCleanup` is the fallback if your Solid
|
|
583
|
+
toolchain trips on it.
|
|
584
|
+
|
|
585
|
+
Purely additive — existing `flux-md` / `flux-md/react` / `flux-md/client` users
|
|
586
|
+
are unaffected (the React renderer and core are byte-identical; the only change
|
|
587
|
+
to existing code was a type-only import repoint so the neutral entry points
|
|
588
|
+
typecheck without React). `vue`, `svelte`, and `solid-js` join `react` as
|
|
589
|
+
optional peer dependencies — import only the binding you need. See the new
|
|
590
|
+
"Framework bindings" section in the README. 65 → 85 tests.
|
|
591
|
+
|
|
592
|
+
## 0.5.6 — 2026-05-28
|
|
593
|
+
|
|
594
|
+
### Performance
|
|
595
|
+
|
|
596
|
+
- **`ContainerCache` now handles multi-paragraph inner content.** A blockquote
|
|
597
|
+
or GitHub alert with blank `>` lines inside (`> [!NOTE]\n> Para one.\n>\n>
|
|
598
|
+
Para two.\n`) used to drop the cache and fall back to the O(n²) full path
|
|
599
|
+
the moment the first blank arrived. The cache now closes the current
|
|
600
|
+
paragraph on a blank `>` and starts a new one, preserving the
|
|
601
|
+
streaming-O(new bytes) shape across multi-paragraph inner content. Each
|
|
602
|
+
completed inner paragraph is pre-rendered into a growing
|
|
603
|
+
`committed_paras_html` string; the single-paragraph fast path (the bench's
|
|
604
|
+
`big_blockquote` / `big_alert`) is unchanged within noise.
|
|
605
|
+
|
|
606
|
+
- **`ListCache` now handles loose lists.** A flat list with blank lines
|
|
607
|
+
between siblings (`- one\n\n- two\n\n- three\n`) is a CommonMark "loose"
|
|
608
|
+
list — every item body gets wrapped in `<p>…</p>` — and the cache used to
|
|
609
|
+
bail on the first blank. The cache now flips to loose on the first
|
|
610
|
+
blank-then-marker sequence, re-renders prior cached items with `<p>`
|
|
611
|
+
wrappers from stored source spans (one-time O(items)), and continues the
|
|
612
|
+
streaming-O(new bytes) shape from there. Tight→loose is sticky.
|
|
613
|
+
|
|
614
|
+
50 KB loose-list bench, before-fix → after-fix:
|
|
615
|
+
|
|
616
|
+
| chunk | before | after | speedup |
|
|
617
|
+
|------:|---------:|--------:|--------:|
|
|
618
|
+
| 16 | 5593 ms | 21 ms | ~272× |
|
|
619
|
+
| 256 | 355 ms | 7 ms | ~49× |
|
|
620
|
+
|
|
621
|
+
Tight `big_list` perf is unchanged within bench noise.
|
|
622
|
+
|
|
623
|
+
### Added
|
|
624
|
+
|
|
625
|
+
- **React `CodeBlock` default renderer ships a copy-to-clipboard button.**
|
|
626
|
+
Closed code blocks now show an icon + "Copy" in their header (the existing
|
|
627
|
+
"streaming" pill takes that slot until close, so streaming code is never
|
|
628
|
+
copy-clickable mid-arrival). Click → copies the decoded source via
|
|
629
|
+
`navigator.clipboard.writeText` → swaps to a checkmark + "Copied" for
|
|
630
|
+
1.5 s → reverts. Native `<button>` (keyboard-reachable), `aria-label`
|
|
631
|
+
toggles between "Copy code" and "Copied" with `aria-live="polite"`,
|
|
632
|
+
guards against `navigator.clipboard` being absent (SSR / insecure context)
|
|
633
|
+
and rejected `writeText` promises (permission denied) — both leave the
|
|
634
|
+
button silently usable. No new dependency.
|
|
635
|
+
|
|
636
|
+
### Documentation
|
|
637
|
+
|
|
638
|
+
- README quickstart now uses `useState(() => new FluxClient())` + an
|
|
639
|
+
unmount-only destroy effect instead of `useMemo(() => new FluxClient(),
|
|
640
|
+
[])` + cleanup-on-stream-change (which destroyed the client when the
|
|
641
|
+
`stream` prop changed, leaking a freed parser on the next append).
|
|
642
|
+
- New "when to enable each flag" guide for `ParserConfig` with concrete
|
|
643
|
+
LLM-output triggers (`gfmMath` when `$…$` arrives, `componentTags` for
|
|
644
|
+
`<Thinking>` blocks, etc.) — so a reader picks flags without reading the
|
|
645
|
+
full reference further down.
|
|
646
|
+
- `Alert` block-kind override example added to the `components` docs.
|
|
647
|
+
- `sanitize` example mirrors the realistic memoize-at-module-scope pattern
|
|
648
|
+
from the live demo (a fresh arrow each render busts the per-block memo).
|
|
649
|
+
- New "Performance" section pointing to CHANGELOG / `examples/bench.rs` for
|
|
650
|
+
numbers (no numbers baked into the README — those rot).
|
|
651
|
+
|
|
652
|
+
## 0.5.5 — 2026-05-28
|
|
653
|
+
|
|
654
|
+
### Performance
|
|
655
|
+
|
|
656
|
+
- 1× memcpy in the paragraph / container cache assembly (was 2×). Both caches
|
|
657
|
+
were building the block HTML in two stages — concatenate
|
|
658
|
+
`committed + active` into an intermediate `String`, then concatenate
|
|
659
|
+
`<p>` + that into the output — so a long open paragraph or container did two
|
|
660
|
+
memcpys of the committed inner per append. The fix builds directly into the
|
|
661
|
+
output buffer and trims trailing whitespace in-place; the container case
|
|
662
|
+
backs out a provisional `<p>` opener if the body content turns out to be
|
|
663
|
+
empty (preserving the empty-body fix from 0.5.4). Output is byte-identical.
|
|
664
|
+
|
|
665
|
+
200 KB bench (best of 7), chunk=16:
|
|
666
|
+
|
|
667
|
+
| shape | 0.5.4 | 0.5.5 | speedup |
|
|
668
|
+
|-----------------|---------:|---------:|--------:|
|
|
669
|
+
| `long_paragraph`| 142 ms | **96 ms**| 1.48× |
|
|
670
|
+
| `emphasis_para` | 170 ms | **116 ms**| 1.47× |
|
|
671
|
+
| `big_blockquote`| 213 ms | **157 ms**| 1.36× |
|
|
672
|
+
| `big_alert` | 343 ms | **237 ms**| 1.45× |
|
|
673
|
+
|
|
674
|
+
Modest wins at every chunk size for the affected caches; the
|
|
675
|
+
table / list / fence caches are unchanged (they were already 1× memcpy).
|
|
676
|
+
|
|
677
|
+
## 0.5.4 — 2026-05-28
|
|
678
|
+
|
|
679
|
+
### Fixed (mid-stream rendering)
|
|
680
|
+
|
|
681
|
+
- **GFM tables now form during streaming, not just at finalize.** Streaming a
|
|
682
|
+
table char-by-char (or in any chunking where the delimiter row's `\n` lands
|
|
683
|
+
in a different chunk than the row's content) used to leave the block as a
|
|
684
|
+
`<p>` spanning both lines until `.finalize()` ran. The paragraph cache's
|
|
685
|
+
delimiter-detection walked from the line AFTER the cut and so missed a
|
|
686
|
+
delimiter row that completed inside the line the cut had advanced into. The
|
|
687
|
+
fix re-checks the line containing the cut whenever it has just completed,
|
|
688
|
+
guarded by a cheap `bytes[cut..].contains('\n')` so long open paragraphs
|
|
689
|
+
without interior `\n` still take the O(new bytes) per-call path.
|
|
690
|
+
- **Open alerts/blockquotes with an empty body no longer render an empty
|
|
691
|
+
`<p></p>`.** A `> [!NOTE]\n` shown mid-stream now matches the full renderer:
|
|
692
|
+
`<div class="markdown-alert ...">…<p class="...title">Note</p></div>` with
|
|
693
|
+
no empty body paragraph. The container cache was wrapping the body in
|
|
694
|
+
`<p>…</p>` unconditionally, even when the body was empty.
|
|
695
|
+
|
|
696
|
+
Both bugs only manifested *before* `finalize()`. The post-finalize output —
|
|
697
|
+
what every existing parity test checks — was already correct, which is why
|
|
698
|
+
neither was caught earlier. A new `tests/midstream_parity.rs` asserts that the
|
|
699
|
+
streamed view of an open block matches what one-shot parsing produces for the
|
|
700
|
+
same prefix (tables, alerts, blockquotes, lists, code fences, math fences).
|
|
701
|
+
|
|
702
|
+
### Performance
|
|
703
|
+
|
|
704
|
+
- `big_table` at the artificial `chunk=16` stress case is ~280 ms (was ~145 ms
|
|
705
|
+
in 0.5.3). The 145 ms was the *incorrect* path: the paragraph cache treated
|
|
706
|
+
the whole 200 KB table as a single growing paragraph until finalize, never
|
|
707
|
+
engaging the table cache. The 280 ms is the cost of correctly emitting the
|
|
708
|
+
table mid-stream at the smallest chunk size. Every realistic LLM streaming
|
|
709
|
+
chunk size (≥64 bytes) is unchanged — `big_table` at chunk=64 is 73 ms,
|
|
710
|
+
chunk=256 is 38 ms, etc.
|
|
711
|
+
|
|
712
|
+
## 0.5.3 — 2026-05-28
|
|
713
|
+
|
|
714
|
+
### Performance
|
|
715
|
+
|
|
716
|
+
- **Streaming long open resumable containers is now O(n).** A long
|
|
717
|
+
`> [!NOTE]` alert, a `>`-quoted explanation, or a flat bullet/ordered list
|
|
718
|
+
used to re-run scan + inline render over the whole growing inner on every
|
|
719
|
+
append (O(n²)). Three new tail caches mirror the existing fence/table
|
|
720
|
+
pattern:
|
|
721
|
+
|
|
722
|
+
- `ContainerCache` — single-paragraph blockquote / GitHub alert. Wraps
|
|
723
|
+
the existing paragraph-cache (inline-boundary commit) with a
|
|
724
|
+
`>`-stripped inner buffer; the wrapper HTML (`<blockquote>` /
|
|
725
|
+
alert `<div>`) is built once at arm time, each new `> ` line is
|
|
726
|
+
stripped once into the inner buffer, only the unsettled inline tail is
|
|
727
|
+
re-rendered. Bails on a blank `>`-line (paragraph break inside the
|
|
728
|
+
container), lazy continuation, or `\r`.
|
|
729
|
+
|
|
730
|
+
- `ListCache` — tight, flat list (the LLM-emit shape: one sibling marker
|
|
731
|
+
per line, no blanks, no continuation, no nesting). Opener
|
|
732
|
+
(`<ul>` / `<ol start=N>`) pre-rendered at arm time; each new sibling
|
|
733
|
+
line renders directly into the cache as a tight `<li>…</li>` (GFM
|
|
734
|
+
task-list `[ ] `/`[x] ` supported). Bails on the first blank line
|
|
735
|
+
(loose-list signal), non-marker line, over-edge marker (nested), or
|
|
736
|
+
foreign-family marker — the full path handles those.
|
|
737
|
+
|
|
738
|
+
Measured at 50 KB (best of 7), before → after:
|
|
739
|
+
|
|
740
|
+
| shape | chunk=16 | chunk=256 |
|
|
741
|
+
|-----------------|-------------------|-----------------|
|
|
742
|
+
| `big_blockquote`| 5164 → **22 ms** | 332 → **8.5 ms**|
|
|
743
|
+
| `big_list` | 6141 → **18 ms** | 391 → **7.4 ms**|
|
|
744
|
+
| `big_alert` | 6298 → **28 ms** | 404 → **11 ms** |
|
|
745
|
+
|
|
746
|
+
At 200 KB, `big_list` chunk=256 was extrapolating to ~6.2 s before the
|
|
747
|
+
cache; now **36 ms** (~170×). Every realistic streaming shape now has a
|
|
748
|
+
flat chunk-size curve.
|
|
749
|
+
|
|
750
|
+
Output is byte-identical. Parity gated by `tests/container_cache.rs`
|
|
751
|
+
(blockquote + all five alert kinds, dir_auto, CRLF, lazy continuation,
|
|
752
|
+
multi-paragraph fallback, 400-line stress) and `tests/list_cache.rs` (5
|
|
753
|
+
marker families, ordered with non-default start, dir_auto, CRLF, loose /
|
|
754
|
+
nested / multi-line fallback, 400-item stress).
|
|
755
|
+
|
|
756
|
+
### Documentation
|
|
757
|
+
|
|
758
|
+
- Reworded the "future plugin slot" comments in `renderers/Math.tsx` and
|
|
759
|
+
`renderers/Mermaid.tsx`. The actual extension path is the
|
|
760
|
+
`components.MathBlock` / `components.Mermaid` overrides, which already
|
|
761
|
+
works end-to-end.
|
|
762
|
+
|
|
763
|
+
### Known limitations
|
|
764
|
+
|
|
765
|
+
- The three new caches disarm when `gfmFootnotes` is on, mirroring
|
|
766
|
+
`TableCache` from 0.5.2: cell-level `[^x]` occurrence ids would diverge
|
|
767
|
+
across the cache vs. full-reparse boundary. Footnotes + a long container
|
|
768
|
+
/ table stays on the full O(n²) path — rare combination, may be lifted
|
|
769
|
+
in a later release by tracking per-cache footnote-occ deltas.
|
|
770
|
+
- The blockquote/alert cache covers the *single-paragraph* inner case (the
|
|
771
|
+
realistic LLM shape). A long open container with a multi-block inner
|
|
772
|
+
(lists inside, fenced code inside, etc.) still routes through the full
|
|
773
|
+
path. The bench's `big_blockquote` / `big_alert` are single-paragraph
|
|
774
|
+
shapes — what these caches were built for.
|
|
775
|
+
|
|
776
|
+
## 0.5.2 — 2026-05-28
|
|
777
|
+
|
|
778
|
+
### Performance
|
|
779
|
+
|
|
780
|
+
- **Streaming a long GFM table is now O(n) at every chunk size.** Tables already
|
|
781
|
+
rendered visually incrementally (header at the delimiter row, rows append as
|
|
782
|
+
they arrive) — but `render_table` re-walked every row on every append, so the
|
|
783
|
+
total work was O(n²) once chunks exceeded ~30 bytes (a row). The fix is an
|
|
784
|
+
incremental `TableCache` that mirrors the existing code/math `FenceCache`:
|
|
785
|
+
`<thead>` is pre-rendered once, each newly-complete `<tr>` is folded into the
|
|
786
|
+
cached prefix, and only the trailing partial row is re-rendered each append.
|
|
787
|
+
Output is byte-identical; parity gated by `tests/table_cache.rs` (every chunk
|
|
788
|
+
size 1..=9 × char-by-char against one-shot, with alignments, inline markdown,
|
|
789
|
+
link refs, CRLF fallback, and a 400-row stress case).
|
|
790
|
+
|
|
791
|
+
Measured on a 200 KB table (best of 7 — chunk varies on each row):
|
|
792
|
+
|
|
793
|
+
| chunk | before | after | speedup |
|
|
794
|
+
|------:|---------:|------:|--------:|
|
|
795
|
+
| 16 | 143 ms | 145 ms | ~1× (was already fast) |
|
|
796
|
+
| 64 | 20807 ms | 78 ms | **267×** |
|
|
797
|
+
| 128 | 10414 ms | 54 ms | **193×** |
|
|
798
|
+
| 256 | 5373 ms | 40 ms | **134×** |
|
|
799
|
+
| 512 | 2608 ms | 34 ms | **77×** |
|
|
800
|
+
| 1024 | 1322 ms | 31 ms | **43×** |
|
|
801
|
+
|
|
802
|
+
The pre-fix bench printed only chunks 16 and 256, which hid the regression
|
|
803
|
+
(16 was fine, 256 was the cliff floor). The bench now sweeps 16/64/128/256/
|
|
804
|
+
512/1024 so the next regression in this shape can't slip in unnoticed.
|
|
805
|
+
|
|
806
|
+
Footnotes are the one combination still on the full O(n²) path: the
|
|
807
|
+
cell-level `[^x]` occurrence counter would diverge across the
|
|
808
|
+
cache/full-reparse boundary, so the cache disarms when `gfmFootnotes` is on
|
|
809
|
+
(rare enough to defer to a later release).
|
|
810
|
+
|
|
811
|
+
## 0.5.1 — 2026-05-27
|
|
812
|
+
|
|
813
|
+
### Performance
|
|
814
|
+
|
|
815
|
+
- A document with a very large number of link-reference definitions is now O(n)
|
|
816
|
+
instead of O(n²). The committed reference table was cloned on every append
|
|
817
|
+
(O(refs) per chunk); it's now shared into each render via an `Rc` (O(1)) with a
|
|
818
|
+
two-level lookup (committed, then the uncommitted tail), and folded in place
|
|
819
|
+
via `Rc::make_mut` once the render's clone is dropped. A 235 KB
|
|
820
|
+
reference-definition stream at 16-byte chunks: **~1,395 ms → ~53 ms** (~26×).
|
|
821
|
+
This was believed to be the last remaining O(n²) streaming shape; in fact a
|
|
822
|
+
long open GFM table was still O(n²) (fixed in 0.5.2 — `big_table` at
|
|
823
|
+
chunk=256 went from ~5,400 ms to ~40 ms). Output is unchanged.
|
|
824
|
+
|
|
825
|
+
## 0.5.0 — 2026-05-27
|
|
826
|
+
|
|
827
|
+
### Fixed
|
|
828
|
+
|
|
829
|
+
- **Streaming GFM tables now render incrementally.** A table no longer waits for
|
|
830
|
+
the whole block to arrive: the header renders the moment the delimiter row
|
|
831
|
+
(`|---|`) streams in, and each body row appends as it arrives. Previously the
|
|
832
|
+
incremental paragraph fast-path kept extending the header line as a paragraph
|
|
833
|
+
and only formed the table on a full reparse, so a streaming table appeared all
|
|
834
|
+
at once. The fast-path now bails (like it does for a setext underline) when a
|
|
835
|
+
delimiter row forms a table with its preceding header. Output is unchanged for
|
|
836
|
+
one-shot parsing; streamed output now matches one-shot at every prefix.
|
|
837
|
+
|
|
838
|
+
### Added
|
|
839
|
+
|
|
840
|
+
- **`<FluxMarkdown sanitize={fn} />`** — an optional HTML sanitizer hook. When
|
|
841
|
+
provided, flux-md runs every block's HTML through it before injecting via
|
|
842
|
+
`innerHTML`, **including the streaming (open/speculative) tail** that the raw
|
|
843
|
+
fast path would otherwise expose. Bring your own sanitizer (e.g.
|
|
844
|
+
`DOMPurify.sanitize`) to render untrusted / LLM HTML with `unsafeHtml` on;
|
|
845
|
+
flux-md stays zero-dep. Built-in code/math renderers (already-escaped content)
|
|
846
|
+
are not run through it, so highlighting and math markup are preserved. Omitting
|
|
847
|
+
the prop is byte-identical and zero-cost.
|
|
848
|
+
|
|
849
|
+
## 0.4.0 — 2026-05-27
|
|
850
|
+
|
|
851
|
+
### Added
|
|
852
|
+
|
|
853
|
+
- **`componentTags`** — opt-in custom component tags. List tag names (e.g.
|
|
854
|
+
`componentTags: ['Thinking', 'Callout']`) and a `<Thinking>…</Thinking>` in the
|
|
855
|
+
stream renders as a component whose **inner content is parsed as markdown** —
|
|
856
|
+
safely, **without `unsafeHtml`**: the tag is allowlisted and its attributes are
|
|
857
|
+
sanitized (event handlers dropped, dangerous URL schemes neutralized). The
|
|
858
|
+
container spans blank lines (unlike a raw HTML block) up to its matching close
|
|
859
|
+
tag, supports nesting, and ignores a `</Tag>` inside a code fence. Each renders
|
|
860
|
+
as a `Component` block dispatched on the React side via `components[tag]` (e.g.
|
|
861
|
+
`components.Thinking`) or the generic `components.Component`, receiving `{ tag,
|
|
862
|
+
attrs, … }`. Off unless configured; tag names match case-sensitively.
|
|
863
|
+
|
|
864
|
+
### Performance
|
|
865
|
+
|
|
866
|
+
- Streaming a long open display-math block (`$$…$$` / `\[…\]`) is now O(n)
|
|
867
|
+
instead of O(n²). The incremental fence cache that already covered code fences
|
|
868
|
+
was generalized to math fences: an append only escapes the newly arrived lines
|
|
869
|
+
instead of re-scanning and re-escaping the whole growing body. Measured on a
|
|
870
|
+
200 KB `$$…$$` block at 16-byte chunks: **16,271 ms → ~93 ms** (~174×). Output
|
|
871
|
+
is byte-identical (gated by `tests/math_fence_cache.rs`).
|
|
872
|
+
- A long trailing run of link-reference / footnote definitions now commits
|
|
873
|
+
incrementally instead of being re-scanned on every append. Previously such a
|
|
874
|
+
run produced no renderable blocks, so the committed offset never advanced. A
|
|
875
|
+
document ending in a large reference section streams ~10× faster (235 KB at
|
|
876
|
+
16-byte chunks: **13,799 ms → ~1,380 ms**). Output is byte-identical (gated by
|
|
877
|
+
`tests/ref_defs_streaming.rs`).
|
|
878
|
+
|
|
879
|
+
## 0.3.2 — 2026-05-27
|
|
880
|
+
|
|
881
|
+
### Documentation
|
|
882
|
+
|
|
883
|
+
- Rewrote the README to describe flux-md on its own terms and removed all
|
|
884
|
+
references to and comparisons with other libraries. No code changes — the
|
|
885
|
+
published API and behavior are identical to 0.3.1.
|
|
886
|
+
- Fixed the React quick-start example: import `useEffect` and guard the async
|
|
887
|
+
append loop so it can't run after unmount or a stream change.
|
|
888
|
+
|
|
889
|
+
## 0.3.1 — 2026-05-27
|
|
890
|
+
|
|
891
|
+
### Performance
|
|
892
|
+
|
|
893
|
+
- Streaming a long unbroken paragraph is now O(n) instead of O(n²) — including
|
|
894
|
+
paragraphs **dense with inline constructs** (emphasis, code spans, links,
|
|
895
|
+
inline math), not just plain text. The open paragraph commits its settled
|
|
896
|
+
prefix and re-renders only the short active tail. Because inline output isn't
|
|
897
|
+
prefix-stable (a late `*` re-emphasizes earlier text, a late backtick opens a
|
|
898
|
+
code span), the stable boundary is computed inside the inline renderer itself:
|
|
899
|
+
it tracks unmatched openers, unpaired forward-pairable emphasis, and resolved
|
|
900
|
+
emphasis spans, and commits only up to the largest provably-final cut. Output
|
|
901
|
+
is byte-identical. Measured on 200 KB single paragraphs at 16-byte chunks:
|
|
902
|
+
plain **34,167 ms → ~130 ms** (~260×); emphasis-rich **60,569 ms → ~157 ms**
|
|
903
|
+
(~386×).
|
|
904
|
+
- The open-code-fence fast path no longer clones the accumulated escaped body on
|
|
905
|
+
every append; it assembles the block HTML directly from the cached pieces,
|
|
906
|
+
dropping one full O(body) copy per append. A 200 KB fence streams in **~82 ms**
|
|
907
|
+
at 16-byte chunks (was ~154 ms, ~1.9×). Output is byte-identical.
|
|
908
|
+
|
|
909
|
+
## 0.3.0
|
|
910
|
+
|
|
911
|
+
### Added
|
|
912
|
+
|
|
913
|
+
- **`gfmMath`** — opt-in math. Inline `$…$` and `\(…\)`; display `$$…$$` and
|
|
914
|
+
`\[…\]`. Inline `$` uses the pandoc rule, so currency like `$5 and $10` stays
|
|
915
|
+
literal. Emits KaTeX-ready markup (`<span class="math math-inline">` /
|
|
916
|
+
`<div class="math math-display">`) carrying the LaTeX as text content — bring
|
|
917
|
+
your own KaTeX (flux-md stays zero-dep) or override `components.MathBlock`
|
|
918
|
+
(which receives the LaTeX as `text`). Display fences are blank-line tolerant
|
|
919
|
+
and stream incrementally. Off by default.
|
|
920
|
+
- **`dirAuto`** — opt-in per-block `dir="auto"` on block-level text elements
|
|
921
|
+
(`p`, `h1`–`h6`, `blockquote`, `ul`/`ol`/`li`, `table`, alerts, footnotes), so
|
|
922
|
+
the browser detects each block's direction (RTL/LTR) independently in
|
|
923
|
+
mixed-language documents. Code blocks stay LTR. Off by default.
|
|
924
|
+
|
|
925
|
+
### Performance
|
|
926
|
+
|
|
927
|
+
- Streaming a long fenced code block is now **O(n) instead of O(n²)**: an open
|
|
928
|
+
code fence caches its escaped body and extends it by only the newly arrived
|
|
929
|
+
lines. Measured on a 200 KB fence — **14,278 ms → 230 ms** at 16-byte chunks,
|
|
930
|
+
**898 ms → 22 ms** at 256-byte chunks. Output is byte-identical.
|
|
931
|
+
- Dropped a redundant per-append clone of the link-reference table.
|
|
932
|
+
|
|
933
|
+
### Known limitations
|
|
934
|
+
|
|
935
|
+
- Streaming a very long **unbroken** paragraph (no blank lines) is still O(n²):
|
|
936
|
+
inline rendering re-runs over the whole paragraph each chunk, and unlike code
|
|
937
|
+
it can't be prefix-cached (a late `*` can emphasize earlier text). Tracked for
|
|
938
|
+
a future release; breaking the text into paragraphs avoids it.
|
|
939
|
+
|
|
940
|
+
### Internal
|
|
941
|
+
|
|
942
|
+
- Added a Rust streaming-throughput benchmark (`cargo run --release --example
|
|
943
|
+
bench`) plus char-by-char streaming-parity tests for the code-fence cache,
|
|
944
|
+
math, and bidi paths.
|
|
945
|
+
|
|
946
|
+
## 0.2.0
|
|
947
|
+
|
|
948
|
+
- Initial public release: zero-dep streaming markdown, Rust→WASM core, one Web
|
|
949
|
+
Worker per stream, CommonMark 0.31 (652/652) + GFM (tables, strikethrough,
|
|
950
|
+
task lists, extended autolinks, GitHub alerts, footnotes).
|