@reblu/site-next 1.16.0 → 1.17.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 +16 -0
- package/README.md +22 -2
- package/dist/consent-server.cjs +65 -0
- package/dist/consent-server.cjs.map +1 -1
- package/dist/consent-server.d.cts +2 -1
- package/dist/consent-server.d.ts +2 -1
- package/dist/consent-server.js +62 -0
- package/dist/consent-server.js.map +1 -1
- package/dist/consent.cjs.map +1 -1
- package/dist/consent.d.cts +4 -63
- package/dist/consent.d.ts +4 -63
- package/dist/{consent-shared-DbdhEXW2.d.cts → texts-DfpyCIJK.d.cts} +63 -1
- package/dist/{consent-shared-DbdhEXW2.d.ts → texts-DfpyCIJK.d.ts} +63 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @reblu/site-next
|
|
2
2
|
|
|
3
|
+
## 1.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ccf8feb: Fix `cookieBannerToConsentTexts` throwing when called from a Server
|
|
8
|
+
Component. `@reblu/site-next/consent` carries a leading `'use client'`
|
|
9
|
+
directive for its providers/components, which made the whole entry —
|
|
10
|
+
including the pure, dependency-free `cookieBannerToConsentTexts` (and
|
|
11
|
+
`mergeConsentBannerTexts`/`DEFAULT_CONSENT_BANNER_TEXTS`/`ConsentBannerTexts`)
|
|
12
|
+
— look client-only to Next's RSC boundary. `next build` failed with
|
|
13
|
+
"Attempted to call cookieBannerToConsentTexts() from the server but
|
|
14
|
+
cookieBannerToConsentTexts is on the client" for the documented root-layout
|
|
15
|
+
recipe. `@reblu/site-next/consent-server` now also re-exports these helpers
|
|
16
|
+
for Server Components to import from; `@reblu/site-next/consent` still
|
|
17
|
+
re-exports them unchanged for existing client-side call sites.
|
|
18
|
+
|
|
3
19
|
## 1.16.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -609,14 +609,34 @@ import { cookiePolicyVersion } from '@reblu/site-next/consent-server'
|
|
|
609
609
|
response's `cookieBanner` field onto `texts` with `cookieBannerToConsentTexts`
|
|
610
610
|
instead of hand-rolling the mapping — it already drops `null` AND
|
|
611
611
|
blank/whitespace-only fields, so an unfilled nexus field falls back to the
|
|
612
|
-
default instead of rendering an empty title or an unlabelled button
|
|
612
|
+
default instead of rendering an empty title or an unlabelled button.
|
|
613
|
+
|
|
614
|
+
Import `cookieBannerToConsentTexts` from **`@reblu/site-next/consent-server`**,
|
|
615
|
+
not from `./consent`, when you call it from a Server Component — the root
|
|
616
|
+
layout that mounts `<ConsentProvider>`/`<CookieBanner>` is one (REB-846).
|
|
617
|
+
`./consent` carries its own leading `'use client'` directive, and with this
|
|
618
|
+
package's `splitting: false` build that directive tags the WHOLE entry, so
|
|
619
|
+
calling any of its exports — even a pure one with no React — from a Server
|
|
620
|
+
Component throws `Attempted to call cookieBannerToConsentTexts() from the
|
|
621
|
+
server but cookieBannerToConsentTexts is on the client`. `cookieBannerToConsentTexts`
|
|
622
|
+
itself has no React/browser dependency, so `./consent-server` (directive-free)
|
|
623
|
+
re-exports the same function for exactly this call site; `<CookieBanner>` the
|
|
624
|
+
*component* still comes from `./consent` as usual — importing a Client
|
|
625
|
+
Component into a Server Component's JSX is fine, it is only the function
|
|
626
|
+
*call* that must happen off the client-tagged module:
|
|
613
627
|
|
|
614
628
|
```tsx
|
|
615
|
-
import { CookieBanner
|
|
629
|
+
import { CookieBanner } from '@reblu/site-next/consent'
|
|
630
|
+
import { cookieBannerToConsentTexts } from '@reblu/site-next/consent-server'
|
|
616
631
|
|
|
617
632
|
<CookieBanner texts={cookieBannerToConsentTexts(analytics?.cookieBanner)} />
|
|
618
633
|
```
|
|
619
634
|
|
|
635
|
+
`DEFAULT_CONSENT_BANNER_TEXTS`, `mergeConsentBannerTexts` and the
|
|
636
|
+
`ConsentBannerTexts` type are available from both `./consent` and
|
|
637
|
+
`./consent-server` for the same reason — use whichever subpath matches where
|
|
638
|
+
the call happens.
|
|
639
|
+
|
|
620
640
|
Even if you build `texts` yourself, `<CookieBanner>` treats a blank or
|
|
621
641
|
whitespace-only value for any key the same as `undefined` — it always falls
|
|
622
642
|
back to that key's default rather than rendering empty. There is no need to
|
package/dist/consent-server.cjs
CHANGED
|
@@ -22,8 +22,11 @@ var consent_server_exports = {};
|
|
|
22
22
|
__export(consent_server_exports, {
|
|
23
23
|
CONSENT_CATEGORIES: () => CONSENT_CATEGORIES,
|
|
24
24
|
CONSENT_COOKIE_NAME: () => CONSENT_COOKIE_NAME,
|
|
25
|
+
DEFAULT_CONSENT_BANNER_TEXTS: () => DEFAULT_CONSENT_BANNER_TEXTS,
|
|
25
26
|
DEFAULT_CONSENT_CATEGORIES: () => DEFAULT_CONSENT_CATEGORIES,
|
|
27
|
+
cookieBannerToConsentTexts: () => cookieBannerToConsentTexts,
|
|
26
28
|
cookiePolicyVersion: () => cookiePolicyVersion,
|
|
29
|
+
mergeConsentBannerTexts: () => mergeConsentBannerTexts,
|
|
27
30
|
parseConsentCookie: () => parseConsentCookie,
|
|
28
31
|
serializeConsentCookie: () => serializeConsentCookie
|
|
29
32
|
});
|
|
@@ -71,6 +74,65 @@ function serializeConsentCookie(payload) {
|
|
|
71
74
|
return JSON.stringify(payload);
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
// src/consent/texts.ts
|
|
78
|
+
var DEFAULT_CONSENT_BANNER_TEXTS = {
|
|
79
|
+
title: "Utilizamos cookies",
|
|
80
|
+
description: "Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, tambi\xE9n usar\xEDamos cookies de anal\xEDtica y marketing para mejorar tu experiencia.",
|
|
81
|
+
acceptLabel: "Aceptar todas",
|
|
82
|
+
rejectLabel: "Rechazar todas",
|
|
83
|
+
manageLabel: "Configurar",
|
|
84
|
+
policyLinkLabel: "Pol\xEDtica de cookies",
|
|
85
|
+
preferencesTitle: "Preferencias de cookies",
|
|
86
|
+
preferencesDescription: "Elige qu\xE9 categor\xEDas de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.",
|
|
87
|
+
saveLabel: "Guardar preferencias",
|
|
88
|
+
necessaryLabel: "Necesarias",
|
|
89
|
+
necessaryDescription: "Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.",
|
|
90
|
+
categoryLabels: {
|
|
91
|
+
analytics: "Anal\xEDtica",
|
|
92
|
+
marketing: "Marketing",
|
|
93
|
+
preferences: "Preferencias"
|
|
94
|
+
},
|
|
95
|
+
categoryDescriptions: {
|
|
96
|
+
analytics: "Nos ayudan a entender c\xF3mo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).",
|
|
97
|
+
marketing: "Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).",
|
|
98
|
+
preferences: "Recuerdan tus preferencias de navegaci\xF3n entre visitas."
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var CONSENT_BANNER_TEXT_KEYS = [
|
|
102
|
+
"title",
|
|
103
|
+
"description",
|
|
104
|
+
"acceptLabel",
|
|
105
|
+
"rejectLabel",
|
|
106
|
+
"manageLabel",
|
|
107
|
+
"policyLinkLabel",
|
|
108
|
+
"preferencesTitle",
|
|
109
|
+
"preferencesDescription",
|
|
110
|
+
"saveLabel",
|
|
111
|
+
"necessaryLabel",
|
|
112
|
+
"necessaryDescription"
|
|
113
|
+
];
|
|
114
|
+
function mergeConsentBannerTexts(overrides) {
|
|
115
|
+
const merged = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
|
|
116
|
+
for (const key of CONSENT_BANNER_TEXT_KEYS) {
|
|
117
|
+
const value = overrides?.[key];
|
|
118
|
+
const isUnset = value == null || typeof value === "string" && value.trim() === "";
|
|
119
|
+
if (isUnset) {
|
|
120
|
+
merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return merged;
|
|
124
|
+
}
|
|
125
|
+
function cookieBannerToConsentTexts(cookieBanner) {
|
|
126
|
+
if (!cookieBanner) return {};
|
|
127
|
+
const overrides = {};
|
|
128
|
+
if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title;
|
|
129
|
+
if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body;
|
|
130
|
+
if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel;
|
|
131
|
+
if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel;
|
|
132
|
+
if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel;
|
|
133
|
+
return overrides;
|
|
134
|
+
}
|
|
135
|
+
|
|
74
136
|
// src/consent-server.ts
|
|
75
137
|
function cookiePolicyVersion(policy) {
|
|
76
138
|
if (!policy) return null;
|
|
@@ -80,8 +142,11 @@ function cookiePolicyVersion(policy) {
|
|
|
80
142
|
0 && (module.exports = {
|
|
81
143
|
CONSENT_CATEGORIES,
|
|
82
144
|
CONSENT_COOKIE_NAME,
|
|
145
|
+
DEFAULT_CONSENT_BANNER_TEXTS,
|
|
83
146
|
DEFAULT_CONSENT_CATEGORIES,
|
|
147
|
+
cookieBannerToConsentTexts,
|
|
84
148
|
cookiePolicyVersion,
|
|
149
|
+
mergeConsentBannerTexts,
|
|
85
150
|
parseConsentCookie,
|
|
86
151
|
serializeConsentCookie
|
|
87
152
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/consent-server.ts","../src/consent-shared.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n} from './consent-shared'\n\n/**\n * `@reblu/site-next/consent-server` — the full server-side surface of cookie\n * consent (REB-642): everything in `./consent-shared` (cookie name, category\n * taxonomy, fail-closed parse/serialise), PLUS `cookiePolicyVersion()`, the\n * one function in this whole mechanism that needs a real dependency\n * (`node:crypto`, a Node builtin — not an npm package).\n *\n * This is the PUBLIC subpath (`@reblu/site-next/consent-server`) — a SITE's\n * server-side code (Route Handler, Server Component) imports from here.\n * `./consent-shared` re-exported below is intentionally the ONLY thing the\n * CLIENT entry (`./consent`) imports from; see that file's doc comment for\n * why `cookiePolicyVersion` and its `node:crypto` import must never be\n * reachable from a client component, even transitively.\n */\n\nexport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n}\n\n/**\n * A content hash of the tenant's cookie policy, standing in for a stored\n * version string (REB-642 §7.2). A hash cannot drift the way a manually\n * bumped version number can: a tenant editing the policy text automatically\n * produces a new version, and `./consent`'s provider re-asks consent the\n * moment the version it holds no longer matches. `null` in, `null` out — a\n * tenant with no cookie policy stored has no version to pin.\n *\n * SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)\n * and passes the resulting string to `<ConsentProvider policyVersion>` as a\n * plain prop. Nothing under `@reblu/site-next/consent` calls this function or\n * imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.\n *\n * First 12 hex characters of SHA-256, matching the length asserted by this\n * function's own test and short enough to store cheaply alongside the\n * categories in the cookie.\n */\nexport function cookiePolicyVersion(policy: string | null | undefined): string | null {\n if (!policy) return null\n return createHash('sha256').update(policy, 'utf8').digest('hex').slice(0, 12)\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA2B;;;AC0CpB,IAAM,sBAAsB;AAQ5B,IAAM,qBAAqB,CAAC,aAAa,aAAa,aAAa;AAgBnE,IAAM,6BAA6D,OAAO;AAAA,EAC/E,mBAAmB;AAAA,IACjB,CAAC,OAAO,cAAc,EAAE,GAAG,OAAO,CAAC,QAAQ,GAAG,MAAM;AAAA,IACpD,CAAC;AAAA,EACH;AACF;AAEA,SAAS,cAAc,OAAkD;AACvE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAaA,SAAS,gBAAgB,OAA6C;AACpE,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAElC,QAAM,SAAS,CAAC;AAChB,aAAW,YAAY,oBAAoB;AACzC,UAAM,MAAM,MAAM,QAAQ;AAC1B,QAAI,OAAO,QAAQ,UAAW,QAAO;AACrC,WAAO,QAAQ,IAAI;AAAA,EACrB;AACA,SAAO;AACT;AAOO,SAAS,mBAAmB,KAA6D;AAC9F,MAAI,CAAC,IAAK,QAAO;AAEjB,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AAEjC,QAAM,aAAa,gBAAgB,KAAK,UAAU;AAClD,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,gBAAgB,KAAK;AAC3B,MAAI,kBAAkB,QAAQ,OAAO,kBAAkB,SAAU,QAAO;AAExE,SAAO,EAAE,YAAY,cAAc;AACrC;AAGO,SAAS,uBAAuB,SAAuC;AAC5E,SAAO,KAAK,UAAU,OAAO;AAC/B;;;ADzEO,SAAS,oBAAoB,QAAkD;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAO,+BAAW,QAAQ,EAAE,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AAC9E;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/consent-server.ts","../src/consent-shared.ts","../src/consent/texts.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n} from './consent-shared'\nimport {\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n} from './consent/texts'\n\n/**\n * `@reblu/site-next/consent-server` — the full server-side surface of cookie\n * consent (REB-642): everything in `./consent-shared` (cookie name, category\n * taxonomy, fail-closed parse/serialise), `cookiePolicyVersion()` (the one\n * function in this mechanism that needs a real dependency — `node:crypto`, a\n * Node builtin, not an npm package), PLUS the `./consent/texts` helpers\n * (REB-846): `DEFAULT_CONSENT_BANNER_TEXTS`, `mergeConsentBannerTexts`,\n * `cookieBannerToConsentTexts` and the `ConsentBannerTexts` type.\n *\n * This is the PUBLIC subpath (`@reblu/site-next/consent-server`) — a SITE's\n * server-side code (Route Handler, Server Component — e.g. the root layout\n * that computes `<CookieBanner texts={...}>`) imports from here.\n *\n * The `./consent/texts` re-export exists because `./consent` (the CLIENT\n * entry) carries its OWN leading `'use client'` directive, and with this\n * package's `splitting: false` build that directive covers the WHOLE entry —\n * every name it exports, including `cookieBannerToConsentTexts`, which is\n * itself pure (no React, no browser API). Next's RSC runtime does not\n * distinguish \"pure helper\" from \"the rest of the client module it shipped\n * from\": calling ANY export of a `'use client'`-tagged module from a Server\n * Component throws \"Attempted to call … from the server but … is on the\n * client\" (REB-846). `./consent/texts` has zero React/browser imports of its\n * own, so re-exporting it here — a directive-free entry — lets a Server\n * Component call it directly. `./consent` keeps re-exporting the same names\n * too, for a Client Component that already has the barrel imported; see that\n * file's doc comment for why it must never import from THIS file (the other\n * direction: `cookiePolicyVersion`'s `node:crypto` must never reach the\n * browser bundle).\n */\n\nexport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n}\n\n/**\n * A content hash of the tenant's cookie policy, standing in for a stored\n * version string (REB-642 §7.2). A hash cannot drift the way a manually\n * bumped version number can: a tenant editing the policy text automatically\n * produces a new version, and `./consent`'s provider re-asks consent the\n * moment the version it holds no longer matches. `null` in, `null` out — a\n * tenant with no cookie policy stored has no version to pin.\n *\n * SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)\n * and passes the resulting string to `<ConsentProvider policyVersion>` as a\n * plain prop. Nothing under `@reblu/site-next/consent` calls this function or\n * imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.\n *\n * First 12 hex characters of SHA-256, matching the length asserted by this\n * function's own test and short enough to store cheaply alongside the\n * categories in the cookie.\n */\nexport function cookiePolicyVersion(policy: string | null | undefined): string | null {\n if (!policy) return null\n return createHash('sha256').update(policy, 'utf8').digest('hex').slice(0, 12)\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","import type { CookieBanner } from '@reblu/site-contracts'\n\nimport type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n\n/**\n * The string-valued keys of `ConsentBannerTexts` — every key EXCEPT\n * `categoryLabels`/`categoryDescriptions`, which are nested `Record`s, not\n * plain copy. `mergeConsentBannerTexts` only applies its blank-is-unset rule\n * to these.\n */\nconst CONSENT_BANNER_TEXT_KEYS = [\n 'title',\n 'description',\n 'acceptLabel',\n 'rejectLabel',\n 'manageLabel',\n 'policyLinkLabel',\n 'preferencesTitle',\n 'preferencesDescription',\n 'saveLabel',\n 'necessaryLabel',\n 'necessaryDescription',\n] as const satisfies readonly (keyof ConsentBannerTexts)[]\n\n/**\n * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).\n *\n * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —\n * nexus persists `\"\"` for a cookie-banner copy field nobody filled in (see\n * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered\n * an empty title, empty description and three blank buttons instead of the\n * SDK's Spanish defaults. An empty or whitespace-only string here means the\n * SAME THING as `undefined`: \"no override — use the default.\"\n *\n * Same for a PRESENT key holding `undefined` or `null` (R3-001 review\n * finding): the spread copies that value right over the default because the\n * key still exists on `overrides`, so `value == null` is checked first,\n * before the string/blank check, rather than relying on `typeof value ===\n * 'string'` to fall through.\n */\nexport function mergeConsentBannerTexts(\n overrides?: Partial<ConsentBannerTexts>,\n): ConsentBannerTexts {\n const merged: ConsentBannerTexts = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n for (const key of CONSENT_BANNER_TEXT_KEYS) {\n const value = overrides?.[key]\n const isUnset = value == null || (typeof value === 'string' && value.trim() === '')\n if (isUnset) {\n merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key]\n }\n }\n return merged\n}\n\n/**\n * Maps the `/api/site/analytics` `cookieBanner` contract shape onto\n * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping\n * `null` and blank fields so a SITE never has to hand-roll this mapping (and\n * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`\n * already applies on the way in).\n *\n * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;\n * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —\n * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is\n * `<CookieBanner policyHref>`, not a text key.\n */\nexport function cookieBannerToConsentTexts(\n cookieBanner:\n | Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'>\n | null\n | undefined,\n): Partial<ConsentBannerTexts> {\n if (!cookieBanner) return {}\n\n const overrides: Partial<ConsentBannerTexts> = {}\n if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title\n if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body\n if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel\n if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel\n if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel\n return overrides\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { C as CONSENT_CATEGORIES, a as CONSENT_COOKIE_NAME, b as
|
|
1
|
+
export { C as CONSENT_CATEGORIES, a as CONSENT_COOKIE_NAME, b as ConsentBannerTexts, c as ConsentCategory, d as ConsentCategoryState, e as ConsentCookiePayload, D as DEFAULT_CONSENT_BANNER_TEXTS, f as DEFAULT_CONSENT_CATEGORIES, g as cookieBannerToConsentTexts, m as mergeConsentBannerTexts, p as parseConsentCookie, s as serializeConsentCookie } from './texts-DfpyCIJK.cjs';
|
|
2
|
+
import '@reblu/site-contracts';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A content hash of the tenant's cookie policy, standing in for a stored
|
package/dist/consent-server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { C as CONSENT_CATEGORIES, a as CONSENT_COOKIE_NAME, b as
|
|
1
|
+
export { C as CONSENT_CATEGORIES, a as CONSENT_COOKIE_NAME, b as ConsentBannerTexts, c as ConsentCategory, d as ConsentCategoryState, e as ConsentCookiePayload, D as DEFAULT_CONSENT_BANNER_TEXTS, f as DEFAULT_CONSENT_CATEGORIES, g as cookieBannerToConsentTexts, m as mergeConsentBannerTexts, p as parseConsentCookie, s as serializeConsentCookie } from './texts-DfpyCIJK.js';
|
|
2
|
+
import '@reblu/site-contracts';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A content hash of the tenant's cookie policy, standing in for a stored
|
package/dist/consent-server.js
CHANGED
|
@@ -42,6 +42,65 @@ function serializeConsentCookie(payload) {
|
|
|
42
42
|
return JSON.stringify(payload);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// src/consent/texts.ts
|
|
46
|
+
var DEFAULT_CONSENT_BANNER_TEXTS = {
|
|
47
|
+
title: "Utilizamos cookies",
|
|
48
|
+
description: "Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, tambi\xE9n usar\xEDamos cookies de anal\xEDtica y marketing para mejorar tu experiencia.",
|
|
49
|
+
acceptLabel: "Aceptar todas",
|
|
50
|
+
rejectLabel: "Rechazar todas",
|
|
51
|
+
manageLabel: "Configurar",
|
|
52
|
+
policyLinkLabel: "Pol\xEDtica de cookies",
|
|
53
|
+
preferencesTitle: "Preferencias de cookies",
|
|
54
|
+
preferencesDescription: "Elige qu\xE9 categor\xEDas de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.",
|
|
55
|
+
saveLabel: "Guardar preferencias",
|
|
56
|
+
necessaryLabel: "Necesarias",
|
|
57
|
+
necessaryDescription: "Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.",
|
|
58
|
+
categoryLabels: {
|
|
59
|
+
analytics: "Anal\xEDtica",
|
|
60
|
+
marketing: "Marketing",
|
|
61
|
+
preferences: "Preferencias"
|
|
62
|
+
},
|
|
63
|
+
categoryDescriptions: {
|
|
64
|
+
analytics: "Nos ayudan a entender c\xF3mo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).",
|
|
65
|
+
marketing: "Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).",
|
|
66
|
+
preferences: "Recuerdan tus preferencias de navegaci\xF3n entre visitas."
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var CONSENT_BANNER_TEXT_KEYS = [
|
|
70
|
+
"title",
|
|
71
|
+
"description",
|
|
72
|
+
"acceptLabel",
|
|
73
|
+
"rejectLabel",
|
|
74
|
+
"manageLabel",
|
|
75
|
+
"policyLinkLabel",
|
|
76
|
+
"preferencesTitle",
|
|
77
|
+
"preferencesDescription",
|
|
78
|
+
"saveLabel",
|
|
79
|
+
"necessaryLabel",
|
|
80
|
+
"necessaryDescription"
|
|
81
|
+
];
|
|
82
|
+
function mergeConsentBannerTexts(overrides) {
|
|
83
|
+
const merged = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
|
|
84
|
+
for (const key of CONSENT_BANNER_TEXT_KEYS) {
|
|
85
|
+
const value = overrides?.[key];
|
|
86
|
+
const isUnset = value == null || typeof value === "string" && value.trim() === "";
|
|
87
|
+
if (isUnset) {
|
|
88
|
+
merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return merged;
|
|
92
|
+
}
|
|
93
|
+
function cookieBannerToConsentTexts(cookieBanner) {
|
|
94
|
+
if (!cookieBanner) return {};
|
|
95
|
+
const overrides = {};
|
|
96
|
+
if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title;
|
|
97
|
+
if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body;
|
|
98
|
+
if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel;
|
|
99
|
+
if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel;
|
|
100
|
+
if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel;
|
|
101
|
+
return overrides;
|
|
102
|
+
}
|
|
103
|
+
|
|
45
104
|
// src/consent-server.ts
|
|
46
105
|
function cookiePolicyVersion(policy) {
|
|
47
106
|
if (!policy) return null;
|
|
@@ -50,8 +109,11 @@ function cookiePolicyVersion(policy) {
|
|
|
50
109
|
export {
|
|
51
110
|
CONSENT_CATEGORIES,
|
|
52
111
|
CONSENT_COOKIE_NAME,
|
|
112
|
+
DEFAULT_CONSENT_BANNER_TEXTS,
|
|
53
113
|
DEFAULT_CONSENT_CATEGORIES,
|
|
114
|
+
cookieBannerToConsentTexts,
|
|
54
115
|
cookiePolicyVersion,
|
|
116
|
+
mergeConsentBannerTexts,
|
|
55
117
|
parseConsentCookie,
|
|
56
118
|
serializeConsentCookie
|
|
57
119
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/consent-server.ts","../src/consent-shared.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n} from './consent-shared'\n\n/**\n * `@reblu/site-next/consent-server` — the full server-side surface of cookie\n * consent (REB-642): everything in `./consent-shared` (cookie name, category\n * taxonomy, fail-closed parse/serialise), PLUS `cookiePolicyVersion()`, the\n * one function in this whole mechanism that needs a real dependency\n * (`node:crypto`, a Node builtin — not an npm package).\n *\n * This is the PUBLIC subpath (`@reblu/site-next/consent-server`) — a SITE's\n * server-side code (Route Handler, Server Component) imports from here.\n * `./consent-shared` re-exported below is intentionally the ONLY thing the\n * CLIENT entry (`./consent`) imports from; see that file's doc comment for\n * why `cookiePolicyVersion` and its `node:crypto` import must never be\n * reachable from a client component, even transitively.\n */\n\nexport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n}\n\n/**\n * A content hash of the tenant's cookie policy, standing in for a stored\n * version string (REB-642 §7.2). A hash cannot drift the way a manually\n * bumped version number can: a tenant editing the policy text automatically\n * produces a new version, and `./consent`'s provider re-asks consent the\n * moment the version it holds no longer matches. `null` in, `null` out — a\n * tenant with no cookie policy stored has no version to pin.\n *\n * SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)\n * and passes the resulting string to `<ConsentProvider policyVersion>` as a\n * plain prop. Nothing under `@reblu/site-next/consent` calls this function or\n * imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.\n *\n * First 12 hex characters of SHA-256, matching the length asserted by this\n * function's own test and short enough to store cheaply alongside the\n * categories in the cookie.\n */\nexport function cookiePolicyVersion(policy: string | null | undefined): string | null {\n if (!policy) return null\n return createHash('sha256').update(policy, 'utf8').digest('hex').slice(0, 12)\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":";AAAA,SAAS,kBAAkB;;;AC0CpB,IAAM,sBAAsB;AAQ5B,IAAM,qBAAqB,CAAC,aAAa,aAAa,aAAa;AAgBnE,IAAM,6BAA6D,OAAO;AAAA,EAC/E,mBAAmB;AAAA,IACjB,CAAC,OAAO,cAAc,EAAE,GAAG,OAAO,CAAC,QAAQ,GAAG,MAAM;AAAA,IACpD,CAAC;AAAA,EACH;AACF;AAEA,SAAS,cAAc,OAAkD;AACvE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAaA,SAAS,gBAAgB,OAA6C;AACpE,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAElC,QAAM,SAAS,CAAC;AAChB,aAAW,YAAY,oBAAoB;AACzC,UAAM,MAAM,MAAM,QAAQ;AAC1B,QAAI,OAAO,QAAQ,UAAW,QAAO;AACrC,WAAO,QAAQ,IAAI;AAAA,EACrB;AACA,SAAO;AACT;AAOO,SAAS,mBAAmB,KAA6D;AAC9F,MAAI,CAAC,IAAK,QAAO;AAEjB,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AAEjC,QAAM,aAAa,gBAAgB,KAAK,UAAU;AAClD,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,gBAAgB,KAAK;AAC3B,MAAI,kBAAkB,QAAQ,OAAO,kBAAkB,SAAU,QAAO;AAExE,SAAO,EAAE,YAAY,cAAc;AACrC;AAGO,SAAS,uBAAuB,SAAuC;AAC5E,SAAO,KAAK,UAAU,OAAO;AAC/B;;;ADzEO,SAAS,oBAAoB,QAAkD;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,WAAW,QAAQ,EAAE,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AAC9E;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/consent-server.ts","../src/consent-shared.ts","../src/consent/texts.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n} from './consent-shared'\nimport {\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n} from './consent/texts'\n\n/**\n * `@reblu/site-next/consent-server` — the full server-side surface of cookie\n * consent (REB-642): everything in `./consent-shared` (cookie name, category\n * taxonomy, fail-closed parse/serialise), `cookiePolicyVersion()` (the one\n * function in this mechanism that needs a real dependency — `node:crypto`, a\n * Node builtin, not an npm package), PLUS the `./consent/texts` helpers\n * (REB-846): `DEFAULT_CONSENT_BANNER_TEXTS`, `mergeConsentBannerTexts`,\n * `cookieBannerToConsentTexts` and the `ConsentBannerTexts` type.\n *\n * This is the PUBLIC subpath (`@reblu/site-next/consent-server`) — a SITE's\n * server-side code (Route Handler, Server Component — e.g. the root layout\n * that computes `<CookieBanner texts={...}>`) imports from here.\n *\n * The `./consent/texts` re-export exists because `./consent` (the CLIENT\n * entry) carries its OWN leading `'use client'` directive, and with this\n * package's `splitting: false` build that directive covers the WHOLE entry —\n * every name it exports, including `cookieBannerToConsentTexts`, which is\n * itself pure (no React, no browser API). Next's RSC runtime does not\n * distinguish \"pure helper\" from \"the rest of the client module it shipped\n * from\": calling ANY export of a `'use client'`-tagged module from a Server\n * Component throws \"Attempted to call … from the server but … is on the\n * client\" (REB-846). `./consent/texts` has zero React/browser imports of its\n * own, so re-exporting it here — a directive-free entry — lets a Server\n * Component call it directly. `./consent` keeps re-exporting the same names\n * too, for a Client Component that already has the barrel imported; see that\n * file's doc comment for why it must never import from THIS file (the other\n * direction: `cookiePolicyVersion`'s `node:crypto` must never reach the\n * browser bundle).\n */\n\nexport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n}\n\n/**\n * A content hash of the tenant's cookie policy, standing in for a stored\n * version string (REB-642 §7.2). A hash cannot drift the way a manually\n * bumped version number can: a tenant editing the policy text automatically\n * produces a new version, and `./consent`'s provider re-asks consent the\n * moment the version it holds no longer matches. `null` in, `null` out — a\n * tenant with no cookie policy stored has no version to pin.\n *\n * SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)\n * and passes the resulting string to `<ConsentProvider policyVersion>` as a\n * plain prop. Nothing under `@reblu/site-next/consent` calls this function or\n * imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.\n *\n * First 12 hex characters of SHA-256, matching the length asserted by this\n * function's own test and short enough to store cheaply alongside the\n * categories in the cookie.\n */\nexport function cookiePolicyVersion(policy: string | null | undefined): string | null {\n if (!policy) return null\n return createHash('sha256').update(policy, 'utf8').digest('hex').slice(0, 12)\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","import type { CookieBanner } from '@reblu/site-contracts'\n\nimport type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n\n/**\n * The string-valued keys of `ConsentBannerTexts` — every key EXCEPT\n * `categoryLabels`/`categoryDescriptions`, which are nested `Record`s, not\n * plain copy. `mergeConsentBannerTexts` only applies its blank-is-unset rule\n * to these.\n */\nconst CONSENT_BANNER_TEXT_KEYS = [\n 'title',\n 'description',\n 'acceptLabel',\n 'rejectLabel',\n 'manageLabel',\n 'policyLinkLabel',\n 'preferencesTitle',\n 'preferencesDescription',\n 'saveLabel',\n 'necessaryLabel',\n 'necessaryDescription',\n] as const satisfies readonly (keyof ConsentBannerTexts)[]\n\n/**\n * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).\n *\n * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —\n * nexus persists `\"\"` for a cookie-banner copy field nobody filled in (see\n * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered\n * an empty title, empty description and three blank buttons instead of the\n * SDK's Spanish defaults. An empty or whitespace-only string here means the\n * SAME THING as `undefined`: \"no override — use the default.\"\n *\n * Same for a PRESENT key holding `undefined` or `null` (R3-001 review\n * finding): the spread copies that value right over the default because the\n * key still exists on `overrides`, so `value == null` is checked first,\n * before the string/blank check, rather than relying on `typeof value ===\n * 'string'` to fall through.\n */\nexport function mergeConsentBannerTexts(\n overrides?: Partial<ConsentBannerTexts>,\n): ConsentBannerTexts {\n const merged: ConsentBannerTexts = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n for (const key of CONSENT_BANNER_TEXT_KEYS) {\n const value = overrides?.[key]\n const isUnset = value == null || (typeof value === 'string' && value.trim() === '')\n if (isUnset) {\n merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key]\n }\n }\n return merged\n}\n\n/**\n * Maps the `/api/site/analytics` `cookieBanner` contract shape onto\n * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping\n * `null` and blank fields so a SITE never has to hand-roll this mapping (and\n * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`\n * already applies on the way in).\n *\n * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;\n * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —\n * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is\n * `<CookieBanner policyHref>`, not a text key.\n */\nexport function cookieBannerToConsentTexts(\n cookieBanner:\n | Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'>\n | null\n | undefined,\n): Partial<ConsentBannerTexts> {\n if (!cookieBanner) return {}\n\n const overrides: Partial<ConsentBannerTexts> = {}\n if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title\n if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body\n if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel\n if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel\n if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel\n return overrides\n}\n"],"mappings":"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","names":[]}
|
package/dist/consent.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/consent/index.ts","../src/consent/context.tsx","../src/consent-shared.ts","../src/consent/gate.tsx","../src/analytics.tsx","../src/json-ld.tsx","../src/consent/consented-analytics.tsx","../src/consent/banner.tsx","../src/consent/texts.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/consent` — REB-642's consent mechanism.\n *\n * This barrel MUST begin with its OWN leading `'use client'` directive: tsup\n * only preserves a directive prologue declared by the entry file itself, not\n * one merely re-exported from a child module (see the `cta-buttons` /\n * `auth` R4 precedent in `../../tsup.config.ts`).\n *\n * See `docs/architecture/cookie-consent.md` for the full design.\n *\n * Deliberately does NOT re-export `CONSENT_CATEGORIES`, `CONSENT_COOKIE_NAME`,\n * `ConsentCategory` or anything else from `../consent-server` (or\n * `../consent-shared`) here — even though every one of those individual names\n * is itself dependency-free. This package's `treeshake: false` build (see the\n * R4 note in `../../tsup.config.ts`) bundles a re-exporting barrel's IMPORTED\n * FILES wholesale, and keeping this barrel's only source-level edge to\n * anything under `../consent-server*` at zero is what makes that provable by\n * `tests/consent-subpath-isolation.test.ts` rather than merely asserted by\n * this comment. A caller that needs the category taxonomy or the cookie name\n * imports them directly from `@reblu/site-next/consent-server`, which stays\n * the one full public surface (client + server share the same values).\n */\nexport {\n ConsentProvider,\n useConsent,\n type ConsentStatus,\n type ConsentContextValue,\n type ConsentProviderProps,\n} from './context'\nexport { ConsentGate, type ConsentGateProps } from './gate'\nexport { ConsentedAnalytics } from './consented-analytics'\nexport {\n CookieBanner,\n CookiePreferencesTrigger,\n type CookieBannerProps,\n type CookiePreferencesTriggerProps,\n} from './banner'\nexport {\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n} from './texts'\n","'use client'\n\nimport { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n // Deliberately `../consent-shared`, NEVER `../consent-server`: the latter\n // pulls in `cookiePolicyVersion` and its `node:crypto` import, which must\n // never reach this client entry — see `../consent-shared.ts`'s doc comment.\n} from '../consent-shared'\n\n/**\n * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent\n * (REB-642). See `../consent-shared` for the fail-closed parsing this relies\n * on (imported from there, not `../consent-server`, to keep `node:crypto` out\n * of this client entry), and `docs/architecture/cookie-consent.md` for the\n * full design.\n *\n * `status: 'unknown'` is the DEFAULT-DENIED state and the value every\n * `<ConsentGate>` treats as \"not granted\" — see `./gate.tsx`. It covers three\n * cases identically, on purpose: no cookie yet, a cookie that failed\n * `parseConsentCookie`, and a cookie recorded under a `policyVersion` that no\n * longer matches (the tenant edited the cookie policy — REB-642's \"re-ask when\n * the version changes\"). None of the three may ever resolve to granted.\n */\n\n/** Whether the visitor's choice for the CURRENT policy version is known. */\nexport type ConsentStatus = 'unknown' | 'resolved'\n\nexport interface ConsentContextValue {\n status: ConsentStatus\n categories: ConsentCategoryState\n /** True only when `status === 'resolved'` AND the category was explicitly granted. */\n isGranted: (category: ConsentCategory) => boolean\n /** Grants every category and persists immediately. */\n acceptAll: () => void\n /** Denies every category and persists immediately — the denial persists across reloads. */\n rejectAll: () => void\n /** Persists exactly the categories passed — no category is granted implicitly. */\n savePreferences: (categories: ConsentCategoryState) => void\n preferencesOpen: boolean\n openPreferences: () => void\n closePreferences: () => void\n /**\n * Refocuses whatever element had focus immediately before `openPreferences`\n * was called. The preferences dialog is openable from more than one place\n * (the banner's \"Configure\" button, `<CookiePreferencesTrigger>` elsewhere\n * in the tree) with no single `<Dialog.Trigger>` for Radix's own\n * focus-restore to key off, so this is tracked explicitly here — the one\n * funnel every opener already goes through.\n */\n restoreLastFocus: () => void\n}\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport interface ConsentProviderProps {\n children: ReactNode\n /**\n * `cookiePolicyVersion(tenant.cookiePolicy)` from the SITE's analytics\n * config, or `null` when no cookie policy is stored. A stored consent\n * recorded under a DIFFERENT version is treated as unresolved.\n */\n policyVersion: string | null\n}\n\n/** 1 year — matches the retention window discussed in the design proposal §9. */\nconst COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365\n\nfunction readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null\n const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))\n return match ? decodeURIComponent(match[1]) : null\n}\n\nfunction writeCookie(name: string, value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`\n}\n\nfunction allCategories(granted: boolean): ConsentCategoryState {\n return CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: granted }),\n {} as ConsentCategoryState,\n )\n}\n\nexport function ConsentProvider({ children, policyVersion }: ConsentProviderProps) {\n const [status, setStatus] = useState<ConsentStatus>('unknown')\n const [categories, setCategories] = useState<ConsentCategoryState>(DEFAULT_CONSENT_CATEGORIES)\n const [preferencesOpen, setPreferencesOpen] = useState(false)\n const lastFocusedRef = useRef<HTMLElement | null>(null)\n\n // Resolve from the cookie on mount, and whenever the tenant's policy version\n // changes (e.g. a client-navigation that re-fetches SITE config). `document`\n // is unavailable during SSR, so this is deliberately an effect, not a\n // useState initializer — the first render is always 'unknown', avoiding a\n // server/client markup mismatch, and resolves one tick later if a valid\n // cookie exists.\n useEffect(() => {\n const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME))\n if (parsed && parsed.policyVersion === policyVersion) {\n setCategories(parsed.categories)\n setStatus('resolved')\n } else {\n setCategories(DEFAULT_CONSENT_CATEGORIES)\n setStatus('unknown')\n }\n }, [policyVersion])\n\n function persist(next: ConsentCategoryState) {\n writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }))\n setCategories(next)\n setStatus('resolved')\n setPreferencesOpen(false)\n }\n\n const value = useMemo<ConsentContextValue>(\n () => ({\n status,\n categories,\n isGranted: (category) => status === 'resolved' && categories[category] === true,\n acceptAll: () => persist(allCategories(true)),\n rejectAll: () => persist(allCategories(false)),\n savePreferences: (next) => persist(next),\n preferencesOpen,\n openPreferences: () => {\n lastFocusedRef.current =\n typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null\n setPreferencesOpen(true)\n },\n closePreferences: () => setPreferencesOpen(false),\n restoreLastFocus: () => lastFocusedRef.current?.focus(),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [status, categories, preferencesOpen, policyVersion],\n )\n\n return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>\n}\n\n/** Throws outside a `<ConsentProvider>` — a silent default would be a silent consent bypass. */\nexport function useConsent(): ConsentContextValue {\n const ctx = useContext(ConsentContext)\n if (!ctx) throw new Error('useConsent() must be used within a <ConsentProvider>')\n return ctx\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","'use client'\n\nimport type { ReactNode } from 'react'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport type { ConsentCategory } from '../consent-shared'\nimport { useConsent } from './context'\n\nexport interface ConsentGateProps {\n category: ConsentCategory\n children: ReactNode\n}\n\n/**\n * `<ConsentGate>` — the allowlist enforced by construction that REB-642's\n * design settles on: a third party is never rendered except through a\n * component that names a consent category, and a denied or UNRESOLVED\n * category renders `null`.\n *\n * The `status !== 'resolved'` branch matters as much as the denied branch: a\n * gate that rendered children while consent is still `'unknown'` would load\n * the third party on every fresh visitor for the split second before their\n * cookie is read, which is exactly the race `docs/architecture/cookie-consent.md`\n * §6.1 designs around by building tags inside a client component instead of\n * passing a server-rendered `<AnalyticsScripts>` through as `children`.\n */\nexport function ConsentGate({ category, children }: ConsentGateProps) {\n const { status, isGranted } = useConsent()\n\n if (status !== 'resolved') return null\n if (!isGranted(category)) return null\n\n return <>{children}</>\n}\n","import Script from 'next/script'\n\nimport { jsonLdScriptContent } from './json-ld'\n\n/**\n * `<AnalyticsScripts>` — safe rendering of tenant-configured analytics tags (REB-617).\n *\n * THE HOLE THIS CLOSES. Every SITE built on this SDK interpolated the tenant's\n * analytics ids straight into the body of an executing `<script>`:\n *\n * gtag('config','${analytics.gaId}');\n * …\"clarity\",\"script\",\"${analytics.clarityId}\");\n *\n * No validation, no escaping. A `gaId` of `x');alert(document.cookie);//` closes\n * the string literal, closes the call, comments out the rest, and runs arbitrary\n * JS on the TENANT'S OWN ORIGIN — on every page including checkout, same origin\n * as the portal session cookie. For a medical tenant that is patient-session\n * territory. It is the same trust boundary as the nexus-authored HTML that\n * REB-613 closed on the sink next door, except this one reached the JS\n * interpreter with no sanitiser anywhere in the path.\n *\n * TWO INDEPENDENT LAYERS, because either alone is one refactor away from failing:\n *\n * 1. VALIDATION — an id that does not match its vendor-documented shape is\n * dropped and NO script renders. Analytics silently does not load; the site\n * keeps working. Failing closed matters more than failing loud here: the\n * value is cosmetic, the sink is not.\n * 2. ESCAPING — `analyticsScriptLiteral` emits the id as a JSON string literal\n * with the `<script>` boundary neutralised, so even a value that skipped\n * layer 1 (a future field with a looser shape, a caller that reaches for the\n * builders directly) is inert DATA rather than code.\n *\n * WHY VALIDATION ALONE IS NOT ENOUGH, and escaping alone is not either: layer 1\n * is a policy that a product decision can widen (someone adds `GT-` ids, or a\n * Meta Pixel field whose shape is numeric-but-unbounded), and layer 2 is a\n * mechanism that cannot express \"this is not a usable analytics id\". Keeping both\n * means widening the policy never reopens the sink.\n *\n * This lives in the SDK, not in `apps/demo-site`, because every external SITE repo\n * consuming `@reblu/site-contracts` carries the identical snippet — publishing the\n * safe component is the only way the fix reaches them.\n */\n\n/**\n * GA4 measurement id: `G-` followed by alphanumeric characters.\n *\n * Vendor source: Google Analytics Help 12270356 (\"formatted as 'G-' followed by\n * alphanumeric characters\", example `G-PSW1MY7HB4`).\n *\n * NO MINIMUM LENGTH, deliberately. The familiar id is ten characters, but Google\n * documents no length at all and its own docs print `gtag('config', 'G-12345')`\n * (Help 11259997) — inventing a floor here would reject an id Google itself\n * publishes. The upper bound is sanity, not security: the `[A-Z0-9]` class already\n * excludes every character that could leave a JS string literal, so length adds\n * nothing to the guarantee.\n *\n * Uppercase only — every documented example is uppercase, and a lowercase variant\n * is not a Google-issued id. Google tag ids also exist as `GT-` and `AW-`\n * (Help 12326985), but this field is specifically the GA4 *measurement id*\n * (the nexus form labels it so, and the SITE reads it as `gaId`); accepting the\n * other prefixes is a product decision, not a security one, and is deliberately\n * left out of this fix.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/\n\n/**\n * Microsoft Clarity project id: lowercase alphanumeric.\n *\n * Vendor source: Microsoft Learn `clarity-setup` publishes a live demo project\n * under id `3t0wlogvdz`; every id Microsoft prints is lowercase alphanumeric of\n * about ten characters. Microsoft documents no formal grammar, so — as with GA —\n * the bound is generous and the character class carries the guarantee.\n *\n * RESIDUAL RISK, stated plainly: no vendor page enumerates the legal Clarity\n * alphabet, so this is inferred from published examples. If some tenant holds an\n * id containing a character outside `[a-z0-9]`, this rejects it. That is why the\n * API coerces a non-matching id to `null` instead of erroring — analytics stops\n * loading for that tenant, the site keeps serving, and the misconfiguration is\n * visible and fixable rather than an outage.\n */\nexport const CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/\n\n/** True only for a string matching the vendor-documented GA4 measurement id shape. */\nexport function isValidGaMeasurementId(value: unknown): value is string {\n return typeof value === 'string' && GA_MEASUREMENT_ID_PATTERN.test(value)\n}\n\n/** True only for a string matching the vendor-documented Clarity project id shape. */\nexport function isValidClarityProjectId(value: unknown): value is string {\n return typeof value === 'string' && CLARITY_PROJECT_ID_PATTERN.test(value)\n}\n\n/**\n * Serialises `id` as a JS string literal safe to embed in a `<script>` body.\n *\n * Reuses `jsonLdScriptContent` rather than growing a second escaper: a JSON string\n * literal IS a valid JS string literal, and that helper already does the two things\n * required here — `JSON.stringify` for the quote/backslash/newline boundary, plus\n * `<` `>` `&` U+2028 U+2029 escaping for the HTML raw-text boundary that\n * `JSON.stringify` leaves wide open. One audited primitive, one place to fix.\n */\nexport function analyticsScriptLiteral(id: string): string {\n return jsonLdScriptContent(id)\n}\n\n/** Body of the GA4 bootstrap script. Always escapes; never trusts its caller. */\nexport function gaInitScript(gaId: string): string {\n return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`\n}\n\n/** Body of the Microsoft Clarity bootstrap script. Always escapes; never trusts its caller. */\nexport function clarityInitScript(clarityId: string): string {\n return `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,\"clarity\",\"script\",${analyticsScriptLiteral(clarityId)});`\n}\n\nexport interface AnalyticsScriptsProps {\n /** GA4 measurement id from the SITE analytics contract. Ignored unless it matches `G-…`. */\n gaId?: string | null\n /** Microsoft Clarity project id from the SITE analytics contract. Ignored unless lowercase alphanumeric. */\n clarityId?: string | null\n}\n\n/**\n * Renders the Google Analytics and Microsoft Clarity tags for a tenant, dropping\n * any id that does not match its vendor-documented shape.\n */\nexport function AnalyticsScripts({ gaId, clarityId }: AnalyticsScriptsProps) {\n const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null\n const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null\n\n if (!safeGaId && !safeClarityId) return null\n\n return (\n <>\n {safeGaId && (\n <>\n <Script\n src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`}\n strategy='afterInteractive'\n />\n <Script\n id='ga-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: gaInitScript(safeGaId) }}\n />\n </>\n )}\n\n {safeClarityId && (\n <Script\n id='clarity-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: clarityInitScript(safeClarityId) }}\n />\n )}\n </>\n )\n}\n","/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n","'use client'\n\nimport { AnalyticsScripts, type AnalyticsScriptsProps } from '../analytics'\nimport { ConsentGate } from './gate'\n\n/**\n * `<ConsentedAnalytics>` — the `'analytics'`-gated replacement for rendering\n * `<AnalyticsScripts>` directly in a SITE layout (REB-642 Slice 1).\n *\n * Reuses the REB-617 validation + escaping builders verbatim via\n * `<AnalyticsScripts>` — this component adds NOTHING to the sink-safety\n * argument in `../analytics.tsx`, it only decides WHETHER that already-safe\n * renderer mounts at all. A SITE layout that still imports\n * `@reblu/site-next/analytics` directly bypasses consent entirely, which is\n * exactly what the source-scanning guard (REB-642 Slice 1, demo-site) exists\n * to catch — this component is the mechanism that guard enforces.\n */\nexport function ConsentedAnalytics(props: AnalyticsScriptsProps) {\n return (\n <ConsentGate category='analytics'>\n <AnalyticsScripts {...props} />\n </ConsentGate>\n )\n}\n","'use client'\n\nimport { useEffect, useState, type ReactNode } from 'react'\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button, cn } from '@reblu/site-ui'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport { CONSENT_CATEGORIES, type ConsentCategoryState } from '../consent-shared'\nimport { useConsent } from './context'\nimport { mergeConsentBannerTexts, type ConsentBannerTexts } from './texts'\n\n/**\n * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of\n * REB-642's consent mechanism.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * ARCHITECTURE: ONE DIALOG, TWO OPENERS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `<CookieBanner>` owns rendering the preferences dialog (mount it ONCE, at\n * the root of the SITE layout — it portals to `<body>` regardless of where it\n * is mounted). `<CookiePreferencesTrigger>` is a small, unstyled-by-default\n * button usable ANYWHERE ELSE in the tree (a footer link, a settings page) that\n * opens the SAME dialog via `useConsent().openPreferences()`. Both read the\n * single `preferencesOpen` boolean the `<ConsentProvider>` owns, so there is\n * never more than one dialog instance and never two focus traps competing.\n *\n * This is what REB-642 calls \"permanent withdrawal\" — a visitor's route back\n * to their preferences does not depend on the first-layer banner still being\n * on screen; the banner disappears once resolved, the trigger does not.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE THREE-BUTTON RULE (owner decision, not configurable)\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Reject / Configure / Accept render as three same-variant, same-size buttons\n * in that visual order — no button is styled as more prominent than another.\n * A tenant can override `title`/`description` text (REB-642 §7.1 recommends\n * v1 ship with NO tenant-editable copy at all, but the prop exists so a later\n * slice can add it without touching this component's contract); the buttons,\n * their order, and the category taxonomy are fixed.\n */\n\nexport interface CookieBannerProps {\n /** Overrides merged onto `DEFAULT_CONSENT_BANNER_TEXTS`. */\n texts?: Partial<ConsentBannerTexts>\n /** Where \"Política de cookies\" links to. */\n policyHref?: string\n}\n\nfunction resolveTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts {\n // REB-845 — a blank/whitespace-only override falls back to the SDK default\n // for that key instead of rendering empty. See `mergeConsentBannerTexts`'s\n // doc comment for why: nexus persists `\"\"` for an unfilled cookie-banner\n // copy field, and a naive `{ ...defaults, ...overrides }` spread let that\n // `\"\"` win over the Spanish default.\n return mergeConsentBannerTexts(overrides)\n}\n\nexport function CookieBanner({ texts: textsOverride, policyHref = '/cookies' }: CookieBannerProps) {\n const { status, acceptAll, rejectAll, openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <>\n {status === 'unknown' && (\n <div\n role='region'\n aria-label={texts.title}\n className='fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/80'\n data-slot='cookie-banner'\n >\n <div className='mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>\n <div className='text-sm'>\n <p className='font-semibold'>{texts.title}</p>\n <p className='text-muted-foreground'>\n {texts.description}{' '}\n <a href={policyHref} className='underline underline-offset-2'>\n {texts.policyLinkLabel}\n </a>\n </p>\n </div>\n {/* Three equal-weight buttons, fixed order: reject, configure, accept.\n Same variant/size for all three — rejecting must cost exactly what\n accepting costs (AEPD first-layer requirement, REB-642). */}\n <div className='flex shrink-0 flex-wrap gap-2'>\n <Button type='button' variant='outline' size='sm' onClick={rejectAll}>\n {texts.rejectLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={openPreferences}>\n {texts.manageLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={acceptAll}>\n {texts.acceptLabel}\n </Button>\n </div>\n </div>\n </div>\n )}\n <ConsentPreferencesDialog texts={texts} />\n </>\n )\n}\n\nexport interface CookiePreferencesTriggerProps {\n children?: ReactNode\n className?: string\n texts?: Partial<ConsentBannerTexts>\n}\n\n/**\n * A persistent \"configure cookies\" entry point, safe to place anywhere in the\n * tree (typically a footer link) once `<CookieBanner>` is mounted at the root.\n * Opens the same preferences dialog `<CookieBanner>` owns.\n */\nexport function CookiePreferencesTrigger({\n children,\n className,\n texts: textsOverride,\n}: CookiePreferencesTriggerProps) {\n const { openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <button type='button' onClick={openPreferences} className={className} data-slot='cookie-preferences-trigger'>\n {children ?? `${texts.manageLabel} cookies`}\n </button>\n )\n}\n\ninterface ConsentPreferencesDialogProps {\n texts: ConsentBannerTexts\n}\n\n/**\n * The second layer — a per-category preferences panel. `necessary` is\n * rendered as an always-checked, disabled checkbox for transparency (a\n * visitor can SEE that necessary cookies exist and why) without offering a\n * toggle that has no real effect, which would be dishonest UI.\n */\nfunction ConsentPreferencesDialog({ texts }: ConsentPreferencesDialogProps) {\n const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent()\n const [draft, setDraft] = useState<ConsentCategoryState>(categories)\n\n // Re-seed the draft from the last-saved categories every time the dialog\n // opens, so stale unsaved toggles from a previous open are never carried\n // into the next one.\n useEffect(() => {\n if (preferencesOpen) setDraft(categories)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [preferencesOpen])\n\n return (\n <Dialog.Root\n open={preferencesOpen}\n onOpenChange={(open) => {\n // Radix only calls this for state changes IT initiates (Escape,\n // overlay click, `<Dialog.Close>`) — opening is entirely driven by\n // `useConsent().openPreferences()` from outside, which is also where\n // focus is captured (see `../consent/context.tsx`). So this handler\n // only ever needs to handle the closing transition.\n if (!open) closePreferences()\n }}\n >\n <Dialog.Portal>\n <Dialog.Overlay className='fixed inset-0 z-50 bg-black/50' />\n <Dialog.Content\n className='fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg'\n data-slot='cookie-preferences-dialog'\n onCloseAutoFocus={(event) => {\n event.preventDefault()\n restoreLastFocus()\n }}\n >\n <Dialog.Title className='text-base font-semibold'>{texts.preferencesTitle}</Dialog.Title>\n <Dialog.Description className='mt-1 text-sm text-muted-foreground'>\n {texts.preferencesDescription}\n </Dialog.Description>\n\n <div className='mt-4 flex flex-col gap-3'>\n <CategoryRow\n id='consent-category-necessary'\n label={texts.necessaryLabel}\n description={texts.necessaryDescription}\n checked\n disabled\n onChange={() => undefined}\n />\n {CONSENT_CATEGORIES.map((category) => (\n <CategoryRow\n key={category}\n id={`consent-category-${category}`}\n label={texts.categoryLabels[category]}\n description={texts.categoryDescriptions[category]}\n checked={draft[category]}\n disabled={false}\n onChange={(checked) => setDraft((prev) => ({ ...prev, [category]: checked }))}\n />\n ))}\n </div>\n\n <div className='mt-6 flex justify-end'>\n <Button type='button' size='sm' onClick={() => savePreferences(draft)}>\n {texts.saveLabel}\n </Button>\n </div>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n )\n}\n\ninterface CategoryRowProps {\n id: string\n label: string\n description: string\n checked: boolean\n disabled: boolean\n onChange: (checked: boolean) => void\n}\n\nfunction CategoryRow({ id, label, description, checked, disabled, onChange }: CategoryRowProps) {\n return (\n <div className={cn('flex items-start gap-3', disabled && 'opacity-70')}>\n <input\n id={id}\n type='checkbox'\n checked={checked}\n disabled={disabled}\n onChange={(event) => onChange(event.target.checked)}\n className='mt-1 size-4'\n />\n <label htmlFor={id} className='flex flex-col text-sm'>\n <span className='font-medium'>{label}</span>\n <span className='text-muted-foreground'>{description}</span>\n </label>\n </div>\n )\n}\n","import type { CookieBanner } from '@reblu/site-contracts'\n\nimport type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n\n/**\n * The string-valued keys of `ConsentBannerTexts` — every key EXCEPT\n * `categoryLabels`/`categoryDescriptions`, which are nested `Record`s, not\n * plain copy. `mergeConsentBannerTexts` only applies its blank-is-unset rule\n * to these.\n */\nconst CONSENT_BANNER_TEXT_KEYS = [\n 'title',\n 'description',\n 'acceptLabel',\n 'rejectLabel',\n 'manageLabel',\n 'policyLinkLabel',\n 'preferencesTitle',\n 'preferencesDescription',\n 'saveLabel',\n 'necessaryLabel',\n 'necessaryDescription',\n] as const satisfies readonly (keyof ConsentBannerTexts)[]\n\n/**\n * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).\n *\n * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —\n * nexus persists `\"\"` for a cookie-banner copy field nobody filled in (see\n * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered\n * an empty title, empty description and three blank buttons instead of the\n * SDK's Spanish defaults. An empty or whitespace-only string here means the\n * SAME THING as `undefined`: \"no override — use the default.\"\n *\n * Same for a PRESENT key holding `undefined` or `null` (R3-001 review\n * finding): the spread copies that value right over the default because the\n * key still exists on `overrides`, so `value == null` is checked first,\n * before the string/blank check, rather than relying on `typeof value ===\n * 'string'` to fall through.\n */\nexport function mergeConsentBannerTexts(\n overrides?: Partial<ConsentBannerTexts>,\n): ConsentBannerTexts {\n const merged: ConsentBannerTexts = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n for (const key of CONSENT_BANNER_TEXT_KEYS) {\n const value = overrides?.[key]\n const isUnset = value == null || (typeof value === 'string' && value.trim() === '')\n if (isUnset) {\n merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key]\n }\n }\n return merged\n}\n\n/**\n * Maps the `/api/site/analytics` `cookieBanner` contract shape onto\n * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping\n * `null` and blank fields so a SITE never has to hand-roll this mapping (and\n * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`\n * already applies on the way in).\n *\n * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;\n * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —\n * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is\n * `<CookieBanner policyHref>`, not a text key.\n */\nexport function cookieBannerToConsentTexts(\n cookieBanner:\n | Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'>\n | null\n | undefined,\n): Partial<ConsentBannerTexts> {\n if (!cookieBanner) return {}\n\n const overrides: Partial<ConsentBannerTexts> = {}\n if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title\n if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body\n if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel\n if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel\n if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel\n return overrides\n}\n"],"mappings":"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","names":["import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","Script","import_jsx_runtime","import_react","import_jsx_runtime"]}
|
|
1
|
+
{"version":3,"sources":["../src/consent/index.ts","../src/consent/context.tsx","../src/consent-shared.ts","../src/consent/gate.tsx","../src/analytics.tsx","../src/json-ld.tsx","../src/consent/consented-analytics.tsx","../src/consent/banner.tsx","../src/consent/texts.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/consent` — REB-642's consent mechanism.\n *\n * This barrel MUST begin with its OWN leading `'use client'` directive: tsup\n * only preserves a directive prologue declared by the entry file itself, not\n * one merely re-exported from a child module (see the `cta-buttons` /\n * `auth` R4 precedent in `../../tsup.config.ts`).\n *\n * See `docs/architecture/cookie-consent.md` for the full design.\n *\n * Deliberately does NOT re-export `CONSENT_CATEGORIES`, `CONSENT_COOKIE_NAME`,\n * `ConsentCategory` or anything else from `../consent-server` (or\n * `../consent-shared`) here — even though every one of those individual names\n * is itself dependency-free. This package's `treeshake: false` build (see the\n * R4 note in `../../tsup.config.ts`) bundles a re-exporting barrel's IMPORTED\n * FILES wholesale, and keeping this barrel's only source-level edge to\n * anything under `../consent-server*` at zero is what makes that provable by\n * `tests/consent-subpath-isolation.test.ts` rather than merely asserted by\n * this comment. A caller that needs the category taxonomy or the cookie name\n * imports them directly from `@reblu/site-next/consent-server`, which stays\n * the one full public surface (client + server share the same values).\n *\n * `./texts` (`DEFAULT_CONSENT_BANNER_TEXTS`, `mergeConsentBannerTexts`,\n * `cookieBannerToConsentTexts`, `ConsentBannerTexts`) is re-exported below for\n * a Client Component that already has this barrel imported, but it is ALSO\n * re-exported from `../consent-server` (REB-846) — that is the entry a Server\n * Component (e.g. the root layout computing `<CookieBanner texts={...}>`)\n * MUST use instead: every export of THIS module is tagged `'use client'` by\n * the directive above, so calling `cookieBannerToConsentTexts` imported from\n * here on the server throws, even though the function itself has no React.\n * `./texts` has no React/browser import of its own, which is what makes\n * re-exporting it from the directive-free `../consent-server` entry safe.\n */\nexport {\n ConsentProvider,\n useConsent,\n type ConsentStatus,\n type ConsentContextValue,\n type ConsentProviderProps,\n} from './context'\nexport { ConsentGate, type ConsentGateProps } from './gate'\nexport { ConsentedAnalytics } from './consented-analytics'\nexport {\n CookieBanner,\n CookiePreferencesTrigger,\n type CookieBannerProps,\n type CookiePreferencesTriggerProps,\n} from './banner'\nexport {\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n} from './texts'\n","'use client'\n\nimport { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n // Deliberately `../consent-shared`, NEVER `../consent-server`: the latter\n // pulls in `cookiePolicyVersion` and its `node:crypto` import, which must\n // never reach this client entry — see `../consent-shared.ts`'s doc comment.\n} from '../consent-shared'\n\n/**\n * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent\n * (REB-642). See `../consent-shared` for the fail-closed parsing this relies\n * on (imported from there, not `../consent-server`, to keep `node:crypto` out\n * of this client entry), and `docs/architecture/cookie-consent.md` for the\n * full design.\n *\n * `status: 'unknown'` is the DEFAULT-DENIED state and the value every\n * `<ConsentGate>` treats as \"not granted\" — see `./gate.tsx`. It covers three\n * cases identically, on purpose: no cookie yet, a cookie that failed\n * `parseConsentCookie`, and a cookie recorded under a `policyVersion` that no\n * longer matches (the tenant edited the cookie policy — REB-642's \"re-ask when\n * the version changes\"). None of the three may ever resolve to granted.\n */\n\n/** Whether the visitor's choice for the CURRENT policy version is known. */\nexport type ConsentStatus = 'unknown' | 'resolved'\n\nexport interface ConsentContextValue {\n status: ConsentStatus\n categories: ConsentCategoryState\n /** True only when `status === 'resolved'` AND the category was explicitly granted. */\n isGranted: (category: ConsentCategory) => boolean\n /** Grants every category and persists immediately. */\n acceptAll: () => void\n /** Denies every category and persists immediately — the denial persists across reloads. */\n rejectAll: () => void\n /** Persists exactly the categories passed — no category is granted implicitly. */\n savePreferences: (categories: ConsentCategoryState) => void\n preferencesOpen: boolean\n openPreferences: () => void\n closePreferences: () => void\n /**\n * Refocuses whatever element had focus immediately before `openPreferences`\n * was called. The preferences dialog is openable from more than one place\n * (the banner's \"Configure\" button, `<CookiePreferencesTrigger>` elsewhere\n * in the tree) with no single `<Dialog.Trigger>` for Radix's own\n * focus-restore to key off, so this is tracked explicitly here — the one\n * funnel every opener already goes through.\n */\n restoreLastFocus: () => void\n}\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport interface ConsentProviderProps {\n children: ReactNode\n /**\n * `cookiePolicyVersion(tenant.cookiePolicy)` from the SITE's analytics\n * config, or `null` when no cookie policy is stored. A stored consent\n * recorded under a DIFFERENT version is treated as unresolved.\n */\n policyVersion: string | null\n}\n\n/** 1 year — matches the retention window discussed in the design proposal §9. */\nconst COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365\n\nfunction readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null\n const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))\n return match ? decodeURIComponent(match[1]) : null\n}\n\nfunction writeCookie(name: string, value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`\n}\n\nfunction allCategories(granted: boolean): ConsentCategoryState {\n return CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: granted }),\n {} as ConsentCategoryState,\n )\n}\n\nexport function ConsentProvider({ children, policyVersion }: ConsentProviderProps) {\n const [status, setStatus] = useState<ConsentStatus>('unknown')\n const [categories, setCategories] = useState<ConsentCategoryState>(DEFAULT_CONSENT_CATEGORIES)\n const [preferencesOpen, setPreferencesOpen] = useState(false)\n const lastFocusedRef = useRef<HTMLElement | null>(null)\n\n // Resolve from the cookie on mount, and whenever the tenant's policy version\n // changes (e.g. a client-navigation that re-fetches SITE config). `document`\n // is unavailable during SSR, so this is deliberately an effect, not a\n // useState initializer — the first render is always 'unknown', avoiding a\n // server/client markup mismatch, and resolves one tick later if a valid\n // cookie exists.\n useEffect(() => {\n const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME))\n if (parsed && parsed.policyVersion === policyVersion) {\n setCategories(parsed.categories)\n setStatus('resolved')\n } else {\n setCategories(DEFAULT_CONSENT_CATEGORIES)\n setStatus('unknown')\n }\n }, [policyVersion])\n\n function persist(next: ConsentCategoryState) {\n writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }))\n setCategories(next)\n setStatus('resolved')\n setPreferencesOpen(false)\n }\n\n const value = useMemo<ConsentContextValue>(\n () => ({\n status,\n categories,\n isGranted: (category) => status === 'resolved' && categories[category] === true,\n acceptAll: () => persist(allCategories(true)),\n rejectAll: () => persist(allCategories(false)),\n savePreferences: (next) => persist(next),\n preferencesOpen,\n openPreferences: () => {\n lastFocusedRef.current =\n typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null\n setPreferencesOpen(true)\n },\n closePreferences: () => setPreferencesOpen(false),\n restoreLastFocus: () => lastFocusedRef.current?.focus(),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [status, categories, preferencesOpen, policyVersion],\n )\n\n return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>\n}\n\n/** Throws outside a `<ConsentProvider>` — a silent default would be a silent consent bypass. */\nexport function useConsent(): ConsentContextValue {\n const ctx = useContext(ConsentContext)\n if (!ctx) throw new Error('useConsent() must be used within a <ConsentProvider>')\n return ctx\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","'use client'\n\nimport type { ReactNode } from 'react'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport type { ConsentCategory } from '../consent-shared'\nimport { useConsent } from './context'\n\nexport interface ConsentGateProps {\n category: ConsentCategory\n children: ReactNode\n}\n\n/**\n * `<ConsentGate>` — the allowlist enforced by construction that REB-642's\n * design settles on: a third party is never rendered except through a\n * component that names a consent category, and a denied or UNRESOLVED\n * category renders `null`.\n *\n * The `status !== 'resolved'` branch matters as much as the denied branch: a\n * gate that rendered children while consent is still `'unknown'` would load\n * the third party on every fresh visitor for the split second before their\n * cookie is read, which is exactly the race `docs/architecture/cookie-consent.md`\n * §6.1 designs around by building tags inside a client component instead of\n * passing a server-rendered `<AnalyticsScripts>` through as `children`.\n */\nexport function ConsentGate({ category, children }: ConsentGateProps) {\n const { status, isGranted } = useConsent()\n\n if (status !== 'resolved') return null\n if (!isGranted(category)) return null\n\n return <>{children}</>\n}\n","import Script from 'next/script'\n\nimport { jsonLdScriptContent } from './json-ld'\n\n/**\n * `<AnalyticsScripts>` — safe rendering of tenant-configured analytics tags (REB-617).\n *\n * THE HOLE THIS CLOSES. Every SITE built on this SDK interpolated the tenant's\n * analytics ids straight into the body of an executing `<script>`:\n *\n * gtag('config','${analytics.gaId}');\n * …\"clarity\",\"script\",\"${analytics.clarityId}\");\n *\n * No validation, no escaping. A `gaId` of `x');alert(document.cookie);//` closes\n * the string literal, closes the call, comments out the rest, and runs arbitrary\n * JS on the TENANT'S OWN ORIGIN — on every page including checkout, same origin\n * as the portal session cookie. For a medical tenant that is patient-session\n * territory. It is the same trust boundary as the nexus-authored HTML that\n * REB-613 closed on the sink next door, except this one reached the JS\n * interpreter with no sanitiser anywhere in the path.\n *\n * TWO INDEPENDENT LAYERS, because either alone is one refactor away from failing:\n *\n * 1. VALIDATION — an id that does not match its vendor-documented shape is\n * dropped and NO script renders. Analytics silently does not load; the site\n * keeps working. Failing closed matters more than failing loud here: the\n * value is cosmetic, the sink is not.\n * 2. ESCAPING — `analyticsScriptLiteral` emits the id as a JSON string literal\n * with the `<script>` boundary neutralised, so even a value that skipped\n * layer 1 (a future field with a looser shape, a caller that reaches for the\n * builders directly) is inert DATA rather than code.\n *\n * WHY VALIDATION ALONE IS NOT ENOUGH, and escaping alone is not either: layer 1\n * is a policy that a product decision can widen (someone adds `GT-` ids, or a\n * Meta Pixel field whose shape is numeric-but-unbounded), and layer 2 is a\n * mechanism that cannot express \"this is not a usable analytics id\". Keeping both\n * means widening the policy never reopens the sink.\n *\n * This lives in the SDK, not in `apps/demo-site`, because every external SITE repo\n * consuming `@reblu/site-contracts` carries the identical snippet — publishing the\n * safe component is the only way the fix reaches them.\n */\n\n/**\n * GA4 measurement id: `G-` followed by alphanumeric characters.\n *\n * Vendor source: Google Analytics Help 12270356 (\"formatted as 'G-' followed by\n * alphanumeric characters\", example `G-PSW1MY7HB4`).\n *\n * NO MINIMUM LENGTH, deliberately. The familiar id is ten characters, but Google\n * documents no length at all and its own docs print `gtag('config', 'G-12345')`\n * (Help 11259997) — inventing a floor here would reject an id Google itself\n * publishes. The upper bound is sanity, not security: the `[A-Z0-9]` class already\n * excludes every character that could leave a JS string literal, so length adds\n * nothing to the guarantee.\n *\n * Uppercase only — every documented example is uppercase, and a lowercase variant\n * is not a Google-issued id. Google tag ids also exist as `GT-` and `AW-`\n * (Help 12326985), but this field is specifically the GA4 *measurement id*\n * (the nexus form labels it so, and the SITE reads it as `gaId`); accepting the\n * other prefixes is a product decision, not a security one, and is deliberately\n * left out of this fix.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/\n\n/**\n * Microsoft Clarity project id: lowercase alphanumeric.\n *\n * Vendor source: Microsoft Learn `clarity-setup` publishes a live demo project\n * under id `3t0wlogvdz`; every id Microsoft prints is lowercase alphanumeric of\n * about ten characters. Microsoft documents no formal grammar, so — as with GA —\n * the bound is generous and the character class carries the guarantee.\n *\n * RESIDUAL RISK, stated plainly: no vendor page enumerates the legal Clarity\n * alphabet, so this is inferred from published examples. If some tenant holds an\n * id containing a character outside `[a-z0-9]`, this rejects it. That is why the\n * API coerces a non-matching id to `null` instead of erroring — analytics stops\n * loading for that tenant, the site keeps serving, and the misconfiguration is\n * visible and fixable rather than an outage.\n */\nexport const CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/\n\n/** True only for a string matching the vendor-documented GA4 measurement id shape. */\nexport function isValidGaMeasurementId(value: unknown): value is string {\n return typeof value === 'string' && GA_MEASUREMENT_ID_PATTERN.test(value)\n}\n\n/** True only for a string matching the vendor-documented Clarity project id shape. */\nexport function isValidClarityProjectId(value: unknown): value is string {\n return typeof value === 'string' && CLARITY_PROJECT_ID_PATTERN.test(value)\n}\n\n/**\n * Serialises `id` as a JS string literal safe to embed in a `<script>` body.\n *\n * Reuses `jsonLdScriptContent` rather than growing a second escaper: a JSON string\n * literal IS a valid JS string literal, and that helper already does the two things\n * required here — `JSON.stringify` for the quote/backslash/newline boundary, plus\n * `<` `>` `&` U+2028 U+2029 escaping for the HTML raw-text boundary that\n * `JSON.stringify` leaves wide open. One audited primitive, one place to fix.\n */\nexport function analyticsScriptLiteral(id: string): string {\n return jsonLdScriptContent(id)\n}\n\n/** Body of the GA4 bootstrap script. Always escapes; never trusts its caller. */\nexport function gaInitScript(gaId: string): string {\n return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`\n}\n\n/** Body of the Microsoft Clarity bootstrap script. Always escapes; never trusts its caller. */\nexport function clarityInitScript(clarityId: string): string {\n return `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,\"clarity\",\"script\",${analyticsScriptLiteral(clarityId)});`\n}\n\nexport interface AnalyticsScriptsProps {\n /** GA4 measurement id from the SITE analytics contract. Ignored unless it matches `G-…`. */\n gaId?: string | null\n /** Microsoft Clarity project id from the SITE analytics contract. Ignored unless lowercase alphanumeric. */\n clarityId?: string | null\n}\n\n/**\n * Renders the Google Analytics and Microsoft Clarity tags for a tenant, dropping\n * any id that does not match its vendor-documented shape.\n */\nexport function AnalyticsScripts({ gaId, clarityId }: AnalyticsScriptsProps) {\n const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null\n const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null\n\n if (!safeGaId && !safeClarityId) return null\n\n return (\n <>\n {safeGaId && (\n <>\n <Script\n src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`}\n strategy='afterInteractive'\n />\n <Script\n id='ga-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: gaInitScript(safeGaId) }}\n />\n </>\n )}\n\n {safeClarityId && (\n <Script\n id='clarity-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: clarityInitScript(safeClarityId) }}\n />\n )}\n </>\n )\n}\n","/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n","'use client'\n\nimport { AnalyticsScripts, type AnalyticsScriptsProps } from '../analytics'\nimport { ConsentGate } from './gate'\n\n/**\n * `<ConsentedAnalytics>` — the `'analytics'`-gated replacement for rendering\n * `<AnalyticsScripts>` directly in a SITE layout (REB-642 Slice 1).\n *\n * Reuses the REB-617 validation + escaping builders verbatim via\n * `<AnalyticsScripts>` — this component adds NOTHING to the sink-safety\n * argument in `../analytics.tsx`, it only decides WHETHER that already-safe\n * renderer mounts at all. A SITE layout that still imports\n * `@reblu/site-next/analytics` directly bypasses consent entirely, which is\n * exactly what the source-scanning guard (REB-642 Slice 1, demo-site) exists\n * to catch — this component is the mechanism that guard enforces.\n */\nexport function ConsentedAnalytics(props: AnalyticsScriptsProps) {\n return (\n <ConsentGate category='analytics'>\n <AnalyticsScripts {...props} />\n </ConsentGate>\n )\n}\n","'use client'\n\nimport { useEffect, useState, type ReactNode } from 'react'\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button, cn } from '@reblu/site-ui'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport { CONSENT_CATEGORIES, type ConsentCategoryState } from '../consent-shared'\nimport { useConsent } from './context'\nimport { mergeConsentBannerTexts, type ConsentBannerTexts } from './texts'\n\n/**\n * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of\n * REB-642's consent mechanism.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * ARCHITECTURE: ONE DIALOG, TWO OPENERS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `<CookieBanner>` owns rendering the preferences dialog (mount it ONCE, at\n * the root of the SITE layout — it portals to `<body>` regardless of where it\n * is mounted). `<CookiePreferencesTrigger>` is a small, unstyled-by-default\n * button usable ANYWHERE ELSE in the tree (a footer link, a settings page) that\n * opens the SAME dialog via `useConsent().openPreferences()`. Both read the\n * single `preferencesOpen` boolean the `<ConsentProvider>` owns, so there is\n * never more than one dialog instance and never two focus traps competing.\n *\n * This is what REB-642 calls \"permanent withdrawal\" — a visitor's route back\n * to their preferences does not depend on the first-layer banner still being\n * on screen; the banner disappears once resolved, the trigger does not.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE THREE-BUTTON RULE (owner decision, not configurable)\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Reject / Configure / Accept render as three same-variant, same-size buttons\n * in that visual order — no button is styled as more prominent than another.\n * A tenant can override `title`/`description` text (REB-642 §7.1 recommends\n * v1 ship with NO tenant-editable copy at all, but the prop exists so a later\n * slice can add it without touching this component's contract); the buttons,\n * their order, and the category taxonomy are fixed.\n */\n\nexport interface CookieBannerProps {\n /** Overrides merged onto `DEFAULT_CONSENT_BANNER_TEXTS`. */\n texts?: Partial<ConsentBannerTexts>\n /** Where \"Política de cookies\" links to. */\n policyHref?: string\n}\n\nfunction resolveTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts {\n // REB-845 — a blank/whitespace-only override falls back to the SDK default\n // for that key instead of rendering empty. See `mergeConsentBannerTexts`'s\n // doc comment for why: nexus persists `\"\"` for an unfilled cookie-banner\n // copy field, and a naive `{ ...defaults, ...overrides }` spread let that\n // `\"\"` win over the Spanish default.\n return mergeConsentBannerTexts(overrides)\n}\n\nexport function CookieBanner({ texts: textsOverride, policyHref = '/cookies' }: CookieBannerProps) {\n const { status, acceptAll, rejectAll, openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <>\n {status === 'unknown' && (\n <div\n role='region'\n aria-label={texts.title}\n className='fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/80'\n data-slot='cookie-banner'\n >\n <div className='mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>\n <div className='text-sm'>\n <p className='font-semibold'>{texts.title}</p>\n <p className='text-muted-foreground'>\n {texts.description}{' '}\n <a href={policyHref} className='underline underline-offset-2'>\n {texts.policyLinkLabel}\n </a>\n </p>\n </div>\n {/* Three equal-weight buttons, fixed order: reject, configure, accept.\n Same variant/size for all three — rejecting must cost exactly what\n accepting costs (AEPD first-layer requirement, REB-642). */}\n <div className='flex shrink-0 flex-wrap gap-2'>\n <Button type='button' variant='outline' size='sm' onClick={rejectAll}>\n {texts.rejectLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={openPreferences}>\n {texts.manageLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={acceptAll}>\n {texts.acceptLabel}\n </Button>\n </div>\n </div>\n </div>\n )}\n <ConsentPreferencesDialog texts={texts} />\n </>\n )\n}\n\nexport interface CookiePreferencesTriggerProps {\n children?: ReactNode\n className?: string\n texts?: Partial<ConsentBannerTexts>\n}\n\n/**\n * A persistent \"configure cookies\" entry point, safe to place anywhere in the\n * tree (typically a footer link) once `<CookieBanner>` is mounted at the root.\n * Opens the same preferences dialog `<CookieBanner>` owns.\n */\nexport function CookiePreferencesTrigger({\n children,\n className,\n texts: textsOverride,\n}: CookiePreferencesTriggerProps) {\n const { openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <button type='button' onClick={openPreferences} className={className} data-slot='cookie-preferences-trigger'>\n {children ?? `${texts.manageLabel} cookies`}\n </button>\n )\n}\n\ninterface ConsentPreferencesDialogProps {\n texts: ConsentBannerTexts\n}\n\n/**\n * The second layer — a per-category preferences panel. `necessary` is\n * rendered as an always-checked, disabled checkbox for transparency (a\n * visitor can SEE that necessary cookies exist and why) without offering a\n * toggle that has no real effect, which would be dishonest UI.\n */\nfunction ConsentPreferencesDialog({ texts }: ConsentPreferencesDialogProps) {\n const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent()\n const [draft, setDraft] = useState<ConsentCategoryState>(categories)\n\n // Re-seed the draft from the last-saved categories every time the dialog\n // opens, so stale unsaved toggles from a previous open are never carried\n // into the next one.\n useEffect(() => {\n if (preferencesOpen) setDraft(categories)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [preferencesOpen])\n\n return (\n <Dialog.Root\n open={preferencesOpen}\n onOpenChange={(open) => {\n // Radix only calls this for state changes IT initiates (Escape,\n // overlay click, `<Dialog.Close>`) — opening is entirely driven by\n // `useConsent().openPreferences()` from outside, which is also where\n // focus is captured (see `../consent/context.tsx`). So this handler\n // only ever needs to handle the closing transition.\n if (!open) closePreferences()\n }}\n >\n <Dialog.Portal>\n <Dialog.Overlay className='fixed inset-0 z-50 bg-black/50' />\n <Dialog.Content\n className='fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg'\n data-slot='cookie-preferences-dialog'\n onCloseAutoFocus={(event) => {\n event.preventDefault()\n restoreLastFocus()\n }}\n >\n <Dialog.Title className='text-base font-semibold'>{texts.preferencesTitle}</Dialog.Title>\n <Dialog.Description className='mt-1 text-sm text-muted-foreground'>\n {texts.preferencesDescription}\n </Dialog.Description>\n\n <div className='mt-4 flex flex-col gap-3'>\n <CategoryRow\n id='consent-category-necessary'\n label={texts.necessaryLabel}\n description={texts.necessaryDescription}\n checked\n disabled\n onChange={() => undefined}\n />\n {CONSENT_CATEGORIES.map((category) => (\n <CategoryRow\n key={category}\n id={`consent-category-${category}`}\n label={texts.categoryLabels[category]}\n description={texts.categoryDescriptions[category]}\n checked={draft[category]}\n disabled={false}\n onChange={(checked) => setDraft((prev) => ({ ...prev, [category]: checked }))}\n />\n ))}\n </div>\n\n <div className='mt-6 flex justify-end'>\n <Button type='button' size='sm' onClick={() => savePreferences(draft)}>\n {texts.saveLabel}\n </Button>\n </div>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n )\n}\n\ninterface CategoryRowProps {\n id: string\n label: string\n description: string\n checked: boolean\n disabled: boolean\n onChange: (checked: boolean) => void\n}\n\nfunction CategoryRow({ id, label, description, checked, disabled, onChange }: CategoryRowProps) {\n return (\n <div className={cn('flex items-start gap-3', disabled && 'opacity-70')}>\n <input\n id={id}\n type='checkbox'\n checked={checked}\n disabled={disabled}\n onChange={(event) => onChange(event.target.checked)}\n className='mt-1 size-4'\n />\n <label htmlFor={id} className='flex flex-col text-sm'>\n <span className='font-medium'>{label}</span>\n <span className='text-muted-foreground'>{description}</span>\n </label>\n </div>\n )\n}\n","import type { CookieBanner } from '@reblu/site-contracts'\n\nimport type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n\n/**\n * The string-valued keys of `ConsentBannerTexts` — every key EXCEPT\n * `categoryLabels`/`categoryDescriptions`, which are nested `Record`s, not\n * plain copy. `mergeConsentBannerTexts` only applies its blank-is-unset rule\n * to these.\n */\nconst CONSENT_BANNER_TEXT_KEYS = [\n 'title',\n 'description',\n 'acceptLabel',\n 'rejectLabel',\n 'manageLabel',\n 'policyLinkLabel',\n 'preferencesTitle',\n 'preferencesDescription',\n 'saveLabel',\n 'necessaryLabel',\n 'necessaryDescription',\n] as const satisfies readonly (keyof ConsentBannerTexts)[]\n\n/**\n * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).\n *\n * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —\n * nexus persists `\"\"` for a cookie-banner copy field nobody filled in (see\n * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered\n * an empty title, empty description and three blank buttons instead of the\n * SDK's Spanish defaults. An empty or whitespace-only string here means the\n * SAME THING as `undefined`: \"no override — use the default.\"\n *\n * Same for a PRESENT key holding `undefined` or `null` (R3-001 review\n * finding): the spread copies that value right over the default because the\n * key still exists on `overrides`, so `value == null` is checked first,\n * before the string/blank check, rather than relying on `typeof value ===\n * 'string'` to fall through.\n */\nexport function mergeConsentBannerTexts(\n overrides?: Partial<ConsentBannerTexts>,\n): ConsentBannerTexts {\n const merged: ConsentBannerTexts = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n for (const key of CONSENT_BANNER_TEXT_KEYS) {\n const value = overrides?.[key]\n const isUnset = value == null || (typeof value === 'string' && value.trim() === '')\n if (isUnset) {\n merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key]\n }\n }\n return merged\n}\n\n/**\n * Maps the `/api/site/analytics` `cookieBanner` contract shape onto\n * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping\n * `null` and blank fields so a SITE never has to hand-roll this mapping (and\n * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`\n * already applies on the way in).\n *\n * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;\n * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —\n * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is\n * `<CookieBanner policyHref>`, not a text key.\n */\nexport function cookieBannerToConsentTexts(\n cookieBanner:\n | Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'>\n | null\n | undefined,\n): Partial<ConsentBannerTexts> {\n if (!cookieBanner) return {}\n\n const overrides: Partial<ConsentBannerTexts> = {}\n if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title\n if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body\n if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel\n if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel\n if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel\n return overrides\n}\n"],"mappings":"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","names":["import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","Script","import_jsx_runtime","import_react","import_jsx_runtime"]}
|
package/dist/consent.d.cts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { d as ConsentCategoryState, c as ConsentCategory, b as ConsentBannerTexts } from './texts-DfpyCIJK.cjs';
|
|
4
|
+
export { D as DEFAULT_CONSENT_BANNER_TEXTS, g as cookieBannerToConsentTexts, m as mergeConsentBannerTexts } from './texts-DfpyCIJK.cjs';
|
|
4
5
|
import { AnalyticsScriptsProps } from './analytics.cjs';
|
|
5
|
-
import
|
|
6
|
+
import '@reblu/site-contracts';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* `<ConsentProvider>` / `useConsent()` — the client half of cookie consent
|
|
@@ -90,66 +91,6 @@ declare function ConsentGate({ category, children }: ConsentGateProps): react_js
|
|
|
90
91
|
*/
|
|
91
92
|
declare function ConsentedAnalytics(props: AnalyticsScriptsProps): react_jsx_runtime.JSX.Element;
|
|
92
93
|
|
|
93
|
-
/**
|
|
94
|
-
* Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE
|
|
95
|
-
* renders `<html lang='es'>` today).
|
|
96
|
-
*
|
|
97
|
-
* REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY
|
|
98
|
-
* override for v1 — the per-category descriptions, the "necessary" copy and
|
|
99
|
-
* every button label stay fixed, because the copy is a compliance surface: a
|
|
100
|
-
* tenant writing "we use cookies — OK" with no reject option is the exact
|
|
101
|
-
* misconfiguration this design exists to prevent. This type is intentionally
|
|
102
|
-
* a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a
|
|
103
|
-
* later tenant-configuration slice can override `title`/`description` without
|
|
104
|
-
* touching this component's contract.
|
|
105
|
-
*/
|
|
106
|
-
interface ConsentBannerTexts {
|
|
107
|
-
title: string;
|
|
108
|
-
description: string;
|
|
109
|
-
acceptLabel: string;
|
|
110
|
-
rejectLabel: string;
|
|
111
|
-
manageLabel: string;
|
|
112
|
-
policyLinkLabel: string;
|
|
113
|
-
preferencesTitle: string;
|
|
114
|
-
preferencesDescription: string;
|
|
115
|
-
saveLabel: string;
|
|
116
|
-
necessaryLabel: string;
|
|
117
|
-
necessaryDescription: string;
|
|
118
|
-
categoryLabels: Record<ConsentCategory, string>;
|
|
119
|
-
categoryDescriptions: Record<ConsentCategory, string>;
|
|
120
|
-
}
|
|
121
|
-
declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
|
|
122
|
-
/**
|
|
123
|
-
* Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
|
|
124
|
-
*
|
|
125
|
-
* A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
|
|
126
|
-
* nexus persists `""` for a cookie-banner copy field nobody filled in (see
|
|
127
|
-
* `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
|
|
128
|
-
* an empty title, empty description and three blank buttons instead of the
|
|
129
|
-
* SDK's Spanish defaults. An empty or whitespace-only string here means the
|
|
130
|
-
* SAME THING as `undefined`: "no override — use the default."
|
|
131
|
-
*
|
|
132
|
-
* Same for a PRESENT key holding `undefined` or `null` (R3-001 review
|
|
133
|
-
* finding): the spread copies that value right over the default because the
|
|
134
|
-
* key still exists on `overrides`, so `value == null` is checked first,
|
|
135
|
-
* before the string/blank check, rather than relying on `typeof value ===
|
|
136
|
-
* 'string'` to fall through.
|
|
137
|
-
*/
|
|
138
|
-
declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
|
|
139
|
-
/**
|
|
140
|
-
* Maps the `/api/site/analytics` `cookieBanner` contract shape onto
|
|
141
|
-
* `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
|
|
142
|
-
* `null` and blank fields so a SITE never has to hand-roll this mapping (and
|
|
143
|
-
* never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
|
|
144
|
-
* already applies on the way in).
|
|
145
|
-
*
|
|
146
|
-
* Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
|
|
147
|
-
* `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
|
|
148
|
-
* `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
|
|
149
|
-
* `<CookieBanner policyHref>`, not a text key.
|
|
150
|
-
*/
|
|
151
|
-
declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner$1, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
|
|
152
|
-
|
|
153
94
|
/**
|
|
154
95
|
* `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of
|
|
155
96
|
* REB-642's consent mechanism.
|
|
@@ -200,4 +141,4 @@ interface CookiePreferencesTriggerProps {
|
|
|
200
141
|
*/
|
|
201
142
|
declare function CookiePreferencesTrigger({ children, className, texts: textsOverride, }: CookiePreferencesTriggerProps): react_jsx_runtime.JSX.Element;
|
|
202
143
|
|
|
203
|
-
export {
|
|
144
|
+
export { ConsentBannerTexts, type ConsentContextValue, ConsentGate, type ConsentGateProps, ConsentProvider, type ConsentProviderProps, type ConsentStatus, ConsentedAnalytics, CookieBanner, type CookieBannerProps, CookiePreferencesTrigger, type CookiePreferencesTriggerProps, useConsent };
|
package/dist/consent.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { d as ConsentCategoryState, c as ConsentCategory, b as ConsentBannerTexts } from './texts-DfpyCIJK.js';
|
|
4
|
+
export { D as DEFAULT_CONSENT_BANNER_TEXTS, g as cookieBannerToConsentTexts, m as mergeConsentBannerTexts } from './texts-DfpyCIJK.js';
|
|
4
5
|
import { AnalyticsScriptsProps } from './analytics.js';
|
|
5
|
-
import
|
|
6
|
+
import '@reblu/site-contracts';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* `<ConsentProvider>` / `useConsent()` — the client half of cookie consent
|
|
@@ -90,66 +91,6 @@ declare function ConsentGate({ category, children }: ConsentGateProps): react_js
|
|
|
90
91
|
*/
|
|
91
92
|
declare function ConsentedAnalytics(props: AnalyticsScriptsProps): react_jsx_runtime.JSX.Element;
|
|
92
93
|
|
|
93
|
-
/**
|
|
94
|
-
* Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE
|
|
95
|
-
* renders `<html lang='es'>` today).
|
|
96
|
-
*
|
|
97
|
-
* REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY
|
|
98
|
-
* override for v1 — the per-category descriptions, the "necessary" copy and
|
|
99
|
-
* every button label stay fixed, because the copy is a compliance surface: a
|
|
100
|
-
* tenant writing "we use cookies — OK" with no reject option is the exact
|
|
101
|
-
* misconfiguration this design exists to prevent. This type is intentionally
|
|
102
|
-
* a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a
|
|
103
|
-
* later tenant-configuration slice can override `title`/`description` without
|
|
104
|
-
* touching this component's contract.
|
|
105
|
-
*/
|
|
106
|
-
interface ConsentBannerTexts {
|
|
107
|
-
title: string;
|
|
108
|
-
description: string;
|
|
109
|
-
acceptLabel: string;
|
|
110
|
-
rejectLabel: string;
|
|
111
|
-
manageLabel: string;
|
|
112
|
-
policyLinkLabel: string;
|
|
113
|
-
preferencesTitle: string;
|
|
114
|
-
preferencesDescription: string;
|
|
115
|
-
saveLabel: string;
|
|
116
|
-
necessaryLabel: string;
|
|
117
|
-
necessaryDescription: string;
|
|
118
|
-
categoryLabels: Record<ConsentCategory, string>;
|
|
119
|
-
categoryDescriptions: Record<ConsentCategory, string>;
|
|
120
|
-
}
|
|
121
|
-
declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
|
|
122
|
-
/**
|
|
123
|
-
* Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
|
|
124
|
-
*
|
|
125
|
-
* A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
|
|
126
|
-
* nexus persists `""` for a cookie-banner copy field nobody filled in (see
|
|
127
|
-
* `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
|
|
128
|
-
* an empty title, empty description and three blank buttons instead of the
|
|
129
|
-
* SDK's Spanish defaults. An empty or whitespace-only string here means the
|
|
130
|
-
* SAME THING as `undefined`: "no override — use the default."
|
|
131
|
-
*
|
|
132
|
-
* Same for a PRESENT key holding `undefined` or `null` (R3-001 review
|
|
133
|
-
* finding): the spread copies that value right over the default because the
|
|
134
|
-
* key still exists on `overrides`, so `value == null` is checked first,
|
|
135
|
-
* before the string/blank check, rather than relying on `typeof value ===
|
|
136
|
-
* 'string'` to fall through.
|
|
137
|
-
*/
|
|
138
|
-
declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
|
|
139
|
-
/**
|
|
140
|
-
* Maps the `/api/site/analytics` `cookieBanner` contract shape onto
|
|
141
|
-
* `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
|
|
142
|
-
* `null` and blank fields so a SITE never has to hand-roll this mapping (and
|
|
143
|
-
* never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
|
|
144
|
-
* already applies on the way in).
|
|
145
|
-
*
|
|
146
|
-
* Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
|
|
147
|
-
* `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
|
|
148
|
-
* `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
|
|
149
|
-
* `<CookieBanner policyHref>`, not a text key.
|
|
150
|
-
*/
|
|
151
|
-
declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner$1, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
|
|
152
|
-
|
|
153
94
|
/**
|
|
154
95
|
* `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of
|
|
155
96
|
* REB-642's consent mechanism.
|
|
@@ -200,4 +141,4 @@ interface CookiePreferencesTriggerProps {
|
|
|
200
141
|
*/
|
|
201
142
|
declare function CookiePreferencesTrigger({ children, className, texts: textsOverride, }: CookiePreferencesTriggerProps): react_jsx_runtime.JSX.Element;
|
|
202
143
|
|
|
203
|
-
export {
|
|
144
|
+
export { ConsentBannerTexts, type ConsentContextValue, ConsentGate, type ConsentGateProps, ConsentProvider, type ConsentProviderProps, type ConsentStatus, ConsentedAnalytics, CookieBanner, type CookieBannerProps, CookiePreferencesTrigger, type CookiePreferencesTriggerProps, useConsent };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CookieBanner } from '@reblu/site-contracts';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):
|
|
3
5
|
* the cookie name, the category taxonomy, and fail-closed parse/serialise.
|
|
@@ -68,4 +70,64 @@ declare function parseConsentCookie(raw: string | null | undefined): ConsentCook
|
|
|
68
70
|
/** Serialises a consent payload for storage in the cookie. */
|
|
69
71
|
declare function serializeConsentCookie(payload: ConsentCookiePayload): string;
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE
|
|
75
|
+
* renders `<html lang='es'>` today).
|
|
76
|
+
*
|
|
77
|
+
* REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY
|
|
78
|
+
* override for v1 — the per-category descriptions, the "necessary" copy and
|
|
79
|
+
* every button label stay fixed, because the copy is a compliance surface: a
|
|
80
|
+
* tenant writing "we use cookies — OK" with no reject option is the exact
|
|
81
|
+
* misconfiguration this design exists to prevent. This type is intentionally
|
|
82
|
+
* a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a
|
|
83
|
+
* later tenant-configuration slice can override `title`/`description` without
|
|
84
|
+
* touching this component's contract.
|
|
85
|
+
*/
|
|
86
|
+
interface ConsentBannerTexts {
|
|
87
|
+
title: string;
|
|
88
|
+
description: string;
|
|
89
|
+
acceptLabel: string;
|
|
90
|
+
rejectLabel: string;
|
|
91
|
+
manageLabel: string;
|
|
92
|
+
policyLinkLabel: string;
|
|
93
|
+
preferencesTitle: string;
|
|
94
|
+
preferencesDescription: string;
|
|
95
|
+
saveLabel: string;
|
|
96
|
+
necessaryLabel: string;
|
|
97
|
+
necessaryDescription: string;
|
|
98
|
+
categoryLabels: Record<ConsentCategory, string>;
|
|
99
|
+
categoryDescriptions: Record<ConsentCategory, string>;
|
|
100
|
+
}
|
|
101
|
+
declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
|
|
102
|
+
/**
|
|
103
|
+
* Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
|
|
104
|
+
*
|
|
105
|
+
* A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
|
|
106
|
+
* nexus persists `""` for a cookie-banner copy field nobody filled in (see
|
|
107
|
+
* `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
|
|
108
|
+
* an empty title, empty description and three blank buttons instead of the
|
|
109
|
+
* SDK's Spanish defaults. An empty or whitespace-only string here means the
|
|
110
|
+
* SAME THING as `undefined`: "no override — use the default."
|
|
111
|
+
*
|
|
112
|
+
* Same for a PRESENT key holding `undefined` or `null` (R3-001 review
|
|
113
|
+
* finding): the spread copies that value right over the default because the
|
|
114
|
+
* key still exists on `overrides`, so `value == null` is checked first,
|
|
115
|
+
* before the string/blank check, rather than relying on `typeof value ===
|
|
116
|
+
* 'string'` to fall through.
|
|
117
|
+
*/
|
|
118
|
+
declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
|
|
119
|
+
/**
|
|
120
|
+
* Maps the `/api/site/analytics` `cookieBanner` contract shape onto
|
|
121
|
+
* `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
|
|
122
|
+
* `null` and blank fields so a SITE never has to hand-roll this mapping (and
|
|
123
|
+
* never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
|
|
124
|
+
* already applies on the way in).
|
|
125
|
+
*
|
|
126
|
+
* Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
|
|
127
|
+
* `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
|
|
128
|
+
* `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
|
|
129
|
+
* `<CookieBanner policyHref>`, not a text key.
|
|
130
|
+
*/
|
|
131
|
+
declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
|
|
132
|
+
|
|
133
|
+
export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_BANNER_TEXTS as D, CONSENT_COOKIE_NAME as a, type ConsentBannerTexts as b, type ConsentCategory as c, type ConsentCategoryState as d, type ConsentCookiePayload as e, DEFAULT_CONSENT_CATEGORIES as f, cookieBannerToConsentTexts as g, mergeConsentBannerTexts as m, parseConsentCookie as p, serializeConsentCookie as s };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CookieBanner } from '@reblu/site-contracts';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):
|
|
3
5
|
* the cookie name, the category taxonomy, and fail-closed parse/serialise.
|
|
@@ -68,4 +70,64 @@ declare function parseConsentCookie(raw: string | null | undefined): ConsentCook
|
|
|
68
70
|
/** Serialises a consent payload for storage in the cookie. */
|
|
69
71
|
declare function serializeConsentCookie(payload: ConsentCookiePayload): string;
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE
|
|
75
|
+
* renders `<html lang='es'>` today).
|
|
76
|
+
*
|
|
77
|
+
* REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY
|
|
78
|
+
* override for v1 — the per-category descriptions, the "necessary" copy and
|
|
79
|
+
* every button label stay fixed, because the copy is a compliance surface: a
|
|
80
|
+
* tenant writing "we use cookies — OK" with no reject option is the exact
|
|
81
|
+
* misconfiguration this design exists to prevent. This type is intentionally
|
|
82
|
+
* a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a
|
|
83
|
+
* later tenant-configuration slice can override `title`/`description` without
|
|
84
|
+
* touching this component's contract.
|
|
85
|
+
*/
|
|
86
|
+
interface ConsentBannerTexts {
|
|
87
|
+
title: string;
|
|
88
|
+
description: string;
|
|
89
|
+
acceptLabel: string;
|
|
90
|
+
rejectLabel: string;
|
|
91
|
+
manageLabel: string;
|
|
92
|
+
policyLinkLabel: string;
|
|
93
|
+
preferencesTitle: string;
|
|
94
|
+
preferencesDescription: string;
|
|
95
|
+
saveLabel: string;
|
|
96
|
+
necessaryLabel: string;
|
|
97
|
+
necessaryDescription: string;
|
|
98
|
+
categoryLabels: Record<ConsentCategory, string>;
|
|
99
|
+
categoryDescriptions: Record<ConsentCategory, string>;
|
|
100
|
+
}
|
|
101
|
+
declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
|
|
102
|
+
/**
|
|
103
|
+
* Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
|
|
104
|
+
*
|
|
105
|
+
* A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
|
|
106
|
+
* nexus persists `""` for a cookie-banner copy field nobody filled in (see
|
|
107
|
+
* `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
|
|
108
|
+
* an empty title, empty description and three blank buttons instead of the
|
|
109
|
+
* SDK's Spanish defaults. An empty or whitespace-only string here means the
|
|
110
|
+
* SAME THING as `undefined`: "no override — use the default."
|
|
111
|
+
*
|
|
112
|
+
* Same for a PRESENT key holding `undefined` or `null` (R3-001 review
|
|
113
|
+
* finding): the spread copies that value right over the default because the
|
|
114
|
+
* key still exists on `overrides`, so `value == null` is checked first,
|
|
115
|
+
* before the string/blank check, rather than relying on `typeof value ===
|
|
116
|
+
* 'string'` to fall through.
|
|
117
|
+
*/
|
|
118
|
+
declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
|
|
119
|
+
/**
|
|
120
|
+
* Maps the `/api/site/analytics` `cookieBanner` contract shape onto
|
|
121
|
+
* `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
|
|
122
|
+
* `null` and blank fields so a SITE never has to hand-roll this mapping (and
|
|
123
|
+
* never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
|
|
124
|
+
* already applies on the way in).
|
|
125
|
+
*
|
|
126
|
+
* Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
|
|
127
|
+
* `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
|
|
128
|
+
* `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
|
|
129
|
+
* `<CookieBanner policyHref>`, not a text key.
|
|
130
|
+
*/
|
|
131
|
+
declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
|
|
132
|
+
|
|
133
|
+
export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_BANNER_TEXTS as D, CONSENT_COOKIE_NAME as a, type ConsentBannerTexts as b, type ConsentCategory as c, type ConsentCategoryState as d, type ConsentCookiePayload as e, DEFAULT_CONSENT_CATEGORIES as f, cookieBannerToConsentTexts as g, mergeConsentBannerTexts as m, parseConsentCookie as p, serializeConsentCookie as s };
|
package/package.json
CHANGED