flux-md 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,109 @@ Notable changes to flux-md. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.6.0 — 2026-05-28
8
+
9
+ ### Added — flux-md is no longer React-only
10
+
11
+ The core (`FluxClient` + the WASM worker) was always framework-neutral; only
12
+ the renderer was React-bound. This release adds five new entry points, each
13
+ **thin lifecycle glue** over one new framework-agnostic DOM renderer — none
14
+ re-implements the subscribe/diff loop, and none destroys your client (you own
15
+ the worker/stream).
16
+
17
+ - **`flux-md/dom`** — the foundation. `mountFluxMarkdown(client, container,
18
+ options?) → { destroy(), refresh() }` incrementally patches a DOM subtree
19
+ using the parser's stable block IDs: a committed block's node is never
20
+ recreated (so one-shot work like syntax highlighting and the copy-button
21
+ listener runs exactly once), only the streaming tail re-renders. Reuses the
22
+ in-house highlighter for deferred code, applies your `sanitize` hook to the
23
+ open/speculative tail, and batches patches per `requestAnimationFrame`.
24
+ Block-kind overrides via `components` (`(props) => HTMLElement | string`);
25
+ tag-level overrides remain React-only.
26
+ - **`flux-md/element`** — `defineFluxMarkdown(tag = "flux-markdown")` defines a
27
+ `<flux-markdown>` custom element. Light DOM (your markdown CSS applies),
28
+ SSR-safe (no auto-register), and usable three ways: a caller-owned `client`
29
+ property, a self-owned client driven by `append()`/`finalize()`, or zero-JS
30
+ via a `src` URL it fetch-streams / inline text / a `markdown` attribute.
31
+ Config flags map to tri-state attributes (`gfm-math`, `dir-auto`, …). Covers
32
+ **Angular** with `CUSTOM_ELEMENTS_SCHEMA` — no separate package.
33
+ - **`flux-md/vue`** — a `<FluxMarkdown>` component + `useFluxMarkdown`
34
+ composable (Vue 3, optional peer dep).
35
+ - **`flux-md/svelte`** — a `fluxMarkdown` action, `use:fluxMarkdown={{ client }}`
36
+ (Svelte 4 and 5, optional peer dep).
37
+ - **`flux-md/solid`** — a `<FluxMarkdown>` component (Solid, optional peer dep).
38
+ Newest binding: its mount/teardown glue is tested, but the JSX component shell
39
+ has only been exercised via a real `vite-plugin-solid` build, not in CI — the
40
+ `flux-md/dom` mount inside `onMount`/`onCleanup` is the fallback if your Solid
41
+ toolchain trips on it.
42
+
43
+ Purely additive — existing `flux-md` / `flux-md/react` / `flux-md/client` users
44
+ are unaffected (the React renderer and core are byte-identical; the only change
45
+ to existing code was a type-only import repoint so the neutral entry points
46
+ typecheck without React). `vue`, `svelte`, and `solid-js` join `react` as
47
+ optional peer dependencies — import only the binding you need. See the new
48
+ "Framework bindings" section in the README. 65 → 85 tests.
49
+
50
+ ## 0.5.6 — 2026-05-28
51
+
52
+ ### Performance
53
+
54
+ - **`ContainerCache` now handles multi-paragraph inner content.** A blockquote
55
+ or GitHub alert with blank `>` lines inside (`> [!NOTE]\n> Para one.\n>\n>
56
+ Para two.\n`) used to drop the cache and fall back to the O(n²) full path
57
+ the moment the first blank arrived. The cache now closes the current
58
+ paragraph on a blank `>` and starts a new one, preserving the
59
+ streaming-O(new bytes) shape across multi-paragraph inner content. Each
60
+ completed inner paragraph is pre-rendered into a growing
61
+ `committed_paras_html` string; the single-paragraph fast path (the bench's
62
+ `big_blockquote` / `big_alert`) is unchanged within noise.
63
+
64
+ - **`ListCache` now handles loose lists.** A flat list with blank lines
65
+ between siblings (`- one\n\n- two\n\n- three\n`) is a CommonMark "loose"
66
+ list — every item body gets wrapped in `<p>…</p>` — and the cache used to
67
+ bail on the first blank. The cache now flips to loose on the first
68
+ blank-then-marker sequence, re-renders prior cached items with `<p>`
69
+ wrappers from stored source spans (one-time O(items)), and continues the
70
+ streaming-O(new bytes) shape from there. Tight→loose is sticky.
71
+
72
+ 50 KB loose-list bench, before-fix → after-fix:
73
+
74
+ | chunk | before | after | speedup |
75
+ |------:|---------:|--------:|--------:|
76
+ | 16 | 5593 ms | 21 ms | ~272× |
77
+ | 256 | 355 ms | 7 ms | ~49× |
78
+
79
+ Tight `big_list` perf is unchanged within bench noise.
80
+
81
+ ### Added
82
+
83
+ - **React `CodeBlock` default renderer ships a copy-to-clipboard button.**
84
+ Closed code blocks now show an icon + "Copy" in their header (the existing
85
+ "streaming" pill takes that slot until close, so streaming code is never
86
+ copy-clickable mid-arrival). Click → copies the decoded source via
87
+ `navigator.clipboard.writeText` → swaps to a checkmark + "Copied" for
88
+ 1.5 s → reverts. Native `<button>` (keyboard-reachable), `aria-label`
89
+ toggles between "Copy code" and "Copied" with `aria-live="polite"`,
90
+ guards against `navigator.clipboard` being absent (SSR / insecure context)
91
+ and rejected `writeText` promises (permission denied) — both leave the
92
+ button silently usable. No new dependency.
93
+
94
+ ### Documentation
95
+
96
+ - README quickstart now uses `useState(() => new FluxClient())` + an
97
+ unmount-only destroy effect instead of `useMemo(() => new FluxClient(),
98
+ [])` + cleanup-on-stream-change (which destroyed the client when the
99
+ `stream` prop changed, leaking a freed parser on the next append).
100
+ - New "when to enable each flag" guide for `ParserConfig` with concrete
101
+ LLM-output triggers (`gfmMath` when `$…$` arrives, `componentTags` for
102
+ `<Thinking>` blocks, etc.) — so a reader picks flags without reading the
103
+ full reference further down.
104
+ - `Alert` block-kind override example added to the `components` docs.
105
+ - `sanitize` example mirrors the realistic memoize-at-module-scope pattern
106
+ from the live demo (a fresh arrow each render busts the per-block memo).
107
+ - New "Performance" section pointing to CHANGELOG / `examples/bench.rs` for
108
+ numbers (no numbers baked into the README — those rot).
109
+
7
110
  ## 0.5.5 — 2026-05-28
