flux-md 0.9.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,37 @@ 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
+
7
38
  ## 0.9.0 — 2026-05-29
8
39
 
9
40
  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
  ```
@@ -463,8 +464,11 @@ type is at `block.kind.data.kind`).
463
464
 
464
465
  Rules worth knowing:
465
466
 
466
- - **There is no `node` prop.** flux-md has no hast tree; introspect via
467
- `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.
468
472
  - **Open (streaming) blocks render via `innerHTML`** — their HTML is still
469
473
  partial, so a tag-level override takes effect the moment the block commits.
470
474
  - **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
@@ -474,6 +478,35 @@ Rules worth knowing:
474
478
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
475
479
  or `components.code`.
476
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
+
477
510
  ### Component tags
478
511
 
479
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.9.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
@@ -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/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";
@@ -244,13 +244,25 @@ function blockKindProps(block: Block): BlockComponentProps {
244
244
  speculative: block.speculative,
245
245
  };
246
246
  const data = block.kind.data as
247
- | { 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][] }
248
248
  | undefined;
249
249
  if (block.kind.type === "CodeBlock") {
250
- 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);
251
253
  props.language = data?.lang ?? "";
254
+ if (typeof data?.code === "string") {
255
+ props.code = { lang: data.lang ?? null, code: data.code };
256
+ }
252
257
  } else if (block.kind.type === "MathBlock") {
253
- 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
+ }
254
266
  } else if (block.kind.type === "Component") {
255
267
  props.tag = data?.tag ?? "";
256
268
  // React-form attribute names, so `{...attrs}` spreads cleanly onto an element
@@ -259,6 +271,17 @@ function blockKindProps(block: Block): BlockComponentProps {
259
271
  // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
260
272
  // (markdown already rendered) rather than the full wrapped block.
261
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
+ }
262
285
  }
263
286
  return props;
264
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.9.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;