@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,307 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { AstroIntegration } from "astro";
5
+ import type { GeneratedFile } from "../file.ts";
6
+ import {
7
+ buildCloudflareRedirects,
8
+ type ResolvedRedirect,
9
+ } from "../redirects.ts";
10
+ import type { Sitemap } from "../sitemap.ts";
11
+ import type { HttpsUrl } from "../url.ts";
12
+ import { warn } from "../warn.ts";
13
+
14
+ /**
15
+ * What this integration needs of a site, and no more.
16
+ *
17
+ * Structural rather than `Site<L, Catalog, RouteId>`, so any site satisfies it
18
+ * without having to name its three type arguments here — and so this states
19
+ * exactly which parts are used.
20
+ */
21
+ export interface SiteFiles {
22
+ readonly url: HttpsUrl;
23
+ /** Segments with pages under them but none at them; see `orphanSegments`. */
24
+ readonly orphanSegments: readonly string[];
25
+ /** Undefined when the site config turned `llms.txt` off. */
26
+ readonly llmsUrl: HttpsUrl | undefined;
27
+ sitemap(): Sitemap;
28
+ robots(): GeneratedFile;
29
+ llms(): GeneratedFile;
30
+ }
31
+
32
+ /**
33
+ * What `updateConfig` accepts: a deep partial of the resolved Astro config.
34
+ *
35
+ * Read off the hook rather than named directly. Astro types the parameter as
36
+ * `DeepPartial<AstroConfig>`, but `DeepPartial` is internal — importing it
37
+ * means reaching past the package entry point into a path that can move
38
+ * between releases. `AstroConfig` itself is the wrong type here: it is the
39
+ * *resolved* config, so annotating a three-key patch with it fails on every
40
+ * field the patch does not set.
41
+ */
42
+ type ConfigUpdate = Parameters<
43
+ Parameters<
44
+ NonNullable<AstroIntegration["hooks"]["astro:config:setup"]>
45
+ >[0]["updateConfig"]
46
+ >[0];
47
+
48
+ /** Byte count of a UTF-8 string, for a log line that says what it cost. */
49
+ function size(body: string): string {
50
+ const bytes = new TextEncoder().encode(body).length;
51
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} kB`;
52
+ }
53
+
54
+ export interface SiteRoutesOptions {
55
+ /**
56
+ * The site, imported. Not a module path: everything below is derived from
57
+ * plain data — URLs, route ids, locales — so it can be built where an Astro
58
+ * config is evaluated, and a real import is checked where a specifier would
59
+ * not be.
60
+ */
61
+ readonly site: SiteFiles;
62
+ /** From `site.redirects([…])`. Omit to write no `_redirects`. */
63
+ readonly redirects?: readonly ResolvedRedirect[];
64
+ /**
65
+ * From `site.llms({…})`.
66
+ *
67
+ * The finished file rather than a flag, because this is the one output whose
68
+ * words lib does not have: page names and summaries live in a catalog under
69
+ * a key convention it cannot guess. Omitting it does not turn the file off —
70
+ * `site.llms()` still writes one, listing every page by route id with no
71
+ * summaries. That is a worse file and a real one; switching it off is a
72
+ * decision, made with `llms: false` in the site config.
73
+ */
74
+ readonly llms?: GeneratedFile;
75
+ /** Write the sitemap. Defaults to true. */
76
+ readonly sitemap?: boolean;
77
+ /** Write `robots.txt`. Defaults to true. */
78
+ readonly robots?: boolean;
79
+ }
80
+
81
+ /**
82
+ * Emits the files a static site owes the outside world.
83
+ *
84
+ * Written into the output directory when the build is done, rather than served
85
+ * by injected routes. Two reasons, and the first is not a preference:
86
+ *
87
+ * - `_redirects` cannot be a page at all. Astro refuses to route anything in
88
+ * `src/pages` whose name begins with `_`, and URL-escaping does not help —
89
+ * `%5F` is decoded before the entrypoint is opened.
90
+ * - A route is injected by *entrypoint*, and that file is compiled into the
91
+ * build's own module graph, where it cannot see a caller's `site`. Reaching it
92
+ * means naming a module in a string and resolving it through a virtual module
93
+ * — a lot of machinery, and nothing type-checks the string. None of these
94
+ * files needs the asset pipeline or a page context, so none of that buys
95
+ * anything.
96
+ */
97
+ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
98
+ const { site, redirects, llms, sitemap = true, robots = true } = options;
99
+
100
+ /** `llms.txt (4.2 kB)` — the name and what it actually weighs. */
101
+ const describe = (file: GeneratedFile): string =>
102
+ `${file.name} (${size(file.body)})`;
103
+
104
+ // Rebuilt per request in dev and once at the end of a build, rather than
105
+ // computed here: in dev the site's data changes under the server, and a list
106
+ // captured when the integration was constructed would serve yesterday's
107
+ // sitemap until you restarted.
108
+ const generate = (): GeneratedFile[] => {
109
+ const files: GeneratedFile[] = [];
110
+ if (sitemap) files.push(...site.sitemap().files);
111
+ if (robots) files.push(site.robots());
112
+ // `llmsUrl` is what every page's head links to, so a file must exist at
113
+ // it: the described one when given, a bare listing otherwise. Undefined
114
+ // means the config turned both off.
115
+ if (site.llmsUrl !== undefined) files.push(llms ?? site.llms());
116
+ // Length rather than presence: a site with no rules gets no file, not an
117
+ // empty one.
118
+ if (redirects?.length) {
119
+ files.push(
120
+ buildCloudflareRedirects(redirects, { siteUrl: site.url })
121
+ );
122
+ }
123
+ return files;
124
+ };
125
+
126
+ /**
127
+ * Said once, at startup, listing every gap together.
128
+ *
129
+ * Not raised by `breadcrumbFor`: a page would then report a fact about the
130
+ * route table, and only the first page to render one would say anything —
131
+ * which in dev means once per server lifetime, long after you scrolled past.
132
+ */
133
+ const reportOrphans = () => {
134
+ if (site.orphanSegments.length === 0) return;
135
+
136
+ warn(
137
+ "routes",
138
+ `${site.orphanSegments.length} URL segment${site.orphanSegments.length === 1 ? " has" : "s have"} pages under ${site.orphanSegments.length === 1 ? "it" : "them"} but none at ${site.orphanSegments.length === 1 ? "it" : "them"}: ${site.orphanSegments.map((segment) => `/${segment}`).join(", ")}. Breadcrumbs through ${site.orphanSegments.length === 1 ? "it" : "them"} will have a gap, since structured data will not accept a step without a URL.`
139
+ );
140
+ };
141
+
142
+ return {
143
+ name: "site-routes",
144
+ hooks: {
145
+ /**
146
+ * The three settings lib's own output would contradict, set here
147
+ * rather than asked for.
148
+ *
149
+ * Not a preference imposed on a consumer — each is the half of a
150
+ * contract whose other half this library already wrote:
151
+ *
152
+ * - `output: "static"`. Every file below is generated once, at
153
+ * build time, from data known then. There is no request to
154
+ * render against, and `robots.txt`, the sitemap and `_redirects`
155
+ * are written into the output directory by `astro:build:done`,
156
+ * which an on-demand build never reaches.
157
+ * - `build.format: "file"`. lib emits every canonical, `hreflang`,
158
+ * sitemap and `llms.txt` URL without a trailing slash. A host
159
+ * canonicalising HTML sends `/about-us` to `/about-us/` when the
160
+ * file on disk is `about-us/index.html`, and the other way round
161
+ * when it is `about-us.html` — so `directory` would leave every
162
+ * URL this library publishes redirecting to a different one, with
163
+ * nothing in the build to say so.
164
+ * - `trailingSlash: "ignore"`. Dev-server matching only, and set to
165
+ * the one value that cannot contradict the links lib hands out:
166
+ * `"always"` would 404 every one of them, and `"never"` would
167
+ * refuse a form the host answers with a redirect rather than a
168
+ * 404 — dev stricter than production, which teaches you nothing.
169
+ *
170
+ * Set instead of validated because there is no way to tell a
171
+ * deliberate `"directory"` from Astro's default: both arrive here
172
+ * as the same resolved value, so a check could only warn at
173
+ * everyone or at no one.
174
+ */
175
+ "astro:config:setup": ({ updateConfig, logger }) => {
176
+ // Annotated, not just passed: `updateConfig(config)` alone
177
+ // would not catch a misspelled key, because excess-property
178
+ // checking fires on a fresh object literal at the call site and
179
+ // not on a variable — and every field of the target is
180
+ // optional, so `buld: {…}` would be assignable and silently do
181
+ // nothing. The annotation is what makes a typo an error.
182
+ const config: ConfigUpdate = {
183
+ output: "static",
184
+ build: { format: "file" },
185
+ trailingSlash: "ignore",
186
+ };
187
+ updateConfig(config);
188
+ // Said out loud: a setting changed from under you is worth a
189
+ // line, and reading `astro.config.ts` would otherwise leave you
190
+ // to wonder why the output is not shaped the way its defaults
191
+ // say. One line at startup, naming every value it set.
192
+ logger.info(
193
+ `updated config: ${JSON.stringify(config, null, 2)}`
194
+ );
195
+ reportOrphans();
196
+ },
197
+
198
+ /**
199
+ * The same files, served from memory while developing.
200
+ *
201
+ * Without this they exist only after `astro build`: `astro:build:done`
202
+ * does not run under `astro dev`, so every one of them 404s in the
203
+ * one place you would go to look at it. They were routes once, which
204
+ * gave this for free; serving them here is the cost of having moved
205
+ * them out of the page graph.
206
+ */
207
+ "astro:server:setup": ({ server, logger }) => {
208
+ /**
209
+ * The paths this integration answers on, learned by generating
210
+ * once and refreshed whenever we generate again.
211
+ *
212
+ * Its whole job is to keep `generate()` off the hot path. The
213
+ * middleware sees every request the dev server takes — each
214
+ * page, each asset, each Vite ping — and regenerating for all
215
+ * of them rebuilds the sitemap three times over (once directly,
216
+ * once inside `robots()`, once inside `llms()`) to answer a
217
+ * request for `/favicon.ico`.
218
+ *
219
+ * A name that appears only later is the one thing this can
220
+ * miss: a sitemap crossing `entryLimit` mid-session gains
221
+ * `sitemap-0.xml` and friends, which are not in the set until
222
+ * something regenerates. Acceptable because the set is
223
+ * refreshed on every hit and the dev server restarts on a
224
+ * config change — and because the alternative is paying for a
225
+ * full generate on every asset request to catch it.
226
+ */
227
+ const served = new Set<string>();
228
+ const remember = (files: readonly GeneratedFile[]) => {
229
+ served.clear();
230
+ for (const file of files) served.add(`/${file.name}`);
231
+ return files;
232
+ };
233
+
234
+ remember(generate()).map((file) =>
235
+ logger.info(`generated ${describe(file)}`)
236
+ );
237
+ server.middlewares.use((request, response, next) => {
238
+ const path = (request.url ?? "").split("?")[0] ?? "";
239
+
240
+ // The dev server knows nothing about `_redirects`, which is
241
+ // a static-host feature applied to the built output. Left
242
+ // alone, every redirect 404s in dev — including `/` when
243
+ // every locale is prefixed, which is the first URL anyone
244
+ // opens. Serving them here makes dev answer as production
245
+ // will, rather than as a place where the rules do not exist.
246
+ // Matched with and without a trailing slash, because a host
247
+ // either normalises one away before its rules run or treats
248
+ // the two as the same URL. Dev should not be the only place
249
+ // where `/rooms/` misses a rule that `/rooms` hits.
250
+ const canonical =
251
+ path.length > 1 && path.endsWith("/")
252
+ ? path.slice(0, -1)
253
+ : path;
254
+ const rule = redirects?.find((it) => it.from === canonical);
255
+ if (rule !== undefined) {
256
+ logger.info(
257
+ `redirect: ${path} → ${rule.to} (${rule.status})`
258
+ );
259
+ response.statusCode = rule.status;
260
+ response.setHeader("Location", rule.to);
261
+ response.end();
262
+ return;
263
+ }
264
+
265
+ // Nothing of ours sits at this path, so hand it on without
266
+ // building anything. This is the check that keeps the cost
267
+ // below off every page and asset request in dev.
268
+ if (!served.has(path)) {
269
+ next();
270
+ return;
271
+ }
272
+
273
+ // Regenerated per request, so an edit to a route or a
274
+ // message shows up on reload rather than at restart.
275
+ const file = remember(generate()).find(
276
+ (candidate) => `/${candidate.name}` === path
277
+ );
278
+ if (file === undefined) {
279
+ // The set said this was ours and the fresh build
280
+ // disagrees — a file that stopped being generated, so
281
+ // it is now a 404 like any other missing path.
282
+ next();
283
+ return;
284
+ }
285
+ logger.info(`${path} → ${describe(file)}`);
286
+ response.setHeader("Content-Type", file.contentType);
287
+ response.end(file.body);
288
+ });
289
+ },
290
+
291
+ "astro:build:done": async ({ dir, logger }) => {
292
+ const files = generate();
293
+
294
+ await Promise.all(
295
+ files.map(async (file) => {
296
+ // `name` may carry a subdirectory, and the sitemap's
297
+ // does once it splits into numbered parts.
298
+ const path = fileURLToPath(new URL(file.name, dir));
299
+ await mkdir(dirname(path), { recursive: true });
300
+ await writeFile(path, file.body, "utf8");
301
+ })
302
+ );
303
+ files.map((file) => logger.info(`wrote ${describe(file)}`));
304
+ },
305
+ },
306
+ };
307
+ }
package/src/config.ts ADDED
@@ -0,0 +1,218 @@
1
+ import type { LlmsConfig } from "./llms.ts";
2
+ import type { RobotsConfig } from "./robots.ts";
3
+ import type { SitemapConfig } from "./sitemap.ts";
4
+ import type { StringKeys } from "./types.ts";
5
+
6
+ export interface LocaleMeta {
7
+ /** Name of the language, written in that language. */
8
+ readonly label: string;
9
+ // No language tag here: the locale's own key is the tag. One identifier,
10
+ // used for the URL segment, `lang`, `hreflang` and `og:locale` alike.
11
+ readonly dir: "ltr" | "rtl";
12
+ }
13
+
14
+ /** `LocaleMeta` after defaults are filled in. */
15
+ export interface ResolvedLocaleMeta {
16
+ readonly label: string;
17
+ readonly htmlLang: string;
18
+ readonly dir: "ltr" | "rtl";
19
+ /** Derived: `htmlLang` with `-` swapped for `_`, which is the OG form. */
20
+ readonly ogLocale: string;
21
+ }
22
+
23
+ /**
24
+ * A well-formed language tag, or `never`.
25
+ *
26
+ * The region is required: `en` is rejected, `en-US` accepted. A bare language
27
+ * leaves the territory to the reader, and `en-US` and `en-GB` differ in spelling,
28
+ * currency and date order — decisions worth stating rather than inferring.
29
+ *
30
+ * Checks shape and casing only, so `en_US`, `en-us` and `EN-US` are all
31
+ * rejected. It deliberately does not check that the subtags are *real*: the
32
+ * authoritative list is the IANA Language Subtag Registry, thousands of entries
33
+ * that would go stale the moment they were copied into this folder.
34
+ */
35
+ export type LanguageTag<T extends string> =
36
+ T extends `${infer Lang}-${infer Region}`
37
+ ? Lang extends Lowercase<Lang>
38
+ ? Region extends Uppercase<Region>
39
+ ? T
40
+ : never
41
+ : never
42
+ : never;
43
+
44
+ interface MalformedLanguageTag<T extends string> {
45
+ readonly __MALFORMED_LANGUAGE_TAG__: `"${T}" is not a well-formed language tag: expected a lowercase language and an uppercase region, like "en-US"`;
46
+ }
47
+
48
+ /**
49
+ * Every locale key must itself be a well-formed tag, since it *is* the tag.
50
+ *
51
+ * A malformed key demands a value no object can satisfy, so the error lands on
52
+ * that locale and names the expected shape.
53
+ */
54
+ type ValidateLocales<T> = {
55
+ [K in StringKeys<T>]: [LanguageTag<K>] extends [never]
56
+ ? MalformedLanguageTag<K>
57
+ : LocaleMeta;
58
+ };
59
+
60
+ export interface RoutingConfig<L extends string> {
61
+ readonly defaultLocale: L;
62
+ /**
63
+ * `false` -> default locale at `/`, others at `/<locale>`.
64
+ * `true` -> every locale prefixed, and `/` redirects to `/<defaultLocale>`.
65
+ */
66
+ readonly prefixDefaultLocale: boolean;
67
+ /**
68
+ * The segment marking a page of a paginated list: `/news/page/2`.
69
+ *
70
+ * Defaults to `"page"`, which is what Hugo and WordPress produce and what a
71
+ * reader expects. A segment of its own rather than `/news/2`, so a post
72
+ * slugged with a number cannot collide with a page number.
73
+ */
74
+ readonly pageSegment?: string;
75
+ /**
76
+ * Per-locale spellings of it — `{ "el-GR": "selida" }`.
77
+ *
78
+ * Translated because it sits in a URL beside slugs that are: leaving an
79
+ * English word in the middle of `/el-GR/nea/…` is the one untranslated
80
+ * thing on the page.
81
+ */
82
+ readonly pageSegmentByLocale?: Readonly<Partial<Record<L, string>>>;
83
+ }
84
+
85
+ /**
86
+ * The shape of `site.config.ts`: which languages exist and how URLs are shaped.
87
+ *
88
+ * The per-feature sections are defined by the features themselves, so a change
89
+ * to what a sitemap can be told reaches this file without editing it.
90
+ */
91
+ export interface SiteConfigShape {
92
+ readonly locales: Readonly<Record<string, LocaleMeta>>;
93
+ /**
94
+ * The URL shape a project gets unless it says otherwise.
95
+ *
96
+ * Named for what it is: a project's `overrideRouting` wins over it, so
97
+ * reading this file is not enough to know how one deployment's URLs look.
98
+ */
99
+ readonly defaultRouting: RoutingConfig<string>;
100
+ readonly sitemap?: SitemapConfig;
101
+ readonly robots?: RobotsConfig;
102
+ /**
103
+ * `false` to publish no `llms.txt`. Omitted, one is published under the
104
+ * default name.
105
+ *
106
+ * Opt-out like the two above, even though lib cannot write this one
107
+ * unaided — it needs a name and a summary per page, which only the consumer
108
+ * has. Forgetting to supply them is a build error rather than a reason to
109
+ * make the file opt-in: this key is also what puts `<link rel="describedby">`
110
+ * in every head, and a page cannot tell whether `site.llms()` was called
111
+ * somewhere, so the declaration stands in for it and `siteRoutes` checks
112
+ * that it was kept.
113
+ */
114
+ readonly llms?: LlmsConfig | false;
115
+ // No share image here on purpose: an image belongs to the page it
116
+ // represents, so the page passes its own to `metaFor`.
117
+ }
118
+
119
+ /** The locale union a site config declares, e.g. `"en" | "el"`. */
120
+ export type LocalesOf<C> = C extends { readonly locales: infer M }
121
+ ? StringKeys<M>
122
+ : never;
123
+
124
+ /**
125
+ * Declares the site's locale universe and URL shape.
126
+ *
127
+ * Every other helper takes this object as its first argument, which is how they
128
+ * learn the locale union without you ever writing a type argument.
129
+ *
130
+ * Enforced at compile time: `defaultRouting.defaultLocale` must be one of
131
+ * `locales`, and every locale key must be a well-formed language tag.
132
+ *
133
+ * Both checks live in the constraint rather than the parameter. In parameter
134
+ * position a single unrelated mistake widens `T`, every key then resolves to
135
+ * `string`, and the config reports a cascade of malformed-tag errors on lines
136
+ * that were fine.
137
+ */
138
+ export function defineSiteConfig<
139
+ const T extends SiteConfigShape & {
140
+ readonly defaultRouting: {
141
+ readonly defaultLocale: StringKeys<T["locales"]>;
142
+ // Mapped over the keys actually written, not declared as a
143
+ // `Partial<Record<…>>`: a constraint is checked by assignability,
144
+ // and an extra key is assignable to a type whose properties are all
145
+ // optional. Demanding an impossible value for a bad key is what
146
+ // makes the error land on that line.
147
+ readonly pageSegmentByLocale?: {
148
+ readonly [K in StringKeys<
149
+ T["defaultRouting"]["pageSegmentByLocale"]
150
+ >]: K extends StringKeys<T["locales"]>
151
+ ? string
152
+ : LocaleNotDeclared<K>;
153
+ };
154
+ };
155
+ readonly locales: ValidateLocales<T["locales"]>;
156
+ },
157
+ >(config: T): T {
158
+ return config;
159
+ }
160
+
161
+ /**
162
+ * What a project may restate about a locale.
163
+ *
164
+ * `dir` is deliberately not here: writing direction is a fact about the script a
165
+ * language is written in, not a choice a deployment makes. Greek is
166
+ * left-to-right for everybody, and a project that could say otherwise would only
167
+ * ever be wrong. The label is the one part that is a matter of taste.
168
+ */
169
+ export type LocaleMetaOverride = Pick<LocaleMeta, "label">;
170
+
171
+ /**
172
+ * Editor-facing error for a locale this site does not declare at all.
173
+ *
174
+ * The sibling of `LocaleNotEnabled`, one level up: that one is for a locale the
175
+ * site has but a project does not publish, this one is for a tag that exists
176
+ * nowhere — a typo, or a language someone meant to add and did not.
177
+ */
178
+ export interface LocaleNotDeclared<L extends string> {
179
+ readonly __LOCALE_NOT_DECLARED__: `locale "${L}" is not among this site's locales, so anything keyed by it would never be read`;
180
+ }
181
+
182
+ /**
183
+ * Editor-facing error for anything written for a locale a project does not ship.
184
+ *
185
+ * Lives here rather than beside one of its users because two of them need it:
186
+ * `defineProject`, for locale metadata, and `defineMessageOverrides`, for copy.
187
+ */
188
+ export interface LocaleNotEnabled<L extends string> {
189
+ readonly __LOCALE_NOT_ENABLED__: `locale "${L}" is not among this project's enabledLocales, so anything written for it would never be built`;
190
+ }
191
+
192
+ /**
193
+ * Applies a project's overrides and derives the tag fields from the key.
194
+ *
195
+ * The locale key is the language tag, and `ogLocale` is that tag with `-`
196
+ * swapped for `_` — Open Graph writes `en_US` where BCP 47 writes `en-US`.
197
+ */
198
+ export function mergeLocaleMeta<L extends string>(
199
+ base: Readonly<Record<L, LocaleMeta>>,
200
+ overrides: Readonly<Partial<Record<L, LocaleMetaOverride>>> | undefined,
201
+ locales: readonly L[]
202
+ ): Readonly<Record<L, ResolvedLocaleMeta>> {
203
+ const merged: Record<string, ResolvedLocaleMeta> = {};
204
+ for (const locale of locales) {
205
+ const defaults = base[locale];
206
+ if (defaults === undefined) {
207
+ throw new Error(`No locale metadata for "${locale}".`);
208
+ }
209
+ const meta = { ...defaults, ...overrides?.[locale] };
210
+ merged[locale] = {
211
+ label: meta.label,
212
+ dir: meta.dir,
213
+ htmlLang: locale,
214
+ ogLocale: locale.replace(/-/g, "_"),
215
+ };
216
+ }
217
+ return merged as Readonly<Record<L, ResolvedLocaleMeta>>;
218
+ }