flux-md 0.13.0 → 0.15.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,95 @@ 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.15.0 — 2026-06-17
8
+
9
+ ### Added
10
+
11
+ - **Safe raw-HTML sanitizer (`htmlAllowlist` / `dropHtmlTags`)** — render a safe
12
+ subset of *inline* raw HTML (`<br>`, `<sub>`, `<sup>`, `<mark>`, …) **without**
13
+ `unsafeHtml`. Setting either list (even to `[]`) engages it: `htmlAllowlist`
14
+ non-empty renders only those tags (others escaped); **empty allows all tags
15
+ except a built-in, non-overridable dangerous set** (`script`, `style`,
16
+ `iframe`, `object`, `embed`, `form`, `svg`, `xmp`, `plaintext`, …);
17
+ `dropHtmlTags` removes tags entirely. Every rendered tag's attributes are
18
+ sanitized — `on*` handlers and `style` (a CSS beacon / clickjacking vector)
19
+ dropped, dangerous URL schemes (incl. multi-encoded) → `#`. Inline-scoped;
20
+ block-level raw HTML stays escaped. Matching is case-insensitive.
21
+
22
+ ### Fixed
23
+
24
+ - **HTML comments are dropped instead of escaped to visible text.** `<!--mk:id-->`
25
+ (a common LLM marker) previously rendered as a literal `&lt;!--…--&gt;` run or a
26
+ `<pre><code>` block; it now has no visible representation, in every mode except
27
+ bare `unsafeHtml` pass-through (which keeps it verbatim for CommonMark fidelity —
28
+ the browser ignores it either way). A comment-led block with trailing content
29
+ keeps that content (only comment-*only* blocks are dropped).
30
+
31
+ ### Security
32
+
33
+ - The dangerous-tag set is **non-overridable** (allowlisting `script`/`iframe`/`svg`
34
+ still drops them), `style` is stripped from every sanitized/component tag, and
35
+ raw-text elements (`xmp`/`plaintext`/`noembed`/`noframes`/`listing`) are blocked
36
+ in allow-all mode — closing CSS-exfiltration / clickjacking / DOM-corruption
37
+ vectors found in adversarial review. The React `htmlToReact` path mirrors the
38
+ `style` value-filter as defense-in-depth (safe declarations like `text-align`
39
+ still pass).
40
+
41
+ Feature-off output is byte-identical except HTML comments now drop (the
42
+ CommonMark/GFM suites run with `unsafeHtml` on, so the 652/GFM floors are
43
+ unaffected).
44
+
45
+ ## 0.14.0 — 2026-06-17
46
+
47
+ ### Added
48
+
49
+ - **Inline custom component tags (`inlineComponentTags`)** — the headline gap for
50
+ rich apps. An allowlisted inline tag like `<tik symbol="AAPL">AAPL</tik>` (or
51
+ self-closing `<tik/>`) **anywhere inline** — paragraphs, headings, list items,
52
+ and **table cells** — renders as a real custom element with its inner parsed as
53
+ **inline markdown** and its attributes sanitized (event handlers dropped,
54
+ dangerous URL schemes → `#`). The React renderer dispatches it to
55
+ `components[tag]` with the inner markdown as `children` and the attributes as
56
+ props — **XSS-safe without `unsafeHtml`**. Independent of `componentTags`
57
+ (block containers): list a tag under either or both. Use lowercase tag names.
58
+ - **`children` on `Component` block overrides** — a `Component` override now also
59
+ receives the inner content pre-parsed to a React tree (`children`), so you can
60
+ `return <Chip {...attrs}>{children}</Chip>` instead of
61
+ `dangerouslySetInnerHTML`-ing `html`. The html-vs-children contract is now loud
62
+ in the types and docs (an override that renders neither shows empty).
63
+ - **`flux-md/server` — worker-free synchronous SSR / RSC rendering.** The Rust→
64
+ WASM core is a plain synchronous parser, so finished markdown renders on the
65
+ server with no worker: `initFlux()` (async, idempotent — reads the co-located
66
+ `.wasm` in Node, or `initFluxSync(bytes)` on edge), `renderToString(md, {
67
+ config })` (sync HTML string, zero React dep), `parseToBlocks(md, { config })`,
68
+ and `<FluxMarkdownStatic content config components />` — a hookless, RSC-safe
69
+ React component that emits the same `flux-md` tree a client `<FluxMarkdown>`
70
+ hydrates, with the same overrides (inline/block component tags dispatch on the
71
+ server too).
72
+ - **`FluxParser.allBlocks()` (WASM)** — returns the whole parsed document as a
73
+ block array, the one-shot render primitive used by `flux-md/server`.
74
+
75
+ ### Fixed
76
+
77
+ - **Data-loss: a block component tag used inline swallowed sibling blocks.** With
78
+ e.g. `componentTags: ["tik"]`, an inline occurrence such as
79
+ `<tik>AAPL</tik> is up.` on a line with following content opened a block
80
+ container that consumed the rest of the document (the paragraph and a following
81
+ table vanished). A block component open tag must now be the **whole line** (only
82
+ trailing whitespace after `>`); otherwise it's treated as inline and degrades
83
+ inertly — it never eats surrounding content.
84
+
85
+ ### Changed
86
+
87
+ - The React HTML→tree converter (`htmlToReact` / `parseTrustedHtml`) now preserves
88
+ a tag's original **case** for component dispatch (so a capitalized inline tag
89
+ like `<Cite>` maps to `components.Cite`); HTML semantics (void elements, `input`,
90
+ close-tag matching) still compare case-insensitively, so standard output is
91
+ unchanged.
92
+
93
+ Feature-off output is byte-identical (CommonMark 652 + GFM floors hold); both
94
+ allowlists are empty by default.
95
+
7
96
  ## 0.13.0 — 2026-06-04
