flux-md 0.18.4 → 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,129 @@ 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
+
79
+ ## 0.19.0 — 2026-06-30
80
+
81
+ ### Added
82
+
83
+ - **`decorators` — wrap/replace matched inline text while streaming.** A
84
+ declarative matcher list (`{ match: RegExp | string, replace: (text, groups) =>
85
+ node, skipInside?: string[] }`) on `<FluxMarkdown>` (React) and the DOM mount
86
+ options, applied to inline **text nodes only** after parsing — so it never sees
87
+ link URLs, code, or markup (no avoidance rules to hand-roll), and it runs once
88
+ per committed block, staying linear over a stream. Wrapping matched figures
89
+ (e.g. `$2.5B`, `10-15%`) is a one-liner. Decorator output is a **trusted**
90
+ surface (like `components`); `safeUrl` is now exported and `wrapLink(text, {
91
+ href })` ships as the safe link path. The `decorators` prop must be
92
+ referentially stable (hoist/memoize) — a dev-mode warning fires if it isn't,
93
+ since an unstable prop would re-decorate every committed block each tick.
94
+ - **`urlTransform`** — rewrite `href`/`src`/`poster` URLs (image proxy, allowlist,
95
+ relative resolution). The output is re-sanitized through the same scheme filter,
96
+ so a transform can't introduce a dangerous URL.
97
+
98
+ ### Performance
99
+
100
+ - **Nested lists now stream in O(n) instead of O(n²).** A loose outer list with
101
+ indented sub-bullets — and any list whose items have multi-line or nested-block
102
+ bodies — used to make the incremental list cache bail to a full reparse on every
103
+ appended chunk (re-scanning the whole growing list). It now renders each item's
104
+ full body, nested sub-lists included, through the shared item renderer, so it
105
+ stays linear. Streamed and one-shot output are byte-identical. (WASM −0.3 KB.)
106
+
107
+ ## 0.18.5 — 2026-06-30
108
+
109
+ ### Performance
110
+
111
+ - **Blockquotes and GFM alerts with structured bodies now stream in O(n) instead
112
+ of O(n²).** When a `>` blockquote or `> [!NOTE]` alert contains a list, table,
113
+ nested quote, heading, or code block, the incremental container cache used to
114
+ bail to a full reparse on every appended chunk — re-scanning and re-rendering
115
+ the whole growing block, so a long quoted list or alert-with-list went
116
+ quadratic (a 256 KB body streamed in small chunks did ~250× the parse work of a
117
+ 16 KB one). It now renders the `>`-stripped inner through a recursive nested
118
+ parser, committing settled inner blocks and re-rendering only the open tail, so
119
+ the work is linear in document size. Streamed and one-shot output stay
120
+ byte-identical. (WASM +3.8 KB.)
121
+
122
+ ### Internal
123
+
124
+ - A deterministic complexity-scaling gate (`cargo test --features perf_counters
125
+ --test scaling`), a proptest chunk-independence parity suite, and a cargo-fuzz
126
+ parity target now run in CI to catch O(n²) streaming regressions and chunk-
127
+ boundary divergences before they ship. The container regression above was
128
+ surfaced by the new gate on its first run.
129
+
7
130
  ## 0.18.4 — 2026-06-29
8
131
 
9
132
  ### Fixed
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
@@ -723,6 +763,46 @@ Rules worth knowing:
723
763
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
724
764
  or `components.code`.
725
765
 
766
+ #### Inline text decorators
767
+
768
+ Wrap or replace matched inline **text** while streaming — e.g. bold financial
769
+ figures — without writing your own HTML re-parser. A `decorators` entry runs
770
+ POST-parse on real inline **text nodes only** (never URLs, code, or markup), once
771
+ per committed block, so a long document stays **O(n)**.
772
+
773
+ ```tsx
774
+ import { FluxMarkdown, wrapLink } from "flux-md";
775
+
776
+ // HOIST it (module scope) or memoize — a fresh identity each render busts the
777
+ // per-block memo and re-decorates every block on every patch (a dev warning fires).
778
+ const decorators = [
779
+ { match: /\$[\d.]+[BMK]|FY\d{4}|\d+(?:[-–]\d+)?%/g, replace: (t) => <mark>{t}</mark> },
780
+ // Linkify a ticker — route the href through the safe helper (see below):
781
+ { match: /\$[A-Z]{1,5}\b/g, replace: (t) => wrapLink(t, { href: `/sym/${t.slice(1)}` }) },
782
+ ];
783
+
784
+ <FluxMarkdown client={client} decorators={decorators} />;
785
+ ```
786
+
787
+ - **Trusted surface — not sanitized.** A decorator's `replace` output is spliced
788
+ straight into the tree and does **not** pass through flux's attribute sanitizer
789
+ (React renders a `javascript:` href without complaint). Treat `decorators`
790
+ exactly like `components`: build only trusted nodes, and route any link href
791
+ through `wrapLink` or the exported `safeUrl`.
792
+ - **`skipInside`** defaults to `['a','code','pre','kbd']`; override per decorator.
793
+ - **Per-text-node.** A value split by inline markup (e.g. `$2.<em>5</em>B`) is two
794
+ text nodes and won't match across them — match against settled, contiguous text.
795
+ - Matching is pure and stateless, so a value streamed char-by-char decorates
796
+ **identically** to a one-shot render. Same API on `flux-md/dom`
797
+ (`mountFluxMarkdown(client, el, { decorators })`); a decorator there returns a
798
+ `Node` or string.
799
+
800
+ `urlTransform?: (url, { tag, attr }) => string` rewrites `href`/`src`/`poster`
801
+ URLs as blocks render (proxy images, add UTM params). Its output is re-sanitized
802
+ (`safeUrl(urlTransform(safeUrl(value)))`), so a buggy transform can never emit a
803
+ `javascript:` / `data:text/html` URL. Hoist/memoize it for the same reason as
804
+ `decorators`.
805
+
726
806
  ### Structured block data (`setBlockData`)
