@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.
- package/README.md +48 -7
- package/docs/NOT-BUILT.md +84 -0
- package/docs/toolchain.md +49 -22
- package/package.json +11 -6
- package/src/astro/MetaTags.astro +2 -4
- package/src/astro/build-cache.ts +80 -0
- package/src/astro/carousel.ts +342 -0
- package/src/astro/dev-log.ts +128 -0
- package/src/astro/dom.ts +53 -0
- package/src/astro/element.ts +259 -0
- package/src/astro/filters.ts +457 -0
- package/src/astro/images.ts +73 -0
- package/src/astro/site-routes.ts +126 -21
- package/src/astro/tsconfig.json +8 -0
- package/src/contact-form.ts +177 -0
- package/src/index.ts +7 -0
- package/src/meta/index.ts +79 -32
- package/src/routes/define.ts +3 -1
- package/src/routes/resolve.ts +28 -3
- package/src/site/api.ts +12 -0
- package/src/site/create.ts +144 -43
package/src/astro/site-routes.ts
CHANGED
|
@@ -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.
|
|
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`.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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
|
|
147
|
-
*
|
|
155
|
+
* The Astro settings this integration decides, and the one it
|
|
156
|
+
* refuses.
|
|
148
157
|
*
|
|
149
|
-
*
|
|
150
|
-
* contract whose other half this library
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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": ({
|
|
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,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
|
|
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
|
}
|
package/src/routes/define.ts
CHANGED
|
@@ -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.
|
package/src/routes/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|