@escape-game-over/atlas 0.1.5 → 0.1.7

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 CHANGED
@@ -105,9 +105,10 @@ export default defineProject(config, baseMessages, baseRoutes, {...})
105
105
  export const site = createSite(config, baseMessages, baseRoutes, project)
106
106
  ```
107
107
 
108
- Step 4 returns `t()`, `pathFor()`, `urlFor()`, `fileUrl()`, `alternatesFor()`,
109
- `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`, `staticPaths()`, `routes`,
110
- `entries`, `sitemap()`, `robots()`, `llms()` and `redirects()` already wired.
108
+ Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
109
+ `alternatesFor()`, `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
110
+ `staticPaths()`, `routes`, `entries`, `sitemap()`, `robots()`, `llms()` and
111
+ `redirects()` already wired.
111
112
 
112
113
  `t()` is bound to a locale and knows each message's `{placeholders}` from its
113
114
  text:
@@ -125,6 +126,49 @@ t("footer.copyright", { company: "A", year: "1", x: "" }) // ✗ {x} is not a pl
125
126
  Nothing is auto-injected. Site-wide values are ordinary placeholders, passed
126
127
  explicitly.
127
128
 
129
+ ## A sentence is one message
130
+
131
+ Copy that needs emphasis, a link or a line break carries **marks**, and stays one
132
+ key — so each language orders and splits it however it needs to, rather than
133
+ around an array of `hero.subtitle_text_1…_7` whose length was fixed by whoever
134
+ typed the English.
135
+
136
+ ```ts
137
+ "about.intro": {
138
+ "en-US": "Book [v:accent]up to six players[/v] at [a:venue]our venue[/a].",
139
+ "el-GR": "Κλείσε [a:venue]στον χώρο μας[/a] [v:accent]έως έξι παίκτες[/v].",
140
+ }
141
+ ```
142
+
143
+ ```ts
144
+ const rich = site.rich(locale)
145
+ rich("about.intro", { venue: "contact" }) // the destination, stated once
146
+ ```
147
+
148
+ | Mark | Short for | Is |
149
+ | ----------------- | ----------- | ---------------------------------------- |
150
+ | `[b]…[/b]` | **b**old | `<strong>` — emphasis, not a font weight |
151
+ | `[v:accent]…[/v]` | **v**ariant | a role the project's renderer maps |
152
+ | `[a:venue]…[/a]` | **a**nchor | a link **slot** the call site fills |
153
+ | `[mail]…[/mail]` | `mailto:` | a `mailto:` derived from the words |
154
+ | `[tel]…[/tel]` | `tel:` | a `tel:` derived from the words |
155
+ | `[br]` | **br**eak | a line break |
156
+ | `[[` and `]]` | an escape | a literal `[` and `]` |
157
+
158
+ Four of the six are HTML's own tags or schemes, so there is little to memorise.
159
+ Only `[v:]` is Atlas's, and it is the one that has to be: HTML has no tag for "a
160
+ role this project names", which is precisely the decision being kept out of the
161
+ copy.
162
+
163
+ `rich()` returns `Span[]` — translated text with resolved `href`s — and the
164
+ project owns the `switch` that draws them. Copy never holds a colour, a class, a
165
+ route id or a URL. A slot is filled at the call site, where it is checked against
166
+ the routes this deployment builds and written once instead of once per language.
167
+
168
+ `site.plain(locale)` reads the same message as words alone, for a
169
+ `<meta description>` or `llms.txt`; `t()` refuses a message with marks rather
170
+ than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
171
+
128
172
  ## What the build emits, and who asks for it
129
173
 
130
174
  | Output | Comes from | Needs from the project |
@@ -159,6 +203,8 @@ regression-tested by an `@ts-expect-error` suite.
159
203
  | A locale of a message using different `{placeholders}` than its siblings | error, naming the locale |
160
204
  | An override for a key that is not in the default catalog | error — typos can't become dead strings |
161
205
  | An override that drops or invents a `{placeholder}` | error — existing `t()` calls stay valid |
206
+ | A locale of a message using different marks than its siblings | error — a lost `[a:venue]` fails the build |
207
+ | Filling a `[a:slot]` with a route this project does not build | error at the call site |
162
208
  | Calling `t()` without a required placeholder, or with a wrong/extra name | error |
