@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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
package/src/site/host.ts
ADDED
|
@@ -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
|
|