727
807
 
728
808
  Set `blockData: true` in the per-stream config and each block carries typed
@@ -0,0 +1,24 @@
1
+ import type { Decorator } from "./types-core.js";
2
+ /** Ancestor tags inside which decoration is skipped by default. */
3
+ export declare const DEFAULT_SKIP: readonly string[];
4
+ /** A run of the original text, or one matched span the caller turns into a node. */
5
+ export type DecorateSegment = {
6
+ type: "text";
7
+ text: string;
8
+ } | {
9
+ type: "match";
10
+ decorator: Decorator;
11
+ matchText: string;
12
+ groups: string[];
13
+ };
14
+ /**
15
+ * Split one inline text node's string into segments per the active decorators.
16
+ * Decorators apply in order; each one only re-scans the still-TEXT segments left
17
+ * by the previous ones (so a match is never decorated twice). `ancestors` is the
18
+ * chain of enclosing tag names — a decorator whose `skipInside` (default
19
+ * {@link DEFAULT_SKIP}) intersects it is not applied here.
20
+ *
21
+ * Returns `null` when nothing matched at all, so the caller can take the
22
+ * zero-allocation fast path of emitting the original text node unchanged.
23
+ */
24
+ export declare function decorateSegments(text: string, decorators: Decorator[], ancestors: string[]): DecorateSegment[] | null;
@@ -0,0 +1,71 @@
1
+ const DEFAULT_SKIP = ["a", "code", "pre", "kbd"];
2
+ function escapeRegExp(s) {
3
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4
+ }
5
+ function toGlobalRegExp(match) {
6
+ if (typeof match === "string") return new RegExp(escapeRegExp(match), "g");
7
+ let flags = match.flags.replace("y", "");
8
+ if (!flags.includes("g")) flags += "g";
9
+ return new RegExp(match.source, flags);
10
+ }
11
+ function skippedByAncestor(ancestors, skip) {
12
+ for (let i = 0; i < ancestors.length; i++) {
13
+ const a = ancestors[i].toLowerCase();
14
+ for (let j = 0; j < skip.length; j++) {
15
+ if (a === skip[j].toLowerCase()) return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+ function splitOne(text, dec, out) {
21
+ const re = toGlobalRegExp(dec.match);
22
+ let last = 0;
23
+ let matched = false;
24
+ let m;
25
+ while ((m = re.exec(text)) !== null) {
26
+ const matchText = m[0];
27
+ if (matchText.length === 0) {
28
+ re.lastIndex++;
29
+ continue;
30
+ }
31
+ matched = true;
32
+ if (m.index > last) out.push({ type: "text", text: text.slice(last, m.index) });
33
+ const groups = [];
34
+ for (let i = 1; i < m.length; i++) groups.push(m[i] ?? "");
35
+ out.push({ type: "match", decorator: dec, matchText, groups });
36
+ last = m.index + matchText.length;
37
+ }
38
+ if (!matched) return false;
39
+ if (last < text.length) out.push({ type: "text", text: text.slice(last) });
40
+ return true;
41
+ }
42
+ function decorateSegments(text, decorators, ancestors) {
43
+ let segments = null;
44
+ for (let d = 0; d < decorators.length; d++) {
45
+ const dec = decorators[d];
46
+ if (skippedByAncestor(ancestors, dec.skipInside ?? DEFAULT_SKIP)) continue;
47
+ const source = segments ?? [{ type: "text", text }];
48
+ let changedThisDecorator = false;
49
+ const next = [];
50
+ for (let i = 0; i < source.length; i++) {
51
+ const seg = source[i];
52
+ if (seg.type !== "text") {
53
+ next.push(seg);
54
+ continue;
55
+ }
56
+ const before = next.length;
57
+ if (splitOne(seg.text, dec, next)) {
58
+ changedThisDecorator = true;
59
+ } else {
60
+ next.length = before;
61
+ next.push(seg);
62
+ }
63
+ }
64
+ if (changedThisDecorator) segments = next;
65
+ }
66
+ return segments;
67
+ }
68
+ export {
69
+ DEFAULT_SKIP,
70
+ decorateSegments
71
+ };
package/dist/dom.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FluxClient } from "./client.js";
2
- import type { Block, BlockComponentProps, RenderMetricsHook } from "./types-core.js";
2
+ import type { Block, BlockComponentProps, Decorator, RenderMetricsHook, UrlTransform } from "./types-core.js";
3
3
  /**
4
4
  * Framework-neutral DOM renderer for a {@link FluxClient}. Mounts the streaming
5
5
  * document into a container and keeps it in sync via direct DOM mutation,
@@ -76,6 +76,26 @@ export interface MountOptions {
76
76
  * fast path (not code/math/mermaid/component overrides). The morphed subtree is
77
77
  * equivalent to the rebuilt one. */
78
78
  morphOpenBlocks?: boolean;
79
+ /**
80
+ * Wrap or replace matched inline **text** while streaming (parity with the
81
+ * React `decorators` prop). Each {@link Decorator} runs POST-render over the
82
+ * block's real TEXT nodes via a `TreeWalker` (after `innerHTML`), once per
83
+ * committed block, honoring `skipInside` (default `a`/`code`/`pre`/`kbd`).
84
+ *
85
+ * **Trusted surface.** A decorator's `replace` may return a `Node` or a string;
86
+ * a returned Node is inserted as-is and is NOT sanitized (a `javascript:` href
87
+ * on a user-built `<a>` reaches the DOM). Route hrefs through the exported
88
+ * {@link safeUrl}. Enabling decorators moves a block onto the walk path (off the
89
+ * `innerHTML`/prefix-append/morph fast paths) — still O(n) per block.
90
+ */
91
+ decorators?: Decorator[];
92
+ /**
93
+ * Rewrite `href`/`src`/`poster` URLs as blocks render (parity with the React
94
+ * `urlTransform` prop). The output is re-sanitized
95
+ * (`safeUrl(urlTransform(safeUrl(v)))`) so it can never introduce a dangerous
96
+ * scheme. O(1) per attribute.
97
+ */
98
+ urlTransform?: UrlTransform;
79
99
  /** Appended to the root's `className` (the `flux-md` class is always present). */
80
100
  className?: string;
81
101
  /** Set on the root element. */
package/dist/dom.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { highlight } from "./hi.js";
2
2
  import { morph } from "./morph.js";
3
3
  import { blockProps, extractLang } from "./block-props.js";
4
+ import { decorateSegments } from "./decorate.js";
5
+ import { safeUrl } from "./url-safety.js";
4
6
  const INTRINSIC_PX = {
5
7
  Paragraph: 80,
6
8
  Heading: 44,
@@ -21,6 +23,9 @@ function mountFluxMarkdown(client, container, options = {}) {
21
23
  }
22
24
  const components = options.components && Object.keys(options.components).length > 0 ? options.components : void 0;
23
25
  const { sanitize, virtualize, stickToBottom, onRenderMetrics } = options;
26
+ const decorators = options.decorators && options.decorators.length > 0 ? options.decorators : void 0;
27
+ const urlTransform = options.urlTransform;
28
+ const hasInlineTransforms = !!decorators || !!urlTransform;
24
29
  const hasPerf = typeof performance !== "undefined";
25
30
  const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
26
31
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
@@ -184,17 +189,17 @@ function mountFluxMarkdown(client, container, options = {}) {
184
189
  case "Mermaid":
185
190
  return renderMermaid(b);
186
191
  }
187
- if (kind === "Table" && b.open) {
192
+ if (kind === "Table" && b.open && !hasInlineTransforms) {
188
193
  const data = tableData(b);
189
194
  if (data) return buildKeyedTable(b, data, mb);
190
195
  }
191
- if (b.open && kind === "List") {
196
+ if (b.open && kind === "List" && !hasInlineTransforms) {
192
197
  const keyed = renderKeyedList(b);
193
198
  if (keyed) return keyed;
194
199
  }
195
200
  const node = document.createElement("div");
196
201
  node.className = genericClassName(b);
197
- if (b.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
202
+ if (b.open && !sanitize && !hasInlineTransforms && (kind === "Blockquote" || kind === "Alert")) {
198
203
  const wrapper = renderKeyedContainer(b);
199
204
  if (wrapper) {
200
205
  node.appendChild(wrapper);
@@ -202,7 +207,9 @@ function mountFluxMarkdown(client, container, options = {}) {
202
207
  }
203
208
  }
204
209
  node.innerHTML = sanitize ? sanitize(b.html) : b.html;
205
- lastRenderGeneric = !sanitize;
210
+ if (decorators) decorateDomSubtree(node, decorators);
211
+ if (urlTransform) applyUrlTransformDom(node, urlTransform);
212
+ lastRenderGeneric = !sanitize && !hasInlineTransforms;
206
213
  return node;
207
214
  }
208
215
  function renderKeyedList(b) {
@@ -570,6 +577,50 @@ function alertTitleHtml(html) {
570
577
  const m = html.match(/<p class="markdown-alert-title"[^>]*>[\s\S]*?<\/p>/);
571
578
  return m ? m[0] : "";
572
579
  }
580
+ function ancestorTags(node, root) {
581
+ const out = [];
582
+ let p = node.parentNode;
583
+ while (p && p !== root) {
584
+ if (p.tagName) out.push(p.tagName.toLowerCase());
585
+ p = p.parentNode;
586
+ }
587
+ return out;
588
+ }
589
+ const SHOW_ALL = 4294967295;
590
+ function decorateDomSubtree(root, decorators) {
591
+ const walker = document.createTreeWalker(root, SHOW_ALL);
592
+ const texts = [];
593
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
594
+ if (n.nodeType === 3) texts.push(n);
595
+ }
596
+ for (const t of texts) {
597
+ const segs = decorateSegments(t.data, decorators, ancestorTags(t, root));
598
+ if (segs === null) continue;
599
+ const frag = document.createDocumentFragment();
600
+ for (const s of segs) {
601
+ if (s.type === "text") {
602
+ frag.appendChild(document.createTextNode(s.text));
603
+ continue;
604
+ }
605
+ const replacement = s.decorator.replace(s.matchText, s.groups);
606
+ if (replacement == null) continue;
607
+ frag.appendChild(typeof replacement === "string" ? document.createTextNode(replacement) : replacement);
608
+ }
609
+ t.parentNode?.replaceChild(frag, t);
610
+ }
611
+ }
612
+ function applyUrlTransformDom(root, urlTransform) {
613
+ const els = root.querySelectorAll("[href],[src],[poster]");
614
+ const attrs = ["href", "src", "poster"];
615
+ els.forEach((el) => {
616
+ for (const attr of attrs) {
617
+ if (!el.hasAttribute(attr)) continue;
618
+ const cur = el.getAttribute(attr) ?? "";
619
+ const next = safeUrl(urlTransform(safeUrl(cur), { tag: el.tagName.toLowerCase(), attr }));
620
+ el.setAttribute(attr, next);
621
+ }
622
+ });
623
+ }
573
624
  export {
574
625
  mountFluxMarkdown,
575
626
  tailOpenBlockId
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");
@@ -1,5 +1,8 @@
1
- import { type ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import type { Components } from "./types.js";
3
+ import type { Decorator, UrlTransform } from "./types-core.js";
4
+ import { decodeEntities, safeUrl } from "./url-safety.js";
5
+ export { decodeEntities, safeUrl };
3
6
  type HNode = {
4
7
  kind: "text";
5
8
  text: string;
@@ -9,8 +12,6 @@ type HNode = {
9
12
  attrs: Record<string, string | true>;
10
13
  children: HNode[];
11
14
  };
12
- /** Decode the (small, known) set of entities the core emits, plus numeric refs. */
13
- export declare function decodeEntities(s: string): string;
14
15
  /**
15
16
  * Parse an inline CSS string (`"text-align:left;color:red"`) into the object
16
17
  * React's `style` prop requires, camelCasing property names. Custom properties
@@ -36,5 +37,25 @@ export declare function parseTrustedHtml(html: string): HNode[];
36
37
  * carries the React node built under the previous components map). Segment keys
37
38
  * carry their original document order via `keyOffset` so React keys stay stable.
38
39
  */
39
- export declare function htmlToReact(html: string, components: Components, childMemoMap?: Map<string, ReactNode>): ReactNode;
40
- export {};
40
+ export declare function htmlToReact(html: string, components: Components, childMemoMap?: Map<string, ReactNode>, opts?: {
41
+ decorators?: Decorator[];
42
+ urlTransform?: UrlTransform;
43
+ }): ReactNode;
44
+ /**
45
+ * Build a SAFE `<a>` for use inside a {@link Decorator}'s `replace`. Decorator
46
+ * output is a TRUSTED surface that does NOT pass through flux's attribute
47
+ * sanitizer, and React renders a `javascript:` href without complaint — so this
48
+ * runs `href` through {@link safeUrl} (the same scheme filter the core uses) and
49
+ * spreads the remaining attributes verbatim. Prefer this over a hand-built
50
+ * `<a>` whenever the href can come from model output.
51
+ *
52
+ * ```tsx
53
+ * const decorators = [{
54
+ * match: /\$[\d.]+[BMK]/g,
55
+ * replace: (t) => wrapLink(t, { href: "/figures/" + t, className: "fig" }),
56
+ * }];
57
+ * ```
58
+ */
59
+ export declare function wrapLink(text: ReactNode, attrs: {
60
+ href: string;
61
+ } & Record<string, unknown>): ReactElement;
@@ -1,4 +1,6 @@
1
- import { createElement } from "react";
1
+ import { createElement, Fragment } from "react";
2
+ import { decorateSegments } from "./decorate.js";
3
+ import { decodeEntities, safeUrl } from "./url-safety.js";
2
4
  const VOID = /* @__PURE__ */ new Set([
3
5
  "area",
4
6
  "base",
@@ -43,47 +45,6 @@ const PROP_DENY = /* @__PURE__ */ new Set([
43
45
  "suppresscontenteditablewarning"
44
46
  ]);
45
47
  const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
46
- function safeUrl(value) {
47
- let decoded = value;
48
- for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
49
- prev = decoded;
50
- decoded = decodeEntities(decoded);
51
- }
52
- const probe = decoded.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
53
- if (probe.startsWith("javascript:") || probe.startsWith("vbscript:") || probe.startsWith("data:text/html") || probe.startsWith("data:text/javascript")) {
54
- return "#";
55
- }
56
- return value;
57
- }
58
- const NAMED_ENTITIES = {
59
- amp: "&",
60
- lt: "<",
61
- gt: ">",
62
- quot: '"',
63
- apos: "'",
64
- nbsp: "\xA0",
65
- copy: "\xA9",
66
- reg: "\xAE",
67
- hellip: "\u2026",
68
- mdash: "\u2014",
69
- ndash: "\u2013"
70
- };
71
- function decodeEntities(s) {
72
- if (s.indexOf("&") === -1) return s;
73
- return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body) => {
74
- if (body[0] === "#") {
75
- const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
76
- if (Number.isNaN(code) || code < 0 || code > 1114111) return m;
77
- try {
78
- return String.fromCodePoint(code);
79
- } catch {
80
- return m;
81
- }
82
- }
83
- const named = NAMED_ENTITIES[body];
84
- return named === void 0 ? m : named;
85
- });
86
- }
87
48
  function parseStyle(css) {
88
49
  const out = {};
89
50
  for (const decl of css.split(";")) {
@@ -215,7 +176,7 @@ function parseTrustedHtml(html) {
215
176
  }
216
177
  return root;
217
178
  }
218
- function attrsToProps(tag, attrs, key) {
179
+ function attrsToProps(tag, attrs, key, urlTransform) {
219
180
  const props = { key };
220
181
  for (const name in attrs) {
221
182
  const value = attrs[name];
@@ -227,7 +188,11 @@ function attrsToProps(tag, attrs, key) {
227
188
  continue;
228
189
  }
229
190
  if (URL_ATTRS.has(lower) && typeof value === "string") {
230
- props[ATTR_MAP[lower] ?? name] = safeUrl(value);
191
+ let url = safeUrl(value);
192
+ if (urlTransform && (lower === "href" || lower === "src" || lower === "poster")) {
193
+ url = safeUrl(urlTransform(url, { tag: tag.toLowerCase(), attr: lower }));
194
+ }
195
+ props[ATTR_MAP[lower] ?? name] = url;
231
196
  continue;
232
197
  }
233
198
  if (tag.toLowerCase() === "input" && lower === "checked") {
@@ -239,21 +204,43 @@ function attrsToProps(tag, attrs, key) {
239
204
  }
240
205
  return props;
241
206
  }
242
- function nodesToReact(nodes, components, keyPrefix) {
207
+ function pushDecoratedText(out, text, decorators, ancestors, keyBase) {
208
+ const segs = decorateSegments(text, decorators, ancestors);
209
+ if (segs === null) {
210
+ out.push(text);
211
+ return;
212
+ }
213
+ for (let i = 0; i < segs.length; i++) {
214
+ const s = segs[i];
215
+ if (s.type === "text") {
216
+ out.push(s.text);
217
+ continue;
218
+ }
219
+ const replacement = s.decorator.replace(s.matchText, s.groups);
220
+ out.push(createElement(Fragment, { key: keyBase + ":" + i }, replacement));
221
+ }
222
+ }
223
+ function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
243
224
  const out = [];
244
225
  for (let idx = 0; idx < nodes.length; idx++) {
245
226
  const n = nodes[idx];
246
227
  if (n.kind === "text") {
247
- out.push(n.text);
228
+ if (ctx && ctx.decorators) {
229
+ pushDecoratedText(out, n.text, ctx.decorators, ancestors, keyPrefix + idx);
230
+ } else {
231
+ out.push(n.text);
232
+ }
248
233
  continue;
249
234
  }
250
235
  const key = keyPrefix + idx;
251
236
  const type = components[n.tag] ?? n.tag;
252
- const props = attrsToProps(n.tag, n.attrs, key);
237
+ const props = attrsToProps(n.tag, n.attrs, key, ctx?.urlTransform);
253
238
  if (VOID.has(n.tag.toLowerCase())) {
254
239
  out.push(createElement(type, props));
255
240
  } else {
256
- out.push(createElement(type, props, nodesToReact(n.children, components, key + ".")));
241
+ ancestors.push(n.tag);
242
+ out.push(createElement(type, props, nodesToReact(n.children, components, key + ".", ctx, ancestors)));
243
+ ancestors.pop();
257
244
  }
258
245
  }
259
246
  return out.length === 0 ? null : out.length === 1 ? out[0] : out;
@@ -316,8 +303,9 @@ function topLevelSegments(html) {
316
303
  if (segStart < html.length) segs.push(html.slice(segStart));
317
304
  return segs;
318
305
  }
319
- function htmlToReact(html, components, childMemoMap) {
320
- if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "");
306
+ function htmlToReact(html, components, childMemoMap, opts) {
307
+ const ctx = opts && (opts.decorators || opts.urlTransform) ? { decorators: opts.decorators, urlTransform: opts.urlTransform } : void 0;
308
+ if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "", ctx, []);
321
309
  const segs = topLevelSegments(html);
322
310
  const out = [];
323
311
  for (let idx = 0; idx < segs.length; idx++) {
@@ -328,17 +316,23 @@ function htmlToReact(html, components, childMemoMap) {
328
316
  out.push(hit);
329
317
  continue;
330
318
  }
331
- const node = nodesToReact(parseTrustedHtml(seg), components, idx + ".");
319
+ const node = nodesToReact(parseTrustedHtml(seg), components, idx + ".", ctx, []);
332
320
  childMemoMap.set(cacheKey, node);
333
321
  out.push(node);
334
322
  }
335
323
  return out.length === 0 ? null : out.length === 1 ? out[0] : out;
336
324
  }
325
+ function wrapLink(text, attrs) {
326
+ const { href, ...rest } = attrs;
327
+ return createElement("a", { ...rest, href: safeUrl(String(href)) }, text);
328
+ }
337
329
  export {
338
330
  decodeEntities,
339
331
  getParseCount,
340
332
  htmlToReact,
341
333
  parseStyle,
342
334
  parseTrustedHtml,
343
- resetParseCount
335
+ resetParseCount,
336
+ safeUrl,
337
+ wrapLink
344
338
  };
package/dist/index.d.ts CHANGED
@@ -18,5 +18,5 @@
18
18
  export { FluxClient, FluxPool, getDefaultPool } from "./client.js";
19
19
  export { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react.js";
20
20
  export { highlight, supportedLangs } from "./hi.js";
21
- export { htmlToReact, parseTrustedHtml } from "./html-to-react.js";
22
- export type { Block, BlockKind, BlockKindTag, BlockComponentProps, Components, Patch, FromWorker, ToWorker, WorkerLike, ParserConfig, Align, TableCell, TableData, HeadingData, CodeBlockData, MathBlockData, ListData, NestedBlock, ContainerData, } from "./types.js";
21
+ export { htmlToReact, parseTrustedHtml, safeUrl, wrapLink } from "./html-to-react.js";
22
+ export type { Block, BlockKind, BlockKindTag, BlockComponentProps, Components, Patch, FromWorker, ToWorker, WorkerLike, ParserConfig, Align, TableCell, TableData, HeadingData, CodeBlockData, MathBlockData, ListData, NestedBlock, ContainerData, Decorator, UrlTransform, FluxNode, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { FluxClient, FluxPool, getDefaultPool } from "./client.js";
2
2
  import { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react.js";
3
3
  import { highlight, supportedLangs } from "./hi.js";
4
- import { htmlToReact, parseTrustedHtml } from "./html-to-react.js";
4
+ import { htmlToReact, parseTrustedHtml, safeUrl, wrapLink } from "./html-to-react.js";
5
5
  export {
6
6
  FluxClient,
7
7
  FluxMarkdown,
@@ -10,7 +10,9 @@ export {
10
10
  highlight,
11
11
  htmlToReact,
12
12
  parseTrustedHtml,
13
+ safeUrl,
13
14
  supportedLangs,
14
15
  useFluxMarkdownString,
15
- useFluxStream
16
+ useFluxStream,
17
+ wrapLink
16
18
  };
package/dist/react.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Block, BlockComponentProps, Components } from "./types.js";
2
2
  import { FluxClient } from "./client.js";
3
- import type { ParserConfig, RenderMetricsHook } from "./types-core.js";
3
+ import type { Decorator, ParserConfig, RenderMetricsHook, UrlTransform } from "./types-core.js";
4
4
  /**
5
5
  * Render a streaming markdown document from a FluxClient. Each block is its
6
6
  * own memoized React node keyed by its stable parser-assigned ID, so React
@@ -89,6 +89,33 @@ interface FluxMarkdownProps {
89
89
  * on every patch instead of only the streaming tail.
90
90
  */
91
91
  sanitize?: (html: string) => string;
92
+ /**
93
+ * Wrap or replace matched inline **text** while streaming, in O(n) — e.g. to
94
+ * bold financial figures (`$2.5B`, `10-15%`, `FY2024`) or linkify tickers. Each
95
+ * {@link Decorator} runs POST-PARSE on real inline TEXT nodes only (never URLs,
96
+ * code, or markup), once per committed block, so a long document stays linear.
97
+ *
98
+ * **Trusted surface.** A decorator's `replace` output is spliced straight into
99
+ * the tree and is **NOT** sanitized (React renders a `javascript:` href without
100
+ * complaint). Treat this exactly like `components`: build only trusted nodes,
101
+ * and route any link href through the exported `safeUrl` / the `wrapLink`
102
+ * helper. Matching is per-text-node — a value split by inline markup like
103
+ * `$2.<em>5</em>B` is two text nodes and won't match across them.
104
+ *
105
+ * **HOIST / memoize this array.** A fresh identity each render busts the block
106
+ * memo, forcing every committed block to re-parse + re-decorate every patch
107
+ * (O(n²)); a one-time dev warning fires if the identity changes. Enabling
108
+ * decorators routes a block through the walk path (off the `innerHTML` fast
109
+ * path) — expected, and still O(n) per block.
110
+ */
111
+ decorators?: Decorator[];
112
+ /**
113
+ * Rewrite `href`/`src`/`poster` URLs as blocks render — proxy images, add UTM
114
+ * params, etc. The output is re-sanitized (`safeUrl(urlTransform(safeUrl(v)))`)
115
+ * so it can never introduce a `javascript:` / `data:text/html` URL. **HOIST /
116
+ * memoize** for the same reason as `decorators`.
117
+ */
118
+ urlTransform?: UrlTransform;
92
119
  /**
93
120
  * Opt-in: when an OPEN (streaming) block re-renders each patch, reuse the
94
121
  * React nodes of its top-level children whose HTML is unchanged and re-parse
@@ -139,6 +166,7 @@ interface FluxMarkdownProps {
139
166
  */
140
167
  deferTail?: boolean;
141
168
  }
169
+ export declare function __resetUnstableWarnings(): void;
142
170
  /**
143
171
  * Own a {@link FluxClient} for the lifetime of a component and drive it from a
144
172
  * `stream` (a `Response`, `ReadableStream<Uint8Array>`, or
@@ -193,6 +221,8 @@ export declare function blocksEqual(prev: {
193
221
  sanitize?: (html: string) => string;
194
222
  childMemo?: boolean;
195
223
  onRenderMetrics?: RenderMetricsHook;
224
+ decorators?: Decorator[];
225
+ urlTransform?: UrlTransform;
196
226
  }, next: {
197
227
  block: Block;
198
228
  components?: Components;
@@ -200,5 +230,7 @@ export declare function blocksEqual(prev: {
200
230
  sanitize?: (html: string) => string;
201
231
  childMemo?: boolean;
202
232
  onRenderMetrics?: RenderMetricsHook;
233
+ decorators?: Decorator[];
234
+ urlTransform?: UrlTransform;
203
235
  }): boolean;
204
236
  export {};
package/dist/react.js CHANGED
@@ -15,6 +15,25 @@ import { MathBlock } from "./renderers/Math.js";
15
15
  import { Mermaid } from "./renderers/Mermaid.js";
16
16
  import { htmlToReact } from "./html-to-react.js";
17
17
  const NO_DEFER_BLOCKS = [];
18
+ const warnedUnstable = /* @__PURE__ */ new Set();
19
+ function useUnstablePropWarning(name, value) {
20
+ const ref = useRef(value);
21
+ if (ref.current !== value) {
22
+ const prevDefined = ref.current !== void 0 && ref.current !== null;
23
+ const nextDefined = value !== void 0 && value !== null;
24
+ ref.current = value;
25
+ const env = globalThis.process?.env;
26
+ if (prevDefined && nextDefined && !warnedUnstable.has(name) && (!env || env.NODE_ENV !== "production")) {
27
+ warnedUnstable.add(name);
28
+ console.warn(
29
+ `<FluxMarkdown>: the \`${name}\` prop changed identity between renders. Hoist it to module scope or wrap it in useMemo \u2014 a fresh identity each render busts the per-block memo and re-parses every block on every patch.`
30
+ );
31
+ }
32
+ }
33
+ }
34
+ function __resetUnstableWarnings() {
35
+ warnedUnstable.clear();
36
+ }
18
37
  function FluxMarkdownFromClient({
19
38
  client,
20
39
  components,
@@ -28,9 +47,13 @@ function FluxMarkdownFromClient({
28
47
  "aria-live": ariaLive,
29
48
  "aria-atomic": ariaAtomic,
30
49
  onRenderMetrics,
31
- deferTail
50
+ deferTail,
51
+ decorators,
52
+ urlTransform
32
53
  }) {
33
54
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
55
+ useUnstablePropWarning("decorators", decorators);
56
+ useUnstablePropWarning("urlTransform", urlTransform);
34
57
  const deferred = useDeferredValue(deferTail ? blocks : NO_DEFER_BLOCKS);
35
58
  const rendered = deferTail ? deferred : blocks;
36
59
  const isDeferring = deferTail ? rendered !== blocks : false;
@@ -63,7 +86,9 @@ function FluxMarkdownFromClient({
63
86
  virtualize,
64
87
  sanitize,
65
88
  childMemo,
66
- onRenderMetrics: onMetrics
89
+ onRenderMetrics: onMetrics,
90
+ decorators,
91
+ urlTransform
67
92
  },
68
93
  b.id
69
94
  )),
@@ -217,23 +242,30 @@ const CHILD_MEMO_CAP = 4096;
217
242
  function SafeHtml({
218
243
  html,
219
244
  components,
220
- childMemo
245
+ childMemo,
246
+ decorators,
247
+ urlTransform
221
248
  }) {
222
249
  const memoRef = useRef(null);
223
250
  const compRef = useRef(null);
251
+ const decoRef = useRef(void 0);
252
+ const urlRef = useRef(void 0);
224
253
  return useMemo(() => {
254
+ const opts = { decorators, urlTransform };
225
255
  if (!childMemo) {
226
256
  memoRef.current = null;
227
- return htmlToReact(html, components);
257
+ return htmlToReact(html, components, void 0, opts);
228
258
  }
229
- if (memoRef.current === null || compRef.current !== components) {
259
+ if (memoRef.current === null || compRef.current !== components || decoRef.current !== decorators || urlRef.current !== urlTransform) {
230
260
  memoRef.current = /* @__PURE__ */ new Map();
231
261
  compRef.current = components;
262
+ decoRef.current = decorators;
263
+ urlRef.current = urlTransform;
232
264
  }
233
265
  const map = memoRef.current;
234
266
  if (map.size > CHILD_MEMO_CAP) map.clear();
235
- return htmlToReact(html, components, map);
236
- }, [html, components, childMemo]);
267
+ return htmlToReact(html, components, map, opts);
268
+ }, [html, components, childMemo, decorators, urlTransform]);
237
269
  }
238
270
  function KeyedListItemImpl({
239
271
  html,
@@ -408,9 +440,12 @@ function renderBlockContent({
408
440
  block,
409
441
  components,
410
442
  sanitize,
411
- childMemo
443
+ childMemo,
444
+ decorators,
445
+ urlTransform
412
446
  }) {
413
447
  const kind = block.kind.type;
448
+ const hasInlineTransforms = !!decorators || !!urlTransform;
414
449
  if (components) {
415
450
  if (kind === "Component") {
416
451
  const tag = block.kind.data?.tag;
@@ -436,13 +471,13 @@ function renderBlockContent({
436
471
  return /* @__PURE__ */ jsx(Mermaid, { html: block.html, open: block.open });
437
472
  }
438
473
  const className = "flux-block flux-block-" + kind.toLowerCase() + (block.open ? " flux-open" : "") + (block.speculative ? " flux-speculative" : "");
439
- if (kind === "Table" && block.open) {
474
+ if (kind === "Table" && block.open && !hasInlineTransforms) {
440
475
  const data = block.kind.data;
441
476
  if (data && Array.isArray(data.rows)) {
442
477
  return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedTable, { data, html: block.html, components, sanitize }) });
443
478
  }
444
479
  }
445
- if (block.open && kind === "List") {
480
+ if (block.open && kind === "List" && !hasInlineTransforms) {
446
481
  const ld = block.kind.data;
447
482
  const items = ld?.items;
448
483
  const tagOverride = !!components && (!!components.ul || !!components.ol || !!components.li);
@@ -460,15 +495,24 @@ function renderBlockContent({
460
495
  );
461
496
  }
462
497
  }
463
- if (components) {
464
- if (block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
498
+ if (components || hasInlineTransforms) {
499
+ if (components && !hasInlineTransforms && block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
465
500
  const nested = block.kind.data?.nested;
466
501
  if (Array.isArray(nested)) {
467
502
  return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components }) });
468
503
  }
469
504
  }
470
505
  const safe = sanitize ? sanitize(block.html) : block.html;
471
- return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(SafeHtml, { html: safe, components, childMemo: childMemo && block.open }) });
506
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
507
+ SafeHtml,
508
+ {
509
+ html: safe,
510
+ components: components ?? NO_COMPONENTS,
511
+ childMemo: childMemo && block.open,
512
+ decorators,
513
+ urlTransform
514
+ }
515
+ ) });
472
516
  }
473
517
  return /* @__PURE__ */ jsx(
474
518
  "div",
@@ -479,11 +523,15 @@ function renderBlockContent({
479
523
  );
480
524
  }
481
525
  function blocksEqual(prev, next) {
482
- return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.onRenderMetrics === next.onRenderMetrics;
526
+ return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.onRenderMetrics === next.onRenderMetrics && // Identity compare: an unstable decorators/urlTransform (fresh each render)
527
+ // busts the memo so every committed block re-decorates — the O(n²) footgun
528
+ // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
529
+ prev.decorators === next.decorators && prev.urlTransform === next.urlTransform;
483
530
  }
484
531
  const BlockView = memo(BlockViewImpl, blocksEqual);
485
532
  export {
486
533
  FluxMarkdown,
534
+ __resetUnstableWarnings,
487
535
  blockKindProps,
488
536
  blocksEqual,
489
537
  useFluxMarkdownString,
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); }
@@ -3,6 +3,51 @@ export interface BlockKind {
3
3
  type: BlockKindTag;
4
4
  data?: unknown;
5
5
  }
6
+ /**
7
+ * The node type a {@link Decorator} (or `wrapLink`) builds. Kept `unknown` here
8
+ * so this framework-neutral types module stays React-free: the React binding
9
+ * treats it as `ReactNode`, the DOM binding (`flux-md/dom`) as `Node | string`.
10
+ */
11
+ export type FluxNode = unknown;
12
+ /**
13
+ * Wrap or replace matched inline **text** while streaming, in O(n). A decorator
14
+ * runs POST-PARSE on real inline TEXT nodes only (after the core renders a block
15
+ * to HTML and the walker parses it), once per committed block — so it never sees
16
+ * URLs, code, or markup, and a value split by inline markup (e.g.
17
+ * `$2.<em>5</em>B`) is two text nodes and won't match across them.
18
+ *
19
+ * **Trusted surface (read this).** A decorator's `replace` output is spliced
20
+ * directly into the render tree and does **NOT** pass through flux's attribute
21
+ * sanitizer (that only runs on attributes the trusted core emitted). React and
22
+ * the DOM both happily render a `javascript:` href. Treat `decorators` exactly
23
+ * like `components`: only build trusted nodes, and route any link href through
24
+ * the exported `safeUrl` (or use the `wrapLink` helper, which does it for you).
25
+ *
26
+ * **Stability matters (the #1 footgun).** Pass a HOISTED / memoized array — a
27
+ * fresh `decorators` identity every render busts the per-block memo, so every
28
+ * committed block re-parses and re-decorates on every patch (O(n²)). The React
29
+ * binding emits a one-time dev warning if the identity changes.
30
+ */
31
+ export interface Decorator {
32
+ /** Tested against each inline TEXT node's string only (never URLs/code/markup). */
33
+ match: RegExp | string;
34
+ /** PURE fn building the replacement for ONE match. Returns framework nodes. */
35
+ replace: (matchText: string, groups: string[]) => FluxNode;
36
+ /** Ancestor tags to skip. Default `['a','code','pre','kbd']`. */
37
+ skipInside?: string[];
38
+ }
39
+ /**
40
+ * Rewrite a URL attribute (`href`/`src`/`poster`) as a block renders — e.g. to
41
+ * proxy images or add UTM params. Applied O(1) per attribute. The renderer
42
+ * re-sanitizes the OUTPUT (`safeUrl(urlTransform(safeUrl(value)))`), so a buggy
43
+ * or hostile transform can never emit a `javascript:` / `data:text/html` URL
44
+ * that reaches the DOM. Like `decorators`, pass a HOISTED / memoized function so
45
+ * the per-block memo holds.
46
+ */
47
+ export type UrlTransform = (url: string, ctx: {
48
+ tag: string;
49
+ attr: "href" | "src" | "poster";
50
+ }) => string;
6
51
  /** Column alignment from the `|:--|:-:|--:|` delimiter row; `null` = unset. */
7
52
  export type Align = "left" | "center" | "right" | null;
8
53
  /**
@@ -251,6 +296,15 @@ export interface ParserConfig {
251
296
  gfmAutolinks?: boolean;
252
297
  /** GitHub alerts (`> [!NOTE]` → callouts). Default true. */
253
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;
254
308
  /** GFM footnotes (`[^1]` + `[^1]:` → footnote section). Default false. */
255
309
  gfmFootnotes?: boolean;
256
310
  /**
@@ -0,0 +1,12 @@
1
+ /** Decode the (small, known) set of entities the core emits, plus numeric refs. */
2
+ export declare function decodeEntities(s: string): string;
3
+ /** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
4
+ * strip control chars (C0, DEL, C1 — matching Rust char::is_control),
5
+ * lowercase, then match. The strip affects only the probe, never output.
6
+ *
7
+ * Exported as the SAFE URL path for user `decorators` / `urlTransform`: their
8
+ * output is a TRUSTED surface that does NOT pass through the attribute
9
+ * sanitizer, and React/the DOM happily render a `javascript:` href, so a
10
+ * decorator that builds a link must route its href through this (see
11
+ * `wrapLink`), and `urlTransform` output is re-run through it by the renderer. */
12
+ export declare function safeUrl(value: string): string;
@@ -0,0 +1,45 @@
1
+ const NAMED_ENTITIES = {
2
+ amp: "&",
3
+ lt: "<",
4
+ gt: ">",
5
+ quot: '"',
6
+ apos: "'",
7
+ nbsp: " ",
8
+ copy: "\xA9",
9
+ reg: "\xAE",
10
+ hellip: "\u2026",
11
+ mdash: "\u2014",
12
+ ndash: "\u2013"
13
+ };
14
+ function decodeEntities(s) {
15
+ if (s.indexOf("&") === -1) return s;
16
+ return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body) => {
17
+ if (body[0] === "#") {
18
+ const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
19
+ if (Number.isNaN(code) || code < 0 || code > 1114111) return m;
20
+ try {
21
+ return String.fromCodePoint(code);
22
+ } catch {
23
+ return m;
24
+ }
25
+ }
26
+ const named = NAMED_ENTITIES[body];
27
+ return named === void 0 ? m : named;
28
+ });
29
+ }
30
+ function safeUrl(value) {
31
+ let decoded = value;
32
+ for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
33
+ prev = decoded;
34
+ decoded = decodeEntities(decoded);
35
+ }
36
+ const probe = decoded.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
37
+ if (probe.startsWith("javascript:") || probe.startsWith("vbscript:") || probe.startsWith("data:text/html") || probe.startsWith("data:text/javascript")) {
38
+ return "#";
39
+ }
40
+ return value;
41
+ }
42
+ export {
43
+ decodeEntities,
44
+ safeUrl
45
+ };
@@ -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.18.4",
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"],