@escape-game-over/atlas 0.1.6 → 0.1.9

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 |
@@ -313,7 +359,8 @@ src/ the package — see the export map above for what is reachable
313
359
  jsonld/ the @graph — one file per node, each linked to Google's docs
314
360
  i18n/ defineMessages(), t(), placeholder extraction
315
361
  routes/ defineRoutes(), routeFamily(), merging and collisions
316
- analytics/ Umami and Google, and the Consent Mode defaults
362
+ analytics/ Umami and Google, the Consent Mode defaults, and whether a
363
+ visitor has to be asked anything at all
317
364
  astro/ the only framework-aware code, plus the one component
318
365
  bin/ the `atlas` CLI
319
366
  tests/ runtime behaviour — what a merge resolves to, what a builder rejects
@@ -401,6 +448,9 @@ upstream, and each explains a rule that would otherwise read as an oversight.
401
448
 
402
449
  - [`docs/checks.md`](docs/checks.md) — every guarantee, where it is enforced, and
403
450
  how the type tests and runtime tests divide the work.
451
+ - [`docs/rich-text.md`](docs/rich-text.md) — every mark copy may carry, what each
452
+ produces, why a link names a slot rather than an address, and the renderer
453
+ contract on the other side of `Span`.
404
454
  - [`docs/share-images.md`](docs/share-images.md) — what each social surface
405
455
  crops, and why one 1200×630 image serves all of them.
406
456
  - [`docs/client-scripts.md`](docs/client-scripts.md) — the browser half: what
package/docs/NOT-BUILT.md CHANGED
@@ -312,6 +312,13 @@ visitor changes their mind. Those are product and legal decisions that differ
312
312
  per deployment, and a template that guessed at them would be guessing at
313
313
  somebody's compliance.
314
314
 
315
+ **One part of it is lib's, and is built as of August 2026: whether there should
316
+ be a banner at all.** `consentRequired(analytics)`, with the reasoning in
317
+ `src/analytics/index.ts`. It sits here rather than in each project because it is
318
+ a question about *tags* — which lib owns — wearing the costume of a question
319
+ about banners, and because the obvious check is wrong in both directions.
320
+ Everything above about the banner itself still stands.
321
+
315
322
  The seam between the halves is a global — `CONSENT_UPDATE_GLOBAL` — because
316
323
  that is the only place they *can* meet: the head script runs before any module
317
324
  exists. lib emits an updater covering exactly the signals it defaulted, so a
@@ -326,6 +333,28 @@ Note the shape sibling projects use, and do not copy it: they group
326
333
  key. Verification is a `<meta>` proving ownership; analytics is a script. One
327
334
  config block, two different kinds of thing.
328
335
 
336
+ ### Should lib preload the analytics scripts, not just open the connection?
337
+
338
+ **No — and the connection is already opened, which is the part worth having.**
339
+ `preconnect`, built as of August 2026; see `AnalyticsTag` in
340
+ `src/analytics/tags.ts` for how and why.
341
+
342
+ The reason to stop there is priority. A preload does not merely start a fetch
343
+ earlier, it raises what that fetch competes with — and what would be raised here
344
+ is a third-party analytics script, against the image the page is judged on. The
345
+ tag would arrive sooner by making the LCP arrive later, which is a worse trade
346
+ than the one being fixed. A handshake has no such cost: it is latency with
347
+ nothing else contending for it, so moving it earlier takes nothing from
348
+ anything.
349
+
350
+ Two traps live with this, both spelled out where the code is. A preconnect is
351
+ reused only by a request whose CORS mode matches it, so one marked
352
+ `crossorigin` for a classic `<script src>` warms nothing while looking entirely
353
+ correct — fonts are the opposite case, and the two must not be copied between.
354
+ And Tag Manager's loader URL is written *by* the inline block, so nothing in the
355
+ markup names that origin until the script has run, which is what makes the
356
+ connection worth stating rather than leaving to the preload scanner.
357
+
329
358
  ### Should a room be an `Event`?
