flux-md 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ 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.4.0 — 2026-05-27
8
+
9
+ ### Added
10
+
11
+ - **`componentTags`** — opt-in custom component tags. List tag names (e.g.
12
+ `componentTags: ['Thinking', 'Callout']`) and a `<Thinking>…</Thinking>` in the
13
+ stream renders as a component whose **inner content is parsed as markdown** —
14
+ safely, **without `unsafeHtml`**: the tag is allowlisted and its attributes are
15
+ sanitized (event handlers dropped, dangerous URL schemes neutralized). The
16
+ container spans blank lines (unlike a raw HTML block) up to its matching close
17
+ tag, supports nesting, and ignores a `</Tag>` inside a code fence. Each renders
18
+ as a `Component` block dispatched on the React side via `components[tag]` (e.g.
19
+ `components.Thinking`) or the generic `components.Component`, receiving `{ tag,
20
+ attrs, … }`. Off unless configured; tag names match case-sensitively.
21
+
22
+ ### Performance
23
+
24
+ - Streaming a long open display-math block (`$$…$$` / `\[…\]`) is now O(n)
25
+ instead of O(n²). The incremental fence cache that already covered code fences
26
+ was generalized to math fences: an append only escapes the newly arrived lines
27
+ instead of re-scanning and re-escaping the whole growing body. Measured on a
28
+ 200 KB `$$…$$` block at 16-byte chunks: **16,271 ms → ~93 ms** (~174×). Output
29
+ is byte-identical (gated by `tests/math_fence_cache.rs`).
30
+ - A long trailing run of link-reference / footnote definitions now commits
31
+ incrementally instead of being re-scanned on every append. Previously such a
32
+ run produced no renderable blocks, so the committed offset never advanced. A
33
+ document ending in a large reference section streams ~10× faster (235 KB at
34
+ 16-byte chunks: **13,799 ms → ~1,380 ms**). Output is byte-identical (gated by
35
+ `tests/ref_defs_streaming.rs`).
36
+
37
+ ## 0.3.2 — 2026-05-27
38
+
39
+ ### Documentation
40
+
41
+ - Rewrote the README to describe flux-md on its own terms and removed all
42
+ references to and comparisons with other libraries. No code changes — the
43
+ published API and behavior are identical to 0.3.1.
44
+ - Fixed the React quick-start example: import `useEffect` and guard the async
45
+ append loop so it can't run after unmount or a stream change.
46
+
7
47
  ## 0.3.1 — 2026-05-27
8
48
 
9
49
  ### Performance
@@ -34,12 +74,11 @@ Notable changes to flux-md. Format based on
34
74
  `<div class="math math-display">`) carrying the LaTeX as text content — bring
35
75
  your own KaTeX (flux-md stays zero-dep) or override `components.MathBlock`
36
76
  (which receives the LaTeX as `text`). Display fences are blank-line tolerant
