@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.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. 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");