@escape-game-over/atlas 0.1.82 → 0.1.83

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
@@ -61,33 +61,37 @@ Linking to a page the active deployment switched off is a compile error, not a
61
61
  One file per deployment, saying only what differs:
62
62
 
63
63
  ```ts
64
- export const { project, redirects } = atlas.project(defaultMessages, defaultRoutes, {
65
- url: "https://acme.example",
66
- siteName: "Acme Rome",
67
- icon,
68
- themeColor: "#1d51e0",
69
-
70
- enabledLocales: ["el-GR", "en-US"],
71
- overrideRouting: { defaultLocale: "el-GR", prefixDefaultLocale: true },
72
-
73
- overrideMessages: {
74
- // Greek only — the English copy still falls through to the default catalog.
75
- "home.hero.body": { "el-GR": "Η {company} κρατά το απόθεμα…" },
76
- },
77
- overrideRoutes: {
78
- careers: { enabled: true }, // opt into a page
79
- contact: { slugByLocale: { "el-GR": "epikoinoniste-mazi-mas" } }, // retranslate a URL
80
- },
81
- })
64
+ export const { project, redirects } = atlas.project(
65
+ defaultMessages,
66
+ defaultRoutes,
67
+ {
68
+ url: "https://acme.example",
69
+ siteName: "Acme Rome",
70
+ icon,
71
+ themeColor: "#1d51e0",
72
+
73
+ enabledLocales: ["el-GR", "en-US"],
74
+ overrideRouting: { defaultLocale: "el-GR", prefixDefaultLocale: true },
75
+
76
+ overrideMessages: {
77
+ // Greek only — the English copy still falls through to the default catalog.
78
+ "home.hero.body": { "el-GR": "Η {company} κρατά το απόθεμα…" },
79
+ },
80
+ overrideRoutes: {
81
+ careers: { enabled: true }, // opt into a page
82
+ contact: { slugByLocale: { "el-GR": "epikoinoniste-mazi-mas" } }, // retranslate a URL
83
+ },
84
+ }
85
+ );
82
86
  ```
83
87
 
84
- Every field is named for what it *does* to the defaults, so a project file can
88
+ Every field is named for what it _does_ to the defaults, so a project file can
85
89
  never be mistaken for the whole truth: `enabledLocales` picks from what the site
86
90
  declares, and every `override*` is a patch over it.
87
91
 
88
92
  ## The four steps
89
93
 
90
- Every step infers its types from the *values* of the last, so a consuming project
94
+ Every step infers its types from the _values_ of the last, so a consuming project
91
95
  never writes a type argument:
92
96
 
93
97
  ```ts
@@ -111,29 +115,33 @@ Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
111
115
  wired.
112
116
 
113
117
  Step 3's `redirects` is the one way to write a redirect: it types one
114
- deployment's old URLs against *that* deployment's pages and languages,
118
+ deployment's old URLs against _that_ deployment's pages and languages,
115
119
  whichever project the site is currently built from. `siteRoutes()` takes what
116
120
  it returns and nothing else, resolves it, and writes `_redirects`:
117
121
 
118
122
  ```ts
119
123
  // projects/rome/redirects.ts
120
124
  export default redirects([
121
- { from: "/jobs", to: { route: "careers" }, kind: "permanent" }, // ✗ if Rome has no careers page
122
- { from: "/en/*", to: { route: "home", locale: "en-US", splat: true }, kind: "permanent" }, // /en/faq → /en-US/faq
123
- ])
125
+ { from: "/jobs", to: { route: "careers" }, kind: "permanent" }, // ✗ if Rome has no careers page
126
+ {
127
+ from: "/en/*",
128
+ to: { route: "home", locale: "en-US", splat: true },
129
+ kind: "permanent",
130
+ }, // /en/faq → /en-US/faq
131
+ ]);
124
132
  ```
125
133
 
126
134
  `t()` is bound to a locale and knows each message's `{placeholders}` from its
127
135
  text:
128
136
 
129
137
  ```ts
130
- const t = site.translate(locale)
138
+ const t = site.translate(locale);
131
139
 
132
- t("contact.intro") // no placeholders, no second argument
133
- t("footer.copyright", { company: "Acme Rome", year: "2026" })
134
- t("footer.copyright") // ✗ params are not optional
135
- t("footer.copyright", { company: "Acme Rome" }) // ✗ {year} missing
136
- t("footer.copyright", { company: "A", year: "1", x: "" }) // ✗ {x} is not a placeholder
140
+ t("contact.intro"); // no placeholders, no second argument
141
+ t("footer.copyright", { company: "Acme Rome", year: "2026" });
142
+ t("footer.copyright"); // ✗ params are not optional
143
+ t("footer.copyright", { company: "Acme Rome" }); // ✗ {year} missing
144
+ t("footer.copyright", { company: "A", year: "1", x: "" }); // ✗ {x} is not a placeholder
137
145
  ```
138
146
 
139
147
  Nothing is auto-injected. Site-wide values are ordinary placeholders, passed
@@ -154,8 +162,8 @@ typed the English.
154
162
  ```
155
163
 
156
164
  ```ts
157
- const rich = site.rich(locale)
158
- rich("about.intro", { venue: "contact" }) // the destination, stated once
165
+ const rich = site.rich(locale);
166
+ rich("about.intro", { venue: "contact" }); // the destination, stated once
159
167
  ```
160
168
 
161
169
  | Mark | Short for | Is |
@@ -235,7 +243,7 @@ regression-tested by an `@ts-expect-error` suite.
235
243
  | Two routes resolving to the same URL | build throws, naming both |
236
244
  | A site icon that is not square, a multiple of 48px, and PNG | build throws |
