flux-md 0.18.3 → 0.19.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,79 @@ 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.19.0 — 2026-06-30
8
+
9
+ ### Added
10
+
11
+ - **`decorators` — wrap/replace matched inline text while streaming.** A
12
+ declarative matcher list (`{ match: RegExp | string, replace: (text, groups) =>
13
+ node, skipInside?: string[] }`) on `<FluxMarkdown>` (React) and the DOM mount
14
+ options, applied to inline **text nodes only** after parsing — so it never sees
15
+ link URLs, code, or markup (no avoidance rules to hand-roll), and it runs once
16
+ per committed block, staying linear over a stream. Wrapping matched figures
17
+ (e.g. `$2.5B`, `10-15%`) is a one-liner. Decorator output is a **trusted**
18
+ surface (like `components`); `safeUrl` is now exported and `wrapLink(text, {
19
+ href })` ships as the safe link path. The `decorators` prop must be
20
+ referentially stable (hoist/memoize) — a dev-mode warning fires if it isn't,
21
+ since an unstable prop would re-decorate every committed block each tick.
22
+ - **`urlTransform`** — rewrite `href`/`src`/`poster` URLs (image proxy, allowlist,
23
+ relative resolution). The output is re-sanitized through the same scheme filter,
24
+ so a transform can't introduce a dangerous URL.
25
+
26
+ ### Performance
27
+
28
+ - **Nested lists now stream in O(n) instead of O(n²).** A loose outer list with
29
+ indented sub-bullets — and any list whose items have multi-line or nested-block
30
+ bodies — used to make the incremental list cache bail to a full reparse on every
31
+ appended chunk (re-scanning the whole growing list). It now renders each item's
32
+ full body, nested sub-lists included, through the shared item renderer, so it
33
+ stays linear. Streamed and one-shot output are byte-identical. (WASM −0.3 KB.)
34
+
35
+ ## 0.18.5 — 2026-06-30
36
+
37
+ ### Performance
38
+
39
+ - **Blockquotes and GFM alerts with structured bodies now stream in O(n) instead
40
+ of O(n²).** When a `>` blockquote or `> [!NOTE]` alert contains a list, table,
41
+ nested quote, heading, or code block, the incremental container cache used to
42
+ bail to a full reparse on every appended chunk — re-scanning and re-rendering
43
+ the whole growing block, so a long quoted list or alert-with-list went
44
+ quadratic (a 256 KB body streamed in small chunks did ~250× the parse work of a
45
+ 16 KB one). It now renders the `>`-stripped inner through a recursive nested
46
+ parser, committing settled inner blocks and re-rendering only the open tail, so
47
+ the work is linear in document size. Streamed and one-shot output stay
48
+ byte-identical. (WASM +3.8 KB.)
49
+
50
+ ### Internal
51
+
52
+ - A deterministic complexity-scaling gate (`cargo test --features perf_counters
53
+ --test scaling`), a proptest chunk-independence parity suite, and a cargo-fuzz
54
+ parity target now run in CI to catch O(n²) streaming regressions and chunk-
55
+ boundary divergences before they ship. The container regression above was
56
+ surfaced by the new gate on its first run.
57
+
58
+ ## 0.18.4 — 2026-06-29
59
+
60
+ ### Fixed
61
+
62
+ - **Blockquote / alert inner content flattened mid-stream (same flicker class as
63
+ 0.18.3's nested lists).** The container (blockquote / GFM alert) cache rendered
64
+ ALL inner content as plain paragraph text while streaming, so a list, nested
65
+ blockquote, heading, setext heading, fenced or indented code, table, thematic
66
+ break, HTML block, ordered list (incl. `start ≠ 1`), or link-reference
67
+ definition inside a `>` block showed as escaped paragraph text until finalize,
68
+ then snapped into its real structure. The cache now bails to the full reparse
69
+ whenever an inner line is anything other than plain paragraph prose. Found by
70
+ fuzzing the streaming prefix-parity invariant (the streamed view must equal a
71
+ one-shot parse at **every** prefix) over ~15k construct interactions plus an
72
+ adversarial corpus; streamed output now matches one-shot at every prefix for
73
+ these shapes.
74
+
75
+ ### Internal
76
+
77
+ - Removed a dead struct field and an unnecessary `mut` left by recent changes
78
+ (clean build, no warnings).
79
+
7
80
  ## 0.18.3 — 2026-06-29
8
81
 
9
82
  ### Fixed
package/README.md CHANGED
@@ -723,6 +723,46 @@ Rules worth knowing:
723
723
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
724
724
  or `components.code`.
725
725
 
726
+ #### Inline text decorators
727
+
728
+ Wrap or replace matched inline **text** while streaming — e.g. bold financial
729
+ figures — without writing your own HTML re-parser. A `decorators` entry runs
730
+ POST-parse on real inline **text nodes only** (never URLs, code, or markup), once
731
+ per committed block, so a long document stays **O(n)**.
732
+
733
+ ```tsx
734
+ import { FluxMarkdown, wrapLink } from "flux-md";
735
+
736
+ // HOIST it (module scope) or memoize — a fresh identity each render busts the
737
+ // per-block memo and re-decorates every block on every patch (a dev warning fires).
738
+ const decorators = [
739
+ { match: /\$[\d.]+[BMK]|FY\d{4}|\d+(?:[-–]\d+)?%/g, replace: (t) => <mark>{t}</mark> },
740
+ // Linkify a ticker — route the href through the safe helper (see below):
741
+ { match: /\$[A-Z]{1,5}\b/g, replace: (t) => wrapLink(t, { href: `/sym/${t.slice(1)}` }) },
742
+ ];
743
+
744
+ <FluxMarkdown client={client} decorators={decorators} />;
745
+ ```
746
+
747
+ - **Trusted surface — not sanitized.** A decorator's `replace` output is spliced
748
+ straight into the tree and does **not** pass through flux's attribute sanitizer
749
+ (React renders a `javascript:` href without complaint). Treat `decorators`
750
+ exactly like `components`: build only trusted nodes, and route any link href
751
+ through `wrapLink` or the exported `safeUrl`.
752
+ - **`skipInside`** defaults to `['a','code','pre','kbd']`; override per decorator.
753
+ - **Per-text-node.** A value split by inline markup (e.g. `$2.<em>5</em>B`) is two
754
+ text nodes and won't match across them — match against settled, contiguous text.
755
+ - Matching is pure and stateless, so a value streamed char-by-char decorates
756
+ **identically** to a one-shot render. Same API on `flux-md/dom`
757
+ (`mountFluxMarkdown(client, el, { decorators })`); a decorator there returns a
758
+ `Node` or string.
759
+
760
+ `urlTransform?: (url, { tag, attr }) => string` rewrites `href`/`src`/`poster`
761
+ URLs as blocks render (proxy images, add UTM params). Its output is re-sanitized
762
+ (`safeUrl(urlTransform(safeUrl(value)))`), so a buggy transform can never emit a
763
+ `javascript:` / `data:text/html` URL. Hoist/memoize it for the same reason as
764
+ `decorators`.
765
+
726
766
  ### Structured block data (`setBlockData`)
727
767
 
728
768
  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
@@ -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,
@@ -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
  /**
@@ -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
+ };
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.18.3",
3
+ "version": "0.19.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"],