37
- and stream incrementally. Addresses [Streamdown #522]. Off by default.
77
+ and stream incrementally. Off by default.
38
78
  - **`dirAuto`** — opt-in per-block `dir="auto"` on block-level text elements
39
79
  (`p`, `h1`–`h6`, `blockquote`, `ul`/`ol`/`li`, `table`, alerts, footnotes), so
40
80
  the browser detects each block's direction (RTL/LTR) independently in
41
- mixed-language documents. Code blocks stay LTR. Addresses [Streamdown #509].
42
- Off by default.
81
+ mixed-language documents. Code blocks stay LTR. Off by default.
43
82
 
44
83
  ### Performance
45
84
 
@@ -67,6 +106,3 @@ Notable changes to flux-md. Format based on
67
106
  - Initial public release: zero-dep streaming markdown, Rust→WASM core, one Web
68
107
  Worker per stream, CommonMark 0.31 (652/652) + GFM (tables, strikethrough,
69
108
  task lists, extended autolinks, GitHub alerts, footnotes).
70
-
71
- [Streamdown #522]: https://github.com/vercel/streamdown/issues/522
72
- [Streamdown #509]: https://github.com/vercel/streamdown/issues/509
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Zero-dep streaming markdown for the browser. Rust→WASM core, one Web Worker per stream, incremental parse with speculative closure for mid-stream constructs.
4
4
 
5
- Built because [Streamdown](https://streamdown.ai) crashes the main thread when you run 5 concurrent LLM calls. Same input, this library uses **~8× less peak heap, ~6× less retained memory, and ~2× less main-thread blocking** measured in-browser with `performance.memory`. See [the live demo](https://md.hsingh.app/) for an A/B comparison.
5
+ Parsing runs entirely **off the main thread** each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document, and heavy renderers (syntax highlighting, math, mermaid) are **deferred until a block closes**. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
6
6
 
7
7
  ## Install
8
8
 
@@ -37,18 +37,27 @@ client.finalize();
37
37
  In React:
38
38
 
39
39
  ```tsx
40
- import { useMemo } from "react";
40
+ import { useEffect, useMemo } from "react";
41
41
  import { FluxClient, FluxMarkdown } from "flux-md";
42
42
 
43
43
  export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
44
44
  const client = useMemo(() => new FluxClient(), []);
45
+
45
46
  useEffect(() => {
47
+ let cancelled = false;
46
48
  (async () => {
47
- for await (const chunk of stream) client.append(chunk);
48
- client.finalize();
49
+ for await (const chunk of stream) {
50
+ if (cancelled) return; // stream changed / unmounted mid-flight
51
+ client.append(chunk);
52
+ }
53
+ if (!cancelled) client.finalize();
49
54
  })();
50
- return () => client.destroy();
55
+ return () => {
56
+ cancelled = true;
57
+ client.destroy();
58
+ };
51
59
  }, [stream]);
60
+
52
61
  return <FluxMarkdown client={client} />;
53
62
  }
54
63
  ```
@@ -57,7 +66,7 @@ Multiple concurrent streams just need multiple clients — each runs in its own
57
66
 
58
67
  ## What it does
59
68
 
60
- | Concern | flux-md | typical react-markdown / Streamdown |
69
+ | Concern | flux-md | conventional main-thread renderer |
61
70
  |---|---|---|
62
71
  | Re-parse on each token | No — only the active tail | Yes, full string |
63
72
  | Where parse runs | Web Worker (off main thread) | Main thread |
@@ -96,6 +105,7 @@ const client = new FluxClient({
96
105
  gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
97
106
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
98
107
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
108
+ componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
99
109
  },
100
110
  });
101
111
  ```
@@ -202,6 +212,48 @@ Rules worth knowing:
202
212
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
203
213
  or `components.code`.
204
214
 
215
+ ### Component tags
216
+
217
+ LLMs increasingly emit custom component tags like `<Thinking>…</Thinking>`. By
218
+ default these are inert (escaped, or — with `unsafeHtml` — raw HTML whose body
219
+ is *not* markdown). Opt in by allowlisting the tag names:
220
+
221
+ ```tsx
222
+ const client = new FluxClient({ config: { componentTags: ["Thinking", "Callout"] } });
223
+ ```
224
+
225
+ Now a listed tag is a **markdown container**: its inner content is parsed as
226
+ markdown, it spans blank lines up to its matching close tag (not split like a
227
+ raw HTML block), it nests, and a `</Tag>` inside a code fence stays content. It's
228
+ **safe without `unsafeHtml`** — the tag is allowlisted and its attributes are
229
+ sanitized (event handlers dropped, dangerous URL schemes → `#`).
230
+
231
+ Each renders as a `Component` block. Override it in React by tag name (or with
232
+ the generic `Component` fallback). The override receives `tag`, the sanitized
233
+ `attrs`, and `html` — the **inner** (already-rendered markdown) HTML, so you can
234
+ wrap it in your own element:
235
+
236
+ ```tsx
237
+ <FluxMarkdown
238
+ client={client}
239
+ components={{
240
+ Thinking: ({ html }) => (
241
+ <details className="thinking">
242
+ <summary>Reasoning</summary>
243
+ <div dangerouslySetInnerHTML={{ __html: html }} />
244
+ </details>
245
+ ),
246
+ }}
247
+ />
248
+ ```
249
+
250
+ With no override, the component renders as `<thinking …>…</thinking>` HTML. The
251
+ override's `html` is the inner content only; `attrs` keys are React-form
252
+ (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While the
253
+ component is still streaming, `html` is the partial inner content and re-renders
254
+ as more arrives. Tag names match case-sensitively; the feature is off unless
255
+ `componentTags` is set.
256
+
205
257
  ### Types
206
258
 
207
259
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.3.1",
3
+ "version": "0.4.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
  "main": "./src/index.ts",
package/src/hi.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * languages fall through to plain escaped text. ~6KB minified.
6
6
  *
7
7
  * Highlighting is per-block, runs once when the block closes. We never
8
- * highlight an open (streaming) block that's what gives us the big perf
9
- * win vs Streamdown's per-chunk Shiki invocation.
8
+ * highlight an open (streaming) block, which avoids re-highlighting the same
9
+ * code on every chunk the main perf win for streaming code.
10
10
  */
11
11
 
12
12
  const KEYWORDS_JS = new Set(
package/src/react.tsx CHANGED
@@ -117,16 +117,49 @@ function blockKindProps(block: Block): BlockComponentProps {
117
117
  open: block.open,
118
118
  speculative: block.speculative,
119
119
  };
120
- const data = block.kind.data as { lang?: string | null } | undefined;
120
+ const data = block.kind.data as
121
+ | { lang?: string | null; tag?: string; attrs?: [string, string][] }
122
+ | undefined;
121
123
  if (block.kind.type === "CodeBlock") {
122
124
  props.text = decodeCodeText(block.html);
123
125
  props.language = data?.lang ?? "";
124
126
  } else if (block.kind.type === "MathBlock") {
125
127
  props.text = decodeMathText(block.html);
128
+ } else if (block.kind.type === "Component") {
129
+ props.tag = data?.tag ?? "";
130
+ // React-form attribute names, so `{...attrs}` spreads cleanly onto an element
131
+ // (HTML `class`/`for` → React `className`/`htmlFor`).
132
+ props.attrs = reactAttrs(data?.attrs ?? []);
133
+ // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
134
+ // (markdown already rendered) rather than the full wrapped block.
135
+ props.html = componentInnerHtml(block.html, props.tag);
126
136
  }
127
137
  return props;
128
138
  }
129
139
 
140
+ const REACT_ATTR_NAME: Record<string, string> = { class: "className", for: "htmlFor" };
141
+
142
+ /** Convert sanitized HTML attribute pairs into a React-spreadable object,
143
+ * renaming the two names React requires (`class`→`className`, `for`→`htmlFor`).
144
+ * Other names (including `data-*` / `aria-*`) pass through unchanged. */
145
+ function reactAttrs(pairs: [string, string][]): Record<string, string> {
146
+ const out: Record<string, string> = {};
147
+ for (const [k, v] of pairs) out[REACT_ATTR_NAME[k] ?? k] = v;
148
+ return out;
149
+ }
150
+
151
+ /** Strip the `<tag …>` open and trailing `</tag>` from a component block's HTML,
152
+ * leaving the inner (already-rendered markdown) HTML. Handles open (unclosed)
153
+ * blocks, where there is no close tag yet. */
154
+ function componentInnerHtml(html: string, tag: string): string {
155
+ const gt = html.indexOf(">");
156
+ if (gt < 0) return "";
157
+ let inner = html.slice(gt + 1);
158
+ const close = `</${tag}>`;
159
+ if (inner.endsWith(close)) inner = inner.slice(0, -close.length);
160
+ return inner.replace(/^\n/, "").replace(/\n$/, "");
161
+ }
162
+
130
163
  /** Convert a closed block's HTML to a React tree, memoized on html+components. */
131
164
  function SafeHtml({ html, components }: { html: string; components: Components }) {
132
165
  return useMemo(() => htmlToReact(html, components), [html, components]) as JSX.Element;
@@ -138,6 +171,7 @@ function SafeHtml({ html, components }: { html: string; components: Components }
138
171
  const INTRINSIC_PX: Record<string, number> = {
139
172
  Paragraph: 80, Heading: 44, CodeBlock: 300, MathBlock: 140, Mermaid: 220,
140
173
  List: 120, Blockquote: 100, Alert: 120, Table: 200, Rule: 24, Html: 80,
174
+ Component: 120,
141
175
  };
142
176
 
143
177
  function BlockViewImpl(props: { block: Block; components?: Components; virtualize?: boolean }) {
@@ -161,8 +195,17 @@ function BlockViewImpl(props: { block: Block; components?: Components; virtualiz
161
195
  function renderBlockContent({ block, components }: { block: Block; components?: Components }) {
162
196
  const kind = block.kind.type;
163
197
 
164
- // Block-kind override replaces the entire renderer for this block.
198
+ // Block-kind override replaces the entire renderer for this block. A
199
+ // `Component` block also dispatches on its tag name, so `components.Thinking`
200
+ // (the specific tag) wins over `components.Component` (the generic fallback).
165
201
  if (components) {
202
+ if (kind === "Component") {
203
+ const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
204
+ const override = (tag && components[tag]) || components.Component;
205
+ if (override) {
206
+ return createElement(override, blockKindProps(block));
207
+ }
208
+ }
166
209
  const blockOverride = components[kind];
167
210
  if (blockOverride) {
168
211
  return createElement(blockOverride, blockKindProps(block));
package/src/types.ts CHANGED
@@ -9,7 +9,8 @@ export type BlockKindTag =
9
9
  | "Alert"
10
10
  | "Table"
11
11
  | "Rule"
12
- | "Html";
12
+ | "Html"
13
+ | "Component";
13
14
 
14
15
  export interface BlockKind {
15
16
  type: BlockKindTag;
@@ -60,6 +61,15 @@ export interface BlockComponentProps {
60
61
  text?: string;
61
62
  /** Info-string language — present for `CodeBlock` (from `kind.data.lang`). */
62
63
  language?: string;
64
+ /** Component tag name — present for `Component` blocks (from `kind.data.tag`). */
65
+ tag?: string;
66
+ /**
67
+ * Sanitized attributes — present for `Component` blocks. Names are React-form
68
+ * (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly onto
69
+ * an element. For `Component` blocks, `html` is the **inner** rendered-markdown
70
+ * HTML (not the `<tag>…</tag>` wrapper), so an override can wrap it itself.
71
+ */
72
+ attrs?: Record<string, string>;
63
73
  }
64
74
 
65
75
  /**
@@ -93,6 +103,16 @@ export interface ParserConfig {
93
103
  dirAuto?: boolean;
94
104
  /** Pass raw HTML through unescaped. Default false. **Never enable for untrusted input.** */
95
105
  unsafeHtml?: boolean;
106
+ /**
107
+ * Opt-in allowlist of custom component tag names (e.g. `["Thinking",
108
+ * "Callout"]`). A `<Tag>…</Tag>` whose name is listed renders as a component
109
+ * whose inner content is parsed as **markdown** — safely, without `unsafeHtml`
110
+ * (the tag is allowlisted and its attributes are sanitized: event handlers
111
+ * dropped, dangerous URL schemes neutralized). The block is dispatched on the
112
+ * React side via `components[tag]` (or `components.Component`). Empty/omitted =
113
+ * off. Names match case-sensitively.
114
+ */
115
+ componentTags?: string[];
96
116
  }
97
117
 
98
118
  // Each message carries a `streamId` so one worker can multiplex many parsers
@@ -14,6 +14,13 @@ export class FluxParser {
14
14
  * memory cost against alternatives.
15
15
  */
16
16
  retainedBytes(): number;
17
+ /**
18
+ * Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
19
+ * A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
20
+ * content is markdown — safely, without unsafe HTML (the tag is allowlisted
21
+ * and its attributes are sanitized). Empty by default (feature off).
22
+ */
23
+ setComponentTags(tags: string[]): void;
17
24
  /**
18
25
  * Emit `dir="auto"` on block-level text elements so the browser detects
19
26
  * each block's direction (LTR/RTL) independently — correct rendering for
@@ -60,6 +67,7 @@ export interface InitOutput {
60
67
  readonly fluxparser_finalize: (a: number, b: number) => void;
61
68
  readonly fluxparser_new: () => number;
62
69
  readonly fluxparser_retainedBytes: (a: number) => number;
70
+ readonly fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
63
71
  readonly fluxparser_setDirAuto: (a: number, b: number) => void;
64
72
  readonly fluxparser_setGfmAlerts: (a: number, b: number) => void;
65
73
  readonly fluxparser_setGfmAutolinks: (a: number, b: number) => void;
@@ -73,6 +73,18 @@ export class FluxParser {
73
73
  const ret = wasm.fluxparser_retainedBytes(this.__wbg_ptr);
74
74
  return ret >>> 0;
75
75
  }
76
+ /**
77
+ * Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
78
+ * A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
79
+ * content is markdown — safely, without unsafe HTML (the tag is allowlisted
80
+ * and its attributes are sanitized). Empty by default (feature off).
81
+ * @param {string[]} tags
82
+ */
83
+ setComponentTags(tags) {
84
+ const ptr0 = passArrayJsValueToWasm0(tags, wasm.__wbindgen_export);
85
+ const len0 = WASM_VECTOR_LEN;
86
+ wasm.fluxparser_setComponentTags(this.__wbg_ptr, ptr0, len0);
87
+ }
76
88
  /**
77
89
  * Emit `dir="auto"` on block-level text elements so the browser detects
78
90
  * each block's direction (LTR/RTL) independently — correct rendering for
@@ -141,6 +153,14 @@ function __wbg_get_imports() {
141
153
  getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
142
154
  getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
143
155
  },
156
+ __wbg___wbindgen_string_get_72bdf95d3ae505b1: function(arg0, arg1) {
157
+ const obj = getObject(arg1);
158
+ const ret = typeof(obj) === 'string' ? obj : undefined;
159
+ var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
160
+ var len1 = WASM_VECTOR_LEN;
161
+ getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
162
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
163
+ },
144
164
  __wbg___wbindgen_throw_1506f2235d1bdba0: function(arg0, arg1) {
145
165
  throw new Error(getStringFromWasm0(arg0, arg1));
146
166
  },
@@ -233,6 +253,20 @@ heap.push(undefined, null, true, false);
233
253
 
234
254
  let heap_next = heap.length;
235
255
 
256
+ function isLikeNone(x) {
257
+ return x === undefined || x === null;
258
+ }
259
+
260
+ function passArrayJsValueToWasm0(array, malloc) {
261
+ const ptr = malloc(array.length * 4, 4) >>> 0;
262
+ const mem = getDataViewMemory0();
263
+ for (let i = 0; i < array.length; i++) {
264
+ mem.setUint32(ptr + 4 * i, addHeapObject(array[i]), true);
265
+ }
266
+ WASM_VECTOR_LEN = array.length;
267
+ return ptr;
268
+ }
269
+
236
270
  function passStringToWasm0(arg, malloc, realloc) {
237
271
  if (realloc === undefined) {
238
272
  const buf = cachedTextEncoder.encode(arg);
Binary file
@@ -7,6 +7,7 @@ export const fluxparser_bufferLen: (a: number) => number;
7
7
  export const fluxparser_finalize: (a: number, b: number) => void;
8
8
  export const fluxparser_new: () => number;
9
9
  export const fluxparser_retainedBytes: (a: number) => number;
10
+ export const fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
10
11
  export const fluxparser_setDirAuto: (a: number, b: number) => void;
11
12
  export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
12
13
  export const fluxparser_setGfmAutolinks: (a: number, b: number) => void;
package/src/worker.ts CHANGED
@@ -45,6 +45,7 @@ function getOrCreate(streamId: number): FluxParser {
45
45
  p.setGfmMath(c?.gfmMath ?? false);
46
46
  p.setDirAuto(c?.dirAuto ?? false);
47
47
  p.setUnsafeHtml(c?.unsafeHtml ?? false);
48
+ p.setComponentTags(c?.componentTags ?? []);
48
49
  parsers.set(streamId, p);
49
50
  }
50
51
  return p;