@escape-game-over/atlas 0.1.46 → 0.1.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -48,7 +48,8 @@
48
48
  "peerDependencies": {
49
49
  "@types/node": ">=22",
50
50
  "astro": ">=7",
51
- "typescript": ">=5"
51
+ "typescript": ">=5",
52
+ "vite": ">=8"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@biomejs/biome": "2.5.14",
@@ -0,0 +1,123 @@
1
+ // Google's inline head block, emitted by `googleScripts` in `google.ts`.
2
+ //
3
+ // A classic script, not a module: `google.ts` wraps this source in a block that
4
+ // declares `config`, and `MetaTags` strips the types and minifies the result.
5
+ // So no imports or exports — the type below is referenced, not imported.
6
+
7
+ declare const config: import("./google.ts").GoogleTagConfig;
8
+
9
+ // The banner's way in, under the name `astro/consent.ts` calls: spelled once,
10
+ // in `CONSENT_UPDATE_GLOBAL`, so a rename there fails to compile here.
11
+ type ConsentGlobal = {
12
+ [Name in typeof import("./google.ts").CONSENT_UPDATE_GLOBAL]?: (
13
+ choices: Readonly<Record<string, string>>
14
+ ) => void;
15
+ };
16
+
17
+ // biome-ignore lint/correctness/noUnusedVariables: merges into the global `Window`.
18
+ interface Window extends ConsentGlobal {
19
+ dataLayer: unknown[];
20
+ gtag: (...args: unknown[]) => void;
21
+ }
22
+
23
+ {
24
+ type Consent = NonNullable<typeof config.consent>;
25
+ type Choices = Readonly<Record<string, string>>;
26
+
27
+ // Global, and pushing `arguments` itself: `gtag.js` tells a command from a
28
+ // data object by it, and a site's own code calls `gtag` as Google documents.
29
+ const defineGtag = () => {
30
+ window.dataLayer = window.dataLayer || [];
31
+ window.gtag = function gtag() {
32
+ // biome-ignore lint/complexity/noArguments: the queue wants the arguments object, not an array.
33
+ window.dataLayer.push(arguments);
34
+ };
35
+ };
36
+
37
+ // The way back out of the defaults: each signal from the answer for its
38
+ // category.
39
+ const updateConsent = (signals: Consent["signals"], choices: Choices) => {
40
+ const moved: Record<string, string | undefined> = {};
41
+ for (const [category, names] of Object.entries(signals)) {
42
+ for (const name of names) moved[name] = choices[category];
43
+ }
44
+ window.gtag("consent", "update", moved);
45
+ };
46
+
47
+ // A returning visitor's answer, or nothing. Anything unreadable, expired,
48
+ // or missing a category — an answer given before that category was asked
49
+ // — leaves the defaults standing, and the banner asks.
50
+ const storedChoices = ({ signals, key, months }: Consent) => {
51
+ try {
52
+ const stored = JSON.parse(localStorage.getItem(key) ?? "null");
53
+ const expires = new Date(stored.at);
54
+ expires.setMonth(expires.getMonth() + months);
55
+ const categories = Object.keys(signals);
56
+ const answered = categories.every(
57
+ (category) =>
58
+ stored[category] === "granted" ||
59
+ stored[category] === "denied"
60
+ );
61
+ // Negated rather than `<=`: an unreadable date is `NaN`, which
62
+ // compares false both ways and must count as expired.
63
+ if (!(answered && expires > new Date())) return undefined;
64
+ return Object.fromEntries(
65
+ categories.map((category) => [category, stored[category]])
66
+ );
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ };
71
+
72
+ // A script from Google's host, fetched without blocking the page.
73
+ const fetchScript = (path: string) => {
74
+ const script = document.createElement("script");
75
+ script.async = true;
76
+ script.src = `${config.origin}${path}`;
77
+ document.head.appendChild(script);
78
+ };
79
+
80
+ // After `load`, so its first run does not compete with the page. The
81
+ // queue is replayed when it arrives.
82
+ const afterLoad = (run: () => void) => {
83
+ if (document.readyState === "complete") run();
84
+ else addEventListener("load", run);
85
+ };
86
+
87
+ defineGtag();
88
+
89
+ // Ahead of everything, which is the entire point of emitting this here.
90
+ for (const given of config.defaults) {
91
+ window.gtag("consent", "default", given);
92
+ }
93
+
94
+ const { consent } = config;
95
+ if (consent !== undefined) {
96
+ window.__consent = (choices) => updateConsent(consent.signals, choices);
97
+ // Still ahead of the first tag.
98
+ const choices = storedChoices(consent);
99
+ if (choices !== undefined) updateConsent(consent.signals, choices);
100
+ }
101
+
102
+ // Before any `config`, which is what reads it.
103
+ if (config.urlPassthrough) {
104
+ window.gtag("set", "url_passthrough", true);
105
+ }
106
+
107
+ // The library once, for the first id, and every id configured against it —
108
+ // Google's documented arrangement for several tag ids.
109
+ const [first] = config.tagIds;
110
+ if (first !== undefined) {
111
+ window.gtag("js", new Date());
112
+ for (const id of config.tagIds) window.gtag("config", id);
113
+ afterLoad(() =>
114
+ fetchScript(`/gtag/js?id=${encodeURIComponent(first)}`)
115
+ );
116
+ }
117
+
118
+ // Tag Manager's own bootstrap, once per container.
119
+ for (const id of config.containerIds) {
120
+ window.dataLayer.push({ "gtm.start": Date.now(), event: "gtm.js" });
121
+ fetchScript(`/gtm.js?id=${encodeURIComponent(id)}`);
122
+ }
123
+ }
@@ -6,6 +6,7 @@ import {
6
6
  CONSENT_MONTHS,
7
7
  type ConsentCategory,
8
8
  } from "./consent-storage.ts";
9
+ import googleTag from "./google-tag.ts?raw";
9
10
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
10
11
 
11
12
  /**
@@ -249,65 +250,64 @@ const UNASKED = SIGNAL_KEYS.filter(
249
250
  );
250
251
 
251
252
  /**
252
- * `window.__consent(choices)`, updating precisely what was defaulted, each
253
- * signal from the answer for its category.
253
+ * What `google-tag.ts` is handed: the settings, already in Google's own names.
254
254
  *
255
- * Only signals a default mentioned. Updating one that was never defaulted is
256
- * legal and pointless — Google reads it as a change from its own implicit
257
- * grant, which was never in force here.
255
+ * Data rather than code, so the script is written once, readable and
256
+ * type-checked, and only these values differ between sites.
258
257
  */
259
- function consentUpdater(defaults: readonly ConsentDefaults[]): string {
260
- const signals = CONSENT_CATEGORIES.flatMap((category) =>
261
- CATEGORY_SIGNALS[category]
262
- .filter((key) => defaults.some((given) => given[key] !== undefined))
263
- .map((key) => `${literal(CONSENT_KEYS[key])}:c.${category}`)
264
- );
258
+ export interface GoogleTagConfig {
259
+ /** One `gtag('consent','default',…)` payload per stated default, in order. */
260
+ readonly defaults: readonly Readonly<Record<string, unknown>>[];
261
+ /**
262
+ * How an answer moves the signals, and where a returning visitor's answer is
263
+ * kept. Absent when no default mentions a signal an answer can move —
264
+ * there is then nothing to update.
265
+ */
266
+ readonly consent?: {
267
+ /** Per category, the signals its answer moves: only those defaulted. */
268
+ readonly signals: Readonly<Record<string, readonly string[]>>;
269
+ readonly key: string;
270
+ readonly months: number;
271
+ };
272
+ readonly urlPassthrough: boolean;
273
+ readonly tagIds: readonly string[];
274
+ readonly containerIds: readonly string[];
275
+ readonly origin: string;
276
+ }
265
277
 
266
- if (signals.length === 0) return "";
267
- return `window.${CONSENT_UPDATE_GLOBAL}=function(c){gtag('consent','update',{${signals.join(",")}})};`;
278
+ /** One default in Google's names, dropping what it does not state. */
279
+ function consentDefault(
280
+ given: ConsentDefaults
281
+ ): Readonly<Record<string, unknown>> {
282
+ return Object.fromEntries(
283
+ (Object.entries(CONSENT_KEYS) as [keyof ConsentDefaults, string][])
284
+ .filter(([key]) => given[key] !== undefined)
285
+ .map(([key, name]) => [name, given[key]])
286
+ );
268
287
  }
269
288
 
270
289
  /**
271
- * A returning visitor's answer, handed to Google before any tag fires.
272
- *
273
- * It has to happen here, in the head, ahead of `gtag('config', …)`. Left to the
274
- * banner's module script it arrives after `gtag.js` has already sent the page
275
- * view on the defaults — so every page a consenting visitor opened was recorded
276
- * as denied: no cookie, no returning user, no session. Measured, not supposed:
277
- * `gcs=G100` on the `page_view`, `G111` only on the hits after it.
290
+ * Per category, the signals its answer moves — only those a default mentioned.
278
291
  *
279
- * The same key, expiry and categories `astro/consent.ts` reads, from the one
280
- * place both take them. Anything unreadable, expired, absent or missing a
281
- * category — an answer given before that category was asked — leaves the
282
- * defaults standing, and the banner will ask.
292
+ * Updating one that was never defaulted is legal and pointless: Google reads it
293
+ * as a change from its own implicit grant, which was never in force here.
283
294
  */
284
- function restoredConsent(): string {
285
- const answered = CONSENT_CATEGORIES.map(
286
- (category) => `(c.${category}==="granted"||c.${category}==="denied")`
287
- ).join("&&");
288
- const choices = CONSENT_CATEGORIES.map(
289
- (category) => `${category}:c.${category}`
290
- ).join(",");
291
- return `try{var c=JSON.parse(localStorage.getItem(${literal(CONSENT_KEY)})),e=new Date(c.at);e.setMonth(e.getMonth()+${CONSENT_MONTHS});if(${answered}&&e>new Date())window.${CONSENT_UPDATE_GLOBAL}({${choices}})}catch(_){}`;
292
- }
293
-
294
- /** One `gtag('consent','default',{…})` per stated default, in order. */
295
- function consentCalls(defaults: readonly ConsentDefaults[]): string {
296
- return defaults
297
- .map((given) => {
298
- const pairs = (
299
- Object.entries(CONSENT_KEYS) as [
300
- keyof ConsentDefaults,
301
- string,
302
- ][]
303
- )
304
- .filter(([key]) => given[key] !== undefined)
305
- .map(
306
- ([key, name]) => `${literal(name)}:${literal(given[key])}`
307
- );
308
- return `gtag('consent','default',{${pairs.join(",")}});`;
309
- })
310
- .join("");
295
+ function movedSignals(
296
+ defaults: readonly ConsentDefaults[]
297
+ ): Readonly<Record<string, readonly string[]>> | undefined {
298
+ const signals = Object.fromEntries(
299
+ CONSENT_CATEGORIES.map((category) => [
300
+ category,
301
+ CATEGORY_SIGNALS[category]
302
+ .filter((key) =>
303
+ defaults.some((given) => given[key] !== undefined)
304
+ )
305
+ .map((key) => CONSENT_KEYS[key]),
306
+ ])
307
+ );
308
+ return Object.values(signals).some((names) => names.length > 0)
309
+ ? signals
310
+ : undefined;
311
311
  }
312
312
 
313
313
  /**
@@ -408,30 +408,27 @@ export function googleScripts(
408
408
  );
409
409
  }
410
410
  }
411
- // The way back out of the defaults, covering what they denied.
412
- const updater = consentUpdater(consent);
413
- const inline = [
414
- "window.dataLayer=window.dataLayer||[];",
415
- "function gtag(){dataLayer.push(arguments)}",
416
- // Ahead of everything, which is the entire point of emitting this here.
417
- consentCalls(consent),
418
- updater,
419
- // Then a remembered answer, still ahead of the first tag.
420
- updater === "" ? "" : restoredConsent(),
421
- // Before any `config`, which is what reads it.
422
- google.urlPassthrough === true
423
- ? "gtag('set','url_passthrough',true);"
424
- : "",
425
- tags.length > 0 ? "gtag('js',new Date());" : "",
426
- ...tags.map((id) => `gtag('config',${literal(id)});`),
427
- loader(tags[0]),
428
- // Tag Manager's own loader, once per container. It appends its script
429
- // itself, so it runs after the consent calls already queued above.
430
- ...containers.map(
431
- (id) =>
432
- `(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='${TAG_ORIGIN}/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f)})(window,document,'script','dataLayer',${literal(id)});`
433
- ),
434
- ].join("");
411
+ const signals = movedSignals(consent);
412
+ const config: GoogleTagConfig = {
413
+ defaults: consent.map(consentDefault),
414
+ ...(signals === undefined
415
+ ? {}
416
+ : {
417
+ consent: {
418
+ signals,
419
+ key: CONSENT_KEY,
420
+ months: CONSENT_MONTHS,
421
+ },
422
+ }),
423
+ urlPassthrough: google.urlPassthrough === true,
424
+ tagIds: tags,
425
+ containerIds: containers,
426
+ origin: TAG_ORIGIN,
427
+ };
428
+ // TypeScript, compiled and minified by `MetaTags`. The outer block keeps
429
+ // `config` out of the page's globals; the inner one holds the script's own
430
+ // `declare const config`, which compiles away and leaves this one.
431
+ const inline = `{const config=${literal(config)};{\n${googleTag}\n}}`;
435
432
 
436
433
  return {
437
434
  head: [
@@ -450,31 +447,3 @@ export function googleScripts(
450
447
  })),
451
448
  };
452
449
  }
453
-
454
- /**
455
- * `gtag.js`, appended once the page has loaded.
456
- *
457
- * Its parse and first run — including a forced reflow of its own — otherwise
458
- * compete with the page for the load window. Nothing is lost by waiting:
459
- * `dataLayer` is a queue, and every call above is replayed when it arrives.
460
- * What waiting costs is a visitor who leaves before `load`, who is not counted.
461
- *
462
- * The library is fetched once, for the first id, and every id gets its own
463
- * `config` above. That is Google's documented arrangement rather than a
464
- * shortcut: *"A single Google tag can have multiple tag IDs"*, and their
465
- * own example loads `gtag/js?id=G-XXXXXX` once and then configures
466
- * `GT-XXXXXX` and `DC-ZZZZZZ` against it. The `?id=` only bootstraps the
467
- * library; the `config` calls are what register a destination.
468
- *
469
- * Loading it per id would fetch the same script several times and re-run
470
- * its bootstrap — more bytes for nothing, and a second copy of a global.
471
- *
472
- * <https://developers.google.com/tag-platform/gtagjs/configure>
473
- */
474
- function loader(first: string | undefined): string {
475
- if (first === undefined) return "";
476
- const src = literal(
477
- `${TAG_ORIGIN}/gtag/js?id=${encodeURIComponent(first)}`
478
- );
479
- return `(function(){function l(){var s=document.createElement('script');s.async=true;s.src=${src};document.head.appendChild(s)}document.readyState==='complete'?l():addEventListener('load',l)})();`;
480
- }
@@ -8,15 +8,25 @@
8
8
  // consumer's name for this package, and depending on it here would make the
9
9
  // library's own internals rely on how a project spells them.
10
10
  import type { MetaTag } from "../meta/index.ts";
11
+ import { inlineScript } from "./inline-script.ts";
11
12
 
12
13
  interface Props {
13
14
  readonly tags: readonly MetaTag[];
14
15
  }
15
16
 
16
17
  const { tags } = Astro.props;
18
+
19
+ // JavaScript only: a typed block is data, JSON-LD above all, and not code.
20
+ const rendered = await Promise.all(
21
+ tags.map(async (tag) =>
22
+ tag.kind === "script" && tag.type === undefined
23
+ ? { ...tag, content: await inlineScript(tag.content) }
24
+ : tag
25
+ )
26
+ );
17
27
  ---
18
28
 
19
- {tags.map((tag) => {
29
+ {rendered.map((tag) => {
20
30
  if (tag.kind === "title") return <title>{tag.text}</title>;
21
31
  if (tag.kind === "link") return <link {...tag.attrs} />;
22
32
  // `set:html` writes the JSON raw. `serializeJsonLd` has already replaced
@@ -10,48 +10,13 @@
10
10
  * Rendered by `Document` after the page, where the layout already exists.
11
11
  * Inline, because a bundled module runs too late: after the first paint.
12
12
  */
13
- ---
14
-
15
- <script is:inline>
16
- {
17
- history.scrollRestoration = "manual";
18
-
19
- const save = () => {
20
- try {
21
- history.replaceState({ ...history.state, scrollY }, "");
22
- } catch {
23
- // Safari throttles replaceState; the last saved position stands.
24
- }
25
- };
13
+ import { inlineScript } from "./inline-script.ts";
14
+ import source from "./scroll-restore.ts?raw";
26
15
 
27
- // Once scrolling settles, not per frame: Safari caps replaceState calls.
28
- let settle = 0;
29
- addEventListener(
30
- "scroll",
31
- () => {
32
- clearTimeout(settle);
33
- settle = setTimeout(save, 300);
34
- },
35
- { passive: true }
36
- );
37
- // No save on `pagehide`. WebKit fires it after moving to the previous entry,
38
- // and Firefox on iOS restores a page at the top and reloads it at once, so
39
- // either way the save would wipe the position about to be restored.
40
-
41
- // `instant`, or a site with `scroll-behavior: smooth` glides down from the
42
- // top — the jump this exists to remove, in slow motion.
43
- const restore = (state) => {
44
- if (typeof state?.scrollY === "number") {
45
- scrollTo({ top: state.scrollY, behavior: "instant" });
46
- }
47
- };
48
-
49
- restore(history.state);
16
+ const code = await inlineScript(source);
17
+ ---
50
18
 
51
- // Filters push entries on the same page; `manual` turned off restoring
52
- // those too. A frame later, so the filtered list has re-rendered first.
53
- addEventListener("popstate", (event) => {
54
- requestAnimationFrame(() => restore(event.state));
55
- });
56
- }
57
- </script>
19
+ <script
20
+ is:inline
21
+ set:html={code}
22
+ />
@@ -0,0 +1,22 @@
1
+ import { minifySync, transformWithOxc } from "vite";
2
+
3
+ /**
4
+ * TypeScript source as the body of a `<script is:inline>`: types stripped, then
5
+ * minified.
6
+ *
7
+ * Astro writes an inline script exactly as written, comments and all. Both
8
+ * steps are needed — the minifier accepts TypeScript and silently keeps its
9
+ * annotations, which a browser then fails to parse.
10
+ */
11
+ export async function inlineScript(source: string): Promise<string> {
12
+ const { code } = await transformWithOxc(source, "inline.ts");
13
+ // A classic script, not a module: its top-level names are globals other
14
+ // scripts call — `gtag` above all — and a module's may be renamed.
15
+ const minified = minifySync("inline.js", code, { module: false });
16
+ if (minified.errors.length > 0) {
17
+ throw new Error(
18
+ `inline script failed to minify: ${minified.errors.map((error) => error.message).join("; ")}`
19
+ );
20
+ }
21
+ return minified.code;
22
+ }
@@ -0,0 +1,44 @@
1
+ // The body of `ScrollRestore.astro`'s inline script, minified into the page by
2
+ // `inlineScript`. No imports or exports: it runs as a classic script, and the
3
+ // block keeps its names out of the page's global scope.
4
+ {
5
+ history.scrollRestoration = "manual";
6
+
7
+ const save = () => {
8
+ try {
9
+ history.replaceState({ ...history.state, scrollY }, "");
10
+ } catch {
11
+ // Safari throttles replaceState; the last saved position stands.
12
+ }
13
+ };
14
+
15
+ // Once scrolling settles, not per frame: Safari caps replaceState calls.
16
+ let settle = 0;
17
+ addEventListener(
18
+ "scroll",
19
+ () => {
20
+ clearTimeout(settle);
21
+ settle = window.setTimeout(save, 300);
22
+ },
23
+ { passive: true }
24
+ );
25
+ // No save on `pagehide`. WebKit fires it after moving to the previous entry,
26
+ // and Firefox on iOS restores a page at the top and reloads it at once, so
27
+ // either way the save would wipe the position about to be restored.
28
+
29
+ // `instant`, or a site with `scroll-behavior: smooth` glides down from the
30
+ // top — the jump this exists to remove, in slow motion.
31
+ const restore = (state: { readonly scrollY?: unknown } | null) => {
32
+ if (typeof state?.scrollY === "number") {
33
+ scrollTo({ top: state.scrollY, behavior: "instant" });
34
+ }
35
+ };
36
+
37
+ restore(history.state);
38
+
39
+ // Filters push entries on the same page; `manual` turned off restoring
40
+ // those too. A frame later, so the filtered list has re-rendered first.
41
+ addEventListener("popstate", (event) => {
42
+ requestAnimationFrame(() => restore(event.state));
43
+ });
44
+ }
@@ -212,6 +212,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
212
212
  "astro:config:setup": ({
213
213
  config: current,
214
214
  updateConfig,
215
+ addWatchFile,
215
216
  logger,
216
217
  }) => {
217
218
  /**
@@ -290,6 +291,16 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
290
291
  `updated config: ${JSON.stringify(config, null, 2)}`
291
292
  );
292
293
  reportOrphans();
294
+
295
+ // `atlas use` swaps the project by relinking `config/project`,
296
+ // and nothing Vite watches changes when it does: the files
297
+ // behind the link are new, but every module already loaded
298
+ // through it stays cached, so a running `astro dev` keeps
299
+ // serving the old project — its messages, its routes, a map
300
+ // from one country with the pins of another. Watching the link
301
+ // itself turns the relink into a restart. A project without
302
+ // overlays has no such link, and the watch is simply idle.
303
+ addWatchFile(new URL("config/project", current.root));
293
304
  },
294
305
 
295
306
  /**
package/src/raw.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Vite's `?raw` suffix — a file's source as a string — for the type check that
2
+ // runs without Vite's client types. `astro/client` declares the same for
3
+ // `src/astro`.
4
+ declare module "*?raw" {
5
+ const source: string;
6
+ export default source;
7
+ }