flux-md 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -0
- package/README.md +276 -22
- package/package.json +2 -1
- package/src/client.ts +74 -0
- package/src/html-to-react.ts +23 -8
- package/src/index.ts +1 -1
- package/src/react.tsx +70 -8
- package/src/server.tsx +209 -0
- package/src/solid.tsx +72 -2
- package/src/svelte.ts +100 -1
- package/src/types-core.ts +33 -1
- package/src/vue.ts +58 -1
- package/src/wasm/flux_md_core.d.ts +17 -0
- package/src/wasm/flux_md_core.js +35 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +2 -0
- package/src/wasm/package.json +1 -1
- package/src/worker-core.ts +174 -0
- package/src/worker.ts +40 -136
package/src/html-to-react.ts
CHANGED
|
@@ -38,8 +38,17 @@ const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action",
|
|
|
38
38
|
* strip control chars (C0, DEL, C1 — matching Rust char::is_control),
|
|
39
39
|
* lowercase, then match. The strip affects only the probe, never output. */
|
|
40
40
|
function safeUrl(value: string): string {
|
|
41
|
+
// Decode-STABLE probe: a value can be entity-decoded more than once before it
|
|
42
|
+
// reaches the DOM, so peel layers to a fixpoint before the scheme check —
|
|
43
|
+
// catches `javascript:` and double-encoded `javascript:`. Only the
|
|
44
|
+
// probe is decoded; the returned value is untouched (safe URLs stay verbatim).
|
|
45
|
+
let decoded = value;
|
|
46
|
+
for (let prev = ""; decoded !== prev; ) {
|
|
47
|
+
prev = decoded;
|
|
48
|
+
decoded = decodeEntities(decoded);
|
|
49
|
+
}
|
|
41
50
|
// eslint-disable-next-line no-control-regex
|
|
42
|
-
const probe =
|
|
51
|
+
const probe = decoded.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
|
|
43
52
|
if (
|
|
44
53
|
probe.startsWith("javascript:") ||
|
|
45
54
|
probe.startsWith("vbscript:") ||
|
|
@@ -106,7 +115,11 @@ function parseOpenTag(html: string, start: number) {
|
|
|
106
115
|
let i = start + 1;
|
|
107
116
|
let j = i;
|
|
108
117
|
while (j < html.length && /[a-zA-Z0-9-]/.test(html[j])) j++;
|
|
109
|
-
|
|
118
|
+
// Preserve the tag's ORIGINAL case so an inline custom-component element (e.g.
|
|
119
|
+
// `<Cite>`) dispatches to `components.Cite`. Standard elements the core emits
|
|
120
|
+
// are already lowercase; the semantic checks below (VOID, `input`, close-tag
|
|
121
|
+
// matching) lowercase as needed, so HTML behavior is unchanged.
|
|
122
|
+
const tag = html.slice(i, j);
|
|
110
123
|
i = j;
|
|
111
124
|
const attrs: Record<string, string | true> = {};
|
|
112
125
|
while (i < html.length) {
|
|
@@ -193,9 +206,9 @@ export function parseTrustedHtml(html: string): HNode[] {
|
|
|
193
206
|
}
|
|
194
207
|
if (html[lt + 1] === "/") {
|
|
195
208
|
const end = html.indexOf(">", lt);
|
|
196
|
-
const
|
|
209
|
+
const closeLower = html.slice(lt + 2, end === -1 ? html.length : end).trim().toLowerCase();
|
|
197
210
|
for (let s = stack.length - 1; s >= 0; s--) {
|
|
198
|
-
if (stack[s].tag ===
|
|
211
|
+
if (stack[s].tag.toLowerCase() === closeLower) {
|
|
199
212
|
stack.length = s;
|
|
200
213
|
break;
|
|
201
214
|
}
|
|
@@ -216,7 +229,7 @@ export function parseTrustedHtml(html: string): HNode[] {
|
|
|
216
229
|
const { tag, attrs, selfClose, next } = parseOpenTag(html, lt);
|
|
217
230
|
const el: Extract<HNode, { kind: "el" }> = { kind: "el", tag, attrs, children: [] };
|
|
218
231
|
push(el);
|
|
219
|
-
if (!selfClose && !VOID.has(tag)) stack.push(el);
|
|
232
|
+
if (!selfClose && !VOID.has(tag.toLowerCase())) stack.push(el);
|
|
220
233
|
i = next;
|
|
221
234
|
}
|
|
222
235
|
return root;
|
|
@@ -242,7 +255,7 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
|
|
|
242
255
|
}
|
|
243
256
|
// A static checkbox carries `checked` with no handler; render it
|
|
244
257
|
// uncontrolled so React doesn't warn about a missing onChange.
|
|
245
|
-
if (tag === "input" && lower === "checked") {
|
|
258
|
+
if (tag.toLowerCase() === "input" && lower === "checked") {
|
|
246
259
|
props.defaultChecked = value === true ? true : value;
|
|
247
260
|
continue;
|
|
248
261
|
}
|
|
@@ -262,13 +275,15 @@ function nodesToReact(nodes: HNode[], components: Components, keyPrefix: string)
|
|
|
262
275
|
const key = keyPrefix + idx;
|
|
263
276
|
const type = components[n.tag] ?? n.tag;
|
|
264
277
|
const props = attrsToProps(n.tag, n.attrs, key);
|
|
265
|
-
if (VOID.has(n.tag)) {
|
|
278
|
+
if (VOID.has(n.tag.toLowerCase())) {
|
|
266
279
|
out.push(createElement(type, props));
|
|
267
280
|
} else {
|
|
268
281
|
out.push(createElement(type, props, nodesToReact(n.children, components, key + ".")));
|
|
269
282
|
}
|
|
270
283
|
}
|
|
271
|
-
|
|
284
|
+
// `null` (not an empty array) for no children, so a self-closing / empty inline
|
|
285
|
+
// component's `children` is nullish and a `{children ?? fallback}` override fires.
|
|
286
|
+
return out.length === 0 ? null : out.length === 1 ? out[0] : out;
|
|
272
287
|
}
|
|
273
288
|
|
|
274
289
|
/**
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* client.finalize();
|
|
17
17
|
*/
|
|
18
18
|
export { FluxClient, FluxPool, getDefaultPool } from "./client";
|
|
19
|
-
export { FluxMarkdown } from "./react";
|
|
19
|
+
export { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react";
|
|
20
20
|
export { highlight, supportedLangs } from "./hi";
|
|
21
21
|
export { htmlToReact, parseTrustedHtml } from "./html-to-react";
|
|
22
22
|
export type {
|
package/src/react.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
useSyncExternalStore,
|
|
9
9
|
type CSSProperties,
|
|
10
|
+
type ReactElement,
|
|
10
11
|
} from "react";
|
|
11
12
|
import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
|
|
12
13
|
import { FluxClient } from "./client";
|
|
@@ -209,6 +210,52 @@ export function useFluxStream(
|
|
|
209
210
|
return client;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
|
|
215
|
+
* UIs that hold a streaming message as a single growing string prop (the common
|
|
216
|
+
* React shape) rather than as a stream. Pass the whole document-so-far on each
|
|
217
|
+
* render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
|
|
218
|
+
* only the delta; any divergence (e.g. the finished text swapped for a
|
|
219
|
+
* re-processed final string) resets and reparses. Returns the owned client —
|
|
220
|
+
* pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
|
|
221
|
+
*
|
|
222
|
+
* Pass `streaming: false` once the content is final to finalize the stream and
|
|
223
|
+
* commit its last block (only then does a finished code fence highlight + show
|
|
224
|
+
* its copy button). If `streaming` is omitted or `true` the stream is left OPEN
|
|
225
|
+
* — right for a still-growing string, but a *complete static* string rendered as
|
|
226
|
+
* `useFluxMarkdownString(md)` keeps its last block in the streaming state until
|
|
227
|
+
* you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
|
|
228
|
+
* deliberately avoided: it would re-finalize on every token for callers that
|
|
229
|
+
* grow the string without the flag — an O(n²) reparse trap.) The client is
|
|
230
|
+
* created once and destroyed on unmount; StrictMode's dev double-mount is handled
|
|
231
|
+
* (reattach re-feeds the document). For a true stream source
|
|
232
|
+
* (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
|
|
233
|
+
* instead — it avoids buffering the whole document as a string.
|
|
234
|
+
*/
|
|
235
|
+
export function useFluxMarkdownString(
|
|
236
|
+
content: string,
|
|
237
|
+
options?: { config?: ParserConfig; streaming?: boolean },
|
|
238
|
+
): FluxClient {
|
|
239
|
+
const [client] = useState(() => new FluxClient({ config: options?.config }));
|
|
240
|
+
|
|
241
|
+
// Own the client's pool attachment (StrictMode dev double-mount destroys on the
|
|
242
|
+
// simulated unmount then remounts the SAME instance; reattach re-registers and
|
|
243
|
+
// clears setContent's diff baseline so the document is re-fed). Destroy on the
|
|
244
|
+
// real unmount.
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
client.reattach();
|
|
247
|
+
return () => client.destroy();
|
|
248
|
+
}, [client]);
|
|
249
|
+
|
|
250
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
251
|
+
// so this stays correct whether `content` grows by a token or is swapped wholesale.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
client.setContent(content, { done: options?.streaming === false });
|
|
254
|
+
}, [client, content, options?.streaming]);
|
|
255
|
+
|
|
256
|
+
return client;
|
|
257
|
+
}
|
|
258
|
+
|
|
212
259
|
// Stream mode: own a client via the hook, then render the normal client path.
|
|
213
260
|
function FluxMarkdownFromStream(props: FluxMarkdownProps) {
|
|
214
261
|
const client = useFluxStream(props.stream, {
|
|
@@ -261,7 +308,7 @@ function decodeMathText(html: string): string {
|
|
|
261
308
|
return decodeCodeText(html);
|
|
262
309
|
}
|
|
263
310
|
|
|
264
|
-
function blockKindProps(block: Block): BlockComponentProps {
|
|
311
|
+
export function blockKindProps(block: Block, components?: Components): BlockComponentProps {
|
|
265
312
|
const props: BlockComponentProps = {
|
|
266
313
|
block,
|
|
267
314
|
html: block.html,
|
|
@@ -296,6 +343,10 @@ function blockKindProps(block: Block): BlockComponentProps {
|
|
|
296
343
|
// An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
|
|
297
344
|
// (markdown already rendered) rather than the full wrapped block.
|
|
298
345
|
props.html = componentInnerHtml(block.html, props.tag);
|
|
346
|
+
// Convenience: the inner markdown pre-parsed to a React tree (with nested
|
|
347
|
+
// tag/inline-component overrides applied). Render `{children}` directly
|
|
348
|
+
// instead of dangerouslySetInnerHTML-ing `html` — the easy, correct path.
|
|
349
|
+
props.children = htmlToReact(props.html, components ?? {});
|
|
299
350
|
} else if (block.kind.type === "Table") {
|
|
300
351
|
// Pure structured data (present only when `blockData` is on) — unlike
|
|
301
352
|
// `attrs` there is no React/DOM name-form divergence, so this is the same
|
|
@@ -336,7 +387,10 @@ function componentInnerHtml(html: string, tag: string): string {
|
|
|
336
387
|
|
|
337
388
|
/** Convert a closed block's HTML to a React tree, memoized on html+components. */
|
|
338
389
|
function SafeHtml({ html, components }: { html: string; components: Components }) {
|
|
339
|
-
|
|
390
|
+
// `ReactElement` (not the global `JSX.Element`) so the source type-checks under
|
|
391
|
+
// both @types/react 18 and 19 — React 19 removed the global `JSX` namespace,
|
|
392
|
+
// and a consumer's `next build` type-checks this shipped source.
|
|
393
|
+
return useMemo(() => htmlToReact(html, components), [html, components]) as ReactElement;
|
|
340
394
|
}
|
|
341
395
|
|
|
342
396
|
// Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
|
|
@@ -390,12 +444,12 @@ function renderBlockContent({
|
|
|
390
444
|
const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
|
|
391
445
|
const override = (tag && components[tag]) || components.Component;
|
|
392
446
|
if (override) {
|
|
393
|
-
return createElement(override, blockKindProps(block));
|
|
447
|
+
return createElement(override, blockKindProps(block, components));
|
|
394
448
|
}
|
|
395
449
|
}
|
|
396
450
|
const blockOverride = components[kind];
|
|
397
451
|
if (blockOverride) {
|
|
398
|
-
return createElement(blockOverride, blockKindProps(block));
|
|
452
|
+
return createElement(blockOverride, blockKindProps(block, components));
|
|
399
453
|
}
|
|
400
454
|
}
|
|
401
455
|
|
|
@@ -419,12 +473,20 @@ function renderBlockContent({
|
|
|
419
473
|
(block.open ? " flux-open" : "") +
|
|
420
474
|
(block.speculative ? " flux-speculative" : "");
|
|
421
475
|
|
|
422
|
-
// Tag-level overrides
|
|
423
|
-
//
|
|
424
|
-
|
|
476
|
+
// Tag-level / inline overrides apply to OPEN and speculative blocks too, not
|
|
477
|
+
// just settled ones: the streaming tail's HTML is always well-formed (the
|
|
478
|
+
// parser speculatively closes it), so a design-system renderer (Tailwind
|
|
479
|
+
// classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream
|
|
480
|
+
// instead of only after a block commits. A supplied `sanitize` runs FIRST
|
|
481
|
+
// (same as the innerHTML path below), so overrides compose with sanitization on
|
|
482
|
+
// every block — closing the gap where a component-rendered block previously
|
|
483
|
+
// bypassed the user sanitizer. The no-`components` fast path is untouched
|
|
484
|
+
// (byte-identical innerHTML).
|
|
485
|
+
if (components) {
|
|
486
|
+
const safe = sanitize ? sanitize(block.html) : block.html;
|
|
425
487
|
return (
|
|
426
488
|
<div className={className}>
|
|
427
|
-
<SafeHtml html={
|
|
489
|
+
<SafeHtml html={safe} components={components} />
|
|
428
490
|
</div>
|
|
429
491
|
);
|
|
430
492
|
}
|
package/src/server.tsx
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from "react";
|
|
2
|
+
import initWasmAsync, { FluxParser, initSync } from "./wasm/flux_md_core.js";
|
|
3
|
+
import { htmlToReact } from "./html-to-react";
|
|
4
|
+
import { blockKindProps } from "./react";
|
|
5
|
+
import type { Block, Components, ParserConfig } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Synchronous, worker-free server / static rendering for flux-md.
|
|
9
|
+
*
|
|
10
|
+
* The browser path runs the Rust→WASM core in a Web Worker, but the very same
|
|
11
|
+
* `FluxParser` is a plain synchronous class — so on the server (Node, RSC, a
|
|
12
|
+
* build step) you can parse a finished markdown string with no worker and no
|
|
13
|
+
* async ceremony:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { initFlux, renderToString } from "flux-md/server";
|
|
17
|
+
* await initFlux(); // once, at startup
|
|
18
|
+
* const html = renderToString(markdown); // sync, no worker
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* For React server rendering (RSC, static generation, SSR) use {@link
|
|
22
|
+
* FluxMarkdownStatic} — a hookless, RSC-safe component with the same `components`
|
|
23
|
+
* overrides. It targets **render-once** contexts; the streaming, interactive
|
|
24
|
+
* `<FluxMarkdown>` (client-side code highlighting, Mermaid, live updates) is a
|
|
25
|
+
* separate component. If you SSR-then-hydrate, use the *same* component on both
|
|
26
|
+
* sides.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
let ready = false;
|
|
30
|
+
|
|
31
|
+
/** Has the sync WASM core been initialized in this process? */
|
|
32
|
+
export function isFluxReady(): boolean {
|
|
33
|
+
return ready;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Initialize the sync core from compiled WASM bytes (or a `WebAssembly.Module`).
|
|
37
|
+
* Idempotent. Use on runtimes without a filesystem (edge) or to control exactly
|
|
38
|
+
* when init happens; otherwise {@link initFlux} auto-loads the co-located WASM. */
|
|
39
|
+
export function initFluxSync(wasm: BufferSource | WebAssembly.Module): void {
|
|
40
|
+
if (ready) return;
|
|
41
|
+
initSync({ module: wasm });
|
|
42
|
+
ready = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let initPromise: Promise<void> | null = null;
|
|
46
|
+
|
|
47
|
+
/** Initialize the sync core once. In Node it reads the package's co-located
|
|
48
|
+
* `.wasm` off disk (Node's `fetch` can't load `file://`); on the web it fetches
|
|
49
|
+
* the bundler-resolved asset URL. Pass `{ wasm }` to supply bytes yourself
|
|
50
|
+
* (edge runtimes). Safe to call repeatedly / concurrently. */
|
|
51
|
+
export function initFlux(opts?: { wasm?: BufferSource | WebAssembly.Module }): Promise<void> {
|
|
52
|
+
if (ready) return Promise.resolve();
|
|
53
|
+
if (opts?.wasm) {
|
|
54
|
+
initFluxSync(opts.wasm);
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
}
|
|
57
|
+
if (!initPromise) {
|
|
58
|
+
initPromise = (async () => {
|
|
59
|
+
const wasmUrl = new URL("./wasm/flux_md_core_bg.wasm", import.meta.url);
|
|
60
|
+
if (wasmUrl.protocol === "file:") {
|
|
61
|
+
// Node: read the bytes (Node's fetch can't load file://). A non-literal
|
|
62
|
+
// specifier keeps `node:fs` out of web bundles and off tsc's module graph
|
|
63
|
+
// (no @types/node needed to compile this source).
|
|
64
|
+
const nodeFs = "node:fs/promises";
|
|
65
|
+
const { readFile } = await import(nodeFs);
|
|
66
|
+
initFluxSync(await readFile(wasmUrl));
|
|
67
|
+
} else {
|
|
68
|
+
await initWasmAsync({ module_or_path: wasmUrl });
|
|
69
|
+
ready = true;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
return initPromise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Configure a one-shot parser exactly as the worker does, so server output is
|
|
77
|
+
// byte-identical to the streamed/browser output (defaults: autolinks + alerts
|
|
78
|
+
// on, raw HTML escaped, footnotes/math off).
|
|
79
|
+
function makeParser(config?: ParserConfig): FluxParser {
|
|
80
|
+
const p = new FluxParser();
|
|
81
|
+
p.setGfmAutolinks(config?.gfmAutolinks ?? true);
|
|
82
|
+
p.setGfmAlerts(config?.gfmAlerts ?? true);
|
|
83
|
+
p.setGfmFootnotes(config?.gfmFootnotes ?? false);
|
|
84
|
+
p.setGfmMath(config?.gfmMath ?? false);
|
|
85
|
+
p.setDirAuto(config?.dirAuto ?? false);
|
|
86
|
+
p.setA11y(config?.a11y ?? false);
|
|
87
|
+
p.setUnsafeHtml(config?.unsafeHtml ?? false);
|
|
88
|
+
p.setComponentTags(config?.componentTags ?? []);
|
|
89
|
+
p.setInlineComponentTags(config?.inlineComponentTags ?? []);
|
|
90
|
+
p.setBlockData(config?.blockData ?? false);
|
|
91
|
+
return p;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function requireReady(): void {
|
|
95
|
+
if (!ready) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"flux-md/server: WASM not initialized. Call `await initFlux()` (or `initFluxSync(bytes)`) once before rendering.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse a complete markdown string to its block array synchronously (committed +
|
|
104
|
+
* any trailing block, in document order). Requires {@link initFlux} to have run.
|
|
105
|
+
*/
|
|
106
|
+
export function parseToBlocks(markdown: string, opts?: { config?: ParserConfig }): Block[] {
|
|
107
|
+
requireReady();
|
|
108
|
+
const p = makeParser(opts?.config);
|
|
109
|
+
try {
|
|
110
|
+
p.append(markdown);
|
|
111
|
+
p.finalize();
|
|
112
|
+
return p.allBlocks() as Block[];
|
|
113
|
+
} finally {
|
|
114
|
+
p.free();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a complete markdown string to an HTML string synchronously — no worker,
|
|
120
|
+
* no React. The concatenated per-block HTML (XSS-safe with `unsafeHtml` off).
|
|
121
|
+
* For component dispatch / a `<FluxMarkdown>`-matching React tree, use
|
|
122
|
+
* {@link FluxMarkdownStatic} with your framework's server renderer instead.
|
|
123
|
+
*/
|
|
124
|
+
export function renderToString(markdown: string, opts?: { config?: ParserConfig }): string {
|
|
125
|
+
return parseToBlocks(markdown, opts)
|
|
126
|
+
.map((b) => b.html)
|
|
127
|
+
.join("");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Hookless block renderer (RSC-safe): mirrors the client renderer's dispatch
|
|
131
|
+
// (block-kind overrides, a Component block dispatched by tag, tag-level overrides
|
|
132
|
+
// via htmlToReact) but uses no hooks and skips the client-only interactive
|
|
133
|
+
// renderers (Mermaid; client-side code highlighting) — those activate on the
|
|
134
|
+
// client after hydration. Kept in step with react.tsx's renderBlockContent.
|
|
135
|
+
function renderStaticBlock(block: Block, components?: Components): ReactNode {
|
|
136
|
+
const kind = block.kind.type;
|
|
137
|
+
if (components) {
|
|
138
|
+
if (kind === "Component") {
|
|
139
|
+
const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
|
|
140
|
+
const override = (tag && components[tag]) || components.Component;
|
|
141
|
+
if (override) return createElement(override, { key: block.id, ...blockKindProps(block, components) });
|
|
142
|
+
}
|
|
143
|
+
const blockOverride = components[kind];
|
|
144
|
+
if (blockOverride) return createElement(blockOverride, { key: block.id, ...blockKindProps(block, components) });
|
|
145
|
+
}
|
|
146
|
+
const className =
|
|
147
|
+
"flux-block flux-block-" +
|
|
148
|
+
kind.toLowerCase() +
|
|
149
|
+
(block.open ? " flux-open" : "") +
|
|
150
|
+
(block.speculative ? " flux-speculative" : "");
|
|
151
|
+
if (components) {
|
|
152
|
+
return createElement("div", { key: block.id, className }, htmlToReact(block.html, components));
|
|
153
|
+
}
|
|
154
|
+
return createElement("div", { key: block.id, className, dangerouslySetInnerHTML: { __html: block.html } });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface FluxMarkdownStaticProps {
|
|
158
|
+
/** The complete markdown to render (server/static use is for finished content). */
|
|
159
|
+
content: string;
|
|
160
|
+
/** Parser config (same shape as the streaming client's). */
|
|
161
|
+
config?: ParserConfig;
|
|
162
|
+
/** Tag-level / block-kind / component-tag overrides (see {@link Components}). */
|
|
163
|
+
components?: Components;
|
|
164
|
+
/** Appended to the root's `className` (the `flux-md` class is always present). */
|
|
165
|
+
className?: string;
|
|
166
|
+
/** Set on the root element. */
|
|
167
|
+
id?: string;
|
|
168
|
+
/** Set on the root element (e.g. `"article"`). */
|
|
169
|
+
role?: string;
|
|
170
|
+
/** Make the root a live region (parity with `<FluxMarkdown>` if you hydrate). */
|
|
171
|
+
"aria-live"?: "off" | "polite" | "assertive";
|
|
172
|
+
/** Live-region atomicity; pair with `aria-live`. */
|
|
173
|
+
"aria-atomic"?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Synchronous, worker-free React rendering of finished markdown — a React Server
|
|
178
|
+
* Component, or any one-shot SSR / static render. Emits the `flux-md` root +
|
|
179
|
+
* per-block structure with the same `components` overrides (inline/block
|
|
180
|
+
* component tags dispatch here too). Requires {@link initFlux} (or
|
|
181
|
+
* {@link initFluxSync}) to have run. Uses no hooks (RSC-safe). A **render-once**
|
|
182
|
+
* component: for live streaming, client-side code highlighting, or Mermaid use
|
|
183
|
+
* the client `<FluxMarkdown>` instead (and if you SSR-then-hydrate, render the
|
|
184
|
+
* *same* component on both sides).
|
|
185
|
+
*/
|
|
186
|
+
export function FluxMarkdownStatic({
|
|
187
|
+
content,
|
|
188
|
+
config,
|
|
189
|
+
components,
|
|
190
|
+
className,
|
|
191
|
+
id,
|
|
192
|
+
role,
|
|
193
|
+
"aria-live": ariaLive,
|
|
194
|
+
"aria-atomic": ariaAtomic,
|
|
195
|
+
}: FluxMarkdownStaticProps): ReactNode {
|
|
196
|
+
const blocks = parseToBlocks(content, { config });
|
|
197
|
+
const comps = components && Object.keys(components).length > 0 ? components : undefined;
|
|
198
|
+
return createElement(
|
|
199
|
+
"div",
|
|
200
|
+
{
|
|
201
|
+
className: className ? `flux-md ${className}` : "flux-md",
|
|
202
|
+
id,
|
|
203
|
+
role,
|
|
204
|
+
"aria-live": ariaLive,
|
|
205
|
+
"aria-atomic": ariaAtomic,
|
|
206
|
+
},
|
|
207
|
+
blocks.map((b) => renderStaticBlock(b, comps)),
|
|
208
|
+
);
|
|
209
|
+
}
|
package/src/solid.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { onCleanup, onMount, type JSX } from "solid-js";
|
|
2
|
-
import
|
|
1
|
+
import { createEffect, onCleanup, onMount, type JSX } from "solid-js";
|
|
2
|
+
import { FluxClient } from "./client";
|
|
3
|
+
import type { ParserConfig } from "./types-core";
|
|
3
4
|
import { mountFluxMarkdown, type MountHandle, type MountOptions } from "./dom";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -73,3 +74,72 @@ export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
|
|
|
73
74
|
onMount(() => mountSolid(() => props, container, onCleanup));
|
|
74
75
|
return container;
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wire a controlled string to a freshly-constructed {@link FluxClient}, free of
|
|
80
|
+
* Solid's reactive runtime so it runs (and is tested) under any toolchain. The
|
|
81
|
+
* registrars are injected: the public {@link createFluxMarkdownString} passes
|
|
82
|
+
* Solid's real `createEffect` / `onCleanup`; tests pass hand-rolled stand-ins
|
|
83
|
+
* (mirroring how {@link mountSolid} takes `registerCleanup`).
|
|
84
|
+
*
|
|
85
|
+
* Ownership DIFFERS from {@link mountSolid}: this constructs the client and so
|
|
86
|
+
* `registerCleanup`s `client.destroy()` — it OWNS the worker/stream. `config` is
|
|
87
|
+
* read ONCE here (the constructor treats it as immutable); `getContent()` and
|
|
88
|
+
* `streaming` are read INSIDE the effect so the effect tracks them reactively.
|
|
89
|
+
*/
|
|
90
|
+
export function setupFluxMarkdownString(
|
|
91
|
+
getContent: () => string,
|
|
92
|
+
getOptions: (() => { config?: ParserConfig; streaming?: boolean }) | undefined,
|
|
93
|
+
registerEffect: (fn: () => void) => void,
|
|
94
|
+
registerCleanup: (fn: () => void) => void,
|
|
95
|
+
): FluxClient {
|
|
96
|
+
// One client per helper instance. Constructor is worker-free → SSR-safe; the
|
|
97
|
+
// worker is spawned lazily by the first setContent → append, which only runs
|
|
98
|
+
// inside the effect below. config is read once and is immutable thereafter.
|
|
99
|
+
const client = new FluxClient({ config: getOptions?.()?.config });
|
|
100
|
+
|
|
101
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
102
|
+
// so this is correct whether `content` grows by a token or is swapped wholesale.
|
|
103
|
+
// `streaming === false` (never `!streaming`) → only an explicit false finalizes;
|
|
104
|
+
// an absent/true flag leaves the stream open (inferring "done" from an absent
|
|
105
|
+
// flag would re-finalize on every token — an O(n²) reparse trap).
|
|
106
|
+
registerEffect(() => {
|
|
107
|
+
client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// This helper OWNS the client (unlike the client-based bindings above), so it
|
|
111
|
+
// destroys it on cleanup — freeing its pool slot.
|
|
112
|
+
registerCleanup(() => client.destroy());
|
|
113
|
+
|
|
114
|
+
return client;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the Solid
|
|
119
|
+
* analogue of React's `useFluxMarkdownString`, for UIs that hold a streaming
|
|
120
|
+
* message as a single growing string (a signal/memo) rather than as a stream.
|
|
121
|
+
* Pass an accessor for the whole document-so-far; on every change
|
|
122
|
+
* {@link FluxClient.setContent} diffs it and does the minimal work (a
|
|
123
|
+
* prefix-extension appends only the delta; any divergence resets and reparses).
|
|
124
|
+
*
|
|
125
|
+
* Pass `streaming: false` (via `getOptions`) once the content is final to
|
|
126
|
+
* finalize the stream and commit its last block (only then does a finished code
|
|
127
|
+
* fence highlight + show its copy button). If `streaming` is omitted or `true`
|
|
128
|
+
* the stream is left OPEN. `config` is read once at construction and is
|
|
129
|
+
* immutable, so it is not a change trigger.
|
|
130
|
+
*
|
|
131
|
+
* **Returns the owned client** — pass it to `<FluxMarkdown client={client} />`
|
|
132
|
+
* (and read `outline()` / `getMetrics()` off it). The client is constructed in
|
|
133
|
+
* the body (constructor is worker-free → SSR-safe) and destroyed on cleanup.
|
|
134
|
+
*
|
|
135
|
+
* SSR-safety: `setContent` is what spawns a Worker (via `append`), so it runs
|
|
136
|
+
* ONLY inside a `createEffect` — Solid does not run user effects during
|
|
137
|
+
* `renderToString`, so nothing touches a Worker on the server render path (the
|
|
138
|
+
* body only constructs the worker-free client).
|
|
139
|
+
*/
|
|
140
|
+
export function createFluxMarkdownString(
|
|
141
|
+
getContent: () => string,
|
|
142
|
+
getOptions?: () => { config?: ParserConfig; streaming?: boolean },
|
|
143
|
+
): FluxClient {
|
|
144
|
+
return setupFluxMarkdownString(getContent, getOptions, createEffect, onCleanup);
|
|
145
|
+
}
|
package/src/svelte.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ActionReturn } from "svelte/action";
|
|
2
|
-
import
|
|
2
|
+
import { FluxClient } from "./client";
|
|
3
|
+
import type { ParserConfig } from "./types-core";
|
|
3
4
|
import { mountFluxMarkdown, type DomComponents, type MountOptions } from "./dom";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -53,3 +54,101 @@ export function fluxMarkdown(
|
|
|
53
54
|
},
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Controlled-string sibling of {@link fluxMarkdown}: instead of taking a
|
|
60
|
+
* caller-owned client, this action OWNS a single {@link FluxClient} (constructed
|
|
61
|
+
* from `config`) and drives it from a CONTROLLED full string — the bridge for
|
|
62
|
+
* Svelte UIs that hold a streaming message as one growing `content` prop rather
|
|
63
|
+
* than feeding the client by hand. Each update passes the whole document-so-far
|
|
64
|
+
* and {@link FluxClient.setContent} diffs it: a prefix-extension appends only the
|
|
65
|
+
* delta; any divergence resets and reparses.
|
|
66
|
+
*
|
|
67
|
+
* ```svelte
|
|
68
|
+
* <div use:fluxMarkdownString={{ content, streaming: !done }} />
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* Pass `streaming: false` once the content is final to finalize the stream and
|
|
72
|
+
* commit its last block (only then does a finished code fence highlight + show
|
|
73
|
+
* its copy button). When `streaming` is omitted or `true` the stream is left
|
|
74
|
+
* OPEN — right for a still-growing string, but a *complete static* string keeps
|
|
75
|
+
* its last block in the streaming state until you pass `{ streaming: false }`.
|
|
76
|
+
* (Inferring "done" from an absent flag is deliberately avoided — it would
|
|
77
|
+
* re-finalize on every token and trip an O(n²) reparse.)
|
|
78
|
+
*
|
|
79
|
+
* SSR-safe by construction: a Svelte action runs ONLY in the browser, and the
|
|
80
|
+
* `FluxClient` constructor is worker-free — the first worker is spawned lazily by
|
|
81
|
+
* `setContent`, which only runs here (never during a server render).
|
|
82
|
+
*
|
|
83
|
+
* Lifecycle differs from {@link fluxMarkdown}: this action constructs the client
|
|
84
|
+
* once (a later `config` change is ignored, like a created-once instance) and
|
|
85
|
+
* `destroy()`s it on teardown — it OWNS the client. The mount-option reconcile
|
|
86
|
+
* (`components`/`sanitize`/`virtualize`/`stickToBottom`) matches `fluxMarkdown`,
|
|
87
|
+
* but the remount reuses the SAME client so its `setContent` diff baseline
|
|
88
|
+
* survives.
|
|
89
|
+
*/
|
|
90
|
+
export interface FluxMarkdownStringParams extends Omit<FluxMarkdownParams, "client"> {
|
|
91
|
+
/** The full document-so-far. Diffed against the prior value on every update. */
|
|
92
|
+
content: string;
|
|
93
|
+
/** Leave the stream open while true/omitted; `false` finalizes (commits the tail). */
|
|
94
|
+
streaming?: boolean;
|
|
95
|
+
/** Per-stream parser flags. Applied once at construction; later changes are ignored. */
|
|
96
|
+
config?: ParserConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Strip the action-only inputs (`content`/`streaming`/`config`), leaving the
|
|
100
|
+
* fields {@link mountFluxMarkdown} reads — so they never leak into the mount. */
|
|
101
|
+
function mountOptionsOf(p: FluxMarkdownStringParams): Omit<FluxMarkdownParams, "client"> {
|
|
102
|
+
const { content: _c, streaming: _s, config: _cfg, ...rest } = p;
|
|
103
|
+
void _c;
|
|
104
|
+
void _s;
|
|
105
|
+
void _cfg;
|
|
106
|
+
return rest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function fluxMarkdownString(
|
|
110
|
+
node: HTMLElement,
|
|
111
|
+
params: FluxMarkdownStringParams,
|
|
112
|
+
): ActionReturn<FluxMarkdownStringParams> {
|
|
113
|
+
// This action OWNS the client — construct it once from `config` (a later
|
|
114
|
+
// `config` change is ignored, mirroring the created-once React hook). The
|
|
115
|
+
// content/streaming diff baseline lives INSIDE the client (setContent), so we
|
|
116
|
+
// keep no outer copy; only the mount-option fields are tracked for the remount
|
|
117
|
+
// comparison.
|
|
118
|
+
let options = mountOptionsOf(params);
|
|
119
|
+
const client = new FluxClient({ config: params.config });
|
|
120
|
+
let handle = mountFluxMarkdown(client, node, options as MountOptions);
|
|
121
|
+
// First worker-bound op: spawns the lazy Worker — browser-only, never SSR.
|
|
122
|
+
client.setContent(params.content, { done: params.streaming === false });
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
update(next: FluxMarkdownStringParams) {
|
|
126
|
+
// Content/streaming are the primary changing inputs, so reconcile them on
|
|
127
|
+
// EVERY update — setContent self-no-ops when the string is unchanged, so
|
|
128
|
+
// this is cheap. (Unlike fluxMarkdown, we cannot early-return: that would
|
|
129
|
+
// swallow content updates.)
|
|
130
|
+
client.setContent(next.content, { done: next.streaming === false });
|
|
131
|
+
|
|
132
|
+
// Then reconcile mount options exactly like fluxMarkdown: remount only when
|
|
133
|
+
// a field the renderer reads actually changed identity, and reuse the SAME
|
|
134
|
+
// client so its setContent diff baseline (lastContent) survives the remount.
|
|
135
|
+
if (
|
|
136
|
+
next.components === options.components &&
|
|
137
|
+
next.sanitize === options.sanitize &&
|
|
138
|
+
next.virtualize === options.virtualize &&
|
|
139
|
+
next.stickToBottom === options.stickToBottom
|
|
140
|
+
) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
handle.destroy();
|
|
144
|
+
options = mountOptionsOf(next);
|
|
145
|
+
handle = mountFluxMarkdown(client, node, options as MountOptions);
|
|
146
|
+
},
|
|
147
|
+
destroy() {
|
|
148
|
+
// This action OWNS the client (unlike fluxMarkdown) — tear down the mount
|
|
149
|
+
// AND destroy the client so its pool slot is freed.
|
|
150
|
+
handle.destroy();
|
|
151
|
+
client.destroy();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/types-core.ts
CHANGED
|
@@ -116,8 +116,25 @@ export interface Patch {
|
|
|
116
116
|
export interface BlockComponentProps {
|
|
117
117
|
/** The full parsed block, including `kind` (with `kind.data`) and offsets. */
|
|
118
118
|
block: Block;
|
|
119
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Rendered, XSS-safe HTML for this block. For `Component` blocks this is the
|
|
121
|
+
* **inner** rendered-markdown HTML (not the `<tag>…</tag>` wrapper). NOTE: a
|
|
122
|
+
* `Component` override that ignores both `html` and `children` renders empty —
|
|
123
|
+
* use {@link children} (the easy path) or `dangerouslySetInnerHTML={{__html:
|
|
124
|
+
* html}}`.
|
|
125
|
+
*/
|
|
120
126
|
html: string;
|
|
127
|
+
/**
|
|
128
|
+
* React only: this block's inner content already parsed to a React node tree
|
|
129
|
+
* (markdown rendered, nested tag/inline-component overrides applied). For a
|
|
130
|
+
* `Component` block it is the inner markdown — render it directly
|
|
131
|
+
* (`return <Chip {...attrs}>{children}</Chip>`) instead of dangerously setting
|
|
132
|
+
* `html`. Populated by `<FluxMarkdown>` / `<FluxMarkdownStatic>` when a
|
|
133
|
+
* `components` map is supplied; DOM and other bindings leave it `undefined`
|
|
134
|
+
* (they consume `html`). Typed `unknown` to keep this surface framework-neutral
|
|
135
|
+
* — cast to `ReactNode` in a React override.
|
|
136
|
+
*/
|
|
137
|
+
children?: unknown;
|
|
121
138
|
/** True while the block is still streaming (its HTML may still change). */
|
|
122
139
|
open: boolean;
|
|
123
140
|
/** True if the block was closed speculatively and may yet be revised. */
|
|
@@ -229,6 +246,21 @@ export interface ParserConfig {
|
|
|
229
246
|
* off. Names match case-sensitively.
|
|
230
247
|
*/
|
|
231
248
|
componentTags?: string[];
|
|
249
|
+
/**
|
|
250
|
+
* Opt-in allowlist of INLINE component tag names (e.g. `["tik", "cite"]`). An
|
|
251
|
+
* allowlisted `<tik>…</tik>` (or self-closing `<tik/>`) anywhere in inline
|
|
252
|
+
* content — paragraphs, headings, table cells, list items — renders as a real
|
|
253
|
+
* custom element with **markdown** inner content and sanitized attributes
|
|
254
|
+
* (event handlers dropped, dangerous URL schemes neutralized) — XSS-safe
|
|
255
|
+
* without `unsafeHtml`. The React renderer dispatches it via `components[tag]`,
|
|
256
|
+
* with the inner markdown as the component's `children` and the sanitized
|
|
257
|
+
* attributes as props. Separate from `componentTags` (block containers): list a
|
|
258
|
+
* tag here for inline chips (tickers, citations, @mentions), or in both lists
|
|
259
|
+
* to allow both positions. Names match **case-sensitively** and dispatch
|
|
260
|
+
* verbatim to `components[tag]` (e.g. `"Cite"` → `components.Cite`), same as
|
|
261
|
+
* `componentTags`. Empty/omitted = off.
|
|
262
|
+
*/
|
|
263
|
+
inlineComponentTags?: string[];
|
|
232
264
|
/**
|
|
233
265
|
* Opt-in structured table data. When on, a `Table` block's `kind.data` is
|
|
234
266
|
* populated with `{ headers, rows, aligns }` (each cell `{ text, html }`) so a
|