@escape-game-over/atlas 0.1.77 → 0.1.79

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -48,7 +48,7 @@
48
48
  "check-fmt": "biome check . --error-on-warnings"
49
49
  },
50
50
  "dependencies": {
51
- "@types/google.maps": "3.66.4"
51
+ "@types/google.maps": "3.66.5"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@types/node": ">=22",
@@ -60,7 +60,7 @@
60
60
  "@biomejs/biome": "2.5.15",
61
61
  "@types/node": "26.6.4",
62
62
  "@vitest/coverage-istanbul": "5.0.3",
63
- "astro": "7.3.5",
63
+ "astro": "7.3.6",
64
64
  "typescript": "6.0.3",
65
65
  "vitest": "5.0.3"
66
66
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from "./consent-storage.ts";
9
9
  import googleTag from "./google-tag.ts?raw";
10
10
  import { type GoogleTagId, invalidId, type TagManagerId } from "./ids.ts";
11
+ import type { StorageVendor } from "./storage.ts";
11
12
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
12
13
 
13
14
  /**
@@ -150,6 +151,16 @@ export interface GoogleSettings {
150
151
  * ads has no click id to carry.
151
152
  */
152
153
  readonly urlPassthrough?: true;
154
+ /**
155
+ * The vendors these tags bring that their ids do not name: whatever a
156
+ * Tag Manager container fires, or a `GT-` tag's destinations. Lib cannot
157
+ * see inside either, so `storedItems` lists only what is stated here — a
158
+ * `G-` id already implies Google Analytics and an `AW-` id Google Ads.
159
+ *
160
+ * A statement about someone else's configuration, so it goes stale the day
161
+ * they add a tag: whoever runs the container should know it is here.
162
+ */
163
+ readonly alsoLoads?: readonly StorageVendor[];
153
164
  }
154
165
 
