flux-md 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +950 -0
  2. package/README.md +111 -82
  3. package/dist/block-props.d.ts +18 -0
  4. package/dist/block-props.js +75 -0
  5. package/dist/client.d.ts +311 -0
  6. package/{src/client.ts → dist/client.js} +143 -325
  7. package/dist/dom.d.ts +110 -0
  8. package/dist/dom.js +576 -0
  9. package/dist/element.d.ts +20 -0
  10. package/dist/element.js +287 -0
  11. package/dist/hi.d.ts +12 -0
  12. package/{src/hi.ts → dist/hi.js} +42 -74
  13. package/dist/html-to-react.d.ts +40 -0
  14. package/dist/html-to-react.js +344 -0
  15. package/{src/index.ts → dist/index.d.ts} +5 -25
  16. package/dist/index.js +16 -0
  17. package/dist/morph.d.ts +28 -0
  18. package/dist/morph.js +166 -0
  19. package/dist/react.d.ts +204 -0
  20. package/dist/react.js +490 -0
  21. package/dist/renderers/CodeBlock.d.ts +7 -0
  22. package/dist/renderers/CodeBlock.js +75 -0
  23. package/dist/renderers/Math.d.ts +14 -0
  24. package/dist/renderers/Math.js +15 -0
  25. package/dist/renderers/Mermaid.d.ts +13 -0
  26. package/dist/renderers/Mermaid.js +15 -0
  27. package/dist/server-react.d.ts +32 -0
  28. package/dist/server-react.js +48 -0
  29. package/dist/server.d.ts +31 -0
  30. package/dist/server.js +81 -0
  31. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  32. package/dist/solid.js +54 -0
  33. package/dist/svelte.d.ts +80 -0
  34. package/dist/svelte.js +59 -0
  35. package/dist/types-core.d.ts +382 -0
  36. package/dist/types-core.js +0 -0
  37. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  38. package/dist/types-react.js +0 -0
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.js +2 -0
  41. package/dist/vue.d.ts +94 -0
  42. package/dist/vue.js +79 -0
  43. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  44. package/{src → dist}/wasm/flux_md_core.js +50 -55
  45. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  46. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  47. package/dist/worker-core.d.ts +65 -0
  48. package/dist/worker-core.js +154 -0
  49. package/dist/worker.d.ts +1 -0
  50. package/dist/worker.js +48 -0
  51. package/package.json +25 -19
  52. package/src/block-props.ts +0 -141
  53. package/src/dom.ts +0 -946
  54. package/src/element.ts +0 -400
  55. package/src/html-to-react.ts +0 -455
  56. package/src/morph.ts +0 -253
  57. package/src/react.tsx +0 -1020
  58. package/src/renderers/CodeBlock.tsx +0 -116
  59. package/src/renderers/Math.tsx +0 -28
  60. package/src/renderers/Mermaid.tsx +0 -27
  61. package/src/server.tsx +0 -221
  62. package/src/svelte.ts +0 -179
  63. package/src/types-core.ts +0 -398
  64. package/src/types.ts +0 -7
  65. package/src/vue.ts +0 -184
  66. package/src/wasm/flux_md_core_bg.wasm +0 -0
  67. package/src/worker-core.ts +0 -174
  68. package/src/worker.ts +0 -72
  69. /package/{src → dist}/styles.css +0 -0
