@escape-game-over/atlas 0.1.36 → 0.1.38

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.
@@ -46,8 +46,12 @@ finding them.
46
46
  analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`) renders the
47
47
  site's markup as its children, skips itself — script included — when the
48
48
  analytics need no permission, and remembers, expires and applies the answer. The
49
- markup marks its buttons `data-consent="grant"` and `data-consent="deny"`,
50
- and the control that brings it back `data-consent-reopen hidden`.
49
+ answer is two, one per category: `analytics` (Google's `analytics_storage`) and
50
+ `marketing` (the three `ad_*` signals, and any other ad network's tag). The
51
+ markup marks its buttons `data-consent="grant"` and `data-consent="deny"` for
52
+ everything at once, and `data-consent="save"` for the answer its checkboxes
53
+ give — `data-consent-category="analytics"` and `"marketing"`, never ticked by
54
+ default. The control that brings it back is `data-consent-reopen hidden`.
51
55
 
52
56
  `Document` ships one more: scroll restoration, on unless `restoreScroll={false}`.
53
57
  WebKit restores a page's position only at `load`, after every image, so a page
@@ -562,7 +566,8 @@ right properties is a faithful stand-in.
562
566
  - `tests/element.test.ts` stubs `customElements` to upgrade on `define`, as a
563
567
  browser does, and pins what `connect` is handed.
564
568
  - `tests/consent.test.ts` fakes `localStorage`, with a switch that makes it
565
- throw, and the Google hook, and pins the six-month expiry to the day.
569
+ throw, and the Google hook, and pins the six-month expiry to the day and
570
+ that an answer missing a category is asked again.
566
571
 
567
572
  `carousel` has none, nor does the consent banner's element, and `element` has
568
573
  no lifecycle test — moves and teardown. They need a real DOM and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What a visitor's consent answer is, where it is kept, and for how long it
3
+ * counts.
4
+ *
5
+ * Shared by the two halves that read it: the banner's module script
6
+ * (`astro/consent.ts`), and the inline head block Google's tags emit, which
7
+ * restores an answer before any tag fires. Two readers with their own copies of
8
+ * the key, the expiry or the categories would disagree the first time either
9
+ * changed.
10
+ */
11
+
12
+ /** Granted or denied — the only two answers Consent Mode has. */
13
+ export type ConsentChoice = "granted" | "denied";
14
+
15
+ /**
16
+ * What a visitor is asked about, each answered on its own.
17
+ *
18
+ * - `analytics` — measuring how the site is used. Google's `analytics_storage`.
19
+ * - `marketing` — advertising: measuring ad conversions and remarketing. Google's
20
+ * `ad_storage`, `ad_user_data` and `ad_personalization`, and any other ad
21
+ * network's tag a site adds.
22
+ *
23
+ * Two, because the GDPR wants a choice per purpose: a yes to analytics that also
24
+ * granted advertising would not have asked about advertising at all.
25
+ */
26
+ export type ConsentCategory = "analytics" | "marketing";
27
+
28
+ export const CONSENT_CATEGORIES: readonly ConsentCategory[] = [
29
+ "analytics",
30
+ "marketing",
31
+ ];
32
+
33
+ /** One answer per category. */
34
+ export type ConsentChoices = Readonly<Record<ConsentCategory, ConsentChoice>>;
35
+
36
+ /**
37
+ * How long an answer stands before the question is asked again.
38
+ *
39
+ * Consent is not forever and regulators say so: France's CNIL puts the outside
40
+ * limit at 13 months and recommends six, and the EDPB's position is that a
41
+ * choice made long enough ago is no longer informed. Six is the conservative
42
+ * reading, and the reason the record carries a date at all — without one there
43
+ * is no way to expire it, and no way to answer "when did this visitor agree?",
44
+ * which is a question only ever asked when somebody is already unhappy.
45
+ */
46
+ export const CONSENT_MONTHS = 6;
47
+
48
+ /**
49
+ * The `localStorage` key the answer is kept under. Prefixed to keep clear of the
50
+ * bare `"consent"` a third-party widget would reach for.
51
+ */
52
+ export const CONSENT_KEY = "atlas-consent";
@@ -1,5 +1,11 @@
1
1
  import type { NonEmpty } from "../types.ts";
2
2
  import { warn } from "../warn.ts";
3
+ import {
4
+ CONSENT_CATEGORIES,
5
+ CONSENT_KEY,
6
+ CONSENT_MONTHS,
7
+ type ConsentCategory,
8
+ } from "./consent-storage.ts";
3
9
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
4
10
 
5
11
  /**
@@ -131,6 +137,17 @@ export interface GoogleSettings {
131
137
  * unstated case emits.
132
138
  */
133
139
  readonly consent?: NonEmpty<ConsentDefaults>;
140
+ /**
141
+ * Carries an ad click's id (`gclid`, `gbraid`, `wbraid`) from page to page
142
+ * in the URL while `ad_storage` is denied. **Off unless stated.**
143
+ *
144
+ * Denied, the Google tag cannot keep the click id in a cookie, so a visitor
145
+ * who lands on one page and converts on another loses it on the way. For a
146
+ * site running Google Ads — where that id is what ties a lead back to the
147
+ * click — this keeps it on the internal links they follow. A site without
148
+ * ads has no click id to carry.
149
+ */
150
+ readonly urlPassthrough?: true;
134
151
  }
135
152
 
136
153
  /**
@@ -180,7 +197,8 @@ const CONSENT_KEYS = {
180
197
  } as const satisfies Readonly<Record<keyof ConsentDefaults, string>>;
181
198
 
182
199
  /**
183
- * The global a consent banner calls to change its mind: `__consent('granted')`.
200
+ * The global a consent banner calls to change its mind:
201
+ * `__consent({ analytics: "granted", marketing: "denied" })`.
184
202
  *
185
203
  * Emitted by lib rather than written in each project, because the *signals* it
186
204
  * updates must be exactly the ones lib defaulted. A banner that built the
@@ -209,19 +227,68 @@ const SIGNAL_KEYS = [
209
227
  ] as const satisfies readonly (keyof ConsentDefaults)[];
210
228
 
211
229
  /**
212
- * `window.__consent(state)`, updating precisely what was defaulted.
230
+ * Which signals each answer moves.
231
+ *
232
+ * `functionality`, `personalization` and `security` are in neither: they are
233
+ * the site working, not something a visitor is asked about. A deployment that
234
+ * defaults one of them to denied is warned, since no answer can grant it back.
235
+ */
236
+ const CATEGORY_SIGNALS: Readonly<
237
+ Record<ConsentCategory, readonly (keyof ConsentDefaults)[]>
238
+ > = {
239
+ analytics: ["analyticsStorage"],
240
+ marketing: ["adStorage", "adUserData", "adPersonalization"],
241
+ };
242
+
243
+ /** The signals no answer grants — see `CATEGORY_SIGNALS`. */
244
+ const UNASKED = SIGNAL_KEYS.filter(
245
+ (key) =>
246
+ !CONSENT_CATEGORIES.some((category) =>
247
+ CATEGORY_SIGNALS[category].includes(key)
248
+ )
249
+ );
250
+
251
+ /**
252
+ * `window.__consent(choices)`, updating precisely what was defaulted, each
253
+ * signal from the answer for its category.
213
254
  *
214
- * Every signal any default mentioned, and nothing else. Updating one that was
215
- * never defaulted is legal and pointless — Google reads it as a change from its
216
- * own implicit grant, which was never in force here.
255
+ * Only signals a default mentioned. Updating one that was never defaulted is
256
+ * legal and pointless — Google reads it as a change from its own implicit
257
+ * grant, which was never in force here.
217
258
  */
218
259
  function consentUpdater(defaults: readonly ConsentDefaults[]): string {
219
- const signals = SIGNAL_KEYS.filter((key) =>
220
- defaults.some((given) => given[key] !== undefined)
221
- ).map((key) => `${literal(CONSENT_KEYS[key])}:s`);
260
+ const signals = CONSENT_CATEGORIES.flatMap((category) =>
261
+ CATEGORY_SIGNALS[category]
262
+ .filter((key) => defaults.some((given) => given[key] !== undefined))
263
+ .map((key) => `${literal(CONSENT_KEYS[key])}:c.${category}`)
264
+ );
222
265
 
223
266
  if (signals.length === 0) return "";
224
- return `window.${CONSENT_UPDATE_GLOBAL}=function(s){gtag('consent','update',{${signals.join(",")}})};`;
267
+ return `window.${CONSENT_UPDATE_GLOBAL}=function(c){gtag('consent','update',{${signals.join(",")}})};`;
268
+ }
269
+
270
+ /**
271
+ * A returning visitor's answer, handed to Google before any tag fires.
272
+ *
273
+ * It has to happen here, in the head, ahead of `gtag('config', …)`. Left to the
274
+ * banner's module script it arrives after `gtag.js` has already sent the page
275
+ * view on the defaults — so every page a consenting visitor opened was recorded
276
+ * as denied: no cookie, no returning user, no session. Measured, not supposed:
277
+ * `gcs=G100` on the `page_view`, `G111` only on the hits after it.
278
+ *
279
+ * The same key, expiry and categories `astro/consent.ts` reads, from the one
280
+ * place both take them. Anything unreadable, expired, absent or missing a
281
+ * category — an answer given before that category was asked — leaves the
282
+ * defaults standing, and the banner will ask.
283
+ */
284
+ function restoredConsent(): string {
285
+ const answered = CONSENT_CATEGORIES.map(
286
+ (category) => `(c.${category}==="granted"||c.${category}==="denied")`
287
+ ).join("&&");
288
+ const choices = CONSENT_CATEGORIES.map(
289
+ (category) => `${category}:c.${category}`
290
+ ).join(",");
291
+ return `try{var c=JSON.parse(localStorage.getItem(${literal(CONSENT_KEY)})),e=new Date(c.at);e.setMonth(e.getMonth()+${CONSENT_MONTHS});if(${answered}&&e>new Date())window.${CONSENT_UPDATE_GLOBAL}({${choices}})}catch(_){}`;
225
292
  }
226
293
 
227
294
  /** One `gtag('consent','default',{…})` per stated default, in order. */
@@ -333,13 +400,28 @@ export function googleScripts(
333
400
  }
334
401
 
335
402
  const consent = google.consent ?? DENIED_BY_DEFAULT;
403
+ for (const key of UNASKED) {
404
+ if (consent.some((given) => given[key] === "denied")) {
405
+ warn(
406
+ at,
407
+ `consent defaults ${CONSENT_KEYS[key]} to denied, and no answer a visitor gives grants it — it is neither analytics nor marketing. Leave it to Google's default unless it is meant to stay denied.`
408
+ );
409
+ }
410
+ }
411
+ // The way back out of the defaults, covering what they denied.
412
+ const updater = consentUpdater(consent);
336
413
  const inline = [
337
414
  "window.dataLayer=window.dataLayer||[];",
338
415
  "function gtag(){dataLayer.push(arguments)}",
339
416
  // Ahead of everything, which is the entire point of emitting this here.
340
417
  consentCalls(consent),
341
- // And the way back out of it, covering exactly what was just denied.
342
- consentUpdater(consent),
418
+ updater,
419
+ // Then a remembered answer, still ahead of the first tag.
420
+ updater === "" ? "" : restoredConsent(),
421
+ // Before any `config`, which is what reads it.
422
+ google.urlPassthrough === true
423
+ ? "gtag('set','url_passthrough',true);"
424
+ : "",
343
425
  tags.length > 0 ? "gtag('js',new Date());" : "",
344
426
  ...tags.map((id) => `gtag('config',${literal(id)});`),
345
427
  // Tag Manager's own loader, once per container. It appends its script
@@ -1,44 +1,87 @@
1
+ import { CONSENT_CATEGORIES } from "../analytics/consent-storage.ts";
1
2
  import {
2
- applyConsent,
3
- type ConsentChoice,
3
+ type ConsentCategory,
4
+ type ConsentChoices,
5
+ everyCategory,
4
6
  readConsent,
5
7
  recordConsent,
6
8
  } from "./consent.ts";
7
9
  import { element } from "./element.ts";
8
10
  import { data, refs } from "./ref.ts";
9
11
 
10
- /** What each button does, in the words Consent Mode records. */
11
- const ACTIONS: Readonly<Record<string, ConsentChoice>> = {
12
- grant: "granted",
13
- deny: "denied",
14
- };
15
-
16
12
  /**
17
13
  * The banner's behaviour. Its own module, and not re-exported from `client.ts`,
18
14
  * so the script ships only where `<ConsentBanner>` renders.
19
15
  *
20
- * A site's markup marks the two buttons inside the banner `data-consent="grant"`
21
- * and `data-consent="deny"`, and the control that brings it back — usually in
22
- * the footer — `data-consent-reopen hidden`: it stays hidden until there is an
23
- * answer to withdraw.
16
+ * A site's markup marks its buttons with what they do:
17
+ *
18
+ * - `data-consent="grant"` — every category granted.
19
+ * - `data-consent="deny"` — every category denied.
20
+ * - `data-consent="save"` — each category as its checkbox says:
21
+ * `<input type="checkbox" data-consent-category="analytics">`, and one for
22
+ * `marketing`.
23
+ *
24
+ * A banner with only the first two still works — one answer for everything.
25
+ * The checkboxes are never ticked by default; they show an answer already
26
+ * given, and nothing else.
27
+ *
28
+ * The control that brings it back — usually in the footer — is
29
+ * `data-consent-reopen hidden`: it stays hidden until there is an answer to
30
+ * withdraw.
24
31
  */
25
32
  export const consentBanner = element("atlas-consent", ({ root, signal }) => {
33
+ const boxes = refs<HTMLInputElement>(root, "[data-consent-category]").map(
34
+ (box) => {
35
+ const category = data(box, "consent-category");
36
+ if (!CONSENT_CATEGORIES.includes(category as ConsentCategory)) {
37
+ throw new Error(
38
+ `data-consent-category is "${category}", not ${CONSENT_CATEGORIES.join(" or ")}`
39
+ );
40
+ }
41
+ return { box, category: category as ConsentCategory };
42
+ }
43
+ );
44
+
45
+ const fromBoxes = (): ConsentChoices => {
46
+ const granted = (category: ConsentCategory) =>
47
+ boxes.some((each) => each.category === category && each.box.checked)
48
+ ? "granted"
49
+ : "denied";
50
+ return {
51
+ analytics: granted("analytics"),
52
+ marketing: granted("marketing"),
53
+ };
54
+ };
55
+
26
56
  const show = (): void => {
57
+ // Only an answer already given ticks a box.
58
+ const stored = readConsent();
59
+ for (const { box, category } of boxes) {
60
+ box.checked = stored?.[category] === "granted";
61
+ }
27
62
  root.hidden = false;
28
63
  };
29
64
 
65
+ const ACTIONS: Readonly<Record<string, () => ConsentChoices>> = {
66
+ grant: () => everyCategory("granted"),
67
+ deny: () => everyCategory("denied"),
68
+ save: fromBoxes,
69
+ };
70
+
30
71
  for (const button of refs(root, "[data-consent]")) {
31
72
  const action = data(button, "consent");
32
- const choice = Object.hasOwn(ACTIONS, action)
73
+ const choose = Object.hasOwn(ACTIONS, action)
33
74
  ? ACTIONS[action]
34
75
  : undefined;
35
- if (choice === undefined) {
36
- throw new Error(`data-consent is "${action}", not grant or deny`);
76
+ if (choose === undefined) {
77
+ throw new Error(
78
+ `data-consent is "${action}", not grant, deny or save`
79
+ );
37
80
  }
38
81
  button.addEventListener(
39
82
  "click",
40
83
  () => {
41
- recordConsent(choice);
84
+ recordConsent(choose());
42
85
  root.hidden = true;
43
86
  },
44
87
  { signal }
@@ -51,9 +94,9 @@ export const consentBanner = element("atlas-consent", ({ root, signal }) => {
51
94
  button.addEventListener("click", show, { signal });
52
95
  }
53
96
 
54
- // Applied, not re-recorded: recording stamps today's date, and an answer
55
- // renewed on every page view never expires.
56
- const stored = readConsent();
57
- if (stored === undefined) show();
58
- else applyConsent(stored.choice);
97
+ // A stored answer is already applied: the head block hands it to Google
98
+ // before any tag fires, which this module runs too late to do. Nor is it
99
+ // re-recorded — that stamps today's date, and an answer renewed on every
100
+ // page view never expires.
101
+ if (readConsent() === undefined) show();
59
102
  });
@@ -1,5 +1,17 @@
1
+ import {
2
+ CONSENT_KEY,
3
+ CONSENT_MONTHS,
4
+ type ConsentChoice,
5
+ type ConsentChoices,
6
+ } from "../analytics/consent-storage.ts";
1
7
  import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
2
8
 
9
+ export type {
10
+ ConsentCategory,
11
+ ConsentChoice,
12
+ ConsentChoices,
13
+ } from "../analytics/consent-storage.ts";
14
+
3
15
  /**
4
16
  * The browser half of consent: remembering an answer, expiring it, and handing
5
17
  * it to Google.
@@ -17,45 +29,32 @@ import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
17
29
  * subtly wrong.
18
30
  */
19
31
 
20
- /** Granted or denied — the only two answers Consent Mode has. */
21
- export type ConsentChoice = "granted" | "denied";
22
-
23
- /** An answer, and when it was given. */
24
- export interface ConsentRecord {
25
- readonly choice: ConsentChoice;
32
+ /** An answer for every category, and when it was given. */
33
+ export type ConsentRecord = ConsentChoices & {
26
34
  /** ISO 8601, so it is legible in devtools rather than a number. */
27
35
  readonly at: string;
28
- }
36
+ };
29
37
 
30
- /**
31
- * How long an answer stands before the question is asked again.
32
- *
33
- * Consent is not forever and regulators say so: France's CNIL puts the outside
34
- * limit at 13 months and recommends six, and the EDPB's position is that a
35
- * choice made long enough ago is no longer informed. Six is the conservative
36
- * reading, and the reason the record carries a date at all — without one there
37
- * is no way to expire it, and no way to answer "when did this visitor agree?",
38
- * which is a question only ever asked when somebody is already unhappy.
39
- */
40
- const MONTHS = 6;
38
+ /** The same answer for every category — what "accept all" and "reject all" give. */
39
+ export function everyCategory(choice: ConsentChoice): ConsentChoices {
40
+ return { analytics: choice, marketing: choice };
41
+ }
41
42
 
42
- /**
43
- * The `localStorage` key the answer is kept under. Prefixed to keep clear of the
44
- * bare `"consent"` a third-party widget would reach for.
45
- */
46
- const KEY = "atlas-consent";
43
+ const isChoice = (value: unknown): value is ConsentChoice =>
44
+ value === "granted" || value === "denied";
47
45
 
48
46
  /**
49
47
  * The answer this visitor gave, if it still counts.
50
48
  *
51
- * `undefined` for never asked, for an expired answer, and for anything
52
- * unparseable — all three mean the same thing to a banner, and all three should
53
- * ask rather than assume.
49
+ * `undefined` for never asked, for an expired answer, for anything unparseable,
50
+ * and for one missing a category — given before that category was asked about.
51
+ * All of them mean the same thing to a banner, and all of them should ask
52
+ * rather than assume.
54
53
  */
55
54
  export function readConsent(): ConsentRecord | undefined {
56
55
  let raw: string | null = null;
57
56
  try {
58
- raw = localStorage.getItem(KEY);
57
+ raw = localStorage.getItem(CONSENT_KEY);
59
58
  } catch {
60
59
  // Private browsing, or storage disabled. Nothing was remembered, so
61
60
  // nothing is assumed.
@@ -64,18 +63,17 @@ export function readConsent(): ConsentRecord | undefined {
64
63
  if (raw === null) return undefined;
65
64
 
66
65
  try {
67
- const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
68
- if (parsed.choice !== "granted" && parsed.choice !== "denied") {
69
- return undefined;
70
- }
71
- if (typeof parsed.at !== "string") return undefined;
66
+ const parsed = JSON.parse(raw) as Partial<Record<string, unknown>>;
67
+ const { analytics, marketing, at } = parsed;
68
+ if (!isChoice(analytics) || !isChoice(marketing)) return undefined;
69
+ if (typeof at !== "string") return undefined;
72
70
 
73
- const expiry = new Date(parsed.at);
71
+ const expiry = new Date(at);
74
72
  if (Number.isNaN(expiry.getTime())) return undefined;
75
- expiry.setMonth(expiry.getMonth() + MONTHS);
73
+ expiry.setMonth(expiry.getMonth() + CONSENT_MONTHS);
76
74
  if (expiry < new Date()) return undefined;
77
75
 
78
- return { choice: parsed.choice, at: parsed.at };
76
+ return { analytics, marketing, at };
79
77
  } catch {
80
78
  return undefined;
81
79
  }
@@ -85,31 +83,36 @@ export function readConsent(): ConsentRecord | undefined {
85
83
  * Remembers an answer, dated now, and tells Google about it — one call, so the
86
84
  * two cannot come apart.
87
85
  */
88
- export function recordConsent(choice: ConsentChoice): void {
89
- const record: ConsentRecord = { choice, at: new Date().toISOString() };
86
+ export function recordConsent(choices: ConsentChoices): void {
87
+ const record: ConsentRecord = { ...choices, at: new Date().toISOString() };
90
88
  try {
91
- localStorage.setItem(KEY, JSON.stringify(record));
89
+ localStorage.setItem(CONSENT_KEY, JSON.stringify(record));
92
90
  } catch {
93
91
  // Unable to remember it, which is a worse experience and not a wrong
94
92
  // one — the choice still applies to this page.
95
93
  }
96
- applyConsent(choice);
94
+ applyConsent(choices);
97
95
  }
98
96
 
99
97
  /**
100
- * Hands a choice to Google, if there is a Google tag on this deployment.
98
+ * Hands an answer to Google, if there is a Google tag on this deployment.
101
99
  *
102
100
  * Reaches the head script through a global, which is the only way the two can
103
101
  * meet: that script runs before any module exists. The name lives in one place
104
102
  * — `CONSENT_UPDATE_GLOBAL` — and is referenced here and where it is emitted,
105
103
  * so a project never types it.
106
104
  *
107
- * What it updates is decided over there, from the same settings that decided
108
- * what to deny. A payload built here would be a second copy of that list.
105
+ * Which signals each category moves is decided over there, from the same
106
+ * settings that decided what to deny. A payload built here would be a second
107
+ * copy of that list.
109
108
  */
110
- export function applyConsent(choice: ConsentChoice): void {
109
+ export function applyConsent(choices: ConsentChoices): void {
111
110
  const update = (
112
- window as unknown as Record<string, ((c: string) => void) | undefined>
111
+ window as unknown as Record<
112
+ string,
113
+ ((c: ConsentChoices) => void) | undefined
114
+ >
113
115
  )[CONSENT_UPDATE_GLOBAL];
114
- update?.(choice);
116
+ // Only the categories, so a record's date does not ride along.
117
+ update?.({ analytics: choices.analytics, marketing: choices.marketing });
115
118
  }