8
111
 
9
112
  ### 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,10 @@ import.meta.url)`** pattern, so any bundler with asset-module support resolves
16
18
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
17
19
  modules), and **Parcel**. Next.js (webpack/turbopack) should work but is
18
20
  untested — file an issue if it doesn't. It is **browser-only** (it constructs
19
- Web Workers); it does not run under SSR/RSC. `react` is an optional peer
20
- dependencyonly needed if you import `flux-md/react`.
21
+ Web Workers); it does not run under SSR/RSC. The framework packages `react`,
22
+ `vue`, `svelte`, `solid-js` are all **optional** peer dependencies; you only
23
+ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
24
+ `flux-md/dom`, `flux-md/element`) needs none.
21
25
 
22
26
  ## Quick start
23
27
 
@@ -37,11 +41,13 @@ client.finalize();
37
41
  In React:
38
42
 
39
43
  ```tsx
40
- import { useEffect, useMemo } from "react";
44
+ import { useEffect, useState } from "react";
41
45
  import { FluxClient, FluxMarkdown } from "flux-md";
42
46
 
43
47
  export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
44
- const client = useMemo(() => new FluxClient(), []);
48
+ // One client per component instance. Destroy on unmount, not on stream change.
49
+ const [client] = useState(() => new FluxClient());
50
+ useEffect(() => () => client.destroy(), [client]);
45
51
 
46
52
  useEffect(() => {
47
53
  let cancelled = false;
@@ -52,11 +58,8 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
52
58
  }
53
59
  if (!cancelled) client.finalize();
54
60
  })();
55
- return () => {
56
- cancelled = true;
57
- client.destroy();
58
- };
59
- }, [stream]);
61
+ return () => { cancelled = true; };
62
+ }, [client, stream]);
60
63
 
61
64
  return <FluxMarkdown client={client} />;
62
65
  }
@@ -64,6 +67,166 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
64
67
 
65
68
  Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
66
69
 
70
+ ## Framework bindings
71
+
72
+ `FluxClient` is framework-neutral — it owns the worker and exposes
73
+ `subscribe`/`getSnapshot`. Pick a renderer to put its blocks on screen. Every
74
+ binding below is thin glue over the same incremental DOM renderer, so they
75
+ share one identity contract: a committed block's node is never recreated, only
76
+ the streaming tail re-renders.
77
+
78
+ **One ownership rule across all bindings:** the renderer's teardown (React
79
+ unmount, `handle.destroy()`, element disconnect, etc.) frees only the rendered
80
+ DOM and the subscription — it **never** destroys the client. You call
81
+ `client.destroy()` when you're done with the stream. (React's `<FluxMarkdown>`,
82
+ documented [below](#fluxmarkdown-react), is the same.)
83
+
84
+ ### Vanilla / any framework — `flux-md/dom`
85
+
86
+ ```ts
87
+ import { FluxClient } from "flux-md/client";
88
+ import { mountFluxMarkdown } from "flux-md/dom";
89
+
90
+ const client = new FluxClient();
91
+ const handle = mountFluxMarkdown(client, document.getElementById("out")!, {
92
+ stickToBottom: true,
93
+ });
94
+
95
+ // Feed it from a fetch/SSE reader:
96
+ const reader = (await fetch("/api/chat")).body!.getReader();
97
+ const dec = new TextDecoder();
98
+ for (;;) {
99
+ const { value, done } = await reader.read();
100
+ if (done) break;
101
+ client.append(dec.decode(value, { stream: true })); // stream:true carries multibyte across chunks
102
+ }
103
+ client.append(dec.decode());
104
+ client.finalize();
105
+
106
+ // Teardown: destroy BOTH — the renderer and the client you created.
107
+ handle.destroy();
108
+ client.destroy();
109
+ ```
110
+
111
+ `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
112
+ Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
113
+ (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
114
+ Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
115
+ `Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
116
+ (lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
117
+ tree on the fast `innerHTML` path; a block-kind override can rewrite the `html`
118
+ it's handed instead.
119
+
120
+ ### Web Component `<flux-markdown>` — `flux-md/element`
121
+
122
+ The universal binding — plain HTML, Angular, or any framework that renders DOM.
123
+ Register once, then use the element:
124
+
125
+ ```ts
126
+ import { defineFluxMarkdown } from "flux-md/element";
127
+ defineFluxMarkdown(); // defines <flux-markdown>; pass a custom tag name if you like
128
+ ```
129
+
130
+ ```html
131
+ <!-- zero-JS streaming straight from a URL -->
132
+ <flux-markdown src="/api/post.md" gfm-math stick-to-bottom></flux-markdown>
133
+
134
+ <!-- one-shot from inline text -->
135
+ <flux-markdown># Hello **world**</flux-markdown>
136
+ ```
137
+
138
+ ```js
139
+ // or caller-owned streaming — drive your own client:
140
+ const el = document.querySelector("flux-markdown");
141
+ el.client = myFluxClient; // element subscribes; never destroys it
142
+ el.components = { Thinking: (p) => myNode(p) };
143
+ myFluxClient.append(delta);
144
+ ```
145
+
146
+ Config flags are **tri-state attributes**: absent = library default;
147
+ `gfm-math` / `gfm-math="true"` / `="1"` = on; `gfm-math="false"` / `="0"` = off
148
+ (the only way to turn off a default-on flag such as `gfm-alerts`). It renders in
149
+ light DOM so your markdown CSS applies, and `defineFluxMarkdown` is a no-op under
150
+ SSR (no `customElements`). A self-owned element (`src` / `markdown` / inline
151
+ text / `append()`) is torn down on disconnect; a caller-supplied `client` is left
152
+ alone.
153
+
154
+ **Angular** consumes the same element — no separate package:
155
+
156
+ ```ts
157
+ import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
158
+ import { defineFluxMarkdown } from "flux-md/element";
159
+ defineFluxMarkdown(); // once at bootstrap
160
+
161
+ @Component({
162
+ standalone: true,
163
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
164
+ template: `<flux-markdown [attr.src]="url" stick-to-bottom></flux-markdown>`,
165
+ })
166
+ export class Answer { url = "/api/post.md"; }
167
+ ```
168
+
169
+ ### Vue 3 — `flux-md/vue`
170
+
171
+ ```vue
172
+ <script setup lang="ts">
173
+ import { onBeforeUnmount } from "vue";
174
+ import { FluxClient } from "flux-md/client";
175
+ import { FluxMarkdown } from "flux-md/vue";
176
+
177
+ const client = new FluxClient();
178
+ // feed client.append(delta) from your stream, then client.finalize()
179
+ onBeforeUnmount(() => client.destroy());
180
+ </script>
181
+
182
+ <template>
183
+ <FluxMarkdown :client="client" stick-to-bottom />
184
+ </template>
185
+ ```
186
+
187
+ Props: `client` (required), `components`, `sanitize`, `virtualize`,
188
+ `stickToBottom`. There's also a `useFluxMarkdown` composable returning a
189
+ `container` ref if you'd rather mount into your own element.
190
+
191
+ ### Svelte (4 & 5) — `flux-md/svelte`
192
+
193
+ A Svelte action — works in both v4 and v5, no `.svelte` build step:
194
+
195
+ ```svelte
196
+ <script lang="ts">
197
+ import { onDestroy } from "svelte";
198
+ import { FluxClient } from "flux-md/client";
199
+ import { fluxMarkdown } from "flux-md/svelte";
200
+
201
+ const client = new FluxClient();
202
+ // feed client.append(delta) then client.finalize()
203
+ onDestroy(() => client.destroy());
204
+ </script>
205
+
206
+ <div use:fluxMarkdown={{ client, stickToBottom: true }} />
207
+ ```
208
+
209
+ ### Solid — `flux-md/solid`
210
+
211
+ ```tsx
212
+ import { onCleanup } from "solid-js";
213
+ import { FluxClient } from "flux-md/client";
214
+ import { FluxMarkdown } from "flux-md/solid";
215
+
216
+ const client = new FluxClient();
217
+ // feed client.append(delta) then client.finalize()
218
+ onCleanup(() => client.destroy());
219
+
220
+ <FluxMarkdown client={client} stickToBottom />;
221
+ ```
222
+
223
+ The Solid binding's mount/teardown logic is tested, but its JSX component shell
224
+ has so far only been exercised through a real Solid (`vite-plugin-solid`) build
225
+ in development, not in CI — treat it as the newest of the bindings and file an
226
+ issue if your Solid setup trips on it. The component is a thin `ref`'d `<div>`;
227
+ if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
228
+ `onMount`/`onCleanup` is the zero-surprise fallback.
229
+
67
230
  ## What it does
68
231
 
69
232
  | Concern | flux-md | conventional main-thread renderer |
@@ -73,7 +236,7 @@ Multiple concurrent streams just need multiple clients — each runs in its own
73
236
  | Block identity across chunks | Stable monotonic IDs | New keys on every render |
74
237
  | Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
75
238
  | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
76
- | XSS sanitization | Allowlist in Rust + URL scheme check | rehype-sanitize on JS thread |
239
+ | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
77
240
 
78
241
  ## Public API
79
242
 
@@ -114,6 +277,25 @@ Omitted fields use the defaults above, so `new FluxClient()` is unchanged.
114
277
  Config is applied when the stream's parser is created and is **immutable** for
115
278
  that stream (`reset()` keeps it; use a new client for different flags).
116
279
 
280
+ When to enable each flag:
281
+
282
+ - `gfmAutolinks` — on by default. Leave it on unless you want strict CommonMark.
283
+ - `gfmAlerts` — on by default. Leave it on unless you want strict CommonMark.
284
+ - `gfmMath: true` — when your LLM emits `$…$` or `$$…$$` (or LaTeX `\(…\)` /
285
+ `\[…\]`). flux-md emits KaTeX-ready markup; you bring the KaTeX pass (or
286
+ `components.MathBlock`).
287
+ - `gfmFootnotes: true` — when your input uses `[^1]` references and `[^1]:`
288
+ definitions. Off by default; see the footnote streaming caveat above.
289
+ - `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
290
+ `dir="auto"` so the browser detects direction independently per block.
291
+ - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
292
+ LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
293
+ similar — see [Security](#security)).
294
+ - `componentTags: ["Thinking", …]` — when your LLM emits custom tags like
295
+ `<Thinking>…</Thinking>` and you want their inner content parsed as markdown
296
+ and dispatched to a React component. Safe without `unsafeHtml` (attributes are
297
+ sanitized; allowlisted tags only).
298
+
117
299
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
118
300
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
119
301
  can't re-render), and the footnote **section is emitted at finalize**. So a
@@ -160,15 +342,15 @@ Subscribes to a `FluxClient`, renders each block keyed by its stable parser-assi
160
342
 
161
343
  #### Custom components / overrides
162
344
 
163
- Pass a `components` map to replace how elements render the same idea as
164
- react-markdown's `components` prop, but the keys come in **two namespaces**:
345
+ Pass a `components` map to replace how elements render. Keys come in **two
346
+ namespaces**:
165
347
 
166
348
  ```tsx
167
349
  import { useMemo } from "react";
168
350
  import { FluxClient, FluxMarkdown, type Components } from "flux-md";
169
351
 
170
352
  function Message({ client }: { client: FluxClient }) {
171
- // ⚠️ Memoize (or hoist to module scope). A fresh object every render busts
353
+ // Memoize (or hoist to module scope). A fresh object every render busts
172
354
  // FluxMarkdown's block memo, so every block re-parses on every patch.
173
355
  const components: Components = useMemo(
174
356
  () => ({
@@ -181,6 +363,15 @@ function Message({ client }: { client: FluxClient }) {
181
363
  CodeBlock: ({ text, language, open }) => (
182
364
  <MyCodeBlockWithCopyButton code={text} lang={language} streaming={open} />
183
365
  ),
366
+
367
+ // GitHub alerts (`> [!NOTE]` / `[!TIP]` / `[!WARNING]` / `[!CAUTION]` /
368
+ // `[!IMPORTANT]`) — swap in your own callout component. The alert kind
369
+ // is on `block.kind.data.kind`; `html` is the rendered inner body.
370
+ Alert: ({ block, html }) => (
371
+ <MyCallout kind={(block.kind.data as { kind: string }).kind}>
372
+ <div dangerouslySetInnerHTML={{ __html: html }} />
373
+ </MyCallout>
374
+ ),
184
375
  }),
185
376
  [],
186
377
  );
@@ -313,8 +504,8 @@ styles them, and they're overridable as a block kind via `components.Alert`.
313
504
  By design, not yet, or only partially:
314
505
 
315
506
  - **Raw HTML in markdown** — escaped by default, not passed through. (Security
316
- default. A `setUnsafeHtml(true)` opt-in exists but must never be enabled for
317
- untrusted input.)
507
+ default. The `unsafeHtml: true` config flag disables the escape but must never
508
+ be enabled for untrusted input without a `sanitize` hook.)
318
509
  - **Forward link references when streaming** — a `[ref]` used *before* its later
319
510
  `[ref]: url` definition can't resolve until the definition arrives; one-shot
320
511
  parsing handles it fully, streaming converges once the definition streams in.
@@ -326,13 +517,28 @@ By design, not yet, or only partially:
326
517
  - **Syntax highlighting on open code blocks** — deferred until close. This is a
327
518
  deliberate perf choice.
328
519
 
520
+ ## Performance
521
+
522
+ Every realistic streaming shape (long paragraph, fenced code block, GFM table,
523
+ blockquote/alert, flat list, math fence, reference-heavy document) parses in
524
+ **O(n) total work**, not O(n²) — at every chunk size from 16 bytes (char-by-char)
525
+ up. Each shape has an incremental cache that mirrors the structure of the block
526
+ so that an append only does work proportional to the *newly arrived* bytes, not
527
+ the growing tail. See [CHANGELOG.md](./CHANGELOG.md) for per-shape numbers and
528
+ the regression that prompted each cache; the canonical bench is
529
+ `crates/flux-md-core/examples/bench.rs` (`cargo run --release --example bench`).
530
+
531
+ Headline numbers are not durable across machines, but the curve is: chunk size
532
+ shouldn't change the order of magnitude for any shape. If you hit one that does,
533
+ file an issue with the input and chunking — that's the next bench scenario.
534
+
329
535
  ## Security
330
536
 
331
537
  flux-md is XSS-safe by default — its HTML output is meant to be injected via
332
538
  `innerHTML` without a downstream sanitizer:
333
539
 
334
- - **Raw HTML is escaped** (the `unsafe_html` / `setUnsafeHtml(true)` opt-in
335
- disables this; **never enable it for untrusted input**).
540
+ - **Raw HTML is escaped** (the `unsafeHtml: true` config flag disables this;
541
+ **never enable it for untrusted input without a `sanitize` hook**).
336
542
  - **Dangerous URL schemes are neutralized** in `<a href>` and `<img src>` —
337
543
  `javascript:`, `vbscript:`, `data:text/html`, `data:text/javascript` become
338
544
  `#`. The check runs on the *decoded* URL and strips characters browsers
@@ -352,12 +558,17 @@ that returns raw HTML), **bring a real sanitizer** and pass it via
352
558
  `<FluxMarkdown sanitize={…} />`. flux-md applies it to every block's HTML before
353
559
  injection — **including the streaming (open) tail**, which the raw-`innerHTML`
354
560
  fast path would otherwise expose. flux-md stays zero-dep; you choose the
355
- sanitizer:
561
+ sanitizer. The realistic pattern (matches the live demo):
356
562
 
357
563
  ```tsx
358
564
  import DOMPurify from "dompurify";
359
565
 
360
- <FluxMarkdown client={client} sanitize={(html) => DOMPurify.sanitize(html)} />
566
+ // Hoist to module scope (or wrap in useCallback). A fresh arrow each render
567
+ // busts FluxMarkdown's per-block memo and re-runs every block through sanitize.
568
+ const sanitize = (html: string) => DOMPurify.sanitize(html);
569
+
570
+ // …then in your component:
571
+ <FluxMarkdown client={client} sanitize={sanitize} />
361
572
  ```
362
573
 
363
574
  The built-in code/math renderers operate on already-escaped content and are not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.5.5",
3
+ "version": "0.6.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
6
  "main": "./src/index.ts",
@@ -9,6 +9,11 @@
9
9
  ".": "./src/index.ts",
10
10
  "./client": "./src/client.ts",
11
11
  "./react": "./src/react.tsx",
12
+ "./dom": "./src/dom.ts",
13
+ "./element": "./src/element.ts",
14
+ "./vue": "./src/vue.ts",
15
+ "./svelte": "./src/svelte.ts",
16
+ "./solid": "./src/solid.tsx",
12
17
  "./highlight": "./src/hi.ts",
13
18
  "./types": "./src/types.ts"
14
19
  },
