@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10

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 (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -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
  }
@@ -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
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Side-effect stylesheet imports (the popup package's admin.css). The bundler
3
+ * that consumes this source understands them natively; this declaration only
4
+ * teaches the package's own typechecker that they exist.
5
+ */
6
+ declare module "*.css";
package/src/ui/Reveal.tsx CHANGED
@@ -1,25 +1,87 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
2
 
3
+ /**
4
+ * How a block appears when the reader reaches it.
5
+ *
6
+ * - `immediate` — present and visible, with no entrance animation.
7
+ * - `fade` — fades and rises gently the first time it enters the viewport.
8
+ */
9
+ export type RevealMode = "immediate" | "fade";
10
+
11
+ export const REVEAL_MODES: readonly RevealMode[] = ["immediate", "fade"];
12
+
13
+ /**
14
+ * Blocks saved before the setting existed always faded in, so that is what a
15
+ * block without one keeps doing. Changing this would restyle every page that
16
+ * is already published.
17
+ */
18
+ export const DEFAULT_REVEAL_MODE: RevealMode = "fade";
19
+
20
+ /** Read a stored value that may predate the setting, or be anything at all. */
21
+ export function normalizeRevealMode(value: unknown): RevealMode {
22
+ return value === "immediate" || value === "fade" ? value : DEFAULT_REVEAL_MODE;
23
+ }
24
+
25
+ function prefersReducedMotion(): boolean {
26
+ return (
27
+ typeof window !== "undefined" &&
28
+ typeof window.matchMedia === "function" &&
29
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
30
+ );
31
+ }
32
+
33
+ // Hiding has to happen before the browser paints, or the reader sees the
34
+ // content flash in and back out. On the server there is nothing to lay out.
35
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
36
+
37
+ const MOTION = "transition-all duration-[900ms] ease-out will-change-transform";
38
+
39
+ /**
40
+ * Fade a block in the first time it is scrolled into view.
41
+ *
42
+ * The content is *rendered visible* and only then hidden, by a layout effect,
43
+ * for the animation. That order matters: the server's HTML, a crawler's copy
44
+ * and a reader whose script never arrived all get the text plainly visible,
45
+ * and nothing worth reading is behind an IntersectionObserver. Readers who
46
+ * asked for reduced motion are never hidden at all.
47
+ */
3
48
  export function Reveal({
4
49
  children,
5
50
  delay = 0,
6
51
  className = "",
52
+ mode = DEFAULT_REVEAL_MODE,
7
53
  }: {
8
54
  children: ReactNode;
9
55
  delay?: number;
10
56
  className?: string;
57
+ /** `immediate` skips the animation entirely. */
58
+ mode?: RevealMode;
11
59
  }) {
12
60
  const ref = useRef<HTMLDivElement>(null);
13
- const [shown, setShown] = useState(false);
61
+ const [state, setState] = useState<"visible" | "armed" | "revealed">("visible");
14
62
 
15
- useEffect(() => {
63
+ useBeforePaint(() => {
64
+ // A live preview can change an already-mounted block from Fade to
65
+ // Immediate while it is still below the viewport. Clear the armed state
66
+ // before taking any non-animation path, or the old opacity-0 classes would
67
+ // remain after its observer has been disconnected.
68
+ if (
69
+ mode !== "fade" ||
70
+ prefersReducedMotion() ||
71
+ typeof IntersectionObserver === "undefined"
72
+ ) {
73
+ setState("visible");
74
+ return;
75
+ }
16
76
  const el = ref.current;
17
77
  if (!el) return;
78
+
79
+ setState("armed");
18
80
  const io = new IntersectionObserver(
19
81
  (entries) => {
20
82
  for (const e of entries) {
21
83
  if (e.isIntersecting) {
22
- setShown(true);
84
+ setState("revealed");
23
85
  io.disconnect();
24
86
  break;
25
87
  }
@@ -29,17 +91,20 @@ export function Reveal({
29
91
  );
30
92
  io.observe(el);
31
93
  return () => io.disconnect();
32
- }, []);
94
+ }, [mode]);
33
95
 
34
96
  return (
35
97
  <div
36
98
  ref={ref}
37
- style={{ transitionDelay: `${delay}ms` }}
99
+ style={state === "visible" ? undefined : { transitionDelay: `${delay}ms` }}
38
100
  className={[
39
- "transition-all duration-[900ms] ease-out will-change-transform",
40
- shown ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6",
101
+ state === "visible" ? "" : MOTION,
102
+ state === "armed" ? "opacity-0 translate-y-6" : "",
103
+ state === "revealed" ? "opacity-100 translate-y-0" : "",
41
104
  className,
42
- ].join(" ")}
105
+ ]
106
+ .filter(Boolean)
107
+ .join(" ")}
43
108
  >
44
109
  {children}
45
110
  </div>