flux-md 0.11.0 → 0.13.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 +64 -0
- package/README.md +185 -7
- package/package.json +4 -3
- package/src/client.ts +74 -0
- package/src/index.ts +1 -1
- package/src/react.tsx +63 -5
- package/src/solid.tsx +72 -2
- package/src/styles.css +182 -0
- package/src/svelte.ts +100 -1
- package/src/vue.ts +58 -1
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/package.json +1 -1
- package/src/worker-core.ts +174 -0
- package/src/worker.ts +39 -136
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,70 @@ 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.13.0 — 2026-06-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`FluxClient.setContent(content, { done })` + controlled-string helpers for
|
|
12
|
+
every binding** — a first-class bridge for UIs that hold a streaming message as
|
|
13
|
+
a single growing/controlled string prop (rather than a stream). setContent diffs
|
|
14
|
+
against the last value: a **prefix-extension** appends only the delta (committed
|
|
15
|
+
blocks stay put); any **divergence** (e.g. a finished message swapped for a
|
|
16
|
+
re-processed final string) resets and reparses. No hand-rolled diff, no
|
|
17
|
+
readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
|
|
18
|
+
framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
|
|
19
|
+
per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
|
|
20
|
+
(composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
|
|
21
|
+
(action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
|
|
22
|
+
/ `<flux-markdown>` use a caller-owned client + `setContent` directly.
|
|
23
|
+
- **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
|
|
24
|
+
on app load) so the one-time WASM init is off the first-token critical path; the
|
|
25
|
+
warm worker is the one the first stream attaches to, so the work isn't wasted.
|
|
26
|
+
- **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
|
|
27
|
+
block**, not just settled ones — a design-system renderer (Tailwind classes on
|
|
28
|
+
`p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
|
|
29
|
+
of only after a block commits. This also closes a gap where a supplied
|
|
30
|
+
`sanitize` previously bypassed component-rendered blocks; it now runs on every
|
|
31
|
+
block. The no-`components` path is unchanged (byte-identical `innerHTML`).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Worker no longer drops the first chunk(s) under a slow WASM load.** The
|
|
36
|
+
worker buffered appends but did not gate parser creation on WASM readiness, so
|
|
37
|
+
an append that arrived before `init()` resolved would call `new FluxParser()`
|
|
38
|
+
against an uninitialized module — throwing `fluxparser_new of undefined` and
|
|
39
|
+
silently losing that chunk. Appends now accumulate (and `finalize` defers)
|
|
40
|
+
until init completes, then drain in order. Surfaced on a fresh Next.js /
|
|
41
|
+
Turbopack production load, where the worker+WASM fetch is slow enough to lose
|
|
42
|
+
the race; the fix is bundler-agnostic. The worker's message/readiness state
|
|
43
|
+
machine was extracted to `worker-core.ts` (dependency-injected, like
|
|
44
|
+
`FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
|
|
45
|
+
covering the gate — buffer-until-ready, drain order, finalize/reset before
|
|
46
|
+
ready — so the regression can't silently return.
|
|
47
|
+
- **React 19 / Next.js type compatibility.** The shipped source used the global
|
|
48
|
+
`JSX.Element`, which React 19's `@types/react` removed — a consumer's
|
|
49
|
+
`next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
|
|
50
|
+
*"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
|
|
51
|
+
under `@types/react` 18 **and** 19.
|
|
52
|
+
|
|
53
|
+
### Docs
|
|
54
|
+
|
|
55
|
+
- **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
|
|
56
|
+
Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
|
|
57
|
+
and use it from a `"use client"` component. See the README's Next.js callout.
|
|
58
|
+
|
|
59
|
+
## 0.12.0 — 2026-05-30
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **Optional default theme — `import "flux-md/styles.css"`.** A drop-in stylesheet
|
|
64
|
+
for good-looking output out of the box, **including the built-in syntax
|
|
65
|
+
highlighter's colors** (without any CSS, `highlight()` output is uncolored).
|
|
66
|
+
Scoped to `.flux-md`, driven by `--flux-*` CSS variables (re-theme by overriding
|
|
67
|
+
a few), light by default with automatic dark via `prefers-color-scheme` (force
|
|
68
|
+
with `class="flux-md flux-dark"` / `flux-light`). Opt-in and zero-runtime — the
|
|
69
|
+
rendered HTML is unchanged; skip the import to bring your own CSS.
|
|
70
|
+
|
|
7
71
|
## 0.11.0 — 2026-05-30
|
|
8
72
|
|
|
9
73
|
### Added
|
package/README.md
CHANGED
|
@@ -16,9 +16,9 @@ flux-md ships as **source** (TypeScript + the compiled WASM). The worker and
|
|
|
16
16
|
WASM asset are referenced with the **web-standard `new URL(asset,
|
|
17
17
|
import.meta.url)`** pattern, so any bundler with asset-module support resolves
|
|
18
18
|
them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
|
|
19
|
-
modules), and **
|
|
20
|
-
|
|
21
|
-
Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
|
|
19
|
+
modules), **Parcel**, and **Next.js** (App Router — Turbopack *and* webpack;
|
|
20
|
+
**verified on Next.js 16**, see the [Next.js callout](#nextjs) below). It is
|
|
21
|
+
**browser-only** (it constructs Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
|
|
22
22
|
`vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
|
|
23
23
|
need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
24
24
|
`flux-md/dom`, `flux-md/element`) needs none.
|
|
@@ -37,6 +37,54 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
|
37
37
|
>
|
|
38
38
|
> No other bundler needs this — it's specific to Vite's optimizer.
|
|
39
39
|
|
|
40
|
+
<a id="nextjs"></a>
|
|
41
|
+
|
|
42
|
+
> **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
|
|
43
|
+
> **Turbopack** (the default for both `next dev` and `next build`). The same two
|
|
44
|
+
> requirements apply under webpack. Because flux-md ships TypeScript source:
|
|
45
|
+
>
|
|
46
|
+
> 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
|
|
47
|
+
> by default — without this, Turbopack errors with *"Unknown module type"* on
|
|
48
|
+
> `react.tsx`. Add flux-md to `transpilePackages`:
|
|
49
|
+
>
|
|
50
|
+
> ```ts
|
|
51
|
+
> // next.config.ts
|
|
52
|
+
> import type { NextConfig } from "next";
|
|
53
|
+
> const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
|
|
54
|
+
> export default nextConfig;
|
|
55
|
+
> ```
|
|
56
|
+
>
|
|
57
|
+
> 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
|
|
58
|
+
> spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
|
|
59
|
+
> a Server Component. (It is still SSR-safe: on the server it renders an empty
|
|
60
|
+
> shell and only starts streaming after hydration, so there's no SSR crash —
|
|
61
|
+
> the constraint is hooks, not the worker.)
|
|
62
|
+
>
|
|
63
|
+
> ```tsx
|
|
64
|
+
> "use client";
|
|
65
|
+
> import { FluxMarkdown } from "flux-md/react";
|
|
66
|
+
>
|
|
67
|
+
> export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
|
|
68
|
+
> return <FluxMarkdown stream={stream} />;
|
|
69
|
+
> }
|
|
70
|
+
> ```
|
|
71
|
+
>
|
|
72
|
+
> **Create the `stream` in Client Component code, not in a Server Component.**
|
|
73
|
+
> A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
|
|
74
|
+
> can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
|
|
75
|
+
> throws *"Only plain objects can be passed to Client Components."* Pass a
|
|
76
|
+
> serializable prop (a URL, the chat messages) from the server and open the
|
|
77
|
+
> stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
|
|
78
|
+
> effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
|
|
79
|
+
>
|
|
80
|
+
> That's it — Turbopack bundles the worker and emits the `.wasm` to
|
|
81
|
+
> `_next/static/media` itself, so no extra asset/loader config is needed (and the
|
|
82
|
+
> Vite `optimizeDeps` workaround above does **not** apply). Both `next dev` and
|
|
83
|
+
> `next build && next start` are verified to spawn the worker, load the WASM, and
|
|
84
|
+
> stream markdown. _Dev tip:_ open the app on `localhost` — Next dev blocks
|
|
85
|
+
> cross-origin dev resources (HMR, chunks) from other hosts (e.g. `127.0.0.1`)
|
|
86
|
+
> unless you add them to `allowedDevOrigins` in `next.config`.
|
|
87
|
+
|
|
40
88
|
## Quick start
|
|
41
89
|
|
|
42
90
|
```ts
|
|
@@ -79,6 +127,38 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
|
|
|
79
127
|
}
|
|
80
128
|
```
|
|
81
129
|
|
|
130
|
+
### Already holding a growing string? — `useFluxMarkdownString`
|
|
131
|
+
|
|
132
|
+
Many apps keep the streaming message as a **single growing string prop** (it
|
|
133
|
+
re-renders with the full text-so-far each token), not as a stream. Feed that
|
|
134
|
+
string straight in — `useFluxMarkdownString` diffs it for you and forwards only
|
|
135
|
+
the delta, so you don't hand-roll an append/reset bridge:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import { FluxMarkdown, useFluxMarkdownString } from "flux-md/react";
|
|
139
|
+
|
|
140
|
+
export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
|
|
141
|
+
const client = useFluxMarkdownString(text, { streaming });
|
|
142
|
+
return <FluxMarkdown client={client} />;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It handles the two shapes a controlled string takes: a **prefix-extension** (the
|
|
147
|
+
common token-by-token growth) appends only the new suffix; a **divergence** (e.g.
|
|
148
|
+
the finished text swapped for a re-processed final string — bolded numbers,
|
|
149
|
+
wrapped tickers) resets and reparses. Pass `streaming: false` once the content is
|
|
150
|
+
final so the last block commits (a finished code fence then highlights). The
|
|
151
|
+
framework-neutral primitive is **`client.setContent(fullString, { done })`** —
|
|
152
|
+
use it from any binding.
|
|
153
|
+
|
|
154
|
+
> **Transforming streamed content?** If the enrichment runs **live per token**
|
|
155
|
+
> (e.g. bold every number as it arrives), do it at **render time** via
|
|
156
|
+
> [`components`](#custom-components--overrides) — keep the markdown source
|
|
157
|
+
> append-only so parsing stays incremental. Re-transforming the *whole* string
|
|
158
|
+
> each token (so earlier bytes change) forces `setContent` to reparse every tick
|
|
159
|
+
> (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
|
|
160
|
+
> for the **once**-at-the-end reprocess swap, not per-token rewrites.
|
|
161
|
+
|
|
82
162
|
<details>
|
|
83
163
|
<summary>Full manual control (caller-owned client)</summary>
|
|
84
164
|
|
|
@@ -150,6 +230,12 @@ handle.destroy();
|
|
|
150
230
|
client.destroy();
|
|
151
231
|
```
|
|
152
232
|
|
|
233
|
+
**Already holding a growing string?** There's no framework reactivity to wrap,
|
|
234
|
+
so just call **`client.setContent(fullString, { done })`** instead of the
|
|
235
|
+
`append` loop — it diffs internally (prefix → delta; divergence → reparse) and
|
|
236
|
+
finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
|
|
237
|
+
controlled-string helpers wrap; in vanilla you call it directly.
|
|
238
|
+
|
|
153
239
|
`mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
|
|
154
240
|
Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
|
|
155
241
|
(default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
|
|
@@ -208,6 +294,13 @@ defineFluxMarkdown(); // once at bootstrap
|
|
|
208
294
|
export class Answer { url = "/api/post.md"; }
|
|
209
295
|
```
|
|
210
296
|
|
|
297
|
+
**Controlled growing string?** Assign a caller-owned client and drive it with
|
|
298
|
+
`setContent` — `el.client = myClient; myClient.setContent(fullString, { done })`
|
|
299
|
+
— the element subscribes and renders, you own the diffing. (The self-owned
|
|
300
|
+
`markdown` attribute is **one-shot** — it re-parses the whole document on each
|
|
301
|
+
change, so don't point it at a per-token-growing string; use a client +
|
|
302
|
+
`setContent` for that.)
|
|
303
|
+
|
|
211
304
|
### Vue 3 — `flux-md/vue`
|
|
212
305
|
|
|
213
306
|
```vue
|
|
@@ -230,6 +323,20 @@ Props: `client` (required), `components`, `sanitize`, `virtualize`,
|
|
|
230
323
|
`stickToBottom`. There's also a `useFluxMarkdown` composable returning a
|
|
231
324
|
`container` ref if you'd rather mount into your own element.
|
|
232
325
|
|
|
326
|
+
**Already holding a growing string?** `useFluxMarkdownString` owns a client and
|
|
327
|
+
diffs the string for you (the Vue analogue of the React hook — see
|
|
328
|
+
[Controlled strings](#already-holding-a-growing-string--usefluxmarkdownstring)):
|
|
329
|
+
|
|
330
|
+
```vue
|
|
331
|
+
<script setup lang="ts">
|
|
332
|
+
import { FluxMarkdown, useFluxMarkdownString } from "flux-md/vue";
|
|
333
|
+
const props = defineProps<{ text: string; streaming: boolean }>();
|
|
334
|
+
// Pass getters so the composable tracks the live values; it owns + destroys the client.
|
|
335
|
+
const client = useFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
|
|
336
|
+
</script>
|
|
337
|
+
<template><FluxMarkdown :client="client" /></template>
|
|
338
|
+
```
|
|
339
|
+
|
|
233
340
|
### Svelte (4 & 5) — `flux-md/svelte`
|
|
234
341
|
|
|
235
342
|
A Svelte action — works in both v4 and v5, no `.svelte` build step:
|
|
@@ -248,6 +355,20 @@ A Svelte action — works in both v4 and v5, no `.svelte` build step:
|
|
|
248
355
|
<div use:fluxMarkdown={{ client, stickToBottom: true }} />
|
|
249
356
|
```
|
|
250
357
|
|
|
358
|
+
**Growing string?** The `fluxMarkdownString` action owns a client and diffs the
|
|
359
|
+
string — `use:fluxMarkdownString={{ content, streaming }}` (it destroys its
|
|
360
|
+
client on `destroy`, so no manual cleanup):
|
|
361
|
+
|
|
362
|
+
```svelte
|
|
363
|
+
<script lang="ts">
|
|
364
|
+
import { fluxMarkdownString } from "flux-md/svelte";
|
|
365
|
+
export let content: string; // the growing message
|
|
366
|
+
export let streaming: boolean; // false once complete → finalizes
|
|
367
|
+
</script>
|
|
368
|
+
|
|
369
|
+
<div use:fluxMarkdownString={{ content, streaming, stickToBottom: true }} />
|
|
370
|
+
```
|
|
371
|
+
|
|
251
372
|
### Solid — `flux-md/solid`
|
|
252
373
|
|
|
253
374
|
```tsx
|
|
@@ -262,6 +383,19 @@ onCleanup(() => client.destroy());
|
|
|
262
383
|
<FluxMarkdown client={client} stickToBottom />;
|
|
263
384
|
```
|
|
264
385
|
|
|
386
|
+
**Growing string?** `createFluxMarkdownString` owns a client and diffs the string
|
|
387
|
+
(the Solid analogue of the React hook), driving `setContent` from a
|
|
388
|
+
`createEffect` and destroying the client on cleanup:
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
import { FluxMarkdown, createFluxMarkdownString } from "flux-md/solid";
|
|
392
|
+
|
|
393
|
+
function Message(props: { text: string; streaming: boolean }) {
|
|
394
|
+
const client = createFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
|
|
395
|
+
return <FluxMarkdown client={client} />;
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
265
399
|
The Solid binding's mount/teardown logic is tested, but its JSX component shell
|
|
266
400
|
has so far only been exercised through a real Solid (`vite-plugin-solid`) build
|
|
267
401
|
in development, not in CI — treat it as the newest of the bindings and file an
|
|
@@ -280,6 +414,32 @@ if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
|
|
|
280
414
|
| Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
|
|
281
415
|
| XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
|
|
282
416
|
|
|
417
|
+
## Styling
|
|
418
|
+
|
|
419
|
+
flux-md emits semantic HTML under a `.flux-md` root and **ships no CSS by
|
|
420
|
+
default** — bring your own design system, or opt into the bundled theme:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
import "flux-md/styles.css";
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
It gives good-looking output out of the box, **including the built-in syntax
|
|
427
|
+
highlighter's colors** (without any CSS, `highlight()` renders uncolored). The
|
|
428
|
+
theme is scoped to `.flux-md`, zero-runtime, and **does not change the rendered
|
|
429
|
+
HTML** — skip the import and nothing is styled.
|
|
430
|
+
|
|
431
|
+
Re-theme by overriding a few CSS variables; it's light by default and switches to
|
|
432
|
+
dark automatically via `prefers-color-scheme` (force a mode with
|
|
433
|
+
`class="flux-md flux-dark"` or `flux-light`):
|
|
434
|
+
|
|
435
|
+
```css
|
|
436
|
+
.flux-md {
|
|
437
|
+
--flux-accent: #7c3aed; /* links */
|
|
438
|
+
--flux-bg-code: #faf7ff; /* code background */
|
|
439
|
+
--flux-t-kw: #c026d3; /* syntax: keywords (also --flux-t-str/num/com/fn/ty/…) */
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
283
443
|
## Public API
|
|
284
444
|
|
|
285
445
|
### `FluxClient`
|
|
@@ -298,6 +458,10 @@ class FluxClient {
|
|
|
298
458
|
opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
|
|
299
459
|
): Promise<void>;
|
|
300
460
|
finalize(): void; // mark stream complete
|
|
461
|
+
setContent( // drive from a controlled full string
|
|
462
|
+
full: string, // diffs vs last: prefix → append delta; else reset+reparse
|
|
463
|
+
opts?: { done?: boolean }, // done:true → finalize
|
|
464
|
+
): void;
|
|
301
465
|
reset(): void; // wipe and reuse
|
|
302
466
|
destroy(): void; // free this stream's parser
|
|
303
467
|
whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
|
|
@@ -476,11 +640,15 @@ Rules worth knowing:
|
|
|
476
640
|
channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
|
|
477
641
|
`block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
|
|
478
642
|
`list` fields) directly — no HTML re-parsing.
|
|
479
|
-
- **
|
|
480
|
-
|
|
643
|
+
- **Overrides apply to the OPEN (streaming) block too**, not just settled ones —
|
|
644
|
+
so a design-system renderer (Tailwind classes on `p`/`ul`/`li`, inline
|
|
645
|
+
`<a>`/`<code>` overrides) stays styled mid-stream. The tail's HTML is always
|
|
646
|
+
well-formed (the parser speculatively closes it). If a `sanitize` is supplied
|
|
647
|
+
it runs first, on every block.
|
|
481
648
|
- **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
|
|
482
|
-
output). The HTML→React conversion
|
|
483
|
-
|
|
649
|
+
output). The HTML→React conversion runs only when you actually supply
|
|
650
|
+
overrides, and is memoized per `(block id, html)` so committed blocks don't
|
|
651
|
+
re-parse as the stream grows.
|
|
484
652
|
- For **code blocks** the built-in highlighter is the default; it is bypassed
|
|
485
653
|
(so your override wins) when you pass `components.CodeBlock`, `components.pre`,
|
|
486
654
|
or `components.code`.
|
|
@@ -710,6 +878,16 @@ teardown? Construct your own `new FluxPool(factory, cap)` and pass it to
|
|
|
710
878
|
**per-page singleton** — don't rely on it in SSR/RSC. For isolation between
|
|
711
879
|
independent feature areas, give each its own `new FluxPool()`.
|
|
712
880
|
|
|
881
|
+
**Warm the pool to hide WASM init.** The one-time WASM load happens on the first
|
|
882
|
+
worker-bound op, which lands on the first-token critical path. Call
|
|
883
|
+
`getDefaultPool().warm()` on app load / route entry to start it early — the warm
|
|
884
|
+
worker is the one the first stream attaches to, so the init isn't wasted:
|
|
885
|
+
|
|
886
|
+
```ts
|
|
887
|
+
import { getDefaultPool } from "flux-md";
|
|
888
|
+
useEffect(() => { getDefaultPool().warm(); }, []); // (or your framework's mount hook)
|
|
889
|
+
```
|
|
890
|
+
|
|
713
891
|
### Long documents — `virtualize`
|
|
714
892
|
|
|
715
893
|
For very long documents (hundreds+ of blocks), pass `virtualize` to apply CSS
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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
|
+
"sideEffects": ["./src/worker.ts", "./src/styles.css"],
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"types": "./src/index.ts",
|
|
9
9
|
"exports": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"./svelte": "./src/svelte.ts",
|
|
17
17
|
"./solid": "./src/solid.tsx",
|
|
18
18
|
"./highlight": "./src/hi.ts",
|
|
19
|
-
"./types": "./src/types.ts"
|
|
19
|
+
"./types": "./src/types.ts",
|
|
20
|
+
"./styles.css": "./src/styles.css"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"src",
|
package/src/client.ts
CHANGED
|
@@ -151,6 +151,21 @@ export class FluxPool {
|
|
|
151
151
|
return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Eagerly spin up one worker so WASM init starts BEFORE the first stream —
|
|
156
|
+
* taking the one-time init off the first-token critical path (e.g. call
|
|
157
|
+
* `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
|
|
158
|
+
* if one exists; the warm worker is the one the first stream attaches to (it
|
|
159
|
+
* has spare capacity), so the work is not wasted. Resolves when that worker has
|
|
160
|
+
* finished initializing WASM; rejects if init fails fatally. Browser-only (it
|
|
161
|
+
* constructs a `Worker`).
|
|
162
|
+
*/
|
|
163
|
+
warm(): Promise<void> {
|
|
164
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
165
|
+
const pw = live[0] ?? this.create();
|
|
166
|
+
return this.whenWorkerReady(pw);
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
155
170
|
disposeAll(): void {
|
|
156
171
|
for (const pw of this.workers) {
|
|
@@ -286,6 +301,11 @@ export class FluxClient {
|
|
|
286
301
|
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
287
302
|
private onBlock?: (block: Block) => void;
|
|
288
303
|
private attached = true;
|
|
304
|
+
// Diff baseline for setContent(): the full string fed in so far, and whether
|
|
305
|
+
// it has been finalized. Cleared by reset()/reattach() (the worker drops the
|
|
306
|
+
// parser there, so the baseline is stale and the document must be re-fed).
|
|
307
|
+
private lastContent = "";
|
|
308
|
+
private contentDone = false;
|
|
289
309
|
|
|
290
310
|
// Perf
|
|
291
311
|
private appendedBytes = 0;
|
|
@@ -446,6 +466,53 @@ export class FluxClient {
|
|
|
446
466
|
}
|
|
447
467
|
}
|
|
448
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Drive the parser from a CONTROLLED full string instead of manual appends.
|
|
471
|
+
* Pass the whole document-so-far each time; setContent diffs it against the
|
|
472
|
+
* last value and does the minimal work:
|
|
473
|
+
* - **prefix-extension** (the streaming-growth case) → append only the new
|
|
474
|
+
* suffix, so committed blocks stay put and only the active tail re-parses;
|
|
475
|
+
* - **any other change** (e.g. a finished stream swapped for a re-processed
|
|
476
|
+
* final string) → `reset()` + reparse the whole new string.
|
|
477
|
+
*
|
|
478
|
+
* This is the first-class bridge for UIs that hold a streaming message as a
|
|
479
|
+
* single growing string prop (the common React shape) — no hand-rolled diff,
|
|
480
|
+
* no readiness gate (appends before WASM is ready are buffered). Pass
|
|
481
|
+
* `{ done: true }` once the content is final to `finalize()` (idempotent within
|
|
482
|
+
* a generation; a content change *after* done reopens the stream via a fresh
|
|
483
|
+
* reparse, since a finalized parser is terminal and can't be appended to).
|
|
484
|
+
* Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
|
|
485
|
+
* not both — they share the internal diff baseline.
|
|
486
|
+
*
|
|
487
|
+
* v1 note: the non-prefix path is a full reparse, not a partial rewind —
|
|
488
|
+
* committed blocks are frozen, so there is no truncate-to-offset. For the
|
|
489
|
+
* common case (append-growth + one end-of-stream swap) that is optimal. A
|
|
490
|
+
* transform that rewrites *earlier* bytes on every update is an anti-pattern
|
|
491
|
+
* here (it forces a reparse each tick); do that enrichment at render time via
|
|
492
|
+
* `components` instead, keeping the source append-only.
|
|
493
|
+
*/
|
|
494
|
+
setContent(content: string, opts?: { done?: boolean }) {
|
|
495
|
+
if (content !== this.lastContent) {
|
|
496
|
+
// Fast path appends the delta into the EXISTING parser — but a parser that
|
|
497
|
+
// was already finalized ({ done: true }) is terminal: the core drops any
|
|
498
|
+
// further append. So gate the fast path on !contentDone; reopening a
|
|
499
|
+
// finalized stream (or any divergence) falls through to reset()+reparse,
|
|
500
|
+
// which frees the dead parser and rebuilds a fresh one.
|
|
501
|
+
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
502
|
+
this.append(content.slice(this.lastContent.length));
|
|
503
|
+
} else {
|
|
504
|
+
this.reset(); // diverged, or reopening a finalized stream — rebuild
|
|
505
|
+
this.append(content);
|
|
506
|
+
}
|
|
507
|
+
this.lastContent = content;
|
|
508
|
+
this.contentDone = false;
|
|
509
|
+
}
|
|
510
|
+
if (opts?.done && !this.contentDone) {
|
|
511
|
+
this.finalize();
|
|
512
|
+
this.contentDone = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
449
516
|
reset() {
|
|
450
517
|
this.store = emptyBlockStore();
|
|
451
518
|
this.appendedBytes = 0;
|
|
@@ -455,6 +522,8 @@ export class FluxClient {
|
|
|
455
522
|
this.firstAppendMs = 0;
|
|
456
523
|
this.retainedBytes = 0;
|
|
457
524
|
this.wasmMemoryBytes = 0;
|
|
525
|
+
this.lastContent = ""; // setContent baseline: the worker drops the parser here
|
|
526
|
+
this.contentDone = false;
|
|
458
527
|
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
459
528
|
const pw = this.ensureAcquired();
|
|
460
529
|
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
@@ -481,6 +550,11 @@ export class FluxClient {
|
|
|
481
550
|
*/
|
|
482
551
|
reattach() {
|
|
483
552
|
if (this.attached) return;
|
|
553
|
+
// The prior destroy()→dispose dropped this stream's parser, so setContent's
|
|
554
|
+
// diff baseline is stale — clear it so the next setContent re-feeds the whole
|
|
555
|
+
// document (StrictMode dev double-mount on the SAME instance).
|
|
556
|
+
this.lastContent = "";
|
|
557
|
+
this.contentDone = false;
|
|
484
558
|
if (!this.pw) {
|
|
485
559
|
// Never acquired (e.g. constructed during SSR, first real mount on client).
|
|
486
560
|
// No prior pool slot to re-register; just mark attached. The next
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* client.finalize();
|
|
17
17
|
*/
|
|
18
18
|
export { FluxClient, FluxPool, getDefaultPool } from "./client";
|
|
19
|
-
export { FluxMarkdown } from "./react";
|
|
19
|
+
export { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react";
|
|
20
20
|
export { highlight, supportedLangs } from "./hi";
|
|
21
21
|
export { htmlToReact, parseTrustedHtml } from "./html-to-react";
|
|
22
22
|
export type {
|
package/src/react.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
useSyncExternalStore,
|
|
9
9
|
type CSSProperties,
|
|
10
|
+
type ReactElement,
|
|
10
11
|
} from "react";
|
|
11
12
|
import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
|
|
12
13
|
import { FluxClient } from "./client";
|
|
@@ -209,6 +210,52 @@ export function useFluxStream(
|
|
|
209
210
|
return client;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
|
|
215
|
+
* UIs that hold a streaming message as a single growing string prop (the common
|
|
216
|
+
* React shape) rather than as a stream. Pass the whole document-so-far on each
|
|
217
|
+
* render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
|
|
218
|
+
* only the delta; any divergence (e.g. the finished text swapped for a
|
|
219
|
+
* re-processed final string) resets and reparses. Returns the owned client —
|
|
220
|
+
* pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
|
|
221
|
+
*
|
|
222
|
+
* Pass `streaming: false` once the content is final to finalize the stream and
|
|
223
|
+
* commit its last block (only then does a finished code fence highlight + show
|
|
224
|
+
* its copy button). If `streaming` is omitted or `true` the stream is left OPEN
|
|
225
|
+
* — right for a still-growing string, but a *complete static* string rendered as
|
|
226
|
+
* `useFluxMarkdownString(md)` keeps its last block in the streaming state until
|
|
227
|
+
* you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
|
|
228
|
+
* deliberately avoided: it would re-finalize on every token for callers that
|
|
229
|
+
* grow the string without the flag — an O(n²) reparse trap.) The client is
|
|
230
|
+
* created once and destroyed on unmount; StrictMode's dev double-mount is handled
|
|
231
|
+
* (reattach re-feeds the document). For a true stream source
|
|
232
|
+
* (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
|
|
233
|
+
* instead — it avoids buffering the whole document as a string.
|
|
234
|
+
*/
|
|
235
|
+
export function useFluxMarkdownString(
|
|
236
|
+
content: string,
|
|
237
|
+
options?: { config?: ParserConfig; streaming?: boolean },
|
|
238
|
+
): FluxClient {
|
|
239
|
+
const [client] = useState(() => new FluxClient({ config: options?.config }));
|
|
240
|
+
|
|
241
|
+
// Own the client's pool attachment (StrictMode dev double-mount destroys on the
|
|
242
|
+
// simulated unmount then remounts the SAME instance; reattach re-registers and
|
|
243
|
+
// clears setContent's diff baseline so the document is re-fed). Destroy on the
|
|
244
|
+
// real unmount.
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
client.reattach();
|
|
247
|
+
return () => client.destroy();
|
|
248
|
+
}, [client]);
|
|
249
|
+
|
|
250
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
251
|
+
// so this stays correct whether `content` grows by a token or is swapped wholesale.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
client.setContent(content, { done: options?.streaming === false });
|
|
254
|
+
}, [client, content, options?.streaming]);
|
|
255
|
+
|
|
256
|
+
return client;
|
|
257
|
+
}
|
|
258
|
+
|
|
212
259
|
// Stream mode: own a client via the hook, then render the normal client path.
|
|
213
260
|
function FluxMarkdownFromStream(props: FluxMarkdownProps) {
|
|
214
261
|
const client = useFluxStream(props.stream, {
|
|
@@ -336,7 +383,10 @@ function componentInnerHtml(html: string, tag: string): string {
|
|
|
336
383
|
|
|
337
384
|
/** Convert a closed block's HTML to a React tree, memoized on html+components. */
|
|
338
385
|
function SafeHtml({ html, components }: { html: string; components: Components }) {
|
|
339
|
-
|
|
386
|
+
// `ReactElement` (not the global `JSX.Element`) so the source type-checks under
|
|
387
|
+
// both @types/react 18 and 19 — React 19 removed the global `JSX` namespace,
|
|
388
|
+
// and a consumer's `next build` type-checks this shipped source.
|
|
389
|
+
return useMemo(() => htmlToReact(html, components), [html, components]) as ReactElement;
|
|
340
390
|
}
|
|
341
391
|
|
|
342
392
|
// Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
|
|
@@ -419,12 +469,20 @@ function renderBlockContent({
|
|
|
419
469
|
(block.open ? " flux-open" : "") +
|
|
420
470
|
(block.speculative ? " flux-speculative" : "");
|
|
421
471
|
|
|
422
|
-
// Tag-level overrides
|
|
423
|
-
//
|
|
424
|
-
|
|
472
|
+
// Tag-level / inline overrides apply to OPEN and speculative blocks too, not
|
|
473
|
+
// just settled ones: the streaming tail's HTML is always well-formed (the
|
|
474
|
+
// parser speculatively closes it), so a design-system renderer (Tailwind
|
|
475
|
+
// classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream
|
|
476
|
+
// instead of only after a block commits. A supplied `sanitize` runs FIRST
|
|
477
|
+
// (same as the innerHTML path below), so overrides compose with sanitization on
|
|
478
|
+
// every block — closing the gap where a component-rendered block previously
|
|
479
|
+
// bypassed the user sanitizer. The no-`components` fast path is untouched
|
|
480
|
+
// (byte-identical innerHTML).
|
|
481
|
+
if (components) {
|
|
482
|
+
const safe = sanitize ? sanitize(block.html) : block.html;
|
|
425
483
|
return (
|
|
426
484
|
<div className={className}>
|
|
427
|
-
<SafeHtml html={
|
|
485
|
+
<SafeHtml html={safe} components={components} />
|
|
428
486
|
</div>
|
|
429
487
|
);
|
|
430
488
|
}
|
package/src/solid.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { onCleanup, onMount, type JSX } from "solid-js";
|
|
2
|
-
import
|
|
1
|
+
import { createEffect, onCleanup, onMount, type JSX } from "solid-js";
|
|
2
|
+
import { FluxClient } from "./client";
|
|
3
|
+
import type { ParserConfig } from "./types-core";
|
|
3
4
|
import { mountFluxMarkdown, type MountHandle, type MountOptions } from "./dom";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -73,3 +74,72 @@ export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
|
|
|
73
74
|
onMount(() => mountSolid(() => props, container, onCleanup));
|
|
74
75
|
return container;
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wire a controlled string to a freshly-constructed {@link FluxClient}, free of
|
|
80
|
+
* Solid's reactive runtime so it runs (and is tested) under any toolchain. The
|
|
81
|
+
* registrars are injected: the public {@link createFluxMarkdownString} passes
|
|
82
|
+
* Solid's real `createEffect` / `onCleanup`; tests pass hand-rolled stand-ins
|
|
83
|
+
* (mirroring how {@link mountSolid} takes `registerCleanup`).
|
|
84
|
+
*
|
|
85
|
+
* Ownership DIFFERS from {@link mountSolid}: this constructs the client and so
|
|
86
|
+
* `registerCleanup`s `client.destroy()` — it OWNS the worker/stream. `config` is
|
|
87
|
+
* read ONCE here (the constructor treats it as immutable); `getContent()` and
|
|
88
|
+
* `streaming` are read INSIDE the effect so the effect tracks them reactively.
|
|
89
|
+
*/
|
|
90
|
+
export function setupFluxMarkdownString(
|
|
91
|
+
getContent: () => string,
|
|
92
|
+
getOptions: (() => { config?: ParserConfig; streaming?: boolean }) | undefined,
|
|
93
|
+
registerEffect: (fn: () => void) => void,
|
|
94
|
+
registerCleanup: (fn: () => void) => void,
|
|
95
|
+
): FluxClient {
|
|
96
|
+
// One client per helper instance. Constructor is worker-free → SSR-safe; the
|
|
97
|
+
// worker is spawned lazily by the first setContent → append, which only runs
|
|
98
|
+
// inside the effect below. config is read once and is immutable thereafter.
|
|
99
|
+
const client = new FluxClient({ config: getOptions?.()?.config });
|
|
100
|
+
|
|
101
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
102
|
+
// so this is correct whether `content` grows by a token or is swapped wholesale.
|
|
103
|
+
// `streaming === false` (never `!streaming`) → only an explicit false finalizes;
|
|
104
|
+
// an absent/true flag leaves the stream open (inferring "done" from an absent
|
|
105
|
+
// flag would re-finalize on every token — an O(n²) reparse trap).
|
|
106
|
+
registerEffect(() => {
|
|
107
|
+
client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// This helper OWNS the client (unlike the client-based bindings above), so it
|
|
111
|
+
// destroys it on cleanup — freeing its pool slot.
|
|
112
|
+
registerCleanup(() => client.destroy());
|
|
113
|
+
|
|
114
|
+
return client;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the Solid
|
|
119
|
+
* analogue of React's `useFluxMarkdownString`, for UIs that hold a streaming
|
|
120
|
+
* message as a single growing string (a signal/memo) rather than as a stream.
|
|
121
|
+
* Pass an accessor for the whole document-so-far; on every change
|
|
122
|
+
* {@link FluxClient.setContent} diffs it and does the minimal work (a
|
|
123
|
+
* prefix-extension appends only the delta; any divergence resets and reparses).
|
|
124
|
+
*
|
|
125
|
+
* Pass `streaming: false` (via `getOptions`) once the content is final to
|
|
126
|
+
* finalize the stream and commit its last block (only then does a finished code
|
|
127
|
+
* fence highlight + show its copy button). If `streaming` is omitted or `true`
|
|
128
|
+
* the stream is left OPEN. `config` is read once at construction and is
|
|
129
|
+
* immutable, so it is not a change trigger.
|
|
130
|
+
*
|
|
131
|
+
* **Returns the owned client** — pass it to `<FluxMarkdown client={client} />`
|
|
132
|
+
* (and read `outline()` / `getMetrics()` off it). The client is constructed in
|
|
133
|
+
* the body (constructor is worker-free → SSR-safe) and destroyed on cleanup.
|
|
134
|
+
*
|
|
135
|
+
* SSR-safety: `setContent` is what spawns a Worker (via `append`), so it runs
|
|
136
|
+
* ONLY inside a `createEffect` — Solid does not run user effects during
|
|
137
|
+
* `renderToString`, so nothing touches a Worker on the server render path (the
|
|
138
|
+
* body only constructs the worker-free client).
|
|
139
|
+
*/
|
|
140
|
+
export function createFluxMarkdownString(
|
|
141
|
+
getContent: () => string,
|
|
142
|
+
getOptions?: () => { config?: ParserConfig; streaming?: boolean },
|
|
143
|
+
): FluxClient {
|
|
144
|
+
return setupFluxMarkdownString(getContent, getOptions, createEffect, onCleanup);
|
|
145
|
+
}
|