@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 +736 -0
- package/README.md +90 -0
- package/dist/index.cjs +191 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -2
- package/dist/index.d.ts +108 -2
- package/dist/index.js +187 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
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
|