@@ -18,23 +23,33 @@
18
23
  "CHANGELOG.md"
19
24
  ],
20
25
  "peerDependencies": {
21
- "react": ">=18"
26
+ "react": ">=18",
27
+ "vue": ">=3",
28
+ "svelte": ">=4",
29
+ "solid-js": "^1.8.0"
22
30
  },
23
31
  "peerDependenciesMeta": {
24
- "react": { "optional": true }
32
+ "react": { "optional": true },
33
+ "vue": { "optional": true },
34
+ "svelte": { "optional": true },
35
+ "solid-js": { "optional": true }
25
36
  },
26
37
  "devDependencies": {
27
38
  "@types/react": "^18.3.12",
28
39
  "@types/react-dom": "^18.3.1",
40
+ "happy-dom": "^15.11.6",
29
41
  "react": "^18.3.1",
30
42
  "react-dom": "^18.3.1",
31
- "typescript": "^5.6.3"
43
+ "solid-js": "^1.8.0",
44
+ "svelte": "^4.2.0",
45
+ "typescript": "^5.6.3",
46
+ "vue": "^3.4.0"
32
47
  },
33
48
  "scripts": {
34
49
  "test": "bun test",
35
50
  "prepublishOnly": "cd ../.. && bun run build:wasm"
36
51
  },
