flux-md 0.16.2 → 0.18.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +950 -0
  2. package/README.md +111 -82
  3. package/dist/block-props.d.ts +18 -0
  4. package/dist/block-props.js +75 -0
  5. package/dist/client.d.ts +311 -0
  6. package/{src/client.ts → dist/client.js} +143 -325
  7. package/dist/dom.d.ts +110 -0
  8. package/dist/dom.js +576 -0
  9. package/dist/element.d.ts +20 -0
  10. package/dist/element.js +287 -0
  11. package/dist/hi.d.ts +12 -0
  12. package/{src/hi.ts → dist/hi.js} +42 -74
  13. package/dist/html-to-react.d.ts +40 -0
  14. package/dist/html-to-react.js +344 -0
  15. package/{src/index.ts → dist/index.d.ts} +5 -25
  16. package/dist/index.js +16 -0
  17. package/dist/morph.d.ts +28 -0
  18. package/dist/morph.js +166 -0
  19. package/dist/react.d.ts +204 -0
  20. package/dist/react.js +490 -0
  21. package/dist/renderers/CodeBlock.d.ts +7 -0
  22. package/dist/renderers/CodeBlock.js +75 -0
  23. package/dist/renderers/Math.d.ts +14 -0
  24. package/dist/renderers/Math.js +15 -0
  25. package/dist/renderers/Mermaid.d.ts +13 -0
  26. package/dist/renderers/Mermaid.js +15 -0
  27. package/dist/server-react.d.ts +32 -0
  28. package/dist/server-react.js +48 -0
  29. package/dist/server.d.ts +31 -0
  30. package/dist/server.js +81 -0
  31. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  32. package/dist/solid.js +54 -0
  33. package/dist/svelte.d.ts +80 -0
  34. package/dist/svelte.js +59 -0
  35. package/dist/types-core.d.ts +382 -0
  36. package/dist/types-core.js +0 -0
  37. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  38. package/dist/types-react.js +0 -0
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.js +2 -0
  41. package/dist/vue.d.ts +94 -0
  42. package/dist/vue.js +79 -0
  43. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  44. package/{src → dist}/wasm/flux_md_core.js +50 -55
  45. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  46. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  47. package/dist/worker-core.d.ts +65 -0
  48. package/dist/worker-core.js +154 -0
  49. package/dist/worker.d.ts +1 -0
  50. package/dist/worker.js +48 -0
  51. package/package.json +25 -19
  52. package/src/block-props.ts +0 -141
  53. package/src/dom.ts +0 -946
  54. package/src/element.ts +0 -400
  55. package/src/html-to-react.ts +0 -455
  56. package/src/morph.ts +0 -253
  57. package/src/react.tsx +0 -1020
  58. package/src/renderers/CodeBlock.tsx +0 -116
  59. package/src/renderers/Math.tsx +0 -28
  60. package/src/renderers/Mermaid.tsx +0 -27
  61. package/src/server.tsx +0 -221
  62. package/src/svelte.ts +0 -179
  63. package/src/types-core.ts +0 -398
  64. package/src/types.ts +0 -7
  65. package/src/vue.ts +0 -184
  66. package/src/wasm/flux_md_core_bg.wasm +0 -0
  67. package/src/worker-core.ts +0 -174
  68. package/src/worker.ts +0 -72
  69. /package/{src → dist}/styles.css +0 -0
