@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 +3 -2
- package/src/analytics/google-tag.ts +123 -0
- package/src/analytics/google.ts +72 -103
- package/src/astro/MetaTags.astro +11 -1
- package/src/astro/ScrollRestore.astro +8 -43
- package/src/astro/inline-script.ts +22 -0
- package/src/astro/scroll-restore.ts +44 -0
- package/src/astro/site-routes.ts +11 -0
- package/src/raw.d.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|
package/src/analytics/google.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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
|
-
*
|
|
256
|
-
*
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
280
|
-
*
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
}
|
package/src/astro/MetaTags.astro
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -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