@reblu/site-next 1.16.0 → 1.18.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 +62 -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/seo.cjs +101 -0
- package/dist/seo.cjs.map +1 -0
- package/dist/seo.d.cts +76 -0
- package/dist/seo.d.ts +76 -0
- package/dist/seo.js +68 -0
- package/dist/seo.js.map +1 -0
- 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 +14 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @reblu/site-next
|
|
2
2
|
|
|
3
|
+
## 1.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e4dff25: New `@reblu/site-next/seo` subpath (REB-871) — the one page-title rule, shared with the Reblu SITE API. `resolveSeoTitle({ seoTitle, title, sectionLabel }, { template, siteName })`: a non-empty entity SEO title is used as-is (never suffixed); otherwise the tenant template (`Config.seoTitleTemplate`, default `%s | {siteName}`) is applied exactly once to the display title or the list route's section label, and the brand is never duplicated. `buildMetadata({ seo, config, ... })` returns Next `Metadata` with an absolute title (a root-layout `title.template` can no longer append the brand twice). Also exports `DEFAULT_SEO_TITLE_TEMPLATE`, `validateSeoTitleTemplate` and `resolveSeoTitleTemplate`. Dependency-free, no `'use client'`.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [e4dff25]
|
|
12
|
+
- @reblu/site-contracts@1.11.0
|
|
13
|
+
|
|
14
|
+
## 1.17.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- ccf8feb: Fix `cookieBannerToConsentTexts` throwing when called from a Server
|
|
19
|
+
Component. `@reblu/site-next/consent` carries a leading `'use client'`
|
|
20
|
+
directive for its providers/components, which made the whole entry —
|
|
21
|
+
including the pure, dependency-free `cookieBannerToConsentTexts` (and
|
|
22
|
+
`mergeConsentBannerTexts`/`DEFAULT_CONSENT_BANNER_TEXTS`/`ConsentBannerTexts`)
|
|
23
|
+
— look client-only to Next's RSC boundary. `next build` failed with
|
|
24
|
+
"Attempted to call cookieBannerToConsentTexts() from the server but
|
|
25
|
+
cookieBannerToConsentTexts is on the client" for the documented root-layout
|
|
26
|
+
recipe. `@reblu/site-next/consent-server` now also re-exports these helpers
|
|
27
|
+
for Server Components to import from; `@reblu/site-next/consent` still
|
|
28
|
+
re-exports them unchanged for existing client-side call sites.
|
|
29
|
+
|
|
3
30
|
## 1.16.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -609,19 +609,79 @@ 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
|
|
623
643
|
`?.trim()` a value before passing it.
|
|
624
644
|
|
|
645
|
+
## Page titles — `buildMetadata` (REB-871)
|
|
646
|
+
|
|
647
|
+
`@reblu/site-next/seo` (dependency-free, no `'use client'`) holds the one
|
|
648
|
+
page-title rule the Reblu SITE API also applies server-side:
|
|
649
|
+
|
|
650
|
+
- an entity SEO title (`seoTitle` / `metaTitle`) that is set is the **whole**
|
|
651
|
+
title — used as-is, the site name is never appended;
|
|
652
|
+
- otherwise the tenant template (`config.seoTitleTemplate`, default
|
|
653
|
+
`%s | {siteName}`) is applied **once** to the display title — or to the
|
|
654
|
+
section label on a list/index route;
|
|
655
|
+
- the brand is never duplicated.
|
|
656
|
+
|
|
657
|
+
Detail endpoints already return `seo.title` resolved, so a detail route is a
|
|
658
|
+
drop-in:
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
// app/servicios/[slug]/page.tsx
|
|
662
|
+
import type { Metadata } from 'next'
|
|
663
|
+
import { buildMetadata } from '@reblu/site-next/seo'
|
|
664
|
+
|
|
665
|
+
export async function generateMetadata({ params }): Promise<Metadata> {
|
|
666
|
+
const { slug } = await params
|
|
667
|
+
const [page, config] = await Promise.all([reblu.pages.bySlug(slug), reblu.config.get()])
|
|
668
|
+
return buildMetadata({ seo: page.seo, title: page.title, seoTitle: page.seoTitle, config })
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// app/blog/page.tsx — list/index route: the template with the section label
|
|
672
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
673
|
+
return buildMetadata({ sectionLabel: 'Blog', config: await reblu.config.get() })
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`buildMetadata` emits `title: { absolute }`, so a `title.template` in the root
|
|
678
|
+
layout can never append the brand a second time — drop any hand-written
|
|
679
|
+
`` `${title} · Brand` `` from your routes. Passing `title`/`seoTitle` alongside
|
|
680
|
+
`seo` keeps the route correct against an older API that does not send `seo`
|
|
681
|
+
yet. Need the string only? `resolveSeoTitle({ seoTitle, title, sectionLabel },
|
|
682
|
+
{ template: config.seoTitleTemplate, siteName: config.siteName })`.
|
|
683
|
+
Description/Open Graph are intentionally minimal here (REB-823 extends them).
|
|
684
|
+
|
|
625
685
|
## Structured data (JSON-LD)
|
|
626
686
|
|
|
627
687
|
`@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":[]}
|