@reblu/site-blocks 0.4.3 → 0.4.4

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.
Files changed (2) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/package.json +5 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,679 @@
1
+ # @reblu/site-blocks
2
+
3
+ ## 0.4.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 9f54c58: REB-774 — ship `CHANGELOG.md` inside the published npm tarball for every
8
+ `@reblu/site-*` package (verified via `pnpm pack`: it was previously absent,
9
+ since `"files": ["dist"]` limited the tarball to `dist/` and npm only
10
+ auto-includes `README.md`, `LICENSE` and `package.json`). Consumers can now
11
+ read the changelog for the version they installed straight from
12
+ `node_modules/@reblu/<pkg>/CHANGELOG.md`.
13
+ - 9f54c58: REB-774 — widen `@reblu/site-client`'s and `@reblu/site-blocks`' internal
14
+ `@reblu/site-*` dependency ranges from `workspace:*` to `workspace:^`, which
15
+ pnpm publishes as a caret range (e.g. `^1.0.0`) instead of an exact pin.
16
+ Verified against the packed tarballs: `site-client`'s `@reblu/site-contracts`
17
+ peerDependency and devDependency, and `site-blocks`' `@reblu/site-contracts`
18
+ and `@reblu/site-ui` dependencies, now publish as `^1.0.0` / `^0.5.0`. A
19
+ consuming SITE on a caret range for those packages no longer risks two copies
20
+ of the same dependency in its tree, or an unmet peer, the next time
21
+ `site-contracts` or `site-ui` ships an additive release.
22
+ - Updated dependencies [9f54c58]
23
+ - @reblu/site-contracts@1.0.1
24
+ - @reblu/site-ui@0.5.1
25
+
26
+ ## 0.4.3
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies [340db30]
31
+ - @reblu/site-contracts@1.0.0
32
+
33
+ ## 0.4.2
34
+
35
+ ### Patch Changes
36
+
37
+ - e91b35e: Correct what the docs promise about `hidden`.
38
+
39
+ `0.4.0`'s changelog said a block marked `hidden` "stays unpublished instead of
40
+ appearing on the live page". The first half is true and the second is not: the
41
+ element is not rendered and not announced to a screen reader, but it is still
42
+ present in the HTML the server sends. Anyone who views source reads it, and so
43
+ does a crawler working on raw HTML.
44
+
45
+ That is precisely what the HTML specification says `hidden` means, so nothing in
46
+ the renderer changes here — only the documentation, which described a stronger
47
+ guarantee than the attribute gives. Measured against the published package:
48
+
49
+ ```
50
+ IN : <p hidden>Unpublished draft</p><p>Published</p>
51
+ OUT: <p hidden="" class="…">Unpublished draft</p><p class="…">Published</p>
52
+ ```
53
+
54
+ The distinction matters because these are tenant-authored legal pages that
55
+ nobody on the platform side proofreads. An author who reads "not-for-publication"
56
+ and leaves half a draft clause behind `hidden` has published it — invisibly, but
57
+ published. The README's authoring notes now say so, and say what to do instead:
58
+ to keep text unpublished, keep it out of the page.
59
+
60
+ Found by the `reblu-site-dermamedicin` session, measuring the installed package
61
+ rather than trusting the changelog.
62
+
63
+ - Updated dependencies [65e124a]
64
+ - @reblu/site-ui@0.5.0
65
+
66
+ ## 0.4.1
67
+
68
+ ### Patch Changes
69
+
70
+ - abf1ab1: Stop `MarkdownRenderer` from silently renumbering an ordered list, and keep the rest of
71
+ what an author wrote about a document's meaning.
72
+
73
+ A privacy policy written as
74
+
75
+ ```md
76
+ 4. The data controller may …
77
+ ```
78
+
79
+ rendered a list starting at **1**. Prose elsewhere on the page saying "as established in
80
+ clause 4" then pointed at the wrong clause. The Markdown pipeline was never at fault —
81
+ `markdownToSafeHtml` emits `<ol start="4">` and `sanitizeHtml` preserves it — but the
82
+ renderer's component map rebuilds each element to apply the tenant's design tokens, and
83
+ it re-emitted only `className`. Everything else the author wrote was dropped on the floor.
84
+ Legal pages are tenant-authored and nobody on the platform side proofreads them, so this
85
+ reached visitors unseen.
86
+
87
+ The map now forwards the authored attributes that change what a document **says**:
88
+ - `start` and `type` on `<ol>`, `value` and `type` on `<li>` — list numbering.
89
+ - `id` — in-document links (`see <a href="#clause-4">`) resolve to a real target again.
90
+ - `hidden` — a block the author marked as not-for-publication stays unpublished instead
91
+ of appearing on the live page.
92
+ - `lang` and `dir` — a passage in another language is announced in that language.
93
+ - `title`, `role`, every `aria-*` and every `data-*`.
94
+
95
+ Deliberately still dropped: `class` and `style`, because presentation belongs to the
96
+ tenant's theme rather than the document body; and `target`/`rel` on links, which the
97
+ renderer sets itself so an external link is always `noopener noreferrer` — an author
98
+ cannot weaken that through this path.
99
+
100
+ `reversed` on `<ol>` remains unavailable: the sanitiser does not allow it, and this
101
+ release does **not** widen the sanitiser's allowlist to change that. Number a descending
102
+ list explicitly with `value` on each `<li>`.
103
+
104
+ Also documented, unchanged behaviour worth knowing about: a single newline inside a
105
+ paragraph is a soft break (whitespace), not a `<br>`. That is standard CommonMark, and it
106
+ is what this renderer has done since it replaced the earlier non-standard one — but text
107
+ typed on consecutive lines under the old behaviour, such as addresses and signature
108
+ blocks, reads differently now. The package README's new "Authoring notes" section shows
109
+ how to write a hard break.
110
+
111
+ ## 0.4.0
112
+
113
+ ### Minor Changes
114
+
115
+ - e5cda17: Make `@reblu/site-blocks` importable in a React Server Component, make its CommonJS entry
116
+ render, and replace the "RSC-safe" comments with gates that fail when the claim stops
117
+ being true.
118
+
119
+ A consuming SITE was stuck between two mutually exclusive configurations. Bundled, its
120
+ `next build` failed collecting `/condiciones`:
121
+
122
+ ```
123
+ TypeError: H.createContext is not a function
124
+ ```
125
+
126
+ Externalized through `serverExternalPackages` — the documented remedy — it built, then
127
+ returned HTTP 500 with `ERR_REQUIRE_ESM: require() of ES Module … domhandler … from
128
+ html-react-parser` on `/blog` **and on `/`**. There was no consumer-side setting that
129
+ satisfied both, so the site shipped a plain-text interim renderer and its legal pages
130
+ displayed raw Markdown (`##`, `**`) to visitors. Three defects, one release.
131
+
132
+ ## 1. Radix Slot in the Server Component graph
133
+
134
+ `_page-parts.tsx` — reachable from the RSC-safe root entry, and the module that calls
135
+ `sanitizeHtml`, which must run server-side (REB-613) — rendered
136
+ `<Button asChild><a …/></Button>`. `Button` imports `@radix-ui/react-slot`, and
137
+ `@radix-ui/react-slot@1.3.1` opens with `var SlotContext = React.createContext(mergeProps)`
138
+ at module scope. React's `react-server` build exports no `createContext`, so the entry
139
+ throws on import, before rendering anything.
140
+
141
+ `asChild` was doing exactly one job here: paint a link with the button's class list.
142
+ `buttonVariants` does that with no React and no Radix in its graph, so `SectionCta` emits
143
+ the anchor directly. The rendered element is unchanged — `organisms.test.tsx` builds the
144
+ reference from `Button` itself and asserts attribute-for-attribute equality, so a future
145
+ change to the Button's `data-*` set or classes fails rather than silently drifting.
146
+
147
+ **No public API changed.** `Button` still ships from `@reblu/site-ui`'s root.
148
+
149
+ ## 2. Two new `@reblu/site-ui` subpaths (minor, additive)
150
+
151
+ Seven of the eight component files in `@reblu/site-blocks` imported **only** `cn` — four
152
+ lines of `clsx` + `tailwind-merge` — and had to take the root barrel to get it, resolving
153
+ `@radix-ui/react-slot` and `class-variance-authority` along the way. There was no import
154
+ that avoided it.
155
+
156
+ | subpath | exports | imports |
157
+ | -------------------------------- | ---------------- | -------------------------- |
158
+ | `@reblu/site-ui/utils` | `cn` | `clsx`, `tailwind-merge` |
159
+ | `@reblu/site-ui/button-variants` | `buttonVariants` | `class-variance-authority` |
160
+
161
+ Neither imports React. Both remain exported from the root, so this is additive:
162
+ `import { cn } from '@reblu/site-ui'` keeps working. This is the fourth time the answer to
163
+ a barrel dragging UI into a server surface has been an isolated subpath (REB-469's
164
+ `./cache`, REB-622's JSON-LD entry, and now these two) — worth stating as a package rule.
165
+
166
+ `@radix-ui/react-slot` is also pinned EXACTLY (`1.2.4`, previously `^1.2.4`). `1.2.4` is
167
+ what this repo already resolved and what every test here is green on, so the pin changes
168
+ no behaviour; it removes a consumer's ability to resolve 1.3.1 for a package whose
169
+ server-evaluability we are asserting.
170
+
171
+ ## 3. `html-react-parser` pinned to `5.2.17` (exact), and its CommonJS entry fixed
172
+
173
+ The whole 6.x line depends on `domhandler@6`, which is `"type": "module"` with no
174
+ `require` condition. `^6.1.5` resolved to `6.1.7` — the range, not the resolution, was the
175
+ defect, and the production trace names `html-react-parser@6.1.7` in the failing path, so
176
+ moving forward inside 6.x was never an available fix. `5.2.17` pins `domhandler` to
177
+ exactly `5.0.3` and `html-dom-parser` to exactly `5.1.8`, both of which ship a `require`
178
+ condition. The pin is exact because an external SITE resolves this range on its own
179
+ machine, where our CI cannot see it.
180
+
181
+ **What the downgrade gives up.** An earlier draft of this changeset called 5.2.17 "an
182
+ identical parser API", and that was not true. 6.0.0's breaking changes were indeed only
183
+ those dependency majors plus an `es5` → `es2016` target, but 6.1.x added two things that
184
+ do not exist in 5.2.17:
185
+
186
+ | lost | added in | what it is |
187
+ | --------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
188
+ | `options.trustedTypePolicy` | 6.1.0 | Trusted Types / CSP support. Unused here. |
189
+ | `normalizeDOMNodes` | 6.1.1 | `Object.setPrototypeOf(node, DomHandlerElement.prototype)` before iterating, so `node instanceof Element` survives a duplicated `domhandler`. |
190
+
191
+ The second one is not academic. `markdown-renderer.tsx` gates its **entire** component map
192
+ on `if (!(node instanceof Element)) return undefined`. If that ever returned false there
193
+ would be no crash — just silently unstyled default HTML: no heading offset (a duplicate
194
+ `<h1>` per post), no `loading="lazy"`, and no `rel="noopener noreferrer"` on external
195
+ links. A silent, security-relevant degradation on tenant-authored content.
196
+
197
+ This is accepted, not dismissed. 5.2.17 and its `html-dom-parser@5.1.8` both pin
198
+ `domhandler` to exactly `5.0.3`, so a second 5.x copy needs a third package to introduce
199
+ one. Not reproduced, and not fixed here — but a maintainer reading this must not be
200
+ surprised by it later.
201
+
202
+ **What the trace does and does not say.** 54 occurrences on 2026-09-09, `bundler:
203
+ turbopack`, and `routes=/blog.rsc, /` — the home page was affected too, so the blast
204
+ radius was wider than the issue recorded. The outer frame is Turbopack's external-module
205
+ wrapper, which reads `raw = await import(id)` inside a try/catch that re-throws: it
206
+ _reports_ the failure, it does not cause it. The `require()` that fails is Node's own,
207
+ inside `html-react-parser`'s CommonJS graph loading `domhandler`. **Why `require(esm)` was
208
+ not in effect on that lambda is still open** — Node 24 has it on by default, and the
209
+ project's `nodeVersion: "24.x"` is what it declares today, not necessarily what ran on
210
+ 2026-09-09. That question is deliberately left open rather than written closed, which is
211
+ the same discipline the rest of this change is about. What does not depend on the answer:
212
+ a package whose graph reaches an ESM-only module is loadable only where `require(esm)`
213
+ happens to be available, and `dependencies` is the one place we can remove the
214
+ "happens to".
215
+
216
+ Building the gate for that surfaced a **second, independent defect, present on 6.1.5 too**:
217
+ `dist/index.cjs` could never render Markdown at all. `html-react-parser`'s CommonJS build
218
+ is `tsc` output (`exports.default = …`, `__esModule` set, `module.exports` left as the
219
+ exports object), and Node's CJS→ESM interop binds a default import to `module.exports`
220
+ itself — so `import parse from 'html-react-parser'` resolved to that object and calling it
221
+ threw `TypeError: (0, import_html_react_parser.default) is not a function` on first render.
222
+ `dist/index.js` (ESM) was fine, because it resolves the package's `esm/` build. Every
223
+ consumer on the CommonJS entry — which is precisely what `serverExternalPackages` selects
224
+ — had a `MarkdownRenderer` and a `SafeHtml` that threw. `MarkdownRenderer` now composes
225
+ the named exports `htmlToDOM` + `domToReact`, which survive that interop identically in
226
+ both formats and are what the default export calls internally.
227
+
228
+ ## How this is guarded
229
+
230
+ `packages/site-ui/test/rsc-safety.test.ts` and `packages/site-blocks/test/rsc-safety.test.ts`
231
+ build through the real tsup config and load each RSC-safe entry in a child process started
232
+ with `--conditions=react-server`, then render the exported components there. Each child
233
+ first asserts `React.createContext === undefined`, so a lost flag is a red test rather than
234
+ a silent downgrade to the ordinary client React the rest of the suite already exercises.
235
+
236
+ That behavioural layer alone would **not** have caught this, and saying so is the point.
237
+ The module that threw was `@radix-ui/react-slot@1.3.1`; this repo's lockfile resolves
238
+ 1.2.4. A child process here loads the graph _we_ resolved, not the graph the SITE
239
+ installed, so it would have stayed green for the entire outage. A distributable cannot
240
+ verify a property that depends on a consumer's resolution — the property has to be made
241
+ resolution-independent. So a second layer asserts, from the emitted artifact, that the
242
+ RSC-safe entries reach for no `@radix-ui/*` package, and that the only `@reblu/site-ui`
243
+ entries they take are the two `site-ui`'s own gate certifies free of React and Radix
244
+ (`./utils`, `./button-variants`). That holds whichever Slot anyone installs.
245
+
246
+ That second assertion is an **allowlist over the whole `@reblu/site-ui/*` namespace**, and
247
+ not by accident. Banning the root barrel alone bans one specifier: `@reblu/site-ui/tabs`,
248
+ `/form`, `/label` and `/next` all passed it. `./tabs` pulls `@radix-ui/react-tabs`, whose
249
+ module body calls `React.createContext` unconditionally in every version; `./form` and
250
+ `./label` reach a `@radix-ui/react-slot` copy that `site-ui`'s exact pin does not govern,
251
+ because it hangs off `@radix-ui/react-label` → `@radix-ui/react-primitive`. Verified by
252
+ mutation: importing `@reblu/site-ui/label` into `_page-parts.tsx` leaves the `@radix-ui/*`
253
+ assertion, both `react-server` render gates and all fifteen `'use client'` smoke tests
254
+ green — and fails only this one.
255
+
256
+ A third layer, `test/dependency-pins.test.ts` in each package, asserts that the specifiers
257
+ this all rests on are still exact (`/^\d+\.\d+\.\d+$/`) by reading the real `package.json`.
258
+ Nothing else could: every other gate exercises the graph _this_ checkout resolved, so
259
+ `pnpm up` or Renovate with `rangeStrategy: bump` could turn `"1.2.4"` back into `"^1.3.3"`
260
+ and leave the whole suite green while the published manifest handed the caret — the actual
261
+ defect — straight back to the consumer.
262
+
263
+ `test/use-client-smoke.test.ts` previously carried a written exemption saying
264
+ `dist/index.cjs` could not be gated, because the ESM-only chain was "a property of a
265
+ first-class React dependency this package does not control" and was "fine on every runtime
266
+ this repo targets". Both halves were wrong, and both are removed: the entry is now loaded
267
+ AND rendered in a child process under `--no-experimental-require-module`.
268
+
269
+ The `'use client'` smoke test could not have covered any of this. It asserts the
270
+ _directive_ is absent from the root entry — an entry can be directive-free and still be
271
+ impossible to evaluate in a Server Component. The directive is a declaration; the module
272
+ graph is the fact.
273
+
274
+ ## Why `@reblu/site-blocks` is a minor and not a patch
275
+
276
+ Two independent reasons, and the first would settle it on its own.
277
+
278
+ **Additive public API.** `@reblu/site-ui` gains two subpath exports (`./utils`,
279
+ `./button-variants`). New public surface is a minor by definition, and `@reblu/site-blocks`
280
+ ships in the same release. Nothing here ends up over-versioned.
281
+
282
+ **The parser downgrade.** Under strict semver a `patch` is defensible: no exported symbol
283
+ of `@reblu/site-blocks` changed signature or behaviour, and a dependency range is not
284
+ public API. It is still the wrong call, for a reason specific to this package.
285
+
286
+ `html-react-parser` is **externalized** — that is the whole point of the pin — so it is
287
+ resolved from the consumer's tree rather than bundled into ours. The strongest consequence
288
+ has nothing to do with anyone else's dependency graph: it changes **which parser runs
289
+ inside `@reblu/site-blocks`' own output**, for every consumer, whether or not they use
290
+ `html-react-parser` themselves. `MarkdownRenderer` and `SafeHtml` go from 6.1.7 to 5.2.17
291
+ underneath, losing `normalizeDOMNodes`. That is a change in what this package renders,
292
+ arriving through a version bump.
293
+
294
+ The narrower exposure is the **hoisted** case: a consumer with no direct dependency,
295
+ reading a flattened `node_modules`, now finds 5.2.17 at the top level where 6.1.7 used to
296
+ be. A consumer that _does_ declare `html-react-parser` itself is largely insulated — pnpm
297
+ gives it its own isolated copy, and under npm's hoisting its own direct dependency wins at
298
+ the top level. That case is deliberately **not** part of the argument.
299
+
300
+ **And the process half, which is the one that does not depend on any of the above.**
301
+ `patch` is what auto-merge and Renovate's default rules wave through unattended. `minor`
302
+ puts a two-major downgrade of a runtime dependency in front of a human. That is the point
303
+ of choosing it: not that semver forbids `patch`, but that this is a decision someone should
304
+ have to look at.
305
+
306
+ ### Patch Changes
307
+
308
+ - Updated dependencies [e5cda17]
309
+ - Updated dependencies [7dcfb4f]
310
+ - @reblu/site-ui@0.4.0
311
+ - @reblu/site-contracts@0.5.0
312
+
313
+ ## 0.3.1
314
+
315
+ ### Patch Changes
316
+
317
+ - Updated dependencies [eef13bf]
318
+ - @reblu/site-contracts@0.4.0
319
+
320
+ ## 0.3.0
321
+
322
+ ### Minor Changes
323
+
324
+ - 1b0151b: Replace `isomorphic-dompurify` with `hast-util-sanitize` in `sanitizeHtml` /
325
+ `markdownToSafeHtml`. `isomorphic-dompurify` pulls in `jsdom`, whose transitive
326
+ `html-encoding-sniffer` → `@exodus/bytes` chain is ESM-only and fails to
327
+ `require()` under Vercel's Node runtime (`ERR_REQUIRE_ESM`) — every route
328
+ rendering tenant HTML (`/[slug]`, `/services/[slug]`, and any route reachable
329
+ through the catch-all) 500'd in production since 2026-08-06 while CI stayed
330
+ green, because Vitest's jsdom test environment papered over the very module that
331
+ fails to load in prod.
332
+
333
+ The replacement is `parse5` → `hast-util-from-parse5` → `hast-util-sanitize` →
334
+ `hast-util-to-html`, called directly rather than through `unified`/`rehype-*`.
335
+ That is deliberate: `unified().processSync` builds a `VFile`, whose constructor
336
+ calls `process.cwd()`, which makes the bundle acquire bare `path`/`process`/`url`
337
+ imports and reproduces REB-606's exact shape (a build-invisible, runtime-only
338
+ module failure) in a different runtime. `marked` is bundled for the same reason
339
+ (see "Operational"). The published artifact now imports **nothing** outside this
340
+ package's declared runtime surface, in both formats:
341
+
342
+ | entry | runtime imports |
343
+ | ------------------------- | ---------------------------------------------------------- |
344
+ | `dist/index.js` | `@reblu/site-ui`, `html-react-parser`, `react/jsx-runtime` |
345
+ | `dist/index.cjs` | `@reblu/site-ui`, `html-react-parser`, `react/jsx-runtime` |
346
+ | `dist/client.js` / `.cjs` | `@reblu/site-ui`, `react`, `react/jsx-runtime` |
347
+
348
+ `packages/site-blocks/test/use-client-smoke.test.ts` guards that behaviourally,
349
+ not by pinning the list above — see "How the regression is guarded".
350
+
351
+ ## Read this before taking 0.3.0
352
+
353
+ `hast-util-sanitize`'s `defaultSchema` is GitHub's markdown-comment policy, which
354
+ is meaningfully _stricter_ — and in a number of places _wider_ — than
355
+ `isomorphic-dompurify`'s unconfigured default. The schema in this change restores
356
+ DOMPurify's default profile wherever that carries no new injection surface, but
357
+ it is **not** a byte-for-byte reproduction. Everything below was measured by
358
+ differential sweep of both built bundles against `isomorphic-dompurify@3.21.0`,
359
+ not inferred from intent. If tenant `customHtml` (authored via the nexus HTML
360
+ editor, stored per-tenant, not visible from this codebase) relies on anything in
361
+ the "removed" lists, it will render differently after this upgrade — hence
362
+ `minor`.
363
+
364
+ ### Restored (parity with the last month of production)
365
+ - `class`, `style`, `target`, `rel`, `loading`, and `class` specifically on
366
+ `a`/`code`/`h2`/`li`/`ol`/`section`/`ul`, where `defaultSchema` value-restricts
367
+ it per tag and it would otherwise be wiped to `class=""`.
368
+ - Structural/formatting tags: `figure`, `figcaption`, `article`, `aside`, `nav`,
369
+ `header`, `footer`, `main`, `hgroup`, `address`, `small`, `cite`, `mark`,
370
+ `time`, `data`, `abbr`, `acronym`, `dfn`, `bdi`, `bdo`, `caption`, `col`,
371
+ `colgroup`, `center`, `font`, `big`, `u`, `wbr`.
372
+ - `<video>`, `<audio>`, `<track>` — with `src`/`poster` protocol-gated. A
373
+ self-hosted clinic testimonial video would otherwise be silently unwrapped.
374
+ - URL schemes `tel:`, `callto:`, `sms:`, `ftp:`, `ftps:`, `cid:`, `matrix:` on
375
+ `href`. `defaultSchema.protocols.href` omits them, so a `<a href="tel:…">`
376
+ phone CTA lost its `href` and became inert text. (`mailto:` is in both lists,
377
+ which is exactly why a spot check misses this.)
378
+ - Global attributes `hidden`, `download`, `sizes`, `draggable`, `spellcheck`,
379
+ `translate`, `slot`, `part`, `popover`, `inert`, `autocapitalize`,
380
+ `enterkeyhint`. **`hidden` is not a styling nit**: without it, a tenant block
381
+ authored as `<p hidden>` is published visible on deploy.
382
+ - `type` on `<ol>`/`<li>` (list numbering) — restored **per tag** and
383
+ value-restricted to the marker enum (`1 a A i I disc square circle`), not globally.
384
+ `hast-util-sanitize` falls back to the `'*'` list whenever the tag-specific
385
+ value check returns undefined, so a global `type` would silently unpin
386
+ `defaultSchema`'s `input: [['type','checkbox']]` coercion.
387
+ - `role` + the standard `aria-*` set, and arbitrary `data-*`.
388
+ - `id`/`name` are no longer clobber-prefixed, so tenant anchor links keep working.
389
+ - **Uppercase URL schemes.** `hast-util-sanitize` compares the scheme
390
+ case-_sensitively_; DOMPurify's regex is `/i`. `<a href="HTTP://example.com/">`
391
+ silently lost its `href` — and a mixed-case scheme is exactly what a
392
+ Word/Outlook paste produces. The scheme is now lowercased before sanitising.
393
+ This cannot launder a disallowed scheme: `JaVaScRiPt:` normalises to
394
+ `javascript:` and is still rejected. A scheme with leading whitespace or an
395
+ embedded newline is left untouched and keeps failing the allowlist.
396
+
397
+ ### Removed relative to `isomorphic-dompurify` — complete list
398
+
399
+ Tags no longer rendered (**unwrapped**: the element is dropped, its text content
400
+ kept):
401
+ - Media/graphics: `canvas`, `map`, `area`.
402
+ - Form controls: `form`, `button`, `select`, `textarea`, `label`, `legend`,
403
+ `fieldset`, `option`, `optgroup`, `datalist`, `output`, `progress`, `meter`.
404
+ - Structural: `dialog`, `menu`, `search`.
405
+ - Shadow-DOM plumbing: `slot`, `content`, `shadow`, `element`, `decorator`.
406
+ - Obsolete: `dir`, `marquee`, `nobr`, `spacer`, `blink`, `menuitem`.
407
+
408
+ `<input>` is **removed outright, not unwrapped**, unless it is
409
+ `type="checkbox"` — and a surviving checkbox is forced `disabled`. This is the
410
+ GFM task-list marker and the only reason `input` is in the allowlist at all.
411
+ Spelling it out because the mechanism is a trap: `defaultSchema.required` is
412
+ `{input: {disabled: true, type: 'checkbox'}}`, and `required` does **not**
413
+ filter — it FORCES values onto elements that survive. Without an explicit
414
+ removal, `<input type="text" name="q">` came out as
415
+ `<input name="q" disabled type="checkbox">`, and a `<input type="hidden">` field
416
+ a tenant contact form had carried invisibly for a month rendered as a **visible**
417
+ orphan checkbox. Not a security hole (the checkbox is inert) — a content
418
+ regression, and now it does not happen.
419
+
420
+ `<svg>`, `<math>` and everything inside them are **removed with their content**,
421
+ not unwrapped. DOMPurify allowed both namespaces by default, so an inline SVG
422
+ logo or a pasted MathML equation rendered before this release and does not now.
423
+ This is the same decision as before (SVG/MathML are a recurring source of
424
+ real-world sanitiser bypasses via `foreignObject` / namespace confusion), but it
425
+ is now actually implemented: unwrapping dropped the tags and dumped the inner
426
+ text into the page as body copy, which was neither rendering the graphic nor
427
+ removing it. It is also what stops `sanitizeHtml` from THROWING — see
428
+ "Foreign content no longer crashes the render".
429
+
430
+ `object`, `embed`, `html`, `head` and `body` were listed here in an earlier draft.
431
+ That was wrong and is corrected: DOMPurify's default allowlist never contained
432
+ `object`/`embed`, and `html`/`head`/`body` are unwrapped by both sanitisers.
433
+ There is no delta on any of the five.
434
+
435
+ Content-holding elements are removed **with** their content rather than
436
+ unwrapped, matching DOMPurify's `DEFAULT_FORBID_CONTENTS`: `style`, `script`,
437
+ `template`, plus `title`, `xmp`, `noembed`, `noframes`, `plaintext`,
438
+ `selectedcontent`, `desc`, `foreignobject`, `annotation-xml`, `mi`, `mn`, `mo`,
439
+ `ms`, `mtext`. Unwrapping these is a content leak, not a sanitisation:
440
+ `<title>Documento interno</title>` — exactly what a Word / LibreOffice "save as
441
+ HTML" paste carries — was being published as a stray paragraph of tenant body
442
+ copy.
443
+
444
+ One residual difference is disclosed rather than fixed. A **bare** SVG or MathML
445
+ element name written with no `<svg>`/`<math>` parent is parsed as an unknown HTML
446
+ element, so `dropForeignContent` does not see it and the strip list above does not
447
+ name it; this module unwraps it and emits its escaped text, where DOMPurify
448
+ force-removed it. Not XSS — the serialiser escapes text and the tag is gone. The
449
+ complete measured set is `altglyph`, `altglyphdef`, `altglyphitem`,
450
+ `animatecolor`, `animatemotion`, `animatetransform`, `circle`, `clippath`,
451
+ `defs`, `ellipse`, `filter`, `g`, `glyph`, `glyphref`, `hkern`, `line`,
452
+ `lineargradient`, `marker`, `mask`, `menclose`, `merror`, `metadata`, `mfenced`,
453
+ `mfrac`, `mglyph`, `mlabeledtr`, `mmultiscripts`, `mover`, `mpadded`, `mpath`,
454
+ `mphantom`, `mprescripts`, `mroot`, `mrow`, `mspace`, `msqrt`, `mstyle`, `msub`,
455
+ `msubsup`, `msup`, `mtable`, `mtd`, `mtr`, `munder`, `munderover`, `path`,
456
+ `pattern`, `polygon`, `polyline`, `radialgradient`, `rect`, `stop`, `switch`,
457
+ `symbol`, `text`, `textpath`, `tref`, `tspan`, `view`, `vkern` — plus
458
+ `part`, `inputmode`, `enterkeyhint` and `exportparts`, which are attribute names
459
+ rather than real elements but behave the same way when written as tags.
460
+
461
+ Attributes no longer emitted on ordinary elements: `background` (a URL sink
462
+ DOMPurify allowed), `bgcolor`, `face`, `high`, `low`, `optimum`, `pubdate`,
463
+ `crossorigin`, `decoding`, `integrity`, `nonce`, `exportparts`, `popovertarget`,
464
+ `popovertargetaction`, `command`, `commandfor`, `capture`, `inputmode`, `list`,
465
+ `pattern`, `placeholder`, `min`, `max`, `step`, `minlength`, `required`,
466
+ `novalidate`, `reversed`, `radiogroup`, `autocomplete`, `wrap`, `xmlns`,
467
+ `disabled` (outside the pinned task-list checkbox), `autopictureinpicture`,
468
+ `disablepictureinpicture`, `disableremoteplayback`, `controlslist`, `type` on
469
+ anything other than `ol`/`li`/`source`, and `is`. `is` is called out separately:
470
+ DOMPurify allows it, and it is the only one of this group that changes an
471
+ element's _behaviour_ (it binds markup to a registered custom-element class)
472
+ rather than its presentation. Media-specific attributes (`controls`, `autoplay`,
473
+ `loop`, `muted`, `preload`, `playsinline`, `poster`, `kind`, `srclang`,
474
+ `default`, `media`) survive on the media tags listed above, and only there.
475
+
476
+ `data:` image URIs are dropped: `<img src="data:image/png;base64,…">` becomes
477
+ `<img alt="…">`. This is a deliberate decision, not an oversight. DOMPurify
478
+ restricts `data:` to a tag list (`img`/`video`/`audio`/`source`/`track`);
479
+ `hast-util-sanitize`'s `protocols` are attribute-scoped, not tag-scoped, so
480
+ allowing `data:` on `src` would allow it on **every** `src`-bearing tag. If inline
481
+ base64 images turn out to be used, the fix is a per-tag gate, not a protocol
482
+ entry.
483
+
484
+ `srcset` is dropped everywhere — including on `<source>`, which is where
485
+ `defaultSchema` actually puts it (`source: ['srcSet']`, with `source`/`picture`
486
+ both in `tagNames`). `protocols` validates only a single leading scheme, and
487
+ `srcset` is a comma-separated candidate list, so this schema model cannot gate it
488
+ at all; left inherited it would have been the only completely ungated URL sink in
489
+ the policy. Calibrated honestly: that is **not** proven XSS — browsers do not
490
+ execute `javascript:` from an image source — it is an arbitrary tenant-authored
491
+ third-party request carrying a visitor's IP, UA and Referer. Responsive
492
+ `<picture>`/`<source>` art direction stops working; the `<img>` fallback still
493
+ renders.
494
+
495
+ ### Narrowed relative to `defaultSchema` (i.e. this release does not silently widen)
496
+
497
+ `defaultSchema` allows seventeen attributes DOMPurify's default profile drops.
498
+ All seventeen are removed rather than inherited, so this release adds nothing
499
+ that has not been rendering for the last month. The set was measured by rendering
500
+ every `(tag, attribute)` pair in `defaultSchema.attributes` through both
501
+ sanitisers, and it is pinned by test:
502
+ - `longdesc` on `<img>` — the one that is not cosmetic. It is a **URL-bearing**
503
+ attribute DOMPurify dropped, and inheriting it would have added a
504
+ tenant-authored outbound-request sink gated by nothing but `http`/`https`,
505
+ paragraphs below the argument for removing `srcset` on exactly that ground.
506
+ - `accesskey` — lets tenant content steal a browser/AT keyboard shortcut.
507
+ - `itemprop` (on `'*'`) and `itemscope` / `itemtype` (on `div`) — microdata in
508
+ the page's structured-data graph. Dropping only `itemprop` would leave the
509
+ microdata ROOT with its properties stripped.
510
+ - Inert legacy attributes: `abbr`, `accept-charset`, `axis`, `char`, `charoff`,
511
+ `charset`, `compact`, `hspace`, `for` (`htmlFor`), `nohref`, `prompt`, `rules`.
512
+
513
+ An earlier draft of this changeset claimed `for` was "one inherited widening
514
+ knowingly kept" and that it was the only one. Both halves were wrong; there were
515
+ thirteen more, and `for` is now removed with the rest.
516
+
517
+ ### Foreign content no longer crashes the render
518
+
519
+ `hast-util-from-parse5` branches on `tagName === 'template'` alone
520
+ (`lib/index.js:197`) and then recurses into `reference.content`. In foreign
521
+ content `parse5` creates that `template` in the MathML/SVG namespace, where there
522
+ is **no** `.content` DocumentFragment, so the recursion dereferences
523
+ `undefined.nodeName` and throws. `'<math><template>'`, `'<svg><template>'` and
524
+ their variants therefore threw a `TypeError` out of `sanitizeHtml` — before the
525
+ sanitiser ran, and with no `try`/`catch` anywhere on the call path
526
+ (`apps/demo-site/src/app/[slug]/page.tsx`, `services/[slug]/page.tsx`,
527
+ `packages/site-blocks/src/components/_page-parts.tsx`, `MarkdownRenderer`). An
528
+ uncaught throw in a Server Component render is a 500 on that route — REB-606's
529
+ own failure mode through a different door. The trigger is realistic: pasted
530
+ Word/LibreOffice/MathML output and inline SVG sprites are where `<math>`/`<svg>`
531
+ wrappers come from. `isomorphic-dompurify` returned `"<math></math>"` for the same
532
+ input, so this was a regression introduced by the migration, not a pre-existing
533
+ bug.
534
+
535
+ Two things fix it. Foreign content is removed from the `parse5` tree before it is
536
+ converted, so no foreign `<template>` ever reaches the conversion; and a
537
+ `<template>` with no `.content` is removed by name as well, so a future change to
538
+ the namespace rule cannot silently re-open the 500.
539
+
540
+ **What a parse failure does now, deliberately.** Everything up to `fromParse5`
541
+ runs before the sanitiser, so a throw there cannot be caught downstream.
542
+ `sanitizeHtml` therefore fails **closed**: it returns `''` and reports through
543
+ `console.error`. Propagating would turn one malformed block inside one tenant's
544
+ page into a 500 for the whole route, which is the incident this module exists to
545
+ end; returning the input (escaped or not) would emit unsanitised content, which
546
+ the function's contract forbids; returning `''` silently would hide tenant content
547
+ with no signal. A dropped block plus a server-log line is recoverable, a 500 is
548
+ not.
549
+
550
+ ### How the regression is guarded
551
+
552
+ An earlier draft pinned the emitted runtime specifier set to a hardcoded list.
553
+ That does not guard, and it fails silently. Move `hast-util-sanitize` to
554
+ `dependencies` (a reasonable-looking move — it IS a runtime import), delete it
555
+ from `tsup.config.ts`'s `noExternal` (which described itself as "currently
556
+ redundant"), and CI goes red on the exact-match pin; the only sanctioned repair
557
+ is to add the specifier to the expected list, after which the suite is green with
558
+ `require("hast-util-sanitize")` in the shipped CJS, against a package with no
559
+ CommonJS entry point. A "declared dependencies only" layer does not help either:
560
+ step one made it declared.
561
+
562
+ The gate is now three derived layers with nothing to edit:
563
+ 1. Every emitted specifier must be a declared dependency/peer and not a Node
564
+ builtin — this is what catches the `unified`/`vfile` pipeline's bare
565
+ `path`/`process`/`url` imports.
566
+ 2. Every specifier the CommonJS artifacts `require()` must resolve, under the
567
+ `require` condition, to a module Node loads as CommonJS. This is the layer the
568
+ laundering sequence above trips, and it has no list to append to.
569
+ 3. The sanitiser pipeline — built to CommonJS through the real `tsup` config — is
570
+ loaded in a child process started with `--no-experimental-require-module`, and
571
+ has to load AND sanitise. That flag disables `require(esm)`, which is what makes
572
+ the original failure reproducible on any machine rather than only on Vercel:
573
+ `require("isomorphic-dompurify")` under it fails with the exact production
574
+ chain (`ERR_REQUIRE_ESM … @exodus/bytes/encoding-lite.js from …
575
+ html-encoding-sniffer.js`) and loads fine without it. Unlike layer 2 this is
576
+ transitive, so it also catches a revert to `isomorphic-dompurify`, whose own
577
+ entry point is CommonJS.
578
+
579
+ `dist/index.cjs` as a whole cannot carry layer 3, and the exemption is stated
580
+ rather than assumed: `html-react-parser@6` → `html-dom-parser@8` → `domhandler@6`
581
+ is ESM-only, so the full entry needs `require(esm)` regardless of anything this
582
+ package does. That is a property of a first-class React dependency, it is fine on
583
+ every runtime this repo targets (`engines.node: 24.x`; `require(esm)` has been
584
+ unflagged since Node 20.19/22.12), and `demo-site` resolves the ESM entry anyway.
585
+ Bundling `html-react-parser` is not the fix — its `browser`/`node` conditional
586
+ exports would be collapsed to a single branch at build time, breaking the other.
587
+
588
+ ### Scope: which consumers these guarantees reach
589
+
590
+ All of the above describes the STRING `sanitizeHtml` returns. It reaches the page
591
+ unchanged only on the `dangerouslySetInnerHTML` consumers — `page.customHtml` /
592
+ `section.contentHtml` in `apps/demo-site/src/app/[slug]/page.tsx` and
593
+ `services/[slug]/page.tsx`, which is the surface this fix is about.
594
+ `MarkdownRenderer` re-parses that string into a token-styled component map, and
595
+ that map rebuilds most elements from scratch, so on the blog-body path author
596
+ `class`/`id`/`hidden`/`style`/`data-*`/`aria-*` are dropped **downstream of the
597
+ sanitiser** regardless of what the schema allows. That is pre-existing renderer
598
+ behaviour, unchanged here; widening its attribute forwarding is a separate change.
599
+
600
+ ### Unchanged, and worth stating plainly
601
+
602
+ The `style` **attribute** is kept, exactly as `isomorphic-dompurify` kept it, and
603
+ it is **not** validated. `<p style="background:url(https://third-party/x)">`
604
+ still issues an outbound request, and `style="…"` remains an unvalidated CSS
605
+ sink. This is DOMPurify parity, not a regression introduced here; closing it
606
+ needs a CSS-property allowlist and is a separate decision. The `<style>`
607
+ _element_ is a different, wider primitive — a tenant stylesheet applies to the
608
+ whole document and can read values out of markup this sanitiser never saw — and
609
+ stays removed.
610
+
611
+ ### Operational
612
+ - **Bundle size**: `dist/index.js` 19,094 B → 475,101 B (`dist/index.cjs`
613
+ 479,434 B). The growth is `parse5`, `property-information` and `marked`: the
614
+ previous artifact was small only because `isomorphic-dompurify`/`jsdom` were
615
+ resolved at runtime instead of bundled — which is precisely the bug.
616
+ `treeshake` stays off (it strips the `'use client'` prologue), so this is
617
+ untreeshaken.
618
+ - `marked` moves from `dependencies` to `devDependencies` and is bundled
619
+ (~54 KB of the growth above). It was the last ESM-only specifier the sanitiser's
620
+ own module graph reached for — `marked@16` is `"type": "module"` with no
621
+ `require` condition — so while it stayed external the CommonJS artifact could
622
+ not be loaded without `require(esm)`, and the guard in layer 3 above would have
623
+ needed an exemption nobody could justify later. The module already builds its
624
+ own `new Marked()` instance rather than sharing the global singleton, so
625
+ nothing is lost by not sharing the copy with a consumer's `marked`.
626
+ - `apps/demo-site` now carries **two** copies of `hast-util-sanitize`: one
627
+ bundled here, one from its own `rehype-sanitize` dependency
628
+ (`src/components/blog/post-content.tsx`, `packages/ui/src/editor/markdown-viewer.tsx`).
629
+ Deduplicating means converging those call sites on this package's
630
+ `sanitizeHtml`; not done here.
631
+ - MIT-licensed third-party code (`parse5`, `marked`, the `hast-*`/`unist-*`
632
+ utilities) is now inlined into this Apache-2.0 published artifact with no
633
+ `NOTICE` file. Flagged, not solved in this change.
634
+
635
+ ### Patch Changes
636
+
637
+ - Updated dependencies [495d32d]
638
+ - @reblu/site-contracts@0.3.0
639
+
640
+ ## 0.2.3
641
+
642
+ ### Patch Changes
643
+
644
+ - Updated dependencies [d081f12]
645
+ - @reblu/site-contracts@0.2.0
646
+
647
+ ## 0.2.2
648
+
649
+ ### Patch Changes
650
+
651
+ - Updated dependencies [33da822]
652
+ - @reblu/site-ui@0.3.1
653
+
654
+ ## 0.2.1
655
+
656
+ ### Patch Changes
657
+
658
+ - Updated dependencies [b0cbc4c]
659
+ - @reblu/site-ui@0.3.0
660
+
661
+ ## 0.2.0
662
+
663
+ ### Minor Changes
664
+
665
+ - 3b7c958: Scaffold `@reblu/site-blocks` — data-driven, token-driven presentational
666
+ organisms for Reblu SITEs (the layer above `@reblu/site-ui` atoms):
667
+ - `useCollection` — headless filterable / sortable / paginated collection hook
668
+ (facets, category, tag, search) shared by page and blog indexes.
669
+ - `Collection` — token-driven presentational grid over `useCollection`.
670
+ - `MarkdownRenderer` — canonical Blog renderer (`marked` → DOMPurify sanitize →
671
+ component map) with a YouTube-domain allowlist and no `dangerouslySetInnerHTML`.
672
+ - Helpers: `readingTime`, `formatPrice`, `buildShareUrls`.
673
+ - `ShareButtons` and the presentational organisms `Treatment`, `Specialty`,
674
+ `ProfileCard`, `ProductCard`.
675
+
676
+ ### Patch Changes
677
+
678
+ - Updated dependencies [356e04d]
679
+ - @reblu/site-contracts@0.1.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reblu/site-blocks",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Data-driven presentational organisms for Reblu SITEs (Collection, MarkdownRenderer, Treatment, Specialty, ProfileCard, ProductCard) — clean, token-driven, white-label.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Reblu — Adolfo Unturbe",
@@ -26,7 +26,8 @@
26
26
  "type": "module",
27
27
  "sideEffects": false,
28
28
  "files": [
29
- "dist"
29
+ "dist",
30
+ "CHANGELOG.md"
30
31
  ],
31
32
  "main": "./dist/index.cjs",
32
33
  "module": "./dist/index.js",
@@ -59,8 +60,8 @@
59
60
  "dependencies": {
60
61
  "class-variance-authority": "^0.7.1",
61
62
  "html-react-parser": "5.2.17",
62
- "@reblu/site-contracts": "1.0.0",
63
- "@reblu/site-ui": "0.5.0"
63
+ "@reblu/site-contracts": "^1.0.1",
64
+ "@reblu/site-ui": "^0.5.1"
64
65
  },
65
66
  "peerDependencies": {
66
67
  "react": ">=19",