@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * One hostname implementation for the whole platform.
3
+ *
4
+ * Locale resolution, tracking domain scope, the admin-domain comparison and
5
+ * every generated origin used to carry their own near-miss normalizer. They
6
+ * disagreed on ports, on whitespace, on casing and — worst — on `www.`, so a
7
+ * domain saved through the backoffice could be unmatchable by construction.
8
+ *
9
+ * Two distinct jobs, kept as two functions rather than one ambiguous one:
10
+ *
11
+ * `normalizeCanonicalDomain` is the STORAGE form. It is what the site
12
+ * advertises: canonical URLs, hreflang, sitemaps. It preserves whether the
13
+ * configured hostname starts with `www.`, because that is a real choice.
14
+ *
15
+ * `hostMatchKey` is the MATCHING form. Apex and exactly one leading `www.`
16
+ * are aliases of each other, so both fold to the same key.
17
+ *
18
+ * Pure module — no database, no filesystem, no network.
19
+ */
20
+
21
+ /** Longest a hostname may be, per RFC 1035. */
22
+ const MAX_HOST_LENGTH = 253;
23
+ /** Longest a single label may be, per RFC 1035. */
24
+ const MAX_LABEL_LENGTH = 63;
25
+
26
+ /** A label: alphanumerics and inner hyphens. Underscores are not hostnames. */
27
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
28
+
29
+ /**
30
+ * Reduce any host-ish string to its canonical stored form.
31
+ *
32
+ * Trims, strips a scheme, strips path/query/fragment, strips a trailing slash,
33
+ * strips a port, lowercases — and leaves a leading `www.` exactly as given.
34
+ *
35
+ * `https://WWW.Voyara.se/foo/` → `www.voyara.se`
36
+ * `voyara.se:443` → `voyara.se`
37
+ */
38
+ export function normalizeCanonicalDomain(value: string | null | undefined): string {
39
+ return (value ?? "")
40
+ .trim()
41
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
42
+ .replace(/[/?#].*$/, "")
43
+ .replace(/\.+$/, "")
44
+ .replace(/:\d*$/, "")
45
+ .toLowerCase();
46
+ }
47
+
48
+ /**
49
+ * The key two hostnames are compared on.
50
+ *
51
+ * Exactly one leading `www.` is folded away, so `voyara.se`, `www.voyara.se`
52
+ * and `WWW.VOYARA.SE` all resolve to the same mapping. `www.www.voyara.se`
53
+ * does not — only one level is an alias.
54
+ */
55
+ export function hostMatchKey(value: string | null | undefined): string {
56
+ return normalizeCanonicalDomain(value).replace(/^www\./, "");
57
+ }
58
+
59
+ /**
60
+ * Whether a host only ever resolves on the machine serving it.
61
+ *
62
+ * Kept here rather than beside the backoffice guard because the server needs
63
+ * it too: a loopback address stored as the admin domain is a lockout nobody
64
+ * outside the box can undo, so it has to be refusable at the write.
65
+ */
66
+ export function isLoopbackHost(value: string | null | undefined): boolean {
67
+ const host = normalizeCanonicalDomain(value);
68
+ return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "[::1]";
69
+ }
70
+
71
+ /** Whether two host-ish strings name the same site, apex/`www.` included. */
72
+ export function hostsMatch(a: string | null | undefined, b: string | null | undefined): boolean {
73
+ const left = hostMatchKey(a);
74
+ return left !== "" && left === hostMatchKey(b);
75
+ }
76
+
77
+ /**
78
+ * `https://<canonical host>`, or "" when there is no usable host.
79
+ *
80
+ * Validity is checked here rather than at each call site because the rows this
81
+ * runs on predate the write validation. A `locale_domains` table filled before
82
+ * the backoffice started refusing bad hostnames still holds them, and without
83
+ * this every caller — canonical, hreflang, the sitemap index, the sitemap
84
+ * urlset — would happily publish `https://not a host/om-oss` as an address.
85
+ */
86
+ export function domainOrigin(value: string | null | undefined): string {
87
+ if (!isValidCanonicalDomain(value)) return "";
88
+ const host = normalizeCanonicalDomain(value);
89
+ return host ? `https://${host}` : "";
90
+ }
91
+
92
+ export type CanonicalDomainResult =
93
+ | { ok: true; domain: string }
94
+ | { ok: false; reason: CanonicalDomainProblem };
95
+
96
+ export type CanonicalDomainProblem =
97
+ | "empty"
98
+ | "credentials"
99
+ | "unsupported-scheme"
100
+ | "invalid-port"
101
+ | "whitespace"
102
+ | "too-long"
103
+ | "invalid-label";
104
+
105
+ /**
106
+ * Validate and normalize a domain a human typed into the backoffice.
107
+ *
108
+ * Normalization is generous about the forms people paste — a full URL, a
109
+ * trailing slash, a port, stray casing. It is deliberately not generous about
110
+ * input that cannot be a hostname at all: embedded credentials, a non-HTTP
111
+ * scheme, internal whitespace or an unusable label are rejected rather than
112
+ * quietly stored as something the matcher will never find.
113
+ */
114
+ export function parseCanonicalDomain(value: string | null | undefined): CanonicalDomainResult {
115
+ const raw = (value ?? "").trim();
116
+ if (!raw) return { ok: false, reason: "empty" };
117
+ if (/\s/.test(raw)) return { ok: false, reason: "whitespace" };
118
+
119
+ // No hostname contains one, in any of the forms people paste.
120
+ if (raw.includes("@")) return { ok: false, reason: "credentials" };
121
+
122
+ // The slashes are optional so `javascript:...` is named for what it is
123
+ // rather than failing later as a bad port. Two things keep a hostname from
124
+ // reading as a scheme: a colon followed by digits is a port (`example.com:443`),
125
+ // and a dotted prefix is a hostname (`example.com:notaport`), since no scheme
126
+ // anyone types contains a dot.
127
+ const scheme = /^([a-z][a-z0-9+-]*):(?=\/\/|[^\d\s])/i.exec(raw);
128
+ if (scheme && !/^https?$/i.test(scheme[1] as string)) {
129
+ return { ok: false, reason: "unsupported-scheme" };
130
+ }
131
+
132
+ const authority = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
133
+ const port = /:(.*)$/.exec(authority);
134
+ if (port && !/^\d{1,5}$/.test(port[1] as string)) return { ok: false, reason: "invalid-port" };
135
+
136
+ const domain = normalizeCanonicalDomain(raw);
137
+ if (!domain) return { ok: false, reason: "empty" };
138
+ if (domain.length > MAX_HOST_LENGTH) return { ok: false, reason: "too-long" };
139
+
140
+ const labels = domain.split(".");
141
+ const usable = labels.every(
142
+ (label) => label.length > 0 && label.length <= MAX_LABEL_LENGTH && LABEL.test(label),
143
+ );
144
+ if (!usable) return { ok: false, reason: "invalid-label" };
145
+
146
+ return { ok: true, domain };
147
+ }
148
+
149
+ /** Whether a typed domain would be stored as-is. */
150
+ export function isValidCanonicalDomain(value: string | null | undefined): boolean {
151
+ return parseCanonicalDomain(value).ok;
152
+ }
153
+
154
+ /** Human-readable reason a domain was rejected. */
155
+ export function canonicalDomainProblemMessage(reason: CanonicalDomainProblem): string {
156
+ switch (reason) {
157
+ case "empty":
158
+ return "Enter a domain.";
159
+ case "credentials":
160
+ return "A domain cannot contain a username or password.";
161
+ case "unsupported-scheme":
162
+ return "Only http:// and https:// addresses are accepted.";
163
+ case "invalid-port":
164
+ return "That port is not a number.";
165
+ case "whitespace":
166
+ return "A domain cannot contain spaces.";
167
+ case "too-long":
168
+ return "That domain is too long.";
169
+ case "invalid-label":
170
+ return "That is not a valid hostname.";
171
+ }
172
+ }
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Locale → domain mapping, as pure routing logic.
3
+ *
4
+ * Request matching, the public projection and the "which hostname serves the
5
+ * backoffice" question all live here rather than next to the database calls,
6
+ * because the backoffice screen needs the same answers as the server and must
7
+ * not pull server-only modules into the browser bundle to get them.
8
+ *
9
+ * Pure module — no database, no environment, no network.
10
+ */
11
+
12
+ import {
13
+ canonicalDomainProblemMessage,
14
+ domainOrigin,
15
+ hostMatchKey,
16
+ hostsMatch,
17
+ isLoopbackHost,
18
+ isValidCanonicalDomain,
19
+ normalizeCanonicalDomain,
20
+ parseCanonicalDomain,
21
+ } from "./host";
22
+
23
+ export type LocaleDomainRecord = {
24
+ locale: string;
25
+ domain: string;
26
+ isDefault: boolean;
27
+ isAdminDomain: boolean;
28
+ };
29
+
30
+ /**
31
+ * What public routing and SEO are allowed to see.
32
+ *
33
+ * `isAdminDomain` names the hostname that serves the backoffice. Nothing on
34
+ * the public site needs it, and the migration that revoked anonymous reads of
35
+ * `locale_domains` exists precisely to keep it unpublished — so the public
36
+ * shape omits it rather than relying on every caller to remember.
37
+ */
38
+ export type PublicLocaleDomain = {
39
+ locale: string;
40
+ domain: string;
41
+ isDefault: boolean;
42
+ };
43
+
44
+ /** Drop admin-only fields from one mapping. */
45
+ export function toPublicLocaleDomain(record: LocaleDomainRecord): PublicLocaleDomain {
46
+ return { locale: record.locale, domain: record.domain, isDefault: record.isDefault };
47
+ }
48
+
49
+ /** Drop admin-only fields from a list of mappings. */
50
+ export function toPublicLocaleDomains(records: LocaleDomainRecord[]): PublicLocaleDomain[] {
51
+ return records.map(toPublicLocaleDomain);
52
+ }
53
+
54
+ /** The canonical stored form of a domain. Preserves a configured `www.`. */
55
+ export function normalizeDomain(domain: string): string {
56
+ return normalizeCanonicalDomain(domain);
57
+ }
58
+
59
+ /** The key a request host is matched on. Apex and one `www.` fold together. */
60
+ export function normalizeHost(host: string): string {
61
+ return hostMatchKey(host);
62
+ }
63
+
64
+ /**
65
+ * The mapping whose domain serves `host`.
66
+ *
67
+ * Apex and exactly one leading `www.` are aliases, so `voyara.se`,
68
+ * `www.voyara.se` and `WWW.VOYARA.SE` all reach the same row — whichever form
69
+ * was configured. Generated URLs still use the stored canonical hostname.
70
+ *
71
+ * A row only becomes eligible once its domain passes the same validation the
72
+ * backoffice applies at the write. Comparing on the normalized value alone
73
+ * lets a legacy `ftp://example.com` win the request for `example.com`, because
74
+ * normalizing strips the scheme and leaves exactly the host being looked up.
75
+ * Everything downstream then treats that row as the answer — its locale, and
76
+ * its unusable domain wherever a URL gets built.
77
+ */
78
+ export function matchDomain<T extends { domain: string }>(
79
+ domains: T[],
80
+ host: string,
81
+ ): T | undefined {
82
+ return domains.find((d) => isValidCanonicalDomain(d.domain) && hostsMatch(d.domain, host));
83
+ }
84
+
85
+ /**
86
+ * Resolve the active locale for a request host, falling back to the default
87
+ * mapping, then to the first mapping, then to a synthetic host-based record.
88
+ *
89
+ * Only rows whose domain is a usable hostname are eligible for the fallback.
90
+ * A table written before the backoffice validated its input can hold a row
91
+ * that no request will ever match; letting it win the fallback hands the whole
92
+ * site that row's language, and hands generated URLs its unusable host.
93
+ */
94
+ export function resolveRequestLocale<
95
+ T extends { locale: string; domain: string; isDefault?: boolean },
96
+ >(domains: T[], host: string, defaultLocale: string): { locale: string; domain: T } {
97
+ const match = matchDomain(domains, host);
98
+ if (match) return { locale: match.locale, domain: match };
99
+
100
+ const usable = domains.filter((d) => isValidCanonicalDomain(d.domain));
101
+ const fallback = (usable.find((d) => d.isDefault) ??
102
+ usable[0] ?? {
103
+ locale: defaultLocale,
104
+ domain: normalizeCanonicalDomain(host) || "localhost",
105
+ isDefault: true,
106
+ }) as T;
107
+ return { locale: fallback.locale, domain: fallback };
108
+ }
109
+
110
+ /**
111
+ * Absolute origin for a locale, or "" when the locale has no usable mapping.
112
+ *
113
+ * An unusable row is skipped and the scan continues rather than returning ""
114
+ * on the first match for that language: one bad row left over from before the
115
+ * write validation must not hide a good one beside it.
116
+ */
117
+ export function resolveLocaleOrigin(
118
+ locale: string,
119
+ domains: { locale: string; domain: string }[],
120
+ ): string {
121
+ for (const d of domains) {
122
+ if (d.locale !== locale) continue;
123
+ const mapped = domainOrigin(d.domain);
124
+ if (mapped) return mapped;
125
+ }
126
+ return "";
127
+ }
128
+
129
+ export type AdminHostSource =
130
+ /** `SITE_URL` — authoritative on a self-hosted install. */
131
+ | "site-url"
132
+ /** The row explicitly flagged `is_admin_domain`. */
133
+ | "database"
134
+ /** Nothing designates one, so the request keeps the host it arrived on. */
135
+ | "request-host"
136
+ /** No request to fall back to, so the mapping flagged as default is used. */
137
+ | "default-mapping"
138
+ /** No request and no default, so the first usable mapping is used. */
139
+ | "first-mapping"
140
+ /** Nothing is configured and there is no request — a local build. */
141
+ | "localhost";
142
+
143
+ export type EffectiveAdminHost = {
144
+ /** Canonical hostname the backoffice should be served from. */
145
+ host: string;
146
+ /** Where that hostname came from — what the UI needs to explain itself. */
147
+ source: AdminHostSource;
148
+ /** Locale of the mapping the hostname came from, when there was one. */
149
+ locale: string;
150
+ /** True when `SITE_URL` overrides the database flag for this install. */
151
+ siteUrlAuthoritative: boolean;
152
+ /** Hostname the flagged database row names, when a row is flagged. */
153
+ flaggedHost: string | null;
154
+ };
155
+
156
+ export type AdminHostOptions = {
157
+ /** `SITE_URL`'s hostname on a self-hosted install, when configured. */
158
+ forcedHost?: string | null;
159
+ /** Host of the request being served. */
160
+ requestHost?: string | null;
161
+ defaultLocale: string;
162
+ };
163
+
164
+ /**
165
+ * The canonical form of a host, or "" when it could never be reached.
166
+ *
167
+ * The value is validated before it is normalized, not after: normalizing
168
+ * strips the scheme, so `ftp://example.com` checked afterwards is the
169
+ * perfectly valid `example.com` and the rejection never happens.
170
+ */
171
+ function usableHost(value: string | null | undefined): string {
172
+ if (!isValidCanonicalDomain(value)) return "";
173
+ return normalizeDomain(value ?? "");
174
+ }
175
+
176
+ /**
177
+ * Which hostname actually serves the backoffice, and why.
178
+ *
179
+ * The order matters more than any single rule in this file, because getting it
180
+ * wrong locks an administrator out of the only screen that could fix it:
181
+ *
182
+ * 1. `SITE_URL`, when the deployment sets one. It outranks the database on
183
+ * purpose — an imported row from another install must never send the
184
+ * backoffice to that other install's hostname, and this is the recovery
185
+ * path back out of a bad row.
186
+ * 2. The row explicitly flagged as the admin domain, when its host is usable.
187
+ * 3. **The host the request actually arrived on.** Nothing designates a
188
+ * hostname, so the honest answer is the one already working. Reaching past
189
+ * a live request for `domains[0]` — a hostname chosen by nothing but sort
190
+ * order, quite possibly not serving the backoffice at all — turns a
191
+ * missing flag into a redirect away from a session that was fine.
192
+ * 4. Only with no request to speak of (a build step, a job) does the table
193
+ * decide: the mapping flagged as default,
194
+ * 5. then the first usable mapping,
195
+ * 6. then `localhost`, so the answer is never empty.
196
+ */
197
+ export function resolveEffectiveAdminHost(
198
+ domains: LocaleDomainRecord[],
199
+ options: AdminHostOptions,
200
+ ): EffectiveAdminHost {
201
+ /*
202
+ * More than one row can carry the flag, and the first of them may be a
203
+ * legacy value no request will ever match. Taking it and stopping there
204
+ * drops the whole `database` step — the backoffice then follows the request
205
+ * host, silently ignoring a perfectly good flagged row further down the
206
+ * table. The reported `flaggedHost` still falls back to the first flagged
207
+ * row when none is usable, so the banner can say the flag is unusable.
208
+ */
209
+ const flaggedRows = domains.filter((d) => d.isAdminDomain);
210
+ const flaggedRow = flaggedRows.find((d) => usableHost(d.domain)) ?? flaggedRows[0];
211
+ const flaggedHost = flaggedRow ? normalizeDomain(flaggedRow.domain) : null;
212
+
213
+ const forced = usableHost(options.forcedHost);
214
+ if (forced) {
215
+ return {
216
+ host: forced,
217
+ source: "site-url",
218
+ locale: flaggedRow?.locale ?? domains[0]?.locale ?? options.defaultLocale,
219
+ siteUrlAuthoritative: true,
220
+ flaggedHost,
221
+ };
222
+ }
223
+
224
+ const rest = { siteUrlAuthoritative: false, flaggedHost };
225
+
226
+ if (flaggedRow && usableHost(flaggedRow.domain)) {
227
+ return {
228
+ host: usableHost(flaggedRow.domain),
229
+ source: "database",
230
+ locale: flaggedRow.locale,
231
+ ...rest,
232
+ };
233
+ }
234
+
235
+ const requestHost = usableHost(options.requestHost);
236
+ if (requestHost) {
237
+ // Whatever language this hostname serves, if any — so the record stays
238
+ // coherent without letting the mapping override where the request is.
239
+ const match = matchDomain(domains, requestHost);
240
+ return {
241
+ host: requestHost,
242
+ source: "request-host",
243
+ locale: match?.locale ?? options.defaultLocale,
244
+ ...rest,
245
+ };
246
+ }
247
+
248
+ const configuredDefault = domains.find((d) => d.isDefault && usableHost(d.domain));
249
+ if (configuredDefault) {
250
+ return {
251
+ host: usableHost(configuredDefault.domain),
252
+ source: "default-mapping",
253
+ locale: configuredDefault.locale,
254
+ ...rest,
255
+ };
256
+ }
257
+
258
+ const first = domains.find((d) => usableHost(d.domain));
259
+ if (first) {
260
+ return {
261
+ host: usableHost(first.domain),
262
+ source: "first-mapping",
263
+ locale: first.locale,
264
+ ...rest,
265
+ };
266
+ }
267
+
268
+ return { host: "localhost", source: "localhost", locale: options.defaultLocale, ...rest };
269
+ }
270
+
271
+ /** The backoffice hostname as a mapping record, for existing callers. */
272
+ export function resolveAdminDomain(
273
+ domains: LocaleDomainRecord[],
274
+ options: AdminHostOptions,
275
+ ): LocaleDomainRecord {
276
+ const effective = resolveEffectiveAdminHost(domains, options);
277
+ const source = domains.find((d) => d.locale === effective.locale);
278
+ return {
279
+ locale: effective.locale,
280
+ domain: effective.host,
281
+ isDefault: source?.isDefault ?? true,
282
+ isAdminDomain: true,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Narrow an untyped server result into an `EffectiveAdminHost`.
288
+ *
289
+ * The value crosses a server-function boundary the package does not own, and
290
+ * the screen states it to an administrator as fact. Anything that is not the
291
+ * full shape becomes `null` — the banner then says nothing, which is the point
292
+ * of it being optional.
293
+ */
294
+ export function parseEffectiveAdminHost(value: unknown): EffectiveAdminHost | null {
295
+ if (!value || typeof value !== "object") return null;
296
+ const v = value as Record<string, unknown>;
297
+
298
+ // Normalizing is not validating. `normalizeDomain("not a host")` returns
299
+ // "not a host" — non-empty, and previously enough to pass — which the banner
300
+ // then states as the hostname to move to.
301
+ const host = typeof v.host === "string" ? usableHost(v.host) : "";
302
+ if (!host) return null;
303
+
304
+ const sources: AdminHostSource[] = [
305
+ "site-url",
306
+ "database",
307
+ "request-host",
308
+ "default-mapping",
309
+ "first-mapping",
310
+ "localhost",
311
+ ];
312
+ if (typeof v.source !== "string" || !sources.includes(v.source as AdminHostSource)) return null;
313
+ const source = v.source as AdminHostSource;
314
+
315
+ const locale = typeof v.locale === "string" ? v.locale : "";
316
+ if (!locale.trim()) return null;
317
+
318
+ // The two ways of saying "SITE_URL decides" must agree. A result claiming
319
+ // SITE_URL authority under any other source, or the `site-url` source
320
+ // without it, is not a shape this package produces, so it is not one the
321
+ // screen should explain to an administrator as fact.
322
+ const siteUrlAuthoritative = v.siteUrlAuthoritative === true;
323
+ if ((source === "site-url") !== siteUrlAuthoritative) return null;
324
+
325
+ // The flagged row is context, not a destination: an unusable one is reported
326
+ // as absent rather than sinking the whole result.
327
+ const flaggedHost = typeof v.flaggedHost === "string" ? usableHost(v.flaggedHost) || null : null;
328
+
329
+ return { host, source, locale, siteUrlAuthoritative, flaggedHost };
330
+ }
331
+
332
+ export type AdminDomainChange =
333
+ | {
334
+ kind: "save";
335
+ locale: string;
336
+ /** The domain as typed. Included because the impact depends on it. */
337
+ domain: string;
338
+ isDefault: boolean;
339
+ isAdminDomain: boolean;
340
+ }
341
+ | { kind: "delete"; locale: string };
342
+
343
+ /**
344
+ * The mapping table as it would be after a change, mirroring what the server
345
+ * writes: one row upserted, and a set flag cleared everywhere else.
346
+ */
347
+ export function applyDomainChange(
348
+ domains: LocaleDomainRecord[],
349
+ change: AdminDomainChange,
350
+ ): LocaleDomainRecord[] {
351
+ if (change.kind === "delete") return domains.filter((d) => d.locale !== change.locale);
352
+
353
+ const row: LocaleDomainRecord = {
354
+ locale: change.locale,
355
+ domain: normalizeDomain(change.domain),
356
+ isDefault: change.isDefault,
357
+ isAdminDomain: change.isAdminDomain,
358
+ };
359
+ const others = domains
360
+ .filter((d) => d.locale !== change.locale)
361
+ .map((d) => ({
362
+ ...d,
363
+ isDefault: change.isDefault ? false : d.isDefault,
364
+ isAdminDomain: change.isAdminDomain ? false : d.isAdminDomain,
365
+ }));
366
+ return domains.some((d) => d.locale === change.locale)
367
+ ? domains.map((d) => (d.locale === change.locale ? row : others.find((o) => o.locale === d.locale) ?? d))
368
+ : [...others, row];
369
+ }
370
+
371
+ export type DomainChangeProblemReason =
372
+ | "invalid-domain"
373
+ | "duplicate-host"
374
+ | "unroutable-admin-host"
375
+ | "lost-default";
376
+
377
+ export type DomainChangeValidationOptions = {
378
+ /** True when `SITE_URL`, not the table, decides where the backoffice is. */
379
+ siteUrlAuthoritative?: boolean;
380
+ };
381
+
382
+ export type DomainChangeProblem = {
383
+ reason: DomainChangeProblemReason;
384
+ message: string;
385
+ /** The other locale involved, for a duplicate. */
386
+ locale?: string;
387
+ };
388
+
389
+ /**
390
+ * Why a change must be refused outright, or null when it may go ahead.
391
+ *
392
+ * These are the conditions the server also enforces, kept pure so the screen
393
+ * can say the same thing before spending a round trip. They are refusals, not
394
+ * warnings: nothing here is offered as a confirmable choice, because the write
395
+ * would fail anyway. Moving the backoffice to a different hostname is
396
+ * deliberately *not* on this list — that is a valid change the administrator
397
+ * confirms, and `adminHostImpact` is what describes it.
398
+ */
399
+ export function validateDomainChange(
400
+ domains: LocaleDomainRecord[],
401
+ change: AdminDomainChange,
402
+ options: DomainChangeValidationOptions = {},
403
+ ): DomainChangeProblem | null {
404
+ if (change.kind === "save") {
405
+ const parsed = parseCanonicalDomain(change.domain);
406
+ if (!parsed.ok) {
407
+ return { reason: "invalid-domain", message: canonicalDomainProblemMessage(parsed.reason) };
408
+ }
409
+
410
+ // Apex and `www.` resolve to the same site, so handing them to two
411
+ // languages makes the request-to-locale answer depend on which row a query
412
+ // happened to return first.
413
+ const clash = domains.find((d) => d.locale !== change.locale && hostsMatch(d.domain, parsed.domain));
414
+ if (clash) {
415
+ return {
416
+ reason: "duplicate-host",
417
+ locale: clash.locale,
418
+ message: `"${normalizeDomain(clash.domain)}" is already mapped to ${clash.locale}, and a hostname can only serve one language. Apex and www. count as the same hostname.`,
419
+ };
420
+ }
421
+
422
+ // Only a lockout where the flag is what routes the backoffice. With
423
+ // `SITE_URL` set the column is inert metadata, so refusing would be
424
+ // pedantry rather than protection.
425
+ if (change.isAdminDomain && !options.siteUrlAuthoritative && isLoopbackHost(parsed.domain)) {
426
+ return {
427
+ reason: "unroutable-admin-host",
428
+ message: `"${parsed.domain}" only resolves on the server itself, so nobody could reach the backoffice there.`,
429
+ };
430
+ }
431
+ }
432
+
433
+ const after = applyDomainChange(domains, change);
434
+ if (domains.some((d) => d.isDefault) && after.length > 0 && !after.some((d) => d.isDefault)) {
435
+ return {
436
+ reason: "lost-default",
437
+ message:
438
+ "That would leave no default language. Mark another domain as the default first — the default is what x-default and unmatched hosts fall back to.",
439
+ };
440
+ }
441
+
442
+ return null;
443
+ }
444
+
445
+ /** Human-readable reason a change was refused. */
446
+ export function domainChangeProblemMessage(problem: DomainChangeProblem): string {
447
+ return problem.message;
448
+ }
449
+
450
+ export type AdminHostImpact = {
451
+ /** Hostname serving the backoffice now. */
452
+ from: string;
453
+ fromSource: AdminHostSource;
454
+ /** Hostname that would serve it after the change. */
455
+ to: string;
456
+ toSource: AdminHostSource;
457
+ };
458
+
459
+ /**
460
+ * How a valid change would move the backoffice, or null when it would not.
461
+ *
462
+ * Computed by resolving the effective host on both sides of the change rather
463
+ * than by pattern-matching the edit, so every route to a moved backoffice is
464
+ * caught by construction: retyping the flagged row's domain, promoting another
465
+ * row, deleting or unflagging the current one, and — where the table is what
466
+ * decides — editing the mapping the fallback lands on.
467
+ *
468
+ * A deployment pinned by `SITE_URL` gets `null` for all of them, because none
469
+ * of those edits change where the backoffice is actually served.
470
+ */
471
+ export function adminHostImpact(
472
+ domains: LocaleDomainRecord[],
473
+ change: AdminDomainChange,
474
+ options: AdminHostOptions,
475
+ ): AdminHostImpact | null {
476
+ const before = resolveEffectiveAdminHost(domains, options);
477
+ const after = resolveEffectiveAdminHost(applyDomainChange(domains, change), options);
478
+ if (before.host === after.host) return null;
479
+ return { from: before.host, fromSource: before.source, to: after.host, toSource: after.source };
480
+ }
481
+
482
+ /**
483
+ * The confirmation shown before the backoffice moves to another hostname.
484
+ *
485
+ * `authoritative` is false when the caller could not find out whether
486
+ * `SITE_URL` is set — a screen whose host has registered no way to ask. The
487
+ * move is then stated as a condition rather than as a fact, because a
488
+ * deployment pinned by `SITE_URL` would not move at all.
489
+ */
490
+ export function adminHostImpactMessage(
491
+ impact: AdminHostImpact,
492
+ options: { authoritative?: boolean } = {},
493
+ ): string {
494
+ const move = `the backoffice will move from "${impact.from}" to "${impact.to}"`;
495
+ const lead =
496
+ options.authoritative === false
497
+ ? `Unless SITE_URL pins it on this deployment, ${move}.`
498
+ : `${move.charAt(0).toUpperCase()}${move.slice(1)}.`;
499
+ return `${lead} Open the backoffice on the new hostname after saving. Continue?`;
500
+ }
@@ -73,10 +73,21 @@ export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): Publ
73
73
  }
74
74
  if (res.kind !== "page") throw notFound();
75
75
 
76
- const [ctx] = await Promise.all([
76
+ const [ctx, page] = await Promise.all([
77
77
  context.queryClient.ensureQueryData(deps.pageContextQueryOptions),
78
78
  context.queryClient.ensureQueryData(deps.pageQueryOptions(res.pageKey)),
79
79
  ]);
80
+
81
+ /*
82
+ * `resolvePath` only knows the path map, which lists drafts too. The
83
+ * published-page query is the thing that applies the publication rules,
84
+ * and it answers with nothing for a draft, a scheduled page before its
85
+ * window opens, or a soft-deleted one. Without this the route would
86
+ * answer 200 with an empty body under the page's own title, description
87
+ * and canonical URL — publishing a draft's SEO to anyone who guesses the
88
+ * path.
89
+ */
90
+ if (page == null) throw notFound();
80
91
  return { pageKey: res.pageKey, ctx: ctx as Ctx };
81
92
  },
82
93