@escape-game-over/atlas 0.1.1
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/README.md +364 -0
- package/bin/use-project.mjs +131 -0
- package/docs/NOT-BUILT.md +329 -0
- package/docs/checks.md +139 -0
- package/docs/share-images.md +52 -0
- package/docs/toolchain.md +83 -0
- package/package.json +51 -0
- package/src/analytics/google.ts +351 -0
- package/src/analytics/index.ts +102 -0
- package/src/analytics/tags.ts +57 -0
- package/src/analytics/umami.ts +285 -0
- package/src/astro/MetaTags.astro +87 -0
- package/src/astro/consent.ts +165 -0
- package/src/astro/images.ts +315 -0
- package/src/astro/index.ts +44 -0
- package/src/astro/public-files.ts +129 -0
- package/src/astro/site-routes.ts +307 -0
- package/src/config.ts +218 -0
- package/src/contact.ts +233 -0
- package/src/file.ts +16 -0
- package/src/files.ts +39 -0
- package/src/hours.ts +312 -0
- package/src/i18n/define.ts +217 -0
- package/src/i18n/placeholders.ts +94 -0
- package/src/i18n/translate.ts +190 -0
- package/src/image.ts +29 -0
- package/src/index.ts +222 -0
- package/src/jsonld/article.ts +165 -0
- package/src/jsonld/breadcrumb.ts +34 -0
- package/src/jsonld/business.ts +196 -0
- package/src/jsonld/ids.ts +106 -0
- package/src/jsonld/index.ts +59 -0
- package/src/jsonld/node.ts +78 -0
- package/src/jsonld/organization.ts +154 -0
- package/src/jsonld/place.ts +96 -0
- package/src/jsonld/product.ts +172 -0
- package/src/jsonld/quantity.ts +55 -0
- package/src/jsonld/service.ts +237 -0
- package/src/jsonld/video.ts +239 -0
- package/src/jsonld/website.ts +58 -0
- package/src/llms.ts +160 -0
- package/src/meta/content.ts +190 -0
- package/src/meta/index.ts +432 -0
- package/src/meta/robots.ts +212 -0
- package/src/meta/share-image.ts +232 -0
- package/src/meta/tag.ts +133 -0
- package/src/meta/verification.ts +53 -0
- package/src/money.ts +237 -0
- package/src/project.ts +249 -0
- package/src/redirects.ts +266 -0
- package/src/robots.ts +80 -0
- package/src/routes/define.ts +412 -0
- package/src/routes/family.ts +251 -0
- package/src/routes/resolve.ts +266 -0
- package/src/site/api.ts +354 -0
- package/src/site/create.ts +660 -0
- package/src/site/index.ts +32 -0
- package/src/site/page.ts +148 -0
- package/src/sitemap.ts +257 -0
- package/src/types.ts +160 -0
- package/src/url.ts +144 -0
- package/src/warn.ts +88 -0
- package/src/xml.ts +103 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { warn } from "../warn.ts";
|
|
2
|
+
import { type AnalyticsTags, literal } from "./tags.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Google's tags — Analytics and Tag Manager — and the consent state they read.
|
|
6
|
+
*
|
|
7
|
+
* The value here is *order*. `gtag()` does not talk to Google; it pushes onto
|
|
8
|
+
* `window.dataLayer`, which is a plain array. Google's library, whenever it
|
|
9
|
+
* arrives, replays that array from the start. So everything can be queued
|
|
10
|
+
* before the loader exists — and everything queued earlier is applied first.
|
|
11
|
+
*
|
|
12
|
+
* Which makes consent a sequencing problem. `gtag('consent','default',…)` has
|
|
13
|
+
* to be in the queue ahead of any tag that reads it; behind, and Google applies
|
|
14
|
+
* its *implicit* default, which is everything granted. Nothing errors, nothing
|
|
15
|
+
* warns, the numbers arrive — slightly more of them than should have. That is
|
|
16
|
+
* the failure this file exists to make unwritable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Granted or denied. Google's own vocabulary, and a closed one. */
|
|
20
|
+
export type ConsentState = "granted" | "denied";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One Consent Mode v2 default, queued before any tag loads.
|
|
24
|
+
*
|
|
25
|
+
* **The default, not the answer.** It says what holds before a visitor has
|
|
26
|
+
* chosen. Asking them, remembering the choice and calling
|
|
27
|
+
* `gtag('consent','update',…)` is the consuming project's half: it needs a
|
|
28
|
+
* banner, storage and a click, none of which is a head tag. lib guarantees the
|
|
29
|
+
* half that is invisible when wrong — that the default is already in the queue
|
|
30
|
+
* when the banner is still loading.
|
|
31
|
+
*
|
|
32
|
+
* The `gtag` the banner calls is the one this file defines, so the two halves
|
|
33
|
+
* meet on a global. That coupling is the price of the mechanism, not a choice.
|
|
34
|
+
*
|
|
35
|
+
* Reference:
|
|
36
|
+
* <https://developers.google.com/tag-platform/security/guides/consent>
|
|
37
|
+
*/
|
|
38
|
+
export interface ConsentDefaults {
|
|
39
|
+
/** Cookies for advertising. */
|
|
40
|
+
readonly adStorage?: ConsentState;
|
|
41
|
+
/** Sending user data to Google for advertising. */
|
|
42
|
+
readonly adUserData?: ConsentState;
|
|
43
|
+
/** Personalised advertising. */
|
|
44
|
+
readonly adPersonalization?: ConsentState;
|
|
45
|
+
/**
|
|
46
|
+
* Cookies for analytics — the one GA4 itself waits on.
|
|
47
|
+
*
|
|
48
|
+
* Denied does not mean silent: GA4 still sends a cookieless ping, so you
|
|
49
|
+
* keep rough counts and Google's modelling. It means no identifier and no
|
|
50
|
+
* stitching one visit to the next.
|
|
51
|
+
*/
|
|
52
|
+
readonly analyticsStorage?: ConsentState;
|
|
53
|
+
/** Cookies the site needs to function. */
|
|
54
|
+
readonly functionalityStorage?: ConsentState;
|
|
55
|
+
/** Cookies remembering a visitor's preferences. */
|
|
56
|
+
readonly personalizationStorage?: ConsentState;
|
|
57
|
+
/** Cookies for authentication and fraud prevention. */
|
|
58
|
+
readonly securityStorage?: ConsentState;
|
|
59
|
+
/**
|
|
60
|
+
* Where this default applies — ISO 3166-2 codes such as `["ES", "US-CA"]`.
|
|
61
|
+
*
|
|
62
|
+
* Omitted, it applies everywhere. Several entries let a strict default
|
|
63
|
+
* cover the regions that require one and a looser default cover the rest,
|
|
64
|
+
* which is why the field takes a list of these rather than one.
|
|
65
|
+
*/
|
|
66
|
+
readonly region?: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* Milliseconds to hold tags while a consent tool decides.
|
|
69
|
+
*
|
|
70
|
+
* For a banner that loads asynchronously: without it a tag can fire on
|
|
71
|
+
* `denied` before the stored choice has been read back, and a visitor who
|
|
72
|
+
* had already agreed is counted as though they had not.
|
|
73
|
+
*/
|
|
74
|
+
readonly waitForUpdate?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GoogleSettings {
|
|
78
|
+
/**
|
|
79
|
+
* Everything to `gtag('config', …)`, by its own prefix:
|
|
80
|
+
*
|
|
81
|
+
* - `G-` — a GA4 measurement id, the ordinary case.
|
|
82
|
+
* - `GT-` — a Google tag id, the newer unified form. One id, with its
|
|
83
|
+
* destinations configured on Google's side rather than here.
|
|
84
|
+
* - `AW-` — a Google Ads conversion id, for conversion tracking and
|
|
85
|
+
* remarketing.
|
|
86
|
+
* - `DC-` — Floodlight, for Campaign Manager.
|
|
87
|
+
*
|
|
88
|
+
* Not `GTM-`, which is a Tag Manager container and a different mechanism —
|
|
89
|
+
* `containerIds` below.
|
|
90
|
+
*
|
|
91
|
+
* Not `UA-` either, and that one is worth naming: Universal Analytics
|
|
92
|
+
* stopped processing data on 1 July 2023 for standard properties and
|
|
93
|
+
* 1 July 2024 for 360, and both lost their interface on the later date. A
|
|
94
|
+
* `UA-` id here is not a typo, it is a property that no longer exists —
|
|
95
|
+
* which is exactly the kind of thing that survives a config file for years
|
|
96
|
+
* because nothing complains.
|
|
97
|
+
*
|
|
98
|
+
* A list because a site can report to several at once: a venue's own
|
|
99
|
+
* property, the group's roll-up, an Ads account. The library is fetched
|
|
100
|
+
* once, for the first, and the rest are configured against it — Google's
|
|
101
|
+
* documented arrangement.
|
|
102
|
+
*/
|
|
103
|
+
readonly tagIds?: readonly string[];
|
|
104
|
+
/**
|
|
105
|
+
* Tag Manager container ids — `GTM-XXXXXXX`.
|
|
106
|
+
*
|
|
107
|
+
* Note what a container *is*: a place where someone adds tags later,
|
|
108
|
+
* without touching this repo. The consent defaults below still govern them,
|
|
109
|
+
* which is most of why they are worth stating — they apply to tags nobody
|
|
110
|
+
* here has seen.
|
|
111
|
+
*/
|
|
112
|
+
readonly containerIds?: readonly string[];
|
|
113
|
+
/**
|
|
114
|
+
* What holds before a visitor has chosen. **Denied unless stated.**
|
|
115
|
+
*
|
|
116
|
+
* Google's own implicit default is the opposite — no consent block means
|
|
117
|
+
* everything granted, cookies set, nobody asked. So lib states the safe one
|
|
118
|
+
* for you, and the permissive direction is the one that costs a deliberate
|
|
119
|
+
* act.
|
|
120
|
+
*
|
|
121
|
+
* That asymmetry is the whole design. Forgetting this field leaves
|
|
122
|
+
* analytics in cookieless mode: the counts survive, the identifiers do not,
|
|
123
|
+
* and the degradation is plain in the dashboard within a week. Getting it
|
|
124
|
+
* *wrong* the other way — cookies without consent — shows up nowhere at
|
|
125
|
+
* all. A default that fails visibly beats a required field that can be
|
|
126
|
+
* answered wrongly in silence.
|
|
127
|
+
*
|
|
128
|
+
* State it to loosen it, to add regions, or to tune `waitForUpdate` for a
|
|
129
|
+
* banner that loads slowly. See `DENIED_BY_DEFAULT` for exactly what the
|
|
130
|
+
* unstated case emits.
|
|
131
|
+
*/
|
|
132
|
+
readonly consent?: readonly [ConsentDefaults, ...ConsentDefaults[]];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What a deployment gets for saying nothing: the four consent-gated signals
|
|
137
|
+
* denied, and the rest left to Google.
|
|
138
|
+
*
|
|
139
|
+
* The four are the ones Consent Mode v2 exists for — advertising and analytics
|
|
140
|
+
* storage, and the two ad signals that ride with them. The three not named
|
|
141
|
+
* here (`functionality`, `personalization`, `security`) are the site working,
|
|
142
|
+
* remembering a preference, and fraud prevention: denying those by default
|
|
143
|
+
* would break things a visitor never objected to, so they keep Google's
|
|
144
|
+
* default and a deployment denies them deliberately if it means to.
|
|
145
|
+
*
|
|
146
|
+
* No `waitForUpdate`. How long to hold tags is a fact about *this* project's
|
|
147
|
+
* banner — how fast it loads, whether it reads from storage — and a number
|
|
148
|
+
* guessed here would delay the first hit of every site that has no banner at
|
|
149
|
+
* all, for nothing.
|
|
150
|
+
*/
|
|
151
|
+
const DENIED_BY_DEFAULT: readonly [ConsentDefaults] = [
|
|
152
|
+
{
|
|
153
|
+
adStorage: "denied",
|
|
154
|
+
adUserData: "denied",
|
|
155
|
+
adPersonalization: "denied",
|
|
156
|
+
analyticsStorage: "denied",
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What `gtag('config', …)` accepts, by prefix — see `GoogleSettings.tagIds`.
|
|
162
|
+
*
|
|
163
|
+
* A list rather than one check because they are four different products that
|
|
164
|
+
* happen to share a mechanism, and a site commonly has more than one.
|
|
165
|
+
*/
|
|
166
|
+
const GTAG_PREFIXES = ["G-", "GT-", "AW-", "DC-"] as const;
|
|
167
|
+
|
|
168
|
+
/** Consent Mode's own spelling, which is snake_case and not ours. */
|
|
169
|
+
const CONSENT_KEYS = {
|
|
170
|
+
adStorage: "ad_storage",
|
|
171
|
+
adUserData: "ad_user_data",
|
|
172
|
+
adPersonalization: "ad_personalization",
|
|
173
|
+
analyticsStorage: "analytics_storage",
|
|
174
|
+
functionalityStorage: "functionality_storage",
|
|
175
|
+
personalizationStorage: "personalization_storage",
|
|
176
|
+
securityStorage: "security_storage",
|
|
177
|
+
region: "region",
|
|
178
|
+
waitForUpdate: "wait_for_update",
|
|
179
|
+
} as const satisfies Readonly<Record<keyof ConsentDefaults, string>>;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The global a consent banner calls to change its mind: `__consent('granted')`.
|
|
183
|
+
*
|
|
184
|
+
* Emitted by lib rather than written in each project, because the *signals* it
|
|
185
|
+
* updates must be exactly the ones lib defaulted. A banner that built the
|
|
186
|
+
* payload itself would be a second copy of that list — free to grant something
|
|
187
|
+
* never denied, or to miss something that was, the moment either side changed.
|
|
188
|
+
* Here the list is derived from the same settings, so it cannot drift.
|
|
189
|
+
*
|
|
190
|
+
* A global, because that is how the two halves can meet at all: lib emits head
|
|
191
|
+
* script, the banner is a component, and neither imports the other. Exactly the
|
|
192
|
+
* arrangement `gtag` itself uses.
|
|
193
|
+
*
|
|
194
|
+
* What stays with the project is *when* to call it — the banner, the click, and
|
|
195
|
+
* whether the answer is remembered. None of that is a head tag.
|
|
196
|
+
*/
|
|
197
|
+
export const CONSENT_UPDATE_GLOBAL = "__consent";
|
|
198
|
+
|
|
199
|
+
/** The signal keys of a set of defaults — not `region`, not `waitForUpdate`. */
|
|
200
|
+
const SIGNAL_KEYS = [
|
|
201
|
+
"adStorage",
|
|
202
|
+
"adUserData",
|
|
203
|
+
"adPersonalization",
|
|
204
|
+
"analyticsStorage",
|
|
205
|
+
"functionalityStorage",
|
|
206
|
+
"personalizationStorage",
|
|
207
|
+
"securityStorage",
|
|
208
|
+
] as const satisfies readonly (keyof ConsentDefaults)[];
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `window.__consent(state)`, updating precisely what was defaulted.
|
|
212
|
+
*
|
|
213
|
+
* Every signal any default mentioned, and nothing else. Updating one that was
|
|
214
|
+
* never defaulted is legal and pointless — Google reads it as a change from its
|
|
215
|
+
* own implicit grant, which was never in force here.
|
|
216
|
+
*/
|
|
217
|
+
function consentUpdater(defaults: readonly ConsentDefaults[]): string {
|
|
218
|
+
const signals = SIGNAL_KEYS.filter((key) =>
|
|
219
|
+
defaults.some((given) => given[key] !== undefined)
|
|
220
|
+
).map((key) => `${literal(CONSENT_KEYS[key])}:s`);
|
|
221
|
+
|
|
222
|
+
if (signals.length === 0) return "";
|
|
223
|
+
return `window.${CONSENT_UPDATE_GLOBAL}=function(s){gtag('consent','update',{${signals.join(",")}})};`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** One `gtag('consent','default',{…})` per stated default, in order. */
|
|
227
|
+
function consentCalls(defaults: readonly ConsentDefaults[]): string {
|
|
228
|
+
return defaults
|
|
229
|
+
.map((given) => {
|
|
230
|
+
const pairs = (
|
|
231
|
+
Object.entries(CONSENT_KEYS) as [
|
|
232
|
+
keyof ConsentDefaults,
|
|
233
|
+
string,
|
|
234
|
+
][]
|
|
235
|
+
)
|
|
236
|
+
.filter(([key]) => given[key] !== undefined)
|
|
237
|
+
.map(
|
|
238
|
+
([key, name]) => `${literal(name)}:${literal(given[key])}`
|
|
239
|
+
);
|
|
240
|
+
return `gtag('consent','default',{${pairs.join(",")}});`;
|
|
241
|
+
})
|
|
242
|
+
.join("");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Google's tags: one inline block and the loader in the head, Tag Manager's
|
|
247
|
+
* `<noscript>` fallback in the body.
|
|
248
|
+
*
|
|
249
|
+
* One block rather than several because `dataLayer` is a queue — consent, the
|
|
250
|
+
* timestamp, every `config` and Tag Manager's own bootstrap can all be pushed
|
|
251
|
+
* before `gtag.js` arrives, and are replayed when it does. That removes the
|
|
252
|
+
* ordering problem rather than documenting it: there is no second script that
|
|
253
|
+
* could run first.
|
|
254
|
+
*
|
|
255
|
+
* The `<noscript>` iframe is Google's documented second half of a Tag Manager
|
|
256
|
+
* install, and belongs immediately after `<body>` — in the head it is ignored.
|
|
257
|
+
* It serves visitors with JavaScript off, who will not run the tags it backs
|
|
258
|
+
* up, so it records a bare pageview and nothing else. Small, and free now that
|
|
259
|
+
* there is a channel for it.
|
|
260
|
+
*/
|
|
261
|
+
export function googleScripts(
|
|
262
|
+
google: GoogleSettings | undefined,
|
|
263
|
+
at: string
|
|
264
|
+
): AnalyticsTags {
|
|
265
|
+
const nothing: AnalyticsTags = { head: [], body: [] };
|
|
266
|
+
if (google === undefined) return nothing;
|
|
267
|
+
|
|
268
|
+
const tags = google.tagIds ?? [];
|
|
269
|
+
const containers = google.containerIds ?? [];
|
|
270
|
+
if (tags.length === 0 && containers.length === 0) return nothing;
|
|
271
|
+
|
|
272
|
+
// An id in the wrong field is the silent failure here: it is configured,
|
|
273
|
+
// it is emitted, and it reports nowhere — which reads as a quiet week.
|
|
274
|
+
for (const id of tags) {
|
|
275
|
+
if (id.startsWith("UA-")) {
|
|
276
|
+
warn(
|
|
277
|
+
at,
|
|
278
|
+
`"${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`
|
|
279
|
+
);
|
|
280
|
+
} else if (id.startsWith("GTM-")) {
|
|
281
|
+
warn(
|
|
282
|
+
at,
|
|
283
|
+
`"${id}" is a Tag Manager container, not something gtag can configure — it belongs in containerIds.`
|
|
284
|
+
);
|
|
285
|
+
} else if (!GTAG_PREFIXES.some((prefix) => id.startsWith(prefix))) {
|
|
286
|
+
warn(
|
|
287
|
+
at,
|
|
288
|
+
`"${id}" does not look like anything gtag configures: those start with ${GTAG_PREFIXES.join(", ")}.`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const id of containers) {
|
|
293
|
+
if (!id.startsWith("GTM-")) {
|
|
294
|
+
warn(
|
|
295
|
+
at,
|
|
296
|
+
`"${id}" is configured as a Tag Manager container but does not look like one — those start with "GTM-".`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const consent = google.consent ?? DENIED_BY_DEFAULT;
|
|
302
|
+
const inline = [
|
|
303
|
+
"window.dataLayer=window.dataLayer||[];",
|
|
304
|
+
"function gtag(){dataLayer.push(arguments)}",
|
|
305
|
+
// Ahead of everything, which is the entire point of emitting this here.
|
|
306
|
+
consentCalls(consent),
|
|
307
|
+
// And the way back out of it, covering exactly what was just denied.
|
|
308
|
+
consentUpdater(consent),
|
|
309
|
+
tags.length > 0 ? "gtag('js',new Date());" : "",
|
|
310
|
+
...tags.map((id) => `gtag('config',${literal(id)});`),
|
|
311
|
+
// Tag Manager's own loader, once per container. It appends its script
|
|
312
|
+
// itself, so it runs after the consent calls already queued above.
|
|
313
|
+
...containers.map(
|
|
314
|
+
(id) =>
|
|
315
|
+
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f)})(window,document,'script','dataLayer',${literal(id)});`
|
|
316
|
+
),
|
|
317
|
+
].join("");
|
|
318
|
+
|
|
319
|
+
// The library is fetched once, for the first id, and every id gets its own
|
|
320
|
+
// `config` above. That is Google's documented arrangement rather than a
|
|
321
|
+
// shortcut: *"A single Google tag can have multiple tag IDs"*, and their
|
|
322
|
+
// own example loads `gtag/js?id=G-XXXXXX` once and then configures
|
|
323
|
+
// `GT-XXXXXX` and `DC-ZZZZZZ` against it. The `?id=` only bootstraps the
|
|
324
|
+
// library; the `config` calls are what register a destination.
|
|
325
|
+
//
|
|
326
|
+
// Loading it per id would fetch the same script several times and re-run
|
|
327
|
+
// its bootstrap — more bytes for nothing, and a second copy of a global.
|
|
328
|
+
//
|
|
329
|
+
// <https://developers.google.com/tag-platform/gtagjs/configure>
|
|
330
|
+
const first = tags[0];
|
|
331
|
+
return {
|
|
332
|
+
head: [
|
|
333
|
+
{ kind: "inline", content: inline },
|
|
334
|
+
...(first === undefined
|
|
335
|
+
? []
|
|
336
|
+
: [
|
|
337
|
+
{
|
|
338
|
+
kind: "external" as const,
|
|
339
|
+
src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(first)}`,
|
|
340
|
+
attributes: {},
|
|
341
|
+
},
|
|
342
|
+
]),
|
|
343
|
+
],
|
|
344
|
+
// One per container, and only for Tag Manager: GA4 has no such
|
|
345
|
+
// fallback, because `gtag.js` is the only way it collects anything.
|
|
346
|
+
body: containers.map((id) => ({
|
|
347
|
+
kind: "noscriptFrame" as const,
|
|
348
|
+
src: `https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(id)}`,
|
|
349
|
+
})),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { HttpsUrl } from "../url.ts";
|
|
2
|
+
import { type GoogleSettings, googleScripts } from "./google.ts";
|
|
3
|
+
import type { AnalyticsTag, AnalyticsTags } from "./tags.ts";
|
|
4
|
+
import {
|
|
5
|
+
checkUmamiDomains,
|
|
6
|
+
type UmamiSettings,
|
|
7
|
+
umamiScripts,
|
|
8
|
+
} from "./umami.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Analytics, as head tags.
|
|
12
|
+
*
|
|
13
|
+
* Here rather than in each consuming repo because it is the same decisions
|
|
14
|
+
* every time — which host serves the script, which setting spells which
|
|
15
|
+
* attribute, and in what order the consent state has to reach the queue — and a
|
|
16
|
+
* decision made in four repos is a decision made differently in four repos.
|
|
17
|
+
*
|
|
18
|
+
* The precedent is `SiteVerification`, which is equally vendor-specific: lib
|
|
19
|
+
* already knows the string `google-site-verification` and emits that tag. What
|
|
20
|
+
* lib refuses is a *passthrough* — a slot a consumer pours arbitrary tags
|
|
21
|
+
* through — not knowledge of a named vendor it can build something from. A
|
|
22
|
+
* project states ids; lib decides everything that follows from them.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
CONSENT_UPDATE_GLOBAL,
|
|
27
|
+
type ConsentDefaults,
|
|
28
|
+
type ConsentState,
|
|
29
|
+
type GoogleSettings,
|
|
30
|
+
} from "./google.ts";
|
|
31
|
+
export type { AnalyticsTag, AnalyticsTags } from "./tags.ts";
|
|
32
|
+
export type {
|
|
33
|
+
UmamiReplay,
|
|
34
|
+
UmamiSettings,
|
|
35
|
+
UmamiTracker,
|
|
36
|
+
} from "./umami.ts";
|
|
37
|
+
|
|
38
|
+
/** What a project may switch on. A vendor is a key here. */
|
|
39
|
+
export interface AnalyticsSettings {
|
|
40
|
+
readonly umami?: UmamiSettings;
|
|
41
|
+
readonly google?: GoogleSettings;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every analytics tag this deployment loads, split by where it goes and ordered
|
|
46
|
+
* within each.
|
|
47
|
+
*
|
|
48
|
+
* Google first in the head — not because it matters to Umami, but because its
|
|
49
|
+
* consent defaults are the one thing here that must precede something else, and
|
|
50
|
+
* putting them first leaves no room for the question. Within Google the order
|
|
51
|
+
* is fixed by `googleScripts`; across vendors nothing is coupled.
|
|
52
|
+
*
|
|
53
|
+
* `origin` is the site's own URL, which Umami checks its `domains` against.
|
|
54
|
+
*/
|
|
55
|
+
export function analyticsScripts(
|
|
56
|
+
analytics: AnalyticsSettings | undefined,
|
|
57
|
+
origin: HttpsUrl
|
|
58
|
+
): AnalyticsTags {
|
|
59
|
+
if (analytics === undefined) return { head: [], body: [] };
|
|
60
|
+
|
|
61
|
+
checkUmamiDomains(analytics.umami, origin);
|
|
62
|
+
const google = googleScripts(analytics.google, origin);
|
|
63
|
+
return {
|
|
64
|
+
head: [...google.head, ...umamiScripts(analytics.umami)],
|
|
65
|
+
// Umami has no body half — its tracker is a single fetched script, and
|
|
66
|
+
// there is nothing for a visitor without JavaScript to fall back to.
|
|
67
|
+
body: google.body,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The tag a 404 grouped under, so the misses can be read on their own.
|
|
73
|
+
*
|
|
74
|
+
* Umami's `data-tag` replaces rather than adds — it is one string — so a
|
|
75
|
+
* project's own tag does not survive on this page. That is the right way round:
|
|
76
|
+
* on a 404 the fact worth grouping by is that it *is* one.
|
|
77
|
+
*/
|
|
78
|
+
export const NOT_FOUND_TAG = "404";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a 404 loads. Umami only, and deliberately.
|
|
82
|
+
*
|
|
83
|
+
* The point of measuring a 404 is *finding a broken link* — a redirect somebody
|
|
84
|
+
* forgot when a slug moved, a printed URL that no longer resolves. Cloudflare
|
|
85
|
+
* serves this page at the address that was asked for, so Umami records
|
|
86
|
+
* `/old-pricing` rather than `/404`: the report names the redirect to write.
|
|
87
|
+
* Someone typing nonsense is noise you scroll past, and it is obvious which is
|
|
88
|
+
* which by whether one path repeats.
|
|
89
|
+
*
|
|
90
|
+
* **No Google.** Nothing here needs advertising or conversion tracking, and
|
|
91
|
+
* bringing GA would bring its consent gate — which needs a banner, on a page
|
|
92
|
+
* whose whole job is to be passed through in a second. Umami sets no cookies,
|
|
93
|
+
* so it has nothing to ask.
|
|
94
|
+
*
|
|
95
|
+
* No `checkUmamiDomains` either: the pages already ran it, and one mistake
|
|
96
|
+
* deserves one warning.
|
|
97
|
+
*/
|
|
98
|
+
export function notFoundAnalytics(
|
|
99
|
+
analytics: AnalyticsSettings | undefined
|
|
100
|
+
): readonly AnalyticsTag[] {
|
|
101
|
+
return umamiScripts(analytics?.umami, NOT_FOUND_TAG);
|
|
102
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an analytics vendor comes to, as data.
|
|
3
|
+
*
|
|
4
|
+
* A list rather than a set, because **order is part of the answer**. Google's
|
|
5
|
+
* consent defaults have to be queued before any tag that reads them, and a tag
|
|
6
|
+
* that fires first does not error — it simply applies the permissive implicit
|
|
7
|
+
* default and sets cookies nobody agreed to. Returning a sequence makes that
|
|
8
|
+
* ordering something lib decides once, rather than something a layout could
|
|
9
|
+
* shuffle.
|
|
10
|
+
*/
|
|
11
|
+
export type AnalyticsTag =
|
|
12
|
+
| {
|
|
13
|
+
readonly kind: "external";
|
|
14
|
+
readonly src: string;
|
|
15
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
readonly kind: "inline";
|
|
19
|
+
readonly content: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A hidden iframe inside `<noscript>` — Tag Manager's fallback, and so far
|
|
23
|
+
* only that.
|
|
24
|
+
*
|
|
25
|
+
* Structured rather than a string of HTML, for the reason `externalScript`
|
|
26
|
+
* is: a field holding markup is a passthrough, and lib does not have those.
|
|
27
|
+
* A `src` is all this shape can carry, so it is all anyone can put in it.
|
|
28
|
+
*/
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: "noscriptFrame";
|
|
31
|
+
readonly src: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a tag goes. Two channels, because two tags genuinely differ.
|
|
36
|
+
*
|
|
37
|
+
* Most of what lib emits belongs in `<head>`. Tag Manager's `<noscript>`
|
|
38
|
+
* fallback does not — Google's install puts it immediately after `<body>`, and
|
|
39
|
+
* an iframe in the head is ignored. Returning the two separately means a layout
|
|
40
|
+
* cannot put one where the other goes.
|
|
41
|
+
*/
|
|
42
|
+
export interface AnalyticsTags {
|
|
43
|
+
readonly head: readonly AnalyticsTag[];
|
|
44
|
+
readonly body: readonly AnalyticsTag[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A value, as a JavaScript literal that cannot escape the string it sits in.
|
|
49
|
+
*
|
|
50
|
+
* `JSON.stringify` handles quotes and backslashes. The `<` escape handles the
|
|
51
|
+
* one thing it cannot: a `</script` anywhere in the text ends the element,
|
|
52
|
+
* whatever JavaScript makes of it. `<` is a valid escape inside a JS
|
|
53
|
+
* string and invisible to anything reading the value — the same bargain
|
|
54
|
+
* `serializeJsonLd` strikes, for the same reason.
|
|
55
|
+
*/
|
|
56
|
+
export const literal = (value: unknown): string =>
|
|
57
|
+
JSON.stringify(value).replaceAll("<", "\\u003c");
|