@reblu/site-blocks 0.2.2 → 0.3.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/dist/index.d.cts CHANGED
@@ -34,24 +34,138 @@ declare function buildShareUrls(url: string, title: string): ShareUrls;
34
34
  * Blog `content` arrives as raw Markdown (tiptap-markdown). This module owns the
35
35
  * single, hardened conversion every SITE reuses instead of reinventing it:
36
36
  *
37
- * Markdown ──(marked, GFM)──▶ HTML ──(DOMPurify)──▶ safe HTML
37
+ * Markdown ──(marked, GFM)──▶ HTML ──(hast-util-sanitize)──▶ safe HTML
38
38
  *
39
- * DOMPurify is MANDATORY (XSS): the output is later parsed into React nodes by
40
- * `MarkdownRenderer` (no `dangerouslySetInnerHTML`), but sanitising here means the
41
- * string is safe regardless of how a consumer chooses to render it.
39
+ * Sanitisation is MANDATORY (XSS): the output is later parsed into React nodes by
40
+ * `MarkdownRenderer` (no `dangerouslySetInnerHTML`), but `sanitizeHtml` is also used
41
+ * directly against `dangerouslySetInnerHTML` for tenant-authored HTML — the nexus
42
+ * "HTML personalizado" editor (`apps/base-api/src/components/nexus/pages/page-editor/
43
+ * page-editor-form.tsx`) writes `page.customHtml`/`section.contentHtml`, rendered in
44
+ * `apps/demo-site/src/app/[slug]/page.tsx` and `apps/demo-site/src/app/services/
45
+ * [slug]/page.tsx` — so the string must be safe on its own regardless of how a
46
+ * consumer chooses to render it.
42
47
  *
