@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.
@@ -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
+ }
@@ -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
- const prefix = localePrefix(locale);
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(