flux-md 0.19.0 → 0.20.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/CHANGELOG.md +72 -0
- package/README.md +49 -4
- package/dist/client.d.ts +53 -1
- package/dist/client.js +150 -7
- package/dist/element.js +1 -0
- package/dist/server.js +1 -0
- package/dist/styles.css +6 -0
- package/dist/types-core.d.ts +9 -0
- package/dist/wasm/flux_md_core.d.ts +9 -0
- package/dist/wasm/flux_md_core.js +11 -0
- package/dist/wasm/flux_md_core_bg.wasm +0 -0
- package/dist/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker-core.js +2 -1
- package/dist/worker.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,78 @@ 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.20.0 — 2026-07-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Streaming links render cleanly from the first character.** An open link's
|
|
12
|
+
label used to show as raw bracketed text (`[Earnings Call](`) until the URL
|
|
13
|
+
started arriving, then snap into an anchor. The label now renders inside an
|
|
14
|
+
inert pending anchor from its first character — no brackets, no raw URL, and
|
|
15
|
+
the pending HTML is byte-stable through the whole URL so completion is a
|
|
16
|
+
single attribute swap (no DOM churn). Pending anchors carry
|
|
17
|
+
`data-flux-pending=""` (gone the moment `href` lands, never in final output)
|
|
18
|
+
so you can style them like settled links immediately; the optional theme
|
|
19
|
+
does this out of the box. Deliberate exclusions so common non-link brackets
|
|
20
|
+
never flash as links: footnote refs (`[^1]`), task checkboxes, alert
|
|
21
|
+
markers, and all-digit citation labels (`[1]`, `[12]`).
|
|
22
|
+
- **`gfmTagfilter`** — the GFM "Disallowed Raw HTML" extension, opt-in like
|
|
23
|
+
the other GFM options (`gfm-tagfilter` on the custom element,
|
|
24
|
+
`with_gfm_tagfilter` in the core). With raw HTML enabled, the nine
|
|
25
|
+
page-hijacking tags (`title`, `textarea`, `style`, `xmp`, `iframe`,
|
|
26
|
+
`noembed`, `noframes`, `script`, `plaintext`) get their leading `<`
|
|
27
|
+
escaped, matching GitHub's rendering. The GFM extension suite is now 24/24.
|
|
28
|
+
|
|
29
|
+
### Performance
|
|
30
|
+
|
|
31
|
+
- **Fourteen classes of streaming O(n²) eliminated.** An adversarial
|
|
32
|
+
multi-agent audit probed 137 document shapes, confirmed 17 quadratic
|
|
33
|
+
root-cause groups at the work-counter level, and this release fixes 14 —
|
|
34
|
+
every one now streams in O(new bytes) and is pinned linear by the
|
|
35
|
+
deterministic complexity gate (now three counters: slow-path scans,
|
|
36
|
+
inline-render bytes, emitted bytes). Highlights at 512 KB streamed in
|
|
37
|
+
256-byte chunks unless noted:
|
|
38
|
+
- **CRLF input** made every incremental cache bail — a plain list cost
|
|
39
|
+
49.4 s; now line endings normalize at ingest and CRLF streams cost the
|
|
40
|
+
same as LF (210 ms).
|
|
41
|
+
- **Footnotes**: a no-blank run of definitions stalled commits and every
|
|
42
|
+
append recloned the footnote maps — a list with per-item refs cost 48 s
|
|
43
|
+
at 256 KB; now 200 ms.
|
|
44
|
+
- **Link-reference definitions inside blockquotes** armed no cache
|
|
45
|
+
(44 s → 134 ms); lazy continuation lines no longer disarm quote caches.
|
|
46
|
+
- **Open list items with multi-line bodies** re-rendered whole every
|
|
47
|
+
append (23 s → 0.3 s); legal interior blank lines no longer permanently
|
|
48
|
+
disarm the list/indented-code caches (5 s → 54 ms).
|
|
49
|
+
- **Tables**: the growing trailing row re-split and re-rendered every
|
|
50
|
+
append (23.8 s → 254 ms; a thousand-column row 25.4 s → 65 ms).
|
|
51
|
+
- **`blockData` mode** disabled the container cache outright (a 256 KB
|
|
52
|
+
alert cost 41.7 s → 226 ms, 185×) and rebuilt code/math/list/table data
|
|
53
|
+
channels from scratch per append (512 KB math fence 7.4 s → 132 ms).
|
|
54
|
+
- **Streaming component blocks** (`<Chart>` bodies), giant headings,
|
|
55
|
+
thematic breaks, and growing fence info strings had no incremental cache
|
|
56
|
+
at all (a 256 KB component body 48 s → 345 ms).
|
|
57
|
+
- **Inline engine**: emphasis edits now apply in one forward pass instead
|
|
58
|
+
of per-pair splicing, unpairable delimiters no longer rescan the whole
|
|
59
|
+
stack (the CommonMark mod-3 pathology was 54 s for a one-shot 256 KB
|
|
60
|
+
render — now 51 ms, and it hit server-side rendering too), unmatched `$`
|
|
61
|
+
with math enabled no longer rescans to end-of-input per candidate
|
|
62
|
+
(57.7 s → 33 ms), and space-free text (entities, long tokens) commits
|
|
63
|
+
incrementally instead of pinning the whole paragraph.
|
|
64
|
+
- HTML blocks whose appends landed exactly on line boundaries dropped
|
|
65
|
+
their cache every append (7.7 s → 59 ms).
|
|
66
|
+
- WASM binary: 197 KB (+28 KB), the cost of seven new incremental caches.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- CRLF documents no longer leak raw `\r` bytes into rendered code blocks;
|
|
71
|
+
CRLF output is byte-identical to the LF equivalent (line endings are
|
|
72
|
+
equivalent per CommonMark).
|
|
73
|
+
- Two latent mid-stream parity divergences (speculative rendering at
|
|
74
|
+
whitespace-only tails; a link-reference definition on an alert's first
|
|
75
|
+
body line) now match the one-shot render exactly.
|
|
76
|
+
- A link awaiting its title (`[label](url "ti…`) no longer flashes literal
|
|
77
|
+
source mid-stream.
|
|
78
|
+
|
|
7
79
|
## 0.19.0 — 2026-06-30
|
|
8
80
|
|
|
9
81
|
### Added
|
package/README.md
CHANGED
|
@@ -156,7 +156,12 @@ use it from any binding.
|
|
|
156
156
|
> append-only so parsing stays incremental. Re-transforming the *whole* string
|
|
157
157
|
> each token (so earlier bytes change) forces `setContent` to reparse every tick
|
|
158
158
|
> (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
|
|
159
|
-
> for the **once**-at-the-end reprocess swap, not per-token rewrites.
|
|
159
|
+
> for the **once**-at-the-end reprocess swap, not per-token rewrites. That swap
|
|
160
|
+
> is seamless: the current view stays on screen while the new string reparses —
|
|
161
|
+
> the document never blanks, scroll never moves, and blocks whose rendered
|
|
162
|
+
> content is unchanged keep their identity (and React keys), so only genuinely
|
|
163
|
+
> changed blocks re-render. (`setContent("")` is an explicit clear and resets
|
|
164
|
+
> immediately.)
|
|
160
165
|
|
|
161
166
|
<details>
|
|
162
167
|
<summary>Full manual control (caller-owned client)</summary>
|
|
@@ -467,9 +472,41 @@ export default function Doc({ md }: { md: string }) {
|
|
|
467
472
|
| Where parse runs | Web Worker (off main thread) | Main thread |
|
|
468
473
|
| Block identity across chunks | Stable monotonic IDs | New keys on every render |
|
|
469
474
|
| Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
|
|
475
|
+
| Half-streamed link `[label](https://…` | Label-only inert anchor (`data-flux-pending`), URL never leaks | Raw brackets + partial URL flash |
|
|
470
476
|
| Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
|
|
471
477
|
| XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
|
|
472
478
|
|
|
479
|
+
### Streaming links
|
|
480
|
+
|
|
481
|
+
A link's destination is the last thing a model emits — `[Earnings Call](https://…`
|
|
482
|
+
often spans many tokens. While a link is still streaming, flux-md renders it as
|
|
483
|
+
an **inert, label-only anchor**: the label text inside an `<a>` with no `href`
|
|
484
|
+
(the half-typed URL never flashes on screen), marked so you can style it:
|
|
485
|
+
|
|
486
|
+
```html
|
|
487
|
+
<a data-flux-pending="" target="_blank" rel="noopener noreferrer nofollow">Earnings Call</a>
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
An anchor without an `href` gets **no default link styling** from the browser, so
|
|
491
|
+
without a rule for the marker the link would "pop" blue only when the URL
|
|
492
|
+
completes. The bundled theme (`import "flux-md/styles.css"`) already styles it;
|
|
493
|
+
if you bring your own CSS, copy this:
|
|
494
|
+
|
|
495
|
+
```css
|
|
496
|
+
.flux-md a[data-flux-pending] {
|
|
497
|
+
color: var(--flux-accent, #0969da); /* match your settled link's resting style */
|
|
498
|
+
cursor: default; /* not clickable yet */
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The moment the closing `)` arrives, the `href` appears and `data-flux-pending`
|
|
503
|
+
is dropped — committed and finalized output never carry the marker, and the
|
|
504
|
+
finished block is byte-identical to a one-shot parse. Two composition notes:
|
|
505
|
+
`urlTransform` runs only on a real `href`, so it never sees a half-streamed URL
|
|
506
|
+
prefix — only the complete one (it may run again on re-renders while the
|
|
507
|
+
surrounding block is still open); `decorators` skip text
|
|
508
|
+
inside `<a>` by default (`skipInside`), pending or not.
|
|
509
|
+
|
|
473
510
|
## Styling
|
|
474
511
|
|
|
475
512
|
flux-md emits semantic HTML under a `.flux-md` root and **ships no CSS by
|
|
@@ -520,9 +557,9 @@ class FluxClient {
|
|
|
520
557
|
): Promise<void>;
|
|
521
558
|
finalize(): void; // mark stream complete
|
|
522
559
|
setContent( // drive from a controlled full string
|
|
523
|
-
full: string, // diffs vs last: prefix → append delta; else
|
|
524
|
-
opts?: { done?: boolean }, //
|
|
525
|
-
): void;
|
|
560
|
+
full: string, // diffs vs last: prefix → append delta; else seamless
|
|
561
|
+
opts?: { done?: boolean }, // reset+reparse (view held, unchanged blocks keep identity)
|
|
562
|
+
): void; // done:true → finalize
|
|
526
563
|
reset(): void; // wipe and reuse
|
|
527
564
|
destroy(): void; // free this stream's parser
|
|
528
565
|
whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
|
|
@@ -555,6 +592,7 @@ const client = new FluxClient({
|
|
|
555
592
|
config: {
|
|
556
593
|
gfmAutolinks: true, // bare www./http(s):// URLs + emails → links (default true)
|
|
557
594
|
gfmAlerts: true, // > [!NOTE] → callouts (default true)
|
|
595
|
+
gfmTagfilter: false, // GFM disallowed raw HTML: escape <script>/<title>/… under unsafeHtml (default false)
|
|
558
596
|
gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
|
|
559
597
|
gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
|
|
560
598
|
dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
|
|
@@ -591,6 +629,13 @@ When to enable each flag:
|
|
|
591
629
|
- `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
|
|
592
630
|
LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
|
|
593
631
|
similar — see [Security](#security)).
|
|
632
|
+
- `gfmTagfilter: true` — the GFM "Disallowed Raw HTML" extension, for use with
|
|
633
|
+
`unsafeHtml`: the nine disallowed tags (`<title>`, `<textarea>`, `<style>`,
|
|
634
|
+
`<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get
|
|
635
|
+
their leading `<` escaped so they display as text instead of taking effect —
|
|
636
|
+
opening and closing forms, any case, in blocks and inline. Off by default
|
|
637
|
+
(strict CommonMark passes them through under `unsafeHtml`); it's a tag
|
|
638
|
+
denylist, **not** a sanitizer — untrusted input still wants `sanitize`.
|
|
594
639
|
- `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
|
|
595
640
|
like `<Thinking>…</Thinking>` (on their own line) and you want their inner
|
|
596
641
|
content parsed as markdown and dispatched to a React component. Safe without
|
package/dist/client.d.ts
CHANGED
|
@@ -133,6 +133,10 @@ export declare class FluxClient {
|
|
|
133
133
|
private rafHandle;
|
|
134
134
|
private finalizePending;
|
|
135
135
|
private epoch;
|
|
136
|
+
private staleSnapshot;
|
|
137
|
+
private staleTrimmed;
|
|
138
|
+
private idNamespace;
|
|
139
|
+
private mergeCache;
|
|
136
140
|
private appendedBytes;
|
|
137
141
|
private patchCount;
|
|
138
142
|
private totalParseMicros;
|
|
@@ -221,7 +225,12 @@ export declare class FluxClient {
|
|
|
221
225
|
* - **prefix-extension** (the streaming-growth case) → append only the new
|
|
222
226
|
* suffix, so committed blocks stay put and only the active tail re-parses;
|
|
223
227
|
* - **any other change** (e.g. a finished stream swapped for a re-processed
|
|
224
|
-
* final string) →
|
|
228
|
+
* final string) → reset + reparse the whole new string, keeping the
|
|
229
|
+
* current view on screen until the reparse lands: the document never
|
|
230
|
+
* blanks, scroll never moves, and blocks whose rendered content is
|
|
231
|
+
* unchanged keep their identity (and React keys) so only genuinely
|
|
232
|
+
* changed blocks re-render. An empty new string is an explicit clear and
|
|
233
|
+
* hard-resets immediately.
|
|
225
234
|
*
|
|
226
235
|
* This is the first-class bridge for UIs that hold a streaming message as a
|
|
227
236
|
* single growing string prop (the common React shape) — no hand-rolled diff,
|
|
@@ -243,6 +252,27 @@ export declare class FluxClient {
|
|
|
243
252
|
done?: boolean;
|
|
244
253
|
}): void;
|
|
245
254
|
reset(): void;
|
|
255
|
+
/**
|
|
256
|
+
* setContent's divergence reset: rebuild the parser exactly like {@link reset},
|
|
257
|
+
* but keep `preserve` (the currently displayed view) on screen while the new
|
|
258
|
+
* content reparses. No notify fires here — subscribers keep reading the same
|
|
259
|
+
* snapshot reference until the first new-generation patch merges over it, so
|
|
260
|
+
* the swap is seamless: no empty frame, no container collapse, no scroll
|
|
261
|
+
* clamp, and blocks whose content survives the reprocess never re-render.
|
|
262
|
+
*/
|
|
263
|
+
private softReset;
|
|
264
|
+
/**
|
|
265
|
+
* Finish a preserved-view divergence swap by making the merged view THE
|
|
266
|
+
* store: adopted ids become the committed keys, the merged array becomes the
|
|
267
|
+
* snapshot, and every scrap of merge state drops. From here getSnapshot() is
|
|
268
|
+
* a plain field read again (zero steady-state overhead) and the superseded
|
|
269
|
+
* generation's blocks are garbage — only one document stays in memory.
|
|
270
|
+
* Runs on the terminal (final) patch, which commits everything — if anything
|
|
271
|
+
* is somehow still open, the lazy merge simply stays live instead (the
|
|
272
|
+
* incremental reuse keeps it linear).
|
|
273
|
+
*/
|
|
274
|
+
private collapseStale;
|
|
275
|
+
private resetParser;
|
|
246
276
|
destroy(): void;
|
|
247
277
|
/**
|
|
248
278
|
* Re-register with the pool after {@link destroy} so the client receives
|
|
@@ -253,6 +283,28 @@ export declare class FluxClient {
|
|
|
253
283
|
reattach(): void;
|
|
254
284
|
subscribe: (fn: () => void) => () => boolean;
|
|
255
285
|
getSnapshot: () => Block[];
|
|
286
|
+
/**
|
|
287
|
+
* Positional merge of the preserved pre-divergence view over the rebuilding
|
|
288
|
+
* store (see {@link softReset}). Per position:
|
|
289
|
+
* - identical committed block (html + kind + open + speculative) → the OLD
|
|
290
|
+
* block object, so its id and reference survive the swap and the block
|
|
291
|
+
* never re-renders (blocksEqual / the DOM keyed reconcile hold);
|
|
292
|
+
* - changed committed block → the new block, its id offset into this
|
|
293
|
+
* generation's namespace (adopted old ids and raw new ids could collide);
|
|
294
|
+
* - still-open block over old content → the old block (never a shrinking
|
|
295
|
+
* partial where complete content was already on screen); past the old
|
|
296
|
+
* document's end the live tail streams in as-is;
|
|
297
|
+
* - position the reparse hasn't reached → the old block, until the terminal
|
|
298
|
+
* patch sets `staleTrimmed` and the view clamps to the new length.
|
|
299
|
+
*
|
|
300
|
+
* LINEARITY: committed blocks are reference-stable across patches (the store
|
|
301
|
+
* contract), so a base entry pointer-equal to the previous merge's reproduces
|
|
302
|
+
* its previous decision without re-running the O(html) equality compare. Each
|
|
303
|
+
* block is string-compared exactly once — when it first commits — keeping a
|
|
304
|
+
* long post-divergence stream linear instead of quadratic. Only the active
|
|
305
|
+
* tail (fresh references each patch, small by design) re-compares per patch.
|
|
306
|
+
*/
|
|
307
|
+
private mergeStale;
|
|
256
308
|
/**
|
|
257
309
|
* Internal: a renderer with an `onRenderMetrics` hook calls this once per
|
|
258
310
|
* actual React block render so `getMetrics().renderCount` aggregates churn.
|
package/dist/client.js
CHANGED
|
@@ -162,6 +162,7 @@ class FluxPool {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
const ID_NAMESPACE_STRIDE = 4294967296;
|
|
165
166
|
function poolCap() {
|
|
166
167
|
const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
|
|
167
168
|
return Math.min(hc || 4, 8);
|
|
@@ -213,6 +214,29 @@ class FluxClient {
|
|
|
213
214
|
// echoed back on each patch; a patch whose epoch is older than this is a
|
|
214
215
|
// pre-reset straggler and is dropped before it can repopulate the cleared store.
|
|
215
216
|
epoch = 0;
|
|
217
|
+
// --- Preserved-view divergence swap (setContent's reset+reparse path) ---
|
|
218
|
+
// The displayed snapshot captured by softReset(). While set, getSnapshot()
|
|
219
|
+
// returns a positional merge of this view over the rebuilding store, so the
|
|
220
|
+
// document never blanks out during a divergence reparse and unchanged blocks
|
|
221
|
+
// keep their object identity AND id (React key / DOM node key) across the
|
|
222
|
+
// swap. It persists after the reparse completes — dropping it would revert
|
|
223
|
+
// the adopted ids and remount every block one notify later.
|
|
224
|
+
staleSnapshot = null;
|
|
225
|
+
// Set when the reparse's terminal (final) patch lands: the merge stops
|
|
226
|
+
// padding with the old document's tail, so a shorter replacement trims to the
|
|
227
|
+
// new length on that very notify.
|
|
228
|
+
staleTrimmed = false;
|
|
229
|
+
// Id offset applied to non-adopted new blocks in a merged view. A divergence
|
|
230
|
+
// reparse restarts core block ids at 0 (fresh parser), and streamed ids are
|
|
231
|
+
// chunk-dependent (tail reparses burn ids) — so a changed block's new id can
|
|
232
|
+
// collide with a retained old block's id in the same merged snapshot.
|
|
233
|
+
// Bumped by ID_NAMESPACE_STRIDE per generation, which keeps every merged id
|
|
234
|
+
// provably unique: adopted ids are all below the current namespace.
|
|
235
|
+
idNamespace = 0;
|
|
236
|
+
// getSnapshot() cache: (store.snapshot ref, trimmed) → merged array. Repeated
|
|
237
|
+
// reads between notifies must return the SAME reference — the
|
|
238
|
+
// useSyncExternalStore cached-snapshot contract.
|
|
239
|
+
mergeCache = null;
|
|
216
240
|
// Perf
|
|
217
241
|
appendedBytes = 0;
|
|
218
242
|
patchCount = 0;
|
|
@@ -366,7 +390,12 @@ class FluxClient {
|
|
|
366
390
|
* - **prefix-extension** (the streaming-growth case) → append only the new
|
|
367
391
|
* suffix, so committed blocks stay put and only the active tail re-parses;
|
|
368
392
|
* - **any other change** (e.g. a finished stream swapped for a re-processed
|
|
369
|
-
* final string) →
|
|
393
|
+
* final string) → reset + reparse the whole new string, keeping the
|
|
394
|
+
* current view on screen until the reparse lands: the document never
|
|
395
|
+
* blanks, scroll never moves, and blocks whose rendered content is
|
|
396
|
+
* unchanged keep their identity (and React keys) so only genuinely
|
|
397
|
+
* changed blocks re-render. An empty new string is an explicit clear and
|
|
398
|
+
* hard-resets immediately.
|
|
370
399
|
*
|
|
371
400
|
* This is the first-class bridge for UIs that hold a streaming message as a
|
|
372
401
|
* single growing string prop (the common React shape) — no hand-rolled diff,
|
|
@@ -389,7 +418,9 @@ class FluxClient {
|
|
|
389
418
|
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
390
419
|
this.append(content.slice(this.lastContent.length));
|
|
391
420
|
} else {
|
|
392
|
-
this.
|
|
421
|
+
const displayed = this.getSnapshot();
|
|
422
|
+
if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
|
|
423
|
+
else this.reset();
|
|
393
424
|
this.append(content);
|
|
394
425
|
}
|
|
395
426
|
this.lastContent = content;
|
|
@@ -401,7 +432,57 @@ class FluxClient {
|
|
|
401
432
|
}
|
|
402
433
|
}
|
|
403
434
|
reset() {
|
|
404
|
-
const hadContent = this.
|
|
435
|
+
const hadContent = this.getSnapshot().length > 0;
|
|
436
|
+
this.staleSnapshot = null;
|
|
437
|
+
this.staleTrimmed = false;
|
|
438
|
+
this.mergeCache = null;
|
|
439
|
+
this.resetParser();
|
|
440
|
+
if (hadContent) this.emit(true);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* setContent's divergence reset: rebuild the parser exactly like {@link reset},
|
|
444
|
+
* but keep `preserve` (the currently displayed view) on screen while the new
|
|
445
|
+
* content reparses. No notify fires here — subscribers keep reading the same
|
|
446
|
+
* snapshot reference until the first new-generation patch merges over it, so
|
|
447
|
+
* the swap is seamless: no empty frame, no container collapse, no scroll
|
|
448
|
+
* clamp, and blocks whose content survives the reprocess never re-render.
|
|
449
|
+
*/
|
|
450
|
+
softReset(preserve) {
|
|
451
|
+
this.staleSnapshot = preserve;
|
|
452
|
+
this.staleTrimmed = false;
|
|
453
|
+
this.idNamespace += ID_NAMESPACE_STRIDE;
|
|
454
|
+
this.resetParser();
|
|
455
|
+
this.mergeCache = { base: this.store.snapshot, trimmed: false, view: preserve };
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Finish a preserved-view divergence swap by making the merged view THE
|
|
459
|
+
* store: adopted ids become the committed keys, the merged array becomes the
|
|
460
|
+
* snapshot, and every scrap of merge state drops. From here getSnapshot() is
|
|
461
|
+
* a plain field read again (zero steady-state overhead) and the superseded
|
|
462
|
+
* generation's blocks are garbage — only one document stays in memory.
|
|
463
|
+
* Runs on the terminal (final) patch, which commits everything — if anything
|
|
464
|
+
* is somehow still open, the lazy merge simply stays live instead (the
|
|
465
|
+
* incremental reuse keeps it linear).
|
|
466
|
+
*/
|
|
467
|
+
collapseStale() {
|
|
468
|
+
if (this.store.active.length > 0) return;
|
|
469
|
+
const view = this.getSnapshot();
|
|
470
|
+
const committed = /* @__PURE__ */ new Map();
|
|
471
|
+
const committedOrder = new Array(view.length);
|
|
472
|
+
for (let i = 0; i < view.length; i++) {
|
|
473
|
+
committedOrder[i] = view[i].id;
|
|
474
|
+
committed.set(view[i].id, view[i]);
|
|
475
|
+
}
|
|
476
|
+
this.store = { committed, committedOrder, active: [], snapshot: view };
|
|
477
|
+
this.staleSnapshot = null;
|
|
478
|
+
this.staleTrimmed = false;
|
|
479
|
+
this.mergeCache = null;
|
|
480
|
+
}
|
|
481
|
+
// Shared generation teardown: swap in an empty store, zero the metrics and
|
|
482
|
+
// the setContent baseline, invalidate any coalesced frame, bump the epoch,
|
|
483
|
+
// and tell the worker to drop the parser. View concerns (stale preservation,
|
|
484
|
+
// subscriber notify) belong to the callers — reset() and softReset().
|
|
485
|
+
resetParser() {
|
|
405
486
|
this.store = emptyBlockStore();
|
|
406
487
|
this.appendedBytes = 0;
|
|
407
488
|
this.patchCount = 0;
|
|
@@ -417,7 +498,6 @@ class FluxClient {
|
|
|
417
498
|
this.epoch += 1;
|
|
418
499
|
const pw = this.ensureAcquired();
|
|
419
500
|
this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
|
|
420
|
-
if (hadContent) this.emit(true);
|
|
421
501
|
}
|
|
422
502
|
destroy() {
|
|
423
503
|
if (!this.attached) return;
|
|
@@ -449,7 +529,65 @@ class FluxClient {
|
|
|
449
529
|
this.listeners.add(fn);
|
|
450
530
|
return () => this.listeners.delete(fn);
|
|
451
531
|
};
|
|
452
|
-
getSnapshot = () =>
|
|
532
|
+
getSnapshot = () => {
|
|
533
|
+
const base = this.store.snapshot;
|
|
534
|
+
if (!this.staleSnapshot) return base;
|
|
535
|
+
const cache = this.mergeCache;
|
|
536
|
+
if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
|
|
537
|
+
const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
|
|
538
|
+
this.mergeCache = { base, trimmed: this.staleTrimmed, view };
|
|
539
|
+
return view;
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* Positional merge of the preserved pre-divergence view over the rebuilding
|
|
543
|
+
* store (see {@link softReset}). Per position:
|
|
544
|
+
* - identical committed block (html + kind + open + speculative) → the OLD
|
|
545
|
+
* block object, so its id and reference survive the swap and the block
|
|
546
|
+
* never re-renders (blocksEqual / the DOM keyed reconcile hold);
|
|
547
|
+
* - changed committed block → the new block, its id offset into this
|
|
548
|
+
* generation's namespace (adopted old ids and raw new ids could collide);
|
|
549
|
+
* - still-open block over old content → the old block (never a shrinking
|
|
550
|
+
* partial where complete content was already on screen); past the old
|
|
551
|
+
* document's end the live tail streams in as-is;
|
|
552
|
+
* - position the reparse hasn't reached → the old block, until the terminal
|
|
553
|
+
* patch sets `staleTrimmed` and the view clamps to the new length.
|
|
554
|
+
*
|
|
555
|
+
* LINEARITY: committed blocks are reference-stable across patches (the store
|
|
556
|
+
* contract), so a base entry pointer-equal to the previous merge's reproduces
|
|
557
|
+
* its previous decision without re-running the O(html) equality compare. Each
|
|
558
|
+
* block is string-compared exactly once — when it first commits — keeping a
|
|
559
|
+
* long post-divergence stream linear instead of quadratic. Only the active
|
|
560
|
+
* tail (fresh references each patch, small by design) re-compares per patch.
|
|
561
|
+
*/
|
|
562
|
+
mergeStale(base, prev) {
|
|
563
|
+
const stale = this.staleSnapshot;
|
|
564
|
+
const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
|
|
565
|
+
const view = new Array(len);
|
|
566
|
+
for (let i = 0; i < len; i++) {
|
|
567
|
+
const nb = i < base.length ? base[i] : void 0;
|
|
568
|
+
if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
|
|
569
|
+
view[i] = prev.view[i];
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const ob = i < stale.length ? stale[i] : void 0;
|
|
573
|
+
if (!nb) {
|
|
574
|
+
view[i] = ob;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (ob) {
|
|
578
|
+
if (nb.open && !this.staleTrimmed) {
|
|
579
|
+
view[i] = ob;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (nb.html === ob.html && nb.kind.type === ob.kind.type && nb.open === ob.open && nb.speculative === ob.speculative) {
|
|
583
|
+
view[i] = ob;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
view[i] = { ...nb, id: this.idNamespace + nb.id };
|
|
588
|
+
}
|
|
589
|
+
return view;
|
|
590
|
+
}
|
|
453
591
|
/**
|
|
454
592
|
* Internal: a renderer with an `onRenderMetrics` hook calls this once per
|
|
455
593
|
* actual React block render so `getMetrics().renderCount` aggregates churn.
|
|
@@ -497,7 +635,7 @@ class FluxClient {
|
|
|
497
635
|
*/
|
|
498
636
|
outline() {
|
|
499
637
|
const out = [];
|
|
500
|
-
for (const b of this.
|
|
638
|
+
for (const b of this.getSnapshot()) {
|
|
501
639
|
if (b.kind.type === "Heading") {
|
|
502
640
|
const d = b.kind.data;
|
|
503
641
|
const level = typeof d === "number" ? d : d?.level ?? 1;
|
|
@@ -515,7 +653,7 @@ class FluxClient {
|
|
|
515
653
|
*/
|
|
516
654
|
toPlaintext() {
|
|
517
655
|
const parts = [];
|
|
518
|
-
for (const b of this.
|
|
656
|
+
for (const b of this.getSnapshot()) {
|
|
519
657
|
const t = htmlToText(b.html);
|
|
520
658
|
if (t) parts.push(t);
|
|
521
659
|
}
|
|
@@ -535,6 +673,11 @@ class FluxClient {
|
|
|
535
673
|
break;
|
|
536
674
|
}
|
|
537
675
|
applyPatch(this.store, patch);
|
|
676
|
+
if (msg.final === true && this.staleSnapshot) {
|
|
677
|
+
this.staleTrimmed = true;
|
|
678
|
+
this.mergeCache = null;
|
|
679
|
+
this.collapseStale();
|
|
680
|
+
}
|
|
538
681
|
this.appendedBytes = msg.appendedBytes;
|
|
539
682
|
this.totalParseMicros += msg.parseMicros;
|
|
540
683
|
this.retainedBytes = msg.retainedBytes;
|
package/dist/element.js
CHANGED
|
@@ -146,6 +146,7 @@ function defineFluxMarkdown(tag = "flux-markdown") {
|
|
|
146
146
|
};
|
|
147
147
|
set("gfm-autolinks", "gfmAutolinks");
|
|
148
148
|
set("gfm-alerts", "gfmAlerts");
|
|
149
|
+
set("gfm-tagfilter", "gfmTagfilter");
|
|
149
150
|
set("gfm-footnotes", "gfmFootnotes");
|
|
150
151
|
set("gfm-math", "gfmMath");
|
|
151
152
|
set("dir-auto", "dirAuto");
|
package/dist/server.js
CHANGED
|
@@ -36,6 +36,7 @@ function makeParser(config) {
|
|
|
36
36
|
const p = new FluxParser();
|
|
37
37
|
p.setGfmAutolinks(config?.gfmAutolinks ?? true);
|
|
38
38
|
p.setGfmAlerts(config?.gfmAlerts ?? true);
|
|
39
|
+
p.setGfmTagfilter(config?.gfmTagfilter ?? false);
|
|
39
40
|
p.setGfmFootnotes(config?.gfmFootnotes ?? false);
|
|
40
41
|
p.setGfmMath(config?.gfmMath ?? false);
|
|
41
42
|
p.setDirAuto(config?.dirAuto ?? false);
|
package/dist/styles.css
CHANGED
|
@@ -113,6 +113,12 @@
|
|
|
113
113
|
/* ---- inline ---------------------------------------------------------------- */
|
|
114
114
|
.flux-md a { color: var(--flux-accent); text-decoration: none; }
|
|
115
115
|
.flux-md a:hover { text-decoration: underline; }
|
|
116
|
+
/* A streaming link whose URL hasn't finished arriving renders as an inert
|
|
117
|
+
`<a data-flux-pending>` with no href — the UA gives an href-less anchor no
|
|
118
|
+
link styling, so restate the settled resting style here (identical color and
|
|
119
|
+
decoration, so completion changes nothing visually). Cursor stays default:
|
|
120
|
+
not clickable yet. */
|
|
121
|
+
.flux-md a[data-flux-pending] { color: var(--flux-accent); text-decoration: none; cursor: default; }
|
|
116
122
|
.flux-md strong { font-weight: 600; }
|
|
117
123
|
.flux-md em { font-style: italic; }
|
|
118
124
|
.flux-md del { color: var(--flux-fg-muted); }
|
package/dist/types-core.d.ts
CHANGED
|
@@ -296,6 +296,15 @@ export interface ParserConfig {
|
|
|
296
296
|
gfmAutolinks?: boolean;
|
|
297
297
|
/** GitHub alerts (`> [!NOTE]` → callouts). Default true. */
|
|
298
298
|
gfmAlerts?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* GFM "Disallowed Raw HTML" (tagfilter): with `unsafeHtml` on, the nine
|
|
301
|
+
* disallowed tags (`<title>`, `<textarea>`, `<style>`, `<xmp>`, `<iframe>`,
|
|
302
|
+
* `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get their leading
|
|
303
|
+
* `<` escaped so they display as text instead of taking effect. Default
|
|
304
|
+
* false (strict CommonMark passes them through under `unsafeHtml`); no
|
|
305
|
+
* effect while raw HTML is escaped (default) or sanitized — already inert.
|
|
306
|
+
*/
|
|
307
|
+
gfmTagfilter?: boolean;
|
|
299
308
|
/** GFM footnotes (`[^1]` + `[^1]:` → footnote section). Default false. */
|
|
300
309
|
gfmFootnotes?: boolean;
|
|
301
310
|
/**
|
|
@@ -82,6 +82,14 @@ export class FluxParser {
|
|
|
82
82
|
* `<div class="math math-display">` for a KaTeX pass on the JS side.
|
|
83
83
|
*/
|
|
84
84
|
setGfmMath(on: boolean): void;
|
|
85
|
+
/**
|
|
86
|
+
* Enable the GFM "Disallowed Raw HTML" extension (tagfilter): with raw
|
|
87
|
+
* HTML passing through (`setUnsafeHtml(true)`), the nine disallowed tags
|
|
88
|
+
* (`<title>`, `<script>`, `<iframe>`, …) get their leading `<` escaped so
|
|
89
|
+
* they display as text instead of taking effect. Off by default; no
|
|
90
|
+
* effect while raw HTML is escaped or sanitized (already inert).
|
|
91
|
+
*/
|
|
92
|
+
setGfmTagfilter(on: boolean): void;
|
|
85
93
|
/**
|
|
86
94
|
* Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
|
|
87
95
|
* sanitized without full unsafe HTML: `allow` empty = allow all tags except
|
|
@@ -125,6 +133,7 @@ export interface InitOutput {
|
|
|
125
133
|
readonly fluxparser_setGfmAutolinks: (a: number, b: number) => void;
|
|
126
134
|
readonly fluxparser_setGfmFootnotes: (a: number, b: number) => void;
|
|
127
135
|
readonly fluxparser_setGfmMath: (a: number, b: number) => void;
|
|
136
|
+
readonly fluxparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
128
137
|
readonly fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
129
138
|
readonly fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
130
139
|
readonly fluxparser_setUnsafeHtml: (a: number, b: number) => void;
|
|
@@ -205,6 +205,17 @@ export class FluxParser {
|
|
|
205
205
|
setGfmMath(on) {
|
|
206
206
|
wasm.fluxparser_setGfmMath(this.__wbg_ptr, on);
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Enable the GFM "Disallowed Raw HTML" extension (tagfilter): with raw
|
|
210
|
+
* HTML passing through (`setUnsafeHtml(true)`), the nine disallowed tags
|
|
211
|
+
* (`<title>`, `<script>`, `<iframe>`, …) get their leading `<` escaped so
|
|
212
|
+
* they display as text instead of taking effect. Off by default; no
|
|
213
|
+
* effect while raw HTML is escaped or sanitized (already inert).
|
|
214
|
+
* @param {boolean} on
|
|
215
|
+
*/
|
|
216
|
+
setGfmTagfilter(on) {
|
|
217
|
+
wasm.fluxparser_setGfmTagfilter(this.__wbg_ptr, on);
|
|
218
|
+
}
|
|
208
219
|
/**
|
|
209
220
|
* Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
|
|
210
221
|
* sanitized without full unsafe HTML: `allow` empty = allow all tags except
|
|
Binary file
|
|
@@ -16,6 +16,7 @@ export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
|
|
16
16
|
export const fluxparser_setGfmAutolinks: (a: number, b: number) => void;
|
|
17
17
|
export const fluxparser_setGfmFootnotes: (a: number, b: number) => void;
|
|
18
18
|
export const fluxparser_setGfmMath: (a: number, b: number) => void;
|
|
19
|
+
export const fluxparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
19
20
|
export const fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
20
21
|
export const fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
21
22
|
export const fluxparser_setUnsafeHtml: (a: number, b: number) => void;
|
package/dist/worker-core.js
CHANGED
|
@@ -125,8 +125,9 @@ class WorkerCore {
|
|
|
125
125
|
try {
|
|
126
126
|
const parser = this.getOrCreate(streamId);
|
|
127
127
|
if (buffered && buffered.length > 0) {
|
|
128
|
-
parser.append(buffered);
|
|
128
|
+
const drained = parser.append(buffered);
|
|
129
129
|
this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + buffered.length);
|
|
130
|
+
this.emitPatch(streamId, drained, parser, 0, false);
|
|
130
131
|
}
|
|
131
132
|
const patch = parser.finalize();
|
|
132
133
|
this.emitPatch(streamId, patch, parser, 0, true);
|
package/dist/worker.js
CHANGED
|
@@ -11,6 +11,7 @@ const core = new WorkerCore({
|
|
|
11
11
|
const p = new FluxParser();
|
|
12
12
|
p.setGfmAutolinks(c?.gfmAutolinks ?? true);
|
|
13
13
|
p.setGfmAlerts(c?.gfmAlerts ?? true);
|
|
14
|
+
p.setGfmTagfilter(c?.gfmTagfilter ?? false);
|
|
14
15
|
p.setGfmFootnotes(c?.gfmFootnotes ?? false);
|
|
15
16
|
p.setGfmMath(c?.gfmMath ?? false);
|
|
16
17
|
p.setDirAuto(c?.dirAuto ?? false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": ["./dist/worker.js", "./dist/styles.css"],
|