flux-md 0.4.0 → 0.5.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,30 @@ 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.0 — 2026-05-27
8
+
9
+ ### Fixed
10
+
11
+ - **Streaming GFM tables now render incrementally.** A table no longer waits for
12
+ the whole block to arrive: the header renders the moment the delimiter row
13
+ (`|---|`) streams in, and each body row appends as it arrives. Previously the
14
+ incremental paragraph fast-path kept extending the header line as a paragraph
15
+ and only formed the table on a full reparse, so a streaming table appeared all
16
+ at once. The fast-path now bails (like it does for a setext underline) when a
17
+ delimiter row forms a table with its preceding header. Output is unchanged for
18
+ one-shot parsing; streamed output now matches one-shot at every prefix.
19
+
20
+ ### Added
21
+
22
+ - **`<FluxMarkdown sanitize={fn} />`** — an optional HTML sanitizer hook. When
23
+ provided, flux-md runs every block's HTML through it before injecting via
24
+ `innerHTML`, **including the streaming (open/speculative) tail** that the raw
25
+ fast path would otherwise expose. Bring your own sanitizer (e.g.
26
+ `DOMPurify.sanitize`) to render untrusted / LLM HTML with `unsafeHtml` on;
27
+ flux-md stays zero-dep. Built-in code/math renderers (already-escaped content)
28
+ are not run through it, so highlighting and math markup are preserved. Omitting
29
+ the prop is byte-identical and zero-cost.
30
+
7
31
  ## 0.4.0 — 2026-05-27
8
32
 
9
33
  ### 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.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/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