@escape-game-over/atlas 0.1.53 → 0.1.55
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 +2 -1
- package/package.json +1 -1
- package/src/analytics/index.ts +21 -5
- package/src/analytics/meta-pixel.ts +23 -0
- package/src/astro/ConsentBanner.astro +8 -4
- package/src/astro/Document.astro +10 -0
- package/src/astro/MetaPixel.astro +18 -0
- package/src/astro/client.ts +1 -0
- package/src/astro/consent-gate.ts +48 -0
- package/src/astro/meta-event.ts +61 -0
- package/src/astro/meta-pixel.ts +76 -0
- package/src/index.ts +2 -0
- package/src/site/create.ts +4 -0
- package/src/site/page.ts +2 -0
package/docs/client-scripts.md
CHANGED
|
@@ -43,7 +43,8 @@ finding them.
|
|
|
43
43
|
| `element` | registering a custom element, and ending what it started | what the element does while it is on the page |
|
|
44
44
|
|
|
45
45
|
`consent` is the one module that ships its own behaviour: `<ConsentBanner
|
|
46
|
-
analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`)
|
|
46
|
+
slot="consent" analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`),
|
|
47
|
+
passed to `Document`, which fails the build when a page needs one and has none. It renders the
|
|
47
48
|
site's markup as its children, skips itself — script included — when the
|
|
48
49
|
analytics need no permission, and remembers, expires and applies the answer. The
|
|
49
50
|
answer is two, one per category: `analytics` (Google's `analytics_storage`) and
|
package/package.json
CHANGED
package/src/analytics/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MetaTag } from "../meta/tag.ts";
|
|
2
2
|
import type { HttpsUrl } from "../url.ts";
|
|
3
3
|
import { type GoogleSettings, googleEmits, googleScripts } from "./google.ts";
|
|
4
|
+
import { checkMetaPixel, type MetaPixelSettings } from "./meta-pixel.ts";
|
|
4
5
|
import type { AnalyticsTags } from "./tags.ts";
|
|
5
6
|
import {
|
|
6
7
|
checkUmamiDomains,
|
|
@@ -29,6 +30,7 @@ export {
|
|
|
29
30
|
type ConsentState,
|
|
30
31
|
type GoogleSettings,
|
|
31
32
|
} from "./google.ts";
|
|
33
|
+
export type { MetaPixelSettings } from "./meta-pixel.ts";
|
|
32
34
|
export type { AnalyticsTags } from "./tags.ts";
|
|
33
35
|
export type {
|
|
34
36
|
UmamiReplay,
|
|
@@ -40,6 +42,8 @@ export type {
|
|
|
40
42
|
export interface AnalyticsSettings {
|
|
41
43
|
readonly umami?: UmamiSettings;
|
|
42
44
|
readonly google?: GoogleSettings;
|
|
45
|
+
/** Loaded by `ConsentBanner`, after marketing is granted. */
|
|
46
|
+
readonly meta?: MetaPixelSettings;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
@@ -60,6 +64,7 @@ export function analyticsScripts(
|
|
|
60
64
|
if (analytics === undefined) return { head: [], body: [] };
|
|
61
65
|
|
|
62
66
|
checkUmamiDomains(analytics.umami, origin);
|
|
67
|
+
checkMetaPixel(analytics.meta, origin);
|
|
63
68
|
const google = googleScripts(analytics.google, origin);
|
|
64
69
|
return {
|
|
65
70
|
head: [...google.head, ...umamiScripts(analytics.umami)],
|
|
@@ -70,7 +75,7 @@ export function analyticsScripts(
|
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
/**
|
|
73
|
-
*
|
|
78
|
+
* The vendors this deployment loads that a visitor has to be asked about.
|
|
74
79
|
*
|
|
75
80
|
* The one place that question is answered, and at build time: a project leaves
|
|
76
81
|
* the banner — script and all — out of the HTML, rather than shipping a script
|
|
@@ -88,19 +93,30 @@ export function analyticsScripts(
|
|
|
88
93
|
* permission — which is the failure worth having, since the alternative is a
|
|
89
94
|
* new vendor silently setting cookies behind a banner that never appears.
|
|
90
95
|
*/
|
|
91
|
-
export function
|
|
96
|
+
export function consentVendors(
|
|
92
97
|
analytics: AnalyticsSettings | undefined
|
|
93
|
-
):
|
|
94
|
-
if (analytics === undefined) return
|
|
98
|
+
): readonly (keyof AnalyticsSettings)[] {
|
|
99
|
+
if (analytics === undefined) return [];
|
|
95
100
|
|
|
96
101
|
const VENDORS: Readonly<Record<keyof AnalyticsSettings, boolean>> = {
|
|
97
102
|
// Cookieless by design — nothing to permit, nothing to withdraw.
|
|
98
103
|
umami: false,
|
|
99
104
|
// Consent Mode, but only once there is a tag reading it.
|
|
100
105
|
google: googleEmits(analytics.google),
|
|
106
|
+
// No cookieless mode: nothing loads until marketing is granted.
|
|
107
|
+
meta: analytics.meta !== undefined,
|
|
101
108
|
};
|
|
102
109
|
|
|
103
|
-
return Object.
|
|
110
|
+
return (Object.keys(VENDORS) as (keyof AnalyticsSettings)[]).filter(
|
|
111
|
+
(vendor) => VENDORS[vendor]
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether any vendor this deployment loads needs permission. */
|
|
116
|
+
export function consentRequired(
|
|
117
|
+
analytics: AnalyticsSettings | undefined
|
|
118
|
+
): boolean {
|
|
119
|
+
return consentVendors(analytics).length > 0;
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NonEmpty } from "../types.ts";
|
|
2
|
+
import { warn } from "../warn.ts";
|
|
3
|
+
|
|
4
|
+
/** Meta's pixel. Marketing: it loads only once a visitor grants that. */
|
|
5
|
+
export interface MetaPixelSettings {
|
|
6
|
+
/** From Events Manager: 15 or 16 digits. Several share one script. */
|
|
7
|
+
readonly pixelIds: NonEmpty<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Warns about an id that is not a pixel id: it would report nowhere. */
|
|
11
|
+
export function checkMetaPixel(
|
|
12
|
+
meta: MetaPixelSettings | undefined,
|
|
13
|
+
at: string
|
|
14
|
+
): void {
|
|
15
|
+
for (const id of meta?.pixelIds ?? []) {
|
|
16
|
+
if (!/^\d{15,16}$/.test(id)) {
|
|
17
|
+
warn(
|
|
18
|
+
at,
|
|
19
|
+
`Meta pixel id "${id}" is not 15 or 16 digits, so it reports nowhere. Copy it from Events Manager.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* buttons.
|
|
6
6
|
*
|
|
7
7
|
* Renders nothing — and ships no script — when this site's analytics need no
|
|
8
|
-
* permission. Otherwise the banner starts hidden and shows only to a visitor
|
|
8
|
+
* permission. Also loads the vendors that wait for consent, such as Meta's pixel. Otherwise the banner starts hidden and shows only to a visitor
|
|
9
9
|
* with no answer on record, or when a `data-consent-reopen` control is pressed.
|
|
10
10
|
*/
|
|
11
11
|
import type { HTMLAttributes } from "astro/types";
|
|
12
12
|
import { type AnalyticsSettings, consentRequired } from "../analytics/index.ts";
|
|
13
13
|
import ConsentElement from "./ConsentElement.astro";
|
|
14
|
+
import MetaPixel from "./MetaPixel.astro";
|
|
14
15
|
|
|
15
16
|
type Props = HTMLAttributes<"div"> & {
|
|
16
17
|
readonly analytics: AnalyticsSettings | undefined;
|
|
@@ -20,7 +21,10 @@ const { analytics, ...attrs } = Astro.props;
|
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
{consentRequired(analytics) && (
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
24
|
+
<>
|
|
25
|
+
<ConsentElement {...attrs}>
|
|
26
|
+
<slot />
|
|
27
|
+
</ConsentElement>
|
|
28
|
+
{analytics?.meta && <MetaPixel settings={analytics.meta} />}
|
|
29
|
+
</>
|
|
26
30
|
)}
|
package/src/astro/Document.astro
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* It sends a reader arriving from elsewhere to the page in the language they
|
|
17
17
|
* last chose; see `RememberLocale.astro`. `rememberLocale={false}` turns it off.
|
|
18
18
|
*
|
|
19
|
+
* A deployment whose analytics need permission must pass its banner in the
|
|
20
|
+
* `consent` slot — `<ConsentBanner slot="consent" …>` — or the build fails.
|
|
21
|
+
*
|
|
19
22
|
* `fonts={site.fonts}` writes the `@font-face` rules and preloads for the
|
|
20
23
|
* faces `siteFonts(site)` registered in the Astro config.
|
|
21
24
|
*
|
|
@@ -58,6 +61,12 @@ const {
|
|
|
58
61
|
rememberCampaign = false,
|
|
59
62
|
rememberLocale = true,
|
|
60
63
|
} = Astro.props;
|
|
64
|
+
|
|
65
|
+
if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${meta.consentVendors.join(", ")} ${meta.consentVendors.length === 1 ? "needs" : "need"} permission, but this page has no consent banner. Pass one to Document: <ConsentBanner slot="consent" …>.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
61
70
|
---
|
|
62
71
|
|
|
63
72
|
<!doctype html>
|
|
@@ -81,6 +90,7 @@ const {
|
|
|
81
90
|
<body {...body}>
|
|
82
91
|
<MetaTags tags={meta.bodyTags} />
|
|
83
92
|
<slot />
|
|
93
|
+
<slot name="consent" />
|
|
84
94
|
{restoreScroll && <ScrollRestore />}
|
|
85
95
|
{rememberCampaign && <RememberCampaign />}
|
|
86
96
|
</body>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Internal to `ConsentBanner.astro`, so the pixel's script ships only where it is configured. */
|
|
3
|
+
import type { MetaPixelSettings } from "../analytics/meta-pixel.ts";
|
|
4
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
5
|
+
import { metaPixelElement } from "./meta-pixel.ts";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
readonly settings: MetaPixelSettings;
|
|
9
|
+
}
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<script src="./meta-pixel.ts" />
|
|
13
|
+
|
|
14
|
+
<AtlasElement
|
|
15
|
+
of={metaPixelElement}
|
|
16
|
+
data-pixel-ids={JSON.stringify(Astro.props.settings.pixelIds)}
|
|
17
|
+
hidden
|
|
18
|
+
/>
|
package/src/astro/client.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONSENT_EVENT,
|
|
3
|
+
type ConsentCategory,
|
|
4
|
+
type ConsentChoices,
|
|
5
|
+
readConsent,
|
|
6
|
+
} from "./consent.ts";
|
|
7
|
+
|
|
8
|
+
/** What a vendor does as its category is granted and withdrawn. */
|
|
9
|
+
export interface ConsentGated {
|
|
10
|
+
/** After `load`, each time the category is granted: a stored answer, or one given now. */
|
|
11
|
+
readonly grant: () => void;
|
|
12
|
+
/** Each time a grant is withdrawn on this page. */
|
|
13
|
+
readonly revoke?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Runs `vendor` as the visitor's answer for `category` says, until `signal` aborts. */
|
|
17
|
+
export function whenGranted(
|
|
18
|
+
category: ConsentCategory,
|
|
19
|
+
vendor: ConsentGated,
|
|
20
|
+
signal?: AbortSignal
|
|
21
|
+
): void {
|
|
22
|
+
let granted = readConsent()?.[category] === "granted";
|
|
23
|
+
|
|
24
|
+
// After `load`, so a vendor does not compete with the page. Checked again
|
|
25
|
+
// then: an answer withdrawn before `load` cancels the grant.
|
|
26
|
+
const grantAfterLoad = () => {
|
|
27
|
+
const run = () => {
|
|
28
|
+
if (granted) vendor.grant();
|
|
29
|
+
};
|
|
30
|
+
if (document.readyState === "complete") run();
|
|
31
|
+
else addEventListener("load", run, { once: true, signal });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (granted) grantAfterLoad();
|
|
35
|
+
|
|
36
|
+
addEventListener(
|
|
37
|
+
CONSENT_EVENT,
|
|
38
|
+
(event) => {
|
|
39
|
+
const choices = (event as CustomEvent<ConsentChoices>).detail;
|
|
40
|
+
const now = choices[category] === "granted";
|
|
41
|
+
if (now === granted) return;
|
|
42
|
+
granted = now;
|
|
43
|
+
if (now) grantAfterLoad();
|
|
44
|
+
else vendor.revoke?.();
|
|
45
|
+
},
|
|
46
|
+
{ signal }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CurrencyCode } from "../money.ts";
|
|
2
|
+
|
|
3
|
+
/** Meta's standard events, which its ad optimisation understands by name. */
|
|
4
|
+
export type MetaStandardEvent =
|
|
5
|
+
| "AddPaymentInfo"
|
|
6
|
+
| "AddToCart"
|
|
7
|
+
| "AddToWishlist"
|
|
8
|
+
| "CompleteRegistration"
|
|
9
|
+
| "Contact"
|
|
10
|
+
| "CustomizeProduct"
|
|
11
|
+
| "Donate"
|
|
12
|
+
| "FindLocation"
|
|
13
|
+
| "InitiateCheckout"
|
|
14
|
+
| "Lead"
|
|
15
|
+
| "Purchase"
|
|
16
|
+
| "Schedule"
|
|
17
|
+
| "Search"
|
|
18
|
+
| "StartTrial"
|
|
19
|
+
| "SubmitApplication"
|
|
20
|
+
| "Subscribe"
|
|
21
|
+
| "ViewContent";
|
|
22
|
+
|
|
23
|
+
/** Meta's standard parameters. What the visitor chose, never who they are. */
|
|
24
|
+
export interface MetaEventData {
|
|
25
|
+
readonly value?: number;
|
|
26
|
+
readonly currency?: CurrencyCode;
|
|
27
|
+
readonly content_name?: string;
|
|
28
|
+
readonly content_category?: string;
|
|
29
|
+
readonly content_ids?: readonly string[];
|
|
30
|
+
readonly content_type?: "product" | "product_group";
|
|
31
|
+
readonly num_items?: number;
|
|
32
|
+
readonly search_string?: string;
|
|
33
|
+
readonly status?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `Purchase` is the one event Meta rejects without a value and currency. */
|
|
37
|
+
type MetaEventArgs<E extends MetaStandardEvent> = E extends "Purchase"
|
|
38
|
+
? [data: MetaEventData & { value: number; currency: CurrencyCode }]
|
|
39
|
+
: [data?: MetaEventData];
|
|
40
|
+
|
|
41
|
+
/** What `meta-pixel.ts` defines once marketing is granted. */
|
|
42
|
+
interface MetaWindow {
|
|
43
|
+
fbq?: (...args: unknown[]) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Records a standard event with Meta's pixel — a lead sent, a booking made.
|
|
48
|
+
*
|
|
49
|
+
* `fbq` exists only once the visitor granted marketing and the pixel loaded.
|
|
50
|
+
* Before that, without consent, or where Meta is not configured, the event is
|
|
51
|
+
* dropped: no consent means nothing to send, and an analytics call must never
|
|
52
|
+
* be the reason a page misbehaves.
|
|
53
|
+
*/
|
|
54
|
+
export function metaEvent<E extends MetaStandardEvent>(
|
|
55
|
+
event: E,
|
|
56
|
+
...[data]: MetaEventArgs<E>
|
|
57
|
+
): void {
|
|
58
|
+
const fbq = (window as unknown as MetaWindow).fbq;
|
|
59
|
+
if (data === undefined) fbq?.("track", event);
|
|
60
|
+
else fbq?.("track", event, data);
|
|
61
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ConsentGated } from "./consent-gate.ts";
|
|
2
|
+
import { whenGranted } from "./consent-gate.ts";
|
|
3
|
+
import { element } from "./element.ts";
|
|
4
|
+
import { data } from "./ref.ts";
|
|
5
|
+
|
|
6
|
+
/** Meta's `fbq`: a queue until `fbevents.js` arrives and replays it. */
|
|
7
|
+
interface Fbq {
|
|
8
|
+
(...args: unknown[]): void;
|
|
9
|
+
callMethod?: (...args: unknown[]) => void;
|
|
10
|
+
queue: unknown[];
|
|
11
|
+
push: Fbq;
|
|
12
|
+
loaded: boolean;
|
|
13
|
+
version: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PixelWindow {
|
|
17
|
+
fbq?: Fbq;
|
|
18
|
+
_fbq?: Fbq;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SCRIPT = "https://connect.facebook.net/en_US/fbevents.js";
|
|
22
|
+
|
|
23
|
+
/** Meta's own base code, as it defines `fbq` before the script loads. */
|
|
24
|
+
function defineFbq(): Fbq {
|
|
25
|
+
const global = window as unknown as PixelWindow;
|
|
26
|
+
if (global.fbq !== undefined) return global.fbq;
|
|
27
|
+
const fbq = function () {
|
|
28
|
+
// biome-ignore lint/complexity/noArguments: Meta's queue takes the arguments object, as its snippet pushes it.
|
|
29
|
+
if (fbq.callMethod) Reflect.apply(fbq.callMethod, fbq, arguments);
|
|
30
|
+
// biome-ignore lint/complexity/noArguments: as above.
|
|
31
|
+
else fbq.queue.push(arguments);
|
|
32
|
+
} as Fbq;
|
|
33
|
+
fbq.push = fbq;
|
|
34
|
+
fbq.loaded = true;
|
|
35
|
+
fbq.version = "2.0";
|
|
36
|
+
fbq.queue = [];
|
|
37
|
+
global.fbq = fbq;
|
|
38
|
+
global._fbq ??= fbq;
|
|
39
|
+
return fbq;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The pixel: loaded on the first grant, paused and resumed by later answers. */
|
|
43
|
+
export function metaPixel(pixelIds: readonly string[]): ConsentGated {
|
|
44
|
+
let loaded = false;
|
|
45
|
+
return {
|
|
46
|
+
grant: () => {
|
|
47
|
+
const fbq = defineFbq();
|
|
48
|
+
if (loaded) {
|
|
49
|
+
fbq("consent", "grant");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
loaded = true;
|
|
53
|
+
for (const id of pixelIds) fbq("init", id);
|
|
54
|
+
fbq("track", "PageView");
|
|
55
|
+
const script = document.createElement("script");
|
|
56
|
+
script.async = true;
|
|
57
|
+
script.src = SCRIPT;
|
|
58
|
+
document.head.append(script);
|
|
59
|
+
},
|
|
60
|
+
revoke: () => {
|
|
61
|
+
(window as unknown as PixelWindow).fbq?.("consent", "revoke");
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Internal to `MetaPixel.astro`. */
|
|
67
|
+
export const metaPixelElement = element(
|
|
68
|
+
"atlas-meta-pixel",
|
|
69
|
+
({ root, signal }) => {
|
|
70
|
+
whenGranted(
|
|
71
|
+
"marketing",
|
|
72
|
+
metaPixel(JSON.parse(data(root, "pixel-ids"))),
|
|
73
|
+
signal
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
);
|
package/src/index.ts
CHANGED
package/src/site/create.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { consentVendors } from "../analytics/index.ts";
|
|
1
2
|
import {
|
|
2
3
|
type LocaleMeta,
|
|
3
4
|
type LocalesOf,
|
|
@@ -342,6 +343,8 @@ export function createSite<
|
|
|
342
343
|
// Nothing. The body channel exists for Tag Manager's `<noscript>`,
|
|
343
344
|
// and no Google tag reaches this page.
|
|
344
345
|
bodyTags: [],
|
|
346
|
+
// Umami only, which asks nobody.
|
|
347
|
+
consentVendors: [],
|
|
345
348
|
};
|
|
346
349
|
}
|
|
347
350
|
|
|
@@ -418,6 +421,7 @@ export function createSite<
|
|
|
418
421
|
dir: localeMeta[locale].dir,
|
|
419
422
|
tags: document.head,
|
|
420
423
|
bodyTags: document.body,
|
|
424
|
+
consentVendors: consentVendors(project.analytics),
|
|
421
425
|
};
|
|
422
426
|
}
|
|
423
427
|
|
package/src/site/page.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface PageMeta {
|
|
|
65
65
|
* both, and cannot put either in the other's place.
|
|
66
66
|
*/
|
|
67
67
|
readonly bodyTags: MetaTag[];
|
|
68
|
+
/** The vendors that need a consent banner on this page; `Document` fails without one. */
|
|
69
|
+
readonly consentVendors: readonly string[];
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|