flux-md 0.4.0 → 0.5.1

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,43 @@ 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.5.1 — 2026-05-27
8
+
9
+ ### Performance
10
+
11
+ - A document with a very large number of link-reference definitions is now O(n)
12
+ instead of O(n²). The committed reference table was cloned on every append
13
+ (O(refs) per chunk); it's now shared into each render via an `Rc` (O(1)) with a
14
+ two-level lookup (committed, then the uncommitted tail), and folded in place
15
+ via `Rc::make_mut` once the render's clone is dropped. A 235 KB
16
+ reference-definition stream at 16-byte chunks: **~1,395 ms → ~53 ms** (~26×).
17
+ This was the last remaining O(n²) streaming shape — every realistic shape is
18
+ now O(n). Output is unchanged.
19
+
20
+ ## 0.5.0 — 2026-05-27
21
+
22
+ ### Fixed
23
+
24
+ - **Streaming GFM tables now render incrementally.** A table no longer waits for
25
+ the whole block to arrive: the header renders the moment the delimiter row
26
+ (`|---|`) streams in, and each body row appends as it arrives. Previously the
27
+ incremental paragraph fast-path kept extending the header line as a paragraph
28
+ and only formed the table on a full reparse, so a streaming table appeared all
29
+ at once. The fast-path now bails (like it does for a setext underline) when a
30
+ delimiter row forms a table with its preceding header. Output is unchanged for
31
+ one-shot parsing; streamed output now matches one-shot at every prefix.
32
+
33
+ ### Added
34
+
35
+ - **`<FluxMarkdown sanitize={fn} />`** — an optional HTML sanitizer hook. When
36
+ provided, flux-md runs every block's HTML through it before injecting via
37
+ `innerHTML`, **including the streaming (open/speculative) tail** that the raw
38
+ fast path would otherwise expose. Bring your own sanitizer (e.g.
39
+ `DOMPurify.sanitize`) to render untrusted / LLM HTML with `unsafeHtml` on;
40
+ flux-md stays zero-dep. Built-in code/math renderers (already-escaped content)
41
+ are not run through it, so highlighting and math markup are preserved. Omitting
42
+ the prop is byte-identical and zero-cost.
43
+
7
44
  ## 0.4.0 — 2026-05-27
8
45
 
9
46
  ### Added
package/README.md CHANGED
@@ -345,6 +345,28 @@ flux-md is XSS-safe by default — its HTML output is meant to be injected via
345
345
  third-party HTML, these guards are your only line of defense — prefer a
346
346
  dedicated HTML sanitizer for genuinely hostile input.
347
347
 
348
+ ### Rendering untrusted / LLM HTML safely
349
+
350
+ If you enable `unsafeHtml` to render HTML from an untrusted source (e.g. an LLM
351
+ that returns raw HTML), **bring a real sanitizer** and pass it via
352
+ `<FluxMarkdown sanitize={…} />`. flux-md applies it to every block's HTML before
353
+ injection — **including the streaming (open) tail**, which the raw-`innerHTML`
354
+ fast path would otherwise expose. flux-md stays zero-dep; you choose the
355
+ sanitizer:
356
+
357
+ ```tsx
358
+ import DOMPurify from "dompurify";
359
+
360
+ <FluxMarkdown client={client} sanitize={(html) => DOMPurify.sanitize(html)} />
361
+ ```
362
+
363
+ The built-in code/math renderers operate on already-escaped content and are not
364
+ run through `sanitize`, so syntax highlighting and math markup are preserved.
365
+ With no `sanitize` prop, rendering is byte-identical and zero-cost. For
366
+ genuinely hostile content where CSS-overlay/clickjacking matters, render inside
367
+ a sandboxed `<iframe>` instead — sanitization stops injection, not every
368
+ visual-overlay trick.
369
+
348
370
  ## Scaling
349
371
 
350
372
  `FluxClient`s share a **worker pool** (`getDefaultPool()`), so concurrency
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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/react.tsx CHANGED
@@ -65,9 +65,19 @@ interface FluxMarkdownProps {
65
65
  * up (and re-locks when they scroll back near the bottom). Off by default.
66
66
  */
67
67
  stickToBottom?: boolean;
68
+ /**
69
+ * Optional HTML sanitizer applied to every block's HTML before it is injected
70
+ * via `innerHTML` — **including the streaming (open/speculative) tail**, the
71
+ * path that raw `innerHTML` would otherwise expose. Pass a real sanitizer
72
+ * (e.g. DOMPurify's `sanitize`) when rendering untrusted / LLM HTML with
73
+ * `unsafeHtml` on. flux-md stays zero-dep — you bring the sanitizer. The
74
+ * built-in code/math renderers operate on already-escaped content and are not
75
+ * run through it. When omitted, rendering is byte-identical and zero-cost.
76
+ */
77
+ sanitize?: (html: string) => string;
68
78
  }
