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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +50 -0
- package/src/client.ts +315 -0
- package/src/hi.ts +244 -0
- package/src/html-to-react.ts +282 -0
- package/src/index.ts +33 -0
- package/src/react.tsx +223 -0
- package/src/renderers/CodeBlock.tsx +62 -0
- package/src/renderers/Math.tsx +26 -0
- package/src/renderers/Mermaid.tsx +26 -0
- package/src/types.ts +130 -0
- package/src/wasm/flux_md_core.d.ts +94 -0
- package/src/wasm/flux_md_core.js +399 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +18 -0
- package/src/wasm/package.json +17 -0
- package/src/worker.ts +151 -0
|
@@ -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
|
+
// <, 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(/</g, "<")
|
|
90
|
+
.replace(/>/g, ">")
|
|
91
|
+
.replace(/"/g, '"')
|
|
92
|
+
.replace(/'/g, "'")
|
|
93
|
+
.replace(/&/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(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, "'")
|
|
19
|
+
.replace(/&/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);
|