flux-md 0.9.0 → 0.11.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,55 @@ 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.11.0 — 2026-05-30
8
+
9
+ ### Added
10
+
11
+ - **Opt-in live region + root attributes** on `<FluxMarkdown>` and
12
+ `mountFluxMarkdown`. The root accepts `className` (appended to `flux-md`),
13
+ `id`, `role`, and `aria-live` / `aria-atomic`. Set `aria-live="polite"` to
14
+ announce streamed content to screen readers — `polite` coalesces rapid updates
15
+ and does **not** read every token. Off by default; covers React and the DOM
16
+ mount (so the Web Component and the Vue/Svelte/Solid adapters too).
17
+
18
+ ### Docs
19
+
20
+ - A repository root README, a "Structured block data" guide in the package
21
+ README, and a runnable **Data Studio** demo in the playground — a
22
+ sort/filter/CSV table and a live table of contents built entirely from
23
+ `block.data`, mid-stream.
24
+
25
+ ## 0.10.0 — 2026-05-30
26
+
27
+ Server-side rendering safety, plus an opt-in structured-data channel so consumers
28
+ build toolbars / tables of contents / charts from **data** instead of re-parsing
29
+ rendered HTML (no hast tree, no rehype).
30
+
31
+ ### Added
32
+
33
+ - **SSR-safe.** `new FluxClient()` and `renderToString(<FluxMarkdown …/>)` no
34
+ longer touch a Web Worker during construction or server render — worker
35
+ creation is deferred to the first `append`/`pipeFrom` (client-side) — so the
36
+ library imports and server-renders cleanly across React / Vue / Solid / Svelte.
37
+ A fresh-process SSR cold-import check guards it in CI.
38
+ - **Structured block data — `blockData: true`** (per-stream config; opt-in,
39
+ default off — output and CommonMark/GFM conformance are **byte-identical** when
40
+ off). When on, `block.kind.data` carries typed structured data per kind, also
41
+ surfaced as typed `BlockComponentProps` fields, and it **streams** in lock-step
42
+ with the HTML:
43
+ - **Table** → `{ headers, rows, aligns }`, cells `{ text, html }` (`props.table`)
44
+ — sort / filter / transpose / CSV / chart.
45
+ - **Heading** → `{ level, text, id }` (`props.heading`) — TOC with anchors.
46
+ - **CodeBlock** → `{ lang, code }` (`props.code`) — decoded source.
47
+ - **MathBlock** → `{ latex }` (`props.math`) — LaTeX source.
48
+ - **List** → `{ ordered, start }` (`props.list`).
49
+
50
+ ### Fixed
51
+
52
+ - Packaging: the published tarball ships the WASM deterministically on every npm
53
+ version (build removes wasm-pack's nested `.gitignore`), with a tarball tripwire
54
+ in CI and the publish workflow.
55
+
7
56
  ## 0.9.0 — 2026-05-29
8
57
 
9
58
  Kills the React streaming boilerplate. The common case — render an LLM stream —
package/README.md CHANGED
@@ -336,6 +336,7 @@ const client = new FluxClient({
336
336
  a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
337
337
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
338
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")
339
340
  },
340
341
  });
341
342
  ```
@@ -411,6 +412,13 @@ Subscribes to a `FluxClient`, renders each block keyed by its stable parser-assi
411
412
  <FluxMarkdown client={client} />
412
413
  ```
413
414
 
415
+ The root element accepts opt-in `className` (appended to `flux-md`), `id`,
416
+ `role`, and `aria-live` / `aria-atomic`. Set `aria-live="polite"` to make the
417
+ output a live region so screen readers announce streamed content as it settles —
418
+ `polite` coalesces rapid updates and does **not** read every token. The same
419
+ options exist on the DOM mount (`mountFluxMarkdown(client, el, { ariaLive: "polite" })`),
420
+ covering the Web Component and the Vue/Svelte/Solid adapters.
421
+
414
422
  #### Custom components / overrides
415
423
 
416
424
  Pass a `components` map to replace how elements render. Keys come in **two
@@ -463,8 +471,11 @@ type is at `block.kind.data.kind`).
463
471
 
464
472
  Rules worth knowing:
465
473
 