163
209
  | A route that does not state `enabled` | error — a page is never built by implication |
164
210
  | A slug or message naming a locale the site doesn't ship | error |
@@ -401,6 +447,9 @@ upstream, and each explains a rule that would otherwise read as an oversight.
401
447
 
402
448
  - [`docs/checks.md`](docs/checks.md) — every guarantee, where it is enforced, and
403
449
  how the type tests and runtime tests divide the work.
450
+ - [`docs/rich-text.md`](docs/rich-text.md) — every mark copy may carry, what each
451
+ produces, why a link names a slot rather than an address, and the renderer
452
+ contract on the other side of `Span`.
404
453
  - [`docs/share-images.md`](docs/share-images.md) — what each social surface
405
454
  crops, and why one 1200×630 image serves all of them.
406
455
  - [`docs/client-scripts.md`](docs/client-scripts.md) — the browser half: what
package/docs/NOT-BUILT.md CHANGED
@@ -227,6 +227,73 @@ type could — a property that is not valid for the type it sits on.
227
227
 
228
228
  ## Answers that will not change on their own
229
229
 
230
+ ### Should a paragraph be authored as an array of translation keys?
231
+
232
+ **No. One sentence is one message, and the styling lives in the copy as
233
+ `[marks]`. `content/` carries the parser, the runs and `plain()`; nothing
234
+ carries a `ContentItem[]`.**
235
+
236
+ This is the shape the sibling B2C template uses, and it is the one thing from
237
+ there that was deliberately not ported — so the reasoning is here, because
238
+ reading those configs is what prompts the question. A paragraph is written as an
239
+ array of `{ type, content }` objects, each `content` a translation key, and the
240
+ renderer concatenates them in the order the array was typed:
241
+
242
+ ```ts
243
+ subtitle: [
244
+ { type: "text", content: "hero.subtitle_text_1" },
245
+ { type: "bold", content: "hero.subtitle_text_2" },
246
+ { type: "text", content: "hero.subtitle_text_3" },
247
+ // …through _7
248
+ ]
249
+ ```
250
+
251
+ **It fixes two things copy is not allowed to fix.** Word order, which differs
252
+ per language — the array says the bold clause comes second, in every locale,
253
+ forever. And the *number of runs*, which differs more: a clause English spends
254
+ four fragments on is one word in Greek, and there is no way to write that in an
255
+ array whose length was decided by whoever typed the English. The extra keys get
256
+ padded with empty strings or the spare ones go untranslated, and either way the
257
+ translator is editing around a data structure instead of writing a sentence.
258
+
259
+ The `spaceAfter: true` flag on those objects is the tell. It exists because the
260
+ runs are concatenated with nothing between them, so a sentence split across keys
261
+ needs someone to remember which fragments end in a space — a per-fragment
262
+ decision that is invisible until it renders. With one message the spaces are
263
+ just in the copy.
264
+
265
+ What replaces it is a mark in the message: `"Book [b]up to six[/b] at
266
+ [a:venue]our venue[/a]."`. Each language moves them where its grammar wants
267
+ them, `rich()` returns the runs, and the renderer styles them. The mark set is
268
+ compared across locales by the machinery that already compares `{placeholders}`
269
+ — see `Tokens` in `i18n/placeholders.ts` — so a translation that drops the link
270
+ fails the build rather than the page. That check is the reason marks are safe to
271
+ put in copy at all, and it is what the array shape genuinely did buy: with the
272
+ structure in config, no locale *could* lose a link. Marks had to earn that back.
273
+
274
+ **A mark names a slot, never a destination**, and that distinction is the second
275
+ thing the array shape got right and the first version of this got wrong. `venue`
276
+ above is a name the call site fills — `rich(key, { venue: "contact" })` — so the
277
+ route id is checked against the route table like every other link in the package,
278
+ it is written once rather than once per language, and an external URL never
279
+ enters a file that translators edit. Copy holding `[a:https://…]` in ten
280
+ languages is ten unchecked copies of an address, and the eleventh is the one
281
+ somebody "corrects".
282
+
283
+ **What is still fine to build.** Composing runs in code: a `Span[]` is the
284
+ resolved type, already plain strings and finished hrefs, so a project assembling
285
+ a line from config values — a price, a phone number, a room name — builds one
286
+ directly and needs nothing from `rich()`. And a list is still an array: spans are
287
+ inline, so bullets are `readonly RichText[]` in the project's own config, which
288
+ is what the old `bulletList` run should become.
289
+
290
+ **What is not a reason, and was the strongest argument for porting it.** That
291
+ around 6,300 authored runs across fifty-four deployments would migrate verbatim.
292
+ They would — and they would carry the fragmentation with them into a codebase
293
+ whose whole claim is that a translation cannot silently go wrong. The migration
294
+ is happening once; this is the moment it costs least to fix, and every store
295
+ collapsed to marks is a store whose copy a translator can finally read.
296
+
230
297
  ### Should lib carry the consent *banner* as well as the tags?