@@ -1,116 +0,0 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { highlight } from "../hi";
3
- import { extractLang } from "../block-props";
4
-
5
- /**
6
- * Deferred-highlighting code block. Open (streaming) blocks render plain;
7
- * the moment the parser commits the block (open=false), we run our in-house
8
- * tokenizer on the source and swap in highlighted HTML. Highlighting is
9
- * memoized on html identity so closed blocks never re-tokenize.
10
- */
11
-
12
- function decodeText(html: string): string {
13
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
14
- if (!m) return "";
15
- return m[1]
16
- .replace(/&lt;/g, "<")
17
- .replace(/&gt;/g, ">")
18
- .replace(/&quot;/g, '"')
19
- .replace(/&#39;/g, "'")
20
- .replace(/&amp;/g, "&");
21
- }
22
-
23
-
24
- interface Props {
25
- html: string;
26
- open: boolean;
27
- }
28
-
29
- function CodeBlockImpl({ html, open }: Props) {
30
- const lang = extractLang(html) || "text";
31
- // Decode once: highlighter and copy handler share the same source.
32
- const text = useMemo(() => (open ? "" : decodeText(html)), [html, open]);
33
- const highlighted = useMemo(() => {
34
- if (!text) return null;
35
- return highlight(text, lang);
36
- }, [text, lang]);
37
-
38
- const [copied, setCopied] = useState(false);
39
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
40
-
41
- // Reset "Copied" if the block re-opens or its content changes underneath us.
42
- useEffect(() => {
43
- if (open) setCopied(false);
44
- }, [open, html]);
45
-
46
- useEffect(() => {
47
- return () => {
48
- if (timerRef.current !== null) clearTimeout(timerRef.current);
49
- };
50
- }, []);
51
-
52
- const onCopy = useCallback(() => {
53
- const write = (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText)
54
- ? navigator.clipboard.writeText.bind(navigator.clipboard)
55
- : null;
56
- if (!write || !text) return;
57
- write(text).then(
58
- () => {
59
- setCopied(true);
60
- if (timerRef.current !== null) clearTimeout(timerRef.current);
61
- timerRef.current = setTimeout(() => setCopied(false), 1500);
62
- },
63
- // Permission denied / blocked: stay silent, leave button usable.
64
- () => {},
65
- );
66
- }, [text]);
67
-
68
- return (
69
- <div className={"flux-code-block" + (open ? " flux-streaming" : "")}>
70
- <div className="flux-code-header">
71
- <span className="flux-code-lang">{lang}</span>
72
- {open ? (
73
- <span className="flux-code-streaming-pill">streaming</span>
74
- ) : (
75
- <button
76
- type="button"
77
- className="flux-code-copy"
78
- onClick={onCopy}
79
- aria-label={copied ? "Copied" : "Copy code"}
80
- aria-live="polite"
81
- >
82
- {copied ? (
83
- <>
84
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
85
- <path d="M20 6 9 17l-5-5" />
86
- </svg>
87
- <span>Copied</span>
88
- </>
89
- ) : (
90
- <>
91
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
92
- <rect x="9" y="9" width="11" height="11" rx="2" />
93
- <path d="M5 15V5a2 2 0 0 1 2-2h10" />
94
- </svg>
95
- <span>Copy</span>
96
- </>
97
- )}
98
- </button>
99
- )}
100
- </div>
101
- <div className="flux-code-body">
102
- {highlighted ? (
103
- // tabIndex=0 + role/label so keyboard users can scroll long code and
104
- // screen readers announce the region with its language.
105
- <pre tabIndex={0} role="region" aria-label={`${lang} code`}>
106
- <code dangerouslySetInnerHTML={{ __html: highlighted }} />
107
- </pre>
108
- ) : (
109
- <div tabIndex={0} role="region" aria-label={`${lang} code`} dangerouslySetInnerHTML={{ __html: html }} />
110
- )}
111
- </div>
112
- </div>
113
- );
114
- }
115
-
116
- export const CodeBlock = memo(CodeBlockImpl);
@@ -1,28 +0,0 @@
1
- import { memo } from "react";
2
-
3
- /**
4
- * Default math block — emits the LaTeX inside a `<div class="math
5
- * math-display">` (or `<span class="math math-inline">` for inline). flux-md
6
- * stays zero-dep, so it does not ship KaTeX/MathJax: bring your own typesetter
7
- * (run it over the rendered `.math` nodes once a block closes), or override
8
- * this slot via `components.MathBlock` to render the LaTeX yourself.
9
- */
10
-
11
- interface Props {
12
- html: string;
13
- open: boolean;
14
- }
15
-
16
- function MathImpl({ html, open }: Props) {
17
- return (
18
- <div className={"flux-math-block" + (open ? " flux-streaming" : "")}>
19
- <div className="flux-math-header">
20
- <span className="flux-math-lang">math</span>
21
- {open && <span className="flux-code-streaming-pill">streaming</span>}
22
- </div>
23
- <div className="flux-math-body" dangerouslySetInnerHTML={{ __html: html }} />
24
- </div>
25
- );
26
- }
27
-
28
- export const MathBlock = memo(MathImpl);
@@ -1,27 +0,0 @@
1
- import { memo } from "react";
2
-
3
- /**
4
- * Default mermaid block — renders the diagram source verbatim in a code-like
5
- * container. flux-md stays zero-dep and does not ship the Mermaid runtime:
6
- * override this slot via `components.Mermaid` to render to SVG with your own
7
- * Mermaid build (typically `mermaid.run` over the closed-block source text).
8
- */
9
-
10
- interface Props {
11
- html: string;
12
- open: boolean;
13
- }
14
-
15
- function MermaidImpl({ html, open }: Props) {
16
- return (
17
- <div className={"flux-mermaid-block" + (open ? " flux-streaming" : "")}>
18
- <div className="flux-mermaid-header">
19
- <span className="flux-mermaid-lang">mermaid</span>
20
- {open && <span className="flux-code-streaming-pill">streaming</span>}
21
- </div>
22
- <div className="flux-mermaid-body" dangerouslySetInnerHTML={{ __html: html }} />
23
- </div>
24
- );
25
- }
26
-
27
- export const Mermaid = memo(MermaidImpl);
package/src/server.tsx DELETED
@@ -1,221 +0,0 @@
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://). The literal
62
- // `node:` specifier is externalized by bundlers, so node:fs never reaches
63
- // a web bundle (this branch is also file:-only, never true in browsers).
64
- // @ts-ignore — no @types/node in this package; node:fs/promises is a builtin.
65
- const { readFile } = await import("node:fs/promises");
66
- initFluxSync(await readFile(wasmUrl));
67
- } else {
68
- await initWasmAsync({ module_or_path: wasmUrl });
69
- ready = true;
70
- }
71
- })().catch((err) => {
72
- // Drop the cached rejected promise so a transient failure (e.g. a flaky
73
- // .wasm fetch on the web path) can be retried by the next initFlux()
74
- // instead of poisoning every subsequent call until a process restart.
75
- initPromise = null;
76
- throw err;
77
- });
78
- }
79
- return initPromise;
80
- }
81
-
82
- // Configure a one-shot parser exactly as the worker does, so server output is
83
- // byte-identical to the streamed/browser output (defaults: autolinks + alerts
84
- // on, raw HTML escaped, footnotes/math off).
85
- function makeParser(config?: ParserConfig): FluxParser {
86
- const p = new FluxParser();
87
- p.setGfmAutolinks(config?.gfmAutolinks ?? true);
88
- p.setGfmAlerts(config?.gfmAlerts ?? true);
89
- p.setGfmFootnotes(config?.gfmFootnotes ?? false);
90
- p.setGfmMath(config?.gfmMath ?? false);
91
- p.setDirAuto(config?.dirAuto ?? false);
92
- p.setA11y(config?.a11y ?? false);
93
- p.setUnsafeHtml(config?.unsafeHtml ?? false);
94
- p.setComponentTags(config?.componentTags ?? []);
95
- p.setInlineComponentTags(config?.inlineComponentTags ?? []);
96
- // Engage the safe raw-HTML sanitizer when either list is provided (even []).
97
- p.setHtmlSanitize(
98
- config?.htmlAllowlist !== undefined || config?.dropHtmlTags !== undefined,
99
- config?.htmlAllowlist ?? [],
100
- config?.dropHtmlTags ?? [],
101
- );
102
- p.setBlockData(config?.blockData ?? false);
103
- return p;
104
- }
105
-
106
- function requireReady(): void {
107
- if (!ready) {
108
- throw new Error(
109
- "flux-md/server: WASM not initialized. Call `await initFlux()` (or `initFluxSync(bytes)`) once before rendering.",
110
- );
111
- }
112
- }
113
-
114
- /**
115
- * Parse a complete markdown string to its block array synchronously (committed +
116
- * any trailing block, in document order). Requires {@link initFlux} to have run.
117
- */
118
- export function parseToBlocks(markdown: string, opts?: { config?: ParserConfig }): Block[] {
119
- requireReady();
120
- const p = makeParser(opts?.config);
121
- try {
122
- p.append(markdown);
123
- p.finalize();
124
- return p.allBlocks() as Block[];
125
- } finally {
126
- p.free();
127
- }
128
- }
129
-
130
- /**
131
- * Render a complete markdown string to an HTML string synchronously — no worker,
132
- * no React. The concatenated per-block HTML (XSS-safe with `unsafeHtml` off).
133
- * For component dispatch / a `<FluxMarkdown>`-matching React tree, use
134
- * {@link FluxMarkdownStatic} with your framework's server renderer instead.
135
- */
136
- export function renderToString(markdown: string, opts?: { config?: ParserConfig }): string {
137
- return parseToBlocks(markdown, opts)
138
- .map((b) => b.html)
139
- .join("");
140
- }
141
-
142
- // Hookless block renderer (RSC-safe): mirrors the client renderer's dispatch
143
- // (block-kind overrides, a Component block dispatched by tag, tag-level overrides
144
- // via htmlToReact) but uses no hooks and skips the client-only interactive
145
- // renderers (Mermaid; client-side code highlighting) — those activate on the
146
- // client after hydration. Kept in step with react.tsx's renderBlockContent.
147
- function renderStaticBlock(block: Block, components?: Components): ReactNode {
148
- const kind = block.kind.type;
149
- if (components) {
150
- if (kind === "Component") {
151
- const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
152
- const override = (tag && components[tag]) || components.Component;
153
- if (override) return createElement(override, { key: block.id, ...blockKindProps(block, components) });
154
- }
155
- const blockOverride = components[kind];
156
- if (blockOverride) return createElement(blockOverride, { key: block.id, ...blockKindProps(block, components) });
157
- }
158
- const className =
159
- "flux-block flux-block-" +
160
- kind.toLowerCase() +
161
- (block.open ? " flux-open" : "") +
162
- (block.speculative ? " flux-speculative" : "");
163
- if (components) {
164
- return createElement("div", { key: block.id, className }, htmlToReact(block.html, components));
165
- }
166
- return createElement("div", { key: block.id, className, dangerouslySetInnerHTML: { __html: block.html } });
167
- }
168
-
169
- interface FluxMarkdownStaticProps {
170
- /** The complete markdown to render (server/static use is for finished content). */
171
- content: string;
172
- /** Parser config (same shape as the streaming client's). */
173
- config?: ParserConfig;
174
- /** Tag-level / block-kind / component-tag overrides (see {@link Components}). */
175
- components?: Components;
176
- /** Appended to the root's `className` (the `flux-md` class is always present). */
177
- className?: string;
178
- /** Set on the root element. */
179
- id?: string;
180
- /** Set on the root element (e.g. `"article"`). */
181
- role?: string;
182
- /** Make the root a live region (parity with `<FluxMarkdown>` if you hydrate). */
183
- "aria-live"?: "off" | "polite" | "assertive";
184
- /** Live-region atomicity; pair with `aria-live`. */
185
- "aria-atomic"?: boolean;
186
- }
187
-
188
- /**
189
- * Synchronous, worker-free React rendering of finished markdown — a React Server
190
- * Component, or any one-shot SSR / static render. Emits the `flux-md` root +
191
- * per-block structure with the same `components` overrides (inline/block
192
- * component tags dispatch here too). Requires {@link initFlux} (or
193
- * {@link initFluxSync}) to have run. Uses no hooks (RSC-safe). A **render-once**
194
- * component: for live streaming, client-side code highlighting, or Mermaid use
195
- * the client `<FluxMarkdown>` instead (and if you SSR-then-hydrate, render the
196
- * *same* component on both sides).
197
- */
198
- export function FluxMarkdownStatic({
199
- content,
200
- config,
201
- components,
202
- className,
203
- id,
204
- role,
205
- "aria-live": ariaLive,
206
- "aria-atomic": ariaAtomic,
207
- }: FluxMarkdownStaticProps): ReactNode {
208
- const blocks = parseToBlocks(content, { config });
209
- const comps = components && Object.keys(components).length > 0 ? components : undefined;
210
- return createElement(
211
- "div",
212
- {
213
- className: className ? `flux-md ${className}` : "flux-md",
214
- id,
215
- role,
216
- "aria-live": ariaLive,
217
- "aria-atomic": ariaAtomic,
218
- },
219
- blocks.map((b) => renderStaticBlock(b, comps)),
220
- );
221
- }
package/src/svelte.ts DELETED
@@ -1,179 +0,0 @@
1
- import type { ActionReturn } from "svelte/action";
2
- import { readable, type Readable } from "svelte/store";
3
- import { FluxClient } from "./client";
4
- import type { ParserConfig } from "./types-core";
5
- import { mountFluxMarkdown, tailOpenBlockId, type DomComponents, type MountOptions } from "./dom";
6
-
7
- /**
8
- * Svelte action that mounts a streaming {@link FluxClient} into the host node.
9
- * Plain `.ts` — no `.svelte` compile step — so `use:` works unchanged in
10
- * Svelte 4 and 5. The action owns only lifecycle: it mounts on creation and
11
- * tears the mount down on destroy. The caller keeps ownership of the client
12
- * (the worker/stream); the action never calls `client.destroy()`.
13
- *
14
- * ```svelte
15
- * <div use:fluxMarkdown={{ client, stickToBottom: true }} />
16
- * ```
17
- */
18
- export interface FluxMarkdownParams {
19
- client: FluxClient;
20
- components?: DomComponents;
21
- sanitize?: (h: string) => string;
22
- virtualize?: boolean;
23
- stickToBottom?: boolean;
24
- }
25
-
26
- export function fluxMarkdown(
27
- node: HTMLElement,
28
- params: FluxMarkdownParams,
29
- ): ActionReturn<FluxMarkdownParams> {
30
- let { client, ...options } = params;
31
- let handle = mountFluxMarkdown(client, node, options as MountOptions);
32
-
33
- return {
34
- update(next: FluxMarkdownParams) {
35
- // Svelte fires update on every params change, even when nothing the mount
36
- // depends on moved (a fresh object literal with identical field values).
37
- // Remount only when an input the renderer reads actually changed identity;
38
- // otherwise the live mount keeps streaming untouched.
39
- if (
40
- next.client === client &&
41
- next.components === options.components &&
42
- next.sanitize === options.sanitize &&
43
- next.virtualize === options.virtualize &&
44
- next.stickToBottom === options.stickToBottom
45
- ) {
46
- return;
47
- }
48
- handle.destroy();
49
- ({ client, ...options } = next);
50
- handle = mountFluxMarkdown(client, node, options as MountOptions);
51
- },
52
- destroy() {
53
- // Only the mount is torn down. The caller owns the client.
54
- handle.destroy();
55
- },
56
- };
57
- }
58
-
59
- /**
60
- * A fine-grained Svelte `Readable` store of the streaming **tail** block id — the
61
- * one block that may still re-render — driven by the client's own subscribe loop.
62
- * The store sets a new value only when the tail id changes, so a `$tail`
63
- * subscription or `derived(tail, …)` re-evaluates *only* for the tail, never for
64
- * the committed body. Subscribing renders nothing: {@link fluxMarkdown} draws the
65
- * document; this mirrors `MountHandle.openBlockId` through Svelte's primitive for
66
- * any extra tail-scoped work. The client subscription is owned by the store and
67
- * torn down when the last subscriber leaves (Svelte's `readable` stop fn).
68
- *
69
- * ```svelte
70
- * const tail = tailBlockId(client); // $tail is the open block id, or null
71
- * ```
72
- */
73
- export function tailBlockId(client: FluxClient): Readable<number | null> {
74
- return readable<number | null>(tailOpenBlockId(client.getSnapshot()), (set) => {
75
- // `set` is identity-checked by Svelte's store, so pure tail-html growth that
76
- // keeps the same open id never re-fires subscribers. The returned stop fn
77
- // unsubscribes from the client when no one is listening.
78
- const unsubscribe = client.subscribe(() => set(tailOpenBlockId(client.getSnapshot())));
79
- return unsubscribe;
80
- });
81
- }
82
-
83
- /**
84
- * Controlled-string sibling of {@link fluxMarkdown}: instead of taking a
85
- * caller-owned client, this action OWNS a single {@link FluxClient} (constructed
86
- * from `config`) and drives it from a CONTROLLED full string — the bridge for
87
- * Svelte UIs that hold a streaming message as one growing `content` prop rather
88
- * than feeding the client by hand. Each update passes the whole document-so-far
89
- * and {@link FluxClient.setContent} diffs it: a prefix-extension appends only the
90
- * delta; any divergence resets and reparses.
91
- *
92
- * ```svelte
93
- * <div use:fluxMarkdownString={{ content, streaming: !done }} />
94
- * ```
95
- *
96
- * Pass `streaming: false` once the content is final to finalize the stream and
97
- * commit its last block (only then does a finished code fence highlight + show
98
- * its copy button). When `streaming` is omitted or `true` the stream is left
99
- * OPEN — right for a still-growing string, but a *complete static* string keeps
100
- * its last block in the streaming state until you pass `{ streaming: false }`.
101
- * (Inferring "done" from an absent flag is deliberately avoided — it would
102
- * re-finalize on every token and trip an O(n²) reparse.)
103
- *
104
- * SSR-safe by construction: a Svelte action runs ONLY in the browser, and the
105
- * `FluxClient` constructor is worker-free — the first worker is spawned lazily by
106
- * `setContent`, which only runs here (never during a server render).
107
- *
108
- * Lifecycle differs from {@link fluxMarkdown}: this action constructs the client
109
- * once (a later `config` change is ignored, like a created-once instance) and
110
- * `destroy()`s it on teardown — it OWNS the client. The mount-option reconcile
111
- * (`components`/`sanitize`/`virtualize`/`stickToBottom`) matches `fluxMarkdown`,
112
- * but the remount reuses the SAME client so its `setContent` diff baseline
113
- * survives.
114
- */
115
- export interface FluxMarkdownStringParams extends Omit<FluxMarkdownParams, "client"> {
116
- /** The full document-so-far. Diffed against the prior value on every update. */
117
- content: string;
118
- /** Leave the stream open while true/omitted; `false` finalizes (commits the tail). */
119
- streaming?: boolean;
120
- /** Per-stream parser flags. Applied once at construction; later changes are ignored. */
121
- config?: ParserConfig;
122
- }
123
-
124
- /** Strip the action-only inputs (`content`/`streaming`/`config`), leaving the
125
- * fields {@link mountFluxMarkdown} reads — so they never leak into the mount. */
126
- function mountOptionsOf(p: FluxMarkdownStringParams): Omit<FluxMarkdownParams, "client"> {
127
- const { content: _c, streaming: _s, config: _cfg, ...rest } = p;
128
- void _c;
129
- void _s;
130
- void _cfg;
131
- return rest;
132
- }
133
-
134
- export function fluxMarkdownString(
135
- node: HTMLElement,
136
- params: FluxMarkdownStringParams,
137
- ): ActionReturn<FluxMarkdownStringParams> {
138
- // This action OWNS the client — construct it once from `config` (a later
139
- // `config` change is ignored, mirroring the created-once React hook). The
140
- // content/streaming diff baseline lives INSIDE the client (setContent), so we
141
- // keep no outer copy; only the mount-option fields are tracked for the remount
142
- // comparison.
143
- let options = mountOptionsOf(params);
144
- const client = new FluxClient({ config: params.config });
145
- let handle = mountFluxMarkdown(client, node, options as MountOptions);
146
- // First worker-bound op: spawns the lazy Worker — browser-only, never SSR.
147
- client.setContent(params.content, { done: params.streaming === false });
148
-
149
- return {
150
- update(next: FluxMarkdownStringParams) {
151
- // Content/streaming are the primary changing inputs, so reconcile them on
152
- // EVERY update — setContent self-no-ops when the string is unchanged, so
153
- // this is cheap. (Unlike fluxMarkdown, we cannot early-return: that would
154
- // swallow content updates.)
155
- client.setContent(next.content, { done: next.streaming === false });
156
-
157
- // Then reconcile mount options exactly like fluxMarkdown: remount only when
158
- // a field the renderer reads actually changed identity, and reuse the SAME
159
- // client so its setContent diff baseline (lastContent) survives the remount.
160
- if (
161
- next.components === options.components &&
162
- next.sanitize === options.sanitize &&
163
- next.virtualize === options.virtualize &&
164
- next.stickToBottom === options.stickToBottom
165
- ) {
166
- return;
167
- }
168
- handle.destroy();
169
- options = mountOptionsOf(next);
170
- handle = mountFluxMarkdown(client, node, options as MountOptions);
171
- },
172
- destroy() {
173
- // This action OWNS the client (unlike fluxMarkdown) — tear down the mount
174
- // AND destroy the client so its pool slot is freed.
175
- handle.destroy();
176
- client.destroy();
177
- },
178
- };
179
- }