@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 +18 -6
- package/docs/NOT-BUILT.md +1 -1
- package/docs/toolchain.md +1 -1
- package/package.json +2 -2
- package/src/astro/site-routes.ts +28 -9
- package/src/config.ts +20 -8
- package/src/index.ts +7 -2
- package/src/project.ts +65 -0
- package/src/redirects.ts +28 -4
- package/src/routes/define.ts +1 -1
- package/src/site/api.ts +10 -11
- package/src/site/create.ts +7 -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,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()
|
|
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
|
+
])
|
|
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 `
|
|
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:
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
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": ">=
|
|
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",
|
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/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
|
-
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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:
|
|
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
|
|
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";
|
|
@@ -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;
|
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) => ({
|
|
@@ -833,7 +831,7 @@ export function createSite<
|
|
|
833
831
|
robots,
|
|
834
832
|
llms,
|
|
835
833
|
llmsUrl,
|
|
836
|
-
|
|
834
|
+
[resolveRedirects]: resolve,
|
|
837
835
|
metaFor,
|
|
838
836
|
notFoundMetaFor,
|
|
839
837
|
};
|
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. */
|