flux-md 0.13.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 CHANGED
@@ -4,6 +4,57 @@ Notable changes to flux-md. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.14.0 — 2026-06-17
8
+
9
+ ### Added
10
+
11
+ - **Inline custom component tags (`inlineComponentTags`)** — the headline gap for
12
+ rich apps. An allowlisted inline tag like `<tik symbol="AAPL">AAPL</tik>` (or
13
+ self-closing `<tik/>`) **anywhere inline** — paragraphs, headings, list items,
14
+ and **table cells** — renders as a real custom element with its inner parsed as
15
+ **inline markdown** and its attributes sanitized (event handlers dropped,
16
+ dangerous URL schemes → `#`). The React renderer dispatches it to
17
+ `components[tag]` with the inner markdown as `children` and the attributes as
18
+ props — **XSS-safe without `unsafeHtml`**. Independent of `componentTags`
19
+ (block containers): list a tag under either or both. Use lowercase tag names.
20
+ - **`children` on `Component` block overrides** — a `Component` override now also
21
+ receives the inner content pre-parsed to a React tree (`children`), so you can
22
+ `return <Chip {...attrs}>{children}</Chip>` instead of
23
+ `dangerouslySetInnerHTML`-ing `html`. The html-vs-children contract is now loud
24
+ in the types and docs (an override that renders neither shows empty).
25
+ - **`flux-md/server` — worker-free synchronous SSR / RSC rendering.** The Rust→
26
+ WASM core is a plain synchronous parser, so finished markdown renders on the
27
+ server with no worker: `initFlux()` (async, idempotent — reads the co-located
28
+ `.wasm` in Node, or `initFluxSync(bytes)` on edge), `renderToString(md, {
29
+ config })` (sync HTML string, zero React dep), `parseToBlocks(md, { config })`,
30
+ and `<FluxMarkdownStatic content config components />` — a hookless, RSC-safe
31
+ React component that emits the same `flux-md` tree a client `<FluxMarkdown>`
32
+ hydrates, with the same overrides (inline/block component tags dispatch on the
33
+ server too).
34
+ - **`FluxParser.allBlocks()` (WASM)** — returns the whole parsed document as a
35
+ block array, the one-shot render primitive used by `flux-md/server`.
36
+
37
+ ### Fixed
38
+
39
+ - **Data-loss: a block component tag used inline swallowed sibling blocks.** With
40
+ e.g. `componentTags: ["tik"]`, an inline occurrence such as
41
+ `<tik>AAPL</tik> is up.` on a line with following content opened a block
42
+ container that consumed the rest of the document (the paragraph and a following
43
+ table vanished). A block component open tag must now be the **whole line** (only
44
+ trailing whitespace after `>`); otherwise it's treated as inline and degrades
45
+ inertly — it never eats surrounding content.
46
+
47
+ ### Changed
48
+
49
+ - The React HTML→tree converter (`htmlToReact` / `parseTrustedHtml`) now preserves
50
+ a tag's original **case** for component dispatch (so a capitalized inline tag
51
+ like `<Cite>` maps to `components.Cite`); HTML semantics (void elements, `input`,
52
+ close-tag matching) still compare case-insensitively, so standard output is
53
+ unchanged.
54
+
55
+ Feature-off output is byte-identical (CommonMark 652 + GFM floors hold); both
56
+ allowlists are empty by default.
57
+
7
58
  ## 0.13.0 — 2026-06-04
8
59
 
9
60
  ### Added
package/README.md CHANGED
@@ -18,7 +18,10 @@ import.meta.url)`** pattern, so any bundler with asset-module support resolves
18
18
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
19
19
  modules), **Parcel**, and **Next.js** (App Router — Turbopack *and* webpack;
20
20
  **verified on Next.js 16**, see the [Next.js callout](#nextjs) below). It is
21
- **browser-only** (it constructs Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
21
+ The streaming client (`<FluxMarkdown>` / `FluxClient`) is **browser-only** (it
22
+ constructs Web Workers). For **server-side / static rendering of finished
23
+ content** — SSR, React Server Components, build steps — use the worker-free,
24
+ synchronous [`flux-md/server`](#server-side-rendering) entry. The framework packages — `react`,
22
25
  `vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
23
26
  need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
24
27
  `flux-md/dom`, `flux-md/element`) needs none.
@@ -403,6 +406,55 @@ issue if your Solid setup trips on it. The component is a thin `ref`'d `<div>`;
403
406
  if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
