@escape-game-over/atlas 0.1.62 → 0.1.63

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.62",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -1,5 +1,4 @@
1
- import { warn } from "../warn.ts";
2
- import { UUID } from "./snap-pixel.ts";
1
+ import { invalidId, UUID, type Uuid } from "./ids.ts";
3
2
 
4
3
  /**
5
4
  * AppLovin's Axon pixel. Marketing: it loads only once a visitor grants that.
@@ -9,17 +8,17 @@ import { UUID } from "./snap-pixel.ts";
9
8
  */
10
9
  export interface AxonPixelSettings {
11
10
  /** From the Axon dashboard: a UUID. */
12
- readonly eventKey: string;
11
+ readonly eventKey: Uuid;
13
12
  }
14
13
 
15
- /** Warns about a key that is not an event key: it would report nowhere. */
14
+ /** Fails the build on a key that is not an event key: it would report nowhere. */
16
15
  export function checkAxonPixel(
17
16
  axon: AxonPixelSettings | undefined,
18
17
  at: string
19
18
  ): void {
20
19
  if (axon === undefined || UUID.test(axon.eventKey)) return;
21
- warn(
20
+ invalidId(
22
21
  at,
23
- `Axon event key "${axon.eventKey}" is not a UUID, so it reports nowhere. Copy it from the Axon dashboard.`
22
+ `Axon event key "${axon.eventKey}" is not a UUID, so it would report nowhere. Copy it from the Axon dashboard.`
24
23
  );
25
24
  }
@@ -1,4 +1,4 @@
1
- import { warn } from "../warn.ts";
1
+ import { CLARITY_PROJECT_ID, invalidId } from "./ids.ts";
2
2
 
3
3
  /**
4
4
  * Microsoft Clarity. It has a cookieless mode, so like Google it loads for
@@ -14,16 +14,16 @@ export interface ClaritySettings {
14
14
  readonly projectId: string;
15
15
  }
16
16
 
17
- /** Warns about an id that is not a project id: it would report nowhere. */
17
+ /** Fails the build on an id that is not a project id: it would report nowhere. */
18
18
  export function checkClarity(
19
19
  clarity: ClaritySettings | undefined,
20
20
  at: string
21
21
  ): void {
22
- if (clarity === undefined || /^[a-z0-9]{10}$/.test(clarity.projectId)) {
22
+ if (clarity === undefined || CLARITY_PROJECT_ID.test(clarity.projectId)) {
23
23
  return;
24
24
  }
25
- warn(
25
+ invalidId(
26
26
  at,
27
- `Clarity project id "${clarity.projectId}" is not 10 lowercase letters and digits, so it reports nowhere. Copy it from the Clarity project's settings.`
27
+ `Clarity project id "${clarity.projectId}" is not 10 lowercase letters and digits, so it would report nowhere. Copy it from the Clarity project's settings.`
28
28
  );
29
29
  }
@@ -1,4 +1,4 @@
1
- import { warn } from "../warn.ts";
1
+ import { DRIP_ACCOUNT_ID, invalidId, type NumericId } from "./ids.ts";
2
2
 
3
3
  /**
4
4
  * Drip's site tracking. Marketing: it loads only once a visitor grants that.
@@ -7,14 +7,14 @@ import { warn } from "../warn.ts";
7
7
  */
8
8
  export interface DripSettings {
9
9
  /** From Drip's Site Setup: digits, the `_dcs.account` of its snippet. */
10
- readonly accountId: string;
10
+ readonly accountId: NumericId;
11
11
  }
12
12
 
13
- /** Warns about an id that is not an account id: it would report nowhere. */
13
+ /** Fails the build on an id that is not an account id: it would report nowhere. */
14
14
  export function checkDrip(drip: DripSettings | undefined, at: string): void {
15
- if (drip === undefined || /^\d+$/.test(drip.accountId)) return;
16
- warn(
15
+ if (drip === undefined || DRIP_ACCOUNT_ID.test(drip.accountId)) return;
16
+ invalidId(
17
17
  at,
18
- `Drip account id "${drip.accountId}" is not all digits, so it reports nowhere. Copy it from Drip's Site Setup.`
18
+ `Drip account id "${drip.accountId}" is not all digits, so it would report nowhere. Copy it from Drip's Site Setup.`
19
19
  );
20
20
  }
@@ -7,6 +7,7 @@ import {
7
7
  type ConsentCategory,
8
8
  } from "./consent-storage.ts";
9
9
  import googleTag from "./google-tag.ts?raw";
10
+ import { type GoogleTagId, invalidId, type TagManagerId } from "./ids.ts";
10
11
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
11
12
 
12
13
  /**
@@ -108,7 +109,7 @@ export interface GoogleSettings {
108
109
  * once, for the first, and the rest are configured against it — Google's
109
110
  * documented arrangement.
110
111
  */
111
- readonly tagIds?: readonly string[];
112
+ readonly tagIds?: readonly GoogleTagId[];
112
113
  /**
113
114
  * Tag Manager container ids — `GTM-XXXXXXX`.
114
115
  *
@@ -117,7 +118,7 @@ export interface GoogleSettings {
117
118
  * which is most of why they are worth stating — they apply to tags nobody
118
119
  * here has seen.
119
120
  */
120
- readonly containerIds?: readonly string[];
121
+ readonly containerIds?: readonly TagManagerId[];
121
122
  /**
122
123
  * What holds before a visitor has chosen. **Denied unless stated.**
123
124
  *
@@ -371,20 +372,21 @@ export function googleScripts(
371
372
  const containers = google.containerIds ?? [];
372
373
 
373
374
  // An id in the wrong field is the silent failure here: it is configured,
374
- // it is emitted, and it reports nowhere — which reads as a quiet week.
375
+ // it is emitted, and it reports nowhere — which reads as a quiet week. So
376
+ // the build fails rather than warns.
375
377
  for (const id of tags) {
376
378
  if (id.startsWith("UA-")) {
377
- warn(
379
+ invalidId(
378
380
  at,
379
381
  `"${id}" is a Universal Analytics property, and those stopped processing data on 1 July 2023 (1 July 2024 for 360). It reports nowhere. The GA4 property that replaced it starts with "G-". https://support.google.com/analytics/answer/11583528`
380
382
  );
381
383
  } else if (id.startsWith("GTM-")) {
382
- warn(
384
+ invalidId(
383
385
  at,
384
386
  `"${id}" is a Tag Manager container, not something gtag can configure — it belongs in containerIds.`
385
387
  );
386
388
  } else if (!GTAG_PREFIXES.some((prefix) => id.startsWith(prefix))) {
387
- warn(
389
+ invalidId(
388
390
  at,
389
391
  `"${id}" does not look like anything gtag configures: those start with ${GTAG_PREFIXES.join(", ")}.`
390
392
  );
@@ -392,7 +394,7 @@ export function googleScripts(
392
394
  }
393
395
  for (const id of containers) {
394
396
  if (!id.startsWith("GTM-")) {
395
- warn(
397
+ invalidId(
396
398
  at,
397
399
  `"${id}" is configured as a Tag Manager container but does not look like one — those start with "GTM-".`
398
400
  );
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What an analytics id is declared as, and the error a wrong one raises.
3
+ *
4
+ * A wrong id fails silently — configured, emitted, reporting nowhere — so the
5
+ * build fails instead of warning: an id is config, and there is no page worth
6
+ * shipping with one that is wrong.
7
+ */
8
+
9
+ /**
10
+ * The shapes an id's setting is declared with. As close as a type can get
11
+ * without a literal to inspect: a UUID's dashes, a Google prefix, digits. The
12
+ * exact format is checked when a page is built, and a wrong id fails the build.
13
+ */
14
+ export type Uuid = `${string}-${string}-${string}-${string}-${string}`;
15
+ /** A `gtag` id: GA4 `G-`, Google tag `GT-`, Ads `AW-`, Floodlight `DC-`. */
16
+ export type GoogleTagId = `${"G" | "GT" | "AW" | "DC"}-${string}`;
17
+ /** A Tag Manager container. */
18
+ export type TagManagerId = `GTM-${string}`;
19
+ /** An id that is a number written out: Meta's pixels, Drip's accounts. */
20
+ export type NumericId = `${number}`;
21
+
22
+ /** The error for an id that is not what its vendor issues. */
23
+ export function invalidId(at: string, message: string): never {
24
+ throw new Error(`${at}: ${message}`);
25
+ }
26
+
27
+ /** A Meta pixel id: 15 or 16 digits. */
28
+ export const META_PIXEL_ID = /^\d{15,16}$/;
29
+
30
+ /** A TikTok pixel id: 20 capital letters and digits. */
31
+ export const TIKTOK_PIXEL_ID = /^[A-Z0-9]{20}$/;
32
+
33
+ /** A Drip account id: digits. */
34
+ export const DRIP_ACCOUNT_ID = /^\d+$/;
35
+
36
+ /** A Clarity project id: 10 lowercase letters and digits. */
37
+ export const CLARITY_PROJECT_ID = /^[a-z0-9]{10}$/;
38
+
39
+ /** A UUID, as Umami, Snap and Axon issue them. */
40
+ export const UUID =
41
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -38,6 +38,7 @@ export {
38
38
  type ConsentState,
39
39
  type GoogleSettings,
40
40
  } from "./google.ts";
41
+ export type { GoogleTagId, NumericId, TagManagerId, Uuid } from "./ids.ts";
41
42
  export type { MetaPixelSettings } from "./meta-pixel.ts";
42
43
  export type { SnapPixelSettings } from "./snap-pixel.ts";
43
44
  export type { AnalyticsTags } from "./tags.ts";
@@ -1,5 +1,5 @@
1
1
  import type { NonEmpty } from "../types.ts";
2
- import { warn } from "../warn.ts";
2
+ import { invalidId, META_PIXEL_ID, type NumericId } from "./ids.ts";
3
3
 
4
4
  /**
5
5
  * Meta's pixel. Marketing: it loads only once a visitor grants that.
@@ -10,19 +10,19 @@ import { warn } from "../warn.ts";
10
10
  */
11
11
  export interface MetaPixelSettings {
12
12
  /** From Events Manager: 15 or 16 digits. Several share one script. */
13
- readonly pixelIds: NonEmpty<string>;
13
+ readonly pixelIds: NonEmpty<NumericId>;
14
14
  }
15
15
 
16
- /** Warns about an id that is not a pixel id: it would report nowhere. */
16
+ /** Fails the build on an id that is not a pixel id: it would report nowhere. */
17
17
  export function checkMetaPixel(
18
18
  meta: MetaPixelSettings | undefined,
19
19
  at: string
20
20
  ): void {
21
21
  for (const id of meta?.pixelIds ?? []) {
22
- if (!/^\d{15,16}$/.test(id)) {
23
- warn(
22
+ if (!META_PIXEL_ID.test(id)) {
23
+ invalidId(
24
24
  at,
25
- `Meta pixel id "${id}" is not 15 or 16 digits, so it reports nowhere. Copy it from Events Manager.`
25
+ `Meta pixel id "${id}" is not 15 or 16 digits, so it would report nowhere. Copy it from Events Manager.`
26
26
  );
27
27
  }
28
28
  }
@@ -1,5 +1,5 @@
1
1
  import type { NonEmpty } from "../types.ts";
2
- import { warn } from "../warn.ts";
2
+ import { invalidId, UUID, type Uuid } from "./ids.ts";
3
3
 
4
4
  /**
5
5
  * Snapchat's pixel. Marketing: it loads only once a visitor grants that.
@@ -9,22 +9,19 @@ import { warn } from "../warn.ts";
9
9
  */
10
10
  export interface SnapPixelSettings {
11
11
  /** From Snap's Events Manager: a UUID. */
12
- readonly pixelIds: NonEmpty<string>;
12
+ readonly pixelIds: NonEmpty<Uuid>;
13
13
  }
14
14
 
15
- export const UUID =
16
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
-
18
- /** Warns about an id that is not a pixel id: it would report nowhere. */
15
+ /** Fails the build on an id that is not a pixel id: it would report nowhere. */
19
16
  export function checkSnapPixel(
20
17
  snapchat: SnapPixelSettings | undefined,
21
18
  at: string
22
19
  ): void {
23
20
  for (const id of snapchat?.pixelIds ?? []) {
24
21
  if (!UUID.test(id)) {
25
- warn(
22
+ invalidId(
26
23
  at,
27
- `Snap pixel id "${id}" is not a UUID, so it reports nowhere. Copy it from Snap's Events Manager.`
24
+ `Snap pixel id "${id}" is not a UUID, so it would report nowhere. Copy it from Snap's Events Manager.`
28
25
  );
29
26
  }
30
27
  }
@@ -1,5 +1,5 @@
1
1
  import type { NonEmpty } from "../types.ts";
2
- import { warn } from "../warn.ts";
2
+ import { invalidId, TIKTOK_PIXEL_ID } from "./ids.ts";
3
3
 
4
4
  /**
5
5
  * TikTok's pixel. Marketing: it loads only once a visitor grants that.
@@ -13,16 +13,16 @@ export interface TikTokPixelSettings {
13
13
  readonly pixelIds: NonEmpty<string>;
14
14
  }
15
15
 
16
- /** Warns about an id that is not a pixel id: it would report nowhere. */
16
+ /** Fails the build on an id that is not a pixel id: it would report nowhere. */
17
17
  export function checkTikTokPixel(
18
18
  tiktok: TikTokPixelSettings | undefined,
19
19
  at: string
20
20
  ): void {
21
21
  for (const id of tiktok?.pixelIds ?? []) {
22
- if (!/^[A-Z0-9]{20}$/.test(id)) {
23
- warn(
22
+ if (!TIKTOK_PIXEL_ID.test(id)) {
23
+ invalidId(
24
24
  at,
25
- `TikTok pixel id "${id}" is not 20 capital letters and digits, so it reports nowhere. Copy it from TikTok Events Manager.`
25
+ `TikTok pixel id "${id}" is not 20 capital letters and digits, so it would report nowhere. Copy it from TikTok Events Manager.`
26
26
  );
27
27
  }
28
28
  }
@@ -2,6 +2,7 @@ import type { MetaTag } from "../meta/tag.ts";
2
2
  import type { NonEmpty } from "../types.ts";
3
3
  import { type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
4
4
  import { warn } from "../warn.ts";
5
+ import { invalidId, UUID, type Uuid } from "./ids.ts";
5
6
  import { preconnect } from "./tags.ts";
6
7
 
7
8
  /**
@@ -102,7 +103,7 @@ export interface UmamiSettings {
102
103
  * single site, and nobody notices until one venue's traffic appears to
103
104
  * double the week another launches.
104
105
  */
105
- readonly websiteId: string;
106
+ readonly websiteId: Uuid;
106
107
  /**
107
108
  * Where the scripts are served from — `https://src.example.com`.
108
109
  *
@@ -188,7 +189,8 @@ const stated = (
188
189
  ) as Readonly<Record<string, string>>;
189
190
 
190
191
  /**
191
- * Warns when the `domains` list leaves out the host the site is served from.
192
+ * Fails the build on a website id that is not a UUID, and warns when the
193
+ * `domains` list leaves out the host the site is served from.
192
194
  *
193
195
  * Separate from building the scripts because the pages are not the only thing
194
196
  * that builds them — the 404 does too — and a check that ran per caller would
@@ -207,6 +209,12 @@ export function checkUmamiDomains(
207
209
  origin: HttpsUrl
208
210
  ): void {
209
211
  if (umami === undefined) return;
212
+ if (!UUID.test(umami.websiteId)) {
213
+ invalidId(
214
+ origin,
215
+ `Umami website id "${umami.websiteId}" is not a UUID, so it would record nowhere. Copy it from the Umami dashboard.`
216
+ );
217
+ }
210
218
  const site = new URL(origin).hostname;
211
219
  if (umami.domains.includes(site)) return;
212
220
 
@@ -1,8 +1,14 @@
1
- /** Fetches a vendor's script without blocking the page. */
2
- export function appendScript(src: string): HTMLScriptElement {
1
+ /**
2
+ * Fetches a vendor's script without blocking the page: into `<head>`, or into
3
+ * `into` for a widget that renders where its script stands.
4
+ */
5
+ export function appendScript(
6
+ src: string,
7
+ into: Element = document.head
8
+ ): HTMLScriptElement {
3
9
  const script = document.createElement("script");
4
10
  script.async = true;
5
11
  script.src = src;
6
- document.head.append(script);
12
+ into.append(script);
7
13
  return script;
8
14
  }
@@ -3,6 +3,7 @@ import type { MapFrame } from "../map.ts";
3
3
  import { appendScript } from "./append-script.ts";
4
4
  import { reportDevError } from "./dev-log.ts";
5
5
  import { element } from "./element.ts";
6
+ import { triggered } from "./load-script.ts";
6
7
  import { data } from "./ref.ts";
7
8
 
8
9
  /** What `GoogleMap.astro` hands the element, serialised. */
@@ -121,18 +122,10 @@ export const googleMap = element("atlas-google-map", ({ root, signal }) => {
121
122
  draw(root, config).catch((error) => reportDevError("GoogleMap", error));
122
123
  };
123
124
 
124
- if (config.load === "click") {
125
- root.addEventListener("click", show, { once: true, signal });
126
- return;
127
- }
128
- const observer = new IntersectionObserver(
129
- (entries) => {
130
- if (!entries.some((entry) => entry.isIntersecting)) return;
131
- observer.disconnect();
132
- show();
133
- },
134
- { rootMargin: "200px" }
135
- );
136
- observer.observe(root);
137
- return () => observer.disconnect();
125
+ triggered(
126
+ config.load === "click"
127
+ ? { on: "click", element: root }
128
+ : { on: "visible", element: root },
129
+ signal
130
+ ).then(show);
138
131
  });
@@ -0,0 +1,145 @@
1
+ import { appendScript } from "./append-script.ts";
2
+
3
+ /** When a script is fetched. All but `click` also wait for the page's `load`. */
4
+ export type LoadTrigger =
5
+ /** At `load`. */
6
+ | { readonly on: "load" }
7
+ /** After `load`, once the browser has nothing else to do. */
8
+ | { readonly on: "idle" }
9
+ /** After `load`, `ms` later. */
10
+ | { readonly on: "delay"; readonly ms: number }
11
+ /** When `element` comes within `margin` of the viewport. */
12
+ | {
13
+ readonly on: "visible";
14
+ readonly element: Element;
15
+ readonly margin?: string;
16
+ }
17
+ /** At the visitor's first tap, click, key or scroll anywhere. */
18
+ | { readonly on: "interaction" }
19
+ /** When `element` is clicked: a visitor asking for it. */
20
+ | { readonly on: "click"; readonly element: Element };
21
+
22
+ export interface LoadScriptOptions {
23
+ readonly when: LoadTrigger;
24
+ /** Where the script goes, for a widget that renders beside it. Defaults to `<head>`. */
25
+ readonly into?: Element;
26
+ /** Cancels a load not yet triggered. */
27
+ readonly signal?: AbortSignal;
28
+ }
29
+
30
+ const INTERACTIONS = ["pointerdown", "keydown", "scroll", "touchstart"];
31
+
32
+ function afterLoad(signal?: AbortSignal): Promise<void> {
33
+ return new Promise((resolve) => {
34
+ if (document.readyState === "complete") resolve();
35
+ else addEventListener("load", () => resolve(), { once: true, signal });
36
+ });
37
+ }
38
+
39
+ /** Resolves when `when` fires, on its own. */
40
+ function fires(when: LoadTrigger, signal?: AbortSignal): Promise<void> {
41
+ return new Promise<void>((resolve) => {
42
+ const done = () => {
43
+ if (!signal?.aborted) resolve();
44
+ };
45
+ switch (when.on) {
46
+ case "load":
47
+ done();
48
+ return;
49
+ case "idle":
50
+ // Not in Safari: a frame later is its nearest equivalent.
51
+ if (typeof requestIdleCallback === "function") {
52
+ requestIdleCallback(done);
53
+ } else {
54
+ setTimeout(done, 1);
55
+ }
56
+ return;
57
+ case "delay":
58
+ setTimeout(done, when.ms);
59
+ return;
60
+ case "visible": {
61
+ const observer = new IntersectionObserver(
62
+ (entries) => {
63
+ if (!entries.some((entry) => entry.isIntersecting)) {
64
+ return;
65
+ }
66
+ observer.disconnect();
67
+ done();
68
+ },
69
+ { rootMargin: when.margin ?? "200px" }
70
+ );
71
+ observer.observe(when.element);
72
+ signal?.addEventListener("abort", () => observer.disconnect());
73
+ return;
74
+ }
75
+ case "interaction": {
76
+ const controller = new AbortController();
77
+ signal?.addEventListener("abort", () => controller.abort());
78
+ for (const type of INTERACTIONS) {
79
+ addEventListener(
80
+ type,
81
+ () => {
82
+ controller.abort();
83
+ done();
84
+ },
85
+ { once: true, passive: true, signal: controller.signal }
86
+ );
87
+ }
88
+ return;
89
+ }
90
+ case "click":
91
+ when.element.addEventListener("click", done, {
92
+ once: true,
93
+ signal,
94
+ });
95
+ return;
96
+ }
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Resolves when `when` fires; never, if `signal` aborts first.
102
+ *
103
+ * A click is a visitor asking, so it is answered at once. Every other trigger
104
+ * also waits for `load`: an interaction is listened for from the start, so an
105
+ * early one still counts, and the rest start counting at `load`.
106
+ */
107
+ export async function triggered(
108
+ when: LoadTrigger,
109
+ signal?: AbortSignal
110
+ ): Promise<void> {
111
+ if (when.on === "click") return fires(when, signal);
112
+ if (when.on === "interaction") {
113
+ await Promise.all([fires(when, signal), afterLoad(signal)]);
114
+ return;
115
+ }
116
+ await afterLoad(signal);
117
+ await fires(when, signal);
118
+ }
119
+
120
+ const scripts = new Map<string, Promise<HTMLScriptElement>>();
121
+
122
+ /**
123
+ * Fetches `src` once `when` fires, and once per page however many callers ask:
124
+ * each gets the same promise, which settles when the script has run. A failed
125
+ * fetch is forgotten, so the next caller tries again.
126
+ */
127
+ export async function loadScript(
128
+ src: string,
129
+ { when, into, signal }: LoadScriptOptions
130
+ ): Promise<HTMLScriptElement> {
131
+ await triggered(when, signal);
132
+ let loading = scripts.get(src);
133
+ if (loading === undefined) {
134
+ loading = new Promise((resolve, reject) => {
135
+ const script = appendScript(src, into);
136
+ script.addEventListener("load", () => resolve(script));
137
+ script.addEventListener("error", () => {
138
+ scripts.delete(src);
139
+ reject(new Error(`${src} failed to load`));
140
+ });
141
+ });
142
+ scripts.set(src, loading);
143
+ }
144
+ return loading;
145
+ }
package/src/index.ts CHANGED
@@ -31,12 +31,16 @@ export {
31
31
  consentVendors,
32
32
  type DripSettings,
33
33
  type GoogleSettings,
34
+ type GoogleTagId,
34
35
  type MetaPixelSettings,
36
+ type NumericId,
35
37
  type SnapPixelSettings,
38
+ type TagManagerId,
36
39
  type TikTokPixelSettings,
37
40
  type UmamiReplay,
38
41
  type UmamiSettings,
39
42
  type UmamiTracker,
43
+ type Uuid,
40
44
  } from "./analytics/index.ts";
41
45
  export type {
42
46
  LanguageTag,