@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
package/src/project.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { AnalyticsSettings } from "./analytics/index.ts";
|
|
2
|
+
import type {
|
|
3
|
+
LocaleMetaOverride,
|
|
4
|
+
LocaleNotEnabled,
|
|
5
|
+
LocalesOf,
|
|
6
|
+
RoutingConfig,
|
|
7
|
+
SiteConfigShape,
|
|
8
|
+
} from "./config.ts";
|
|
9
|
+
import type {
|
|
10
|
+
BaseCatalog,
|
|
11
|
+
MessageOverlayKeys,
|
|
12
|
+
OverrideCatalog,
|
|
13
|
+
ValidateOverrideCatalog,
|
|
14
|
+
} from "./i18n/define.ts";
|
|
15
|
+
import type { SiteIcon, SiteVerification, ThemeColor } from "./meta/index.ts";
|
|
16
|
+
import type {
|
|
17
|
+
RouteOverlayKeys,
|
|
18
|
+
RouteOverlayShape,
|
|
19
|
+
RouteOverrideMap,
|
|
20
|
+
RouteRegistry,
|
|
21
|
+
} from "./routes/define.ts";
|
|
22
|
+
import type { NoExcessKeys, StringKeys } from "./types.ts";
|
|
23
|
+
import type { HttpsUrl, ValidSiteUrl } from "./url.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One deployment's overlay: everything that differs between sites built from the
|
|
27
|
+
* same template *and that lib needs to do its job*.
|
|
28
|
+
*
|
|
29
|
+
* Anything lib does not use — a company name, contact details — is not declared
|
|
30
|
+
* here. A project exports those separately, typed by the template's own
|
|
31
|
+
* contract, so lib never carries data it cannot reason about.
|
|
32
|
+
*/
|
|
33
|
+
export interface ProjectInput<L extends string, Routes> {
|
|
34
|
+
/**
|
|
35
|
+
* Absolute origin, e.g. `https://example.com`.
|
|
36
|
+
*
|
|
37
|
+
* No trailing slash — which the type cannot say, since a template literal
|
|
38
|
+
* cannot express "does not end in". `ValidSiteUrl` in `defineProject`
|
|
39
|
+
* checks it where the URL is written, and `createSite` throws on it at
|
|
40
|
+
* build time for a project object assembled some other way.
|
|
41
|
+
*
|
|
42
|
+
* The scheme *is* stated here, and the two together read better than either
|
|
43
|
+
* alone: the constraint names the offending literal, and `ValidSiteUrl`
|
|
44
|
+
* still supplies the sentence explaining it.
|
|
45
|
+
*/
|
|
46
|
+
readonly url: HttpsUrl;
|
|
47
|
+
/**
|
|
48
|
+
* The publisher's human-readable name, for `og:site_name`.
|
|
49
|
+
*
|
|
50
|
+
* Site-level, not per page: it is identical on every page, so stating it
|
|
51
|
+
* once here beats restating it at every `metaFor` call.
|
|
52
|
+
*/
|
|
53
|
+
readonly siteName: string;
|
|
54
|
+
/** `@handle` of the site, for `twitter:site`. Also identical everywhere. */
|
|
55
|
+
readonly twitterSite?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The site's icon: one square asset, used for `icon` and `apple-touch-icon`.
|
|
58
|
+
*
|
|
59
|
+
* Site-wide and required: search engines use one favicon per hostname, read
|
|
60
|
+
* from the home page, so a per-page icon would change the browser tab and
|
|
61
|
+
* nothing else.
|
|
62
|
+
*/
|
|
63
|
+
readonly icon: SiteIcon;
|
|
64
|
+
/**
|
|
65
|
+
* Ownership tokens from Search Console, Bing Webmaster Tools and the like.
|
|
66
|
+
*
|
|
67
|
+
* Per project rather than per site: each deployment is its own property in
|
|
68
|
+
* those tools, verified by whoever owns that domain.
|
|
69
|
+
*/
|
|
70
|
+
readonly verification?: SiteVerification;
|
|
71
|
+
/**
|
|
72
|
+
* What this deployment records about its own traffic.
|
|
73
|
+
*
|
|
74
|
+
* Per project, and beside `verification` because it is the same kind of
|
|
75
|
+
* thing: one property in one third-party dashboard, identified by an id
|
|
76
|
+
* that must not be shared with another deployment.
|
|
77
|
+
*
|
|
78
|
+
* lib builds the tags — the host, the script names and the attribute
|
|
79
|
+
* spellings are all derived from these settings and from `url` above, so a
|
|
80
|
+
* consuming repo states an id and nothing else. See `analytics.ts`.
|
|
81
|
+
*/
|
|
82
|
+
readonly analytics?: AnalyticsSettings;
|
|
83
|
+
/**
|
|
84
|
+
* Tints the browser UI. One value, or one per `prefers-color-scheme`.
|
|
85
|
+
*
|
|
86
|
+
* Required: the browser picks its own chrome colour otherwise, and it will
|
|
87
|
+
* not match your page.
|
|
88
|
+
*/
|
|
89
|
+
readonly themeColor: ThemeColor;
|
|
90
|
+
/** Which colour schemes the pages support, e.g. `"light"`, `"dark light"`. */
|
|
91
|
+
readonly colorScheme?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Which of the site's locales this project publishes.
|
|
94
|
+
*
|
|
95
|
+
* A selection, not an override: the site config declares which languages
|
|
96
|
+
* exist at all, and a project picks from them. Naming one it does not
|
|
97
|
+
* declare is a compile error.
|
|
98
|
+
*/
|
|
99
|
+
readonly enabledLocales: readonly L[];
|
|
100
|
+
/**
|
|
101
|
+
* Replaces parts of the site's `defaultRouting` for this project.
|
|
102
|
+
*
|
|
103
|
+
* Every `override*` field below is named for what it does to the template's
|
|
104
|
+
* defaults, because a project file is read alongside them and `routing: {…}`
|
|
105
|
+
* reads like the whole truth when it is only a patch.
|
|
106
|
+
*/
|
|
107
|
+
readonly overrideRouting?: Partial<RoutingConfig<L>>;
|
|
108
|
+
/** Restates a locale's label. Its writing direction is not overridable. */
|
|
109
|
+
readonly overrideLocaleMeta?: Readonly<
|
|
110
|
+
Partial<Record<L, LocaleMetaOverride>>
|
|
111
|
+
>;
|
|
112
|
+
/**
|
|
113
|
+
* Copy overrides: any subset of keys, any subset of locales.
|
|
114
|
+
*
|
|
115
|
+
* **Required, and `{}` is the answer for a deployment that overrides
|
|
116
|
+
* nothing.** Unlike the two fields above, which are optional.
|
|
117
|
+
*
|
|
118
|
+
* The reason is not that an absent overlay and an empty one read
|
|
119
|
+
* differently — they do, and that argument would make all four of these
|
|
120
|
+
* required. It is that these two are what the type machinery *computes
|
|
121
|
+
* from*: the route ids a project builds, the message keys it may override,
|
|
122
|
+
* and the locales each may be written for are all read off these fields,
|
|
123
|
+
* at seven sites in this file. Optional means `undefined` flowing into
|
|
124
|
+
* every one of them, `NonNullable<>` at each — see `overrideRouting` above,
|
|
125
|
+
* the one optional field that is read, for what that looks like — and an
|
|
126
|
+
* inference that widens where it should narrow.
|
|
127
|
+
*
|
|
128
|
+
* `{}` costing two characters is the price of the ids downstream being
|
|
129
|
+
* exact. It also happens to read as a statement, which is a bonus rather
|
|
130
|
+
* than the argument.
|
|
131
|
+
*/
|
|
132
|
+
readonly overrideMessages: OverrideCatalog<L>;
|
|
133
|
+
/** Slug retranslations and page on/off switches. See `overrideMessages`. */
|
|
134
|
+
readonly overrideRoutes: RouteOverrideMap<L, Routes>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Declares one deployment, validated against the site config and base data.
|
|
139
|
+
*
|
|
140
|
+
* Everything is inferred from the values passed in, so a project file writes no
|
|
141
|
+
* type arguments at all.
|
|
142
|
+
*
|
|
143
|
+
* Enforced at compile time:
|
|
144
|
+
* - `url` must not end with a slash;
|
|
145
|
+
* - `overrideMessages` keys must exist in the default catalog, and must keep
|
|
146
|
+
* the default message's `{placeholders}`;
|
|
147
|
+
* - `overrideRoutes` keys must be real route ids;
|
|
148
|
+
* - unknown top-level fields are rejected, so a typo cannot sit there unread;
|
|
149
|
+
* - locales named anywhere must be ones the site ships and this project enabled.
|
|
150
|
+
*/
|
|
151
|
+
export function defineProject<
|
|
152
|
+
const C extends SiteConfigShape,
|
|
153
|
+
const Catalog extends BaseCatalog<LocalesOf<C>>,
|
|
154
|
+
const Routes extends RouteRegistry<LocalesOf<C>>,
|
|
155
|
+
const T extends ProjectInput<LocalesOf<C>, Routes> &
|
|
156
|
+
NoExcessKeys<T, keyof ProjectInput<LocalesOf<C>, Routes>> & {
|
|
157
|
+
readonly url: ValidSiteUrl<T["url"]>;
|
|
158
|
+
readonly overrideRouting?: {
|
|
159
|
+
readonly defaultLocale?: T["enabledLocales"][number];
|
|
160
|
+
// Against `enabledLocales` rather than the site's list: a
|
|
161
|
+
// segment spelled for a language this deployment does not
|
|
162
|
+
// publish is a word that appears in no URL it builds. Mapped
|
|
163
|
+
// over the keys written, for the reason `overrideLocaleMeta`
|
|
164
|
+
// below is.
|
|
165
|
+
readonly pageSegmentByLocale?: {
|
|
166
|
+
readonly [K in StringKeys<
|
|
167
|
+
NonNullable<T["overrideRouting"]>["pageSegmentByLocale"]
|
|
168
|
+
>]: K extends T["enabledLocales"][number]
|
|
169
|
+
? string
|
|
170
|
+
: LocaleNotEnabled<K>;
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
// Two separate checks on the same field: the keys must be messages
|
|
174
|
+
// the default catalog declares, and the locales inside each must be
|
|
175
|
+
// ones this project publishes. Copy written for a locale left out of
|
|
176
|
+
// `enabledLocales` is never built, so it is always a mistake — a
|
|
177
|
+
// locale removed from the list, or a project copied from another.
|
|
178
|
+
readonly overrideMessages: MessageOverlayKeys<
|
|
179
|
+
Catalog,
|
|
180
|
+
T["overrideMessages"]
|
|
181
|
+
> & {
|
|
182
|
+
readonly [K in StringKeys<T["overrideMessages"]>]: {
|
|
183
|
+
readonly [L in StringKeys<
|
|
184
|
+
T["overrideMessages"][K]
|
|
185
|
+
>]: L extends T["enabledLocales"][number]
|
|
186
|
+
? T["overrideMessages"][K][L]
|
|
187
|
+
: LocaleNotEnabled<L>;
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
// Shared with `defineRouteOverrides` rather than restated — see
|
|
191
|
+
// `RouteOverlayKeys` for why the two must not drift.
|
|
192
|
+
readonly overrideRoutes: RouteOverlayKeys<
|
|
193
|
+
Routes,
|
|
194
|
+
T["overrideRoutes"]
|
|
195
|
+
>;
|
|
196
|
+
// Nested, so that `dir` is rejected inside each locale's entry.
|
|
197
|
+
// Declaring the field as `Pick<LocaleMeta, "label">` is not enough:
|
|
198
|
+
// `T` is *inferred* from the literal, so a stray `dir` just becomes
|
|
199
|
+
// part of `T` and there is no fresh object for excess-property
|
|
200
|
+
// checking to fire on.
|
|
201
|
+
readonly overrideLocaleMeta?: {
|
|
202
|
+
readonly [K in StringKeys<
|
|
203
|
+
T["overrideLocaleMeta"]
|
|
204
|
+
>]: K extends T["enabledLocales"][number]
|
|
205
|
+
? NoExcessKeys<
|
|
206
|
+
T["overrideLocaleMeta"][K],
|
|
207
|
+
keyof LocaleMetaOverride
|
|
208
|
+
>
|
|
209
|
+
: LocaleNotEnabled<K>;
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
>(
|
|
213
|
+
// The first three are read for their types: they are how the locale union,
|
|
214
|
+
// the message keys and the route ids are inferred without type arguments.
|
|
215
|
+
_config: C,
|
|
216
|
+
_messages: Catalog,
|
|
217
|
+
_routes: Routes,
|
|
218
|
+
// Both validators sit in parameter position rather than in the constraint
|
|
219
|
+
// above. They map the *whole* override object, and a whole-object mapped
|
|
220
|
+
// type in a constraint makes one unrelated mistake fail the argument
|
|
221
|
+
// outright — TypeScript then reports at this call instead of on the line
|
|
222
|
+
// that is wrong.
|
|
223
|
+
project: T & {
|
|
224
|
+
readonly overrideMessages: ValidateOverrideCatalog<
|
|
225
|
+
T["overrideMessages"],
|
|
226
|
+
Catalog,
|
|
227
|
+
LocalesOf<C>
|
|
228
|
+
>;
|
|
229
|
+
// The same alias `defineRouteOverrides` uses, so writing the overlay
|
|
230
|
+
// inline is never the weaker option.
|
|
231
|
+
readonly overrideRoutes: RouteOverlayShape<Routes, T["overrideRoutes"]>;
|
|
232
|
+
}
|
|
233
|
+
): T {
|
|
234
|
+
// Checked at runtime as well as in the type, because the type is not always
|
|
235
|
+
// what fails first. An Astro config is *evaluated* before `astro check`
|
|
236
|
+
// runs, so a project file missing one of these reaches `mergeCatalog` and
|
|
237
|
+
// throws there — a stack trace inside lib, naming a variable the reader
|
|
238
|
+
// never wrote, for a field the compiler would have named precisely.
|
|
239
|
+
//
|
|
240
|
+
// Two lines to turn that back into the error it already was.
|
|
241
|
+
for (const field of ["overrideMessages", "overrideRoutes"] as const) {
|
|
242
|
+
if (project[field] === undefined) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Project "${project.siteName}" declares no ${field}. It is required even when nothing is overridden — write \`${field}: {}\`, which is what the type asks for and what tells a reader the shared ${field === "overrideMessages" ? "copy" : "route table"} is used as it is.`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return project;
|
|
249
|
+
}
|
package/src/redirects.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirects: old URLs that must keep working after the site moved on.
|
|
3
|
+
*
|
|
4
|
+
* The source is always a path on this site, because a redirect you serve is one
|
|
5
|
+
* you are asked for — a host you do not control is not yours to redirect. The
|
|
6
|
+
* target is either another page of this site, named by route id so it is checked
|
|
7
|
+
* and its URL derived, or an absolute `https://` URL somewhere else.
|
|
8
|
+
*
|
|
9
|
+
* `site.redirects()` returns the resolved rules *and* a rendered file, the way
|
|
10
|
+
* `sitemap()` returns entries and files. A consumer that only wants the data —
|
|
11
|
+
* to feed a host with its own format — reads `rules`; one deploying to a static
|
|
12
|
+
* host writes `file` and is done.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GeneratedFile } from "./file.ts";
|
|
16
|
+
import type { PublicFilePath } from "./files.ts";
|
|
17
|
+
import {
|
|
18
|
+
type HttpsUrl,
|
|
19
|
+
joinUrl,
|
|
20
|
+
type UrlPath,
|
|
21
|
+
type ValidHttpsUrl,
|
|
22
|
+
} from "./url.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a redirect is a move or a detour.
|
|
26
|
+
*
|
|
27
|
+
* Named rather than numbered, and required rather than defaulted, because it is
|
|
28
|
+
* a decision with consequences in both directions and no safe default. A
|
|
29
|
+
* permanent redirect hands the old URL's search standing to the new one and is
|
|
30
|
+
* cached by browsers more or less forever — getting it wrong on a campaign URL
|
|
31
|
+
* means visitors who cannot be sent back. A temporary one transfers nothing, so
|
|
32
|
+
* using it for a real move quietly throws away everything the old URL earned.
|
|
33
|
+
*
|
|
34
|
+
* Two values, not four. The HTTP vocabulary also has `307` and `308`, which
|
|
35
|
+
* differ from `302` and `301` only by forbidding the method to change from POST
|
|
36
|
+
* to GET — a distinction a static site of `GET` requests can never exercise.
|
|
37
|
+
* Offering all four would mean three ways to spell the same outcome and one
|
|
38
|
+
* chance to pick the wrong one for a reason that does not apply here.
|
|
39
|
+
*/
|
|
40
|
+
export type RedirectKind = "permanent" | "temporary";
|
|
41
|
+
|
|
42
|
+
/** The wire status each kind is served with. */
|
|
43
|
+
const STATUS: Readonly<Record<RedirectKind, RedirectStatus>> = {
|
|
44
|
+
permanent: 301,
|
|
45
|
+
temporary: 302,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** What ends up in the file. `RedirectKind` is what a consumer writes. */
|
|
49
|
+
export type RedirectStatus = 301 | 302;
|
|
50
|
+
|
|
51
|
+
/** Resolves a rule's kind to the status it is served with. */
|
|
52
|
+
export function statusFor(kind: RedirectKind): RedirectStatus {
|
|
53
|
+
return STATUS[kind];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A path on *this* site, from the root. Never a host.
|
|
58
|
+
*
|
|
59
|
+
* `UrlPath` under a name that says whose site it is: a redirect source has to
|
|
60
|
+
* be local, since a host you do not control is not yours to redirect. Aliased
|
|
61
|
+
* rather than re-declared, so the two cannot drift into different shapes.
|
|
62
|
+
*/
|
|
63
|
+
export type SitePath = UrlPath;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Somewhere else entirely. `https` only — a redirect to `http` is a downgrade.
|
|
67
|
+
*
|
|
68
|
+
* `HttpsUrl` under a name that says whose site it is *not*, aliased for the
|
|
69
|
+
* same reason `SitePath` is: spelling the pattern again here would be a second
|
|
70
|
+
* definition of "acceptable URL", and `ValidHttpsUrl` — which is what actually
|
|
71
|
+
* reports the mistake — is written against the first one.
|
|
72
|
+
*/
|
|
73
|
+
export type ExternalUrl = HttpsUrl;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rejects an external target that is not `https://`, naming it.
|
|
77
|
+
*
|
|
78
|
+
* Applied to the rules array in parameter position, so a bad URL reports on its
|
|
79
|
+
* own line rather than as the whole array failing to match. The check itself is
|
|
80
|
+
* `ValidHttpsUrl`, shared with the site origin — one idea of an acceptable URL,
|
|
81
|
+
* stated once.
|
|
82
|
+
*/
|
|
83
|
+
export type ValidateRedirectTargets<Rules> = {
|
|
84
|
+
readonly [K in keyof Rules]: Rules[K] extends {
|
|
85
|
+
readonly to: infer To extends string;
|
|
86
|
+
}
|
|
87
|
+
? {
|
|
88
|
+
// Every key carried through, and only `to` rewritten. Declaring
|
|
89
|
+
// just `to` would make the rest read as excess properties.
|
|
90
|
+
readonly [P in keyof Rules[K]]: P extends "to"
|
|
91
|
+
? ValidHttpsUrl<To>
|
|
92
|
+
: Rules[K][P];
|
|
93
|
+
}
|
|
94
|
+
: Rules[K];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where a redirect points: a page of this site, or an external URL.
|
|
99
|
+
*
|
|
100
|
+
* The internal form names a route rather than spelling a path, so it is checked
|
|
101
|
+
* against what this project builds and its URL is derived — a redirect cannot
|
|
102
|
+
* outlive the page it points at, or miss a slug that was retranslated. The
|
|
103
|
+
* locale is optional and defaults to the site's own, since an old URL usually
|
|
104
|
+
* predates translation.
|
|
105
|
+
*/
|
|
106
|
+
export type RedirectTarget<Id extends string, L extends string> =
|
|
107
|
+
| ExternalUrl
|
|
108
|
+
| { readonly route: Id; readonly locale?: L }
|
|
109
|
+
/**
|
|
110
|
+
* A file served verbatim from `public/` — a PDF, a spreadsheet.
|
|
111
|
+
*
|
|
112
|
+
* Its own shape rather than a bare path, so it cannot be confused with a
|
|
113
|
+
* route: a route target is checked against the pages this project builds,
|
|
114
|
+
* and lib has no way to see what sits in `public/`. What it can do is refuse
|
|
115
|
+
* to guess — a path here is taken as given, and narrowing it is the
|
|
116
|
+
* project's job. The `PublicFile` union that `publicFiles()` generates is
|
|
117
|
+
* exactly that narrowing.
|
|
118
|
+
*/
|
|
119
|
+
| { readonly file: PublicFilePath };
|
|
120
|
+
|
|
121
|
+
export interface RedirectRule<Id extends string, L extends string> {
|
|
122
|
+
/** The old path, exactly as it was requested. */
|
|
123
|
+
readonly from: SitePath;
|
|
124
|
+
readonly to: RedirectTarget<Id, L>;
|
|
125
|
+
/**
|
|
126
|
+
* Whether this is a move or a detour. Required — see `RedirectKind`.
|
|
127
|
+
*
|
|
128
|
+
* Stated on every rule for the same reason `robots` is stated on every
|
|
129
|
+
* page: the wrong answer is invisible in the output, so inheriting one is
|
|
130
|
+
* how a mistake gets shipped without anyone choosing it.
|
|
131
|
+
*/
|
|
132
|
+
readonly kind: RedirectKind;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One rule with its target resolved to a URL or path, ready to serve. */
|
|
136
|
+
export interface ResolvedRedirect {
|
|
137
|
+
readonly from: string;
|
|
138
|
+
readonly to: string;
|
|
139
|
+
readonly status: RedirectStatus;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What the Cloudflare renderer needs beyond the rules themselves. */
|
|
143
|
+
export interface CloudflareRedirectsOptions {
|
|
144
|
+
readonly siteUrl: HttpsUrl;
|
|
145
|
+
/** Filename it is served under. Defaults to `_redirects`. */
|
|
146
|
+
readonly name?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Cloudflare Pages honours the first 2000 rules and drops the rest in silence.
|
|
151
|
+
*
|
|
152
|
+
* Not an option: it is a fact about the host this renderer is named for, not a
|
|
153
|
+
* preference. Exposing it would only let a caller supply a number that is not
|
|
154
|
+
* the real one, and the failure it guards against — redirects that quietly stop
|
|
155
|
+
* working because they sit at the bottom of a long file — is exactly the kind
|
|
156
|
+
* nobody goes looking for.
|
|
157
|
+
*/
|
|
158
|
+
const CLOUDFLARE_RULE_LIMIT = 2000;
|
|
159
|
+
|
|
160
|
+
export interface RedirectsInput {
|
|
161
|
+
readonly rules: readonly ResolvedRedirect[];
|
|
162
|
+
/**
|
|
163
|
+
* Every path this build actually serves.
|
|
164
|
+
*
|
|
165
|
+
* Used to reject a redirect that shadows a real page. Hosts disagree about
|
|
166
|
+
* which of the two wins — some serve the file and leave the rule dead,
|
|
167
|
+
* while Cloudflare Pages states that redirects are followed whether or not
|
|
168
|
+
* an asset matches, which instead makes the *page* unreachable. Since the
|
|
169
|
+
* outcome is a coin toss decided somewhere the build cannot see, and one
|
|
170
|
+
* side of it silently deletes a page, both are rejected here.
|
|
171
|
+
*/
|
|
172
|
+
readonly builtPaths: ReadonlySet<string>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const REDIRECTS_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Checks a set of resolved rules and hands them back.
|
|
179
|
+
*
|
|
180
|
+
* Separate from rendering because these are facts about the *set* — a duplicate
|
|
181
|
+
* source, a source that is already a page, a rule pointing at itself — and hold
|
|
182
|
+
* whatever format the rules end up in. A consumer feeding a host with its own
|
|
183
|
+
* redirect syntax gets the same guarantees as one writing `_redirects`.
|
|
184
|
+
*/
|
|
185
|
+
export function buildRedirects(
|
|
186
|
+
input: RedirectsInput
|
|
187
|
+
): readonly ResolvedRedirect[] {
|
|
188
|
+
const seen = new Map<string, string>();
|
|
189
|
+
|
|
190
|
+
for (const rule of input.rules) {
|
|
191
|
+
if (!rule.from.startsWith("/")) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Redirect source "${rule.from}" must be a path beginning with "/". A redirect answers a request to this site, so its source is never a host.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
// A source that is not the canonical form of its own URL. Hosts differ
|
|
197
|
+
// on whether they strip a trailing slash before matching or treat the
|
|
198
|
+
// two as one URL, and on either reading this rule is unreliable: at
|
|
199
|
+
// best it duplicates the slash-free form, at worst it never fires. lib
|
|
200
|
+
// builds no such path, so nothing legitimate produces one.
|
|
201
|
+
if (rule.from.length > 1 && rule.from.endsWith("/")) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Redirect source "${rule.from}" ends with a slash. Write it as "${rule.from.slice(0, -1)}": a host that strips the slash before matching would never reach this rule.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (rule.from.startsWith("//")) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Redirect source "${rule.from}" starts with "//", which browsers read as a protocol-relative host rather than a path.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const clash = seen.get(rule.from);
|
|
213
|
+
if (clash !== undefined) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Two redirects both claim "${rule.from}" (to "${clash}" and "${rule.to}"). Only the first would ever fire.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
seen.set(rule.from, rule.to);
|
|
219
|
+
|
|
220
|
+
if (input.builtPaths.has(rule.from)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Redirect source "${rule.from}" is a page this site builds. One of the two wins and the other is dead, and which one depends on the host: Cloudflare follows redirects whether or not an asset matches, so there the page becomes unreachable. Remove the rule, or the page.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (rule.from === rule.to) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Redirect "${rule.from}" points at itself, which is a loop.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return input.rules;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Renders resolved rules as Cloudflare Pages' `_redirects`: `from to status`,
|
|
237
|
+
* one per line. Netlify reads the same format.
|
|
238
|
+
*
|
|
239
|
+
* Named for the host on purpose. It is *one* way to serve these rules, and the
|
|
240
|
+
* rules themselves know nothing about it — a Vercel `vercel.json` or an nginx
|
|
241
|
+
* `return 301` renderer sits beside this one and takes the same input. A vaguer
|
|
242
|
+
* name would suggest there is only ever one file, which is the assumption worth
|
|
243
|
+
* not baking in.
|
|
244
|
+
*/
|
|
245
|
+
export function buildCloudflareRedirects(
|
|
246
|
+
redirects: readonly ResolvedRedirect[],
|
|
247
|
+
options: CloudflareRedirectsOptions
|
|
248
|
+
): GeneratedFile {
|
|
249
|
+
if (redirects.length > CLOUDFLARE_RULE_LIMIT) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`${redirects.length} redirects exceeds the ${CLOUDFLARE_RULE_LIMIT} Cloudflare Pages honours. Everything past that would be dropped silently.`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const name = options.name ?? "_redirects";
|
|
256
|
+
const body = redirects
|
|
257
|
+
.map((rule) => `${rule.from} ${rule.to} ${rule.status}`)
|
|
258
|
+
.join("\n");
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
name,
|
|
262
|
+
url: joinUrl(options.siteUrl, `/${name}`),
|
|
263
|
+
body: body === "" ? "" : `${body}\n`,
|
|
264
|
+
contentType: REDIRECTS_CONTENT_TYPE,
|
|
265
|
+
};
|
|
266
|
+
}
|
package/src/robots.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { GeneratedFile } from "./file.ts";
|
|
2
|
+
import { type HttpsUrl, joinUrl } from "./url.ts";
|
|
3
|
+
|
|
4
|
+
/** One `User-agent` block. */
|
|
5
|
+
export interface RobotsGroup {
|
|
6
|
+
/** One agent or several, e.g. `"*"` or `["Googlebot", "Bingbot"]`. */
|
|
7
|
+
readonly userAgent: string | readonly string[];
|
|
8
|
+
readonly allow?: readonly string[];
|
|
9
|
+
readonly disallow?: readonly string[];
|
|
10
|
+
/** Seconds between requests. Ignored by Google, honoured by some others. */
|
|
11
|
+
readonly crawlDelay?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** How a site configures `robots.txt`. Part of `site.config.ts`. */
|
|
15
|
+
export interface RobotsConfig {
|
|
16
|
+
/** Filename it is served under. Defaults to `robots.txt`. */
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
/** Defaults to one group allowing everything. */
|
|
19
|
+
readonly groups?: readonly RobotsGroup[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RobotsInput {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly siteUrl: HttpsUrl;
|
|
25
|
+
/**
|
|
26
|
+
* Absolute URL of the sitemap to advertise.
|
|
27
|
+
*
|
|
28
|
+
* `robots.txt` permits several `Sitemap:` lines, but one pointing at the
|
|
29
|
+
* index is enough — the index already lists every part. Required, because a
|
|
30
|
+
* site always has a sitemap here and omitting the line would only hide it
|
|
31
|
+
* from crawlers.
|
|
32
|
+
*/
|
|
33
|
+
readonly sitemap: string;
|
|
34
|
+
/** Defaults to one group allowing everything. */
|
|
35
|
+
readonly groups?: readonly RobotsGroup[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_GROUPS: readonly RobotsGroup[] = [
|
|
39
|
+
{ userAgent: "*", allow: ["/"] },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Renders `robots.txt`.
|
|
44
|
+
*
|
|
45
|
+
* Site-wide crawl rules belong here rather than in a `robots` meta tag: a tag
|
|
46
|
+
* only speaks for the page it sits on, and a crawler has to fetch that page to
|
|
47
|
+
* read it — which is exactly what you were trying to prevent.
|
|
48
|
+
*/
|
|
49
|
+
export function buildRobots(input: RobotsInput): GeneratedFile {
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
|
|
52
|
+
for (const group of input.groups ?? DEFAULT_GROUPS) {
|
|
53
|
+
const agents =
|
|
54
|
+
typeof group.userAgent === "string"
|
|
55
|
+
? [group.userAgent]
|
|
56
|
+
: group.userAgent;
|
|
57
|
+
for (const agent of agents) {
|
|
58
|
+
lines.push(`User-agent: ${agent}`);
|
|
59
|
+
}
|
|
60
|
+
for (const path of group.allow ?? []) {
|
|
61
|
+
lines.push(`Allow: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
for (const path of group.disallow ?? []) {
|
|
64
|
+
lines.push(`Disallow: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
if (group.crawlDelay !== undefined) {
|
|
67
|
+
lines.push(`Crawl-delay: ${group.crawlDelay}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push(`Sitemap: ${input.sitemap}`);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
name: input.name,
|
|
76
|
+
url: joinUrl(input.siteUrl, `/${input.name}`),
|
|
77
|
+
body: `${lines.join("\n").trimEnd()}\n`,
|
|
78
|
+
contentType: "text/plain; charset=utf-8",
|
|
79
|
+
};
|
|
80
|
+
}
|