@devslab/site-kit 0.15.0 → 0.16.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,567 @@
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
+ /** Default strings, Korean. Products pass these (or their own) to the banner. */
522
+ export const CONSENT_MESSAGES_KO = Object.freeze({
523
+ regionLabel: "쿠키 동의",
524
+ title: "방문 통계에 쿠키를 써도 될까요?",
525
+ body: "동의하시면 Google Analytics로 어떤 페이지를 얼마나 보는지 통계를 냅니다. 데이터는 Google LLC(미국)로 전송되어 14개월 동안 보관되고, 광고에는 쓰지 않습니다. 동의하지 않아도 사이트의 모든 기능을 그대로 쓸 수 있고, 바닥글의 ‘쿠키 설정’에서 언제든 바꿀 수 있습니다.",
526
+ privacyLink: "개인정보처리방침",
527
+ acceptAll: "모두 허용",
528
+ rejectAll: "거부",
529
+ settings: "설정",
530
+ dismiss: "선택하지 않고 닫기",
531
+ settingsTitle: "쿠키 설정",
532
+ settingsIntro: "필수 쿠키는 사이트가 동작하는 데 필요해 항상 사용합니다. 방문 통계는 동의하실 때만 사용하며, 여기서 언제든 바꾸거나 철회할 수 있습니다.",
533
+ necessaryTitle: "필수",
534
+ necessaryBody: "서비스 제공과 보안, 법령상 의무를 위해 꼭 필요해 거부할 수 없는 쿠키입니다. 이 선택을 기억하는 쿠키도 여기에 속합니다.",
535
+ necessaryStatus: "항상 사용",
536
+ analyticsTitle: "방문 통계 (선택)",
537
+ analyticsBody: "Google Analytics 4로 방문한 페이지, 머문 시간, 기기·브라우저 종류를 가명 식별자와 함께 수집해 통계를 냅니다. 받는 곳: Google LLC(미국으로 국외 이전). 보관 기간: 14개월. 광고에는 쓰지 않습니다.",
538
+ analyticsSwitch: "방문 통계 허용",
539
+ save: "선택 저장",
540
+ close: "닫기",
541
+ saved: "쿠키 설정을 저장했습니다.",
542
+ trigger: "쿠키 설정",
543
+ });
544
+
545
+ /** Default strings, English. */
546
+ export const CONSENT_MESSAGES_EN = Object.freeze({
547
+ regionLabel: "Cookie consent",
548
+ title: "May we use cookies for visit statistics?",
549
+ body: "If you agree, Google Analytics counts which pages are viewed and for how long. The data goes to Google LLC in the United States, is kept for 14 months, and is never used for ads. Everything on this site works without it, and you can change your choice at any time under “Cookie settings” in the footer.",
550
+ privacyLink: "Privacy policy",
551
+ acceptAll: "Accept all",
552
+ rejectAll: "Reject",
553
+ settings: "Settings",
554
+ dismiss: "Close without choosing",
555
+ settingsTitle: "Cookie settings",
556
+ settingsIntro: "Necessary cookies keep the site working and are always on. Visit statistics run only with your consent, and you can change or withdraw it here at any time.",
557
+ necessaryTitle: "Necessary",
558
+ necessaryBody: "Needed to provide the service, keep it secure and meet legal obligations, so they cannot be turned off. The cookie that remembers this choice is one of them.",
559
+ necessaryStatus: "Always on",
560
+ analyticsTitle: "Visit statistics (optional)",
561
+ analyticsBody: "Google Analytics 4 records the pages you view, time spent, and device and browser type under a pseudonymous ID to produce statistics. Recipient: Google LLC (transferred to the United States). Kept for 14 months. Never used for ads.",
562
+ analyticsSwitch: "Allow visit statistics",
563
+ save: "Save choices",
564
+ close: "Close",
565
+ saved: "Your cookie settings are saved.",
566
+ trigger: "Cookie settings",
567
+ });
@@ -5,3 +5,4 @@ export * from "./geo.mjs";
5
5
  export * from "./publisher.mjs";
6
6
  export * from "./gtm.mjs";
7
7
  export * from "./fonts.mjs";
8
+ export * from "./consent.mjs";
@@ -5,3 +5,7 @@ export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.
5
5
  export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
6
6
  export { GTM_CONTAINER_ID_PATTERN, GTM_CSP_SOURCES, gtmHeadScript, gtmNoscriptIframe } from "./gtm.mjs";