155
166
  /**
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The keys Atlas keeps on a visitor's device besides the consent record
3
+ * (`CONSENT_KEY`). On their own so the browser scripts that use them import
4
+ * two strings, not the catalog `storedItems` lists them from.
5
+ */
6
+
7
+ /**
8
+ * The `localStorage` key `RememberLocale` keeps the chosen locale under. Its
9
+ * script is inline and imports nothing; `inlineScript` declares this around it.
10
+ */
11
+ export const LOCALE_KEY = "atlas-locale";
12
+
13
+ /** The `sessionStorage` key `rememberCampaign` keeps a visit's `utm_*` under. */
14
+ export const CAMPAIGN_KEY = "atlas-campaign";
@@ -0,0 +1,386 @@
1
+ import {
2
+ CONSENT_KEY,
3
+ CONSENT_MONTHS,
4
+ type ConsentCategory,
5
+ } from "./consent-storage.ts";
6
+ import { type AnalyticsSettings, consentVendors } from "./index.ts";
7
+ import { CAMPAIGN_KEY, LOCALE_KEY } from "./storage-keys.ts";
8
+
9
+ /**
10
+ * What a deployment keeps on a visitor's device, row by row, for the table a
11
+ * privacy notice owes them: each name, who sets it, how long it stays, and
12
+ * what has to be granted first.
13
+ *
14
+ * Here because the facts are the vendors' and the library's, not a site's.
15
+ * Cookie names and lifetimes change when a vendor changes its tag, and the
16
+ * three keys Atlas sets itself change when Atlas does; a table copied into
17
+ * each site is a table that is wrong in each site on a different day. The
18
+ * words — what each row is *for*, in each language — stay with the project,
19
+ * keyed by `StoredItem.id`.
20
+ *
21
+ * Built from the same settings that load the tags, so a vendor cannot run
22
+ * without a row. What lib cannot see — the tags inside a Tag Manager
23
+ * container — comes from `GoogleSettings.alsoLoads`.
24
+ */
25
+
26
+ /** A vendor whose tag sets storage, by product rather than by company. */
27
+ export type StorageVendor =
28
+ | "google-analytics"
29
+ | "google-ads"
30
+ | "linkedin"
31
+ | "meta"
32
+ | "tiktok"
33
+ | "snapchat"
34
+ | "axon"
35
+ | "drip"
36
+ | "clarity";
37
+
38
+ /**
39
+ * Each vendor's product as it calls itself, for a table's "set by" column.
40
+ * Names, not copy: the same in every language, so a project translates only
41
+ * the site's own label.
42
+ */
43
+ export const STORAGE_VENDOR_NAMES = {
44
+ "google-analytics": "Google Analytics",
45
+ "google-ads": "Google Ads",
46
+ linkedin: "LinkedIn",
47
+ meta: "Meta",
48
+ tiktok: "TikTok",
49
+ snapchat: "Snapchat",
50
+ axon: "AppLovin Axon",
51
+ drip: "Drip",
52
+ clarity: "Microsoft Clarity",
53
+ } as const satisfies Record<StorageVendor, string>;
54
+
55
+ /** A span `Intl.NumberFormat` can word in any locale: `{ 2, "year" }`. */
56
+ export interface Lifetime {
57
+ readonly count: number;
58
+ readonly unit: "day" | "month" | "year";
59
+ }
60
+
61
+ /** The vendor-domain row of a vendor that has one. */
62
+ type OwnRow = `${VendorWithOwn}-own`;
63
+
64
+ /** The keys Atlas itself sets. */
65
+ export type AtlasStoredId = "atlas-locale" | "atlas-campaign" | "atlas-consent";
66
+
67
+ /** One row's id: what a project keys the row's wording by. */
68
+ export type StoredId = AtlasStoredId | StorageVendor | OwnRow;
69
+
70
+ export interface StoredItem {
71
+ readonly id: StoredId;
72
+ /** As the browser shows them; a pattern like `_ga_<id>` where the vendor appends one. */
73
+ readonly names: readonly string[];
74
+ /** Who sets it: this site, or the vendor. */
75
+ readonly provider: "site" | StorageVendor;
76
+ /** Set on the vendor's own domains rather than the site's; absent for the site's. */
77
+ readonly domains?: readonly string[];
78
+ /** A span; `tab` for session storage; `kept` until the visitor clears it. */
79
+ readonly lasts: Lifetime | "tab" | "kept";
80
+ /** What has to be granted first; `none` is kept without asking. */
81
+ readonly consent: ConsentCategory | "none";
82
+ }
83
+
84
+ export interface StoredItemsOptions {
85
+ /** As passed to `Document`, and with its default: `true`. */
86
+ readonly rememberLocale?: boolean;
87
+ /** As passed to `Document`, and with its default: `false`. */
88
+ readonly rememberCampaign?: boolean;
89
+ /**
90
+ * Rows the site sets first, then by what has to be granted — nothing,
91
+ * analytics, marketing — and otherwise in catalog order. Off, the rows
92
+ * come in catalog order: Atlas's own, then each vendor's site row before
93
+ * its vendor-domain one.
94
+ */
95
+ readonly sort?: boolean;
96
+ }
97
+
98
+ interface Stored {
99
+ readonly names: readonly string[];
100
+ /** The longest of the group's. */
101
+ readonly lasts: Lifetime;
102
+ }
103
+
104
+ interface VendorStorage {
105
+ readonly consent: ConsentCategory;
106
+ /** On the site's domain. */
107
+ readonly site: Stored;
108
+ /** On the vendor's, when its tag sets any there. */
109
+ readonly own?: Stored & { readonly domains: readonly string[] };
110
+ }
111
+
112
+ const day = (count: number): Lifetime => ({ count, unit: "day" });
113
+ const month = (count: number): Lifetime => ({ count, unit: "month" });
114
+ const year = (count: number): Lifetime => ({ count, unit: "year" });
115
+
116
+ /**
117
+ * Each vendor's storage, from its own cookie documentation, read 2026-10-06.
118
+ * These go stale without anything failing, so each entry names its source;
119
+ * where the vendor's docs were silent, the source is its tag's own code.
120
+ *
121
+ * What a tag sets only in a mode Atlas does not configure — server-side
122
+ * tagging's `FPID`, Drip's pop-ups — is left out, and so is what a vendor
123
+ * documents only for its own sites or never names.
124
+ */
125
+ const CATALOG = {
126
+ // support.google.com/analytics/answer/11397207. Two years as Google
127
+ // states it; Chrome caps any cookie at 400 days.
128
+ "google-analytics": {
129
+ consent: "analytics",
130
+ site: { names: ["_ga", "_ga_<id>"], lasts: year(2) },
131
+ },
132
+ // business.safety.google/adscookies, support.google.com/tagmanager/answer/7549390.
133
+ // `IDE` is 13 months in the EEA and UK, 24 elsewhere: the EEA's, since a
134
+ // notice that needs this table is written for it. Seen live 2026-10-06.
135
+ "google-ads": {
136
+ consent: "marketing",
137
+ site: {
138
+ names: [
139
+ "_gcl_au",
140
+ "_gcl_aw",
141
+ "_gcl_gs",
142
+ "_gcl_dc",
143
+ "_gcl_gb",
144
+ "_gcl_ag",
145
+ "_gcl_ls",
146
+ ],
147
+ lasts: day(90),
148
+ },
149
+ own: {
150
+ names: ["IDE", "GCL_AW_P", "DSID", "test_cookie", "ar_debug"],
151
+ lasts: month(13),
152
+ domains: ["doubleclick.net", "googleadservices.com", "google.com"],
153
+ },
154
+ },
155
+ // linkedin.com/legal/l/cookie-table. Seen live 2026-10-06.
156
+ linkedin: {
157
+ consent: "marketing",
158
+ site: { names: ["li_fat_id", "li_giant", "ln_or"], lasts: day(30) },
159
+ own: {
160
+ names: [
161
+ "bcookie",
162
+ "bscookie",
163
+ "li_gc",
164
+ "lidc",
165
+ "li_sugr",
166
+ "UserMatchHistory",
167
+ "AnalyticsSyncHistory",
168
+ "lms_ads",
169
+ "lms_analytics",
170
+ "_guid",
171
+ ],
172
+ lasts: year(1),
173
+ domains: ["linkedin.com"],
174
+ },
175
+ },
176
+ // developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc,
177
+ // facebook.com/privacy/policies/cookies. `datr` is facebook.com's own,
178
+ // there only for someone who has visited it; the pixel does not set it.
179
+ meta: {
180
+ consent: "marketing",
181
+ site: { names: ["_fbp", "_fbc"], lasts: day(90) },
182
+ own: { names: ["fr"], lasts: day(90), domains: ["facebook.com"] },
183
+ },
184
+ // ads.tiktok.com/help/article/using-cookies-with-tiktok-pixel.
185
+ tiktok: {
186
+ consent: "marketing",
187
+ site: {
188
+ names: [
189
+ "_ttp",
190
+ "_tt_enable_cookie",
191
+ "ttcsid",
192
+ "ttcsid_<id>",
193
+ "ttclid",
194
+ ],
195
+ lasts: month(13),
196
+ },
197
+ own: { names: ["_ttp"], lasts: month(13), domains: ["tiktok.com"] },
198
+ },
199
+ // snap.com/en-US/privacy/cookie-information gives `_scid` and `_sctr` a
200
+ // year; the pixel's own code sets 13 months, which is what lands.
201
+ snapchat: {
202
+ consent: "marketing",
203
+ site: {
204
+ names: ["_scid", "_sctr", "u_sclid", "u_scsid"],
205
+ lasts: month(13),
206
+ },
207
+ own: { names: ["sc_at"], lasts: year(1), domains: ["snapchat.com"] },
208
+ },
209
+ // support.applovin.com/en/ecommerce/google-tag-manager names `_axwrt`;
210
+ // the rest and the 365 days are from s.axon.ai/pixel.js. AppLovin lists
211
+ // no cookies of its own domains.
212
+ axon: {
213
+ consent: "marketing",
214
+ site: {
215
+ names: ["_axwrt", "_axeid", "_axart", "ax_visitor", "_axsid"],
216
+ lasts: day(365),
217
+ },
218
+ },
219
+ // help.drip.com/hc/en-us/articles/4424702605453 gives the year but not
220
+ // the name, which is cookiedatabase.org's.
221
+ drip: {
222
+ consent: "marketing",
223
+ site: { names: ["_drip_client_<id>"], lasts: year(1) },
224
+ },
225
+ // learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-cookies
226
+ // names them; the lifetimes are from microsoft/clarity's source, and
227
+ // `MUID`'s year from cookiedatabase.org.
228
+ clarity: {
229
+ consent: "analytics",
230
+ site: { names: ["_clck", "_clsk", "_cltk"], lasts: year(1) },
231
+ own: {
232
+ names: ["CLID", "MUID", "ANONCHK", "MR", "SM"],
233
+ lasts: year(1),
234
+ domains: ["clarity.ms"],
235
+ },
236
+ },
237
+ } as const satisfies Record<StorageVendor, VendorStorage>;
238
+
239
+ type VendorWithOwn = {
240
+ [V in keyof typeof CATALOG]: (typeof CATALOG)[V] extends { own: object }
241
+ ? V
242
+ : never;
243
+ }[keyof typeof CATALOG];
244
+
245
+ /** Catalog order, which is also the order rows come in unsorted. */
246
+ const VENDOR_ORDER: readonly StorageVendor[] = [
247
+ "google-analytics",
248
+ "google-ads",
249
+ "linkedin",
250
+ "meta",
251
+ "tiktok",
252
+ "snapchat",
253
+ "axon",
254
+ "drip",
255
+ "clarity",
256
+ ];
257
+
258
+ /** The vendors a deployment's settings load, in catalog order. */
259
+ function loadedVendors(
260
+ analytics: AnalyticsSettings | undefined
261
+ ): readonly StorageVendor[] {
262
+ const loaded = new Set<StorageVendor>();
263
+ const google = analytics?.google;
264
+ for (const id of google?.tagIds ?? []) {
265
+ if (id.startsWith("G-")) loaded.add("google-analytics");
266
+ if (id.startsWith("AW-")) loaded.add("google-ads");
267
+ }
268
+ for (const vendor of google?.alsoLoads ?? []) loaded.add(vendor);
269
+ if (analytics?.meta !== undefined) loaded.add("meta");
270
+ if (analytics?.tiktok !== undefined) loaded.add("tiktok");
271
+ if (analytics?.snapchat !== undefined) loaded.add("snapchat");
272
+ if (analytics?.axon !== undefined) loaded.add("axon");
273
+ if (analytics?.drip !== undefined) loaded.add("drip");
274
+ if (analytics?.clarity !== undefined) loaded.add("clarity");
275
+ return VENDOR_ORDER.filter((vendor) => loaded.has(vendor));
276
+ }
277
+
278
+ const CONSENT_ORDER: readonly StoredItem["consent"][] = [
279
+ "none",
280
+ "analytics",
281
+ "marketing",
282
+ ];
283
+
284
+ /** The rows the `sort` option puts first come first; ties keep their order. */
285
+ export function compareStoredItems(a: StoredItem, b: StoredItem): number {
286
+ return (
287
+ Number(b.provider === "site") - Number(a.provider === "site") ||
288
+ CONSENT_ORDER.indexOf(a.consent) - CONSENT_ORDER.indexOf(b.consent)
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Each row with what the project says it is for — a message key, usually,
294
+ * since the words are the project's and in every locale.
295
+ *
296
+ * Throws, naming every row without one, so a vendor switched on, or a row a
297
+ * new Atlas adds, fails the build until the site has said what it is for: a
298
+ * notice that lists a cookie without a purpose is the one thing it must not
299
+ * publish. `purposes` may name rows this deployment does not have; a site
300
+ * keeps its wording when a vendor is switched off.
301
+ */
302
+ export function withPurposes<K>(
303
+ rows: readonly StoredItem[],
304
+ purposes: Partial<Record<StoredId, K>>
305
+ ): readonly (StoredItem & { readonly purpose: K })[] {
306
+ const missing = rows.filter((row) => purposes[row.id] === undefined);
307
+ if (missing.length > 0) {
308
+ throw new Error(
309
+ `The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to the purposes given to withPurposes.`
310
+ );
311
+ }
312
+ return rows.map((row) => ({ ...row, purpose: purposes[row.id] as K }));
313
+ }
314
+
315
+ /**
316
+ * Everything this deployment keeps on a device: Atlas's own keys, then each
317
+ * loaded vendor's.
318
+ *
319
+ * Atlas's rows follow what the page renders, which `Document` decides from
320
+ * its props, so pass the same `rememberLocale` and `rememberCampaign` here.
321
+ * The consent record is listed whenever there is a banner to answer.
322
+ */
323
+ export function storedItems(
324
+ analytics: AnalyticsSettings | undefined,
325
+ options: StoredItemsOptions = {}
326
+ ): readonly StoredItem[] {
327
+ const {
328
+ rememberLocale = true,
329
+ rememberCampaign = false,
330
+ sort = false,
331
+ } = options;
332
+ const asked = consentVendors(analytics).length > 0;
333
+
334
+ const rows: StoredItem[] = [];
335
+ if (rememberLocale) {
336
+ rows.push({
337
+ id: "atlas-locale",
338
+ names: [LOCALE_KEY],
339
+ provider: "site",
340
+ lasts: "kept",
341
+ consent: "none",
342
+ });
343
+ }
344
+ // Kept only once analytics is granted, so only where it can be.
345
+ if (rememberCampaign && asked) {
346
+ rows.push({
347
+ id: "atlas-campaign",
348
+ names: [CAMPAIGN_KEY],
349
+ provider: "site",
350
+ lasts: "tab",
351
+ consent: "analytics",
352
+ });
353
+ }
354
+ if (asked) {
355
+ rows.push({
356
+ id: "atlas-consent",
357
+ names: [CONSENT_KEY],
358
+ provider: "site",
359
+ lasts: month(CONSENT_MONTHS),
360
+ consent: "none",
361
+ });
362
+ }
363
+
364
+ for (const vendor of loadedVendors(analytics)) {
365
+ const entry: VendorStorage = CATALOG[vendor];
366
+ rows.push({
367
+ id: vendor,
368
+ names: entry.site.names,
369
+ provider: vendor,
370
+ lasts: entry.site.lasts,
371
+ consent: entry.consent,
372
+ });
373
+ if (entry.own !== undefined) {
374
+ rows.push({
375
+ id: `${vendor}-own` as OwnRow,
376
+ names: entry.own.names,
377
+ provider: vendor,
378
+ domains: entry.own.domains,
379
+ lasts: entry.own.lasts,
380
+ consent: entry.consent,
381
+ });
382
+ }
383
+ }
384
+
385
+ return sort ? rows.toSorted(compareStoredItems) : rows;
386
+ }
@@ -30,11 +30,13 @@
30
30
  import { Font } from "astro:assets";
31
31
  import type { HTMLAttributes } from "astro/types";
32
32
  import type { Fonts } from "../fonts.ts";
33
+ import { iconLinks } from "../meta/index.ts";
33
34
  import type { PageMeta } from "../site/index.ts";
34
35
  import MetaTags from "./MetaTags.astro";
35
36
  import RememberCampaign from "./RememberCampaign.astro";
36
37
  import RememberLocale from "./RememberLocale.astro";
37
38
  import ScrollRestore from "./ScrollRestore.astro";
39
+ import { siteIcons } from "./site-icons.ts";
38
40
 
39
41
  interface Props {
40
42
  readonly meta: PageMeta;
@@ -65,6 +67,9 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
65
67
  `${meta.consentVendors.join(", ")} ${meta.consentVendors.length === 1 ? "needs" : "need"} permission, but this page has no consent banner. Pass one to Document: <ConsentBanner slot="consent" …>.`
66
68
  );
67
69
  }
70
+
71
+ // Here rather than in `meta.tags`: cutting the icon to size needs the build.
72
+ const icons = iconLinks(await siteIcons(meta.icon));
68
73
  ---
69
74
 
70
75
  <!doctype html>
@@ -76,6 +81,7 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
76
81
  <head>
77
82
  <slot name="head-start" />
78
83
  <MetaTags tags={meta.tags} />
84
+ <MetaTags tags={icons} />
79
85
  {rememberLocale && <RememberLocale />}
80
86
  {Object.entries(fonts).map(([key, font]) => (
81
87
  <Font
@@ -11,10 +11,11 @@
11
11
  * Rendered by `Document` after the head tags, whose alternates it reads.
12
12
  * Inline and blocking, so the wrong language is never painted.
13
13
  */
14
+ import { LOCALE_KEY } from "../analytics/storage-keys.ts";
14
15
  import { inlineScript } from "./inline-script.ts";
15
16
  import source from "./remember-locale.ts?raw";
16
17
 
17
- const code = await inlineScript(source);
18
+ const code = await inlineScript(source, { LOCALE_KEY });
18
19
  ---
19
20
 
20
21
  <script
@@ -19,6 +19,8 @@
19
19
  * in its own `_gcl_aw` cookie once granted. `adClick()` reads it from there.
20
20
  */
21
21
 
22
+ import { CAMPAIGN_KEY } from "../analytics/storage-keys.ts";
23
+
22
24
  /** The campaign tags a link can carry. */
23
25
  export type CampaignTag =
24
26
  | "utm_source"
@@ -43,9 +45,6 @@ const AD_CLICK_IDS: readonly AdClickId[] = ["gclid", "gbraid", "wbraid"];
43
45
  export type Campaign = Partial<Record<CampaignTag, string>>;
44
46
  export type AdClick = Partial<Record<AdClickId, string>>;
45
47
 
46
- /** The `sessionStorage` key the tags are kept under. */
47
- const KEY = "atlas-campaign";
48
-
49
48
  /** The given parameters present on this page's URL. */
50
49
  function fromUrl<K extends string>(
51
50
  names: readonly K[]
@@ -64,7 +63,7 @@ export function rememberCampaign(): void {
64
63
  const landed = fromUrl(CAMPAIGN_TAGS);
65
64
  if (Object.keys(landed).length === 0) return;
66
65
  try {
67
- sessionStorage.setItem(KEY, JSON.stringify(landed));
66
+ sessionStorage.setItem(CAMPAIGN_KEY, JSON.stringify(landed));
68
67
  } catch {
69
68
  // Storage refused: the tags still count on the page they arrived on.
70
69
  }
@@ -75,7 +74,9 @@ export function campaign(): Campaign {
75
74
  const here = fromUrl(CAMPAIGN_TAGS);
76
75
  if (Object.keys(here).length > 0) return here;
77
76
  try {
78
- const kept: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
77
+ const kept: unknown = JSON.parse(
78
+ sessionStorage.getItem(CAMPAIGN_KEY) ?? "{}"
79
+ );
79
80
  if (typeof kept !== "object" || kept === null) return {};
80
81
  const found: Campaign = {};
81
82
  for (const tag of CAMPAIGN_TAGS) {
@@ -26,7 +26,12 @@
26
26
  * quietly wrong:
27
27
  *
28
28
  * - **Reduced motion starts it paused.** A reader who asked their system for
29
- * less motion should not get a full-width loop behind the headline.
29
+ * less motion should not get a full-width loop behind the headline. So does
30
+ * Data Saver, where the browser says so: a loop is megabytes nobody asked for.
31
+ * - **Not before the page has loaded.** A hero loop is the heaviest thing on
32
+ * the page, and started at once it shares the connection with the poster —
33
+ * usually the largest paint — and with everything else the page needs first.
34
+ * It starts on `load`, unless the reader pressed play before that.
30
35
  * - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for
31
36
  * more than five seconds, and a loop moves forever. The control is the
32
37
  * project's; the state it shows is this.
@@ -45,7 +50,9 @@
45
50
  * `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
46
51
  * starts the video itself, and without this script there is no control to stop
47
52
  * it with, while with the attribute a reduced-motion reader sees the first
48
- * frames move before this gets to pause them. Left in, everything still works.
53
+ * frames move before this gets to pause them, and the download starts before
54
+ * the page has loaded. Left in, everything else still works. `preload="none"`
55
+ * or `"metadata"` keeps the browser from fetching the loop on its own.
49
56
  */
50
57
 
51
58
  export interface BackgroundVideoState {
@@ -99,6 +106,27 @@ export interface BackgroundVideo {
99
106
 
100
107
  const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
101
108
 
109
+ /**
110
+ * Whether the video starts paused when the reader has not chosen: they asked
111
+ * their system for less motion, or for less data. Data Saver is Chromium's,
112
+ * the only engine that says; elsewhere this is reduced motion alone.
113
+ */
114
+ function stillByDefault(): boolean {
115
+ const connection = (
116
+ navigator as Navigator & { connection?: { saveData?: boolean } }
117
+ ).connection;
118
+ return matchMedia(REDUCED_MOTION).matches || connection?.saveData === true;
119
+ }
120
+
121
+ /** Runs `then` once the page has loaded: now if it has, else on `load`. */
122
+ function whenLoaded(then: () => void, signal: AbortSignal): void {
123
+ if (document.readyState === "complete") {
124
+ then();
125
+ return;
126
+ }
127
+ addEventListener("load", then, { once: true, signal });
128
+ }
129
+
102
130
  /**
103
131
  * `HTMLMediaElement.NETWORK_NO_SOURCE`, as a literal.
104
132
  *
@@ -278,7 +306,19 @@ export function backgroundVideo(
278
306
  );
279
307
  }
280
308
 
281
- apply(chosen ?? !matchMedia(REDUCED_MOTION).matches);
309
+ if (chosen !== undefined) {
310
+ apply(chosen);
311
+ } else if (stillByDefault()) {
312
+ apply(false);
313
+ } else {
314
+ // Not before the page has loaded: a loop of a few megabytes
315
+ // started now shares the connection with the poster the reader
316
+ // is waiting for, and on a phone the poster loses.
317
+ whenLoaded(() => {
318
+ // Unless the reader chose meanwhile, or it moved on.
319
+ if (video === element && chosen === undefined) apply(true);
320
+ }, signal);
321
+ }
282
322
  sync();
283
323
 
284
324
  return () => {
@@ -7,9 +7,20 @@ import { minifySync, transformWithOxc } from "vite";
7
7
  * Astro writes an inline script exactly as written, comments and all. Both
8
8
  * steps are needed — the minifier accepts TypeScript and silently keeps its
9
9
  * annotations, which a browser then fails to parse.
10
+ *
11
+ * `constants` are declared around the source, which `declare const`s them: a
12
+ * classic script imports nothing, so this is how a value spelled once in the
13
+ * library reaches it. The outer block keeps them out of the page's globals.
10
14
  */
11
- export async function inlineScript(source: string): Promise<string> {
12
- const { code } = await transformWithOxc(source, "inline.ts");
15
+ export async function inlineScript(
16
+ source: string,
17
+ constants: Readonly<Record<string, unknown>> = {}
18
+ ): Promise<string> {
19
+ const declared = Object.entries(constants)
20
+ .map(([name, value]) => `const ${name}=${JSON.stringify(value)};`)
21
+ .join("");
22
+ const wrapped = declared === "" ? source : `{${declared}{\n${source}\n}}`;
23
+ const { code } = await transformWithOxc(wrapped, "inline.ts");
13
24
  // A classic script, not a module: its top-level names are globals other
14
25
  // scripts call — `gtag` above all — and a module's may be renamed.
15
26
  const minified = minifySync("inline.js", code, { module: false });
@@ -1,18 +1,21 @@
1
1
  // The body of `RememberLocale.astro`'s inline script. A classic script: no
2
2
  // imports or exports, and the block keeps its names out of the global scope.
3
+ // `LOCALE_KEY` is declared around it by `inlineScript`, from `storage-keys.ts`.
4
+
5
+ declare const LOCALE_KEY: typeof import("../analytics/storage-keys.ts").LOCALE_KEY;
6
+
3
7
  {
4
- const KEY = "atlas-locale";
5
8
  const current = document.documentElement.lang;
6
9
 
7
10
  const remember = () => {
8
11
  // Came from this site: the visitor is browsing, or just switched
9
12
  // language. Either way, this locale is their choice.
10
13
  if (document.referrer.startsWith(`${location.origin}/`)) {
11
- localStorage.setItem(KEY, current);
14
+ localStorage.setItem(LOCALE_KEY, current);
12
15
  return;
13
16
  }
14
17
 
15
- const chosen = localStorage.getItem(KEY);
18
+ const chosen = localStorage.getItem(LOCALE_KEY);
16
19
  if (chosen === null || chosen === current) return;
17
20
 
18
21
  // No alternate means this page is not built in that locale.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The site icon, cut to size. `Document`'s, and in `astro/` because cutting
3
+ * needs `astro:assets`, which only exists inside the build.
4
+ */
5
+
6
+ import { getImage } from "astro:assets";
7
+ import type { ImageAsset } from "../image.ts";
8
+ import type { SiteIcons } from "../meta/index.ts";
9
+
10
+ /**
11
+ * The favicon sizes, for `rel="icon"`.
12
+ *
13
+ * 48 for the tab: a browser scales it down to 16 or 32 itself, sharply, and 48
14
+ * is also the smallest Google takes for a search result. 192 for everything
15
+ * that shows the icon large — a search result on a dense screen, an Android
16
+ * home screen. A browser picks between them from `sizes`, fetching one.
17
+ */
18
+ const FAVICON_SIZES = [48, 192] as const;
19
+
20
+ /** Apple's size for `apple-touch-icon`, which it scales down from. */
21
+ const TOUCH_SIZE = 180;
22
+
23
+ /**
24
+ * `icon` at `size` square, as PNG.
25
+ *
26
+ * Never larger than the source: Astro's image service does not enlarge, so a
27
+ * size past the source's comes back as the source, and is reported at its
28
+ * own width rather than the one asked for.
29
+ */
30
+ async function cut(icon: ImageAsset, size: number): Promise<ImageAsset> {
31
+ if (size >= icon.width) return icon;
32
+ const image = await getImage({
33
+ src: icon as ImageMetadata,
34
+ width: size,
35
+ height: size,
36
+ format: "png",
37
+ });
38
+ return { src: image.src, width: size, height: size, format: "png" };
39
+ }
40
+
41
+ /**
42
+ * Every icon a page links, from the project's one square source.
43
+ *
44
+ * Cut here rather than linked as given because the source is sized for the
45
+ * largest use — 480px is common — and a browser fetches a favicon on every
46
+ * first visit, before anything it actually shows. Linked as-is, that was the
47
+ * same tens-of-kilobytes PNG for a 16px tab.
48
+ */
49
+ export async function siteIcons(icon: ImageAsset): Promise<SiteIcons> {
50
+ const favicons = await Promise.all(
51
+ FAVICON_SIZES.map((size) => cut(icon, size))
52
+ );
53
+ return {
54
+ // A small source makes two sizes one; linking it twice says nothing.
55
+ favicons: favicons.filter(
56
+ (each, index) =>
57
+ favicons.findIndex((other) => other.src === each.src) === index
58
+ ),
59
+ touch: await cut(icon, TOUCH_SIZE),
60
+ };
61
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,18 @@ export {
43
43
  type UmamiTracker,
44
44
  type Uuid,
45
45
  } from "./analytics/index.ts";
46
+ export {
47
+ type AtlasStoredId,
48
+ compareStoredItems,
49
+ type Lifetime,
50
+ STORAGE_VENDOR_NAMES,
51
+ type StorageVendor,
52
+ type StoredId,
53
+ type StoredItem,
54
+ type StoredItemsOptions,
55
+ storedItems,
56
+ withPurposes,
57
+ } from "./analytics/storage.ts";
46
58
  export type {
47
59
  LanguageTag,
48
60
  LocaleMeta,
package/src/meta/index.ts CHANGED
@@ -29,7 +29,6 @@ import {
29
29
  notFoundAnalytics,
30
30
  } from "../analytics/index.ts";
31
31
  import type { ResolvedLocaleMeta } from "../config.ts";
32
- import type { ImageAsset } from "../image.ts";
33
32
  import {
34
33
  article,
35
34
  type BreadcrumbStep,
@@ -41,7 +40,6 @@ import type { HttpsUrl } from "../url.ts";
41
40
  import type { MetaContentBase, PageKind, ThemeColor } from "./content.ts";
42
41
  import { robotsContent, warnAboutRobots } from "./robots.ts";
43
42
  import {
44
- iconLinks,
45
43
  type OgImage,
46
44
  warnAboutShareImage,
47
45
  warnIfClipped,
@@ -60,8 +58,10 @@ export type { RobotsDirective, RobotsPolicy } from "./robots.ts";
60
58
  export {
61
59
  fitDescription,
62
60
  fitTitle,
61
+ iconLinks,
63
62
  resolveShareImage,
64
63
  type ShareImage,
64
+ type SiteIcons,
65
65
  } from "./share-image.ts";
66
66
  export type { MetaTag } from "./tag.ts";
67
67
  export type { SiteVerification } from "./verification.ts";
@@ -153,8 +153,6 @@ interface MetaInputDerived<L extends string> extends ChromeInput {
153
153
  * from the chrome that they left.
154
154
  */
155
155
  export interface ChromeInput {
156
- /** The site's square icon, used for every icon link. */
157
- readonly icon: ImageAsset;
158
156
  /**
159
157
  * Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
160
158
  * Two values emit one tag per `prefers-color-scheme`.
@@ -184,18 +182,18 @@ export interface DocumentTags {
184
182
  }
185
183
 
186
184
  /**
187
- * What the browser dresses its own furniture with: the tab's icon, the tint of
188
- * the address bar, and which schemes the page renders form controls for.
185
+ * What the browser dresses its own furniture with: the tint of the address
186
+ * bar, and which schemes the page renders form controls for. The tab's icon is
187
+ * furniture too, but it has to be cut to size first, which only the build can
188
+ * do: `Document` emits it, from `PageMeta.icon`.
189
189
  *
190
190
  * Pulled out of `buildMeta` because the 404 needs exactly this and nothing else
191
191
  * around it. Sharing the code is the point rather than a convenience: these are
192
- * the tags a reader sees *as* the site — a 404 with a different tab icon looks
193
- * like it came from somewhere else, which is the opposite of what a 404 is for —
194
- * and two copies of a four-branch block is how one of them quietly stops
195
- * matching the other.
192
+ * the tags a reader sees *as* the site, and two copies of a four-branch block
193
+ * is how one of them quietly stops matching the other.
196
194
  */
197
195
  function chromeTags(input: ChromeInput): MetaTag[] {
198
- const tags: MetaTag[] = [...iconLinks(input.icon)];
196
+ const tags: MetaTag[] = [];
199
197
 
200
198
  if (input.colorScheme !== undefined) {
201
199
  tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
@@ -222,26 +222,39 @@ export function resolveShareImage(
222
222
  }
223
223
 
224
224
  /**
225
- * The icon links: one asset, two `rel`s.
225
+ * The site icon, cut to the sizes its readers ask for — see `siteIcons` in
226
+ * `astro/`, which cuts them from the one square source a project gives.
227
+ */
228
+ export interface SiteIcons {
229
+ /** For `rel="icon"`, smallest first: the tab's, and the search result's. */
230
+ readonly favicons: readonly ImageAsset[];
231
+ /** For `apple-touch-icon`, which Apple scales from 180. */
232
+ readonly touch: ImageAsset;
233
+ }
234
+
235
+ /**
236
+ * The icon links: each favicon, and the touch icon.
226
237
  *
227
- * Browsers read `icon`, Apple reads `apple-touch-icon` and scales it, and both
228
- * point at the same file — so they are built together rather than left to a
229
- * layout to remember the second one. `sizes` is stated so a browser need not
230
- * fetch the file to find out what it is choosing between.
238
+ * Built together rather than left to a layout to remember the second `rel`.
239
+ * `sizes` is stated so a browser picks without fetching each file to find out
240
+ * what it is choosing between — which, with the source served as-is, was a
241
+ * 480px PNG of tens of kilobytes for a 16px tab.
231
242
  *
232
243
  * The href stays as written. Unlike `og:image` these are read by a browser that
233
244
  * already has the page, so root-relative is correct and making it absolute would
234
245
  * only hard-code the origin.
235
246
  */
236
- export function iconLinks(icon: ImageAsset): MetaTag[] {
237
- const mime = imageMime(icon.format);
238
- const attrs = {
239
- href: icon.src,
240
- sizes: `${icon.width}x${icon.height}`,
241
- ...(mime !== undefined ? { type: mime } : {}),
247
+ export function iconLinks(icons: SiteIcons): MetaTag[] {
248
+ const attrs = (icon: ImageAsset) => {
249
+ const mime = imageMime(icon.format);
250
+ return {
251
+ href: icon.src,
252
+ sizes: `${icon.width}x${icon.height}`,
253
+ ...(mime !== undefined ? { type: mime } : {}),
254
+ };
242
255
  };
243
256
  return [
244
- link({ rel: "icon", ...attrs }),
245
- link({ rel: "apple-touch-icon", ...attrs }),
257
+ ...icons.favicons.map((icon) => link({ rel: "icon", ...attrs(icon) })),
258
+ link({ rel: "apple-touch-icon", ...attrs(icons.touch) }),
246
259
  ];
247
260
  }
@@ -326,18 +326,17 @@ export function createSite<
326
326
  // Umami only, tagged so the misses read on their own — the point
327
327
  // of measuring this page is finding a redirect somebody forgot,
328
328
  // not counting the people who mistype. See `notFoundAnalytics`.
329
- //
330
- // `checkedIcon()` rather than `project.icon`: the 404 is held to the
331
- // same square-and-PNG rules as every other page. It is also the one
332
- // page that could plausibly render before any other, so letting it
333
- // read the icon unchecked would move where a bad one is caught.
334
329
  tags: buildNotFoundMeta({
335
330
  title,
336
331
  analytics: project.analytics,
337
- icon: checkedIcon(),
338
332
  themeColor: project.themeColor,
339
333
  colorScheme: project.colorScheme,
340
334
  }),
335
+ // `checkedIcon()` rather than `project.icon`: the 404 is held to the
336
+ // same square-and-PNG rules as every other page. It is also the one
337
+ // page that could plausibly render before any other, so letting it
338
+ // read the icon unchecked would move where a bad one is caught.
339
+ icon: checkedIcon(),
341
340
  // Nothing. The body channel exists for Tag Manager's `<noscript>`,
342
341
  // and no Google tag reaches this page.
343
342
  bodyTags: [],
@@ -407,7 +406,6 @@ export function createSite<
407
406
  ),
408
407
  siteName: project.siteName,
409
408
  twitterSite: project.twitterSite,
410
- icon: checkedIcon(),
411
409
  verification: project.verification,
412
410
  analytics: project.analytics,
413
411
  llmsUrl,
@@ -419,6 +417,7 @@ export function createSite<
419
417
  dir: localeMeta[locale].dir,
420
418
  tags: document.head,
421
419
  bodyTags: document.body,
420
+ icon: checkedIcon(),
422
421
  consentVendors: consentVendors(project.analytics),
423
422
  };
424
423
  }
package/src/site/page.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ImageAsset } from "../image.ts";
1
2
  import type { MetaContent, MetaTag } from "../meta/index.ts";
2
3
  import type { HttpsUrl, UrlPath } from "../url.ts";
3
4
 
@@ -65,6 +66,12 @@ export interface PageMeta {
65
66
  * both, and cannot put either in the other's place.
66
67
  */
67
68
  readonly bodyTags: MetaTag[];
69
+ /**
70
+ * The site's square icon, as the project gave it. `Document` cuts it to
71
+ * each size a browser asks for and links those; the source itself, often
72
+ * hundreds of pixels across, is never linked.
73
+ */
74
+ readonly icon: ImageAsset;
68
75
  /** The vendors that need a consent banner on this page; `Document` fails without one. */
69
76
  readonly consentVendors: readonly string[];
70
77
  }