404
407
  `onMount`/`onCleanup` is the zero-surprise fallback.
405
408
 
409
+ ## Server-side rendering
410
+
411
+ `<FluxMarkdown>` / `FluxClient` are browser-only (they spawn a Web Worker), but
412
+ the Rust→WASM core is a plain **synchronous** parser. So `flux-md/server` renders
413
+ **finished** markdown on the server with no worker and no async ceremony — Node
414
+ SSR, React Server Components, or a build step:
415
+
416
+ ```ts
417
+ import { initFlux, renderToString } from "flux-md/server";
418
+
419
+ await initFlux(); // once at startup (loads the WASM)
420
+ const html = renderToString("# Hello\n\n**world**"); // sync HTML string, no worker
421
+ ```
422
+
423
+ For React server rendering (RSC, static generation, or SSR), use
424
+ `<FluxMarkdownStatic>` — a hookless, RSC-safe component that renders finished
425
+ content with the same `components` overrides (inline/block component tags
426
+ dispatch on the server too):
427
+
428
+ ```tsx
429
+ import { initFlux, FluxMarkdownStatic } from "flux-md/server";
430
+
431
+ await initFlux();
432
+ export default function Doc({ md }: { md: string }) {
433
+ return (
434
+ <FluxMarkdownStatic
435
+ content={md}
436
+ config={{ inlineComponentTags: ["tik"] }}
437
+ components={{ tik: ({ symbol }) => <span className="ticker">{symbol}</span> }}
438
+ />
439
+ );
440
+ }
441
+ ```
442
+
443
+ - **`initFlux()`** — async, idempotent. In Node it reads the package's `.wasm` off
444
+ disk (Node's `fetch` can't load `file://`); on the web it fetches the
445
+ bundler-resolved asset. On edge runtimes pass bytes yourself:
446
+ `initFluxSync(wasmBytes)`.
447
+ - **`renderToString(md, { config })`** — synchronous HTML string, **zero React
448
+ dependency**.
449
+ - **`parseToBlocks(md, { config })`** — the block array, for custom rendering.
450
+ - **`<FluxMarkdownStatic content config components />`** — synchronous React tree
451
+ for **render-once** contexts; render it with your framework's server renderer
452
+ (`renderToStaticMarkup`, RSC, …). For live streaming, client-side code
453
+ highlighting, or Mermaid, render `<FluxMarkdown>` on the client instead — it's a
454
+ separate component. (If you SSR-then-hydrate, use the *same* component on both
455
+ sides; the dedicated client renderers in `<FluxMarkdown>` don't hydrate
456
+ `<FluxMarkdownStatic>`'s plainer markup.)
457
+
406
458
  ## What it does
407
459
 
408
460
  | Concern | flux-md | conventional main-thread renderer |
@@ -428,6 +480,11 @@ highlighter's colors** (without any CSS, `highlight()` renders uncolored). The
428
480
  theme is scoped to `.flux-md`, zero-runtime, and **does not change the rendered
429
481
  HTML** — skip the import and nothing is styled.
430
482
 
483
+ > **Next.js Pages Router:** `flux-md/styles.css` is global CSS, which the Pages
484
+ > Router only allows importing from `pages/_app`. Import it there (App Router and
485
+ > other bundlers can import it from any component). Or skip it and bring your own
486
+ > `.flux-md` styles.
487
+
431
488
  Re-theme by overriding a few CSS variables; it's light by default and switches to
432
489
  dark automatically via `prefers-color-scheme` (force a mode with
433
490
  `class="flux-md flux-dark"` or `flux-light`):
@@ -499,7 +556,8 @@ const client = new FluxClient({
499
556
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
500
557
  a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
501
558
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
502
- componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
559
+ componentTags: ["Thinking", "Callout"], // BLOCK custom tags w/ markdown inside (default none)
560
+ inlineComponentTags: ["tik", "cite"], // INLINE custom tags (chips/citations) w/ markdown inside (default none)
503
561
  blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
504
562
  },
505
563
  });
@@ -527,10 +585,13 @@ When to enable each flag:
527
585
  - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
528
586
  LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
