@bygghammar/cms-platform 0.91.0 → 0.92.0
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/CHANGELOG.md +151 -0
- package/README.md +28 -4
- package/docs/NEW-SITE.md +29 -14
- package/docs/PAGES.md +79 -21
- package/docs/PUBLICATION-REVIEW.md +298 -0
- package/migrations/20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql +3298 -0
- package/package.json +8 -2
- package/scripts/cms-review.mjs +832 -0
- package/scripts/review-fingerprint.sql +43 -0
- package/scripts/review-scan.sql +56 -0
- package/src/backup/datasets.ts +203 -2
- package/src/backup/restore-core.ts +382 -11
- package/src/platform/contract.ts +82 -60
- package/src/platform/digest.ts +203 -0
- package/src/platform/migration-manifest.ts +310 -0
- package/src/platform/publication-review.ts +413 -0
- package/src/server/backup-core.ts +291 -18
- package/src/server/page-seo-core.ts +56 -10
- package/src/server/pages-core.ts +79 -46
- package/src/server/redirects-core.ts +51 -9
- package/src/server/schema-drift.ts +21 -65
- package/src/site/route-index.ts +641 -0
- package/src/site/routes.ts +2 -8
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes two URLs the same URL.
|
|
3
|
+
*
|
|
4
|
+
* A route collision is not a comparison of slugs. Two pages collide when the
|
|
5
|
+
* address a browser actually sends is the same address, and browsers agree
|
|
6
|
+
* about far more than string equality: `/OM-OSS` and `/om-oss` on a site that
|
|
7
|
+
* folds case, `/om//oss` and `/om/oss`, `/om-oss/` and `/om-oss`,
|
|
8
|
+
* `/caf%C3%A9` and `/café`, `café` written composed and `café` written with a
|
|
9
|
+
* combining accent, `https://voyara.se:443` and `https://voyara.se`. Every one
|
|
10
|
+
* of those pairs is one served URL, and a CMS that lets both be claimed has
|
|
11
|
+
* published a site that serves two different pages at one address depending on
|
|
12
|
+
* how the link was typed.
|
|
13
|
+
*
|
|
14
|
+
* So this module answers one question — what is the exact normalized served
|
|
15
|
+
* URL — and answers it in one place. It does not answer whether a claim
|
|
16
|
+
* succeeds. `cms_route_claim_apply` does, and it does so with a primary key on
|
|
17
|
+
* `(origin, path)` rather than a lookup, because a lookup is a read followed by
|
|
18
|
+
* a write and two concurrent editors both read the path as free. What this
|
|
19
|
+
* module decides is what the key is being asked *about*.
|
|
20
|
+
*
|
|
21
|
+
* The identity, in the order it is applied:
|
|
22
|
+
*
|
|
23
|
+
* 1. Scheme and host lowercased.
|
|
24
|
+
* 2. Port preserved. Only `:80` on http and `:443` on https are dropped,
|
|
25
|
+
* because `https://x` and `https://x:8443` are different sites.
|
|
26
|
+
* 3. Unicode NFC normalization of host and path.
|
|
27
|
+
* 4. Percent-decode, then re-encode canonically: unreserved characters end up
|
|
28
|
+
* literal, everything else escaped with uppercase hex.
|
|
29
|
+
* 5. Duplicate slashes collapse; `.` and `..` segments are removed.
|
|
30
|
+
* 6. The registry's trailing-slash policy applied exactly once.
|
|
31
|
+
* 7. The registry's case policy; a leading locale prefix is always lowercased.
|
|
32
|
+
*
|
|
33
|
+
* Two notes on step 4, because it is the step with a choice in it. Decoding
|
|
34
|
+
* everything and re-encoding merges `/a%3Db` with `/a=b`, which strict RFC 3986
|
|
35
|
+
* equivalence would keep apart. That direction is deliberate: over-merging
|
|
36
|
+
* refuses two pages that differ only by how a character was written, while
|
|
37
|
+
* under-merging lets both be claimed and publishes the ambiguity. Refusing a
|
|
38
|
+
* pair a pedant would allow costs an editor one rename; allowing a pair a
|
|
39
|
+
* browser cannot tell apart costs the site a URL that serves two pages. And a
|
|
40
|
+
* segment whose escapes are malformed is not a character sequence at all, so
|
|
41
|
+
* there is nothing to normalize — it keeps the bytes it was given, with the hex
|
|
42
|
+
* uppercased, which is still a stable identity.
|
|
43
|
+
*
|
|
44
|
+
* Dot-segment removal is not in the list this design was written from. It is
|
|
45
|
+
* here because leaving it out is an under-merge: `/a/../b` and `/b` are the
|
|
46
|
+
* same address to every client that resolves a reference, and two claims for
|
|
47
|
+
* one served URL is the failure this whole mechanism exists to prevent. It runs
|
|
48
|
+
* twice, before and after decoding, because the parser browsers actually use
|
|
49
|
+
* treats `%2E%2E` as a double-dot segment as well: `/om/%2E%2E/oss` is a
|
|
50
|
+
* request for `/oss`, and an identity that stored the literal spelling would
|
|
51
|
+
* hold an address no request can arrive at.
|
|
52
|
+
*
|
|
53
|
+
* Pure module — no database, no filesystem, no network, no clock.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { domainOrigin } from "./host";
|
|
57
|
+
|
|
58
|
+
/** Whether a served path keeps a trailing slash. Fixed per release. */
|
|
59
|
+
export type TrailingSlashPolicy = "never" | "always";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The release's own account of its routes: the `snapshot` column of
|
|
63
|
+
* `cms_route_registry`, written once per deploy by `cms_route_registry_set`.
|
|
64
|
+
*
|
|
65
|
+
* The database cannot infer any of this. Which paths belong to the API and the
|
|
66
|
+
* backoffice, which locales exist, which domain serves which language, whether
|
|
67
|
+
* a trailing slash is kept, whether case is folded — each is decided by the
|
|
68
|
+
* application at build time, and each changes what two URLs have to agree about
|
|
69
|
+
* before they count as the same URL. The snapshot is how those decisions reach
|
|
70
|
+
* the authority, and `release_sha` is how a claim normalized under one set of
|
|
71
|
+
* them is told apart from a claim normalized under the set before.
|
|
72
|
+
*/
|
|
73
|
+
export type RouteRegistry = {
|
|
74
|
+
/** Every origin this release serves, canonical and sorted. */
|
|
75
|
+
origins: string[];
|
|
76
|
+
/** Locale tags the release publishes, e.g. `["sv", "en"]`. */
|
|
77
|
+
locales: string[];
|
|
78
|
+
/** The locale that carries no URL prefix. */
|
|
79
|
+
defaultLocale: string;
|
|
80
|
+
/** Locale → the origin that serves it. Empty means one origin serves all. */
|
|
81
|
+
localeDomains: Record<string, string>;
|
|
82
|
+
/** Paths the release keeps for itself, normalized like any other path. */
|
|
83
|
+
reservedPaths: string[];
|
|
84
|
+
trailingSlash: TrailingSlashPolicy;
|
|
85
|
+
caseSensitive: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* What the release believed each page's slug was, per locale.
|
|
88
|
+
*
|
|
89
|
+
* Carried so an operator reading the registry can see which build wrote it.
|
|
90
|
+
* Nothing consults it to decide anything; the claims are the decision.
|
|
91
|
+
*/
|
|
92
|
+
pages?: Record<string, Record<string, string>>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The part of the registry a path is normalized against. */
|
|
96
|
+
export type RoutePathPolicy = {
|
|
97
|
+
trailingSlash?: TrailingSlashPolicy;
|
|
98
|
+
caseSensitive?: boolean;
|
|
99
|
+
locales?: readonly string[];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** What a claim is: the page's own address, an old one, or the release's. */
|
|
103
|
+
export type RouteClaimKind = "page" | "redirect" | "reserved";
|
|
104
|
+
|
|
105
|
+
/** One row of `cms_route_claims`. */
|
|
106
|
+
export type RouteClaim = {
|
|
107
|
+
origin: string;
|
|
108
|
+
path: string;
|
|
109
|
+
kind: RouteClaimKind;
|
|
110
|
+
/** The locale served here, or `null` for a reserved path. */
|
|
111
|
+
locale: string | null;
|
|
112
|
+
/** The page holding it, or `null`/absent when the release holds it. */
|
|
113
|
+
pageId?: string | null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Sub-delimiters RFC 3986 allows to stand unencoded inside a path segment. */
|
|
117
|
+
const SEGMENT_LITERALS: [string, string][] = [
|
|
118
|
+
["%24", "$"],
|
|
119
|
+
["%26", "&"],
|
|
120
|
+
["%2B", "+"],
|
|
121
|
+
["%2C", ","],
|
|
122
|
+
["%3A", ":"],
|
|
123
|
+
["%3B", ";"],
|
|
124
|
+
["%3D", "="],
|
|
125
|
+
["%40", "@"],
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Uppercase the hex of every percent-escape and touch nothing else. */
|
|
129
|
+
function upperEscapes(value: string): string {
|
|
130
|
+
return value.replace(/%[0-9a-f]{2}/gi, (escape) => escape.toUpperCase());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The decoded segment, or `null` when its escapes are not decodable. */
|
|
134
|
+
function decodeSegment(segment: string): string | null {
|
|
135
|
+
try {
|
|
136
|
+
return decodeURIComponent(segment);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A segment encoded the one canonical way.
|
|
144
|
+
*
|
|
145
|
+
* `encodeURIComponent` escapes `/`, which is what keeps `%2F` from merging two
|
|
146
|
+
* segments into one, and emits uppercase hex, which is step 4. It also escapes
|
|
147
|
+
* eight sub-delimiters that are legal unencoded in a path, so those are put
|
|
148
|
+
* back — a server sees `/a=b`, and an identity that wrote `/a%3Db` would be
|
|
149
|
+
* comparing something the site never serves.
|
|
150
|
+
*/
|
|
151
|
+
function encodeSegment(value: string): string {
|
|
152
|
+
let encoded = encodeURIComponent(value);
|
|
153
|
+
for (const [escape, literal] of SEGMENT_LITERALS) {
|
|
154
|
+
encoded = encoded.split(escape).join(literal);
|
|
155
|
+
}
|
|
156
|
+
return encoded;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One segment reduced to its identity.
|
|
161
|
+
*
|
|
162
|
+
* The NFC pass runs after the case fold rather than before it, because folding
|
|
163
|
+
* case can itself change which code points are present, and normalizing before
|
|
164
|
+
* the last transform that can move them normalizes the wrong string.
|
|
165
|
+
*/
|
|
166
|
+
function canonicalSegment(segment: string, fold: boolean): string {
|
|
167
|
+
const decoded = decodeSegment(segment);
|
|
168
|
+
// Nothing to normalize: escapes that do not decode are not a character
|
|
169
|
+
// sequence. The case policy still applies to the letters that are there, or
|
|
170
|
+
// one page would have two identities on a site that folds case.
|
|
171
|
+
if (decoded === null) return upperEscapes(fold ? segment.toLowerCase() : segment);
|
|
172
|
+
const cased = fold ? decoded.toLowerCase() : decoded;
|
|
173
|
+
return encodeSegment(cased.normalize("NFC"));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `.` and `..` resolved away, never above the root.
|
|
178
|
+
*
|
|
179
|
+
* RFC 3986 removes these during reference resolution, so a client removes them
|
|
180
|
+
* before the request is sent and the server never sees them. A claim that kept
|
|
181
|
+
* them would be a claim on an address nobody can ask for.
|
|
182
|
+
*/
|
|
183
|
+
function removeDotSegments(segments: string[]): string[] {
|
|
184
|
+
const kept: string[] = [];
|
|
185
|
+
for (const segment of segments) {
|
|
186
|
+
if (segment === ".") continue;
|
|
187
|
+
if (segment === "..") {
|
|
188
|
+
kept.pop();
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
kept.push(segment);
|
|
192
|
+
}
|
|
193
|
+
return kept;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether a raw segment names one of the release's locales. */
|
|
197
|
+
function namesLocale(segment: string, locales: Set<string>): boolean {
|
|
198
|
+
const decoded = decodeSegment(segment) ?? segment;
|
|
199
|
+
return locales.has(decoded.toLowerCase());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `scheme://host[:port]`, or "" when the value cannot be an origin.
|
|
204
|
+
*
|
|
205
|
+
* The WHATWG parser is the whole of steps 1 to 3 for the host: it lowercases
|
|
206
|
+
* the scheme and the host, applies IDNA — which carries the Unicode
|
|
207
|
+
* normalization with it — and drops the port only when it is the scheme's
|
|
208
|
+
* default. Nothing here re-implements any of that.
|
|
209
|
+
*
|
|
210
|
+
* What is refused rather than repaired: a value with no scheme, because
|
|
211
|
+
* `localhost:8443` parses as the scheme `localhost` and the platform would be
|
|
212
|
+
* guessing which of two readings was meant; and a value carrying a path, query,
|
|
213
|
+
* fragment or credentials, because dropping them would answer about
|
|
214
|
+
* `https://example.com` when the caller asked about `https://example.com/sv`,
|
|
215
|
+
* and an origin that quietly means something else than it says is the failure
|
|
216
|
+
* this module exists to prevent.
|
|
217
|
+
*/
|
|
218
|
+
export function normalizeOrigin(value: string | null | undefined): string {
|
|
219
|
+
const raw = (value ?? "").trim();
|
|
220
|
+
if (!/^https?:\/\//i.test(raw)) return "";
|
|
221
|
+
let url: URL;
|
|
222
|
+
try {
|
|
223
|
+
url = new URL(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return "";
|
|
226
|
+
}
|
|
227
|
+
if (url.username || url.password) return "";
|
|
228
|
+
if (url.search || url.hash) return "";
|
|
229
|
+
if (url.pathname !== "" && url.pathname !== "/") return "";
|
|
230
|
+
return url.origin;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A served path reduced to its identity: steps 3 to 7.
|
|
235
|
+
*
|
|
236
|
+
* A query string and a fragment are removed first. Neither reaches the server
|
|
237
|
+
* as part of the path a route is matched on — a fragment never leaves the
|
|
238
|
+
* client at all — so a claim that kept them would be a claim on an address
|
|
239
|
+
* nobody can request.
|
|
240
|
+
*
|
|
241
|
+
* The empty path is the root. That is not a fallback for unusable input: a page
|
|
242
|
+
* with no slug is the site's front page, and `/` is the address it serves.
|
|
243
|
+
*/
|
|
244
|
+
export function normalizeRoutePath(
|
|
245
|
+
input: string | null | undefined,
|
|
246
|
+
policy: RoutePathPolicy = {},
|
|
247
|
+
): string {
|
|
248
|
+
const raw = (input ?? "").trim().split("#")[0].split("?")[0];
|
|
249
|
+
const locales = new Set((policy.locales ?? []).map((locale) => locale.toLowerCase()));
|
|
250
|
+
const foldEverything = policy.caseSensitive !== true;
|
|
251
|
+
|
|
252
|
+
// An empty segment is a duplicate slash, a leading slash or a trailing one.
|
|
253
|
+
// All three are the same non-event, and dropping them is step 5.
|
|
254
|
+
const kept = removeDotSegments(raw.split("/").filter((segment) => segment !== ""));
|
|
255
|
+
|
|
256
|
+
// Dot segments are removed a second time, on the decoded forms, because
|
|
257
|
+
// `%2E%2E` is a double-dot segment to a browser as surely as `..` is.
|
|
258
|
+
const canonical = removeDotSegments(
|
|
259
|
+
kept.map((segment, index) =>
|
|
260
|
+
canonicalSegment(segment, foldEverything || (index === 0 && namesLocale(segment, locales))),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const path = canonical.length === 0 ? "/" : `/${canonical.join("/")}`;
|
|
265
|
+
return policy.trailingSlash === "always" && path !== "/" ? `${path}/` : path;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The path a site serves for one slug in one locale, before normalization.
|
|
270
|
+
*
|
|
271
|
+
* The default locale carries no prefix and every other locale does — on its own
|
|
272
|
+
* domain as much as on a shared one, because the prefix is what makes a Swedish
|
|
273
|
+
* and an English page with the same slug two addresses instead of one
|
|
274
|
+
* collision. An empty slug is the front page.
|
|
275
|
+
*
|
|
276
|
+
* Everything that needs this answer calls this function: the site's own route
|
|
277
|
+
* builder, the conflict pre-check the backoffice runs while an editor types,
|
|
278
|
+
* and the resolver that turns an incoming request into a page. A second
|
|
279
|
+
* implementation of it is how a page comes to claim an address it does not
|
|
280
|
+
* serve.
|
|
281
|
+
*/
|
|
282
|
+
export function servedPath(
|
|
283
|
+
slug: string | null | undefined,
|
|
284
|
+
locale: string,
|
|
285
|
+
defaultLocale: string,
|
|
286
|
+
): string {
|
|
287
|
+
const prefix = locale === defaultLocale ? "" : `/${locale}`;
|
|
288
|
+
const trimmed = (slug ?? "").trim().replace(/^\/+|\/+$/g, "");
|
|
289
|
+
const full = `${prefix}${trimmed ? `/${trimmed}` : ""}`;
|
|
290
|
+
return full === "" ? "/" : full;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The address a claim is stored and compared at. */
|
|
294
|
+
export function routeKey(origin: string, path: string): string {
|
|
295
|
+
return `${origin}${path}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A whole URL split into the two halves the authority stores, or `null`.
|
|
300
|
+
*
|
|
301
|
+
* For the cases where a person hands the system an address rather than a route:
|
|
302
|
+
* a redirect source typed into the backoffice, an operator asking who holds a
|
|
303
|
+
* URL that turned up in a log.
|
|
304
|
+
*/
|
|
305
|
+
export function normalizeRouteUrl(
|
|
306
|
+
value: string | null | undefined,
|
|
307
|
+
policy: RoutePathPolicy = {},
|
|
308
|
+
): { origin: string; path: string } | null {
|
|
309
|
+
const raw = (value ?? "").trim();
|
|
310
|
+
if (!/^https?:\/\//i.test(raw)) return null;
|
|
311
|
+
let url: URL;
|
|
312
|
+
try {
|
|
313
|
+
url = new URL(raw);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const origin = normalizeOrigin(url.origin);
|
|
318
|
+
if (!origin) return null;
|
|
319
|
+
return { origin, path: normalizeRoutePath(url.pathname, policy) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Rows of `locale_domains`, in the shape the table stores them. */
|
|
323
|
+
export type LocaleDomainRow = { locale: string; domain: string };
|
|
324
|
+
|
|
325
|
+
export type RouteRegistryInput = {
|
|
326
|
+
/** The origin this deployment advertises when no locale mapping applies. */
|
|
327
|
+
publicOrigin: string;
|
|
328
|
+
locales: string[];
|
|
329
|
+
defaultLocale: string;
|
|
330
|
+
localeDomains?: LocaleDomainRow[];
|
|
331
|
+
reservedPaths?: string[];
|
|
332
|
+
trailingSlash?: TrailingSlashPolicy;
|
|
333
|
+
caseSensitive?: boolean;
|
|
334
|
+
pages?: Record<string, Record<string, string>>;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export type RouteRegistryResult =
|
|
338
|
+
| { ok: true; registry: RouteRegistry }
|
|
339
|
+
| { ok: false; problem: string };
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The snapshot a deploy step sends to `cms_route_registry_set`.
|
|
343
|
+
*
|
|
344
|
+
* An unusable locale mapping stops the deploy here, and says which row. This is
|
|
345
|
+
* stricter than `originForLocale` in `routes.ts`, which skips a bad row and
|
|
346
|
+
* keeps scanning so that one broken mapping cannot hide a good one for the same
|
|
347
|
+
* language — and the difference is deliberate. That function is serving a site
|
|
348
|
+
* that is already running, where the only thing worse than a bad mapping is a
|
|
349
|
+
* blank page. This one runs at the moment somebody can still fix the row, and
|
|
350
|
+
* the alternative to refusing is a release that claims one set of URLs and
|
|
351
|
+
* serves another, with a canonical tag pointing at a host nobody deployed.
|
|
352
|
+
* There is no fallback to single-origin mode.
|
|
353
|
+
*
|
|
354
|
+
* The reserved paths are normalized with the same function that normalizes a
|
|
355
|
+
* page's path, which is the only reason reserving `/API` refuses a page at
|
|
356
|
+
* `/api`. A reserved list held to a different standard than the paths it is
|
|
357
|
+
* compared against reserves nothing.
|
|
358
|
+
*/
|
|
359
|
+
export function buildRouteRegistry(input: RouteRegistryInput): RouteRegistryResult {
|
|
360
|
+
const origin = normalizeOrigin(input.publicOrigin);
|
|
361
|
+
if (!origin) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
problem:
|
|
365
|
+
`The deployment's public origin is ${describe(input.publicOrigin)}, which is not an ` +
|
|
366
|
+
`origin. Every claim is stored against one, so there is nothing to normalize against.`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const locales = input.locales.map((locale) => locale.trim()).filter(Boolean);
|
|
371
|
+
if (locales.length === 0) {
|
|
372
|
+
return { ok: false, problem: "The route registry has to name the locales the release serves." };
|
|
373
|
+
}
|
|
374
|
+
const defaultLocale = input.defaultLocale.trim();
|
|
375
|
+
if (!locales.includes(defaultLocale)) {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
problem:
|
|
379
|
+
`The default locale ${describe(input.defaultLocale)} is not one of the locales this ` +
|
|
380
|
+
`release serves (${locales.join(", ")}). The locale that carries no URL prefix has to ` +
|
|
381
|
+
`be one of them, or every unprefixed path belongs to a language nobody publishes.`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const localeDomains: Record<string, string> = {};
|
|
386
|
+
for (const row of input.localeDomains ?? []) {
|
|
387
|
+
const locale = row.locale.trim();
|
|
388
|
+
if (!locales.includes(locale)) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
problem:
|
|
392
|
+
`The domain ${describe(row.domain)} is mapped to the locale ${describe(row.locale)}, ` +
|
|
393
|
+
`which this release does not serve. A mapping for a language that is not published ` +
|
|
394
|
+
`is a mapping nobody has checked.`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// `locale_domains` stores a hostname without a port, so this is always a
|
|
398
|
+
// default-port origin. A port survives normalization everywhere it can
|
|
399
|
+
// appear — a configured public origin, an explicit claim — it just cannot
|
|
400
|
+
// appear here.
|
|
401
|
+
const mapped = domainOrigin(row.domain);
|
|
402
|
+
if (!mapped) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
problem:
|
|
406
|
+
`The locale ${locale} is mapped to ${describe(row.domain)}, which is not a hostname ` +
|
|
407
|
+
`this release can serve from. Fix or remove the mapping and deploy again; a locale ` +
|
|
408
|
+
`whose domain cannot be read is refused rather than served from the default origin.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (localeDomains[locale] && localeDomains[locale] !== mapped) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
problem:
|
|
415
|
+
`The locale ${locale} is mapped to both ${localeDomains[locale]} and ${mapped}. One ` +
|
|
416
|
+
`language cannot be canonical on two hosts; pick the one the site advertises.`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
localeDomains[locale] = mapped;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const trailingSlash: TrailingSlashPolicy = input.trailingSlash ?? "never";
|
|
423
|
+
const caseSensitive = input.caseSensitive === true;
|
|
424
|
+
const policy: RoutePathPolicy = { trailingSlash, caseSensitive, locales };
|
|
425
|
+
|
|
426
|
+
const reserved = new Set<string>();
|
|
427
|
+
for (const path of input.reservedPaths ?? []) {
|
|
428
|
+
const normalized = normalizeRoutePath(path, policy);
|
|
429
|
+
reserved.add(normalized);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const origins = new Set<string>([origin, ...Object.values(localeDomains)]);
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
registry: {
|
|
437
|
+
origins: [...origins].sort(),
|
|
438
|
+
locales,
|
|
439
|
+
defaultLocale,
|
|
440
|
+
localeDomains,
|
|
441
|
+
reservedPaths: [...reserved].sort(),
|
|
442
|
+
trailingSlash,
|
|
443
|
+
caseSensitive,
|
|
444
|
+
...(input.pages ? { pages: input.pages } : {}),
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** One address a page serves, as the site's own route builder produced it. */
|
|
450
|
+
export type PageRoute = {
|
|
451
|
+
locale: string;
|
|
452
|
+
/** The path the site serves, e.g. `/en/about`. Normalized here, not before. */
|
|
453
|
+
path: string;
|
|
454
|
+
/** `page` for an address it serves, `redirect` for one it used to. */
|
|
455
|
+
kind?: "page" | "redirect";
|
|
456
|
+
/** An origin other than the one the locale maps to. Rare; kept explicit. */
|
|
457
|
+
origin?: string;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
export type PageClaimsResult =
|
|
461
|
+
| {
|
|
462
|
+
ok: true;
|
|
463
|
+
claims: RouteClaim[];
|
|
464
|
+
/** Locales left unclaimed, with the reason, so a deploy step can log it. */
|
|
465
|
+
skipped: { locale: string; reason: string }[];
|
|
466
|
+
}
|
|
467
|
+
| { ok: false; problem: string };
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* One page's complete claim set, ready for `cms_route_claim_apply`.
|
|
471
|
+
*
|
|
472
|
+
* The paths come in already built, by `createSiteRoutes` in `routes.ts`, and
|
|
473
|
+
* are not rebuilt here. That is the point: the slug, the locale prefix and the
|
|
474
|
+
* default-locale rule are decided in one place, and this function normalizes
|
|
475
|
+
* what that place produced. A second implementation of path building, however
|
|
476
|
+
* careful, is a second opinion, and a second opinion is exactly how a page ends
|
|
477
|
+
* up claiming an address it does not serve.
|
|
478
|
+
*
|
|
479
|
+
* Reserved paths are not checked here either, for the same reason. They are
|
|
480
|
+
* rows in the same table with the same primary key, so a page sitting on `/api`
|
|
481
|
+
* is refused by the key that refuses a page sitting on another page's path,
|
|
482
|
+
* with the same error, through the same code. A separate list of forbidden
|
|
483
|
+
* strings consulted here would be a second authority waiting to disagree.
|
|
484
|
+
*/
|
|
485
|
+
export function buildPageClaims(
|
|
486
|
+
routes: PageRoute[],
|
|
487
|
+
registry: RouteRegistry,
|
|
488
|
+
): PageClaimsResult {
|
|
489
|
+
const served = new Set(registry.origins);
|
|
490
|
+
const mapped = registry.localeDomains ?? {};
|
|
491
|
+
const mappingMode = Object.keys(mapped).length > 0;
|
|
492
|
+
const claims: RouteClaim[] = [];
|
|
493
|
+
const skipped: { locale: string; reason: string }[] = [];
|
|
494
|
+
const seen = new Map<string, PageRoute>();
|
|
495
|
+
|
|
496
|
+
for (const route of routes) {
|
|
497
|
+
let origin: string;
|
|
498
|
+
|
|
499
|
+
if (route.origin) {
|
|
500
|
+
origin = normalizeOrigin(route.origin);
|
|
501
|
+
if (!origin) {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
problem:
|
|
505
|
+
`The route for ${route.locale} names the origin ${describe(route.origin)}, which is ` +
|
|
506
|
+
`not an origin.`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
} else if (mappingMode) {
|
|
510
|
+
const domain = mapped[route.locale];
|
|
511
|
+
if (!domain) {
|
|
512
|
+
skipped.push({
|
|
513
|
+
locale: route.locale,
|
|
514
|
+
reason:
|
|
515
|
+
`this release maps locales to domains and ${route.locale} has none, so the page ` +
|
|
516
|
+
`serves no address in that language`,
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
origin = normalizeOrigin(domain);
|
|
521
|
+
if (!origin) {
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
problem:
|
|
525
|
+
`The locale ${route.locale} is mapped to ${describe(domain)}, which is not an ` +
|
|
526
|
+
`origin. The claim is refused rather than moved to the default origin.`,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
if (registry.origins.length !== 1) {
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
problem:
|
|
534
|
+
`This release serves ${registry.origins.join(", ")} and maps no locale to any of ` +
|
|
535
|
+
`them, so there is no way to tell which one ${route.locale} belongs on.`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
origin = registry.origins[0] as string;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!served.has(origin)) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
problem:
|
|
545
|
+
`This release serves ${registry.origins.join(", ")}, and ${route.locale} resolves to ` +
|
|
546
|
+
`${origin}. An origin the release does not serve has no reserved paths on it, so a ` +
|
|
547
|
+
`claim there would find /api free.`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const path = normalizeRoutePath(route.path, registry);
|
|
552
|
+
const key = routeKey(origin, path);
|
|
553
|
+
const twin = seen.get(key);
|
|
554
|
+
if (twin) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
problem:
|
|
558
|
+
`This page serves ${path} on ${origin} in both ${twin.locale} and ${route.locale}. ` +
|
|
559
|
+
`One page cannot serve one URL two ways — the two locales need different slugs, ` +
|
|
560
|
+
`different prefixes or different domains.`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
seen.set(key, route);
|
|
564
|
+
claims.push({ origin, path, kind: route.kind ?? "page", locale: route.locale });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return { ok: true, claims, skipped };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** What the claims table currently says, keyed for lookup. */
|
|
571
|
+
export type RouteIndex = {
|
|
572
|
+
/** How many claims the index holds. */
|
|
573
|
+
size: number;
|
|
574
|
+
/** The claim at this address, or `null`. The query is normalized; rows are not. */
|
|
575
|
+
holderOf: (origin: string, path: string) => RouteClaim | null;
|
|
576
|
+
/** Every claim one page holds. */
|
|
577
|
+
claimsFor: (pageId: string) => RouteClaim[];
|
|
578
|
+
/** The claims a proposal would land on that somebody else already holds. */
|
|
579
|
+
collisions: (proposed: RouteClaim[], pageId?: string | null) => RouteClaim[];
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A request-scoped view of `cms_route_claims`.
|
|
584
|
+
*
|
|
585
|
+
* Read once per render and threaded through, rather than recomputed by
|
|
586
|
+
* navigation, the page picker, canonical generation and the sitemap in four
|
|
587
|
+
* places that can disagree. There is no process-global cache: a slug change has
|
|
588
|
+
* to take effect without a redeploy, and a cached route table is a stale second
|
|
589
|
+
* opinion with a long memory.
|
|
590
|
+
*
|
|
591
|
+
* It reports; it does not decide. Every answer here is what the database said
|
|
592
|
+
* when the rows were read, and by the time a save runs, another editor may have
|
|
593
|
+
* taken the address. That is not a gap to be closed with a re-check — it is why
|
|
594
|
+
* the authority is a primary key inside the writing transaction. Use this to
|
|
595
|
+
* tell an editor a slug looks taken; never to conclude that one is free.
|
|
596
|
+
*
|
|
597
|
+
* Stored rows are keyed exactly as stored. A row written under an earlier
|
|
598
|
+
* release was normalized under that release's policy, and re-normalizing it
|
|
599
|
+
* here would file it under an address the database does not hold — inventing
|
|
600
|
+
* agreement instead of showing the disagreement a policy change caused.
|
|
601
|
+
*/
|
|
602
|
+
export function createRouteIndex(claims: RouteClaim[], registry: RouteRegistry): RouteIndex {
|
|
603
|
+
const byAddress = new Map<string, RouteClaim>();
|
|
604
|
+
const byPage = new Map<string, RouteClaim[]>();
|
|
605
|
+
|
|
606
|
+
for (const claim of claims) {
|
|
607
|
+
byAddress.set(routeKey(claim.origin, claim.path), claim);
|
|
608
|
+
if (claim.pageId) {
|
|
609
|
+
const held = byPage.get(claim.pageId);
|
|
610
|
+
if (held) held.push(claim);
|
|
611
|
+
else byPage.set(claim.pageId, [claim]);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const holderOf = (origin: string, path: string): RouteClaim | null => {
|
|
616
|
+
const address = routeKey(normalizeOrigin(origin), normalizeRoutePath(path, registry));
|
|
617
|
+
return byAddress.get(address) ?? null;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
size: byAddress.size,
|
|
622
|
+
holderOf,
|
|
623
|
+
claimsFor: (pageId: string) => byPage.get(pageId) ?? [],
|
|
624
|
+
collisions: (proposed: RouteClaim[], pageId?: string | null) => {
|
|
625
|
+
const found: RouteClaim[] = [];
|
|
626
|
+
for (const claim of proposed) {
|
|
627
|
+
const held = byAddress.get(routeKey(claim.origin, claim.path));
|
|
628
|
+
if (!held) continue;
|
|
629
|
+
if (pageId != null && held.pageId === pageId) continue;
|
|
630
|
+
found.push(held);
|
|
631
|
+
}
|
|
632
|
+
return found;
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** A value quoted for a message, or named as empty rather than shown as "". */
|
|
638
|
+
function describe(value: string | null | undefined): string {
|
|
639
|
+
const raw = (value ?? "").trim();
|
|
640
|
+
return raw === "" ? "empty" : `"${raw}"`;
|
|
641
|
+
}
|
package/src/site/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "../i18n/locales";
|
|
19
19
|
import { domainOrigin, isValidCanonicalDomain } from "./host";
|
|
20
|
+
import { servedPath } from "./route-index";
|
|
20
21
|
import type { Localized, LocalizedImage, LocalizedString } from "../i18n/types";
|
|
21
22
|
import { t, tv } from "../i18n/resolve";
|
|
22
23
|
|
|
@@ -86,10 +87,6 @@ export type SitemapEntry = {
|
|
|
86
87
|
alternates: Array<{ locale: Locale | "x-default"; url: string }>;
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
function localePrefix(locale: Locale): string {
|
|
90
|
-
return locale === DEFAULT_LOCALE ? "" : `/${locale}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
90
|
/**
|
|
94
91
|
* Merge a static page definition with an optional CMS override.
|
|
95
92
|
* Returns a new PageDef; the original inputs are not mutated.
|
|
@@ -326,10 +323,7 @@ export function createSiteRoutes(PAGES: Record<string, PageDef>): SiteRoutes {
|
|
|
326
323
|
|
|
327
324
|
function pathFromDef(page: PageDef, locale: Locale): string {
|
|
328
325
|
const slug = slugFor(page, locale) ?? slugFor(page, DEFAULT_LOCALE) ?? "";
|
|
329
|
-
|
|
330
|
-
const path = slug ? `/${slug}` : "";
|
|
331
|
-
const full = `${prefix}${path}`;
|
|
332
|
-
return full === "" ? "/" : full;
|
|
326
|
+
return servedPath(slug, locale, DEFAULT_LOCALE);
|
|
333
327
|
}
|
|
334
328
|
|
|
335
329
|
function resolveRoute(
|