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 +64 -0
- package/README.md +80 -16
- package/package.json +2 -1
- package/src/block-props.ts +39 -4
- package/src/client.ts +131 -18
- package/src/index.ts +7 -0
- package/src/react.tsx +147 -8
- package/src/solid.tsx +5 -0
- package/src/types-core.ts +129 -0
- package/src/wasm/flux_md_core.d.ts +9 -0
- package/src/wasm/flux_md_core.js +11 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/src/wasm/package.json +1 -1
- package/src/worker.ts +1 -0
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
|
-
|
|
68
|
-
(
|
|
69
|
-
|
|
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(
|
|
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
|
|
436
|
-
`
|
|
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.
|
|
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"],
|
package/src/block-props.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
350
|
+
return this.pw?.ready ?? false;
|
|
318
351
|
}
|
|
319
352
|
|
|
320
353
|
whenReady(): Promise<void> {
|
|
321
|
-
|
|
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(
|
|
370
|
+
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
|
|
336
371
|
}
|
|
337
372
|
|
|
338
373
|
finalize() {
|
|
339
|
-
|
|
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
|
|
344
|
-
*
|
|
345
|
-
* client.pipeFrom(await fetch("/api/chat"))
|
|
346
|
-
* `
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/react.tsx
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/wasm/flux_md_core.js
CHANGED
|
@@ -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;
|
package/src/wasm/package.json
CHANGED
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;
|