8
97
 
9
98
  ### 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,10 @@ 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)
561
+ htmlAllowlist: ["br", "sub", "sup"], // safe raw-HTML sanitizer: [] = allow all but dangerous; list = only those (default off)
562
+ dropHtmlTags: [], // tags removed entirely (comments always dropped when sanitizing; default off)
503
563
  blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
504
564
  },
505
565
  });
@@ -527,10 +587,16 @@ When to enable each flag:
527
587
  - `unsafeHtml: true` — only when rendering trusted HTML. For untrusted /
528
588
  LLM-produced HTML, pair this with `<FluxMarkdown sanitize={…} />` (DOMPurify or
529
589
  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).
590
+ - `componentTags: ["Thinking", …]` — when your LLM emits **block** custom tags
591
+ like `<Thinking>…</Thinking>` (on their own line) and you want their inner
592
+ content parsed as markdown and dispatched to a React component. Safe without
593
+ `unsafeHtml` (attributes are sanitized; allowlisted tags only).
594
+ - `inlineComponentTags: ["tik", …]` — same idea for **inline** custom elements
595
+ that sit inside a paragraph, heading, list item, or **table cell** (ticker
596
+ chips, citations, `@mentions`). See [Inline component tags](#inline-component-tags).
597
+ - `htmlAllowlist` / `dropHtmlTags` — render a **safe subset of raw HTML** (e.g.
598
+ `<br>`, `<sub>`, `<sup>`) natively without `unsafeHtml`, drop specific tags, and
599
+ drop HTML comments. See [Safe raw HTML](#safe-raw-html).
534
600
 
535
601
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
536
602
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
@@ -700,29 +766,101 @@ sanitized (event handlers dropped, dangerous URL schemes → `#`).
700
766
 
701
767
  Each renders as a `Component` block. Override it in React by tag name (or with
702
768
  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:
769
+ `attrs`, the inner content as ready-to-render **`children`** (the easy path), and
770
+ also `html` (the inner already-rendered markdown string, for
771
+ `dangerouslySetInnerHTML`):
705
772
 
706
773
  ```tsx
707
774
  <FluxMarkdown
708
775
  client={client}
709
776
  components={{
710
- Thinking: ({ html }) => (
777
+ Thinking: ({ children }) => (
711
778
  <details className="thinking">
712
779
  <summary>Reasoning</summary>
713
- <div dangerouslySetInnerHTML={{ __html: html }} />
780
+ {children}
714
781
  </details>
715
782
  ),
716
783
  }}
717
784
  />
718
785
  ```
719
786
 
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.
787
+ > **`children` vs `html`.** A `Component` override that renders *neither* shows
788
+ > **empty** (a common first-try gotcha). Prefer **`children`** a parsed React
789
+ > tree with nested overrides applied; reach for `dangerouslySetInnerHTML={{ __html:
790
+ > html }}` only when you need the raw string. `attrs` keys are React-form
791
+ > (`class`→`className`, `for`→`htmlFor`) so `{...attrs}` spreads cleanly. While
792
+ > streaming, both reflect the partial inner content and re-render as more arrives.
793
+ > With no override the block renders as `<thinking …>…</thinking>`. Tag names
794
+ > match case-sensitively; off unless `componentTags` is set.
795
+
796
+ <a id="inline-component-tags"></a>
797
+
798
+ #### Inline component tags
799
+
800
+ `componentTags` handles **block** containers (a `<Thinking>` on its own line). For
801
+ **inline** custom elements — ticker chips, citations, `@mentions`, inline tooltips
802
+ that sit *inside* a paragraph, heading, list item, or **table cell** — use
803
+ `inlineComponentTags`:
804
+
805
+ ```tsx
806
+ const client = new FluxClient({ config: { inlineComponentTags: ["tik"] } });
807
+
808
+ <FluxMarkdown
809
+ client={client}
810
+ components={{
811
+ tik: ({ symbol, children }) => <span className="ticker">{children ?? symbol}</span>,
812
+ }}
813
+ />;
814
+ ```
815
+
816
+ Now `Apple <tik symbol="AAPL">AAPL</tik> rose 2%` (or self-closing
817
+ `<tik symbol="AAPL"/>`) dispatches the inline `<tik>` to `components.tik`: its
818
+ inner is parsed as **inline markdown** (the `children`), its attributes become
819
+ props, and it's **safe without `unsafeHtml`** (attributes sanitized, allowlisted
820
+ tags only). It works everywhere inline content does — **including table cells**.
821
+ Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
822
+ (`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
823
+ two lists are independent: list a tag under `componentTags` for blocks,
824
+ `inlineComponentTags` for inline, or both for both. An allowlisted tag used in an
825
+ unsupported position degrades **inertly** (escaped) — it never consumes
826
+ surrounding content.
827
+
828
+ > **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
829
+ > inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
830
+ > override `a` to render a chip when the href scheme matches. It's XSS-safe and
831
+ > renders inline-in-cells too — `inlineComponentTags` simply replaces that
832
+ > workaround with first-class inline elements.
833
+
834
+ ### Safe raw HTML
835
+
836
+ LLMs emit a little raw HTML — `<br>`, `<sub>`/`<sup>`, `<mark>`, and HTML comments
837
+ as markers (`<!--mk:id-->`). `unsafeHtml` is all-or-nothing; instead opt into a
838
+ **sanitizer** that renders a safe subset natively. Setting `htmlAllowlist` and/or
839
+ `dropHtmlTags` (even to `[]`) engages it:
840
+
841
+ ```ts
842
+ // Render only these inline tags; escape everything else:
843
+ new FluxClient({ config: { htmlAllowlist: ["br", "sub", "sup", "mark"] } });
844
+
845
+ // Or allow everything except a built-in dangerous set:
846
+ new FluxClient({ config: { htmlAllowlist: [] } });
847
+ ```
848
+
849
+ - **HTML comments are dropped** — no more `<!--mk:id-->` surfacing as escaped text
850
+ — in every mode except bare `unsafeHtml` pass-through.
851
+ - **`htmlAllowlist: ["br", …]`** renders only those inline tags; everything else is
852
+ escaped. **`htmlAllowlist: []`** (empty) allows *all* tags **except a built-in
853
+ dangerous set** (`script`, `style`, `iframe`, `object`, `embed`, `form`, `svg`,
854
+ `xmp`, `plaintext`, … — **non-overridable**: allowlisting one still drops it).
855
+ - **`dropHtmlTags: ["mk", …]`** removes those tags entirely (markup gone; inner
856
+ text stays as inert text).
857
+ - Every rendered tag's **attributes are sanitized**: `on*` handlers and `style`
858
+ (a CSS beacon / clickjacking vector) are dropped, and dangerous URL schemes
859
+ (`javascript:`, …, including multi-encoded) become `#`.
860
+ - **Scope:** *inline* raw HTML. Block-level raw HTML stays escaped for now (use
861
+ `unsafeHtml` **without** the sanitizer to render block HTML — when the sanitizer
862
+ is engaged, block HTML stays escaped even if `unsafeHtml` is also on). Tag
863
+ matching is case-insensitive.
726
864
 
727
865
  ### Types
728
866
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.13.0",
3
+ "version": "0.15.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:") ||
@@ -101,12 +110,37 @@ export function parseStyle(css: string): Record<string, string> {
101
110
  return out;
102
111
  }
103
112
 
113
+ // CSS values that beacon/exfiltrate (`url(`), execute (legacy `expression(`,
114
+ // `-moz-binding`, `behavior:`), or pull external resources (`@import`,
115
+ // `image-set(`). Defense-in-depth: the core sanitizer already drops `style`, but
116
+ // `htmlToReact` is exported and may be handed untrusted HTML directly.
117
+ const DANGEROUS_CSS_VALUE = /url\(|expression\(|image-set\(|-moz-binding|@import|behavior\s*:/i;
118
+
119
+ /** Strip CSS declarations that can beacon/exfiltrate, execute, or overlay the
120
+ * viewport (`position: fixed/sticky` → clickjacking). Safe declarations
121
+ * (`text-align`, `color`, …) — including flux's own table-alignment style —
122
+ * pass through untouched. */
123
+ function safeStyle(style: Record<string, string>): Record<string, string> {
124
+ const out: Record<string, string> = {};
125
+ for (const k in style) {
126
+ const v = style[k];
127
+ if (DANGEROUS_CSS_VALUE.test(v)) continue;
128
+ if (k.toLowerCase() === "position" && /\b(?:fixed|sticky)\b/i.test(v)) continue;
129
+ out[k] = v;
130
+ }
131
+ return out;
132
+ }
133
+
104
134
  /** Parse one opening tag starting at `start` (the `<`). */
105
135
  function parseOpenTag(html: string, start: number) {
106
136
  let i = start + 1;
107
137
  let j = i;
108
138
  while (j < html.length && /[a-zA-Z0-9-]/.test(html[j])) j++;
109
- const tag = html.slice(i, j).toLowerCase();
139
+ // Preserve the tag's ORIGINAL case so an inline custom-component element (e.g.
140
+ // `<Cite>`) dispatches to `components.Cite`. Standard elements the core emits
141
+ // are already lowercase; the semantic checks below (VOID, `input`, close-tag
142
+ // matching) lowercase as needed, so HTML behavior is unchanged.
143
+ const tag = html.slice(i, j);
110
144
  i = j;
111
145
  const attrs: Record<string, string | true> = {};
112
146
  while (i < html.length) {
@@ -193,9 +227,9 @@ export function parseTrustedHtml(html: string): HNode[] {
193
227
  }
194
228
  if (html[lt + 1] === "/") {
195
229
  const end = html.indexOf(">", lt);
196
- const tag = html.slice(lt + 2, end === -1 ? html.length : end).trim().toLowerCase();
230
+ const closeLower = html.slice(lt + 2, end === -1 ? html.length : end).trim().toLowerCase();
197
231
  for (let s = stack.length - 1; s >= 0; s--) {
198
- if (stack[s].tag === tag) {
232
+ if (stack[s].tag.toLowerCase() === closeLower) {
199
233
  stack.length = s;
200
234
  break;
201
235
  }
@@ -216,7 +250,7 @@ export function parseTrustedHtml(html: string): HNode[] {
216
250
  const { tag, attrs, selfClose, next } = parseOpenTag(html, lt);
217
251
  const el: Extract<HNode, { kind: "el" }> = { kind: "el", tag, attrs, children: [] };
218
252
  push(el);
219
- if (!selfClose && !VOID.has(tag)) stack.push(el);
253
+ if (!selfClose && !VOID.has(tag.toLowerCase())) stack.push(el);
220
254
  i = next;
221
255
  }
222
256
  return root;
@@ -232,7 +266,7 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
232
266
  // future React behavior.
233
267
  if (lower.startsWith("on")) continue;
234
268
  if (lower === "style" && typeof value === "string") {
235
- props.style = parseStyle(value);
269
+ props.style = safeStyle(parseStyle(value));
236
270
  continue;
237
271
  }
238
272
  // Neutralize dangerous-scheme URLs (javascript:, vbscript:, data:text/html).
@@ -242,7 +276,7 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
242
276
  }
243
277
  // A static checkbox carries `checked` with no handler; render it
244
278
  // uncontrolled so React doesn't warn about a missing onChange.
245
- if (tag === "input" && lower === "checked") {
279
+ if (tag.toLowerCase() === "input" && lower === "checked") {
246
280
  props.defaultChecked = value === true ? true : value;
247
281
  continue;
248
282
  }
@@ -262,13 +296,15 @@ function nodesToReact(nodes: HNode[], components: Components, keyPrefix: string)
262
296
  const key = keyPrefix + idx;
263
297
  const type = components[n.tag] ?? n.tag;
264
298
  const props = attrsToProps(n.tag, n.attrs, key);
265
- if (VOID.has(n.tag)) {
299
+ if (VOID.has(n.tag.toLowerCase())) {
266
300
  out.push(createElement(type, props));
267
301
  } else {
268
302
  out.push(createElement(type, props, nodesToReact(n.children, components, key + ".")));
269
303
  }
270
304
  }
271
- return out.length === 1 ? out[0] : out;
305
+ // `null` (not an empty array) for no children, so a self-closing / empty inline
306
+ // component's `children` is nullish and a `{children ?? fallback}` override fires.
307
+ return out.length === 0 ? null : out.length === 1 ? out[0] : out;
272
308
  }
273
309
 
274
310
  /**
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,215 @@
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
+ // Engage the safe raw-HTML sanitizer when either list is provided (even []).
91
+ p.setHtmlSanitize(
92
+ config?.htmlAllowlist !== undefined || config?.dropHtmlTags !== undefined,
93
+ config?.htmlAllowlist ?? [],
94
+ config?.dropHtmlTags ?? [],
95
+ );
96
+ p.setBlockData(config?.blockData ?? false);
97
+ return p;
98
+ }
99
+
100
+ function requireReady(): void {
101
+ if (!ready) {
102
+ throw new Error(
103
+ "flux-md/server: WASM not initialized. Call `await initFlux()` (or `initFluxSync(bytes)`) once before rendering.",
104
+ );
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Parse a complete markdown string to its block array synchronously (committed +
110
+ * any trailing block, in document order). Requires {@link initFlux} to have run.
111
+ */
112
+ export function parseToBlocks(markdown: string, opts?: { config?: ParserConfig }): Block[] {
113
+ requireReady();
114
+ const p = makeParser(opts?.config);
115
+ try {
116
+ p.append(markdown);
117
+ p.finalize();
118
+ return p.allBlocks() as Block[];
119
+ } finally {
120
+ p.free();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Render a complete markdown string to an HTML string synchronously — no worker,
126
+ * no React. The concatenated per-block HTML (XSS-safe with `unsafeHtml` off).
127
+ * For component dispatch / a `<FluxMarkdown>`-matching React tree, use
128
+ * {@link FluxMarkdownStatic} with your framework's server renderer instead.
129
+ */
130
+ export function renderToString(markdown: string, opts?: { config?: ParserConfig }): string {
131
+ return parseToBlocks(markdown, opts)
132
+ .map((b) => b.html)
133
+ .join("");
134
+ }
135
+
136
+ // Hookless block renderer (RSC-safe): mirrors the client renderer's dispatch
137
+ // (block-kind overrides, a Component block dispatched by tag, tag-level overrides
138
+ // via htmlToReact) but uses no hooks and skips the client-only interactive
139
+ // renderers (Mermaid; client-side code highlighting) — those activate on the
140
+ // client after hydration. Kept in step with react.tsx's renderBlockContent.
141
+ function renderStaticBlock(block: Block, components?: Components): ReactNode {
142
+ const kind = block.kind.type;
143
+ if (components) {
144
+ if (kind === "Component") {
145
+ const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
146
+ const override = (tag && components[tag]) || components.Component;
147
+ if (override) return createElement(override, { key: block.id, ...blockKindProps(block, components) });
148
+ }
149
+ const blockOverride = components[kind];
150
+ if (blockOverride) return createElement(blockOverride, { key: block.id, ...blockKindProps(block, components) });
151
+ }
152
+ const className =
153
+ "flux-block flux-block-" +
154
+ kind.toLowerCase() +
155
+ (block.open ? " flux-open" : "") +
156
+ (block.speculative ? " flux-speculative" : "");
157
+ if (components) {
158
+ return createElement("div", { key: block.id, className }, htmlToReact(block.html, components));
159
+ }
160
+ return createElement("div", { key: block.id, className, dangerouslySetInnerHTML: { __html: block.html } });
161
+ }
162
+
163
+ interface FluxMarkdownStaticProps {
164
+ /** The complete markdown to render (server/static use is for finished content). */
165
+ content: string;
166
+ /** Parser config (same shape as the streaming client's). */
167
+ config?: ParserConfig;
168
+ /** Tag-level / block-kind / component-tag overrides (see {@link Components}). */
169
+ components?: Components;
170
+ /** Appended to the root's `className` (the `flux-md` class is always present). */
171
+ className?: string;
172
+ /** Set on the root element. */
173
+ id?: string;
174
+ /** Set on the root element (e.g. `"article"`). */
175
+ role?: string;
176
+ /** Make the root a live region (parity with `<FluxMarkdown>` if you hydrate). */
177
+ "aria-live"?: "off" | "polite" | "assertive";
178
+ /** Live-region atomicity; pair with `aria-live`. */
179
+ "aria-atomic"?: boolean;
180
+ }
181
+
182
+ /**
183
+ * Synchronous, worker-free React rendering of finished markdown — a React Server
184
+ * Component, or any one-shot SSR / static render. Emits the `flux-md` root +
185
+ * per-block structure with the same `components` overrides (inline/block
186
+ * component tags dispatch here too). Requires {@link initFlux} (or
187
+ * {@link initFluxSync}) to have run. Uses no hooks (RSC-safe). A **render-once**
188
+ * component: for live streaming, client-side code highlighting, or Mermaid use
189
+ * the client `<FluxMarkdown>` instead (and if you SSR-then-hydrate, render the
190
+ * *same* component on both sides).
191
+ */
192
+ export function FluxMarkdownStatic({
193
+ content,
194
+ config,
195
+ components,
196
+ className,
197
+ id,
198
+ role,
199
+ "aria-live": ariaLive,
200
+ "aria-atomic": ariaAtomic,
201
+ }: FluxMarkdownStaticProps): ReactNode {
202
+ const blocks = parseToBlocks(content, { config });
203
+ const comps = components && Object.keys(components).length > 0 ? components : undefined;
204
+ return createElement(
205
+ "div",
206
+ {
207
+ className: className ? `flux-md ${className}` : "flux-md",
208
+ id,
209
+ role,
210
+ "aria-live": ariaLive,
211
+ "aria-atomic": ariaAtomic,
212
+ },
213
+ blocks.map((b) => renderStaticBlock(b, comps)),
214
+ );
215
+ }
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,41 @@ 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[];
264
+ /**
265
+ * Opt-in **safe raw-HTML allowlist**. Setting this (even to `[]`) engages a
266
+ * sanitizer that renders a safe subset of *inline* raw HTML **without**
267
+ * `unsafeHtml`: an **empty** array means "allow all tags except a built-in
268
+ * dangerous set" (`script`, `style`, `iframe`, `object`, `embed`, `form`,
269
+ * `input`, `svg`, …); a **non-empty** array renders only those tags (e.g.
270
+ * `["br","sub","sup"]`) and escapes the rest. Every rendered tag's attributes
271
+ * are sanitized (event handlers dropped, dangerous URL schemes → `#`), and HTML
272
+ * comments are dropped. Block-level raw HTML stays escaped (sanitize is
273
+ * inline-scoped for now). Unset/omitted = off (raw HTML handling unchanged).
274
+ * Matching is case-insensitive. See also {@link dropHtmlTags}.
275
+ */
276
+ htmlAllowlist?: string[];
277
+ /**
278
+ * Tags removed entirely (markup dropped; any text between an open/close pair
279
+ * stays as inert text) — e.g. app marker tags, or belt-and-suspenders
280
+ * `["script","style"]`. Setting this (even to `[]`) also engages the safe
281
+ * raw-HTML sanitizer (see {@link htmlAllowlist}). Case-insensitive.
282
+ */
283
+ dropHtmlTags?: string[];
232
284
  /**
233
285
  * Opt-in structured table data. When on, a `Table` block's `kind.data` is
234
286
  * 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,23 @@ 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
+ * Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
76
+ * sanitized without full unsafe HTML: `allow` empty = allow all tags except
77
+ * a built-in dangerous set (`script`, `style`, `iframe`, …); `allow`
78
+ * non-empty = only those render (others escaped); `drop` tags are removed
79
+ * entirely; HTML comments are dropped; every rendered tag's attributes are
80
+ * sanitized. Off by default (raw-HTML handling unchanged).
81
+ */
82
+ setHtmlSanitize(on: boolean, allow: string[], drop: string[]): void;
83
+ /**
84
+ * Set the opt-in INLINE component-tag allowlist (e.g. `["tik", "cite"]`).
85
+ * An allowlisted inline `<tik>…</tik>` (or self-closing `<tik/>`) renders as
86
+ * a custom element (markdown inner, sanitized attributes) so a JSX/DOM layer
87
+ * can dispatch it via `components[tag]` — in paragraphs, headings, table
88
+ * cells, and list items. Empty by default (inline output unchanged).
89
+ */
90
+ setInlineComponentTags(tags: string[]): void;
67
91
  /**
68
92
  * Enable or disable raw-HTML pass-through. Default off. Do not enable
69
93
  * when rendering untrusted input — bypasses XSS protection.
@@ -76,6 +100,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
76
100
  export interface InitOutput {
77
101
  readonly memory: WebAssembly.Memory;
78
102
  readonly __wbg_fluxparser_free: (a: number, b: number) => void;
103
+ readonly fluxparser_allBlocks: (a: number, b: number) => void;
79
104
  readonly fluxparser_append: (a: number, b: number, c: number, d: number) => void;
80
105
  readonly fluxparser_bufferLen: (a: number) => number;
81
106
  readonly fluxparser_finalize: (a: number, b: number) => void;
@@ -89,6 +114,8 @@ export interface InitOutput {
89
114
  readonly fluxparser_setGfmAutolinks: (a: number, b: number) => void;
90
115
  readonly fluxparser_setGfmFootnotes: (a: number, b: number) => void;
91
116
  readonly fluxparser_setGfmMath: (a: number, b: number) => void;
117
+ readonly fluxparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
118
+ readonly fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
92
119
  readonly fluxparser_setUnsafeHtml: (a: number, b: number) => void;
93
120
  readonly __wbindgen_export: (a: number, b: number) => number;
94
121
  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,37 @@ export class FluxParser {
149
171
  setGfmMath(on) {
150
172
  wasm.fluxparser_setGfmMath(this.__wbg_ptr, on);
151
173
  }
174
+ /**
175
+ * Engage the safe raw-HTML sanitizer. When `on`, inline raw HTML renders
176
+ * sanitized without full unsafe HTML: `allow` empty = allow all tags except
177
+ * a built-in dangerous set (`script`, `style`, `iframe`, …); `allow`
178
+ * non-empty = only those render (others escaped); `drop` tags are removed
179
+ * entirely; HTML comments are dropped; every rendered tag's attributes are
180
+ * sanitized. Off by default (raw-HTML handling unchanged).
181
+ * @param {boolean} on
182
+ * @param {string[]} allow
183
+ * @param {string[]} drop
184
+ */
185
+ setHtmlSanitize(on, allow, drop) {
186
+ const ptr0 = passArrayJsValueToWasm0(allow, wasm.__wbindgen_export);
187
+ const len0 = WASM_VECTOR_LEN;
188
+ const ptr1 = passArrayJsValueToWasm0(drop, wasm.__wbindgen_export);
189
+ const len1 = WASM_VECTOR_LEN;
190
+ wasm.fluxparser_setHtmlSanitize(this.__wbg_ptr, on, ptr0, len0, ptr1, len1);
191
+ }
192
+ /**
193
+ * Set the opt-in INLINE component-tag allowlist (e.g. `["tik", "cite"]`).
194
+ * An allowlisted inline `<tik>…</tik>` (or self-closing `<tik/>`) renders as
195
+ * a custom element (markdown inner, sanitized attributes) so a JSX/DOM layer
196
+ * can dispatch it via `components[tag]` — in paragraphs, headings, table
197
+ * cells, and list items. Empty by default (inline output unchanged).
198
+ * @param {string[]} tags
199
+ */
200
+ setInlineComponentTags(tags) {
201
+ const ptr0 = passArrayJsValueToWasm0(tags, wasm.__wbindgen_export);
202
+ const len0 = WASM_VECTOR_LEN;
203
+ wasm.fluxparser_setInlineComponentTags(this.__wbg_ptr, ptr0, len0);
204
+ }
152
205
  /**
153
206
  * Enable or disable raw-HTML pass-through. Default off. Do not enable
154
207
  * 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,8 @@ 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_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
20
+ export const fluxparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
18
21
  export const fluxparser_setUnsafeHtml: (a: number, b: number) => void;
19
22
  export const __wbindgen_export: (a: number, b: number) => number;
20
23
  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.15.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",
package/src/worker.ts CHANGED
@@ -30,6 +30,13 @@ 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 ?? []);
34
+ // Engage the safe raw-HTML sanitizer when either list is provided (even []).
35
+ p.setHtmlSanitize(
36
+ c?.htmlAllowlist !== undefined || c?.dropHtmlTags !== undefined,
37
+ c?.htmlAllowlist ?? [],
38
+ c?.dropHtmlTags ?? [],
39
+ );
33
40
  p.setBlockData(c?.blockData ?? false);
34
41
  return p;
35
42
  },