@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,660 @@
1
+ import {
2
+ type LocaleMeta,
3
+ type LocalesOf,
4
+ mergeLocaleMeta,
5
+ type SiteConfigShape,
6
+ } from "../config.ts";
7
+ import type { GeneratedFile } from "../file.ts";
8
+ import type { PublicFilePath } from "../files.ts";
9
+ import { type BaseCatalog, mergeCatalog } from "../i18n/define.ts";
10
+ import { createTranslateFactory } from "../i18n/translate.ts";
11
+ import { buildLlms, type LlmsItem, type LlmsSection } from "../llms.ts";
12
+ import {
13
+ buildMeta,
14
+ buildNotFoundMeta,
15
+ resolveShareImage,
16
+ type SiteIcon,
17
+ } from "../meta/index.ts";
18
+ import type { ProjectInput } from "../project.ts";
19
+ import {
20
+ buildRedirects,
21
+ type RedirectRule,
22
+ type ResolvedRedirect,
23
+ statusFor,
24
+ } from "../redirects.ts";
25
+ import { buildRobots, type RobotsGroup } from "../robots.ts";
26
+ import type {
27
+ EnabledRouteIdFor,
28
+ OrphanSectionPrefixOf,
29
+ RouteRegistry,
30
+ SectionPrefixOf,
31
+ } from "../routes/define.ts";
32
+ import {
33
+ buildPath,
34
+ listRouteEntries,
35
+ mergeRoutes,
36
+ type PathContext,
37
+ pagePath,
38
+ type RouteEntry,
39
+ slugFor,
40
+ } from "../routes/resolve.ts";
41
+ import { buildSitemap, type Sitemap } from "../sitemap.ts";
42
+ import type { StringKeys } from "../types.ts";
43
+ import {
44
+ absoluteUrl,
45
+ type HttpsUrl,
46
+ isHttpsUrl,
47
+ joinUrl,
48
+ type UrlPath,
49
+ } from "../url.ts";
50
+ import type { LlmsOptions, Site } from "./api.ts";
51
+ import type {
52
+ Alternate,
53
+ Crumb,
54
+ LinkOptions,
55
+ LocaleLink,
56
+ PageContent,
57
+ PageMeta,
58
+ StaticPath,
59
+ } from "./page.ts";
60
+
61
+ function decorate(path: UrlPath, options: LinkOptions | undefined): UrlPath {
62
+ if (options === undefined) return path;
63
+ const query = options.query
64
+ ? new URLSearchParams(options.query).toString()
65
+ : "";
66
+ const hash = options.hash === undefined ? "" : `#${options.hash}`;
67
+ return `${path}${query === "" ? "" : `?${query}`}${hash}`;
68
+ }
69
+
70
+ /**
71
+ * Turns a site config, its base data and one project overlay into the API the
72
+ * pages use.
73
+ *
74
+ * This is the only wiring a consuming project writes: the merging, slug
75
+ * fallbacks, URL building, translation and sitemap all come back ready to use.
76
+ */
77
+ export function createSite<
78
+ const C extends SiteConfigShape,
79
+ const Catalog extends BaseCatalog<LocalesOf<C>>,
80
+ const Routes extends RouteRegistry<LocalesOf<C>>,
81
+ // Inferred from `project.enabledLocales` as its own parameter rather than
82
+ // read back as `P["enabledLocales"][number]`: that indexed access resolves
83
+ // through the constraint's `readonly L[]` and collapses to `string`, which
84
+ // silently un-types every locale the API accepts.
85
+ const Published extends LocalesOf<C>,
86
+ const P extends ProjectInput<LocalesOf<C>, Routes>,
87
+ >(
88
+ config_: C,
89
+ messages: Catalog,
90
+ routes: Routes,
91
+ // `Published` is named in the *parameter*, not only in `P`'s constraint:
92
+ // a type parameter mentioned nowhere an argument reaches has nothing to
93
+ // infer from and silently falls back to its constraint — here the whole
94
+ // locale universe, which is the bug this was meant to fix.
95
+ project: P & { readonly enabledLocales: readonly Published[] }
96
+ ): Site<
97
+ Published,
98
+ Catalog,
99
+ EnabledRouteIdFor<Routes, P["overrideRoutes"]> & StringKeys<Routes>,
100
+ SectionPrefixOf<Routes, P["overrideRoutes"]>,
101
+ OrphanSectionPrefixOf<Routes, P["overrideRoutes"]>
102
+ > {
103
+ // The locales this *project* publishes, not the universe the site config
104
+ // declares. The runtime has always used the narrow set — `locales` below is
105
+ // `project.enabledLocales` — but the type used to be the wide one, so
106
+ // `pathFor("home", "el-GR")` type-checked on a build with no Greek pages and
107
+ // returned a URL that was never generated.
108
+ type L = Published;
109
+ type RouteId = EnabledRouteIdFor<Routes, P["overrideRoutes"]> &
110
+ StringKeys<Routes>;
111
+
112
+ const locales = project.enabledLocales as readonly L[];
113
+ const defaultLocale = (project.overrideRouting?.defaultLocale ??
114
+ config_.defaultRouting.defaultLocale) as L;
115
+ const prefixDefaultLocale =
116
+ project.overrideRouting?.prefixDefaultLocale ??
117
+ config_.defaultRouting.prefixDefaultLocale;
118
+
119
+ // Checks that types cannot express, run once at build time.
120
+ if (locales.length === 0) {
121
+ throw new Error("A project must publish at least one locale.");
122
+ }
123
+ if (new Set(locales).size !== locales.length) {
124
+ throw new Error("A project lists a locale twice.");
125
+ }
126
+ // Backstop for a URL typed as plain `string` — an env var, say — where the
127
+ // compile-time checks in `defineProject` pass vacuously.
128
+ if (project.url.endsWith("/")) {
129
+ throw new Error(
130
+ `Project "url" must not end with a slash: ${project.url}`
131
+ );
132
+ }
133
+ if (!isHttpsUrl(project.url)) {
134
+ throw new Error(
135
+ `Project "url" must start with https://: ${project.url}`
136
+ );
137
+ }
138
+ if (!locales.includes(defaultLocale)) {
139
+ throw new Error(
140
+ `defaultLocale "${defaultLocale}" is not among this project's locales.`
141
+ );
142
+ }
143
+
144
+ // Captured once, after the guard above has narrowed it. Everything absolute
145
+ // this file builds is a template on this value, so they all inherit
146
+ // `https://` without a check of their own — and a local const keeps the
147
+ // narrowing inside the closures below, which a property access would not.
148
+ const siteUrl = project.url;
149
+
150
+ const localeMeta = mergeLocaleMeta<L>(
151
+ config_.locales as Readonly<Record<L, LocaleMeta>>,
152
+ project.overrideLocaleMeta,
153
+ locales
154
+ );
155
+ const pathContext: PathContext<L> = {
156
+ defaultLocale,
157
+ prefixDefaultLocale,
158
+ pageSegment:
159
+ project.overrideRouting?.pageSegment ??
160
+ config_.defaultRouting.pageSegment ??
161
+ "page",
162
+ pageSegmentByLocale: (project.overrideRouting?.pageSegmentByLocale ??
163
+ config_.defaultRouting.pageSegmentByLocale) as
164
+ | Readonly<Partial<Record<L, string>>>
165
+ | undefined,
166
+ };
167
+
168
+ // Declared config, not a call to `llms()`: the head is built page by page
169
+ // and cannot know whether some other module generated the file, whereas
170
+ // stating `llms` in the site config is a promise that one is published.
171
+ const llmsName =
172
+ (config_.llms === false ? undefined : config_.llms?.name) ?? "llms.txt";
173
+ // Annotated, not inferred: a template expression widens to `string` unless
174
+ // something contextually asks for the narrower type.
175
+ const llmsUrl: HttpsUrl | undefined =
176
+ config_.llms === false ? undefined : joinUrl(siteUrl, `/${llmsName}`);
177
+
178
+ const catalog = mergeCatalog<L>(messages, project.overrideMessages);
179
+ const translateFactory = createTranslateFactory<Catalog, L>();
180
+
181
+ const resolved = mergeRoutes<L, Routes>(routes, project.overrideRoutes);
182
+
183
+ function pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath {
184
+ const route = resolved[id];
185
+ if (route === undefined) {
186
+ throw new Error(`Unknown route id "${id}".`);
187
+ }
188
+ if (!route.enabled) {
189
+ throw new Error(
190
+ `Route "${id}" is disabled for this project and has no URL.`
191
+ );
192
+ }
193
+ const base = buildPath(slugFor(route, locale), locale, pathContext);
194
+ const page = options?.page ?? 1;
195
+
196
+ // Asking for a page the route does not have would produce a URL nothing
197
+ // built, and a link to it is a 404 that no test would catch. The count
198
+ // is right here, so the mistake is worth refusing rather than serving.
199
+ if (page > route.pages) {
200
+ throw new Error(
201
+ `Route "${id}" has ${route.pages} page${route.pages === 1 ? "" : "s"}, so there is no page ${page}.`
202
+ );
203
+ }
204
+ return decorate(pagePath(base, page, locale, pathContext), options);
205
+ }
206
+
207
+ function urlFor(id: RouteId, locale: L, options?: LinkOptions): HttpsUrl {
208
+ return joinUrl(siteUrl, pathFor(id, locale, options));
209
+ }
210
+
211
+ function alternatesFor(
212
+ id: RouteId,
213
+ options?: LinkOptions
214
+ ): readonly Alternate<L>[] {
215
+ return locales.map((locale) => {
216
+ const path = pathFor(id, locale, options);
217
+ return { locale, path, url: joinUrl(siteUrl, path) };
218
+ });
219
+ }
220
+
221
+ function sitemap(): Sitemap {
222
+ return buildSitemap<L, RouteId>({
223
+ siteUrl,
224
+ name: config_.sitemap?.name ?? "sitemap.xml",
225
+ entryLimit: config_.sitemap?.entryLimit,
226
+ entries,
227
+ localeMeta,
228
+ alternatesFor,
229
+ });
230
+ }
231
+
232
+ // Registry declaration order, filtered to what this project builds.
233
+ const enabledRouteIds: readonly RouteId[] = Object.values(resolved)
234
+ .filter((route) => route.enabled)
235
+ // Narrowing to the enabled subset, which the filter above guarantees.
236
+ .map((route) => route.id as RouteId);
237
+
238
+ // Only enabled routes are listed, so the ids are exactly the enabled set.
239
+ const entries = listRouteEntries(
240
+ resolved,
241
+ locales,
242
+ pathContext
243
+ ) as readonly RouteEntry<L, RouteId>[];
244
+
245
+ // In prefix-everything mode no route owns `/`; the locale root stands in for
246
+ // it, provided some route actually claims that path.
247
+ const localeRootPath = buildPath("", defaultLocale, pathContext);
248
+ const rootEntry = prefixDefaultLocale
249
+ ? entries.find((entry) => entry.path === localeRootPath)
250
+ : undefined;
251
+
252
+ function staticPaths(param: string): StaticPath<L, RouteId>[] {
253
+ const paths: StaticPath<L, RouteId>[] = entries.map((entry) => ({
254
+ params: {
255
+ [param]: entry.path === "/" ? undefined : entry.path.slice(1),
256
+ },
257
+ props: {
258
+ routeId: entry.routeId,
259
+ locale: entry.locale,
260
+ page: entry.page,
261
+ },
262
+ }));
263
+
264
+ return paths;
265
+ }
266
+
267
+ function localeLinksFor(
268
+ id: RouteId,
269
+ current: L,
270
+ options?: LinkOptions
271
+ ): readonly LocaleLink<L>[] {
272
+ return alternatesFor(id, options).map((alternate) => ({
273
+ ...alternate,
274
+ label: localeMeta[alternate.locale].label,
275
+ htmlLang: localeMeta[alternate.locale].htmlLang,
276
+ dir: localeMeta[alternate.locale].dir,
277
+ isCurrent: alternate.locale === current,
278
+ }));
279
+ }
280
+
281
+ function notFoundMetaFor(locale: L, title: string): PageMeta {
282
+ return {
283
+ lang: localeMeta[locale].htmlLang,
284
+ dir: localeMeta[locale].dir,
285
+ // Umami only, tagged so the misses read on their own — the point
286
+ // of measuring this page is finding a redirect somebody forgot,
287
+ // not counting the people who mistype. See `notFoundAnalytics`.
288
+ tags: buildNotFoundMeta(title, project.analytics),
289
+ // Nothing. The body channel exists for Tag Manager's `<noscript>`,
290
+ // and no Google tag reaches this page.
291
+ bodyTags: [],
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Icon rules, checked on first use rather than at construction, and checked
297
+ * once.
298
+ *
299
+ * They are facts about the file — square and a multiple of 48 are what
300
+ * search engines ask for, and PNG is forced by this design reusing one asset
301
+ * for `apple-touch-icon`, which needs a raster image — so they can only be
302
+ * read from an image the asset pipeline has processed. Checking eagerly
303
+ * would make a `Site` impossible to build anywhere the pipeline has not run,
304
+ * an Astro config being the case that matters: everything else here is
305
+ * derived from plain data, and holding the whole object hostage to the one
306
+ * field that is not made every deploy artifact unreachable from there.
307
+ *
308
+ * Nothing is skipped by waiting: every page's `<head>` goes through here, so
309
+ * a bad icon still fails the build, at the first page that renders.
310
+ */
311
+ let iconChecked = false;
312
+ function checkedIcon(): SiteIcon {
313
+ if (iconChecked) return project.icon;
314
+ if (project.icon.width !== project.icon.height) {
315
+ throw new Error(
316
+ `The site icon must be square: got ${project.icon.width}x${project.icon.height}.`
317
+ );
318
+ }
319
+ if (project.icon.width % 48 !== 0) {
320
+ throw new Error(
321
+ `The site icon must be a multiple of 48px (48, 96, 144, 192, …): got ${project.icon.width}.`
322
+ );
323
+ }
324
+ if (project.icon.format?.toLowerCase() !== "png") {
325
+ throw new Error(
326
+ `The site icon must be a PNG: got ${project.icon.format ?? "an unknown format"}. It is reused for apple-touch-icon, which needs a raster image.`
327
+ );
328
+ }
329
+ iconChecked = true;
330
+ return project.icon;
331
+ }
332
+
333
+ function metaFor(id: RouteId, locale: L, content: PageContent): PageMeta {
334
+ const { page, breadcrumb, ...meta } = content;
335
+ const link = page === undefined ? undefined : { page };
336
+ const document = buildMeta<L>({
337
+ ...meta,
338
+ locale,
339
+ // Named rather than left to the spread above. It reaches
340
+ // `buildMeta` either way, but a reader of this function should
341
+ // be able to see that a page's trail is part of its head.
342
+ breadcrumb,
343
+ canonical: urlFor(id, locale, link),
344
+ alternates: alternatesFor(id, link),
345
+ localeMeta,
346
+ defaultLocale,
347
+ image: resolveShareImage(siteUrl, content.image),
348
+ // Resolved here rather than in `buildMeta`, which is handed a
349
+ // canonical and no origin — and an origin is what a root-relative
350
+ // asset path has to be joined to.
351
+ articleImages: content.article?.images?.map((crop) =>
352
+ absoluteUrl(siteUrl, crop.src, "article image")
353
+ ),
354
+ siteName: project.siteName,
355
+ twitterSite: project.twitterSite,
356
+ icon: checkedIcon(),
357
+ verification: project.verification,
358
+ analytics: project.analytics,
359
+ llmsUrl,
360
+ themeColor: project.themeColor,
361
+ colorScheme: project.colorScheme,
362
+ });
363
+ return {
364
+ lang: localeMeta[locale].htmlLang,
365
+ dir: localeMeta[locale].dir,
366
+ tags: document.head,
367
+ bodyTags: document.body,
368
+ };
369
+ }
370
+
371
+ /** The enabled route whose own slug is exactly this, if any. */
372
+ function routeWithSlug(slug: string): RouteId | undefined {
373
+ return enabledRouteIds.find((candidate) => {
374
+ const route = resolved[candidate];
375
+ return route !== undefined && route.slug === slug;
376
+ });
377
+ }
378
+
379
+ /**
380
+ * URL segments that no page occupies — `/rooms` when `/rooms/alpha` is
381
+ * built and nothing sits at `/rooms` itself.
382
+ *
383
+ * Derived once from the route table, because that is what it is a fact
384
+ * about. A breadcrumb through such a segment is left with a gap, and
385
+ * structured data will not accept an intermediate step without a URL.
386
+ */
387
+ const orphanSegments: readonly string[] = [
388
+ ...new Set(
389
+ enabledRouteIds.flatMap((routeId) => {
390
+ const segments = (resolved[routeId]?.slug ?? "")
391
+ .split("/")
392
+ .filter((segment) => segment !== "");
393
+ // Every prefix except the whole slug, which is the page itself.
394
+ return segments
395
+ .slice(0, -1)
396
+ .map((_, depth) => segments.slice(0, depth + 1).join("/"));
397
+ })
398
+ ),
399
+ ]
400
+ .filter((prefix) => routeWithSlug(prefix) === undefined)
401
+ .sort();
402
+
403
+ function breadcrumbFor(
404
+ id: RouteId,
405
+ locale: L,
406
+ name: (id: RouteId) => string
407
+ ): readonly Crumb[] {
408
+ const crumb = (routeId: RouteId): Crumb => ({
409
+ name: name(routeId),
410
+ path: pathFor(routeId, locale),
411
+ url: urlFor(routeId, locale),
412
+ });
413
+
414
+ // The untranslated slug, as everywhere else that identifies a route:
415
+ // the trail's *URLs* are localised, but which routes are its ancestors
416
+ // is a fact about the site, not about the language it is read in.
417
+ const segments = (resolved[id]?.slug ?? "")
418
+ .split("/")
419
+ .filter((segment) => segment !== "");
420
+
421
+ const root = routeWithSlug("");
422
+ const trail: Crumb[] =
423
+ root === undefined || root === id ? [] : [crumb(root)];
424
+
425
+ // Every prefix except the whole slug, which is the page itself.
426
+ for (let depth = 1; depth < segments.length; depth++) {
427
+ const prefix = segments.slice(0, depth).join("/");
428
+ const ancestor = routeWithSlug(prefix);
429
+ // A gap, not an error to raise from here: which segments have no
430
+ // page is a fact about the route table, reported once by
431
+ // `orphanSegments` rather than by whichever page happens to render.
432
+ if (ancestor === undefined) continue;
433
+
434
+ trail.push(crumb(ancestor));
435
+ }
436
+
437
+ trail.push(crumb(id));
438
+
439
+ // One step is the page itself, which is not a trail: it says nothing a
440
+ // reader does not already know, and `BreadcrumbList` renders nothing
441
+ // for it. Dropped here, at the only place a trail is built, rather than
442
+ // by each reader — the markup and the rendered list both used to test
443
+ // the length themselves, each with a comment promising it matched the
444
+ // other, which is two enforcements of one rule and one of them free to
445
+ // drift. Downstream now only asks whether it was given a trail.
446
+ return trail.length > 1 ? trail : [];
447
+ }
448
+
449
+ function fileUrl(path: PublicFilePath): HttpsUrl {
450
+ return joinUrl(siteUrl, path);
451
+ }
452
+
453
+ function redirects(
454
+ rules: readonly RedirectRule<RouteId, L>[]
455
+ ): readonly ResolvedRedirect[] {
456
+ // When every locale is prefixed, nothing owns `/` — so lib claims it,
457
+ // with a real 301 to the default locale's root.
458
+ //
459
+ // A redirect rather than a page: a meta-refresh stub at `/` is a soft
460
+ // redirect, which search engines follow slowly and weigh less, and it
461
+ // costs a render before the reader goes anywhere. The two cannot both
462
+ // exist, since a static host serves the file and the rule never fires —
463
+ // which is what `builtPaths` below rejects.
464
+ // Named by route id like any other rule, so the target is derived from
465
+ // the route table and follows a retranslated slug.
466
+ const root: readonly RedirectRule<RouteId, L>[] =
467
+ rootEntry === undefined
468
+ ? []
469
+ : [
470
+ {
471
+ from: "/",
472
+ to: {
473
+ route: rootEntry.routeId,
474
+ locale: rootEntry.locale,
475
+ },
476
+ // The root will never own a page again while every
477
+ // locale is prefixed, which is what permanent means.
478
+ kind: "permanent",
479
+ },
480
+ ];
481
+
482
+ return buildRedirects({
483
+ // Every path this build serves, so a rule that shadows a real page
484
+ // is rejected rather than sitting dead in the file.
485
+ builtPaths: new Set(entries.map((entry) => entry.path)),
486
+ rules: [...root, ...rules].map((rule) => ({
487
+ from: rule.from,
488
+ // Three kinds of target, told apart by shape: a string is
489
+ // external and passes through, `file` is served verbatim from
490
+ // `public/`, and a route is resolved to whatever URL it has in
491
+ // the locale asked for.
492
+ to: ((): string => {
493
+ if (typeof rule.to === "string") return rule.to;
494
+ if ("file" in rule.to) return rule.to.file;
495
+ return pathFor(
496
+ rule.to.route,
497
+ rule.to.locale ?? defaultLocale
498
+ );
499
+ })(),
500
+ status: statusFor(rule.kind),
501
+ })),
502
+ });
503
+ }
504
+
505
+ function llms(options: LlmsOptions<RouteId, L> = {}): GeneratedFile {
506
+ const locale = options.locale ?? defaultLocale;
507
+
508
+ // A page listed by route id beats a page not listed at all: the file is
509
+ // still a complete, correct map of what this site publishes and where.
510
+ const describe: NonNullable<LlmsOptions<RouteId, L>["describe"]> =
511
+ options.describe ?? ((id) => ({ name: id }));
512
+
513
+ // Grouped by the first segment of the slug, which is what a nested route
514
+ // has and a top-level one does not. Derived rather than declared: a
515
+ // family of generated routes already shares a prefix, so the grouping is
516
+ // a fact about the URLs and cannot fall out of step with them.
517
+ //
518
+ // The *untranslated* slug, though the URLs below are localised. The
519
+ // prefix is an identifier — it is what `sectionHeading` matches on, and
520
+ // what `SectionPrefixOf` types — so it has to be the same string in
521
+ // every language's file. Grouping by the translated slug would also
522
+ // silently regroup a family the day someone translated its prefix.
523
+ const baseSlugOf = (id: RouteId): string => resolved[id]?.slug ?? "";
524
+
525
+ const grouped = new Map<string, RouteId[]>();
526
+ const topLevel: RouteId[] = [];
527
+ for (const id of enabledRouteIds) {
528
+ const segments = baseSlugOf(id)
529
+ .split("/")
530
+ .filter((part) => part !== "");
531
+ const parent = segments.length > 1 ? segments[0] : undefined;
532
+ if (parent === undefined) {
533
+ topLevel.push(id);
534
+ continue;
535
+ }
536
+ const members = grouped.get(parent);
537
+ if (members === undefined) {
538
+ grouped.set(parent, [id]);
539
+ } else {
540
+ members.push(id);
541
+ }
542
+ }
543
+
544
+ // A group headed by the index page its children sit under: that page
545
+ // titles the section instead of being listed twice.
546
+ const owners = new Map<string, RouteId>();
547
+ for (const parent of grouped.keys()) {
548
+ const owner = topLevel.find((id) => baseSlugOf(id) === parent);
549
+ if (owner !== undefined) {
550
+ owners.set(parent, owner);
551
+ }
552
+ }
553
+ const ungrouped = topLevel.filter((id) => !owners.has(baseSlugOf(id)));
554
+
555
+ const item = (id: RouteId): LlmsItem => {
556
+ const described = describe(id, locale);
557
+ return {
558
+ name: described.name,
559
+ url: urlFor(id, locale),
560
+ ...(described.description === undefined
561
+ ? {}
562
+ : { description: described.description }),
563
+ };
564
+ };
565
+
566
+ const sections: LlmsSection[] = [
567
+ {
568
+ heading: options.pagesHeading ?? "Pages",
569
+ items: ungrouped.map(item),
570
+ },
571
+ ];
572
+ for (const [parent, members] of grouped) {
573
+ const owner = owners.get(parent);
574
+
575
+ // An explicit heading first, then the owning page's own words — a
576
+ // second name for the same page would only drift from it. Failing
577
+ // both, the URL segment: lib will not fabricate a heading out of
578
+ // copy it does not have.
579
+ const heading =
580
+ options.sectionHeading?.(parent, locale) ??
581
+ (owner === undefined ? parent : describe(owner, locale).name);
582
+
583
+ // The owning page, or the one nominated for a section that has
584
+ // none. Both may be absent — a family with no index page listed
585
+ // nowhere else — and the heading is then left unlinked rather than
586
+ // pointed at a URL this build never wrote.
587
+ const target = owner ?? options.sectionLink?.(parent, locale);
588
+
589
+ sections.push({
590
+ heading,
591
+ ...(target === undefined
592
+ ? {}
593
+ : { url: urlFor(target, locale) }),
594
+ items: members.map(item),
595
+ });
596
+ }
597
+ sections.push(...(options.sections ?? []));
598
+
599
+ const others = locales.filter((other) => other !== locale);
600
+ return buildLlms({
601
+ name: llmsName,
602
+ siteUrl,
603
+ title: project.siteName,
604
+ ...(options.summary === undefined
605
+ ? {}
606
+ : { summary: options.summary }),
607
+ sitemap: sitemap().entry.url,
608
+ language: {
609
+ tag: localeMeta[locale].htmlLang,
610
+ url: joinUrl(siteUrl, buildPath("", locale, pathContext)),
611
+ },
612
+ otherLanguages: others.map((other) => ({
613
+ tag: localeMeta[other].htmlLang,
614
+ url: joinUrl(siteUrl, buildPath("", other, pathContext)),
615
+ })),
616
+ sections,
617
+ });
618
+ }
619
+
620
+ function robots(groups?: readonly RobotsGroup[]): GeneratedFile {
621
+ return buildRobots({
622
+ name: config_.robots?.name ?? "robots.txt",
623
+ siteUrl,
624
+ // Derived, so a renamed or newly split sitemap cannot fall out of
625
+ // sync with what robots.txt advertises.
626
+ sitemap: sitemap().entry.url,
627
+ groups: groups ?? config_.robots?.groups,
628
+ });
629
+ }
630
+
631
+ return {
632
+ locales,
633
+ defaultLocale,
634
+ prefixDefaultLocale,
635
+ url: siteUrl,
636
+ siteName: project.siteName,
637
+ themeColor: project.themeColor,
638
+ localeMeta,
639
+ routes: enabledRouteIds,
640
+
641
+ translate: (locale) => translateFactory(catalog, locale),
642
+
643
+ pathFor,
644
+ urlFor,
645
+ fileUrl,
646
+ breadcrumbFor,
647
+ orphanSegments,
648
+ alternatesFor,
649
+ localeLinksFor,
650
+ entries,
651
+ staticPaths,
652
+ sitemap,
653
+ robots,
654
+ llms,
655
+ llmsUrl,
656
+ redirects,
657
+ metaFor,
658
+ notFoundMetaFor,
659
+ };
660
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The site object: what a project builds once, and every page then reads.
3
+ *
4
+ * Three files, along the seam between what the API *is* and what builds it:
5
+ *
6
+ * - `api.ts` — the `Site` interface, and the types read back off it
7
+ * (`RouteIdOf`, `PerRoute`, `WhenEnabled`). The half worth reading.
8
+ * - `page.ts` — one page's vocabulary: what a view is handed and what it
9
+ * returns. Nothing here mentions a route registry.
10
+ * - `create.ts` — `createSite`, the closure that satisfies the interface. Its
11
+ * methods share captured state — the merged locales, the resolved routes, the
12
+ * catalog — which is why it is one function and not a folder of them.
13
+ */
14
+
15
+ export type {
16
+ LlmsOptions,
17
+ PerRoute,
18
+ RouteIdOf,
19
+ Site,
20
+ WhenEnabled,
21
+ } from "./api.ts";
22
+ export { createSite } from "./create.ts";
23
+ export type {
24
+ Alternate,
25
+ Crumb,
26
+ LinkOptions,
27
+ LocaleLink,
28
+ PageContent,
29
+ PageMeta,
30
+ PageProps,
31
+ StaticPath,
32
+ } from "./page.ts";