@escape-game-over/atlas 0.1.69 → 0.1.71

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 CHANGED
@@ -61,7 +61,7 @@ Linking to a page the active deployment switched off is a compile error, not a
61
61
  One file per deployment, saying only what differs:
62
62
 
63
63
  ```ts
64
- export default atlas.project(defaultMessages, defaultRoutes, {
64
+ export const { project, redirects } = atlas.project(defaultMessages, defaultRoutes, {
65
65
  url: "https://acme.example",
66
66
  siteName: "Acme Rome",
67
67
  icon,
@@ -98,8 +98,8 @@ export const atlas = defineSite({ locales: {...}, defaultRouting: {...} })
98
98
  export const baseMessages = atlas.messages({...})
99
99
  export const baseRoutes = atlas.routes({...})
100
100
 
101
- // 3. one deployment
102
- export default atlas.project(baseMessages, baseRoutes, {...})
101
+ // 3. one deployment, and its redirects, typed against what it builds
102
+ export const { project, redirects } = atlas.project(baseMessages, baseRoutes, {...})
103
103
 
104
104
  // 4. the API the pages use
105
105
  export const site = atlas.site(baseMessages, baseRoutes, project)
@@ -107,8 +107,21 @@ export const site = atlas.site(baseMessages, baseRoutes, project)
107
107
 
108
108
  Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
109
109
  `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
110
- `getStaticPaths()`, `routes`, `sitemap()`, `robots()`, `llms()` and
111
- `redirects()` already wired.
110
+ `getStaticPaths()`, `routes`, `sitemap()`, `robots()` and `llms()` already
111
+ wired.
112
+
113
+ Step 3's `redirects` is the one way to write a redirect: it types one
114
+ deployment's old URLs against *that* deployment's pages and languages,
115
+ whichever project the site is currently built from. `siteRoutes()` takes what
116
+ it returns and nothing else, resolves it, and writes `_redirects`:
117
+
118
+ ```ts
119
+ // projects/rome/redirects.ts
120
+ export default redirects([
121
+ { from: "/jobs", to: { route: "careers" }, kind: "permanent" }, // ✗ if Rome has no careers page
122
+ { from: "/en/*", to: { route: "home", locale: "en-US", splat: true }, kind: "permanent" }, // /en/faq → /en-US/faq
123
+ ])
124
+ ```
112
125
 
113
126
  `t()` is bound to a locale and knows each message's `{placeholders}` from its
114
127
  text:
@@ -177,7 +190,7 @@ than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
177
190
  | `sitemap.xml` | `siteRoutes()`, from the routes | nothing |
178
191
  | `robots.txt` | `siteRoutes()` | nothing |
179
192
  | `llms.txt` | `siteRoutes()` | words, via `site.llms({...})` |
180
- | `_redirects` | `siteRoutes()` | rules, via `site.redirects()` |
193
+ | `_redirects` | `siteRoutes()` | rules, via the project's `redirects()` |
181
194
  | `BreadcrumbList` JSON-LD | `breadcrumbFor()`, from the slug | a name per step, in a layout |
182
195
  | `LocalBusiness` JSON-LD | `localBusiness()` | address, phone, hours |
183
196
  | `Organization` JSON-LD | `organization()` | name, URL, logo |
package/docs/NOT-BUILT.md CHANGED
@@ -384,7 +384,7 @@ repo, which is why this entry exists — reading that file is what prompts the
384
384
  question.
385
385
 
386
386
  Nothing is served by Apache any more. Worth knowing the seam is already there if
387
- that ever changes: `site.redirects()` returns resolved rules as *data*, and
387
+ that ever changes: the site resolves rules to plain *data*, and
388
388
  `buildCloudflareRedirects` is one renderer over them rather than the only
389
389
  possible one, so another host is a new function and no redesign. The two
390
390
  non-redirect halves of that `.htaccess` — extension resolution and slash
package/docs/toolchain.md CHANGED
@@ -82,7 +82,7 @@ The practical consequence: `/about-us` is `about-us.html`, not
82
82
  `about-us/index.html`. `dist/` is plain files — any static host works, and no
