@escape-game-over/atlas 0.1.61 → 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.
@@ -63,9 +63,7 @@ reloads on nearly every back. An inline script at the end of `<body>` saves the
63
63
  position on the history entry once scrolling settles, and puts it back before
64
64
  the first paint. On the entry, not in storage keyed by URL, so a link to the same
65
65
  page still opens at the top. Never on `pagehide`: WebKit fires it after moving to
66
- the previous entry, and the write wipes the position about to be restored. A
67
- site on Astro's `ClientRouter` turns it off; the router keeps its own position
68
- in the same history state.
66
+ the previous entry, and the write wipes the position about to be restored.
69
67
 
70
68
  **Browser code has one import path: `@escape-game-over/atlas/client`**, which
71
69
  re-exports every module above. None of it touches the DOM at import, so an
@@ -102,16 +100,9 @@ undo and then the function again — so it has to be able to run twice. State th
102
100
  must survive a move goes in a `WeakMap` keyed by `root`, which is what the
103
101
  carousel example does with its index.
104
102
 
105
- **The trap this exists for is view transitions.** A bundled `<script src>` is an
106
- ES module, cached by URL, so it executes once per session — not once per
107
- navigation. Bind at module scope with `ClientRouter` on and the incoming page
108
- gets a live list and dead controls: the markup was swapped, the handlers still
109
- point at what was there before. Driving `attach` from `astro:page-load` and
110
- calling its return on teardown is the fix, and it is why none of these modules
111
- does anything at import time.
112
-
113
- Note that the abort half alone does not help. It makes the breakage look
114
- handled. Without a re-`attach` there is simply nothing wired.
103
+ Astro's `ClientRouter`, which swaps pages without reloading them, is refused at
104
+ build time by `siteRoutes()`: every module here, and every analytics vendor,
105
+ assumes one full page load per navigation.
115
106
 
116
107
  ## `filters`
117
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.61",
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
 
@@ -6,9 +6,7 @@
6
6
  * load ahead of the analytics goes there; `head` comes after.
7
7
  *
8
8
  * It also restores the scroll position on back and reload; see
9
- * `docs/client-scripts.md`. A site on Astro's `ClientRouter` passes
10
- * `restoreScroll={false}`: the router keeps its own position in the same
11
- * history state.
9
+ * `docs/client-scripts.md`. `restoreScroll={false}` leaves it to the browser.
12
10
  *
13
11
  * A site with a lead form passes `rememberCampaign`, so the `utm_*` tags a
14
12
  * visit landed with survive to the page the form is on; see `attribution.ts`.
@@ -44,10 +42,10 @@ interface Props {
44
42
  /** `lang` and `dir` are the page's locale's, from `meta`. */
45
43
  readonly html?: Omit<HTMLAttributes<"html">, "lang" | "dir">;
46
44
  readonly body?: HTMLAttributes<"body">;
47
- /** Defaults to `true`; `false` for a site on `ClientRouter`. */
48
- readonly restoreScroll?: boolean;
49
45
  /** Off unless stated: a site with no lead form has nothing to keep them for. */
50
46
  readonly rememberCampaign?: boolean;
47
+ /** Defaults to `true`; `false` leaves scroll restoration to the browser. */
48
+ readonly restoreScroll?: boolean;
51
49
  /** Defaults to `true`. */
52
50
  readonly rememberLocale?: boolean;
53
51
  }
@@ -57,8 +55,8 @@ const {
57
55
  fonts = {},
58
56
  html,
59
57
  body,
60
- restoreScroll = true,
61
58
  rememberCampaign = false,
59
+ restoreScroll = true,
62
60
  rememberLocale = true,
63
61
  } = Astro.props;
64
62
 
@@ -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
  }
