@escape-game-over/atlas 0.1.68 → 0.1.70

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,20 @@ 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
+ ])
123
+ ```
112
124
 
113
125
  `t()` is bound to a locale and knows each message's `{placeholders}` from its
114
126
  text:
@@ -177,7 +189,7 @@ than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
177
189
  | `sitemap.xml` | `siteRoutes()`, from the routes | nothing |
178
190
  | `robots.txt` | `siteRoutes()` | nothing |
179
191
  | `llms.txt` | `siteRoutes()` | words, via `site.llms({...})` |
180
- | `_redirects` | `siteRoutes()` | rules, via `site.redirects()` |
192
+ | `_redirects` | `siteRoutes()` | rules, via the project's `redirects()` |
181
193
  | `BreadcrumbList` JSON-LD | `breadcrumbFor()`, from the slug | a name per step, in a layout |
182
194
  | `LocalBusiness` JSON-LD | `localBusiness()` | address, phone, hours |
183
195
  | `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.68",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -34,7 +34,7 @@
34
34
  "README.md"
35
35
  ],
36
36
  "engines": {
37
- "node": ">=22.12.0"
37
+ "node": ">=24.0.0"
38
38
  },
39
39
  "scripts": {
40
40
  "test": "npm run test:types && npm run test:unit && npm run test:examples",
@@ -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/config.ts CHANGED
@@ -2,20 +2,21 @@ import type { Fonts } from "./fonts.ts";
2
2
  import type { LlmsConfig } from "./llms.ts";
3
3
  import type { RobotsConfig } from "./robots.ts";
4
4
  import type { SitemapConfig } from "./sitemap.ts";
5
- import type { StringKeys } from "./types.ts";
5
+ import type { NoExcessKeys, StringKeys } from "./types.ts";
6
6
 
7
7
  export interface LocaleMeta {
8
8
  /** Name of the language, written in that language. */
9
9
  readonly label: string;
10
10
  // No language tag here: the locale's own key is the tag. One identifier,
11
11
  // used for the URL segment, `lang`, `hreflang` and `og:locale` alike.
12
- readonly dir: "ltr" | "rtl";
12
+ // No `dir` either: writing direction follows from the tag too.
13
13
  }
14
14
 
15
15
  /** `LocaleMeta` after defaults are filled in. */
16
16
  export interface ResolvedLocaleMeta {
17
17
  readonly label: string;
18
18
  readonly htmlLang: string;
19
+ /** Derived: the writing direction of the tag's script, from `Intl`. */
19
20
  readonly dir: "ltr" | "rtl";
20
21
  /** Derived: `htmlLang` with `-` swapped for `_`, which is the OG form. */
21
22
  readonly ogLocale: string;
@@ -55,7 +56,7 @@ interface MalformedLanguageTag<T extends string> {
55
56
  type ValidateLocales<T> = {
56
57
  [K in StringKeys<T>]: [LanguageTag<K>] extends [never]
57
58
  ? MalformedLanguageTag<K>
58
- : LocaleMeta;
59
+ : LocaleMeta & NoExcessKeys<T[K], keyof LocaleMeta>;
59
60
  };
60
61
 
61
62
  export interface RoutingConfig<L extends string> {
@@ -156,10 +157,9 @@ export function defineSiteConfig<
156
157
  /**
157
158
  * What a project may restate about a locale.
158
159
  *
159
- * `dir` is deliberately not here: writing direction is a fact about the script a
160
- * language is written in, not a choice a deployment makes. Greek is
161
- * left-to-right for everybody, and a project that could say otherwise would only
162
- * ever be wrong. The label is the one part that is a matter of taste.
160
+ * Only the label: writing direction is a fact about the script a language is
161
+ * written in, not a choice a deployment makes, so it is derived from the tag.
162
+ * The label is the one part that is a matter of taste.
163
163
  */
164
164
  export type LocaleMetaOverride = Pick<LocaleMeta, "label">;
165
165
 
@@ -184,6 +184,8 @@ export interface LocaleNotEnabled<L extends string> {
184
184
  *
185
185
  * The locale key is the language tag, and `ogLocale` is that tag with `-`
186
186
  * swapped for `_` — Open Graph writes `en_US` where BCP 47 writes `en-US`.
187
+ * `dir` comes from the tag's script, so `ar-EG` and `az-Arab-IR` are
188
+ * right-to-left and `az-AZ` is not.
187
189
  */
188
190
  export function mergeLocaleMeta<L extends string>(
189
191
  base: Readonly<Record<L, LocaleMeta>>,
@@ -199,10 +201,20 @@ export function mergeLocaleMeta<L extends string>(
199
201
  const meta = { ...defaults, ...overrides?.[locale] };
200
202
  merged[locale] = {
201
203
  label: meta.label,
202
- dir: meta.dir,
204
+ dir: writingDirection(locale),
203
205
  htmlLang: locale,
204
206
  ogLocale: locale.replace(/-/g, "_"),
205
207
  };
206
208
  }
207
209
  return merged as Readonly<Record<L, ResolvedLocaleMeta>>;
208
210
  }
211
+
212
+ function writingDirection(tag: string): "ltr" | "rtl" {
213
+ const direction = new Intl.Locale(tag).getTextInfo().direction;
214
+ if (direction === undefined) {
215
+ throw new Error(
216
+ `This runtime cannot tell the writing direction of "${tag}".`
217
+ );
218
+ }
219
+ return direction;
220
+ }
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";
@@ -133,6 +132,31 @@ export interface RedirectRule<Id extends string, L extends string> {
133
132
  readonly kind: RedirectKind;
134
133
  }
135
134
 
135
+ declare const checkedByProject: unique symbol;
136
+
137
+ /**
138
+ * Rules a project's own `redirects` checked — the only form `siteRoutes()`
139
+ * takes.
140
+ *
141
+ * Branded so that a list cannot reach the integration any other way. Written
142
+ * inline at `siteRoutes()`, rules could only be checked against the site, and
143
+ * the site is built from whichever project is linked: every other project's
144
+ * rules would go unchecked until it was the one being built.
145
+ */
146
+ export type ProjectRedirects<
147
+ Id extends string,
148
+ L extends string,
149
+ > = readonly RedirectRule<Id, L>[] & { readonly [checkedByProject]: true };
150
+
151
+ /**
152
+ * The key a site resolves rules under, for `siteRoutes()`.
153
+ *
154
+ * A symbol the package does not export, so the integration is the only caller:
155
+ * a site method anyone could reach would be a second way to turn rules into
156
+ * `_redirects`, and one that takes rules no project checked.
157
+ */
158
+ export const resolveRedirects: unique symbol = Symbol("atlas.resolveRedirects");
159
+
136
160
  /** One rule with its target resolved to a URL or path, ready to serve. */
137
161
  export interface ResolvedRedirect {
138
162
  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) => ({
@@ -833,7 +831,7 @@ export function createSite<
833
831
  robots,
834
832
  llms,
835
833
  llmsUrl,
836
- redirects,
834
+ [resolveRedirects]: resolve,
837
835
  metaFor,
838
836
  notFoundMetaFor,
839
837
  };
@@ -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. */