flux-md 0.6.0 → 0.7.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,46 @@ 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.7.0 — 2026-05-29
8
+
9
+ DX, robustness, and accessibility round — the streaming core (perf, CommonMark
10
+ 652/652, GFM) was already comprehensive, so this release sharpens the surface
11
+ around it.
12
+
13
+ ### Added
14
+
15
+ - **`onError` on `FluxClient`** — `new FluxClient({ onError })` receives worker
16
+ and parse errors (previously only `console.error`'d). A **WASM-init failure**
17
+ now also surfaces: `whenReady()` **rejects** instead of hanging forever, and
18
+ `onError` fires with `{ fatal: true }`.
19
+ - **`a11y` parser option** (`ParserConfig.a11y` / `setA11y` / `<flux-markdown
20
+ a11y>`) — opt-in accessibility markup that intentionally deviates from strict
21
+ GFM byte-output: wraps a task-list checkbox + its text in a `<label>` (so the
22
+ box is programmatically associated for screen readers), and adds
23
+ `scope="col"` to table header cells. **Off by default** (conformance output
24
+ unchanged). Streaming output stays byte-identical to one-shot.
25
+ - **`FluxClient.outline()`** — a heading table-of-contents (level / text /
26
+ stable id) from the current snapshot, in document order; works mid-stream.
27
+ - **`FluxClient.toPlaintext()`** — the rendered document as plain text (tags
28
+ stripped, entities decoded, blocks blank-line separated) for search indexing
29
+ / summaries.
30
+
31
+ ### Fixed
32
+
33
+ - **`<flux-markdown>` `src` race** — rapidly changing `src` (or switching
34
+ between a `src` URL and inline `markdown`/`textContent`) could interleave two
35
+ fetch streams into one parser, corrupting the parse tree. The element now
36
+ supersedes any in-flight fetch (monotonic token + `AbortController`) at a
37
+ single chokepoint.
38
+
39
+ ### Docs / packaging
40
+
41
+ - README documents the one-line Vite `optimizeDeps.exclude` requirement.
42
+ - `"sideEffects": ["./src/worker.ts"]` so bundlers can drop unused framework
43
+ adapters from the export surface.
44
+ - CI now publishes via a tag-triggered workflow with `npm publish --provenance`,
45
+ and asserts every published tarball ships a non-empty WASM artifact.
46
+
7
47
  ## 0.6.0 — 2026-05-28
8
48
 
9
49
  ### 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,29 @@ 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
+ });
248
266
  append(chunk: string): void; // queue text for parsing
249
267
  finalize(): void; // mark stream complete
250
268
  reset(): void; // wipe and reuse
251
269
  destroy(): void; // free this stream's parser
252
- whenReady(): Promise<void>; // resolves once WASM loaded
270
+ whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
253
271
  subscribe(listener: () => void): () => void; // React-friendly store
254
272
  getSnapshot(): Block[]; // ordered current blocks
273
+ outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
274
+ toPlaintext(): string; // rendered document as plain text (search / summaries)
255
275
  getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
256
276
  retainedBytes, wasmMemoryBytes, ... };
257
277
  }
258
278
  ```
259
279
 
280
+ Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
281
+ failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
282
+ load failure surfaces as a rejected `whenReady()`.
283
+
260
284
  #### Per-stream config
261
285
 
262
286
  ```ts