@@ -41,9 +41,9 @@ function panel(): HTMLElement {
41
41
  const existing = document.getElementById(PANEL_ID);
42
42
  if (existing) return existing;
43
43
 
44
- // Either nothing has failed yet, or the document this lived in was swapped
45
- // out from under it — a client router replacing `<body>`. Either way every
46
- // line in `seen` is detached, and counting into one would report nothing.
44
+ // Either nothing has failed yet, or the panel was removed from the page.
45
+ // Either way every line in `seen` is detached, and counting into one would
46
+ // report nothing.
47
47
  seen.clear();
48
48
 
49
49
  const created = document.createElement("div");
@@ -48,8 +48,8 @@ export interface ElementTag<Tag extends string = string> {
48
48
  * template imports the same file.
49
49
  *
50
50
  * - **An element can enter the page more than once.** Moving it runs the undo
51
- * and `connect` again; Astro's `ClientRouter` does so on every navigation.
52
- * State that must survive belongs in a `WeakMap` keyed by `root`.
51
+ * and `connect` again. State that must survive belongs in a `WeakMap` keyed
52
+ * by `root`.
53
53
  * - **The page's script must stay a bundled module** (a plain `<script>`), so
54
54
  * the element has its children when it upgrades.
55
55
  * - **Failures show in dev.** A `connect` that throws leaves that one element
@@ -336,8 +336,8 @@ export function filters<const F extends FieldMap, T>(
336
336
  const query = parts.join("&");
337
337
  // The bare path when nothing is left, rather than a trailing `?`.
338
338
  const url = query === "" ? window.location.pathname : `?${query}`;
339
- // `null` on a pushed step, so Astro's `ClientRouter` leaves its popstate
340
- // to this module; a replaced entry keeps whatever state it already had.
339
+ // `null` on a pushed step; a replaced entry keeps whatever state it
340
+ // already had, such as the position `ScrollRestore` saved there.
341
341
  if (history === "push") window.history.pushState(null, "", url);
342
342
  else window.history.replaceState(window.history.state, "", url);
343
343
  }
@@ -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
+ }
@@ -0,0 +1,35 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ /** What a site imports to turn Astro's `ClientRouter` on. */
4
+ const CLIENT_ROUTER = new Set([
5
+ "astro:transitions",
6
+ "astro/components/ClientRouter.astro",
7
+ ]);
8
+
9
+ /**
10
+ * Fails the build when a site imports `ClientRouter`.
11
+ *
12
+ * Atlas assumes a full page load per navigation: analytics count a page view
13
+ * per load, vendor scripts and widgets start once per page, and nothing tears
14
+ * them down. Under the router none of that holds, and nothing errors — the
15
+ * numbers and the widgets just go quietly wrong. The animation it gives is
16
+ * available without it: `@view-transition { navigation: auto; }` in CSS.
17
+ *
18
+ * Only the site's own imports are checked. Astro and other packages may name
19
+ * the module for their own reasons.
20
+ */
21
+ export function noClientRouter(): Plugin {
22
+ return {
23
+ name: "atlas:no-client-router",
24
+ enforce: "pre",
25
+ resolveId(id, importer) {
26
+ if (!CLIENT_ROUTER.has(id)) return null;
27
+ if (importer === undefined || importer.includes("/node_modules/")) {
28
+ return null;
29
+ }
30
+ throw new Error(
31
+ `${importer} imports ${id}. Astro's ClientRouter is not supported: Atlas's analytics, consent and widgets expect a full page load per navigation. For the animation, use cross-document view transitions — \`@view-transition { navigation: auto; }\` in CSS.`
32
+ );
33
+ },
34
+ };
35
+ }
@@ -11,6 +11,7 @@ import type { Sitemap } from "../sitemap.ts";
11
11
  import type { HttpsUrl } from "../url.ts";
12
12
  import { warn } from "../warn.ts";
13
13
  import { buildCacheDir } from "./build-cache.ts";
14
+ import { noClientRouter } from "./no-client-router.ts";
14
15
 
15
16
  /**
16
17
  * What this integration needs of a site, and no more.
@@ -282,7 +283,12 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
282
283
  },
283
284
  cacheDir: buildCacheDir(),
284
285
  };
285
- updateConfig(config);
286
+ // The plugin is kept out of `config`, which is logged below: a
287
+ // plugin prints as noise.
288
+ updateConfig({
289
+ ...config,
290
+ vite: { plugins: [noClientRouter()] },
291
+ });
286
292
  // Said out loud: a setting changed from under you is worth a
287
293
  // line, and reading `astro.config.ts` would otherwise leave you
288
294
  // to wonder why the output is not shaped the way its defaults
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,