flux-md 0.12.0 → 0.14.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 +103 -0
- package/README.md +276 -22
- package/package.json +2 -1
- package/src/client.ts +74 -0
- package/src/html-to-react.ts +23 -8
- package/src/index.ts +1 -1
- package/src/react.tsx +70 -8
- package/src/server.tsx +209 -0
- package/src/solid.tsx +72 -2
- package/src/svelte.ts +100 -1
- package/src/types-core.ts +33 -1
- package/src/vue.ts +58 -1
- package/src/wasm/flux_md_core.d.ts +17 -0
- package/src/wasm/flux_md_core.js +35 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +2 -0
- package/src/wasm/package.json +1 -1
- package/src/worker-core.ts +174 -0
- package/src/worker.ts +40 -136
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.14.0 — 2026-06-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Inline custom component tags (`inlineComponentTags`)** — the headline gap for
|
|
12
|
+
rich apps. An allowlisted inline tag like `<tik symbol="AAPL">AAPL</tik>` (or
|
|
13
|
+
self-closing `<tik/>`) **anywhere inline** — paragraphs, headings, list items,
|
|
14
|
+
and **table cells** — renders as a real custom element with its inner parsed as
|
|
15
|
+
**inline markdown** and its attributes sanitized (event handlers dropped,
|
|
16
|
+
dangerous URL schemes → `#`). The React renderer dispatches it to
|
|
17
|
+
`components[tag]` with the inner markdown as `children` and the attributes as
|
|
18
|
+
props — **XSS-safe without `unsafeHtml`**. Independent of `componentTags`
|
|
19
|
+
(block containers): list a tag under either or both. Use lowercase tag names.
|
|
20
|
+
- **`children` on `Component` block overrides** — a `Component` override now also
|
|
21
|
+
receives the inner content pre-parsed to a React tree (`children`), so you can
|
|
22
|
+
`return <Chip {...attrs}>{children}</Chip>` instead of
|
|
23
|
+
`dangerouslySetInnerHTML`-ing `html`. The html-vs-children contract is now loud
|
|
24
|
+
in the types and docs (an override that renders neither shows empty).
|
|
25
|
+
- **`flux-md/server` — worker-free synchronous SSR / RSC rendering.** The Rust→
|
|
26
|
+
WASM core is a plain synchronous parser, so finished markdown renders on the
|
|
27
|
+
server with no worker: `initFlux()` (async, idempotent — reads the co-located
|
|
28
|
+
`.wasm` in Node, or `initFluxSync(bytes)` on edge), `renderToString(md, {
|
|
29
|
+
config })` (sync HTML string, zero React dep), `parseToBlocks(md, { config })`,
|
|
30
|
+
and `<FluxMarkdownStatic content config components />` — a hookless, RSC-safe
|
|
31
|
+
React component that emits the same `flux-md` tree a client `<FluxMarkdown>`
|
|
32
|
+
hydrates, with the same overrides (inline/block component tags dispatch on the
|
|
33
|
+
server too).
|
|
34
|
+
- **`FluxParser.allBlocks()` (WASM)** — returns the whole parsed document as a
|
|
35
|
+
block array, the one-shot render primitive used by `flux-md/server`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Data-loss: a block component tag used inline swallowed sibling blocks.** With
|
|
40
|
+
e.g. `componentTags: ["tik"]`, an inline occurrence such as
|
|
41
|
+
`<tik>AAPL</tik> is up.` on a line with following content opened a block
|
|
42
|
+
container that consumed the rest of the document (the paragraph and a following
|
|
43
|
+
table vanished). A block component open tag must now be the **whole line** (only
|
|
44
|
+
trailing whitespace after `>`); otherwise it's treated as inline and degrades
|
|
45
|
+
inertly — it never eats surrounding content.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- The React HTML→tree converter (`htmlToReact` / `parseTrustedHtml`) now preserves
|
|
50
|
+
a tag's original **case** for component dispatch (so a capitalized inline tag
|
|
51
|
+
like `<Cite>` maps to `components.Cite`); HTML semantics (void elements, `input`,
|
|
52
|
+
close-tag matching) still compare case-insensitively, so standard output is
|
|
53
|
+
unchanged.
|
|
54
|
+
|
|
55
|
+
Feature-off output is byte-identical (CommonMark 652 + GFM floors hold); both
|
|
56
|
+
allowlists are empty by default.
|
|
57
|
+
|
|
58
|
+
## 0.13.0 — 2026-06-04
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`FluxClient.setContent(content, { done })` + controlled-string helpers for
|
|
63
|
+
every binding** — a first-class bridge for UIs that hold a streaming message as
|
|
64
|
+
a single growing/controlled string prop (rather than a stream). setContent diffs
|
|
65
|
+
against the last value: a **prefix-extension** appends only the delta (committed
|
|
66
|
+
blocks stay put); any **divergence** (e.g. a finished message swapped for a
|
|
67
|
+
re-processed final string) resets and reparses. No hand-rolled diff, no
|
|
68
|
+
readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
|
|
69
|
+
framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
|
|
70
|
+
per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
|
|
71
|
+
(composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
|
|
72
|
+
(action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
|
|
73
|
+
/ `<flux-markdown>` use a caller-owned client + `setContent` directly.
|
|
74
|
+
- **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
|
|
75
|
+
on app load) so the one-time WASM init is off the first-token critical path; the
|
|
76
|
+
warm worker is the one the first stream attaches to, so the work isn't wasted.
|
|
77
|
+
- **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
|
|
78
|
+
block**, not just settled ones — a design-system renderer (Tailwind classes on
|
|
79
|
+
`p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
|
|
80
|
+
of only after a block commits. This also closes a gap where a supplied
|
|
81
|
+
`sanitize` previously bypassed component-rendered blocks; it now runs on every
|
|
82
|
+
block. The no-`components` path is unchanged (byte-identical `innerHTML`).
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- **Worker no longer drops the first chunk(s) under a slow WASM load.** The
|
|
87
|
+
worker buffered appends but did not gate parser creation on WASM readiness, so
|
|
88
|
+
an append that arrived before `init()` resolved would call `new FluxParser()`
|
|
89
|
+
against an uninitialized module — throwing `fluxparser_new of undefined` and
|
|
90
|
+
silently losing that chunk. Appends now accumulate (and `finalize` defers)
|
|
91
|
+
until init completes, then drain in order. Surfaced on a fresh Next.js /
|
|
92
|
+
Turbopack production load, where the worker+WASM fetch is slow enough to lose
|
|
93
|
+
the race; the fix is bundler-agnostic. The worker's message/readiness state
|
|
94
|
+
machine was extracted to `worker-core.ts` (dependency-injected, like
|
|
95
|
+
`FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
|
|
96
|
+
covering the gate — buffer-until-ready, drain order, finalize/reset before
|
|
97
|
+
ready — so the regression can't silently return.
|
|
98
|
+
- **React 19 / Next.js type compatibility.** The shipped source used the global
|
|
99
|
+
`JSX.Element`, which React 19's `@types/react` removed — a consumer's
|
|
100
|
+
`next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
|
|
101
|
+
*"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
|
|
102
|
+
under `@types/react` 18 **and** 19.
|
|
103
|
+
|
|
104
|
+
### Docs
|
|
105
|
+
|
|
106
|
+
- **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
|
|
107
|
+
Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
|
|
108
|
+
and use it from a `"use client"` component. See the README's Next.js callout.
|
|
109
|
+
|
|
7
110
|
## 0.12.0 — 2026-05-30
|
|
8
111
|
|
|
9
112
|
### Added
|
package/README.md
CHANGED
|
@@ -16,9 +16,12 @@ 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
|
-
|
|
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
|
+
The streaming client (`<FluxMarkdown>` / `FluxClient`) is **browser-only** (it
|
|
22
|
+
constructs Web Workers). For **server-side / static rendering of finished
|
|
23
|
+
content** — SSR, React Server Components, build steps — use the worker-free,
|
|
24
|
+
synchronous [`flux-md/server`](#server-side-rendering) entry. The framework packages — `react`,
|
|
22
25
|
`vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
|
|
23
26
|
need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
24
27
|
`flux-md/dom`, `flux-md/element`) needs none.
|
|
@@ -37,6 +40,54 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
|
37
40
|
>
|
|
38
41
|
> No other bundler needs this — it's specific to Vite's optimizer.
|
|
39
42
|
|
|
43
|
+
<a id="nextjs"></a>
|
|
44
|
+
|
|
45
|
+
> **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
|
|
46
|
+
> **Turbopack** (the default for both `next dev` and `next build`). The same two
|
|
47
|
+
> requirements apply under webpack. Because flux-md ships TypeScript source:
|
|
48
|
+
>
|
|
49
|
+
> 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
|
|
50
|
+
> by default — without this, Turbopack errors with *"Unknown module type"* on
|
|
51
|
+
> `react.tsx`. Add flux-md to `transpilePackages`:
|
|
52
|
+
>
|
|
53
|
+
> ```ts
|
|
54
|
+
> // next.config.ts
|
|
55
|
+
> import type { NextConfig } from "next";
|
|
56
|
+
> const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
|
|
57
|
+
> export default nextConfig;
|
|
58
|
+
> ```
|
|
59
|
+
>
|
|
60
|
+
> 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
|
|
61
|
+
> spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
|
|
62
|
+
> a Server Component. (It is still SSR-safe: on the server it renders an empty
|
|
63
|
+
> shell and only starts streaming after hydration, so there's no SSR crash —
|
|
64
|
+
> the constraint is hooks, not the worker.)
|
|
65
|
+
>
|
|
66
|
+
> ```tsx
|
|
67
|
+
> "use client";
|
|
68
|
+
> import { FluxMarkdown } from "flux-md/react";
|
|
69
|
+
>
|
|
70
|
+
> export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
|
|
71
|
+
> return <FluxMarkdown stream={stream} />;
|
|
72
|
+
> }
|
|
73
|
+
> ```
|
|
74
|
+
>
|
|
75
|
+
> **Create the `stream` in Client Component code, not in a Server Component.**
|
|
76
|
+
> A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
|
|
77
|
+
> can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
|
|
78
|
+
> throws *"Only plain objects can be passed to Client Components."* Pass a
|
|
79
|
+
> serializable prop (a URL, the chat messages) from the server and open the
|
|
80
|
+
> stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
|
|
81
|
+
> effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
|
|
82
|
+
>
|
|
83
|
+
> That's it — Turbopack bundles the worker and emits the `.wasm` to
|
|
84
|
+
> `_next/static/media` itself, so no extra asset/loader config is needed (and the
|
|
85
|
+
> Vite `optimizeDeps` workaround above does **not** apply). Both `next dev` and
|
|
86
|
+
> `next build && next start` are verified to spawn the worker, load the WASM, and
|
|
87
|
+
> stream markdown. _Dev tip:_ open the app on `localhost` — Next dev blocks
|
|
88
|
+
> cross-origin dev resources (HMR, chunks) from other hosts (e.g. `127.0.0.1`)
|
|
89
|
+
> unless you add them to `allowedDevOrigins` in `next.config`.
|
|
90
|
+
|
|
40
91
|
## Quick start
|
|
41
92
|
|
|
42
93
|
```ts
|
|
@@ -79,6 +130,38 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
|
|
|
79
130
|
}
|
|
80
131
|
```
|
|
81
132
|
|
|
133
|
+
### Already holding a growing string? — `useFluxMarkdownString`
|
|
134
|
+
|
|
135
|
+
Many apps keep the streaming message as a **single growing string prop** (it
|
|
136
|
+
re-renders with the full text-so-far each token), not as a stream. Feed that
|
|
137
|
+
string straight in — `useFluxMarkdownString` diffs it for you and forwards only
|
|
138
|
+
the delta, so you don't hand-roll an append/reset bridge:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { FluxMarkdown, useFluxMarkdownString } from "flux-md/react";
|
|
142
|
+
|
|
143
|
+
export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
|
|
144
|
+
const client = useFluxMarkdownString(text, { streaming });
|
|
145
|
+
return <FluxMarkdown client={client} />;
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
It handles the two shapes a controlled string takes: a **prefix-extension** (the
|
|
150
|
+
common token-by-token growth) appends only the new suffix; a **divergence** (e.g.
|
|
151
|
+
the finished text swapped for a re-processed final string — bolded numbers,
|
|
152
|
+
wrapped tickers) resets and reparses. Pass `streaming: false` once the content is
|
|
153
|
+
final so the last block commits (a finished code fence then highlights). The
|
|
154
|
+
framework-neutral primitive is **`client.setContent(fullString, { done })`** —
|
|
155
|
+
use it from any binding.
|
|
156
|
+
|
|
157
|
+
> **Transforming streamed content?** If the enrichment runs **live per token**
|
|
158
|
+
> (e.g. bold every number as it arrives), do it at **render time** via
|
|
159
|
+
> [`components`](#custom-components--overrides) — keep the markdown source
|
|
160
|
+
> append-only so parsing stays incremental. Re-transforming the *whole* string
|
|
161
|
+
> each token (so earlier bytes change) forces `setContent` to reparse every tick
|
|
162
|
+
> (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
|
|
163
|
+
> for the **once**-at-the-end reprocess swap, not per-token rewrites.
|
|
164
|
+
|
|
82
165
|
<details>
|
|
83
166
|
<summary>Full manual control (caller-owned client)</summary>
|
|
84
167
|
|
|
@@ -150,6 +233,12 @@ handle.destroy();
|
|
|
150
233
|
client.destroy();
|
|
151
234
|
```
|
|
152
235
|
|
|
236
|
+
**Already holding a growing string?** There's no framework reactivity to wrap,
|
|
237
|
+
so just call **`client.setContent(fullString, { done })`** instead of the
|
|
238
|
+
`append` loop — it diffs internally (prefix → delta; divergence → reparse) and
|
|
239
|
+
finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
|
|
240
|
+
controlled-string helpers wrap; in vanilla you call it directly.
|
|
241
|
+
|
|
153
242
|
`mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
|
|
154
243
|
Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
|
|
155
244
|
(default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
|
|
@@ -208,6 +297,13 @@ defineFluxMarkdown(); // once at bootstrap
|
|
|
208
297
|
export class Answer { url = "/api/post.md"; }
|
|
209
298
|
```
|
|
210
299
|
|
|
300
|
+
**Controlled growing string?** Assign a caller-owned client and drive it with
|
|
301
|
+
`setContent` — `el.client = myClient; myClient.setContent(fullString, { done })`
|
|
302
|
+
— the element subscribes and renders, you own the diffing. (The self-owned
|
|
303
|
+
`markdown` attribute is **one-shot** — it re-parses the whole document on each
|
|
304
|
+
change, so don't point it at a per-token-growing string; use a client +
|
|
305
|
+
`setContent` for that.)
|
|
306
|
+
|
|
211
307
|
### Vue 3 — `flux-md/vue`
|
|
212
308
|
|
|
213
309
|
```vue
|
|
@@ -230,6 +326,20 @@ Props: `client` (required), `components`, `sanitize`, `virtualize`,
|
|
|
230
326
|
`stickToBottom`. There's also a `useFluxMarkdown` composable returning a
|
|
231
327
|
`container` ref if you'd rather mount into your own element.
|
|
232
328
|
|
|
329
|
+
**Already holding a growing string?** `useFluxMarkdownString` owns a client and
|
|
330
|
+
diffs the string for you (the Vue analogue of the React hook — see
|
|
331
|
+
[Controlled strings](#already-holding-a-growing-string--usefluxmarkdownstring)):
|
|
332
|
+
|
|
333
|
+
```vue
|
|
334
|
+
<script setup lang="ts">
|
|
335
|
+
import { FluxMarkdown, useFluxMarkdownString } from "flux-md/vue";
|
|
336
|
+
const props = defineProps<{ text: string; streaming: boolean }>();
|
|
337
|
+
// Pass getters so the composable tracks the live values; it owns + destroys the client.
|
|
338
|
+
const client = useFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
|
|
339
|
+
</script>
|
|
340
|
+
<template><FluxMarkdown :client="client" /></template>
|
|
341
|
+
```
|
|
342
|
+
|
|
233
343
|
### Svelte (4 & 5) — `flux-md/svelte`
|
|
234
344
|
|
|
235
345
|
A Svelte action — works in both v4 and v5, no `.svelte` build step:
|
|
@@ -248,6 +358,20 @@ A Svelte action — works in both v4 and v5, no `.svelte` build step:
|
|
|
248
358
|
<div use:fluxMarkdown={{ client, stickToBottom: true }} />
|
|
249
359
|
```
|
|
250
360
|
|
|
361
|
+
**Growing string?** The `fluxMarkdownString` action owns a client and diffs the
|
|
362
|
+
string — `use:fluxMarkdownString={{ content, streaming }}` (it destroys its
|
|
363
|
+
client on `destroy`, so no manual cleanup):
|
|
364
|
+
|
|
365
|
+
```svelte
|
|
366
|
+
<script lang="ts">
|
|
367
|
+
import { fluxMarkdownString } from "flux-md/svelte";
|
|
368
|
+
export let content: string; // the growing message
|
|
369
|
+
export let streaming: boolean; // false once complete → finalizes
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<div use:fluxMarkdownString={{ content, streaming, stickToBottom: true }} />
|
|
373
|
+
```
|
|
374
|
+
|
|
251
375
|
### Solid — `flux-md/solid`
|
|
252
376
|
|
|
253
377
|
```tsx
|
|
@@ -262,6 +386,19 @@ onCleanup(() => client.destroy());
|
|
|
262
386
|
<FluxMarkdown client={client} stickToBottom />;
|
|
263
387
|
```
|
|
264
388
|
|
|
389
|
+
**Growing string?** `createFluxMarkdownString` owns a client and diffs the string
|
|
390
|
+
(the Solid analogue of the React hook), driving `setContent` from a
|
|
391
|
+
`createEffect` and destroying the client on cleanup:
|
|
392
|
+
|
|
393
|
+
```tsx
|
|
394
|
+
import { FluxMarkdown, createFluxMarkdownString } from "flux-md/solid";
|
|
395
|
+
|
|
396
|
+
function Message(props: { text: string; streaming: boolean }) {
|
|
397
|
+
const client = createFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
|
|
398
|
+
return <FluxMarkdown client={client} />;
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
265
402
|
The Solid binding's mount/teardown logic is tested, but its JSX component shell
|
|
266
403
|
has so far only been exercised through a real Solid (`vite-plugin-solid`) build
|
|
267
404
|
in development, not in CI — treat it as the newest of the bindings and file an
|
|
@@ -269,6 +406,55 @@ issue if your Solid setup trips on it. The component is a thin `ref`'d `<div>`;
|
|
|
269
406
|
if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
|
|
270
407
|
`onMount`/`onCleanup` is the zero-surprise fallback.
|
|
271
408
|
|
|
409
|
+
## Server-side rendering
|
|
410
|
+
|
|
411
|
+
`<FluxMarkdown>` / `FluxClient` are browser-only (they spawn a Web Worker), but
|
|
412
|
+
the Rust→WASM core is a plain **synchronous** parser. So `flux-md/server` renders
|
|
413
|
+
**finished** markdown on the server with no worker and no async ceremony — Node
|
|
414
|
+
SSR, React Server Components, or a build step:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { initFlux, renderToString } from "flux-md/server";
|
|
418
|
+
|
|
419
|
+
await initFlux(); // once at startup (loads the WASM)
|
|
420
|
+
const html = renderToString("# Hello\n\n**world**"); // sync HTML string, no worker
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
For React server rendering (RSC, static generation, or SSR), use
|
|
424
|
+
`<FluxMarkdownStatic>` — a hookless, RSC-safe component that renders finished
|
|
425
|
+
content with the same `components` overrides (inline/block component tags
|
|
426
|
+
dispatch on the server too):
|
|
427
|
+
|
|
428
|
+
```tsx
|
|
429
|
+
import { initFlux, FluxMarkdownStatic } from "flux-md/server";
|
|
430
|
+
|
|
431
|
+
await initFlux();
|
|
432
|
+
export default function Doc({ md }: { md: string }) {
|
|
433
|
+
return (
|
|
434
|
+
<FluxMarkdownStatic
|
|
435
|
+
content={md}
|
|
436
|
+
config={{ inlineComponentTags: ["tik"] }}
|
|
437
|
+
components={{ tik: ({ symbol }) => <span className="ticker">{symbol}</span> }}
|
|
438
|
+
/>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
- **`initFlux()`** — async, idempotent. In Node it reads the package's `.wasm` off
|
|
444
|
+
disk (Node's `fetch` can't load `file://`); on the web it fetches the
|
|
445
|
+
bundler-resolved asset. On edge runtimes pass bytes yourself:
|
|
446
|
+
`initFluxSync(wasmBytes)`.
|
|
447
|
+
- **`renderToString(md, { config })`** — synchronous HTML string, **zero React
|
|
448
|
+
dependency**.
|
|
449
|
+
- **`parseToBlocks(md, { config })`** — the block array, for custom rendering.
|
|
450
|
+
- **`<FluxMarkdownStatic content config components />`** — synchronous React tree
|
|
451
|
+
for **render-once** contexts; render it with your framework's server renderer
|
|
452
|
+
(`renderToStaticMarkup`, RSC, …). For live streaming, client-side code
|
|
453
|
+
highlighting, or Mermaid, render `<FluxMarkdown>` on the client instead — it's a
|
|
454
|
+
separate component. (If you SSR-then-hydrate, use the *same* component on both
|
|
455
|
+
sides; the dedicated client renderers in `<FluxMarkdown>` don't hydrate
|
|
456
|
+
`<FluxMarkdownStatic>`'s plainer markup.)
|
|
457
|
+
|
|
272
458
|
## What it does
|
|
273
459
|
|
|
274
460
|
| Concern | flux-md | conventional main-thread renderer |
|
|
@@ -294,6 +480,11 @@ highlighter's colors** (without any CSS, `highlight()` renders uncolored). The
|
|
|
294
480
|
theme is scoped to `.flux-md`, zero-runtime, and **does not change the rendered
|
|
295
481
|
HTML** — skip the import and nothing is styled.
|
|
296
482
|
|
|
483
|
+
> **Next.js Pages Router:** `flux-md/styles.css` is global CSS, which the Pages
|
|
484
|
+
> Router only allows importing from `pages/_app`. Import it there (App Router and
|
|
485
|
+
> other bundlers can import it from any component). Or skip it and bring your own
|
|
486
|
+
> `.flux-md` styles.
|
|
487
|
+
|
|
297
488
|
Re-theme by overriding a few CSS variables; it's light by default and switches to
|
|
298
489
|
dark automatically via `prefers-color-scheme` (force a mode with
|
|
299
490
|
`class="flux-md flux-dark"` or `flux-light`):
|
|
@@ -324,6 +515,10 @@ class FluxClient {
|
|
|
324
515
|
opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
|
|
325
516
|
): Promise<void>;
|
|
326
517
|
finalize(): void; // mark stream complete
|
|
518
|
+
setContent( // drive from a controlled full string
|
|
519
|
+
full: string, // diffs vs last: prefix → append delta; else reset+reparse
|
|
520
|
+
opts?: { done?: boolean }, // done:true → finalize
|
|
521
|
+
): void;
|
|
327
522
|
reset(): void; // wipe and reuse
|
|
328
523
|
destroy(): void; // free this stream's parser
|
|
329
524
|
whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
|
|
@@ -361,7 +556,8 @@ const client = new FluxClient({
|
|
|
361
556
|
dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
|
|
362
557
|
a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
|
|
363
558
|
unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
|
|
364
|
-
componentTags: ["Thinking", "Callout"], // custom tags
|
|
559
|
+
componentTags: ["Thinking", "Callout"], // BLOCK custom tags w/ markdown inside (default none)
|
|
560
|
+
inlineComponentTags: ["tik", "cite"], // INLINE custom tags (chips/citations) w/ markdown inside (default none)
|
|
365
561
|
blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
|
|
366
562
|
},
|
|
367
563
|
});
|
|
@@ -389,10 +585,13 @@ When to enable each flag:
|
|
|
389
585
|
- `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
|
|
390
586
|
LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
|
|
391
587
|
similar — see [Security](#security)).
|
|
392
|
-
- `componentTags: ["Thinking", …]` — when your LLM emits custom tags
|
|
393
|
-
`<Thinking>…</Thinking>` and you want their inner
|
|
394
|
-
and dispatched to a React component. Safe without
|
|
395
|
-
sanitized; allowlisted tags only).
|
|
588
|
+
- `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
|
|
589
|
+
like `<Thinking>…</Thinking>` (on their own line) and you want their inner
|
|
590
|
+
content parsed as markdown and dispatched to a React component. Safe without
|
|
591
|
+
`unsafeHtml` (attributes are sanitized; allowlisted tags only).
|
|
592
|
+
- `inlineComponentTags: ["tik", …]` — same idea for **inline** custom elements
|
|
593
|
+
that sit inside a paragraph, heading, list item, or **table cell** (ticker
|
|
594
|
+
chips, citations, `@mentions`). See [Inline component tags](#inline-component-tags).
|
|
396
595
|
|
|
397
596
|
**Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
|
|
398
597
|
`[^1]` reference renders speculatively the moment it's seen (committed blocks
|
|
@@ -502,11 +701,15 @@ Rules worth knowing:
|
|
|
502
701
|
channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
|
|
503
702
|
`block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
|
|
504
703
|
`list` fields) directly — no HTML re-parsing.
|
|
505
|
-
- **
|
|
506
|
-
|
|
704
|
+
- **Overrides apply to the OPEN (streaming) block too**, not just settled ones —
|
|
705
|
+
so a design-system renderer (Tailwind classes on `p`/`ul`/`li`, inline
|
|
706
|
+
`<a>`/`<code>` overrides) stays styled mid-stream. The tail's HTML is always
|
|
707
|
+
well-formed (the parser speculatively closes it). If a `sanitize` is supplied
|
|
708
|
+
it runs first, on every block.
|
|
507
709
|
- **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
|
|
508
|
-
output). The HTML→React conversion
|
|
509
|
-
|
|
710
|
+
output). The HTML→React conversion runs only when you actually supply
|
|
711
|
+
overrides, and is memoized per `(block id, html)` so committed blocks don't
|
|
712
|
+
re-parse as the stream grows.
|
|
510
713
|
- For **code blocks** the built-in highlighter is the default; it is bypassed
|
|
511
714
|
(so your override wins) when you pass `components.CodeBlock`, `components.pre`,
|
|
512
715
|
or `components.code`.
|
|
@@ -558,29 +761,70 @@ sanitized (event handlers dropped, dangerous URL schemes → `#`).
|
|
|
558
761
|
|
|
559
762
|
Each renders as a `Component` block. Override it in React by tag name (or with
|
|
560
763
|
the generic `Component` fallback). The override receives `tag`, the sanitized
|
|
561
|
-
`attrs`,
|
|
562
|
-
|
|
764
|
+
`attrs`, the inner content as ready-to-render **`children`** (the easy path), and
|
|
765
|
+
also `html` (the inner already-rendered markdown string, for
|
|
766
|
+
`dangerouslySetInnerHTML`):
|
|
563
767
|
|
|
564
768
|
```tsx
|
|
565
769
|
<FluxMarkdown
|
|
566
770
|
client={client}
|
|
567
771
|
components={{
|
|
568
|
-
Thinking: ({
|
|
772
|
+
Thinking: ({ children }) => (
|
|
569
773
|
<details className="thinking">
|
|
570
774
|
<summary>Reasoning</summary>
|
|
571
|
-
|
|
775
|
+
{children}
|
|
572
776
|
</details>
|
|
573
777
|
),
|
|
574
778
|
}}
|
|
575
779
|
/>
|
|
576
780
|
```
|
|
577
781
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
782
|
+
> **`children` vs `html`.** A `Component` override that renders *neither* shows
|
|
783
|
+
> **empty** (a common first-try gotcha). Prefer **`children`** — a parsed React
|
|
784
|
+
> tree with nested overrides applied; reach for `dangerouslySetInnerHTML={{ __html:
|
|
785
|
+
> html }}` only when you need the raw string. `attrs` keys are React-form
|
|
786
|
+
> (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While
|
|
787
|
+
> streaming, both reflect the partial inner content and re-render as more arrives.
|
|
788
|
+
> With no override the block renders as `<thinking …>…</thinking>`. Tag names
|
|
789
|
+
> match case-sensitively; off unless `componentTags` is set.
|
|
790
|
+
|
|
791
|
+
<a id="inline-component-tags"></a>
|
|
792
|
+
|
|
793
|
+
#### Inline component tags
|
|
794
|
+
|
|
795
|
+
`componentTags` handles **block** containers (a `<Thinking>` on its own line). For
|
|
796
|
+
**inline** custom elements — ticker chips, citations, `@mentions`, inline tooltips
|
|
797
|
+
that sit *inside* a paragraph, heading, list item, or **table cell** — use
|
|
798
|
+
`inlineComponentTags`:
|
|
799
|
+
|
|
800
|
+
```tsx
|
|
801
|
+
const client = new FluxClient({ config: { inlineComponentTags: ["tik"] } });
|
|
802
|
+
|
|
803
|
+
<FluxMarkdown
|
|
804
|
+
client={client}
|
|
805
|
+
components={{
|
|
806
|
+
tik: ({ symbol, children }) => <span className="ticker">{children ?? symbol}</span>,
|
|
807
|
+
}}
|
|
808
|
+
/>;
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
Now `Apple <tik symbol="AAPL">AAPL</tik> rose 2%` (or self-closing
|
|
812
|
+
`<tik symbol="AAPL"/>`) dispatches the inline `<tik>` to `components.tik`: its
|
|
813
|
+
inner is parsed as **inline markdown** (the `children`), its attributes become
|
|
814
|
+
props, and it's **safe without `unsafeHtml`** (attributes sanitized, allowlisted
|
|
815
|
+
tags only). It works everywhere inline content does — **including table cells**.
|
|
816
|
+
Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
|
|
817
|
+
(`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
|
|
818
|
+
two lists are independent: list a tag under `componentTags` for blocks,
|
|
819
|
+
`inlineComponentTags` for inline, or both for both. An allowlisted tag used in an
|
|
820
|
+
unsupported position degrades **inertly** (escaped) — it never consumes
|
|
821
|
+
surrounding content.
|
|
822
|
+
|
|
823
|
+
> **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
|
|
824
|
+
> inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
|
|
825
|
+
> override `a` to render a chip when the href scheme matches. It's XSS-safe and
|
|
826
|
+
> renders inline-in-cells too — `inlineComponentTags` simply replaces that
|
|
827
|
+
> workaround with first-class inline elements.
|
|
584
828
|
|
|
585
829
|
### Types
|
|
586
830
|
|
|
@@ -736,6 +980,16 @@ teardown? Construct your own `new FluxPool(factory, cap)` and pass it to
|
|
|
736
980
|
**per-page singleton** — don't rely on it in SSR/RSC. For isolation between
|
|
737
981
|
independent feature areas, give each its own `new FluxPool()`.
|
|
738
982
|
|
|
983
|
+
**Warm the pool to hide WASM init.** The one-time WASM load happens on the first
|
|
984
|
+
worker-bound op, which lands on the first-token critical path. Call
|
|
985
|
+
`getDefaultPool().warm()` on app load / route entry to start it early — the warm
|
|
986
|
+
worker is the one the first stream attaches to, so the init isn't wasted:
|
|
987
|
+
|
|
988
|
+
```ts
|
|
989
|
+
import { getDefaultPool } from "flux-md";
|
|
990
|
+
useEffect(() => { getDefaultPool().warm(); }, []); // (or your framework's mount hook)
|
|
991
|
+
```
|
|
992
|
+
|
|
739
993
|
### Long documents — `virtualize`
|
|
740
994
|
|
|
741
995
|
For very long documents (hundreds+ of blocks), pass `virtualize` to apply CSS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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
|
"sideEffects": ["./src/worker.ts", "./src/styles.css"],
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
".": "./src/index.ts",
|
|
11
11
|
"./client": "./src/client.ts",
|
|
12
12
|
"./react": "./src/react.tsx",
|
|
13
|
+
"./server": "./src/server.tsx",
|
|
13
14
|
"./dom": "./src/dom.ts",
|
|
14
15
|
"./element": "./src/element.ts",
|
|
15
16
|
"./vue": "./src/vue.ts",
|
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
|