37
- "keywords": ["markdown", "streaming", "wasm", "rust", "react", "incremental", "llm", "ai", "math", "katex", "latex", "bidi", "rtl"],
52
+ "keywords": ["markdown", "streaming", "wasm", "rust", "react", "vue", "svelte", "solid", "web-component", "custom-element", "incremental", "llm", "ai", "math", "katex", "latex", "bidi", "rtl"],
38
53
  "license": "MIT",
39
54
  "publishConfig": {
40
55
  "access": "public"
@@ -0,0 +1,96 @@
1
+ import type { Block, BlockComponentProps } from "./types-core";
2
+
3
+ // Pure helpers duplicated from the JSX renderer / its CodeBlock so the
4
+ // framework-neutral DOM renderer carries no framework dependency. The JSX
5
+ // renderer is held byte-identical, so these are copies — match it exactly.
6
+
7
+ /** Decode the small entity set the core emits (amp last so `&amp;lt;` → `&lt;`).
8
+ * This is the simple ordered chain, not the numeric/named-entity decoder. */
9
+ function decodeEntities(s: string): string {
10
+ return s
11
+ .replace(/&lt;/g, "<")
12
+ .replace(/&gt;/g, ">")
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&#39;/g, "'")
15
+ .replace(/&amp;/g, "&");
16
+ }
17
+
18
+ /** Decoded source text inside `<pre><code>…</code></pre>`. */
19
+ function decodeCodeText(html: string): string {
20
+ const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
21
+ return m ? decodeEntities(m[1]) : "";
22
+ }
23
+
24
+ /**
25
+ * The LaTeX source for a MathBlock. Display math (`$$…$$` / `\[…\]`) renders as
26
+ * `<div class="math math-display">…</div>`; a fenced ```math block renders as
27
+ * `<pre><code>…</code></pre>`. Either way the body is the HTML-escaped LaTeX —
28
+ * decode it back so a `components.MathBlock` override gets the raw source.
29
+ */
30
+ function decodeMathText(html: string): string {
31
+ const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
32
+ if (d) return decodeEntities(d[1]);
33
+ return decodeCodeText(html);
34
+ }
35
+
36
+ /** Info-string language from a code block's `data-lang="…"`. */
37
+ export function extractLang(html: string): string {
38
+ const m = html.match(/data-lang="([^"]+)"/);
39
+ return m ? m[1] : "";
40
+ }
41
+
42
+ /** Strip the `<tag …>` open and trailing `</tag>` from a component block's HTML,
43
+ * leaving the inner (already-rendered markdown) HTML. Handles open (unclosed)
44
+ * blocks, where there is no close tag yet. */
45
+ function componentInnerHtml(html: string, tag: string): string {
46
+ const gt = html.indexOf(">");
47
+ if (gt < 0) return "";
48
+ let inner = html.slice(gt + 1);
49
+ const close = `</${tag}>`;
50
+ if (inner.endsWith(close)) inner = inner.slice(0, -close.length);
51
+ return inner.replace(/^\n/, "").replace(/\n$/, "");
52
+ }
53
+
54
+ /**
55
+ * Convert sanitized HTML attribute pairs into a spreadable object, keeping the
56
+ * HTML-form names (`class`, `for`) verbatim. This is the deliberate divergence
57
+ * from the JSX renderer (which renames to `className`/`htmlFor` for a prop
58
+ * spread): the DOM renderer applies them via `el.setAttribute(name, value)`,
59
+ * which wants the literal HTML names.
60
+ */
61
+ export function htmlAttrs(pairs: [string, string][]): Record<string, string> {
62
+ const out: Record<string, string> = {};
63
+ for (const [k, v] of pairs) out[k] = v;
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Build the props a block-kind / component-tag override receives — the same
69
+ * shape the JSX renderer's block-kind props carry, with ONE deliberate
70
+ * divergence: for `Component` blocks `attrs` stay in HTML form (`class`/`for`)
71
+ * because DOM overrides apply them via `setAttribute` (see {@link htmlAttrs}).
72
+ */
73
+ export function blockProps(block: Block): BlockComponentProps {
74
+ const props: BlockComponentProps = {
75
+ block,
76
+ html: block.html,
77
+ open: block.open,
78
+ speculative: block.speculative,
79
+ };
80
+ const data = block.kind.data as
81
+ | { lang?: string | null; tag?: string; attrs?: [string, string][] }
82
+ | undefined;
83
+ if (block.kind.type === "CodeBlock") {
84
+ props.text = decodeCodeText(block.html);
85
+ props.language = data?.lang ?? "";
86
+ } else if (block.kind.type === "MathBlock") {
87
+ props.text = decodeMathText(block.html);
88
+ } else if (block.kind.type === "Component") {
89
+ props.tag = data?.tag ?? "";
90
+ props.attrs = htmlAttrs(data?.attrs ?? []);
91
+ // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
92
+ // (markdown already rendered) rather than the full wrapped block.
93
+ props.html = componentInnerHtml(block.html, props.tag);
94
+ }
95
+ return props;
96
+ }
package/src/client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types";
1
+ import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types-core";
2
2
 
3
3
  /**
4
4
  * The ordered-block store backing a stream, extracted as a pure function so