flux-md 0.18.4 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/README.md +80 -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/element.js +1 -0
- 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/server.js +1 -0
- package/dist/styles.css +6 -0
- package/dist/types-core.d.ts +54 -0
- package/dist/url-safety.d.ts +12 -0
- package/dist/url-safety.js +45 -0
- package/dist/wasm/flux_md_core.d.ts +9 -0
- package/dist/wasm/flux_md_core.js +11 -0
- package/dist/wasm/flux_md_core_bg.wasm +0 -0
- package/dist/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,129 @@ Notable changes to flux-md. Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this project aims to follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.20.0 — 2026-07-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Streaming links render cleanly from the first character.** An open link's
|
|
12
|
+
label used to show as raw bracketed text (`[Earnings Call](`) until the URL
|
|
13
|
+
started arriving, then snap into an anchor. The label now renders inside an
|
|
14
|
+
inert pending anchor from its first character — no brackets, no raw URL, and
|
|
15
|
+
the pending HTML is byte-stable through the whole URL so completion is a
|
|
16
|
+
single attribute swap (no DOM churn). Pending anchors carry
|
|
17
|
+
`data-flux-pending=""` (gone the moment `href` lands, never in final output)
|
|
18
|
+
so you can style them like settled links immediately; the optional theme
|
|
19
|
+
does this out of the box. Deliberate exclusions so common non-link brackets
|
|
20
|
+
never flash as links: footnote refs (`[^1]`), task checkboxes, alert
|
|
21
|
+
markers, and all-digit citation labels (`[1]`, `[12]`).
|
|
22
|
+
- **`gfmTagfilter`** — the GFM "Disallowed Raw HTML" extension, opt-in like
|
|
23
|
+
the other GFM options (`gfm-tagfilter` on the custom element,
|
|
24
|
+
`with_gfm_tagfilter` in the core). With raw HTML enabled, the nine
|
|
25
|
+
page-hijacking tags (`title`, `textarea`, `style`, `xmp`, `iframe`,
|
|
26
|
+
`noembed`, `noframes`, `script`, `plaintext`) get their leading `<`
|
|
27
|
+
escaped, matching GitHub's rendering. The GFM extension suite is now 24/24.
|
|
28
|
+
|
|
29
|
+
### Performance
|
|
30
|
+
|
|
31
|
+
- **Fourteen classes of streaming O(n²) eliminated.** An adversarial
|
|
32
|
+
multi-agent audit probed 137 document shapes, confirmed 17 quadratic
|
|
33
|
+
root-cause groups at the work-counter level, and this release fixes 14 —
|
|
34
|
+
every one now streams in O(new bytes) and is pinned linear by the
|
|
35
|
+
deterministic complexity gate (now three counters: slow-path scans,
|
|
36
|
+
inline-render bytes, emitted bytes). Highlights at 512 KB streamed in
|
|
37
|
+
256-byte chunks unless noted:
|
|
38
|
+
- **CRLF input** made every incremental cache bail — a plain list cost
|
|
39
|
+
49.4 s; now line endings normalize at ingest and CRLF streams cost the
|
|
40
|
+
same as LF (210 ms).
|
|
41
|
+
- **Footnotes**: a no-blank run of definitions stalled commits and every
|
|
42
|
+
append recloned the footnote maps — a list with per-item refs cost 48 s
|
|
43
|
+
at 256 KB; now 200 ms.
|
|
44
|
+
- **Link-reference definitions inside blockquotes** armed no cache
|
|
45
|
+
(44 s → 134 ms); lazy continuation lines no longer disarm quote caches.
|
|
46
|
+
- **Open list items with multi-line bodies** re-rendered whole every
|
|
47
|
+
append (23 s → 0.3 s); legal interior blank lines no longer permanently
|
|
48
|
+
disarm the list/indented-code caches (5 s → 54 ms).
|
|
49
|
+
- **Tables**: the growing trailing row re-split and re-rendered every
|
|
50
|
+
append (23.8 s → 254 ms; a thousand-column row 25.4 s → 65 ms).
|
|
51
|
+
- **`blockData` mode** disabled the container cache outright (a 256 KB
|
|
52
|
+
alert cost 41.7 s → 226 ms, 185×) and rebuilt code/math/list/table data
|
|
53
|
+
channels from scratch per append (512 KB math fence 7.4 s → 132 ms).
|
|
54
|
+
- **Streaming component blocks** (`<Chart>` bodies), giant headings,
|
|
55
|
+
thematic breaks, and growing fence info strings had no incremental cache
|
|
56
|
+
at all (a 256 KB component body 48 s → 345 ms).
|
|
57
|
+
- **Inline engine**: emphasis edits now apply in one forward pass instead
|
|
58
|
+
of per-pair splicing, unpairable delimiters no longer rescan the whole
|
|
59
|
+
stack (the CommonMark mod-3 pathology was 54 s for a one-shot 256 KB
|
|
60
|
+
render — now 51 ms, and it hit server-side rendering too), unmatched `$`
|
|
61
|
+
with math enabled no longer rescans to end-of-input per candidate
|
|
62
|
+
(57.7 s → 33 ms), and space-free text (entities, long tokens) commits
|
|
63
|
+
incrementally instead of pinning the whole paragraph.
|
|
64
|
+
- HTML blocks whose appends landed exactly on line boundaries dropped
|
|
65
|
+
their cache every append (7.7 s → 59 ms).
|
|
66
|
+
- WASM binary: 197 KB (+28 KB), the cost of seven new incremental caches.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- CRLF documents no longer leak raw `\r` bytes into rendered code blocks;
|
|
71
|
+
CRLF output is byte-identical to the LF equivalent (line endings are
|
|
72
|
+
equivalent per CommonMark).
|
|
73
|
+
- Two latent mid-stream parity divergences (speculative rendering at
|
|
74
|
+
whitespace-only tails; a link-reference definition on an alert's first
|
|
75
|
+
body line) now match the one-shot render exactly.
|
|
76
|
+
- A link awaiting its title (`[label](url "ti…`) no longer flashes literal
|
|
77
|
+
source mid-stream.
|
|
78
|
+
|
|
79
|
+
## 0.19.0 — 2026-06-30
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- **`decorators` — wrap/replace matched inline text while streaming.** A
|
|
84
|
+
declarative matcher list (`{ match: RegExp | string, replace: (text, groups) =>
|
|
85
|
+
node, skipInside?: string[] }`) on `<FluxMarkdown>` (React) and the DOM mount
|
|
86
|
+
options, applied to inline **text nodes only** after parsing — so it never sees
|
|
87
|
+
link URLs, code, or markup (no avoidance rules to hand-roll), and it runs once
|
|
88
|
+
per committed block, staying linear over a stream. Wrapping matched figures
|
|
89
|
+
(e.g. `$2.5B`, `10-15%`) is a one-liner. Decorator output is a **trusted**
|
|
90
|
+
surface (like `components`); `safeUrl` is now exported and `wrapLink(text, {
|
|
91
|
+
href })` ships as the safe link path. The `decorators` prop must be
|
|
92
|
+
referentially stable (hoist/memoize) — a dev-mode warning fires if it isn't,
|
|
93
|
+
since an unstable prop would re-decorate every committed block each tick.
|
|
94
|
+
- **`urlTransform`** — rewrite `href`/`src`/`poster` URLs (image proxy, allowlist,
|
|
95
|
+
relative resolution). The output is re-sanitized through the same scheme filter,
|
|
96
|
+
so a transform can't introduce a dangerous URL.
|
|
97
|
+
|
|
98
|
+
### Performance
|
|
99
|
+
|
|
100
|
+
- **Nested lists now stream in O(n) instead of O(n²).** A loose outer list with
|
|
101
|
+
indented sub-bullets — and any list whose items have multi-line or nested-block
|
|
102
|
+
bodies — used to make the incremental list cache bail to a full reparse on every
|
|
103
|
+
appended chunk (re-scanning the whole growing list). It now renders each item's
|
|
104
|
+
full body, nested sub-lists included, through the shared item renderer, so it
|
|
105
|
+
stays linear. Streamed and one-shot output are byte-identical. (WASM −0.3 KB.)
|
|
106
|
+
|
|
107
|
+
## 0.18.5 — 2026-06-30
|
|
108
|
+
|
|
109
|
+
### Performance
|
|
110
|
+
|
|
111
|
+
- **Blockquotes and GFM alerts with structured bodies now stream in O(n) instead
|
|
112
|
+
of O(n²).** When a `>` blockquote or `> [!NOTE]` alert contains a list, table,
|
|
113
|
+
nested quote, heading, or code block, the incremental container cache used to
|
|
114
|
+
bail to a full reparse on every appended chunk — re-scanning and re-rendering
|
|
115
|
+
the whole growing block, so a long quoted list or alert-with-list went
|
|
116
|
+
quadratic (a 256 KB body streamed in small chunks did ~250× the parse work of a
|
|
117
|
+
16 KB one). It now renders the `>`-stripped inner through a recursive nested
|
|
118
|
+
parser, committing settled inner blocks and re-rendering only the open tail, so
|
|
119
|
+
the work is linear in document size. Streamed and one-shot output stay
|
|
120
|
+
byte-identical. (WASM +3.8 KB.)
|
|
121
|
+
|
|
122
|
+
### Internal
|
|
123
|
+
|
|
124
|
+
- A deterministic complexity-scaling gate (`cargo test --features perf_counters
|
|
125
|
+
--test scaling`), a proptest chunk-independence parity suite, and a cargo-fuzz
|
|
126
|
+
parity target now run in CI to catch O(n²) streaming regressions and chunk-
|
|
127
|
+
boundary divergences before they ship. The container regression above was
|
|
128
|
+
surfaced by the new gate on its first run.
|
|
129
|
+
|
|
7
130
|
## 0.18.4 — 2026-06-29
|
|
8
131
|
|
|
9
132
|
### Fixed
|
package/README.md
CHANGED
|
@@ -467,9 +467,41 @@ export default function Doc({ md }: { md: string }) {
|
|
|
467
467
|
| Where parse runs | Web Worker (off main thread) | Main thread |
|
|
468
468
|
| Block identity across chunks | Stable monotonic IDs | New keys on every render |
|
|
469
469
|
| Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
|
|
470
|
+
| Half-streamed link `[label](https://…` | Label-only inert anchor (`data-flux-pending`), URL never leaks | Raw brackets + partial URL flash |
|
|
470
471
|
| Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
|
|
471
472
|
| XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
|
|
472
473
|
|
|
474
|
+
### Streaming links
|
|
475
|
+
|
|
476
|
+
A link's destination is the last thing a model emits — `[Earnings Call](https://…`
|
|
477
|
+
often spans many tokens. While a link is still streaming, flux-md renders it as
|
|
478
|
+
an **inert, label-only anchor**: the label text inside an `<a>` with no `href`
|
|
479
|
+
(the half-typed URL never flashes on screen), marked so you can style it:
|
|
480
|
+
|
|
481
|
+
```html
|
|
482
|
+
<a data-flux-pending="" target="_blank" rel="noopener noreferrer nofollow">Earnings Call</a>
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
An anchor without an `href` gets **no default link styling** from the browser, so
|
|
486
|
+
without a rule for the marker the link would "pop" blue only when the URL
|
|
487
|
+
completes. The bundled theme (`import "flux-md/styles.css"`) already styles it;
|
|
488
|
+
if you bring your own CSS, copy this:
|
|
489
|
+
|
|
490
|
+
```css
|
|
491
|
+
.flux-md a[data-flux-pending] {
|
|
492
|
+
color: var(--flux-accent, #0969da); /* match your settled link's resting style */
|
|
493
|
+
cursor: default; /* not clickable yet */
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The moment the closing `)` arrives, the `href` appears and `data-flux-pending`
|
|
498
|
+
is dropped — committed and finalized output never carry the marker, and the
|
|
499
|
+
finished block is byte-identical to a one-shot parse. Two composition notes:
|
|
500
|
+
`urlTransform` runs only on a real `href`, so it never sees a half-streamed URL
|
|
501
|
+
prefix — only the complete one (it may run again on re-renders while the
|
|
502
|
+
surrounding block is still open); `decorators` skip text
|
|
503
|
+
inside `<a>` by default (`skipInside`), pending or not.
|
|
504
|
+
|
|
473
505
|
## Styling
|
|
474
506
|
|
|
475
507
|
flux-md emits semantic HTML under a `.flux-md` root and **ships no CSS by
|
|
@@ -555,6 +587,7 @@ const client = new FluxClient({
|
|
|
555
587
|
config: {
|
|
556
588
|
gfmAutolinks: true, // bare www./http(s):// URLs + emails → links (default true)
|
|
557
589
|
gfmAlerts: true, // > [!NOTE] → callouts (default true)
|
|
590
|
+
gfmTagfilter: false, // GFM disallowed raw HTML: escape <script>/<title>/… under unsafeHtml (default false)
|
|
558
591
|
gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
|
|
559
592
|
gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
|
|
560
593
|
dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
|
|
@@ -591,6 +624,13 @@ When to enable each flag:
|
|
|
591
624
|
- `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
|
|
592
625
|
LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
|
|
593
626
|
similar — see [Security](#security)).
|
|
627
|
+
- `gfmTagfilter: true` — the GFM "Disallowed Raw HTML" extension, for use with
|
|
628
|
+
`unsafeHtml`: the nine disallowed tags (`<title>`, `<textarea>`, `<style>`,
|
|
629
|
+
`<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get
|
|
630
|
+
their leading `<` escaped so they display as text instead of taking effect —
|
|
631
|
+
opening and closing forms, any case, in blocks and inline. Off by default
|
|
632
|
+
(strict CommonMark passes them through under `unsafeHtml`); it's a tag
|
|
633
|
+
denylist, **not** a sanitizer — untrusted input still wants `sanitize`.
|
|
594
634
|
- `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
|
|
595
635
|
like `<Thinking>…</Thinking>` (on their own line) and you want their inner
|
|
596
636
|
content parsed as markdown and dispatched to a React component. Safe without
|
|
@@ -723,6 +763,46 @@ Rules worth knowing:
|
|
|
723
763
|
(so your override wins) when you pass `components.CodeBlock`, `components.pre`,
|
|
724
764
|
or `components.code`.
|
|
725
765
|
|
|
766
|
+
#### Inline text decorators
|
|
767
|
+
|
|
768
|
+
Wrap or replace matched inline **text** while streaming — e.g. bold financial
|
|
769
|
+
figures — without writing your own HTML re-parser. A `decorators` entry runs
|
|
770
|
+
POST-parse on real inline **text nodes only** (never URLs, code, or markup), once
|
|
771
|
+
per committed block, so a long document stays **O(n)**.
|
|
772
|
+
|
|
773
|
+
```tsx
|
|
774
|
+
import { FluxMarkdown, wrapLink } from "flux-md";
|
|
775
|
+
|
|
776
|
+
// HOIST it (module scope) or memoize — a fresh identity each render busts the
|
|
777
|
+
// per-block memo and re-decorates every block on every patch (a dev warning fires).
|
|
778
|
+
const decorators = [
|
|
779
|
+
{ match: /\$[\d.]+[BMK]|FY\d{4}|\d+(?:[-–]\d+)?%/g, replace: (t) => <mark>{t}</mark> },
|
|
780
|
+
// Linkify a ticker — route the href through the safe helper (see below):
|
|
781
|
+
{ match: /\$[A-Z]{1,5}\b/g, replace: (t) => wrapLink(t, { href: `/sym/${t.slice(1)}` }) },
|
|
782
|
+
];
|
|
783
|
+
|
|
784
|
+
<FluxMarkdown client={client} decorators={decorators} />;
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
- **Trusted surface — not sanitized.** A decorator's `replace` output is spliced
|
|
788
|
+
straight into the tree and does **not** pass through flux's attribute sanitizer
|
|
789
|
+
(React renders a `javascript:` href without complaint). Treat `decorators`
|
|
790
|
+
exactly like `components`: build only trusted nodes, and route any link href
|
|
791
|
+
through `wrapLink` or the exported `safeUrl`.
|
|
792
|
+
- **`skipInside`** defaults to `['a','code','pre','kbd']`; override per decorator.
|
|
793
|
+
- **Per-text-node.** A value split by inline markup (e.g. `$2.<em>5</em>B`) is two
|
|
794
|
+
text nodes and won't match across them — match against settled, contiguous text.
|
|
795
|
+
- Matching is pure and stateless, so a value streamed char-by-char decorates
|
|
796
|
+
**identically** to a one-shot render. Same API on `flux-md/dom`
|
|
797
|
+
(`mountFluxMarkdown(client, el, { decorators })`); a decorator there returns a
|
|
798
|
+
`Node` or string.
|
|
799
|
+
|
|
800
|
+
`urlTransform?: (url, { tag, attr }) => string` rewrites `href`/`src`/`poster`
|
|
801
|
+
URLs as blocks render (proxy images, add UTM params). Its output is re-sanitized
|
|
802
|
+
(`safeUrl(urlTransform(safeUrl(value)))`), so a buggy transform can never emit a
|
|
803
|
+
`javascript:` / `data:text/html` URL. Hoist/memoize it for the same reason as
|
|
804
|
+
`decorators`.
|
|
805
|
+
|
|
726
806
|
### Structured block data (`setBlockData`)
|
|
727
807
|
|
|
728
808
|
Set `blockData: true` in the per-stream config and each block carries typed
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Decorator } from "./types-core.js";
|
|
2
|
+
/** Ancestor tags inside which decoration is skipped by default. */
|
|
3
|
+
export declare const DEFAULT_SKIP: readonly string[];
|
|
4
|
+
/** A run of the original text, or one matched span the caller turns into a node. */
|
|
5
|
+
export type DecorateSegment = {
|
|
6
|
+
type: "text";
|
|
7
|
+
text: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "match";
|
|
10
|
+
decorator: Decorator;
|
|
11
|
+
matchText: string;
|
|
12
|
+
groups: string[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Split one inline text node's string into segments per the active decorators.
|
|
16
|
+
* Decorators apply in order; each one only re-scans the still-TEXT segments left
|
|
17
|
+
* by the previous ones (so a match is never decorated twice). `ancestors` is the
|
|
18
|
+
* chain of enclosing tag names — a decorator whose `skipInside` (default
|
|
19
|
+
* {@link DEFAULT_SKIP}) intersects it is not applied here.
|
|
20
|
+
*
|
|
21
|
+
* Returns `null` when nothing matched at all, so the caller can take the
|
|
22
|
+
* zero-allocation fast path of emitting the original text node unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export declare function decorateSegments(text: string, decorators: Decorator[], ancestors: string[]): DecorateSegment[] | null;
|
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/element.js
CHANGED
|
@@ -146,6 +146,7 @@ function defineFluxMarkdown(tag = "flux-markdown") {
|
|
|
146
146
|
};
|
|
147
147
|
set("gfm-autolinks", "gfmAutolinks");
|
|
148
148
|
set("gfm-alerts", "gfmAlerts");
|
|
149
|
+
set("gfm-tagfilter", "gfmTagfilter");
|
|
149
150
|
set("gfm-footnotes", "gfmFootnotes");
|
|
150
151
|
set("gfm-math", "gfmMath");
|
|
151
152
|
set("dir-auto", "dirAuto");
|
package/dist/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/server.js
CHANGED
|
@@ -36,6 +36,7 @@ function makeParser(config) {
|
|
|
36
36
|
const p = new FluxParser();
|
|
37
37
|
p.setGfmAutolinks(config?.gfmAutolinks ?? true);
|
|
38
38
|
p.setGfmAlerts(config?.gfmAlerts ?? true);
|
|
39
|
+
p.setGfmTagfilter(config?.gfmTagfilter ?? false);
|
|
39
40
|
p.setGfmFootnotes(config?.gfmFootnotes ?? false);
|
|
40
41
|
p.setGfmMath(config?.gfmMath ?? false);
|
|
41
42
|
p.setDirAuto(config?.dirAuto ?? false);
|
package/dist/styles.css
CHANGED
|
@@ -113,6 +113,12 @@
|
|
|
113
113
|
/* ---- inline ---------------------------------------------------------------- */
|
|
114
114
|
.flux-md a { color: var(--flux-accent); text-decoration: none; }
|
|
115
115
|
.flux-md a:hover { text-decoration: underline; }
|
|
116
|
+
/* A streaming link whose URL hasn't finished arriving renders as an inert
|
|
117
|
+
`<a data-flux-pending>` with no href — the UA gives an href-less anchor no
|
|
118
|
+
link styling, so restate the settled resting style here (identical color and
|
|
119
|
+
decoration, so completion changes nothing visually). Cursor stays default:
|
|
120
|
+
not clickable yet. */
|
|
121
|
+
.flux-md a[data-flux-pending] { color: var(--flux-accent); text-decoration: none; cursor: default; }
|
|
116
122
|
.flux-md strong { font-weight: 600; }
|
|
117
123
|
.flux-md em { font-style: italic; }
|
|
118
124
|
.flux-md del { color: var(--flux-fg-muted); }
|
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
|
/**
|
|
@@ -251,6 +296,15 @@ export interface ParserConfig {
|
|
|
251
296
|
gfmAutolinks?: boolean;
|
|
252
297
|
/** GitHub alerts (`> [!NOTE]` → callouts). Default true. */
|
|
253
298
|
gfmAlerts?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* GFM "Disallowed Raw HTML" (tagfilter): with `unsafeHtml` on, the nine
|
|
301
|
+
* disallowed tags (`<title>`, `<textarea>`, `<style>`, `<xmp>`, `<iframe>`,
|
|
302
|
+
* `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get their leading
|
|
303
|
+
* `<` escaped so they display as text instead of taking effect. Default
|
|
304
|
+
* false (strict CommonMark passes them through under `unsafeHtml`); no
|
|
305
|
+
* effect while raw HTML is escaped (default) or sanitized — already inert.
|
|
306
|
+
*/
|
|
307
|
+
gfmTagfilter?: boolean;
|
|
254
308
|
/** GFM footnotes (`[^1]` + `[^1]:` → footnote section). Default false. */
|
|
255
309
|
gfmFootnotes?: boolean;
|
|
256
310
|
/**
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Decode the (small, known) set of entities the core emits, plus numeric refs. */
|
|
2
|
+
export declare function decodeEntities(s: string): string;
|
|
3
|
+
/** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
|
|
4
|
+
* strip control chars (C0, DEL, C1 — matching Rust char::is_control),
|
|
5
|
+
* lowercase, then match. The strip affects only the probe, never output.
|
|
6
|
+
*
|
|
7
|
+
* Exported as the SAFE URL path for user `decorators` / `urlTransform`: their
|
|
8
|
+
* output is a TRUSTED surface that does NOT pass through the attribute
|
|
9
|
+
* sanitizer, and React/the DOM happily render a `javascript:` href, so a
|
|
10
|
+
* decorator that builds a link must route its href through this (see
|
|
11
|
+
* `wrapLink`), and `urlTransform` output is re-run through it by the renderer. */
|
|
12
|
+
export declare function safeUrl(value: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const NAMED_ENTITIES = {
|
|
2
|
+
amp: "&",
|
|
3
|
+
lt: "<",
|
|
4
|
+
gt: ">",
|
|
5
|
+
quot: '"',
|
|
6
|
+
apos: "'",
|
|
7
|
+
nbsp: " ",
|
|
8
|
+
copy: "\xA9",
|
|
9
|
+
reg: "\xAE",
|
|
10
|
+
hellip: "\u2026",
|
|
11
|
+
mdash: "\u2014",
|
|
12
|
+
ndash: "\u2013"
|
|
13
|
+
};
|
|
14
|
+
function decodeEntities(s) {
|
|
15
|
+
if (s.indexOf("&") === -1) return s;
|
|
16
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body) => {
|
|
17
|
+
if (body[0] === "#") {
|
|
18
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
19
|
+
if (Number.isNaN(code) || code < 0 || code > 1114111) return m;
|
|
20
|
+
try {
|
|
21
|
+
return String.fromCodePoint(code);
|
|
22
|
+
} catch {
|
|
23
|
+
return m;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const named = NAMED_ENTITIES[body];
|
|
27
|
+
return named === void 0 ? m : named;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function safeUrl(value) {
|
|
31
|
+
let decoded = value;
|
|
32
|
+
for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
|
|
33
|
+
prev = decoded;
|
|
34
|
+
decoded = decodeEntities(decoded);
|
|
35
|
+
}
|
|
36
|
+
const probe = decoded.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
|
|
37
|
+
if (probe.startsWith("javascript:") || probe.startsWith("vbscript:") || probe.startsWith("data:text/html") || probe.startsWith("data:text/javascript")) {
|
|
38
|
+
return "#";
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
decodeEntities,
|
|
44
|
+
safeUrl
|
|
45
|
+
};
|
|
@@ -82,6 +82,14 @@ export class FluxParser {
|
|
|
82
82
|
* `<div class="math math-display">` for a KaTeX pass on the JS side.
|
|
83
83
|
*/
|
|
84
84
|
setGfmMath(on: boolean): void;
|
|
85
|
+
/**
|
|
86
|
+
* Enable the GFM "Disallowed Raw HTML" extension (tagfilter): with raw
|
|
87
|
+
* HTML passing through (`setUnsafeHtml(true)`), the nine disallowed tags
|
|
88
|
+
* (`<title>`, `<script>`, `<iframe>`, …) get their leading `<` escaped so
|
|
89
|
+
* they display as text instead of taking effect. Off by default; no
|
|
90
|
+
* effect while raw HTML is escaped or sanitized (already inert).
|
|
91
|
+
*/
|
|
92
|
+
setGfmTagfilter(on: boolean): void;
|
|
85
93
|
/**
|
|
86
94
|
* Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
|
|
87
95
|
* sanitized without full unsafe HTML: `allow` empty = allow all tags except
|
|
@@ -125,6 +133,7 @@ export interface InitOutput {
|
|
|
125
133
|
readonly fluxparser_setGfmAutolinks: (a: number, b: number) => void;
|
|
126
134
|
readonly fluxparser_setGfmFootnotes: (a: number, b: number) => void;
|
|
127
135
|
readonly fluxparser_setGfmMath: (a: number, b: number) => void;
|
|
136
|
+
readonly fluxparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
128
137
|
readonly fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
129
138
|
readonly fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
130
139
|
readonly fluxparser_setUnsafeHtml: (a: number, b: number) => void;
|
|
@@ -205,6 +205,17 @@ export class FluxParser {
|
|
|
205
205
|
setGfmMath(on) {
|
|
206
206
|
wasm.fluxparser_setGfmMath(this.__wbg_ptr, on);
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Enable the GFM "Disallowed Raw HTML" extension (tagfilter): with raw
|
|
210
|
+
* HTML passing through (`setUnsafeHtml(true)`), the nine disallowed tags
|
|
211
|
+
* (`<title>`, `<script>`, `<iframe>`, …) get their leading `<` escaped so
|
|
212
|
+
* they display as text instead of taking effect. Off by default; no
|
|
213
|
+
* effect while raw HTML is escaped or sanitized (already inert).
|
|
214
|
+
* @param {boolean} on
|
|
215
|
+
*/
|
|
216
|
+
setGfmTagfilter(on) {
|
|
217
|
+
wasm.fluxparser_setGfmTagfilter(this.__wbg_ptr, on);
|
|
218
|
+
}
|
|
208
219
|
/**
|
|
209
220
|
* Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
|
|
210
221
|
* sanitized without full unsafe HTML: `allow` empty = allow all tags except
|
|
Binary file
|
|
@@ -16,6 +16,7 @@ export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
|
|
16
16
|
export const fluxparser_setGfmAutolinks: (a: number, b: number) => void;
|
|
17
17
|
export const fluxparser_setGfmFootnotes: (a: number, b: number) => void;
|
|
18
18
|
export const fluxparser_setGfmMath: (a: number, b: number) => void;
|
|
19
|
+
export const fluxparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
19
20
|
export const fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
20
21
|
export const fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
21
22
|
export const fluxparser_setUnsafeHtml: (a: number, b: number) => void;
|
package/dist/worker.js
CHANGED
|
@@ -11,6 +11,7 @@ const core = new WorkerCore({
|
|
|
11
11
|
const p = new FluxParser();
|
|
12
12
|
p.setGfmAutolinks(c?.gfmAutolinks ?? true);
|
|
13
13
|
p.setGfmAlerts(c?.gfmAlerts ?? true);
|
|
14
|
+
p.setGfmTagfilter(c?.gfmTagfilter ?? false);
|
|
14
15
|
p.setGfmFootnotes(c?.gfmFootnotes ?? false);
|
|
15
16
|
p.setGfmMath(c?.gfmMath ?? false);
|
|
16
17
|
p.setDirAuto(c?.dirAuto ?? false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": ["./dist/worker.js", "./dist/styles.css"],
|