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 +73 -0
- package/README.md +40 -0
- package/dist/decorate.d.ts +24 -0
- package/dist/decorate.js +71 -0
- package/dist/dom.d.ts +21 -1
- package/dist/dom.js +55 -4
- package/dist/html-to-react.d.ts +26 -5
- package/dist/html-to-react.js +46 -52
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -2
- package/dist/react.d.ts +33 -1
- package/dist/react.js +62 -14
- package/dist/types-core.d.ts +45 -0
- package/dist/url-safety.d.ts +12 -0
- package/dist/url-safety.js +45 -0
- package/dist/wasm/flux_md_core_bg.wasm +0 -0
- package/package.json +1 -1
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;
|
package/dist/decorate.js
ADDED
|
@@ -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
|
-
|
|
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/html-to-react.d.ts
CHANGED
|
@@ -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
|
|
40
|
-
|
|
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;
|
package/dist/html-to-react.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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/types-core.d.ts
CHANGED
|
@@ -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.
|
|
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"],
|