@escape-game-over/atlas 0.1.35 → 0.1.37

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.
@@ -22,11 +22,16 @@ So **1200×630 for everything**, and let the square surfaces crop.
22
22
 
23
23
  That crop is the whole reason `shareImage` takes a fit at all:
24
24
 
25
- | The page's art | `shareImage` call | Why |
26
- | --------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
27
- | A photograph | `shareImage(src)` — `cover` | edges are scenery; a square crop takes the middle, which is the subject |
28
- | A logo, square source | `shareImage(src, { fit: "contain", background })` | scaled to 630×630 and centred, so a square crop lands **exactly on the logo** |
29
- | A logo under ~630px | neither — compose one or get a bigger file | `contain` would enlarge it, and `shareImage` refuses rather than ship a blurred card |
25
+ | The page's art | `shareImage` call | Why |
26
+ | --------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
27
+ | A photograph | `shareImage(src)` — `cover`, `jpeg` | edges are scenery; a square crop takes the middle, which is the subject |
28
+ | A logo, square source | `shareImage(src, { fit: "contain", background, format: "png" })` | scaled to 630×630 and centred, so a square crop lands **exactly on the logo** |
29
+ | A logo under ~630px | neither — compose one or get a bigger file | `contain` would enlarge it, and `shareImage` refuses rather than ship a blurred card |
30
+
31
+ The format follows the art, not the surface. A photograph is `jpeg`, the default:
32
+ as `png` it runs to 1–2MB, and WhatsApp shows no preview for an image past
33
+ about 300kB. A logo on a flat field is `png`, which is smaller and sharper for
34
+ flat colour than `jpeg`'s blocks.
30
35
 
31
36
  Both examples use the second row for their catalogue pages: a room's own mark on
32
37
  the brand field, so a chat thumbnail shows which room it is rather than a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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,26 @@
1
+ /**
2
+ * Where a visitor's consent answer is kept, and for how long it counts.
3
+ *
4
+ * Shared by the two halves that read it: the banner's module script
5
+ * (`astro/consent.ts`), and the inline head block Google's tags emit, which
6
+ * 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
+ */
9
+
10
+ /**
11
+ * How long an answer stands before the question is asked again.
12
+ *
13
+ * Consent is not forever and regulators say so: France's CNIL puts the outside
14
+ * limit at 13 months and recommends six, and the EDPB's position is that a
15
+ * choice made long enough ago is no longer informed. Six is the conservative
16
+ * reading, and the reason the record carries a date at all — without one there
17
+ * is no way to expire it, and no way to answer "when did this visitor agree?",
18
+ * which is a question only ever asked when somebody is already unhappy.
19
+ */
20
+ export const CONSENT_MONTHS = 6;
21
+
22
+ /**
23
+ * The `localStorage` key the answer is kept under. Prefixed to keep clear of the
24
+ * bare `"consent"` a third-party widget would reach for.
25
+ */
26
+ export const CONSENT_KEY = "atlas-consent";
@@ -1,5 +1,6 @@
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
4
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
4
5
 
5
6
  /**
@@ -131,6 +132,17 @@ export interface GoogleSettings {
131
132
  * unstated case emits.
132
133
  */
133
134
  readonly consent?: NonEmpty<ConsentDefaults>;
135
+ /**
136
+ * Whether a visitor's yes also grants the three advertising signals —
137
+ * `ad_storage`, `ad_user_data`, `ad_personalization`. **Off unless stated.**
138
+ *
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
+ */
145
+ readonly advertising?: true;
134
146
  }
135
147
 
136
148
  /**
@@ -208,22 +220,52 @@ const SIGNAL_KEYS = [
208
220
  "securityStorage",
209
221
  ] as const satisfies readonly (keyof ConsentDefaults)[];
210
222
 
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
+
211
230
  /**
212
231
  * `window.__consent(state)`, updating precisely what was defaulted.
213
232
  *
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.
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.
217
237
  */
218
- function consentUpdater(defaults: readonly ConsentDefaults[]): string {
219
- const signals = SIGNAL_KEYS.filter((key) =>
220
- defaults.some((given) => given[key] !== undefined)
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)
221
246
  ).map((key) => `${literal(CONSENT_KEYS[key])}:s`);
222
247
 
223
248
  if (signals.length === 0) return "";
224
249
  return `window.${CONSENT_UPDATE_GLOBAL}=function(s){gtag('consent','update',{${signals.join(",")}})};`;
225
250
  }
226
251
 