43
- * `<iframe>` is the one non-default tag we allow, and ONLY when its `src` host is
44
- * on the YouTube allowlist — every other embed (arbitrary iframes) is dropped.
48
+ * SCOPE OF THE PARITY GUARANTEES BELOW. Everything this module says about preserving a
49
+ * tenant's `class`, `style`, `id`, `hidden`, `data-*` and `aria-*` is a guarantee about
50
+ * the STRING `sanitizeHtml` returns, and it reaches the page unchanged only on the
51
+ * `dangerouslySetInnerHTML` consumers listed above. `MarkdownRenderer` re-parses that
52
+ * string through `html-react-parser` into a token-styled component map
53
+ * (`markdown-renderer.tsx` `buildOptions`), and that map rebuilds most elements from
54
+ * scratch — so on the blog-body path author attributes are dropped downstream of this
55
+ * module regardless of what the schema allows. Widening the renderer's attribute
56
+ * forwarding is a separate change; do not read these comments as a claim about it.
57
+ *
58
+ * REB-606: this used to run on `isomorphic-dompurify`, which pulls `jsdom` into the
59
+ * import graph. `jsdom`'s transitive `html-encoding-sniffer` → `@exodus/bytes` chain
60
+ * is ESM-only and fails to load under Vercel's Node runtime (`ERR_REQUIRE_ESM`),
61
+ * producing a 500 on every route that renders tenant HTML.
62
+ *
63
+ * The replacement is deliberately assembled from the three `hast` utilities DIRECTLY
64
+ * (`parse5` → `hast-util-from-parse5` → `hast-util-sanitize` → `hast-util-to-html`)
65
+ * rather than through the `unified`/`rehype-*` plugin wrappers. `unified().processSync`
66
+ * builds a `VFile`, and `VFile`'s constructor calls `process.cwd()` through `vfile`'s
67
+ * `minproc`/`minpath`/`minurl` modules — which a bundler resolves under the **node**
68
+ * condition, so the bundle acquires bare `path`/`process`/`url` imports. That would
69
+ * reproduce REB-606's exact shape (a build-invisible, runtime-only module failure) in
70
+ * a different runtime: a SITE importing this framework-agnostic entry into a client
71
+ * component gets `Module not found: Can't resolve 'path'`, and an Edge route gets a
72
+ * `TypeError` the first time `sanitizeHtml` reaches `process.cwd`. The direct
73
+ * utilities never construct a `VFile`, so the emitted bundle has NO runtime import
74
+ * outside this package's declared dependencies. `packages/site-blocks/test/
75
+ * use-client-smoke.test.ts` pins that specifier set for both emitted entries — that
76
+ * assertion, not this comment, is what keeps it true.
77
+ *
78
+ * `hast-util-sanitize`'s own `defaultSchema` is GitHub's markdown-comment policy, which
79
+ * is meaningfully STRICTER than DOMPurify's unconfigured default (no `class`, no
80
+ * `style`, no `target`, structural tags like `figure`/`article`/`nav` missing, no
81
+ * `tel:` links, and it rewrites `id`/`name` to fight DOM clobbering). Swapping the
82
+ * library is not "behaviour preserving" if it also silently swaps the policy — a
83
+ * tenant's `customHtml` authored under the old, permissive DOMPurify default would
84
+ * lose styling, anchors, phone CTAs and structure on this deploy. `SANITIZE_SCHEMA`
85
+ * below restores DOMPurify's default HTML profile (see `dompurify/dist/purify.cjs.js`
86
+ * `html$1`/`html`/`IS_ALLOWED_URI`) for everything that carries **no new injection
87
+ * surface**, and it turns GitHub's `id`/`name` clobber-rewrite off (DOMPurify never did
88
+ * this, so leaving it on would break tenant anchor links that worked for the last
89
+ * month).
90
+ *
91
+ * It deliberately does NOT restore the parts of DOMPurify's default that ARE
92
+ * meaningfully riskier and have no evidenced use in this codebase: the `<style>`
93
+ * element, form controls (`form`/`input`/`button`/`select`/`textarea`/`label`/
94
+ * `fieldset`/`legend`/`option`/`optgroup`/`datalist`/`output`/`progress`/`meter`
95
+ * beyond the existing GFM task-list checkbox — phishing/UI-redress surface),
96
+ * `<script>`/`<template>`/`<object>`/`<embed>`/`<canvas>`, image maps (`map`/`area`),
97
+ * document/shadow-DOM plumbing (`html`/`head`/`body`/`slot`/`content`/`shadow`/
98
+ * `element`/`decorator`), the obsolete set (`dir`/`marquee`/`nobr`/`spacer`/`blink`/
99
+ * `menuitem`), `dialog`/`menu`/`search`, and the SVG/MathML namespaces (recurring
100
+ * source of real-world DOMPurify/sanitiser bypasses via `foreignObject`/namespace
101
+ * confusion). The changeset for this release enumerates every one of them.
102
+ *
103
+ * `<style>` specifically: it is not restored because a tenant-authored stylesheet
104
+ * applies to the WHOLE document, not just the injected fragment — attribute-selector
105
+ * rules like `input[value^="a"]{background:url(...)}` read and exfiltrate values from
106
+ * markup this sanitiser never saw. The `style` ATTRIBUTE is a different, narrower
107
+ * primitive (it can only style the element that carries it), it is part of DOMPurify's
108
+ * default that tenant `customHtml` has relied on for a month, and it is kept — see the
109
+ * changeset, which discloses that an inline `style` can still issue an outbound
110
+ * `url()` request. Closing that is a separate, deliberate decision (a CSS property
111
+ * allowlist) this bug-fix does not make.
112
+ *
113
+ * ARIA (`role` + the standard `aria-*` set, enumerated in `RESTORED_ARIA_ATTRIBUTES`
114
+ * below using `property-information`'s exact camelCase spellings — e.g.
115
+ * `ariaValueMax`, not `ariaValuemax`; hast-util-sanitize matches property names
116
+ * case-sensitively, so a naive hyphen-to-camelCase guess silently drops the
117
+ * attribute instead of erroring) is restored too — DOMPurify allows it by default
118
+ * (`ALLOW_ARIA_ATTR`) and losing it would be an accessibility regression on tenant
119
+ * content, not a security fix (see REB-286's axe gate). Custom `data-*` attributes
120
+ * (DOMPurify's `ALLOW_DATA_ATTR`) are restored via hast-util-sanitize's `'data*'`
121
+ * wildcard property name (`lib/index.js:758` in `hast-util-sanitize`) — they carry
122
+ * no URL/script/CSS interpretation, so allowing arbitrary values is safe.
123
+ *
124
+ * `srcset` is the one attribute this schema takes AWAY from DOMPurify's default, on
125
+ * purpose. `protocols` validates only the attribute's leading scheme
126
+ * (`hast-util-sanitize`'s `safeProtocol` looks at the text before the first
127
+ * `:`/`/`/`?`/`#`), but `srcset` is a comma-separated candidate list — so a value like
128
+ * `"https://ok.png 1x, //evil.tld/track.png 2x"` cannot be gated by this schema model
129
+ * at all. `defaultSchema` ships `source: ['srcSet']` and keeps `source`/`picture` in
130
+ * `tagNames`, which would leave `srcSet` on `<source>` as the ONLY completely ungated
131
+ * URL sink in the policy: not script execution (browsers do not run `javascript:` from
132
+ * an image source), but an arbitrary tenant-authored third-party request carrying a
133
+ * visitor's IP, UA and Referer. `RESTORED_MEDIA_ATTRIBUTES.source` replaces that list
134
+ * with protocol-gated `src` only.
135
+ *
136
+ * `<iframe>` is the one non-default TAG we allow, and ONLY when its `src` host is on
137
+ * the YouTube allowlist — every other embed (arbitrary iframes) is dropped.
138
+ *
139
+ * FOREIGN CONTENT (SVG/MathML) IS REMOVED BEFORE THE TREE IS EVEN BUILT, and that
140
+ * pre-pass is load-bearing for three separate reasons — see `dropForeignContent`.
141
+ * The policy above already said the SVG and MathML namespaces are not allowed; until
142
+ * this pass existed that was implemented by UNWRAPPING them, which is a different
143
+ * thing and got two of the three wrong.
45
144
  */