330
359
 
331
360
  **No.**
package/docs/checks.md CHANGED
@@ -105,27 +105,27 @@ They are **excluded from the type check**, because they import `vitest` and that
105
105
  command's promise is that the package type-checks with no ambient types and no
106
106
  dependencies at all. Vitest type-checks them itself when it runs them.
107
107
 
108
- | File | What it pins |
109
- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
110
- | `meta.test.ts` | the `robots` tag a policy builds, the pinned `twitter:card`, share-image warnings, length warnings, `fileUrl` |
111
- | `not-found.test.ts` | that a 404 carries the site's icon and theme colour, still names no canonical, and is held to the same icon rules |
112
- | `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap |
113
- | `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way |
114
- | `merge.test.ts` | catalog and route overlay precedence — per locale, never per key |
115
- | `llms.test.ts` | section grouping, owners, heading and link precedence, and the undescribed fallback |
116
- | `public-files.test.ts` | walking `public/`, what is skipped, and why the declaration must be a module |
117
- | `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported |
118
- | `jsonld/*.test.ts` | that a `</script>` in any value cannot close the block, each node's shape and `@id`, and how a price table becomes offers |
119
- | `analytics.test.ts` | Umami's attributes, its three-state booleans, and the `domains` list that would record nothing |
120
- | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, and dead `UA-` properties |
121
- | `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
122
- | `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
123
- | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
124
- | `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
125
- | `warn.test.ts` | the shared prefix, and reducing a URL to its path |
126
- | `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach |
127
- | `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order |
128
- | `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives |
108
+ | File | What it pins |
109
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | `meta.test.ts` | the `robots` tag a policy builds, the pinned `twitter:card`, share-image warnings, length warnings, `fileUrl` |
111
+ | `not-found.test.ts` | that a 404 carries the site's icon and theme colour, still names no canonical, and is held to the same icon rules |
112
+ | `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap |
113
+ | `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way |
114
+ | `merge.test.ts` | catalog and route overlay precedence — per locale, never per key |
115
+ | `llms.test.ts` | section grouping, owners, heading and link precedence, and the undescribed fallback |
116
+ | `public-files.test.ts` | walking `public/`, what is skipped, and why the declaration must be a module |
117
+ | `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported |
118
+ | `jsonld/*.test.ts` | that a `</script>` in any value cannot close the block, each node's shape and `@id`, and how a price table becomes offers |
119
+ | `analytics.test.ts` | Umami's attributes, its three-state booleans, the `domains` list that would record nothing, and that `consentRequired` answers exactly when a tag was emitted |
120
+ | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, and the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` |
121
+ | `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
122
+ | `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
123
+ | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
124
+ | `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
125
+ | `warn.test.ts` | the shared prefix, and reducing a URL to its path |
126
+ | `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach |
127
+ | `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order |
128
+ | `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives |
129
129
 
130
130
  The fixture carries two nested families for `breadcrumbs.test.ts`, differing in
131
131
  the only way that changes behaviour: `guides` has a route owning its bare slug
@@ -35,6 +35,15 @@ attribute name in this package has to be matched by any consumer.
35
35
  | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
36
36
  | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
37
37
 
38
+ `./astro/consent` is the only one of these with a build-time half. Its
39
+ `consentApplies()` reports whether there is a Google tag to consent to, which a
40
+ banner has to know — but it can only answer once it has been downloaded, so a
41
+ project that asks it alone ships the banner's script to every page to be told,
42
+ three requests later, that there was nothing to ask about. `consentRequired()`
43
+ from the package root is the same question at build time: false, and the banner
44
+ never reaches the HTML. Gate the render on that and keep the runtime check for
45
+ the banner that is rendered. See NOT-BUILT.md on where the halves divide.
46
+
38
47
  ## Lifetimes are the recurring bug
39
48
 
40
49
  Every module here that binds a listener hands back the undo, and the undo is
@@ -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.6",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -53,10 +53,10 @@
53
53
  "typescript": ">=5"
54
54
  },
55
55
  "devDependencies": {
56
- "@biomejs/biome": "2.5.10",
57
- "@types/node": "26.3.0",
56
+ "@biomejs/biome": "2.5.12",
57
+ "@types/node": "26.4.1",
58
58
  "@vitest/coverage-istanbul": "4.1.11",
59
- "astro": "7.2.6",
59
+ "astro": "7.2.10",
60
60
  "typescript": "6.0.3",
61
61
  "vitest": "4.1.11"
62
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"),
@@ -65,10 +65,13 @@ export type ParsedSpan =
65
65
  /**
66
66
  * The marks, and what each produces.
67
67
  *
68
- * Short names because a translator reads past them to the words, and every
69
- * character between `[` and `]` is noise in a sentence they are trying to keep
70
- * natural. Long enough to guess at from context, which `[b]` and `[a]` are and
71
- * `[x]` would not be.
68
+ * `b` is bold, `v` is variant, `a` is anchor; `mail` and `tel` are the schemes
69
+ * they produce, and `br` is the break below four of the six borrowed from HTML
70
+ * outright, so `v` is the only one anybody has to learn, and it is the one HTML
71
+ * has no tag for. Short names beyond that because a translator
72
+ * reads past them to the words, and every character between `[` and `]` is noise
73
+ * in a sentence they are trying to keep natural. Long enough to guess at from
74
+ * context, which `[b]` and `[a]` are and `[x]` would not be.
72
75
  *
73
76
  * `bold` is the one presentational mark lib names outright, because it is not
74
77
  * presentational: it is `<strong>`, it survives into plain text as emphasis a
@@ -393,8 +396,10 @@ function argumentHint(mark: MarkName): string {
393
396
  * whichever function reads it, and swallowing that to answer a yes-or-no
394
397
  * question would leave `t()` printing the bracket it was written to refuse.
395
398
  *
396
- * `"Open [Mon-Fri]"` passes: that is a bracket nobody meant as a mark, `Mon-Fri`
397
- * is not one of the five, and the parser has already said so.
399
+ * `"Open [[Mon-Fri]] only"` passes: a doubled bracket is an escape rather than a
400
+ * mark, so every run is text and `t()` prints it. Undoubled it fails, and fails
401
+ * in the parser — `[Mon-Fri]` opens neither a mark nor an escape, which is not a
402
+ * question about who is reading the message.
398
403
  */
399
404
  export function assertNoMarks(template: string, at: string): void {
400
405
  const marked = parseMarks(template, at).some(
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  CONSENT_UPDATE_GLOBAL,
27
27
  type ConsentDefaults,
28
28
  type ConsentState,
29
+ consentRequired,
29
30
  type GoogleSettings,
30
31
  type UmamiReplay,
31
32
  type UmamiSettings,
package/src/meta/tag.ts CHANGED
@@ -92,6 +92,15 @@ export function asMetaTag(script: AnalyticsTag): MetaTag {
92
92
  if (script.kind === "noscriptFrame") {
93
93
  return { kind: "noscriptFrame", src: script.src };
94
94
  }
95
+ if (script.kind === "preconnect") {
96
+ // A `link` rather than a kind of its own: the head vocabulary already
97
+ // has the shape, and a preconnect is a link in every sense the renderer
98
+ // cares about. See `AnalyticsTag` for why no `crossorigin`.
99
+ return {
100
+ kind: "link",
101
+ attrs: { rel: "preconnect", href: script.origin },
102
+ };
103
+ }
95
104
  const unhandled: never = script;
96
105
  throw new Error(`Unhandled analytics tag: ${JSON.stringify(unhandled)}`);
97
106
  }