252
+ /**
253
+ * A returning visitor's answer, handed to Google before any tag fires.
254
+ *
255
+ * It has to happen here, in the head, ahead of `gtag('config', …)`. Left to the
256
+ * banner's module script it arrives after `gtag.js` has already sent the page
257
+ * view on the defaults — so every page a consenting visitor opened was recorded
258
+ * as denied: no cookie, no returning user, no session. Measured, not supposed:
259
+ * `gcs=G100` on the `page_view`, `G111` only on the hits after it.
260
+ *
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.
264
+ */
265
+ 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(_){}`;
267
+ }
268
+
227
269
  /** One `gtag('consent','default',{…})` per stated default, in order. */
228
270
  function consentCalls(defaults: readonly ConsentDefaults[]): string {
229
271
  return defaults
@@ -333,13 +375,17 @@ export function googleScripts(
333
375
  }
334
376
 
335
377
  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);
336
381
  const inline = [
337
382
  "window.dataLayer=window.dataLayer||[];",
338
383
  "function gtag(){dataLayer.push(arguments)}",
339
384
  // Ahead of everything, which is the entire point of emitting this here.
340
385
  consentCalls(consent),
341
- // And the way back out of it, covering exactly what was just denied.
342
- consentUpdater(consent),
386
+ updater,
387
+ // Then a remembered answer, still ahead of the first tag.
388
+ updater === "" ? "" : restoredConsent(),
343
389
  tags.length > 0 ? "gtag('js',new Date());" : "",
344
390
  ...tags.map((id) => `gtag('config',${literal(id)});`),
345
391
  // Tag Manager's own loader, once per container. It appends its script
@@ -1,9 +1,4 @@
1
- import {
2
- applyConsent,
3
- type ConsentChoice,
4
- readConsent,
5
- recordConsent,
6
- } from "./consent.ts";
1
+ import { type ConsentChoice, readConsent, recordConsent } from "./consent.ts";
7
2
  import { element } from "./element.ts";
8
3
  import { data, refs } from "./ref.ts";
9
4
 
@@ -51,9 +46,9 @@ export const consentBanner = element("atlas-consent", ({ root, signal }) => {
51
46
  button.addEventListener("click", show, { signal });
52
47
  }
53
48
 
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);
49
+ // A stored answer is already applied: the head block hands it to Google
50
+ // before any tag fires, which this module runs too late to do. Nor is it
51
+ // re-recorded — that stamps today's date, and an answer renewed on every
52
+ // page view never expires.
53
+ if (readConsent() === undefined) show();
59
54
  });
@@ -1,3 +1,4 @@
1
+ import { CONSENT_KEY, CONSENT_MONTHS } from "../analytics/consent-storage.ts";
1
2
  import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
2
3
 
3
4
  /**
@@ -27,24 +28,6 @@ export interface ConsentRecord {
27
28
  readonly at: string;
28
29
  }
29
30
 
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;
41
-
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";
47
-
48
31
  /**
49
32
  * The answer this visitor gave, if it still counts.
50
33
  *
@@ -55,7 +38,7 @@ const KEY = "atlas-consent";
55
38
  export function readConsent(): ConsentRecord | undefined {
56
39
  let raw: string | null = null;
57
40
  try {
58
- raw = localStorage.getItem(KEY);
41
+ raw = localStorage.getItem(CONSENT_KEY);
59
42
  } catch {
60
43
  // Private browsing, or storage disabled. Nothing was remembered, so
61
44
  // nothing is assumed.
@@ -72,7 +55,7 @@ export function readConsent(): ConsentRecord | undefined {
72
55
 
73
56
  const expiry = new Date(parsed.at);
74
57
  if (Number.isNaN(expiry.getTime())) return undefined;
75
- expiry.setMonth(expiry.getMonth() + MONTHS);
58
+ expiry.setMonth(expiry.getMonth() + CONSENT_MONTHS);
76
59
  if (expiry < new Date()) return undefined;
77
60
 
78
61
  return { choice: parsed.choice, at: parsed.at };
@@ -88,7 +71,7 @@ export function readConsent(): ConsentRecord | undefined {
88
71
  export function recordConsent(choice: ConsentChoice): void {
89
72
  const record: ConsentRecord = { choice, at: new Date().toISOString() };
90
73
  try {
91
- localStorage.setItem(KEY, JSON.stringify(record));
74
+ localStorage.setItem(CONSENT_KEY, JSON.stringify(record));
92
75
  } catch {
93
76
  // Unable to remember it, which is a worse experience and not a wrong
94
77
  // one — the choice still applies to this page.
@@ -30,9 +30,11 @@ interface ShareImageBase {
30
30
  /** Defaults to 630. */
31
31
  readonly height?: number;
32
32
  /**
33
- * Defaults to `png`: lossless, and accepted by every scraper. `jpeg` is far
34
- * smaller for photographs. Avoid `avif`, which several scrapers still
35
- * cannot read.
33
+ * Defaults to `jpeg`: accepted by every scraper, and a 1200×630 photograph
34
+ * lands near 150kB where `png` makes it 1–2MB — past the ~300kB WhatsApp
35
+ * will fetch for a preview at all. Ask for `png` for flat art, a logo on
36
+ * its field, where it is both smaller and sharper. Avoid `avif`, which
37
+ * several scrapers still cannot read.
36
38
  */
37
39
  readonly format?: "png" | "jpeg" | "webp";
38
40
  /** Ignored by `png`, which is lossless. */
@@ -146,7 +148,7 @@ export async function shareImage(
146
148
  ): Promise<ImageAsset> {
147
149
  const width = options.width ?? SHARE_WIDTH;
148
150
  const height = options.height ?? SHARE_HEIGHT;
149
- const format = options.format ?? "png";
151
+ const format = options.format ?? "jpeg";
150
152
  const fit = options.fit ?? "cover";
151
153
 
152
154
  // Astro's image service never enlarges: ask for a box bigger than the