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