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 +37 -0
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/react.tsx +37 -8
- package/src/wasm/flux_md_core_bg.wasm +0 -0
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
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: {
|
|
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({
|
|
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
|
|
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
|