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 +89 -0
- package/README.md +154 -16
- package/package.json +2 -1
- package/src/html-to-react.ts +45 -9
- package/src/react.tsx +7 -3
- package/src/server.tsx +215 -0
- package/src/types-core.ts +53 -1
- package/src/wasm/flux_md_core.d.ts +27 -0
- package/src/wasm/flux_md_core.js +53 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +3 -0
- package/src/wasm/package.json +1 -1
- package/src/worker.ts +7 -0
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 `<!--…-->` 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
|
-
|
|
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
|
|
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
|
|
531
|
-
`<Thinking>…</Thinking>` and you want their inner
|
|
532
|
-
and dispatched to a React component. Safe without
|
|
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`,
|
|
704
|
-
|
|
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: ({
|
|
777
|
+
Thinking: ({ children }) => (
|
|
711
778
|
<details className="thinking">
|
|
712
779
|
<summary>Reasoning</summary>
|
|
713
|
-
|
|
780
|
+
{children}
|
|
714
781
|
</details>
|
|
715
782
|
),
|
|
716
783
|
}}
|
|
717
784
|
/>
|
|
718
785
|
```
|
|
719
786
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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.
|
|
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",
|
package/src/html-to-react.ts
CHANGED
|
@@ -38,8 +38,17 @@ const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action",
|
|
|
38
38
|
* strip control chars (C0, DEL, C1 — matching Rust char::is_control),
|
|
39
39
|
* lowercase, then match. The strip affects only the probe, never output. */
|
|
40
40
|
function safeUrl(value: string): string {
|
|
41
|
+
// Decode-STABLE probe: a value can be entity-decoded more than once before it
|
|
42
|
+
// reaches the DOM, so peel layers to a fixpoint before the scheme check —
|
|
43
|
+
// catches `javascript:` and double-encoded `javascript&#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 =
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|
package/src/wasm/flux_md_core.js
CHANGED
|
@@ -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;
|
package/src/wasm/package.json
CHANGED
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
|
},
|