@escape-game-over/atlas 0.1.1 → 0.1.3

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.
@@ -10,6 +10,7 @@ import {
10
10
  import type { Sitemap } from "../sitemap.ts";
11
11
  import type { HttpsUrl } from "../url.ts";
12
12
  import { warn } from "../warn.ts";
13
+ import { buildCacheDir } from "./build-cache.ts";
13
14
 
14
15
  /**
15
16
  * What this integration needs of a site, and no more.
@@ -82,17 +83,19 @@ export interface SiteRoutesOptions {
82
83
  * Emits the files a static site owes the outside world.
83
84
  *
84
85
  * 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
+ * by injected routes. One reason, and it is not the obvious one:
86
87
  *
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
88
  * - 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.
89
+ * build's own module graph, where it cannot see a caller's `site`. Every way
90
+ * of reaching one from there is worse than the staleness it would cure — see
91
+ * the entry in `docs/NOT-BUILT.md`, which records what was measured.
92
+ *
93
+ * **`_redirects` is not the obstacle, whatever this comment used to say.** What
94
+ * Astro refuses is a *filename* beginning with `_` in `src/pages`; an injected
95
+ * route names its pattern and its entrypoint separately, so the underscore
96
+ * lands only on the URL and the entrypoint is an ordinary module elsewhere.
97
+ * That was built and served, in dev and in a build, before being taken out
98
+ * again for the reason above.
96
99
  */
97
100
  export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
98
101
  const { site, redirects, llms, sitemap = true, robots = true } = options;
@@ -102,9 +105,15 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
102
105
  `${file.name} (${size(file.body)})`;
103
106
 
104
107
  // 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
+ // computed here.
109
+ //
110
+ // Worth knowing what that does *not* buy: it does not make dev current. The
111
+ // `site` and the `llms` file this closes over were built when the Astro
112
+ // config was evaluated, and nothing re-evaluates that until the server
113
+ // restarts — so rebuilding per request derives the same answer from the same
114
+ // frozen inputs. Measured: editing a message refreshes the page that renders
115
+ // it and leaves the same string in `llms.txt` untouched. A build is always
116
+ // right, since it evaluates the config in a fresh process.
108
117
  const generate = (): GeneratedFile[] => {
109
118
  const files: GeneratedFile[] = [];
110
119
  if (sitemap) files.push(...site.sitemap().files);
@@ -143,11 +152,12 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
143
152
  name: "site-routes",
144
153
  hooks: {
145
154
  /**
146
- * The three settings lib's own output would contradict, set here
147
- * rather than asked for.
155
+ * The Astro settings this integration decides, and the one it
156
+ * refuses.
148
157
  *
149
- * Not a preference imposed on a consumer — each is the half of a
150
- * contract whose other half this library already wrote:
158
+ * Three are set because lib's own output would contradict them —
159
+ * each is the half of a contract whose other half this library
160
+ * already wrote:
151
161
  *
152
162
  * - `output: "static"`. Every file below is generated once, at
153
163
  * build time, from data known then. There is no request to
@@ -167,12 +177,102 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
167
177
  * refuse a form the host answers with a redirect rather than a
168
178
  * 404 — dev stricter than production, which teaches you nothing.
169
179
  *
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.
180
+ * A fourth is set on weaker grounds, and deliberately so:
181
+ *
182
+ * - `prefetch`, all links, on `viewport`. Not a contract — nothing
183
+ * lib emits contradicts a consumer preferring `hover` — but the
184
+ * documents here are small enough that the usual objection does
185
+ * not apply: every page of the larger example's 25-item grid is
186
+ * around 3 kB gzipped, so prefetching the lot costs under 80 kB,
187
+ * and `rel="prefetch"` fetches the HTML alone rather than its
188
+ * images. A default worth overriding per site, not a rule —
189
+ * though not into `prerender`; see the refusal below.
190
+ *
191
+ * A fifth is set only when the environment asks for it:
192
+ *
193
+ * - `cacheDir`, moved onto whatever cache volume CI mounted. Not a
194
+ * preference — Astro's default sits inside `node_modules`, which
195
+ * a CI install deletes before every run, so a build there
196
+ * re-optimizes every image and re-downloads every font each time,
197
+ * however little changed. Set here rather than per repo because
198
+ * Astro exposes no environment variable for it, and `updateConfig`
199
+ * is already how this integration says what a site's build looks
200
+ * like. Off CI the value is `undefined`, the merge skips the key,
201
+ * and the default stays exactly where it was. See
202
+ * `build-cache.ts` for what shares the volume and what would not.
203
+ *
204
+ * Two are refused rather than set, both because Astro resolves them
205
+ * to a default a deliberate value is distinguishable from — so the
206
+ * check fires at whoever asked for it rather than at everyone:
207
+ *
208
+ * - `compressHTML: true`, which lib's output does not contradict
209
+ * but its *source* does: the templates are written for Astro's
210
+ * `"jsx"` whitespace rules, and `true` renders them differently.
211
+ * - `experimental.clientPrerender: true`, which turns the `prefetch`
212
+ * above into `prerender` and runs every script on the page inside
213
+ * a document nobody opened.
214
+ *
215
+ * The three contracts at the top are set instead of validated for
216
+ * the opposite reason: there is no way to tell a deliberate
217
+ * `"directory"` from Astro's default, since both arrive here as the
218
+ * same resolved value, so a check could only warn at everyone or at
219
+ * no one.
174
220
  */
175
- "astro:config:setup": ({ updateConfig, logger }) => {
221
+ "astro:config:setup": ({
222
+ config: current,
223
+ updateConfig,
224
+ logger,
225
+ }) => {
226
+ /**
227
+ * `compressHTML: true` is refused rather than overridden.
228
+ *
229
+ * Unlike the three below, this is not a setting lib's output
230
+ * contradicts — both modes produce valid HTML. What it
231
+ * contradicts is the *source*: every template here was written
232
+ * under `"jsx"`, Astro 7's default, where whitespace around
233
+ * elements is dropped and a deliberate space is written `{" "}`.
234
+ * `true` keeps whitespace "as needed to maintain the visual
235
+ * rendering" instead, so the same markup renders differently.
236
+ *
237
+ * Thrown at rather than set, because unlike `build.format` a
238
+ * deliberate `true` is distinguishable from the default — so a
239
+ * check can fire at exactly the person who asked for it, and
240
+ * `false` stays available for reading the built HTML.
241
+ */
242
+ if (current.compressHTML === true) {
243
+ throw new Error(
244
+ "compressHTML: true is not supported. The templates are written for " +
245
+ 'Astro\'s default "jsx" whitespace rules, where a deliberate space is `{" "}`; ' +
246
+ "`true` preserves whitespace differently and renders the same markup " +
247
+ 'differently. Use "jsx", or `false` while inspecting the built HTML.'
248
+ );
249
+ }
250
+
251
+ /**
252
+ * `experimental.clientPrerender: true` is refused outright.
253
+ *
254
+ * It swaps the `rel="prefetch"` behind the default below for
255
+ * speculation-rules `prerender`: the browser builds each
256
+ * prefetched page in a hidden renderer and runs every script on
257
+ * it for a visitor who never opened it. Umami posts a pageview,
258
+ * its recorder starts a replay, and a third-party widget does
259
+ * whatever it does. Gating that takes `document.prerendering`,
260
+ * once per script, and a page is never done gaining scripts.
261
+ *
262
+ * Thrown rather than warned because none of it errors on its
263
+ * own — the numbers just read high, and plausibly. `false` is
264
+ * the resolved default, so this fires only at someone who
265
+ * asked.
266
+ */
267
+ if (current.experimental.clientPrerender) {
268
+ throw new Error(
269
+ "experimental.clientPrerender: true is not supported. It prerenders " +
270
+ "prefetched pages, running every script on them — analytics and " +
271
+ "third-party widgets included — for visitors who never open them. " +
272
+ "Which can cause either issues or invalid metrics."
273
+ );
274
+ }
275
+
176
276
  // Annotated, not just passed: `updateConfig(config)` alone
177
277
  // would not catch a misspelled key, because excess-property
178
278
  // checking fires on a fresh object literal at the call site and
@@ -183,6 +283,11 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
183
283
  output: "static",
184
284
  build: { format: "file" },
185
285
  trailingSlash: "ignore",
286
+ prefetch: {
287
+ prefetchAll: true,
288
+ defaultStrategy: "viewport",
289
+ },
290
+ cacheDir: buildCacheDir(),
186
291
  };
187
292
  updateConfig(config);
188
293
  // Said out loud: a setting changed from under you is worth a
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../tsconfig.json", "astro/tsconfigs/strict"],
3
+ "compilerOptions": {
4
+ "types": ["astro/client"]
5
+ },
6
+ "include": ["**/*"],
7
+ "exclude": ["node_modules", "dist"]
8
+ }
@@ -0,0 +1,177 @@
1
+ import { type HttpsUrl, joinUrl } from "./url.ts";
2
+
3
+ /**
4
+ * Posting a contact form to the central mail API, from the browser.
5
+ *
6
+ * The API takes the message and decides everything else: which mailbox it goes
7
+ * to, which template renders it, whether the sending domain is allowed at all.
8
+ * A form therefore never states a recipient — it states which account it is
9
+ * writing on behalf of, and the account owns the rest. That is what lets this
10
+ * ship as a static page with no server of its own.
11
+ *
12
+ * One function, and the wire format stays inside it. What a message *is* — which
13
+ * fields a form asks for, whether a phone number is required, how long is long
14
+ * enough — is a per-site decision, and the API applies its own rules regardless;
15
+ * checking them again here would be the same rules written twice, drifting.
16
+ */
17
+
18
+ /**
19
+ * Which template renders the mail, and which mailbox it reaches.
20
+ *
21
+ * Both fall back to `default` when the type is unknown, so a typo does not lose
22
+ * the message — it files it as an ordinary enquiry, which is the failure worth
23
+ * catching. Hence a closed union rather than `string`.
24
+ */
25
+ export type ContactFormType =
26
+ | "default"
27
+ | "birthday_parties"
28
+ | "birthday_party"
29
+ | "board_games"
30
+ | "group_booking"
31
+ | "team_building"
32
+ | "voucher";
33
+
34
+ /** Where a deployment's contact mail goes. */
35
+ export interface MailEndpoint {
36
+ /**
37
+ * The API's send route, without the account.
38
+ *
39
+ * `https` because the API reads the browser's `Origin` header and refuses
40
+ * anything else — see `sendContactMessage`.
41
+ */
42
+ readonly url: HttpsUrl;
43
+ /**
44
+ * The account the message is sent on behalf of: `"b2b_cube"`.
45
+ *
46
+ * What the API looks the mailbox and the allowed domains up by. A domain
47
+ * that is not registered against it is refused, and nothing configured here
48
+ * substitutes for that.
49
+ */
50
+ readonly account: string;
51
+ /** Defaults to `"default"`, which every account has. */
52
+ readonly type?: ContactFormType;
53
+ }
54
+
55
+ /** A filled-in form, in the words a page uses rather than the API's. */
56
+ export interface ContactMessage {
57
+ readonly name: string;
58
+ readonly email: string;
59
+ readonly subject: string;
60
+ readonly message: string;
61
+ /**
62
+ * Anything else the form asked for: a phone number, a company, a date.
63
+ *
64
+ * Free-form because the API stores it as received and its template renders
65
+ * one titled row per entry — so `estimated_players` arrives as "Estimated
66
+ * Players" with no change on either side. Blanks are dropped rather than
67
+ * sent.
68
+ */
69
+ readonly additional?: Readonly<Record<string, string | undefined>>;
70
+ }
71
+
72
+ /**
73
+ * What happened to a message.
74
+ *
75
+ * Three outcomes rather than a boolean, because a form says something different
76
+ * about each: `rejected` is the API disagreeing and may name fields, while
77
+ * `unreachable` is nobody's fault and is the one worth printing an email
78
+ * address next to.
79
+ */
80
+ export type ContactResult =
81
+ | { readonly ok: true }
82
+ | { readonly ok: false; readonly reason: "unreachable" }
83
+ | {
84
+ readonly ok: false;
85
+ readonly reason: "rejected";
86
+ /** Per field, when the API named any. Keyed as the form names them. */
87
+ readonly problems: Readonly<Record<string, string>>;
88
+ };
89
+
90
+ /**
91
+ * The account as the API files it: lowercase, dashes as underscores, no spaces.
92
+ *
93
+ * The API normalizes what it receives, so this is not needed for a request to
94
+ * work — it is here so what a project writes and what the mail is filed under
95
+ * cannot quietly differ.
96
+ */
97
+ function normalizeAccount(account: string): string {
98
+ return account.toLowerCase().replaceAll("-", "_").replaceAll(" ", "");
99
+ }
100
+
101
+ /** The API's field names, mapped back to the ones a form uses. */
102
+ const FIELD_OF: Readonly<Record<string, string>> = {
103
+ reply_to_name: "name",
104
+ reply_to_email: "email",
105
+ subject: "subject",
106
+ message: "message",
107
+ };
108
+
109
+ /**
110
+ * Posts a message, and says what happened.
111
+ *
112
+ * **The page must be served over https.** The API reads the `Origin` header and
113
+ * refuses anything else before it looks at the body, so a form works on a
114
+ * deployed site and is rejected from `http://localhost`. Preview builds on
115
+ * `*.pages.dev` are recognised and routed to a development mailbox, which is
116
+ * the intended way to try one out.
117
+ *
118
+ * `fetch` is a parameter so this is testable without a global.
119
+ */
120
+ export async function sendContactMessage(
121
+ endpoint: MailEndpoint,
122
+ message: ContactMessage,
123
+ fetchImpl: typeof fetch = fetch
124
+ ): Promise<ContactResult> {
125
+ const additional_info: Record<string, string> = {};
126
+ for (const [key, value] of Object.entries(message.additional ?? {})) {
127
+ const trimmed = value?.trim();
128
+ if (trimmed) additional_info[key] = trimmed;
129
+ }
130
+
131
+ const url = joinUrl(
132
+ endpoint.url,
133
+ `/${normalizeAccount(endpoint.account)}/${endpoint.type ?? "default"}`
134
+ );
135
+
136
+ let response: Response;
137
+ try {
138
+ response = await fetchImpl(url, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json", Accept: "*/*" },
141
+ redirect: "follow",
142
+ body: JSON.stringify({
143
+ reply_to_name: message.name.trim(),
144
+ reply_to_email: message.email.trim(),
145
+ subject: message.subject.trim(),
146
+ message: message.message.trim(),
147
+ additional_info,
148
+ }),
149
+ });
150
+ } catch {
151
+ return { ok: false, reason: "unreachable" };
152
+ }
153
+
154
+ if (response.ok) return { ok: true };
155
+
156
+ // The API answers a refusal with a field-keyed `problems` object, which is
157
+ // worth showing — but it refuses a disallowed origin or an ignored address
158
+ // with no fields at all, and those bodies are not always JSON. Either way
159
+ // the message did not send, so a parse failure is the same outcome with
160
+ // nothing to add.
161
+ try {
162
+ const body: unknown = await response.json();
163
+ const reported = (() => {
164
+ if (typeof body !== "object" || body === null) return undefined;
165
+ if (!("problems" in body)) return undefined;
166
+ return (body as { problems?: Record<string, string> }).problems;
167
+ })();
168
+
169
+ const problems: Record<string, string> = {};
170
+ for (const [key, value] of Object.entries(reported ?? {})) {
171
+ problems[FIELD_OF[key] ?? key] = value;
172
+ }
173
+ return { ok: false, reason: "rejected", problems };
174
+ } catch {
175
+ return { ok: false, reason: "rejected", problems: {} };
176
+ }
177
+ }
package/src/index.ts CHANGED
@@ -53,6 +53,13 @@ export {
53
53
  type PostalAddress,
54
54
  telHref,
55
55
  } from "./contact.ts";
56
+ export {
57
+ type ContactFormType,
58
+ type ContactMessage,
59
+ type ContactResult,
60
+ type MailEndpoint,
61
+ sendContactMessage,
62
+ } from "./contact-form.ts";
56
63
  export type { GeneratedFile } from "./file.ts";
57
64
  export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
58
65
  export {
package/src/meta/index.ts CHANGED
@@ -78,7 +78,7 @@ export type MetaInput<L extends string> = Omit<MetaContentBase, "image"> &
78
78
  PageKind &
79
79
  MetaInputDerived<L>;
80
80
 
81
- interface MetaInputDerived<L extends string> {
81
+ interface MetaInputDerived<L extends string> extends ChromeInput {
82
82
  /** The locale of *this* page, not the site default. */
83
83
  readonly locale: L;
84
84
  readonly canonical: HttpsUrl;
@@ -119,8 +119,6 @@ interface MetaInputDerived<L extends string> {
119
119
  * Graph equivalent to fall back to. Site-level, like `siteName`.
120
120
  */
121
121
  readonly twitterSite?: string;
122
- /** The site's square icon, used for every icon link. */
123
- readonly icon: SiteIcon;
124
122
  /**
125
123
  * Webmaster-tool ownership tokens. Site-level, like `siteName`.
126
124
  *
@@ -144,6 +142,20 @@ interface MetaInputDerived<L extends string> {
144
142
  * generated — a link to a missing one is worse than no link.
145
143
  */
146
144
  readonly llmsUrl?: HttpsUrl;
145
+ }
146
+
147
+ /**
148
+ * The site's own furniture, as opposed to anything about a page.
149
+ *
150
+ * Its own interface because it is what the 404 shares with every real page and
151
+ * nearly all it shares: that document has no canonical, no description, no
152
+ * alternates and no share image, but it is still served under the site's name
153
+ * in the site's tab, and a reader who lands on it should not be able to tell
154
+ * from the chrome that they left.
155
+ */
156
+ export interface ChromeInput {
157
+ /** The site's square icon, used for every icon link. */
158
+ readonly icon: SiteIcon;
147
159
  /**
148
160
  * Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
149
161
  * Two values emit one tag per `prefers-color-scheme`.
@@ -172,6 +184,45 @@ export interface DocumentTags {
172
184
  readonly body: MetaTag[];
173
185
  }
174
186
 
187
+ /**
188
+ * What the browser dresses its own furniture with: the tab's icon, the tint of
189
+ * the address bar, and which schemes the page renders form controls for.
190
+ *
191
+ * Pulled out of `buildMeta` because the 404 needs exactly this and nothing else
192
+ * around it. Sharing the code is the point rather than a convenience: these are
193
+ * the tags a reader sees *as* the site — a 404 with a different tab icon looks
194
+ * like it came from somewhere else, which is the opposite of what a 404 is for —
195
+ * and two copies of a four-branch block is how one of them quietly stops
196
+ * matching the other.
197
+ */
198
+ function chromeTags(input: ChromeInput): MetaTag[] {
199
+ const tags: MetaTag[] = [...iconLinks(input.icon)];
200
+
201
+ if (input.colorScheme !== undefined) {
202
+ tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
203
+ }
204
+ if (typeof input.themeColor === "string") {
205
+ tags.push(meta({ name: "theme-color", content: input.themeColor }));
206
+ } else if (input.themeColor !== undefined) {
207
+ tags.push(
208
+ meta({
209
+ name: "theme-color",
210
+ media: "(prefers-color-scheme: light)",
211
+ content: input.themeColor.light,
212
+ })
213
+ );
214
+ tags.push(
215
+ meta({
216
+ name: "theme-color",
217
+ media: "(prefers-color-scheme: dark)",
218
+ content: input.themeColor.dark,
219
+ })
220
+ );
221
+ }
222
+
223
+ return tags;
224
+ }
225
+
175
226
  /** Builds the head tags every page needs. */
176
227
  export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
177
228
  const tags: MetaTag[] = [
@@ -209,29 +260,7 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
209
260
  tags.push(link({ rel: "describedby", href: input.llmsUrl }));
210
261
  }
211
262
 
212
- for (const iconLink of iconLinks(input.icon)) tags.push(iconLink);
213
-
214
- if (input.colorScheme !== undefined) {
215
- tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
216
- }
217
- if (typeof input.themeColor === "string") {
218
- tags.push(meta({ name: "theme-color", content: input.themeColor }));
219
- } else if (input.themeColor !== undefined) {
220
- tags.push(
221
- meta({
222
- name: "theme-color",
223
- media: "(prefers-color-scheme: light)",
224
- content: input.themeColor.light,
225
- })
226
- );
227
- tags.push(
228
- meta({
229
- name: "theme-color",
230
- media: "(prefers-color-scheme: dark)",
231
- content: input.themeColor.dark,
232
- })
233
- );
234
- }
263
+ for (const tag of chromeTags(input)) tags.push(tag);
235
264
 
236
265
  for (const alternate of input.alternates) {
237
266
  tags.push(
@@ -400,6 +429,20 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
400
429
  return { head: tags, body: bodyTags };
401
430
  }
402
431
 
432
+ /**
433
+ * What a 404 needs, which is the site's furniture and a title and nothing else.
434
+ *
435
+ * An object rather than the positional arguments this took before: the two it
436
+ * gained are both site-level and both optional-looking at a call site, and
437
+ * `buildNotFoundMeta(title, icon, themeColor, analytics)` is four positions
438
+ * where three of them are interchangeable to a reader.
439
+ */
440
+ export interface NotFoundInput extends ChromeInput {
441
+ readonly title: string;
442
+ /** Site-level, and only Umami reaches this page — see `notFoundAnalytics`. */
443
+ readonly analytics?: AnalyticsSettings;
444
+ }
445
+
403
446
  /**
404
447
  * Builds the head of a 404 page.
405
448
  *
@@ -412,13 +455,16 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
412
455
  * host serves this file at the address that was requested, so what gets
413
456
  * recorded is the missing path itself, which is the difference between knowing
414
457
  * there are 404s and knowing which redirect to write.
458
+ *
459
+ * It also carries the site's chrome, through the same `chromeTags` every real
460
+ * page goes through. A 404 is the one page a visitor reaches by accident, and
461
+ * the tab it opens in is how they judge whether they are still on the site they
462
+ * meant to be on — an unstyled tab showing the browser's default globe reads as
463
+ * a different origin, or as nothing at all.
415
464
  */
416
- export function buildNotFoundMeta(
417
- title: string,
418
- analytics?: AnalyticsSettings
419
- ): MetaTag[] {
465
+ export function buildNotFoundMeta(input: NotFoundInput): MetaTag[] {
420
466
  return [
421
- ...preamble({ title }),
467
+ ...preamble({ title: input.title }),
422
468
  // Through the same builder as every other page, so the one document lib
423
469
  // writes for itself cannot spell the tag differently from the ones it
424
470
  // writes for a project. Links are still followed: a 404 often carries
@@ -427,6 +473,7 @@ export function buildNotFoundMeta(
427
473
  name: "robots",
428
474
  content: robotsContent({ index: false, follow: true }),
429
475
  }),
430
- ...notFoundAnalytics(analytics).map(asMetaTag),
476
+ ...notFoundAnalytics(input.analytics).map(asMetaTag),
477
+ ...chromeTags(input),
431
478
  ];
432
479
  }
@@ -49,7 +49,9 @@ export interface RouteData<L extends string> {
49
49
  * `1` or omitted is an ordinary route. Above that, lib emits
50
50
  * `/news/page/2` … `/news/page/n` alongside `/news` — page one is always
51
51
  * the bare slug, never `page/1`, so there is no duplicate to canonicalise
52
- * away.
52
+ * away. `site.redirects()` then claims `/news/page/1` with a 301 onto the
53
+ * bare slug, since a URL nothing publishes is still one a reader edits
54
+ * their way to.
53
55
  *
54
56
  * Usually set per project rather than here: the count is posts divided by
55
57
  * page size, and one venue has more posts than another.
@@ -129,6 +129,16 @@ export function pageSegmentFor<L extends string>(
129
129
  return ctx.pageSegmentByLocale?.[locale] ?? ctx.pageSegment;
130
130
  }
131
131
 
132
+ /** A list's base path with a page number hung under the page segment. */
133
+ function underPageSegment(
134
+ base: UrlPath,
135
+ segment: string,
136
+ page: number
137
+ ): UrlPath {
138
+ // The locale root is `/`, and joining onto it would double the slash.
139
+ return base === "/" ? `/${segment}/${page}` : `${base}/${segment}/${page}`;
140
+ }
141
+
132
142
  /**
133
143
  * The path of one page of a route's list.
134
144
  *
@@ -144,9 +154,24 @@ export function pagePath<L extends string>(
144
154
  ): UrlPath {
145
155
  if (page <= 1) return base;
146
156
 
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}`;
157
+ return underPageSegment(base, pageSegmentFor(locale, ctx), page);
158
+ }
159
+
160
+ /**
161
+ * The `.../page/1` a list is deliberately *not* published at.
162
+ *
163
+ * The counterpart of the rule above: giving page one the bare path leaves the
164
+ * numbered form unclaimed, and unclaimed is not unasked-for — a reader edits
165
+ * `/news/page/2` down to `1`, or a crawler assumes a sequence starts where its
166
+ * segment says. So this is the one path here that exists to be redirected away
167
+ * from rather than linked, and nothing but `redirects()` should name it.
168
+ */
169
+ export function pageOneAlias<L extends string>(
170
+ base: UrlPath,
171
+ locale: L,
172
+ ctx: PathContext<L>
173
+ ): UrlPath {
174
+ return underPageSegment(base, pageSegmentFor(locale, ctx), 1);
150
175
  }
151
176
 
152
177
  export function localePrefix<L extends string>(
package/src/site/api.ts CHANGED
@@ -292,6 +292,18 @@ export interface Site<
292
292
  * a retranslated slug moves it too. External targets are `https://` URLs and
293
293
  * pass through untouched.
294
294
  *
295
+ * **What comes back is more than what went in.** lib adds the rules the
296
+ * route table implies, for the URLs its own design leaves unpublished but
297
+ * reachable: whichever site root the routing mode does not serve — `/` when
298
+ * every locale is prefixed, `/en-US` when the default locale is not — and
299
+ * `/news/page/1` for a list whose page one is the bare path. A rule you
300
+ * state for one of those paths replaces the inferred one, so pass `[]` and
301
+ * you still get a file worth writing.
302
+ *
303
+ * Pages under an unused locale prefix are *not* claimed: that is a rule per
304
+ * page per language, and it still misses the reader who edits a translated
305
+ * slug's prefix. See `NOT-BUILT.md`.
306
+ *
295
307
  * Returns the rules as data. Rendering is a separate step —
296
308
  * `buildCloudflareRedirects` writes the `_redirects` that Cloudflare and
297
309
  * Netlify read, and a host with its own syntax takes these and writes its own.