flux-md 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,149 @@ 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.7.0 — 2026-05-29
8
+
9
+ DX, robustness, and accessibility round — the streaming core (perf, CommonMark
10
+ 652/652, GFM) was already comprehensive, so this release sharpens the surface
11
+ around it.
12
+
13
+ ### Added
14
+
15
+ - **`onError` on `FluxClient`** — `new FluxClient({ onError })` receives worker
16
+ and parse errors (previously only `console.error`'d). A **WASM-init failure**
17
+ now also surfaces: `whenReady()` **rejects** instead of hanging forever, and
18
+ `onError` fires with `{ fatal: true }`.
19
+ - **`a11y` parser option** (`ParserConfig.a11y` / `setA11y` / `<flux-markdown
20
+ a11y>`) — opt-in accessibility markup that intentionally deviates from strict
21
+ GFM byte-output: wraps a task-list checkbox + its text in a `<label>` (so the
22
+ box is programmatically associated for screen readers), and adds
23
+ `scope="col"` to table header cells. **Off by default** (conformance output
24
+ unchanged). Streaming output stays byte-identical to one-shot.
25
+ - **`FluxClient.outline()`** — a heading table-of-contents (level / text /
26
+ stable id) from the current snapshot, in document order; works mid-stream.
27
+ - **`FluxClient.toPlaintext()`** — the rendered document as plain text (tags
28
+ stripped, entities decoded, blocks blank-line separated) for search indexing
29
+ / summaries.
30
+
31
+ ### Fixed
32
+
33
+ - **`<flux-markdown>` `src` race** — rapidly changing `src` (or switching
34
+ between a `src` URL and inline `markdown`/`textContent`) could interleave two
35
+ fetch streams into one parser, corrupting the parse tree. The element now
36
+ supersedes any in-flight fetch (monotonic token + `AbortController`) at a
37
+ single chokepoint.
38
+
39
+ ### Docs / packaging
40
+
41
+ - README documents the one-line Vite `optimizeDeps.exclude` requirement.
42
+ - `"sideEffects": ["./src/worker.ts"]` so bundlers can drop unused framework
43
+ adapters from the export surface.
44
+ - CI now publishes via a tag-triggered workflow with `npm publish --provenance`,
45
+ and asserts every published tarball ships a non-empty WASM artifact.
46
+
47
+ ## 0.6.0 — 2026-05-28
48
+
49
+ ### Added — flux-md is no longer React-only
50
+
51
+ The core (`FluxClient` + the WASM worker) was always framework-neutral; only
52
+ the renderer was React-bound. This release adds five new entry points, each
53
+ **thin lifecycle glue** over one new framework-agnostic DOM renderer — none
54
+ re-implements the subscribe/diff loop, and none destroys your client (you own
55
+ the worker/stream).
56
+
57
+ - **`flux-md/dom`** — the foundation. `mountFluxMarkdown(client, container,
58
+ options?) → { destroy(), refresh() }` incrementally patches a DOM subtree
59
+ using the parser's stable block IDs: a committed block's node is never
60
+ recreated (so one-shot work like syntax highlighting and the copy-button
61
+ listener runs exactly once), only the streaming tail re-renders. Reuses the
62
+ in-house highlighter for deferred code, applies your `sanitize` hook to the
63
+ open/speculative tail, and batches patches per `requestAnimationFrame`.
64
+ Block-kind overrides via `components` (`(props) => HTMLElement | string`);
65
+ tag-level overrides remain React-only.
66
+ - **`flux-md/element`** — `defineFluxMarkdown(tag = "flux-markdown")` defines a
67
+ `<flux-markdown>` custom element. Light DOM (your markdown CSS applies),
68
+ SSR-safe (no auto-register), and usable three ways: a caller-owned `client`
69
+ property, a self-owned client driven by `append()`/`finalize()`, or zero-JS
70
+ via a `src` URL it fetch-streams / inline text / a `markdown` attribute.
71
+ Config flags map to tri-state attributes (`gfm-math`, `dir-auto`, …). Covers
72
+ **Angular** with `CUSTOM_ELEMENTS_SCHEMA` — no separate package.
73
+ - **`flux-md/vue`** — a `<FluxMarkdown>` component + `useFluxMarkdown`
74
+ composable (Vue 3, optional peer dep).
75
+ - **`flux-md/svelte`** — a `fluxMarkdown` action, `use:fluxMarkdown={{ client }}`
76
+ (Svelte 4 and 5, optional peer dep).
77
+ - **`flux-md/solid`** — a `<FluxMarkdown>` component (Solid, optional peer dep).
78
+ Newest binding: its mount/teardown glue is tested, but the JSX component shell
79
+ has only been exercised via a real `vite-plugin-solid` build, not in CI — the
80
+ `flux-md/dom` mount inside `onMount`/`onCleanup` is the fallback if your Solid
81
+ toolchain trips on it.
82
+
83
+ Purely additive — existing `flux-md` / `flux-md/react` / `flux-md/client` users
84
+ are unaffected (the React renderer and core are byte-identical; the only change
85
+ to existing code was a type-only import repoint so the neutral entry points
86
+ typecheck without React). `vue`, `svelte`, and `solid-js` join `react` as
87
+ optional peer dependencies — import only the binding you need. See the new
88
+ "Framework bindings" section in the README. 65 → 85 tests.
89
+
90
+ ## 0.5.6 — 2026-05-28
91
+
92
+ ### Performance
93
+
94
+ - **`ContainerCache` now handles multi-paragraph inner content.** A blockquote
95
+ or GitHub alert with blank `>` lines inside (`> [!NOTE]\n> Para one.\n>\n>
96
+ Para two.\n`) used to drop the cache and fall back to the O(n²) full path
97
+ the moment the first blank arrived. The cache now closes the current
98
+ paragraph on a blank `>` and starts a new one, preserving the
99
+ streaming-O(new bytes) shape across multi-paragraph inner content. Each
100
+ completed inner paragraph is pre-rendered into a growing
101
+ `committed_paras_html` string; the single-paragraph fast path (the bench's
102
+ `big_blockquote` / `big_alert`) is unchanged within noise.
103
+
104
+ - **`ListCache` now handles loose lists.** A flat list with blank lines
105
+ between siblings (`- one\n\n- two\n\n- three\n`) is a CommonMark "loose"
106
+ list — every item body gets wrapped in `<p>…</p>` — and the cache used to
107
+ bail on the first blank. The cache now flips to loose on the first
108
+ blank-then-marker sequence, re-renders prior cached items with `<p>`
109
+ wrappers from stored source spans (one-time O(items)), and continues the
110
+ streaming-O(new bytes) shape from there. Tight→loose is sticky.
111
+
112
+ 50 KB loose-list bench, before-fix → after-fix:
113
+
114
+ | chunk | before | after | speedup |
115
+ |------:|---------:|--------:|--------:|
116
+ | 16 | 5593 ms | 21 ms | ~272× |
117
+ | 256 | 355 ms | 7 ms | ~49× |
118
+
119
+ Tight `big_list` perf is unchanged within bench noise.
120
+
121
+ ### Added
122
+
123
+ - **React `CodeBlock` default renderer ships a copy-to-clipboard button.**
124
+ Closed code blocks now show an icon + "Copy" in their header (the existing
125
+ "streaming" pill takes that slot until close, so streaming code is never
126
+ copy-clickable mid-arrival). Click → copies the decoded source via
127
+ `navigator.clipboard.writeText` → swaps to a checkmark + "Copied" for
128
+ 1.5 s → reverts. Native `<button>` (keyboard-reachable), `aria-label`
129
+ toggles between "Copy code" and "Copied" with `aria-live="polite"`,
130
+ guards against `navigator.clipboard` being absent (SSR / insecure context)
131
+ and rejected `writeText` promises (permission denied) — both leave the
132
+ button silently usable. No new dependency.
133
+
134
+ ### Documentation
135
+
136
+ - README quickstart now uses `useState(() => new FluxClient())` + an
137
+ unmount-only destroy effect instead of `useMemo(() => new FluxClient(),
138
+ [])` + cleanup-on-stream-change (which destroyed the client when the
139
+ `stream` prop changed, leaking a freed parser on the next append).
140
+ - New "when to enable each flag" guide for `ParserConfig` with concrete
141
+ LLM-output triggers (`gfmMath` when `$…$` arrives, `componentTags` for
142
+ `<Thinking>` blocks, etc.) — so a reader picks flags without reading the
143
+ full reference further down.
144
+ - `Alert` block-kind override example added to the `components` docs.
145
+ - `sanitize` example mirrors the realistic memoize-at-module-scope pattern
146
+ from the live demo (a fresh arrow each render busts the per-block memo).
147
+ - New "Performance" section pointing to CHANGELOG / `examples/bench.rs` for
148
+ numbers (no numbers baked into the README — those rot).
149
+
7
150
  ## 0.5.5 — 2026-05-28
8
151
 
9
152
  ### Performance
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,24 @@ import.meta.url)`** pattern, so any bundler with asset-module support resolves
16
18
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
17
19
  modules), and **Parcel**. Next.js (webpack/turbopack) should work but is
18
20
  untested — file an issue if it doesn't. It is **browser-only** (it constructs
19
- Web Workers); it does not run under SSR/RSC. `react` is an optional peer
20
- dependencyonly needed if you import `flux-md/react`.
21
+ Web Workers); it does not run under SSR/RSC. The framework packages `react`,
22
+ `vue`, `svelte`, `solid-js` are all **optional** peer dependencies; you only
23
+ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
24
+ `flux-md/dom`, `flux-md/element`) needs none.
25
+
26
+ > **Vite — one-line config.** Vite's dependency pre-bundling (esbuild) hoists
27
+ > the wasm-bindgen glue into `.vite/deps/`, which breaks the relative
28
+ > `new URL("…_bg.wasm", import.meta.url)` lookup so the worker can't load WASM
29
+ > (you'll see a 404 / "magic word" error). Exclude flux-md from pre-bundling:
30
+ >
31
+ > ```ts
32
+ > // vite.config.ts
33
+ > export default defineConfig({
34
+ > optimizeDeps: { exclude: ["flux-md"] },
35
+ > });
36
+ > ```
37
+ >
38
+ > No other bundler needs this — it's specific to Vite's optimizer.
21
39
 
22
40
  ## Quick start
23
41
 
@@ -37,11 +55,13 @@ client.finalize();
37
55
  In React:
38
56
 
39
57
  ```tsx
