flux-md 0.8.0 → 0.10.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,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.10.0 — 2026-05-30
8
+
9
+ Server-side rendering safety, plus an opt-in structured-data channel so consumers
10
+ build toolbars / tables of contents / charts from **data** instead of re-parsing
11
+ rendered HTML (no hast tree, no rehype).
12
+
13
+ ### Added
14
+
15
+ - **SSR-safe.** `new FluxClient()` and `renderToString(<FluxMarkdown …/>)` no
16
+ longer touch a Web Worker during construction or server render — worker
17
+ creation is deferred to the first `append`/`pipeFrom` (client-side) — so the
18
+ library imports and server-renders cleanly across React / Vue / Solid / Svelte.
19
+ A fresh-process SSR cold-import check guards it in CI.
20
+ - **Structured block data — `blockData: true`** (per-stream config; opt-in,
21
+ default off — output and CommonMark/GFM conformance are **byte-identical** when
22
+ off). When on, `block.kind.data` carries typed structured data per kind, also
23
+ surfaced as typed `BlockComponentProps` fields, and it **streams** in lock-step
24
+ with the HTML:
25
+ - **Table** → `{ headers, rows, aligns }`, cells `{ text, html }` (`props.table`)
26
+ — sort / filter / transpose / CSV / chart.
27
+ - **Heading** → `{ level, text, id }` (`props.heading`) — TOC with anchors.
28
+ - **CodeBlock** → `{ lang, code }` (`props.code`) — decoded source.
29
+ - **MathBlock** → `{ latex }` (`props.math`) — LaTeX source.
30
+ - **List** → `{ ordered, start }` (`props.list`).
31
+
32
+ ### Fixed
33
+
34
+ - Packaging: the published tarball ships the WASM deterministically on every npm
35
+ version (build removes wasm-pack's nested `.gitignore`), with a tarball tripwire
36
+ in CI and the publish workflow.
37
+
38
+ ## 0.9.0 — 2026-05-29
39
+
40
+ Kills the React streaming boilerplate. The common case — render an LLM stream —
41
+ goes from ~17 lines of hand-rolled lifecycle to one:
42
+
43
+ ```tsx
44
+ <FluxMarkdown stream={stream} />
45
+ ```
46
+
47
+ ### Added
48
+
49
+ - **`stream` prop on React `<FluxMarkdown>`** — pass an `AsyncIterable<string>`
50
+ (SSE deltas), a `Response`, or a `ReadableStream<Uint8Array>` and the
51
+ component owns an internal client, pipes the stream, supersedes it on change,
52
+ and destroys it on unmount. The `client` prop is unchanged (now optional);
53
+ passing a `client` keeps the existing caller-owned behavior.
54
+ - **`useFluxStream(stream, options?)` hook (React)** — same lifecycle, returns
55
+ the owned `FluxClient` (so you can read `outline()` / `getMetrics()` or pass it
56
+ to `<FluxMarkdown client={…} />`).
57
+ - **`pipeFrom` now also accepts an `AsyncIterable<string>`** and an optional
58
+ `{ signal }` — the signal is checked every iteration, so an aborted stream
59
+ appends no further chunks and is **not** finalized (and a byte reader is
60
+ `cancel()`'d). Existing `pipeFrom(Response | ReadableStream)` calls are
61
+ unchanged.
62
+
63
+ ### Notes
64
+
65
+ - A stream is single-use, so React StrictMode's dev-only double-mount may
66
+ truncate it in development; production mounts once and is unaffected (the
67
+ prior manual `useEffect` form had the same caveat).
68
+ - Rules of Hooks are respected — `<FluxMarkdown>` dispatches to one of two
69
+ sibling components, never a conditional hook.
70
+
7
71
  ## 0.8.0 — 2026-05-29
8
72
 
9
73
  A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
package/README.md CHANGED
@@ -52,33 +52,61 @@ for await (const delta of streamFromAi()) {
52
52
  client.finalize();
53
53
  ```
54
54
 
55
- In React:
55
+ In React — pass the stream straight to `<FluxMarkdown>`. It owns the client,
56
+ pipes the stream, supersedes it if it changes, and cleans up on unmount:
57
+
58
+ ```tsx
59
+ import { FluxMarkdown } from "flux-md/react";
60
+
61
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
62
+ return <FluxMarkdown stream={stream} />;
63
+ }
64
+ ```
65
+
66
+ `stream` accepts an `AsyncIterable<string>` (e.g. SSE deltas), a `Response`, or
67
+ a `ReadableStream<Uint8Array>` — so `<FluxMarkdown stream={await fetch("/api/chat")} />`
68
+ works too.
69
+
70
+ Need the client handle (for `outline()` / `getMetrics()` / a shared client)? Use
71
+ the `useFluxStream` hook — same lifecycle, returns the owned client:
72
+
73
+ ```tsx
74
+ import { FluxMarkdown, useFluxStream } from "flux-md/react";
75
+
76
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
77
+ const client = useFluxStream(stream);
78
+ return <FluxMarkdown client={client} />;
79
+ }
80
+ ```
81
+
82
+ <details>
83
+ <summary>Full manual control (caller-owned client)</summary>
84
+
85
+ When you want to drive the stream yourself, pass a `client` you own — the
86
+ component never destroys it:
56
87
 
57
88
  ```tsx
58
89
  import { useEffect, useState } from "react";
59
90
  import { FluxClient, FluxMarkdown } from "flux-md";
60
91
 
61
92
  export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
62
- // One client per component instance. Destroy on unmount, not on stream change.
63
93
  const [client] = useState(() => new FluxClient());
64
94
  useEffect(() => () => client.destroy(), [client]);
65
-
66
95
  useEffect(() => {
67
- let cancelled = false;
68
- (async () => {
69
- for await (const chunk of stream) {
70
- if (cancelled) return; // stream changed / unmounted mid-flight
71
- client.append(chunk);
72
- }
73
- if (!cancelled) client.finalize();
74
- })();
75
- return () => { cancelled = true; };
96
+ const ac = new AbortController();
97
+ client.pipeFrom(stream, { signal: ac.signal }); // pipeFrom also accepts AsyncIterable
98
+ return () => ac.abort();
76
99
  }, [client, stream]);
77
-
78
100
  return <FluxMarkdown client={client} />;
79
101
  }
