flux-md 0.3.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.
@@ -0,0 +1,282 @@
1
+ import { createElement, type ReactNode } from "react";
2
+ import type { Components } from "./types";
3
+
4
+ // HTML void elements: no closing tag, never have children.
5
+ const VOID = new Set([
6
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
7
+ "link", "meta", "param", "source", "track", "wbr",
8
+ ]);
9
+
10
+ // Attribute name → React prop name, for the handful that differ. Anything not
11
+ // listed passes through verbatim (React forwards data-*/aria-* and lowercase
12
+ // attributes unchanged).
13
+ const ATTR_MAP: Record<string, string> = {
14
+ class: "className",
15
+ for: "htmlFor",
16
+ colspan: "colSpan",
17
+ rowspan: "rowSpan",
18
+ tabindex: "tabIndex",
19
+ maxlength: "maxLength",
20
+ minlength: "minLength",
21
+ readonly: "readOnly",
22
+ autocomplete: "autoComplete",
23
+ autofocus: "autoFocus",
24
+ spellcheck: "spellCheck",
25
+ contenteditable: "contentEditable",
26
+ crossorigin: "crossOrigin",
27
+ enterkeyhint: "enterKeyHint",
28
+ inputmode: "inputMode",
29
+ };
30
+
31
+ // URL-bearing attributes whose value must be scheme-checked. `htmlToReact` is
32
+ // exported and may be handed untrusted HTML directly; React happily renders a
33
+ // `javascript:` href (it only warns), so we neutralize it here as
34
+ // defense-in-depth — the core's own output is already sanitized.
35
+ const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action", "poster", "data"]);
36
+
37
+ /** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
38
+ * strip control chars (C0, DEL, C1 — matching Rust char::is_control),
39
+ * lowercase, then match. The strip affects only the probe, never output. */
40
+ function safeUrl(value: string): string {
41
+ // eslint-disable-next-line no-control-regex
42
+ const probe = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
43
+ if (
44
+ probe.startsWith("javascript:") ||
45
+ probe.startsWith("vbscript:") ||
46
+ probe.startsWith("data:text/html") ||
47
+ probe.startsWith("data:text/javascript")
48
+ ) {
49
+ return "#";
50
+ }
51
+ return value;
52
+ }
53
+
54
+ type HNode =
55
+ | { kind: "text"; text: string }
56
+ | { kind: "el"; tag: string; attrs: Record<string, string | true>; children: HNode[] };
57
+
58
+ const NAMED_ENTITIES: Record<string, string> = {
59
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
60
+ copy: "©", reg: "®", hellip: "…", mdash: "—", ndash: "–",
61
+ };
62
+
63
+ /** Decode the (small, known) set of entities the core emits, plus numeric refs. */
64
+ export function decodeEntities(s: string): string {
65
+ if (s.indexOf("&") === -1) return s;
66
+ return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body: string) => {
67
+ if (body[0] === "#") {
68
+ const code = body[1] === "x" || body[1] === "X"
69
+ ? parseInt(body.slice(2), 16)
70
+ : parseInt(body.slice(1), 10);
71
+ if (Number.isNaN(code) || code < 0 || code > 0x10ffff) return m;
72
+ try {
73
+ return String.fromCodePoint(code);
74
+ } catch {
75
+ return m;
76
+ }
77
+ }
78
+ const named = NAMED_ENTITIES[body];
79
+ return named === undefined ? m : named;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Parse an inline CSS string (`"text-align:left;color:red"`) into the object
85
+ * React's `style` prop requires, camelCasing property names. Custom properties
86
+ * (`--x`) keep their literal name.
87
+ */
88
+ export function parseStyle(css: string): Record<string, string> {
89
+ const out: Record<string, string> = {};
90
+ for (const decl of css.split(";")) {
91
+ const c = decl.indexOf(":");
92
+ if (c === -1) continue;
93
+ const rawName = decl.slice(0, c).trim();
94
+ const value = decl.slice(c + 1).trim();
95
+ if (!rawName || !value) continue;
96
+ const name = rawName.startsWith("--")
97
+ ? rawName
98
+ : rawName.toLowerCase().replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
99
+ out[name] = value;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Parse one opening tag starting at `start` (the `<`). */
105
+ function parseOpenTag(html: string, start: number) {
106
+ let i = start + 1;
107
+ let j = i;
108
+ while (j < html.length && /[a-zA-Z0-9-]/.test(html[j])) j++;
109
+ const tag = html.slice(i, j).toLowerCase();
110
+ i = j;
111
+ const attrs: Record<string, string | true> = {};
112
+ while (i < html.length) {
113
+ const loopStart = i;
114
+ while (i < html.length && /\s/.test(html[i])) i++;
115
+ if (html[i] === ">") return { tag, attrs, selfClose: false, next: i + 1 };
116
+ if (html[i] === "/" && html[i + 1] === ">") return { tag, attrs, selfClose: true, next: i + 2 };
117
+ if (i >= html.length) break;
118
+ let k = i;
119
+ while (k < html.length && !/[\s=>/]/.test(html[k])) k++;
120
+ const name = html.slice(i, k);
121
+ i = k;
122
+ while (i < html.length && /\s/.test(html[i])) i++;
123
+ if (html[i] === "=") {
124
+ i++;
125
+ while (i < html.length && /\s/.test(html[i])) i++;
126
+ let value = "";
127
+ const q = html[i];
128
+ if (q === '"' || q === "'") {
129
+ i++;
130
+ const e = html.indexOf(q, i);
131
+ value = html.slice(i, e === -1 ? html.length : e);
132
+ i = e === -1 ? html.length : e + 1;
133
+ } else {
134
+ let v = i;
135
+ while (v < html.length && !/[\s>]/.test(html[v])) v++;
136
+ value = html.slice(i, v);
137
+ i = v;
138
+ }
139
+ if (name) attrs[name] = decodeEntities(value);
140
+ } else if (name) {
141
+ attrs[name] = true; // boolean attribute (e.g. checked, disabled)
142
+ }
143
+ // Guarantee forward progress on malformed input (e.g. a stray '/').
144
+ if (i <= loopStart) i = loopStart + 1;
145
+ }
146
+ return { tag, attrs, selfClose: false, next: i };
147
+ }
148
+
149
+ /**
150
+ * Tokenize **trusted** HTML (the kind flux-md's core emits: well-formed,
151
+ * entity-escaped, allowlisted) into a small node tree. This is deliberately not
152
+ * a defensive HTML5 parser — the threat model is "our own serializer output",
153
+ * not hostile markup. Unrecognized constructs (comments, doctype) are skipped.
154
+ */
155
+ // Instrumentation: how many times the tokenizer has run. Used by tests to
156
+ // prove open blocks and the no-override fast path never reach the parser, and
157
+ // that memoized closed blocks parse exactly once. Negligible cost in prod.
158
+ let parseCount = 0;
159
+ export function getParseCount(): number {
160
+ return parseCount;
161
+ }
162
+ export function resetParseCount(): void {
163
+ parseCount = 0;
164
+ }
165
+
166
+ export function parseTrustedHtml(html: string): HNode[] {
167
+ parseCount++;
168
+ const root: HNode[] = [];
169
+ const stack: Array<Extract<HNode, { kind: "el" }>> = [];
170
+ let i = 0;
171
+ const push = (n: HNode) => {
172
+ if (stack.length) stack[stack.length - 1].children.push(n);
173
+ else root.push(n);
174
+ };
175
+ while (i < html.length) {
176
+ const lt = html.indexOf("<", i);
177
+ if (lt === -1) {
178
+ const t = html.slice(i);
179
+ if (t) push({ kind: "text", text: decodeEntities(t) });
180
+ break;
181
+ }
182
+ if (lt > i) push({ kind: "text", text: decodeEntities(html.slice(i, lt)) });
183
+
184
+ if (html.startsWith("<!--", lt)) {
185
+ const end = html.indexOf("-->", lt + 4);
186
+ i = end === -1 ? html.length : end + 3;
187
+ continue;
188
+ }
189
+ if (html[lt + 1] === "!") {
190
+ const end = html.indexOf(">", lt);
191
+ i = end === -1 ? html.length : end + 1;
192
+ continue;
193
+ }
194
+ if (html[lt + 1] === "/") {
195
+ const end = html.indexOf(">", lt);
196
+ const tag = html.slice(lt + 2, end === -1 ? html.length : end).trim().toLowerCase();
197
+ for (let s = stack.length - 1; s >= 0; s--) {
198
+ if (stack[s].tag === tag) {
199
+ stack.length = s;
200
+ break;
201
+ }
202
+ }
203
+ i = end === -1 ? html.length : end + 1;
204
+ continue;
205
+ }
206
+ // An opening tag must start with an ASCII letter. Anything else (a stray
207
+ // '<', as in "3 < 4") is literal text. (Real core output escapes '<' to
208
+ // &lt;, so this only matters for hand-fed input — but it must not hang.)
209
+ const c1 = html[lt + 1];
210
+ const isName = (c1 >= "a" && c1 <= "z") || (c1 >= "A" && c1 <= "Z");
211
+ if (!isName) {
212
+ push({ kind: "text", text: "<" });
213
+ i = lt + 1;
214
+ continue;
215
+ }
216
+ const { tag, attrs, selfClose, next } = parseOpenTag(html, lt);
217
+ const el: Extract<HNode, { kind: "el" }> = { kind: "el", tag, attrs, children: [] };
218
+ push(el);
219
+ if (!selfClose && !VOID.has(tag)) stack.push(el);
220
+ i = next;
221
+ }
222
+ return root;
223
+ }
224
+
225
+ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: string): Record<string, unknown> {
226
+ const props: Record<string, unknown> = { key };
227
+ for (const name in attrs) {
228
+ const value = attrs[name];
229
+ const lower = name.toLowerCase();
230
+ // Defense-in-depth: never forward inline event handlers, even though
231
+ // React drops most lowercase `on*` attrs — this also covers casings and
232
+ // future React behavior.
233
+ if (lower.startsWith("on")) continue;
234
+ if (lower === "style" && typeof value === "string") {
235
+ props.style = parseStyle(value);
236
+ continue;
237
+ }
238
+ // Neutralize dangerous-scheme URLs (javascript:, vbscript:, data:text/html).
239
+ if (URL_ATTRS.has(lower) && typeof value === "string") {
240
+ props[ATTR_MAP[lower] ?? name] = safeUrl(value);
241
+ continue;
242
+ }
243
+ // A static checkbox carries `checked` with no handler; render it
244
+ // uncontrolled so React doesn't warn about a missing onChange.
245
+ if (tag === "input" && lower === "checked") {
246
+ props.defaultChecked = value === true ? true : value;
247
+ continue;
248
+ }
249
+ props[ATTR_MAP[lower] ?? name] = value;
250
+ }
251
+ return props;
252
+ }
253
+
254
+ function nodesToReact(nodes: HNode[], components: Components, keyPrefix: string): ReactNode {
255
+ const out: ReactNode[] = [];
256
+ for (let idx = 0; idx < nodes.length; idx++) {
257
+ const n = nodes[idx];
258
+ if (n.kind === "text") {
259
+ out.push(n.text);
260
+ continue;
261
+ }
262
+ const key = keyPrefix + idx;
263
+ const type = components[n.tag] ?? n.tag;
264
+ const props = attrsToProps(n.tag, n.attrs, key);
265
+ if (VOID.has(n.tag)) {
266
+ out.push(createElement(type, props));
267
+ } else {
268
+ out.push(createElement(type, props, nodesToReact(n.children, components, key + ".")));
269
+ }
270
+ }
271
+ return out.length === 1 ? out[0] : out;
272
+ }
273
+
274
+ /**
275
+ * Convert a block's trusted HTML string into a React node tree, replacing any
276
+ * element whose tag name appears in `components`. Call this only for **closed**
277
+ * blocks (open/streaming blocks have partial HTML); memoize on `(html,
278
+ * components)` at the call site.
279
+ */
280
+ export function htmlToReact(html: string, components: Components): ReactNode {
281
+ return nodesToReact(parseTrustedHtml(html), components, "");
282
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * flux-md: zero-dep streaming markdown for the browser.
3
+ *
4
+ * Public surface:
5
+ * - FluxClient: owns one Web Worker + Rust parser per stream
6
+ * - FluxMarkdown: React component that subscribes to a FluxClient
7
+ * - Block / Patch / BlockKind types
8
+ * - highlight: optional in-house syntax highlighter
9
+ *
10
+ * Typical use (React + a Vite-like bundler):
11
+ *
12
+ * import { FluxClient, FluxMarkdown } from "flux-md";
13
+ * const client = new FluxClient();
14
+ * // ... in your component: <FluxMarkdown client={client} />
15
+ * // ... wherever your tokens land: client.append(deltaText);
16
+ * client.finalize();
17
+ */
18
+ export { FluxClient, FluxPool, getDefaultPool } from "./client";
19
+ export { FluxMarkdown } from "./react";
20
+ export { highlight, supportedLangs } from "./hi";
21
+ export { htmlToReact, parseTrustedHtml } from "./html-to-react";
22
+ export type {
23
+ Block,
24
+ BlockKind,
25
+ BlockKindTag,
26
+ BlockComponentProps,
27
+ Components,
28
+ Patch,
29
+ FromWorker,
30
+ ToWorker,
31
+ WorkerLike,
32
+ ParserConfig,
33
+ } from "./types";
package/src/react.tsx ADDED
@@ -0,0 +1,223 @@
1
+ import { createElement, memo, useMemo, useSyncExternalStore, type CSSProperties } from "react";
2
+ import type { Block, BlockComponentProps, Components } from "./types";
3
+ import type { FluxClient } from "./client";
4
+ import { CodeBlock } from "./renderers/CodeBlock";
5
+ import { MathBlock } from "./renderers/Math";
6
+ import { Mermaid } from "./renderers/Mermaid";
7
+ import { htmlToReact } from "./html-to-react";
8
+
9
+ /**
10
+ * Render a streaming markdown document from a FluxClient. Each block is its
11
+ * own memoized React node keyed by its stable parser-assigned ID, so React
12
+ * only reconciles the blocks whose HTML actually changed since the last
13
+ * patch. Heavy renderers (Shiki, KaTeX, Mermaid) defer work until a block
14
+ * is closed.
15
+ *
16
+ * ## Custom components
17
+ *
18
+ * Pass `components` to override rendering (see {@link Components}):
19
+ *
20
+ * ```tsx
21
+ * <FluxMarkdown
22
+ * client={client}
23
+ * components={{
24
+ * table: (p) => <table className="my-table" {...p} />, // tag-level
25
+ * a: (p) => <a target="_blank" rel="noreferrer" {...p} />,
26
+ * CodeBlock: (p) => <MyCodeBlock {...p} />, // block-kind
27
+ * }}
28
+ * />
29
+ * ```
30
+ *
31
+ * Rules:
32
+ * - **Tag-level** keys (`table`, `a`, `code`, `h1`…) replace that element
33
+ * wherever it appears inside a block. Applied by converting the block's
34
+ * trusted HTML to a React tree.
35
+ * - **Block-kind** keys ({@link BlockKindTag}: `CodeBlock`, `Mermaid`,
36
+ * `Table`…) replace the whole block; the component gets
37
+ * {@link BlockComponentProps}.
38
+ * - **Open / speculative** blocks always render via `innerHTML` (their HTML
39
+ * is partial); a tag-level override takes effect once the block commits.
40
+ * - With no `components` prop the renderer takes the original fast
41
+ * `innerHTML` path — output is byte-identical to before.
42
+ * - **Memoize `components`** (or hoist it) if you define it inside a
43
+ * component — a fresh object identity each render busts the block memo and
44
+ * forces every block to re-parse on every patch.
45
+ * - For code blocks the built-in highlighter is the default; it is bypassed
46
+ * (so your override wins) when you provide `components.CodeBlock`,
47
+ * `components.pre`, or `components.code`.
48
+ */
49
+
50
+ interface FluxMarkdownProps {
51
+ client: FluxClient;
52
+ components?: Components;
53
+ /**
54
+ * Skip layout/paint for off-screen blocks via CSS `content-visibility: auto`
55
+ * — for very long documents (hundreds+ of blocks). Off by default. Applies
56
+ * only to *closed* blocks (the streaming tail always renders fully). Keeps
57
+ * nodes in the DOM; it cuts rendering cost, not node count.
58
+ */
59
+ virtualize?: boolean;
60
+ /**
61
+ * Render a bottom snap target so the view follows the streaming tail. This is
62
+ * CSS-only: it emits a sentinel with `scroll-snap-align: end`; **you** add
63
+ * `scroll-snap-type: y proximity` to your scroll container. The view then
64
+ * follows the bottom as content streams in and releases when the user scrolls
65
+ * up (and re-locks when they scroll back near the bottom). Off by default.
66
+ */
67
+ stickToBottom?: boolean;
68
+ }
69
+
70
+ function FluxMarkdownImpl({ client, components, virtualize, stickToBottom }: FluxMarkdownProps) {
71
+ const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
72
+ // Normalize "no overrides" to a stable `undefined` so memo comparisons and
73
+ // the fast path don't churn on an empty object identity.
74
+ const comps = components && Object.keys(components).length > 0 ? components : undefined;
75
+ return (
76
+ <div className="flux-md">
77
+ {blocks.map((b) => (
78
+ <BlockView key={b.id} block={b} components={comps} virtualize={virtualize} />
79
+ ))}
80
+ {stickToBottom && <div aria-hidden="true" style={{ scrollSnapAlign: "end" }} className="flux-bottom-anchor" />}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export const FluxMarkdown = memo(FluxMarkdownImpl);
86
+
87
+ function decodeEntities(s: string): string {
88
+ return s
89
+ .replace(/&lt;/g, "<")
90
+ .replace(/&gt;/g, ">")
91
+ .replace(/&quot;/g, '"')
92
+ .replace(/&#39;/g, "'")
93
+ .replace(/&amp;/g, "&");
94
+ }
95
+
96
+ function decodeCodeText(html: string): string {
97
+ const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
98
+ return m ? decodeEntities(m[1]) : "";
99
+ }
100
+
101
+ /**
102
+ * The LaTeX source for a MathBlock. Display math (`$$…$$` / `\[…\]`) renders as
103
+ * `<div class="math math-display">…</div>`; a fenced ```math block renders as
104
+ * `<pre><code>…</code></pre>`. Either way the body is the HTML-escaped LaTeX —
105
+ * decode it back so a `components.MathBlock` override gets the raw source.
106
+ */
107
+ function decodeMathText(html: string): string {
108
+ const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
109
+ if (d) return decodeEntities(d[1]);
110
+ return decodeCodeText(html);
111
+ }
112
+
113
+ function blockKindProps(block: Block): BlockComponentProps {
114
+ const props: BlockComponentProps = {
115
+ block,
116
+ html: block.html,
117
+ open: block.open,
118
+ speculative: block.speculative,
119
+ };
120
+ const data = block.kind.data as { lang?: string | null } | undefined;
121
+ if (block.kind.type === "CodeBlock") {
122
+ props.text = decodeCodeText(block.html);
123
+ props.language = data?.lang ?? "";
124
+ } else if (block.kind.type === "MathBlock") {
125
+ props.text = decodeMathText(block.html);
126
+ }
127
+ return props;
128
+ }
129
+
130
+ /** Convert a closed block's HTML to a React tree, memoized on html+components. */
131
+ function SafeHtml({ html, components }: { html: string; components: Components }) {
132
+ return useMemo(() => htmlToReact(html, components), [html, components]) as JSX.Element;
133
+ }
134
+
135
+ // Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
136
+ // scrollbar stable while a block is layout-skipped. Wrong by 2× is fine; the
137
+ // `auto` keyword makes the browser remember the real size once rendered.
138
+ const INTRINSIC_PX: Record<string, number> = {
139
+ Paragraph: 80, Heading: 44, CodeBlock: 300, MathBlock: 140, Mermaid: 220,
140
+ List: 120, Blockquote: 100, Alert: 120, Table: 200, Rule: 24, Html: 80,
141
+ };
142
+
143
+ function BlockViewImpl(props: { block: Block; components?: Components; virtualize?: boolean }) {
144
+ const { block, virtualize } = props;
145
+ const content = renderBlockContent(props);
146
+ // Virtualize only *closed* blocks: the streaming tail (open/speculative) is
147
+ // where the user looks and where heights change fastest — deferring it there
148
+ // causes flicker. A uniform wrapper covers every kind, including dedicated
149
+ // renderers and block-kind overrides.
150
+ if (virtualize && !block.open && !block.speculative) {
151
+ const px = INTRINSIC_PX[block.kind.type] ?? 120;
152
+ return (
153
+ <div style={{ contentVisibility: "auto", containIntrinsicSize: `auto ${px}px` } as CSSProperties}>
154
+ {content}
155
+ </div>
156
+ );
157
+ }
158
+ return content;
159
+ }
160
+
161
+ function renderBlockContent({ block, components }: { block: Block; components?: Components }) {
162
+ const kind = block.kind.type;
163
+
164
+ // Block-kind override replaces the entire renderer for this block.
165
+ if (components) {
166
+ const blockOverride = components[kind];
167
+ if (blockOverride) {
168
+ return createElement(blockOverride, blockKindProps(block));
169
+ }
170
+ }
171
+
172
+ // Dedicated renderers for code / math / mermaid. Code blocks fall through to
173
+ // the generic (override-aware) path if the user supplied a pre/code override.
174
+ switch (kind) {
175
+ case "CodeBlock": {
176
+ const wantsCodeOverride = !!components && (!!components.pre || !!components.code);
177
+ if (!wantsCodeOverride) return <CodeBlock html={block.html} open={block.open} />;
178
+ break; // fall through to generic override-aware rendering
179
+ }
180
+ case "MathBlock":
181
+ return <MathBlock html={block.html} open={block.open} />;
182
+ case "Mermaid":
183
+ return <Mermaid html={block.html} open={block.open} />;
184
+ }
185
+
186
+ const className =
187
+ "flux-block flux-block-" +
188
+ kind.toLowerCase() +
189
+ (block.open ? " flux-open" : "") +
190
+ (block.speculative ? " flux-speculative" : "");
191
+
192
+ // Tag-level overrides only apply to a settled block (open/speculative blocks
193
+ // have partial HTML we must not feed to the parser).
194
+ if (components && !block.open && !block.speculative) {
195
+ return (
196
+ <div className={className}>
197
+ <SafeHtml html={block.html} components={components} />
198
+ </div>
199
+ );
200
+ }
201
+
202
+ return <div className={className} dangerouslySetInnerHTML={{ __html: block.html }} />;
203
+ }
204
+
205
+ // A block is the same render when its identity, HTML, open-state, and the
206
+ // active components map are all unchanged. Exported for tests: this predicate
207
+ // is what stops a committed block from re-rendering (and thus re-parsing) on
208
+ // every streaming patch.
209
+ export function blocksEqual(
210
+ prev: { block: Block; components?: Components; virtualize?: boolean },
211
+ next: { block: Block; components?: Components; virtualize?: boolean },
212
+ ): boolean {
213
+ return (
214
+ prev.block.id === next.block.id &&
215
+ prev.block.html === next.block.html &&
216
+ prev.block.open === next.block.open &&
217
+ prev.block.speculative === next.block.speculative &&
218
+ prev.components === next.components &&
219
+ prev.virtualize === next.virtualize
220
+ );
221
+ }
222
+
223
+ const BlockView = memo(BlockViewImpl, blocksEqual);
@@ -0,0 +1,62 @@
1
+ import { memo, useMemo } from "react";
2
+ import { highlight } from "../hi";
3
+
4
+ /**
5
+ * Deferred-highlighting code block. Open (streaming) blocks render plain;
6
+ * the moment the parser commits the block (open=false), we run our in-house
7
+ * tokenizer on the source and swap in highlighted HTML. Highlighting is
8
+ * memoized on html identity so closed blocks never re-tokenize.
9
+ */
10
+
11
+ function decodeText(html: string): string {
12
+ const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
13
+ if (!m) return "";
14
+ return m[1]
15
+ .replace(/&lt;/g, "<")
16
+ .replace(/&gt;/g, ">")
17
+ .replace(/&quot;/g, '"')
18
+ .replace(/&#39;/g, "'")
19
+ .replace(/&amp;/g, "&");
20
+ }
21
+
22
+ function extractLang(html: string): string {
23
+ const m = html.match(/data-lang="([^"]+)"/);
24
+ return m ? m[1] : "";
25
+ }
26
+
27
+ interface Props {
28
+ html: string;
29
+ open: boolean;
30
+ }
31
+
32
+ function CodeBlockImpl({ html, open }: Props) {
33
+ const lang = extractLang(html) || "text";
34
+ const highlighted = useMemo(() => {
35
+ if (open) return null;
36
+ const text = decodeText(html);
37
+ if (!text) return null;
38
+ return highlight(text, lang);
39
+ }, [html, open, lang]);
40
+
41
+ return (
42
+ <div className={"flux-code-block" + (open ? " flux-streaming" : "")}>
43
+ <div className="flux-code-header">
44
+ <span className="flux-code-lang">{lang}</span>
45
+ {open && <span className="flux-code-streaming-pill">streaming</span>}
46
+ </div>
47
+ <div className="flux-code-body">
48
+ {highlighted ? (
49
+ // tabIndex=0 + role/label so keyboard users can scroll long code and
50
+ // screen readers announce the region with its language.
51
+ <pre tabIndex={0} role="region" aria-label={`${lang} code`}>
52
+ <code dangerouslySetInnerHTML={{ __html: highlighted }} />
53
+ </pre>
54
+ ) : (
55
+ <div tabIndex={0} role="region" aria-label={`${lang} code`} dangerouslySetInnerHTML={{ __html: html }} />
56
+ )}
57
+ </div>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ export const CodeBlock = memo(CodeBlockImpl);
@@ -0,0 +1,26 @@
1
+ import { memo } from "react";
2
+
3
+ /**
4
+ * Math block — preformatted-text only. flux-md is zero-dep, so we don't
5
+ * ship KaTeX/MathJax. If you want rendered math, drop in a renderer via
6
+ * the (future) plugin slot and override this component.
7
+ */
8
+
9
+ interface Props {
10
+ html: string;
11
+ open: boolean;
12
+ }
13
+
14
+ function MathImpl({ html, open }: Props) {
15
+ return (
16
+ <div className={"flux-math-block" + (open ? " flux-streaming" : "")}>
17
+ <div className="flux-math-header">
18
+ <span className="flux-math-lang">math</span>
19
+ {open && <span className="flux-code-streaming-pill">streaming</span>}
20
+ </div>
21
+ <div className="flux-math-body" dangerouslySetInnerHTML={{ __html: html }} />
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export const MathBlock = memo(MathImpl);
@@ -0,0 +1,26 @@
1
+ import { memo } from "react";
2
+
3
+ /**
4
+ * Mermaid block — preformatted-text only. flux-md is zero-dep, so we don't
5
+ * ship the Mermaid runtime. The diagram source is shown in a code block;
6
+ * plug in your own renderer at this slot if you want SVG output.
7
+ */
8
+
9
+ interface Props {
10
+ html: string;
11
+ open: boolean;
12
+ }
13
+
14
+ function MermaidImpl({ html, open }: Props) {
15
+ return (
16
+ <div className={"flux-mermaid-block" + (open ? " flux-streaming" : "")}>
17
+ <div className="flux-mermaid-header">
18
+ <span className="flux-mermaid-lang">mermaid</span>
19
+ {open && <span className="flux-code-streaming-pill">streaming</span>}
20
+ </div>
21
+ <div className="flux-mermaid-body" dangerouslySetInnerHTML={{ __html: html }} />
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export const Mermaid = memo(MermaidImpl);