@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
|
@@ -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
|
+
}
|