@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.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. 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
+ }
@@ -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
+ }