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 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?: { pool?: FluxPool; config?: ParserConfig });
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.6.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. `&amp;` decodes last so `&amp;lt;` → `&lt;`, not `<`. */
41
+ function htmlToText(html: string): string {
42
+ return html
43
+ .replace(/<[^>]*>/g, " ")
44
+ .replace(/&lt;/g, "<")
45
+ .replace(/&gt;/g, ">")
46
+ .replace(/&quot;/g, '"')
47
+ .replace(/&#39;/g, "'")
48
+ .replace(/&amp;/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
- readyResolvers: Array<() => void>;
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
- return new Promise((resolve) => pw.readyResolvers.push(resolve));
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 existing worker is busy;
125
- // otherwise attach to the least-loaded existing worker.
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
- if (this.workers.length < this.cap && this.workers.every((w) => w.streamCount > 0)) {
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
- return this.workers.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
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 = { worker: this.factory(), ready: false, streamCount: 0, readyResolvers: [] };
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 resolvers = pw.readyResolvers;
144
- pw.readyResolvers = [];
145
- for (const r of resolvers) r();
189
+ const waiters = pw.readyWaiters;
190
+ pw.readyWaiters = [];
191
+ for (const w of waiters) w.resolve();
146
192
  return;
147
193
  }
148
- this.handlers.get(msg.streamId)?.(msg);
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(options: { pool?: FluxPool; config?: ParserConfig } = {}) {
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
- // eslint-disable-next-line no-console
307
- console.error("flux worker error:", msg.message);
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
- owned.append(await res.text());
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
- // The element may have been disconnected (and `owned` destroyed) or
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
- | { type: "error"; streamId: number; message: string };
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;
@@ -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;
@@ -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.1.0",
5
+ "version": "0.8.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",
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
- wasmExports = await init({ module_or_path: wasmUrl });
31
- post({ type: "ready" });
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);