7
7
  export { FAMILY_FONT_PRELOAD_FILE, fontPreloadLinks } from "./fonts.mjs";
8
+ export {
9
+ CONSENT_COOKIE_NAME, CONSENT_MESSAGES_EN, CONSENT_MESSAGES_KO, CONSENT_MODE_DEFAULTS, CONSENT_RESPONSE_HEADERS, analyticsConsented, consentCookieGrantsAnalytics,
10
+ consentHeadScript, createConsentManager, isSameOriginRequest, parseConsentRecord, postConsentRecord, readConsentCookie,
11
+ } 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;
@@ -1,6 +1,14 @@
1
1
  import { brandIconLinks } from "./core/seo.mjs";
2
2
  import { gtmHeadScript } from "./core/gtm.mjs";
3
3
  import { fontPreloadLinks } from "./core/fonts.mjs";
4
+ import { consentCookieGrantsAnalytics, consentHeadScript } from "./core/consent.mjs";
5
+
6
+ /**
7
+ * Send these on every route that uses `consent` (TanStack Start: the route's
8
+ * `headers` option). The head differs per visitor's cookie, so the page must
9
+ * never sit in a shared cache.
10
+ */
11
+ export { CONSENT_RESPONSE_HEADERS } from "./core/consent.mjs";
4
12
 
5
13
  /**
6
14
  * A route `head().scripts` entry that loads Google Tag Manager (D-031).
@@ -17,11 +25,33 @@ export function gtmHeadEntry(containerId) {
17
25
  return { children: gtmHeadScript(containerId) };
18
26
  }
19
27
 
28
+ /**
29
+ * The consent-gated head entry (D-034): one inline script. Without a current
30
+ * analytics grant in `cookie` it only pushes Consent Mode defaults (all
31
+ * denied) into the page's dataLayer — nothing contacts Google. With one, it
32
+ * also grants `analytics_storage` and runs Google's Tag Manager loader.
33
+ *
34
+ * `cookie` is the Cookie header on the server and `document.cookie` in the
35
+ * browser (TanStack Start: createIsomorphicFn), so both sides of hydration
36
+ * render the same script. Only the consent cookie is read from it; nothing
37
+ * from it is written into the page. Like gtmHeadEntry, no nonce: the router
38
+ * stamps `ssr.nonce`.
39
+ */
40
+ export function consentHeadEntry(options) {
41
+ const granted = consentCookieGrantsAnalytics(options?.cookie, {
42
+ policyVersion: options?.policyVersion,
43
+ ...(options.cookieName === undefined ? {} : { cookieName: options.cookieName }),
44
+ });
45
+ return { children: consentHeadScript({ granted, gtm: options.gtm }) };
46
+ }
47
+
20
48
  // `icons` is opt-in: the adapter cannot know where (or whether) a product
21
49
  // serves the linq-brand files, and a head that links icons the server 404s
22
50
  // is worse than one that links none. `true` takes the /brand default.
23
51
  // `gtm` is opt-in the same way, and per route: the product decides which
24
52
  // pages carry Tag Manager (public marketing and legal pages, not consoles).
53
+ // It loads Tag Manager unconditionally and stays for compatibility; products
54
+ // switch to `consent` (D-034), which loads it only after an opt-in.
25
55
  // `fontPreload` is opt-in too: only the product knows the hashed URL its
26
56
  // bundler gave the face (D-033). Appended after the icons.
27
57
  export function toTanStackHead(metadata, options = {}) {
@@ -49,7 +79,11 @@ export function toTanStackHead(metadata, options = {}) {
49
79
  ...(options.fontPreload === undefined ? [] : fontPreloadLinks(options.fontPreload)),
50
80
  ],
51
81
  };
52
- if (options.gtm !== undefined) head.scripts = [gtmHeadEntry(options.gtm)];
82
+ if (options.gtm !== undefined && options.consent !== undefined) {
83
+ throw new TypeError("toTanStackHead: pass the container id as consent.gtm, not both gtm and consent — gtm alone loads Tag Manager without consent");
84
+ }
85
+ if (options.consent !== undefined) head.scripts = [consentHeadEntry(options.consent)];
86
+ else if (options.gtm !== undefined) head.scripts = [gtmHeadEntry(options.gtm)];
53
87
  return head;
54
88
  }
55
89