83
83
  server rules are required.
84
84
 
85
- ## The URLs `site.redirects()` claims on its own
85
+ ## The URLs `_redirects` claims on its own
86
86
 
87
87
  Two of this toolchain's decisions leave a URL unpublished that people still ask
88
88
  for. Each is answered with a real **301** written into `_redirects` alongside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -5,7 +5,10 @@ import type { AstroIntegration } from "astro";
5
5
  import type { GeneratedFile } from "../file.ts";
6
6
  import {
7
7
  buildCloudflareRedirects,
8
+ type ProjectRedirects,
9
+ type RedirectRule,
8
10
  type ResolvedRedirect,
11
+ resolveRedirects,
9
12
  } from "../redirects.ts";
10
13
  import type { Sitemap } from "../sitemap.ts";
11
14
  import type { HttpsUrl } from "../url.ts";
@@ -20,7 +23,7 @@ import { noClientRouter } from "./no-client-router.ts";
20
23
  * without having to name its three type arguments here — and so this states
21
24
  * exactly which parts are used.
22
25
  */
23
- export interface SiteFiles {
26
+ export interface SiteFiles<Id extends string, L extends string> {
24
27
  readonly url: HttpsUrl;
25
28
  /** Segments with pages under them but none at them; see `orphanSegments`. */
26
29
  readonly orphanSegments: readonly string[];
@@ -29,6 +32,10 @@ export interface SiteFiles {
29
32
  sitemap(): Sitemap;
30
33
  robots(): GeneratedFile;
31
34
  llms(): GeneratedFile;
35
+ /** Where `Id` and `L` are read from, so the rules below must match them. */
36
+ readonly [resolveRedirects]: (
37
+ rules: readonly RedirectRule<Id, L>[]
38
+ ) => readonly ResolvedRedirect[];
32
39
  }
33
40
 
34
41
  /**
@@ -53,16 +60,23 @@ function size(body: string): string {
53
60
  return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} kB`;
54
61
  }
55
62
 
56
- export interface SiteRoutesOptions {
63
+ export interface SiteRoutesOptions<Id extends string, L extends string> {
57
64
  /**
58
65
  * The site, imported. Not a module path: everything below is derived from
59
66
  * plain data — URLs, route ids, locales — so it can be built where an Astro
60
67
  * config is evaluated, and a real import is checked where a specifier would
61
68
  * not be.
62
69
  */
63
- readonly site: SiteFiles;
64
- /** From `site.redirects([…])`. Omit to write no `_redirects`. */
65
- readonly redirects?: readonly ResolvedRedirect[];
70
+ readonly site: SiteFiles<Id, L>;
71
+ /**
72
+ * From the `redirects` that `atlas.project()` returns — the only form taken,
73
+ * so every rule here was checked against the project it belongs to.
74
+ *
75
+ * Resolved against `site`, which adds the rules its route table implies;
76
+ * omitted, `_redirects` holds only those. Not inferred from: a list typed
77
+ * for another project is an error here, not a widening of `Id`.
78
+ */
79
+ readonly redirects?: ProjectRedirects<NoInfer<Id>, NoInfer<L>>;
66
80
  /**
67
81
  * From `site.llms({…})`.
68
82
  *
@@ -92,8 +106,13 @@ export interface SiteRoutesOptions {
92
106
  * That was built and served, in dev and in a build, before being taken out
93
107
  * again for the reason above.
94
108
  */
95
- export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
96
- const { site, redirects, llms } = options;
109
+ export function siteRoutes<Id extends string, L extends string>(
110
+ options: SiteRoutesOptions<Id, L>
111
+ ): AstroIntegration {
112
+ const { site, llms } = options;
113
+ // Resolved here, once, so a rule that cannot be served throws while the
114
+ // config is evaluated rather than at the end of a build.
115
+ const redirects = site[resolveRedirects](options.redirects ?? []);
97
116
 
98
117
  /** `llms.txt (4.2 kB)` — the name and what it actually weighs. */
99
118
  const describe = (file: GeneratedFile): string =>
@@ -116,7 +135,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
116
135
  if (site.llmsUrl !== undefined) files.push(llms ?? site.llms());
117
136
  // Length rather than presence: a site with no rules gets no file, not an
118
137
  // empty one.
119
- if (redirects?.length) {
138
+ if (redirects.length > 0) {
120
139
  files.push(
121
140
  buildCloudflareRedirects(redirects, { siteUrl: site.url })
122
141
  );
@@ -381,7 +400,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
381
400
  path.length > 1 && path.endsWith("/")
382
401
  ? path.slice(0, -1)
383
402
  : path;
384
- const rule = redirects?.find((it) => it.from === canonical);
403
+ const rule = redirects.find((it) => it.from === canonical);
385
404
  if (rule !== undefined) {
386
405
  logger.info(
387
406
  `redirect: ${path} → ${rule.to} (${rule.status})`
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * export const atlas = defineSite({ locales: {...}, defaultRouting: {...} }) // site.config.ts
8
8
  * export const defaultMessages = atlas.messages({...}) // messages.ts
9
9
  * export const defaultRoutes = atlas.routes({...}) // routes.ts
10
- * export default atlas.project(defaultMessages, defaultRoutes, {...}) // projects/<name>/project.ts
10
+ * export const { project, redirects } = atlas.project(defaultMessages, defaultRoutes, {...}) // projects/<name>/project.ts
11
11
  * export const site = atlas.site(defaultMessages, defaultRoutes, project) // site.ts
12
12
  * ```
13
13
  */
@@ -206,12 +206,17 @@ export {
206
206
  type TieredPrice,
207
207
  tierRange,
208
208
  } from "./money.ts";
209
- export type { ProjectInput } from "./project.ts";
209
+ export type {
210
+ DefinedProject,
211
+ ProjectInput,
212
+ ProjectRouteId,
213
+ } from "./project.ts";
210
214
  export {
211
215
  buildCloudflareRedirects,
212
216
  buildRedirects,
213
217
  type CloudflareRedirectsOptions,
214
218
  type ExternalUrl,
219
+ type ProjectRedirects,
215
220
  type RedirectKind,
216
221
  type RedirectRule,
217
222
  type RedirectStatus,
package/src/project.ts CHANGED
@@ -15,6 +15,13 @@ import type {
15
15
  import type { ImageAsset } from "./image.ts";
16
16
  import type { SiteVerification, ThemeColor } from "./meta/index.ts";
17
17
  import type {
18
+ ProjectRedirects,
19
+ RedirectRule,
20
+ ValidateRedirectTargets,
21
+ } from "./redirects.ts";
22
+ import type {
23
+ EnabledFlagSource,
24
+ EnabledRouteIdFor,
18
25
  RouteOverrides,
19
26
  RouteRegistry,
20
27
  ValidateOverrideSlugDepth,
@@ -136,6 +143,64 @@ export interface ProjectInput<L extends string, Routes> {
136
143
  readonly overrideRoutes: RouteOverrides<Routes, L>;
137
144
  }
138
145
 
146
+ /**
147
+ * The route ids one project builds: the registry's, minus what it switched off.
148
+ *
149
+ * The single definition, read by `site` and by a project's own `redirects` —
150
+ * so a rule typed against the project fits the site built from it, and the two
151
+ * cannot drift apart. A near-miss spelling of this type is not harmless: an
152
+ * intersection with the registry's constraint was enough to let switched-off
153
+ * routes back in, silently.
154
+ */
155
+ export type ProjectRouteId<
156
+ Routes extends EnabledFlagSource,
157
+ P extends { readonly overrideRoutes: unknown },
158
+ > = EnabledRouteIdFor<Routes, P["overrideRoutes"]> & StringKeys<Routes>;
159
+
160
+ /**
161
+ * What `atlas.project()` hands back: the project, and its redirects.
162
+ *
163
+ * `redirects` is bound to *this* project, because the rules belong to it: an
164
+ * old URL is a fact about one deployment's past, and a target can only be a
165
+ * page that deployment builds, in a language it publishes. Typed here rather
166
+ * than through `site`, which is built from whichever project is linked — so a
167
+ * project that is not the linked one has its rules checked all the same.
168
+ *
169
+ * The one way to type a rule. What it returns is what `siteRoutes()` takes,
170
+ * resolves, and writes to `_redirects` with the rules the route table implies.
171
+ */
172
+ export interface DefinedProject<
173
+ Routes extends EnabledFlagSource,
174
+ T extends ProjectInput<string, Routes>,
175
+ > {
176
+ readonly project: T;
177
+ /** Rules for this project's old URLs, checked against what it builds. */
178
+ readonly redirects: <
179
+ const Rules extends readonly RedirectRule<
180
+ ProjectRouteId<Routes, T>,
181
+ T["enabledLocales"][number]
182
+ >[],
183
+ >(
184
+ rules: Rules & ValidateRedirectTargets<Rules>
185
+ ) => ProjectRedirects<
186
+ ProjectRouteId<Routes, T>,
187
+ T["enabledLocales"][number]
188
+ >;
189
+ }
190
+
191
+ /**
192
+ * A project's `redirects`. The one place a rule list is branded as checked.
193
+ *
194
+ * Takes the routes and the project only for their types; the rules come back
195
+ * as they went in.
196
+ */
197
+ export function bindRedirects<
198
+ Routes extends EnabledFlagSource,
199
+ T extends ProjectInput<string, Routes>,
200
+ >(_routes: Routes, _project: T): DefinedProject<Routes, T>["redirects"] {
201
+ return (rules) => rules as never;
202
+ }
203
+
139
204
  /**
140
205
  * Declares one deployment, validated against the site config and base data.
141
206
  *
package/src/redirects.ts CHANGED
@@ -6,10 +6,9 @@
6
6
  * target is either another page of this site, named by route id so it is checked
7
7
  * and its URL derived, or an absolute `https://` URL somewhere else.
8
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.
9
+ * There is one way in: the `redirects` that `atlas.project()` returns types a
10
+ * project's rules against what that project builds, and `siteRoutes()` takes
11
+ * them, resolves them against the site and writes `_redirects`.
13
12
  */
14
13
 
15
14
  import type { GeneratedFile } from "./file.ts";
@@ -72,13 +71,19 @@ export type SitePath = UrlPath;
72
71
  */
73
72
  export type ExternalUrl = HttpsUrl;
74
73
 
74
+ /** A `splat` target whose source has no `*` to carry: there is no rest to keep. */
75
+ export interface SplatNeedsWildcard<From extends string> {
76
+ readonly __SPLAT_WITHOUT_WILDCARD__: `"${From}" has no "/*" to carry over: a rule with \`splat: true\` needs a source ending in "/*"`;
77
+ }
78
+
75
79
  /**
76
- * Rejects an external target that is not `https://`, naming it.
80
+ * Rejects an external target that is not `https://`, and a `splat` target
81
+ * whose source has no wildcard, naming each.
77
82
  *
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.
83
+ * Applied to the rules array in parameter position, so a bad rule reports on
84
+ * its own line rather than as the whole array failing to match. The URL check
85
+ * itself is `ValidHttpsUrl`, shared with the site origin — one idea of an
86
+ * acceptable URL, stated once.
82
87
  */
83
88
  export type ValidateRedirectTargets<Rules> = {
84
89
  readonly [K in keyof Rules]: Rules[K] extends {
@@ -91,7 +96,18 @@ export type ValidateRedirectTargets<Rules> = {
91
96
  ? ValidHttpsUrl<To>
92
97
  : Rules[K][P];
93
98
  }
94
- : Rules[K];
99
+ : Rules[K] extends {
100
+ readonly from: infer From extends string;
101
+ readonly to: { readonly splat: true };
102
+ }
103
+ ? From extends `${string}/*`
104
+ ? Rules[K]
105
+ : {
106
+ readonly [P in keyof Rules[K]]: P extends "from"
107
+ ? SplatNeedsWildcard<From>
108
+ : Rules[K][P];
109
+ }
110
+ : Rules[K];
95
111
  };
96
112
 
97
113
  /**
@@ -106,7 +122,28 @@ export type ValidateRedirectTargets<Rules> = {
106
122
  */
107
123
  export type RedirectTarget<Id extends string, L extends string> =
108
124
  | ExternalUrl
109
- | { readonly route: Id; readonly locale?: L; readonly page?: number }
125
+ | {
126
+ readonly route: Id;
127
+ readonly locale?: L;
128
+ readonly page?: number;
129
+ readonly splat?: never;
130
+ }
131
+ /**
132
+ * A page, with whatever the source's `*` matched kept after its path:
133
+ * `/en/*` to `{ route: "home", locale: "en-US", splat: true }` sends
134
+ * `/en/faq` to `/en-US/faq`.
135
+ *
136
+ * For an old prefix whose pages kept their slugs, so one rule answers every
137
+ * URL under it. The target stays a route, so the prefix it lands on is
138
+ * derived like every other link rather than spelled out by hand. Not with
139
+ * `page`: the rest of a path cannot follow a page number.
140
+ */
141
+ | {
142
+ readonly route: Id;
143
+ readonly locale?: L;
144
+ readonly splat: true;
145
+ readonly page?: never;
146
+ }
110
147
  /**
111
148
  * A file served verbatim from `public/` — a PDF, a spreadsheet.
112
149
  *
@@ -133,6 +170,31 @@ export interface RedirectRule<Id extends string, L extends string> {
133
170
  readonly kind: RedirectKind;
134
171
  }
135
172
 
173
+ declare const checkedByProject: unique symbol;
174
+
175
+ /**
176
+ * Rules a project's own `redirects` checked — the only form `siteRoutes()`
177
+ * takes.
178
+ *
179
+ * Branded so that a list cannot reach the integration any other way. Written
180
+ * inline at `siteRoutes()`, rules could only be checked against the site, and
181
+ * the site is built from whichever project is linked: every other project's
182
+ * rules would go unchecked until it was the one being built.
183
+ */
184
+ export type ProjectRedirects<
185
+ Id extends string,
186
+ L extends string,
187
+ > = readonly RedirectRule<Id, L>[] & { readonly [checkedByProject]: true };
188
+
189
+ /**
190
+ * The key a site resolves rules under, for `siteRoutes()`.
191
+ *
192
+ * A symbol the package does not export, so the integration is the only caller:
193
+ * a site method anyone could reach would be a second way to turn rules into
194
+ * `_redirects`, and one that takes rules no project checked.
195
+ */
196
+ export const resolveRedirects: unique symbol = Symbol("atlas.resolveRedirects");
197
+
136
198
  /** One rule with its target resolved to a URL or path, ready to serve. */
137
199
  export interface ResolvedRedirect {
138
200
  readonly from: string;
@@ -49,7 +49,7 @@ 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. `site.redirects()` then claims `/news/page/1` with a 301 onto the
52
+ * away. A redirect then claims `/news/page/1` with a 301 onto the
53
53
  * bare slug, since a URL nothing publishes is still one a reader edits
54
54
  * their way to.
55
55
  *
package/src/site/api.ts CHANGED
@@ -9,7 +9,7 @@ import type { ThemeColor } from "../meta/index.ts";
9
9
  import type {
10
10
  RedirectRule,
11
11
  ResolvedRedirect,
12
- ValidateRedirectTargets,
12
+ resolveRedirects,
13
13
  } from "../redirects.ts";
14
14
  import type { RobotsGroup } from "../robots.ts";
15
15
  import type { Sitemap } from "../sitemap.ts";
@@ -261,7 +261,7 @@ export interface Site<
261
261
  * emitted as `undefined`, which is what a rest parameter expects.
262
262
  *
263
263
  * When every locale is prefixed nothing owns `/`, and no page is generated
264
- * for it: `redirects()` claims it with a 301 to the default locale's root
264
+ * for it: a redirect claims it with a 301 to the default locale's root
265
265
  * instead, which is a real redirect rather than a rendered stub. The target
266
266
  * is whichever route has an empty slug — lib has no notion of a "home"
267
267
  * page, so the caller does not have to name one.
@@ -302,13 +302,16 @@ export interface Site<
302
302
  ): GeneratedFile;
303
303
 
304
304
  /**
305
- * Old URLs that must keep working, resolved and rendered.
305
+ * Old URLs that must keep working, resolved — for `siteRoutes()` only.
306
306
  *
307
307
  * A rule's target names a *route*, not a path, so the URL is derived the way
308
308
  * every other link is: a redirect cannot outlive the page it points at, and
309
309
  * a retranslated slug moves it too. External targets are `https://` URLs and
310
310
  * pass through untouched.
311
311
  *
312
+ * Rules are typed by the `redirects` that `atlas.project()` returns, not
313
+ * here: see `ProjectRedirects`.
314
+ *
312
315
  * **What comes back is more than what went in.** lib adds the rules the
313
316
  * route table implies, for the URLs its own design leaves unpublished but
314
317
  * reachable: whichever site root the routing mode does not serve — `/` when
@@ -316,15 +319,11 @@ export interface Site<
316
319
  * pages under that unused `/en-US/`, as one `/en-US/* /:splat` wildcard
317
320
  * written last, and `/news/page/1` for a list whose page one is the bare
318
321
  * path. A rule you state for one of those paths replaces the inferred one,
319
- * so pass `[]` and you still get a file worth writing.
320
- *
321
- * Returns the rules as data. Rendering is a separate step —
322
- * `buildCloudflareRedirects` writes the `_redirects` that Cloudflare and
323
- * Netlify read, and a host with its own syntax takes these and writes its own.
322
+ * so a project with no rules of its own still gets a file worth writing.
324
323
  */
325
- redirects<const Rules extends readonly RedirectRule<RouteId, L>[]>(
326
- rules: Rules & ValidateRedirectTargets<Rules>
327
- ): readonly ResolvedRedirect[];
324
+ readonly [resolveRedirects]: (
325
+ rules: readonly RedirectRule<RouteId, L>[]
326
+ ) => readonly ResolvedRedirect[];
328
327
 
329
328
  /**
330
329
  * Everything the document head needs for one page: the `<html>` attributes,
@@ -22,16 +22,16 @@ import {
22
22
  buildNotFoundMeta,
23
23
  resolveShareImage,
24
24
  } from "../meta/index.ts";
25
- import type { ProjectInput } from "../project.ts";
25
+ import type { ProjectInput, ProjectRouteId } from "../project.ts";
26
26
  import {
27
27
  buildRedirects,
28
28
  type RedirectRule,
29
29
  type ResolvedRedirect,
30
+ resolveRedirects,
30
31
  statusFor,
31
32
  } from "../redirects.ts";
32
33
  import { buildRobots, type RobotsGroup } from "../robots.ts";
33
34
  import type {
34
- EnabledRouteIdFor,
35
35
  OrphanSectionPrefixOf,
36
36
  RouteRegistry,
37
37
  SectionPrefixOf,
@@ -47,7 +47,6 @@ import {
47
47
  slugFor,
48
48
  } from "../routes/resolve.ts";
49
49
  import { buildSitemap, type Sitemap } from "../sitemap.ts";
50
- import type { StringKeys } from "../types.ts";
51
50
  import {
52
51
  absoluteUrl,
53
52
  type HttpsUrl,
@@ -104,7 +103,7 @@ export function createSite<
104
103
  ): Site<
105
104
  Published,
106
105
  Catalog,
107
- EnabledRouteIdFor<Routes, P["overrideRoutes"]> & StringKeys<Routes>,
106
+ ProjectRouteId<Routes, P>,
108
107
  SectionPrefixOf<Routes, P["overrideRoutes"]>,
109
108
  OrphanSectionPrefixOf<Routes, P["overrideRoutes"]>
110
109
  > {
@@ -114,8 +113,7 @@ export function createSite<
114
113
  // `pathFor("home", "el-GR")` type-checked on a build with no Greek pages and
115
114
  // returned a URL that was never generated.
116
115
  type L = Published;
117
- type RouteId = EnabledRouteIdFor<Routes, P["overrideRoutes"]> &
118
- StringKeys<Routes>;
116
+ type RouteId = ProjectRouteId<Routes, P>;
119
117
 
120
118
  const locales = project.enabledLocales as readonly L[];
121
119
  const defaultLocale = (project.overrideRouting?.defaultLocale ??
@@ -284,7 +282,7 @@ export function createSite<
284
282
  /**
285
283
  * Every path this build serves.
286
284
  *
287
- * Read by `redirects()` twice over: to reject a stated rule that shadows a
285
+ * Read by `resolve()` twice over: to reject a stated rule that shadows a
288
286
  * real page, and to keep an inferred one from doing the same.
289
287
  */
290
288
  const builtPaths: ReadonlySet<string> = new Set(
@@ -600,7 +598,7 @@ export function createSite<
600
598
  ];
601
599
  }
602
600
 
603
- function redirects(
601
+ function resolve(
604
602
  rules: readonly RedirectRule<RouteId, L>[]
605
603
  ): readonly ResolvedRedirect[] {
606
604
  const stated: readonly ResolvedRedirect[] = rules.map((rule) => ({
@@ -612,6 +610,20 @@ export function createSite<
612
610
  to: ((): string => {
613
611
  if (typeof rule.to === "string") return rule.to;
614
612
  if ("file" in rule.to) return rule.to.file;
613
+ if (rule.to.splat === true) {
614
+ // Checked here too, for a rule the types could not see:
615
+ // without a `*`, the host has nothing to put in `:splat`.
616
+ if (!rule.from.endsWith("/*")) {
617
+ throw new Error(
618
+ `Redirect "${rule.from}" keeps the rest of the path (\`splat: true\`) but has no "/*" to take it from.`
619
+ );
620
+ }
621
+ const path = pathFor(
622
+ rule.to.route,
623
+ rule.to.locale ?? defaultLocale
624
+ );
625
+ return path === "/" ? "/:splat" : `${path}/:splat`;
626
+ }
615
627
  return pathFor(rule.to.route, rule.to.locale ?? defaultLocale, {
616
628
  page: rule.to.page,
617
629
  });
@@ -833,7 +845,7 @@ export function createSite<
833
845
  robots,
834
846
  llms,
835
847
  llmsUrl,
836
- redirects,
848
+ [resolveRedirects]: resolve,
837
849
  metaFor,
838
850
  notFoundMetaFor,
839
851
  };
@@ -11,6 +11,8 @@ import {
11
11
  type ValidateBase,
12
12
  } from "../i18n/define.ts";
13
13
  import {
14
+ bindRedirects,
15
+ type DefinedProject,
14
16
  defineProject,
15
17
  type ProjectChecks,
16
18
  type ProjectInput,
@@ -40,7 +42,7 @@ import { createSite } from "./create.ts";
40
42
  * const rooms = atlas.family("rooms", ROOMS, { enabled: true });
41
43
  * export const defaultRoutes = atlas.routes({ ...rooms, home: {…} });
42
44
  * export const defaultMessages = atlas.messages({…});
43
- * export default atlas.project(defaultMessages, defaultRoutes, {…});
45
+ * export const { project, redirects } = atlas.project(defaultMessages, defaultRoutes, {…});
44
46
  * export const site = atlas.site(defaultMessages, defaultRoutes, project);
45
47
  * ```
46
48
  *
@@ -94,13 +96,17 @@ export function defineSite<
94
96
  messages: Catalog,
95
97
  routes: Routes,
96
98
  project: T & ProjectOverrideChecks<L, Catalog, Routes, T>
97
- ): T {
98
- return defineProject(
99
+ ): DefinedProject<Routes, T> {
100
+ const defined = defineProject(
99
101
  config,
100
102
  messages,
101
103
  routes,
102
104
  project as never
103
105
  ) as T;
106
+ return {
107
+ project: defined,
108
+ redirects: bindRedirects(routes, defined),
109
+ };
104
110
  },
105
111
 
106
112
  /** The API every page reads, wired for one project. */