@@ -267,6 +291,7 @@ const client = new FluxClient({
267
291
  gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
268
292
  gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
269
293
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
294
+ a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
270
295
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
271
296
  componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
272
297
  },
@@ -288,6 +313,10 @@ When to enable each flag:
288
313
  definitions. Off by default; see the footnote streaming caveat above.
289
314
  - `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
290
315
  `dir="auto"` so the browser detects direction independently per block.
316
+ - `a11y: true` — opt-in accessibility markup that deviates from strict GFM
317
+ byte-output: wraps task-list checkboxes in a `<label>` (screen-reader
318
+ association) and adds `scope="col"` to table headers. Off by default so
319
+ conformance output stays exact.
291
320
  - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
292
321
  LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
293
322
  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.7.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,32 @@ 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. The core's HTML is well-formed and escapes `>` inside
37
+ * attributes, so the simple tag regex is safe here. `&amp;` decodes last so
38
+ * `&amp;lt;` → `&lt;`, not `<`. */
39
+ function htmlToText(html: string): string {
40
+ return html
41
+ .replace(/<[^>]*>/g, " ")
42
+ .replace(/&lt;/g, "<")
43
+ .replace(/&gt;/g, ">")
44
+ .replace(/&quot;/g, '"')
45
+ .replace(/&#39;/g, "'")
46
+ .replace(/&amp;/g, "&")
47
+ .replace(/\s+/g, " ")
48
+ .trim();
49
+ }
50
+
25
51
  export function applyPatch(store: BlockStore, patch: Patch): void {
26
52
  for (const b of patch.newly_committed) {
27
53
  if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
@@ -47,8 +73,12 @@ export function applyPatch(store: BlockStore, patch: Patch): void {
47
73
  interface PoolWorker {
48
74
  worker: WorkerLike;
49
75
  ready: boolean;
76
+ /** Set once WASM init fails; whenWorkerReady rejects with this thereafter. */
77
+ failed: Error | null;
50
78
  streamCount: number;
51
- readyResolvers: Array<() => void>;
79
+ /** Live stream ids on this worker — so a fatal failure can notify each one. */
80
+ streamIds: Set<number>;
81
+ readyWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }>;
52
82
  }
53
83
 
54
84
  /**
@@ -79,6 +109,7 @@ export class FluxPool {
79
109
  const streamId = this.nextStreamId++;
80
110
  const pw = this.pick();
81
111
  pw.streamCount++;
112
+ pw.streamIds.add(streamId);
82
113
  this.handlers.set(streamId, handler);
83
114
  return { streamId, pw };
84
115
  }
@@ -86,6 +117,7 @@ export class FluxPool {
86
117
  /** Free a stream's parser in its worker; keep the worker warm for siblings. */
87
118
  release(streamId: number, pw: PoolWorker): void {
88
119
  this.handlers.delete(streamId);
120
+ pw.streamIds.delete(streamId);
89
121
  pw.streamCount = Math.max(0, pw.streamCount - 1);
90
122
  try {
91
123
  pw.worker.postMessage({ type: "dispose", streamId });
@@ -98,10 +130,11 @@ export class FluxPool {
98
130
  pw.worker.postMessage(msg);
99
131
  }
100
132
 
101
- /** Resolves when the given worker has finished WASM init. */
133
+ /** Resolves when the given worker has finished WASM init; rejects if it failed. */
102
134
  whenWorkerReady(pw: PoolWorker): Promise<void> {
103
135
  if (pw.ready) return Promise.resolve();
104
- return new Promise((resolve) => pw.readyResolvers.push(resolve));
136
+ if (pw.failed) return Promise.reject(pw.failed);
137
+ return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
105
138
  }
106
139
 
107
140
  /** Terminate every worker (test teardown / full shutdown). */
@@ -131,7 +164,14 @@ export class FluxPool {
131
164
  }
132
165
 
133
166
  private create(): PoolWorker {
134
- const pw: PoolWorker = { worker: this.factory(), ready: false, streamCount: 0, readyResolvers: [] };
167
+ const pw: PoolWorker = {
168
+ worker: this.factory(),
169
+ ready: false,
170
+ failed: null,
171
+ streamCount: 0,
172
+ streamIds: new Set(),
173
+ readyWaiters: [],
174
+ };
135
175
  pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
136
176
  this.workers.push(pw);
137
177
  return pw;
@@ -140,9 +180,23 @@ export class FluxPool {
140
180
  private onMessage(pw: PoolWorker, msg: FromWorker): void {
141
181
  if (msg.type === "ready") {
142
182
  pw.ready = true;
143
- const resolvers = pw.readyResolvers;
144
- pw.readyResolvers = [];
145
- for (const r of resolvers) r();
183
+ const waiters = pw.readyWaiters;
184
+ pw.readyWaiters = [];
185
+ for (const w of waiters) w.resolve();
186
+ return;
187
+ }
188
+ if (msg.type === "error" && msg.fatal) {
189
+ // A fatal (WASM-init) failure dooms every stream on this worker. Reject
190
+ // anyone awaiting readiness, then notify each live stream's client so its
191
+ // onError fires — the message carries no real streamId to route by. The
192
+ // doomed worker stays in the pool: a later stream that pick()s it rejects
193
+ // immediately via pw.failed (no auto-recovery — fine for a load failure).
194
+ const err = new Error(msg.message);
195
+ pw.failed = err;
196
+ const waiters = pw.readyWaiters;
197
+ pw.readyWaiters = [];
198
+ for (const w of waiters) w.reject(err);
199
+ for (const sid of pw.streamIds) this.handlers.get(sid)?.(msg);
146
200
  return;
147
201
  }
148
202
  this.handlers.get(msg.streamId)?.(msg);
@@ -194,6 +248,7 @@ export class FluxClient {
194
248
  private configSent = false;
195
249
  private listeners = new Set<() => void>();
196
250
  private store: BlockStore = emptyBlockStore();
251
+ private onError?: (err: { message: string; fatal?: boolean }) => void;
197
252
 
198
253
  // Perf
199
254
  private appendedBytes = 0;
@@ -209,10 +264,20 @@ export class FluxClient {
209
264
  * process-wide pool — pass a dedicated `FluxPool` only for isolation).
210
265
  * @param options.config per-stream parser flags (see {@link ParserConfig});
211
266
  * omitted fields use library defaults. Applied once, immutable thereafter.
267
+ * @param options.onError invoked on a worker/parse error or a fatal WASM-init
268
+ * failure (`fatal: true`). Without it, errors are only `console.error`d and
269
+ * a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
212
270
  */
213
- constructor(options: { pool?: FluxPool; config?: ParserConfig } = {}) {
271
+ constructor(
272
+ options: {
273
+ pool?: FluxPool;
274
+ config?: ParserConfig;
275
+ onError?: (err: { message: string; fatal?: boolean }) => void;
276
+ } = {},
277
+ ) {
214
278
  this.pool = options.pool ?? getDefaultPool();
215
279
  this.config = options.config;
280
+ this.onError = options.onError;
216
281
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
217
282
  this.streamId = streamId;
218
283
  this.pw = pw;
@@ -290,6 +355,37 @@ export class FluxClient {
290
355
  };
291
356
  }
292
357
 
358
+ /**
359
+ * A heading outline of the current snapshot (committed + active), in document
360
+ * order — for a table of contents. Works mid-stream; entries appear as their
361
+ * headings stream in. The `id` is stable, so a built ToC won't re-key.
362
+ */
363
+ outline(): OutlineEntry[] {
364
+ const out: OutlineEntry[] = [];
365
+ for (const b of this.store.snapshot) {
366
+ if (b.kind.type === "Heading") {
367
+ out.push({ level: (b.kind.data as number) ?? 1, text: htmlToText(b.html), id: b.id });
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+
373
+ /**
374
+ * The rendered document as plain text — tags stripped, entities decoded,
375
+ * blocks separated by blank lines. Derived from the rendered HTML (the source
376
+ * markdown is parsed away in WASM and not retained client-side), so it is a
377
+ * readable approximation for search indexing / summaries, not a round-trip of
378
+ * the original source.
379
+ */
380
+ toPlaintext(): string {
381
+ const parts: string[] = [];
382
+ for (const b of this.store.snapshot) {
383
+ const t = htmlToText(b.html);
384
+ if (t) parts.push(t);
385
+ }
386
+ return parts.join("\n\n");
387
+ }
388
+
293
389
  private onMessage(msg: FromWorker) {
294
390
  switch (msg.type) {
295
391
  case "patch":
@@ -303,8 +399,12 @@ export class FluxClient {
303
399
  this.emit();
304
400
  break;
305
401
  case "error":
306
- // eslint-disable-next-line no-console
307
- console.error("flux worker error:", msg.message);
402
+ if (this.onError) {
403
+ this.onError({ message: msg.message, fatal: msg.fatal });
404
+ } else {
405
+ // eslint-disable-next-line no-console
406
+ console.error("flux worker error:", msg.message);
407
+ }
308
408
  break;
309
409
  }
310
410
  }
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
 
@@ -106,7 +115,9 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
106
115
  }
107
116
 
108
117
  reset(): void {
109
- // Keep config; just clear the current stream's blocks.
118
+ // Keep config; just clear the current stream's blocks. Also abandon any
119
+ // in-flight `src` fetch so it can't append into the freshly-reset stream.
120
+ this.#cancelSrcStream();
110
121
  this.#client?.reset();
111
122
  }
112
123
 
@@ -171,6 +182,8 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
171
182
 
172
183
  disconnectedCallback(): void {
173
184
  this.#connected = false;
185
+ // Stop any in-flight `src` fetch before we (maybe) destroy its client.
186
+ this.#cancelSrcStream();
174
187
  // ALWAYS tear down the mount (the only teardown path for the renderer).
175
188
  this.#handle?.destroy();
176
189
  this.#handle = null;
@@ -210,6 +223,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
210
223
  set("gfm-footnotes", "gfmFootnotes");
211
224
  set("gfm-math", "gfmMath");
212
225
  set("dir-auto", "dirAuto");
226
+ set("a11y", "a11y");
213
227
  set("unsafe-html", "unsafeHtml");
214
228
 
215
229
  const tags = this.getAttribute("component-tags");
@@ -252,6 +266,8 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
252
266
  // Destroys the client only if self-owned, then clears it and the mount so
253
267
  // the next mount targets a fresh client.
254
268
  #teardownClient(): void {
269
+ // A swap/destroy abandons the current client; stop feeding it from src.
270
+ this.#cancelSrcStream();
255
271
  this.#handle?.destroy();
256
272
  this.#handle = null;
257
273
  if (this.#ownsClient) this.#client?.destroy();
@@ -263,6 +279,12 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
263
279
  // in priority order: `src` (fetch+stream) > `markdown` (one-shot) >
264
280
  // textContent (one-shot). A caller-owned client never reaches here.
265
281
  #resolveInitialContent(): void {
282
+ // Single chokepoint: every content-source resolution supersedes any
283
+ // in-flight `src` fetch. This covers the src→markdown / src→textContent
284
+ // transitions too — #oneShot reuses (resets + finalizes) the same client,
285
+ // so without this a still-pending fetch would append into the finished
286
+ // stream. (#streamFromSrc bumps again; the extra bump is harmless.)
287
+ this.#cancelSrcStream();
266
288
  const src = this.getAttribute("src");
267
289
  if (src) {
268
290
  void this.#streamFromSrc(src);
@@ -301,17 +323,38 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
301
323
  this.#client!.finalize();
302
324
  }
303
325
 
326
+ // Abort any in-flight `src` fetch and invalidate its read loop, so it can
327
+ // no longer append into a client we're about to reuse, swap, or destroy.
328
+ #cancelSrcStream(): void {
329
+ this.#srcSeq++;
330
+ this.#srcAbort?.abort();
331
+ this.#srcAbort = null;
332
+ }
333
+
304
334
  // Fetch a URL and stream its body. TextDecoder with {stream:true} carries a
305
335
  // multibyte sequence that straddles a chunk boundary into the next decode.
306
336
  async #streamFromSrc(src: string): Promise<void> {
337
+ // Supersede any prior in-flight src, then tag this run with a fresh token.
338
+ this.#cancelSrcStream();
339
+ const token = this.#srcSeq;
340
+ const abort = new AbortController();
341
+ this.#srcAbort = abort;
342
+
307
343
  this.#ensureClient();
308
344
  this.#client!.reset();
309
345
  const owned = this.#client!;
346
+ // True while THIS run is still the active stream: not superseded by a
347
+ // newer src, and the client wasn't swapped/destroyed out from under us.
348
+ const current = () => this.#srcSeq === token && this.#client === owned;
349
+
310
350
  try {
311
- const res = await fetch(src);
351
+ const res = await fetch(src, { signal: abort.signal });
352
+ if (!current()) return;
312
353
  const body = res.body;
313
354
  if (!body) {
314
- owned.append(await res.text());
355
+ const text = await res.text();
356
+ if (!current()) return;
357
+ owned.append(text);
315
358
  owned.finalize();
316
359
  return;
317
360
  }
@@ -319,16 +362,15 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
319
362
  const decoder = new TextDecoder();
320
363
  for (;;) {
321
364
  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;
365
+ if (!current()) return;
325
366
  if (done) break;
326
367
  if (value) owned.append(decoder.decode(value, { stream: true }));
327
368
  }
328
- if (this.#client !== owned) return;
329
369
  owned.append(decoder.decode()); // flush any trailing partial sequence
330
370
  owned.finalize();
331
371
  } catch (err) {
372
+ // A supersede/disconnect aborts the fetch — intentional, not an error.
373
+ if (abort.signal.aborted || !current()) return;
332
374
  // eslint-disable-next-line no-console
333
375
  console.error("<flux-markdown>: failed to stream src", src, err);
334
376
  }
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.7.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);