@devslab/site-kit 0.15.0 → 0.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.
@@ -0,0 +1,577 @@
1
+ import { GTM_CONTAINER_ID_PATTERN, gtmHeadScript } from "./gtm.mjs";
2
+
3
+ /**
4
+ * Opt-in analytics consent for the family sites (D-034).
5
+ *
6
+ * Nothing that contacts Google — the Tag Manager loader, gtag, GA cookies —
7
+ * runs until the visitor grants analytics for the current policy version.
8
+ * Without a decision, after "Reject", after a dismissed banner, or under an
9
+ * older policy version, the page only pushes Consent Mode v2 defaults (all
10
+ * denied) into its own dataLayer. That push is local; it reaches nobody.
11
+ *
12
+ * This file is framework-free and dependency-free: it runs in the source
13
+ * stage of CI (no packages installed), in Workers, in Node and in browsers.
14
+ * The DOM is touched only inside a manager's methods, never at import time
15
+ * or creation, so a manager can be created in shared module scope on a
16
+ * server that renders many requests.
17
+ */
18
+
19
+ export const CONSENT_COOKIE_NAME = "site_consent";
20
+ /** Twelve months. The cookie expires then, and a decision older than this no longer counts. */
21
+ export const CONSENT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
22
+ /** Letters, digits, `.`, `_` and `-`, starting with a letter or digit, at most 32 characters: "2026-10-05". */
23
+ export const CONSENT_POLICY_VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/;
24
+ /** 128 random bits as lowercase hex. Not derived from anything about the visitor. */
25
+ export const CONSENT_ANONYMOUS_ID_PATTERN = /^[0-9a-f]{32}$/;
26
+ export const CONSENT_ACTIONS = Object.freeze(["grant", "deny", "withdraw", "update"]);
27
+ /**
28
+ * Consent Mode v2 defaults: everything denied. There is no advertising
29
+ * category in the family, so the three ad signals are never updated.
30
+ */
31
+ export const CONSENT_MODE_DEFAULTS = Object.freeze({
32
+ ad_personalization: "denied",
33
+ ad_storage: "denied",
34
+ ad_user_data: "denied",
35
+ analytics_storage: "denied",
36
+ });
37
+ /** Cookies Google Analytics writes: `_ga`, `_ga_<stream>`, and the older `_gid` / `_gat*`. */
38
+ export const GA_COOKIE_PATTERN = /^(?:_ga|_gid|_gat)(?:_[0-9A-Za-z_-]+)?$/;
39
+ /**
40
+ * Headers for every response whose HTML was rendered from the consent cookie
41
+ * (a TanStack route using `consent`). A granted visitor's head carries the
42
+ * Tag Manager loader; if a CDN or edge cache stored that page, visitors who
43
+ * never consented would be served the loader. `private, no-store` keeps it
44
+ * out of shared caches; `Vary: Cookie` covers a cache that honours it anyway.
45
+ */
46
+ export const CONSENT_RESPONSE_HEADERS = Object.freeze({ "Cache-Control": "private, no-store", Vary: "Cookie" });
47
+ /** A consent record body is a handful of short fields; anything longer is refused before parsing. */
48
+ export const CONSENT_RECORD_MAX_BYTES = 1024;
49
+
50
+ const GTM_SCRIPT_PREFIX = "https://www.googletagmanager.com/gtm.js";
51
+ const COOKIE_NAME_PATTERN = /^[0-9A-Za-z_-]{1,64}$/;
52
+ const COOKIE_DOMAIN_PATTERN = /^\.?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)+$/;
53
+ const MEASUREMENT_ID_PATTERN = /^G-[0-9A-Z]+$/;
54
+ const COOKIE_KEYS = ["a", "id", "t", "v"];
55
+ const RECORD_KEYS = ["action", "analytics", "anonymousId", "decidedAt", "path", "policyVersion", "source"];
56
+ const CLOCK_SKEW_SECONDS = 10 * 60;
57
+ const MAX_PATH_LENGTH = 512;
58
+ const GATE = Symbol.for("@devslab/site-kit/consent-gate");
59
+
60
+ const fail = (message) => { throw new RangeError(message); };
61
+ const checkVersion = (value) =>
62
+ typeof value === "string" && CONSENT_POLICY_VERSION_PATTERN.test(value)
63
+ ? value
64
+ : fail(`consent policy version must match ${CONSENT_POLICY_VERSION_PATTERN}; got ${JSON.stringify(value)}`);
65
+ const checkCookieName = (value) =>
66
+ typeof value === "string" && COOKIE_NAME_PATTERN.test(value) ? value : fail(`consent cookie name must match ${COOKIE_NAME_PATTERN}; got ${JSON.stringify(value)}`);
67
+ const checkDomain = (value) =>
68
+ typeof value === "string" && COOKIE_DOMAIN_PATTERN.test(value) ? value : fail(`consent cookie domain must be a host name; got ${JSON.stringify(value)}`);
69
+ const checkMaxAge = (value) =>
70
+ Number.isInteger(value) && value > 0 && value <= CONSENT_MAX_AGE_SECONDS ? value : fail(`consent max age must be 1..${CONSENT_MAX_AGE_SECONDS} seconds; got ${JSON.stringify(value)}`);
71
+ const checkGtm = (value) =>
72
+ typeof value === "string" && GTM_CONTAINER_ID_PATTERN.test(value) ? value : fail(`Google Tag Manager container id must match ${GTM_CONTAINER_ID_PATTERN}; got ${JSON.stringify(value)}`);
73
+ const checkMeasurementId = (value) =>
74
+ typeof value === "string" && MEASUREMENT_ID_PATTERN.test(value) ? value : fail(`GA4 measurement id must match ${MEASUREMENT_ID_PATTERN}; got ${JSON.stringify(value)}`);
75
+
76
+ const isConsentState = (state) =>
77
+ state !== null && typeof state === "object" &&
78
+ typeof state.v === "string" && CONSENT_POLICY_VERSION_PATTERN.test(state.v) &&
79
+ (state.a === 0 || state.a === 1) &&
80
+ Number.isSafeInteger(state.t) && state.t > 0 &&
81
+ typeof state.id === "string" && CONSENT_ANONYMOUS_ID_PATTERN.test(state.id);
82
+
83
+ /**
84
+ * Parses a consent cookie value (`v=<version>&a=<0|1>&t=<unix seconds>&id=<hex>`).
85
+ * Strict: exactly those four keys, each once and well-formed; anything else is `null`.
86
+ */
87
+ export function parseConsentCookie(value) {
88
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) return null;
89
+ const pairs = value.split("&").map((pair) => pair.split("="));
90
+ if (pairs.some((pair) => pair.length !== 2)) return null;
91
+ const keys = pairs.map(([key]) => key).sort();
92
+ if (keys.join() !== COOKIE_KEYS.join()) return null;
93
+ const fields = Object.fromEntries(pairs);
94
+ if (fields.a !== "0" && fields.a !== "1") return null;
95
+ if (!/^[1-9][0-9]{0,11}$/.test(fields.t)) return null;
96
+ const state = { v: fields.v, a: Number(fields.a), t: Number(fields.t), id: fields.id };
97
+ return isConsentState(state) ? Object.freeze(state) : null;
98
+ }
99
+
100
+ /** The cookie value for a state. Throws RangeError for a malformed state. */
101
+ export function formatConsentCookie(state) {
102
+ if (!isConsentState(state)) fail(`not a consent state: ${JSON.stringify(state)}`);
103
+ return `v=${state.v}&a=${state.a}&t=${state.t}&id=${state.id}`;
104
+ }
105
+
106
+ /**
107
+ * The full cookie string for `document.cookie` or a `Set-Cookie` header:
108
+ * first-party, `Path=/`, `SameSite=Lax`, `Secure`, twelve months. Not
109
+ * `HttpOnly` — the banner reads it. Host-only unless `domain` is given.
110
+ */
111
+ export function serializeConsentCookie(state, options = {}) {
112
+ const name = checkCookieName(options.name ?? CONSENT_COOKIE_NAME);
113
+ const maxAge = checkMaxAge(options.maxAgeSeconds ?? CONSENT_MAX_AGE_SECONDS);
114
+ const domain = options.domain === undefined ? "" : `; Domain=${checkDomain(options.domain)}`;
115
+ return `${name}=${formatConsentCookie(state)}; Path=/; Max-Age=${maxAge}${domain}; SameSite=Lax; Secure`;
116
+ }
117
+
118
+ /** The first value of `name` in a Cookie header (or `document.cookie`). */
119
+ function cookieValue(header, name) {
120
+ if (typeof header !== "string") return undefined;
121
+ for (const part of header.split(";")) {
122
+ const index = part.indexOf("=");
123
+ if (index === -1) continue;
124
+ if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ const nowSeconds = (now) => Math.floor((now ?? Date.now()) / 1000);
130
+
131
+ /**
132
+ * True when `state` grants analytics for `policyVersion` and is neither older
133
+ * than the cookie's lifetime nor dated in the future.
134
+ */
135
+ export function analyticsConsented(state, policyVersion, options = {}) {
136
+ checkVersion(policyVersion);
137
+ if (!isConsentState(state) || state.v !== policyVersion || state.a !== 1) return false;
138
+ return isFresh(state, options);
139
+ }
140
+
141
+ function isFresh(state, options) {
142
+ const now = nowSeconds(options.now);
143
+ const maxAge = checkMaxAge(options.maxAgeSeconds ?? CONSENT_MAX_AGE_SECONDS);
144
+ return state.t <= now + CLOCK_SKEW_SECONDS && now - state.t <= maxAge;
145
+ }
146
+
147
+ /**
148
+ * The visitor's decision for the current policy version, read from a Cookie
149
+ * header (server) or `document.cookie` (browser): `null` when there is none,
150
+ * when it is malformed, expired, or made under another policy version. A
151
+ * `null` means "ask".
152
+ */
153
+ export function readConsentCookie(cookieHeader, options) {
154
+ const policyVersion = checkVersion(options?.policyVersion);
155
+ const state = parseConsentCookie(cookieValue(cookieHeader, checkCookieName(options.cookieName ?? CONSENT_COOKIE_NAME)));
156
+ if (!state || state.v !== policyVersion || !isFresh(state, options)) return null;
157
+ return state;
158
+ }
159
+
160
+ /** Shorthand for "load analytics on this request": a current, fresh grant in the Cookie header. */
161
+ export function consentCookieGrantsAnalytics(cookieHeader, options) {
162
+ return readConsentCookie(cookieHeader, options)?.a === 1;
163
+ }
164
+
165
+ const DEFAULTS_JSON = JSON.stringify(CONSENT_MODE_DEFAULTS);
166
+
167
+ /**
168
+ * The body of the head `<script>` every page with Tag Manager carries,
169
+ * without the tag or a nonce (the renderer owns both, as with gtmHeadScript).
170
+ *
171
+ * Not granted: Consent Mode defaults pushed into the page's own dataLayer,
172
+ * nothing else — no Google host appears in the string.
173
+ * Granted (and a container id given): the same defaults, an update granting
174
+ * `analytics_storage`, then Google's loader, which is skipped if gtm.js is
175
+ * already on the page (a client-side navigation re-running the head).
176
+ * Either way the first copy to run marks the page so later copies do nothing.
177
+ */
178
+ export function consentHeadScript(options = {}) {
179
+ const granted = options.granted === true;
180
+ const gtm = options.gtm === undefined ? undefined : checkGtm(options.gtm);
181
+ const mode = granted ? "granted" : "denied";
182
+ const update = granted ? `g("consent","update",{"analytics_storage":"granted"});` : "";
183
+ const bootstrap = `(function(w){if(w.__siteConsent)return;w.__siteConsent="${mode}";w.dataLayer=w.dataLayer||[];function g(){w.dataLayer.push(arguments)}g("consent","default",${DEFAULTS_JSON});${update}})(window);`;
184
+ if (!granted || gtm === undefined) return bootstrap;
185
+ return `${bootstrap}\nif(!document.querySelector('script[src^="${GTM_SCRIPT_PREFIX}"]')){${gtmHeadScript(gtm)}}`;
186
+ }
187
+
188
+ /**
189
+ * A page path as it goes into a consent record: the part before any query or
190
+ * fragment, starting with "/", at most 512 characters. Anything else is "/".
191
+ */
192
+ export function normalizeConsentPath(path) {
193
+ if (typeof path !== "string") return "/";
194
+ const bare = path.split(/[?#]/, 1)[0] ?? "";
195
+ if (!bare.startsWith("/") || bare.startsWith("//") || bare.length > MAX_PATH_LENGTH || /[\s\0-\x1f\x7f]/.test(bare)) return "/";
196
+ return bare;
197
+ }
198
+
199
+ const isPlainObject = (value) => value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
200
+
201
+ /**
202
+ * Validates a consent record a browser POSTed (server side). Accepts the JSON
203
+ * text or the parsed object; returns a frozen record or `null`. Strict: the
204
+ * exact seven keys, a known policy version (one, or a list while an older
205
+ * version's pending records drain), an action that agrees with `analytics`,
206
+ * a normalised path, a `decidedAt` neither in the future nor older than
207
+ * `maxAgeSeconds` (default 31 days). The server adds who, where from and
208
+ * when it arrived; nothing here identifies a person.
209
+ */
210
+ export function parseConsentRecord(input, options) {
211
+ const versions = [options?.policyVersion].flat().map(checkVersion);
212
+ let body = input;
213
+ if (typeof input === "string") {
214
+ if (input.length > CONSENT_RECORD_MAX_BYTES) return null;
215
+ try { body = JSON.parse(input); } catch { return null; }
216
+ }
217
+ if (!isPlainObject(body) || Object.keys(body).sort().join() !== RECORD_KEYS.join()) return null;
218
+ const { action, analytics, anonymousId, decidedAt, path, policyVersion, source } = body;
219
+ if (!versions.includes(policyVersion)) return null;
220
+ if (typeof analytics !== "boolean" || !CONSENT_ACTIONS.includes(action)) return null;
221
+ if ((action === "grant" && !analytics) || ((action === "deny" || action === "withdraw") && analytics)) return null;
222
+ if (typeof anonymousId !== "string" || !CONSENT_ANONYMOUS_ID_PATTERN.test(anonymousId)) return null;
223
+ if (source !== "web") return null;
224
+ if (typeof path !== "string" || normalizeConsentPath(path) !== path) return null;
225
+ const now = nowSeconds(options.now);
226
+ const maxAge = options.maxAgeSeconds ?? 31 * 24 * 60 * 60;
227
+ if (!Number.isSafeInteger(decidedAt) || decidedAt <= 0 || decidedAt > now + CLOCK_SKEW_SECONDS || now - decidedAt > maxAge) return null;
228
+ return Object.freeze({ action, analytics, anonymousId, decidedAt, path, policyVersion, source });
229
+ }
230
+
231
+ /**
232
+ * True when a request comes from `expectedOrigin`: its `Origin` header
233
+ * matches, or, with no `Origin`, the browser says `Sec-Fetch-Site:
234
+ * same-origin`. Takes a Request, a Headers, or a plain header object.
235
+ */
236
+ export function isSameOriginRequest(request, expectedOrigin) {
237
+ const expected = new URL(expectedOrigin).origin;
238
+ const headers = request?.headers ?? request;
239
+ const get = (name) => {
240
+ if (typeof headers?.get === "function") return headers.get(name);
241
+ const key = Object.keys(headers ?? {}).find((candidate) => candidate.toLowerCase() === name);
242
+ const value = key === undefined ? undefined : headers[key];
243
+ return Array.isArray(value) ? value[0] : value;
244
+ };
245
+ const origin = get("origin");
246
+ if (origin) return origin === expected;
247
+ return get("sec-fetch-site") === "same-origin";
248
+ }
249
+
250
+ /**
251
+ * An `onChange` for createConsentManager that POSTs the record as JSON to a
252
+ * same-origin path. A failure throws an error whose `retryable` says whether
253
+ * the manager should keep the record and send it again on the next page
254
+ * (network errors, 429 and 5xx: yes; other 4xx: no — the server refused it).
255
+ */
256
+ export function postConsentRecord(endpoint, options = {}) {
257
+ if (typeof endpoint !== "string" || !endpoint.startsWith("/") || endpoint.startsWith("//")) {
258
+ fail(`consent records go to a same-origin path such as "/api/consent"; got ${JSON.stringify(endpoint)}`);
259
+ }
260
+ return async (record) => {
261
+ const send = options.fetch ?? ((...args) => globalThis.fetch(...args));
262
+ let response;
263
+ try {
264
+ response = await send(endpoint, {
265
+ method: "POST",
266
+ credentials: "same-origin",
267
+ keepalive: true,
268
+ headers: { "content-type": "application/json" },
269
+ body: JSON.stringify(record),
270
+ });
271
+ } catch (cause) {
272
+ throw Object.assign(new Error("consent record was not sent"), { retryable: true, cause });
273
+ }
274
+ if (!response.ok) {
275
+ throw Object.assign(new Error(`consent record was refused (${response.status})`), { retryable: response.status === 429 || response.status >= 500 });
276
+ }
277
+ };
278
+ }
279
+
280
+ function randomId(scope) {
281
+ const crypto = scope?.crypto ?? globalThis.crypto;
282
+ const bytes = new Uint8Array(16);
283
+ crypto.getRandomValues(bytes);
284
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
285
+ }
286
+
287
+ const isConsentCommand = (item) => item !== null && typeof item === "object" && item[0] === "consent";
288
+
289
+ /**
290
+ * The browser side. Create it once per page (module scope is fine — it does
291
+ * nothing until `start()`), mount the banner, which calls `start()`, or call
292
+ * `start()` yourself in a framework-free page.
293
+ */
294
+ export function createConsentManager(options) {
295
+ const policyVersion = checkVersion(options?.policyVersion);
296
+ const gtm = options.gtm === undefined ? undefined : checkGtm(options.gtm);
297
+ const cookieName = checkCookieName(options.cookieName ?? CONSENT_COOKIE_NAME);
298
+ const cookieDomain = options.cookieDomain === undefined ? undefined : checkDomain(options.cookieDomain);
299
+ const maxAgeSeconds = checkMaxAge(options.maxAgeSeconds ?? CONSENT_MAX_AGE_SECONDS);
300
+ const measurementIds = (options.measurementIds ?? []).map(checkMeasurementId);
301
+ // ga-disable-<id> is the only switch that stops a GA4 tag Tag Manager has
302
+ // already initialised: its own listeners (history page_view, scroll,
303
+ // outbound clicks) bypass the dataLayer gate and would keep sending
304
+ // cookieless pings to Google until the page reloads.
305
+ if (gtm !== undefined && measurementIds.length === 0) {
306
+ fail("a consent manager that loads Tag Manager needs measurementIds (the GA4 streams the container sends to), so a withdrawal can switch them off");
307
+ }
308
+ const pendingKey = `${cookieName}_pending`;
309
+ const listeners = new Set();
310
+ const inFlight = new Set();
311
+ let started = false;
312
+ let dismissed = false;
313
+ let analyticsOn = false;
314
+ // The decision made on this page, for when the browser refuses the cookie:
315
+ // the choice still holds until the page is left, so the bar does not stay
316
+ // up after a click as if nothing happened.
317
+ let decidedHere = null;
318
+
319
+ const scope = () => options.window ?? (typeof window === "undefined" ? undefined : window);
320
+ const now = () => (options.now ? options.now() : Date.now());
321
+ const readRaw = () => {
322
+ const w = scope();
323
+ return w ? parseConsentCookie(cookieValue(w.document.cookie, cookieName)) : null;
324
+ };
325
+ const current = (candidate) => (candidate && candidate.v === policyVersion && isFresh(candidate, { now: now(), maxAgeSeconds }) ? candidate : null);
326
+ const state = () => current(readRaw()) ?? current(decidedHere);
327
+ const emit = (event) => {
328
+ for (const listener of [...listeners]) {
329
+ try { listener(event); } catch (error) { queueMicrotask(() => { throw error; }); }
330
+ }
331
+ };
332
+ const consentCommand = (...args) => {
333
+ const w = scope();
334
+ w.dataLayer = w.dataLayer || [];
335
+ // Tag Manager reads gtag commands only as `arguments` objects.
336
+ (function g() { w.dataLayer.push(arguments); })(...args);
337
+ };
338
+
339
+ // While analytics is not granted, the page's dataLayer accepts consent
340
+ // commands and drops everything else, so events a product pushes before
341
+ // consent are not queued for a Tag Manager that loads later, and events
342
+ // after a withdrawal never reach the one that already loaded.
343
+ const installGate = () => {
344
+ const w = scope();
345
+ const layer = (w.dataLayer = w.dataLayer || []);
346
+ if (layer.push[GATE]) return;
347
+ const inner = layer.push;
348
+ const gated = function (...items) {
349
+ const pass = analyticsOn ? items : items.filter(isConsentCommand);
350
+ return pass.length > 0 ? inner.apply(this, pass) : this.length;
351
+ };
352
+ gated[GATE] = true;
353
+ layer.push = gated;
354
+ };
355
+
356
+ const readNonce = (document) => {
357
+ for (const selector of ['meta[property="csp-nonce"]', 'meta[name="csp-nonce"]']) {
358
+ const meta = document.querySelector(selector);
359
+ const value = meta && (meta.nonce || meta.getAttribute("nonce") || meta.getAttribute("content"));
360
+ if (value) return value;
361
+ }
362
+ const element = document.querySelector("[nonce]");
363
+ return (element && (element.nonce || element.getAttribute("nonce"))) || undefined;
364
+ };
365
+
366
+ const loadGtm = () => {
367
+ const w = scope();
368
+ if (gtm === undefined || !w) return;
369
+ const document = w.document;
370
+ if (document.querySelector(`script[src^="${GTM_SCRIPT_PREFIX}"]`)) return;
371
+ w.dataLayer.push({ "gtm.start": now(), event: "gtm.js" });
372
+ const script = document.createElement("script");
373
+ script.async = true;
374
+ script.src = `${GTM_SCRIPT_PREFIX}?id=${gtm}`;
375
+ const nonce = options.nonce ?? readNonce(document);
376
+ if (nonce) script.setAttribute("nonce", nonce);
377
+ const first = document.getElementsByTagName("script")[0];
378
+ if (first?.parentNode) first.parentNode.insertBefore(script, first);
379
+ else (document.head ?? document.documentElement).appendChild(script);
380
+ };
381
+
382
+ const deleteAnalyticsCookies = () => {
383
+ const w = scope();
384
+ const document = w.document;
385
+ const host = w.location?.hostname ?? "";
386
+ const labels = /^[0-9.]+$|^\[|:/.test(host) ? [] : host.split(".");
387
+ const domains = [undefined];
388
+ for (let index = 0; index <= labels.length - 2; index += 1) domains.push(labels.slice(index).join("."));
389
+ const names = new Set(
390
+ document.cookie.split(";").map((part) => part.split("=", 1)[0].trim()).filter((name) => GA_COOKIE_PATTERN.test(name)),
391
+ );
392
+ for (const name of names) {
393
+ for (const domain of domains) document.cookie = `${name}=; Max-Age=0; Path=/${domain ? `; Domain=${domain}` : ""}`;
394
+ }
395
+ };
396
+
397
+ const setGaDisabled = (disabled) => {
398
+ const w = scope();
399
+ for (const id of measurementIds) w[`ga-disable-${id}`] = disabled;
400
+ };
401
+
402
+ const apply = (granted) => {
403
+ const w = scope();
404
+ if (granted) {
405
+ analyticsOn = true;
406
+ setGaDisabled(false);
407
+ if (w.__siteConsent !== "granted") consentCommand("consent", "update", { analytics_storage: "granted" });
408
+ w.__siteConsent = "granted";
409
+ loadGtm();
410
+ return;
411
+ }
412
+ analyticsOn = false;
413
+ installGate();
414
+ if (w.__siteConsent === "granted") consentCommand("consent", "update", { analytics_storage: "denied" });
415
+ w.__siteConsent = "denied";
416
+ setGaDisabled(true);
417
+ deleteAnalyticsCookies();
418
+ };
419
+
420
+ const readPending = () => {
421
+ try {
422
+ const value = JSON.parse(scope()?.localStorage?.getItem(pendingKey) ?? "[]");
423
+ return Array.isArray(value) ? value : [];
424
+ } catch { return []; }
425
+ };
426
+ const writePending = (records) => {
427
+ try {
428
+ const storage = scope()?.localStorage;
429
+ if (!storage) return;
430
+ if (records.length === 0) storage.removeItem(pendingKey);
431
+ else storage.setItem(pendingKey, JSON.stringify(records.slice(-20)));
432
+ } catch { /* storage blocked: the record is lost, the cookie still holds the choice */ }
433
+ };
434
+ const deliver = (record) => {
435
+ if (!options.onChange) return;
436
+ const attempt = (async () => {
437
+ try { await options.onChange(record); } catch (error) {
438
+ if (error?.retryable !== false) writePending([...readPending(), record]);
439
+ }
440
+ })();
441
+ inFlight.add(attempt);
442
+ attempt.finally(() => inFlight.delete(attempt));
443
+ };
444
+
445
+ const start = () => {
446
+ const w = scope();
447
+ if (!w || started) return;
448
+ started = true;
449
+ analyticsOn = state()?.a === 1;
450
+ installGate();
451
+ if (w.__siteConsent === undefined) {
452
+ consentCommand("consent", "default", { ...CONSENT_MODE_DEFAULTS });
453
+ w.__siteConsent = "denied";
454
+ }
455
+ // The head may disagree with the cookie (another tab changed it after
456
+ // this page was rendered); the cookie wins.
457
+ if (analyticsOn) apply(true);
458
+ else if (w.__siteConsent === "granted") apply(false);
459
+ const pending = readPending();
460
+ if (pending.length > 0) {
461
+ writePending([]);
462
+ for (const record of pending) deliver(record);
463
+ }
464
+ };
465
+
466
+ const decide = (analytics) => {
467
+ const w = scope();
468
+ if (!w) throw new Error("consent decisions are made in a browser");
469
+ start();
470
+ const previous = state();
471
+ const action = !previous
472
+ ? (analytics ? "grant" : "deny")
473
+ : previous.a === 1 ? (analytics ? "update" : "withdraw") : (analytics ? "grant" : "update");
474
+ const next = Object.freeze({ v: policyVersion, a: analytics ? 1 : 0, t: nowSeconds(now()), id: previous?.id ?? readRaw()?.id ?? randomId(w) });
475
+ w.document.cookie = serializeConsentCookie(next, { name: cookieName, maxAgeSeconds, ...(cookieDomain ? { domain: cookieDomain } : {}) });
476
+ decidedHere = next;
477
+ dismissed = false;
478
+ apply(analytics);
479
+ const path = normalizeConsentPath(options.recordPath ? options.recordPath(w.location?.pathname ?? "/") : w.location?.pathname);
480
+ const record = Object.freeze({ policyVersion, analytics, action, anonymousId: next.id, decidedAt: next.t, source: "web", path });
481
+ emit({ type: "change", state: next, record });
482
+ deliver(record);
483
+ return record;
484
+ };
485
+
486
+ return Object.freeze({
487
+ policyVersion,
488
+ /** Applies the stored decision to this page: Consent Mode defaults, then Tag Manager only if granted. Idempotent; a no-op outside a browser. */
489
+ start,
490
+ state,
491
+ analyticsGranted: () => state()?.a === 1,
492
+ needsDecision: () => state() === null,
493
+ acceptAll: () => decide(true),
494
+ rejectAll: () => decide(false),
495
+ save: (choice) => decide(choice?.analytics === true),
496
+ withdraw: () => decide(false),
497
+ openSettings: () => emit({ type: "open-settings" }),
498
+ dismiss: () => { dismissed = true; emit({ type: "dismiss" }); },
499
+ dismissed: () => dismissed,
500
+ subscribe(listener) {
501
+ listeners.add(listener);
502
+ return () => listeners.delete(listener);
503
+ },
504
+ /** Opens settings from any click on `[data-consent-settings]` under `root` (a footer link in a page without the Solid kit). */
505
+ bindTriggers(root) {
506
+ const target = root ?? scope()?.document;
507
+ if (!target) return () => {};
508
+ const onClick = (event) => {
509
+ if (!event.target?.closest?.("[data-consent-settings]")) return;
510
+ event.preventDefault();
511
+ emit({ type: "open-settings" });
512
+ };
513
+ target.addEventListener("click", onClick);
514
+ return () => target.removeEventListener("click", onClick);
515
+ },
516
+ /** Resolves when every record sent so far has been delivered or queued. */
517
+ settled: () => Promise.all([...inFlight]).then(() => undefined),
518
+ });
519
+ }
520
+
521
+ /**
522
+ * Default strings, Korean. Products pass these (or their own) to the banner.
523
+ * Short on purpose (owner decision, 2026-10-06): the bar says what and why and
524
+ * that it is optional; "자세히 보기" links to the product's privacy policy,
525
+ * whose analytics section carries the full disclosure (Google Analytics,
526
+ * recipient, overseas transfer, retention, no advertising, withdrawal).
527
+ */
528
+ export const CONSENT_MESSAGES_KO = Object.freeze({
529
+ regionLabel: "쿠키 동의",
530
+ title: "이용 통계 수집 동의 (선택)",
531
+ body: "서비스를 더 낫게 만들기 위해 이용 통계를 수집합니다. 동의는 선택이며, 동의하지 않아도 모든 기능을 쓸 수 있습니다.",
532
+ learnMore: "자세히 보기",
533
+ privacyLink: "개인정보처리방침",
534
+ acceptAll: "모두 허용",
535
+ rejectAll: "거부",
536
+ settings: "설정",
537
+ dismiss: "선택하지 않고 닫기",
538
+ settingsTitle: "쿠키 설정",
539
+ settingsIntro: "바닥글의 ‘쿠키 설정’에서 언제든 다시 바꿀 수 있습니다.",
540
+ necessaryTitle: "필수",
541
+ necessaryBody: "사이트가 동작하는 데 꼭 필요해 항상 사용합니다.",
542
+ necessaryStatus: "항상 사용",
543
+ analyticsTitle: "이용 통계 (선택)",
544
+ analyticsBody: "서비스를 개선하는 데 쓰는 이용 통계입니다.",
545
+ analyticsSwitch: "이용 통계 수집 허용",
546
+ save: "선택 저장",
547
+ cancel: "취소",
548
+ close: "닫기",
549
+ saved: "쿠키 설정을 저장했습니다.",
550
+ trigger: "쿠키 설정",
551
+ });
552
+
553
+ /** Default strings, English. */
554
+ export const CONSENT_MESSAGES_EN = Object.freeze({
555
+ regionLabel: "Cookie consent",
556
+ title: "Analytics (optional)",
557
+ body: "We collect usage statistics to improve the service. It's optional, and everything works without it.",
558
+ learnMore: "Learn more",
559
+ privacyLink: "Privacy policy",
560
+ acceptAll: "Accept all",
561
+ rejectAll: "Reject",
562
+ settings: "Settings",
563
+ dismiss: "Close without choosing",
564
+ settingsTitle: "Cookie settings",
565
+ settingsIntro: "You can change this at any time under “Cookie settings” in the footer.",
566
+ necessaryTitle: "Necessary",
567
+ necessaryBody: "Needed for the site to work, so always on.",
568
+ necessaryStatus: "Always on",
569
+ analyticsTitle: "Analytics (optional)",
570
+ analyticsBody: "Usage statistics that help us improve the service.",
571
+ analyticsSwitch: "Allow analytics",
572
+ save: "Save choices",
573
+ cancel: "Cancel",
574
+ close: "Close",
575
+ saved: "Your cookie settings are saved.",
576
+ trigger: "Cookie settings",
577
+ });
@@ -1,3 +1,4 @@
1
+ // Mirrors index.mjs statement for statement: each module re-exported whole.
1
2
  export * from "./locales.mjs";
2
3
  export * from "./catalog.mjs";
3
4
  export * from "./seo.mjs";
@@ -5,3 +6,4 @@ export * from "./geo.mjs";
5
6
  export * from "./publisher.mjs";
6
7
  export * from "./gtm.mjs";
7
8
  export * from "./fonts.mjs";
9
+ export * from "./consent.mjs";
@@ -1,7 +1,15 @@
1
- export { FAMILY_LOCALES, LOCALES, canonicalLocale, defineLocaleRegistry, localeAttributes, resolveLocale } from "./locales.mjs";
2
- export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs";
3
- export { BRAND_ICON_FILES, ROBOTS_USER_AGENTS, brandIconLinks, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
4
- export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
5
- export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
6
- export { GTM_CONTAINER_ID_PATTERN, GTM_CSP_SOURCES, gtmHeadScript, gtmNoscriptIframe } from "./gtm.mjs";
7
- export { FAMILY_FONT_PRELOAD_FILE, fontPreloadLinks } from "./fonts.mjs";
1
+ // The root barrel re-exports each module whole, statement for statement the
2
+ // same as index.d.mts. It used to name every export by hand, and the consent
3
+ // list fell behind its own declarations: CONSENT_RECORD_MAX_BYTES and nine
4
+ // other names type-checked from "@devslab/site-kit" and were undefined at
5
+ // runtime. A module that should stay off the root (flags, devslab) is simply
6
+ // not listed here; scripts/verify-site-kit-release.mjs checks the packed
7
+ // tarball's declared exports against what each entry point really exports.
8
+ export * from "./locales.mjs";
9
+ export * from "./catalog.mjs";
10
+ export * from "./seo.mjs";
11
+ export * from "./geo.mjs";
12
+ export * from "./publisher.mjs";
13
+ export * from "./gtm.mjs";
14
+ export * from "./fonts.mjs";
15
+ export * from "./consent.mjs";
@@ -9,9 +9,20 @@ import type { SiteLocale } from "./core/locales.mjs";
9
9
  /**
10
10
  * `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted.
11
11
  * `fontPreload` appends fontPreloadLinks(fontPreload) — the same-origin URL(s) the product's bundler gave the family face, e.g. Geist Latin imported with `?url` (D-033). Omitted, no preload links are emitted.
12
- * `gtm` adds Google Tag Manager's head loader for that container id as `scripts[0]`; omitted or `undefined` (a route without Tag Manager), the result has no `scripts` key.
12
+ * `gtm` adds Google Tag Manager's head loader for that container id as `scripts[0]` — unconditionally, without consent. Kept for compatibility; family products use `consent` instead (D-034).
13
+ * `consent` adds consentHeadEntry(consent) as `scripts[0]`: Consent Mode defaults, plus the loader only when the cookie grants analytics for the current policy version. Passing both `gtm` and `consent` throws TypeError.
14
+ * Neither (a route without Tag Manager): the result has no `scripts` key.
13
15
  */
14
- export interface TanStackHeadOptions { icons?: boolean | { basePath?: string }; fontPreload?: string | readonly string[] | undefined; gtm?: string | undefined }
16
+ export interface TanStackHeadOptions { icons?: boolean | { basePath?: string }; fontPreload?: string | readonly string[] | undefined; gtm?: string | undefined; consent?: ConsentHeadOptions | undefined }
17
+ /**
18
+ * `cookie`: the request's Cookie header on the server, `document.cookie` in the browser (createIsomorphicFn), so hydration sees the same script.
19
+ * `gtm`: the container to load once analytics is granted; omitted, the entry is the Consent Mode defaults only.
20
+ */
21
+ export interface ConsentHeadOptions { policyVersion: string; cookie: string | null | undefined; gtm?: string | undefined; cookieName?: string | undefined }
22
+ /** The consent-gated `scripts` entry (inline script body only; the router adds `ssr.nonce`). Throws RangeError for a malformed id or policy version. */
23
+ export declare function consentHeadEntry(options: ConsentHeadOptions): GtmHeadEntry;
24
+ /** Send on every route that uses `consent` (the route's `headers` option): the head differs per visitor, so the page must never sit in a shared cache. */
25
+ export { CONSENT_RESPONSE_HEADERS } from "./core/consent.mjs";
15
26
  /** A route `head().scripts` entry: the loader body only. The router adds `ssr.nonce` when it renders the tag. Throws RangeError for a malformed id. */
16
27
  export interface GtmHeadEntry { children: string }
17
28
  export declare function gtmHeadEntry(containerId: string): GtmHeadEntry;