69
79
 
70
- function FluxMarkdownImpl({ client, components, virtualize, stickToBottom }: FluxMarkdownProps) {
80
+ function FluxMarkdownImpl({ client, components, virtualize, stickToBottom, sanitize }: FluxMarkdownProps) {
71
81
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
72
82
  // Normalize "no overrides" to a stable `undefined` so memo comparisons and
73
83
  // the fast path don't churn on an empty object identity.
@@ -75,7 +85,7 @@ function FluxMarkdownImpl({ client, components, virtualize, stickToBottom }: Flu
75
85
  return (
76
86
  <div className="flux-md">
77
87
  {blocks.map((b) => (
78
- <BlockView key={b.id} block={b} components={comps} virtualize={virtualize} />
88
+ <BlockView key={b.id} block={b} components={comps} virtualize={virtualize} sanitize={sanitize} />
79
89
  ))}
80
90
  {stickToBottom && <div aria-hidden="true" style={{ scrollSnapAlign: "end" }} className="flux-bottom-anchor" />}
81
91
  </div>
@@ -174,7 +184,12 @@ const INTRINSIC_PX: Record<string, number> = {
174
184
  Component: 120,
175
185
  };
176
186
 
177
- function BlockViewImpl(props: { block: Block; components?: Components; virtualize?: boolean }) {
187
+ function BlockViewImpl(props: {
188
+ block: Block;
189
+ components?: Components;
190
+ virtualize?: boolean;
191
+ sanitize?: (html: string) => string;
192
+ }) {
178
193
  const { block, virtualize } = props;
179
194
  const content = renderBlockContent(props);
180
195
  // Virtualize only *closed* blocks: the streaming tail (open/speculative) is
@@ -192,7 +207,15 @@ function BlockViewImpl(props: { block: Block; components?: Components; virtualiz
192
207
  return content;
193
208
  }
194
209
 
195
- function renderBlockContent({ block, components }: { block: Block; components?: Components }) {
210
+ function renderBlockContent({
211
+ block,
212
+ components,
213
+ sanitize,
214
+ }: {
215
+ block: Block;
216
+ components?: Components;
217
+ sanitize?: (html: string) => string;
218
+ }) {
196
219
  const kind = block.kind.type;
197
220
 
198
221
  // Block-kind override replaces the entire renderer for this block. A
@@ -242,7 +265,12 @@ function renderBlockContent({ block, components }: { block: Block; components?:
242
265
  );
243
266
  }
244
267
 
245
- return <div className={className} dangerouslySetInnerHTML={{ __html: block.html }} />;
268
+ return (
269
+ <div
270
+ className={className}
271
+ dangerouslySetInnerHTML={{ __html: sanitize ? sanitize(block.html) : block.html }}
272
+ />
273
+ );
246
274
  }
247
275
 
248
276
  // A block is the same render when its identity, HTML, open-state, and the
@@ -250,8 +278,8 @@ function renderBlockContent({ block, components }: { block: Block; components?:
250
278
  // is what stops a committed block from re-rendering (and thus re-parsing) on
251
279
  // every streaming patch.
252
280
  export function blocksEqual(
253
- prev: { block: Block; components?: Components; virtualize?: boolean },
254
- next: { block: Block; components?: Components; virtualize?: boolean },
281
+ prev: { block: Block; components?: Components; virtualize?: boolean; sanitize?: (html: string) => string },
282
+ next: { block: Block; components?: Components; virtualize?: boolean; sanitize?: (html: string) => string },
255
283
  ): boolean {
256
284
  return (
257
285
  prev.block.id === next.block.id &&
@@ -259,7 +287,8 @@ export function blocksEqual(
259
287
  prev.block.open === next.block.open &&
260
288
  prev.block.speculative === next.block.speculative &&
261
289
  prev.components === next.components &&
262
- prev.virtualize === next.virtualize
290
+ prev.virtualize === next.virtualize &&
291
+ prev.sanitize === next.sanitize
263
292
  );
264
293
  }
265
294
 
Binary file