@reblu/site-next 1.15.3 → 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 +27 -0
- package/README.md +56 -0
- 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 +39 -1
- package/dist/consent.cjs.map +1 -1
- package/dist/consent.d.cts +4 -32
- package/dist/consent.d.ts +4 -32
- package/dist/consent.js +37 -1
- package/dist/consent.js.map +1 -1
- 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 +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
19
|
+
## 1.16.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- 59ccb29: Fix `<CookieBanner>` rendering an empty title/description and three blank buttons when a tenant left a nexus cookie-banner copy field unset (persisted as `""`, not `null`). A blank or whitespace-only `texts` override now falls back to the SDK's default copy for that key instead of winning the merge. Add `cookieBannerToConsentTexts` (`@reblu/site-next/consent`) to map the `/api/site/analytics` `cookieBanner` contract shape onto `texts` directly, dropping `null` and blank fields. `@reblu/site-contracts` now also exports the standalone `CookieBanner` type.
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [59ccb29]
|
|
28
|
+
- @reblu/site-contracts@1.6.1
|
|
29
|
+
|
|
3
30
|
## 1.15.3
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -586,6 +586,62 @@ function CartPage() {
|
|
|
586
586
|
the default «Pago cancelado: tu carrito sigue aquí» copy when `true`, or any
|
|
587
587
|
custom `ReactNode` — see that package's README.
|
|
588
588
|
|
|
589
|
+
## Cookie consent
|
|
590
|
+
|
|
591
|
+
`./consent` (REB-642) — `<ConsentProvider>`/`useConsent()`, `<CookieBanner>` +
|
|
592
|
+
`<CookiePreferencesTrigger>`, `<ConsentGate>`, and `<ConsentedAnalytics>`. Mount
|
|
593
|
+
`<ConsentProvider>` once at the layout root and `<CookieBanner>` once inside it:
|
|
594
|
+
|
|
595
|
+
```tsx
|
|
596
|
+
import { ConsentProvider, CookieBanner } from '@reblu/site-next/consent'
|
|
597
|
+
import { cookiePolicyVersion } from '@reblu/site-next/consent-server'
|
|
598
|
+
|
|
599
|
+
<ConsentProvider policyVersion={cookiePolicyVersion(legal?.cookiePolicy ?? null)}>
|
|
600
|
+
{children}
|
|
601
|
+
<CookieBanner />
|
|
602
|
+
</ConsentProvider>
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Tenant-editable copy — `cookieBannerToConsentTexts` (REB-845)
|
|
606
|
+
|
|
607
|
+
`<CookieBanner>` renders the SDK's own Spanish defaults unless you pass
|
|
608
|
+
`texts` (a `Partial<ConsentBannerTexts>`). Map the `/api/site/analytics`
|
|
609
|
+
response's `cookieBanner` field onto `texts` with `cookieBannerToConsentTexts`
|
|
610
|
+
instead of hand-rolling the mapping — it already drops `null` AND
|
|
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.
|
|
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:
|
|
627
|
+
|
|
628
|
+
```tsx
|
|
629
|
+
import { CookieBanner } from '@reblu/site-next/consent'
|
|
630
|
+
import { cookieBannerToConsentTexts } from '@reblu/site-next/consent-server'
|
|
631
|
+
|
|
632
|
+
<CookieBanner texts={cookieBannerToConsentTexts(analytics?.cookieBanner)} />
|
|
633
|
+
```
|
|
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
|
+
|
|
640
|
+
Even if you build `texts` yourself, `<CookieBanner>` treats a blank or
|
|
641
|
+
whitespace-only value for any key the same as `undefined` — it always falls
|
|
642
|
+
back to that key's default rather than rendering empty. There is no need to
|
|
643
|
+
`?.trim()` a value before passing it.
|
|
644
|
+
|
|
589
645
|
## Structured data (JSON-LD)
|
|
590
646
|
|
|
591
647
|
`@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
|
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
CHANGED
|
@@ -37,6 +37,8 @@ __export(consent_exports, {
|
|
|
37
37
|
CookieBanner: () => CookieBanner,
|
|
38
38
|
CookiePreferencesTrigger: () => CookiePreferencesTrigger,
|
|
39
39
|
DEFAULT_CONSENT_BANNER_TEXTS: () => DEFAULT_CONSENT_BANNER_TEXTS,
|
|
40
|
+
cookieBannerToConsentTexts: () => cookieBannerToConsentTexts,
|
|
41
|
+
mergeConsentBannerTexts: () => mergeConsentBannerTexts,
|
|
40
42
|
useConsent: () => useConsent
|
|
41
43
|
});
|
|
42
44
|
module.exports = __toCommonJS(consent_exports);
|
|
@@ -266,11 +268,45 @@ var DEFAULT_CONSENT_BANNER_TEXTS = {
|
|
|
266
268
|
preferences: "Recuerdan tus preferencias de navegaci\xF3n entre visitas."
|
|
267
269
|
}
|
|
268
270
|
};
|
|
271
|
+
var CONSENT_BANNER_TEXT_KEYS = [
|
|
272
|
+
"title",
|
|
273
|
+
"description",
|
|
274
|
+
"acceptLabel",
|
|
275
|
+
"rejectLabel",
|
|
276
|
+
"manageLabel",
|
|
277
|
+
"policyLinkLabel",
|
|
278
|
+
"preferencesTitle",
|
|
279
|
+
"preferencesDescription",
|
|
280
|
+
"saveLabel",
|
|
281
|
+
"necessaryLabel",
|
|
282
|
+
"necessaryDescription"
|
|
283
|
+
];
|
|
284
|
+
function mergeConsentBannerTexts(overrides) {
|
|
285
|
+
const merged = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
|
|
286
|
+
for (const key of CONSENT_BANNER_TEXT_KEYS) {
|
|
287
|
+
const value = overrides?.[key];
|
|
288
|
+
const isUnset = value == null || typeof value === "string" && value.trim() === "";
|
|
289
|
+
if (isUnset) {
|
|
290
|
+
merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return merged;
|
|
294
|
+
}
|
|
295
|
+
function cookieBannerToConsentTexts(cookieBanner) {
|
|
296
|
+
if (!cookieBanner) return {};
|
|
297
|
+
const overrides = {};
|
|
298
|
+
if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title;
|
|
299
|
+
if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body;
|
|
300
|
+
if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel;
|
|
301
|
+
if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel;
|
|
302
|
+
if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel;
|
|
303
|
+
return overrides;
|
|
304
|
+
}
|
|
269
305
|
|
|
270
306
|
// src/consent/banner.tsx
|
|
271
307
|
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
272
308
|
function resolveTexts(overrides) {
|
|
273
|
-
return
|
|
309
|
+
return mergeConsentBannerTexts(overrides);
|
|
274
310
|
}
|
|
275
311
|
function CookieBanner({ texts: textsOverride, policyHref = "/cookies" }) {
|
|
276
312
|
const { status, acceptAll, rejectAll, openPreferences } = useConsent();
|
|
@@ -399,6 +435,8 @@ function CategoryRow({ id, label, description, checked, disabled, onChange }) {
|
|
|
399
435
|
CookieBanner,
|
|
400
436
|
CookiePreferencesTrigger,
|
|
401
437
|
DEFAULT_CONSENT_BANNER_TEXTS,
|
|
438
|
+
cookieBannerToConsentTexts,
|
|
439
|
+
mergeConsentBannerTexts,
|
|
402
440
|
useConsent
|
|
403
441
|
});
|
|
404
442
|
//# sourceMappingURL=consent.cjs.map
|