@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/routes.ts
CHANGED
|
@@ -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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
const
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
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(
|
|
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
|
|
255
|
-
|
|
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 =
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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 }> =
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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,
|
package/src/site/site-url.ts
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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, "&")
|
|
66
|
+
.replace(/</g, "<")
|
|
67
|
+
.replace(/>/g, ">")
|
|
68
|
+
.replace(/"/g, """);
|
|
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="${
|
|
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
|
-
|
|
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
|
}
|