@bygghammar/cms-platform 0.94.0 → 0.96.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +8 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  6. package/src/backoffice/NavigationView.tsx +38 -0
  7. package/src/backoffice/PageBuilderView.tsx +149 -3
  8. package/src/backoffice/PageEditorView.tsx +47 -24
  9. package/src/backoffice/PagesListView.tsx +173 -57
  10. package/src/backoffice/SiteSettingsView.tsx +4 -0
  11. package/src/backoffice/StatusBadge.tsx +38 -2
  12. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  13. package/src/backoffice/routes/host.ts +24 -0
  14. package/src/backoffice/routes/index.tsx +54 -0
  15. package/src/backoffice/routes/pages.tsx +13 -0
  16. package/src/cms/booking-links.ts +176 -0
  17. package/src/cms/page-publication.ts +11 -6
  18. package/src/cms/page-visibility.ts +206 -0
  19. package/src/forms/form-audit.ts +22 -2
  20. package/src/html/html-tokenizer.ts +254 -0
  21. package/src/html/sanitize-html.ts +72 -37
  22. package/src/navigation/visibility.ts +235 -0
  23. package/src/popups/runtime.tsx +155 -4
  24. package/src/server/forms-core.ts +9 -0
  25. package/src/server/forms-delivery-core.ts +29 -22
  26. package/src/server/forms-submissions-core.ts +8 -5
  27. package/src/server/media-usage-core.ts +53 -13
  28. package/src/server/outbound-address.ts +131 -0
  29. package/src/server/outbound-http.ts +565 -0
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/popup-optin-webhook-core.ts +79 -140
  33. package/src/server/published-cache.ts +23 -3
  34. package/src/server/schemas.ts +17 -0
  35. package/src/site/PageRenderer.tsx +34 -5
  36. package/src/site/SiteFooter.tsx +11 -2
  37. package/src/site/SiteHeader.tsx +41 -21
  38. package/src/site/link-visibility.tsx +28 -0
  39. package/src/site/preview-messaging.ts +232 -0
  40. package/src/site/route-aliases.ts +74 -0
  41. package/src/site/routes/preview-page.tsx +75 -31
  42. package/src/site/routes/public-page.tsx +81 -9
  43. package/src/util/redact-url.ts +18 -0
@@ -131,6 +131,23 @@ export const pageSchemas = {
131
131
  status: z.enum(["draft", "published"]),
132
132
  editSessionId: editSessionId.optional(),
133
133
  }),
