@escape-game-over/atlas 0.1.77 → 0.1.78

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.78",
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,363 @@
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
+ * Everything this deployment keeps on a device: Atlas's own keys, then each
294
+ * loaded vendor's.
295
+ *
296
+ * Atlas's rows follow what the page renders, which `Document` decides from
297
+ * its props, so pass the same `rememberLocale` and `rememberCampaign` here.
298
+ * The consent record is listed whenever there is a banner to answer.
299
+ */
300
+ export function storedItems(
301
+ analytics: AnalyticsSettings | undefined,
302
+ options: StoredItemsOptions = {}
303
+ ): readonly StoredItem[] {
304
+ const {
305
+ rememberLocale = true,
306
+ rememberCampaign = false,
307
+ sort = false,
308
+ } = options;
309
+ const asked = consentVendors(analytics).length > 0;
310
+
311
+ const rows: StoredItem[] = [];
312
+ if (rememberLocale) {
313
+ rows.push({
314
+ id: "atlas-locale",
315
+ names: [LOCALE_KEY],
316
+ provider: "site",
317
+ lasts: "kept",
318
+ consent: "none",
319
+ });
320
+ }
321
+ // Kept only once analytics is granted, so only where it can be.
322
+ if (rememberCampaign && asked) {
323
+ rows.push({
324
+ id: "atlas-campaign",
325
+ names: [CAMPAIGN_KEY],
326
+ provider: "site",
327
+ lasts: "tab",
328
+ consent: "analytics",
329
+ });
330
+ }
331
+ if (asked) {
332
+ rows.push({
333
+ id: "atlas-consent",
334
+ names: [CONSENT_KEY],
335
+ provider: "site",
336
+ lasts: month(CONSENT_MONTHS),
337
+ consent: "none",
338
+ });
339
+ }
340
+
341
+ for (const vendor of loadedVendors(analytics)) {
342
+ const entry: VendorStorage = CATALOG[vendor];
343
+ rows.push({
344
+ id: vendor,
345
+ names: entry.site.names,
346
+ provider: vendor,
347
+ lasts: entry.site.lasts,
348
+ consent: entry.consent,
349
+ });
350
+ if (entry.own !== undefined) {
351
+ rows.push({
352
+ id: `${vendor}-own` as OwnRow,
353
+ names: entry.own.names,
354
+ provider: vendor,
355
+ domains: entry.own.domains,
356
+ lasts: entry.own.lasts,
357
+ consent: entry.consent,
358
+ });
359
+ }
360
+ }
361
+
362
+ return sort ? rows.toSorted(compareStoredItems) : rows;
363
+ }
@@ -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) {
@@ -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.
package/src/index.ts CHANGED
@@ -43,6 +43,17 @@ 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
+ } from "./analytics/storage.ts";
46
57
  export type {
47
58
  LanguageTag,
48
59
  LocaleMeta,