flux-md 0.15.0 → 0.16.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/README.md +42 -1
- package/package.json +2 -3
- package/src/block-props.ts +13 -3
- package/src/client.ts +115 -3
- package/src/dom.ts +502 -14
- package/src/element.ts +9 -1
- package/src/hi.ts +5 -2
- package/src/html-to-react.ts +137 -8
- package/src/index.ts +2 -0
- package/src/morph.ts +253 -0
- package/src/react.tsx +511 -18
- package/src/server.tsx +5 -5
- package/src/solid.tsx +37 -2
- package/src/svelte.ts +26 -1
- package/src/types-core.ts +73 -3
- package/src/vue.ts +29 -2
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/CHANGELOG.md +0 -712
- package/src/wasm/package.json +0 -17
package/README.md
CHANGED
|
@@ -241,7 +241,11 @@ controlled-string helpers wrap; in vanilla you call it directly.
|
|
|
241
241
|
|
|
242
242
|
`mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
|
|
243
243
|
Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
|
|
244
|
-
(default true), `batch` (default true — one DOM write per `requestAnimationFrame`)
|
|
244
|
+
(default true), `batch` (default true — one DOM write per `requestAnimationFrame`),
|
|
245
|
+
`morphOpenBlocks` (default false — morph a growing generic open block's subtree in
|
|
246
|
+
place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
|
|
247
|
+
and focus/selection in the streaming tail survive; the rendered result is
|
|
248
|
+
equivalent to the default rebuild path).
|
|
245
249
|
Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
|
|
246
250
|
`Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
|
|
247
251
|
(lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
|
|
@@ -995,6 +999,43 @@ genuinely hostile content where CSS-overlay/clickjacking matters, render inside
|
|
|
995
999
|
a sandboxed `<iframe>` instead — sanitization stops injection, not every
|
|
996
1000
|
visual-overlay trick.
|
|
997
1001
|
|
|
1002
|
+
### Supply-chain transparency
|
|
1003
|
+
|
|
1004
|
+
flux-md is **zero runtime dependency** — no third-party packages are pulled in
|
|
1005
|
+
at runtime. The parsing core is Rust compiled to WebAssembly, reproducibly
|
|
1006
|
+
buildable from `crates/flux-md-core/` via `bun run build:wasm`.
|
|
1007
|
+
|
|
1008
|
+
**Native code (WebAssembly).** The shipped `flux_md_core_bg.wasm` (~200 KB) is
|
|
1009
|
+
first-party, built from the Rust source in this repo, and runs inside a sandboxed
|
|
1010
|
+
Web Worker (browser) or Node worker thread. Supply-chain scanners such as
|
|
1011
|
+
[Socket.dev](https://socket.dev) will flag it as `nativeCode` — this is accurate
|
|
1012
|
+
and expected. The WASM is not a vendored third-party binary; it is reproducible
|
|
1013
|
+
from source.
|
|
1014
|
+
|
|
1015
|
+
**Network access.** flux-md performs network I/O in exactly two scenarios, both
|
|
1016
|
+
caller-driven:
|
|
1017
|
+
|
|
1018
|
+
- `<flux-markdown src="URL">` — the Web Component fetches the URL you supply and
|
|
1019
|
+
streams the response. No URL is ever chosen by flux-md itself.
|
|
1020
|
+
- The wasm-bindgen glue (`wasm/flux_md_core.js`) loads the co-located `.wasm`
|
|
1021
|
+
asset via `fetch(new URL("…_bg.wasm", import.meta.url))` — bundlers resolve
|
|
1022
|
+
this to a local build artifact, not a remote endpoint.
|
|
1023
|
+
|
|
1024
|
+
flux-md has no telemetry, no analytics, and no first-party remote endpoints.
|
|
1025
|
+
Socket will flag the `networkAccess` signal — it is accurate and expected. In
|
|
1026
|
+
privileged contexts (browser extensions, Electron, environments where the
|
|
1027
|
+
same-origin policy may not apply), treat the `src` attribute value as you would
|
|
1028
|
+
any external URL and allowlist it in your CSP / security policy.
|
|
1029
|
+
|
|
1030
|
+
**Filesystem access (Node/SSR only).** `flux-md/server` reads the package's
|
|
1031
|
+
own `.wasm` file off disk on Node.js (Node's `fetch` cannot load `file://`
|
|
1032
|
+
URLs). This is a Node-only path; it reads only the package-internal asset and
|
|
1033
|
+
never touches caller-supplied paths. Socket will flag `filesystemAccess` — also
|
|
1034
|
+
accurate and expected.
|
|
1035
|
+
|
|
1036
|
+
The `socket.yml` at the repository root documents these signals with their
|
|
1037
|
+
justifications for Socket's GitHub app.
|
|
1038
|
+
|
|
998
1039
|
## Scaling
|
|
999
1040
|
|
|
1000
1041
|
`FluxClient`s share a **worker pool** (`getDefaultPool()`), so concurrency
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": ["./src/worker.ts", "./src/styles.css"],
|
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"src",
|
|
25
|
-
"README.md"
|
|
26
|
-
"CHANGELOG.md"
|
|
25
|
+
"README.md"
|
|
27
26
|
],
|
|
28
27
|
"peerDependencies": {
|
|
29
28
|
"react": ">=18",
|
package/src/block-props.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
Block,
|
|
3
3
|
BlockComponentProps,
|
|
4
4
|
CodeBlockData,
|
|
5
|
+
ContainerData,
|
|
5
6
|
HeadingData,
|
|
6
7
|
ListData,
|
|
7
8
|
MathBlockData,
|
|
@@ -86,7 +87,7 @@ export function blockProps(block: Block): BlockComponentProps {
|
|
|
86
87
|
speculative: block.speculative,
|
|
87
88
|
};
|
|
88
89
|
const data = block.kind.data as
|
|
89
|
-
| { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
|
|
90
|
+
| { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; items?: { html: string }[]; tag?: string; attrs?: [string, string][] }
|
|
90
91
|
| undefined;
|
|
91
92
|
if (block.kind.type === "CodeBlock") {
|
|
92
93
|
// Prefer the structured `code` (present when blockData is on) over the HTML
|
|
@@ -105,9 +106,10 @@ export function blockProps(block: Block): BlockComponentProps {
|
|
|
105
106
|
}
|
|
106
107
|
} else if (block.kind.type === "List") {
|
|
107
108
|
// Structured list data is present only when blockData is on (the `start` key
|
|
108
|
-
// rides the opt-in channel); surface it as the typed convenience field
|
|
109
|
+
// rides the opt-in channel); surface it as the typed convenience field,
|
|
110
|
+
// including the opt-in per-item HTML (`items`) for a keyed renderer.
|
|
109
111
|
if (data && typeof data.start === "number") {
|
|
110
|
-
props.list = { ordered: !!data.ordered, start: data.start } as ListData;
|
|
112
|
+
props.list = { ordered: !!data.ordered, start: data.start, items: data.items } as ListData;
|
|
111
113
|
}
|
|
112
114
|
} else if (block.kind.type === "Component") {
|
|
113
115
|
props.tag = data?.tag ?? "";
|
|
@@ -126,6 +128,14 @@ export function blockProps(block: Block): BlockComponentProps {
|
|
|
126
128
|
if (typeof block.kind.data === "object" && block.kind.data !== null) {
|
|
127
129
|
props.heading = block.kind.data as HeadingData;
|
|
128
130
|
}
|
|
131
|
+
} else if (block.kind.type === "Blockquote" || block.kind.type === "Alert") {
|
|
132
|
+
// When `blockData` is on, a Blockquote's `kind.data` is `{ nested }` and an
|
|
133
|
+
// Alert's is `{ kind, nested }`; off, a Blockquote has no `data` and an Alert
|
|
134
|
+
// only `{ kind }`. Surface the keyed `nested` sub-blocks only when present.
|
|
135
|
+
const cd = block.kind.data as { nested?: { html: string }[] } | undefined;
|
|
136
|
+
if (cd && Array.isArray(cd.nested)) {
|
|
137
|
+
props.container = { nested: cd.nested } as ContainerData;
|
|
138
|
+
}
|
|
129
139
|
}
|
|
130
140
|
return props;
|
|
131
141
|
}
|
package/src/client.ts
CHANGED
|
@@ -271,6 +271,15 @@ export function getDefaultPool(): FluxPool {
|
|
|
271
271
|
return defaultPool;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** TEST-ONLY: drop the process-wide default pool so the next {@link getDefaultPool}
|
|
275
|
+
* rebuilds it (lazily, with the current global `Worker`). Lets a test file that
|
|
276
|
+
* drives the default pool start from a clean, deterministic state regardless of
|
|
277
|
+
* which other file warmed it first in bun's shared test process. Not part of the
|
|
278
|
+
* public API and a no-op for normal runtime use. */
|
|
279
|
+
export function __resetDefaultPool(): void {
|
|
280
|
+
defaultPool = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
274
283
|
// --------------------------------------------------------------------------
|
|
275
284
|
// Client
|
|
276
285
|
// --------------------------------------------------------------------------
|
|
@@ -307,6 +316,20 @@ export class FluxClient {
|
|
|
307
316
|
private lastContent = "";
|
|
308
317
|
private contentDone = false;
|
|
309
318
|
|
|
319
|
+
// Opt-in rAF coalescing (see constructor `coalesce`). When on AND
|
|
320
|
+
// requestAnimationFrame exists, intra-frame emit()s collapse into ONE
|
|
321
|
+
// rAF-scheduled flush to listeners — the React useSyncExternalStore path then
|
|
322
|
+
// renders once per frame instead of once per patch (the DOM renderer already
|
|
323
|
+
// batches to 1/frame independently). Lossless: committed blocks are
|
|
324
|
+
// reference-stable, so a dropped intermediate notify only skips a tail-only
|
|
325
|
+
// render that the next flush supersedes. The finalize/done patch is exempt and
|
|
326
|
+
// flushes synchronously, never deferred to the next frame.
|
|
327
|
+
private coalesce = false;
|
|
328
|
+
private rafHandle: number | null = null;
|
|
329
|
+
// Set by finalize(); the next patch's emit flushes synchronously (a 'done'
|
|
330
|
+
// notification must not be deferred a frame) and clears it.
|
|
331
|
+
private finalizePending = false;
|
|
332
|
+
|
|
310
333
|
// Perf
|
|
311
334
|
private appendedBytes = 0;
|
|
312
335
|
private patchCount = 0;
|
|
@@ -315,6 +338,11 @@ export class FluxClient {
|
|
|
315
338
|
private firstAppendMs = 0;
|
|
316
339
|
private retainedBytes = 0;
|
|
317
340
|
private wasmMemoryBytes = 0;
|
|
341
|
+
// Render-path observability (advanced ONLY when an onRenderMetrics hook is
|
|
342
|
+
// wired into a renderer; zero-cost otherwise). renderCount = React BlockView
|
|
343
|
+
// body renders; rebuildCount = DOM node rebuilds.
|
|
344
|
+
private renderCount = 0;
|
|
345
|
+
private rebuildCount = 0;
|
|
318
346
|
|
|
319
347
|
/**
|
|
320
348
|
* @param options.pool worker pool to join (defaults to the shared
|
|
@@ -328,6 +356,14 @@ export class FluxClient {
|
|
|
328
356
|
* order, after the store updates) — for side effects like lazily
|
|
329
357
|
* highlighting a finished code block or analytics. A committed block never
|
|
330
358
|
* re-fires; the streaming tail does not (subscribe for live tail updates).
|
|
359
|
+
* @param options.coalesce opt-in (default `false`): collapse multiple
|
|
360
|
+
* intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
|
|
361
|
+
* flush to subscribers, so a React `useSyncExternalStore` consumer renders at
|
|
362
|
+
* most once per frame instead of once per patch. Lossless — committed blocks
|
|
363
|
+
* are reference-stable, so only superseded tail-only renders are skipped. The
|
|
364
|
+
* stream-completion (finalize) patch always flushes synchronously, and a
|
|
365
|
+
* pending frame is cancelled on `reset()`/`destroy()`. No effect when
|
|
366
|
+
* `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
|
|
331
367
|
*/
|
|
332
368
|
constructor(
|
|
333
369
|
options: {
|
|
@@ -335,12 +371,14 @@ export class FluxClient {
|
|
|
335
371
|
config?: ParserConfig;
|
|
336
372
|
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
337
373
|
onBlock?: (block: Block) => void;
|
|
374
|
+
coalesce?: boolean;
|
|
338
375
|
} = {},
|
|
339
376
|
) {
|
|
340
377
|
this.pool = options.pool ?? getDefaultPool();
|
|
341
378
|
this.config = options.config;
|
|
342
379
|
this.onError = options.onError;
|
|
343
380
|
this.onBlock = options.onBlock;
|
|
381
|
+
this.coalesce = options.coalesce ?? false;
|
|
344
382
|
}
|
|
345
383
|
|
|
346
384
|
/**
|
|
@@ -392,6 +430,9 @@ export class FluxClient {
|
|
|
392
430
|
|
|
393
431
|
finalize() {
|
|
394
432
|
const pw = this.ensureAcquired();
|
|
433
|
+
// The terminal patch this triggers must reach subscribers synchronously, not
|
|
434
|
+
// a frame late — mark it so the next emit flushes now even under coalescing.
|
|
435
|
+
this.finalizePending = true;
|
|
395
436
|
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
396
437
|
}
|
|
397
438
|
|
|
@@ -514,6 +555,11 @@ export class FluxClient {
|
|
|
514
555
|
}
|
|
515
556
|
|
|
516
557
|
reset() {
|
|
558
|
+
// Only notify subscribers if there was content to clear: resetting an
|
|
559
|
+
// already-empty store leaves the view empty either way, so skip the no-op
|
|
560
|
+
// emit (which would otherwise drive every subscriber through a wasted,
|
|
561
|
+
// output-identical render pass).
|
|
562
|
+
const hadContent = this.store.snapshot.length > 0;
|
|
517
563
|
this.store = emptyBlockStore();
|
|
518
564
|
this.appendedBytes = 0;
|
|
519
565
|
this.patchCount = 0;
|
|
@@ -524,10 +570,14 @@ export class FluxClient {
|
|
|
524
570
|
this.wasmMemoryBytes = 0;
|
|
525
571
|
this.lastContent = ""; // setContent baseline: the worker drops the parser here
|
|
526
572
|
this.contentDone = false;
|
|
573
|
+
// A frame coalesced from the just-cleared stream is now stale — cancel it so
|
|
574
|
+
// it can't fire a notify referencing content reset() just dropped.
|
|
575
|
+
this.cancelFrame();
|
|
576
|
+
this.finalizePending = false;
|
|
527
577
|
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
528
578
|
const pw = this.ensureAcquired();
|
|
529
579
|
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
530
|
-
this.emit();
|
|
580
|
+
if (hadContent) this.emit(true); // clear-the-view notify is synchronous
|
|
531
581
|
}
|
|
532
582
|
|
|
533
583
|
destroy() {
|
|
@@ -538,6 +588,9 @@ export class FluxClient {
|
|
|
538
588
|
// We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
|
|
539
589
|
// on the SAME instance needs the same pw/streamId to re-register.
|
|
540
590
|
if (this.pw) this.pool.release(this.streamId, this.pw);
|
|
591
|
+
// Drop any pending coalesced frame so it can't fire into cleared listeners.
|
|
592
|
+
this.cancelFrame();
|
|
593
|
+
this.finalizePending = false;
|
|
541
594
|
this.listeners.clear();
|
|
542
595
|
this.attached = false;
|
|
543
596
|
}
|
|
@@ -578,6 +631,25 @@ export class FluxClient {
|
|
|
578
631
|
|
|
579
632
|
getSnapshot = (): Block[] => this.store.snapshot;
|
|
580
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Internal: a renderer with an `onRenderMetrics` hook calls this once per
|
|
636
|
+
* actual React block render so `getMetrics().renderCount` aggregates churn.
|
|
637
|
+
* No-op cost when no hook is wired (it is simply never called). Not part of
|
|
638
|
+
* the public API surface — the underscore marks it renderer-internal.
|
|
639
|
+
*/
|
|
640
|
+
__noteRender(): void {
|
|
641
|
+
this.renderCount++;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Internal: the DOM renderer calls this once per actual node rebuild (the
|
|
646
|
+
* changed-block branch) when an `onRenderMetrics` hook is wired, so
|
|
647
|
+
* `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
|
|
648
|
+
*/
|
|
649
|
+
__noteRebuild(): void {
|
|
650
|
+
this.rebuildCount++;
|
|
651
|
+
}
|
|
652
|
+
|
|
581
653
|
getMetrics() {
|
|
582
654
|
const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
|
|
583
655
|
return {
|
|
@@ -594,6 +666,11 @@ export class FluxClient {
|
|
|
594
666
|
// clients on the same worker report the same number. Use Math.max (not
|
|
595
667
|
// sum) when aggregating across clients; summing double-counts.
|
|
596
668
|
wasmMemoryBytes: this.wasmMemoryBytes,
|
|
669
|
+
// Render-path churn (0 unless an onRenderMetrics hook is wired into a
|
|
670
|
+
// renderer): renderCount = React block-body renders, rebuildCount = DOM
|
|
671
|
+
// node rebuilds. Committed blocks memo-skip, so they contribute once.
|
|
672
|
+
renderCount: this.renderCount,
|
|
673
|
+
rebuildCount: this.rebuildCount,
|
|
597
674
|
};
|
|
598
675
|
}
|
|
599
676
|
|
|
@@ -644,7 +721,11 @@ export class FluxClient {
|
|
|
644
721
|
this.wasmMemoryBytes = msg.wasmMemoryBytes;
|
|
645
722
|
this.patchCount += 1;
|
|
646
723
|
this.lastPatchMs = performance.now();
|
|
647
|
-
|
|
724
|
+
// The post-finalize (done) patch must notify synchronously — never defer
|
|
725
|
+
// stream completion to the next frame. One-shot: cleared after use.
|
|
726
|
+
const sync = this.finalizePending;
|
|
727
|
+
this.finalizePending = false;
|
|
728
|
+
this.emit(sync);
|
|
648
729
|
// After subscribers see the new snapshot, fire the per-block hook for
|
|
649
730
|
// anything that just committed (document order). A throw here is
|
|
650
731
|
// isolated by the pool's dispatch boundary and won't skip emit().
|
|
@@ -663,7 +744,38 @@ export class FluxClient {
|
|
|
663
744
|
}
|
|
664
745
|
}
|
|
665
746
|
|
|
666
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Notify subscribers of a new snapshot.
|
|
749
|
+
*
|
|
750
|
+
* With `coalesce` off (default) this is fully synchronous, exactly as before.
|
|
751
|
+
* With it on and `requestAnimationFrame` available, a normal emit only
|
|
752
|
+
* *schedules* a single per-frame flush — repeated intra-frame emits collapse
|
|
753
|
+
* into one notify. `sync` forces an immediate flush (stream completion / reset)
|
|
754
|
+
* and cancels any frame already pending so the snapshot is delivered once.
|
|
755
|
+
*/
|
|
756
|
+
private emit(sync = false) {
|
|
757
|
+
if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
|
|
758
|
+
if (this.rafHandle !== null) return; // a flush is already scheduled this frame
|
|
759
|
+
this.rafHandle = requestAnimationFrame(() => {
|
|
760
|
+
this.rafHandle = null;
|
|
761
|
+
this.flushNow();
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
// Synchronous path: drop any pending frame so its notify can't double-fire
|
|
766
|
+
// after this one, then flush immediately.
|
|
767
|
+
this.cancelFrame();
|
|
768
|
+
this.flushNow();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private flushNow() {
|
|
667
772
|
for (const fn of this.listeners) fn();
|
|
668
773
|
}
|
|
774
|
+
|
|
775
|
+
private cancelFrame() {
|
|
776
|
+
if (this.rafHandle !== null) {
|
|
777
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
|
|
778
|
+
this.rafHandle = null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
669
781
|
}
|