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.
@@ -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 = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
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
- const tag = html.slice(i, j).toLowerCase();
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 tag = html.slice(lt + 2, end === -1 ? html.length : end).trim().toLowerCase();
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 === 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
- return out.length === 1 ? out[0] : out;
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
- return useMemo(() => htmlToReact(html, components), [html, components]) as JSX.Element;
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 only apply to a settled block (open/speculative blocks
423
- // have partial HTML we must not feed to the parser).
424
- if (components && !block.open && !block.speculative) {
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={block.html} components={components} />
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 type { FluxClient } from "./client";
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 type { FluxClient } from "./client";
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
- /** Rendered, XSS-safe HTML for this block. */
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