46
145
  /** Hosts permitted for `<iframe>` embeds. Exact host match — no suffix tricks. */
47
146
  declare const ALLOWED_YOUTUBE_HOSTS: readonly string[];
48
147
  /** True when `src` is an absolute URL whose host is exactly a YouTube host. */
49
148
  declare function isAllowedYouTubeHost(src: string | null | undefined): boolean;
50
149
  /**
51
- * Sanitise an already-HTML string with the same policy (default-safe tags plus a
52
- * host-gated `<iframe>`). Use for content that arrives as HTML — e.g. page
53
- * `section.contentHtml` from base-api, which is `marked` output that has NOT been
54
- * sanitised server-side.
150
+ * Sanitise an already-HTML string with the same policy (DOMPurify-default-equivalent
151
+ * tags/attributes plus a host-gated `<iframe>`). Use for content that arrives as
152
+ * HTML — e.g. page `section.contentHtml` from base-api, which is `marked` output
153
+ * that has NOT been sanitised server-side.
154
+ *
155
+ * WHAT A PARSE FAILURE DOES, AND WHY. Everything up to `fromParse5` runs BEFORE the
156
+ * sanitiser, so a throw there cannot be caught by anything downstream — and none of
157
+ * this module's callers catch anything at all. There are only two behaviours that keep
158
+ * the function's contract ("the returned string is safe to inject as HTML"):
159
+ * propagate, or return `''`. Propagating turns one malformed block inside one tenant's
160
+ * page into a 500 for the WHOLE route, which is the exact incident this module exists
161
+ * to end. So it fails closed: the block is dropped, the rest of the page renders, and
162
+ * the failure is reported through `console.error` so it is not silent — a dropped
163
+ * block with a log line is recoverable, a 500 is not. Returning the input, escaped or
164
+ * otherwise, is not an option: unparsed input is unsanitised input.
165
+ *
166
+ * This is a backstop, not the fix. The known crash class (foreign-content `<template>`)
167
+ * is removed structurally by `dropForeignContent`; this catch exists because
168
+ * `fromParse5` has already surprised us once.
55
169
  */
56
170
  declare function sanitizeHtml(html: string | null | undefined): string;
57
171
  /** Convert raw Markdown to sanitised, XSS-safe HTML. */
