@escape-game-over/atlas 0.1.114 → 0.1.116

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/docs/NOT-BUILT.md CHANGED
@@ -209,10 +209,10 @@ something renders a currency picker, and not before.
209
209
 
210
210
  `LocalBusinessInput.type` and `ProductInput.alsoA` take bare strings, so
211
211
  `"EntertainmentBussiness"` compiles, gets emitted, and is ignored by every
212
- reader — the silent failure that `RobotsDirective` and `OpenGraphType` were
212
+ reader — the silent failure that `RobotsDirective` was
213
213
  tightened to prevent.
214
214
 
215
- They are not the same case. Those two vocabularies are closed; schema.org has
215
+ They are not the same case. That vocabulary is closed; schema.org has
216
216
  around 800 types and is still growing. A partial enumeration of an open
217
217
  vocabulary rejects types that are perfectly valid, and the response to that is
218
218
  to cast past it — at which point the check is theatre. A literal union that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.114",
3
+ "version": "0.1.116",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -97,7 +97,7 @@ export interface StoredItem {
97
97
  readonly consent: ConsentCategory | "none";
98
98
  }
99
99
 
100
- export interface StoredItemsOptions {
100
+ export interface StoredItemsOptions<Purpose> {
101
101
  /** As passed to `Document`, and with its default: `true`. */
102
102
  readonly rememberLocale?: boolean;
103
103
  /** As passed to `Document`, and with its default: `false`. */
@@ -109,12 +109,17 @@ export interface StoredItemsOptions {
109
109
  */
110
110
  readonly booking?: readonly BookingVendor[];
111
111
  /**
112
- * Rows the site sets first, then by what has to be granted — nothing,
113
- * analytics, marketing — and otherwise in catalog order. Off, the rows
114
- * come in catalog order: Atlas's own, then each vendor's site row before
115
- * its vendor-domain one.
112
+ * What each row is for, in the project's words — a message key, usually,
113
+ * since the words are the project's and in every locale.
114
+ *
115
+ * Required, and every row this deployment has must be named: a notice
116
+ * that lists a cookie without a purpose is the one thing it must not
117
+ * publish, so a vendor switched on, or a row a new Atlas adds, fails the
118
+ * build until the site says what it is for. Rows this deployment does not
119
+ * have may be named too; a site keeps its wording when a vendor is
120
+ * switched off.
116
121
  */
117
- readonly sort?: boolean;
122
+ readonly purposes: Readonly<Partial<Record<StoredId, Purpose>>>;
118
123
  }
119
124
 
120
125
  interface Stored {
@@ -264,7 +269,7 @@ type VendorWithOwn = {
264
269
  : never;
265
270
  }[keyof typeof CATALOG];
266
271
 
267
- /** Catalog order, which is also the order rows come in unsorted. */
272
+ /** Catalog order, which is how rows that sort alike stay ordered. */
268
273
  const VENDOR_ORDER: readonly StorageVendor[] = [
269
274
  "google-analytics",
270
275
  "google-ads",
@@ -372,8 +377,12 @@ const CONSENT_ORDER: readonly StoredItem["consent"][] = [
372
377
  "marketing",
373
378
  ];
374
379
 
375
- /** The rows the `sort` option puts first come first; ties keep their order. */
376
- export function compareStoredItems(a: StoredItem, b: StoredItem): number {
380
+ /**
381
+ * Rows the site sets first, then by what has to be granted — nothing,
382
+ * analytics, marketing. Ties keep catalog order: Atlas's own, then each
383
+ * vendor's site row before its vendor-domain one.
384
+ */
385
+ function compareStoredItems(a: StoredItem, b: StoredItem): number {
377
386
  return (
378
387
  Number(b.provider === "site") - Number(a.provider === "site") ||
379
388
  CONSENT_ORDER.indexOf(a.consent) - CONSENT_ORDER.indexOf(b.consent)
@@ -381,45 +390,22 @@ export function compareStoredItems(a: StoredItem, b: StoredItem): number {
381
390
  }
382
391
 
383
392
  /**
384
- * Each row with what the project says it is for — a message key, usually,
385
- * since the words are the project's and in every locale.
386
- *
387
- * Throws, naming every row without one, so a vendor switched on, or a row a
388
- * new Atlas adds, fails the build until the site has said what it is for: a
389
- * notice that lists a cookie without a purpose is the one thing it must not
390
- * publish. `purposes` may name rows this deployment does not have; a site
391
- * keeps its wording when a vendor is switched off.
392
- */
393
- export function withPurposes<K>(
394
- rows: readonly StoredItem[],
395
- purposes: Partial<Record<StoredId, K>>
396
- ): readonly (StoredItem & { readonly purpose: K })[] {
397
- const missing = rows.filter((row) => purposes[row.id] === undefined);
398
- if (missing.length > 0) {
399
- throw new Error(
400
- `The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to the purposes given to withPurposes.`
401
- );
402
- }
403
- return rows.map((row) => ({ ...row, purpose: purposes[row.id] as K }));
404
- }
405
-
406
- /**
407
- * Everything this deployment keeps on a device: Atlas's own keys, then each
408
- * loaded vendor's.
393
+ * Everything this deployment keeps on a device, each row with its purpose:
394
+ * the site's own rows first, then by what has to be granted.
409
395
  *
410
396
  * Atlas's rows follow what the page renders, which `Document` decides from
411
397
  * its props, so pass the same `rememberLocale` and `rememberCampaign` here.
412
398
  * The consent record is listed whenever there is a banner to answer.
413
399
  */
414
- export function storedItems(
400
+ export function storedItems<const Purpose>(
415
401
  analytics: AnalyticsSettings | undefined,
416
- options: StoredItemsOptions = {}
417
- ): readonly StoredItem[] {
402
+ options: StoredItemsOptions<Purpose>
403
+ ): readonly (StoredItem & { readonly purpose: Purpose })[] {
418
404
  const {
419
405
  rememberLocale = true,
420
406
  rememberCampaign = false,
421
407
  booking = [],
422
- sort = false,
408
+ purposes,
423
409
  } = options;
424
410
  const asked = consentVendors(analytics).length > 0;
425
411
 
@@ -506,5 +492,14 @@ export function storedItems(
506
492
 
507
493
  rows.push(...widgetRows);
508
494
 
509
- return sort ? rows.toSorted(compareStoredItems) : rows;
495
+ const missing = rows.filter((row) => purposes[row.id] === undefined);
496
+ if (missing.length > 0) {
497
+ throw new Error(
498
+ `The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to storedItems' purposes.`
499
+ );
500
+ }
501
+ return rows.toSorted(compareStoredItems).flatMap((row) => {
502
+ const purpose = purposes[row.id];
503
+ return purpose === undefined ? [] : [{ ...row, purpose }];
504
+ });
510
505
  }
@@ -331,36 +331,22 @@ export async function photoSet(
331
331
  export type ImagePathOptions = Omit<Parameters<typeof getImage>[0], "src">;
332
332
 
333
333
  /**
334
- * The built URL of an optimised image, for the places that take a path rather
335
- * than an `<img>` — a JSON-LD node, a `<link>`, a manifest.
334
+ * The built URL of an optimised image, for places that take a path rather than
335
+ * an `<img>`: a CSS `url()` in an inline style or custom property, a
336
+ * `<link rel="preload">`, a manifest icon.
336
337
  *
337
- * ```ts
338
- * import logo from "~/assets/logo.png";
339
- * import { imagePath } from "@escape-game-over/atlas/astro/images";
340
- *
341
- * const company = organization({
342
- * logo: await imagePath(logo, { width: 512, height: 512, format: "png" }),
343
- * // …
344
- * });
338
+ * ```astro
339
+ * <section style={`--hero: url(${await imagePath(hero, { width: 1600, format: "webp" })})`}>
345
340
  * ```
346
341
  *
347
- * The narrowing is the reason this is a function rather than three lines at
348
- * each call site. `getImage().src` is typed `string`, every field it feeds here
349
- * is typed `UrlPath`, and a cast at each of those would put the same unchecked
350
- * assumption in as many places as there are consumers.
351
- *
352
- * **Not for entity data.** What comes back is `/_astro/logo.<hash>.png`, and the
353
- * hash moves every time the source file is touched. That is right for a page
354
- * resource, which is re-fetched from a document that was just re-fetched, and
355
- * wrong for anything a search engine stores against an identity — an
356
- * `Organization.logo` above all, which is held long after the build that
357
- * produced it. Those want a stable path out of `public/`, written as `/logo.png`
358
- * and joined to the site's own origin; both examples in this repository do that
359
- * deliberately and say so at the call site.
342
+ * A function rather than `getImage().src` at each call site because that is
343
+ * typed `string`, and a path is wanted as `UrlPath`: narrowed once here, with
344
+ * no cast at every caller.
360
345
  *
361
- * So the fit is: a `<link rel="preload">`, a manifest icon, an `<img>` built by
362
- * hand — resources fetched *with* the page. Long-lived references belong in
363
- * `public/`.
346
+ * **Not for structured data.** What comes back is `/_astro/hero.<hash>.webp`,
347
+ * and the hash moves whenever the source changes. Right for a resource fetched
348
+ * with the page; wrong for an `Organization.logo` or anything else a search
349
+ * engine stores against an identity, which wants a stable path in `public/`.
364
350
  */
365
351
  export async function imagePath(
366
352
  src: ImageMetadata,
@@ -27,12 +27,5 @@
27
27
  */
28
28
 
29
29
  export { siteFonts } from "./fonts.ts";
30
- export {
31
- type PublicFilesOptions,
32
- publicFiles,
33
- } from "./public-files.ts";
34
- export {
35
- type SiteFiles,
36
- type SiteRoutesOptions,
37
- siteRoutes,
38
- } from "./site-routes.ts";
30
+ export { publicFiles } from "./public-files.ts";
31
+ export { siteRoutes } from "./site-routes.ts";
@@ -2,13 +2,8 @@ import type { Dirent } from "node:fs";
2
2
  import { readdir } from "node:fs/promises";
3
3
  import type { AstroIntegration } from "astro";
4
4
 
5
- /**
6
- * The module the generated union is declared in.
7
- *
8
- * Types only, so nothing has to exist at runtime: a consumer reaches it with
9
- * `import type`, which is erased before anything tries to resolve it.
10
- */
11
- const PUBLIC_MODULE = "atlas:public";
5
+ /** The package the generated declaration merges into, as consumers import it. */
6
+ const LIB_MODULE = "@escape-game-over/atlas";
12
7
 
13
8
  /** Files in `public/` that are not content: editor litter and placeholders. */
14
9
  const IGNORED = new Set([".DS_Store", "Thumbs.db", ".gitkeep"]);
@@ -38,13 +33,9 @@ async function walk(dir: URL, prefix = ""): Promise<string[]> {
38
33
  }
39
34
 
40
35
  /**
41
- * Types every file in `public/`, so linking to one is checked.
42
- *
43
- * ```ts
44
- * import type { PublicFile } from "atlas:public";
45
- *
46
- * const statement: PublicFile = "/reports/annual-2026.pdf";
47
- * ```
36
+ * Types every file in `public/`, so linking to one is checked: `site.fileUrl()`
37
+ * and a redirect's `{ file }` target take `PublicFilePath`, which this narrows
38
+ * from any path to the files that are there.
48
39
  *
49
40
  * `public/` is the one part of a site the compiler cannot see: files are copied
50
41
  * to the output verbatim, so a link to one is a string nothing verifies, and a
@@ -56,45 +47,19 @@ async function walk(dir: URL, prefix = ""): Promise<string[]> {
56
47
  * commit or add to a tsconfig. A file added while the dev server runs is picked
57
48
  * up when it restarts.
58
49
  */
59
- export interface PublicFilesOptions {
60
- /**
61
- * How the consuming project imports this package.
62
- *
63
- * Defaults to the published name, which is what a project installing it
64
- * from the registry writes. State it only where that is not the specifier
65
- * in the import statements — a path alias, or a fork under another name.
66
- *
67
- * Needed because the generated file *augments* that module: the union has to
68
- * reach `fileUrl` and the redirect target, and a declaration can only be
69
- * merged into a module named the way the consumer names it.
70
- */
71
- readonly libModule?: string;
72
- }
73
-
74
- export function publicFiles(
75
- options: PublicFilesOptions = {}
76
- ): AstroIntegration {
77
- const libModule = options.libModule ?? "@escape-game-over/atlas";
78
-
50
+ export function publicFiles(): AstroIntegration {
79
51
  return {
80
52
  name: "public-files",
81
53
  hooks: {
82
54
  "astro:config:done": async ({ config, injectTypes, logger }) => {
83
55
  const files = (await walk(config.publicDir)).sort();
84
- /**
85
- * The union, one file per line, indented by `spaces`.
86
- *
87
- * A count rather than the whitespace itself: the two call sites
88
- * sit at different depths in the emitted file, and passing the
89
- * indent as a literal means counting spaces by eye in a string
90
- * that looks identical whether it holds eight of them or nine.
91
- */
92
- const union = (spaces: number) =>
56
+ // One file per line, at the depth the declaration sits.
57
+ const union =
93
58
  files.length === 0
94
59
  ? "never"
95
60
  : files
96
61
  .map((file) => `"${file}"`)
97
- .join(`\n${" ".repeat(spaces)}| `);
62
+ .join("\n | ");
98
63
 
99
64
  injectTypes({
100
65
  filename: "public-files.d.ts",
@@ -105,17 +70,11 @@ export function publicFiles(
105
70
  // export lib has.
106
71
  content: `export {};
107
72
 
108
- declare module "${PUBLIC_MODULE}" {
109
- /** Every file in \`public/\`, as it is served. */
110
- export type PublicFile =
111
- | ${union(8)};
112
- }
113
-
114
- declare module "${libModule}" {
73
+ declare module "${LIB_MODULE}" {
115
74
  /** Merged so \`fileUrl\` and redirect targets accept only these. */
116
75
  interface PublicFileRegistry {
117
76
  path:
118
- | ${union(12)};
77
+ | ${union};
119
78
  }
120
79
  }
121
80
  `,
@@ -119,16 +119,11 @@ export function siteRoutes<Id extends string, L extends string>(
119
119
  const describe = (file: GeneratedFile): string =>
120
120
  `${file.name} (${size(file.body)})`;
121
121
 
122
- // Rebuilt per request in dev and once at the end of a build, rather than
123
- // computed here.
124
- //
125
- // Worth knowing what that does *not* buy: it does not make dev current. The
126
- // `site` and the `llms` file this closes over were built when the Astro
127
- // config was evaluated, and nothing re-evaluates that until the server
128
- // restarts — so rebuilding per request derives the same answer from the same
129
- // frozen inputs. Measured: editing a message refreshes the page that renders
130
- // it and leaves the same string in `llms.txt` untouched. A build is always
131
- // right, since it evaluates the config in a fresh process.
122
+ // Built when the dev server starts and when a build ends. The `site` and
123
+ // `llms` this closes over are fixed when the Astro config is evaluated, so
124
+ // in dev these files change only on a restart: editing a message refreshes
125
+ // the page that renders it, not `llms.txt`. A build is always right, since
126
+ // it evaluates the config in a fresh process.
132
127
  const generate = (): GeneratedFile[] => {
133
128
  const files: GeneratedFile[] = [...site.sitemap().files, site.robots()];
134
129
  // Every page's head links to it, so one is written unless the config
@@ -352,35 +347,11 @@ export function siteRoutes<Id extends string, L extends string>(
352
347
  * them out of the page graph.
353
348
  */
354
349
  "astro:server:setup": ({ server, logger }) => {
355
- /**
356
- * The paths this integration answers on, learned by generating
357
- * once and refreshed whenever we generate again.
358
- *
359
- * Its whole job is to keep `generate()` off the hot path. The
360
- * middleware sees every request the dev server takes — each
361
- * page, each asset, each Vite ping — and regenerating for all
362
- * of them rebuilds the sitemap three times over (once directly,
363
- * once inside `robots()`, once inside `llms()`) to answer a
364
- * request for `/favicon.ico`.
365
- *
366
- * A name that appears only later is the one thing this can
367
- * miss: a sitemap crossing `entryLimit` mid-session gains
368
- * `sitemap-0.xml` and friends, which are not in the set until
369
- * something regenerates. Acceptable because the set is
370
- * refreshed on every hit and the dev server restarts on a
371
- * config change — and because the alternative is paying for a
372
- * full generate on every asset request to catch it.
373
- */
374
- const served = new Set<string>();
375
- const remember = (files: readonly GeneratedFile[]) => {
376
- served.clear();
377
- for (const file of files) served.add(`/${file.name}`);
378
- return files;
379
- };
380
-
381
- remember(generate()).map((file) =>
382
- logger.info(`generated ${describe(file)}`)
383
- );
350
+ const files = new Map<string, GeneratedFile>();
351
+ for (const file of generate()) {
352
+ logger.info(`generated ${describe(file)}`);
353
+ files.set(`/${file.name}`, file);
354
+ }
384
355
  server.middlewares.use((request, response, next) => {
385
356
  const path = (request.url ?? "").split("?")[0] ?? "";
386
357
 
@@ -406,23 +377,8 @@ export function siteRoutes<Id extends string, L extends string>(
406
377
  return;
407
378
  }
408
379
 
409
- // Nothing of ours sits at this path, so hand it on without
410
- // building anything. This is the check that keeps the cost
411
- // below off every page and asset request in dev.
412
- if (!served.has(path)) {
413
- next();
414
- return;
415
- }
416
-
417
- // Regenerated per request, so an edit to a route or a
418
- // message shows up on reload rather than at restart.
419
- const file = remember(generate()).find(
420
- (candidate) => `/${candidate.name}` === path
421
- );
380
+ const file = files.get(path);
422
381
  if (file === undefined) {
423
- // The set said this was ours and the fresh build
424
- // disagrees — a file that stopped being generated, so
425
- // it is now a 404 like any other missing path.
426
382
  next();
427
383
  return;
428
384
  }
package/src/config.ts CHANGED
@@ -173,15 +173,6 @@ export function defineSiteConfig<
173
173
  return config;
174
174
  }
175
175
 
176
- /**
177
- * What a project may restate about a locale.
178
- *
179
- * Only the label: writing direction is a fact about the script a language is
180
- * written in, not a choice a deployment makes, so it is derived from the tag.
181
- * The label is the one part that is a matter of taste.
182
- */
183
- export type LocaleMetaOverride = Pick<LocaleMeta, "label">;
184
-
185
176
  /**
186
177
  * Editor-facing error for a locale this site does not declare at all.
187
178
  *
@@ -199,27 +190,27 @@ export interface LocaleNotEnabled<L extends string> {
199
190
  }
200
191
 
201
192
  /**
202
- * Applies a project's overrides and derives the tag fields from the key.
193
+ * Each published locale's label, the project's where it gives one, with the
194
+ * tag fields derived from its key.
203
195
  *
204
196
  * The locale key is the language tag, and `ogLocale` is that tag with `-`
205
197
  * swapped for `_` — Open Graph writes `en_US` where BCP 47 writes `en-US`.
206
198
  * `dir` comes from the tag's script, so `ar-EG` and `az-Arab-IR` are
207
199
  * right-to-left and `az-AZ` is not.
208
200
  */
209
- export function mergeLocaleMeta<L extends string>(
201
+ export function resolveLocaleMeta<L extends string>(
210
202
  base: Readonly<Record<L, LocaleMeta>>,
211
- overrides: Readonly<Partial<Record<L, LocaleMetaOverride>>> | undefined,
212
- locales: readonly L[]
203
+ locales: readonly L[],
204
+ labels: Readonly<Partial<Record<L, string>>>
213
205
  ): Readonly<Record<L, ResolvedLocaleMeta>> {
214
206
  const merged: Record<string, ResolvedLocaleMeta> = {};
215
207
  for (const locale of locales) {
216
- const defaults = base[locale];
217
- if (defaults === undefined) {
208
+ const meta = base[locale];
209
+ if (meta === undefined) {
218
210
  throw new Error(`No locale metadata for "${locale}".`);
219
211
  }
220
- const meta = { ...defaults, ...overrides?.[locale] };
221
212
  merged[locale] = {
222
- label: meta.label,
213
+ label: labels[locale] ?? meta.label,
223
214
  dir: writingDirection(locale),
224
215
  htmlLang: locale,
225
216
  ogLocale: locale.replace(/-/g, "_"),
@@ -61,8 +61,8 @@ export type Span =
61
61
  /**
62
62
  * A link, in the three shapes a destination actually comes in.
63
63
  *
64
- * **A union rather than an optional `url` and an `external` flag**, for the
65
- * reason `PageKind` is one: those two fields could disagree, and `external:
64
+ * **A union rather than an optional `url` and an `external` flag**: those
65
+ * two fields could disagree, and `external:
66
66
  * true` with no absolute URL is a state that means nothing and would still
67
67
  * type-check. Here the discriminant *is* the answer — a consumer that needs
68
68
  * an absolute URL narrows on `to` and gets one, or is told plainly that this
@@ -297,16 +297,15 @@ export function createRichText<
297
297
  ): RichTextFor<Catalog, RouteId, Links, Values> {
298
298
  const { catalog, locale, link, origin, links, values } = options;
299
299
 
300
- return <K extends StringKeys<Catalog>>(
301
- ...args: RichArgsOf<Catalog, K, RouteId, Links, Values>
300
+ // Untyped inside: the return type is what callers see, keys and params
301
+ // checked. Placeholder values and link targets are both strings once here —
302
+ // the distinction they are worth making is entirely a compile-time one.
303
+ return (
304
+ key: string,
305
+ params?: Readonly<Record<string, string>>
302
306
  ): RichText => {
303
- // The conditional tuple cannot be destructured directly; the runtime
304
- // shape is always `[key]` or `[key, params]`. As in `createTranslate`.
305
- // Placeholder values and link targets are both strings once here — the
306
- // distinction they are worth making is entirely a compile-time one.
307
- const [key, params] = args as [K, Readonly<Record<string, string>>?];
308
- const at = `Message "${String(key)}" (${locale})`;
309
- const template = lookupTemplate(catalog, locale, String(key));
307
+ const at = `Message "${key}" (${locale})`;
308
+ const template = lookupTemplate(catalog, locale, key);
310
309
 
311
310
  return parseMarks(template, at).map((span) =>
312
311
  resolve(span, at, params, { links, values }, link, origin)
@@ -379,7 +378,7 @@ function fill(
379
378
  * The destination a slot was filled with.
380
379
  *
381
380
  * `Object.hasOwn` rather than reading the index straight, for the reason
382
- * `createRawTranslate` uses it: `__proto__` is a legal slot name by `SLOT`, so
381
+ * `createTranslate` uses it: `__proto__` is a legal slot name by `SLOT`, so
383
382
  * a plain object would answer with `Object.prototype` — not `undefined`, and so
384
383
  * not caught below.
385
384
  *
@@ -620,12 +619,10 @@ export function createPlainText<
620
619
  ): PlainTextFor<Catalog, Values> {
621
620
  const { catalog, locale, values } = options;
622
621
 
623
- return <K extends StringKeys<Catalog>>(
624
- ...args: TranslateArgsOf<Catalog, K, Values>
625
- ): string => {
626
- const [key, params] = args as [K, Readonly<Record<string, string>>?];
627
- const at = `Message "${String(key)}" (${locale})`;
628
- const template = lookupTemplate(catalog, locale, String(key));
622
+ // Untyped inside, as `rich()`: the return type is what callers see.
623
+ return (key: string, params?: Readonly<Record<string, string>>): string => {
624
+ const at = `Message "${key}" (${locale})`;
625
+ const template = lookupTemplate(catalog, locale, key);
629
626
 
630
627
  return collapse(
631
628
  parseMarks(template, at).map((span) =>
@@ -29,6 +29,8 @@ export interface ProjectCatalog<Base, Published extends string, Override, Add> {
29
29
  readonly override: Override;
30
30
  /** Messages only this project has. */
31
31
  readonly add: Add;
32
+ /** The project's names for its languages, where the site's do not fit. */
33
+ readonly labels: Readonly<Partial<Record<Published, string>>>;
32
34
  }
33
35
 
34
36
  /**
@@ -99,7 +101,8 @@ export type OverrideChecks<Base, Published extends string, O> = NoExcessKeys<
99
101
  * - `override` keys exist in the base catalog, keep its `{placeholders}` and
100
102
  * `[marks]`, and are written only for published locales;
101
103
  * - `add` keys are new to the base catalog, written for exactly the published
102
- * locales, with the same `{placeholders}` and `[marks]` in each.
104
+ * locales, with the same `{placeholders}` and `[marks]` in each;
105
+ * - `labels` are for published locales.
103
106
  */
104
107
  export function defineCatalog<
105
108
  const C extends SiteConfigShape,
@@ -119,6 +122,13 @@ export function defineCatalog<
119
122
  readonly override: Override &
120
123
  ValidateOverrideCatalog<Override, Base, LocalesOf<C>>;
121
124
  readonly add?: Add & ValidateAdditions<Add, Base, Published>;
125
+ /**
126
+ * A language's name in the language switcher, replacing the site's
127
+ * label for that locale on this project only. `NoInfer`, so a label
128
+ * cannot widen the published locales: a locale written here that
129
+ * `locales` lacks is an error on its own line.
130
+ */
131
+ readonly labels?: Readonly<Partial<Record<NoInfer<Published>, string>>>;
122
132
  }
123
133
  ): ProjectCatalog<Base, Published, Override, Add> {
124
134
  // Checked at runtime as well as in the type: an Astro config is evaluated
@@ -144,10 +154,19 @@ export function defineCatalog<
144
154
  );
145
155
  }
146
156
  }
157
+ const labels = own.labels ?? {};
158
+ for (const locale of Object.keys(labels)) {
159
+ if (!own.locales.some((published) => published === locale)) {
160
+ throw new Error(
161
+ `labels: "${locale}" is not among this catalog's locales, so its label would never be shown.`
162
+ );
163
+ }
164
+ }
147
165
  return {
148
166
  base,
149
167
  locales: own.locales,
150
168
  override: own.override,
151
169
  add: add as Add,
170
+ labels,
152
171
  };
153
172
  }
@@ -54,11 +54,6 @@ export type OverrideCatalog<L extends string> = Readonly<
54
54
  Record<string, OverrideEntry<L>>
55
55
  >;
56
56
 
57
- /** The locale union a catalog covers, read back off its own entries. */
58
- export type LocalesOfCatalog<Catalog> = StringKeys<
59
- Catalog[StringKeys<Catalog>]
60
- >;
61
-
62
57
  type SelfConsistent<E, K> = [MismatchedLocales<E, EntryTokens<E>>] extends [
63
58
  never,
64
59
  ]