@escape-game-over/atlas 0.1.1

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