flux-md 0.15.1 → 0.16.1
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 +5 -1
- package/package.json +2 -3
- package/src/block-props.ts +13 -3
- package/src/client.ts +101 -3
- package/src/dom.ts +502 -14
- package/src/html-to-react.ts +109 -5
- package/src/index.ts +2 -0
- package/src/morph.ts +253 -0
- package/src/react.tsx +478 -15
- 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 -777
- 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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
|
@@ -316,6 +316,20 @@ export class FluxClient {
|
|
|
316
316
|
private lastContent = "";
|
|
317
317
|
private contentDone = false;
|
|
318
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
|
+
|
|
319
333
|
// Perf
|
|
320
334
|
private appendedBytes = 0;
|
|
321
335
|
private patchCount = 0;
|
|
@@ -324,6 +338,11 @@ export class FluxClient {
|
|
|
324
338
|
private firstAppendMs = 0;
|
|
325
339
|
private retainedBytes = 0;
|
|
326
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;
|
|
327
346
|
|
|
328
347
|
/**
|
|
329
348
|
* @param options.pool worker pool to join (defaults to the shared
|
|
@@ -337,6 +356,14 @@ export class FluxClient {
|
|
|
337
356
|
* order, after the store updates) — for side effects like lazily
|
|
338
357
|
* highlighting a finished code block or analytics. A committed block never
|
|
339
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.
|
|
340
367
|
*/
|
|
341
368
|
constructor(
|
|
342
369
|
options: {
|
|
@@ -344,12 +371,14 @@ export class FluxClient {
|
|
|
344
371
|
config?: ParserConfig;
|
|
345
372
|
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
346
373
|
onBlock?: (block: Block) => void;
|
|
374
|
+
coalesce?: boolean;
|
|
347
375
|
} = {},
|
|
348
376
|
) {
|
|
349
377
|
this.pool = options.pool ?? getDefaultPool();
|
|
350
378
|
this.config = options.config;
|
|
351
379
|
this.onError = options.onError;
|
|
352
380
|
this.onBlock = options.onBlock;
|
|
381
|
+
this.coalesce = options.coalesce ?? false;
|
|
353
382
|
}
|
|
354
383
|
|
|
355
384
|
/**
|
|
@@ -401,6 +430,9 @@ export class FluxClient {
|
|
|
401
430
|
|
|
402
431
|
finalize() {
|
|
403
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;
|
|
404
436
|
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
405
437
|
}
|
|
406
438
|
|
|
@@ -538,10 +570,14 @@ export class FluxClient {
|
|
|
538
570
|
this.wasmMemoryBytes = 0;
|
|
539
571
|
this.lastContent = ""; // setContent baseline: the worker drops the parser here
|
|
540
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;
|
|
541
577
|
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
542
578
|
const pw = this.ensureAcquired();
|
|
543
579
|
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
544
|
-
if (hadContent) this.emit();
|
|
580
|
+
if (hadContent) this.emit(true); // clear-the-view notify is synchronous
|
|
545
581
|
}
|
|
546
582
|
|
|
547
583
|
destroy() {
|
|
@@ -552,6 +588,9 @@ export class FluxClient {
|
|
|
552
588
|
// We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
|
|
553
589
|
// on the SAME instance needs the same pw/streamId to re-register.
|
|
554
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;
|
|
555
594
|
this.listeners.clear();
|
|
556
595
|
this.attached = false;
|
|
557
596
|
}
|
|
@@ -592,6 +631,25 @@ export class FluxClient {
|
|
|
592
631
|
|
|
593
632
|
getSnapshot = (): Block[] => this.store.snapshot;
|
|
594
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
|
+
|
|
595
653
|
getMetrics() {
|
|
596
654
|
const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
|
|
597
655
|
return {
|
|
@@ -608,6 +666,11 @@ export class FluxClient {
|
|
|
608
666
|
// clients on the same worker report the same number. Use Math.max (not
|
|
609
667
|
// sum) when aggregating across clients; summing double-counts.
|
|
610
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,
|
|
611
674
|
};
|
|
612
675
|
}
|
|
613
676
|
|
|
@@ -658,7 +721,11 @@ export class FluxClient {
|
|
|
658
721
|
this.wasmMemoryBytes = msg.wasmMemoryBytes;
|
|
659
722
|
this.patchCount += 1;
|
|
660
723
|
this.lastPatchMs = performance.now();
|
|
661
|
-
|
|
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);
|
|
662
729
|
// After subscribers see the new snapshot, fire the per-block hook for
|
|
663
730
|
// anything that just committed (document order). A throw here is
|
|
664
731
|
// isolated by the pool's dispatch boundary and won't skip emit().
|
|
@@ -677,7 +744,38 @@ export class FluxClient {
|
|
|
677
744
|
}
|
|
678
745
|
}
|
|
679
746
|
|
|
680
|
-
|
|
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() {
|
|
681
772
|
for (const fn of this.listeners) fn();
|
|
682
773
|
}
|
|
774
|
+
|
|
775
|
+
private cancelFrame() {
|
|
776
|
+
if (this.rafHandle !== null) {
|
|
777
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
|
|
778
|
+
this.rafHandle = null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
683
781
|
}
|