@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,266 @@
1
+ import type { StringKeys } from "../types.ts";
2
+ import type { UrlPath } from "../url.ts";
3
+ import type { RouteOverride, RouteRegistry, RouteSitemap } from "./define.ts";
4
+
5
+ export interface ResolvedRoute<L extends string, Id extends string = string> {
6
+ readonly id: Id;
7
+ /** Fallback slug, used by every locale without its own translation. */
8
+ readonly slug: string;
9
+ readonly slugByLocale: Readonly<Partial<Record<L, string>>>;
10
+ readonly enabled: boolean;
11
+ /** How many pages this route's list runs to. `1` is an ordinary route. */
12
+ readonly pages: number;
13
+ readonly sitemap: RouteSitemap | undefined;
14
+ }
15
+
16
+ export type ResolvedRoutes<L extends string, Id extends string> = Readonly<
17
+ Record<Id, ResolvedRoute<L, Id>>
18
+ >;
19
+
20
+ /** How many path segments a slug has. `""` is one, the locale root. */
21
+ function segmentCount(slug: string): number {
22
+ return slug.split("/").length;
23
+ }
24
+
25
+ /** The slug a route uses in one locale: its translation, else the fallback. */
26
+ export function slugFor<L extends string>(
27
+ route: ResolvedRoute<L, string>,
28
+ locale: L
29
+ ): string {
30
+ return route.slugByLocale[locale] ?? route.slug;
31
+ }
32
+
33
+ /**
34
+ * How many pages a route's list runs to, settled once for everyone who reads it.
35
+ *
36
+ * Resolved here rather than defended at each use, because it was read in two
37
+ * places that disagreed: the entry builder clamped it to at least one and built
38
+ * the page, while `pathFor` compared against the raw number and refused to link
39
+ * it. A route with `pages: 0` therefore produced a file nothing in the project
40
+ * was allowed to point at. One value, decided at the boundary, and neither
41
+ * reader has an opinion left to hold.
42
+ *
43
+ * **Zero normalises to one**, and is the reason this is not simply a check. It
44
+ * is what a list divided by a page size returns when there is nothing in it —
45
+ * `Math.ceil(0 / 12)` — and a news index with no posts is still a page you can
46
+ * open. Making that a build error would push a `Math.max(1, …)` into every
47
+ * project that paginates, which is the rule living in four repos instead of
48
+ * here.
49
+ *
50
+ * **Anything else invalid throws**, for the reason `checkEntryLimit` throws:
51
+ * normalise a wrong value that plainly means something, refuse one that does
52
+ * not. A negative page count and a fractional one mean nothing — no list
53
+ * produces them — so they are typos, and a typo quietly clamped to 1 is a route
54
+ * that silently stops paginating.
55
+ */
56
+ function pageCount(pages: number | undefined, id: string): number {
57
+ if (pages === undefined || pages === 0) return 1;
58
+
59
+ if (!Number.isInteger(pages) || pages < 0) {
60
+ throw new Error(
61
+ `Route "${id}" has pages: ${pages}, which is not a number of pages a list can run to: it wants a whole number from 0, where 0 and 1 both mean the single page an empty list still has.`
62
+ );
63
+ }
64
+ return pages;
65
+ }
66
+
67
+ /**
68
+ * Merges a project's route overlay over the base registry.
69
+ *
70
+ * Slug translations merge key by key, so a project can retranslate one locale
71
+ * without restating the others.
72
+ */
73
+ export function mergeRoutes<L extends string, Base extends RouteRegistry<L>>(
74
+ base: Base,
75
+ overrides: Readonly<Partial<Record<StringKeys<Base>, RouteOverride<L>>>>
76
+ ): ResolvedRoutes<L, StringKeys<Base>> {
77
+ type Id = StringKeys<Base>;
78
+ const merged: Record<string, ResolvedRoute<L, Id>> = {};
79
+
80
+ for (const [id, data] of Object.entries<RouteRegistry<L>[string]>(base)) {
81
+ const override = overrides[id as Id];
82
+ merged[id] = {
83
+ id: id as Id,
84
+ slug: override?.slug ?? data.slug,
85
+ // Spreading two partials of a generic Record widens to `{}`, so the
86
+ // shape is restated rather than inferred.
87
+ slugByLocale: {
88
+ ...data.slugByLocale,
89
+ ...override?.slugByLocale,
90
+ } as Readonly<Partial<Record<L, string>>>,
91
+ enabled: override?.enabled ?? data.enabled,
92
+ // A project's count wins outright rather than merging: how much
93
+ // this deployment has published is not a variation on the shared
94
+ // table's number, it replaces it. Normalised here so every reader
95
+ // downstream sees the same count — see `pageCount`.
96
+ pages: pageCount(override?.pages ?? data.pages, id),
97
+ sitemap: override?.sitemap ?? data.sitemap,
98
+ };
99
+ }
100
+
101
+ return merged as ResolvedRoutes<L, Id>;
102
+ }
103
+
104
+ export interface PathContext<L extends string> {
105
+ readonly defaultLocale: L;
106
+ /** When true every locale is prefixed, including the default one. */
107
+ readonly prefixDefaultLocale: boolean;
108
+ /**
109
+ * The segment that marks a page of a list — `"page"`, giving
110
+ * `/news/page/2`.
111
+ *
112
+ * Translated like any other slug, because it appears in a URL beside slugs
113
+ * that are: `/el-GR/nea/selida/2`. Locales left out fall back to the
114
+ * default.
115
+ *
116
+ * A segment of its own rather than `/news/2`, so that a post slugged with a
117
+ * number cannot collide with a page number — and so the URL says which of
118
+ * the two it is.
119
+ */
120
+ readonly pageSegment: string;
121
+ readonly pageSegmentByLocale?: Readonly<Partial<Record<L, string>>>;
122
+ }
123
+
124
+ /** The word this locale spells the page segment with. */
125
+ export function pageSegmentFor<L extends string>(
126
+ locale: L,
127
+ ctx: PathContext<L>
128
+ ): string {
129
+ return ctx.pageSegmentByLocale?.[locale] ?? ctx.pageSegment;
130
+ }
131
+
132
+ /**
133
+ * The path of one page of a route's list.
134
+ *
135
+ * Page 1 is the bare path, never `.../page/1`: the two would be the same list
136
+ * at two URLs, which is a duplicate to canonicalise away rather than a page to
137
+ * publish.
138
+ */
139
+ export function pagePath<L extends string>(
140
+ base: UrlPath,
141
+ page: number,
142
+ locale: L,
143
+ ctx: PathContext<L>
144
+ ): UrlPath {
145
+ if (page <= 1) return base;
146
+
147
+ const segment = pageSegmentFor(locale, ctx);
148
+ // The locale root is `/`, and joining onto it would double the slash.
149
+ return base === "/" ? `/${segment}/${page}` : `${base}/${segment}/${page}`;
150
+ }
151
+
152
+ export function localePrefix<L extends string>(
153
+ locale: L,
154
+ ctx: PathContext<L>
155
+ ): "" | UrlPath {
156
+ return locale === ctx.defaultLocale && !ctx.prefixDefaultLocale
157
+ ? ""
158
+ : `/${locale}`;
159
+ }
160
+
161
+ /**
162
+ * Joins a locale prefix and a slug into an absolute, trailing-slash-free path.
163
+ *
164
+ * Returns `UrlPath`, so the leading slash is a fact the type carries rather
165
+ * than a convention every caller has to trust: every absolute URL lib emits is
166
+ * this value concatenated onto an origin.
167
+ */
168
+ export function buildPath<L extends string>(
169
+ slug: string,
170
+ locale: L,
171
+ ctx: PathContext<L>
172
+ ): UrlPath {
173
+ const trimmed = slug.replace(/^\/+|\/+$/g, "");
174
+ const prefix = localePrefix(locale, ctx);
175
+ const path: "" | UrlPath = trimmed === "" ? prefix : `${prefix}/${trimmed}`;
176
+ return path === "" ? "/" : path;
177
+ }
178
+
179
+ export interface RouteEntry<L extends string, Id extends string = string> {
180
+ readonly routeId: Id;
181
+ readonly locale: L;
182
+ readonly path: UrlPath;
183
+ /**
184
+ * Which page of the route's list this is, from 1.
185
+ *
186
+ * Every entry has one, and for the vast majority it is `1` — an ordinary
187
+ * page is the first and only page of itself. Readers that do not paginate
188
+ * can ignore it; the ones that must tell page 3 from an article cannot.
189
+ */
190
+ readonly page: number;
191
+ readonly sitemap: RouteSitemap | undefined;
192
+ }
193
+
194
+ /**
195
+ * Every (route, locale) pair this build should emit.
196
+ *
197
+ * Collisions throw. Since untranslated locales fall back to the default slug,
198
+ * two routes can collide in one locale while differing in another — this is the
199
+ * check that turns that into a build failure instead of a page silently
200
+ * overwriting another.
201
+ */
202
+ export function listRouteEntries<L extends string, Id extends string>(
203
+ routes: ResolvedRoutes<L, Id>,
204
+ locales: readonly L[],
205
+ ctx: PathContext<L>
206
+ ): readonly RouteEntry<L, Id>[] {
207
+ const entries: RouteEntry<L, Id>[] = [];
208
+ const seen = new Map<string, string>();
209
+
210
+ for (const route of Object.values<ResolvedRoute<L, Id>>(routes)) {
211
+ // Depth is checked before the `enabled` guard, so a switched-off page is
212
+ // validated too — the alternative is a broken translation lying dormant
213
+ // until the day someone turns the page on.
214
+ //
215
+ // A backstop rather than the main check: `defineRoutes` and
216
+ // `defineRouteOverrides` both reject this at compile time. What survives
217
+ // to here is a slug that was typed as plain `string` — computed, or read
218
+ // from an env var — where the literal comparison passed vacuously.
219
+ const depth = segmentCount(route.slug);
220
+ for (const [locale, translated] of Object.entries<string | undefined>(
221
+ route.slugByLocale
222
+ )) {
223
+ // A locale with no translation falls back to the slug, so it is at
224
+ // the right depth by construction.
225
+ if (translated === undefined) continue;
226
+ if (segmentCount(translated) !== depth) {
227
+ throw new Error(
228
+ `Route "${route.id}" resolves to "${route.slug}" (${depth} segments) but "${translated}" (${segmentCount(translated)}) in locale "${locale}". Every locale of a route must sit at the same depth.`
229
+ );
230
+ }
231
+ }
232
+
233
+ if (!route.enabled) continue;
234
+
235
+ for (const locale of locales) {
236
+ const base = buildPath(slugFor(route, locale), locale, ctx);
237
+
238
+ // One entry per page of the list, page 1 being the bare path. A
239
+ // route that does not paginate has `pages: 1` and so runs once,
240
+ // which is why nothing below is conditional on paginating.
241
+ //
242
+ // Read straight, with no clamp of its own: `mergeRoutes` settled
243
+ // the count, and a second opinion here is what let this build a
244
+ // page that `pathFor` then refused to link.
245
+ for (let page = 1; page <= route.pages; page++) {
246
+ const path = pagePath(base, page, locale, ctx);
247
+ const owner = seen.get(path);
248
+ if (owner !== undefined) {
249
+ throw new Error(
250
+ `Routes "${owner}" and "${route.id}" both resolve to "${path}" in locale "${locale}".`
251
+ );
252
+ }
253
+ seen.set(path, route.id);
254
+ entries.push({
255
+ routeId: route.id,
256
+ locale,
257
+ path,
258
+ page,
259
+ sitemap: route.sitemap,
260
+ });
261
+ }
262
+ }
263
+ }
264
+
265
+ return entries;
266
+ }
@@ -0,0 +1,354 @@
1
+ import type { ResolvedLocaleMeta } from "../config.ts";
2
+ import type { GeneratedFile } from "../file.ts";
3
+ import type { PublicFilePath } from "../files.ts";
4
+ import type { TranslateFor } from "../i18n/translate.ts";
5
+ import type { LlmsSection } from "../llms.ts";
6
+ import type { ThemeColor } from "../meta/index.ts";
7
+ import type {
8
+ RedirectRule,
9
+ ResolvedRedirect,
10
+ ValidateRedirectTargets,
11
+ } from "../redirects.ts";
12
+ import type { RobotsGroup } from "../robots.ts";
13
+ import type { RouteEntry } from "../routes/resolve.ts";
14
+ import type { Sitemap } from "../sitemap.ts";
15
+ import type { HttpsUrl, UrlPath } from "../url.ts";
16
+ import type {
17
+ Alternate,
18
+ Crumb,
19
+ LinkOptions,
20
+ LocaleLink,
21
+ PageContent,
22
+ PageMeta,
23
+ StaticPath,
24
+ } from "./page.ts";
25
+
26
+ /**
27
+ * The API a project's pages are written against, and the types read back off it.
28
+ *
29
+ * Declared apart from `create.ts`, which builds one. This is the half worth
30
+ * reading — every method carries why it exists and what it refuses to do — and
31
+ * it stays legible only while it is not interleaved with the closure that
32
+ * satisfies it.
33
+ */
34
+
35
+ /**
36
+ * What `llms.txt` needs that lib cannot know: the words.
37
+ *
38
+ * Every URL, every grouping and every language line is derived — what is left is
39
+ * copy, which lives in the consumer's catalog under whatever key convention it
40
+ * chose. `describe` is how it hands those over without lib having to guess at
41
+ * message keys.
42
+ */
43
+ export interface LlmsOptions<
44
+ RouteId extends string,
45
+ L extends string,
46
+ Prefix extends string = string,
47
+ OrphanPrefix extends string = Prefix,
48
+ > {
49
+ /**
50
+ * This route's name and one-line summary, in the file's language.
51
+ *
52
+ * A callback rather than a table, so it can read the same catalog the pages
53
+ * do — the alternative is restating every title next to every route and
54
+ * letting the two drift.
55
+ *
56
+ * Optional, and worth supplying: without it a page is listed under its route
57
+ * id and no summary, which is a far poorer file — but still a real map of
58
+ * the site, which is why lib falls back rather than refusing to write one.
59
+ */
60
+ describe?(
61
+ id: RouteId,
62
+ locale: L
63
+ ): { readonly name: string; readonly description?: string };
64
+ /** Heading for pages that are not part of a nested group. Defaults to `"Pages"`. */
65
+ readonly pagesHeading?: string;
66
+ /**
67
+ * Heading for the group of pages nested under one URL segment — `prefix` is
68
+ * that segment, e.g. `"challenges"`.
69
+ *
70
+ * Defaults to the name of the page that owns the bare prefix, so a family
71
+ * with an index page is titled by it and needs nothing here. Supply this
72
+ * when there is no such page, or when the group should be called something
73
+ * other than the page heading it: without it the fallback is the URL
74
+ * segment itself, which is an identifier rather than a name.
75
+ *
76
+ * Return `undefined` for the groups you do not want to name, and each falls
77
+ * back on its own — naming one group is not a commitment to name them all.
78
+ *
79
+ * `prefix` is the union of segments the routes actually produce, not a bare
80
+ * string, so a near miss like `"challenge"` is a compile error rather than a
81
+ * comparison that quietly never matches and leaves the default in place.
82
+ */
83
+ sectionHeading?(prefix: Prefix, locale: L): string | undefined;
84
+ /**
85
+ * Where a section with no index page should link.
86
+ *
87
+ * Separate from the heading because it is an enrichment rather than half of
88
+ * one decision: a section can be renamed without being linked, and linked
89
+ * without being renamed.
90
+ *
91
+ * Only sections that *have* no page of their own are offered. Where an index
92
+ * page exists it is already the link, and `OrphanPrefix` excludes it — so
93
+ * pointing a named family somewhere other than the page it is named after is
94
+ * a compile error rather than a choice. Returning `undefined` leaves the
95
+ * heading unlinked, which is the right answer when those pages are listed
96
+ * nowhere else.
97
+ */
98
+ sectionLink?(prefix: OrphanPrefix, locale: L): RouteId | undefined;
99
+ /** The `>` block under the title: what this site is, in a sentence or two. */
100
+ readonly summary?: string;
101
+ /**
102
+ * Sections appended after the derived ones — an address, opening hours, a
103
+ * phone number. Everything lib has no way to know.
104
+ */
105
+ readonly sections?: readonly LlmsSection[];
106
+ /** Which language to write the file in. Defaults to the site's default. */
107
+ readonly locale?: L;
108
+ }
109
+
110
+ /**
111
+ * Everything the pages need, derived from the data handed in.
112
+ *
113
+ * `RouteId` is narrowed to the routes this project actually builds, so linking
114
+ * to a switched-off page does not type-check.
115
+ */
116
+ export interface Site<
117
+ L extends string,
118
+ Catalog,
119
+ RouteId extends string,
120
+ /**
121
+ * The URL segments that head a group of nested pages, for `llms()`.
122
+ *
123
+ * Defaulted, so the three-argument form still names a valid type — this
124
+ * says nothing about what the site *is*, only what its section names may
125
+ * be, and no annotation should have to state it.
126
+ */
127
+ Prefix extends string = string,
128
+ /** Of those, the ones with no index page of their own. */
129
+ OrphanPrefix extends string = Prefix,
130
+ > {
131
+ readonly locales: readonly L[];
132
+ readonly defaultLocale: L;
133
+ readonly prefixDefaultLocale: boolean;
134
+ /**
135
+ * This deployment's origin, guaranteed `https://` and without a trailing
136
+ * slash — `createSite` throws on anything else before this is handed back.
137
+ *
138
+ * Typed rather than left as `string` so everything built from it inherits
139
+ * the guarantee: every absolute URL below is a template on this value, so
140
+ * they are all `https://…` without a check of their own, and a structured
141
+ * -data node can demand one.
142
+ */
143
+ readonly url: HttpsUrl;
144
+ /** The publisher's name, as declared by the project. */
145
+ readonly siteName: string;
146
+ /**
147
+ * The project's brand colour, as declared for the browser UI.
148
+ *
149
+ * Exposed so anything drawing in the site's colours — a share image, an
150
+ * embedded SVG — reads the brand from one place rather than restating a hex
151
+ * value that then drifts from the one every page's `<head>` carries.
152
+ */
153
+ readonly themeColor: ThemeColor;
154
+ readonly localeMeta: Readonly<Record<L, ResolvedLocaleMeta>>;
155
+ /**
156
+ * Every route this project builds, in the order the base registry declares
157
+ * them. Derived — a project never lists these. Build menus by filtering it.
158
+ */
159
+ readonly routes: readonly RouteId[];
160
+
161
+ /** A locale-bound `t()`. */
162
+ translate(locale: L): TranslateFor<Catalog>;
163
+
164
+ /** Absolute path of a route in one locale, e.g. `/el/sxetika-me-emas`. */
165
+ pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath;
166
+ /** The same, prefixed with the site origin — for canonicals and sitemaps. */
167
+ urlFor(id: RouteId, locale: L, options?: LinkOptions): HttpsUrl;
168
+ /**
169
+ * Absolute URL of a file served verbatim from `public/`.
170
+ *
171
+ * The path half needs no helper — a file in `public/` is served at the path
172
+ * it sits at, so the literal *is* the link. What is not free is the origin,
173
+ * which only the project knows, and which anything leaving the page needs:
174
+ * a share link, a redirect target, structured data.
175
+ *
176
+ * Unchecked by lib, which cannot see the directory. Narrow the argument with
177
+ * the `PublicFile` union that `publicFiles()` generates.
178
+ */
179
+ fileUrl(path: PublicFilePath): HttpsUrl;
180
+
181
+ /** One entry per published locale, for `hreflang` tags. */
182
+ alternatesFor(id: RouteId, options?: LinkOptions): readonly Alternate<L>[];
183
+
184
+ /**
185
+ * The same, ready to render as a language switcher: each locale's own label
186
+ * and writing direction, and which one the reader is on.
187
+ *
188
+ * Switching language keeps the page — `about` stays `about`, so `/about-us`
189
+ * leads to `/el-GR/sxetika-me-emas` rather than dumping the reader home.
190
+ */
191
+ localeLinksFor(
192
+ id: RouteId,
193
+ current: L,
194
+ options?: LinkOptions
195
+ ): readonly LocaleLink<L>[];
196
+
197
+ /**
198
+ * The trail from the site root to one page: root, each ancestor this project
199
+ * builds, then the page itself.
200
+ *
201
+ * Derived from the slug rather than declared, so it cannot disagree with the
202
+ * URLs — a retranslated slug moves the trail with it, and a page nobody
203
+ * nests under simply has none. `name` supplies the words, which lib does not
204
+ * have; it is called for every step including the root.
205
+ *
206
+ * An ancestor this project does not build is left out rather than linked: a
207
+ * crumb pointing at a page that was never generated is a link to a 404, and
208
+ * structured data will not accept an intermediate step without a URL. That
209
+ * gap is warned about, once per segment.
210
+ *
211
+ * A trail of one is a page with no ancestors — that is not a trail, and
212
+ * marking it up as one gets nothing rendered.
213
+ */
214
+ breadcrumbFor(
215
+ id: RouteId,
216
+ locale: L,
217
+ name: (id: RouteId) => string
218
+ ): readonly Crumb[];
219
+
220
+ /**
221
+ * URL segments this project builds pages *under* but not *at*.
222
+ *
223
+ * A structural fact, so it is stated once rather than discovered by whatever
224
+ * page renders first: every breadcrumb passing through one of these has a
225
+ * gap in it. `siteRoutes` reports them at build and at dev-server start.
226
+ */
227
+ readonly orphanSegments: readonly string[];
228
+
229
+ /** Every (route, locale) pair this build emits. Throws on colliding URLs. */
230
+ readonly entries: readonly RouteEntry<L, RouteId>[];
231
+
232
+ /**
233
+ * Every page to generate, as `{ params, props }` for a catch-all route.
234
+ *
235
+ * `param` is the name of the rest parameter in the router's filename, e.g.
236
+ * `"route"` for `[...route].astro`. The root path is emitted as `undefined`,
237
+ * which is what such routers expect.
238
+ *
239
+ * When every locale is prefixed nothing owns `/`, and no page is generated
240
+ * for it: `redirects()` claims it with a 301 to the default locale's root
241
+ * instead, which is a real redirect rather than a rendered stub. The target
242
+ * is whichever route has an empty slug — lib has no notion of a "home"
243
+ * page, so the caller does not have to name one.
244
+ *
245
+ * Returns a fresh mutable array, because SSG routers typically demand one.
246
+ */
247
+ staticPaths(param: string): StaticPath<L, RouteId>[];
248
+
249
+ /**
250
+ * The rendered sitemap: `entry` is the file to submit, `files` is everything
251
+ * that must be served (an index plus parts once it splits).
252
+ */
253
+ sitemap(): Sitemap;
254
+
255
+ /**
256
+ * The rendered `robots.txt`, already pointing at this site's sitemap.
257
+ *
258
+ * Site-wide crawl rules live here rather than in a `robots` meta tag, which
259
+ * only speaks for its own page and has to be fetched to be read.
260
+ */
261
+ robots(groups?: readonly RobotsGroup[]): GeneratedFile;
262
+
263
+ /**
264
+ * Where this site's `llms.txt` is published, or `undefined` if the config
265
+ * turned it off.
266
+ *
267
+ * Public because it is a promise made in two places at once: every page's
268
+ * head links to this URL, and something has to write a file there. It is
269
+ * what `siteRoutes` checks so the two cannot disagree.
270
+ */
271
+ readonly llmsUrl: HttpsUrl | undefined;
272
+
273
+ /**
274
+ * The rendered `llms.txt`: a map of the site for readers that want the
275
+ * content rather than the markup.
276
+ *
277
+ * lib derives the structure — which pages exist, their URLs, how they nest,
278
+ * the sitemap and language lines — and asks you only for the words, through
279
+ * `describe`. That split is the point: a hand-written file of links is stale
280
+ * the first time a route is switched off or a slug is retranslated, and
281
+ * nothing reports it, because nothing reads the file during a build.
282
+ */
283
+ llms(
284
+ options?: LlmsOptions<RouteId, L, Prefix, OrphanPrefix>
285
+ ): GeneratedFile;
286
+
287
+ /**
288
+ * Old URLs that must keep working, resolved and rendered.
289
+ *
290
+ * A rule's target names a *route*, not a path, so the URL is derived the way
291
+ * every other link is: a redirect cannot outlive the page it points at, and
292
+ * a retranslated slug moves it too. External targets are `https://` URLs and
293
+ * pass through untouched.
294
+ *
295
+ * Returns the rules as data. Rendering is a separate step —
296
+ * `buildCloudflareRedirects` writes the `_redirects` that Cloudflare and
297
+ * Netlify read, and a host with its own syntax takes these and writes its own.
298
+ */
299
+ redirects<const Rules extends readonly RedirectRule<RouteId, L>[]>(
300
+ rules: Rules & ValidateRedirectTargets<Rules>
301
+ ): readonly ResolvedRedirect[];
302
+
303
+ /**
304
+ * Everything the document head needs for one page: the `<html>` attributes,
305
+ * plus title, description, canonical, `hreflang` alternates, `x-default`,
306
+ * Open Graph and Twitter — all derived from one place so they cannot
307
+ * disagree.
308
+ *
309
+ * You supply the words and the page's share image; lib supplies the URLs.
310
+ */
311
+ metaFor(id: RouteId, locale: L, content: PageContent): PageMeta;
312
+
313
+ /**
314
+ * The head of the 404 page: `<html>` attributes, the title you pass, and
315
+ * `noindex`.
316
+ *
317
+ * No canonical and no share tags — a 404 represents no URL, so there is
318
+ * nothing for either to name.
319
+ */
320
+ notFoundMetaFor(locale: L, title: string): PageMeta;
321
+ }
322
+
323
+ /** The routes a given site builds, read back off the site's own type. */
324
+ export type RouteIdOf<S> =
325
+ S extends Site<infer _L, infer _Catalog, infer Id> ? Id : never;
326
+
327
+ /**
328
+ * `T` when this site builds route `Id`, `never` when it does not.
329
+ *
330
+ * For a value that only makes sense alongside an optional page — a field that
331
+ * should vanish rather than sit there unread once the page is switched off.
332
+ */
333
+ export type WhenEnabled<S, Id extends string, T> =
334
+ Id extends RouteIdOf<S> ? T : never;
335
+
336
+ /**
337
+ * A `T` for every route this site builds — and only those.
338
+ *
339
+ * Both halves are enforced, which is the point: a page that is built but has no
340
+ * entry is a missing-property error, and an entry for a page that is *not*
341
+ * built is an excess-property error. Config can be neither absent nor stale.
342
+ *
343
+ * ```ts
344
+ * const pageConfig = {
345
+ * home: { … },
346
+ * careers: { … }, // ✗ unless this project enabled `careers`
347
+ * } satisfies PerRoute<typeof site, PageConfig>;
348
+ * ```
349
+ *
350
+ * Use it with `satisfies`, not an annotation: excess-property checking is what
351
+ * catches the stale half, and only a literal checked against a known target
352
+ * gets it.
353
+ */
354
+ export type PerRoute<S, T> = { readonly [K in RouteIdOf<S>]: T };