@@ -60,7 +174,7 @@ declare function markdownToSafeHtml(markdown: string | null | undefined): string
60
174
  /**
61
175
  * `MarkdownRenderer` — the canonical Blog body renderer.
62
176
  *
63
- * Pipeline: raw Markdown → `markdownToSafeHtml` (marked → DOMPurify, XSS-safe) →
177
+ * Pipeline: raw Markdown → `markdownToSafeHtml` (marked → hast-util-sanitize, XSS-safe) →
64
178
  * `html-react-parser` into a token-styled React tree. We parse the sanitised HTML
65
179
  * into real React nodes (a component map) rather than using
66
180
  * `dangerouslySetInnerHTML`, so images lazy-load, external links are hardened, and
package/dist/index.d.ts CHANGED
@@ -34,24 +34,138 @@ declare function buildShareUrls(url: string, title: string): ShareUrls;
34
34
  * Blog `content` arrives as raw Markdown (tiptap-markdown). This module owns the
35
35
  * single, hardened conversion every SITE reuses instead of reinventing it:
36
36
  *
37
- * Markdown ──(marked, GFM)──▶ HTML ──(DOMPurify)──▶ safe HTML
37
+ * Markdown ──(marked, GFM)──▶ HTML ──(hast-util-sanitize)──▶ safe HTML
38
38
  *
39
- * DOMPurify is MANDATORY (XSS): the output is later parsed into React nodes by
40
- * `MarkdownRenderer` (no `dangerouslySetInnerHTML`), but sanitising here means the
41
- * string is safe regardless of how a consumer chooses to render it.
39
+ * Sanitisation is MANDATORY (XSS): the output is later parsed into React nodes by
40
+ * `MarkdownRenderer` (no `dangerouslySetInnerHTML`), but `sanitizeHtml` is also used
41
+ * directly against `dangerouslySetInnerHTML` for tenant-authored HTML — the nexus
42
+ * "HTML personalizado" editor (`apps/base-api/src/components/nexus/pages/page-editor/
43
+ * page-editor-form.tsx`) writes `page.customHtml`/`section.contentHtml`, rendered in
44
+ * `apps/demo-site/src/app/[slug]/page.tsx` and `apps/demo-site/src/app/services/
45
+ * [slug]/page.tsx` — so the string must be safe on its own regardless of how a
46
+ * consumer chooses to render it.
42
47
  *
43
- * `<iframe>` is the one non-default tag we allow, and ONLY when its `src` host is
44
- * on the YouTube allowlist — every other embed (arbitrary iframes) is dropped.
48
+ * SCOPE OF THE PARITY GUARANTEES BELOW. Everything this module says about preserving a
49
+ * tenant's `class`, `style`, `id`, `hidden`, `data-*` and `aria-*` is a guarantee about
50
+ * the STRING `sanitizeHtml` returns, and it reaches the page unchanged only on the
51
+ * `dangerouslySetInnerHTML` consumers listed above. `MarkdownRenderer` re-parses that
52
+ * string through `html-react-parser` into a token-styled component map
53
+ * (`markdown-renderer.tsx` `buildOptions`), and that map rebuilds most elements from
54
+ * scratch — so on the blog-body path author attributes are dropped downstream of this
55
+ * module regardless of what the schema allows. Widening the renderer's attribute
56
+ * forwarding is a separate change; do not read these comments as a claim about it.
57
+ *
58
+ * REB-606: this used to run on `isomorphic-dompurify`, which pulls `jsdom` into the
59
+ * import graph. `jsdom`'s transitive `html-encoding-sniffer` → `@exodus/bytes` chain
60
+ * is ESM-only and fails to load under Vercel's Node runtime (`ERR_REQUIRE_ESM`),
61
+ * producing a 500 on every route that renders tenant HTML.
62
+ *
63
+ * The replacement is deliberately assembled from the three `hast` utilities DIRECTLY
64
+ * (`parse5` → `hast-util-from-parse5` → `hast-util-sanitize` → `hast-util-to-html`)
65
+ * rather than through the `unified`/`rehype-*` plugin wrappers. `unified().processSync`
66
+ * builds a `VFile`, and `VFile`'s constructor calls `process.cwd()` through `vfile`'s
67
+ * `minproc`/`minpath`/`minurl` modules — which a bundler resolves under the **node**
68
+ * condition, so the bundle acquires bare `path`/`process`/`url` imports. That would
69
+ * reproduce REB-606's exact shape (a build-invisible, runtime-only module failure) in
70
+ * a different runtime: a SITE importing this framework-agnostic entry into a client
71
+ * component gets `Module not found: Can't resolve 'path'`, and an Edge route gets a
72
+ * `TypeError` the first time `sanitizeHtml` reaches `process.cwd`. The direct
73
+ * utilities never construct a `VFile`, so the emitted bundle has NO runtime import
74
+ * outside this package's declared dependencies. `packages/site-blocks/test/
75
+ * use-client-smoke.test.ts` pins that specifier set for both emitted entries — that
76
+ * assertion, not this comment, is what keeps it true.
77
+ *
78
+ * `hast-util-sanitize`'s own `defaultSchema` is GitHub's markdown-comment policy, which
79
+ * is meaningfully STRICTER than DOMPurify's unconfigured default (no `class`, no
80
+ * `style`, no `target`, structural tags like `figure`/`article`/`nav` missing, no
81
+ * `tel:` links, and it rewrites `id`/`name` to fight DOM clobbering). Swapping the
82
+ * library is not "behaviour preserving" if it also silently swaps the policy — a
83
+ * tenant's `customHtml` authored under the old, permissive DOMPurify default would
84
+ * lose styling, anchors, phone CTAs and structure on this deploy. `SANITIZE_SCHEMA`
85
+ * below restores DOMPurify's default HTML profile (see `dompurify/dist/purify.cjs.js`
86
+ * `html$1`/`html`/`IS_ALLOWED_URI`) for everything that carries **no new injection
87
+ * surface**, and it turns GitHub's `id`/`name` clobber-rewrite off (DOMPurify never did
88
+ * this, so leaving it on would break tenant anchor links that worked for the last
89
+ * month).
90
+ *
91
+ * It deliberately does NOT restore the parts of DOMPurify's default that ARE
92
+ * meaningfully riskier and have no evidenced use in this codebase: the `<style>`
93
+ * element, form controls (`form`/`input`/`button`/`select`/`textarea`/`label`/
94
+ * `fieldset`/`legend`/`option`/`optgroup`/`datalist`/`output`/`progress`/`meter`
95
+ * beyond the existing GFM task-list checkbox — phishing/UI-redress surface),
96
+ * `<script>`/`<template>`/`<object>`/`<embed>`/`<canvas>`, image maps (`map`/`area`),
97
+ * document/shadow-DOM plumbing (`html`/`head`/`body`/`slot`/`content`/`shadow`/
98
+ * `element`/`decorator`), the obsolete set (`dir`/`marquee`/`nobr`/`spacer`/`blink`/
99
+ * `menuitem`), `dialog`/`menu`/`search`, and the SVG/MathML namespaces (recurring
100
+ * source of real-world DOMPurify/sanitiser bypasses via `foreignObject`/namespace
101
+ * confusion). The changeset for this release enumerates every one of them.
102
+ *
103
+ * `<style>` specifically: it is not restored because a tenant-authored stylesheet
104
+ * applies to the WHOLE document, not just the injected fragment — attribute-selector
105
+ * rules like `input[value^="a"]{background:url(...)}` read and exfiltrate values from
106
+ * markup this sanitiser never saw. The `style` ATTRIBUTE is a different, narrower
107
+ * primitive (it can only style the element that carries it), it is part of DOMPurify's
108
+ * default that tenant `customHtml` has relied on for a month, and it is kept — see the
109
+ * changeset, which discloses that an inline `style` can still issue an outbound
110
+ * `url()` request. Closing that is a separate, deliberate decision (a CSS property
111
+ * allowlist) this bug-fix does not make.
112
+ *
113
+ * ARIA (`role` + the standard `aria-*` set, enumerated in `RESTORED_ARIA_ATTRIBUTES`
114
+ * below using `property-information`'s exact camelCase spellings — e.g.
115
+ * `ariaValueMax`, not `ariaValuemax`; hast-util-sanitize matches property names
116
+ * case-sensitively, so a naive hyphen-to-camelCase guess silently drops the
117
+ * attribute instead of erroring) is restored too — DOMPurify allows it by default
118
+ * (`ALLOW_ARIA_ATTR`) and losing it would be an accessibility regression on tenant
119
+ * content, not a security fix (see REB-286's axe gate). Custom `data-*` attributes
120
+ * (DOMPurify's `ALLOW_DATA_ATTR`) are restored via hast-util-sanitize's `'data*'`
121
+ * wildcard property name (`lib/index.js:758` in `hast-util-sanitize`) — they carry
122
+ * no URL/script/CSS interpretation, so allowing arbitrary values is safe.
123
+ *
124
+ * `srcset` is the one attribute this schema takes AWAY from DOMPurify's default, on
125
+ * purpose. `protocols` validates only the attribute's leading scheme
126
+ * (`hast-util-sanitize`'s `safeProtocol` looks at the text before the first
127
+ * `:`/`/`/`?`/`#`), but `srcset` is a comma-separated candidate list — so a value like
128
+ * `"https://ok.png 1x, //evil.tld/track.png 2x"` cannot be gated by this schema model
129
+ * at all. `defaultSchema` ships `source: ['srcSet']` and keeps `source`/`picture` in
130
+ * `tagNames`, which would leave `srcSet` on `<source>` as the ONLY completely ungated
131
+ * URL sink in the policy: not script execution (browsers do not run `javascript:` from
132
+ * an image source), but an arbitrary tenant-authored third-party request carrying a
133
+ * visitor's IP, UA and Referer. `RESTORED_MEDIA_ATTRIBUTES.source` replaces that list
134
+ * with protocol-gated `src` only.
135
+ *
136
+ * `<iframe>` is the one non-default TAG we allow, and ONLY when its `src` host is on
137
+ * the YouTube allowlist — every other embed (arbitrary iframes) is dropped.
138
+ *
139
+ * FOREIGN CONTENT (SVG/MathML) IS REMOVED BEFORE THE TREE IS EVEN BUILT, and that
140
+ * pre-pass is load-bearing for three separate reasons — see `dropForeignContent`.
141
+ * The policy above already said the SVG and MathML namespaces are not allowed; until
142
+ * this pass existed that was implemented by UNWRAPPING them, which is a different
143
+ * thing and got two of the three wrong.
45
144
  */
