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 +143 -0
- package/README.md +261 -21
- package/package.json +21 -5
- package/src/block-props.ts +96 -0
- package/src/client.ts +111 -11
- package/src/dom.ts +430 -0
- package/src/element.ts +381 -0
- package/src/renderers/CodeBlock.tsx +62 -5
- package/src/solid.tsx +70 -0
- package/src/svelte.ts +55 -0
- package/src/types-core.ts +147 -0
- package/src/types-react.ts +14 -0
- package/src/types.ts +7 -150
- package/src/vue.ts +100 -0
- package/src/wasm/flux_md_core.d.ts +7 -0
- package/src/wasm/flux_md_core.js +9 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/src/wasm/package.json +1 -1
- package/src/worker.ts +11 -2
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.
|
|
20
|
-
|
|
21
|
+
Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
|
|
22
|
+
`vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
|
|
23
|
+
need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
24
|
+
`flux-md/dom`, `flux-md/element`) needs none.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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?: {
|
|
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
|
|
164
|
-
|
|
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
|
-
//
|
|
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.
|
|
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 `
|
|
335
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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"
|