@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,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";
|