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 +40 -0
- package/README.md +31 -2
- package/package.json +2 -1
- package/src/client.ts +110 -10
- package/src/element.ts +49 -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,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?: {
|
|
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.
|
|
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. `&` decodes last so
|
|
38
|
+
* `&lt;` → `<`, not `<`. */
|
|
39
|
+
function htmlToText(html: string): string {
|
|
40
|
+
return html
|
|
41
|
+
.replace(/<[^>]*>/g, " ")
|
|
42
|
+
.replace(/</g, "<")
|
|
43
|
+
.replace(/>/g, ">")
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, "'")
|
|
46
|
+
.replace(/&/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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
144
|
-
pw.
|
|
145
|
-
for (const
|
|
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(
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|