@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.
- package/docs/client-scripts.md +4 -13
- package/package.json +1 -1
- package/src/analytics/axon-pixel.ts +5 -6
- package/src/analytics/clarity.ts +5 -5
- package/src/analytics/drip.ts +6 -6
- package/src/analytics/google.ts +9 -7
- package/src/analytics/ids.ts +41 -0
- package/src/analytics/index.ts +1 -0
- package/src/analytics/meta-pixel.ts +6 -6
- package/src/analytics/snap-pixel.ts +5 -8
- package/src/analytics/tiktok-pixel.ts +5 -5
- package/src/analytics/umami.ts +10 -2
- package/src/astro/Document.astro +4 -6
- package/src/astro/append-script.ts +9 -3
- package/src/astro/dev-log.ts +3 -3
- package/src/astro/element.ts +2 -2
- package/src/astro/filters.ts +2 -2
- package/src/astro/google-map.ts +7 -14
- package/src/astro/load-script.ts +145 -0
- package/src/astro/no-client-router.ts +35 -0
- package/src/astro/site-routes.ts +7 -1
- package/src/index.ts +4 -0
package/docs/client-scripts.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
11
|
+
readonly eventKey: Uuid;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
/**
|
|
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
|
-
|
|
20
|
+
invalidId(
|
|
22
21
|
at,
|
|
23
|
-
`Axon event key "${axon.eventKey}" is not a UUID, so it
|
|
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
|
}
|
package/src/analytics/clarity.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
/**
|
|
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 ||
|
|
22
|
+
if (clarity === undefined || CLARITY_PROJECT_ID.test(clarity.projectId)) {
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
invalidId(
|
|
26
26
|
at,
|
|
27
|
-
`Clarity project id "${clarity.projectId}" is not 10 lowercase letters and digits, so it
|
|
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
|
}
|
package/src/analytics/drip.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
10
|
+
readonly accountId: NumericId;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
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 ||
|
|
16
|
-
|
|
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
|
|
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
|
}
|
package/src/analytics/google.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/analytics/index.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
13
|
+
readonly pixelIds: NonEmpty<NumericId>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
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 (
|
|
23
|
-
|
|
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
|
|
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 {
|
|
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<
|
|
12
|
+
readonly pixelIds: NonEmpty<Uuid>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
|
|
22
|
+
invalidId(
|
|
26
23
|
at,
|
|
27
|
-
`Snap pixel id "${id}" is not a UUID, so it
|
|
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 {
|
|
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
|
-
/**
|
|
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 (
|
|
23
|
-
|
|
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
|
|
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
|
}
|
package/src/analytics/umami.ts
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
|
package/src/astro/Document.astro
CHANGED
|
@@ -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`.
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
12
|
+
into.append(script);
|
|
7
13
|
return script;
|
|
8
14
|
}
|
package/src/astro/dev-log.ts
CHANGED
|
@@ -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
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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");
|
package/src/astro/element.ts
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
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
|
package/src/astro/filters.ts
CHANGED
|
@@ -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
|
|
340
|
-
//
|
|
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
|
}
|
package/src/astro/google-map.ts
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|