@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,412 @@
1
+ import type { LocalesOf, SiteConfigShape } from "../config.ts";
2
+ import type { IsNever, IsoDate, NoExcessKeys, StringKeys } from "../types.ts";
3
+
4
+ export type ChangeFreq =
5
+ | "always"
6
+ | "hourly"
7
+ | "daily"
8
+ | "weekly"
9
+ | "monthly"
10
+ | "yearly"
11
+ | "never";
12
+
13
+ export interface RouteSitemap {
14
+ readonly priority?: number;
15
+ readonly changefreq?: ChangeFreq;
16
+ /**
17
+ * The day this page's content last changed, as `"2026-03-01"`.
18
+ *
19
+ * Omitted means no `<lastmod>` at all, which is the honest default: the
20
+ * alternative is stamping the build date on every page, which says
21
+ * "everything changed" on every deploy until crawlers stop believing any of
22
+ * it. State it only where you know it, and only where it is true.
23
+ */
24
+ readonly lastmod?: IsoDate;
25
+ /** Set true to build the page but keep it out of the sitemap. */
26
+ readonly exclude?: boolean;
27
+ }
28
+
29
+ export interface RouteData<L extends string> {
30
+ /**
31
+ * The route's URL segment, used for every locale that does not translate it.
32
+ * No leading or trailing slashes. `""` is the locale root, and nested
33
+ * segments (`"services/consulting"`) are fine.
34
+ */
35
+ readonly slug: string;
36
+ /** Per-locale translations of `slug`. Locales left out fall back to it. */
37
+ readonly slugByLocale?: Readonly<Partial<Record<L, string>>>;
38
+ /** Whether this route is built. Required: a page's presence is never implied. */
39
+ readonly enabled: boolean;
40
+ /**
41
+ * How many pages this route's list runs to, counting the first.
42
+ *
43
+ * A property of the route rather than a set of routes, because a page of a
44
+ * list is not a page of the site: it has no copy of its own, it is not
45
+ * content a map should list, and Google exempts a sequence from needing
46
+ * distinct titles. Modelling each one as a route means four messages per
47
+ * page and a `llms.txt` that presents page 3 as an article.
48
+ *
49
+ * `1` or omitted is an ordinary route. Above that, lib emits
50
+ * `/news/page/2` … `/news/page/n` alongside `/news` — page one is always
51
+ * the bare slug, never `page/1`, so there is no duplicate to canonicalise
52
+ * away.
53
+ *
54
+ * Usually set per project rather than here: the count is posts divided by
55
+ * page size, and one venue has more posts than another.
56
+ */
57
+ readonly pages?: number;
58
+ readonly sitemap?: RouteSitemap;
59
+ // No share image here: an image and its alt text belong to the page being
60
+ // rendered, and pages can be generated dynamically, so they are passed to
61
+ // `metaFor` rather than declared in a static registry.
62
+ }
63
+
64
+ export type RouteRegistry<L extends string> = Readonly<
65
+ Record<string, RouteData<L>>
66
+ >;
67
+
68
+ /** A slug split on `/`, as a tuple, so its length can be compared. */
69
+ type Segments<S extends string> = S extends `${infer Head}/${infer Rest}`
70
+ ? [Head, ...Segments<Rest>]
71
+ : [S];
72
+
73
+ /** How deep a slug sits: `"services/consulting"` is 2, `"about"` and `""` are 1. */
74
+ type SegmentCount<S extends string> = Segments<S>["length"];
75
+
76
+ interface SlugDepthMismatch<Id extends string, L extends string> {
77
+ readonly __SLUG_DEPTH_MISMATCH__: `Route "${Id}" translates its slug for "${L}" to a different number of path segments; every locale of a route must sit at the same depth`;
78
+ }
79
+
80
+ /**
81
+ * Requires every translation of a slug to have the same number of segments.
82
+ *
83
+ * A route is one page, and its translations are the same page in another
84
+ * language — so `"services/consulting"` cannot become `"ypiresies"` in Greek.
85
+ * Letting it would silently give one locale a page nested under another route's
86
+ * URL and the other a top-level one, which reads as two different pages to a
87
+ * crawler however carefully the `hreflang` tags pair them up.
88
+ *
89
+ * Stated in the constraint rather than the parameter, like the other checks
90
+ * here: in parameter position one bad slug widens `T` and every *other* route
91
+ * then reports as malformed.
92
+ */
93
+ type ValidateSlugDepth<T> = {
94
+ readonly [K in StringKeys<T>]: {
95
+ // Every key of the route is carried through unchanged, and only
96
+ // `slugByLocale` is rewritten. Mapping the whole shape rather than
97
+ // declaring one property is what keeps `enabled` and `sitemap` legal:
98
+ // an object literal is checked against this type, so a property it does
99
+ // not mention reads as an excess one.
100
+ readonly [P in keyof T[K]]: P extends "slugByLocale"
101
+ ? {
102
+ readonly [L in keyof T[K][P]]: T[K] extends {
103
+ readonly slug: infer S extends string;
104
+ }
105
+ ? T[K][P][L] extends infer V extends string
106
+ ? SegmentCount<S> extends SegmentCount<V>
107
+ ? V
108
+ : SlugDepthMismatch<K, L & string>
109
+ : T[K][P][L]
110
+ : T[K][P][L];
111
+ }
112
+ : T[K][P];
113
+ };
114
+ };
115
+
116
+ /**
117
+ * Declares the base routes: one id per page, with its slug and translations.
118
+ *
119
+ * The config argument supplies the locale union, so there are no type arguments
120
+ * to write.
121
+ */
122
+ export function defineRoutes<
123
+ const C extends SiteConfigShape,
124
+ const T extends RouteRegistry<LocalesOf<C>>,
125
+ >(
126
+ // Read for its type only: it is how `L` is inferred without a type argument.
127
+ _config: C,
128
+ routes: T & ValidateSlugDepth<T>
129
+ ): T {
130
+ return routes;
131
+ }
132
+
133
+ export interface RouteOverride<L extends string> {
134
+ /** Replace the fallback slug. */
135
+ readonly slug?: string;
136
+ /** Replace or add per-locale slugs. Merged over the base translations. */
137
+ readonly slugByLocale?: Readonly<Partial<Record<L, string>>>;
138
+ /** Add or remove this page from this project's build. */
139
+ readonly enabled?: boolean;
140
+ /**
141
+ * How many pages this venue's list runs to. See `RouteData.pages`.
142
+ *
143
+ * The usual home for it: the count follows from how much this deployment
144
+ * has published, which the shared table cannot know.
145
+ */
146
+ readonly pages?: number;
147
+ readonly sitemap?: RouteSitemap;
148
+ }
149
+
150
+ export type RouteOverrideMap<L extends string, Base> = Readonly<
151
+ Partial<Record<StringKeys<Base>, RouteOverride<L>>>
152
+ >;
153
+
154
+ /** The slug a registry declares for route `K`, if it declares one. */
155
+ type BaseSlug<Base, K extends PropertyKey> = K extends keyof Base
156
+ ? Base[K] extends { readonly slug: infer S extends string }
157
+ ? S
158
+ : never
159
+ : never;
160
+
161
+ /**
162
+ * The slug route `K` ends up with: the override's if it has one, else the base's.
163
+ *
164
+ * `mergeRoutes`' `override?.slug ?? data.slug` said in types, and the two have
165
+ * to agree — this decides both the depth checks below and, through
166
+ * `EnabledSlugs`, the section prefixes a consumer is offered to match on.
167
+ *
168
+ * The no-override branch matters only to `EnabledSlugs`, which asks about every
169
+ * enabled route rather than only the overridden ones. `ValidateOverrideSlugDepth`
170
+ * maps `StringKeys<T>`, so its `K` is always an overlay key and it never reaches
171
+ * that branch.
172
+ */
173
+ type EffectiveSlug<Base, T, K extends PropertyKey> = K extends keyof T
174
+ ? T[K] extends { readonly slug: infer S extends string }
175
+ ? S
176
+ : BaseSlug<Base, K>
177
+ : BaseSlug<Base, K>;
178
+
179
+ /** The locales this override restates, which therefore replace the base's. */
180
+ type OverriddenLocales<T, K extends PropertyKey> = K extends keyof T
181
+ ? T[K] extends { readonly slugByLocale?: infer O }
182
+ ? keyof O
183
+ : never
184
+ : never;
185
+
186
+ /**
187
+ * The locales whose *inherited* translation would end up at the wrong depth.
188
+ *
189
+ * Only the ones this override leaves alone: a locale it retranslates is checked
190
+ * against the new slug by the `slugByLocale` clause instead, so counting it here
191
+ * too would reject deepening a route and its translations together.
192
+ */
193
+ type InheritedDepthMismatch<
194
+ Base,
195
+ T,
196
+ K extends PropertyKey,
197
+ Depth,
198
+ > = K extends keyof Base
199
+ ? Base[K] extends { readonly slugByLocale?: infer M }
200
+ ? {
201
+ [L in keyof M]: L extends OverriddenLocales<T, K>
202
+ ? never
203
+ : M[L] extends infer V extends string
204
+ ? SegmentCount<V> extends Depth
205
+ ? never
206
+ : L
207
+ : never;
208
+ }[keyof M]
209
+ : never
210
+ : never;
211
+
212
+ /**
213
+ * The same depth rule as {@link ValidateSlugDepth}, across the overlay seam.
214
+ *
215
+ * Two ways to break it here, and both are checked, because a project sees only
216
+ * half of what it is changing: a new `slugByLocale` entry has to match whatever
217
+ * slug the route *ends up* with, and a new `slug` has to match the translations
218
+ * the base already declared and this project is inheriting.
219
+ */
220
+ export type ValidateOverrideSlugDepth<Base, T> = {
221
+ readonly [K in StringKeys<T>]: {
222
+ readonly [P in keyof T[K]]: P extends "slugByLocale"
223
+ ? {
224
+ readonly [L in keyof T[K][P]]: T[K][P][L] extends infer V extends
225
+ string
226
+ ? SegmentCount<
227
+ EffectiveSlug<Base, T, K>
228
+ > extends SegmentCount<V>
229
+ ? V
230
+ : SlugDepthMismatch<K, L & string>
231
+ : T[K][P][L];
232
+ }
233
+ : P extends "slug"
234
+ ? T[K][P] extends infer S extends string
235
+ ? [
236
+ InheritedDepthMismatch<Base, T, K, SegmentCount<S>>,
237
+ ] extends [never]
238
+ ? S
239
+ : SlugDepthMismatch<
240
+ K,
241
+ InheritedDepthMismatch<
242
+ Base,
243
+ T,
244
+ K,
245
+ SegmentCount<S>
246
+ > &
247
+ string
248
+ >
249
+ : T[K][P]
250
+ : T[K][P];
251
+ };
252
+ };
253
+
254
+ /**
255
+ * Everything a route overlay must satisfy — and the only place it is stated.
256
+ *
257
+ * Two entry points accept an overlay: `defineRouteOverrides`, for one written in
258
+ * its own file, and `defineProject`, for one written inline. They must check it
259
+ * identically, or whichever has the extra check quietly becomes the safer place
260
+ * to write overrides. That is not hypothetical: the depth rule below lived in
261
+ * `defineRouteOverrides` alone for a while, and the inline form was weaker for
262
+ * it with nothing to say so.
263
+ *
264
+ * **Add new overlay checks here, never at a call site.** Both entry points name
265
+ * these two aliases, so anything added reaches both.
266
+ *
267
+ * Split in two because the positions are not interchangeable. Excess keys belong
268
+ * in the *constraint*, where a wrong id reports on that key. The depth rule
269
+ * belongs in the *parameter*: it maps the whole object, and a whole-object
270
+ * mapped type in constraint position makes one unrelated mistake fail the
271
+ * argument outright, moving the error off the offending line.
272
+ */
273
+ export type RouteOverlayKeys<Base, T> = NoExcessKeys<T, StringKeys<Base>>;
274
+
275
+ /** @see {@link RouteOverlayKeys} — the parameter-position half. */
276
+ export type RouteOverlayShape<Base, T> = ValidateOverrideSlugDepth<Base, T>;
277
+
278
+ /**
279
+ * Declares a project's route overlay: retranslated slugs and on/off switches.
280
+ *
281
+ * Enforced at compile time: unknown route ids are rejected, a slug override
282
+ * cannot invent a locale the site does not ship, and no override may leave a
283
+ * route sitting at different depths in different languages.
284
+ */
285
+ export function defineRouteOverrides<
286
+ const C extends SiteConfigShape,
287
+ const Base extends RouteRegistry<LocalesOf<C>>,
288
+ const T extends RouteOverrideMap<LocalesOf<C>, Base> &
289
+ RouteOverlayKeys<Base, T>,
290
+ >(_config: C, _base: Base, overrides: T & RouteOverlayShape<Base, T>): T {
291
+ return overrides;
292
+ }
293
+
294
+ /** The minimum `EnabledRouteIdFor` needs to know about a registry. */
295
+ export type EnabledFlagSource = Readonly<
296
+ Record<string, { readonly enabled: boolean }>
297
+ >;
298
+
299
+ /**
300
+ * The project's override entry for one route, or `undefined` where it said
301
+ * nothing.
302
+ *
303
+ * The {@link IsNever} guard matters: an override map typed with an index
304
+ * signature rather than as a literal yields `never` for `O[K]`, and `never`
305
+ * satisfies every constraint — without the guard, such a map would read as
306
+ * "every page enabled".
307
+ */
308
+ type OverrideEntryFor<O, K extends string> = K extends keyof O
309
+ ? IsNever<O[K]> extends true
310
+ ? undefined
311
+ : O[K]
312
+ : undefined;
313
+
314
+ /**
315
+ * Whether route `K` is built: the project's flag if it set one, otherwise the
316
+ * base registry's. An override that only retranslates a slug falls through,
317
+ * because `{ slug: … }` does not match `{ enabled: … }`.
318
+ */
319
+ type EffectiveEnabled<
320
+ Base extends EnabledFlagSource,
321
+ O,
322
+ K extends StringKeys<Base>,
323
+ > =
324
+ OverrideEntryFor<O, K> extends { readonly enabled: infer E extends boolean }
325
+ ? E
326
+ : Base[K]["enabled"];
327
+
328
+ /**
329
+ * The first segment of a nested slug. A top-level slug contributes nothing:
330
+ * there is no group to name if nothing sits under it.
331
+ */
332
+ type FirstSegment<S extends string> = S extends `${infer Head}/${string}`
333
+ ? Head
334
+ : never;
335
+
336
+ /** The ids this project builds, as keys usable with `Pick`. */
337
+ type EnabledIds<Routes, Overrides> = EnabledRouteIdFor<
338
+ Routes & EnabledFlagSource,
339
+ Overrides
340
+ > &
341
+ keyof Routes;
342
+
343
+ /**
344
+ * The slugs of the pages this project actually publishes.
345
+ *
346
+ * Filtered to the enabled set, because both types below are about what exists
347
+ * at build time: a family whose index page a project switched off has no page
348
+ * to title or link its section, however the shared registry declares it.
349
+ *
350
+ * **The untranslated slug, in the language it was declared in.** A section
351
+ * prefix is an identifier a consumer matches on, and one that changed with the
352
+ * locale — `challenges` in one file, its Greek translation in another — could
353
+ * not be matched at all. `llms()` groups on this same untranslated slug for
354
+ * that reason; only the URLs it produces are localised.
355
+ *
356
+ * A slug that is not a literal — computed, or read from an env var — widens
357
+ * this to `string`, the same graceful degradation the other slug checks have.
358
+ *
359
+ * **Per route, the overlay replaces the registry — it does not join it.** The
360
+ * earlier form unioned every declared slug from both sides, so a project that
361
+ * retranslated a nested slug kept the *old* prefix in the union alongside the
362
+ * new one. Nothing valid was rejected, which is why it went unnoticed: what it
363
+ * cost was the check itself. `sectionHeading` would accept a segment no build
364
+ * produces, the comparison against it would never match, and the section would
365
+ * quietly keep its default heading — the exact failure `SectionPrefixOf` is
366
+ * typed to make impossible, since a bare `string` there would have been the
367
+ * same silence.
368
+ */
369
+ type EnabledSlugs<Routes, Overrides> = {
370
+ [K in EnabledIds<Routes, Overrides>]: EffectiveSlug<Routes, Overrides, K>;
371
+ }[EnabledIds<Routes, Overrides>];
372
+
373
+ /**
374
+ * The URL segments that head a group of nested pages — the sections an
375
+ * `llms.txt` is organised into.
376
+ *
377
+ * Derived from the slugs rather than from route ids, because the two do not
378
+ * match: `routeFamily` builds the id `challenges-red-room` from the slug
379
+ * `challenges/red-room`, and only the slug knows where the boundary was.
380
+ */
381
+ export type SectionPrefixOf<Routes, Overrides> = FirstSegment<
382
+ EnabledSlugs<Routes, Overrides>
383
+ >;
384
+
385
+ /**
386
+ * The sections with no page of their own — a prefix that heads a group but is
387
+ * not itself a slug anything is published at.
388
+ *
389
+ * These are the only sections whose link a consumer may choose. Where an index
390
+ * page exists it is the answer, and offering a second one would invite a
391
+ * heading that points somewhere other than the page it names.
392
+ */
393
+ export type OrphanSectionPrefixOf<Routes, Overrides> = Exclude<
394
+ SectionPrefixOf<Routes, Overrides>,
395
+ EnabledSlugs<Routes, Overrides>
396
+ >;
397
+
398
+ /**
399
+ * The route ids that survive a given override map, computed from literal types.
400
+ *
401
+ * The mapped-type-then-index shape is the standard "filter a union" idiom: map
402
+ * every id to either itself or `never`, then index by the whole key union —
403
+ * `never` members drop out of the resulting union automatically.
404
+ *
405
+ * This is what makes `pathFor()` reject a page the project switched off,
406
+ * instead of emitting a link to a URL that was never built.
407
+ */
408
+ export type EnabledRouteIdFor<Base extends EnabledFlagSource, O> = {
409
+ [K in StringKeys<Base>]: EffectiveEnabled<Base, O, K> extends false
410
+ ? never
411
+ : K;
412
+ }[StringKeys<Base>];
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Route families: many pages that share a URL prefix and a view.
3
+ *
4
+ * Forty-odd escape rooms, a handful of paginated pages. Each is an ordinary
5
+ * route — its own id, its own slug, its own on/off switch, its own copy — so
6
+ * everything downstream keeps working unchanged: `pathFor` still rejects a typo'd
7
+ * id, a project can still enable one member and not another, and each member can
8
+ * still translate its own slug. The family helper only saves the typing.
9
+ *
10
+ * Deliberately *not* a dynamic `:param` route. A parameter would collapse the
11
+ * family to one id, and with it the typo checking, the per-member switch and the
12
+ * per-member slug translation — three things that already work, traded for
13
+ * shorter source.
14
+ */
15
+
16
+ import type { LocalesOf, SiteConfigShape } from "../config.ts";
17
+ import type { StringKeys } from "../types.ts";
18
+ import type { RouteData, RouteSitemap } from "./define.ts";
19
+
20
+ /** One member of a family: an id, and optionally its own translated segments. */
21
+ export interface FamilyMember<L extends string> {
22
+ /** The URL segment and the tail of the route id, e.g. `"all-in"`. */
23
+ readonly id: string;
24
+ /**
25
+ * Per-locale translations of this member's own segment.
26
+ *
27
+ * The family prefix is translated once, on the family; this is for the part
28
+ * that differs per member — a post whose slug reads differently in Greek.
29
+ */
30
+ readonly segmentByLocale?: Readonly<Partial<Record<L, string>>>;
31
+ }
32
+
33
+ /** A member written as a bare id, for the common case of no translations. */
34
+ export type FamilyMemberInput<L extends string> = string | FamilyMember<L>;
35
+
36
+ type MemberId<M> = M extends string
37
+ ? M
38
+ : M extends { readonly id: infer I }
39
+ ? I
40
+ : never;
41
+
42
+ /**
43
+ * The route ids a family produced.
44
+ *
45
+ * ```ts
46
+ * const challengeRoutes = routeFamily(config, "challenges", challenges);
47
+ * export type ChallengeRouteId = RouteIdsOf<typeof challengeRoutes>;
48
+ * ```
49
+ *
50
+ * Reading them back off the family means the prefix is written once, in the
51
+ * call. Spelling the ids out as `` `challenges-${string}` `` would be a second
52
+ * copy of it, free to drift the day the URL changes.
53
+ */
54
+ export type RouteIdsOf<Family> = StringKeys<Family>;
55
+
56
+ /**
57
+ * A URL prefix as a route-id prefix: `"news/posts"` -> `"news-posts"`.
58
+ *
59
+ * The family's id prefix is derived rather than passed, because a second name
60
+ * would only ever differ cosmetically from the first — and then a reader has to
61
+ * hold both. Deriving it means a route id can be guessed from a URL and back.
62
+ */
63
+ type Dashed<S extends string> = S extends `${infer Head}/${infer Rest}`
64
+ ? `${Head}-${Dashed<Rest>}`
65
+ : S;
66
+
67
+ export interface FamilyOptions<L extends string> {
68
+ /**
69
+ * Per-locale translation of the shared prefix: `challenges` -> `dokimasies`.
70
+ * Members inherit it, so one line retranslates the whole family.
71
+ */
72
+ readonly prefixByLocale?: Readonly<Partial<Record<L, string>>>;
73
+ /** Applied to every member. A project can still override one at a time. */
74
+ readonly enabled?: boolean;
75
+ readonly sitemap?: RouteSitemap;
76
+ }
77
+
78
+ /**
79
+ * Expands a family into ordinary route entries, ready to spread into
80
+ * `defineRoutes`.
81
+ *
82
+ * ```ts
83
+ * export const defaultRoutes = defineRoutes(config, {
84
+ * challenges: { enabled: true, slug: "challenges" },
85
+ * ...routeFamily(config, "challenges", CHALLENGE_IDS),
86
+ * });
87
+ * ```
88
+ *
89
+ * Ids come out as the prefix and the member joined by `-` —
90
+ * `challenges-all-in`, `news-posts-hello` — which keeps them literal, keeps them
91
+ * greppable, and gives the copy convention something to hang off:
92
+ * `route.challenges-all-in.title` needs no new rule.
93
+ */
94
+ export function routeFamily<
95
+ const C extends SiteConfigShape,
96
+ const Prefix extends string,
97
+ const Members extends readonly FamilyMemberInput<LocalesOf<C>>[],
98
+ const Options extends FamilyOptions<LocalesOf<C>> = Record<string, never>,
99
+ >(
100
+ // Read for its type only, as everywhere else: it supplies the locale union.
101
+ _config: C,
102
+ prefix: Prefix,
103
+ members: Members,
104
+ options?: Options
105
+ ): {
106
+ readonly [M in Members[number] as `${Dashed<Prefix>}-${MemberId<M> &
107
+ string}`]: {
108
+ readonly slug: `${Prefix}/${MemberId<M> & string}`;
109
+ // The literal, not `boolean`. `EnabledRouteIdFor` asks whether this
110
+ // extends `false`, and `boolean` does not — so widening here would make
111
+ // every member of every family read as enabled on every project, and
112
+ // `pathFor` would accept a room the venue does not run. The build still
113
+ // threw, but only at build time and only if something linked to it.
114
+ readonly enabled: Options extends {
115
+ readonly enabled: infer E extends boolean;
116
+ }
117
+ ? E
118
+ : true;
119
+ readonly slugByLocale?: Readonly<Partial<Record<LocalesOf<C>, string>>>;
120
+ readonly sitemap?: RouteSitemap;
121
+ };
122
+ } {
123
+ const routes: Record<string, RouteData<LocalesOf<C>>> = {};
124
+
125
+ for (const member of members) {
126
+ const id = typeof member === "string" ? member : member.id;
127
+ const segmentByLocale =
128
+ typeof member === "string" ? undefined : member.segmentByLocale;
129
+
130
+ // A locale gets a translated slug when either half is translated; the
131
+ // untranslated half falls back to what the default slug uses, so the
132
+ // number of segments is the same in every locale by construction.
133
+ const locales = new Set([
134
+ ...Object.keys(options?.prefixByLocale ?? {}),
135
+ ...Object.keys(segmentByLocale ?? {}),
136
+ ]);
137
+ const slugByLocale: Record<string, string> = {};
138
+ for (const locale of locales) {
139
+ const localePrefix =
140
+ options?.prefixByLocale?.[locale as LocalesOf<C>] ?? prefix;
141
+ const localeSegment =
142
+ segmentByLocale?.[locale as LocalesOf<C>] ?? id;
143
+ slugByLocale[locale] = `${localePrefix}/${localeSegment}`;
144
+ }
145
+
146
+ routes[`${prefix.replaceAll("/", "-")}-${id}`] = {
147
+ slug: `${prefix}/${id}`,
148
+ enabled: options?.enabled ?? true,
149
+ ...(locales.size > 0
150
+ ? {
151
+ slugByLocale: slugByLocale as Readonly<
152
+ Partial<Record<LocalesOf<C>, string>>
153
+ >,
154
+ }
155
+ : {}),
156
+ ...(options?.sitemap === undefined
157
+ ? {}
158
+ : { sitemap: options.sitemap }),
159
+ };
160
+ }
161
+
162
+ return routes as never;
163
+ }
164
+
165
+ /**
166
+ * One override, applied to many members of a family — the overlay counterpart of
167
+ * {@link routeFamily}.
168
+ *
169
+ * ```ts
170
+ * overrideRoutes: {
171
+ * ...enableRoutes("challenges", venueRooms),
172
+ * careers: { enabled: true },
173
+ * }
174
+ * ```
175
+ *
176
+ * Its job is the keys, and that job is load-bearing. A project deriving its
177
+ * overlay with `Object.fromEntries` gets `Record<string, …>`, whose index
178
+ * signature answers for *every* route id — so `EnabledRouteIdFor` reads the
179
+ * project as building every page in the registry, and `pathFor` accepts rooms
180
+ * the venue does not run. This composes the same ids `routeFamily` composed and
181
+ * keeps them literal, so the narrowing survives.
182
+ *
183
+ * It does not check the members: the list should already be typed against the
184
+ * family's own ids, so a typo fails in the venue's data file by name rather than
185
+ * here as an unrecognised key.
186
+ */
187
+ export function enableRoutes<const Prefix extends string>(
188
+ prefix: Prefix
189
+ ): <
190
+ const Members extends readonly (string | { readonly id: string })[],
191
+ const Override extends { readonly enabled?: boolean } = {
192
+ readonly enabled: true;
193
+ },
194
+ >(
195
+ members: Members,
196
+ override?: Override
197
+ ) => {
198
+ readonly [M in Members[number] as `${Dashed<Prefix>}-${MemberId<M> &
199
+ string}`]: Override;
200
+ } {
201
+ return ((
202
+ members: readonly (string | { readonly id: string })[],
203
+ override?: { readonly enabled?: boolean }
204
+ ) => {
205
+ const applied = override ?? { enabled: true };
206
+ const out: Record<string, unknown> = {};
207
+ for (const member of members) {
208
+ const id = typeof member === "string" ? member : member.id;
209
+ out[`${prefix.replaceAll("/", "-")}-${id}`] = applied;
210
+ }
211
+ return out;
212
+ }) as never;
213
+ }
214
+
215
+ /**
216
+ * A type guard for "is this route id a member of that family?".
217
+ *
218
+ * ```ts
219
+ * const challengeRoutes = routeFamily(config, "challenges", challenges);
220
+ * export const isChallengeRoute = familyGuard(challengeRoutes);
221
+ * ```
222
+ *
223
+ * One component usually serves a whole family, and it receives the wide
224
+ * `RouteId` like any other view. This is what puts it back on the family's own
225
+ * union — which is what lets a templated `t(\`route.${id}.title\`)` resolve its
226
+ * `{placeholders}`, since a family's copy is uniform and the site's pages are
227
+ * not.
228
+ *
229
+ * **Generic in the input, and that is load-bearing.** Written as
230
+ * `(id: string) => id is FamilyId`, `Array.filter` cannot narrow with it: the
231
+ * guarded type must be a subtype of the array's element type, and a family's ids
232
+ * cover every member the registry declares while a project's `RouteId` covers
233
+ * only the ones it builds. The filter then returns the array unnarrowed and says
234
+ * nothing — the caller believes it holds rooms and is holding the whole site.
235
+ * Intersecting with `T` makes it a subtype by construction.
236
+ *
237
+ * **`Object.hasOwn`, not `in`.** `in` walks the prototype chain, so it answers
238
+ * `true` for `"toString"`, `"constructor"` and `"valueOf"` — ids the family
239
+ * never declared. Nothing collides with those today, and that is a fact about
240
+ * the current route tables rather than about this function: it is exported,
241
+ * generic over any record, and route ids are composed from data a project
242
+ * supplies. A guard whose correctness rests on nobody naming a route
243
+ * `constructor` is a guard waiting for the one that does, and it would narrow a
244
+ * type that the value does not have.
245
+ */
246
+ export function familyGuard<
247
+ const Family extends Readonly<Record<string, unknown>>,
248
+ >(family: Family): <T extends string>(id: T) => id is T & StringKeys<Family> {
249
+ return <T extends string>(id: T): id is T & StringKeys<Family> =>
250
+ Object.hasOwn(family, id);
251
+ }