@escape-game-over/atlas 0.1.37 → 0.1.39

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.37",
3
+ "version": "0.1.39",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -1,12 +1,38 @@
1
1
  /**
2
- * Where a visitor's consent answer is kept, and for how long it counts.
2
+ * What a visitor's consent answer is, where it is kept, and for how long it
3
+ * counts.
3
4
  *
4
5
  * Shared by the two halves that read it: the banner's module script
5
6
  * (`astro/consent.ts`), and the inline head block Google's tags emit, which
6
7
  * restores an answer before any tag fires. Two readers with their own copies of
7
- * the key or the expiry would disagree the first time either changed.
8
+ * the key, the expiry or the categories would disagree the first time either
9
+ * changed.
8
10
  */
9
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
+
10
36
  /**
11
37
  * How long an answer stands before the question is asked again.
12
38
  *
@@ -1,6 +1,11 @@
1
1
  import type { NonEmpty } from "../types.ts";
2
2
  import { warn } from "../warn.ts";
3
- import { CONSENT_KEY, CONSENT_MONTHS } from "./consent-storage.ts";
3
+ import {
4
+ CONSENT_CATEGORIES,
5
+ CONSENT_KEY,
6
+ CONSENT_MONTHS,
7
+ type ConsentCategory,
8
+ } from "./consent-storage.ts";
4
9
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
5
10
 
6
11
  /**
@@ -133,16 +138,16 @@ export interface GoogleSettings {
133
138
  */
134
139
  readonly consent?: NonEmpty<ConsentDefaults>;
135
140
  /**
136
- * Whether a visitor's yes also grants the three advertising signals —
137
- * `ad_storage`, `ad_user_data`, `ad_personalization`. **Off unless stated.**
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.**
138
143
  *
139
- * They are for ads and remarketing: showing a visitor ads elsewhere for
140
- * having been here. A banner that asks about analytics and then grants
141
- * those as well has not asked about them, which is not consent under the
142
- * GDPR. So `__consent('granted')` leaves them denied, and a site that does
143
- * run ads says so here — and says so in its banner.
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.
144
149
  */
145
- readonly advertising?: true;
150
+ readonly urlPassthrough?: true;
146
151
  }
147
152
 
148
153
  /**
@@ -192,7 +197,8 @@ const CONSENT_KEYS = {
192
197
  } as const satisfies Readonly<Record<keyof ConsentDefaults, string>>;
193
198
 
194
199
  /**
195
- * 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" })`.
196
202
  *
197
203
  * Emitted by lib rather than written in each project, because the *signals* it
198
204
  * updates must be exactly the ones lib defaulted. A banner that built the
@@ -220,33 +226,45 @@ const SIGNAL_KEYS = [
220
226
  "securityStorage",
221
227
  ] as const satisfies readonly (keyof ConsentDefaults)[];
222
228
 
223
- /** The signals only a site that runs ads may grant — see `advertising`. */
224
- const AD_KEYS: ReadonlySet<keyof ConsentDefaults> = new Set([
225
- "adStorage",
226
- "adUserData",
227
- "adPersonalization",
228
- ]);
229
+ /**
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
+ );
229
250
 
230
251
  /**
231
- * `window.__consent(state)`, updating precisely what was defaulted.
252
+ * `window.__consent(choices)`, updating precisely what was defaulted, each
253
+ * signal from the answer for its category.
232
254
  *
233
- * Every signal any default mentioned, and nothing else — less the advertising
234
- * ones unless the site runs ads. Updating one that was never defaulted is legal
235
- * and pointless — Google reads it as a change from its own implicit grant,
236
- * 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.
237
258
  */
238
- function consentUpdater(
239
- defaults: readonly ConsentDefaults[],
240
- advertising: boolean
241
- ): string {
242
- const signals = SIGNAL_KEYS.filter(
243
- (key) =>
244
- (advertising || !AD_KEYS.has(key)) &&
245
- defaults.some((given) => given[key] !== undefined)
246
- ).map((key) => `${literal(CONSENT_KEYS[key])}:s`);
259
+ function consentUpdater(defaults: readonly ConsentDefaults[]): string {
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
+ );
247
265
 
248
266
  if (signals.length === 0) return "";
249
- 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(",")}})};`;
250
268
  }
251
269
 
252
270
  /**
@@ -258,12 +276,19 @@ function consentUpdater(
258
276
  * as denied: no cookie, no returning user, no session. Measured, not supposed:
259
277
  * `gcs=G100` on the `page_view`, `G111` only on the hits after it.
260
278
  *
261
- * The same key and expiry `astro/consent.ts` reads, from the one place both
262
- * take them. Anything unreadable, expired or absent leaves the defaults
263
- * standing — the banner will ask.
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.
264
283
  */
265
284
  function restoredConsent(): string {
266
- return `try{var c=JSON.parse(localStorage.getItem(${literal(CONSENT_KEY)})),e=new Date(c.at);e.setMonth(e.getMonth()+${CONSENT_MONTHS});if((c.choice==="granted"||c.choice==="denied")&&e>new Date())window.${CONSENT_UPDATE_GLOBAL}(c.choice)}catch(_){}`;
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(_){}`;
267
292
  }