46
145
  /** Hosts permitted for `<iframe>` embeds. Exact host match — no suffix tricks. */
47
146
  declare const ALLOWED_YOUTUBE_HOSTS: readonly string[];
48
147
  /** True when `src` is an absolute URL whose host is exactly a YouTube host. */
49
148
  declare function isAllowedYouTubeHost(src: string | null | undefined): boolean;
50
149
  /**
51
- * Sanitise an already-HTML string with the same policy (default-safe tags plus a
52
- * host-gated `<iframe>`). Use for content that arrives as HTML — e.g. page
53
- * `section.contentHtml` from base-api, which is `marked` output that has NOT been
54
- * sanitised server-side.
150
+ * Sanitise an already-HTML string with the same policy (DOMPurify-default-equivalent
151
+ * tags/attributes plus a host-gated `<iframe>`). Use for content that arrives as
152
+ * HTML — e.g. page `section.contentHtml` from base-api, which is `marked` output
153
+ * that has NOT been sanitised server-side.
154
+ *
155
+ * WHAT A PARSE FAILURE DOES, AND WHY. Everything up to `fromParse5` runs BEFORE the
156
+ * sanitiser, so a throw there cannot be caught by anything downstream — and none of
157
+ * this module's callers catch anything at all. There are only two behaviours that keep
158
+ * the function's contract ("the returned string is safe to inject as HTML"):
159
+ * propagate, or return `''`. Propagating turns one malformed block inside one tenant's
160
+ * page into a 500 for the WHOLE route, which is the exact incident this module exists
161
+ * to end. So it fails closed: the block is dropped, the rest of the page renders, and
162
+ * the failure is reported through `console.error` so it is not silent — a dropped
163
+ * block with a log line is recoverable, a 500 is not. Returning the input, escaped or
164
+ * otherwise, is not an option: unparsed input is unsanitised input.
165
+ *
166
+ * This is a backstop, not the fix. The known crash class (foreign-content `<template>`)
167
+ * is removed structurally by `dropForeignContent`; this catch exists because
168
+ * `fromParse5` has already surprised us once.
55
169
  */
56
170
  declare function sanitizeHtml(html: string | null | undefined): string;
57
171
  /** Convert raw Markdown to sanitised, XSS-safe HTML. */
@@ -60,7 +174,7 @@ declare function markdownToSafeHtml(markdown: string | null | undefined): string
60
174
  /**
61
175
  * `MarkdownRenderer` — the canonical Blog body renderer.
62
176
  *
63
- * Pipeline: raw Markdown → `markdownToSafeHtml` (marked → DOMPurify, XSS-safe) →
177
+ * Pipeline: raw Markdown → `markdownToSafeHtml` (marked → hast-util-sanitize, XSS-safe) →
64
178
  * `html-react-parser` into a token-styled React tree. We parse the sanitised HTML
65
179
  * into real React nodes (a component map) rather than using
66
180
  * `dangerouslySetInnerHTML`, so images lazy-load, external links are hardened, and