80
102
  ```
81
103
 
104
+ </details>
105
+
106
+ > **StrictMode note:** a stream (SSE generator / `Response`) can be consumed only
107
+ > once, so React StrictMode's dev-only double-mount may truncate it in
108
+ > development. Production mounts once and is unaffected.
109
+
82
110
  Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
83
111
 
84
112
  ## Framework bindings
@@ -265,7 +293,10 @@ class FluxClient {
265
293
  onBlock?: (block: Block) => void; // fires once per block as it commits
266
294
  });
267
295
  append(chunk: string): void; // queue text for parsing
268
- pipeFrom(src: ReadableStream<Uint8Array> | Response): Promise<void>; // read → append → finalize
296
+ pipeFrom( // read → append → finalize
297
+ src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
298
+ opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
299
+ ): Promise<void>;
269
300
  finalize(): void; // mark stream complete
270
301
  reset(): void; // wipe and reuse
271
302
  destroy(): void; // free this stream's parser
@@ -305,6 +336,7 @@ const client = new FluxClient({
305
336
  a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
306
337
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
307
338
  componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
339
+ blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
308
340
  },
309
341
  });
310
342
  ```
@@ -432,8 +464,11 @@ type is at `block.kind.data.kind`).
432
464
 
433
465
  Rules worth knowing:
434
466
 