268
293
 
269
294
  /** One `gtag('consent','default',{…})` per stated default, in order. */
@@ -375,9 +400,16 @@ export function googleScripts(
375
400
  }
376
401
 
377
402
  const consent = google.consent ?? DENIED_BY_DEFAULT;
378
- // The way back out of the defaults, covering what they denied — the ad
379
- // signals only for a site that runs ads.
380
- const updater = consentUpdater(consent, google.advertising === true);
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);
381
413
  const inline = [
382
414
  "window.dataLayer=window.dataLayer||[];",
383
415
  "function gtag(){dataLayer.push(arguments)}",
@@ -386,6 +418,10 @@ export function googleScripts(
386
418
  updater,
387
419
  // Then a remembered answer, still ahead of the first tag.
388
420
  updater === "" ? "" : restoredConsent(),
421
+ // Before any `config`, which is what reads it.
422
+ google.urlPassthrough === true
423
+ ? "gtag('set','url_passthrough',true);"
424
+ : "",
389
425
  tags.length > 0 ? "gtag('js',new Date());" : "",
390
426
  ...tags.map((id) => `gtag('config',${literal(id)});`),
391
427
  // Tag Manager's own loader, once per container. It appends its script
@@ -1,39 +1,87 @@
1
- import { type ConsentChoice, readConsent, recordConsent } from "./consent.ts";
1
+ import { CONSENT_CATEGORIES } from "../analytics/consent-storage.ts";
2
+ import {
3
+ type ConsentCategory,
4
+ type ConsentChoices,
5
+ everyCategory,
6
+ readConsent,
7
+ recordConsent,
8
+ } from "./consent.ts";
2
9
  import { element } from "./element.ts";
3
10
  import { data, refs } from "./ref.ts";
4
11
 
5
- /** What each button does, in the words Consent Mode records. */
6
- const ACTIONS: Readonly<Record<string, ConsentChoice>> = {
7
- grant: "granted",
8
- deny: "denied",
9
- };
10
-
11
12
  /**
12
13
  * The banner's behaviour. Its own module, and not re-exported from `client.ts`,
13
14
  * so the script ships only where `<ConsentBanner>` renders.
14
15
  *
15
- * A site's markup marks the two buttons inside the banner `data-consent="grant"`
16
- * and `data-consent="deny"`, and the control that brings it back — usually in
17
- * the footer — `data-consent-reopen hidden`: it stays hidden until there is an
18
- * 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.
19
31
  */
20
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
+
21
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
+ }
22
62
  root.hidden = false;
23
63
  };
24
64
 
65
+ const ACTIONS: Readonly<Record<string, () => ConsentChoices>> = {
66
+ grant: () => everyCategory("granted"),
67
+ deny: () => everyCategory("denied"),
68
+ save: fromBoxes,
69
+ };
70
+
25
71
  for (const button of refs(root, "[data-consent]")) {
26
72
  const action = data(button, "consent");
27
- const choice = Object.hasOwn(ACTIONS, action)
73
+ const choose = Object.hasOwn(ACTIONS, action)
28
74
  ? ACTIONS[action]
29
75
  : undefined;
30
- if (choice === undefined) {
31
- 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
+ );
32
80
  }