466
- - **There is no `node` prop.** flux-md has no hast tree; introspect via
467
- `className` / `data-*` instead.
474
+ - **There is no `node` prop / no hast tree.** Introspect via `className` /
475
+ `data-*`, or — better — opt into the typed **[structured-data
476
+ channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
477
+ `block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
478
+ `list` fields) directly — no HTML re-parsing.
468
479
  - **Open (streaming) blocks render via `innerHTML`** — their HTML is still
469
480
  partial, so a tag-level override takes effect the moment the block commits.
470
481
  - **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
@@ -474,6 +485,35 @@ Rules worth knowing:
474
485
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
475
486
  or `components.code`.
476
487
 
488
+ ### Structured block data (`setBlockData`)
489
+
490
+ Set `blockData: true` in the per-stream config and each block carries typed
491
+ structured data on `block.kind.data`, also surfaced as typed fields on the
492
+ component props — so you build toolbars, tables of contents, charts, copy
493
+ buttons, etc. from **data**, never by re-parsing the rendered HTML (no hast tree,
494
+ no rehype). Off by default; when off, output and CommonMark/GFM conformance are
495
+ byte-identical, so non-users pay nothing.
496
+
497
+ | Kind | `block.kind.data` | prop | use |
498
+ |------|-------------------|------|-----|
499
+ | `Table` | `{ headers, rows, aligns }`, cells `{ text, html }` | `props.table` | sort / filter / transpose / CSV / chart |
500
+ | `Heading` | `{ level, text, id }` | `props.heading` | table of contents with anchors |
501
+ | `CodeBlock` | `{ lang, code }` | `props.code` | decoded source (copy / run) |
502
+ | `MathBlock` | `{ latex }` | `props.math` | LaTeX source (re-render) |
503
+ | `List` | `{ ordered, start }` | `props.list` | ordered-list numbering |
504
+
505
+ Each cell's `text` is inline-stripped plaintext (for sort/filter/CSV/logic);
506
+ `html` is the inline-rendered display HTML. The data **streams** with the
507
+ document — a growing table or a heading carries its structured data on every
508
+ patch, in lock-step with the HTML — something a batch HTML-AST cannot do.
509
+
510
+ ```tsx
511
+ // Table of contents from heading data — no DOM, works mid-stream:
512
+ const toc = client.getSnapshot()
513
+ .filter((b) => b.kind.type === "Heading" && b.kind.data)
514
+ .map((b) => b.kind.data as { level: number; text: string; id: string });
515
+ ```
516
+
477
517
  ### Component tags
478
518
 
479
519
  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.9.0",
3
+ "version": "0.11.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"],
@@ -30,12 +30,21 @@
30
30
  "solid-js": "^1.8.0"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
- "react": { "optional": true },
34
- "vue": { "optional": true },
35
- "svelte": { "optional": true },
36
- "solid-js": { "optional": true }
33
+ "react": {
34
+ "optional": true
35
+ },
36
+ "vue": {
37
+ "optional": true
38
+ },
39
+ "svelte": {
40
+ "optional": true
41
+ },
42
+ "solid-js": {
43
+ "optional": true
44
+ }
37
45
  },
38
46
  "devDependencies": {
47
+ "@types/bun": "^1.3.14",
39
48
  "@types/react": "^18.3.12",
40
49
  "@types/react-dom": "^18.3.1",
41
50
  "happy-dom": "^15.11.6",
@@ -48,6 +57,8 @@
48
57
  },
49
58
  "scripts": {
50
59
  "test": "bun test",
60
+ "test:ssr-cold": "bun test/ssr-cold.mjs",
61
+ "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
51
62
  "prepublishOnly": "cd ../.. && bun run build:wasm"
52
63
  },
53
64
  "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
@@ -277,8 +277,8 @@ export function getDefaultPool(): FluxPool {
277
277
  */
278
278
  export class FluxClient {
279
279
  private pool: FluxPool;
280
- private pw: PoolWorker;
281
- private streamId: number;
280
+ private pw: PoolWorker | null = null;
281
+ private streamId = 0;
282
282
  private config?: ParserConfig;
283
283
  private configSent = false;
284
284
  private listeners = new Set<() => void>();
@@ -321,17 +321,38 @@ export class FluxClient {
321
321
  this.config = options.config;
322
322
  this.onError = options.onError;
323
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;
324
343
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
325
344
  this.streamId = streamId;
326
345
  this.pw = pw;
346
+ return pw;
327
347
  }
328
348
 
329
349
  get ready(): boolean {
330
- return this.pw.ready;
350
+ return this.pw?.ready ?? false;
331
351
  }
332
352
 
333
353
  whenReady(): Promise<void> {
334
- return this.pool.whenWorkerReady(this.pw);
354
+ const pw = this.ensureAcquired();
355
+ return this.pool.whenWorkerReady(pw);
335
356
  }
336
357
 
337
358
  // The config rides on the first message a stream sends; the worker applies it
@@ -344,12 +365,14 @@ export class FluxClient {
344
365
  }
345
366
 
346
367
  append(chunk: string) {
368
+ const pw = this.ensureAcquired();
347
369
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
348
- 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() });
349
371
  }
350
372
 
351
373
  finalize() {
352
- 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() });
353
376
  }
354
377
 
355
378
  /**
@@ -433,14 +456,19 @@ export class FluxClient {
433
456
  this.retainedBytes = 0;
434
457
  this.wasmMemoryBytes = 0;
435
458
  // Same streamId + worker — the worker frees and lazily recreates the parser.
436
- 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 });
437
461
  this.emit();
438
462
  }
439
463
 
440
464
  destroy() {
441
465
  if (!this.attached) return; // idempotent
442
466
  // Free this stream's parser; the shared worker stays warm for siblings.
443
- 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);
444
472
  this.listeners.clear();
445
473
  this.attached = false;
446
474
  }
@@ -453,6 +481,14 @@ export class FluxClient {
453
481
  */
454
482
  reattach() {
455
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
+ }
456
492
  this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
457
493
  this.attached = true;
458
494
  // The worker discarded this stream's config on `dispose` (unlike `reset`,
@@ -496,7 +532,13 @@ export class FluxClient {
496
532
  const out: OutlineEntry[] = [];
497
533
  for (const b of this.store.snapshot) {
498
534
  if (b.kind.type === "Heading") {
499
- 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 });
500
542
  }
501
543
  }
502
544
  return out;
package/src/dom.ts CHANGED
@@ -61,6 +61,19 @@ export interface MountOptions {
61
61
  highlightCode?: boolean;
62
62
  /** Coalesce patches into one DOM write per animation frame. Default true. */
63
63
  batch?: boolean;
64
+ /** Appended to the root's `className` (the `flux-md` class is always present). */
65
+ className?: string;
66
+ /** Set on the root element. */
67
+ id?: string;
68
+ /** Set on the root element (e.g. `"article"`, `"log"`). */
69
+ role?: string;
70
+ /**
71
+ * Make the root a live region so screen readers announce streamed content.
72
+ * `"polite"` coalesces rapid updates (does not read every token). Off by default.
73
+ */
74
+ ariaLive?: "off" | "polite" | "assertive";
75
+ /** Live-region atomicity; pair with `ariaLive`. Off by default. */
76
+ ariaAtomic?: boolean;
64
77
  }
65
78
 
66
79
  // Per-kind off-screen size estimate for `contain-intrinsic-size`. Duplicated
@@ -99,7 +112,11 @@ export function mountFluxMarkdown(
99
112
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
100
113
 
101
114
  const root = document.createElement("div");
102
- root.className = "flux-md";
115
+ root.className = options.className ? `flux-md ${options.className}` : "flux-md";
116
+ if (options.id) root.id = options.id;
117
+ if (options.role) root.setAttribute("role", options.role);
118
+ if (options.ariaLive) root.setAttribute("aria-live", options.ariaLive);
119
+ if (options.ariaAtomic !== undefined) root.setAttribute("aria-atomic", String(options.ariaAtomic));
103
120
  container.appendChild(root);
104
121
 
105
122
  // CSS-only stick-to-bottom: a permanent sentinel pinned as the last child.
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
@@ -8,7 +8,7 @@ import {
8
8
  useSyncExternalStore,
9
9
  type CSSProperties,
10
10
  } from "react";
11
- import type { Block, BlockComponentProps, Components } from "./types";
11
+ import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
12
12
  import { FluxClient } from "./client";
13
13
  import type { ParserConfig } from "./types-core";
14
14
  import { CodeBlock } from "./renderers/CodeBlock";
@@ -101,6 +101,20 @@ interface FluxMarkdownProps {
101
101
  * run through it. When omitted, rendering is byte-identical and zero-cost.
102
102
  */
103
103
  sanitize?: (html: string) => string;
104
+ /** Appended to the root's `className` (the `flux-md` class is always present). */
105
+ className?: string;
106
+ /** Set on the root element. */
107
+ id?: string;
108
+ /** Set on the root element (e.g. `"article"`, `"log"`). */
109
+ role?: string;
110
+ /**
111
+ * Make the root a live region so screen readers announce streamed content.
112
+ * `"polite"` (recommended) coalesces rapid updates and announces when the
113
+ * reader is idle — it does **not** read every token. Off by default.
114
+ */
115
+ "aria-live"?: "off" | "polite" | "assertive";
116
+ /** Live-region atomicity; pair with `aria-live`. Off by default. */
117
+ "aria-atomic"?: boolean;
104
118
  }
105
119
 
106
120
  // The original render path: subscribe to a (required, caller- or hook-owned)
@@ -111,13 +125,24 @@ function FluxMarkdownFromClient({
111
125
  virtualize,
112
126
  stickToBottom,
113
127
  sanitize,
128
+ className,
129
+ id,
130
+ role,
131
+ "aria-live": ariaLive,
132
+ "aria-atomic": ariaAtomic,
114
133
  }: FluxMarkdownProps & { client: FluxClient }) {
115
134
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
116
135
  // Normalize "no overrides" to a stable `undefined` so memo comparisons and
117
136
  // the fast path don't churn on an empty object identity.
118
137
  const comps = components && Object.keys(components).length > 0 ? components : undefined;
119
138
  return (
120
- <div className="flux-md">
139
+ <div
140
+ className={className ? `flux-md ${className}` : "flux-md"}
141
+ id={id}
142
+ role={role}
143
+ aria-live={ariaLive}
144
+ aria-atomic={ariaAtomic}
145
+ >
121
146
  {blocks.map((b) => (
122
147
  <BlockView key={b.id} block={b} components={comps} virtualize={virtualize} sanitize={sanitize} />
123
148
  ))}
@@ -244,13 +269,25 @@ function blockKindProps(block: Block): BlockComponentProps {
244
269
  speculative: block.speculative,
245
270
  };
246
271
  const data = block.kind.data as
247
- | { lang?: string | null; tag?: string; attrs?: [string, string][] }
272
+ | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
248
273
  | undefined;
249
274
  if (block.kind.type === "CodeBlock") {
250
- props.text = decodeCodeText(block.html);
275
+ // Prefer the structured `code` (present when blockData is on) over the HTML
276
+ // regex — the lossless decoded source. Fall back to the regex when off.
277
+ props.text = data?.code ?? decodeCodeText(block.html);
251
278
  props.language = data?.lang ?? "";
279
+ if (typeof data?.code === "string") {
280
+ props.code = { lang: data.lang ?? null, code: data.code };
281
+ }
252
282
  } else if (block.kind.type === "MathBlock") {
253
- props.text = decodeMathText(block.html);
283
+ props.text = data?.latex ?? decodeMathText(block.html);
284
+ if (typeof data?.latex === "string") {
285
+ props.math = { latex: data.latex };
286
+ }
287
+ } else if (block.kind.type === "List") {
288
+ if (data && typeof data.start === "number") {
289
+ props.list = { ordered: !!data.ordered, start: data.start };
290
+ }
254
291
  } else if (block.kind.type === "Component") {
255
292
  props.tag = data?.tag ?? "";
256
293
  // React-form attribute names, so `{...attrs}` spreads cleanly onto an element
@@ -259,6 +296,17 @@ function blockKindProps(block: Block): BlockComponentProps {
259
296
  // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
260
297
  // (markdown already rendered) rather than the full wrapped block.
261
298
  props.html = componentInnerHtml(block.html, props.tag);
299
+ } else if (block.kind.type === "Table") {
300
+ // Pure structured data (present only when `blockData` is on) — unlike
301
+ // `attrs` there is no React/DOM name-form divergence, so this is the same
302
+ // line as block-props.ts's branch.
303
+ props.table = block.kind.data as TableData | undefined;
304
+ } else if (block.kind.type === "Heading") {
305
+ // When `blockData` is on, `kind.data` is `{ level, text, id }`; off, it is the
306
+ // bare level `number`. Surface the rich object only (mirrors block-props.ts).
307
+ if (typeof block.kind.data === "object" && block.kind.data !== null) {
308
+ props.heading = block.kind.data as HeadingData;
309
+ }
262
310
  }
263
311
  return props;
264
312
  }
@@ -1,5 +1,6 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { highlight } from "../hi";
3
+ import { extractLang } from "../block-props";
3
4
 
4
5
  /**
5
6
  * Deferred-highlighting code block. Open (streaming) blocks render plain;
@@ -19,10 +20,6 @@ function decodeText(html: string): string {
19
20
  .replace(/&amp;/g, "&");
20
21
  }
21
22
 
22
- function extractLang(html: string): string {
23
- const m = html.match(/data-lang="([^"]+)"/);
24
- return m ? m[1] : "";
25
- }
26
23
 
27
24
  interface Props {
28
25
  html: string;
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.9.0",
5
+ "version": "0.11.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;