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 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 reset+reparse
524
- opts?: { done?: boolean }, // done:true finalize
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) → `reset()` + reparse the whole new 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) → `reset()` + reparse the whole new 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.reset();
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.store.snapshot.length > 0;
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 = () => this.store.snapshot;
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.store.snapshot) {
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.store.snapshot) {
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); }
@@ -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;
@@ -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.19.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"],