@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,285 @@
|
|
|
1
|
+
import { type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
|
|
2
|
+
import { warn } from "../warn.ts";
|
|
3
|
+
import type { AnalyticsTag } from "./tags.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Umami's tracker — the script that counts pageviews.
|
|
7
|
+
*
|
|
8
|
+
* Every attribute its source reads is here except one. `data-before-send`
|
|
9
|
+
* names a *global function* the page must also define, so the setting is only
|
|
10
|
+
* half the thing: a project that states it and forgets the function loses every
|
|
11
|
+
* event silently. That is a runtime coupling lib cannot check and a static
|
|
12
|
+
* config cannot express, so it stays out until something needs it badly enough
|
|
13
|
+
* to design the other half.
|
|
14
|
+
*/
|
|
15
|
+
export interface UmamiTracker {
|
|
16
|
+
/** Where it sits on the host. Defaults to `/script.js`. */
|
|
17
|
+
readonly path?: UrlPath;
|
|
18
|
+
/**
|
|
19
|
+
* Collect Core Web Vitals from real visitors. **Defaults to on here.**
|
|
20
|
+
*
|
|
21
|
+
* Umami's own default is off, and this overrides it — the same trade
|
|
22
|
+
* `max-image-preview:large` makes with Google's, and for the same reason: a
|
|
23
|
+
* conservative vendor default costs you something you would have wanted,
|
|
24
|
+
* and stating the better answer once beats every deployment remembering.
|
|
25
|
+
*
|
|
26
|
+
* What it buys is field data. Search Console reports against real devices,
|
|
27
|
+
* not a lab run, and this is a site made mostly of photographs — the pages
|
|
28
|
+
* that are slow are slow on somebody's phone, which is the only place that
|
|
29
|
+
* shows. The cost is a little more in each pageview payload.
|
|
30
|
+
*
|
|
31
|
+
* `false` turns it off, and writes the word rather than omitting it.
|
|
32
|
+
*/
|
|
33
|
+
readonly performance?: boolean;
|
|
34
|
+
/** Leave `?query` out of recorded URLs. */
|
|
35
|
+
readonly excludeSearch?: boolean;
|
|
36
|
+
/** Leave `#hash` out of recorded URLs. */
|
|
37
|
+
readonly excludeHash?: boolean;
|
|
38
|
+
/** Honour the browser's Do Not Track setting. */
|
|
39
|
+
readonly doNotTrack?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the tracker starts itself at all. Umami's default is **on**.
|
|
42
|
+
*
|
|
43
|
+
* One of the two switches here that defaults on, which is why `false` has
|
|
44
|
+
* to be written rather than implied — the tracker reads
|
|
45
|
+
* `config('auto-track') !== 'false'`. Off means nothing is recorded until
|
|
46
|
+
* something calls Umami's API by hand.
|
|
47
|
+
*/
|
|
48
|
+
readonly autoTrack?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether a pageview is sent automatically. Umami's default is **on**.
|
|
51
|
+
*
|
|
52
|
+
* The other one that defaults on. Off leaves the tracker loaded and
|
|
53
|
+
* listening but silent until a pageview is sent deliberately — for a site
|
|
54
|
+
* that wants to decide what counts as a page.
|
|
55
|
+
*/
|
|
56
|
+
readonly autoPageview?: boolean;
|
|
57
|
+
/** A name to group these events under, for filtering or an A/B split. */
|
|
58
|
+
readonly tag?: string;
|
|
59
|
+
/**
|
|
60
|
+
* What the tracker does with credentials when it posts.
|
|
61
|
+
*
|
|
62
|
+
* A closed vocabulary — it is `RequestCredentials` — so it is spelled out
|
|
63
|
+
* rather than left a string. Umami's default is `omit`, which is right for
|
|
64
|
+
* the `src.<domain>` convention: a subdomain is a different origin, and
|
|
65
|
+
* sending cookies to it would be sending them for nothing.
|
|
66
|
+
*/
|
|
67
|
+
readonly fetchCredentials?: "omit" | "same-origin" | "include";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Umami's session replay — a *second* script, alongside the tracker rather than
|
|
72
|
+
* instead of it. Present means on; omit it and nothing is recorded.
|
|
73
|
+
*
|
|
74
|
+
* A path and nothing else, and this is settled rather than assumed: the
|
|
75
|
+
* recorder's source reads exactly two attributes off its own tag,
|
|
76
|
+
* `data-website-id` and `data-host-url`. Sample rate, mask level, max duration
|
|
77
|
+
* and block selector are real settings, and every one of them is read from the
|
|
78
|
+
* config the *server* returns —
|
|
79
|
+
*
|
|
80
|
+
* ```js
|
|
81
|
+
* if (typeof data.sampleRate === 'number') sampleRate = data.sampleRate;
|
|
82
|
+
* if (typeof data.maskLevel === 'string') maskLevel = data.maskLevel;
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* — so they are set per site in Umami's own dashboard, under Replays &
|
|
86
|
+
* Heatmaps, and an attribute cannot override one. Fields for them here would
|
|
87
|
+
* look like they did something. Checked against `src/recorder/index.js` on
|
|
88
|
+
* `master`, August 2026.
|
|
89
|
+
*/
|
|
90
|
+
export interface UmamiReplay {
|
|
91
|
+
/** Where it sits on the host. Defaults to `/recorder.js`. */
|
|
92
|
+
readonly path?: UrlPath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface UmamiSettings {
|
|
96
|
+
/**
|
|
97
|
+
* This deployment's site id, from its Umami dashboard.
|
|
98
|
+
*
|
|
99
|
+
* Per project: two deployments sharing an id report into one dashboard as a
|
|
100
|
+
* single site, and nobody notices until one venue's traffic appears to
|
|
101
|
+
* double the week another launches.
|
|
102
|
+
*/
|
|
103
|
+
readonly websiteId: string;
|
|
104
|
+
/**
|
|
105
|
+
* Where the scripts are served from — `https://src.example.com`.
|
|
106
|
+
*
|
|
107
|
+
* First-party by convention, because an analytics domain is on every
|
|
108
|
+
* blocklist and a subdomain of the site is not: served from `src.` on a
|
|
109
|
+
* domain the site owns, the numbers do not quietly exclude every reader who
|
|
110
|
+
* blocks trackers.
|
|
111
|
+
*
|
|
112
|
+
* **Required, and deliberately not derived from `url`.** `src.<this site's
|
|
113
|
+
* own host>` is right only where the site sits at the domain root. A fleet
|
|
114
|
+
* that puts each site on a subdomain — `atlanta.example.com`,
|
|
115
|
+
* `lecce.example.it` — serves one `src.example.com` for all of them, so
|
|
116
|
+
* deriving would produce `src.atlanta.example.com`, which does not exist.
|
|
117
|
+
* The script 404s, nothing is recorded, and there is no error anywhere: the
|
|
118
|
+
* dashboard simply stays at zero.
|
|
119
|
+
*
|
|
120
|
+
* Nor can the parent be derived. Stripping a subdomain needs the public
|
|
121
|
+
* suffix list to know that `example.co.uk` is a site and `co.uk` is not,
|
|
122
|
+
* which is a registry lib will not carry. One line per deployment is the
|
|
123
|
+
* cheaper answer, and it is a line that cannot be wrong by accident.
|
|
124
|
+
*/
|
|
125
|
+
readonly host: HttpsUrl;
|
|
126
|
+
/**
|
|
127
|
+
* Every hostname that serves this site.
|
|
128
|
+
*
|
|
129
|
+
* Required, and required for a reason the optional version taught: left
|
|
130
|
+
* out, Umami records from anywhere — so a preview deployment reports into
|
|
131
|
+
* the production dashboard, and so does every reload on localhost. Neither
|
|
132
|
+
* looks wrong; the numbers are simply too high, and nobody audits numbers
|
|
133
|
+
* that flatter.
|
|
134
|
+
*
|
|
135
|
+
* **Every host, not just the canonical one.** A site reachable at the apex
|
|
136
|
+
* and at `www.` needs both unless one only ever redirects — a visitor
|
|
137
|
+
* landing on the host you left out is not counted, and nothing says so.
|
|
138
|
+
*
|
|
139
|
+
* Not derived from `url`, though it nearly could be. That would make the
|
|
140
|
+
* safe-looking default "only the canonical host", which silently drops the
|
|
141
|
+
* `www.` traffic of every site serving both. Stating it is a moment's
|
|
142
|
+
* thought; the alternative is a number that reads low forever.
|
|
143
|
+
*/
|
|
144
|
+
readonly domains: readonly [string, ...string[]];
|
|
145
|
+
/**
|
|
146
|
+
* Where the collected data is *sent*, if not where the script came from.
|
|
147
|
+
*
|
|
148
|
+
* `data-host-url` — the one option beyond the id that both scripts read.
|
|
149
|
+
* Distinct from `host` above: that is where the JavaScript is fetched, this
|
|
150
|
+
* is where it posts. Left out, Umami derives it from the script's own URL,
|
|
151
|
+
* which for the `src.<domain>` convention is already the right answer.
|
|
152
|
+
*/
|
|
153
|
+
readonly collectUrl?: HttpsUrl;
|
|
154
|
+
readonly tracker?: UmamiTracker;
|
|
155
|
+
readonly replay?: UmamiReplay;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Three states, not two: `undefined` leaves the attribute out and takes Umami's
|
|
160
|
+
* own default, while `false` writes `"false"` and overrides it.
|
|
161
|
+
*
|
|
162
|
+
* Omitting is *not* a way to say false, and the tracker's source is explicit
|
|
163
|
+
* about why — the two kinds of switch are read differently:
|
|
164
|
+
*
|
|
165
|
+
* ```js
|
|
166
|
+
* const perf = config('performance') === 'true'; // omit ⇒ off
|
|
167
|
+
* const autoTrack = config('auto-track') !== 'false'; // omit ⇒ ON
|
|
168
|
+
* const autoPageview = config('auto-pageview') !== 'false'; // omit ⇒ ON
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* For the first kind, leaving it out and writing `"false"` agree. For the
|
|
172
|
+
* other two, the literal word is the only thing that turns them off. A helper
|
|
173
|
+
* that collapsed `false` into "leave it out" would silently keep those enabled.
|
|
174
|
+
*
|
|
175
|
+
* Checked against `src/tracker/index.js` on `master`, August 2026.
|
|
176
|
+
*/
|
|
177
|
+
const flag = (on: boolean | undefined): string | undefined =>
|
|
178
|
+
on === undefined ? undefined : String(on);
|
|
179
|
+
|
|
180
|
+
/** Drops the attributes this deployment did not ask for. */
|
|
181
|
+
const stated = (
|
|
182
|
+
attributes: Readonly<Record<string, string | undefined>>
|
|
183
|
+
): Readonly<Record<string, string>> =>
|
|
184
|
+
Object.fromEntries(
|
|
185
|
+
Object.entries(attributes).filter(([, value]) => value !== undefined)
|
|
186
|
+
) as Readonly<Record<string, string>>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Warns when the `domains` list leaves out the host the site is served from.
|
|
190
|
+
*
|
|
191
|
+
* Separate from building the scripts because the pages are not the only thing
|
|
192
|
+
* that builds them — the 404 does too — and a check that ran per caller would
|
|
193
|
+
* say the same thing several times about one mistake.
|
|
194
|
+
*
|
|
195
|
+
* The mistake is worth catching: a list without the real host records nothing
|
|
196
|
+
* at all. No error, no empty dashboard to notice, just a number that stays at
|
|
197
|
+
* zero, and a typo in one hostname is enough.
|
|
198
|
+
*
|
|
199
|
+
* Only the canonical host is checked. Whether `www.` or a second domain also
|
|
200
|
+
* serves is a fact about DNS lib cannot see, so listing extras is never
|
|
201
|
+
* questioned and leaving one out cannot be.
|
|
202
|
+
*/
|
|
203
|
+
export function checkUmamiDomains(
|
|
204
|
+
umami: UmamiSettings | undefined,
|
|
205
|
+
origin: HttpsUrl
|
|
206
|
+
): void {
|
|
207
|
+
if (umami === undefined) return;
|
|
208
|
+
const site = new URL(origin).hostname;
|
|
209
|
+
if (umami.domains.includes(site)) return;
|
|
210
|
+
|
|
211
|
+
warn(
|
|
212
|
+
origin,
|
|
213
|
+
`analytics records only on ${umami.domains.join(", ")}, and this site is served from ${site} — which is not among them, so nothing will be recorded. Add every host that serves the site, including www. if it does.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Umami's scripts: the tracker, and the recorder where one was asked for.
|
|
219
|
+
*
|
|
220
|
+
* `tag` overrides whatever the tracker configured, for a page that wants its
|
|
221
|
+
* traffic grouped separately — the 404 does. Umami's `data-tag` is a single
|
|
222
|
+
* string, so it is a replacement rather than an addition; there is no way to
|
|
223
|
+
* carry both.
|
|
224
|
+
*/
|
|
225
|
+
export function umamiScripts(
|
|
226
|
+
umami: UmamiSettings | undefined,
|
|
227
|
+
tag?: string
|
|
228
|
+
): readonly AnalyticsTag[] {
|
|
229
|
+
if (umami === undefined) return [];
|
|
230
|
+
|
|
231
|
+
// Through `joinUrl`, not concatenation. `HttpsUrl` cannot express "without
|
|
232
|
+
// a trailing slash", so a host written `https://src.example.com/` — which
|
|
233
|
+
// type-checks, and is how anyone who copied it out of a browser bar would
|
|
234
|
+
// write it — would otherwise yield `https://src.example.com//script.js`.
|
|
235
|
+
// That is a different URL: it may 404, and if it does the failure is
|
|
236
|
+
// silent, because nothing on the page reports a script that did not load
|
|
237
|
+
// and the dashboard simply stays at zero.
|
|
238
|
+
const at = (path: UrlPath): string => joinUrl(umami.host, path);
|
|
239
|
+
|
|
240
|
+
// Both scripts read the same two: the id, and where to post. Everything
|
|
241
|
+
// after that is the tracker's alone — the recorder takes nothing else.
|
|
242
|
+
//
|
|
243
|
+
// Left unfiltered here so that each script's attributes are filtered once,
|
|
244
|
+
// where they are assembled, rather than here and then again on the way in.
|
|
245
|
+
const common = {
|
|
246
|
+
"data-website-id": umami.websiteId,
|
|
247
|
+
"data-host-url": umami.collectUrl,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
{
|
|
252
|
+
kind: "external",
|
|
253
|
+
src: at(umami.tracker?.path ?? "/script.js"),
|
|
254
|
+
attributes: stated({
|
|
255
|
+
...common,
|
|
256
|
+
// The one default lib overrides — see `UmamiTracker`. The
|
|
257
|
+
// others are left to Umami: `auto-track` and `auto-pageview`
|
|
258
|
+
// are already on unless the word `false` appears, so stating
|
|
259
|
+
// them would be bytes that change nothing, and whether to
|
|
260
|
+
// honour Do Not Track or drop query strings is a decision about
|
|
261
|
+
// this business rather than about this kind of site.
|
|
262
|
+
"data-performance": flag(umami.tracker?.performance ?? true),
|
|
263
|
+
"data-exclude-search": flag(umami.tracker?.excludeSearch),
|
|
264
|
+
"data-exclude-hash": flag(umami.tracker?.excludeHash),
|
|
265
|
+
"data-do-not-track": flag(umami.tracker?.doNotTrack),
|
|
266
|
+
"data-auto-track": flag(umami.tracker?.autoTrack),
|
|
267
|
+
"data-auto-pageview": flag(umami.tracker?.autoPageview),
|
|
268
|
+
"data-tag": tag ?? umami.tracker?.tag,
|
|
269
|
+
"data-fetch-credentials": umami.tracker?.fetchCredentials,
|
|
270
|
+
// Comma-delimited, which is Umami's format and not a shape a
|
|
271
|
+
// project should have to know: it writes a list.
|
|
272
|
+
"data-domains": umami.domains.join(","),
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
...(umami.replay === undefined
|
|
276
|
+
? []
|
|
277
|
+
: [
|
|
278
|
+
{
|
|
279
|
+
kind: "external" as const,
|
|
280
|
+
src: at(umami.replay.path ?? "/recorder.js"),
|
|
281
|
+
attributes: stated(common),
|
|
282
|
+
},
|
|
283
|
+
]),
|
|
284
|
+
];
|
|
285
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Renders what `metaFor` described, one element per `MetaTag`.
|
|
4
|
+
*
|
|
5
|
+
* The other half of `asMetaTag` in `lib/meta/tag.ts`: that one converts *into*
|
|
6
|
+
* the union, this one out of it. Both enumerate the same kinds, and keeping
|
|
7
|
+
* them in different repositories is what let them drift — the converter had an
|
|
8
|
+
* exhaustiveness guard from the day it was written and this file did not, so a
|
|
9
|
+
* kind added to `MetaTag` would have rendered here as a bare `<meta>`.
|
|
10
|
+
*
|
|
11
|
+
* In `lib/astro/` rather than a project's `src/`, unlike every other component
|
|
12
|
+
* in this template, because it carries no design and no copy: no classes, no
|
|
13
|
+
* translated strings, nothing from `@config`. It is a `kind` → element switch,
|
|
14
|
+
* and the four things it knows are each a decision worth making once —
|
|
15
|
+
* `set:html` for pre-escaped JSON, `is:inline` so the bundler leaves a
|
|
16
|
+
* third-party URL alone, `defer` so measuring a page does not cost what it
|
|
17
|
+
* measures, and the zero-sized frame Tag Manager wants. Four chances for a
|
|
18
|
+
* repository to get one of them wrong.
|
|
19
|
+
*
|
|
20
|
+
* ```astro
|
|
21
|
+
* import MetaTags from "@escape-game-over/atlas/astro/meta-tags";
|
|
22
|
+
*
|
|
23
|
+
* <head><MetaTags tags={meta.tags} /></head>
|
|
24
|
+
* <body><MetaTags tags={meta.bodyTags} /> …
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Both lists, and in those places: `bodyTags` is usually empty and holds the
|
|
28
|
+
* one tag the head would ignore.
|
|
29
|
+
*/
|
|
30
|
+
// Relative, like every other file in the package: the bare specifier is the
|
|
31
|
+
// consumer's name for this package, and depending on it here would make the
|
|
32
|
+
// library's own internals rely on how a project spells them.
|
|
33
|
+
import type { MetaTag } from "../meta/index.ts";
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
readonly tags: readonly MetaTag[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { tags } = Astro.props;
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
tags.map((tag) => {
|
|
44
|
+
if (tag.kind === "title") return <title>{tag.text}</title>;
|
|
45
|
+
if (tag.kind === "link") return <link {...tag.attrs} />;
|
|
46
|
+
// `set:html` writes the JSON raw. `serializeJsonLd` has already replaced
|
|
47
|
+
// every less-than sign with its unicode escape, which is what stops a
|
|
48
|
+
// `</script` inside a description closing the block early. Escaping it
|
|
49
|
+
// again as HTML would turn each quote into an entity and leave a block no
|
|
50
|
+
// parser reads.
|
|
51
|
+
if (tag.kind === "script") {
|
|
52
|
+
return <script type={tag.type} is:inline set:html={tag.content} />;
|
|
53
|
+
}
|
|
54
|
+
// `is:inline` so the bundler leaves a third-party URL alone, and `defer`
|
|
55
|
+
// because a script that blocks the parser costs the thing it measures.
|
|
56
|
+
if (tag.kind === "externalScript") {
|
|
57
|
+
return <script is:inline defer src={tag.src} {...tag.attrs} />;
|
|
58
|
+
}
|
|
59
|
+
// Tag Manager's fallback, and only ever in the body list. Hidden the way
|
|
60
|
+
// Google's own snippet hides it — a zero-sized iframe nothing can see.
|
|
61
|
+
if (tag.kind === "noscriptFrame") {
|
|
62
|
+
return (
|
|
63
|
+
<noscript>
|
|
64
|
+
<iframe
|
|
65
|
+
src={tag.src}
|
|
66
|
+
title="Google Tag Manager"
|
|
67
|
+
height="0"
|
|
68
|
+
width="0"
|
|
69
|
+
style="display:none;visibility:hidden"
|
|
70
|
+
/>
|
|
71
|
+
</noscript>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (tag.kind === "meta") return <meta {...tag.attrs} />;
|
|
75
|
+
// Named rather than left to a trailing `return`, which used to catch
|
|
76
|
+
// `meta` and everything else alike: a kind added to `MetaTag` and forgotten
|
|
77
|
+
// here would have rendered as `<meta {...tag.attrs}>`, and for a kind
|
|
78
|
+
// carrying no `attrs` that is a bare `<meta>` — valid markup, nothing in
|
|
79
|
+
// the page, nothing in the build.
|
|
80
|
+
//
|
|
81
|
+
// `lib/meta/tag.ts` guards the mirror of this problem the same way, and
|
|
82
|
+
// this half matters more: that one is written once, while this file is
|
|
83
|
+
// copied into every project.
|
|
84
|
+
const unhandled: never = tag;
|
|
85
|
+
throw new Error(`Unhandled meta tag: ${JSON.stringify(unhandled)}`);
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The browser half of consent: remembering an answer, expiring it, and handing
|
|
5
|
+
* it to Google.
|
|
6
|
+
*
|
|
7
|
+
* In `astro/` because it touches `window` and `localStorage`, which the core is
|
|
8
|
+
* type-checked without — this is the one folder allowed them. Imported by a
|
|
9
|
+
* consent banner's client script rather than emitted as head script, so a
|
|
10
|
+
* project gets real functions with real types instead of a global it has to
|
|
11
|
+
* know the name of.
|
|
12
|
+
*
|
|
13
|
+
* What is *not* here: the banner. Its markup, its wording, whether it has a
|
|
14
|
+
* reject button as prominent as accept, and whether a visitor can change their
|
|
15
|
+
* mind later are product and legal decisions, and lib would be guessing at all
|
|
16
|
+
* four. What is here is the part that is the same everywhere and easy to get
|
|
17
|
+
* subtly wrong.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Granted or denied — the only two answers Consent Mode has. */
|
|
21
|
+
export type ConsentChoice = "granted" | "denied";
|
|
22
|
+
|
|
23
|
+
/** An answer, and when it was given. */
|
|
24
|
+
export interface ConsentRecord {
|
|
25
|
+
readonly choice: ConsentChoice;
|
|
26
|
+
/** ISO 8601, so it is legible in devtools rather than a number. */
|
|
27
|
+
readonly at: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How long an answer stands before the question is asked again.
|
|
32
|
+
*
|
|
33
|
+
* Consent is not forever and regulators say so: France's CNIL puts the outside
|
|
34
|
+
* limit at 13 months and recommends six, and the EDPB's position is that a
|
|
35
|
+
* choice made long enough ago is no longer informed. Six is the conservative
|
|
36
|
+
* reading, and the reason the record carries a date at all — without one there
|
|
37
|
+
* is no way to expire it, and no way to answer "when did this visitor agree?",
|
|
38
|
+
* which is a question only ever asked when somebody is already unhappy.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_MONTHS = 6;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where the answer is kept.
|
|
44
|
+
*
|
|
45
|
+
* `localStorage` is already scoped to an origin, so this does not need to
|
|
46
|
+
* identify the site — what it needs is to not collide with something else on
|
|
47
|
+
* the page, and a bare `"consent"` is exactly the key a third-party consent
|
|
48
|
+
* tool or chat widget would reach for. The prefix matches
|
|
49
|
+
* `CONSENT_UPDATE_GLOBAL`, so one concept has one name on both sides.
|
|
50
|
+
*
|
|
51
|
+
* Overridable for the case the default cannot cover: two deployments sharing
|
|
52
|
+
* one origin — `example.com/rome` and `example.com/bucharest` — where one key
|
|
53
|
+
* would mean one answer for both. See `consentStore`.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_KEY = "__consent";
|
|
56
|
+
|
|
57
|
+
export interface ConsentStoreOptions {
|
|
58
|
+
/** Overrides `DEFAULT_KEY`. See it for the one case that needs this. */
|
|
59
|
+
readonly key?: string;
|
|
60
|
+
/** Overrides the six-month window. See `DEFAULT_MONTHS`. */
|
|
61
|
+
readonly months?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reading and writing an answer, bound to one key and one window.
|
|
66
|
+
*
|
|
67
|
+
* A factory rather than two functions each taking options, because the key and
|
|
68
|
+
* the window have to *match* between them: a read that looked in one place and
|
|
69
|
+
* a write that filled another would ask a visitor on every page while
|
|
70
|
+
* faithfully recording each answer. Bound once, they cannot disagree.
|
|
71
|
+
*/
|
|
72
|
+
export function consentStore(options: ConsentStoreOptions = {}): {
|
|
73
|
+
read(): ConsentRecord | undefined;
|
|
74
|
+
record(choice: ConsentChoice): void;
|
|
75
|
+
} {
|
|
76
|
+
const key = options.key ?? DEFAULT_KEY;
|
|
77
|
+
const months = options.months ?? DEFAULT_MONTHS;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
/**
|
|
81
|
+
* The answer this visitor gave, if it still counts.
|
|
82
|
+
*
|
|
83
|
+
* `undefined` for never asked, for an expired answer, and for anything
|
|
84
|
+
* unparseable — all three mean the same thing to a banner, and all
|
|
85
|
+
* three should ask rather than assume. Storage hand-edited, or written
|
|
86
|
+
* by an older version of this code, lands in the third case by design.
|
|
87
|
+
*/
|
|
88
|
+
read(): ConsentRecord | undefined {
|
|
89
|
+
let raw: string | null = null;
|
|
90
|
+
try {
|
|
91
|
+
raw = localStorage.getItem(key);
|
|
92
|
+
} catch {
|
|
93
|
+
// Private browsing, or storage disabled. Nothing was
|
|
94
|
+
// remembered, so nothing is assumed.
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (raw === null) return undefined;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
|
|
101
|
+
if (parsed.choice !== "granted" && parsed.choice !== "denied") {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (typeof parsed.at !== "string") return undefined;
|
|
105
|
+
|
|
106
|
+
const expiry = new Date(parsed.at);
|
|
107
|
+
if (Number.isNaN(expiry.getTime())) return undefined;
|
|
108
|
+
expiry.setMonth(expiry.getMonth() + months);
|
|
109
|
+
if (expiry < new Date()) return undefined;
|
|
110
|
+
|
|
111
|
+
return { choice: parsed.choice, at: parsed.at };
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remembers an answer, dated now, and tells Google about it.
|
|
119
|
+
*
|
|
120
|
+
* One call rather than two, because the two must not come apart: an
|
|
121
|
+
* answer stored but never applied leaves the visitor consented in name
|
|
122
|
+
* only, and one applied but never stored asks them again next page.
|
|
123
|
+
*/
|
|
124
|
+
record(choice: ConsentChoice): void {
|
|
125
|
+
const record: ConsentRecord = {
|
|
126
|
+
choice,
|
|
127
|
+
at: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
localStorage.setItem(key, JSON.stringify(record));
|
|
131
|
+
} catch {
|
|
132
|
+
// Unable to remember it, which is a worse experience and not a
|
|
133
|
+
// wrong one — the choice still applies to this page.
|
|
134
|
+
}
|
|
135
|
+
applyConsent(choice);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hands a choice to Google, if there is a Google tag on this deployment.
|
|
142
|
+
*
|
|
143
|
+
* Reaches the head script through a global, which is the only way the two can
|
|
144
|
+
* meet: that script runs before any module exists. The name lives in one place
|
|
145
|
+
* — `CONSENT_UPDATE_GLOBAL` — and is referenced here and where it is emitted,
|
|
146
|
+
* so a project never types it.
|
|
147
|
+
*
|
|
148
|
+
* What it updates is decided over there, from the same settings that decided
|
|
149
|
+
* what to deny. A payload built here would be a second copy of that list.
|
|
150
|
+
*/
|
|
151
|
+
export function applyConsent(choice: ConsentChoice): void {
|
|
152
|
+
const update = (
|
|
153
|
+
window as unknown as Record<string, ((c: string) => void) | undefined>
|
|
154
|
+
)[CONSENT_UPDATE_GLOBAL];
|
|
155
|
+
update?.(choice);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether this deployment has a Google tag to consent to at all. */
|
|
159
|
+
export function consentApplies(): boolean {
|
|
160
|
+
return (
|
|
161
|
+
(window as unknown as Record<string, unknown>)[
|
|
162
|
+
CONSENT_UPDATE_GLOBAL
|
|
163
|
+
] !== undefined
|
|
164
|
+
);
|
|
165
|
+
}
|