@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 +19 -6
- package/docs/NOT-BUILT.md +1 -1
- package/docs/toolchain.md +1 -1
- package/package.json +1 -1
- package/src/astro/site-routes.ts +28 -9
- package/src/index.ts +7 -2
- package/src/project.ts +65 -0
- package/src/redirects.ts +73 -11
- package/src/routes/define.ts +1 -1
- package/src/site/api.ts +10 -11
- package/src/site/create.ts +21 -9
- package/src/site/define.ts +9 -3
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
|
|
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
|
|
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()
|
|
111
|
-
|
|
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 `
|
|
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:
|
|
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 `
|
|
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
package/src/astro/site-routes.ts
CHANGED
|
@@ -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
|
-
/**
|
|
65
|
-
|
|
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
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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://`,
|
|
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
|
|
79
|
-
* own line rather than as the whole array failing to match. The check
|
|
80
|
-
* `ValidHttpsUrl`, shared with the site origin — one idea of an
|
|
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
|
-
| {
|
|
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;
|
package/src/routes/define.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
326
|
-
rules:
|
|
327
|
-
)
|
|
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,
|
package/src/site/create.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 `
|
|
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
|
|
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
|
-
|
|
848
|
+
[resolveRedirects]: resolve,
|
|
837
849
|
metaFor,
|
|
838
850
|
notFoundMetaFor,
|
|
839
851
|
};
|
package/src/site/define.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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. */
|