435
- - **There is no `node` prop.** flux-md has no hast tree; introspect via
436
- `className` / `data-*` instead.
467
+ - **There is no `node` prop / no hast tree.** Introspect via `className` /
468
+ `data-*`, or — better — opt into the typed **[structured-data
469
+ channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
470
+ `block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
471
+ `list` fields) directly — no HTML re-parsing.
437
472
  - **Open (streaming) blocks render via `innerHTML`** — their HTML is still
438
473
  partial, so a tag-level override takes effect the moment the block commits.
439
474
  - **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
@@ -443,6 +478,35 @@ Rules worth knowing:
443
478
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
444
479
  or `components.code`.
445
480
 
481
+ ### Structured block data (`setBlockData`)
482
+
483
+ Set `blockData: true` in the per-stream config and each block carries typed
484
+ structured data on `block.kind.data`, also surfaced as typed fields on the
485
+ component props — so you build toolbars, tables of contents, charts, copy
486
+ buttons, etc. from **data**, never by re-parsing the rendered HTML (no hast tree,
487
+ no rehype). Off by default; when off, output and CommonMark/GFM conformance are
488
+ byte-identical, so non-users pay nothing.
489
+
490
+ | Kind | `block.kind.data` | prop | use |
491
+ |------|-------------------|------|-----|
492
+ | `Table` | `{ headers, rows, aligns }`, cells `{ text, html }` | `props.table` | sort / filter / transpose / CSV / chart |
493
+ | `Heading` | `{ level, text, id }` | `props.heading` | table of contents with anchors |
494
+ | `CodeBlock` | `{ lang, code }` | `props.code` | decoded source (copy / run) |
495
+ | `MathBlock` | `{ latex }` | `props.math` | LaTeX source (re-render) |
496
+ | `List` | `{ ordered, start }` | `props.list` | ordered-list numbering |
497
+
498
+ Each cell's `text` is inline-stripped plaintext (for sort/filter/CSV/logic);
499
+ `html` is the inline-rendered display HTML. The data **streams** with the
500
+ document — a growing table or a heading carries its structured data on every
501
+ patch, in lock-step with the HTML — something a batch HTML-AST cannot do.
502
+
503
+ ```tsx
504
+ // Table of contents from heading data — no DOM, works mid-stream:
505
+ const toc = client.getSnapshot()
506
+ .filter((b) => b.kind.type === "Heading" && b.kind.data)
507
+ .map((b) => b.kind.data as { level: number; text: string; id: string });
508
+ ```
509
+
446
510
  ### Component tags
447
511
 
448
512
  LLMs increasingly emit custom component tags like `<Thinking>…</Thinking>`. By
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.8.0",
3
+ "version": "0.10.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"],
@@ -48,6 +48,7 @@
48
48
  },
49
49
  "scripts": {
50
50
  "test": "bun test",
51
+ "test:ssr-cold": "bun test/ssr-cold.mjs",
51
52
  "prepublishOnly": "cd ../.. && bun run build:wasm"
52
53
  },
53
54
  "keywords": ["markdown", "streaming", "wasm", "rust", "react", "vue", "svelte", "solid", "web-component", "custom-element", "incremental", "llm", "ai", "math", "katex", "latex", "bidi", "rtl"],
@@ -1,4 +1,12 @@
1
- import type { Block, BlockComponentProps } from "./types-core";
1
+ import type {
2
+ Block,
3
+ BlockComponentProps,
4
+ CodeBlockData,
5
+ HeadingData,
6
+ ListData,
7
+ MathBlockData,
8
+ TableData,
9
+ } from "./types-core";
2
10
 
3
11
  // Pure helpers duplicated from the JSX renderer / its CodeBlock so the
4
12
  // framework-neutral DOM renderer carries no framework dependency. The JSX
@@ -78,19 +86,46 @@ export function blockProps(block: Block): BlockComponentProps {
78
86
  speculative: block.speculative,
79
87
  };
80
88
  const data = block.kind.data as
81
- | { lang?: string | null; tag?: string; attrs?: [string, string][] }
89
+ | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
82
90
  | undefined;
83
91
  if (block.kind.type === "CodeBlock") {
84
- props.text = decodeCodeText(block.html);
92
+ // Prefer the structured `code` (present when blockData is on) over the HTML
93
+ // regex — it is the lossless decoded source. Fall back to the regex when off.
94
+ props.text = data?.code ?? decodeCodeText(block.html);
85
95
  props.language = data?.lang ?? "";
96
+ // Surface the typed convenience field only when the opt-in `code` is present.
97
+ if (typeof data?.code === "string") {
98
+ props.code = { lang: data.lang ?? null, code: data.code } as CodeBlockData;
99
+ }
86
100
  } else if (block.kind.type === "MathBlock") {
87
- props.text = decodeMathText(block.html);
101
+ // Prefer the structured `latex` (present when blockData is on) over the regex.
102
+ props.text = data?.latex ?? decodeMathText(block.html);
103
+ if (typeof data?.latex === "string") {
104
+ props.math = { latex: data.latex } as MathBlockData;
105
+ }
106
+ } else if (block.kind.type === "List") {
107
+ // Structured list data is present only when blockData is on (the `start` key
108
+ // rides the opt-in channel); surface it as the typed convenience field.
109
+ if (data && typeof data.start === "number") {
110
+ props.list = { ordered: !!data.ordered, start: data.start } as ListData;
111
+ }
88
112
  } else if (block.kind.type === "Component") {
89
113
  props.tag = data?.tag ?? "";
90
114
  props.attrs = htmlAttrs(data?.attrs ?? []);
91
115
  // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
92
116
  // (markdown already rendered) rather than the full wrapped block.
93
117
  props.html = componentInnerHtml(block.html, props.tag);
118
+ } else if (block.kind.type === "Table") {
119
+ // Structured data is present only when `blockData` is on (else `undefined`).
120
+ // Pure data — identical for the DOM and JSX renderers, no name-form divergence.
121
+ props.table = block.kind.data as TableData | undefined;
122
+ } else if (block.kind.type === "Heading") {
123
+ // When `blockData` is on, `kind.data` is the `{ level, text, id }` object;
124
+ // when off it is the bare level `number`. Surface the rich object only — the
125
+ // `typeof === "object"` guard keeps the off-path naked int out of `heading`.
126
+ if (typeof block.kind.data === "object" && block.kind.data !== null) {
127
+ props.heading = block.kind.data as HeadingData;
128
+ }
94
129
  }
95
130
  return props;
96
131
  }
package/src/client.ts CHANGED
@@ -128,6 +128,18 @@ export class FluxPool {
128
128
  }
129
129
  }
130
130
 
131
+ /** Inverse of {@link release}: re-register a stream's handler so it receives
132
+ * patches again. For React StrictMode's dev double-mount, which destroys a
133
+ * client on the simulated unmount and remounts the SAME instance. The worker
134
+ * lazily recreates the disposed parser on the next append. */
135
+ reattach(streamId: number, pw: PoolWorker, handler: (msg: FromWorker) => void): void {
136
+ if (!this.handlers.has(streamId)) {
137
+ pw.streamCount++;
138
+ pw.streamIds.add(streamId);
139
+ }
140
+ this.handlers.set(streamId, handler);
141
+ }
142
+
131
143
  send(pw: PoolWorker, msg: ToWorker): void {
132
144
  pw.worker.postMessage(msg);
133
145
  }
@@ -265,14 +277,15 @@ export function getDefaultPool(): FluxPool {
265
277
  */
266
278
  export class FluxClient {
267
279
  private pool: FluxPool;
268
- private pw: PoolWorker;
269
- private streamId: number;
280
+ private pw: PoolWorker | null = null;
281
+ private streamId = 0;
270
282
  private config?: ParserConfig;
271
283
  private configSent = false;
272
284
  private listeners = new Set<() => void>();
273
285
  private store: BlockStore = emptyBlockStore();
274
286
  private onError?: (err: { message: string; fatal?: boolean }) => void;
275
287
  private onBlock?: (block: Block) => void;
288
+ private attached = true;
276
289
 
277
290
  // Perf
278
291
  private appendedBytes = 0;
@@ -308,17 +321,38 @@ export class FluxClient {
308
321
  this.config = options.config;
309
322
  this.onError = options.onError;
310
323
  this.onBlock = options.onBlock;
324
+ }
325
+
326
+ /**
327
+ * Lazily reserve this client's stream id and bind it to a pool worker. The
328
+ * SOLE place that calls pool.acquire() — so the worker is created on the FIRST
329
+ * worker-bound operation (append/finalize/reset/pipeFrom/whenReady), never at
330
+ * construct time. This is what makes `new FluxClient()` SSR-safe: nothing here
331
+ * runs during an SSR render (which only subscribes + reads the snapshot).
332
+ *
333
+ * Idempotent: once this.pw is set it returns it immediately and never
334
+ * re-acquires — this.pw is never nulled (destroy() deliberately keeps it so
335
+ * StrictMode's destroy()→reattach() on the SAME instance re-registers the same
336
+ * slot). Note: streamId/worker assignment now follows first-worker-bound-op
337
+ * order, not construction order — a client constructed first no longer
338
+ * necessarily owns the lowest streamId. This affects neither the pool cap nor
339
+ * multiplexing (pick() is unchanged and remains the only path to create()).
340
+ */
341
+ private ensureAcquired(): PoolWorker {
342
+ if (this.pw) return this.pw;
311
343
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
312
344
  this.streamId = streamId;
313
345
  this.pw = pw;
346
+ return pw;
314
347
  }
315
348
 
316
349
  get ready(): boolean {
317
- return this.pw.ready;
350
+ return this.pw?.ready ?? false;
318
351
  }
319
352
 
320
353
  whenReady(): Promise<void> {
321
- return this.pool.whenWorkerReady(this.pw);
354
+ const pw = this.ensureAcquired();
355
+ return this.pool.whenWorkerReady(pw);
322
356
  }
323
357
 
324
358
  // The config rides on the first message a stream sends; the worker applies it
@@ -331,24 +365,53 @@ export class FluxClient {
331
365
  }
332
366
 
333
367
  append(chunk: string) {
368
+ const pw = this.ensureAcquired();
334
369
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
335
- this.pool.send(this.pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
370
+ this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
336
371
  }
337
372
 
338
373
  finalize() {
339
- this.pool.send(this.pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
374
+ const pw = this.ensureAcquired();
375
+ this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
340
376
  }
341
377
 
342
378
  /**
343
- * Pipe a byte stream straight in: read it to completion, `append()` each
344
- * decoded chunk, then `finalize()`. The LLM-native path — `await
345
- * client.pipeFrom(await fetch("/api/chat"))` (pass a `Response` or its
346
- * `ReadableStream` body). `TextDecoder({ stream: true })` carries a multibyte
347
- * sequence that straddles a chunk boundary into the next read. Resolves once
348
- * finalized; rejects (without finalizing) if the stream errors/aborts — abort
349
- * the underlying fetch to cancel. Browser-only (uses `TextDecoder`).
379
+ * Pipe a source straight in: read it to completion, `append()` each chunk,
380
+ * then `finalize()`. The LLM-native path — e.g.
381
+ * `await client.pipeFrom(await fetch("/api/chat"))`. Accepts:
382
+ * - a `Response` or its `ReadableStream<Uint8Array>` body (bytes; decoded
383
+ * with `TextDecoder({ stream: true })` so a multibyte sequence straddling
384
+ * a chunk boundary carries into the next read), or
385
+ * - an `AsyncIterable<string>` (e.g. an SSE delta generator) — string chunks
386
+ * appended verbatim.
387
+ *
388
+ * Pass `opts.signal` to supersede/cancel: the signal is checked on every
389
+ * iteration, so once aborted no further chunk is appended and **finalize is
390
+ * skipped** (a superseded stream must not finalize). For a byte source the
391
+ * reader is also `cancel()`'d to tear down the upstream. Resolves once
392
+ * finalized (or cleanly on abort); rejects if the source itself errors.
393
+ * Browser-only for byte sources (uses `TextDecoder`).
350
394
  */
351
- async pipeFrom(source: ReadableStream<Uint8Array> | Response): Promise<void> {
395
+ async pipeFrom(
396
+ source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
397
+ opts?: { signal?: AbortSignal },
398
+ ): Promise<void> {
399
+ const signal = opts?.signal;
400
+
401
+ if (signal?.aborted) return; // already superseded before we started
402
+
403
+ // AsyncIterable<string> (SSE deltas, generators). Detected by elimination:
404
+ // a ReadableStream has `getReader`, a Response has `body` — neither here.
405
+ if (!("getReader" in source) && !("body" in source)) {
406
+ for await (const chunk of source as AsyncIterable<string>) {
407
+ if (signal?.aborted) return; // superseded/unmounted: drop late chunks, no finalize
408
+ this.append(chunk);
409
+ }
410
+ if (!signal?.aborted) this.finalize();
411
+ return;
412
+ }
413
+
414
+ // Byte source: a Response (use its body) or a ReadableStream directly.
352
415
  const body = "body" in source ? source.body : source;
353
416
  if (!body) {
354
417
  // An empty Response body (e.g. 204) is a completed, empty stream.
@@ -356,17 +419,30 @@ export class FluxClient {
356
419
  return;
357
420
  }
358
421
  const reader = body.getReader();
422
+ // A pending read() can't observe `aborted` until the next chunk; cancel()
423
+ // on abort tears down the upstream and resolves the pending read so the
424
+ // loop's post-read check fires and bails without finalizing.
425
+ const onAbort = () => {
426
+ reader.cancel().catch(() => {});
427
+ };
428
+ signal?.addEventListener("abort", onAbort, { once: true });
359
429
  const decoder = new TextDecoder();
360
430
  try {
361
431
  for (;;) {
362
432
  const { done, value } = await reader.read();
433
+ if (signal?.aborted) return; // superseded: no finalize (cancel already fired)
363
434
  if (done) break;
364
435
  if (value) this.append(decoder.decode(value, { stream: true }));
365
436
  }
366
437
  this.append(decoder.decode()); // flush any trailing partial sequence
367
438
  this.finalize();
368
439
  } finally {
369
- reader.releaseLock();
440
+ signal?.removeEventListener("abort", onAbort);
441
+ try {
442
+ reader.releaseLock();
443
+ } catch {
444
+ /* already released (e.g. by cancel) */
445
+ }
370
446
  }
371
447
  }
372
448
 
@@ -380,14 +456,45 @@ export class FluxClient {
380
456
  this.retainedBytes = 0;
381
457
  this.wasmMemoryBytes = 0;
382
458
  // Same streamId + worker — the worker frees and lazily recreates the parser.
383
- this.pool.send(this.pw, { type: "reset", streamId: this.streamId });
459
+ const pw = this.ensureAcquired();
460
+ this.pool.send(pw, { type: "reset", streamId: this.streamId });
384
461
  this.emit();
385
462
  }
386
463
 
387
464
  destroy() {
465
+ if (!this.attached) return; // idempotent
388
466
  // Free this stream's parser; the shared worker stays warm for siblings.
389
- this.pool.release(this.streamId, this.pw);
467
+ // Only release a real slot — a never-acquired client (constructed during an
468
+ // SSR render then unmounted) has no pool slot to free, so skip the call.
469
+ // We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
470
+ // on the SAME instance needs the same pw/streamId to re-register.
471
+ if (this.pw) this.pool.release(this.streamId, this.pw);
390
472
  this.listeners.clear();
473
+ this.attached = false;
474
+ }
475
+
476
+ /**
477
+ * Re-register with the pool after {@link destroy} so the client receives
478
+ * patches again. Needed only for React StrictMode's dev double-mount, where
479
+ * the renderer destroys on the simulated unmount then remounts the SAME
480
+ * client instance; apps don't normally call this. No-op if still attached.
481
+ */
482
+ reattach() {
483
+ if (this.attached) return;
484
+ if (!this.pw) {
485
+ // Never acquired (e.g. constructed during SSR, first real mount on client).
486
+ // No prior pool slot to re-register; just mark attached. The next
487
+ // worker-bound op acquires lazily. configSent is already false, so the
488
+ // first append will carry config exactly as a brand-new client would.
489
+ this.attached = true;
490
+ return;
491
+ }
492
+ this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
493
+ this.attached = true;
494
+ // The worker discarded this stream's config on `dispose` (unlike `reset`,
495
+ // which keeps it), so re-send it on the next message — otherwise the parser
496
+ // would be rebuilt with library defaults (gfmMath / componentTags / … lost).
497
+ this.configSent = false;
391
498
  }
392
499
 
393
500
  subscribe = (fn: () => void) => {
@@ -425,7 +532,13 @@ export class FluxClient {
425
532
  const out: OutlineEntry[] = [];
426
533
  for (const b of this.store.snapshot) {
427
534
  if (b.kind.type === "Heading") {
428
- out.push({ level: (b.kind.data as number) ?? 1, text: htmlToText(b.html), id: b.id });
535
+ // `kind.data` is the bare level `number` when `blockData` is off, or the
536
+ // `{ level, text, id }` object when on — accept both. `OutlineEntry.id`
537
+ // stays the numeric block id (stable, non-breaking); the anchor slug is
538
+ // reachable additively via `kind.data.id` for consumers who want it.
539
+ const d = b.kind.data as number | { level?: number } | undefined;
540
+ const level = typeof d === "number" ? d : d?.level ?? 1;
541
+ out.push({ level, text: htmlToText(b.html), id: b.id });
429
542
  }
430
543
  }
431
544
  return out;
package/src/index.ts CHANGED
@@ -30,4 +30,11 @@ export type {
30
30
  ToWorker,
31
31
  WorkerLike,
32
32
  ParserConfig,
33
+ Align,
34
+ TableCell,
35
+ TableData,
36
+ HeadingData,
37
+ CodeBlockData,
38
+ MathBlockData,
39
+ ListData,
33
40
  } from "./types";
package/src/react.tsx CHANGED
@@ -1,6 +1,16 @@
1
- import { createElement, memo, useMemo, useSyncExternalStore, type CSSProperties } from "react";
2
- import type { Block, BlockComponentProps, Components } from "./types";
3
- import type { FluxClient } from "./client";
1
+ import {
2
+ createElement,
3
+ memo,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ type CSSProperties,
10
+ } from "react";
11
+ import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
12
+ import { FluxClient } from "./client";
13
+ import type { ParserConfig } from "./types-core";
4
14
  import { CodeBlock } from "./renderers/CodeBlock";
5
15
  import { MathBlock } from "./renderers/Math";
6
16
  import { Mermaid } from "./renderers/Mermaid";
@@ -48,7 +58,23 @@ import { htmlToReact } from "./html-to-react";
48
58
  */
49
59
 
50
60
  interface FluxMarkdownProps {
51
- client: FluxClient;
61
+ /**
62
+ * A caller-owned client (you drive `append`/`finalize` and own its lifecycle —
63
+ * the component never destroys it). Mutually exclusive with `stream`; if both
64
+ * are given, `client` wins (a dev warning fires).
65
+ */
66
+ client?: FluxClient;
67
+ /**
68
+ * A stream to render directly — the 1-line common case. Pass a `Response`, a
69
+ * `ReadableStream<Uint8Array>`, or an `AsyncIterable<string>` (e.g. SSE
70
+ * deltas) and the component owns an internal client, pipes the stream, and
71
+ * destroys it on unmount. A new `stream` identity supersedes the old.
72
+ */
73
+ stream?: AsyncIterable<string> | ReadableStream<Uint8Array> | Response;
74
+ /** Parser config for the internally-created client (stream mode only). */
75
+ streamConfig?: ParserConfig;
76
+ /** Called if piping the `stream` rejects (the source errored). Not the worker error channel. */
77
+ onStreamError?: (err: Error) => void;
52
78
  components?: Components;
53
79
  /**
54
80
  * Skip layout/paint for off-screen blocks via CSS `content-visibility: auto`
@@ -77,7 +103,15 @@ interface FluxMarkdownProps {
77
103
  sanitize?: (html: string) => string;
78
104
  }
79
105
 
80
- function FluxMarkdownImpl({ client, components, virtualize, stickToBottom, sanitize }: FluxMarkdownProps) {
106
+ // The original render path: subscribe to a (required, caller- or hook-owned)
107
+ // client and render its blocks. NEVER creates or destroys a client.
108
+ function FluxMarkdownFromClient({
109
+ client,
110
+ components,
111
+ virtualize,
112
+ stickToBottom,
113
+ sanitize,
114
+ }: FluxMarkdownProps & { client: FluxClient }) {
81
115
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
82
116
  // Normalize "no overrides" to a stable `undefined` so memo comparisons and
83
117
  // the fast path don't churn on an empty object identity.
@@ -92,6 +126,88 @@ function FluxMarkdownImpl({ client, components, virtualize, stickToBottom, sanit
92
126
  );
93
127
  }
94
128
 
129
+ /**
130
+ * Own a {@link FluxClient} for the lifetime of a component and drive it from a
131
+ * `stream` (a `Response`, `ReadableStream<Uint8Array>`, or
132
+ * `AsyncIterable<string>`). Returns the client (read `outline()` / `getMetrics()`
133
+ * off it, or pass it to `<FluxMarkdown client={…} />`). The client is created
134
+ * once and destroyed on unmount; a new `stream` identity supersedes the old
135
+ * (the prior pipe is aborted, the parser is reset, the new stream is piped).
136
+ *
137
+ * Caveat (matches the manual `useEffect` form): a single-use stream — a
138
+ * `Response`/`ReadableStream`, or an async generator — can only be consumed
139
+ * once, so React **StrictMode**'s dev-only double-mount may truncate it in
140
+ * development. Production mounts once and is unaffected. If you need dev-exact
141
+ * streaming, drive a caller-owned client manually.
142
+ */
143
+ export function useFluxStream(
144
+ stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined,
145
+ options?: { config?: ParserConfig; onError?: (err: Error) => void },
146
+ ): FluxClient {
147
+ // One client per hook instance. (React StrictMode double-invokes this
148
+ // initializer in DEV, constructing a throwaway second client whose worker
149
+ // slot isn't reclaimed — a minor dev-only artifact; production runs it once.
150
+ // The committed client is what's used, and its lifecycle below is correct.)
151
+ const [client] = useState(() => new FluxClient({ config: options?.config }));
152
+ // Read onError through a ref so its identity never re-subscribes the stream.
153
+ const onErrorRef = useRef(options?.onError);
154
+ onErrorRef.current = options?.onError;
155
+ // Track the last stream so we reset() only on a genuine source change — never
156
+ // on a StrictMode replay of the same stream (which would discard its head).
157
+ const prevStream = useRef<typeof stream>(undefined);
158
+
159
+ // Own the client's pool attachment. On (re)mount, reattach (StrictMode's
160
+ // dev double-mount destroys on the simulated unmount, then remounts the SAME
161
+ // instance — without reattach its patches would be dropped and it'd render
162
+ // blank); destroy on real unmount.
163
+ useEffect(() => {
164
+ client.reattach();
165
+ return () => client.destroy();
166
+ }, [client]);
167
+
168
+ // Consume the current stream; supersede (abort, no finalize) on change/unmount.
169
+ useEffect(() => {
170
+ if (stream == null) return;
171
+ const ac = new AbortController();
172
+ if (prevStream.current !== undefined && prevStream.current !== stream) {
173
+ client.reset(); // a different stream replaced a prior one
174
+ }
175
+ prevStream.current = stream;
176
+ client.pipeFrom(stream, { signal: ac.signal }).catch((e) => {
177
+ if (!ac.signal.aborted) {
178
+ onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
179
+ }
180
+ });
181
+ return () => ac.abort();
182
+ }, [stream, client]);
183
+
184
+ return client;
185
+ }
186
+
187
+ // Stream mode: own a client via the hook, then render the normal client path.
188
+ function FluxMarkdownFromStream(props: FluxMarkdownProps) {
189
+ const client = useFluxStream(props.stream, {
190
+ config: props.streamConfig,
191
+ onError: props.onStreamError,
192
+ });
193
+ return <FluxMarkdownFromClient {...props} client={client} />;
194
+ }
195
+
196
+ // Dispatch by rendering one of two SIBLING components (never a hook in a branch,
197
+ // which would violate the Rules of Hooks): `stream` mode owns a client, `client`
198
+ // mode uses the caller's. `memo` skips re-render when props are unchanged. If
199
+ // both are given `client` wins (it owns the lifecycle); passing neither is a
200
+ // usage error and throws (rather than crashing cryptically downstream).
201
+ function FluxMarkdownImpl(props: FluxMarkdownProps) {
202
+ if (props.stream != null && props.client == null) {
203
+ return <FluxMarkdownFromStream {...props} />;
204
+ }
205
+ if (props.client == null) {
206
+ throw new Error("<FluxMarkdown>: pass either a `client` or a `stream` prop.");
207
+ }
208
+ return <FluxMarkdownFromClient {...(props as FluxMarkdownProps & { client: FluxClient })} />;
209
+ }
210
+
95
211
  export const FluxMarkdown = memo(FluxMarkdownImpl);
96
212
 
97
213
  function decodeEntities(s: string): string {
@@ -128,13 +244,25 @@ function blockKindProps(block: Block): BlockComponentProps {
128
244
  speculative: block.speculative,
129
245
  };
130
246
  const data = block.kind.data as
131
- | { lang?: string | null; tag?: string; attrs?: [string, string][] }
247
+ | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
132
248
  | undefined;
133
249
  if (block.kind.type === "CodeBlock") {
134
- props.text = decodeCodeText(block.html);
250
+ // Prefer the structured `code` (present when blockData is on) over the HTML
251
+ // regex — the lossless decoded source. Fall back to the regex when off.
252
+ props.text = data?.code ?? decodeCodeText(block.html);
135
253
  props.language = data?.lang ?? "";
254
+ if (typeof data?.code === "string") {
255
+ props.code = { lang: data.lang ?? null, code: data.code };
256
+ }
136
257
  } else if (block.kind.type === "MathBlock") {
137
- props.text = decodeMathText(block.html);
258
+ props.text = data?.latex ?? decodeMathText(block.html);
259
+ if (typeof data?.latex === "string") {
260
+ props.math = { latex: data.latex };
261
+ }
262
+ } else if (block.kind.type === "List") {
263
+ if (data && typeof data.start === "number") {
264
+ props.list = { ordered: !!data.ordered, start: data.start };
265
+ }
138
266
  } else if (block.kind.type === "Component") {
139
267
  props.tag = data?.tag ?? "";
140
268
  // React-form attribute names, so `{...attrs}` spreads cleanly onto an element
@@ -143,6 +271,17 @@ function blockKindProps(block: Block): BlockComponentProps {
143
271
  // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
144
272
  // (markdown already rendered) rather than the full wrapped block.
145
273
  props.html = componentInnerHtml(block.html, props.tag);
274
+ } else if (block.kind.type === "Table") {
275
+ // Pure structured data (present only when `blockData` is on) — unlike
276
+ // `attrs` there is no React/DOM name-form divergence, so this is the same
277
+ // line as block-props.ts's branch.
278
+ props.table = block.kind.data as TableData | undefined;
279
+ } else if (block.kind.type === "Heading") {
280
+ // When `blockData` is on, `kind.data` is `{ level, text, id }`; off, it is the
281
+ // bare level `number`. Surface the rich object only (mirrors block-props.ts).
282
+ if (typeof block.kind.data === "object" && block.kind.data !== null) {
283
+ props.heading = block.kind.data as HeadingData;
284
+ }
146
285
  }
147
286
  return props;
148
287
  }
package/src/solid.tsx CHANGED
@@ -59,6 +59,11 @@ export function mountSolid(
59
59
  * is equivalent to `<div ref={container} class={props.class} style={props.style} />`.
60
60
  */
61
61
  export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
62
+ // SSR: this renderer is client-only and imperative (creates/owns its own DOM
63
+ // node, no hydration expected). Solid runs component bodies on the server, so
64
+ // guard the document access; the browser path is byte-identical (document is
65
+ // always defined there). onMount never fires on the server anyway.
66
+ if (typeof document === "undefined") return undefined as unknown as JSX.Element;
62
67
  const container = document.createElement("div");
63
68
  if (props.class) container.className = props.class;
64
69
  if (typeof props.style === "string") container.setAttribute("style", props.style);
package/src/types-core.ts CHANGED
@@ -17,6 +17,86 @@ export interface BlockKind {
17
17
  data?: unknown;
18
18
  }
19
19
 
20
+ /** Column alignment from the `|:--|:-:|--:|` delimiter row; `null` = unset. */
21
+ export type Align = "left" | "center" | "right" | null;
22
+
23
+ /**
24
+ * One table cell as STRUCTURED DATA (opt-in via {@link ParserConfig.blockData}).
25
+ * `text` is the inline-stripped plaintext — sort/filter/CSV/chart from DATA,
26
+ * with no HTML re-parse. `html` is the inline-rendered display markup, byte-for-
27
+ * byte the inline content inside the matching `<td>`/`<th>` of `block.html`.
28
+ */
29
+ export interface TableCell {
30
+ text: string;
31
+ html: string;
32
+ }
33
+
34
+ /**
35
+ * A Table block's `kind.data` when {@link ParserConfig.blockData} is on. Lets a
36
+ * consumer build a sort/filter/transpose/chart/CSV toolbar from DATA alone —
37
+ * no HAST tree, no HTML re-parse. `aligns[i]` is column `i`'s alignment.
38
+ */
39
+ export interface TableData {
40
+ headers: TableCell[];
41
+ rows: TableCell[][];
42
+ aligns: Align[];
43
+ }
44
+
45
+ /**
46
+ * A Heading block's `kind.data` when {@link ParserConfig.blockData} is on. Lets a
47
+ * consumer build a table of contents — nested by `level`, anchored by `id` — from
48
+ * DATA alone, with no HTML re-parse. `text` is the inline-stripped plaintext (the
49
+ * heading rendered to plain text, e.g. `## **Bold** & x` → `"Bold & x"`); `id` is
50
+ * a GitHub-style anchor slug of that text (`"bold-x"`) for `#`-links. When
51
+ * `blockData` is off, a Heading's `kind.data` is instead the bare level `number`
52
+ * (byte-identical to before), so consumers reading `kind.data` must accept the
53
+ * `number | HeadingData` union.
54
+ *
55
+ * v1: duplicate heading texts produce identical slugs (no document-wide dedup
56
+ * counter yet) — give same-named headings distinct text if unique anchors matter.
57
+ */
58
+ export interface HeadingData {
59
+ level: number;
60
+ text: string;
61
+ id: string;
62
+ }
63
+
64
+ /**
65
+ * A CodeBlock's `kind.data` when {@link ParserConfig.blockData} is on. `lang` is
66
+ * the always-on info-string language (`null` for none); `code` is the opt-in
67
+ * DECODED source inside `<pre><code>…</code></pre>` (only present when `blockData`
68
+ * is on). Build a copy-to-clipboard string / re-highlight from `code` alone — no
69
+ * HTML re-parse, no entity-decode. When `blockData` is off, `code` is absent and
70
+ * `kind.data` is just `{ lang }`, byte-identical to before.
71
+ */
72
+ export interface CodeBlockData {
73
+ lang: string | null;
74
+ code?: string;
75
+ }
76
+
77
+ /**
78
+ * A MathBlock's `kind.data` when {@link ParserConfig.blockData} is on. `latex` is
79
+ * the DECODED LaTeX source (the display-math body, entity-decoded). Re-render with
80
+ * KaTeX from `latex` alone — no HTML re-parse. When `blockData` is off, a
81
+ * MathBlock has no `kind.data` at all (byte-identical to before).
82
+ */
83
+ export interface MathBlockData {
84
+ latex: string;
85
+ }
86
+
87
+ /**
88
+ * A List's `kind.data` when {@link ParserConfig.blockData} is on. `ordered` is the
89
+ * always-on flag; `start` is the opt-in ordered-list start number (the `start="N"`
90
+ * HTML attribute; `1` for an unordered list), only present when `blockData` is on.
91
+ * Renumber / continue a split list from `start` alone — no HTML re-parse. When
92
+ * `blockData` is off, `start` is absent and `kind.data` is just `{ ordered }`,
93
+ * byte-identical to before.
94
+ */
95
+ export interface ListData {
96
+ ordered: boolean;
97
+ start?: number;
98
+ }
99
+
20
100
  export interface Block {
21
101
  id: number;
22
102
  kind: BlockKind;
@@ -58,6 +138,47 @@ export interface BlockComponentProps {
58
138
  * wrap it itself.
59
139
  */
60
140
  attrs?: Record<string, string>;
141
+ /**
142
+ * Structured table data — present for `Table` blocks when
143
+ * {@link ParserConfig.blockData} is on (otherwise `undefined`). Equivalent to
144
+ * `block.kind.data`, given a typed, documented name. `{ headers, rows, aligns }`
145
+ * with each cell carrying `text` (plaintext, for sort/filter/CSV/chart) and
146
+ * `html` (display). Build a sort/filter/transpose/chart/CSV toolbar from DATA —
147
+ * no HTML re-parse, no HAST tree.
148
+ */
149
+ table?: TableData;
150
+ /**
151
+ * Structured heading data — present for `Heading` blocks when
152
+ * {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ level, text,
153
+ * id }` with `text` the inline-stripped plaintext and `id` a GitHub-style anchor
154
+ * slug. Build a table of contents (nested by `level`, anchored by `id`) from
155
+ * DATA — no HTML re-parse.
156
+ */
157
+ heading?: HeadingData;
158
+ /**
159
+ * Structured code data — present for `CodeBlock` blocks when
160
+ * {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ lang, code }`
161
+ * with `code` the DECODED source. Build a copy-to-clipboard string / re-highlight
162
+ * from `code` — no HTML re-parse, no entity-decode. (`props.text` / `props.language`
163
+ * carry the same source / lang and stay populated even when off, via the HTML
164
+ * regex fallback.)
165
+ */
166
+ code?: CodeBlockData;
167
+ /**
168
+ * Structured math data — present for `MathBlock` blocks when
169
+ * {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ latex }` — the
170
+ * DECODED LaTeX source. Re-render with KaTeX from `latex` — no HTML re-parse.
171
+ * (`props.text` carries the same source and stays populated even when off, via
172
+ * the HTML regex fallback.)
173
+ */
174
+ math?: MathBlockData;
175
+ /**
176
+ * Structured list data — present for `List` blocks when
177
+ * {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ ordered,
178
+ * start }` — renumber / continue a split list from `start` (the ordered-list
179
+ * start number) without re-parsing the `<ol start=…>` attribute.
180
+ */
181
+ list?: ListData;
61
182
  }
62
183
 
63
184
  /**
@@ -108,6 +229,14 @@ export interface ParserConfig {
108
229
  * off. Names match case-sensitively.
109
230
  */
110
231
  componentTags?: string[];
232
+ /**
233
+ * Opt-in structured table data. When on, a `Table` block's `kind.data` is
234
+ * populated with `{ headers, rows, aligns }` (each cell `{ text, html }`) so a
235
+ * consumer can build a sort/filter/transpose/chart/CSV toolbar from DATA — no
236
+ * HTML re-parse, no HAST tree. Default false (non-users pay zero allocation /
237
+ * serde bytes; output and the `kind` serde shape stay byte-identical when off).
238
+ */
239
+ blockData?: boolean;
111
240
  }
112
241
 
113
242
  // Each message carries a `streamId` so one worker can multiplex many parsers
@@ -20,6 +20,14 @@ export class FluxParser {
20
20
  * to table header cells. Off by default (conformance output unchanged).
21
21
  */
22
22
  setA11y(on: boolean): void;
23
+ /**
24
+ * Opt-in structured `kind.data` channel for Table blocks: a Table then
25
+ * carries `{ headers, rows, aligns }` (per-cell `{ text, html }`) so a
26
+ * consumer can build a sort/filter/transpose/chart/CSV toolbar from DATA
27
+ * without re-parsing the HTML. Off by default — when off, Table serializes
28
+ * as `{"type":"Table"}` (no `data` key) and output is byte-identical.
29
+ */
30
+ setBlockData(on: boolean): void;
23
31
  /**
24
32
  * Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
25
33
  * A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
@@ -74,6 +82,7 @@ export interface InitOutput {
74
82
  readonly fluxparser_new: () => number;
75
83
  readonly fluxparser_retainedBytes: (a: number) => number;
76
84
  readonly fluxparser_setA11y: (a: number, b: number) => void;
85
+ readonly fluxparser_setBlockData: (a: number, b: number) => void;
77
86
  readonly fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
78
87
  readonly fluxparser_setDirAuto: (a: number, b: number) => void;
79
88
  readonly fluxparser_setGfmAlerts: (a: number, b: number) => void;
@@ -82,6 +82,17 @@ export class FluxParser {
82
82
  setA11y(on) {
83
83
  wasm.fluxparser_setA11y(this.__wbg_ptr, on);
84
84
  }
85
+ /**
86
+ * Opt-in structured `kind.data` channel for Table blocks: a Table then
87
+ * carries `{ headers, rows, aligns }` (per-cell `{ text, html }`) so a
88
+ * consumer can build a sort/filter/transpose/chart/CSV toolbar from DATA
89
+ * without re-parsing the HTML. Off by default — when off, Table serializes
90
+ * as `{"type":"Table"}` (no `data` key) and output is byte-identical.
91
+ * @param {boolean} on
92
+ */
93
+ setBlockData(on) {
94
+ wasm.fluxparser_setBlockData(this.__wbg_ptr, on);
95
+ }
85
96
  /**
86
97
  * Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
87
98
  * A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
Binary file
@@ -8,6 +8,7 @@ export const fluxparser_finalize: (a: number, b: number) => void;
8
8
  export const fluxparser_new: () => number;
9
9
  export const fluxparser_retainedBytes: (a: number) => number;
10
10
  export const fluxparser_setA11y: (a: number, b: number) => void;
11
+ export const fluxparser_setBlockData: (a: number, b: number) => void;
11
12
  export const fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
12
13
  export const fluxparser_setDirAuto: (a: number, b: number) => void;
13
14
  export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
@@ -2,7 +2,7 @@
2
2
  "name": "flux-md-core",
3
3
  "type": "module",
4
4
  "description": "Incremental, streaming-aware markdown parser with speculative closure",
5
- "version": "0.8.0",
5
+ "version": "0.10.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",
package/src/worker.ts CHANGED
@@ -55,6 +55,7 @@ function getOrCreate(streamId: number): FluxParser {
55
55
  p.setA11y(c?.a11y ?? false);
56
56
  p.setUnsafeHtml(c?.unsafeHtml ?? false);
57
57
  p.setComponentTags(c?.componentTags ?? []);
58
+ p.setBlockData(c?.blockData ?? false);
58
59
  parsers.set(streamId, p);
59
60
  }
60
61
  return p;