flux-md 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +40 -0
- package/dist/element.js +1 -0
- package/dist/server.js +1 -0
- package/dist/styles.css +6 -0
- package/dist/types-core.d.ts +9 -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,78 @@ Notable changes to flux-md. Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this project aims to follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.20.0 — 2026-07-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Streaming links render cleanly from the first character.** An open link's
|
|
12
|
+
label used to show as raw bracketed text (`[Earnings Call](`) until the URL
|
|
13
|
+
started arriving, then snap into an anchor. The label now renders inside an
|
|
14
|
+
inert pending anchor from its first character — no brackets, no raw URL, and
|
|
15
|
+
the pending HTML is byte-stable through the whole URL so completion is a
|
|
16
|
+
single attribute swap (no DOM churn). Pending anchors carry
|
|
17
|
+
`data-flux-pending=""` (gone the moment `href` lands, never in final output)
|
|
18
|
+
so you can style them like settled links immediately; the optional theme
|
|
19
|
+
does this out of the box. Deliberate exclusions so common non-link brackets
|
|
20
|
+
never flash as links: footnote refs (`[^1]`), task checkboxes, alert
|
|
21
|
+
markers, and all-digit citation labels (`[1]`, `[12]`).
|
|
22
|
+
- **`gfmTagfilter`** — the GFM "Disallowed Raw HTML" extension, opt-in like
|
|
23
|
+
the other GFM options (`gfm-tagfilter` on the custom element,
|
|
24
|
+
`with_gfm_tagfilter` in the core). With raw HTML enabled, the nine
|
|
25
|
+
page-hijacking tags (`title`, `textarea`, `style`, `xmp`, `iframe`,
|
|
26
|
+
`noembed`, `noframes`, `script`, `plaintext`) get their leading `<`
|
|
27
|
+
escaped, matching GitHub's rendering. The GFM extension suite is now 24/24.
|
|
28
|
+
|
|
29
|
+
### Performance
|
|
30
|
+
|
|
31
|
+
- **Fourteen classes of streaming O(n²) eliminated.** An adversarial
|
|
32
|
+
multi-agent audit probed 137 document shapes, confirmed 17 quadratic
|
|
33
|
+
root-cause groups at the work-counter level, and this release fixes 14 —
|
|
34
|
+
every one now streams in O(new bytes) and is pinned linear by the
|
|
35
|
+
deterministic complexity gate (now three counters: slow-path scans,
|
|
36
|
+
inline-render bytes, emitted bytes). Highlights at 512 KB streamed in
|
|
37
|
+
256-byte chunks unless noted:
|
|
38
|
+
- **CRLF input** made every incremental cache bail — a plain list cost
|
|
39
|
+
49.4 s; now line endings normalize at ingest and CRLF streams cost the
|
|
40
|
+
same as LF (210 ms).
|
|
41
|
+
- **Footnotes**: a no-blank run of definitions stalled commits and every
|
|
42
|
+
append recloned the footnote maps — a list with per-item refs cost 48 s
|
|
43
|
+
at 256 KB; now 200 ms.
|
|
44
|
+
- **Link-reference definitions inside blockquotes** armed no cache
|
|
45
|
+
(44 s → 134 ms); lazy continuation lines no longer disarm quote caches.
|
|
46
|
+
- **Open list items with multi-line bodies** re-rendered whole every
|
|
47
|
+
append (23 s → 0.3 s); legal interior blank lines no longer permanently
|
|
48
|
+
disarm the list/indented-code caches (5 s → 54 ms).
|
|
49
|
+
- **Tables**: the growing trailing row re-split and re-rendered every
|
|
50
|
+
append (23.8 s → 254 ms; a thousand-column row 25.4 s → 65 ms).
|
|
51
|
+
- **`blockData` mode** disabled the container cache outright (a 256 KB
|
|
52
|
+
alert cost 41.7 s → 226 ms, 185×) and rebuilt code/math/list/table data
|
|
53
|
+
channels from scratch per append (512 KB math fence 7.4 s → 132 ms).
|
|
54
|
+
- **Streaming component blocks** (`<Chart>` bodies), giant headings,
|
|
55
|
+
thematic breaks, and growing fence info strings had no incremental cache
|
|
56
|
+
at all (a 256 KB component body 48 s → 345 ms).
|
|
57
|
+
- **Inline engine**: emphasis edits now apply in one forward pass instead
|
|
58
|
+
of per-pair splicing, unpairable delimiters no longer rescan the whole
|
|
59
|
+
stack (the CommonMark mod-3 pathology was 54 s for a one-shot 256 KB
|
|
60
|
+
render — now 51 ms, and it hit server-side rendering too), unmatched `$`
|
|
61
|
+
with math enabled no longer rescans to end-of-input per candidate
|
|
62
|
+
(57.7 s → 33 ms), and space-free text (entities, long tokens) commits
|
|
63
|
+
incrementally instead of pinning the whole paragraph.
|
|
64
|
+
- HTML blocks whose appends landed exactly on line boundaries dropped
|
|
65
|
+
their cache every append (7.7 s → 59 ms).
|
|
66
|
+
- WASM binary: 197 KB (+28 KB), the cost of seven new incremental caches.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- CRLF documents no longer leak raw `\r` bytes into rendered code blocks;
|
|
71
|
+
CRLF output is byte-identical to the LF equivalent (line endings are
|
|
72
|
+
equivalent per CommonMark).
|
|
73
|
+
- Two latent mid-stream parity divergences (speculative rendering at
|
|
74
|
+
whitespace-only tails; a link-reference definition on an alert's first
|
|
75
|
+
body line) now match the one-shot render exactly.
|
|
76
|
+
- A link awaiting its title (`[label](url "ti…`) no longer flashes literal
|
|
77
|
+
source mid-stream.
|
|
78
|
+
|
|
7
79
|
## 0.19.0 — 2026-06-30
|
|
8
80
|
|
|
9
81
|
### Added
|
package/README.md
CHANGED
|
@@ -467,9 +467,41 @@ export default function Doc({ md }: { md: string }) {
|
|
|
467
467
|
| Where parse runs | Web Worker (off main thread) | Main thread |
|
|
468
468
|
| Block identity across chunks | Stable monotonic IDs | New keys on every render |
|
|
469
469
|
| Mid-stream unclosed `` ``` `` / `*` / `**` | Speculatively closed in render, replaced cleanly | Often renders raw or breaks |
|
|
470
|
+
| Half-streamed link `[label](https://…` | Label-only inert anchor (`data-flux-pending`), URL never leaks | Raw brackets + partial URL flash |
|
|
470
471
|
| Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
|
|
471
472
|
| XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
|
|
472
473
|
|
|
474
|
+
### Streaming links
|
|
475
|
+
|
|
476
|
+
A link's destination is the last thing a model emits — `[Earnings Call](https://…`
|
|
477
|
+
often spans many tokens. While a link is still streaming, flux-md renders it as
|
|
478
|
+
an **inert, label-only anchor**: the label text inside an `<a>` with no `href`
|
|
479
|
+
(the half-typed URL never flashes on screen), marked so you can style it:
|
|
480
|
+
|
|
481
|
+
```html
|
|
482
|
+
<a data-flux-pending="" target="_blank" rel="noopener noreferrer nofollow">Earnings Call</a>
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
An anchor without an `href` gets **no default link styling** from the browser, so
|
|
486
|
+
without a rule for the marker the link would "pop" blue only when the URL
|
|
487
|
+
completes. The bundled theme (`import "flux-md/styles.css"`) already styles it;
|
|
488
|
+
if you bring your own CSS, copy this:
|
|
489
|
+
|
|
490
|
+
```css
|
|
491
|
+
.flux-md a[data-flux-pending] {
|
|
492
|
+
color: var(--flux-accent, #0969da); /* match your settled link's resting style */
|
|
493
|
+
cursor: default; /* not clickable yet */
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The moment the closing `)` arrives, the `href` appears and `data-flux-pending`
|
|
498
|
+
is dropped — committed and finalized output never carry the marker, and the
|
|
499
|
+
finished block is byte-identical to a one-shot parse. Two composition notes:
|
|
500
|
+
`urlTransform` runs only on a real `href`, so it never sees a half-streamed URL
|
|
501
|
+
prefix — only the complete one (it may run again on re-renders while the
|
|
502
|
+
surrounding block is still open); `decorators` skip text
|
|
503
|
+
inside `<a>` by default (`skipInside`), pending or not.
|
|
504
|
+
|
|
473
505
|
## Styling
|
|
474
506
|
|
|
475
507
|
flux-md emits semantic HTML under a `.flux-md` root and **ships no CSS by
|
|
@@ -555,6 +587,7 @@ const client = new FluxClient({
|
|
|
555
587
|
config: {
|
|
556
588
|
gfmAutolinks: true, // bare www./http(s):// URLs + emails → links (default true)
|
|
557
589
|
gfmAlerts: true, // > [!NOTE] → callouts (default true)
|
|
590
|
+
gfmTagfilter: false, // GFM disallowed raw HTML: escape <script>/<title>/… under unsafeHtml (default false)
|
|
558
591
|
gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
|
|
559
592
|
gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
|
|
560
593
|
dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
|
|
@@ -591,6 +624,13 @@ When to enable each flag:
|
|
|
591
624
|
- `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
|
|
592
625
|
LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
|
|
593
626
|
similar — see [Security](#security)).
|
|
627
|
+
- `gfmTagfilter: true` — the GFM "Disallowed Raw HTML" extension, for use with
|
|
628
|
+
`unsafeHtml`: the nine disallowed tags (`<title>`, `<textarea>`, `<style>`,
|
|
629
|
+
`<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get
|
|
630
|
+
their leading `<` escaped so they display as text instead of taking effect —
|
|
631
|
+
opening and closing forms, any case, in blocks and inline. Off by default
|
|
632
|
+
(strict CommonMark passes them through under `unsafeHtml`); it's a tag
|
|
633
|
+
denylist, **not** a sanitizer — untrusted input still wants `sanitize`.
|
|
594
634
|
- `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
|
|
595
635
|
like `<Thinking>…</Thinking>` (on their own line) and you want their inner
|
|
596
636
|
content parsed as markdown and dispatched to a React component. Safe without
|
package/dist/element.js
CHANGED
|
@@ -146,6 +146,7 @@ function defineFluxMarkdown(tag = "flux-markdown") {
|
|
|
146
146
|
};
|
|
147
147
|
set("gfm-autolinks", "gfmAutolinks");
|
|
148
148
|
set("gfm-alerts", "gfmAlerts");
|
|
149
|
+
set("gfm-tagfilter", "gfmTagfilter");
|
|
149
150
|
set("gfm-footnotes", "gfmFootnotes");
|
|
150
151
|
set("gfm-math", "gfmMath");
|
|
151
152
|
set("dir-auto", "dirAuto");
|
package/dist/server.js
CHANGED
|
@@ -36,6 +36,7 @@ function makeParser(config) {
|
|
|
36
36
|
const p = new FluxParser();
|
|
37
37
|
p.setGfmAutolinks(config?.gfmAutolinks ?? true);
|
|
38
38
|
p.setGfmAlerts(config?.gfmAlerts ?? true);
|
|
39
|
+
p.setGfmTagfilter(config?.gfmTagfilter ?? false);
|
|
39
40
|
p.setGfmFootnotes(config?.gfmFootnotes ?? false);
|
|
40
41
|
p.setGfmMath(config?.gfmMath ?? false);
|
|
41
42
|
p.setDirAuto(config?.dirAuto ?? false);
|
package/dist/styles.css
CHANGED
|
@@ -113,6 +113,12 @@
|
|
|
113
113
|
/* ---- inline ---------------------------------------------------------------- */
|
|
114
114
|
.flux-md a { color: var(--flux-accent); text-decoration: none; }
|
|
115
115
|
.flux-md a:hover { text-decoration: underline; }
|
|
116
|
+
/* A streaming link whose URL hasn't finished arriving renders as an inert
|
|
117
|
+
`<a data-flux-pending>` with no href — the UA gives an href-less anchor no
|
|
118
|
+
link styling, so restate the settled resting style here (identical color and
|
|
119
|
+
decoration, so completion changes nothing visually). Cursor stays default:
|
|
120
|
+
not clickable yet. */
|
|
121
|
+
.flux-md a[data-flux-pending] { color: var(--flux-accent); text-decoration: none; cursor: default; }
|
|
116
122
|
.flux-md strong { font-weight: 600; }
|
|
117
123
|
.flux-md em { font-style: italic; }
|
|
118
124
|
.flux-md del { color: var(--flux-fg-muted); }
|
package/dist/types-core.d.ts
CHANGED
|
@@ -296,6 +296,15 @@ export interface ParserConfig {
|
|
|
296
296
|
gfmAutolinks?: boolean;
|
|
297
297
|
/** GitHub alerts (`> [!NOTE]` → callouts). Default true. */
|
|
298
298
|
gfmAlerts?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* GFM "Disallowed Raw HTML" (tagfilter): with `unsafeHtml` on, the nine
|
|
301
|
+
* disallowed tags (`<title>`, `<textarea>`, `<style>`, `<xmp>`, `<iframe>`,
|
|
302
|
+
* `<noembed>`, `<noframes>`, `<script>`, `<plaintext>`) get their leading
|
|
303
|
+
* `<` escaped so they display as text instead of taking effect. Default
|
|
304
|
+
* false (strict CommonMark passes them through under `unsafeHtml`); no
|
|
305
|
+
* effect while raw HTML is escaped (default) or sanitized — already inert.
|
|
306
|
+
*/
|
|
307
|
+
gfmTagfilter?: boolean;
|
|
299
308
|
/** GFM footnotes (`[^1]` + `[^1]:` → footnote section). Default false. */
|
|
300
309
|
gfmFootnotes?: boolean;
|
|
301
310
|
/**
|
|
@@ -82,6 +82,14 @@ export class FluxParser {
|
|
|
82
82
|
* `<div class="math math-display">` for a KaTeX pass on the JS side.
|
|
83
83
|
*/
|
|
84
84
|
setGfmMath(on: boolean): void;
|
|
85
|
+
/**
|
|
86
|
+
* Enable the GFM "Disallowed Raw HTML" extension (tagfilter): with raw
|
|
87
|
+
* HTML passing through (`setUnsafeHtml(true)`), the nine disallowed tags
|
|
88
|
+
* (`<title>`, `<script>`, `<iframe>`, …) get their leading `<` escaped so
|
|
89
|
+
* they display as text instead of taking effect. Off by default; no
|
|
90
|
+
* effect while raw HTML is escaped or sanitized (already inert).
|
|
91
|
+
*/
|
|
92
|
+
setGfmTagfilter(on: boolean): void;
|
|
85
93
|
/**
|
|
86
94
|
* Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
|
|
87
95
|
* sanitized without full unsafe HTML: `allow` empty = allow all tags except
|
|
@@ -125,6 +133,7 @@ export interface InitOutput {
|
|
|
125
133
|
readonly fluxparser_setGfmAutolinks: (a: number, b: number) => void;
|
|
126
134
|
readonly fluxparser_setGfmFootnotes: (a: number, b: number) => void;
|
|
127
135
|
readonly fluxparser_setGfmMath: (a: number, b: number) => void;
|
|
136
|
+
readonly fluxparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
128
137
|
readonly fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
129
138
|
readonly fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
130
139
|
readonly fluxparser_setUnsafeHtml: (a: number, b: number) => void;
|
|
@@ -205,6 +205,17 @@ export class FluxParser {
|
|
|
205
205
|
setGfmMath(on) {
|
|
206
206
|
wasm.fluxparser_setGfmMath(this.__wbg_ptr, on);
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Enable the GFM "Disallowed Raw HTML" extension (tagfilter): with raw
|
|
210
|
+
* HTML passing through (`setUnsafeHtml(true)`), the nine disallowed tags
|
|
211
|
+
* (`<title>`, `<script>`, `<iframe>`, …) get their leading `<` escaped so
|
|
212
|
+
* they display as text instead of taking effect. Off by default; no
|
|
213
|
+
* effect while raw HTML is escaped or sanitized (already inert).
|
|
214
|
+
* @param {boolean} on
|
|
215
|
+
*/
|
|
216
|
+
setGfmTagfilter(on) {
|
|
217
|
+
wasm.fluxparser_setGfmTagfilter(this.__wbg_ptr, on);
|
|
218
|
+
}
|
|
208
219
|
/**
|
|
209
220
|
* Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
|
|
210
221
|
* sanitized without full unsafe HTML: `allow` empty = allow all tags except
|
|
Binary file
|
|
@@ -16,6 +16,7 @@ export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
|
|
16
16
|
export const fluxparser_setGfmAutolinks: (a: number, b: number) => void;
|
|
17
17
|
export const fluxparser_setGfmFootnotes: (a: number, b: number) => void;
|
|
18
18
|
export const fluxparser_setGfmMath: (a: number, b: number) => void;
|
|
19
|
+
export const fluxparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
19
20
|
export const fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
20
21
|
export const fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
21
22
|
export const fluxparser_setUnsafeHtml: (a: number, b: number) => void;
|
package/dist/worker.js
CHANGED
|
@@ -11,6 +11,7 @@ const core = new WorkerCore({
|
|
|
11
11
|
const p = new FluxParser();
|
|
12
12
|
p.setGfmAutolinks(c?.gfmAutolinks ?? true);
|
|
13
13
|
p.setGfmAlerts(c?.gfmAlerts ?? true);
|
|
14
|
+
p.setGfmTagfilter(c?.gfmTagfilter ?? false);
|
|
14
15
|
p.setGfmFootnotes(c?.gfmFootnotes ?? false);
|
|
15
16
|
p.setGfmMath(c?.gfmMath ?? false);
|
|
16
17
|
p.setDirAuto(c?.dirAuto ?? false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
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"],
|