@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
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "../i18n/locales";
19
+ import { domainOrigin, isValidCanonicalDomain } from "./host";
19
20
  import type { Localized, LocalizedImage, LocalizedString } from "../i18n/types";
20
21
  import { t, tv } from "../i18n/resolve";
21
22
 
@@ -124,27 +125,162 @@ export function fallbackPageDef(pageKey: string): PageDef {
124
125
  };
125
126
  }
126
127
 
128
+ /**
129
+ * The absolute origin `locale` is served from, or "" when it has none.
130
+ *
131
+ * A row whose domain is not a usable hostname is skipped rather than returned,
132
+ * and the scan continues: mappings written before the backoffice validated
133
+ * them are still in the table, and one bad row must not hide a good one for
134
+ * the same language.
135
+ */
127
136
  function originForLocale(locale: Locale, domains: RouteLocaleDomain[]): string {
128
- const match = domains.find((d) => d.locale === locale);
129
- if (!match) return "";
130
- const domain = match.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
131
- return `https://${domain}`;
137
+ for (const d of domains) {
138
+ if (d.locale !== locale) continue;
139
+ const mapped = domainOrigin(d.domain);
140
+ if (mapped) return mapped;
141
+ }
142
+ return "";
143
+ }
144
+
145
+ /**
146
+ * A caller-supplied origin reduced to `scheme://host[:port]`, or "" when the
147
+ * value cannot be one.
148
+ *
149
+ * Sitemaps are the reason this exists. Their serializer writes `<loc>` exactly
150
+ * as handed to it, so a missing origin does not degrade into a relative URL —
151
+ * it produces `<loc>/en/about</loc>`, which is not a location at all. An
152
+ * unusable origin yields "" here and the sitemap is then empty, which a
153
+ * crawler can at least read correctly.
154
+ */
155
+ export function publicOrigin(value: string | null | undefined): string {
156
+ const raw = (value ?? "").trim().replace(/\/+$/, "");
157
+ if (!/^https?:\/\//i.test(raw)) return "";
158
+ try {
159
+ return new URL(raw).origin;
160
+ } catch {
161
+ return "";
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The origin the page's own canonical URL is built from.
167
+ *
168
+ * Request matching and canonical output are different jobs. A request that
169
+ * arrives on `voyara.se` when the Swedish mapping stores `www.voyara.se` is
170
+ * served — the matcher folds apex and one `www.` together on purpose — but the
171
+ * canonical it publishes is the *stored* hostname, so the alias does not
172
+ * become a second indexable address for the same page.
173
+ *
174
+ * When the current locale has no valid mapping the request origin remains the
175
+ * answer: a preview host, or a language this deployment serves without giving
176
+ * it a domain, still renders a real page that still needs a canonical.
177
+ */
178
+ function canonicalOrigin(
179
+ locale: Locale,
180
+ domains: RouteLocaleDomain[],
181
+ origin: string,
182
+ ): string {
183
+ return originForLocale(locale, domains) || origin;
132
184
  }
133
185
 
134
186
  /**
135
- * Build the sitemap index entries — one sitemap URL per configured domain.
136
- * Independent of any page registry.
187
+ * Origin an alternate for `locale` may use, or null when it may not have one.
188
+ *
189
+ * The domain table decides which of two modes the deployment is in.
190
+ *
191
+ * SINGLE-ORIGIN MODE (`domains.length === 0`)
192
+ * Nothing is mapped, so every language is served from the one hostname the
193
+ * request arrived on, under locale-prefixed routes — `https://example.com/`
194
+ * and `https://example.com/en`. The current origin is the honest answer for
195
+ * all of them, and this is the behaviour the CMS has always had.
196
+ *
197
+ * EXPLICIT DOMAIN-MAPPING MODE (`domains.length > 0`)
198
+ * The mappings are authoritative. A locale earns an hreflang link, a sitemap
199
+ * alternate and a locale-specific absolute origin by having a domain of its
200
+ * own, and a locale missing from a non-empty table gets none of the three —
201
+ * never a fall back to the current origin. That fall back is what this used
202
+ * to do, and it invents the URL: a site that maps Swedish to its domain and
203
+ * supports English without one published `https://<swedish domain>/en/...`,
204
+ * an address that answers 404, offered to crawlers as the English page.
205
+ *
206
+ * A missing alternate costs nothing. A wrong one costs the page.
137
207
  */
138
- export function sitemapIndexEntries(domains: RouteLocaleDomain[] = []): Array<{
208
+ function alternateOrigin(
209
+ locale: Locale,
210
+ domains: RouteLocaleDomain[],
211
+ origin: string,
212
+ ): string | null {
213
+ if (domains.length === 0) return origin;
214
+ return originForLocale(locale, domains) || null;
215
+ }
216
+
217
+ /**
218
+ * The locale and origin `x-default` should point at, or null when it must be
219
+ * omitted.
220
+ *
221
+ * In explicit domain-mapping mode the answer comes from the mapping table, not
222
+ * from the compiled `DEFAULT_LOCALE`: the deployment states which of its
223
+ * domains is the fallback by flagging it, and a site whose only mapping is
224
+ * `en -> voyara.com` has an English x-default however the package was built.
225
+ * Reading the compiled constant instead pointed x-default at a locale the
226
+ * deployment never mapped, which is the same fabricated origin the alternates
227
+ * were just taught to refuse.
228
+ *
229
+ * In single-origin mode there is no table to ask, so the site's own default
230
+ * locale is the answer — served, like every other locale, from this origin.
231
+ *
232
+ * Either way the page must actually publish that locale. Nothing is invented
233
+ * to fill the slot; the link is simply left out.
234
+ */
235
+ function xDefaultTarget(
236
+ domains: RouteLocaleDomain[],
237
+ origin: string,
238
+ localesForPage: Locale[],
239
+ ): { locale: Locale; origin: string } | null {
240
+ if (domains.length === 0) {
241
+ return localesForPage.includes(DEFAULT_LOCALE) ? { locale: DEFAULT_LOCALE, origin } : null;
242
+ }
243
+ for (const mapping of domains) {
244
+ if (mapping.isDefault !== true) continue;
245
+ if (!isValidCanonicalDomain(mapping.domain)) continue;
246
+ const mapped = domainOrigin(mapping.domain);
247
+ if (!mapped) continue;
248
+ const locale = mapping.locale as Locale;
249
+ if (!localesForPage.includes(locale)) continue;
250
+ return { locale, origin: mapped };
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Build the sitemap index entries. Independent of any page registry.
257
+ *
258
+ * In explicit domain-mapping mode that is one sitemap URL per configured
259
+ * domain, with unusable rows dropped rather than turned into
260
+ * `/sitemap/sv.xml` on no host at all.
261
+ *
262
+ * In single-origin mode — no rows, one hostname serving every language under
263
+ * locale prefixes — the index is built from `origin` and `locales` instead.
264
+ * Returning nothing there was wrong: those per-locale sitemaps exist and are
265
+ * reachable, and an empty index is a claim that they are not.
266
+ */
267
+ export function sitemapIndexEntries(
268
+ domains: RouteLocaleDomain[] = [],
269
+ origin: string = "",
270
+ locales: Locale[] = [...SUPPORTED_LOCALES],
271
+ ): Array<{
139
272
  locale: Locale;
140
273
  url: string;
141
274
  }> {
142
- return domains.map((d) => {
143
- const domain = d.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
144
- return {
145
- locale: d.locale as Locale,
146
- url: `https://${domain}/sitemap/${d.locale}.xml`,
147
- };
275
+ if (domains.length === 0) {
276
+ const base = publicOrigin(origin);
277
+ if (!base) return [];
278
+ return locales.map((l) => ({ locale: l, url: `${base}/sitemap/${l}.xml` }));
279
+ }
280
+ return domains.flatMap((d) => {
281
+ const mapped = domainOrigin(d.domain);
282
+ if (!mapped) return [];
283
+ return [{ locale: d.locale as Locale, url: `${mapped}/sitemap/${d.locale}.xml` }];
148
284
  });
149
285
  }
150
286
 
@@ -171,6 +307,8 @@ export type SiteRoutes = {
171
307
  locale: Locale,
172
308
  domains?: RouteLocaleDomain[],
173
309
  overrides?: PageSeoOverrides,
310
+ /** Origin serving the site, required in single-origin mode. */
311
+ origin?: string,
174
312
  ) => SitemapEntry[];
175
313
  sitemapIndexEntries: typeof sitemapIndexEntries;
176
314
  };
@@ -234,7 +372,12 @@ export function createSiteRoutes(PAGES: Record<string, PageDef>): SiteRoutes {
234
372
  const ogImage = seo.ogImage;
235
373
  const ogType = seo.ogType ?? "website";
236
374
  const twitterCard = seo.twitterCard ?? "summary_large_image";
237
- const canonical = resolveAbsolute(pageKey, locale, origin, overrides);
375
+ const canonical = resolveAbsolute(
376
+ pageKey,
377
+ locale,
378
+ canonicalOrigin(locale, domains, origin),
379
+ overrides,
380
+ );
238
381
  const localesForPage = SUPPORTED_LOCALES.filter((l) => typeof page.slug[l] === "string");
239
382
 
240
383
  const meta: HeadMeta[] = [
@@ -251,9 +394,13 @@ export function createSiteRoutes(PAGES: Record<string, PageDef>): SiteRoutes {
251
394
  ];
252
395
 
253
396
  for (const alt of localesForPage) {
254
- if (alt !== locale) {
255
- meta.push({ property: "og:locale:alternate", content: alt });
256
- }
397
+ if (alt === locale) continue;
398
+ // In explicit domain-mapping mode a language with no usable domain has
399
+ // no URL on this deployment, so naming it as an available translation
400
+ // points at nothing. Same rule as the hreflang links below, for the same
401
+ // reason: a missing alternate costs nothing, a wrong one costs the page.
402
+ if (domains.length > 0 && !originForLocale(alt, domains)) continue;
403
+ meta.push({ property: "og:locale:alternate", content: alt });
257
404
  }
258
405
 
259
406
  if (seo.keywords) {
@@ -273,21 +420,31 @@ export function createSiteRoutes(PAGES: Record<string, PageDef>): SiteRoutes {
273
420
  if (alt) meta.push({ name: "twitter:image:alt", content: alt });
274
421
  }
275
422
 
423
+ // Canonical stands whatever the alternates do: the locale being rendered is
424
+ // routable by definition, and dropping its canonical over a missing sibling
425
+ // domain would take the page's own SEO down with the alternate's.
276
426
  const links: HeadLink[] = [{ rel: "canonical", href: canonical }];
277
427
  for (const alt of localesForPage) {
278
- const altOrigin = originForLocale(alt, domains) || origin;
428
+ const altOrigin = alternateOrigin(alt, domains, origin);
429
+ if (altOrigin === null) continue;
279
430
  links.push({
280
431
  rel: "alternate",
281
432
  hrefLang: alt,
282
433
  href: resolveAbsolute(pageKey, alt, altOrigin, overrides),
283
434
  });
284
435
  }
285
- const defaultOrigin = originForLocale(DEFAULT_LOCALE, domains) || origin;
286
- links.push({
287
- rel: "alternate",
288
- hrefLang: "x-default",
289
- href: resolveAbsolute(pageKey, DEFAULT_LOCALE, defaultOrigin, overrides),
290
- });
436
+ // x-default follows the deployment's own default — the flagged mapping in
437
+ // explicit mode, the site's default locale in single-origin mode. When
438
+ // there is no valid one, or the page does not publish it, the link is
439
+ // omitted rather than pointed at a fabricated origin.
440
+ const fallback = xDefaultTarget(domains, origin, localesForPage);
441
+ if (fallback) {
442
+ links.push({
443
+ rel: "alternate",
444
+ hrefLang: "x-default",
445
+ href: resolveAbsolute(pageKey, fallback.locale, fallback.origin, overrides),
446
+ });
447
+ }
291
448
 
292
449
  const scripts: HeadScript[] = [];
293
450
  if (seo.jsonLd) {
@@ -307,25 +464,36 @@ export function createSiteRoutes(PAGES: Record<string, PageDef>): SiteRoutes {
307
464
  locale: Locale,
308
465
  domains: RouteLocaleDomain[] = [],
309
466
  overrides?: PageSeoOverrides,
467
+ publicOriginValue: string = "",
310
468
  ): SitemapEntry[] {
311
- const origin = originForLocale(locale, domains);
469
+ // In explicit domain-mapping mode a locale with no usable domain of its own
470
+ // has no sitemap: every URL in it would be an address this deployment does
471
+ // not serve. In single-origin mode the one hostname serves every language,
472
+ // so the caller's origin is what the URLs hang off — and without one there
473
+ // is nothing to emit, because a sitemap of relative paths is not a sitemap.
474
+ const origin =
475
+ domains.length > 0 ? originForLocale(locale, domains) : publicOrigin(publicOriginValue);
476
+ if (!origin) return [];
477
+
312
478
  const out: SitemapEntry[] = [];
313
479
  for (const key of Object.keys(PAGES) as PageKey[]) {
314
480
  const locales = publishedLocales(key, overrides);
315
481
  if (!locales.includes(locale)) continue;
316
- const alternates: Array<{ locale: Locale | "x-default"; url: string }> = locales.map((l) => ({
317
- locale: l,
318
- url: resolveAbsolute(key, l, originForLocale(l, domains) || origin, overrides),
319
- }));
320
- alternates.push({
321
- locale: "x-default",
322
- url: resolveAbsolute(
323
- key,
324
- DEFAULT_LOCALE,
325
- originForLocale(DEFAULT_LOCALE, domains) || origin,
326
- overrides,
327
- ),
328
- });
482
+ const alternates: Array<{ locale: Locale | "x-default"; url: string }> = [];
483
+ for (const l of locales) {
484
+ const altOrigin = alternateOrigin(l, domains, origin);
485
+ if (altOrigin === null) continue;
486
+ alternates.push({ locale: l, url: resolveAbsolute(key, l, altOrigin, overrides) });
487
+ }
488
+ // Same rule as the page head, so a crawler cannot be told two different
489
+ // things about which URL is the fallback.
490
+ const fallback = xDefaultTarget(domains, origin, locales);
491
+ if (fallback) {
492
+ alternates.push({
493
+ locale: "x-default",
494
+ url: resolveAbsolute(key, fallback.locale, fallback.origin, overrides),
495
+ });
496
+ }
329
497
  out.push({
330
498
  pageKey: key,
331
499
  locale,
@@ -7,29 +7,92 @@
7
7
  * Pure module — no database, no filesystem, no network. Safe to import from
8
8
  * tests, scripts and server code.
9
9
  */
10
+ import { hostMatchKey, parseCanonicalDomain } from "./host";
10
11
 
11
- /** Strip scheme, path, trailing dots/slashes and a leading `www.`. */
12
+ /** Strip scheme, path, trailing dots/slashes, a port and a leading `www.`. */
12
13
  export function normalizeHost(value: string): string {
13
- return (value ?? "")
14
- .trim()
15
- .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
16
- .replace(/\/.*$/, "")
17
- .replace(/\.+$/, "")
18
- .replace(/^www\./i, "")
19
- .toLowerCase();
14
+ return hostMatchKey(value);
20
15
  }
21
16
 
22
- /** The configured site origin (`https://host`), or null when unset. */
23
- export function siteOrigin(env: NodeJS.ProcessEnv = process.env): string | null {
24
- const raw = (env["SITE_URL"] ?? "").trim().replace(/\/+$/, "");
17
+ /** Highest port number a URL authority can carry. */
18
+ const MAX_PORT = 65535;
19
+
20
+ /** `SITE_URL` broken into the three parts every helper here needs. */
21
+ type SiteUrlParts = { scheme: "http" | "https"; host: string; port: string };
22
+
23
+ /**
24
+ * Read `SITE_URL` into a scheme, a canonical hostname and a port — or null.
25
+ *
26
+ * The raw value is validated *before* it is normalized, which is the whole
27
+ * point of this function. Prefixing `https://` onto anything that does not
28
+ * already look like a URL turns `ftp://example.com` into
29
+ * `https://ftp://example.com`, and normalizing that leaves the hostname `ftp`
30
+ * — a host nobody configured, invented from a value that should have been
31
+ * refused. Every helper below returns null rather than a guess, because a
32
+ * fabricated hostname reaches the backoffice redirect and the canonical URL.
33
+ *
34
+ * `parseCanonicalDomain` does the rejecting: it already knows about
35
+ * credentials, non-HTTP schemes with or without slashes, internal whitespace,
36
+ * unusable labels and non-numeric ports.
37
+ */
38
+ function parseSiteUrl(env: NodeJS.ProcessEnv = process.env): SiteUrlParts | null {
39
+ const raw = (env["SITE_URL"] ?? "").trim();
25
40
  if (!raw) return null;
26
- return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
41
+
42
+ const parsed = parseCanonicalDomain(raw);
43
+ if (!parsed.ok) return null;
44
+
45
+ // A bare hostname is allowed and means https. An explicit scheme is kept,
46
+ // and anything other than http/https was already refused above.
47
+ const explicit = /^(https?):\/\//i.exec(raw);
48
+ const scheme = (explicit ? (explicit[1] as string).toLowerCase() : "https") as "http" | "https";
49
+
50
+ const authority = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
51
+ const port = /:(\d{1,5})$/.exec(authority)?.[1] ?? "";
52
+ if (port && (Number(port) === 0 || Number(port) > MAX_PORT)) return null;
53
+
54
+ return { scheme, host: parsed.domain, port };
27
55
  }
28
56
 
29
- /** The hostname of SITE_URL, normalized, or null when SITE_URL is unset. */
57
+ /**
58
+ * The configured site origin, or null when `SITE_URL` is unset or unusable.
59
+ *
60
+ * An origin is scheme, host and port — never a path. A trailing path left on
61
+ * the end reappears in every link built by concatenation, so
62
+ * `https://voyara.se/path/` would yield `https://voyara.se/path/om-oss`.
63
+ */
64
+ export function siteOrigin(env: NodeJS.ProcessEnv = process.env): string | null {
65
+ const parts = parseSiteUrl(env);
66
+ if (!parts) return null;
67
+ return `${parts.scheme}://${parts.host}${parts.port ? `:${parts.port}` : ""}`;
68
+ }
69
+
70
+ /**
71
+ * The hostname of SITE_URL as a *match key*, or null when it is unset or
72
+ * unusable.
73
+ *
74
+ * A leading `www.` is folded away, which is what comparing an incoming request
75
+ * host against the configured one wants. It is not what stating a hostname
76
+ * wants — see `siteCanonicalHost`.
77
+ */
30
78
  export function siteHost(env: NodeJS.ProcessEnv = process.env): string | null {
31
- const origin = siteOrigin(env);
32
- if (!origin) return null;
33
- const host = normalizeHost(origin);
34
- return host || null;
79
+ const parts = parseSiteUrl(env);
80
+ if (!parts) return null;
81
+ return hostMatchKey(parts.host) || null;
82
+ }
83
+
84
+ /**
85
+ * The hostname of SITE_URL in its canonical form, or null when SITE_URL is
86
+ * unset or unusable. A configured `www.` is preserved.
87
+ *
88
+ * Matching and canonical output are different jobs, so they get different
89
+ * functions. `siteHost` answers "is this request on the configured host?", and
90
+ * folding `www.` away is correct there. Anything that *states* the hostname —
91
+ * the effective admin host, and the redirect built from it — must use this
92
+ * one: a deployment whose SITE_URL is `https://www.voyara.se` is served from
93
+ * `www.voyara.se`, and sending the backoffice to an apex it never configured
94
+ * is a redirect to a hostname that may not answer at all.
95
+ */
96
+ export function siteCanonicalHost(env: NodeJS.ProcessEnv = process.env): string | null {
97
+ return parseSiteUrl(env)?.host ?? null;
35
98
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Applying menu/footer typography at runtime.
3
+ *
4
+ * `SiteFontLinks` renders the Google stylesheet link. React 19 hoists
5
+ * `<link rel="stylesheet" precedence>` into `<head>` and de-duplicates it by
6
+ * href, so the header and the footer can each declare what they need without a
7
+ * site having to wire anything into its root route, and asking for the same
8
+ * family twice still costs one request. When nothing is configured the
9
+ * component renders nothing at all — no preconnect, no stylesheet, no bytes.
10
+ */
11
+
12
+ import { Suspense, type CSSProperties, type ReactElement } from "react";
13
+ import { preconnect } from "react-dom";
14
+
15
+ import {
16
+ resolveTypography,
17
+ type ResolvedFont,
18
+ type SiteTypography,
19
+ type TypographySlot,
20
+ } from "../config/typography";
21
+ import { googleFontsCssUrl, type FontRequest } from "./google-fonts";
22
+
23
+ export type { FontRequest };
24
+
25
+ /** The font requests a typography setting implies, de-duplicated by the URL builder. */
26
+ export function typographyFontRequests(typography: SiteTypography | undefined): FontRequest[] {
27
+ const resolved = resolveTypography(typography);
28
+ const requests: FontRequest[] = [];
29
+ for (const font of Object.values(resolved)) {
30
+ if (font.googleFamily) requests.push({ family: font.googleFamily, weights: font.weights });
31
+ }
32
+ return requests;
33
+ }
34
+
35
+ /**
36
+ * Loads the Google families a set of requests needs. Renders null when empty.
37
+ *
38
+ * The stylesheet sits inside its own `<Suspense>` boundary on purpose. A
39
+ * `precedence` stylesheet suspends whatever boundary contains it until the file
40
+ * loads, and the header must not wait for fonts.googleapis.com to answer before
41
+ * a visitor sees the menu — the URL asks for `display=swap` precisely so text
42
+ * paints in the fallback face straight away. An empty boundary keeps React's
43
+ * hoisting and de-duplication while blocking nothing.
44
+ *
45
+ * Preconnects go through `react-dom`'s own `preconnect`, which de-duplicates by
46
+ * origin, so the header and footer together emit one hint per origin.
47
+ */
48
+ export function SiteFontLinks({ requests }: { requests: readonly FontRequest[] }): ReactElement | null {
49
+ const href = googleFontsCssUrl(requests);
50
+ if (!href) return null;
51
+ preconnect("https://fonts.googleapis.com");
52
+ preconnect("https://fonts.gstatic.com", { crossOrigin: "anonymous" });
53
+ return (
54
+ <Suspense fallback={null}>
55
+ <link rel="stylesheet" href={href} precedence="high" />
56
+ </Suspense>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * The inline style for a slot, or `undefined` when the theme font should stay
62
+ * untouched. Returning `undefined` — rather than a style naming the current
63
+ * theme token — is what keeps an un-configured site pixel-identical.
64
+ */
65
+ export function fontStyle(font: ResolvedFont | undefined): CSSProperties | undefined {
66
+ return font?.fontFamily ? { fontFamily: font.fontFamily } : undefined;
67
+ }
68
+
69
+ /** Resolve one slot straight from a site's stored typography. */
70
+ export function slotFont(
71
+ typography: SiteTypography | undefined,
72
+ slot: TypographySlot,
73
+ ): ResolvedFont {
74
+ return resolveTypography(typography)[slot];
75
+ }
@@ -6,6 +6,24 @@
6
6
  * into three final HTML strings that the root shell renders.
7
7
  */
8
8
 
9
+ import { hostMatchKey, hostsMatch, isValidCanonicalDomain } from "../site/host";
10
+
11
+ /**
12
+ * Raised when an insert cannot be identified, and shown to the administrator
13
+ * verbatim.
14
+ *
15
+ * It lives in this module rather than beside the server function that raises
16
+ * it because the screen has to recognise it too, and a browser-facing route
17
+ * importing a server core to read one string pulls that whole module into the
18
+ * client graph. Both sides import it from here.
19
+ *
20
+ * The wording asks for a reload rather than a retry on purpose: the row may
21
+ * already be there, so sending it again is the one action that could duplicate
22
+ * it.
23
+ */
24
+ export const MISSING_CREATED_ID =
25
+ "The server did not return the new profile ID. Reload before trying again.";
26
+
9
27
  export type InjectionScopeType = "global" | "locale" | "domain";
10
28
 
11
29
  export type ExtraMeta = { name: string; content: string };
@@ -36,18 +54,30 @@ export type InjectionBundle = {
36
54
 
37
55
  export const EMPTY_BUNDLE: InjectionBundle = { head: "", bodyStart: "", bodyEnd: "" };
38
56
 
39
- /** Escapes a value used inside a generated snippet / attribute. */
57
+ /** Escapes a value used inside a generated snippet. IDs are identifiers. */
40
58
  function esc(v: string): string {
41
59
  return v.replace(/[\\'"<>]/g, "");
42
60
  }
43
61
 
62
+ /** Escapes a value placed inside a double-quoted HTML attribute. */
63
+ function attr(v: string): string {
64
+ return v
65
+ .replace(/&/g, "&amp;")
66
+ .replace(/</g, "&lt;")
67
+ .replace(/>/g, "&gt;")
68
+ .replace(/"/g, "&quot;");
69
+ }
70
+
71
+ /**
72
+ * The key a request host is matched on.
73
+ *
74
+ * Delegates to the platform's single hostname implementation, so a
75
+ * domain-scoped profile matches exactly what the Languages & domains system
76
+ * would match — apex and one leading `www.` included, casing and ports
77
+ * handled the same way.
78
+ */
44
79
  export function normalizeHostname(host: string): string {
45
- return host
46
- .replace(/^https?:\/\//, "")
47
- .replace(/\/+$/, "")
48
- .replace(/:\d+$/, "")
49
- .replace(/^www\./, "")
50
- .toLowerCase();
80
+ return hostMatchKey(host);
51
81
  }
52
82
 
53
83
  /* ---------------- Snippet builders ---------------- */
@@ -82,10 +112,17 @@ export function tiktokPixelHead(id: string): string {
82
112
  return `<script>!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"];ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e};ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{};ttq._i[e]=[];ttq._i[e]._u=r;ttq._t=ttq._t||{};ttq._t[e]=+new Date;ttq._o=ttq._o||{};ttq._o[e]=n||{};var o=d.createElement("script");o.type="text/javascript";o.async=!0;o.src=r+"?sdkid="+e+"&lib="+t;var a=d.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};ttq.load('${p}');ttq.page();}(window,document,'ttq');</script>`;
83
113
  }
84
114
 
115
+ /**
116
+ * Verification meta tags.
117
+ *
118
+ * Both halves are escaped as HTML attribute values: this field is meta-only.
119
+ * Raw markup belongs in the head / body-start / body-end code fields, which
120
+ * are deliberately passed through untouched.
121
+ */
85
122
  export function extraMetaTags(items: ExtraMeta[]): string {
86
123
  return items
87
124
  .filter((m) => m.name && m.content)
88
- .map((m) => `<meta name="${esc(m.name)}" content="${m.content.replace(/"/g, "&quot;")}" />`)
125
+ .map((m) => `<meta name="${attr(m.name)}" content="${attr(m.content)}" />`)
89
126
  .join("");
90
127
  }
91
128
 
@@ -136,7 +173,13 @@ export function selectProfiles(
136
173
  .filter((p) => {
137
174
  if (p.scope_type === "global") return true;
138
175
  if (p.scope_type === "locale") return p.scope_value === locale;
139
- return normalizeHostname(p.scope_value) === h;
176
+ /*
177
+ * Validated, not just normalized. Normalizing strips the scheme, so
178
+ * `ftp://example.com` compared afterwards is the perfectly ordinary
179
+ * `example.com` — and a profile nobody could have saved through the
180
+ * backoffice would start firing on a host it never named.
181
+ */
182
+ return isValidCanonicalDomain(p.scope_value) && hostsMatch(p.scope_value, h);
140
183
  })
141
184
  .sort((a, b) => rank[a.scope_type] - rank[b.scope_type] || a.sort_order - b.sort_order);
142
185
  }