flux-md 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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
@@ -467,9 +467,41 @@ export default function Doc({ md }: { md: string }) {
467
467
  | Where parse runs | Web Worker (off main thread) | Main thread |
468
468
  | Block identity across chunks | Stable monotonic IDs | New keys on every render |
469
469
  | Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
470
+ | Half-streamed link `[label](https://…` | Label-only inert anchor (`data-flux-pending`), URL never leaks | Raw brackets + partial URL flash |
470
471
  | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
471
472
  | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
472
473
 
474
+ ### Streaming links
475
+
476
+ A link's destination is the last thing a model emits — `[Earnings Call](https://…`
477
+ often spans many tokens. While a link is still streaming, flux-md renders it as
478
+ an **inert, label-only anchor**: the label text inside an `<a>` with no `href`
479
+ (the half-typed URL never flashes on screen), marked so you can style it:
480
+
481
+ ```html
482
+ <a data-flux-pending="" target="_blank" rel="noopener noreferrer nofollow">Earnings Call</a>
483
+ ```
484
+
485
+ An anchor without an `href` gets **no default link styling** from the browser, so
486
+ without a rule for the marker the link would "pop" blue only when the URL
487
+ completes. The bundled theme (`import "flux-md/styles.css"`) already styles it;
488
+ if you bring your own CSS, copy this:
489
+
490
+ ```css
491
+ .flux-md a[data-flux-pending] {
492
+ color: var(--flux-accent, #0969da); /* match your settled link's resting style */
493
+ cursor: default; /* not clickable yet */
494
+ }
495
+ ```
496
+
497
+ The moment the closing `)` arrives, the `href` appears and `data-flux-pending`
498
+ is dropped — committed and finalized output never carry the marker, and the
499
+ finished block is byte-identical to a one-shot parse. Two composition notes:
500
+ `urlTransform` runs only on a real `href`, so it never sees a half-streamed URL
501
+ prefix — only the complete one (it may run again on re-renders while the
502
+ surrounding block is still open); `decorators` skip text
503
+ inside `<a>` by default (`skipInside`), pending or not.
504
+
473
505
  ## Styling
474
506
 
475
507
  flux-md emits semantic HTML under a `.flux-md` root and **ships no CSS by
@@ -555,6 +587,7 @@ const client = new FluxClient({
555
587
  config: {
556
588
  gfmAutolinks: true, // bare www./http(s):// URLs + emails → links (default true)
557
589
  gfmAlerts: true, // > [!NOTE] → callouts (default true)
590
+ gfmTagfilter: false, // GFM disallowed raw HTML: escape <script>/<title>/… under unsafeHtml (default false)
558
591
  gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
559
592
  gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
560
593
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
@@ -591,6 +624,13 @@ When to enable each flag:
591
624
  - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
592
625
  LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
593
626
  similar — see [Security](#security)).
627
+ - `gfmTagfilter: true` — the GFM "Disallowed Raw HTML" extension, for use with
628
+ `unsafeHtml`: the nine disallowed tags (`<title>`, `<textarea>`, `<style>`,
629
+ `<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get
630
+ their leading `<` escaped so they display as text instead of taking effect —
631
+ opening and closing forms, any case, in blocks and inline. Off by default
632
+ (strict CommonMark passes them through under `unsafeHtml`); it's a tag
633
+ denylist, **not** a sanitizer — untrusted input still wants `sanitize`.
594
634
  - `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
595
635
  like `<Thinking>…</Thinking>` (on their own line) and you want their inner
596
636
  content parsed as markdown and dispatched to a React component. Safe without
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;
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.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": ["./dist/worker.js", "./dist/styles.css"],