@escape-game-over/atlas 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. package/src/xml.ts +103 -0
@@ -0,0 +1,329 @@
1
+ # What we don't build, and why
2
+
3
+ Questions that come up, with the answer and the reasoning behind it. Every one
4
+ of these looks like an obvious addition until you check — so the check is
5
+ written down once, rather than redone by whoever thinks of it next.
6
+
7
+ Each answer says what is *not* built and, where it matters, what is still fine
8
+ to build. The two are easy to run together: "no FAQ structured data" is not "no
9
+ FAQ page", and reading it as the second would lose something worth having.
10
+
11
+ Kept beside the code rather than in the backlog, because a backlog is a list of
12
+ things still to do and every one of these is a thing not to do.
13
+
14
+ **Two groups.** The first rests on decisions the search engines made and could
15
+ reverse — re-read it occasionally. The second turns on how our data and our
16
+ sites are shaped, and will not change on its own.
17
+
18
+ ---
19
+
20
+ ## Answers that could change
21
+
22
+ ### Should we mark up our FAQ with `FAQPage`?
23
+
24
+ **Not now — and this one genuinely depends. The page is a fine idea either way.**
25
+
26
+ The most conditional answer in this file, so it is worth reading before
27
+ assuming. Three things hold it shut today and the first two could move. Two
28
+ further arguments that *sound* decisive are wrong, and are recorded below so
29
+ they stop being made.
30
+
31
+ Google restricted FAQ rich results to *"well-known, authoritative government and
32
+ health websites"* in 2023, then [removed them from Search entirely](https://developers.google.com/search/docs/appearance/structured-data/faqpage)
33
+ on 7 May 2026 and archived the documentation. A commercial site marking up its
34
+ FAQ gets nothing rendered for the effort.
35
+
36
+ **And no, a model does not read it either** — the follow-up worth recording,
37
+ because it is the one that reopens this. JSON-LD sits in a `<script>` block, and
38
+ the HTML-to-text pipelines that feed a model routinely drop `<script>`, often the
39
+ whole `<head>` with it. Where a block does survive, what arrives is
40
+ `{"@type":"Question","name":…}` as tokens: the punctuation of a graph rather than
41
+ a parsed one. Independent testing through 2025–26 has repeatedly found that
42
+ content present *only* in structured data goes unextracted, while the same
43
+ content in visible headings and paragraphs is read reliably.
44
+
45
+ That is not an argument against the nodes lib does build. Schema is parsed at
46
+ *indexing* time, so it reaches the AI surfaces built on top of a search index —
47
+ AI Overviews, Copilot — even though a chatbot fetching the page directly sees
48
+ only the visible text. It is an argument against adding one that no longer
49
+ renders anywhere and is not read directly either.
50
+
51
+ The one cost that counts is the payload. Every other node lib builds is metadata
52
+ *about* the page — an address, a price, a date. This one is a verbatim *copy of*
53
+ it: every question and every full answer, a second time, in a `<script>` block on
54
+ the page already showing them. A ten-question FAQ is several KB duplicated on
55
+ every load, and page weight is paid per visitor, forever.
56
+
57
+ **What is not a reason, and was argued twice before landing here.** That it
58
+ would be hand-maintained: false — the FAQ lives in config as question-and-answer
59
+ pairs the page renders, so the node derives from that same array exactly as
60
+ `Product` derives from the price the page shows. Nothing to keep in step,
61
+ nothing to drift. And that it is code to write in lib: lib exists to absorb that
62
+ cost once for every repo it syncs to, so "it is 150 lines" is an argument
63
+ against lib itself, and applied evenly it forbids everything already in here.
64
+ Neither survives — which is why what stands above is about what consumes the
65
+ markup and what it costs to send, and not about how much work it is to write.
66
+
67
+ **What would flip it.** Google reinstating FAQ rich results is the obvious one.
68
+ The likelier one is Bing: it has said outright that its LLMs read schema, which
69
+ is the single live third-party payoff here, so if Copilot visibility ever
70
+ becomes something these venues actually measure rather than speculate about,
71
+ build it. The data is already in config, so it is a small build on the day the
72
+ trigger fires — which is the reason to leave it until then rather than guess now.
73
+
74
+ Writing the page is a separate question and the answer there is yes: readers
75
+ want one, and `llms.txt` carries question-and-answer copy unusually well, since
76
+ that is close to the form a model is asked things in. It is content, needs
77
+ nothing from lib, and — today — no structured data.
78
+
79
+ ### The other node types the sibling projects emit — which of them do we want?
80
+
81
+ **These six: no. `Service` and `OfferCatalog` were the ones worth taking, and
82
+ they are built.**
83
+
84
+ A sweep of the four Next.js sites this package was extracted from turned up
85
+ every schema type they emit between them. Most of it lib already had. What was
86
+ left is listed here so the sweep does not get run again from scratch, since the
87
+ answer for each is a fact about Google rather than a preference.
88
+
89
+ - **`ItemList` / `CollectionPage`.** Google renders `ItemList` as a carousel in
90
+ a handful of verticals — recipe, course, movie, restaurant — and as nothing at
91
+ all outside them. A list page for challenges or news is outside them.
92
+ - **`WebPage` / `WebPageElement` / `ContactPage`.** Google's guidance is that
93
+ generic page-level markup is not needed; the page already says it is a page.
94
+ Nothing reads these that does not already have the HTML.
95
+ - **`LocationFeatureSpecification`.** Amenity features are used for lodging.
96
+ A venue's facilities are not that, and nothing renders them here.
97
+ - **`ContactPoint` / `ServiceChannel`.** Ruled out with the retired Corporate
98
+ Contact feature — see `organization()` for the full reasoning. `ServiceChannel`
99
+ is the same idea one node over and falls with it.
100
+ - **`PriceSpecification`.** As the siblings use it, it carries a description and
101
+ a currency and no amount — a "contact us" placeholder wearing a price's
102
+ clothes. Where there is a real number, `Offer` already carries it.
103
+ - **`ImageObject`.** Both sibling implementations are `{"@type": "ImageObject",
104
+ url}` and nothing else, which is a wrapper around the plain URL string Google
105
+ already accepts. Article documents the field as *"Repeated `ImageObject` or
106
+ `URL`"* — neither form preferred — and **width and height appear nowhere in
107
+ either the Article guidance or the image-licence guidance**. So having the
108
+ dimensions is not a reason to emit the node; lib does have them for the share
109
+ image, the icon and a video thumbnail, and Google has no use for them.
110
+ What the node *does* unlock is the **Licensable badge** in Google Images, and
111
+ that needs `contentUrl` plus at least one of `license`, `creator`,
112
+ `creditText` or `copyrightNotice` — rights data no project here holds. Build
113
+ it the day someone wants that badge, and build it around the licence fields
114
+ rather than the sizes. Checked August 2026.
115
+
116
+ `FAQPage` is above, and has its own entry because it is the one that keeps
117
+ getting asked.
118
+
119
+ ### Should a post say which `articleSection` it is in?
120
+
121
+ **No — not while there is one feed.**
122
+
123
+ A section is the part of a publication a piece lives in: Sports, Business,
124
+ Opinion. Open Graph's `article:section` takes one, and schema.org's
125
+ `articleSection` is the same idea. It is *where a piece sits*, as against tags,
126
+ which are *what it is about* — one home versus many labels.
127
+
128
+ Everything here sits in the same place. A `section` field would say `"News"` on
129
+ every post for as long as `/news` is the only feed, which is the URL repeated
130
+ into the markup. Tags are the half that varies, so tags are what posts carry.
131
+
132
+ It earns its place the day the feed splits — News, Events, Offers — because then
133
+ it distinguishes something. Add it with that split, not before it.
134
+
135
+ ### Should we add `WebSite` + `SearchAction` so a search box shows in results?
136
+
137
+ **No — the schema. A search page is a separate question.**
138
+
139
+ Google [retired the sitelinks search box](https://developers.google.com/search/blog/2024/10/sitelinks-search-box)
140
+ globally on 21 November 2024, citing lack of use. The markup renders nothing
141
+ now. That none of our sites has a search page to point it at is the second
142
+ reason, not the first — build one if it is useful to readers, just do not expect
143
+ this markup to advertise it.
144
+
145
+ `WebSite` itself is still read for site names, which is the only thread left to
146
+ pull here.
147
+
148
+ ### Should we show star ratings for a venue?
149
+
150
+ **Not for the venue. For a room, the door is open.**
151
+
152
+ > If the entity that's being reviewed controls the reviews about itself, their
153
+ > pages that use LocalBusiness or any other type of Organization structured data
154
+ > are ineligible for star review feature.
155
+
156
+ Reviews a business collects about itself never qualify, so venue-level stars are
157
+ unreachable however good the data is — they come from Google Business Profile
158
+ instead, which is outside this repo.
159
+
160
+ Room-level ratings on the `Product`/`Game` node *are* eligible. They need reviews
161
+ collected on the site itself, shown in full on the page, and averaged over
162
+ exactly the set displayed — never a number typed in beside a shorter list.
163
+
164
+ ---
165
+
166
+ ### Should `CurrencyCode` and `CountryCode` spell out the ISO lists?
167
+
168
+ **Not now.**
169
+
170
+ Both are shapes rather than vocabularies — `` `${Letter}${Letter}${Letter}` ``
171
+ and `` `${Letter}${Letter}` `` — so `"XYZ"` and `"ZZ"` type-check. ISO 4217 has
172
+ around 180 active codes and ISO 3166-1 alpha-2 has 249; both are small, closed
173
+ and effectively frozen, so enumerating them is possible in a way the [IANA
174
+ language registry](https://www.iana.org/assignments/language-subtag-registry) is
175
+ not — which is the case `LanguageTag` in `config.ts` declines and says why.
176
+
177
+ Left as shapes because the mistake is not one anyone has made: a currency is
178
+ written once per project, beside the prices it applies to, and a wrong one is
179
+ visible the first time a page renders. That is a different risk from a typo in a
180
+ directive nobody reads. Worth revisiting if a project ever computes a code
181
+ rather than writing it.
182
+
183
+ ### Should `@type` be checked against schema.org's vocabulary?
184
+
185
+ **Not in the type system. Possibly against the built output, one day.**
186
+
187
+ `LocalBusinessInput.type` and `ProductInput.alsoA` take bare strings, so
188
+ `"EntertainmentBussiness"` compiles, gets emitted, and is ignored by every
189
+ reader — the silent failure that `RobotsDirective` and `OpenGraphType` were
190
+ tightened to prevent.
191
+
192
+ They are not the same case. Those two vocabularies are closed; schema.org has
193
+ around 800 types and is still growing. A partial enumeration of an open
194
+ vocabulary rejects types that are perfectly valid, and the response to that is
195
+ to cast past it — at which point the check is theatre. A literal union that
196
+ still admits any string (`Known | (string & {})`) would give autocomplete
197
+ without the rejection, but it means carrying a curated list for editor
198
+ convenience alone, and a list that earns nothing else is a list that drifts
199
+ unnoticed.
200
+
201
+ The version worth building, if this ever bites: validate the *emitted* JSON-LD
202
+ against a vocabulary dump, in a test. That sees all 800 types, and sees what no
203
+ type could — a property that is not valid for the type it sits on.
204
+
205
+ ## Answers that will not change on their own
206
+
207
+ ### Should lib carry the consent *banner* as well as the tags?
208
+
209
+ **No — and the line between the two halves is the point.**
210
+
211
+ lib carries Umami, Google Analytics and Tag Manager, in `analytics/`. It also
212
+ carries the half of consent that is a sequencing problem: `gtag('consent',
213
+ 'default', …)` has to be queued before any tag reads it, and behind it Google
214
+ applies its implicit default, which is everything granted. Nothing errors, the
215
+ numbers arrive, and slightly more of them than should have. That is exactly the
216
+ kind of failure lib exists to make unwritable, so `googleScripts` emits the
217
+ defaults first and denies by default.
218
+
219
+ What lib will not carry is the banner: markup, wording, whether the reject
220
+ button is as easy to find as accept, where the answer is stored, and how a
221
+ visitor changes their mind. Those are product and legal decisions that differ
222
+ per deployment, and a template that guessed at them would be guessing at
223
+ somebody's compliance.
224
+
225
+ The seam between the halves is a global — `CONSENT_UPDATE_GLOBAL` — because
226
+ that is the only place they *can* meet: the head script runs before any module
227
+ exists. lib emits an updater covering exactly the signals it defaulted, so a
228
+ banner never names them and the two lists cannot drift. `src/astro/consent.ts`
229
+ carries the browser-side parts that are the same everywhere — reading an answer,
230
+ expiring it, applying it — and `examples/b2c/src/components/ConsentBanner.astro`
231
+ is a worked
232
+ example of the rest, marked as a demonstration rather than a product.
233
+
234
+ Note the shape sibling projects use, and do not copy it: they group
235
+ `siteVerification` with `analytics` and `tagManager` under one `integrations`
236
+ key. Verification is a `<meta>` proving ownership; analytics is a script. One
237
+ config block, two different kinds of thing.
238
+
239
+ ### Should a room be an `Event`?
240
+
241
+ **No.**
242
+
243
+ `Event` wants a name, a start date, a location and offers. A room bookable at
244
+ any opening hour is not an event, and lib should not invent a start time to
245
+ satisfy a schema. Reconsider only if a venue runs dated one-off sessions — a
246
+ Halloween night, a tournament — which would be a real event and would earn the
247
+ markup honestly.
248
+
249
+ ### Should lib generate share images — logo over a field, title burnt in?
250
+
251
+ **No. It crops, it does not compose.**
252
+
253
+ Composing a card needs a renderer rather than a resizer, and every project would
254
+ end up rewriting the layout anyway. `shareImage` crops a source it was given and
255
+ refuses to enlarge it; `astro-og-canvas` or satori is the tool for the other
256
+ thing. This was built once, with satori, and removed.
257
+
258
+ ### Should lib render `.htaccess` as well as `_redirects`?
259
+
260
+ **No — everything deploys to Cloudflare now.**
261
+
262
+ One of those sites hand-rolls an Apache `.htaccess`: trailing-slash removal,
263
+ `.html` extension resolution, then the redirects. It is real code in a sibling
264
+ repo, which is why this entry exists — reading that file is what prompts the
265
+ question.
266
+
267
+ Nothing is served by Apache any more. Worth knowing the seam is already there if
268
+ that ever changes: `site.redirects()` returns resolved rules as *data*, and
269
+ `buildCloudflareRedirects` is one renderer over them rather than the only
270
+ possible one, so another host is a new function and no redesign. The two
271
+ non-redirect halves of that `.htaccess` — extension resolution and slash
272
+ handling — are `build.format` and `trailingSlash` here, which `siteRoutes`
273
+ already sets and the next entry explains.
274
+
275
+ ### Should `trailingSlash`, `output` and `build.format` be options?
276
+
277
+ **No. `siteRoutes()` sets them.**
278
+
279
+ They are not preferences. Each is the other half of a contract lib already
280
+ wrote — the files it emits at build time, and the slash-free URLs it publishes
281
+ as canonical — so a project that changed one would silently break the other. The
282
+ integration sets all three and logs that it did, so nothing is hidden.
283
+
284
+ ### Should `/` serve a meta-refresh stub when every locale is prefixed?
285
+
286
+ **No. A real 301 in `_redirects`.**
287
+
288
+ A meta refresh is a soft redirect: followed slowly, weighted less, and it
289
+ renders a page before sending the reader on. The two cannot coexist either — a
290
+ static host serves the file and the rule never fires — so this was built, then
291
+ replaced.
292
+
293
+ ### Should the breadcrumb say which page of a list you are on?
294
+
295
+ **No, and a post's trail should point at the list, not at the page it is listed
296
+ on.**
297
+
298
+ A `BreadcrumbList` states *hierarchical* position, and pagination is lateral
299
+ movement between siblings at one level: `Home › News` is the trail on `/news`
300
+ and on `/news/page/7` alike. Google renders breadcrumbs in results, so a
301
+ trailing `News (2)` buys noise in the SERP for a listing page that is already a
302
+ weak search target — and the trail would change every time something was
303
+ published above the item. The pager already says which page this is, which is
304
+ where that state belongs.
305
+
306
+ Built and removed, along with the per-crumb link callback on `breadcrumbFor`
307
+ that existed only to serve it.
308
+
309
+ ### Should a post link back to the page of the list it appeared on?
310
+
311
+ **No. To the list.**
312
+
313
+ This was built, and it was static — derived from the post's position rather than
314
+ from a referrer — so it prerendered fine. The problem was the reader: someone
315
+ arriving from a search result was sent to page four of an archive they had never
316
+ seen, and the link moved whenever something was published above the post.
317
+
318
+ Reading order is the relationship a reader actually has with an article, so a
319
+ post carries newer/older links instead. Browser back covers "the list I was
320
+ looking at" better than any link can.
321
+
322
+ ---
323
+
324
+ ## Adding to this
325
+
326
+ One question, one answer, one reason. Say what is *not* built and what still is
327
+ — the scope is the part that gets lost. If the answer rests on someone else's
328
+ decision, put it in the first group and name the date, so a reader can tell a
329
+ settled fact from one worth re-checking.
package/docs/checks.md ADDED
@@ -0,0 +1,139 @@
1
+ # What is checked, and where
2
+
3
+ Three layers, and each catches what the one before it cannot see. `npm test`
4
+ runs all three in this order and stops at the first failure.
5
+
6
+ ```bash
7
+ npm run test:types # the package compiles, and the type assertions still bite
8
+ npm run test:unit # runtime behaviour
9
+ npm run test:examples # every example, every deployment: astro check + astro build
10
+ ```
11
+
12
+ The last one **builds** rather than only checking, and that is not thoroughness
13
+ for its own sake. `astro check` sees one deployment — whichever `config/project`
14
+ holds — so checking once leaves every other venue unlooked-at. And the
15
+ right-hand column of the table below can only throw at build time, because each
16
+ entry depends on merged data or on a real file. A gate that stopped at
17
+ type-checking would pass all of it.
18
+
19
+ ## Compile errors, and where they are stated
20
+
21
+ Most guarantees are compile errors, stated in a *constraint* rather than in the
22
+ parameter type — in parameter position a single unrelated mistake widens the
23
+ inferred type and every key then reports as malformed, burying the real error in
24
+ a cascade.
25
+
26
+ That rule inverts when the validator maps the *whole* object rather than one of
27
+ its properties, as the slug-depth checks do: in the constraint, any unrelated
28
+ malformation makes the argument itself unassignable and TypeScript reports at the
29
+ call site instead of the offending line. Those two live in parameter position for
30
+ that reason.
31
+
32
+ An error is carried by an interface with one impossible property, whose name and
33
+ message say what is wrong — `SlugDepthMismatch`, `LocaleNotEnabled`,
34
+ `MalformedLanguageTag`. **Name that property for the specific failure, never a
35
+ generic `__ERROR__`:** TypeScript prints the alias name when the marker is a
36
+ conditional's result, but goes structural — `Property '__X__' is missing` — when
37
+ it is an annotation, and there the property name is the only part the reader
38
+ sees.
39
+
40
+ | Compile error | Build-time throw |
41
+ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
42
+ | unknown message key, or an override dropping a `{placeholder}` | two routes resolving to the same URL |
43
+ | a message the project declared mandatory and never wrote (`CatalogCovers`) | |
44
+ | copy or a locale label written for a locale outside `enabledLocales` | |
45
+ | a slug whose translation sits at a different depth, in the registry or across the overlay | the same, when a slug is not a literal (computed, or from an env var) |
46
+ | unknown route id, or a locale the site does not ship | a locale published twice |
47
+ | a malformed language tag (`en_US`, `en-us`, `EN-US`, bare `en`) | an icon that is not square, a multiple of 48, and PNG |
48
+ | `defaultLocale` outside `locales`; unknown top-level field | a `url` that is not `https://` or ends in `/`, when it is not a literal |
49
+ | a `url` literal that is not `https://` or ends in `/` | |
50
+ | a `lastmod` that is not a `YYYY-MM-DD` calendar date | |
51
+ | config for a route this project does not build, or missing for one it does (`PerRoute`) | |
52
+
53
+ The runtime checks are the ones types cannot see: facts about a *file* (an icon's
54
+ real dimensions), facts only known after merging (URL collisions), or a value
55
+ typed as plain `string` because it came from an env var, where the literal checks
56
+ pass vacuously.
57
+
58
+ ## Isolation
59
+
60
+ ```bash
61
+ npm run test:types # tsc --noEmit, with no consumer paths and no ambient types
62
+ ```
63
+
64
+ That command is the contract. The root `tsconfig.json` runs with `"types": []`
65
+ and excludes `src/astro/`, so the core provably imports nothing from `astro`,
66
+ `node:` or a DOM — and there is no path alias for a consumer to reach through. If
67
+ it passes, the package is genuinely independent of anything consuming it.
68
+
69
+ ## Type tests
70
+
71
+ `type-tests/` asserts the compile-time guarantees with `@ts-expect-error`: if a
72
+ mistake ever becomes legal again, the unused directive fails the build. They live
73
+ with the package, not in a consumer, because they test **the package's**
74
+ behaviour and must travel with it.
75
+
76
+ They run against `type-tests/fixtures.ts` — the single place in the package where
77
+ concrete values are allowed. The fixture locale is `"xx-XX"`, which exists in no
78
+ real project, so a test can never pass by accidentally leaning on a consumer's
79
+ data. Route fixtures are named `blue-room` / `red-room` / `green-room` for the
80
+ same reason.
81
+
82
+ **One thing cannot be tested here: anything typed `PublicFilePath`.** The
83
+ registry it reads is empty in this program, so it widens to any path — and inside
84
+ a project that has run `publicFiles()` it is that project's union instead. An
85
+ assertion written here would mean the opposite thing there. Those belong in the
86
+ consuming project's own `type-tests/`, which is also the only place the
87
+ augmentation is real. Both examples carry one:
88
+ [`b2c`](../examples/b2c/type-tests/wiring.ts) asserts against a `public/` with
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.
91
+
92
+ ## Runtime tests
93
+
94
+ ```bash
95
+ npm run test:unit
96
+ npm run test:watch
97
+ ```
98
+
99
+ `tests/` covers what a type test cannot reach: what a merge resolves to, what a
100
+ builder rejects, what a file ends up containing. They go through the public
101
+ API — `createSite(...).metaFor(...)` rather than the private helper it calls — so
102
+ a test pins a decision rather than an implementation.
103
+
104
+ They are **excluded from the type check**, because they import `vitest` and that
105
+ command's promise is that the package type-checks with no ambient types and no
106
+ dependencies at all. Vitest type-checks them itself when it runs them.
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
+ | `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap |
112
+ | `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way |
113
+ | `merge.test.ts` | catalog and route overlay precedence — per locale, never per key |
114
+ | `llms.test.ts` | section grouping, owners, heading and link precedence, and the undescribed fallback |
115
+ | `public-files.test.ts` | walking `public/`, what is skipped, and why the declaration must be a module |
116
+ | `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported |
117
+ | `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 |
118
+ | `analytics.test.ts` | Umami's attributes, its three-state booleans, and the `domains` list that would record nothing |
119
+ | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, and dead `UA-` properties |
120
+ | `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
121
+ | `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
122
+ | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
123
+ | `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
124
+ | `warn.test.ts` | the shared prefix, and reducing a URL to its path |
125
+
126
+ The fixture carries two nested families for that last one, differing in the only
127
+ way that changes behaviour: `guides` has a route owning its bare slug and `rooms`
128
+ does not, so both the linked and the unlinked section are reachable from one
129
+ site.
130
+
131
+ ## What a consumer checks
132
+
133
+ A consuming project keeps its own thin suite for *wiring* — that its catalog,
134
+ routes and config actually satisfy these contracts, and that its own types have
135
+ not been loosened by a stray `any` or a dropped `satisfies`. It does not restate
136
+ the machinery's behaviour; that is what the two suites above are for.
137
+
138
+ Both examples' `type-tests/wiring.ts` are deliberately short, and every assertion
139
+ in them names something that must stay *rejected*.
@@ -0,0 +1,52 @@
1
+ # Share images: what each surface actually shows
2
+
3
+ One `og:image` has to serve every surface, and they do not agree on a shape. The
4
+ asymmetry is what decides it: a square surface takes the **centre** of a wide
5
+ image, while a wide surface takes a band out of a square one and cuts the top and
6
+ bottom off. Wide degrades; square destroys.
7
+
8
+ | Surface | Reads | Renders | Notes |
9
+ | ------------------------- | -------------------------------- | ---------------- | ----------------------------------------- |
10
+ | Facebook | `og:image` | 1.91:1 large | recommends 1200×630, min 200×200 |
11
+ | LinkedIn | `og:image` | 1.91:1 | 1200×627 |
12
+ | iMessage | `og:image` | wide large | |
13
+ | Slack / Discord | `og:image` | wide | |
14
+ | Telegram | `og:image` | wide | |
15
+ | WhatsApp | `og:image` | **small square** | centre-crops; preview needs a small file |
16
+ | X — `summary_large_image` | `twitter:image`, else `og:image` | 2:1 wide | min 300×157 — the card Atlas always emits |
17
+ | X — `summary` | `twitter:image`, else `og:image` | **1:1 square** | min 144×144 — never asked for; see below |
18
+
19
+ So **1200×630 for everything**, and let the square surfaces crop.
20
+
21
+ ## Why `fit` matters more than it looks
22
+
23
+ That crop is the whole reason `shareImage` takes a fit at all:
24
+
25
+ | The page's art | `shareImage` call | Why |
26
+ | --------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
27
+ | A photograph | `shareImage(src)` — `cover` | edges are scenery; a square crop takes the middle, which is the subject |
28
+ | A logo, square source | `shareImage(src, { fit: "contain", background })` | scaled to 630×630 and centred, so a square crop lands **exactly on the logo** |
29
+ | A logo under ~630px | neither — compose one or get a bigger file | `contain` would enlarge it, and `shareImage` refuses rather than ship a blurred card |
30
+
31
+ Both examples use the second row for their catalogue pages: a room's own mark on
32
+ the brand field, so a chat thumbnail shows which room it is rather than a
33
+ prettier picture that could be any of them.
34
+
35
+ ## `twitter:card` is pinned
36
+
37
+ It is **always `summary_large_image`**, and there is no option to change it.
38
+
39
+ X is the one surface that renders whatever the tag claims rather than reading the
40
+ picture and choosing, so the tag is a decision — and the asymmetry at the top of
41
+ this page decides it the same way it decides the shape: a wide image survives a
42
+ square crop, a square one does not survive a wide. `summary` renders a square
43
+ thumbnail, and there is no image worth serving that wants one.
44
+
45
+ An earlier version derived the card from the image's dimensions and took a
46
+ `twitterCard` override. Both are gone. Deriving it meant a page could ship the
47
+ square card by accident — an image slightly under a threshold, a logo someone
48
+ padded — and the failure was invisible, because a card that renders is a card
49
+ that looks like it worked. One value, no threshold to sit near, nothing to
50
+ override. `tests/meta.test.ts` pins it at three sizes including a square one.
51
+
52
+ What Atlas does *not* model is a second image for X alone: one page, one picture.
@@ -0,0 +1,83 @@
1
+ # Notes on the toolchain
2
+
3
+ Decisions that look like oversights until you know why, and the traps that cost
4
+ an afternoon each.
5
+
6
+ ## TypeScript is pinned to 6.x
7
+
8
+ `astro check` (via `@astrojs/language-server`) needs TypeScript's programmatic
9
+ API, which the native TS 7 compiler does not expose yet. On TS 7 `astro check`
10
+ refuses to run, and `.astro` frontmatter — where most `t()` calls live — would go
11
+ unchecked.
12
+
13
+ That is not a small loss: the frontmatter is where a view builds its `metaFor`
14
+ call, its JSON-LD node and every templated `t()`, so an unchecked frontmatter
15
+ means most of what this package guarantees stops being verified in the one place
16
+ it matters. Track
17
+ [withastro/roadmap#1321](https://github.com/withastro/roadmap/discussions/1321).
18
+
19
+ ## `noUnusedVariables` / `noUnusedImports` are off for `.astro`
20
+
21
+ Biome's Astro support has two modes and neither does both jobs:
22
+
23
+ - **Default mode** formats and lints frontmatter, but cannot see the template —
24
+ so every const the markup uses is reported unused.
25
+ - **`html.experimentalFullSupportEnabled`** lints accurately, but no longer
26
+ formats frontmatter.
27
+
28
+ This repo takes the default mode plus Biome's documented rule exclusions, and
29
+ nothing is lost: `astro check` reports unused frontmatter variables and imports
30
+ itself (`ts(6133)`) while correctly resolving template usage. The two tools cover
31
+ each other.
32
+
33
+ ## `biome.json` must not contain `//` comments
34
+
35
+ Biome silently falls back to its defaults and reformats the whole repo to tabs
36
+ and double quotes. No error, no warning — just a very large diff. This is the
37
+ one config file in the repo that cannot explain itself inline.
38
+
39
+ ## Astro's built-in i18n routing is deliberately unused
40
+
41
+ It cannot express per-locale slugs. A route id resolving to `/about-us` in one
42
+ language and `/el-GR/sxetika-me-emas` in another is the whole first idea of this
43
+ package, and the built-in strategy has no way to say it. Routing is done by the
44
+ single catch-all page in each example, from the route registry.
45
+
46
+ ## Formatting
47
+
48
+ 4-space indent, double quotes, `es5` trailing commas, LF. Biome formats `.astro`
49
+ frontmatter but leaves the markup below it alone, which is why the templates in
50
+ the examples are hand-aligned.
51
+
52
+ ```bash
53
+ npm run lint # check
54
+ npm run lint:fix # check and write
55
+ npm run format # format only
56
+ ```
57
+
58
+ ## The build output is file-style
59
+
60
+ `build.format: "file"` and `trailingSlash: "ignore"` are set by `siteRoutes()`
61
+ rather than asked for in each consumer's config, because each is the other half
62
+ of a contract the integration already wrote: the files it emits, and the
63
+ slash-free URLs it publishes as canonical.
64
+
65
+ Every canonical, `hreflang`, sitemap and `llms.txt` URL is written without a
66
+ trailing slash. `directory` format would leave every one of them redirecting to a
67
+ different URL, with nothing in the build to say so. `src/astro/site-routes.ts`
68
+ explains it where it sets it, and logs what it set.
69
+
70
+ The practical consequence: `/about-us` is `about-us.html`, not
71
+ `about-us/index.html`. `dist/` is plain files — any static host works, and no
72
+ server rules are required.
73
+
74
+ ## Claiming `/` when every locale is prefixed
75
+
76
+ When a project sets `prefixDefaultLocale: true`, nothing owns `/` and no page is
77
+ generated for it. `site.redirects()` claims it with a real **301** in
78
+ `_redirects` instead, pointing at the default locale's root — a redirect rather
79
+ than a rendered stub, so a crawler is told the destination is canonical instead
80
+ of following a refresh.
81
+
82
+ The target is whichever route has an empty slug, so nothing has to name a "home"
83
+ page. See [`NOT-BUILT.md`](NOT-BUILT.md) for why no meta-refresh stub is emitted.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@escape-game-over/atlas",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
+ "private": false,
7
+ "license": "UNLICENSED",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "workspaces": [
12
+ "examples/*"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./astro": "./src/astro/index.ts",
17
+ "./astro/images": "./src/astro/images.ts",
18
+ "./astro/consent": "./src/astro/consent.ts",
19
+ "./astro/meta-tags": "./src/astro/MetaTags.astro"
20
+ },
21
+ "bin": {
22
+ "atlas": "bin/use-project.mjs"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "bin",
27
+ "docs",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "test": "npm run test:types && npm run test:unit && npm run test:examples",
32
+ "test:types": "tsc --noEmit",
33
+ "test:unit": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:examples": "npm run test --workspaces",
36
+ "lint": "biome check .",
37
+ "lint:fix": "biome check --write .",
38
+ "format": "biome format --write .",
39
+ "check-fmt": "biome check . --error-on-warnings"
40
+ },
41
+ "peerDependencies": {
42
+ "astro": ">=7"
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "2.5.8",
46
+ "@types/node": "26.2.0",
47
+ "astro": "7.2.2",
48
+ "typescript": "6.0.3",
49
+ "vitest": "4.1.10"
50
+ }
51
+ }