@@ -0,0 +1,204 @@
1
+ import type { Block, BlockComponentProps, Components } from "./types.js";
2
+ import { FluxClient } from "./client.js";
3
+ import type { ParserConfig, RenderMetricsHook } from "./types-core.js";
4
+ /**
5
+ * Render a streaming markdown document from a FluxClient. Each block is its
6
+ * own memoized React node keyed by its stable parser-assigned ID, so React
7
+ * only reconciles the blocks whose HTML actually changed since the last
8
+ * patch. Heavy renderers (Shiki, KaTeX, Mermaid) defer work until a block
9
+ * is closed.
10
+ *
11
+ * ## Custom components
12
+ *
13
+ * Pass `components` to override rendering (see {@link Components}):
14
+ *
15
+ * ```tsx
16
+ * <FluxMarkdown
17
+ * client={client}
18
+ * components={{
19
+ * table: (p) => <table className="my-table" {...p} />, // tag-level
20
+ * a: (p) => <a target="_blank" rel="noreferrer" {...p} />,
21
+ * CodeBlock: (p) => <MyCodeBlock {...p} />, // block-kind
22
+ * }}
23
+ * />
24
+ * ```
25
+ *
26
+ * Rules:
27
+ * - **Tag-level** keys (`table`, `a`, `code`, `h1`…) replace that element
28
+ * wherever it appears inside a block. Applied by converting the block's
29
+ * trusted HTML to a React tree.
30
+ * - **Block-kind** keys ({@link BlockKindTag}: `CodeBlock`, `Mermaid`,
31
+ * `Table`…) replace the whole block; the component gets
32
+ * {@link BlockComponentProps}.
33
+ * - **Open / speculative** blocks always render via `innerHTML` (their HTML
34
+ * is partial); a tag-level override takes effect once the block commits.
35
+ * - With no `components` prop the renderer takes the original fast
36
+ * `innerHTML` path — output is byte-identical to before.
37
+ * - **Memoize `components`** (or hoist it) if you define it inside a
38
+ * component — a fresh object identity each render busts the block memo and
39
+ * forces every block to re-parse on every patch.
40
+ * - For code blocks the built-in highlighter is the default; it is bypassed
41
+ * (so your override wins) when you provide `components.CodeBlock`,
42
+ * `components.pre`, or `components.code`.
43
+ */
44
+ interface FluxMarkdownProps {
45
+ /**
46
+ * A caller-owned client (you drive `append`/`finalize` and own its lifecycle —
47
+ * the component never destroys it). Mutually exclusive with `stream`; if both
48
+ * are given, `client` wins (a dev warning fires).
49
+ */
50
+ client?: FluxClient;
51
+ /**
52
+ * A stream to render directly — the 1-line common case. Pass a `Response`, a
53
+ * `ReadableStream<Uint8Array>`, or an `AsyncIterable<string>` (e.g. SSE
54
+ * deltas) and the component owns an internal client, pipes the stream, and
55
+ * destroys it on unmount. A new `stream` identity supersedes the old.
56
+ */
57
+ stream?: AsyncIterable<string> | ReadableStream<Uint8Array> | Response;
58
+ /** Parser config for the internally-created client (stream mode only). */
59
+ streamConfig?: ParserConfig;
60
+ /** Called if piping the `stream` rejects (the source errored). Not the worker error channel. */
61
+ onStreamError?: (err: Error) => void;
62
+ components?: Components;
63
+ /**
64
+ * Skip layout/paint for off-screen blocks via CSS `content-visibility: auto`
65
+ * — for very long documents (hundreds+ of blocks). Off by default. Applies
66
+ * only to *closed* blocks (the streaming tail always renders fully). Keeps
67
+ * nodes in the DOM; it cuts rendering cost, not node count.
68
+ */
69
+ virtualize?: boolean;
70
+ /**
71
+ * Render a bottom snap target so the view follows the streaming tail. This is
72
+ * CSS-only: it emits a sentinel with `scroll-snap-align: end`; **you** add
73
+ * `scroll-snap-type: y proximity` to your scroll container. The view then
74
+ * follows the bottom as content streams in and releases when the user scrolls
75
+ * up (and re-locks when they scroll back near the bottom). Off by default.
76
+ */
77
+ stickToBottom?: boolean;
78
+ /**
79
+ * Optional HTML sanitizer applied to every block's HTML before it is injected
80
+ * via `innerHTML` — **including the streaming (open/speculative) tail**, the
81
+ * path that raw `innerHTML` would otherwise expose. Pass a real sanitizer
82
+ * (e.g. DOMPurify's `sanitize`) when rendering untrusted / LLM HTML with
83
+ * `unsafeHtml` on. flux-md stays zero-dep — you bring the sanitizer. The
84
+ * built-in code/math renderers operate on already-escaped content and are not
85
+ * run through it. When omitted, rendering is byte-identical and zero-cost.
86
+ *
87
+ * **Memoize / hoist this** (same trap as `components`): a fresh closure each
88
+ * render busts the per-block memo, so every block re-sanitizes and re-parses
89
+ * on every patch instead of only the streaming tail.
90
+ */
91
+ sanitize?: (html: string) => string;
92
+ /**
93
+ * Opt-in: when an OPEN (streaming) block re-renders each patch, reuse the
94
+ * React nodes of its top-level children whose HTML is unchanged and re-parse
95
+ * only the new trailing content, instead of re-parsing the block's whole HTML
96
+ * every tick. Only takes effect when `components` (or `sanitize`) routes a
97
+ * block through the parser; the no-`components` fast path (`innerHTML`) is
98
+ * untouched. Off by default and byte-identical to default rendering — enable
99
+ * it for documents with a long, slowly-growing streamed block under a custom
100
+ * `components` map. Closed blocks are already wholesale memo-skipped, so this
101
+ * applies only to the streaming tail.
102
+ */
103
+ childMemo?: boolean;
104
+ /** Appended to the root's `className` (the `flux-md` class is always present). */
105
+ className?: string;
106
+ /** Set on the root element. */
107
+ id?: string;
108
+ /** Set on the root element (e.g. `"article"`, `"log"`). */
109
+ role?: string;
110
+ /**
111
+ * Make the root a live region so screen readers announce streamed content.
112
+ * `"polite"` (recommended) coalesces rapid updates and announces when the
113
+ * reader is idle — it does **not** read every token. Off by default.
114
+ */
115
+ "aria-live"?: "off" | "polite" | "assertive";
116
+ /** Live-region atomicity; pair with `aria-live`. Off by default. */
117
+ "aria-atomic"?: boolean;
118
+ /**
119
+ * Optional render-churn probe. Fires once per ACTUAL render of a block —
120
+ * never for a committed block that memo-skips on a tail-only patch. The
121
+ * callback gets the block id and a {@link RenderMetrics} sample (per-block
122
+ * `renderCount`, `speculativeToggleCount`, `lastRenderMs`, `kind`). Zero
123
+ * overhead when omitted. **Memoize / hoist this** (same trap as `components`):
124
+ * a fresh closure each render busts the per-block memo, forcing every block to
125
+ * re-render on every patch.
126
+ */
127
+ onRenderMetrics?: RenderMetricsHook;
128
+ /**
129
+ * **Opt-in, off by default.** Render the block list through React's
130
+ * `useDeferredValue`, so a burst of patches can yield to higher-priority
131
+ * updates and the streaming tail commits at a lower priority. When a deferred
132
+ * render is in flight the root carries an extra `flux-deferred` class (style it
133
+ * however you like). This is a *no-op on a single patch*, has no effect during
134
+ * SSR (`useDeferredValue` is a client-only concern), and **does not change
135
+ * output** — only commit timing. Leaving it unset is recommended; rAF
136
+ * coalescing (the DOM adapter's batched path) is the preferred way to absorb
137
+ * high-frequency patches. Provided for callers who specifically want React's
138
+ * concurrent deferral of the visible tail.
139
+ */
140
+ deferTail?: boolean;
141
+ }
142
+ /**
143
+ * Own a {@link FluxClient} for the lifetime of a component and drive it from a
144
+ * `stream` (a `Response`, `ReadableStream<Uint8Array>`, or
145
+ * `AsyncIterable<string>`). Returns the client (read `outline()` / `getMetrics()`
146
+ * off it, or pass it to `<FluxMarkdown client={…} />`). The client is created
147
+ * once and destroyed on unmount; a new `stream` identity supersedes the old
148
+ * (the prior pipe is aborted, the parser is reset, the new stream is piped).
149
+ *
150
+ * Caveat (matches the manual `useEffect` form): a single-use stream — a
151
+ * `Response`/`ReadableStream`, or an async generator — can only be consumed
152
+ * once, so React **StrictMode**'s dev-only double-mount may truncate it in
153
+ * development. Production mounts once and is unaffected. If you need dev-exact
154
+ * streaming, drive a caller-owned client manually.
155
+ */
156
+ export declare function useFluxStream(stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined, options?: {
157
+ config?: ParserConfig;
158
+ onError?: (err: Error) => void;
159
+ }): FluxClient;
160
+ /**
161
+ * Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
162
+ * UIs that hold a streaming message as a single growing string prop (the common
163
+ * React shape) rather than as a stream. Pass the whole document-so-far on each
164
+ * render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
165
+ * only the delta; any divergence (e.g. the finished text swapped for a
166
+ * re-processed final string) resets and reparses. Returns the owned client —
167
+ * pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
168
+ *
169
+ * Pass `streaming: false` once the content is final to finalize the stream and
170
+ * commit its last block (only then does a finished code fence highlight + show
171
+ * its copy button). If `streaming` is omitted or `true` the stream is left OPEN
172
+ * — right for a still-growing string, but a *complete static* string rendered as
173
+ * `useFluxMarkdownString(md)` keeps its last block in the streaming state until
174
+ * you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
175
+ * deliberately avoided: it would re-finalize on every token for callers that
176
+ * grow the string without the flag — an O(n²) reparse trap.) The client is
177
+ * created once and destroyed on unmount; StrictMode's dev double-mount is handled
178
+ * (reattach re-feeds the document). For a true stream source
179
+ * (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
180
+ * instead — it avoids buffering the whole document as a string.
181
+ */
182
+ export declare function useFluxMarkdownString(content: string, options?: {
183
+ config?: ParserConfig;
184
+ streaming?: boolean;
185
+ }): FluxClient;
186
+ declare function FluxMarkdownImpl(props: FluxMarkdownProps): import("react/jsx-runtime").JSX.Element;
187
+ export declare const FluxMarkdown: import("react").MemoExoticComponent<typeof FluxMarkdownImpl>;
188
+ export declare function blockKindProps(block: Block, components?: Components): BlockComponentProps;
189
+ export declare function blocksEqual(prev: {
190
+ block: Block;
191
+ components?: Components;
192
+ virtualize?: boolean;
193
+ sanitize?: (html: string) => string;
194
+ childMemo?: boolean;
195
+ onRenderMetrics?: RenderMetricsHook;
196
+ }, next: {
197
+ block: Block;
198
+ components?: Components;
199
+ virtualize?: boolean;
200
+ sanitize?: (html: string) => string;
201
+ childMemo?: boolean;
202
+ onRenderMetrics?: RenderMetricsHook;
203
+ }): boolean;
204
+ export {};
package/dist/react.js ADDED
@@ -0,0 +1,490 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import {
3
+ createElement,
4
+ memo,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ useDeferredValue
11
+ } from "react";
12
+ import { FluxClient } from "./client.js";
13
+ import { CodeBlock } from "./renderers/CodeBlock.js";
14
+ import { MathBlock } from "./renderers/Math.js";
15
+ import { Mermaid } from "./renderers/Mermaid.js";
16
+ import { htmlToReact } from "./html-to-react.js";
17
+ function FluxMarkdownFromClient({
18
+ client,
19
+ components,
20
+ virtualize,
21
+ stickToBottom,
22
+ sanitize,
23
+ childMemo,
24
+ className,
25
+ id,
26
+ role,
27
+ "aria-live": ariaLive,
28
+ "aria-atomic": ariaAtomic,
29
+ onRenderMetrics,
30
+ deferTail
31
+ }) {
32
+ const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
33
+ const deferred = useDeferredValue(blocks);
34
+ const rendered = deferTail ? deferred : blocks;
35
+ const isDeferring = deferTail ? rendered !== blocks : false;
36
+ const comps = useMemo(
37
+ () => components && Object.keys(components).length > 0 ? components : void 0,
38
+ [components]
39
+ );
40
+ const onMetrics = useMemo(
41
+ () => onRenderMetrics ? (id2, m) => {
42
+ client.__noteRender();
43
+ onRenderMetrics(id2, m);
44
+ } : void 0,
45
+ [client, onRenderMetrics]
46
+ );
47
+ const rootClass = isDeferring ? className ? `flux-md flux-deferred ${className}` : "flux-md flux-deferred" : className ? `flux-md ${className}` : "flux-md";
48
+ return /* @__PURE__ */ jsxs(
49
+ "div",
50
+ {
51
+ className: rootClass,
52
+ id,
53
+ role,
54
+ "aria-live": ariaLive,
55
+ "aria-atomic": ariaAtomic,
56
+ children: [
57
+ rendered.map((b) => /* @__PURE__ */ jsx(
58
+ BlockView,
59
+ {
60
+ block: b,
61
+ components: comps,
62
+ virtualize,
63
+ sanitize,
64
+ childMemo,
65
+ onRenderMetrics: onMetrics
66
+ },
67
+ b.id
68
+ )),
69
+ stickToBottom && /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { scrollSnapAlign: "end" }, className: "flux-bottom-anchor" })
70
+ ]
71
+ }
72
+ );
73
+ }
74
+ function useFluxStream(stream, options) {
75
+ const [client] = useState(() => new FluxClient({ config: options?.config, coalesce: true }));
76
+ const onErrorRef = useRef(options?.onError);
77
+ onErrorRef.current = options?.onError;
78
+ const prevStream = useRef(void 0);
79
+ useEffect(() => {
80
+ client.reattach();
81
+ return () => client.destroy();
82
+ }, [client]);
83
+ useEffect(() => {
84
+ if (stream == null) return;
85
+ const ac = new AbortController();
86
+ if (prevStream.current !== void 0 && prevStream.current !== stream) {
87
+ client.reset();
88
+ }
89
+ prevStream.current = stream;
90
+ client.pipeFrom(stream, { signal: ac.signal }).catch((e) => {
91
+ if (!ac.signal.aborted) {
92
+ onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
93
+ }
94
+ });
95
+ return () => ac.abort();
96
+ }, [stream, client]);
97
+ return client;
98
+ }
99
+ function useFluxMarkdownString(content, options) {
100
+ const [client] = useState(() => new FluxClient({ config: options?.config, coalesce: true }));
101
+ useEffect(() => {
102
+ client.reattach();
103
+ return () => client.destroy();
104
+ }, [client]);
105
+ const streaming = options?.streaming;
106
+ useEffect(() => {
107
+ client.setContent(content, { done: streaming === false });
108
+ }, [client, content, streaming]);
109
+ return client;
110
+ }
111
+ function FluxMarkdownFromStream(props) {
112
+ const client = useFluxStream(props.stream, {
113
+ config: props.streamConfig,
114
+ onError: props.onStreamError
115
+ });
116
+ return /* @__PURE__ */ jsx(FluxMarkdownFromClient, { ...props, client });
117
+ }
118
+ function FluxMarkdownImpl(props) {
119
+ if (props.stream != null && props.client == null) {
120
+ return /* @__PURE__ */ jsx(FluxMarkdownFromStream, { ...props });
121
+ }
122
+ if (props.client == null) {
123
+ throw new Error("<FluxMarkdown>: pass either a `client` or a `stream` prop.");
124
+ }
125
+ return /* @__PURE__ */ jsx(FluxMarkdownFromClient, { ...props });
126
+ }
127
+ const FluxMarkdown = memo(FluxMarkdownImpl);
128
+ function decodeEntities(s) {
129
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
130
+ }
131
+ function decodeCodeText(html) {
132
+ const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
133
+ return m ? decodeEntities(m[1]) : "";
134
+ }
135
+ function decodeMathText(html) {
136
+ const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
137
+ if (d) return decodeEntities(d[1]);
138
+ return decodeCodeText(html);
139
+ }
140
+ function blockKindProps(block, components) {
141
+ const props = {
142
+ block,
143
+ html: block.html,
144
+ open: block.open,
145
+ speculative: block.speculative
146
+ };
147
+ const data = block.kind.data;
148
+ if (block.kind.type === "CodeBlock") {
149
+ props.text = data?.code ?? decodeCodeText(block.html);
150
+ props.language = data?.lang ?? "";
151
+ if (typeof data?.code === "string") {
152
+ props.code = { lang: data.lang ?? null, code: data.code };
153
+ }
154
+ } else if (block.kind.type === "MathBlock") {
155
+ props.text = data?.latex ?? decodeMathText(block.html);
156
+ if (typeof data?.latex === "string") {
157
+ props.math = { latex: data.latex };
158
+ }
159
+ } else if (block.kind.type === "List") {
160
+ if (data && typeof data.start === "number") {
161
+ props.list = { ordered: !!data.ordered, start: data.start, items: data.items };
162
+ }
163
+ } else if (block.kind.type === "Component") {
164
+ props.tag = data?.tag ?? "";
165
+ props.attrs = reactAttrs(data?.attrs ?? []);
166
+ props.html = componentInnerHtml(block.html, props.tag);
167
+ props.children = htmlToReact(props.html, components ?? {});
168
+ } else if (block.kind.type === "Table") {
169
+ props.table = block.kind.data;
170
+ } else if (block.kind.type === "Heading") {
171
+ if (typeof block.kind.data === "object" && block.kind.data !== null) {
172
+ props.heading = block.kind.data;
173
+ }
174
+ } else if (block.kind.type === "Blockquote" || block.kind.type === "Alert") {
175
+ const cd = block.kind.data;
176
+ if (cd && Array.isArray(cd.nested)) {
177
+ props.container = { nested: cd.nested };
178
+ }
179
+ }
180
+ return props;
181
+ }
182
+ const REACT_ATTR_NAME = Object.assign(/* @__PURE__ */ Object.create(null), {
183
+ class: "className",
184
+ for: "htmlFor"
185
+ });
186
+ const ATTR_DENY = /* @__PURE__ */ new Set([
187
+ "dangerouslysetinnerhtml",
188
+ "ref",
189
+ "key",
190
+ "defaultvalue",
191
+ "defaultchecked",
192
+ "suppresshydrationwarning",
193
+ "suppresscontenteditablewarning"
194
+ ]);
195
+ const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
196
+ function reactAttrs(pairs) {
197
+ const out = {};
198
+ for (const [k, v] of pairs) {
199
+ const lower = k.toLowerCase();
200
+ if (lower.startsWith("on")) continue;
201
+ if (ATTR_DENY.has(lower)) continue;
202
+ if (!(lower in REACT_ATTR_NAME) && !SAFE_ATTR_NAME.test(k)) continue;
203
+ out[REACT_ATTR_NAME[lower] ?? k] = v;
204
+ }
205
+ return out;
206
+ }
207
+ function componentInnerHtml(html, tag) {
208
+ const gt = html.indexOf(">");
209
+ if (gt < 0) return "";
210
+ let inner = html.slice(gt + 1);
211
+ const close = `</${tag}>`;
212
+ if (inner.endsWith(close)) inner = inner.slice(0, -close.length);
213
+ return inner.replace(/^\n/, "").replace(/\n$/, "");
214
+ }
215
+ const CHILD_MEMO_CAP = 4096;
216
+ function SafeHtml({
217
+ html,
218
+ components,
219
+ childMemo
220
+ }) {
221
+ const memoRef = useRef(null);
222
+ const compRef = useRef(null);
223
+ return useMemo(() => {
224
+ if (!childMemo) {
225
+ memoRef.current = null;
226
+ return htmlToReact(html, components);
227
+ }
228
+ if (memoRef.current === null || compRef.current !== components) {
229
+ memoRef.current = /* @__PURE__ */ new Map();
230
+ compRef.current = components;
231
+ }
232
+ const map = memoRef.current;
233
+ if (map.size > CHILD_MEMO_CAP) map.clear();
234
+ return htmlToReact(html, components, map);
235
+ }, [html, components, childMemo]);
236
+ }
237
+ function KeyedListItemImpl({
238
+ html,
239
+ components,
240
+ sanitize
241
+ }) {
242
+ const safe = sanitize ? sanitize(html) : html;
243
+ if (components) {
244
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(SafeHtml, { html: safe, components }) });
245
+ }
246
+ return /* @__PURE__ */ jsx("li", { dangerouslySetInnerHTML: { __html: safe } });
247
+ }
248
+ const KeyedListItem = memo(KeyedListItemImpl);
249
+ function KeyedList({
250
+ className,
251
+ ordered,
252
+ start,
253
+ items,
254
+ components,
255
+ sanitize
256
+ }) {
257
+ const children = items.map((it, i) => /* @__PURE__ */ jsx(KeyedListItem, { html: it.html, components, sanitize }, i));
258
+ const inner = ordered ? createElement("ol", start !== void 0 && start !== 1 ? { start } : null, children) : createElement("ul", null, children);
259
+ return /* @__PURE__ */ jsx("div", { className, children: inner });
260
+ }
261
+ function KeyedContainer({
262
+ block,
263
+ nested,
264
+ components
265
+ }) {
266
+ const tagName = block.kind.type === "Alert" ? "div" : "blockquote";
267
+ const attrs = useMemo(() => parseOpenTagAttrs(block.html), [block.html]);
268
+ const children = [];
269
+ if (block.kind.type === "Alert") {
270
+ const title = alertTitleHtml(block.html);
271
+ if (title) {
272
+ children.push(/* @__PURE__ */ jsx(SafeHtml, { html: title, components }, "title"));
273
+ }
274
+ }
275
+ for (let i = 0; i < nested.length; i++) {
276
+ children.push(/* @__PURE__ */ jsx(SafeHtml, { html: nested[i].html, components }, i));
277
+ }
278
+ return createElement(tagName, attrs, children);
279
+ }
280
+ const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
281
+ function parseOpenTagAttrs(html) {
282
+ const gt = html.indexOf(">");
283
+ const open = gt < 0 ? html : html.slice(0, gt);
284
+ const out = {};
285
+ let m;
286
+ CONTAINER_ATTR_RE.lastIndex = 0;
287
+ while (m = CONTAINER_ATTR_RE.exec(open)) {
288
+ const name = m[1].toLowerCase();
289
+ if (name === "class") out.className = m[2];
290
+ else if (name === "dir" || name === "role" || name.startsWith("data-")) out[name] = m[2];
291
+ }
292
+ return out;
293
+ }
294
+ function alertTitleHtml(html) {
295
+ const m = html.match(/<p class="markdown-alert-title"[^>]*>.*?<\/p>/s);
296
+ return m ? m[0] : "";
297
+ }
298
+ const NO_COMPONENTS = Object.freeze(/* @__PURE__ */ Object.create(null));
299
+ const TableCellView = memo(function TableCellView2({
300
+ tag,
301
+ html,
302
+ align,
303
+ scope,
304
+ components,
305
+ sanitize
306
+ }) {
307
+ const tree = useMemo(
308
+ () => htmlToReact(sanitize ? sanitize(html) : html, components),
309
+ [html, components, sanitize]
310
+ );
311
+ return createElement(
312
+ tag,
313
+ {
314
+ scope: tag === "th" && scope ? "col" : void 0,
315
+ style: align ? { textAlign: align } : void 0
316
+ },
317
+ tree
318
+ );
319
+ });
320
+ function KeyedTable({
321
+ data,
322
+ html,
323
+ components,
324
+ sanitize
325
+ }) {
326
+ const comps = components ?? NO_COMPONENTS;
327
+ const headerPrefix = useMemo(() => {
328
+ const i = html.indexOf("</thead>");
329
+ return i === -1 ? html : html.slice(0, i);
330
+ }, [html]);
331
+ const dir = useMemo(
332
+ () => headerPrefix.startsWith('<table dir="auto"') ? "auto" : void 0,
333
+ [headerPrefix]
334
+ );
335
+ const scope = useMemo(() => headerPrefix.includes('<th scope="col"'), [headerPrefix]);
336
+ const aligns = data.aligns;
337
+ return /* @__PURE__ */ jsxs("table", { dir, children: [
338
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: data.headers.map((c, j) => /* @__PURE__ */ jsx(
339
+ TableCellView,
340
+ {
341
+ tag: "th",
342
+ html: c.html,
343
+ align: aligns[j] ?? null,
344
+ scope,
345
+ components: comps,
346
+ sanitize
347
+ },
348
+ j
349
+ )) }) }),
350
+ data.rows.length > 0 && /* @__PURE__ */ jsx("tbody", { children: data.rows.map((row, i) => /* @__PURE__ */ jsx("tr", { children: row.map((c, j) => /* @__PURE__ */ jsx(
351
+ TableCellView,
352
+ {
353
+ tag: "td",
354
+ html: c.html,
355
+ align: aligns[j] ?? null,
356
+ scope,
357
+ components: comps,
358
+ sanitize
359
+ },
360
+ j
361
+ )) }, i)) })
362
+ ] });
363
+ }
364
+ const INTRINSIC_PX = {
365
+ Paragraph: 80,
366
+ Heading: 44,
367
+ CodeBlock: 300,
368
+ MathBlock: 140,
369
+ Mermaid: 220,
370
+ List: 120,
371
+ Blockquote: 100,
372
+ Alert: 120,
373
+ Table: 200,
374
+ Rule: 24,
375
+ Html: 80,
376
+ Component: 120
377
+ };
378
+ function BlockViewImpl(props) {
379
+ const { block, virtualize, onRenderMetrics } = props;
380
+ const metricsRef = useRef(
381
+ onRenderMetrics ? { renderCount: 0, toggle: 0, speculative: block.speculative } : null
382
+ );
383
+ const hasPerf = typeof performance !== "undefined";
384
+ const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
385
+ const content = renderBlockContent(props);
386
+ if (onRenderMetrics) {
387
+ const m = metricsRef.current ??= { renderCount: 0, toggle: 0, speculative: block.speculative };
388
+ m.renderCount++;
389
+ if (m.speculative !== block.speculative) {
390
+ m.toggle++;
391
+ m.speculative = block.speculative;
392
+ }
393
+ onRenderMetrics(block.id, {
394
+ renderCount: m.renderCount,
395
+ speculativeToggleCount: m.toggle,
396
+ lastRenderMs: hasPerf ? performance.now() - t0 : 0,
397
+ kind: block.kind.type
398
+ });
399
+ }
400
+ if (virtualize && !block.open && !block.speculative) {
401
+ const px = INTRINSIC_PX[block.kind.type] ?? 120;
402
+ return /* @__PURE__ */ jsx("div", { style: { contentVisibility: "auto", containIntrinsicSize: `auto ${px}px` }, children: content });
403
+ }
404
+ return content;
405
+ }
406
+ function renderBlockContent({
407
+ block,
408
+ components,
409
+ sanitize,
410
+ childMemo
411
+ }) {
412
+ const kind = block.kind.type;
413
+ if (components) {
414
+ if (kind === "Component") {
415
+ const tag = block.kind.data?.tag;
416
+ const override = tag && components[tag] || components.Component;
417
+ if (override) {
418
+ return createElement(override, blockKindProps(block, components));
419
+ }
420
+ }
421
+ const blockOverride = components[kind];
422
+ if (blockOverride) {
423
+ return createElement(blockOverride, blockKindProps(block, components));
424
+ }
425
+ }
426
+ switch (kind) {
427
+ case "CodeBlock": {
428
+ const wantsCodeOverride = !!components && (!!components.pre || !!components.code);
429
+ if (!wantsCodeOverride) return /* @__PURE__ */ jsx(CodeBlock, { html: block.html, open: block.open });
430
+ break;
431
+ }
432
+ case "MathBlock":
433
+ return /* @__PURE__ */ jsx(MathBlock, { html: block.html, open: block.open });
434
+ case "Mermaid":
435
+ return /* @__PURE__ */ jsx(Mermaid, { html: block.html, open: block.open });
436
+ }
437
+ const className = "flux-block flux-block-" + kind.toLowerCase() + (block.open ? " flux-open" : "") + (block.speculative ? " flux-speculative" : "");
438
+ if (kind === "Table" && block.open) {
439
+ const data = block.kind.data;
440
+ if (data && Array.isArray(data.rows)) {
441
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedTable, { data, html: block.html, components, sanitize }) });
442
+ }
443
+ }
444
+ if (block.open && kind === "List") {
445
+ const ld = block.kind.data;
446
+ const items = ld?.items;
447
+ const tagOverride = !!components && (!!components.ul || !!components.ol || !!components.li);
448
+ if (Array.isArray(items) && items.length > 0 && !tagOverride) {
449
+ return /* @__PURE__ */ jsx(
450
+ KeyedList,
451
+ {
452
+ className,
453
+ ordered: !!ld?.ordered,
454
+ start: ld?.start,
455
+ items,
456
+ components,
457
+ sanitize
458
+ }
459
+ );
460
+ }
461
+ }
462
+ if (components) {
463
+ if (block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
464
+ const nested = block.kind.data?.nested;
465
+ if (Array.isArray(nested)) {
466
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components }) });
467
+ }
468
+ }
469
+ const safe = sanitize ? sanitize(block.html) : block.html;
470
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(SafeHtml, { html: safe, components, childMemo: childMemo && block.open }) });
471
+ }
472
+ return /* @__PURE__ */ jsx(
473
+ "div",
474
+ {
475
+ className,
476
+ dangerouslySetInnerHTML: { __html: sanitize ? sanitize(block.html) : block.html }
477
+ }
478
+ );
479
+ }
480
+ function blocksEqual(prev, next) {
481
+ return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.onRenderMetrics === next.onRenderMetrics;
482
+ }
483
+ const BlockView = memo(BlockViewImpl, blocksEqual);
484
+ export {
485
+ FluxMarkdown,
486
+ blockKindProps,
487
+ blocksEqual,
488
+ useFluxMarkdownString,
489
+ useFluxStream
490
+ };