134
+ /** Listing only: never publishes, unpublishes or reschedules. */
135
+ setPageListing: z.object({
136
+ pageId: uuid,
137
+ listing: z.enum(["listed", "link-only"]),
138
+ /** The version the editor was shown; null when nothing was stored yet. */
139
+ expectedVersion: z.string().min(1).max(64).nullable(),
140
+ editSessionId: editSessionId.optional(),
141
+ }),
142
+ setBookingEnabled: z.object({
143
+ enabled: z.boolean(),
144
+ expectedVersion: z.string().min(1).max(64).nullable(),
145
+ }),
146
+ setBookingException: z.object({
147
+ pageId: uuid,
148
+ exception: z.boolean(),
149
+ expectedVersion: z.string().min(1).max(64).nullable(),
150
+ }),
134
151
  duplicatePage: z.object({
135
152
  pageId: uuid,
136
153
  name: z.string().min(1).max(120),
@@ -177,14 +177,24 @@ export function createPageQueryOptions(
177
177
  fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
178
178
  defaultStaleTimeMs: number = PAGE_STALE_TIME_MS,
179
179
  ) {
180
- return (key: string) =>
180
+ /*
181
+ * `variant` partitions the cache by whatever the site's public policy says
182
+ * about the page (booking shown or not, say). The server stays the
183
+ * authority on what it returns; the partition only stops an answer fetched
184
+ * under one policy being reused under another, so switching booking back
185
+ * on is not answered from a copy fetched while it was off.
186
+ */
187
+ return (key: string, variant?: string) =>
181
188
  queryOptions({
182
- queryKey: ["cms", "page", key],
189
+ queryKey: variant ? ["cms", "page", key, variant] : ["cms", "page", key],
183
190
  queryFn: () => fetchPublishedPage(key),
184
191
  staleTime: (query) => publishedPageStaleTime(query.state.data, defaultStaleTimeMs),
185
192
  });
186
193
  }
187
194
 
195
+ /** A rendering-time view of a fetched page, e.g. with links the policy forbids removed. */
196
+ export type PageTransform = (page: RenderedPage) => RenderedPage;
197
+
188
198
  /**
189
199
  * Build the published-page renderer for a site.
190
200
  * `fetchPublishedPage` is the site's `getPublishedPage` server function.
@@ -195,9 +205,28 @@ export function createPageRenderer(
195
205
  ) {
196
206
  const pageQueryOptions = createPageQueryOptions(fetchPublishedPage);
197
207
 
198
- function PageRenderer({ pageKey }: { pageKey: string }) {
199
- const { data } = useSuspenseQuery(pageQueryOptions(pageKey));
200
- const page = data as RenderedPage | null;
208
+ /*
209
+ * `transform` is applied to whatever the cache holds, on every render: a
210
+ * page cached before the policy changed, or one still being refetched in
211
+ * the background, is shown as the current policy says, never as it was
212
+ * fetched. The cached original is not changed, so a later policy can show
213
+ * it again. Pass a stable function, or none.
214
+ */
215
+ function PageRenderer({
216
+ pageKey,
217
+ variant,
218
+ transform,
219
+ }: {
220
+ pageKey: string;
221
+ variant?: string;
222
+ transform?: PageTransform;
223
+ }) {
224
+ const { data } = useSuspenseQuery(pageQueryOptions(pageKey, variant));
225
+ const fetched = data as RenderedPage | null;
226
+ const page = useMemo(
227
+ () => (fetched && transform ? transform(fetched) : fetched),
228
+ [fetched, transform],
229
+ );
201
230
  if (!page) return null;
202
231
  return (
203
232
  <DraftPageRendererView blocks={page.blocks} globals={page.globals} registry={registry} />
@@ -12,6 +12,8 @@ import type { SiteTypography } from "../config/typography";
12
12
  import { useT } from "../i18n";
13
13
  import type { LocalizedString } from "../i18n/types";
14
14
  import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
15
+ import { filterFooterColumnsForPolicy } from "../navigation/visibility";
16
+ import { useLinkVisibility } from "./link-visibility";
15
17
 
16
18
  export type SiteFooterCompany = {
17
19
  legalName: string;
@@ -81,12 +83,19 @@ export function SiteFooter({
81
83
  // boundary rather than on the next full page load.
82
84
  const now = useScheduleClock(footerSchedules(columns));
83
85
  const footerFont = slotFont(typography, "footer");
84
- const visibleColumns = columns
86
+ const linkPolicy = useLinkVisibility();
87
+ // Scheduled-out links first, then links the site must not offer right now
88
+ // (an offline or link-only page, booking while it is off). A column left with
89
+ // nothing to link to is not rendered as an empty heading.
90
+ const scheduled = columns
85
91
  .filter((column) => isVisibleAt(column.schedule, now))
86
92
  .map((column) => ({
87
93
  ...column,
88
- links: (column.links ?? []).filter((link) => isVisibleAt(link.schedule, now)),
94
+ links: (column.links ?? []).filter(
95
+ (link) => isVisibleAt(link.schedule, now) && (link.href ?? "").trim() !== "",
96
+ ),
89
97
  }));
98
+ const visibleColumns = filterFooterColumnsForPolicy(scheduled, linkPolicy);
90
99
 
91
100
  const jsonLd = {
92
101
  "@context": "https://schema.org",
@@ -21,6 +21,8 @@ import {
21
21
  type NavigationItem,
22
22
  } from "../navigation";
23
23
  import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
24
+ import { filterNavigationForPolicy, linkHideReason } from "../navigation/visibility";
25
+ import { useLinkVisibility } from "./link-visibility";
24
26
 
25
27
  export type SiteHeaderProps = {
26
28
  /**
@@ -39,8 +41,13 @@ export type SiteHeaderProps = {
39
41
  logoAlt?: string;
40
42
  /** Set false when the logo artwork is already dark. */
41
43
  invertLogoOnLightSurface?: boolean;
42
- /** Primary call to action shown on desktop and at the end of the mobile panel. */
43
- primaryCta: { href: string; label: LocalizedString };
44
+ /**
45
+ * Primary call to action shown on desktop and at the end of the mobile panel.
46
+ * `null` shows none. With `booking: true` it is a booking action, hidden
47
+ * while the site's booking is switched off; either way it is hidden when the
48
+ * page it leads to is offline or link-only (see `LinkVisibilityProvider`).
49
+ */
50
+ primaryCta: { href: string; label: LocalizedString; booking?: boolean } | null;
44
51
  /** Mobile menu toggle labels. */
45
52
  menuLabels: { open: LocalizedString; close: LocalizedString };
46
53
  /** Optional backoffice shortcut, rendered only when `signedIn` is true. */
@@ -252,8 +259,17 @@ export function SiteHeader({
252
259
  // A page left open past a scheduled publish/hide time re-renders on the
253
260
  // boundary instead of showing what was true when it loaded.
254
261
  const now = useScheduleClock(navigationSchedules(nav));
262
+ // Links to offline or link-only pages, and booking links while booking is
263
+ // off, are left out here; the saved menu itself is untouched.
264
+ const linkPolicy = useLinkVisibility();
255
265
  // Half-finished entries are the editor's business, not a visitor's.
256
- const items = renderableNavigation(nav, locale, now);
266
+ const items = renderableNavigation(filterNavigationForPolicy(nav, linkPolicy), locale, now);
267
+ const cta =
268
+ primaryCta &&
269
+ !(primaryCta.booking && !linkPolicy.bookingVisible) &&
270
+ linkHideReason(primaryCta.href, linkPolicy) === null
271
+ ? primaryCta
272
+ : null;
257
273
  const menuFont = slotFont(typography, "menu");
258
274
 
259
275
  return (
@@ -297,17 +313,19 @@ export function SiteHeader({
297
313
  {backoffice.label}
298
314
  </Link>
299
315
  )}
300
- <Link
301
- to={primaryCta.href}
302
- className={[
303
- "inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
304
- !lightText
305
- ? "border-foreground text-foreground hover:bg-foreground hover:text-background"
306
- : "border-paper text-paper hover:bg-paper hover:text-ink",
307
- ].join(" ")}
308
- >
309
- {t(primaryCta.label)}
310
- </Link>
316
+ {cta ? (
317
+ <Link
318
+ to={cta.href}
319
+ className={[
320
+ "inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
321
+ !lightText
322
+ ? "border-foreground text-foreground hover:bg-foreground hover:text-background"
323
+ : "border-paper text-paper hover:bg-paper hover:text-ink",
324
+ ].join(" ")}
325
+ >
326
+ {t(cta.label)}
327
+ </Link>
328
+ ) : null}
311
329
  </div>
312
330
 
313
331
  <button
@@ -395,13 +413,15 @@ export function SiteHeader({
395
413
  {backoffice.label}
396
414
  </Link>
397
415
  )}
398
- <Link
399
- to={primaryCta.href}
400
- className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
401
- onClick={() => setOpen(false)}
402
- >
403
- {t(primaryCta.label)}
404
- </Link>
416
+ {cta ? (
417
+ <Link
418
+ to={cta.href}
419
+ className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
420
+ onClick={() => setOpen(false)}
421
+ >
422
+ {t(cta.label)}
423
+ </Link>
424
+ ) : null}
405
425
  </nav>
406
426
  </div>
407
427
  )}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The public link policy, made available to the site chrome.
3
+ *
4
+ * A site resolves the policy on the server (which pages are offline or
5
+ * link-only, whether booking is on) and provides it here; the shared header and
6
+ * footer read it to leave out links a visitor must not be offered. Without a
7
+ * provider nothing is filtered, which is what the backoffice and any screen
8
+ * that renders the chrome outside the public site get.
9
+ */
10
+ import { createContext, useContext, type ReactNode } from "react";
11
+
12
+ import { OPEN_LINK_POLICY, type LinkVisibilityPolicy } from "../navigation/visibility";
13
+
14
+ const LinkVisibilityContext = createContext<LinkVisibilityPolicy>(OPEN_LINK_POLICY);
15
+
16
+ export function LinkVisibilityProvider({
17
+ policy,
18
+ children,
19
+ }: {
20
+ policy: LinkVisibilityPolicy;
21
+ children: ReactNode;
22
+ }) {
23
+ return <LinkVisibilityContext.Provider value={policy}>{children}</LinkVisibilityContext.Provider>;
24
+ }
25
+
26
+ export function useLinkVisibility(): LinkVisibilityPolicy {
27
+ return useContext(LinkVisibilityContext);
28
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The page builder's channel to its preview canvas.
3
+ *
4
+ * The builder renders the page being edited in an iframe (the preview route)
5
+ * and streams unsaved drafts into it; the canvas answers with a ready signal
6
+ * and block selections. The preview route is public, so its page can be
7
+ * opened or framed by anyone — a message that only *says* it comes from the
8
+ * builder proves nothing. Both ends therefore check who is talking:
9
+ *
10
+ * - the canvas accepts the builder's messages only from its own parent frame
11
+ * (`event.source`), only from an expected origin (its own, plus any origin a
12
+ * site explicitly names), and only in one builder session, in order;
13
+ * - the builder accepts the canvas's messages only from its own iframe's
14
+ * window and the iframe's origin;
15
+ * - nothing is ever posted to `"*"`: every message names the origin that may
16
+ * receive it, so unsaved drafts never reach a page that navigated elsewhere.
17
+ *
18
+ * Malformed, oversized, out-of-order and repeated messages are ignored.
19
+ */
20
+
21
+ export const EDITOR_SOURCE = "lovable-editor";
22
+ export const PREVIEW_SOURCE = "lovable-preview";
23
+
24
+ /** Largest draft accepted, measured as JSON. A whole page with its globals is far smaller. */
25
+ export const PREVIEW_MESSAGE_MAX_BYTES = 4 * 1024 * 1024;
26
+
27
+ const MAX_ID = 200;
28
+ const KEYS_THAT_ARE_NOT_DATA = new Set(["__proto__", "constructor", "prototype"]);
29
+
30
+ export type EditorDraftMessage = {
31
+ source: typeof EDITOR_SOURCE;
32
+ session: string;
33
+ seq: number;
34
+ type: "draft";
35
+ blocks: unknown[];
36
+ globals: Record<string, unknown[]>;
37
+ selectedId: string | null;
38
+ locale?: string;
39
+ sharedContent?: Record<string, unknown>;
40
+ };
41
+
42
+ export type EditorScrollMessage = {
43
+ source: typeof EDITOR_SOURCE;
44
+ session: string;
45
+ seq: number;
46
+ type: "scrollTo";
47
+ blockId: string;
48
+ };
49
+
50
+ export type EditorMessage = EditorDraftMessage | EditorScrollMessage;
51
+
52
+ export type PreviewMessage =
53
+ | { source: typeof PREVIEW_SOURCE; type: "ready" }
54
+ | { source: typeof PREVIEW_SOURCE; type: "select"; blockId: string };
55
+
56
+ /** The parts of a `MessageEvent` the guards read. */
57
+ export type IncomingMessage = { data: unknown; origin: string; source: unknown };
58
+
59
+ /** A serialized origin (`https://host[:port]`), or null for anything else. */
60
+ export function normalizeOrigin(value: string | null | undefined): string | null {
61
+ if (!value) return null;
62
+ try {
63
+ const url = new URL(value);
64
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
65
+ if (url.username || url.password) return null;
66
+ return url.origin;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** The origins the canvas trusts: its own first, then any the site names explicitly. */
73
+ export function trustedEditorOrigins(own: string, extra: readonly string[] = []): string[] {
74
+ const out: string[] = [];
75
+ for (const value of [own, ...extra]) {
76
+ const origin = normalizeOrigin(value);
77
+ if (origin && !out.includes(origin)) out.push(origin);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** The origin of the page the builder frames, resolved against the builder's address. */
83
+ export function previewOriginOf(previewUrl: string, base: string): string | null {
84
+ try {
85
+ return normalizeOrigin(new URL(previewUrl, base).href);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** A fresh, unguessable builder session id. */
92
+ export function newPreviewSession(): string {
93
+ const bytes = new Uint8Array(16);
94
+ globalThis.crypto.getRandomValues(bytes);
95
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
96
+ }
97
+
98
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
99
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
100
+ const proto = Object.getPrototypeOf(value) as unknown;
101
+ return proto === Object.prototype || proto === null;
102
+ }
103
+
104
+ function hasOnlyDataKeys(value: Record<string, unknown>): boolean {
105
+ return Object.keys(value).every((key) => !KEYS_THAT_ARE_NOT_DATA.has(key));
106
+ }
107
+
108
+ const isId = (value: unknown): value is string =>
109
+ typeof value === "string" && value.length > 0 && value.length <= MAX_ID;
110
+
111
+ function sizeWithin(value: unknown, maxBytes: number): boolean {
112
+ try {
113
+ const json = JSON.stringify(value);
114
+ return typeof json === "string" && json.length <= maxBytes;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /** The builder's message, checked for shape; null when it is not one. */
121
+ export function parseEditorMessage(
122
+ data: unknown,
123
+ maxBytes = PREVIEW_MESSAGE_MAX_BYTES,
124
+ ): EditorMessage | null {
125
+ if (!isPlainObject(data) || data["source"] !== EDITOR_SOURCE) return null;
126
+ const session = data["session"];
127
+ const seq = data["seq"];
128
+ if (typeof session !== "string" || session.length < 16 || session.length > 128) return null;
129
+ if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return null;
130
+ if (!sizeWithin(data, maxBytes)) return null;
131
+
132
+ if (data["type"] === "scrollTo") {
133
+ if (!isId(data["blockId"])) return null;
134
+ return { source: EDITOR_SOURCE, session, seq, type: "scrollTo", blockId: data["blockId"] };
135
+ }
136
+ if (data["type"] !== "draft") return null;
137
+
138
+ const blocks = data["blocks"];
139
+ const globals = data["globals"];
140
+ const selectedId = data["selectedId"] ?? null;
141
+ const locale = data["locale"];
142
+ const sharedContent = data["sharedContent"];
143
+ if (!Array.isArray(blocks)) return null;
144
+ if (!isPlainObject(globals) || !hasOnlyDataKeys(globals)) return null;
145
+ if (!Object.values(globals).every((value) => Array.isArray(value))) return null;
146
+ if (selectedId !== null && !isId(selectedId)) return null;
147
+ if (locale !== undefined && (typeof locale !== "string" || locale.length > 16)) return null;
148
+ if (sharedContent !== undefined && (!isPlainObject(sharedContent) || !hasOnlyDataKeys(sharedContent))) {
149
+ return null;
150
+ }
151
+ return {
152
+ source: EDITOR_SOURCE,
153
+ session,
154
+ seq,
155
+ type: "draft",
156
+ blocks,
157
+ globals: globals as Record<string, unknown[]>,
158
+ selectedId,
159
+ ...(locale !== undefined ? { locale: locale as string } : {}),
160
+ ...(sharedContent !== undefined ? { sharedContent } : {}),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The canvas's side: accepts the builder's messages from its parent frame and
166
+ * a trusted origin, in one session and in order.
167
+ */
168
+ export function createEditorMessageGuard(options: {
169
+ trustedOrigins: readonly string[];
170
+ /** The window that framed the canvas (`window.parent`). */
171
+ parent: () => unknown;
172
+ /** The canvas's own window, to tell "not framed" apart. */
173
+ self: () => unknown;
174
+ maxBytes?: number;
175
+ }) {
176
+ const trusted = new Set(options.trustedOrigins);
177
+ let session: string | null = null;
178
+ let lastSeq = -1;
179
+ let origin: string | null = null;
180
+
181
+ return {
182
+ accept(event: IncomingMessage): EditorMessage | null {
183
+ const parent = options.parent();
184
+ if (!parent || parent === options.self()) return null;
185
+ if (event.source !== parent) return null;
186
+ if (!trusted.has(event.origin)) return null;
187
+ if (origin !== null && event.origin !== origin) return null;
188
+ const message = parseEditorMessage(event.data, options.maxBytes);
189
+ if (!message) return null;
190
+ if (session !== null && message.session !== session) return null;
191
+ if (message.seq <= lastSeq) return null;
192
+ session = message.session;
193
+ origin = event.origin;
194
+ lastSeq = message.seq;
195
+ return message;
196
+ },
197
+ /** Where replies may go: the builder's verified origin, or every trusted one before it spoke. */
198
+ replyOrigins(): string[] {
199
+ return origin !== null ? [origin] : [...trusted];
200
+ },
201
+ };
202
+ }
203
+
204
+ /** The canvas's message, checked for shape; null when it is not one. */
205
+ export function parsePreviewMessage(data: unknown): PreviewMessage | null {
206
+ if (!isPlainObject(data) || data["source"] !== PREVIEW_SOURCE) return null;
207
+ if (data["type"] === "ready") return { source: PREVIEW_SOURCE, type: "ready" };
208
+ if (data["type"] === "select" && isId(data["blockId"])) {
209
+ return { source: PREVIEW_SOURCE, type: "select", blockId: data["blockId"] };
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /** The builder's side: accepts only its own iframe's messages, from the iframe's origin. */
215
+ export function acceptPreviewMessage(
216
+ event: IncomingMessage,
217
+ options: { frame: unknown; previewOrigin: string | null },
218
+ ): PreviewMessage | null {
219
+ if (!options.frame || event.source !== options.frame) return null;
220
+ if (!options.previewOrigin || event.origin !== options.previewOrigin) return null;
221
+ return parsePreviewMessage(event.data);
222
+ }
223
+
224
+ /** Posts to each origin in turn; a window at any other origin receives nothing. */
225
+ export function postToOrigins(
226
+ target: { postMessage: (message: unknown, targetOrigin: string) => void } | null | undefined,
227
+ message: unknown,
228
+ origins: readonly string[],
229
+ ): void {
230
+ if (!target) return;
231
+ for (const origin of origins) target.postMessage(message, origin);
232
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Every address that reaches each page, the way the path resolver sees them.
3
+ *
4
+ * Hiding links to an unlisted page has to recognise all of its addresses — the
5
+ * Swedish slug, the English one under `/en`, a slug changed in the SEO screen,
6
+ * the bare `/<key>` every builder page answers to — and none that belong to a
7
+ * different page. This builds the same map `resolvePathCore` consults, in the
8
+ * same order of precedence:
9
+ *
10
+ * 1. slug overrides from `page_seo`;
11
+ * 2. the site's static page registry, for pages with no override;
12
+ * 3. the bare key, `/<key>` and `/<locale>/<key>`, for pages that exist, only
13
+ * where no slug already claims the address.
14
+ *
15
+ * Within one precedence level the first page (in key order) wins an address,
16
+ * matching the resolver's "first entered wins" for the rare collision instead
17
+ * of guessing differently.
18
+ */
19
+ import { normalizeRoutePath, servedPath } from "./route-index";
20
+
21
+ /** Slug map of a page in a site's static registry (as `resolvePathCore` takes it). */
22
+ export type AliasSlugRegistry = Record<string, { slug: Record<string, string | undefined> }>;
23
+
24
+ export type RouteAliasInput = {
25
+ /** The site's static page registry. */
26
+ registry: AliasSlugRegistry;
27
+ /** `page_seo.slugs` per page key, for pages that have a row. */
28
+ overrides: ReadonlyMap<string, Record<string, string | undefined>>;
29
+ /** Keys of CMS pages that exist (not in the trash): their `/<key>` answers too. */
30
+ existingKeys: readonly string[];
31
+ locales: readonly string[];
32
+ defaultLocale: string;
33
+ /** The locale an unprefixed address resolves in on this request's domain. */
34
+ requestLocale: string;
35
+ };
36
+
37
+ const BARE_KEY = /^[a-z0-9_-]+$/;
38
+
39
+ /** Normalized served path → the page key the resolver would answer with. */
40
+ export function routeAliasMap(input: RouteAliasInput): Map<string, string> {
41
+ const policy = { locales: input.locales };
42
+ const prefixed = input.locales.filter((locale) => locale !== input.defaultLocale);
43
+ const map = new Map<string, string>();
44
+ const claim = (path: string, key: string) => {
45
+ const normalized = normalizeRoutePath(path, policy);
46
+ if (!map.has(normalized)) map.set(normalized, key);
47
+ };
48
+
49
+ const slugPaths = (key: string, slugs: Record<string, string | undefined>) => {
50
+ const pick = (locale: string) => slugs[locale] ?? slugs[input.defaultLocale];
51
+ // Unprefixed: the slug of the locale this domain resolves bare paths in.
52
+ const bare = pick(input.requestLocale);
53
+ if (bare !== undefined && bare !== null) claim(servedPath(bare, input.defaultLocale, input.defaultLocale), key);
54
+ // Prefixed: every other locale, each under its own prefix.
55
+ for (const locale of prefixed) {
56
+ const slug = pick(locale);
57
+ if (slug !== undefined && slug !== null) claim(servedPath(slug, locale, input.defaultLocale), key);
58
+ }
59
+ };
60
+
61
+ for (const key of [...input.overrides.keys()].sort()) {
62
+ slugPaths(key, input.overrides.get(key) ?? {});
63
+ }
64
+ for (const key of Object.keys(input.registry).sort()) {
65
+ if (input.overrides.has(key)) continue;
66
+ slugPaths(key, input.registry[key]!.slug);
67
+ }
68
+ for (const key of [...input.existingKeys].sort()) {
69
+ if (!BARE_KEY.test(key)) continue;
70
+ claim(`/${key}`, key);
71
+ for (const locale of input.locales) claim(`/${locale}/${key}`, key);
72
+ }
73
+ return map;
74
+ }