33
81
  button.addEventListener(
34
82
  "click",
35
83
  () => {
36
- recordConsent(choice);
84
+ recordConsent(choose());
37
85
  root.hidden = true;
38
86
  },
39
87
  { signal }
@@ -1,6 +1,17 @@
1
- import { CONSENT_KEY, CONSENT_MONTHS } from "../analytics/consent-storage.ts";
1
+ import {
2
+ CONSENT_KEY,
3
+ CONSENT_MONTHS,
4
+ type ConsentChoice,
5
+ type ConsentChoices,
6
+ } from "../analytics/consent-storage.ts";
2
7
  import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
3
8
 
9
+ export type {
10
+ ConsentCategory,
11
+ ConsentChoice,
12
+ ConsentChoices,
13
+ } from "../analytics/consent-storage.ts";
14
+
4
15
  /**
5
16
  * The browser half of consent: remembering an answer, expiring it, and handing
6
17
  * it to Google.
@@ -18,22 +29,27 @@ import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
18
29
  * subtly wrong.
19
30
  */
20
31
 
21
- /** Granted or denied — the only two answers Consent Mode has. */
22
- export type ConsentChoice = "granted" | "denied";
23
-
24
- /** An answer, and when it was given. */
25
- export interface ConsentRecord {
26
- readonly choice: ConsentChoice;
32
+ /** An answer for every category, and when it was given. */
33
+ export type ConsentRecord = ConsentChoices & {
27
34
  /** ISO 8601, so it is legible in devtools rather than a number. */
28
35
  readonly at: string;
36
+ };
37
+
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 };
29
41
  }
30
42
 
43
+ const isChoice = (value: unknown): value is ConsentChoice =>
44
+ value === "granted" || value === "denied";
45
+
31
46
  /**
32
47
  * The answer this visitor gave, if it still counts.
33
48
  *
34
- * `undefined` for never asked, for an expired answer, and for anything
35
- * unparseable — all three mean the same thing to a banner, and all three should
36
- * 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.
37
53
  */
38
54
  export function readConsent(): ConsentRecord | undefined {
39
55
  let raw: string | null = null;
@@ -47,18 +63,17 @@ export function readConsent(): ConsentRecord | undefined {
47
63
  if (raw === null) return undefined;
48
64
 
49
65
  try {
50
- const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
51
- if (parsed.choice !== "granted" && parsed.choice !== "denied") {
52
- return undefined;
53
- }
54
- 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;
55
70
 
56
- const expiry = new Date(parsed.at);
71
+ const expiry = new Date(at);
57
72
  if (Number.isNaN(expiry.getTime())) return undefined;
58
73
  expiry.setMonth(expiry.getMonth() + CONSENT_MONTHS);
59
74
  if (expiry < new Date()) return undefined;
60
75
 
61
- return { choice: parsed.choice, at: parsed.at };
76
+ return { analytics, marketing, at };
62
77
  } catch {
63
78
  return undefined;
64
79
  }
@@ -68,31 +83,36 @@ export function readConsent(): ConsentRecord | undefined {
68
83
  * Remembers an answer, dated now, and tells Google about it — one call, so the
69
84
  * two cannot come apart.
70
85
  */
71
- export function recordConsent(choice: ConsentChoice): void {
72
- const record: ConsentRecord = { choice, at: new Date().toISOString() };
86
+ export function recordConsent(choices: ConsentChoices): void {
87
+ const record: ConsentRecord = { ...choices, at: new Date().toISOString() };
73
88
  try {
74
89
  localStorage.setItem(CONSENT_KEY, JSON.stringify(record));
75
90
  } catch {
76
91
  // Unable to remember it, which is a worse experience and not a wrong
77
92
  // one — the choice still applies to this page.
78
93
  }
79
- applyConsent(choice);
94
+ applyConsent(choices);
80
95
  }
81
96
 
82
97
  /**
83
- * 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.
84
99
  *
85
100
  * Reaches the head script through a global, which is the only way the two can
86
101
  * meet: that script runs before any module exists. The name lives in one place
87
102
  * — `CONSENT_UPDATE_GLOBAL` — and is referenced here and where it is emitted,
88
103
  * so a project never types it.
89
104
  *
90
- * What it updates is decided over there, from the same settings that decided
91
- * 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.
92
108
  */
93
- export function applyConsent(choice: ConsentChoice): void {
109
+ export function applyConsent(choices: ConsentChoices): void {
94
110
  const update = (
95
- window as unknown as Record<string, ((c: string) => void) | undefined>
111
+ window as unknown as Record<
112
+ string,
113
+ ((c: ConsentChoices) => void) | undefined
114
+ >
96
115
  )[CONSENT_UPDATE_GLOBAL];
97
- update?.(choice);
116
+ // Only the categories, so a record's date does not ride along.
117
+ update?.({ analytics: choices.analytics, marketing: choices.marketing });
98
118
  }
package/src/index.ts CHANGED
@@ -12,6 +12,12 @@
12
12
  * ```
13
13
  */
14
14
 
15
+ export {
16
+ CONSENT_CATEGORIES,
17
+ type ConsentCategory,
18
+ type ConsentChoice,
19
+ type ConsentChoices,
20
+ } from "./analytics/consent-storage.ts";
15
21
  export {
16
22
  type AnalyticsSettings,
17
23
  type AnalyticsTags,