@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,307 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { AstroIntegration } from "astro";
|
|
5
|
+
import type { GeneratedFile } from "../file.ts";
|
|
6
|
+
import {
|
|
7
|
+
buildCloudflareRedirects,
|
|
8
|
+
type ResolvedRedirect,
|
|
9
|
+
} from "../redirects.ts";
|
|
10
|
+
import type { Sitemap } from "../sitemap.ts";
|
|
11
|
+
import type { HttpsUrl } from "../url.ts";
|
|
12
|
+
import { warn } from "../warn.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What this integration needs of a site, and no more.
|
|
16
|
+
*
|
|
17
|
+
* Structural rather than `Site<L, Catalog, RouteId>`, so any site satisfies it
|
|
18
|
+
* without having to name its three type arguments here — and so this states
|
|
19
|
+
* exactly which parts are used.
|
|
20
|
+
*/
|
|
21
|
+
export interface SiteFiles {
|
|
22
|
+
readonly url: HttpsUrl;
|
|
23
|
+
/** Segments with pages under them but none at them; see `orphanSegments`. */
|
|
24
|
+
readonly orphanSegments: readonly string[];
|
|
25
|
+
/** Undefined when the site config turned `llms.txt` off. */
|
|
26
|
+
readonly llmsUrl: HttpsUrl | undefined;
|
|
27
|
+
sitemap(): Sitemap;
|
|
28
|
+
robots(): GeneratedFile;
|
|
29
|
+
llms(): GeneratedFile;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What `updateConfig` accepts: a deep partial of the resolved Astro config.
|
|
34
|
+
*
|
|
35
|
+
* Read off the hook rather than named directly. Astro types the parameter as
|
|
36
|
+
* `DeepPartial<AstroConfig>`, but `DeepPartial` is internal — importing it
|
|
37
|
+
* means reaching past the package entry point into a path that can move
|
|
38
|
+
* between releases. `AstroConfig` itself is the wrong type here: it is the
|
|
39
|
+
* *resolved* config, so annotating a three-key patch with it fails on every
|
|
40
|
+
* field the patch does not set.
|
|
41
|
+
*/
|
|
42
|
+
type ConfigUpdate = Parameters<
|
|
43
|
+
Parameters<
|
|
44
|
+
NonNullable<AstroIntegration["hooks"]["astro:config:setup"]>
|
|
45
|
+
>[0]["updateConfig"]
|
|
46
|
+
>[0];
|
|
47
|
+
|
|
48
|
+
/** Byte count of a UTF-8 string, for a log line that says what it cost. */
|
|
49
|
+
function size(body: string): string {
|
|
50
|
+
const bytes = new TextEncoder().encode(body).length;
|
|
51
|
+
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} kB`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SiteRoutesOptions {
|
|
55
|
+
/**
|
|
56
|
+
* The site, imported. Not a module path: everything below is derived from
|
|
57
|
+
* plain data — URLs, route ids, locales — so it can be built where an Astro
|
|
58
|
+
* config is evaluated, and a real import is checked where a specifier would
|
|
59
|
+
* not be.
|
|
60
|
+
*/
|
|
61
|
+
readonly site: SiteFiles;
|
|
62
|
+
/** From `site.redirects([…])`. Omit to write no `_redirects`. */
|
|
63
|
+
readonly redirects?: readonly ResolvedRedirect[];
|
|
64
|
+
/**
|
|
65
|
+
* From `site.llms({…})`.
|
|
66
|
+
*
|
|
67
|
+
* The finished file rather than a flag, because this is the one output whose
|
|
68
|
+
* words lib does not have: page names and summaries live in a catalog under
|
|
69
|
+
* a key convention it cannot guess. Omitting it does not turn the file off —
|
|
70
|
+
* `site.llms()` still writes one, listing every page by route id with no
|
|
71
|
+
* summaries. That is a worse file and a real one; switching it off is a
|
|
72
|
+
* decision, made with `llms: false` in the site config.
|
|
73
|
+
*/
|
|
74
|
+
readonly llms?: GeneratedFile;
|
|
75
|
+
/** Write the sitemap. Defaults to true. */
|
|
76
|
+
readonly sitemap?: boolean;
|
|
77
|
+
/** Write `robots.txt`. Defaults to true. */
|
|
78
|
+
readonly robots?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Emits the files a static site owes the outside world.
|
|
83
|
+
*
|
|
84
|
+
* Written into the output directory when the build is done, rather than served
|
|
85
|
+
* by injected routes. Two reasons, and the first is not a preference:
|
|
86
|
+
*
|
|
87
|
+
* - `_redirects` cannot be a page at all. Astro refuses to route anything in
|
|
88
|
+
* `src/pages` whose name begins with `_`, and URL-escaping does not help —
|
|
89
|
+
* `%5F` is decoded before the entrypoint is opened.
|
|
90
|
+
* - A route is injected by *entrypoint*, and that file is compiled into the
|
|
91
|
+
* build's own module graph, where it cannot see a caller's `site`. Reaching it
|
|
92
|
+
* means naming a module in a string and resolving it through a virtual module
|
|
93
|
+
* — a lot of machinery, and nothing type-checks the string. None of these
|
|
94
|
+
* files needs the asset pipeline or a page context, so none of that buys
|
|
95
|
+
* anything.
|
|
96
|
+
*/
|
|
97
|
+
export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
98
|
+
const { site, redirects, llms, sitemap = true, robots = true } = options;
|
|
99
|
+
|
|
100
|
+
/** `llms.txt (4.2 kB)` — the name and what it actually weighs. */
|
|
101
|
+
const describe = (file: GeneratedFile): string =>
|
|
102
|
+
`${file.name} (${size(file.body)})`;
|
|
103
|
+
|
|
104
|
+
// Rebuilt per request in dev and once at the end of a build, rather than
|
|
105
|
+
// computed here: in dev the site's data changes under the server, and a list
|
|
106
|
+
// captured when the integration was constructed would serve yesterday's
|
|
107
|
+
// sitemap until you restarted.
|
|
108
|
+
const generate = (): GeneratedFile[] => {
|
|
109
|
+
const files: GeneratedFile[] = [];
|
|
110
|
+
if (sitemap) files.push(...site.sitemap().files);
|
|
111
|
+
if (robots) files.push(site.robots());
|
|
112
|
+
// `llmsUrl` is what every page's head links to, so a file must exist at
|
|
113
|
+
// it: the described one when given, a bare listing otherwise. Undefined
|
|
114
|
+
// means the config turned both off.
|
|
115
|
+
if (site.llmsUrl !== undefined) files.push(llms ?? site.llms());
|
|
116
|
+
// Length rather than presence: a site with no rules gets no file, not an
|
|
117
|
+
// empty one.
|
|
118
|
+
if (redirects?.length) {
|
|
119
|
+
files.push(
|
|
120
|
+
buildCloudflareRedirects(redirects, { siteUrl: site.url })
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return files;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Said once, at startup, listing every gap together.
|
|
128
|
+
*
|
|
129
|
+
* Not raised by `breadcrumbFor`: a page would then report a fact about the
|
|
130
|
+
* route table, and only the first page to render one would say anything —
|
|
131
|
+
* which in dev means once per server lifetime, long after you scrolled past.
|
|
132
|
+
*/
|
|
133
|
+
const reportOrphans = () => {
|
|
134
|
+
if (site.orphanSegments.length === 0) return;
|
|
135
|
+
|
|
136
|
+
warn(
|
|
137
|
+
"routes",
|
|
138
|
+
`${site.orphanSegments.length} URL segment${site.orphanSegments.length === 1 ? " has" : "s have"} pages under ${site.orphanSegments.length === 1 ? "it" : "them"} but none at ${site.orphanSegments.length === 1 ? "it" : "them"}: ${site.orphanSegments.map((segment) => `/${segment}`).join(", ")}. Breadcrumbs through ${site.orphanSegments.length === 1 ? "it" : "them"} will have a gap, since structured data will not accept a step without a URL.`
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name: "site-routes",
|
|
144
|
+
hooks: {
|
|
145
|
+
/**
|
|
146
|
+
* The three settings lib's own output would contradict, set here
|
|
147
|
+
* rather than asked for.
|
|
148
|
+
*
|
|
149
|
+
* Not a preference imposed on a consumer — each is the half of a
|
|
150
|
+
* contract whose other half this library already wrote:
|
|
151
|
+
*
|
|
152
|
+
* - `output: "static"`. Every file below is generated once, at
|
|
153
|
+
* build time, from data known then. There is no request to
|
|
154
|
+
* render against, and `robots.txt`, the sitemap and `_redirects`
|
|
155
|
+
* are written into the output directory by `astro:build:done`,
|
|
156
|
+
* which an on-demand build never reaches.
|
|
157
|
+
* - `build.format: "file"`. lib emits every canonical, `hreflang`,
|
|
158
|
+
* sitemap and `llms.txt` URL without a trailing slash. A host
|
|
159
|
+
* canonicalising HTML sends `/about-us` to `/about-us/` when the
|
|
160
|
+
* file on disk is `about-us/index.html`, and the other way round
|
|
161
|
+
* when it is `about-us.html` — so `directory` would leave every
|
|
162
|
+
* URL this library publishes redirecting to a different one, with
|
|
163
|
+
* nothing in the build to say so.
|
|
164
|
+
* - `trailingSlash: "ignore"`. Dev-server matching only, and set to
|
|
165
|
+
* the one value that cannot contradict the links lib hands out:
|
|
166
|
+
* `"always"` would 404 every one of them, and `"never"` would
|
|
167
|
+
* refuse a form the host answers with a redirect rather than a
|
|
168
|
+
* 404 — dev stricter than production, which teaches you nothing.
|
|
169
|
+
*
|
|
170
|
+
* Set instead of validated because there is no way to tell a
|
|
171
|
+
* deliberate `"directory"` from Astro's default: both arrive here
|
|
172
|
+
* as the same resolved value, so a check could only warn at
|
|
173
|
+
* everyone or at no one.
|
|
174
|
+
*/
|
|
175
|
+
"astro:config:setup": ({ updateConfig, logger }) => {
|
|
176
|
+
// Annotated, not just passed: `updateConfig(config)` alone
|
|
177
|
+
// would not catch a misspelled key, because excess-property
|
|
178
|
+
// checking fires on a fresh object literal at the call site and
|
|
179
|
+
// not on a variable — and every field of the target is
|
|
180
|
+
// optional, so `buld: {…}` would be assignable and silently do
|
|
181
|
+
// nothing. The annotation is what makes a typo an error.
|
|
182
|
+
const config: ConfigUpdate = {
|
|
183
|
+
output: "static",
|
|
184
|
+
build: { format: "file" },
|
|
185
|
+
trailingSlash: "ignore",
|
|
186
|
+
};
|
|
187
|
+
updateConfig(config);
|
|
188
|
+
// Said out loud: a setting changed from under you is worth a
|
|
189
|
+
// line, and reading `astro.config.ts` would otherwise leave you
|
|
190
|
+
// to wonder why the output is not shaped the way its defaults
|
|
191
|
+
// say. One line at startup, naming every value it set.
|
|
192
|
+
logger.info(
|
|
193
|
+
`updated config: ${JSON.stringify(config, null, 2)}`
|
|
194
|
+
);
|
|
195
|
+
reportOrphans();
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The same files, served from memory while developing.
|
|
200
|
+
*
|
|
201
|
+
* Without this they exist only after `astro build`: `astro:build:done`
|
|
202
|
+
* does not run under `astro dev`, so every one of them 404s in the
|
|
203
|
+
* one place you would go to look at it. They were routes once, which
|
|
204
|
+
* gave this for free; serving them here is the cost of having moved
|
|
205
|
+
* them out of the page graph.
|
|
206
|
+
*/
|
|
207
|
+
"astro:server:setup": ({ server, logger }) => {
|
|
208
|
+
/**
|
|
209
|
+
* The paths this integration answers on, learned by generating
|
|
210
|
+
* once and refreshed whenever we generate again.
|
|
211
|
+
*
|
|
212
|
+
* Its whole job is to keep `generate()` off the hot path. The
|
|
213
|
+
* middleware sees every request the dev server takes — each
|
|
214
|
+
* page, each asset, each Vite ping — and regenerating for all
|
|
215
|
+
* of them rebuilds the sitemap three times over (once directly,
|
|
216
|
+
* once inside `robots()`, once inside `llms()`) to answer a
|
|
217
|
+
* request for `/favicon.ico`.
|
|
218
|
+
*
|
|
219
|
+
* A name that appears only later is the one thing this can
|
|
220
|
+
* miss: a sitemap crossing `entryLimit` mid-session gains
|
|
221
|
+
* `sitemap-0.xml` and friends, which are not in the set until
|
|
222
|
+
* something regenerates. Acceptable because the set is
|
|
223
|
+
* refreshed on every hit and the dev server restarts on a
|
|
224
|
+
* config change — and because the alternative is paying for a
|
|
225
|
+
* full generate on every asset request to catch it.
|
|
226
|
+
*/
|
|
227
|
+
const served = new Set<string>();
|
|
228
|
+
const remember = (files: readonly GeneratedFile[]) => {
|
|
229
|
+
served.clear();
|
|
230
|
+
for (const file of files) served.add(`/${file.name}`);
|
|
231
|
+
return files;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
remember(generate()).map((file) =>
|
|
235
|
+
logger.info(`generated ${describe(file)}`)
|
|
236
|
+
);
|
|
237
|
+
server.middlewares.use((request, response, next) => {
|
|
238
|
+
const path = (request.url ?? "").split("?")[0] ?? "";
|
|
239
|
+
|
|
240
|
+
// The dev server knows nothing about `_redirects`, which is
|
|
241
|
+
// a static-host feature applied to the built output. Left
|
|
242
|
+
// alone, every redirect 404s in dev — including `/` when
|
|
243
|
+
// every locale is prefixed, which is the first URL anyone
|
|
244
|
+
// opens. Serving them here makes dev answer as production
|
|
245
|
+
// will, rather than as a place where the rules do not exist.
|
|
246
|
+
// Matched with and without a trailing slash, because a host
|
|
247
|
+
// either normalises one away before its rules run or treats
|
|
248
|
+
// the two as the same URL. Dev should not be the only place
|
|
249
|
+
// where `/rooms/` misses a rule that `/rooms` hits.
|
|
250
|
+
const canonical =
|
|
251
|
+
path.length > 1 && path.endsWith("/")
|
|
252
|
+
? path.slice(0, -1)
|
|
253
|
+
: path;
|
|
254
|
+
const rule = redirects?.find((it) => it.from === canonical);
|
|
255
|
+
if (rule !== undefined) {
|
|
256
|
+
logger.info(
|
|
257
|
+
`redirect: ${path} → ${rule.to} (${rule.status})`
|
|
258
|
+
);
|
|
259
|
+
response.statusCode = rule.status;
|
|
260
|
+
response.setHeader("Location", rule.to);
|
|
261
|
+
response.end();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Nothing of ours sits at this path, so hand it on without
|
|
266
|
+
// building anything. This is the check that keeps the cost
|
|
267
|
+
// below off every page and asset request in dev.
|
|
268
|
+
if (!served.has(path)) {
|
|
269
|
+
next();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Regenerated per request, so an edit to a route or a
|
|
274
|
+
// message shows up on reload rather than at restart.
|
|
275
|
+
const file = remember(generate()).find(
|
|
276
|
+
(candidate) => `/${candidate.name}` === path
|
|
277
|
+
);
|
|
278
|
+
if (file === undefined) {
|
|
279
|
+
// The set said this was ours and the fresh build
|
|
280
|
+
// disagrees — a file that stopped being generated, so
|
|
281
|
+
// it is now a 404 like any other missing path.
|
|
282
|
+
next();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
logger.info(`${path} → ${describe(file)}`);
|
|
286
|
+
response.setHeader("Content-Type", file.contentType);
|
|
287
|
+
response.end(file.body);
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
"astro:build:done": async ({ dir, logger }) => {
|
|
292
|
+
const files = generate();
|
|
293
|
+
|
|
294
|
+
await Promise.all(
|
|
295
|
+
files.map(async (file) => {
|
|
296
|
+
// `name` may carry a subdirectory, and the sitemap's
|
|
297
|
+
// does once it splits into numbered parts.
|
|
298
|
+
const path = fileURLToPath(new URL(file.name, dir));
|
|
299
|
+
await mkdir(dirname(path), { recursive: true });
|
|
300
|
+
await writeFile(path, file.body, "utf8");
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
files.map((file) => logger.info(`wrote ${describe(file)}`));
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { LlmsConfig } from "./llms.ts";
|
|
2
|
+
import type { RobotsConfig } from "./robots.ts";
|
|
3
|
+
import type { SitemapConfig } from "./sitemap.ts";
|
|
4
|
+
import type { StringKeys } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface LocaleMeta {
|
|
7
|
+
/** Name of the language, written in that language. */
|
|
8
|
+
readonly label: string;
|
|
9
|
+
// No language tag here: the locale's own key is the tag. One identifier,
|
|
10
|
+
// used for the URL segment, `lang`, `hreflang` and `og:locale` alike.
|
|
11
|
+
readonly dir: "ltr" | "rtl";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `LocaleMeta` after defaults are filled in. */
|
|
15
|
+
export interface ResolvedLocaleMeta {
|
|
16
|
+
readonly label: string;
|
|
17
|
+
readonly htmlLang: string;
|
|
18
|
+
readonly dir: "ltr" | "rtl";
|
|
19
|
+
/** Derived: `htmlLang` with `-` swapped for `_`, which is the OG form. */
|
|
20
|
+
readonly ogLocale: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A well-formed language tag, or `never`.
|
|
25
|
+
*
|
|
26
|
+
* The region is required: `en` is rejected, `en-US` accepted. A bare language
|
|
27
|
+
* leaves the territory to the reader, and `en-US` and `en-GB` differ in spelling,
|
|
28
|
+
* currency and date order — decisions worth stating rather than inferring.
|
|
29
|
+
*
|
|
30
|
+
* Checks shape and casing only, so `en_US`, `en-us` and `EN-US` are all
|
|
31
|
+
* rejected. It deliberately does not check that the subtags are *real*: the
|
|
32
|
+
* authoritative list is the IANA Language Subtag Registry, thousands of entries
|
|
33
|
+
* that would go stale the moment they were copied into this folder.
|
|
34
|
+
*/
|
|
35
|
+
export type LanguageTag<T extends string> =
|
|
36
|
+
T extends `${infer Lang}-${infer Region}`
|
|
37
|
+
? Lang extends Lowercase<Lang>
|
|
38
|
+
? Region extends Uppercase<Region>
|
|
39
|
+
? T
|
|
40
|
+
: never
|
|
41
|
+
: never
|
|
42
|
+
: never;
|
|
43
|
+
|
|
44
|
+
interface MalformedLanguageTag<T extends string> {
|
|
45
|
+
readonly __MALFORMED_LANGUAGE_TAG__: `"${T}" is not a well-formed language tag: expected a lowercase language and an uppercase region, like "en-US"`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every locale key must itself be a well-formed tag, since it *is* the tag.
|
|
50
|
+
*
|
|
51
|
+
* A malformed key demands a value no object can satisfy, so the error lands on
|
|
52
|
+
* that locale and names the expected shape.
|
|
53
|
+
*/
|
|
54
|
+
type ValidateLocales<T> = {
|
|
55
|
+
[K in StringKeys<T>]: [LanguageTag<K>] extends [never]
|
|
56
|
+
? MalformedLanguageTag<K>
|
|
57
|
+
: LocaleMeta;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface RoutingConfig<L extends string> {
|
|
61
|
+
readonly defaultLocale: L;
|
|
62
|
+
/**
|
|
63
|
+
* `false` -> default locale at `/`, others at `/<locale>`.
|
|
64
|
+
* `true` -> every locale prefixed, and `/` redirects to `/<defaultLocale>`.
|
|
65
|
+
*/
|
|
66
|
+
readonly prefixDefaultLocale: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The segment marking a page of a paginated list: `/news/page/2`.
|
|
69
|
+
*
|
|
70
|
+
* Defaults to `"page"`, which is what Hugo and WordPress produce and what a
|
|
71
|
+
* reader expects. A segment of its own rather than `/news/2`, so a post
|
|
72
|
+
* slugged with a number cannot collide with a page number.
|
|
73
|
+
*/
|
|
74
|
+
readonly pageSegment?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Per-locale spellings of it — `{ "el-GR": "selida" }`.
|
|
77
|
+
*
|
|
78
|
+
* Translated because it sits in a URL beside slugs that are: leaving an
|
|
79
|
+
* English word in the middle of `/el-GR/nea/…` is the one untranslated
|
|
80
|
+
* thing on the page.
|
|
81
|
+
*/
|
|
82
|
+
readonly pageSegmentByLocale?: Readonly<Partial<Record<L, string>>>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The shape of `site.config.ts`: which languages exist and how URLs are shaped.
|
|
87
|
+
*
|
|
88
|
+
* The per-feature sections are defined by the features themselves, so a change
|
|
89
|
+
* to what a sitemap can be told reaches this file without editing it.
|
|
90
|
+
*/
|
|
91
|
+
export interface SiteConfigShape {
|
|
92
|
+
readonly locales: Readonly<Record<string, LocaleMeta>>;
|
|
93
|
+
/**
|
|
94
|
+
* The URL shape a project gets unless it says otherwise.
|
|
95
|
+
*
|
|
96
|
+
* Named for what it is: a project's `overrideRouting` wins over it, so
|
|
97
|
+
* reading this file is not enough to know how one deployment's URLs look.
|
|
98
|
+
*/
|
|
99
|
+
readonly defaultRouting: RoutingConfig<string>;
|
|
100
|
+
readonly sitemap?: SitemapConfig;
|
|
101
|
+
readonly robots?: RobotsConfig;
|
|
102
|
+
/**
|
|
103
|
+
* `false` to publish no `llms.txt`. Omitted, one is published under the
|
|
104
|
+
* default name.
|
|
105
|
+
*
|
|
106
|
+
* Opt-out like the two above, even though lib cannot write this one
|
|
107
|
+
* unaided — it needs a name and a summary per page, which only the consumer
|
|
108
|
+
* has. Forgetting to supply them is a build error rather than a reason to
|
|
109
|
+
* make the file opt-in: this key is also what puts `<link rel="describedby">`
|
|
110
|
+
* in every head, and a page cannot tell whether `site.llms()` was called
|
|
111
|
+
* somewhere, so the declaration stands in for it and `siteRoutes` checks
|
|
112
|
+
* that it was kept.
|
|
113
|
+
*/
|
|
114
|
+
readonly llms?: LlmsConfig | false;
|
|
115
|
+
// No share image here on purpose: an image belongs to the page it
|
|
116
|
+
// represents, so the page passes its own to `metaFor`.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The locale union a site config declares, e.g. `"en" | "el"`. */
|
|
120
|
+
export type LocalesOf<C> = C extends { readonly locales: infer M }
|
|
121
|
+
? StringKeys<M>
|
|
122
|
+
: never;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Declares the site's locale universe and URL shape.
|
|
126
|
+
*
|
|
127
|
+
* Every other helper takes this object as its first argument, which is how they
|
|
128
|
+
* learn the locale union without you ever writing a type argument.
|
|
129
|
+
*
|
|
130
|
+
* Enforced at compile time: `defaultRouting.defaultLocale` must be one of
|
|
131
|
+
* `locales`, and every locale key must be a well-formed language tag.
|
|
132
|
+
*
|
|
133
|
+
* Both checks live in the constraint rather than the parameter. In parameter
|
|
134
|
+
* position a single unrelated mistake widens `T`, every key then resolves to
|
|
135
|
+
* `string`, and the config reports a cascade of malformed-tag errors on lines
|
|
136
|
+
* that were fine.
|
|
137
|
+
*/
|
|
138
|
+
export function defineSiteConfig<
|
|
139
|
+
const T extends SiteConfigShape & {
|
|
140
|
+
readonly defaultRouting: {
|
|
141
|
+
readonly defaultLocale: StringKeys<T["locales"]>;
|
|
142
|
+
// Mapped over the keys actually written, not declared as a
|
|
143
|
+
// `Partial<Record<…>>`: a constraint is checked by assignability,
|
|
144
|
+
// and an extra key is assignable to a type whose properties are all
|
|
145
|
+
// optional. Demanding an impossible value for a bad key is what
|
|
146
|
+
// makes the error land on that line.
|
|
147
|
+
readonly pageSegmentByLocale?: {
|
|
148
|
+
readonly [K in StringKeys<
|
|
149
|
+
T["defaultRouting"]["pageSegmentByLocale"]
|
|
150
|
+
>]: K extends StringKeys<T["locales"]>
|
|
151
|
+
? string
|
|
152
|
+
: LocaleNotDeclared<K>;
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
readonly locales: ValidateLocales<T["locales"]>;
|
|
156
|
+
},
|
|
157
|
+
>(config: T): T {
|
|
158
|
+
return config;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What a project may restate about a locale.
|
|
163
|
+
*
|
|
164
|
+
* `dir` is deliberately not here: writing direction is a fact about the script a
|
|
165
|
+
* language is written in, not a choice a deployment makes. Greek is
|
|
166
|
+
* left-to-right for everybody, and a project that could say otherwise would only
|
|
167
|
+
* ever be wrong. The label is the one part that is a matter of taste.
|
|
168
|
+
*/
|
|
169
|
+
export type LocaleMetaOverride = Pick<LocaleMeta, "label">;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Editor-facing error for a locale this site does not declare at all.
|
|
173
|
+
*
|
|
174
|
+
* The sibling of `LocaleNotEnabled`, one level up: that one is for a locale the
|
|
175
|
+
* site has but a project does not publish, this one is for a tag that exists
|
|
176
|
+
* nowhere — a typo, or a language someone meant to add and did not.
|
|
177
|
+
*/
|
|
178
|
+
export interface LocaleNotDeclared<L extends string> {
|
|
179
|
+
readonly __LOCALE_NOT_DECLARED__: `locale "${L}" is not among this site's locales, so anything keyed by it would never be read`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Editor-facing error for anything written for a locale a project does not ship.
|
|
184
|
+
*
|
|
185
|
+
* Lives here rather than beside one of its users because two of them need it:
|
|
186
|
+
* `defineProject`, for locale metadata, and `defineMessageOverrides`, for copy.
|
|
187
|
+
*/
|
|
188
|
+
export interface LocaleNotEnabled<L extends string> {
|
|
189
|
+
readonly __LOCALE_NOT_ENABLED__: `locale "${L}" is not among this project's enabledLocales, so anything written for it would never be built`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Applies a project's overrides and derives the tag fields from the key.
|
|
194
|
+
*
|
|
195
|
+
* The locale key is the language tag, and `ogLocale` is that tag with `-`
|
|
196
|
+
* swapped for `_` — Open Graph writes `en_US` where BCP 47 writes `en-US`.
|
|
197
|
+
*/
|
|
198
|
+
export function mergeLocaleMeta<L extends string>(
|
|
199
|
+
base: Readonly<Record<L, LocaleMeta>>,
|
|
200
|
+
overrides: Readonly<Partial<Record<L, LocaleMetaOverride>>> | undefined,
|
|
201
|
+
locales: readonly L[]
|
|
202
|
+
): Readonly<Record<L, ResolvedLocaleMeta>> {
|
|
203
|
+
const merged: Record<string, ResolvedLocaleMeta> = {};
|
|
204
|
+
for (const locale of locales) {
|
|
205
|
+
const defaults = base[locale];
|
|
206
|
+
if (defaults === undefined) {
|
|
207
|
+
throw new Error(`No locale metadata for "${locale}".`);
|
|
208
|
+
}
|
|
209
|
+
const meta = { ...defaults, ...overrides?.[locale] };
|
|
210
|
+
merged[locale] = {
|
|
211
|
+
label: meta.label,
|
|
212
|
+
dir: meta.dir,
|
|
213
|
+
htmlLang: locale,
|
|
214
|
+
ogLocale: locale.replace(/-/g, "_"),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return merged as Readonly<Record<L, ResolvedLocaleMeta>>;
|
|
218
|
+
}
|