40
- import { useEffect, useMemo } from "react";
58
+ import { useEffect, useState } from "react";
41
59
  import { FluxClient, FluxMarkdown } from "flux-md";
42
60
 
43
61
  export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
44
- const client = useMemo(() => new FluxClient(), []);
62
+ // One client per component instance. Destroy on unmount, not on stream change.
63
+ const [client] = useState(() => new FluxClient());
64
+ useEffect(() => () => client.destroy(), [client]);
45
65
 
46
66
  useEffect(() => {
47
67
  let cancelled = false;
@@ -52,11 +72,8 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
52
72
  }
53
73
  if (!cancelled) client.finalize();
54
74
  })();
55
- return () => {
56
- cancelled = true;
57
- client.destroy();
58
- };
59
- }, [stream]);
75
+ return () => { cancelled = true; };
76
+ }, [client, stream]);
60
77
 
61
78
  return <FluxMarkdown client={client} />;
62
79
  }
@@ -64,6 +81,166 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
64
81
 
65
82
  Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
66
83
 
84
+ ## Framework bindings
85
+
86
+ `FluxClient` is framework-neutral — it owns the worker and exposes
87
+ `subscribe`/`getSnapshot`. Pick a renderer to put its blocks on screen. Every
88
+ binding below is thin glue over the same incremental DOM renderer, so they
89
+ share one identity contract: a committed block's node is never recreated, only
90
+ the streaming tail re-renders.
91
+
92
+ **One ownership rule across all bindings:** the renderer's teardown (React
93
+ unmount, `handle.destroy()`, element disconnect, etc.) frees only the rendered
94
+ DOM and the subscription — it **never** destroys the client. You call
95
+ `client.destroy()` when you're done with the stream. (React's `<FluxMarkdown>`,
96
+ documented [below](#fluxmarkdown-react), is the same.)
97
+
98
+ ### Vanilla / any framework — `flux-md/dom`
99
+
100
+ ```ts
101
+ import { FluxClient } from "flux-md/client";
102
+ import { mountFluxMarkdown } from "flux-md/dom";
103
+
104
+ const client = new FluxClient();
105
+ const handle = mountFluxMarkdown(client, document.getElementById("out")!, {
106
+ stickToBottom: true,
107
+ });
108
+
109
+ // Feed it from a fetch/SSE reader:
110
+ const reader = (await fetch("/api/chat")).body!.getReader();
111
+ const dec = new TextDecoder();
112
+ for (;;) {
113
+ const { value, done } = await reader.read();
114
+ if (done) break;
115
+ client.append(dec.decode(value, { stream: true })); // stream:true carries multibyte across chunks
116
+ }
117
+ client.append(dec.decode());
118
+ client.finalize();
119
+
120
+ // Teardown: destroy BOTH — the renderer and the client you created.
121
+ handle.destroy();
122
+ client.destroy();
123
+ ```
124
+
125
+ `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
126
+ Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
127
+ (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
128
+ Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
129
+ `Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
130
+ (lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
131
+ tree on the fast `innerHTML` path; a block-kind override can rewrite the `html`
132
+ it's handed instead.
133
+
134
+ ### Web Component `<flux-markdown>` — `flux-md/element`
135
+
136
+ The universal binding — plain HTML, Angular, or any framework that renders DOM.
137
+ Register once, then use the element:
138
+
139
+ ```ts
140
+ import { defineFluxMarkdown } from "flux-md/element";
141
+ defineFluxMarkdown(); // defines <flux-markdown>; pass a custom tag name if you like
142
+ ```
143
+
144
+ ```html
145
+ <!-- zero-JS streaming straight from a URL -->
146
+ <flux-markdown src="/api/post.md" gfm-math stick-to-bottom></flux-markdown>
147
+
148
+ <!-- one-shot from inline text -->
149
+ <flux-markdown># Hello **world**</flux-markdown>
150
+ ```
151
+
152
+ ```js
153
+ // or caller-owned streaming — drive your own client:
154
+ const el = document.querySelector("flux-markdown");
155
+ el.client = myFluxClient; // element subscribes; never destroys it
156
+ el.components = { Thinking: (p) => myNode(p) };
157
+ myFluxClient.append(delta);
158
+ ```
159
+
160
+ Config flags are **tri-state attributes**: absent = library default;
161
+ `gfm-math` / `gfm-math="true"` / `="1"` = on; `gfm-math="false"` / `="0"` = off
162
+ (the only way to turn off a default-on flag such as `gfm-alerts`). It renders in
163
+ light DOM so your markdown CSS applies, and `defineFluxMarkdown` is a no-op under
164
+ SSR (no `customElements`). A self-owned element (`src` / `markdown` / inline
165
+ text / `append()`) is torn down on disconnect; a caller-supplied `client` is left
166
+ alone.
167
+
168
+ **Angular** consumes the same element — no separate package:
169
+
170
+ ```ts
171
+ import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
172
+ import { defineFluxMarkdown } from "flux-md/element";
173
+ defineFluxMarkdown(); // once at bootstrap
174
+
175
+ @Component({
176
+ standalone: true,
177
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
178
+ template: `<flux-markdown [attr.src]="url" stick-to-bottom></flux-markdown>`,
179
+ })
180
+ export class Answer { url = "/api/post.md"; }
181
+ ```
182
+
183
+ ### Vue 3 — `flux-md/vue`
184
+
185
+ ```vue
186
+ <script setup lang="ts">
187
+ import { onBeforeUnmount } from "vue";
188
+ import { FluxClient } from "flux-md/client";
189
+ import { FluxMarkdown } from "flux-md/vue";
190
+
191
+ const client = new FluxClient();
192
+ // feed client.append(delta) from your stream, then client.finalize()
193
+ onBeforeUnmount(() => client.destroy());
194
+ </script>
195
+
196
+ <template>
197
+ <FluxMarkdown :client="client" stick-to-bottom />
198
+ </template>
199
+ ```
200
+
201
+ Props: `client` (required), `components`, `sanitize`, `virtualize`,
202
+ `stickToBottom`. There's also a `useFluxMarkdown` composable returning a
203
+ `container` ref if you'd rather mount into your own element.
204
+
205
+ ### Svelte (4 & 5) — `flux-md/svelte`
206
+
207
+ A Svelte action — works in both v4 and v5, no `.svelte` build step:
208
+
209
+ ```svelte
210
+ <script lang="ts">
211
+ import { onDestroy } from "svelte";
212
+ import { FluxClient } from "flux-md/client";
213
+ import { fluxMarkdown } from "flux-md/svelte";
214
+
215
+ const client = new FluxClient();
216
+ // feed client.append(delta) then client.finalize()
217
+ onDestroy(() => client.destroy());
218
+ </script>
219
+
220
+ <div use:fluxMarkdown={{ client, stickToBottom: true }} />
221
+ ```
222
+
223
+ ### Solid — `flux-md/solid`
224
+
225
+ ```tsx
226
+ import { onCleanup } from "solid-js";
227
+ import { FluxClient } from "flux-md/client";
228
+ import { FluxMarkdown } from "flux-md/solid";
229
+
230
+ const client = new FluxClient();
231
+ // feed client.append(delta) then client.finalize()
232
+ onCleanup(() => client.destroy());
233
+
234
+ <FluxMarkdown client={client} stickToBottom />;
235
+ ```
236
+
237
+ The Solid binding's mount/teardown logic is tested, but its JSX component shell
238
+ has so far only been exercised through a real Solid (`vite-plugin-solid`) build
239
+ in development, not in CI — treat it as the newest of the bindings and file an
240
+ issue if your Solid setup trips on it. The component is a thin `ref`'d `<div>`;
241
+ if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
242
+ `onMount`/`onCleanup` is the zero-surprise fallback.
243
+
67
244
  ## What it does
68
245
 
69
246
  | Concern | flux-md | conventional main-thread renderer |
@@ -73,7 +250,7 @@ Multiple concurrent streams just need multiple clients — each runs in its own
73
250
  | Block identity across chunks | Stable monotonic IDs | New keys on every render |
74
251
  | Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
75
252
  | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
76
- | XSS sanitization | Allowlist in Rust + URL scheme check | rehype-sanitize on JS thread |
253
+ | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
77
254
 
78
255
  ## Public API
79
256
 
@@ -81,19 +258,29 @@ Multiple concurrent streams just need multiple clients — each runs in its own
81
258
 
82
259
  ```ts
83
260
  class FluxClient {
84
- constructor(options?: { pool?: FluxPool; config?: ParserConfig });
261
+ constructor(options?: {
262
+ pool?: FluxPool;
263
+ config?: ParserConfig;
264
+ onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
265
+ });
85
266
  append(chunk: string): void; // queue text for parsing
86
267
  finalize(): void; // mark stream complete
87
268
  reset(): void; // wipe and reuse
88
269
  destroy(): void; // free this stream's parser
89
- whenReady(): Promise<void>; // resolves once WASM loaded
270
+ whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
90
271
  subscribe(listener: () => void): () => void; // React-friendly store
91
272
  getSnapshot(): Block[]; // ordered current blocks
273
+ outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
274
+ toPlaintext(): string; // rendered document as plain text (search / summaries)
92
275
  getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
93
276
  retainedBytes, wasmMemoryBytes, ... };
94
277
  }
95
278
  ```
96
279
 
280
+ Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
281
+ failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
282
+ load failure surfaces as a rejected `whenReady()`.
283
+
97
284
  #### Per-stream config
98
285
 
99
286
  ```ts
@@ -104,6 +291,7 @@ const client = new FluxClient({
104
291
  gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
105
292
  gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
106
293
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
294
+ a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
107
295
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
108
296
  componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
109
297
  },
@@ -114,6 +302,29 @@ Omitted fields use the defaults above, so `new FluxClient()` is unchanged.
114
302
  Config is applied when the stream's parser is created and is **immutable** for
115
303
  that stream (`reset()` keeps it; use a new client for different flags).
116
304
 
305
+ When to enable each flag:
306
+
307
+ - `gfmAutolinks` — on by default. Leave it on unless you want strict CommonMark.
308
+ - `gfmAlerts` — on by default. Leave it on unless you want strict CommonMark.
309
+ - `gfmMath: true` — when your LLM emits `$…$` or `$$…$$` (or LaTeX `\(…\)` /
310
+ `\[…\]`). flux-md emits KaTeX-ready markup; you bring the KaTeX pass (or
311
+ `components.MathBlock`).
312
+ - `gfmFootnotes: true` — when your input uses `[^1]` references and `[^1]:`
313
+ definitions. Off by default; see the footnote streaming caveat above.
314
+ - `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
315
+ `dir="auto"` so the browser detects direction independently per block.
316
+ - `a11y: true` — opt-in accessibility markup that deviates from strict GFM
317
+ byte-output: wraps task-list checkboxes in a `<label>` (screen-reader
318
+ association) and adds `scope="col"` to table headers. Off by default so
319
+ conformance output stays exact.
320
+ - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
321
+ LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
322
+ similar — see [Security](#security)).
323
+ - `componentTags: ["Thinking", …]` — when your LLM emits custom tags like
324
+ `<Thinking>…</Thinking>` and you want their inner content parsed as markdown
325
+ and dispatched to a React component. Safe without `unsafeHtml` (attributes are
326
+ sanitized; allowlisted tags only).
327
+
117
328
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
118
329
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
119
330
  can't re-render), and the footnote **section is emitted at finalize**. So a
@@ -160,15 +371,15 @@ Subscribes to a `FluxClient`, renders each block keyed by its stable parser-assi
160
371
 
161
372
  #### Custom components / overrides
162
373
 
163
- Pass a `components` map to replace how elements render the same idea as
164
- react-markdown's `components` prop, but the keys come in **two namespaces**:
374
+ Pass a `components` map to replace how elements render. Keys come in **two
375
+ namespaces**:
165
376
 
166
377
  ```tsx
167
378
  import { useMemo } from "react";
168
379
  import { FluxClient, FluxMarkdown, type Components } from "flux-md";
169
380
 
170
381
  function Message({ client }: { client: FluxClient }) {
171
- // ⚠️ Memoize (or hoist to module scope). A fresh object every render busts
382
+ // Memoize (or hoist to module scope). A fresh object every render busts
172
383
  // FluxMarkdown's block memo, so every block re-parses on every patch.
173
384
  const components: Components = useMemo(
174
385
  () => ({
@@ -181,6 +392,15 @@ function Message({ client }: { client: FluxClient }) {
181
392
  CodeBlock: ({ text, language, open }) => (
182
393
  <MyCodeBlockWithCopyButton code={text} lang={language} streaming={open} />
183
394
  ),
395
+
396
+ // GitHub alerts (`> [!NOTE]` / `[!TIP]` / `[!WARNING]` / `[!CAUTION]` /
397
+ // `[!IMPORTANT]`) — swap in your own callout component. The alert kind
398
+ // is on `block.kind.data.kind`; `html` is the rendered inner body.
399
+ Alert: ({ block, html }) => (
400
+ <MyCallout kind={(block.kind.data as { kind: string }).kind}>
401
+ <div dangerouslySetInnerHTML={{ __html: html }} />
402
+ </MyCallout>
403
+ ),
184
404
  }),
185
405
  [],
186
406
  );
@@ -313,8 +533,8 @@ styles them, and they're overridable as a block kind via `components.Alert`.
313
533
  By design, not yet, or only partially:
314
534
 
315
535
  - **Raw HTML in markdown** — escaped by default, not passed through. (Security
316
- default. A `setUnsafeHtml(true)` opt-in exists but must never be enabled for
317
- untrusted input.)
536
+ default. The `unsafeHtml: true` config flag disables the escape but must never
537
+ be enabled for untrusted input without a `sanitize` hook.)
318
538
  - **Forward link references when streaming** — a `[ref]` used *before* its later
319
539
  `[ref]: url` definition can't resolve until the definition arrives; one-shot
320
540
  parsing handles it fully, streaming converges once the definition streams in.
@@ -326,13 +546,28 @@ By design, not yet, or only partially:
326
546
  - **Syntax highlighting on open code blocks** — deferred until close. This is a
327
547
  deliberate perf choice.
328
548
 
549
+ ## Performance
550
+
551
+ Every realistic streaming shape (long paragraph, fenced code block, GFM table,
552
+ blockquote/alert, flat list, math fence, reference-heavy document) parses in
553
+ **O(n) total work**, not O(n²) — at every chunk size from 16 bytes (char-by-char)
554
+ up. Each shape has an incremental cache that mirrors the structure of the block
555
+ so that an append only does work proportional to the *newly arrived* bytes, not
556
+ the growing tail. See [CHANGELOG.md](./CHANGELOG.md) for per-shape numbers and
557
+ the regression that prompted each cache; the canonical bench is
558
+ `crates/flux-md-core/examples/bench.rs` (`cargo run --release --example bench`).
559
+
560
+ Headline numbers are not durable across machines, but the curve is: chunk size
561
+ shouldn't change the order of magnitude for any shape. If you hit one that does,
562
+ file an issue with the input and chunking — that's the next bench scenario.
563
+
329
564
  ## Security
330
565
 
331
566
  flux-md is XSS-safe by default — its HTML output is meant to be injected via
332
567
  `innerHTML` without a downstream sanitizer:
333
568
 
334
- - **Raw HTML is escaped** (the `unsafe_html` / `setUnsafeHtml(true)` opt-in
335
- disables this; **never enable it for untrusted input**).
569
+ - **Raw HTML is escaped** (the `unsafeHtml: true` config flag disables this;
570
+ **never enable it for untrusted input without a `sanitize` hook**).
336
571
  - **Dangerous URL schemes are neutralized** in `<a href>` and `<img src>` —
337
572
  `javascript:`, `vbscript:`, `data:text/html`, `data:text/javascript` become
338
573
  `#`. The check runs on the *decoded* URL and strips characters browsers
@@ -352,12 +587,17 @@ that returns raw HTML), **bring a real sanitizer** and pass it via
352
587
  `<FluxMarkdown sanitize={…} />`. flux-md applies it to every block's HTML before
353
588
  injection — **including the streaming (open) tail**, which the raw-`innerHTML`
354
589
  fast path would otherwise expose. flux-md stays zero-dep; you choose the
355
- sanitizer:
590
+ sanitizer. The realistic pattern (matches the live demo):
356
591
 
357
592
  ```tsx
358
593
  import DOMPurify from "dompurify";
359
594
 
360
- <FluxMarkdown client={client} sanitize={(html) => DOMPurify.sanitize(html)} />
595
+ // Hoist to module scope (or wrap in useCallback). A fresh arrow each render
596
+ // busts FluxMarkdown's per-block memo and re-runs every block through sanitize.
597
+ const sanitize = (html: string) => DOMPurify.sanitize(html);
598
+
599
+ // …then in your component:
600
+ <FluxMarkdown client={client} sanitize={sanitize} />
361
601
  ```
362
602
 
363
603
  The built-in code/math renderers operate on already-escaped content and are not
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.5.5",
3
+ "version": "0.7.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
+ "sideEffects": ["./src/worker.ts"],
6
7
  "main": "./src/index.ts",
7
8
  "types": "./src/index.ts",
8
9
  "exports": {
9
10
  ".": "./src/index.ts",
10
11
  "./client": "./src/client.ts",
11
12
  "./react": "./src/react.tsx",
13
+ "./dom": "./src/dom.ts",
14
+ "./element": "./src/element.ts",
15
+ "./vue": "./src/vue.ts",
16
+ "./svelte": "./src/svelte.ts",
17
+ "./solid": "./src/solid.tsx",
12
18
  "./highlight": "./src/hi.ts",
13
19
  "./types": "./src/types.ts"
14
20
  },
@@ -18,23 +24,33 @@
18
24
  "CHANGELOG.md"
19
25
  ],
20
26
  "peerDependencies": {
21
- "react": ">=18"
27
+ "react": ">=18",
28
+ "vue": ">=3",
29
+ "svelte": ">=4",
30
+ "solid-js": "^1.8.0"
22
31
  },
23
32
  "peerDependenciesMeta": {
24
- "react": { "optional": true }
33
+ "react": { "optional": true },
34
+ "vue": { "optional": true },
35
+ "svelte": { "optional": true },
36
+ "solid-js": { "optional": true }
25
37
  },
26
38
  "devDependencies": {
27
39
  "@types/react": "^18.3.12",
28
40
  "@types/react-dom": "^18.3.1",
41
+ "happy-dom": "^15.11.6",
29
42
  "react": "^18.3.1",
30
43
  "react-dom": "^18.3.1",
31
- "typescript": "^5.6.3"
44
+ "solid-js": "^1.8.0",
45
+ "svelte": "^4.2.0",
46
+ "typescript": "^5.6.3",
47
+ "vue": "^3.4.0"
32
48
  },
33
49
  "scripts": {
34
50
  "test": "bun test",
35
51
  "prepublishOnly": "cd ../.. && bun run build:wasm"
36
52
  },
37
- "keywords": ["markdown", "streaming", "wasm", "rust", "react", "incremental", "llm", "ai", "math", "katex", "latex", "bidi", "rtl"],
53
+ "keywords": ["markdown", "streaming", "wasm", "rust", "react", "vue", "svelte", "solid", "web-component", "custom-element", "incremental", "llm", "ai", "math", "katex", "latex", "bidi", "rtl"],
38
54
  "license": "MIT",
39
55
  "publishConfig": {
40
56
  "access": "public"