@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,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
|
+
}
|
package/src/site/api.ts
ADDED
|
@@ -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 };
|