@escape-game-over/atlas 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/client-scripts.md +6 -4
- package/package.json +2 -2
- package/src/analytics/index.ts +3 -6
- package/src/astro/ConsentBanner.astro +3 -2
- package/src/astro/ConsentElement.astro +3 -50
- package/src/astro/client.ts +0 -9
- package/src/astro/consent-element.ts +59 -0
- package/src/astro/consent.ts +49 -119
- package/src/redirects.ts +3 -2
- package/src/site/create.ts +5 -3
package/docs/client-scripts.md
CHANGED
|
@@ -46,8 +46,8 @@ finding them.
|
|
|
46
46
|
analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`) renders the
|
|
47
47
|
site's markup as its children, skips itself — script included — when the
|
|
48
48
|
analytics need no permission, and remembers, expires and applies the answer. The
|
|
49
|
-
markup
|
|
50
|
-
|
|
49
|
+
markup marks its buttons `data-consent="grant"` and `data-consent="deny"`,
|
|
50
|
+
and the control that brings it back `data-consent-reopen hidden`.
|
|
51
51
|
|
|
52
52
|
**Browser code has one import path: `@escape-game-over/atlas/client`**, which
|
|
53
53
|
re-exports every module above. None of it touches the DOM at import, so an
|
|
@@ -550,9 +550,11 @@ right properties is a faithful stand-in.
|
|
|
550
550
|
that `ref` throws naming the root and the selector.
|
|
551
551
|
- `tests/element.test.ts` stubs `customElements` to upgrade on `define`, as a
|
|
552
552
|
browser does, and pins what `connect` is handed.
|
|
553
|
+
- `tests/consent.test.ts` fakes `localStorage`, with a switch that makes it
|
|
554
|
+
throw, and the Google hook, and pins the six-month expiry to the day.
|
|
553
555
|
|
|
554
|
-
`carousel`
|
|
555
|
-
and teardown. They need a real DOM and
|
|
556
|
+
`carousel` has none, nor does the consent banner's element, and `element` has
|
|
557
|
+
no lifecycle test — moves and teardown. They need a real DOM and
|
|
556
558
|
this package carries no environment for one; adding `happy-dom` as a dev
|
|
557
559
|
dependency and setting `environment` in `vitest.config.ts` is what that would
|
|
558
560
|
take.
|
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.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@biomejs/biome": "2.5.14",
|
|
55
55
|
"@types/node": "26.6.2",
|
|
56
56
|
"@vitest/coverage-istanbul": "5.0.1",
|
|
57
|
-
"astro": "7.3.
|
|
57
|
+
"astro": "7.3.4",
|
|
58
58
|
"typescript": "6.0.3",
|
|
59
59
|
"vitest": "5.0.1"
|
|
60
60
|
}
|
package/src/analytics/index.ts
CHANGED
|
@@ -72,12 +72,9 @@ export function analyticsScripts(
|
|
|
72
72
|
/**
|
|
73
73
|
* Whether this deployment loads anything a visitor has to be asked about.
|
|
74
74
|
*
|
|
75
|
-
* The
|
|
76
|
-
* the
|
|
77
|
-
*
|
|
78
|
-
* banner's script has to be downloaded, parsed and run before it can report
|
|
79
|
-
* that there was nothing to consent to — three requests to learn that none of
|
|
80
|
-
* them were needed. Asked here, a project leaves the banner out of the HTML.
|
|
75
|
+
* The one place that question is answered, and at build time: a project leaves
|
|
76
|
+
* the banner — script and all — out of the HTML, rather than shipping a script
|
|
77
|
+
* to find out in the browser that there was nothing to ask.
|
|
81
78
|
*
|
|
82
79
|
* Vendor by vendor rather than `analytics.google !== undefined`, because that
|
|
83
80
|
* is not the question. A `google` block carrying no ids emits no tag and sets
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* The consent banner's behaviour; the site supplies its markup and copy as the
|
|
4
|
-
* children, with `consent
|
|
4
|
+
* children, with `data-consent="grant"` and `data-consent="deny"` on its
|
|
5
|
+
* buttons.
|
|
5
6
|
*
|
|
6
7
|
* Renders nothing — and ships no script — when this site's analytics need no
|
|
7
8
|
* permission. Otherwise the banner starts hidden and shows only to a visitor
|
|
8
|
-
* with no answer on record, or when `consent
|
|
9
|
+
* with no answer on record, or when a `data-consent-reopen` control is pressed.
|
|
9
10
|
*/
|
|
10
11
|
import type { HTMLAttributes } from "astro/types";
|
|
11
12
|
import { type AnalyticsSettings, consentRequired } from "../analytics/index.ts";
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
/** Internal to `ConsentBanner.astro`, so its script ships only where it renders. */
|
|
3
3
|
import type { HTMLAttributes } from "astro/types";
|
|
4
4
|
import AtlasElement from "./AtlasElement.astro";
|
|
5
|
-
import { consentBanner } from "./consent.ts";
|
|
5
|
+
import { consentBanner } from "./consent-element.ts";
|
|
6
6
|
|
|
7
7
|
type Props = HTMLAttributes<"div">;
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
<script src="./consent-element.ts" />
|
|
11
|
+
|
|
10
12
|
<AtlasElement
|
|
11
13
|
of={consentBanner}
|
|
12
14
|
{...Astro.props}
|
|
@@ -16,52 +18,3 @@ type Props = HTMLAttributes<"div">;
|
|
|
16
18
|
>
|
|
17
19
|
<slot />
|
|
18
20
|
</AtlasElement>
|
|
19
|
-
|
|
20
|
-
<script>
|
|
21
|
-
import {
|
|
22
|
-
applyConsent,
|
|
23
|
-
CONSENT_ANSWER,
|
|
24
|
-
CONSENT_REOPEN,
|
|
25
|
-
consentApplies,
|
|
26
|
-
consentStore,
|
|
27
|
-
} from "./consent.ts";
|
|
28
|
-
import { element } from "./element.ts";
|
|
29
|
-
import { refs } from "./ref.ts";
|
|
30
|
-
|
|
31
|
-
element("atlas-consent", ({ root, signal }) => {
|
|
32
|
-
// The head script left no consent hook: nothing here sets a cookie.
|
|
33
|
-
if (!consentApplies()) return;
|
|
34
|
-
|
|
35
|
-
const store = consentStore();
|
|
36
|
-
const show = (): void => {
|
|
37
|
-
root.hidden = false;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
for (const button of refs(root, `[${CONSENT_ANSWER}]`)) {
|
|
41
|
-
const choice = button.getAttribute(CONSENT_ANSWER);
|
|
42
|
-
if (choice !== "granted" && choice !== "denied") {
|
|
43
|
-
throw new Error(`${CONSENT_ANSWER} is "${choice}"`);
|
|
44
|
-
}
|
|
45
|
-
button.addEventListener(
|
|
46
|
-
"click",
|
|
47
|
-
() => {
|
|
48
|
-
store.record(choice);
|
|
49
|
-
root.hidden = true;
|
|
50
|
-
},
|
|
51
|
-
{ signal }
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Outside the banner — withdrawing must be as easy as granting.
|
|
56
|
-
for (const button of refs(document, `[${CONSENT_REOPEN}]`)) {
|
|
57
|
-
button.hidden = false;
|
|
58
|
-
button.addEventListener("click", show, { signal });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Applied, not re-recorded: recording stamps today's date, and an answer
|
|
62
|
-
// renewed on every page view never expires.
|
|
63
|
-
const stored = store.read();
|
|
64
|
-
if (stored === undefined) show();
|
|
65
|
-
else applyConsent(stored.choice);
|
|
66
|
-
});
|
|
67
|
-
</script>
|
package/src/astro/client.ts
CHANGED
|
@@ -11,15 +11,6 @@
|
|
|
11
11
|
|
|
12
12
|
export * from "./background-video.ts";
|
|
13
13
|
export * from "./carousel.ts";
|
|
14
|
-
export {
|
|
15
|
-
applyConsent,
|
|
16
|
-
type ConsentChoice,
|
|
17
|
-
type ConsentRecord,
|
|
18
|
-
type ConsentStoreOptions,
|
|
19
|
-
consent,
|
|
20
|
-
consentApplies,
|
|
21
|
-
consentStore,
|
|
22
|
-
} from "./consent.ts";
|
|
23
14
|
export {
|
|
24
15
|
type Connect,
|
|
25
16
|
type ElementContext,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyConsent,
|
|
3
|
+
type ConsentChoice,
|
|
4
|
+
readConsent,
|
|
5
|
+
recordConsent,
|
|
6
|
+
} from "./consent.ts";
|
|
7
|
+
import { element } from "./element.ts";
|
|
8
|
+
import { data, refs } from "./ref.ts";
|
|
9
|
+
|
|
10
|
+
/** What each button does, in the words Consent Mode records. */
|
|
11
|
+
const ACTIONS: Readonly<Record<string, ConsentChoice>> = {
|
|
12
|
+
grant: "granted",
|
|
13
|
+
deny: "denied",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The banner's behaviour. Its own module, and not re-exported from `client.ts`,
|
|
18
|
+
* so the script ships only where `<ConsentBanner>` renders.
|
|
19
|
+
*
|
|
20
|
+
* A site's markup marks the two buttons inside the banner `data-consent="grant"`
|
|
21
|
+
* and `data-consent="deny"`, and the control that brings it back — usually in
|
|
22
|
+
* the footer — `data-consent-reopen hidden`: it stays hidden until there is an
|
|
23
|
+
* answer to withdraw.
|
|
24
|
+
*/
|
|
25
|
+
export const consentBanner = element("atlas-consent", ({ root, signal }) => {
|
|
26
|
+
const show = (): void => {
|
|
27
|
+
root.hidden = false;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (const button of refs(root, "[data-consent]")) {
|
|
31
|
+
const action = data(button, "consent");
|
|
32
|
+
const choice = Object.hasOwn(ACTIONS, action)
|
|
33
|
+
? ACTIONS[action]
|
|
34
|
+
: undefined;
|
|
35
|
+
if (choice === undefined) {
|
|
36
|
+
throw new Error(`data-consent is "${action}", not grant or deny`);
|
|
37
|
+
}
|
|
38
|
+
button.addEventListener(
|
|
39
|
+
"click",
|
|
40
|
+
() => {
|
|
41
|
+
recordConsent(choice);
|
|
42
|
+
root.hidden = true;
|
|
43
|
+
},
|
|
44
|
+
{ signal }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Outside the banner — withdrawing must be as easy as granting.
|
|
49
|
+
for (const button of refs(document, "[data-consent-reopen]")) {
|
|
50
|
+
button.hidden = false;
|
|
51
|
+
button.addEventListener("click", show, { signal });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Applied, not re-recorded: recording stamps today's date, and an answer
|
|
55
|
+
// renewed on every page view never expires.
|
|
56
|
+
const stored = readConsent();
|
|
57
|
+
if (stored === undefined) show();
|
|
58
|
+
else applyConsent(stored.choice);
|
|
59
|
+
});
|
package/src/astro/consent.ts
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
1
|
import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
|
|
2
|
-
import type { ElementTag } from "./element.ts";
|
|
3
|
-
|
|
4
|
-
/** Internal to `ConsentBanner.astro`: the element, and what its script finds. */
|
|
5
|
-
export const consentBanner: ElementTag<"atlas-consent"> = {
|
|
6
|
-
tag: "atlas-consent",
|
|
7
|
-
};
|
|
8
|
-
export const CONSENT_ANSWER = "data-atlas-consent-answer";
|
|
9
|
-
export const CONSENT_REOPEN = "data-atlas-consent-reopen";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* What a site's consent markup spreads: the two answer buttons inside
|
|
13
|
-
* `<ConsentBanner>`, and the control that brings it back — which belongs on
|
|
14
|
-
* every page, usually the footer, and stays hidden until there is something to
|
|
15
|
-
* withdraw.
|
|
16
|
-
*/
|
|
17
|
-
export const consent = {
|
|
18
|
-
accept: { [CONSENT_ANSWER]: "granted" },
|
|
19
|
-
decline: { [CONSENT_ANSWER]: "denied" },
|
|
20
|
-
reopen: { [CONSENT_REOPEN]: "", hidden: "" },
|
|
21
|
-
};
|
|
22
2
|
|
|
23
3
|
/**
|
|
24
4
|
* The browser half of consent: remembering an answer, expiring it, and handing
|
|
@@ -57,104 +37,63 @@ export interface ConsentRecord {
|
|
|
57
37
|
* is no way to expire it, and no way to answer "when did this visitor agree?",
|
|
58
38
|
* which is a question only ever asked when somebody is already unhappy.
|
|
59
39
|
*/
|
|
60
|
-
const
|
|
40
|
+
const MONTHS = 6;
|
|
61
41
|
|
|
62
42
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* `localStorage` is already scoped to an origin, so this does not need to
|
|
66
|
-
* identify the site — what it needs is to not collide with something else on
|
|
67
|
-
* the page, and a bare `"consent"` is exactly the key a third-party consent
|
|
68
|
-
* tool or chat widget would reach for. The prefix matches
|
|
69
|
-
* `CONSENT_UPDATE_GLOBAL`, so one concept has one name on both sides.
|
|
70
|
-
*
|
|
71
|
-
* Overridable for the case the default cannot cover: two deployments sharing
|
|
72
|
-
* one origin — `example.com/rome` and `example.com/bucharest` — where one key
|
|
73
|
-
* would mean one answer for both. See `consentStore`.
|
|
43
|
+
* The `localStorage` key the answer is kept under. Prefixed to keep clear of the
|
|
44
|
+
* bare `"consent"` a third-party widget would reach for.
|
|
74
45
|
*/
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
export interface ConsentStoreOptions {
|
|
78
|
-
/** Overrides `DEFAULT_KEY`. See it for the one case that needs this. */
|
|
79
|
-
readonly key?: string;
|
|
80
|
-
/** Overrides the six-month window. See `DEFAULT_MONTHS`. */
|
|
81
|
-
readonly months?: number;
|
|
82
|
-
}
|
|
46
|
+
const KEY = "atlas-consent";
|
|
83
47
|
|
|
84
48
|
/**
|
|
85
|
-
*
|
|
49
|
+
* The answer this visitor gave, if it still counts.
|
|
86
50
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* faithfully recording each answer. Bound once, they cannot disagree.
|
|
51
|
+
* `undefined` for never asked, for an expired answer, and for anything
|
|
52
|
+
* unparseable — all three mean the same thing to a banner, and all three should
|
|
53
|
+
* ask rather than assume.
|
|
91
54
|
*/
|
|
92
|
-
export function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
|
|
121
|
-
if (parsed.choice !== "granted" && parsed.choice !== "denied") {
|
|
122
|
-
return undefined;
|
|
123
|
-
}
|
|
124
|
-
if (typeof parsed.at !== "string") return undefined;
|
|
125
|
-
|
|
126
|
-
const expiry = new Date(parsed.at);
|
|
127
|
-
if (Number.isNaN(expiry.getTime())) return undefined;
|
|
128
|
-
expiry.setMonth(expiry.getMonth() + months);
|
|
129
|
-
if (expiry < new Date()) return undefined;
|
|
130
|
-
|
|
131
|
-
return { choice: parsed.choice, at: parsed.at };
|
|
132
|
-
} catch {
|
|
133
|
-
return undefined;
|
|
134
|
-
}
|
|
135
|
-
},
|
|
55
|
+
export function readConsent(): ConsentRecord | undefined {
|
|
56
|
+
let raw: string | null = null;
|
|
57
|
+
try {
|
|
58
|
+
raw = localStorage.getItem(KEY);
|
|
59
|
+
} catch {
|
|
60
|
+
// Private browsing, or storage disabled. Nothing was remembered, so
|
|
61
|
+
// nothing is assumed.
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (raw === null) return undefined;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
|
|
68
|
+
if (parsed.choice !== "granted" && parsed.choice !== "denied") {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
if (typeof parsed.at !== "string") return undefined;
|
|
72
|
+
|
|
73
|
+
const expiry = new Date(parsed.at);
|
|
74
|
+
if (Number.isNaN(expiry.getTime())) return undefined;
|
|
75
|
+
expiry.setMonth(expiry.getMonth() + MONTHS);
|
|
76
|
+
if (expiry < new Date()) return undefined;
|
|
77
|
+
|
|
78
|
+
return { choice: parsed.choice, at: parsed.at };
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
136
83
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
localStorage.setItem(key, JSON.stringify(record));
|
|
151
|
-
} catch {
|
|
152
|
-
// Unable to remember it, which is a worse experience and not a
|
|
153
|
-
// wrong one — the choice still applies to this page.
|
|
154
|
-
}
|
|
155
|
-
applyConsent(choice);
|
|
156
|
-
},
|
|
157
|
-
};
|
|
84
|
+
/**
|
|
85
|
+
* Remembers an answer, dated now, and tells Google about it — one call, so the
|
|
86
|
+
* two cannot come apart.
|
|
87
|
+
*/
|
|
88
|
+
export function recordConsent(choice: ConsentChoice): void {
|
|
89
|
+
const record: ConsentRecord = { choice, at: new Date().toISOString() };
|
|
90
|
+
try {
|
|
91
|
+
localStorage.setItem(KEY, JSON.stringify(record));
|
|
92
|
+
} catch {
|
|
93
|
+
// Unable to remember it, which is a worse experience and not a wrong
|
|
94
|
+
// one — the choice still applies to this page.
|
|
95
|
+
}
|
|
96
|
+
applyConsent(choice);
|
|
158
97
|
}
|
|
159
98
|
|
|
160
99
|
/**
|
|
@@ -174,12 +113,3 @@ export function applyConsent(choice: ConsentChoice): void {
|
|
|
174
113
|
)[CONSENT_UPDATE_GLOBAL];
|
|
175
114
|
update?.(choice);
|
|
176
115
|
}
|
|
177
|
-
|
|
178
|
-
/** Whether this deployment has a Google tag to consent to at all. */
|
|
179
|
-
export function consentApplies(): boolean {
|
|
180
|
-
return (
|
|
181
|
-
(window as unknown as Record<string, unknown>)[
|
|
182
|
-
CONSENT_UPDATE_GLOBAL
|
|
183
|
-
] !== undefined
|
|
184
|
-
);
|
|
185
|
-
}
|
package/src/redirects.ts
CHANGED
|
@@ -101,11 +101,12 @@ export type ValidateRedirectTargets<Rules> = {
|
|
|
101
101
|
* against what this project builds and its URL is derived — a redirect cannot
|
|
102
102
|
* outlive the page it points at, or miss a slug that was retranslated. The
|
|
103
103
|
* locale is optional and defaults to the site's own, since an old URL usually
|
|
104
|
-
* predates translation.
|
|
104
|
+
* predates translation. `page` names a page of a paginated list, and is refused
|
|
105
|
+
* the same way `pathFor` refuses it when the list has no such page.
|
|
105
106
|
*/
|
|
106
107
|
export type RedirectTarget<Id extends string, L extends string> =
|
|
107
108
|
| ExternalUrl
|
|
108
|
-
| { readonly route: Id; readonly locale?: L }
|
|
109
|
+
| { readonly route: Id; readonly locale?: L; readonly page?: number }
|
|
109
110
|
/**
|
|
110
111
|
* A file served verbatim from `public/` — a PDF, a spreadsheet.
|
|
111
112
|
*
|
package/src/site/create.ts
CHANGED
|
@@ -577,12 +577,14 @@ export function createSite<
|
|
|
577
577
|
from: rule.from,
|
|
578
578
|
// Three kinds of target, told apart by shape: a string is external
|
|
579
579
|
// and passes through, `file` is served verbatim from `public/`, and
|
|
580
|
-
// a route is resolved to whatever URL it has in the locale
|
|
581
|
-
// for.
|
|
580
|
+
// a route is resolved to whatever URL it has in the locale and on
|
|
581
|
+
// the page asked for.
|
|
582
582
|
to: ((): string => {
|
|
583
583
|
if (typeof rule.to === "string") return rule.to;
|
|
584
584
|
if ("file" in rule.to) return rule.to.file;
|
|
585
|
-
return pathFor(rule.to.route, rule.to.locale ?? defaultLocale
|
|
585
|
+
return pathFor(rule.to.route, rule.to.locale ?? defaultLocale, {
|
|
586
|
+
page: rule.to.page,
|
|
587
|
+
});
|
|
586
588
|
})(),
|
|
587
589
|
status: statusFor(rule.kind),
|
|
588
590
|
}));
|