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 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 **Parcel**. Next.js (webpack/turbopack) should work but is
20
- untested file an issue if it doesn't. It is **browser-only** (it constructs
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
+ 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 with markdown inside (default none)
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 like
393
- `<Thinking>…</Thinking>` and you want their inner content parsed as markdown
394
- and dispatched to a React component. Safe without `unsafeHtml` (attributes are
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
- - **Open (streaming) blocks render via `innerHTML`** their HTML is still
506
- partial, so a tag-level override takes effect the moment the block commits.
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 only runs for closed blocks when you
509
- actually supply overrides, and is memoized per `(block id, html)`.
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`, and `html` the **inner** (already-rendered markdown) HTML, so you can
562
- wrap it in your own element:
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: ({ html }) => (
772
+ Thinking: ({ children }) => (
569
773
  <details className="thinking">
570
774
  <summary>Reasoning</summary>
571
- <div dangerouslySetInnerHTML={{ __html: html }} />
775
+ {children}
572
776
  </details>
573
777
  ),
574
778
  }}
575
779
  />
576
780
  ```
577
781
 
578
- With no override, the component renders as `<thinking …>…</thinking>` HTML. The
579
- override's `html` is the inner content only; `attrs` keys are React-form
580
- (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While the
581
- component is still streaming, `html` is the partial inner content and re-renders
582
- as more arrives. Tag names match case-sensitively; the feature is off unless
583
- `componentTags` is set.
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.12.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