@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.
- package/README.md +364 -0
- package/bin/use-project.mjs +131 -0
- package/docs/NOT-BUILT.md +329 -0
- package/docs/checks.md +139 -0
- package/docs/share-images.md +52 -0
- package/docs/toolchain.md +83 -0
- package/package.json +51 -0
- package/src/analytics/google.ts +351 -0
- package/src/analytics/index.ts +102 -0
- package/src/analytics/tags.ts +57 -0
- package/src/analytics/umami.ts +285 -0
- package/src/astro/MetaTags.astro +87 -0
- package/src/astro/consent.ts +165 -0
- package/src/astro/images.ts +315 -0
- package/src/astro/index.ts +44 -0
- package/src/astro/public-files.ts +129 -0
- package/src/astro/site-routes.ts +307 -0
- package/src/config.ts +218 -0
- package/src/contact.ts +233 -0
- package/src/file.ts +16 -0
- package/src/files.ts +39 -0
- package/src/hours.ts +312 -0
- package/src/i18n/define.ts +217 -0
- package/src/i18n/placeholders.ts +94 -0
- package/src/i18n/translate.ts +190 -0
- package/src/image.ts +29 -0
- package/src/index.ts +222 -0
- package/src/jsonld/article.ts +165 -0
- package/src/jsonld/breadcrumb.ts +34 -0
- package/src/jsonld/business.ts +196 -0
- package/src/jsonld/ids.ts +106 -0
- package/src/jsonld/index.ts +59 -0
- package/src/jsonld/node.ts +78 -0
- package/src/jsonld/organization.ts +154 -0
- package/src/jsonld/place.ts +96 -0
- package/src/jsonld/product.ts +172 -0
- package/src/jsonld/quantity.ts +55 -0
- package/src/jsonld/service.ts +237 -0
- package/src/jsonld/video.ts +239 -0
- package/src/jsonld/website.ts +58 -0
- package/src/llms.ts +160 -0
- package/src/meta/content.ts +190 -0
- package/src/meta/index.ts +432 -0
- package/src/meta/robots.ts +212 -0
- package/src/meta/share-image.ts +232 -0
- package/src/meta/tag.ts +133 -0
- package/src/meta/verification.ts +53 -0
- package/src/money.ts +237 -0
- package/src/project.ts +249 -0
- package/src/redirects.ts +266 -0
- package/src/robots.ts +80 -0
- package/src/routes/define.ts +412 -0
- package/src/routes/family.ts +251 -0
- package/src/routes/resolve.ts +266 -0
- package/src/site/api.ts +354 -0
- package/src/site/create.ts +660 -0
- package/src/site/index.ts +32 -0
- package/src/site/page.ts +148 -0
- package/src/sitemap.ts +257 -0
- package/src/types.ts +160 -0
- package/src/url.ts +144 -0
- package/src/warn.ts +88 -0
- package/src/xml.ts +103 -0
package/README.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# Atlas
|
|
2
|
+
|
|
3
|
+
Typed, data-driven machinery for **static, multi-locale, multi-deployment Astro
|
|
4
|
+
sites**.
|
|
5
|
+
|
|
6
|
+
You write data — the languages, the copy, the route table, one file per
|
|
7
|
+
deployment. Atlas derives the rest: URLs, `<head>`, `hreflang`, `sitemap.xml`,
|
|
8
|
+
`robots.txt`, `llms.txt`, `_redirects` and the JSON-LD `@graph`. Nothing is
|
|
9
|
+
written down twice, so a retranslated slug moves the sitemap entry, the
|
|
10
|
+
alternates, the `llms.txt` link and the breadcrumb together.
|
|
11
|
+
|
|
12
|
+
**A consuming project writes data, never logic.**
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @escape-game-over/atlas
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> **Published publicly, built for internal use.** It lives on the public registry
|
|
19
|
+
> so our own deployments can install it without auth or a private registry — not
|
|
20
|
+
> as an invitation. Hence `UNLICENSED`: you can fetch it, you have no licence to
|
|
21
|
+
> use it, and there is no support, no semver promise and no issue tracker behind
|
|
22
|
+
> it. It is readable, and you are welcome to read it.
|
|
23
|
+
|
|
24
|
+
Two worked examples live in this repo, and reading one is the fastest way in:
|
|
25
|
+
|
|
26
|
+
| | What it shows |
|
|
27
|
+
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| [`examples/b2c`](examples/b2c) | One operator's public site — **2 deployments, 2 locales**, translated slugs, paginated news, a shared brand over two venues |
|
|
29
|
+
| [`examples/b2b`](examples/b2b) | The manufacturer's own site — **1 deployment, 1 locale**, a priced catalogue, a single `Organization` |
|
|
30
|
+
|
|
31
|
+
They are the same kind of business at either end of it, on purpose: almost
|
|
32
|
+
everything that differs between them is a consequence of who is buying, not of
|
|
33
|
+
which features were switched on.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install # from the repo root
|
|
37
|
+
npm run dev -w examples/b2c # or examples/b2b
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The two ideas
|
|
41
|
+
|
|
42
|
+
**1. A route id is the only name you ever type.**
|
|
43
|
+
|
|
44
|
+
A route maps an id to a slug per locale. Code links by id, so retranslating a
|
|
45
|
+
URL is a one-line data change that no call site notices. A locale without its own
|
|
46
|
+
slug falls back to the shared one:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
about: { enabled: true, slug: "about-us", slugByLocale: { "el-GR": "sxetika-me-emas" } }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```astro
|
|
53
|
+
<a href={site.pathFor("about", locale)}> <!-- /about-us or /el-GR/sxetika-me-emas -->
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Linking to a page the active deployment switched off is a compile error, not a
|
|
57
|
+
404.
|
|
58
|
+
|
|
59
|
+
**2. A deployment is an overlay, not a fork.**
|
|
60
|
+
|
|
61
|
+
One file per deployment, saying only what differs:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export default defineProject(config, 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
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every field is named for what it *does* to the defaults, so a project file can
|
|
85
|
+
never be mistaken for the whole truth: `enabledLocales` picks from what the site
|
|
86
|
+
declares, and every `override*` is a patch over it.
|
|
87
|
+
|
|
88
|
+
## The four steps
|
|
89
|
+
|
|
90
|
+
Every step infers its types from the *values* of the last, so a consuming project
|
|
91
|
+
never writes a type argument:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// 1. which languages exist, how URLs are shaped
|
|
95
|
+
export default defineSiteConfig({ locales: {...}, defaultRouting: {...} })
|
|
96
|
+
|
|
97
|
+
// 2. the base copy and routes
|
|
98
|
+
export const baseMessages = defineMessages(config, {...})
|
|
99
|
+
export const baseRoutes = defineRoutes(config, {...})
|
|
100
|
+
|
|
101
|
+
// 3. one deployment
|
|
102
|
+
export default defineProject(config, baseMessages, baseRoutes, {...})
|
|
103
|
+
|
|
104
|
+
// 4. the API the pages use
|
|
105
|
+
export const site = createSite(config, baseMessages, baseRoutes, project)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Step 4 returns `t()`, `pathFor()`, `urlFor()`, `fileUrl()`, `alternatesFor()`,
|
|
109
|
+
`localeLinksFor()`, `metaFor()`, `breadcrumbFor()`, `staticPaths()`, `routes`,
|
|
110
|
+
`entries`, `sitemap()`, `robots()`, `llms()` and `redirects()` already wired.
|
|
111
|
+
|
|
112
|
+
`t()` is bound to a locale and knows each message's `{placeholders}` from its
|
|
113
|
+
text:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const t = site.translate(locale)
|
|
117
|
+
|
|
118
|
+
t("contact.intro") // no placeholders, no second argument
|
|
119
|
+
t("footer.copyright", { company: "Acme Rome", year: "2026" })
|
|
120
|
+
t("footer.copyright") // ✗ params are not optional
|
|
121
|
+
t("footer.copyright", { company: "Acme Rome" }) // ✗ {year} missing
|
|
122
|
+
t("footer.copyright", { company: "A", year: "1", x: "" }) // ✗ {x} is not a placeholder
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Nothing is auto-injected. Site-wide values are ordinary placeholders, passed
|
|
126
|
+
explicitly.
|
|
127
|
+
|
|
128
|
+
## What the build emits, and who asks for it
|
|
129
|
+
|
|
130
|
+
| Output | Comes from | Needs from the project |
|
|
131
|
+
| ------------------------ | -------------------------------- | --------------------------------------- |
|
|
132
|
+
| `sitemap.xml` | `siteRoutes()`, from the routes | nothing |
|
|
133
|
+
| `robots.txt` | `siteRoutes()` | nothing |
|
|
134
|
+
| `llms.txt` | `siteRoutes()` | words, via `site.llms({...})` |
|
|
135
|
+
| `_redirects` | `siteRoutes()` | rules, via `site.redirects()` |
|
|
136
|
+
| `BreadcrumbList` JSON-LD | `breadcrumbFor()`, from the slug | a name per step, in a layout |
|
|
137
|
+
| `LocalBusiness` JSON-LD | `localBusiness()` | address, phone, hours |
|
|
138
|
+
| `Organization` JSON-LD | `organization()` | name, URL, logo |
|
|
139
|
+
| `WebSite` JSON-LD | `website()`, home page only | site name, optional alternate |
|
|
140
|
+
| `Product` JSON-LD | `product()` | name, price table |
|
|
141
|
+
| `Article` JSON-LD | `article()` | headline, publication date |
|
|
142
|
+
| `VideoObject` JSON-LD | `videoObject()` | a video, its stills and date |
|
|
143
|
+
| Analytics tags | `metaFor()`, from the project | ids, via `analytics` in `defineProject` |
|
|
144
|
+
| `PublicFile` union | `publicFiles()`, from `public/` | nothing |
|
|
145
|
+
|
|
146
|
+
`site.metaFor()` returns the `<html>` attributes and every head tag for one page,
|
|
147
|
+
so canonical, `hreflang`, `x-default`, `og:url` and the rest all come from one
|
|
148
|
+
place and cannot disagree.
|
|
149
|
+
|
|
150
|
+
## What the compiler enforces
|
|
151
|
+
|
|
152
|
+
These are not conventions; each one fails `npm test`, and each is
|
|
153
|
+
regression-tested by an `@ts-expect-error` suite.
|
|
154
|
+
|
|
155
|
+
| Mistake | Result |
|
|
156
|
+
| ------------------------------------------------------------------------------- | -------------------------------------------- |
|
|
157
|
+
| A message missing a locale | error — default copy must cover every locale |
|
|
158
|
+
| A locale of a message using different `{placeholders}` than its siblings | error, naming the locale |
|
|
159
|
+
| An override for a key that is not in the default catalog | error — typos can't become dead strings |
|
|
160
|
+
| An override that drops or invents a `{placeholder}` | error — existing `t()` calls stay valid |
|
|
161
|
+
| Calling `t()` without a required placeholder, or with a wrong/extra name | error |
|
|
162
|
+
| A route that does not state `enabled` | error — a page is never built by implication |
|
|
163
|
+
| A slug or message naming a locale the site doesn't ship | error |
|
|
164
|
+
| A locale key that is not a language tag (`en`, `en_US`, `en-us`) | error, naming the key |
|
|
165
|
+
| Linking to a page the active project disabled | error |
|
|
166
|
+
| Reading a disabled page from `site.routes` | it is not there — the list is derived |
|
|
167
|
+
| A route with no `route.<id>.nav` / `.title` / `.description` / `.imageAlt` copy | error, listing the missing keys |
|
|
168
|
+
| A route declared with no view component | error |
|
|
169
|
+
| A page with no share image, alt text, description or `robots` directives | error — none of them have a default |
|
|
170
|
+
| A `robots` directive that is not a real one | error — the vocabulary is a union |
|
|
171
|
+
| A project `url` that is not `https://`, or ends in a slash | error |
|
|
172
|
+
| `defaultLocale` not among the project's `enabledLocales` | error |
|
|
173
|
+
| An unknown field in a project declaration | error — a typo cannot sit there unread |
|
|
174
|
+
| Two routes resolving to the same URL | build throws, naming both |
|
|
175
|
+
| A site icon that is not square, a multiple of 48px, and PNG | build throws |
|
|
176
|
+
|
|
177
|
+
The runtime throws are the ones types cannot see: facts about a *file* (an
|
|
178
|
+
icon's real dimensions), facts only known after merging (URL collisions), or a
|
|
179
|
+
value typed as plain `string` because it came from an env var. See
|
|
180
|
+
[`docs/checks.md`](docs/checks.md) for the full split and how each error is
|
|
181
|
+
worded.
|
|
182
|
+
|
|
183
|
+
## Site-level versus per-page
|
|
184
|
+
|
|
185
|
+
The same question answers it every time: *would this differ between two pages of
|
|
186
|
+
the same site?*
|
|
187
|
+
|
|
188
|
+
| Per page — passed to `metaFor()` | Site-level — declared in `defineProject()` |
|
|
189
|
+
| -------------------------------- | ------------------------------------------ |
|
|
190
|
+
| `title`, `description` | `siteName`, `twitterSite` |
|
|
191
|
+
| `image` (asset **and** its alt) | `icon`, `themeColor`, `colorScheme` |
|
|
192
|
+
| `robots` policy | `url` |
|
|
193
|
+
|
|
194
|
+
A share image is per page because pages can be generated dynamically — a product
|
|
195
|
+
page's image is part of the product, not of the route table, so routes carry no
|
|
196
|
+
image at all. A favicon is site-level because search engines use one per
|
|
197
|
+
hostname, read from the home page. Image and alt travel as one `ShareImage` value
|
|
198
|
+
so they cannot be supplied from different places and drift apart. See
|
|
199
|
+
[`docs/share-images.md`](docs/share-images.md) for what each social surface
|
|
200
|
+
actually renders, and why `twitter:card` is pinned.
|
|
201
|
+
|
|
202
|
+
## Config that tracks which pages exist
|
|
203
|
+
|
|
204
|
+
A project switches routes off. Anything defined *alongside* a route then has two
|
|
205
|
+
ways to rot: the page is built and its config is missing, or the page is gone and
|
|
206
|
+
its config lingers. Two types, differing only in how many routes they speak for:
|
|
207
|
+
|
|
208
|
+
| | `PerRoute<typeof site, T>` | `WhenEnabled<typeof site, "careers", T>` |
|
|
209
|
+
| --------------------- | ------------------------------------ | ------------------------------------------------ |
|
|
210
|
+
| Shape | a table keyed by route id | a single value |
|
|
211
|
+
| Says | "every built route has one of these" | "this value belongs to *that* page" |
|
|
212
|
+
| When the route is off | that key is rejected | the type is `never` — the field cannot be filled |
|
|
213
|
+
| Use with | `satisfies` | a normal annotation |
|
|
214
|
+
|
|
215
|
+
`PerRoute` must be used with `satisfies`, not as an annotation: excess-property
|
|
216
|
+
checking is what catches the stale half, and only an object literal checked
|
|
217
|
+
against a known target gets it.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const heroes = {
|
|
221
|
+
home: { variant: "wide" },
|
|
222
|
+
about: { variant: "tall" },
|
|
223
|
+
} satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
|
|
224
|
+
|
|
225
|
+
const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; // ✗ if off
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## The rules this package keeps
|
|
229
|
+
|
|
230
|
+
1. **No data, and no slots for data.** No locales, no copy, no route tables, no
|
|
231
|
+
company details — and no passthrough field for a consumer to smuggle them
|
|
232
|
+
through either. If Atlas does not read a value, Atlas does not declare it:
|
|
233
|
+
`defineProject` accepts a URL because it builds absolute links, and rejects a
|
|
234
|
+
company name because it never touches one.
|
|
235
|
+
2. **No imports from a consumer.** Only relative imports inside `src/`.
|
|
236
|
+
3. **No framework imports — outside `src/astro/`.** The core imports nothing from
|
|
237
|
+
`astro`, no `.astro` files, no DOM and no Node built-ins. `npm run test:types`
|
|
238
|
+
runs with `"types": []` to keep it honest, and excludes `src/astro/` because
|
|
239
|
+
that is the one place allowed to import the framework. Put something there
|
|
240
|
+
only when it genuinely cannot work without Astro, and prefer moving the
|
|
241
|
+
framework-dependent *edge* there over moving the logic.
|
|
242
|
+
4. **Infer from values, never from type arguments.** When a helper needs to be
|
|
243
|
+
typed against project data, take that data as a *parameter* and let TypeScript
|
|
244
|
+
infer it. This is what keeps rules 1 and 2 satisfiable without making callers
|
|
245
|
+
write `<Locale, typeof baseMessages>`.
|
|
246
|
+
|
|
247
|
+
## The barrel is the only way in
|
|
248
|
+
|
|
249
|
+
```jsonc
|
|
250
|
+
"exports": {
|
|
251
|
+
".": "./src/index.ts",
|
|
252
|
+
"./astro": "./src/astro/index.ts",
|
|
253
|
+
"./astro/images": "./src/astro/images.ts",
|
|
254
|
+
"./astro/consent": "./src/astro/consent.ts",
|
|
255
|
+
"./astro/meta-tags": "./src/astro/MetaTags.astro"
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**And nothing else** — there is no wildcard, so
|
|
260
|
+
`import { escapeXml } from "@escape-game-over/atlas/xml.ts"` fails to resolve.
|
|
261
|
+
Whatever those entry points do not export is internal by construction rather than
|
|
262
|
+
by convention, which is what makes the file layout under `src/` a private detail:
|
|
263
|
+
modules can be split, renamed or flattened without touching a consumer.
|
|
264
|
+
|
|
265
|
+
The split *within* `astro/` is a further constraint, not tidiness. `./astro` is
|
|
266
|
+
imported by an Astro config, which is evaluated before the build exists — so it
|
|
267
|
+
may use `node:` built-ins but not `astro:assets`. `./astro/images` is the
|
|
268
|
+
opposite: it runs inside the build, from a page, and is unusable from a config.
|
|
269
|
+
Merging them breaks whichever caller loads first.
|
|
270
|
+
|
|
271
|
+
**The package ships TypeScript source, and there is no build step.**
|
|
272
|
+
`MetaTags.astro` could not go through `tsc` anyway, and Astro's own
|
|
273
|
+
`tsconfigs/base.json` already sets `allowImportingTsExtensions`, so every
|
|
274
|
+
consumer gets it for free.
|
|
275
|
+
|
|
276
|
+
## The `atlas` command
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
atlas use <project> # copies config/projects/<name> -> config/project
|
|
280
|
+
atlas use --fallback rome # only when nothing else named one
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The whole directory is copied, so extra files and nested folders come along
|
|
284
|
+
without touching the tool — the contract is just that it contains `project.ts`.
|
|
285
|
+
It resolves paths from the working directory, so a workspace's own
|
|
286
|
+
`package.json` passes nothing. A deployment pipeline can skip it and write its
|
|
287
|
+
own `config/project`.
|
|
288
|
+
|
|
289
|
+
Where a default is wanted it belongs to the caller: the examples pass
|
|
290
|
+
`--fallback` to `dev` and `check`, and deliberately do not to `build`, so the one
|
|
291
|
+
command whose output gets deployed has to say what it is building.
|
|
292
|
+
|
|
293
|
+
## Layout
|
|
294
|
+
|
|
295
|
+
```txt
|
|
296
|
+
src/ the package — see the export map above for what is reachable
|
|
297
|
+
index.ts the public API
|
|
298
|
+
site/ createSite() and the Site it returns
|
|
299
|
+
meta/ head tags: canonical, hreflang, Open Graph, Twitter, robots
|
|
300
|
+
jsonld/ the @graph — one file per node, each linked to Google's docs
|
|
301
|
+
i18n/ defineMessages(), t(), placeholder extraction
|
|
302
|
+
routes/ defineRoutes(), routeFamily(), merging and collisions
|
|
303
|
+
analytics/ Umami and Google, and the Consent Mode defaults
|
|
304
|
+
astro/ the only framework-aware code, plus the one component
|
|
305
|
+
bin/ the `atlas` CLI
|
|
306
|
+
tests/ runtime behaviour — what a merge resolves to, what a builder rejects
|
|
307
|
+
type-tests/ what the types must reject, via @ts-expect-error
|
|
308
|
+
docs/ the long-form reasoning
|
|
309
|
+
examples/ two complete consumers
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Testing
|
|
313
|
+
|
|
314
|
+
**Two commands are the whole gate, and CI needs no more than these:**
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
npm run check-fmt # biome, formatting and lint
|
|
318
|
+
npm test # everything else
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`npm test` runs three suites in order, and stops at the first failure:
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
npm run test:types # tsc --noEmit — the package compiles, and 137 type
|
|
325
|
+
# assertions in type-tests/ still reject what they must
|
|
326
|
+
npm run test:unit # vitest — 355 runtime tests
|
|
327
|
+
npm run test:examples # every example, every deployment: astro check + astro build
|
|
328
|
+
npm run test:watch # vitest, watching
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`test:types` is also the isolation contract: no consumer paths, no ambient types,
|
|
332
|
+
`src/astro/` excluded. If it passes, the package is genuinely independent of
|
|
333
|
+
anything consuming it.
|
|
334
|
+
|
|
335
|
+
`test:examples` **builds every deployment**, not just type-checks one, and both
|
|
336
|
+
halves of that matter. `astro check` only ever sees whichever project
|
|
337
|
+
`config/project` currently holds, so checking once would leave the b2c example's
|
|
338
|
+
second venue entirely unlooked-at. And a whole class of guarantee — two routes
|
|
339
|
+
resolving to the same URL, an icon that is not square, price tiers that
|
|
340
|
+
contradict each other — can only throw at build time, because it depends on
|
|
341
|
+
merged data or on a real file. Type-checking alone would pass all of it.
|
|
342
|
+
|
|
343
|
+
The whole gate is about 15 seconds.
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
## Documentation
|
|
347
|
+
|
|
348
|
+
- [`docs/checks.md`](docs/checks.md) — every guarantee, where it is enforced, and
|
|
349
|
+
how the type tests and runtime tests divide the work.
|
|
350
|
+
- [`docs/share-images.md`](docs/share-images.md) — what each social surface
|
|
351
|
+
crops, and why one 1200×630 image serves all of them.
|
|
352
|
+
- [`docs/toolchain.md`](docs/toolchain.md) — the pinned TypeScript, the Biome
|
|
353
|
+
trade-off for `.astro`, and the traps.
|
|
354
|
+
- [`docs/NOT-BUILT.md`](docs/NOT-BUILT.md) — what we deliberately do not build,
|
|
355
|
+
as questions and answers. Kept beside the code rather than in a backlog,
|
|
356
|
+
because a backlog is a list of things still to do and every one of these is a
|
|
357
|
+
thing not to do.
|
|
358
|
+
|
|
359
|
+
## Publishing
|
|
360
|
+
|
|
361
|
+
The package is `"private": true` today and consumed through the workspace.
|
|
362
|
+
Everything publishing needs is already in place — the export map, `files`, the
|
|
363
|
+
`bin`, `astro` as a peer dependency — so releasing is flipping that one boolean.
|
|
364
|
+
No import statement in any consumer changes.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Copies a project overlay into `config/project`, the folder the whole build
|
|
4
|
+
* reads from. Run automatically before `dev`, `build` and `check`.
|
|
5
|
+
*
|
|
6
|
+
* atlas use acme
|
|
7
|
+
* PROJECT=acme npm run build
|
|
8
|
+
*
|
|
9
|
+
* The entire project directory is copied, so extra files and nested folders come
|
|
10
|
+
* along automatically. A deployment pipeline can skip this command and write its
|
|
11
|
+
* own `config/project` — the contract is just that it contains `project.ts`.
|
|
12
|
+
*/
|
|
13
|
+
import { cp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The consuming project, not the package this file is installed in.
|
|
18
|
+
*
|
|
19
|
+
* `process.cwd()` rather than a path derived from `import.meta.url`, which is
|
|
20
|
+
* the whole difference between a script vendored into a repo and a bin: once
|
|
21
|
+
* this lives in `node_modules`, its own location says where *the package* is
|
|
22
|
+
* and nothing at all about which site is being built. npm runs a workspace's
|
|
23
|
+
* scripts with that workspace as the working directory, so a project's own
|
|
24
|
+
* `package.json` needs to pass nothing.
|
|
25
|
+
*/
|
|
26
|
+
const ROOT = process.cwd();
|
|
27
|
+
const PROJECTS_DIR = join(ROOT, "config", "projects");
|
|
28
|
+
const TARGET_DIR = join(ROOT, "config", "project");
|
|
29
|
+
/**
|
|
30
|
+
* Only an entry-point check — the whole directory is copied recursively, so a
|
|
31
|
+
* project can add files and folders freely without touching this script.
|
|
32
|
+
*/
|
|
33
|
+
const ENTRY_FILE = "project.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which project to copy. No project name is written in this file.
|
|
37
|
+
*
|
|
38
|
+
* This script is machinery: it knows how to copy *a* project and nothing about
|
|
39
|
+
* which ones exist, so naming one as a default would put a single deployment's
|
|
40
|
+
* name inside the generic half. Where a default is wanted it belongs to the
|
|
41
|
+
* caller — `package.json` passes `--fallback` to `dev` and `check`, and
|
|
42
|
+
* deliberately does not to `build`, so the one command whose output gets
|
|
43
|
+
* deployed has to say what it is building.
|
|
44
|
+
*
|
|
45
|
+
* A flag rather than shell expansion. `PROJECT=${PROJECT:-rome} npm run …` says
|
|
46
|
+
* the same thing in one line, and says it in POSIX shell — npm runs scripts
|
|
47
|
+
* through `cmd.exe` on Windows, where that is not syntax but a literal string.
|
|
48
|
+
* The flag costs six lines here and works wherever node does.
|
|
49
|
+
*
|
|
50
|
+
* Precedence is explicit argument, then environment, then fallback: naming a
|
|
51
|
+
* project on the command line beats an exported `PROJECT` left over from
|
|
52
|
+
* earlier in a session, and both beat a default nobody asked for.
|
|
53
|
+
*/
|
|
54
|
+
const argv = process.argv.slice(2);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The subcommand, required and checked rather than assumed.
|
|
58
|
+
*
|
|
59
|
+
* `atlas rome` would read as "atlas, the project" to anyone who has met a CLI
|
|
60
|
+
* before, and there is no reading of it that stays true once this bin grows a
|
|
61
|
+
* second verb. Rejecting the bare form now costs one branch; discovering later
|
|
62
|
+
* that a project may not be named `use` costs a breaking change.
|
|
63
|
+
*/
|
|
64
|
+
const verb = argv[0];
|
|
65
|
+
if (verb !== "use") {
|
|
66
|
+
console.error(
|
|
67
|
+
verb === undefined
|
|
68
|
+
? "Usage: atlas use <project> [--fallback <project>]"
|
|
69
|
+
: `Unknown command "${verb}". Usage: atlas use <project> [--fallback <project>]`
|
|
70
|
+
);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const args = argv.slice(1);
|
|
75
|
+
const flagAt = args.indexOf("--fallback");
|
|
76
|
+
const fallback = flagAt === -1 ? undefined : args[flagAt + 1];
|
|
77
|
+
|
|
78
|
+
// The flag and its value removed, so what is left is what someone typed. Both
|
|
79
|
+
// indices have to be dropped by position rather than by value: a fallback of
|
|
80
|
+
// `rome` and an explicit `rome` are the same string, and filtering by value
|
|
81
|
+
// would take the wrong one out.
|
|
82
|
+
const positional =
|
|
83
|
+
flagAt === -1
|
|
84
|
+
? args
|
|
85
|
+
: args.filter((_, index) => index !== flagAt && index !== flagAt + 1);
|
|
86
|
+
|
|
87
|
+
const name = positional[0] || process.env.PROJECT || fallback;
|
|
88
|
+
|
|
89
|
+
async function projectNames() {
|
|
90
|
+
return (await readdir(PROJECTS_DIR, { withFileTypes: true }))
|
|
91
|
+
.filter((entry) => entry.isDirectory())
|
|
92
|
+
.map((entry) => entry.name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!name) {
|
|
96
|
+
console.error(
|
|
97
|
+
`No project named. Pass one as an argument or set PROJECT.\nAvailable: ${(await projectNames()).join(", ")}`
|
|
98
|
+
);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sourceDir = join(PROJECTS_DIR, name);
|
|
103
|
+
|
|
104
|
+
async function exists(path) {
|
|
105
|
+
try {
|
|
106
|
+
await stat(path);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!(await exists(sourceDir))) {
|
|
114
|
+
console.error(
|
|
115
|
+
`Unknown project "${name}". Available: ${(await projectNames()).join(", ")}`
|
|
116
|
+
);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!(await exists(join(sourceDir, ENTRY_FILE)))) {
|
|
121
|
+
console.error(`Project "${name}" has no ${ENTRY_FILE}.`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await rm(TARGET_DIR, { recursive: true, force: true });
|
|
126
|
+
await cp(sourceDir, TARGET_DIR, { recursive: true });
|
|
127
|
+
await writeFile(join(TARGET_DIR, ".project"), `${name}\n`, "utf8");
|
|
128
|
+
|
|
129
|
+
console.log(
|
|
130
|
+
`Using project "${name}" (config/projects/${name} -> config/project)`
|
|
131
|
+
);
|