@bygghammar/cms-platform 0.47.0 → 0.49.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 +15 -0
- package/package.json +26 -3
- package/scripts/create-cms-site.mjs +0 -0
- package/src/auth/browser.ts +154 -0
- package/src/auth/middleware.ts +36 -0
- package/src/cms/page-draft-store.ts +121 -0
- package/src/media/FocalImage.tsx +152 -0
- package/src/media/LazyVideo.tsx +202 -0
- package/src/media/MediaMetaProvider.tsx +33 -0
- package/src/media/use-media-upload.ts +90 -0
- package/src/runtime/error-capture.ts +84 -0
- package/src/runtime/error-page.ts +31 -0
- package/src/runtime/error-reporting.ts +61 -0
- package/src/server/auth-context.ts +58 -0
- package/src/server/clients.ts +64 -0
- package/src/server/env.ts +74 -0
- package/src/server/http/backup.ts +152 -0
- package/src/server/http/cms-content.ts +26 -0
- package/src/server/http/fonts.ts +38 -0
- package/src/server/http/health.ts +38 -0
- package/src/server/http/robots.ts +53 -0
- package/src/server/http/sitemap.ts +63 -0
- package/src/site/PageRenderer.tsx +151 -0
- package/src/site/routes.ts +350 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized routing + SEO engine (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* A site owns only its page registry: a `pageKey -> { slug, seo }` map.
|
|
5
|
+
* Everything derived from it — path building, canonical URLs, hreflang
|
|
6
|
+
* alternates, the head payload and sitemap entries — is generic and lives
|
|
7
|
+
* here, so every site gets identical SEO behaviour and identical fixes.
|
|
8
|
+
*
|
|
9
|
+
* Usage in a site:
|
|
10
|
+
*
|
|
11
|
+
* export const PAGES: Record<string, PageDef> = { ... };
|
|
12
|
+
* export const {
|
|
13
|
+
* resolveRoute, resolveAbsolute, pageHeadMeta,
|
|
14
|
+
* sitemapEntries, sitemapIndexEntries, publishedLocales,
|
|
15
|
+
* } = createSiteRoutes(PAGES);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "../i18n/locales";
|
|
19
|
+
import type { Localized, LocalizedImage, LocalizedString } from "../i18n/types";
|
|
20
|
+
import { t, tv } from "../i18n/resolve";
|
|
21
|
+
|
|
22
|
+
export type SeoMeta = {
|
|
23
|
+
title: LocalizedString;
|
|
24
|
+
description: LocalizedString;
|
|
25
|
+
ogTitle?: LocalizedString;
|
|
26
|
+
ogDescription?: LocalizedString;
|
|
27
|
+
ogImage?: LocalizedImage;
|
|
28
|
+
ogType?: "website" | "article" | "product";
|
|
29
|
+
twitterCard?: "summary" | "summary_large_image";
|
|
30
|
+
keywords?: LocalizedString;
|
|
31
|
+
robots?: string;
|
|
32
|
+
jsonLd?: Localized<Record<string, unknown>>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type PageDef = {
|
|
36
|
+
key: string;
|
|
37
|
+
slug: Localized<string>;
|
|
38
|
+
seo: SeoMeta;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A CMS override for a single page. All fields are partial —
|
|
43
|
+
* anything omitted falls back to the static registry entry.
|
|
44
|
+
*/
|
|
45
|
+
export type PageSeoOverride = {
|
|
46
|
+
slugs?: Localized<string>;
|
|
47
|
+
seo?: Partial<SeoMeta>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Map keyed by `pageKey`; a missing key means "use registry defaults". */
|
|
51
|
+
export type PageSeoOverrides = Record<string, PageSeoOverride>;
|
|
52
|
+
|
|
53
|
+
export type PageKey = string;
|
|
54
|
+
|
|
55
|
+
/** Minimal shape of a locale→domain mapping row. */
|
|
56
|
+
export type RouteLocaleDomain = { locale: string; domain: string; isDefault?: boolean };
|
|
57
|
+
|
|
58
|
+
/** Locales in which the default locale gets NO URL prefix. */
|
|
59
|
+
export const PREFIX_STRATEGY = {
|
|
60
|
+
default: "none",
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
type HeadMeta =
|
|
64
|
+
| { title: string }
|
|
65
|
+
| { name: string; content: string }
|
|
66
|
+
| { property: string; content: string };
|
|
67
|
+
|
|
68
|
+
type HeadLink = { rel: string; href: string; hrefLang?: string; type?: string };
|
|
69
|
+
|
|
70
|
+
type HeadScript = {
|
|
71
|
+
type: "application/ld+json";
|
|
72
|
+
children: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type PageHead = {
|
|
76
|
+
meta: HeadMeta[];
|
|
77
|
+
links: HeadLink[];
|
|
78
|
+
scripts?: HeadScript[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type SitemapEntry = {
|
|
82
|
+
pageKey: PageKey;
|
|
83
|
+
locale: Locale;
|
|
84
|
+
url: string;
|
|
85
|
+
alternates: Array<{ locale: Locale | "x-default"; url: string }>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function localePrefix(locale: Locale): string {
|
|
89
|
+
return locale === DEFAULT_LOCALE ? "" : `/${locale}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge a static page definition with an optional CMS override.
|
|
94
|
+
* Returns a new PageDef; the original inputs are not mutated.
|
|
95
|
+
*/
|
|
96
|
+
export function mergePageDef(def: PageDef, override?: PageSeoOverride): PageDef {
|
|
97
|
+
if (!override) return def;
|
|
98
|
+
return {
|
|
99
|
+
key: def.key,
|
|
100
|
+
slug: { ...def.slug, ...(override.slugs ?? {}) },
|
|
101
|
+
seo: { ...def.seo, ...(override.seo ?? {}) },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Pages created in the page builder have no static registry entry. They are
|
|
107
|
+
* still routable (`/<key>`) and fully editable through `page_seo`, so build a
|
|
108
|
+
* neutral definition for them and let the CMS override supply the real values.
|
|
109
|
+
*/
|
|
110
|
+
export function fallbackPageDef(pageKey: string): PageDef {
|
|
111
|
+
const slug: Localized<string> = {};
|
|
112
|
+
const title: Localized<string> = {};
|
|
113
|
+
const description: Localized<string> = {};
|
|
114
|
+
const pretty = pageKey.replace(/[-_]+/g, " ").replace(/^\s*\w/, (c) => c.toUpperCase());
|
|
115
|
+
for (const l of SUPPORTED_LOCALES) {
|
|
116
|
+
slug[l] = pageKey;
|
|
117
|
+
title[l] = pretty;
|
|
118
|
+
description[l] = "";
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
key: pageKey,
|
|
122
|
+
slug: slug as Localized<string>,
|
|
123
|
+
seo: { title: title as LocalizedString, description: description as LocalizedString },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
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}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build the sitemap index entries — one sitemap URL per configured domain.
|
|
136
|
+
* Independent of any page registry.
|
|
137
|
+
*/
|
|
138
|
+
export function sitemapIndexEntries(domains: RouteLocaleDomain[] = []): Array<{
|
|
139
|
+
locale: Locale;
|
|
140
|
+
url: string;
|
|
141
|
+
}> {
|
|
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
|
+
};
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type SiteRoutes = {
|
|
152
|
+
PAGES: Record<string, PageDef>;
|
|
153
|
+
mergePageDef: typeof mergePageDef;
|
|
154
|
+
fallbackPageDef: typeof fallbackPageDef;
|
|
155
|
+
resolveRoute: (pageKey: PageKey, locale?: Locale, overrides?: PageSeoOverrides) => string;
|
|
156
|
+
resolveAbsolute: (
|
|
157
|
+
pageKey: PageKey,
|
|
158
|
+
locale?: Locale,
|
|
159
|
+
origin?: string,
|
|
160
|
+
overrides?: PageSeoOverrides,
|
|
161
|
+
) => string;
|
|
162
|
+
publishedLocales: (pageKey: PageKey, overrides?: PageSeoOverrides) => Locale[];
|
|
163
|
+
pageHeadMeta: (
|
|
164
|
+
pageKey: PageKey,
|
|
165
|
+
locale?: Locale,
|
|
166
|
+
origin?: string,
|
|
167
|
+
overrides?: PageSeoOverrides,
|
|
168
|
+
domains?: RouteLocaleDomain[],
|
|
169
|
+
) => PageHead;
|
|
170
|
+
sitemapEntries: (
|
|
171
|
+
locale: Locale,
|
|
172
|
+
domains?: RouteLocaleDomain[],
|
|
173
|
+
overrides?: PageSeoOverrides,
|
|
174
|
+
) => SitemapEntry[];
|
|
175
|
+
sitemapIndexEntries: typeof sitemapIndexEntries;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** Build the routing/SEO engine for one site's page registry. */
|
|
179
|
+
export function createSiteRoutes(PAGES: Record<string, PageDef>): SiteRoutes {
|
|
180
|
+
function getDef(pageKey: PageKey, overrides?: PageSeoOverrides): PageDef {
|
|
181
|
+
const base = PAGES[pageKey] ?? fallbackPageDef(pageKey);
|
|
182
|
+
return mergePageDef(base, overrides?.[pageKey]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function slugFor(page: PageDef, locale: Locale): string | undefined {
|
|
186
|
+
return tv(page.slug, locale);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pathFromDef(page: PageDef, locale: Locale): string {
|
|
190
|
+
const slug = slugFor(page, locale) ?? slugFor(page, DEFAULT_LOCALE) ?? "";
|
|
191
|
+
const prefix = localePrefix(locale);
|
|
192
|
+
const path = slug ? `/${slug}` : "";
|
|
193
|
+
const full = `${prefix}${path}`;
|
|
194
|
+
return full === "" ? "/" : full;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveRoute(
|
|
198
|
+
pageKey: PageKey,
|
|
199
|
+
locale: Locale = DEFAULT_LOCALE,
|
|
200
|
+
overrides?: PageSeoOverrides,
|
|
201
|
+
): string {
|
|
202
|
+
return pathFromDef(getDef(pageKey, overrides), locale);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolveAbsolute(
|
|
206
|
+
pageKey: PageKey,
|
|
207
|
+
locale: Locale = DEFAULT_LOCALE,
|
|
208
|
+
origin: string = "",
|
|
209
|
+
overrides?: PageSeoOverrides,
|
|
210
|
+
): string {
|
|
211
|
+
const base = origin.replace(/\/+$/, "");
|
|
212
|
+
return `${base}${resolveRoute(pageKey, locale, overrides)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function publishedLocales(pageKey: PageKey, overrides?: PageSeoOverrides): Locale[] {
|
|
216
|
+
const def = getDef(pageKey, overrides);
|
|
217
|
+
return SUPPORTED_LOCALES.filter((l) => typeof def.slug[l] === "string");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pageHeadMeta(
|
|
221
|
+
pageKey: PageKey,
|
|
222
|
+
locale: Locale = DEFAULT_LOCALE,
|
|
223
|
+
origin: string = "",
|
|
224
|
+
overrides?: PageSeoOverrides,
|
|
225
|
+
domains: RouteLocaleDomain[] = [],
|
|
226
|
+
): PageHead {
|
|
227
|
+
const page = getDef(pageKey, overrides);
|
|
228
|
+
const seo = page.seo;
|
|
229
|
+
|
|
230
|
+
const title = t(seo.title, locale);
|
|
231
|
+
const description = t(seo.description, locale);
|
|
232
|
+
const ogTitle = t(seo.ogTitle ?? seo.title, locale);
|
|
233
|
+
const ogDescription = t(seo.ogDescription ?? seo.description, locale);
|
|
234
|
+
const ogImage = seo.ogImage;
|
|
235
|
+
const ogType = seo.ogType ?? "website";
|
|
236
|
+
const twitterCard = seo.twitterCard ?? "summary_large_image";
|
|
237
|
+
const canonical = resolveAbsolute(pageKey, locale, origin, overrides);
|
|
238
|
+
const localesForPage = SUPPORTED_LOCALES.filter((l) => typeof page.slug[l] === "string");
|
|
239
|
+
|
|
240
|
+
const meta: HeadMeta[] = [
|
|
241
|
+
{ title },
|
|
242
|
+
{ name: "description", content: description },
|
|
243
|
+
{ property: "og:title", content: ogTitle },
|
|
244
|
+
{ property: "og:description", content: ogDescription },
|
|
245
|
+
{ property: "og:type", content: ogType },
|
|
246
|
+
{ property: "og:url", content: canonical },
|
|
247
|
+
{ property: "og:locale", content: locale },
|
|
248
|
+
{ name: "twitter:card", content: twitterCard },
|
|
249
|
+
{ name: "twitter:title", content: ogTitle },
|
|
250
|
+
{ name: "twitter:description", content: ogDescription },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
for (const alt of localesForPage) {
|
|
254
|
+
if (alt !== locale) {
|
|
255
|
+
meta.push({ property: "og:locale:alternate", content: alt });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (seo.keywords) {
|
|
260
|
+
const kw = t(seo.keywords, locale);
|
|
261
|
+
if (kw) meta.push({ name: "keywords", content: kw });
|
|
262
|
+
}
|
|
263
|
+
if (seo.robots) {
|
|
264
|
+
meta.push({ name: "robots", content: seo.robots });
|
|
265
|
+
}
|
|
266
|
+
if (ogImage) {
|
|
267
|
+
const src = tv(ogImage.srcByLocale, locale) ?? ogImage.src;
|
|
268
|
+
const abs = src.startsWith("http") ? src : `${origin}${src}`;
|
|
269
|
+
const alt = t(ogImage.alt, locale);
|
|
270
|
+
meta.push({ property: "og:image", content: abs });
|
|
271
|
+
if (alt) meta.push({ property: "og:image:alt", content: alt });
|
|
272
|
+
meta.push({ name: "twitter:image", content: abs });
|
|
273
|
+
if (alt) meta.push({ name: "twitter:image:alt", content: alt });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const links: HeadLink[] = [{ rel: "canonical", href: canonical }];
|
|
277
|
+
for (const alt of localesForPage) {
|
|
278
|
+
const altOrigin = originForLocale(alt, domains) || origin;
|
|
279
|
+
links.push({
|
|
280
|
+
rel: "alternate",
|
|
281
|
+
hrefLang: alt,
|
|
282
|
+
href: resolveAbsolute(pageKey, alt, altOrigin, overrides),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
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
|
+
});
|
|
291
|
+
|
|
292
|
+
const scripts: HeadScript[] = [];
|
|
293
|
+
if (seo.jsonLd) {
|
|
294
|
+
const ld = tv(seo.jsonLd, locale);
|
|
295
|
+
if (ld) {
|
|
296
|
+
scripts.push({
|
|
297
|
+
type: "application/ld+json",
|
|
298
|
+
children: JSON.stringify(ld),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { meta, links, scripts: scripts.length ? scripts : undefined };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sitemapEntries(
|
|
307
|
+
locale: Locale,
|
|
308
|
+
domains: RouteLocaleDomain[] = [],
|
|
309
|
+
overrides?: PageSeoOverrides,
|
|
310
|
+
): SitemapEntry[] {
|
|
311
|
+
const origin = originForLocale(locale, domains);
|
|
312
|
+
const out: SitemapEntry[] = [];
|
|
313
|
+
for (const key of Object.keys(PAGES) as PageKey[]) {
|
|
314
|
+
const locales = publishedLocales(key, overrides);
|
|
315
|
+
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
|
+
});
|
|
329
|
+
out.push({
|
|
330
|
+
pageKey: key,
|
|
331
|
+
locale,
|
|
332
|
+
url: resolveAbsolute(key, locale, origin, overrides),
|
|
333
|
+
alternates,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
PAGES,
|
|
341
|
+
mergePageDef,
|
|
342
|
+
fallbackPageDef,
|
|
343
|
+
resolveRoute,
|
|
344
|
+
resolveAbsolute,
|
|
345
|
+
publishedLocales,
|
|
346
|
+
pageHeadMeta,
|
|
347
|
+
sitemapEntries,
|
|
348
|
+
sitemapIndexEntries,
|
|
349
|
+
};
|
|
350
|
+
}
|