flux-md 0.6.0 → 0.8.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 +69 -0
- package/README.md +42 -2
- package/package.json +2 -1
- package/src/client.ts +182 -15
- package/src/element.ts +54 -7
- package/src/types-core.ts +10 -1
- package/src/wasm/flux_md_core.d.ts +7 -0
- package/src/wasm/flux_md_core.js +9 -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 +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ 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.8.0 — 2026-05-29
|
|
8
|
+
|
|
9
|
+
A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
|
|
10
|
+
in the worker pool and added two small, streaming-native conveniences.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`FluxClient.pipeFrom(src)`** — hand it a `Response` or a
|
|
15
|
+
`ReadableStream<Uint8Array>` and it reads the body, `append()`s each decoded
|
|
16
|
+
chunk, and `finalize()`s. The LLM-native one-liner:
|
|
17
|
+
`await client.pipeFrom(await fetch("/api/chat"))`.
|
|
18
|
+
- **`onBlock` option** — `new FluxClient({ onBlock })` fires once per block as it
|
|
19
|
+
commits (document order), for side effects like lazily highlighting a finished
|
|
20
|
+
code block or analytics. Committed blocks never re-fire.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Worker pool: a throwing stream handler no longer breaks sibling streams.** A
|
|
25
|
+
user `onError` (or any handler) that threw could abort the fatal-error fan-out
|
|
26
|
+
mid-loop and escape the worker message listener; dispatch is now isolated.
|
|
27
|
+
- **Worker pool: a fatally-failed worker is no longer re-assigned.** `pick()`
|
|
28
|
+
skipped the `failed` flag, so after a WASM-init failure a new stream could be
|
|
29
|
+
routed onto the dead worker and hang (a client that didn't `await whenReady()`
|
|
30
|
+
had no safety net). Failed workers are now excluded from selection.
|
|
31
|
+
- **`<flux-markdown>`: manual `append()`/`finalize()` supersede an in-flight
|
|
32
|
+
`src` fetch** (mirroring `reset()`), so mixing the two can't interleave.
|
|
33
|
+
- Hardened the CI/publish tarball check (explicit failure if `npm pack` yields
|
|
34
|
+
no tarball) and documented the `htmlToText` core-HTML-only invariant.
|
|
35
|
+
|
|
36
|
+
## 0.7.0 — 2026-05-29
|
|
37
|
+
|
|
38
|
+
DX, robustness, and accessibility round — the streaming core (perf, CommonMark
|
|
39
|
+
652/652, GFM) was already comprehensive, so this release sharpens the surface
|
|
40
|
+
around it.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **`onError` on `FluxClient`** — `new FluxClient({ onError })` receives worker
|
|
45
|
+
and parse errors (previously only `console.error`'d). A **WASM-init failure**
|
|
46
|
+
now also surfaces: `whenReady()` **rejects** instead of hanging forever, and
|
|
47
|
+
`onError` fires with `{ fatal: true }`.
|
|
48
|
+
- **`a11y` parser option** (`ParserConfig.a11y` / `setA11y` / `<flux-markdown
|
|
49
|
+
a11y>`) — opt-in accessibility markup that intentionally deviates from strict
|
|
50
|
+
GFM byte-output: wraps a task-list checkbox + its text in a `<label>` (so the
|
|
51
|
+
box is programmatically associated for screen readers), and adds
|
|
52
|
+
`scope="col"` to table header cells. **Off by default** (conformance output
|
|
53
|
+
unchanged). Streaming output stays byte-identical to one-shot.
|
|
54
|
+
- **`FluxClient.outline()`** — a heading table-of-contents (level / text /
|
|
55
|
+
stable id) from the current snapshot, in document order; works mid-stream.
|
|
56
|
+
- **`FluxClient.toPlaintext()`** — the rendered document as plain text (tags
|
|
57
|
+
stripped, entities decoded, blocks blank-line separated) for search indexing
|
|
58
|
+
/ summaries.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **`<flux-markdown>` `src` race** — rapidly changing `src` (or switching
|
|
63
|
+
between a `src` URL and inline `markdown`/`textContent`) could interleave two
|
|
64
|
+
fetch streams into one parser, corrupting the parse tree. The element now
|
|
65
|
+
supersedes any in-flight fetch (monotonic token + `AbortController`) at a
|
|
66
|
+
single chokepoint.
|
|
67
|
+
|
|
68
|
+
### Docs / packaging
|
|
69
|
+
|
|
70
|
+
- README documents the one-line Vite `optimizeDeps.exclude` requirement.
|
|
71
|
+
- `"sideEffects": ["./src/worker.ts"]` so bundlers can drop unused framework
|
|
72
|
+
adapters from the export surface.
|
|
73
|
+
- CI now publishes via a tag-triggered workflow with `npm publish --provenance`,
|
|
74
|
+
and asserts every published tarball ships a non-empty WASM artifact.
|
|
75
|
+
|
|
7
76
|
## 0.6.0 — 2026-05-28
|
|
8
77
|
|
|
9
78
|
### Added — flux-md is no longer React-only
|
package/README.md
CHANGED
|
@@ -23,6 +23,20 @@ Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
|
|
|
23
23
|
need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
24
24
|
`flux-md/dom`, `flux-md/element`) needs none.
|
|
25
25
|
|
|
26
|
+
> **Vite — one-line config.** Vite's dependency pre-bundling (esbuild) hoists
|
|
27
|
+
> the wasm-bindgen glue into `.vite/deps/`, which breaks the relative
|
|
28
|
+
> `new URL("…_bg.wasm", import.meta.url)` lookup so the worker can't load WASM
|
|
29
|
+
> (you'll see a 404 / "magic word" error). Exclude flux-md from pre-bundling:
|
|
30
|
+
>
|
|
31
|
+
> ```ts
|
|
32
|
+
> // vite.config.ts
|
|
33
|
+
> export default defineConfig({
|
|
34
|
+
> optimizeDeps: { exclude: ["flux-md"] },
|
|
35
|
+
> });
|
|
36
|
+
> ```
|
|
37
|
+
>
|
|
38
|
+
> No other bundler needs this — it's specific to Vite's optimizer.
|
|
39
|
+
|
|
26
40
|
## Quick start
|
|
27
41
|
|
|
28
42
|
```ts
|
|
@@ -244,19 +258,40 @@ if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
|
|
|
244
258
|
|
|
245
259
|
```ts
|
|
246
260
|
class FluxClient {
|
|
247
|
-
constructor(options?: {
|
|
261
|
+
constructor(options?: {
|
|
262
|
+
pool?: FluxPool;
|
|
263
|
+
config?: ParserConfig;
|
|
264
|
+
onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
|
|
265
|
+
onBlock?: (block: Block) => void; // fires once per block as it commits
|
|
266
|
+
});
|
|
248
267
|
append(chunk: string): void; // queue text for parsing
|
|
268
|
+
pipeFrom(src: ReadableStream<Uint8Array> | Response): Promise<void>; // read → append → finalize
|
|
249
269
|
finalize(): void; // mark stream complete
|
|
250
270
|
reset(): void; // wipe and reuse
|
|
251
271
|
destroy(): void; // free this stream's parser
|
|
252
|
-
whenReady(): Promise<void>; // resolves once WASM loaded
|
|
272
|
+
whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
|
|
253
273
|
subscribe(listener: () => void): () => void; // React-friendly store
|
|
254
274
|
getSnapshot(): Block[]; // ordered current blocks
|
|
275
|
+
outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
|
|
276
|
+
toPlaintext(): string; // rendered document as plain text (search / summaries)
|
|
255
277
|
getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
|
|
256
278
|
retainedBytes, wasmMemoryBytes, ... };
|
|
257
279
|
}
|
|
258
280
|
```
|
|
259
281
|
|
|
282
|
+
`pipeFrom` is the LLM-native shortcut — hand it a `fetch` response and it
|
|
283
|
+
reads, appends, and finalizes for you:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const client = new FluxClient();
|
|
287
|
+
await client.pipeFrom(await fetch("/api/chat")); // streams the body in, then finalizes
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
|
|
291
|
+
failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
|
|
292
|
+
load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
|
|
293
|
+
effect each time a block commits (e.g. lazy-highlight a finished code block).
|
|
294
|
+
|
|
260
295
|
#### Per-stream config
|
|
261
296
|
|
|
262
297
|
```ts
|
|
@@ -267,6 +302,7 @@ const client = new FluxClient({
|
|
|
267
302
|
gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
|
|
268
303
|
gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
|
|
269
304
|
dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
|
|
305
|
+
a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
|
|
270
306
|
unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
|
|
271
307
|
componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
|
|
272
308
|
},
|
|
@@ -288,6 +324,10 @@ When to enable each flag:
|
|
|
288
324
|
definitions. Off by default; see the footnote streaming caveat above.
|
|
289
325
|
- `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
|
|
290
326
|
`dir="auto"` so the browser detects direction independently per block.
|
|
327
|
+
- `a11y: true` — opt-in accessibility markup that deviates from strict GFM
|
|
328
|
+
byte-output: wraps task-list checkboxes in a `<label>` (screen-reader
|
|
329
|
+
association) and adds `scope="col"` to table headers. Off by default so
|
|
330
|
+
conformance output stays exact.
|
|
291
331
|
- `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
|
|
292
332
|
LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
|
|
293
333
|
similar — see [Security](#security)).
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
+
"sideEffects": ["./src/worker.ts"],
|
|
6
7
|
"main": "./src/index.ts",
|
|
7
8
|
"types": "./src/index.ts",
|
|
8
9
|
"exports": {
|
package/src/client.ts
CHANGED
|
@@ -22,6 +22,34 @@ export function emptyBlockStore(): BlockStore {
|
|
|
22
22
|
return { committed: new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** A heading entry for building a table of contents — see {@link FluxClient.outline}. */
|
|
26
|
+
export interface OutlineEntry {
|
|
27
|
+
/** Heading level 1–6. */
|
|
28
|
+
level: number;
|
|
29
|
+
/** Plain-text heading content (tags stripped, entities decoded). */
|
|
30
|
+
text: string;
|
|
31
|
+
/** Stable block id — usable as a scroll target / React key. */
|
|
32
|
+
id: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Strip tags (→ space) and decode the small entity set the core emits, then
|
|
36
|
+
* collapse whitespace. INVARIANT: the simple `<[^>]*>` strip is only safe
|
|
37
|
+
* because every input here is HTML the Rust core produced via escape_html /
|
|
38
|
+
* escape_attr — which escape `>` inside attribute values, so no `>` ever
|
|
39
|
+
* appears except as a real tag close. This must NOT be fed externally-authored
|
|
40
|
+
* HTML. `&` decodes last so `&lt;` → `<`, not `<`. */
|
|
41
|
+
function htmlToText(html: string): string {
|
|
42
|
+
return html
|
|
43
|
+
.replace(/<[^>]*>/g, " ")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, "'")
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
export function applyPatch(store: BlockStore, patch: Patch): void {
|
|
26
54
|
for (const b of patch.newly_committed) {
|
|
27
55
|
if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
|
|
@@ -47,8 +75,12 @@ export function applyPatch(store: BlockStore, patch: Patch): void {
|
|
|
47
75
|
interface PoolWorker {
|
|
48
76
|
worker: WorkerLike;
|
|
49
77
|
ready: boolean;
|
|
78
|
+
/** Set once WASM init fails; whenWorkerReady rejects with this thereafter. */
|
|
79
|
+
failed: Error | null;
|
|
50
80
|
streamCount: number;
|
|
51
|
-
|
|
81
|
+
/** Live stream ids on this worker — so a fatal failure can notify each one. */
|
|
82
|
+
streamIds: Set<number>;
|
|
83
|
+
readyWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }>;
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
/**
|
|
@@ -79,6 +111,7 @@ export class FluxPool {
|
|
|
79
111
|
const streamId = this.nextStreamId++;
|
|
80
112
|
const pw = this.pick();
|
|
81
113
|
pw.streamCount++;
|
|
114
|
+
pw.streamIds.add(streamId);
|
|
82
115
|
this.handlers.set(streamId, handler);
|
|
83
116
|
return { streamId, pw };
|
|
84
117
|
}
|
|
@@ -86,6 +119,7 @@ export class FluxPool {
|
|
|
86
119
|
/** Free a stream's parser in its worker; keep the worker warm for siblings. */
|
|
87
120
|
release(streamId: number, pw: PoolWorker): void {
|
|
88
121
|
this.handlers.delete(streamId);
|
|
122
|
+
pw.streamIds.delete(streamId);
|
|
89
123
|
pw.streamCount = Math.max(0, pw.streamCount - 1);
|
|
90
124
|
try {
|
|
91
125
|
pw.worker.postMessage({ type: "dispose", streamId });
|
|
@@ -98,10 +132,11 @@ export class FluxPool {
|
|
|
98
132
|
pw.worker.postMessage(msg);
|
|
99
133
|
}
|
|
100
134
|
|
|
101
|
-
/** Resolves when the given worker has finished WASM init. */
|
|
135
|
+
/** Resolves when the given worker has finished WASM init; rejects if it failed. */
|
|
102
136
|
whenWorkerReady(pw: PoolWorker): Promise<void> {
|
|
103
137
|
if (pw.ready) return Promise.resolve();
|
|
104
|
-
|
|
138
|
+
if (pw.failed) return Promise.reject(pw.failed);
|
|
139
|
+
return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
|
|
105
140
|
}
|
|
106
141
|
|
|
107
142
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
@@ -121,17 +156,28 @@ export class FluxPool {
|
|
|
121
156
|
return this.workers.length;
|
|
122
157
|
}
|
|
123
158
|
|
|
124
|
-
// Create a new worker while under cap and every
|
|
125
|
-
//
|
|
159
|
+
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
160
|
+
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
161
|
+
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
162
|
+
// retained only to reject outstanding whenWorkerReady waiters.
|
|
126
163
|
private pick(): PoolWorker {
|
|
127
|
-
|
|
164
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
165
|
+
if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
|
|
128
166
|
return this.create();
|
|
129
167
|
}
|
|
130
|
-
|
|
168
|
+
if (live.length === 0) return this.create();
|
|
169
|
+
return live.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
|
|
131
170
|
}
|
|
132
171
|
|
|
133
172
|
private create(): PoolWorker {
|
|
134
|
-
const pw: PoolWorker = {
|
|
173
|
+
const pw: PoolWorker = {
|
|
174
|
+
worker: this.factory(),
|
|
175
|
+
ready: false,
|
|
176
|
+
failed: null,
|
|
177
|
+
streamCount: 0,
|
|
178
|
+
streamIds: new Set(),
|
|
179
|
+
readyWaiters: [],
|
|
180
|
+
};
|
|
135
181
|
pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
|
|
136
182
|
this.workers.push(pw);
|
|
137
183
|
return pw;
|
|
@@ -140,12 +186,43 @@ export class FluxPool {
|
|
|
140
186
|
private onMessage(pw: PoolWorker, msg: FromWorker): void {
|
|
141
187
|
if (msg.type === "ready") {
|
|
142
188
|
pw.ready = true;
|
|
143
|
-
const
|
|
144
|
-
pw.
|
|
145
|
-
for (const
|
|
189
|
+
const waiters = pw.readyWaiters;
|
|
190
|
+
pw.readyWaiters = [];
|
|
191
|
+
for (const w of waiters) w.resolve();
|
|
146
192
|
return;
|
|
147
193
|
}
|
|
148
|
-
|
|
194
|
+
if (msg.type === "error" && msg.fatal) {
|
|
195
|
+
// A fatal (WASM-init) failure dooms every stream on this worker. Reject
|
|
196
|
+
// anyone awaiting readiness, then notify each live stream's client so its
|
|
197
|
+
// onError fires — the message carries no real streamId to route by. The
|
|
198
|
+
// worker is kept only to reject those waiters; pick() never reuses it.
|
|
199
|
+
const err = new Error(msg.message);
|
|
200
|
+
pw.failed = err;
|
|
201
|
+
const waiters = pw.readyWaiters;
|
|
202
|
+
pw.readyWaiters = [];
|
|
203
|
+
for (const w of waiters) {
|
|
204
|
+
try {
|
|
205
|
+
w.reject(err);
|
|
206
|
+
} catch {
|
|
207
|
+
/* a waiter's rejection handler is the caller's problem, not ours */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.dispatch(msg.streamId, msg);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Route a message to a stream's handler, isolating a throwing client callback
|
|
217
|
+
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
218
|
+
// loop nor starve sibling streams sharing this worker.
|
|
219
|
+
private dispatch(streamId: number, msg: FromWorker): void {
|
|
220
|
+
try {
|
|
221
|
+
this.handlers.get(streamId)?.(msg);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.error("flux: stream message handler threw", e);
|
|
225
|
+
}
|
|
149
226
|
}
|
|
150
227
|
}
|
|
151
228
|
|
|
@@ -194,6 +271,8 @@ export class FluxClient {
|
|
|
194
271
|
private configSent = false;
|
|
195
272
|
private listeners = new Set<() => void>();
|
|
196
273
|
private store: BlockStore = emptyBlockStore();
|
|
274
|
+
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
275
|
+
private onBlock?: (block: Block) => void;
|
|
197
276
|
|
|
198
277
|
// Perf
|
|
199
278
|
private appendedBytes = 0;
|
|
@@ -209,10 +288,26 @@ export class FluxClient {
|
|
|
209
288
|
* process-wide pool — pass a dedicated `FluxPool` only for isolation).
|
|
210
289
|
* @param options.config per-stream parser flags (see {@link ParserConfig});
|
|
211
290
|
* omitted fields use library defaults. Applied once, immutable thereafter.
|
|
291
|
+
* @param options.onError invoked on a worker/parse error or a fatal WASM-init
|
|
292
|
+
* failure (`fatal: true`). Without it, errors are only `console.error`d and
|
|
293
|
+
* a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
|
|
294
|
+
* @param options.onBlock invoked once per block as it commits (in document
|
|
295
|
+
* order, after the store updates) — for side effects like lazily
|
|
296
|
+
* highlighting a finished code block or analytics. A committed block never
|
|
297
|
+
* re-fires; the streaming tail does not (subscribe for live tail updates).
|
|
212
298
|
*/
|
|
213
|
-
constructor(
|
|
299
|
+
constructor(
|
|
300
|
+
options: {
|
|
301
|
+
pool?: FluxPool;
|
|
302
|
+
config?: ParserConfig;
|
|
303
|
+
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
304
|
+
onBlock?: (block: Block) => void;
|
|
305
|
+
} = {},
|
|
306
|
+
) {
|
|
214
307
|
this.pool = options.pool ?? getDefaultPool();
|
|
215
308
|
this.config = options.config;
|
|
309
|
+
this.onError = options.onError;
|
|
310
|
+
this.onBlock = options.onBlock;
|
|
216
311
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
217
312
|
this.streamId = streamId;
|
|
218
313
|
this.pw = pw;
|
|
@@ -244,6 +339,37 @@ export class FluxClient {
|
|
|
244
339
|
this.pool.send(this.pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
245
340
|
}
|
|
246
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Pipe a byte stream straight in: read it to completion, `append()` each
|
|
344
|
+
* decoded chunk, then `finalize()`. The LLM-native path — `await
|
|
345
|
+
* client.pipeFrom(await fetch("/api/chat"))` (pass a `Response` or its
|
|
346
|
+
* `ReadableStream` body). `TextDecoder({ stream: true })` carries a multibyte
|
|
347
|
+
* sequence that straddles a chunk boundary into the next read. Resolves once
|
|
348
|
+
* finalized; rejects (without finalizing) if the stream errors/aborts — abort
|
|
349
|
+
* the underlying fetch to cancel. Browser-only (uses `TextDecoder`).
|
|
350
|
+
*/
|
|
351
|
+
async pipeFrom(source: ReadableStream<Uint8Array> | Response): Promise<void> {
|
|
352
|
+
const body = "body" in source ? source.body : source;
|
|
353
|
+
if (!body) {
|
|
354
|
+
// An empty Response body (e.g. 204) is a completed, empty stream.
|
|
355
|
+
this.finalize();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const reader = body.getReader();
|
|
359
|
+
const decoder = new TextDecoder();
|
|
360
|
+
try {
|
|
361
|
+
for (;;) {
|
|
362
|
+
const { done, value } = await reader.read();
|
|
363
|
+
if (done) break;
|
|
364
|
+
if (value) this.append(decoder.decode(value, { stream: true }));
|
|
365
|
+
}
|
|
366
|
+
this.append(decoder.decode()); // flush any trailing partial sequence
|
|
367
|
+
this.finalize();
|
|
368
|
+
} finally {
|
|
369
|
+
reader.releaseLock();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
247
373
|
reset() {
|
|
248
374
|
this.store = emptyBlockStore();
|
|
249
375
|
this.appendedBytes = 0;
|
|
@@ -290,6 +416,37 @@ export class FluxClient {
|
|
|
290
416
|
};
|
|
291
417
|
}
|
|
292
418
|
|
|
419
|
+
/**
|
|
420
|
+
* A heading outline of the current snapshot (committed + active), in document
|
|
421
|
+
* order — for a table of contents. Works mid-stream; entries appear as their
|
|
422
|
+
* headings stream in. The `id` is stable, so a built ToC won't re-key.
|
|
423
|
+
*/
|
|
424
|
+
outline(): OutlineEntry[] {
|
|
425
|
+
const out: OutlineEntry[] = [];
|
|
426
|
+
for (const b of this.store.snapshot) {
|
|
427
|
+
if (b.kind.type === "Heading") {
|
|
428
|
+
out.push({ level: (b.kind.data as number) ?? 1, text: htmlToText(b.html), id: b.id });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The rendered document as plain text — tags stripped, entities decoded,
|
|
436
|
+
* blocks separated by blank lines. Derived from the rendered HTML (the source
|
|
437
|
+
* markdown is parsed away in WASM and not retained client-side), so it is a
|
|
438
|
+
* readable approximation for search indexing / summaries, not a round-trip of
|
|
439
|
+
* the original source.
|
|
440
|
+
*/
|
|
441
|
+
toPlaintext(): string {
|
|
442
|
+
const parts: string[] = [];
|
|
443
|
+
for (const b of this.store.snapshot) {
|
|
444
|
+
const t = htmlToText(b.html);
|
|
445
|
+
if (t) parts.push(t);
|
|
446
|
+
}
|
|
447
|
+
return parts.join("\n\n");
|
|
448
|
+
}
|
|
449
|
+
|
|
293
450
|
private onMessage(msg: FromWorker) {
|
|
294
451
|
switch (msg.type) {
|
|
295
452
|
case "patch":
|
|
@@ -301,10 +458,20 @@ export class FluxClient {
|
|
|
301
458
|
this.patchCount += 1;
|
|
302
459
|
this.lastPatchMs = performance.now();
|
|
303
460
|
this.emit();
|
|
461
|
+
// After subscribers see the new snapshot, fire the per-block hook for
|
|
462
|
+
// anything that just committed (document order). A throw here is
|
|
463
|
+
// isolated by the pool's dispatch boundary and won't skip emit().
|
|
464
|
+
if (this.onBlock) {
|
|
465
|
+
for (const b of msg.patch.newly_committed) this.onBlock(b);
|
|
466
|
+
}
|
|
304
467
|
break;
|
|
305
468
|
case "error":
|
|
306
|
-
|
|
307
|
-
|
|
469
|
+
if (this.onError) {
|
|
470
|
+
this.onError({ message: msg.message, fatal: msg.fatal });
|
|
471
|
+
} else {
|
|
472
|
+
// eslint-disable-next-line no-console
|
|
473
|
+
console.error("flux worker error:", msg.message);
|
|
474
|
+
}
|
|
308
475
|
break;
|
|
309
476
|
}
|
|
310
477
|
}
|
package/src/element.ts
CHANGED
|
@@ -38,6 +38,7 @@ const CONFIG_ATTRS = [
|
|
|
38
38
|
"gfm-footnotes",
|
|
39
39
|
"gfm-math",
|
|
40
40
|
"dir-auto",
|
|
41
|
+
"a11y",
|
|
41
42
|
"unsafe-html",
|
|
42
43
|
];
|
|
43
44
|
|
|
@@ -61,6 +62,14 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
61
62
|
#sanitize: ((html: string) => string) | undefined = undefined;
|
|
62
63
|
#handle: MountHandle | null = null;
|
|
63
64
|
#connected = false;
|
|
65
|
+
// In-flight `src` fetch supersession. A self-owned client is REUSED across
|
|
66
|
+
// src changes (not torn down), so two concurrent #streamFromSrc runs would
|
|
67
|
+
// capture the same client and reset() even reuses the worker streamId — an
|
|
68
|
+
// identity guard alone can't separate them. Each run captures the current
|
|
69
|
+
// #srcSeq; a newer src (or teardown) bumps it and aborts the fetch, so a
|
|
70
|
+
// stale run bails before interleaving its chunks into the parser.
|
|
71
|
+
#srcSeq = 0;
|
|
72
|
+
#srcAbort: AbortController | null = null;
|
|
64
73
|
|
|
65
74
|
// --- Accessor properties (objects/functions can't be attributes) ---------
|
|
66
75
|
|
|
@@ -96,17 +105,24 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
96
105
|
// --- Self-owned-client methods -------------------------------------------
|
|
97
106
|
|
|
98
107
|
append(chunk: string): void {
|
|
108
|
+
// Manual drive supersedes any in-flight `src` fetch (mixing the two is out
|
|
109
|
+
// of contract; this makes the manual stream win predictably instead of
|
|
110
|
+
// interleaving a late fetch chunk into it).
|
|
111
|
+
this.#cancelSrcStream();
|
|
99
112
|
this.#ensureClient();
|
|
100
113
|
this.#client!.append(chunk);
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
finalize(): void {
|
|
104
117
|
// Only meaningful for a self-owned stream; a no-op if no client yet.
|
|
118
|
+
this.#cancelSrcStream();
|
|
105
119
|
this.#client?.finalize();
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
reset(): void {
|
|
109
|
-
// Keep config; just clear the current stream's blocks.
|
|
123
|
+
// Keep config; just clear the current stream's blocks. Also abandon any
|
|
124
|
+
// in-flight `src` fetch so it can't append into the freshly-reset stream.
|
|
125
|
+
this.#cancelSrcStream();
|
|
110
126
|
this.#client?.reset();
|
|
111
127
|
}
|
|
112
128
|
|
|
@@ -171,6 +187,8 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
171
187
|
|
|
172
188
|
disconnectedCallback(): void {
|
|
173
189
|
this.#connected = false;
|
|
190
|
+
// Stop any in-flight `src` fetch before we (maybe) destroy its client.
|
|
191
|
+
this.#cancelSrcStream();
|
|
174
192
|
// ALWAYS tear down the mount (the only teardown path for the renderer).
|
|
175
193
|
this.#handle?.destroy();
|
|
176
194
|
this.#handle = null;
|
|
@@ -210,6 +228,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
210
228
|
set("gfm-footnotes", "gfmFootnotes");
|
|
211
229
|
set("gfm-math", "gfmMath");
|
|
212
230
|
set("dir-auto", "dirAuto");
|
|
231
|
+
set("a11y", "a11y");
|
|
213
232
|
set("unsafe-html", "unsafeHtml");
|
|
214
233
|
|
|
215
234
|
const tags = this.getAttribute("component-tags");
|
|
@@ -252,6 +271,8 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
252
271
|
// Destroys the client only if self-owned, then clears it and the mount so
|
|
253
272
|
// the next mount targets a fresh client.
|
|
254
273
|
#teardownClient(): void {
|
|
274
|
+
// A swap/destroy abandons the current client; stop feeding it from src.
|
|
275
|
+
this.#cancelSrcStream();
|
|
255
276
|
this.#handle?.destroy();
|
|
256
277
|
this.#handle = null;
|
|
257
278
|
if (this.#ownsClient) this.#client?.destroy();
|
|
@@ -263,6 +284,12 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
263
284
|
// in priority order: `src` (fetch+stream) > `markdown` (one-shot) >
|
|
264
285
|
// textContent (one-shot). A caller-owned client never reaches here.
|
|
265
286
|
#resolveInitialContent(): void {
|
|
287
|
+
// Single chokepoint: every content-source resolution supersedes any
|
|
288
|
+
// in-flight `src` fetch. This covers the src→markdown / src→textContent
|
|
289
|
+
// transitions too — #oneShot reuses (resets + finalizes) the same client,
|
|
290
|
+
// so without this a still-pending fetch would append into the finished
|
|
291
|
+
// stream. (#streamFromSrc bumps again; the extra bump is harmless.)
|
|
292
|
+
this.#cancelSrcStream();
|
|
266
293
|
const src = this.getAttribute("src");
|
|
267
294
|
if (src) {
|
|
268
295
|
void this.#streamFromSrc(src);
|
|
@@ -301,17 +328,38 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
301
328
|
this.#client!.finalize();
|
|
302
329
|
}
|
|
303
330
|
|
|
331
|
+
// Abort any in-flight `src` fetch and invalidate its read loop, so it can
|
|
332
|
+
// no longer append into a client we're about to reuse, swap, or destroy.
|
|
333
|
+
#cancelSrcStream(): void {
|
|
334
|
+
this.#srcSeq++;
|
|
335
|
+
this.#srcAbort?.abort();
|
|
336
|
+
this.#srcAbort = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
304
339
|
// Fetch a URL and stream its body. TextDecoder with {stream:true} carries a
|
|
305
340
|
// multibyte sequence that straddles a chunk boundary into the next decode.
|
|
306
341
|
async #streamFromSrc(src: string): Promise<void> {
|
|
342
|
+
// Supersede any prior in-flight src, then tag this run with a fresh token.
|
|
343
|
+
this.#cancelSrcStream();
|
|
344
|
+
const token = this.#srcSeq;
|
|
345
|
+
const abort = new AbortController();
|
|
346
|
+
this.#srcAbort = abort;
|
|
347
|
+
|
|
307
348
|
this.#ensureClient();
|
|
308
349
|
this.#client!.reset();
|
|
309
350
|
const owned = this.#client!;
|
|
351
|
+
// True while THIS run is still the active stream: not superseded by a
|
|
352
|
+
// newer src, and the client wasn't swapped/destroyed out from under us.
|
|
353
|
+
const current = () => this.#srcSeq === token && this.#client === owned;
|
|
354
|
+
|
|
310
355
|
try {
|
|
311
|
-
const res = await fetch(src);
|
|
356
|
+
const res = await fetch(src, { signal: abort.signal });
|
|
357
|
+
if (!current()) return;
|
|
312
358
|
const body = res.body;
|
|
313
359
|
if (!body) {
|
|
314
|
-
|
|
360
|
+
const text = await res.text();
|
|
361
|
+
if (!current()) return;
|
|
362
|
+
owned.append(text);
|
|
315
363
|
owned.finalize();
|
|
316
364
|
return;
|
|
317
365
|
}
|
|
@@ -319,16 +367,15 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
319
367
|
const decoder = new TextDecoder();
|
|
320
368
|
for (;;) {
|
|
321
369
|
const { done, value } = await reader.read();
|
|
322
|
-
|
|
323
|
-
// the client swapped mid-stream; stop feeding a stale client.
|
|
324
|
-
if (this.#client !== owned) return;
|
|
370
|
+
if (!current()) return;
|
|
325
371
|
if (done) break;
|
|
326
372
|
if (value) owned.append(decoder.decode(value, { stream: true }));
|
|
327
373
|
}
|
|
328
|
-
if (this.#client !== owned) return;
|
|
329
374
|
owned.append(decoder.decode()); // flush any trailing partial sequence
|
|
330
375
|
owned.finalize();
|
|
331
376
|
} catch (err) {
|
|
377
|
+
// A supersede/disconnect aborts the fetch — intentional, not an error.
|
|
378
|
+
if (abort.signal.aborted || !current()) return;
|
|
332
379
|
// eslint-disable-next-line no-console
|
|
333
380
|
console.error("<flux-markdown>: failed to stream src", src, err);
|
|
334
381
|
}
|
package/src/types-core.ts
CHANGED
|
@@ -89,6 +89,13 @@ export interface ParserConfig {
|
|
|
89
89
|
* apps that render RTL or mixed-direction content.
|
|
90
90
|
*/
|
|
91
91
|
dirAuto?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Opt-in accessibility markup that deviates from strict GFM byte-output:
|
|
94
|
+
* wraps a task-list checkbox + its text in a `<label>` (programmatic
|
|
95
|
+
* association for screen readers) and adds `scope="col"` to table header
|
|
96
|
+
* cells. Default false (so CommonMark/GFM conformance output is unchanged).
|
|
97
|
+
*/
|
|
98
|
+
a11y?: boolean;
|
|
92
99
|
/** Pass raw HTML through unescaped. Default false. **Never enable for untrusted input.** */
|
|
93
100
|
unsafeHtml?: boolean;
|
|
94
101
|
/**
|
|
@@ -124,7 +131,9 @@ export type FromWorker =
|
|
|
124
131
|
retainedBytes: number;
|
|
125
132
|
wasmMemoryBytes: number;
|
|
126
133
|
}
|
|
127
|
-
|
|
134
|
+
// `fatal` marks a worker-level failure (WASM init) that dooms every stream on
|
|
135
|
+
// the worker — not a single parse error. It carries no meaningful streamId.
|
|
136
|
+
| { type: "error"; streamId: number; message: string; fatal?: boolean };
|
|
128
137
|
|
|
129
138
|
/**
|
|
130
139
|
* Minimal structural interface satisfied by the DOM `Worker`. Injectable so the
|
|
@@ -14,6 +14,12 @@ export class FluxParser {
|
|
|
14
14
|
* memory cost against alternatives.
|
|
15
15
|
*/
|
|
16
16
|
retainedBytes(): number;
|
|
17
|
+
/**
|
|
18
|
+
* Opt-in accessibility markup that deviates from strict GFM byte-output:
|
|
19
|
+
* `<label>`-wrap a task-list checkbox with its text, and add `scope="col"`
|
|
20
|
+
* to table header cells. Off by default (conformance output unchanged).
|
|
21
|
+
*/
|
|
22
|
+
setA11y(on: boolean): void;
|
|
17
23
|
/**
|
|
18
24
|
* Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
|
|
19
25
|
* A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
|
|
@@ -67,6 +73,7 @@ export interface InitOutput {
|
|
|
67
73
|
readonly fluxparser_finalize: (a: number, b: number) => void;
|
|
68
74
|
readonly fluxparser_new: () => number;
|
|
69
75
|
readonly fluxparser_retainedBytes: (a: number) => number;
|
|
76
|
+
readonly fluxparser_setA11y: (a: number, b: number) => void;
|
|
70
77
|
readonly fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
|
|
71
78
|
readonly fluxparser_setDirAuto: (a: number, b: number) => void;
|
|
72
79
|
readonly fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
package/src/wasm/flux_md_core.js
CHANGED
|
@@ -73,6 +73,15 @@ export class FluxParser {
|
|
|
73
73
|
const ret = wasm.fluxparser_retainedBytes(this.__wbg_ptr);
|
|
74
74
|
return ret >>> 0;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Opt-in accessibility markup that deviates from strict GFM byte-output:
|
|
78
|
+
* `<label>`-wrap a task-list checkbox with its text, and add `scope="col"`
|
|
79
|
+
* to table header cells. Off by default (conformance output unchanged).
|
|
80
|
+
* @param {boolean} on
|
|
81
|
+
*/
|
|
82
|
+
setA11y(on) {
|
|
83
|
+
wasm.fluxparser_setA11y(this.__wbg_ptr, on);
|
|
84
|
+
}
|
|
76
85
|
/**
|
|
77
86
|
* Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
|
|
78
87
|
* A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
|
|
Binary file
|
|
@@ -7,6 +7,7 @@ export const fluxparser_bufferLen: (a: number) => number;
|
|
|
7
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
|
+
export const fluxparser_setA11y: (a: number, b: number) => void;
|
|
10
11
|
export const fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
|
|
11
12
|
export const fluxparser_setDirAuto: (a: number, b: number) => void;
|
|
12
13
|
export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
package/src/wasm/package.json
CHANGED
package/src/worker.ts
CHANGED
|
@@ -27,8 +27,16 @@ async function setup() {
|
|
|
27
27
|
// init() returns the wasm-bindgen instance; capture its `.memory` export so
|
|
28
28
|
// we can report WASM-side memory usage on every patch. No parser yet — they
|
|
29
29
|
// are created per stream, on demand.
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
try {
|
|
31
|
+
wasmExports = await init({ module_or_path: wasmUrl });
|
|
32
|
+
post({ type: "ready" });
|
|
33
|
+
} catch (e: unknown) {
|
|
34
|
+
// WASM failed to load/instantiate: this worker can never parse anything.
|
|
35
|
+
// Report it so the pool rejects whenReady() (rather than hanging forever)
|
|
36
|
+
// and clients' onError fires. streamId is irrelevant for a worker-level
|
|
37
|
+
// failure — the pool routes a fatal error to every stream it hosts.
|
|
38
|
+
post({ type: "error", streamId: -1, message: e instanceof Error ? e.message : String(e), fatal: true });
|
|
39
|
+
}
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
function getOrCreate(streamId: number): FluxParser {
|
|
@@ -44,6 +52,7 @@ function getOrCreate(streamId: number): FluxParser {
|
|
|
44
52
|
p.setGfmFootnotes(c?.gfmFootnotes ?? false);
|
|
45
53
|
p.setGfmMath(c?.gfmMath ?? false);
|
|
46
54
|
p.setDirAuto(c?.dirAuto ?? false);
|
|
55
|
+
p.setA11y(c?.a11y ?? false);
|
|
47
56
|
p.setUnsafeHtml(c?.unsafeHtml ?? false);
|
|
48
57
|
p.setComponentTags(c?.componentTags ?? []);
|
|
49
58
|
parsers.set(streamId, p);
|