231
298
 
232
299
  **No — and the line between the two halves is the point.**
@@ -0,0 +1,261 @@
1
+ # Rich text: the marks a message may carry
2
+
3
+ `t()` prints a message. `rich()` reads the marks *in* one and hands back runs —
4
+ so a sentence with emphasis, a link and a line break in it stays **one message**,
5
+ which is the only shape a translator can reorder.
6
+
7
+ ```ts
8
+ "about.intro": {
9
+ "en-US": "Book [v:accent]up to six players[/v] at [a:venue]our venue[/a].",
10
+ "el-GR": "Κλείσε [a:venue]στον χώρο μας[/a] [v:accent]έως έξι παίκτες[/v].",
11
+ }
12
+ ```
13
+
14
+ ```ts
15
+ const rich = site.rich(locale);
16
+ rich("about.intro", { venue: "contact" }) // the destination, stated once
17
+ ```
18
+
19
+ Greek moves the link to the front and spends fewer runs on the clause. Neither is
20
+ something the copy is allowed to ask permission for, and neither is expressible
21
+ as an array of `hero.subtitle_text_1…_7` keys glued together in the order someone
22
+ typed the English. See [`src/content/marks.ts`](../src/content/marks.ts) for that
23
+ argument at length.
24
+
25
+ ## The marks
26
+
27
+ Five marks, one void mark, two escapes. That is the whole vocabulary.
28
+
29
+ | Written | Short for | Run | Argument | Notes |
30
+ | ---------------- | ----------- | ---------------------------------- | -------- | -------------------------------------------- |
31
+ | `[b]…[/b]` | **b**old | `{ kind: "bold" }` | — | `<strong>` — emphasis, not a font weight |
32
+ | `[v:name]…[/v]` | **v**ariant | `{ kind: "styled", variant }` | required | the renderer maps `name` to classes |
33
+ | `[a:slot]…[/a]` | **a**nchor | `{ kind: "link", to, href, url? }` | required | the **call site** says where `slot` goes |
34
+ | `[mail]…[/mail]` | `mailto:` | `{ kind: "email", href }` | — | the wrapped text must be the address |
35
+ | `[tel]…[/tel]` | `tel:` | `{ kind: "phone", href }` | — | the wrapped text must be the number |
36
+ | `[br]` | **br**eak | `{ kind: "break" }` | — | wraps nothing, closed by nothing |
37
+ | `[[` and `]]` | an escape | literal `[` and `]` | — | the escapes, matching `{{` and `}}` in `t()` |
38
+
39
+ **Four of the six are HTML's own**, which is the whole memorisation trick: `b`,
40
+ `a` and `br` are the tags they replace, and `mail` and `tel` are the schemes they
41
+ produce. Only `v` is Atlas's, and it is the one that has to be — HTML has no tag
42
+ for "a role this project names", because that is exactly the decision being kept
43
+ out of the copy.
44
+
45
+ They are short because a translator reads past them to the words, and every
46
+ character between `[` and `]` is noise in a sentence someone is trying to keep
47
+ natural. They are not so short as to be unguessable — `[b]` and `[a]` read off a
48
+ sentence the way their tags do, and `[x]` would not.
49
+
50
+ `[b]` is the one presentational-looking mark lib names outright, and it is not
51
+ presentational — it is `<strong>`, it survives into plain text as emphasis a
52
+ screen reader announces. Everything that is genuinely a *style* is `[v:]`.
53
+
54
+ ## An argument is a role or a slot, never a value
55
+
56
+ | | `[v:name]` | `[a:name]` |
57
+ | ----------- | ------------------------------------------ | ------------------------------- |
58
+ | Names | a style the renderer knows | a slot the call site fills |
59
+ | Spelled | `[a-zA-Z0-9_-]+` | `[a-zA-Z0-9_]+` — **no hyphen** |
60
+ | Resolved by | the project's `Record<string, string>` map | `rich(key, { name: … })` |
61
+ | Never holds | a colour, a class, a size | a route id, a path or a URL |
62
+
63
+ The hyphen is the difference and it is deliberate. A variant usually mirrors a
64
+ CSS custom property or a design token, which are kebab by convention, and
65
+ `[v:on-dark]` is the natural spelling. A slot name is a key someone writes in
66
+ TypeScript — `rich(key, { venue: "contact" })` — where a hyphen forces quoting
67
+ and reads as a mistake.
68
+
69
+ **A link names a slot rather than a destination**, which is the rule worth
70
+ understanding before anything else here. A route id written into a translation is
71
+ an unchecked string until the day that message renders, repeated once per
72
+ language, sitting in a file whose reviewers are translators. Written at the call
73
+ site it is checked against the routes this deployment actually builds:
74
+ `{ venue: "contct" }` is a compile error on the line that wrote it, and so is a
75
+ route the project has switched off.
76
+
77
+ Copy that spells an address gets told so by name:
78
+
79
+ ```txt
80
+ Message "about.intro" (en-US) has a link slot that is not a name: "[a:/contact]".
81
+ A link names a slot the call site fills, not an address: write "[a:venue]" and
82
+ pass rich(key, { venue: "contact" }).
83
+ ```
84
+
85
+ ## Every `[` opens a mark or is doubled
86
+
87
+ There is no third option, and a bracket that means neither is refused rather than
88
+ printed:
89
+
90
+ ```txt
91
+ Message "hours.line" (en-US) has a "[" that opens neither a mark nor an escape:
92
+ "[Mon-Fri]". The marks are [b], [v], [a], [mail], [tel] and [br]. To write a
93
+ literal bracket, double it: "[[".
94
+ ```
95
+
96
+ `"Open [[Mon-Fri]] only."` is what that copy meant, and it renders `Open
97
+ [Mon-Fri] only.` This is the same call `render` makes on a stray `{`: a
98
+ half-written mark is a mistake, not an escape hatch, and shipping it to a page as
99
+ literal text is the failure nobody notices until it is quoted in a search result.
100
+
101
+ A **lone `]` stays text** — only `[` opens anything, so there is nothing an
102
+ unmatched closer could be ambiguous about, and refusing it would fail copy that
103
+ is merely writing a bracket.
104
+
105
+ An escaped `[[a:x]]` asks for nothing: it is not a mark, so it demands no slot
106
+ and fills none.
107
+
108
+ ## Marks do not nest
109
+
110
+ One mark is open at a time. `[b]bold [v:accent]and red[/v][/b]` is refused rather
111
+ than flattened, because a run is bold *or* it carries a variant and there is
112
+ nowhere for the second one to go.
113
+
114
+ If the combination is wanted, it is a role: give one variant both properties and
115
+ name it once. (The shape this replaces answered the same limit by inventing a
116
+ `boldUnderline` kind, used once in fifty-four deployments.)
117
+
118
+ `[br]` is the exception that is not one — it wraps nothing, so it splits the run
119
+ without closing the mark:
120
+
121
+ ```ts
122
+ parseMarks("[b]One[br]Two[/b]")
123
+ // [ bold "One", break, bold "Two" ]
124
+ ```
125
+
126
+ An empty run is dropped rather than emitted: `[b][/b]` produces nothing at all,
127
+ because copy someone half-deleted should not render an empty element that still
128
+ takes up a renderer's `switch`.
129
+
130
+ ## Marks are parsed before `{placeholders}` are filled
131
+
132
+ That order is load-bearing in both directions:
133
+
134
+ - **A value can never inject a mark.** A venue name, a price or a phone number
135
+ arriving from config is data, not copy — by the time it lands, the marks are
136
+ already gone, so `{phone}` can hold a `[` and still renders as text.
137
+ - **A mark can wrap a placeholder.** `"Call us on [tel]{phone}[/tel]"` keeps the
138
+ number in config where it belongs and still gets a `tel:` link, without copy
139
+ ever spelling the digits.
140
+
141
+ ## Where a link actually goes
142
+
143
+ The slot's value is a `LinkTarget`, told apart by its first character:
144
+
145
+ | Passed | `span.to` | `href` | `url` |
146
+ | ---------------------- | ---------- | ----------------------------- | ------------ |
147
+ | `"contact"` | `internal` | this locale's path | absolute |
148
+ | `"challenges#booking"` | `internal` | that path, fragment appended | absolute |
149
+ | `"/press/kit"` | `internal` | taken as written | absolute |
150
+ | `"https://…"` | `external` | the URL | the same URL |
151
+ | `"#booking"` | `anchor` | the fragment | **absent** |
152
+ | `"http://…"` | — | refused — write it `https://` | |
153
+
154
+ Prefer the route id. It is the only form that survives a slug being retranslated
155
+ or a page moving; a raw path is the escape hatch for a URL this project owns no
156
+ route for, and lib has nothing to check it against.
157
+
158
+ `external` is decided by the shape the target was written in, not by comparing
159
+ hosts — a sibling deployment on the same domain is still leaving this site, and
160
+ the `target`/`rel` decision is the renderer's.
161
+
162
+ An `anchor` carries **no `url`, and that is not a gap**: `rich()` is given a
163
+ locale, not a route, so it does not know which page a bare `#booking` belongs to.
164
+ A caller that does can build one — `joinUrl(site.urlFor(id, locale), href)`.
165
+
166
+ `[mail]` and `[tel]` derive their `href` from the words themselves, so the
167
+ address or number is written once. The number must be in international form —
168
+ `"+30 210 0000000"` — because a local one does not dial from abroad.
169
+
170
+ ## What the compiler holds in step
171
+
172
+ Which marks a message uses is checked across locales by the same machinery that
173
+ holds `{placeholders}` in step, so a translation that quietly loses `[a:venue]`
174
+ fails the build rather than the page.
175
+
176
+ **The argument is part of the token, on purpose.** `[a:venue]` and `[a:booking]`
177
+ are different tokens, so one language cannot fill a different slot from the
178
+ others, and `[v:accent]` cannot become `[v:inverse]` in the Greek. Those are
179
+ decisions the copy makes once, not per locale.
180
+
181
+ Multiplicity is not carried — two `[b]` runs and one look alike to the type,
182
+ which is the same limit `Placeholders` has with two `{name}`s and the same trade.
183
+
184
+ The link slots a message declares become required arguments:
185
+ `"See [a:venue]us[/a]"` asks the call site for `{ venue: LinkTarget }`, typed
186
+ over the routes this project builds.
187
+
188
+ ## `t()` refuses a message with marks in it
189
+
190
+ `t()` can only print, so it would print the brackets. It throws instead, and says
191
+ what to do:
192
+
193
+ ```txt
194
+ Message "about.intro" (en-US) carries marks, and t() can only print them.
195
+ Read it with rich(), or with plain(rich(…)) for the words alone.
196
+ ```
197
+
198
+ A malformed mark fails there too, with the same error it would give anywhere
199
+ else — `"[b]bold"` is a mistake whichever function reads it.
200
+
201
+ ## The same sentence as words
202
+
203
+ `site.plain(locale)` gives the message with the markup gone: for a
204
+ `<meta name="description">`, an `llms.txt` summary, a structured-data
205
+ `description` — everywhere that takes a string rather than markup. `[br]` becomes
206
+ a space and runs of whitespace collapse, so copy split across a line break does
207
+ not arrive with a double space in the middle of a `<meta>` tag.
208
+
209
+ **It asks for `{placeholders}` and not for link destinations.** A `tel:` it would
210
+ discard is not worth building, and `llms.txt` should not have to name a route for
211
+ every link in every description in order to throw them all away.
212
+
213
+ ```ts
214
+ const plainText = site.plain(locale);
215
+ plainText("about.intro", { company }) // no `venue`, no `ask`
216
+ ```
217
+
218
+ `plain(rich(…))` is the same answer when the runs are already in hand.
219
+
220
+ Reach for `t()` when the copy is structurally plain and should stay that way — a
221
+ button label, an `aria-label`. Reach for `plain()` when the answer is prose: it
222
+ keeps working on the day someone adds emphasis to the sentence, where `t()` would
223
+ start throwing.
224
+
225
+ ## The renderer half
226
+
227
+ lib decides which runs exist and what they say; the project decides what they
228
+ look like. A renderer is a `switch` over `kind` and nothing else — every string
229
+ is already translated, every `href` already resolved.
230
+
231
+ Make the `switch` exhaustive. `Span` is a closed union, so `satisfies never` on
232
+ the fallthrough turns a run type added in lib into a compile error in the
233
+ project, which is the failure a `default: return null` cannot have — a new kind
234
+ rendering as nothing at all, on every page, silently.
235
+
236
+ [`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)
237
+ is one to copy and restyle. The part worth keeping is its `VARIANTS` map: it is
238
+ the only place a role becomes a colour, so a rebrand is that object rather than a
239
+ sweep through every deployment's sentences. It falls back to no classes for a
240
+ variant it does not know — losing the sentence is the worse failure.
241
+
242
+ ## What fails, and where
243
+
244
+ | Written | Result |
245
+ | --------------------------------------------------- | --------------------------------------------------------------------- |
246
+ | A locale dropping `[a:venue]` | compile error — the marks must match across a message |
247
+ | `[a:venue]` in one locale, `[a:booking]` in another | compile error — the argument is part of the token |
248
+ | `rich()` without a slot's target | compile error, then a build error naming the slot |
249
+ | A slot filled with an unbuilt route | compile error at the call site |
250
+ | `[Mon-Fri]` | build error, listing the marks and the `[[` escape |
251
+ | `[b]` never closed, `[/b]` never opened | build error naming the message and locale |
252
+ | `[b]a[v:x]b[/v][/b]` | build error — marks do not nest |
253
+ | `[v:]` or `[a:]` with nothing after the colon | build error, saying what the argument is |
254
+ | `[a:/contact]` — an address in the copy | build error — a slot is a name |
255
+ | `[v:on dark]` — a variant that is not a name | build error |
256
+ | `[b:x]` — an argument on a mark that takes none | build error |
257
+ | `[mail]` around text with no `@` | build error — a `mailto:` with an empty To: still opens a mail client |
258
+ | `[tel]` around a local number | build error — write it with a country code |
259
+ | `http://` as a link target | build error — an `http://` link is a downgrade |
260
+ | `"contact#"` — a fragment that names nothing | build error |
261
+ | A marked message read with `t()` | build error, pointing at `rich()` and `plain()` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -39,7 +39,7 @@
39
39
  "scripts": {
40
40
  "test": "npm run test:types && npm run test:unit && npm run test:examples",
41
41
  "test:types": "tsc --noEmit && astro check --root checks/astro",
42
- "test:unit": "vitest run",
42
+ "test:unit": "vitest run --coverage",
43
43
  "test:watch": "vitest",
44
44
  "test:examples": "npm run test --workspaces",
45
45
  "lint": "biome check .",
@@ -55,7 +55,8 @@
55
55
  "devDependencies": {
56
56
  "@biomejs/biome": "2.5.10",
57
57
  "@types/node": "26.3.0",
58
- "astro": "7.2.6",
58
+ "@vitest/coverage-istanbul": "4.1.11",
59
+ "astro": "7.2.7",
59
60
  "typescript": "6.0.3",
60
61
  "vitest": "4.1.11"
61
62
  }
@@ -243,8 +243,42 @@ function consentCalls(defaults: readonly ConsentDefaults[]): string {
243
243
  }
244
244
 
245
245
  /**
246
- * Google's tags: one inline block and the loader in the head, Tag Manager's
247
- * `<noscript>` fallback in the body.
246
+ * The one host every Google tag here is served from.
247
+ *
248
+ * Named because it is stated four times below — the loader's `src`, the
249
+ * container bootstrap written into the inline block, the `<noscript>` iframe,
250
+ * and the connection opened ahead of all three. The preconnect is only worth
251
+ * anything while it names the origin the scripts actually use, and three
252
+ * hand-written copies of a hostname is how it stops.
253
+ */
254
+ const TAG_ORIGIN = "https://www.googletagmanager.com";
255
+
256
+ /**
257
+ * Whether a `google` block comes to any tag at all.
258
+ *
259
+ * The condition `googleScripts` returns early on, given a name because two
260
+ * callers need the same answer. A consumer deciding whether to render a consent
261
+ * banner is asking exactly this: a block carrying neither `tagIds` nor
262
+ * `containerIds` emits nothing, sets no cookies, and needs no permission — and
263
+ * a banner shown for it asks a visitor to agree to something that was never
264
+ * written. See `consentRequired` in `analytics/index.ts`.
265
+ *
266
+ * A type predicate, so the caller below keeps its narrowing: `true` implies
267
+ * the block is there, which is the only direction anything relies on.
268
+ */
269
+ export function googleEmits(
270
+ google: GoogleSettings | undefined
271
+ ): google is GoogleSettings {
272
+ if (google === undefined) return false;
273
+ return (
274
+ (google.tagIds ?? []).length > 0 ||
275
+ (google.containerIds ?? []).length > 0
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Google's tags: a connection and one inline block and the loader in the head,
281
+ * Tag Manager's `<noscript>` fallback in the body.
248
282
  *
249
283
  * One block rather than several because `dataLayer` is a queue — consent, the
250
284
  * timestamp, every `config` and Tag Manager's own bootstrap can all be pushed
@@ -263,11 +297,10 @@ export function googleScripts(
263
297
  at: string
264
298
  ): AnalyticsTags {
265
299
  const nothing: AnalyticsTags = { head: [], body: [] };
266
- if (google === undefined) return nothing;
300
+ if (!googleEmits(google)) return nothing;
267
301
 
268
302
  const tags = google.tagIds ?? [];
269
303
  const containers = google.containerIds ?? [];
270
- if (tags.length === 0 && containers.length === 0) return nothing;
271
304
 
272
305
  // An id in the wrong field is the silent failure here: it is configured,
273
306
  // it is emitted, and it reports nowhere — which reads as a quiet week.
@@ -312,7 +345,7 @@ export function googleScripts(
312
345
  // itself, so it runs after the consent calls already queued above.
313
346
  ...containers.map(
314
347
  (id) =>
315
- `(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='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f)})(window,document,'script','dataLayer',${literal(id)});`
348
+ `(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)});`
316
349
  ),
317
350
  ].join("");
318
351
 
@@ -330,13 +363,18 @@ export function googleScripts(
330
363
  const first = tags[0];
331
364
  return {
332
365
  head: [
366
+ // Ahead of the inline block, which is the only position that buys
367
+ // anything: for a container the loader's URL is written *by* that
368
+ // block, so this is the only mention of the origin the browser can
369
+ // act on before the script has run. See `AnalyticsTag`.
370
+ { kind: "preconnect", origin: TAG_ORIGIN },
333
371
  { kind: "inline", content: inline },
334
372
  ...(first === undefined
335
373
  ? []
336
374
  : [
337
375
  {
338
376
  kind: "external" as const,
339
- src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(first)}`,
377
+ src: `${TAG_ORIGIN}/gtag/js?id=${encodeURIComponent(first)}`,
340
378
  attributes: {},
341
379
  },
342
380
  ]),
@@ -345,7 +383,7 @@ export function googleScripts(
345
383
  // fallback, because `gtag.js` is the only way it collects anything.
346
384
  body: containers.map((id) => ({
347
385
  kind: "noscriptFrame" as const,
348
- src: `https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(id)}`,
386
+ src: `${TAG_ORIGIN}/ns.html?id=${encodeURIComponent(id)}`,
349
387
  })),
350
388
  };
351
389
  }
@@ -1,5 +1,5 @@
1
1
  import type { HttpsUrl } from "../url.ts";
2
- import { type GoogleSettings, googleScripts } from "./google.ts";
2
+ import { type GoogleSettings, googleEmits, googleScripts } from "./google.ts";
3
3
  import type { AnalyticsTag, AnalyticsTags } from "./tags.ts";
4
4
  import {
5
5
  checkUmamiDomains,
@@ -68,6 +68,43 @@ export function analyticsScripts(
68
68
  };
69
69
  }
70
70
 
71
+ /**
72
+ * Whether this deployment loads anything a visitor has to be asked about.
73
+ *
74
+ * The build-time twin of `consentApplies()` in `astro/consent.ts`, which asks
75
+ * the same question in the browser by looking for the global the Google tag
76
+ * defines. That one cannot answer it early enough to be useful: a consent
77
+ * banner's script has to be downloaded, parsed and run before it can report
78
+ * that there was nothing to consent to — three requests to learn that none of
79
+ * them were needed. Asked here, a project leaves the banner out of the HTML.
80
+ *
81
+ * Vendor by vendor rather than `analytics.google !== undefined`, because that
82
+ * is not the question. A `google` block carrying no ids emits no tag and sets
83
+ * no cookies, and a banner shown for it asks a visitor to agree to something
84
+ * that was never written — which teaches them their answer does not matter.
85
+ * `googleEmits` is the same predicate `googleScripts` returns early on, so the
86
+ * two cannot disagree.
87
+ *
88
+ * **`VENDORS` is exhaustive on purpose.** Adding a key to `AnalyticsSettings`
89
+ * stops this file compiling until someone says whether that vendor needs
90
+ * permission — which is the failure worth having, since the alternative is a
91
+ * new vendor silently setting cookies behind a banner that never appears.
92
+ */
93
+ export function consentRequired(
94
+ analytics: AnalyticsSettings | undefined
95
+ ): boolean {
96
+ if (analytics === undefined) return false;
97
+
98
+ const VENDORS: Readonly<Record<keyof AnalyticsSettings, boolean>> = {
99
+ // Cookieless by design — nothing to permit, nothing to withdraw.
100
+ umami: false,
101
+ // Consent Mode, but only once there is a tag reading it.
102
+ google: googleEmits(analytics.google),
103
+ };
104
+
105
+ return Object.values(VENDORS).some(Boolean);
106
+ }
107
+
71
108
  /**
72
109
  * The tag a 404 grouped under, so the misses can be read on their own.
73
110
  *
@@ -29,6 +29,33 @@ export type AnalyticsTag =
29
29
  | {
30
30
  readonly kind: "noscriptFrame";
31
31
  readonly src: string;
32
+ }
33
+ /**
34
+ * An origin to open a connection to before anything asks it for bytes.
35
+ *
36
+ * Every vendor here is served from a host that is not the site's, so the
37
+ * first tag to load pays a DNS lookup, a TCP handshake and a TLS
38
+ * negotiation before a single byte of script arrives. Tag Manager is the
39
+ * worst case: its loader is written by the inline block above, so the
40
+ * origin appears nowhere a preload scanner can read it and the connection
41
+ * cannot begin until that script has run.
42
+ *
43
+ * An origin rather than a URL, and `preconnect` rather than `preload`,
44
+ * because the handshake is the part worth moving and the fetch is not.
45
+ * Preloading a tag would raise a third-party analytics script to the
46
+ * priority of the things the page is drawn from — it would arrive sooner
47
+ * by making the image beside it arrive later.
48
+ *
49
+ * **No `crossorigin`, and that is load-bearing.** A preconnect is reused
50
+ * only by a request whose CORS mode matches it, and every script here is
51
+ * fetched as a classic `<script src>`, which is not a CORS request. One
52
+ * carrying `crossorigin` would open a second connection that nothing uses,
53
+ * warm nothing, and look entirely correct in the markup. Fonts are the
54
+ * opposite case and do need it, which is why this is worth stating.
55
+ */
56
+ | {
57
+ readonly kind: "preconnect";
58
+ readonly origin: string;
32
59
  };
33
60
 
34
61
  /**
@@ -248,6 +248,11 @@ export function umamiScripts(
248
248
  };
249
249
 
250
250
  return [
251
+ // Before the script that needs it. A `src.` subdomain reads as
252
+ // first-party and is not one to the network stack: it is a separate
253
+ // origin, and the tracker pays its full handshake before it can start.
254
+ // See `AnalyticsTag` for why this is a preconnect and not a preload.
255
+ { kind: "preconnect", origin: umami.host },
251
256
  {
252
257
  kind: "external",
253
258
  src: at(umami.tracker?.path ?? "/script.js"),