237
245
 
238
- The runtime throws are the ones types cannot see: facts about a *file* (an
246
+ The runtime throws are the ones types cannot see: facts about a _file_ (an
239
247
  icon's real dimensions), facts only known after merging (URL collisions), or a
240
248
  value typed as plain `string` because it came from an env var. See
241
249
  [`docs/checks.md`](docs/checks.md) for the full split and how each error is
@@ -243,8 +251,8 @@ worded.
243
251
 
244
252
  ## Site-level versus per-page
245
253
 
246
- The same question answers it every time: *would this differ between two pages of
247
- the same site?*
254
+ The same question answers it every time: _would this differ between two pages of
255
+ the same site?_
248
256
 
249
257
  | Per page — passed to `metaFor()` | Site-level — declared in `atlas.project()` |
250
258
  | -------------------------------- | ------------------------------------------ |
@@ -262,7 +270,7 @@ actually renders, and why `twitter:card` is pinned.
262
270
 
263
271
  ## Config that tracks which pages exist
264
272
 
265
- A project switches routes off. Anything defined *alongside* a route then has two
273
+ A project switches routes off. Anything defined _alongside_ a route then has two
266
274
  ways to rot: the page is built and its config is missing, or the page is gone and
267
275
  its config lingers. `PerRoute<typeof site, T>` rejects both.
268
276
 
@@ -274,7 +282,7 @@ against a known target gets it.
274
282
  const heroes = {
275
283
  home: { variant: "wide" },
276
284
  about: { variant: "tall" },
277
- } satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
285
+ } satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
278
286
  ```
279
287
 
280
288
  ## The rules this package keeps
@@ -290,9 +298,9 @@ const heroes = {
290
298
  runs with `"types": []` to keep it honest, and excludes `src/astro/` because
291
299
  that is the one place allowed to import the framework. Put something there
292
300
  only when it genuinely cannot work without Astro, and prefer moving the
293
- framework-dependent *edge* there over moving the logic.
301
+ framework-dependent _edge_ there over moving the logic.
294
302
  4. **Infer from values, never from type arguments.** When a helper needs to be
295
- typed against project data, take that data as a *parameter* and let TypeScript
303
+ typed against project data, take that data as a _parameter_ and let TypeScript
296
304
  infer it. This is what keeps rules 1 and 2 satisfiable without making callers
297
305
  write `<Locale, typeof baseMessages>`.
298
306
 
@@ -314,7 +322,7 @@ Whatever those entry points do not export is internal by construction rather tha
314
322
  by convention, which is what makes the file layout under `src/` a private detail:
315
323
  modules can be split, renamed or flattened without touching a consumer.
316
324
 
317
- The split *within* `astro/` is a further constraint, not tidiness. `./astro` is
325
+ The split _within_ `astro/` is a further constraint, not tidiness. `./astro` is
318
326
  imported by an Astro config, which is evaluated before the build exists — so it
319
327
  may use `node:` built-ins but not `astro:assets`. `./astro/images` is the
320
328
  opposite: it runs inside the build, from a page, and is unusable from a config.
@@ -427,18 +435,18 @@ Three things the `.astro` setup works around rather than fixes. Each is open
427
435
  upstream, and each explains a rule that would otherwise read as an oversight.
428
436
 
429
437
  - [biomejs/biome#10321](https://github.com/biomejs/biome/issues/10321) —
430
- *Astro: not formatting inside `{( … )}`* (`S-Bug-confirmed`). Biome formats
438
+ _Astro: not formatting inside `{( … )}`_ (`S-Bug-confirmed`). Biome formats
431
439
  `.astro` markup but not the interior of an expression, so the 31 `.map()`
432
440
  blocks across the templates keep their own indentation while the markup around
433
441
  them follows `indentWidth`. Hand-aligned until this lands.
434
442
  - [biomejs/biome#9944](https://github.com/biomejs/biome/issues/9944) —
435
- *Astro: Parser error inside expression*. Biome parses the inside of `{ }` as
443
+ _Astro: Parser error inside expression_. Biome parses the inside of `{ }` as
436
444
  strict JSX, which rejects things Astro itself accepts. This is why every
437
445
  comment in a template is `{/* … */}` and never `<!-- … -->`: an HTML comment
438
446
  anywhere inside an expression is a parse error, and the JSX form is also
439
447
  stripped from the built HTML rather than shipped to readers.
440
448
  - [biomejs/biome#11275](https://github.com/biomejs/biome/issues/11275) —
441
- *LSP formatting deletes Astro frontmatter/HTML in multi-project workspaces*
449
+ _LSP formatting deletes Astro frontmatter/HTML in multi-project workspaces_
442
450
  (`S-Bug-confirmed`). It names `html.experimentalFullSupportEnabled`, which this
443
451
  repository sets, and a workspace of several projects, which this repository is.
444
452
  The gate above is CLI-only and unaffected; editor format-on-save is the thing
package/docs/NOT-BUILT.md CHANGED
@@ -4,7 +4,7 @@ Questions that come up, with the answer and the reasoning behind it. Every one
4
4
  of these looks like an obvious addition until you check — so the check is
5
5
  written down once, rather than redone by whoever thinks of it next.
6
6
 
7
- Each answer says what is *not* built and, where it matters, what is still fine
7
+ Each answer says what is _not_ built and, where it matters, what is still fine
8
8
  to build. The two are easy to run together: "no star-rating markup" is not "no
9
9
  reviews", and reading it as the second would lose something worth having.
10
10
 
@@ -32,17 +32,17 @@ below still stands: the node renders nowhere, a model does not read it out of a
32
32
  `<script>` block, and it duplicates the page's own text at several KB per load.
33
33
  What is left in its favour is Bing, which has said its LLMs read schema.
34
34
 
35
- Which makes this a call a *site* makes per page, not one lib makes for every
35
+ Which makes this a call a _site_ makes per page, not one lib makes for every
36
36
  repo it syncs to — hence a builder that has to be called and passed into a
37
37
  graph, rather than anything wired into `metaFor()`. A project that has not
38
38
  weighed the payload against a Copilot payoff it can actually measure should keep
39
39
  not calling it, which is the same answer this entry always gave.
40
40
 
41
- The two arguments that *sound* decisive and are wrong are recorded below, since
41
+ The two arguments that _sound_ decisive and are wrong are recorded below, since
42
42
  they will be made again either way.
43
43
 
44
- Google restricted FAQ rich results to *"well-known, authoritative government and
45
- health websites"* in 2023, then [removed them from Search entirely](https://developers.google.com/search/docs/appearance/structured-data/faqpage)
44
+ Google restricted FAQ rich results to _"well-known, authoritative government and
45
+ health websites"_ in 2023, then [removed them from Search entirely](https://developers.google.com/search/docs/appearance/structured-data/faqpage)
46
46
  on 7 May 2026 and archived the documentation. A commercial site marking up its
47
47
  FAQ gets nothing rendered for the effort.
48
48
 
@@ -52,17 +52,17 @@ the HTML-to-text pipelines that feed a model routinely drop `<script>`, often th
52
52
  whole `<head>` with it. Where a block does survive, what arrives is
53
53
  `{"@type":"Question","name":…}` as tokens: the punctuation of a graph rather than
54
54
  a parsed one. Independent testing through 2025–26 has repeatedly found that
55
- content present *only* in structured data goes unextracted, while the same
55
+ content present _only_ in structured data goes unextracted, while the same
56
56
  content in visible headings and paragraphs is read reliably.
57
57
 
58
58
  That is not an argument against the nodes lib does build. Schema is parsed at
59
- *indexing* time, so it reaches the AI surfaces built on top of a search index —
59
+ _indexing_ time, so it reaches the AI surfaces built on top of a search index —
60
60
  AI Overviews, Copilot — even though a chatbot fetching the page directly sees
61
61
  only the visible text. It is an argument against reaching for this one expecting
62
62
  a model to pick the answers up, which is the reason it is given every time.
63
63
 
64
64
  The one cost that counts is the payload. Every other node lib builds is metadata
65
- *about* the page — an address, a price, a date. This one is a verbatim *copy of*
65
+ _about_ the page — an address, a price, a date. This one is a verbatim _copy of_
66
66
  it: every question and every full answer, a second time, in a `<script>` block on
67
67
  the page already showing them. A ten-question FAQ is several KB duplicated on
68
68
  every load, and page weight is paid per visitor, forever.
@@ -116,12 +116,12 @@ answer for each is a fact about Google rather than a preference.
116
116
  clothes. Where there is a real number, `Offer` already carries it.
117
117
  - **`ImageObject`.** Both sibling implementations are `{"@type": "ImageObject",
118
118
  url}` and nothing else, which is a wrapper around the plain URL string Google
119
- already accepts. Article documents the field as *"Repeated `ImageObject` or
120
- `URL`"* — neither form preferred — and **width and height appear nowhere in
119
+ already accepts. Article documents the field as _"Repeated `ImageObject` or
120
+ `URL`"_ — neither form preferred — and **width and height appear nowhere in
121
121
  either the Article guidance or the image-licence guidance**. So having the
122
122
  dimensions is not a reason to emit the node; lib does have them for the share
123
123
  image, the icon and a video thumbnail, and Google has no use for them.
124
- What the node *does* unlock is the **Licensable badge** in Google Images, and
124
+ What the node _does_ unlock is the **Licensable badge** in Google Images, and
125
125
  that needs `contentUrl` plus at least one of `license`, `creator`,
126
126
  `creditText` or `copyrightNotice` — rights data no project here holds. Build
127
127
  it the day someone wants that badge, and build it around the licence fields
@@ -137,8 +137,8 @@ does and does not buy.
137
137
 
138
138
  A section is the part of a publication a piece lives in: Sports, Business,
139
139
  Opinion. Open Graph's `article:section` takes one, and schema.org's
140
- `articleSection` is the same idea. It is *where a piece sits*, as against tags,
141
- which are *what it is about* — one home versus many labels.
140
+ `articleSection` is the same idea. It is _where a piece sits_, as against tags,
141
+ which are _what it is about_ — one home versus many labels.
142
142
 
143
143
  Everything here sits in the same place. A `section` field would say `"News"` on
144
144
  every post for as long as `/news` is the only feed, which is the URL repeated
@@ -172,7 +172,7 @@ Reviews a business collects about itself never qualify, so venue-level stars are
172
172
  unreachable however good the data is — they come from Google Business Profile
173
173
  instead, which is outside this repo.
174
174
 
175
- Room-level ratings on the `Product`/`Game` node *are* eligible. They need reviews
175
+ Room-level ratings on the `Product`/`Game` node _are_ eligible. They need reviews
176
176
  collected on the site itself, shown in full on the page, and averaged over
177
177
  exactly the set displayed — never a number typed in beside a shorter list.
178
178
 
@@ -191,14 +191,14 @@ language registry](https://www.iana.org/assignments/language-subtag-registry) is
191
191
  not — which is the case `LanguageTag` in `config.ts` declines and says why.
192
192
 
193
193
  What moved is not the risk, it is the reason to hold the list at all. A country
194
- menu needs the codes *as data* — `countriesFor` renders and sorts them per
194
+ menu needs the codes _as data_ — `countriesFor` renders and sorts them per
195
195
  locale — and once the list exists, deriving the type from it is free and the
196
196
  shape is strictly worse: it admitted 676 strings for 250 countries, including
197
197
  the withdrawn codes (`SU`, `YU`, `AN`) that look plausible enough to be typed by
198
198
  accident. So `CountryCode` is now `(typeof COUNTRY_CODES)[number]`, defined in
199
199
  `countries.ts` and re-exported from `contact.ts` beside `PostalAddress`.
200
200
 
201
- `CurrencyCode` stays a shape, because nothing needs a currency *list*: a
201
+ `CurrencyCode` stays a shape, because nothing needs a currency _list_: a
202
202
  currency is written once per project, beside the prices it applies to, and a
203
203
  wrong one is visible the first time a page renders. Enumerate it the day
204
204
  something renders a currency picker, and not before.
@@ -221,7 +221,7 @@ without the rejection, but it means carrying a curated list for editor
221
221
  convenience alone, and a list that earns nothing else is a list that drifts
222
222
  unnoticed.
223
223
 
224
- The version worth building, if this ever bites: validate the *emitted* JSON-LD
224
+ The version worth building, if this ever bites: validate the _emitted_ JSON-LD
225
225
  against a vocabulary dump, in a test. That sees all 800 types, and sees what no
226
226
  type could — a property that is not valid for the type it sits on.
227
227
 
@@ -245,12 +245,12 @@ subtitle: [
245
245
  { type: "bold", content: "hero.subtitle_text_2" },
246
246
  { type: "text", content: "hero.subtitle_text_3" },
247
247
  // …through _7
248
- ]
248
+ ];
249
249
  ```
250
250
 
251
251
  **It fixes two things copy is not allowed to fix.** Word order, which differs
252
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
253
+ forever. And the _number of runs_, which differs more: a clause English spends
254
254
  four fragments on is one word in Greek, and there is no way to write that in an
255
255
  array whose length was decided by whoever typed the English. The extra keys get
256
256
  padded with empty strings or the spare ones go untranslated, and either way the
@@ -269,7 +269,7 @@ compared across locales by the machinery that already compares `{placeholders}`
269
269
  — see `Tokens` in `i18n/placeholders.ts` — so a translation that drops the link
270
270
  fails the build rather than the page. That check is the reason marks are safe to
271
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.
272
+ structure in config, no locale _could_ lose a link. Marks had to earn that back.
273
273
 
274
274
  **A mark names a slot, never a destination**, and that distinction is the second
275
275
  thing the array shape got right and the first version of this got wrong. `venue`
@@ -294,7 +294,7 @@ whose whole claim is that a translation cannot silently go wrong. The migration
294
294
  is happening once; this is the moment it costs least to fix, and every store
295
295
  collapsed to marks is a store whose copy a translator can finally read.
296
296
 
297
- ### Should lib carry the consent *banner* as well as the tags?
297
+ ### Should lib carry the consent _banner_ as well as the tags?
298
298
 
299
299
  **No — and the line between the two halves is the point.**
300
300
 
@@ -315,12 +315,12 @@ somebody's compliance.
315
315
  **One part of it is lib's, and is built as of August 2026: whether there should
316
316
  be a banner at all.** `consentRequired(analytics)`, with the reasoning in
317
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
318
+ a question about _tags_ — which lib owns — wearing the costume of a question
319
319
  about banners, and because the obvious check is wrong in both directions.
320
320
  Everything above about the banner itself still stands.
321
321
 
322
322
  The seam between the halves is a global — `CONSENT_UPDATE_GLOBAL` — because
323
- that is the only place they *can* meet: the head script runs before any module
323
+ that is the only place they _can_ meet: the head script runs before any module
324
324
  exists. lib emits an updater covering exactly the signals it defaulted, so a
325
325
  banner never names them and the two lists cannot drift. `src/astro/consent.ts`
326
326
  carries the browser-side parts that are the same everywhere — reading an answer,
@@ -351,7 +351,7 @@ Two traps live with this, both spelled out where the code is. A preconnect is
351
351
  reused only by a request whose CORS mode matches it, so one marked
352
352
  `crossorigin` for a classic `<script src>` warms nothing while looking entirely
353
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
354
+ And Tag Manager's loader URL is written _by_ the inline block, so nothing in the
355
355
  markup names that origin until the script has run, which is what makes the
356
356
  connection worth stating rather than leaving to the preload scanner.
357
357
 
@@ -384,7 +384,7 @@ repo, which is why this entry exists — reading that file is what prompts the
384
384
  question.
385
385
 
386
386
  Nothing is served by Apache any more. Worth knowing the seam is already there if
387
- that ever changes: the site resolves rules to plain *data*, and
387
+ that ever changes: the site resolves rules to plain _data_, and
388
388
  `buildCloudflareRedirects` is one renderer over them rather than the only
389
389
  possible one, so another host is a new function and no redesign. The two
390
390
  non-redirect halves of that `.htaccess` — extension resolution and slash
@@ -419,13 +419,13 @@ staleness, and `generate()` rebuilding per request does not cure it — it deriv
419
419
  the same answer from the same frozen inputs.
420
420
 
421
421
  Routes look like the fix, and they genuinely are one. Measured on the b2b
422
- example: an injected endpoint that reaches the site through the *page* graph
422
+ example: an injected endpoint that reaches the site through the _page_ graph
423
423
  served the edited string immediately, side by side with the integration's
424
424
  middleware still serving the old one.
425
425
 
426
426
  **And `_redirects` can be one of those routes** — worth recording, because the
427
427
  old comment in `site-routes.ts` said it could not be, and that was wrong. Astro's
428
- page walker skips a *filename* beginning with `_`; an injected route names its
428
+ page walker skips a _filename_ beginning with `_`; an injected route names its
429
429
  pattern and its entrypoint separately, so the underscore lands only on the URL.
430
430
  It was built, served in dev, and written to `dist/_redirects` by the route alone.
431
431
 
@@ -474,7 +474,7 @@ replaced.
474
474
  **No, and a post's trail should point at the list, not at the page it is listed
475
475
  on.**
476
476
 
477
- A `BreadcrumbList` states *hierarchical* position, and pagination is lateral
477
+ A `BreadcrumbList` states _hierarchical_ position, and pagination is lateral
478
478
  movement between siblings at one level: `Home › News` is the trail on `/news`
479
479
  and on `/news/page/7` alike. Google renders breadcrumbs in results, so a
480
480
  trailing `News (2)` buys noise in the SERP for a listing page that is already a
@@ -505,7 +505,7 @@ looking at" better than any link can.
505
505
  With `prefixDefaultLocale: false` the default locale is served at `/about-us`
506
506
  and nothing is built under `/en-US/`. Claiming the pages under it one rule at a
507
507
  time does not work: slugs are translated per locale, so the URL a reader lands
508
- on by editing `/el-GR/sxetika-me-emas` down to English keeps the *Greek* slug —
508
+ on by editing `/el-GR/sxetika-me-emas` down to English keeps the _Greek_ slug —
509
509
  `/en-US/sxetika-me-emas`. Covering that means the cross product of the prefix
510
510
  with every locale's spelling of every slug, a `_redirects` of hundreds against
511
511
  the 2000 a host reads.
@@ -521,7 +521,7 @@ left out if anything is built under the prefix.
521
521
 
522
522
  ## Adding to this
523
523
 
524
- One question, one answer, one reason. Say what is *not* built and what still is
524
+ One question, one answer, one reason. Say what is _not_ built and what still is
525
525
  — the scope is the part that gets lost. If the answer rests on someone else's
526
526
  decision, put it in the first group and name the date, so a reader can tell a
527
527
  settled fact from one worth re-checking.
package/docs/checks.md CHANGED
@@ -18,12 +18,12 @@ type-checking would pass all of it.
18
18
 
19
19
  ## Compile errors, and where they are stated
20
20
 
21
- Most guarantees are compile errors, stated in a *constraint* rather than in the
21
+ Most guarantees are compile errors, stated in a _constraint_ rather than in the
22
22
  parameter type — in parameter position a single unrelated mistake widens the
23
23
  inferred type and every key then reports as malformed, burying the real error in
24
24
  a cascade.
25
25
 
26
- That rule inverts when the validator maps the *whole* object rather than one of
26
+ That rule inverts when the validator maps the _whole_ object rather than one of
27
27
  its properties, as the slug-depth checks do: in the constraint, any unrelated
28
28
  malformation makes the argument itself unassignable and TypeScript reports at the
29
29
  call site instead of the offending line. Those two live in parameter position for
@@ -50,7 +50,7 @@ sees.
50
50
  | a `lastmod` that is not a `YYYY-MM-DD` calendar date | |
51
51
  | config for a route this project does not build, or missing for one it does (`PerRoute`) | |
52
52
 
53
- The runtime checks are the ones types cannot see: facts about a *file* (an icon's
53
+ The runtime checks are the ones types cannot see: facts about a _file_ (an icon's
54
54
  real dimensions), facts only known after merging (URL collisions), or a value
55
55
  typed as plain `string` because it came from an env var, where the literal checks
56
56
  pass vacuously.
@@ -87,7 +87,7 @@ consuming project's own `type-tests/`, which is also the only place the
87
87
  augmentation is real. Both examples carry one:
88
88
  [`b2c`](../examples/b2c/type-tests/wiring.ts) asserts against a `public/` with
89
89
  two files in it, and [`b2b`](../examples/b2b/type-tests/wiring.ts) against one
90
- with none, where the union is empty and *every* path is rejected.
90
+ with none, where the union is empty and _every_ path is rejected.
91
91
 
92
92
  Declared analytics events are the other augmentation, and the opposite case:
93
93
  nothing is inferred, so a declaration means the same thing here as in a project.
@@ -152,10 +152,10 @@ does not carry; see [`client-scripts.md`](client-scripts.md).
152
152
 
153
153
  ## What a consumer checks
154
154
 
155
- A consuming project keeps its own thin suite for *wiring* — that its catalog,
155
+ A consuming project keeps its own thin suite for _wiring_ — that its catalog,
156
156
  routes and config actually satisfy these contracts, and that its own types have
157
157
  not been loosened by a stray `any` or a dropped `satisfies`. It does not restate
158
158
  the machinery's behaviour; that is what the two suites above are for.
159
159
 
160
160
  Both examples' `type-tests/wiring.ts` are deliberately short, and every assertion
161
- in them names something that must stay *rejected*.
161
+ in them names something that must stay _rejected_.
@@ -22,11 +22,11 @@ than a view of something else, and a `hidden` on a clear button, a no-results
22
22
  message or a section with nothing left in it is binary and derived — there is no
23
23
  version of "the clear button while the field is empty" that is a matter of taste.
24
24
  It sets no class, no style and no `aria`, which is where the choices live. It
25
- also never *finds* an element: it is handed the ones a project looked up, so no
25
+ also never _finds_ an element: it is handed the ones a project looked up, so no
26
26
  attribute name in this package has to be matched by any consumer.
27
27
 
28
28
  `youtube` is the other exception, and the one that cannot be avoided: the player
29
- *is* the behaviour, so something has to put an iframe in the page. What it sets
29
+ _is_ the behaviour, so something has to put an iframe in the page. What it sets
30
30
  on it has no taste in it — the address, the title, the permissions, full screen,
31
31
  the referrer policy — and everything that does, classes above all, comes from the
32
32
  caller through a callback. It too is handed the button and the box rather than
@@ -78,10 +78,10 @@ the options, and when one drifts nothing reports it — a listener added without
78
78
  its twin simply survives teardown.
79
79
 
80
80
  ```ts
81
- const detach = slider.attach(viewport); // carousel: one element
82
- const detach = list.attach(); // filters: no element, only popstate
83
- const detach = loop.attach(video); // background-video: the <video>
84
- const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
81
+ const detach = slider.attach(viewport); // carousel: one element
82
+ const detach = list.attach(); // filters: no element, only popstate
83
+ const detach = loop.attach(video); // background-video: the <video>
84
+ const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
85
85
  ```
86
86
 
87
87
  `element` says the same thing in the shape a custom element needs: its function
@@ -90,8 +90,10 @@ element leaves, and what it returns is the undo for everything else.
90
90
 
91
91
  ```ts
92
92
  export const thing = element("go-thing", ({ root, signal }) => {
93
- ref(root, "[data-thing-button]").addEventListener("click", open, { signal }); // ends with the visit
94
- return loop.attach(root); // and so does this
93
+ ref(root, "[data-thing-button]").addEventListener("click", open, {
94
+ signal,
95
+ }); // ends with the visit
96
+ return loop.attach(root); // and so does this
95
97
  });
96
98
  ```
97
99
 
@@ -129,7 +131,7 @@ const detach = list.attach();
129
131
 
130
132
  A field is its kind, and its URL parameter is its own name — to rename the
131
133
  parameter, rename the field. `{ kind: "text", url: false }` keeps a field out of
132
- the URL, for a list whose page owns the URL itself. `values` is typed *from*
134
+ the URL, for a list whose page owns the URL itself. `values` is typed _from_
133
135
  `fields`, so a `flag` demands a boolean and a mismatch is a compile error rather
134
136
  than an item that silently never matches. It is read once per item, at
135
137
  construction. `matched` is a set of the caller's own items, so there is no key
@@ -149,7 +151,7 @@ and then answers to any of them:
149
151
  values: (room) => ({ category: data(room, "categories").split(" ") }),
150
152
  ```
151
153
 
152
- An escape room is adventure *and* sci-fi; a film is a comedy *and* a drama. This
154
+ An escape room is adventure _and_ sci-fi; a film is a comedy _and_ a drama. This
153
155
  is a widening of `choice` rather than a fourth kind, because a kind for it would
154
156
  carry the same state, the same parameter, the same `set` semantics and the same
155
157
  control, and differ by one operator — two kinds doing one job, which is what the
@@ -158,7 +160,7 @@ bare string and is untouched.
158
160
 
159
161
  **`text` folds both sides** — `NFD`, drop the combining marks, lowercase, trim.
160
162
  This is the piece that is missing nearly everywhere, and it is not cosmetic: a
161
- reader who types `malaga` is otherwise told there is no *Málaga*, and every
163
+ reader who types `malaga` is otherwise told there is no _Málaga_, and every
162
164
  Romanian entry spelled with `ă`, `ș` or `ț` hides from anyone whose keyboard does
163
165
  not carry them. Item text is folded once at construction; only the query is
164
166
  folded per render.
@@ -170,8 +172,8 @@ whole query as one substring makes that accident load-bearing, so a two-word
170
172
  query fails whenever the words are not adjacent.
171
173
 
172
174
  **`flag` is a narrowing, not a two-way filter.** Off keeps everything rather than
173
- keeping the items that are *not* flagged — "In stock", unchecked, does not mean
174
- "out of stock". On, off and *either* is a `choice` with two values; folding that
175
+ keeping the items that are _not_ flagged — "In stock", unchecked, does not mean
176
+ "out of stock". On, off and _either_ is a `choice` with two values; folding that
175
177
  into a toggle produces a control with a third position nothing can reach.
176
178
 
177
179
  Flags are not exclusive. A second toggle is another **field**, not another value
@@ -183,7 +185,7 @@ parameters or comma-joined) that nothing has needed yet.
183
185
  ### Facet counts: `matchedWithout`
184
186
 
185
187
  A dropdown that says how many results each option would give needs a different
186
- set from `matched`: every field *except its own*. Counted against `matched`, a
188
+ set from `matched`: every field _except its own_. Counted against `matched`, a
187
189
  facet whose value is already picked can only ever offer that value — every other
188
190
  option reads zero, because the items holding them were just filtered out by the
189
191
  very field being asked about.
@@ -220,7 +222,7 @@ construction.
220
222
  1. **Replace while typing, push on a choice.** An input emits an event per
221
223
  keystroke, and history should not fill with a word being spelled; picking a
222
224
  category is one deliberate act the back button should undo. The default comes
223
- from the kind, but it is really a fact about the *input* — a `<select>` or a
225
+ from the kind, but it is really a fact about the _input_ — a `<select>` or a
224
226
  preset link bound to a `text` field wants `list.set("q", v, { history: "push" })`.
225
227
  2. **Empty parameters are dropped**, and an empty state is the bare path rather
226
228
  than a trailing `?`. Both spellings are the same page; one of them is worth
@@ -232,7 +234,7 @@ construction.
232
234
  because nobody deleted it. Fields write in declaration order, so one state
233
235
  always produces one URL.
234
236
 
235
- `attach` reads the URL *before* the first `onChange`. That is what lets a list
237
+ `attach` reads the URL _before_ the first `onChange`. That is what lets a list
236
238
  work before this script arrives: a `<form method="get">` submits, the server
237
239
  renders the filtered page, and the first announcement continues from that state
238
240
  instead of flashing the whole list back. Nothing writes to the URL before
@@ -263,7 +265,8 @@ byte.
263
265
 
264
266
  ```ts
265
267
  const list = filters({
266
- fields, items,
268
+ fields,
269
+ items,
267
270
  onChange({ matched }) {
268
271
  // …everything this list draws for itself
269
272
  },
@@ -317,13 +320,13 @@ onChange({ matched }) {
317
320
  just written it and that is the shortest true description of what survived — and
318
321
  because reading it is what lets the helper nest. A country is empty when every
319
322
  city in it is hidden; a region is empty when every country in it is hidden,
320
- *including the ones the previous call just hid*.
323
+ _including the ones the previous call just hid_.
321
324
 
322
325
  **Which makes the order load-bearing: innermost first.**
323
326
 
324
327
  ```ts
325
328
  hideEmpty(countries, (c) => citiesIn.get(c) ?? []);
326
- hideEmpty(regions, (r) => countriesIn.get(r) ?? []);
329
+ hideEmpty(regions, (r) => countriesIn.get(r) ?? []);
327
330
  ```
328
331
 
329
332
  Run the other way round, the regions are judged against countries nothing has
@@ -422,9 +425,20 @@ import AtlasElement from "@escape-game-over/atlas/astro/element";
422
425
 
423
426
  <script src="./faq.ts"></script>
424
427
 
425
- <AtlasElement of={faq} class="…">
426
- <input data-faq-search type="search">
427
- <details data-faq-row data-key={key}>…</details>
428
+ <AtlasElement
429
+ of={faq}
430
+ class="…"
431
+ >
432
+ <input
433
+ data-faq-search
434
+ type="search"
435
+ >
436
+ <details
437
+ data-faq-row
438
+ data-key={key}
439
+ >
440
+ …
441
+ </details>
428
442
  </AtlasElement>
429
443
  ```
430
444
 
@@ -456,11 +470,15 @@ attribute, and the script writes only that run:
456
470
  ```
457
471
 
458
472
  ```astro
459
- <RichText spans={rich("faq.matched", { count: "" })} attrs={{ count: { "data-faq-count": "" } }} />
473
+ <RichText
474
+ spans={rich("faq.matched", { count: "" })}
475
+ attrs={{ count: { "data-faq-count": "" } }}
476
+ />
460
477
  ```
461
478
 
462
479
  ```ts
463
- for (const each of refs(root, "[data-faq-count]")) each.textContent = String(hits.size);
480
+ for (const each of refs(root, "[data-faq-count]"))
481
+ each.textContent = String(hits.size);
464
482
  ```
465
483
 
466
484
  Marks are parsed before placeholders are filled, so a run filled with `""`
@@ -557,7 +575,7 @@ What it owns is what every hero loop needs and most get quietly wrong:
557
575
  - **The right cut after a rotation.** A browser chooses among `<source media>`
558
576
  once, when the element loads, and never again, so a phone turned sideways
559
577
  keeps the portrait cut. This listens to every source's query and calls
560
- `load()` when the answer changes — *the answer*, not any crossing: a later
578
+ `load()` when the answer changes — _the answer_, not any crossing: a later
561
579
  source's query flipping while an earlier one still matches chooses the same
562
580
  file, and a reload would restart the loop from its first frame for nothing.
563
581
  - **The reader's pause surviving that reload.** A reload resets the element to
@@ -633,7 +651,7 @@ carries a name only, so its entries are all `undefined`. Snap and Axon take no
633
651
  declarations because they accept no names but their own.
634
652
 
635
653
  **The file must be a module** — the `export {}` above. Without an import or an
636
- export, `declare module` *replaces* the package's types instead of adding to
654
+ export, `declare module` _replaces_ the package's types instead of adding to
637
655
  them, and every import from it breaks.
638
656
 
639
657
  **Declared data is sent as written, so it names what the visitor chose, never
@@ -651,7 +669,7 @@ the same reason — that sends:
651
669
 
652
670
  - with `tagIds`: `gtag("event", name, data)`, which `gtag.js` reads;
653
671
  - with `containerIds`: `dataLayer.push({ event: name, ...data })`, which is what
654
- a Tag Manager *Custom Event* trigger listens for — the `dataLayer.push` a
672
+ a Tag Manager _Custom Event_ trigger listens for — the `dataLayer.push` a
655
673
  marketing agency's brief asks for;
656
674
  - with both, both. A container that also runs a GA4 tag on the same event then
657
675
  counts it twice; that is set up in Tag Manager, where lib cannot see it.
@@ -673,7 +691,7 @@ right properties is a faithful stand-in.
673
691
  listener, covering the matching, `matchedWithout`, the four URL decisions and
674
692
  attach/detach.
675
693
  - `tests/filters-view.test.ts` fakes plain objects with a `hidden` property,
676
- pins the write-back guard by counting *writes* — so deleting the guard fails
694
+ pins the write-back guard by counting _writes_ — so deleting the guard fails
677
695
  rather than passing on an identical value — and pins the nesting order in
678
696
  both directions.
679
697
  - `tests/filtering.test.ts` stubs `document` with a `startViewTransition` whose
package/docs/rich-text.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rich text: the marks a message may carry
2
2
 
3
- `t()` prints a message. `rich()` reads the marks *in* one and hands back runs —
3
+ `t()` prints a message. `rich()` reads the marks _in_ one and hands back runs —
4
4
  so a sentence with emphasis, a link and a line break in it stays **one message**,
5
5
  which is the only shape a translator can reorder.
6
6
 
@@ -13,7 +13,7 @@ which is the only shape a translator can reorder.
13
13
 
14
14
  ```ts
15
15
  const rich = site.rich(locale);
16
- rich("about.intro", { venue: "contact" }) // the destination, stated once
16
+ rich("about.intro", { venue: "contact" }); // the destination, stated once
17
17
  ```
18
18
 
19
19
  Greek moves the link to the front and spends fewer runs on the clause. Neither is
@@ -49,7 +49,7 @@ sentence the way their tags do, and `[x]` would not.
49
49
 
50
50
  `[b]` is the one presentational-looking mark lib names outright, and it is not
51
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:]`.
52
+ screen reader announces. Everything that is genuinely a _style_ is `[v:]`.
53
53
 
54
54
  ## An argument is a role or a slot, never a value
55
55
 
@@ -108,7 +108,7 @@ and fills none.
108
108
  ## Marks do not nest
109
109
 
110
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
111
+ than flattened, because a run is bold _or_ it carries a variant and there is
112
112
  nowhere for the second one to go.
113
113
 
114
114
  If the combination is wanted, it is a role: give one variant both properties and
@@ -119,7 +119,7 @@ name it once. (The shape this replaces answered the same limit by inventing a
119
119
  without closing the mark:
120
120
 
121
121
  ```ts
122
- parseMarks("[b]One[br]Two[/b]")
122
+ parseMarks("[b]One[br]Two[/b]");
123
123
  // [ bold "One", break, bold "Two" ]
124
124
  ```
125
125
 
@@ -212,7 +212,7 @@ every link in every description in order to throw them all away.
212
212
 
213
213
  ```ts
214
214
  const plainText = site.plain(locale);
215
- plainText("about.intro", { company }) // no `venue`, no `ask`
215
+ plainText("about.intro", { company }); // no `venue`, no `ask`
216
216
  ```
217
217
 
218
218
  Reach for `t()` when the copy is structurally plain and should stay that way — a
@@ -229,7 +229,10 @@ and escapes all the copy. A `[v:]` role and an in-page anchor come out as their
229
229
  words.
230
230
 
231
231
  ```ts
232
- faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }], at)
232
+ faqPage(
233
+ [{ question, answer: html(rich("faq.city.a", { network: "network" })) }],
234
+ at
235
+ );
233
236
  ```
234
237
 
235
238
  ## The renderer half
@@ -241,7 +244,11 @@ per `[v:name]` and one for links — usually in a small wrapper
241
244
 
242
245
  ```astro
243
246
  <p class="…">
244
- <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
247
+ <RichText
248
+ spans={rich("about.intro", { venue: "contact" })}
249
+ variants={{ accent: "…" }}
250
+ link="…"
251
+ />
245
252
  </p>
246
253
  ```
247
254
 
@@ -54,4 +54,4 @@ padded — and the failure was invisible, because a card that renders is a card
54
54
  that looks like it worked. One value, no threshold to sit near, nothing to
55
55
  override. `tests/meta.test.ts` pins it at three sizes including a square one.
56
56
 
57
- What Atlas does *not* model is a second image for X alone: one page, one picture.
57
+ What Atlas does _not_ model is a second image for X alone: one page, one picture.
package/docs/toolchain.md CHANGED
@@ -28,7 +28,7 @@ it matters. Track
28
28
  One without the other is the trap. `experimentalFullSupportEnabled` moves
29
29
  `.astro` onto the HTML-family path, where the formatter is off unless it is
30
30
  asked for — so setting only the first drops every `.astro` file from
31
- `biome format` entirely. It reports them as *ignored* rather than as errors, so
31
+ `biome format` entirely. It reports them as _ignored_ rather than as errors, so
32
32
  `check-fmt` stays green while nothing is being formatted at all.
33
33
 
34
34
  With both set, `noUnusedVariables` and `noUnusedImports` resolve template usage
@@ -56,7 +56,7 @@ single catch-all page in each example, from the route registry.
56
56
  ## Formatting
57
57
 
58
58
  4-space indent, double quotes, `es5` trailing commas, LF. Biome formats `.astro`
59
- frontmatter *and* markup, but not the inside of a `{ }` expression — so the
59
+ frontmatter _and_ markup, but not the inside of a `{ }` expression — so the
60
60
  `.map()` blocks in the examples stay hand-aligned while the markup around them
61
61
  follows `indentWidth`. See the open Biome issues in the README.
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.82",
3
+ "version": "0.1.83",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -49,7 +49,7 @@
49
49
  "check-fmt": "biome check . --error-on-warnings"
50
50
  },
51
51
  "dependencies": {
52
- "@types/google.maps": "3.66.6"
52
+ "@types/google.maps": "3.66.9"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@types/node": ">=22",
@@ -58,11 +58,11 @@
58
58
  "vite": ">= 8"
59
59
  },
60
60
  "devDependencies": {
61
- "@biomejs/biome": "2.5.15",
62
- "@types/node": "26.6.5",
63
- "@vitest/coverage-istanbul": "5.0.4",
64
- "astro": "7.4.0",
61
+ "@biomejs/biome": "2.6.4",
62
+ "@types/node": "26.6.6",
63
+ "@vitest/coverage-istanbul": "5.0.5",
64
+ "astro": "7.4.4",
65
65
  "typescript": "6.0.3",
66
- "vitest": "5.0.4"
66
+ "vitest": "5.0.5"
67
67
  }
68
68
  }