529
587
  similar — see [Security](#security)).
530
- - `componentTags: ["Thinking", …]` — when your LLM emits custom tags like
531
- `<Thinking>…</Thinking>` and you want their inner content parsed as markdown
532
- and dispatched to a React component. Safe without `unsafeHtml` (attributes are
533
- sanitized; allowlisted tags only).
588
+ - `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
589
+ like `<Thinking>…</Thinking>` (on their own line) and you want their inner
590
+ content parsed as markdown and dispatched to a React component. Safe without
591
+ `unsafeHtml` (attributes are sanitized; allowlisted tags only).
592
+ - `inlineComponentTags: ["tik", …]` — same idea for **inline** custom elements
593
+ that sit inside a paragraph, heading, list item, or **table cell** (ticker
594
+ chips, citations, `@mentions`). See [Inline component tags](#inline-component-tags).
534
595
 
535
596
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
536
597
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
@@ -700,29 +761,70 @@ sanitized (event handlers dropped, dangerous URL schemes → `#`).
700
761
 
701
762
  Each renders as a `Component` block. Override it in React by tag name (or with
702
763
  the generic `Component` fallback). The override receives `tag`, the sanitized
703
- `attrs`, and `html` the **inner** (already-rendered markdown) HTML, so you can
704
- wrap it in your own element:
764
+ `attrs`, the inner content as ready-to-render **`children`** (the easy path), and
765
+ also `html` (the inner already-rendered markdown string, for
766
+ `dangerouslySetInnerHTML`):
705
767
 
706
768
  ```tsx
707
769
  <FluxMarkdown
708
770
  client={client}
709
771
  components={{
710
- Thinking: ({ html }) => (
772
+ Thinking: ({ children }) => (
711
773
  <details className="thinking">
712
774
  <summary>Reasoning</summary>
713
- <div dangerouslySetInnerHTML={{ __html: html }} />
775
+ {children}
714
776
  </details>
715
777
  ),
716
778
  }}
717
779
  />
718
780
  ```
719
781
 
720
- With no override, the component renders as `<thinking …>…</thinking>` HTML. The
721
- override's `html` is the inner content only; `attrs` keys are React-form
722
- (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While the
723
- component is still streaming, `html` is the partial inner content and re-renders
724
- as more arrives. Tag names match case-sensitively; the feature is off unless
725
- `componentTags` is set.
782
+ > **`children` vs `html`.** A `Component` override that renders *neither* shows
783
+ > **empty** (a common first-try gotcha). Prefer **`children`** a parsed React
784
+ > tree with nested overrides applied; reach for `dangerouslySetInnerHTML={{ __html:
785
+ > html }}` only when you need the raw string. `attrs` keys are React-form
786
+ > (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While
787
+ > streaming, both reflect the partial inner content and re-render as more arrives.
788
+ > With no override the block renders as `<thinking …>…</thinking>`. Tag names
789
+ > match case-sensitively; off unless `componentTags` is set.
790
+
791
+ <a id="inline-component-tags"></a>
792
+
793
+ #### Inline component tags
794
+
795
+ `componentTags` handles **block** containers (a `<Thinking>` on its own line). For
796
+ **inline** custom elements — ticker chips, citations, `@mentions`, inline tooltips
797
+ that sit *inside* a paragraph, heading, list item, or **table cell** — use
798
+ `inlineComponentTags`:
799
+
800
+ ```tsx
801
+ const client = new FluxClient({ config: { inlineComponentTags: ["tik"] } });
802
+
803
+ <FluxMarkdown
804
+ client={client}
805
+ components={{
806
+ tik: ({ symbol, children }) => <span className="ticker">{children ?? symbol}</span>,
807
+ }}
808
+ />;
809
+ ```
810
+
811
+ Now `Apple <tik symbol="AAPL">AAPL</tik> rose 2%` (or self-closing
812
+ `<tik symbol="AAPL"/>`) dispatches the inline `<tik>` to `components.tik`: its
813
+ inner is parsed as **inline markdown** (the `children`), its attributes become
814
+ props, and it's **safe without `unsafeHtml`** (attributes sanitized, allowlisted
815
+ tags only). It works everywhere inline content does — **including table cells**.
816
+ Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
817
+ (`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
818
+ two lists are independent: list a tag under `componentTags` for blocks,
819
+ `inlineComponentTags` for inline, or both for both. An allowlisted tag used in an
820
+ unsupported position degrades **inertly** (escaped) — it never consumes
821
+ surrounding content.
822
+
823
+ > **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
824
+ > inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
825
+ > override `a` to render a chip when the href scheme matches. It's XSS-safe and
826
+ > renders inline-in-cells too — `inlineComponentTags` simply replaces that
827
+ > workaround with first-class inline elements.
726
828
 
727
829
  ### Types
728
830
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./src/worker.ts", "./src/styles.css"],
@@ -10,6 +10,7 @@
10
10
  ".": "./src/index.ts",
11
11
  "./client": "./src/client.ts",
12
12
  "./react": "./src/react.tsx",
13
+ "./server": "./src/server.tsx",
13
14
  "./dom": "./src/dom.ts",
14
15
  "./element": "./src/element.ts",
15
16
  "./vue": "./src/vue.ts",
@@ -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&#58;` and double-encoded `javascript&amp;#58;`. 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/react.tsx CHANGED
@@ -308,7 +308,7 @@ function decodeMathText(html: string): string {
308
308
  return decodeCodeText(html);
309
309
  }
310
310
 
311
- function blockKindProps(block: Block): BlockComponentProps {
311
+ export function blockKindProps(block: Block, components?: Components): BlockComponentProps {
312
312
  const props: BlockComponentProps = {
313
313
  block,
314
314
  html: block.html,
@@ -343,6 +343,10 @@ function blockKindProps(block: Block): BlockComponentProps {
343
343
  // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
344
344
  // (markdown already rendered) rather than the full wrapped block.
345
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 ?? {});
346
350
  } else if (block.kind.type === "Table") {
347
351
  // Pure structured data (present only when `blockData` is on) — unlike
348
352
  // `attrs` there is no React/DOM name-form divergence, so this is the same
@@ -440,12 +444,12 @@ function renderBlockContent({
440
444
  const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
441
445
  const override = (tag && components[tag]) || components.Component;
442
446
  if (override) {
443
- return createElement(override, blockKindProps(block));
447
+ return createElement(override, blockKindProps(block, components));
444
448
  }
445
449
  }
446
450
  const blockOverride = components[kind];
447
451
  if (blockOverride) {
448
- return createElement(blockOverride, blockKindProps(block));
452
+ return createElement(blockOverride, blockKindProps(block, components));
449
453
  }
450
454
  }
451
455
 
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/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
@@ -4,6 +4,13 @@
4
4
  export class FluxParser {
5
5
  free(): void;
6
6
  [Symbol.dispose](): void;
7
+ /**
8
+ * All blocks currently parsed (committed + active), in document order — the
9
+ * whole rendered document as a JS array of `Block`. The one-shot /
10
+ * server-side render primitive: feed the full markdown via `append`, call
11
+ * `finalize`, then read `allBlocks()` (no worker, no patch accumulation).
12
+ */
13
+ allBlocks(): any;
7
14
  append(chunk: string): any;
8
15
  bufferLen(): number;
9
16
  finalize(): any;
@@ -64,6 +71,14 @@ export class FluxParser {
64
71
  * `<div class="math math-display">` for a KaTeX pass on the JS side.
65
72
  */
66
73
  setGfmMath(on: boolean): void;
74
+ /**
75
+ * Set the opt-in INLINE component-tag allowlist (e.g. `["tik", "cite"]`).
76
+ * An allowlisted inline `<tik>…</tik>` (or self-closing `<tik/>`) renders as
77
+ * a custom element (markdown inner, sanitized attributes) so a JSX/DOM layer
78
+ * can dispatch it via `components[tag]` — in paragraphs, headings, table
79
+ * cells, and list items. Empty by default (inline output unchanged).
80
+ */
81
+ setInlineComponentTags(tags: string[]): void;
67
82
  /**
68
83
  * Enable or disable raw-HTML pass-through. Default off. Do not enable
69
84
  * when rendering untrusted input — bypasses XSS protection.
@@ -76,6 +91,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
76
91
  export interface InitOutput {
77
92
  readonly memory: WebAssembly.Memory;
78
93
  readonly __wbg_fluxparser_free: (a: number, b: number) => void;
94
+ readonly fluxparser_allBlocks: (a: number, b: number) => void;
79
95
  readonly fluxparser_append: (a: number, b: number, c: number, d: number) => void;
80
96
  readonly fluxparser_bufferLen: (a: number) => number;
81
97
  readonly fluxparser_finalize: (a: number, b: number) => void;
@@ -89,6 +105,7 @@ export interface InitOutput {
89
105
  readonly fluxparser_setGfmAutolinks: (a: number, b: number) => void;
90
106
  readonly fluxparser_setGfmFootnotes: (a: number, b: number) => void;
91
107
  readonly fluxparser_setGfmMath: (a: number, b: number) => void;
108
+ readonly fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
92
109
  readonly fluxparser_setUnsafeHtml: (a: number, b: number) => void;
93
110
  readonly __wbindgen_export: (a: number, b: number) => number;
94
111
  readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
@@ -11,6 +11,28 @@ export class FluxParser {
11
11
  const ptr = this.__destroy_into_raw();
12
12
  wasm.__wbg_fluxparser_free(ptr, 0);
13
13
  }
14
+ /**
15
+ * All blocks currently parsed (committed + active), in document order — the
16
+ * whole rendered document as a JS array of `Block`. The one-shot /
17
+ * server-side render primitive: feed the full markdown via `append`, call
18
+ * `finalize`, then read `allBlocks()` (no worker, no patch accumulation).
19
+ * @returns {any}
20
+ */
21
+ allBlocks() {
22
+ try {
23
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
24
+ wasm.fluxparser_allBlocks(retptr, this.__wbg_ptr);
25
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
26
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
27
+ var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
28
+ if (r2) {
29
+ throw takeObject(r1);
30
+ }
31
+ return takeObject(r0);
32
+ } finally {
33
+ wasm.__wbindgen_add_to_stack_pointer(16);
34
+ }
35
+ }
14
36
  /**
15
37
  * @param {string} chunk
16
38
  * @returns {any}
@@ -149,6 +171,19 @@ export class FluxParser {
149
171
  setGfmMath(on) {
150
172
  wasm.fluxparser_setGfmMath(this.__wbg_ptr, on);
151
173
  }
174
+ /**
175
+ * Set the opt-in INLINE component-tag allowlist (e.g. `["tik", "cite"]`).
176
+ * An allowlisted inline `<tik>…</tik>` (or self-closing `<tik/>`) renders as
177
+ * a custom element (markdown inner, sanitized attributes) so a JSX/DOM layer
178
+ * can dispatch it via `components[tag]` — in paragraphs, headings, table
179
+ * cells, and list items. Empty by default (inline output unchanged).
180
+ * @param {string[]} tags
181
+ */
182
+ setInlineComponentTags(tags) {
183
+ const ptr0 = passArrayJsValueToWasm0(tags, wasm.__wbindgen_export);
184
+ const len0 = WASM_VECTOR_LEN;
185
+ wasm.fluxparser_setInlineComponentTags(this.__wbg_ptr, ptr0, len0);
186
+ }
152
187
  /**
153
188
  * Enable or disable raw-HTML pass-through. Default off. Do not enable
154
189
  * when rendering untrusted input — bypasses XSS protection.
Binary file
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable */
3
3
  export const memory: WebAssembly.Memory;
4
4
  export const __wbg_fluxparser_free: (a: number, b: number) => void;
5
+ export const fluxparser_allBlocks: (a: number, b: number) => void;
5
6
  export const fluxparser_append: (a: number, b: number, c: number, d: number) => void;
6
7
  export const fluxparser_bufferLen: (a: number) => number;
7
8
  export const fluxparser_finalize: (a: number, b: number) => void;
@@ -15,6 +16,7 @@ export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
15
16
  export const fluxparser_setGfmAutolinks: (a: number, b: number) => void;
16
17
  export const fluxparser_setGfmFootnotes: (a: number, b: number) => void;
17
18
  export const fluxparser_setGfmMath: (a: number, b: number) => void;
19
+ export const fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
18
20
  export const fluxparser_setUnsafeHtml: (a: number, b: number) => void;
19
21
  export const __wbindgen_export: (a: number, b: number) => number;
20
22
  export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
@@ -2,7 +2,7 @@
2
2
  "name": "flux-md-core",
3
3
  "type": "module",
4
4
  "description": "Incremental, streaming-aware markdown parser with speculative closure",
5
- "version": "0.13.0",
5
+ "version": "0.14.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",
package/src/worker.ts CHANGED
@@ -30,6 +30,7 @@ const core = new WorkerCore({
30
30
  p.setA11y(c?.a11y ?? false);
31
31
  p.setUnsafeHtml(c?.unsafeHtml ?? false);
32
32
  p.setComponentTags(c?.componentTags ?? []);
33
+ p.setInlineComponentTags(c?.inlineComponentTags ?? []);
33
34
  p.setBlockData(c?.blockData ?? false);
34
35
  return p;
35
36
  },