@escape-game-over/atlas
v0.1.18
Published
Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.
Readme
Atlas
Typed, data-driven machinery for static, multi-locale, multi-deployment Astro sites.
You write data — the languages, the copy, the route table, one file per
deployment. Atlas derives the rest: URLs, <head>, hreflang, sitemap.xml,
robots.txt, llms.txt, _redirects and the JSON-LD @graph. Nothing is
written down twice, so a retranslated slug moves the sitemap entry, the
alternates, the llms.txt link and the breadcrumb together.
A consuming project writes data, never logic.
npm install @escape-game-over/atlasPublished publicly, built for internal use. It lives on the public registry so our own deployments can install it without auth or a private registry — not as an invitation. Hence
UNLICENSED: you can fetch it, you have no licence to use it, and there is no support, no semver promise and no issue tracker behind it. It is readable, and you are welcome to read it.
Two worked examples live in this repo, and reading one is the fastest way in:
| | What it shows |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| examples/b2c | One operator's public site — 2 deployments, 2 locales, translated slugs, paginated news, a shared brand over two venues |
| examples/b2b | The manufacturer's own site — 1 deployment, 1 locale, a priced catalogue, a single Organization |
They are the same kind of business at either end of it, on purpose: almost everything that differs between them is a consequence of who is buying, not of which features were switched on.
npm install # from the repo root
npm run dev -w examples/b2c # or examples/b2bThe two ideas
1. A route id is the only name you ever type.
A route maps an id to a slug per locale. Code links by id, so retranslating a URL is a one-line data change that no call site notices. A locale without its own slug falls back to the shared one:
about: { enabled: true, slug: "about-us", slugByLocale: { "el-GR": "sxetika-me-emas" } }<a href={site.pathFor("about", locale)}> <!-- /about-us or /el-GR/sxetika-me-emas -->Linking to a page the active deployment switched off is a compile error, not a 404.
2. A deployment is an overlay, not a fork.
One file per deployment, saying only what differs:
export default defineProject(config, defaultMessages, defaultRoutes, {
url: "https://acme.example",
siteName: "Acme Rome",
icon,
themeColor: "#1d51e0",
enabledLocales: ["el-GR", "en-US"],
overrideRouting: { defaultLocale: "el-GR", prefixDefaultLocale: true },
overrideMessages: {
// Greek only — the English copy still falls through to the default catalog.
"home.hero.body": { "el-GR": "Η {company} κρατά το απόθεμα…" },
},
overrideRoutes: {
careers: { enabled: true }, // opt into a page
contact: { slugByLocale: { "el-GR": "epikoinoniste-mazi-mas" } }, // retranslate a URL
},
})Every field is named for what it does to the defaults, so a project file can
never be mistaken for the whole truth: enabledLocales picks from what the site
declares, and every override* is a patch over it.
The four steps
Every step infers its types from the values of the last, so a consuming project never writes a type argument:
// 1. which languages exist, how URLs are shaped
export default defineSiteConfig({ locales: {...}, defaultRouting: {...} })
// 2. the base copy and routes
export const baseMessages = defineMessages(config, {...})
export const baseRoutes = defineRoutes(config, {...})
// 3. one deployment
export default defineProject(config, baseMessages, baseRoutes, {...})
// 4. the API the pages use
export const site = createSite(config, baseMessages, baseRoutes, project)Step 4 returns t(), rich(), plain(), pathFor(), urlFor(), fileUrl(),
alternatesFor(), localeLinksFor(), metaFor(), breadcrumbFor(),
staticPaths(), routes, entries, sitemap(), robots(), llms() and
redirects() already wired.
t() is bound to a locale and knows each message's {placeholders} from its
text:
const t = site.translate(locale)
t("contact.intro") // no placeholders, no second argument
t("footer.copyright", { company: "Acme Rome", year: "2026" })
t("footer.copyright") // ✗ params are not optional
t("footer.copyright", { company: "Acme Rome" }) // ✗ {year} missing
t("footer.copyright", { company: "A", year: "1", x: "" }) // ✗ {x} is not a placeholderNothing is auto-injected. Site-wide values are ordinary placeholders, passed explicitly.
A sentence is one message
Copy that needs emphasis, a link or a line break carries marks, and stays one
key — so each language orders and splits it however it needs to, rather than
around an array of hero.subtitle_text_1…_7 whose length was fixed by whoever
typed the English.
"about.intro": {
"en-US": "Book [v:accent]up to six players[/v] at [a:venue]our venue[/a].",
"el-GR": "Κλείσε [a:venue]στον χώρο μας[/a] [v:accent]έως έξι παίκτες[/v].",
}const rich = site.rich(locale)
rich("about.intro", { venue: "contact" }) // the destination, stated once| Mark | Short for | Is |
| ----------------- | ----------- | ---------------------------------------- |
| [b]…[/b] | bold | <strong> — emphasis, not a font weight |
| [v:accent]…[/v] | variant | a role the project's renderer maps |
| [a:venue]…[/a] | anchor | a link slot the call site fills |
| [mail]…[/mail] | mailto: | a mailto: derived from the words |
| [tel]…[/tel] | tel: | a tel: derived from the words |
| [br] | break | a line break |
| [[ and ]] | an escape | a literal [ and ] |
Four of the six are HTML's own tags or schemes, so there is little to memorise.
Only [v:] is Atlas's, and it is the one that has to be: HTML has no tag for "a
role this project names", which is precisely the decision being kept out of the
copy.
rich() returns Span[] — translated text with resolved hrefs — and the
project owns the switch that draws them. Copy never holds a colour, a class, a
route id or a URL. A slot is filled at the call site, where it is checked against
the routes this deployment builds and written once instead of once per language.
site.plain(locale) reads the same message as words alone, for a
<meta description> or llms.txt; t() refuses a message with marks rather
than printing the brackets. See docs/rich-text.md.
What the build emits, and who asks for it
| Output | Comes from | Needs from the project |
| ------------------------ | -------------------------------- | --------------------------------------- |
| sitemap.xml | siteRoutes(), from the routes | nothing |
| robots.txt | siteRoutes() | nothing |
| llms.txt | siteRoutes() | words, via site.llms({...}) |
| _redirects | siteRoutes() | rules, via site.redirects() |
| BreadcrumbList JSON-LD | breadcrumbFor(), from the slug | a name per step, in a layout |
| LocalBusiness JSON-LD | localBusiness() | address, phone, hours |
| Organization JSON-LD | organization() | name, URL, logo |
| WebSite JSON-LD | website(), home page only | site name, optional alternate |
| Product JSON-LD | product() | name, price table |
| Article JSON-LD | article() | headline, publication date |
| VideoObject JSON-LD | videoObject() | a video, its stills and date |
| FAQPage JSON-LD | faqPage() | the questions the page itself shows |
| Analytics tags | metaFor(), from the project | ids, via analytics in defineProject |
| PublicFile union | publicFiles(), from public/ | nothing |
site.metaFor() returns the <html> attributes and every head tag for one page,
so canonical, hreflang, x-default, og:url and the rest all come from one
place and cannot disagree.
What the compiler enforces
These are not conventions; each one fails npm test, and each is
regression-tested by an @ts-expect-error suite.
| Mistake | Result |
| ------------------------------------------------------------------------------- | -------------------------------------------- |
| A message missing a locale | error — default copy must cover every locale |
| A locale of a message using different {placeholders} than its siblings | error, naming the locale |
| An override for a key that is not in the default catalog | error — typos can't become dead strings |
| An override that drops or invents a {placeholder} | error — existing t() calls stay valid |
| A locale of a message using different marks than its siblings | error — a lost [a:venue] fails the build |
| Filling a [a:slot] with a route this project does not build | error at the call site |
| Calling t() without a required placeholder, or with a wrong/extra name | error |
| A route that does not state enabled | error — a page is never built by implication |
| A slug or message naming a locale the site doesn't ship | error |
| A locale key that is not a language tag (en, en_US, en-us) | error, naming the key |
| Linking to a page the active project disabled | error |
| Reading a disabled page from site.routes | it is not there — the list is derived |
| A route with no route.<id>.nav / .title / .description / .imageAlt copy | error, listing the missing keys |
| A route declared with no view component | error |
| A page with no share image, alt text, description or robots directives | error — none of them have a default |
| A robots directive that is not a real one | error — the vocabulary is a union |
| A project url that is not https://, or ends in a slash | error |
| defaultLocale not among the project's enabledLocales | error |
| An unknown field in a project declaration | error — a typo cannot sit there unread |
| Two routes resolving to the same URL | build throws, naming both |
| A site icon that is not square, a multiple of 48px, and PNG | build throws |
The runtime throws are the ones types cannot see: facts about a file (an
icon's real dimensions), facts only known after merging (URL collisions), or a
value typed as plain string because it came from an env var. See
docs/checks.md for the full split and how each error is
worded.
Site-level versus per-page
The same question answers it every time: would this differ between two pages of the same site?
| Per page — passed to metaFor() | Site-level — declared in defineProject() |
| -------------------------------- | ------------------------------------------ |
| title, description | siteName, twitterSite |
| image (asset and its alt) | icon, themeColor, colorScheme |
| robots policy | url |
A share image is per page because pages can be generated dynamically — a product
page's image is part of the product, not of the route table, so routes carry no
image at all. A favicon is site-level because search engines use one per
hostname, read from the home page. Image and alt travel as one ShareImage value
so they cannot be supplied from different places and drift apart. See
docs/share-images.md for what each social surface
actually renders, and why twitter:card is pinned.
Config that tracks which pages exist
A project switches routes off. Anything defined alongside a route then has two ways to rot: the page is built and its config is missing, or the page is gone and its config lingers. Two types, differing only in how many routes they speak for:
| | PerRoute<typeof site, T> | WhenEnabled<typeof site, "careers", T> |
| --------------------- | ------------------------------------ | ------------------------------------------------ |
| Shape | a table keyed by route id | a single value |
| Says | "every built route has one of these" | "this value belongs to that page" |
| When the route is off | that key is rejected | the type is never — the field cannot be filled |
| Use with | satisfies | a normal annotation |
PerRoute must be used with satisfies, not as an annotation: excess-property
checking is what catches the stale half, and only an object literal checked
against a known target gets it.
const heroes = {
home: { variant: "wide" },
about: { variant: "tall" },
} satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; // ✗ if offThe rules this package keeps
- No data, and no slots for data. No locales, no copy, no route tables, no
company details — and no passthrough field for a consumer to smuggle them
through either. If Atlas does not read a value, Atlas does not declare it:
defineProjectaccepts a URL because it builds absolute links, and rejects a company name because it never touches one. - No imports from a consumer. Only relative imports inside
src/. - No framework imports — outside
src/astro/. The core imports nothing fromastro, no.astrofiles, no DOM and no Node built-ins.npm run test:typesruns with"types": []to keep it honest, and excludessrc/astro/because that is the one place allowed to import the framework. Put something there only when it genuinely cannot work without Astro, and prefer moving the framework-dependent edge there over moving the logic. - Infer from values, never from type arguments. When a helper needs to be
typed against project data, take that data as a parameter and let TypeScript
infer it. This is what keeps rules 1 and 2 satisfiable without making callers
write
<Locale, typeof baseMessages>.
The barrel is the only way in
"exports": {
".": "./src/index.ts",
"./astro": "./src/astro/index.ts",
"./astro/images": "./src/astro/images.ts",
"./astro/background-video": "./src/astro/background-video.ts",
"./astro/carousel": "./src/astro/carousel.ts",
"./astro/consent": "./src/astro/consent.ts",
"./astro/dev-log": "./src/astro/dev-log.ts",
"./astro/dom": "./src/astro/dom.ts",
"./astro/element": "./src/astro/element.ts",
"./astro/filters": "./src/astro/filters.ts",
"./astro/filters-view": "./src/astro/filters-view.ts",
"./astro/meta-tags": "./src/astro/MetaTags.astro",
"./astro/youtube": "./src/astro/youtube.ts"
}And nothing else — there is no wildcard, so
import { escapeXml } from "@escape-game-over/atlas/xml.ts" fails to resolve.
Whatever those entry points do not export is internal by construction rather than
by convention, which is what makes the file layout under src/ a private detail:
modules can be split, renamed or flattened without touching a consumer.
The split within astro/ is a further constraint, not tidiness. ./astro is
imported by an Astro config, which is evaluated before the build exists — so it
may use node: built-ins but not astro:assets. ./astro/images is the
opposite: it runs inside the build, from a page, and is unusable from a config.
Merging them breaks whichever caller loads first.
The rest of astro/ is the browser half — carousel, filters, filters-view,
youtube, background-video, consent, element, dom, dev-log — which
runs in a reader's browser rather
than in the build, and is separate again for the same reason: none of it can be
reached from a config, and none of it draws. See
docs/client-scripts.md.
The package ships TypeScript source, and there is no build step.
MetaTags.astro could not go through tsc anyway, and Astro's own
tsconfigs/base.json already sets allowImportingTsExtensions, so every
consumer gets it for free.
The atlas command
atlas use <project> # copies config/projects/<name> -> config/project
atlas use --fallback rome # only when nothing else named oneThe whole directory is copied, so extra files and nested folders come along
without touching the tool — the contract is just that it contains project.ts.
It resolves paths from the working directory, so a workspace's own
package.json passes nothing. A deployment pipeline can skip it and write its
own config/project.
Where a default is wanted it belongs to the caller: the examples pass
--fallback to dev and check, and deliberately do not to build, so the one
command whose output gets deployed has to say what it is building.
Layout
src/ the package — see the export map above for what is reachable
index.ts the public API
site/ createSite() and the Site it returns
meta/ head tags: canonical, hreflang, Open Graph, Twitter, robots
jsonld/ the @graph — one file per node, each linked to Google's docs
i18n/ defineMessages(), t(), placeholder extraction
routes/ defineRoutes(), routeFamily(), merging and collisions
analytics/ Umami and Google, the Consent Mode defaults, and whether a
visitor has to be asked anything at all
astro/ the only framework-aware code, plus the one component
bin/ the `atlas` CLI
tests/ runtime behaviour — what a merge resolves to, what a builder rejects
type-tests/ what the types must reject, via @ts-expect-error
checks/astro/ the astro check root for src/astro — config only, nothing to read
docs/ the long-form reasoning
examples/ two complete consumersTesting
Two commands are the whole gate, and CI needs no more than these:
npm run check-fmt # biome, formatting and lint
npm test # everything elsenpm test runs three suites in order, and stops at the first failure:
npm run test:types # tsc --noEmit, then astro check — the package compiles,
# and 137 type assertions in type-tests/ still reject
# what they must
npm run test:unit # vitest — 355 runtime tests
npm run test:examples # every example, every deployment: astro check + astro build
npm run test:watch # vitest, watchingtest:types runs two passes, and the split is the isolation contract. The tsc
pass — no consumer paths, no ambient types, src/astro/ excluded — is what
proves the package is genuinely independent of anything consuming it.
The astro check pass covers src/astro/, the one directory that cannot meet
that bar. It needs astro/client for astro:assets and ImageMetadata, and
MetaTags.astro needs a checker that can parse .astro at all — tsc reads
zero .astro files no matter how they are globbed. Without this pass the one
component the package ships is checked by nothing: astro check in
test:examples only ever walks an example's own src/, so it never reaches it,
and a type error there passes the entire gate.
It runs from checks/astro rather than src/astro for one
practical reason: astro check writes a .astro/ types directory and a
1 MB Vite cache beside whatever it is rooted at, and files: ["src"] would
publish both. The tsconfig.json there adds nothing — it extends
src/astro/tsconfig.json and only widens the glob, so the rules stay in one
place. src/astro/tsconfig.json is what an editor finds; without it, opening
MetaTags.astro resolves against the root config, which supplies no ambient
types, and every prop degrades to any.
test:examples builds every deployment, not just type-checks one, and both
halves of that matter. astro check only ever sees whichever project
config/project currently holds, so checking once would leave the b2c example's
second venue entirely unlooked-at. And a whole class of guarantee — two routes
resolving to the same URL, an icon that is not square, price tiers that
contradict each other — can only throw at build time, because it depends on
merged data or on a real file. Type-checking alone would pass all of it.
The whole gate is about 15 seconds.
Open Biome issues
Three things the .astro setup works around rather than fixes. Each is open
upstream, and each explains a rule that would otherwise read as an oversight.
- biomejs/biome#10321 —
Astro: not formatting inside
{( … )}(S-Bug-confirmed). Biome formats.astromarkup but not the interior of an expression, so the 31.map()blocks across the templates keep their own indentation while the markup around them followsindentWidth. Hand-aligned until this lands. - biomejs/biome#9944 —
Astro: Parser error inside expression. Biome parses the inside of
{ }as strict JSX, which rejects things Astro itself accepts. This is why every comment in a template is{/* … */}and never<!-- … -->: an HTML comment anywhere inside an expression is a parse error, and the JSX form is also stripped from the built HTML rather than shipped to readers. - biomejs/biome#11275 —
LSP formatting deletes Astro frontmatter/HTML in multi-project workspaces
(
S-Bug-confirmed). It nameshtml.experimentalFullSupportEnabled, which this repository sets, and a workspace of several projects, which this repository is. The gate above is CLI-only and unaffected; editor format-on-save is the thing to be careful with.
Documentation
docs/checks.md— every guarantee, where it is enforced, and how the type tests and runtime tests divide the work.docs/rich-text.md— every mark copy may carry, what each produces, why a link names a slot rather than an address, and the renderer contract on the other side ofSpan.docs/share-images.md— what each social surface crops, and why one 1200×630 image serves all of them.docs/client-scripts.md— the browser half: what each client module owns, why none of them draws anything, the lifetime bug view transitions cause, and howfiltersdeclares its fields and its URL.docs/toolchain.md— the pinned TypeScript, the two switches.astrosupport needs from Biome, and the traps.docs/NOT-BUILT.md— what we deliberately do not build, as questions and answers. Kept beside the code rather than in a backlog, because a backlog is a list of things still to do and every one of these is a thing not to do.
Publishing
The package is "private": true today and consumed through the workspace.
Everything publishing needs is already in place — the export map, files, the
bin, astro as a peer dependency — so releasing is flipping that one boolean.
No import statement in any consumer changes.
