@bygghammar/cms-platform 0.57.1 → 0.58.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.58.0
4
+
5
+ - Utilities sweep: the public-page context loader (`site/page-context`), the
6
+ deployment origin resolver (`site/public-origin`), the self-hosted font bundle
7
+ (`site/font-bundle`) and the version screen's build/release metadata
8
+ (`admin/version-core`) are now package-owned factories. Sites pass their own
9
+ data sources, compiled origin and font assets.
10
+ - New drift guard: `config/package-owned-modules` lists every module a site must
11
+ keep as a thin wrapper, so a site test fails the build if platform behaviour is
12
+ reimplemented locally.
13
+
3
14
  ## 0.57.0
4
15
 
5
16
  - Server-function inputs are now platform contracts: `server/schemas` ships the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.57.1",
3
+ "version": "0.58.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -172,6 +172,11 @@
172
172
  "./backoffice/ResetPasswordView": "./src/backoffice/ResetPasswordView.tsx",
173
173
  "./backoffice/routes/pages": "./src/backoffice/routes/pages.tsx",
174
174
  "./config/define-site": "./src/config/define-site.ts",
175
+ "./site/page-context": "./src/site/page-context.ts",
176
+ "./site/public-origin": "./src/site/public-origin.ts",
177
+ "./site/font-bundle": "./src/site/font-bundle.ts",
178
+ "./admin/version-core": "./src/admin/version-core.ts",
179
+ "./config/package-owned-modules": "./src/config/package-owned-modules.ts",
175
180
  "./server/schema-drift": "./src/server/schema-drift.ts",
176
181
  "./server/media-focus-core": "./src/server/media-focus-core.ts",
177
182
  "./server/media-meta-core": "./src/server/media-meta-core.ts",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Build metadata and platform release history for the version screen.
3
+ *
4
+ * The build constants are injected by the site's Vite config (they must be, the
5
+ * values only exist at build time); reading them defensively and fetching the
6
+ * package's published releases is identical everywhere, so it lives here.
7
+ */
8
+
9
+ export type BuildInfo = {
10
+ appVersion: string;
11
+ buildTime: string;
12
+ platformVersion: string;
13
+ platformRange: string;
14
+ };
15
+
16
+ export type PlatformRelease = {
17
+ version: string;
18
+ publishedAt: string | null;
19
+ current: boolean;
20
+ };
21
+
22
+ /** Raw build constants as provided by the host build. */
23
+ export type BuildConstants = {
24
+ appVersion?: unknown;
25
+ buildTime?: unknown;
26
+ platformVersion?: unknown;
27
+ platformRange?: unknown;
28
+ };
29
+
30
+ const text = (value: unknown, fallback: string): string =>
31
+ typeof value === "string" && value ? value : fallback;
32
+
33
+ /** Normalize the injected build constants into a complete `BuildInfo`. */
34
+ export function readBuildInfoFrom(constants: BuildConstants): BuildInfo {
35
+ return {
36
+ appVersion: text(constants.appVersion, "0.0.0"),
37
+ buildTime: text(constants.buildTime, new Date().toISOString()),
38
+ platformVersion: text(constants.platformVersion, "unknown"),
39
+ platformRange: text(constants.platformRange, "unknown"),
40
+ };
41
+ }
42
+
43
+ /** The npm package name of the shared CMS platform. */
44
+ export const PLATFORM_PACKAGE = "@bygghammar/cms-platform";
45
+
46
+ /**
47
+ * The most recent published platform releases, newest first.
48
+ *
49
+ * Network failures are not an error worth surfacing on an information screen —
50
+ * the screen still shows the running version from the build constants.
51
+ */
52
+ export async function fetchPlatformReleases(
53
+ currentVersion: string,
54
+ limit = 3,
55
+ ): Promise<PlatformRelease[]> {
56
+ try {
57
+ const res = await fetch(
58
+ `https://registry.npmjs.org/${PLATFORM_PACKAGE.replace("/", "%2F")}`,
59
+ { headers: { accept: "application/json" } },
60
+ );
61
+ if (!res.ok) return [];
62
+ const json = (await res.json()) as { time?: Record<string, string> };
63
+ const time = json.time ?? {};
64
+ return Object.entries(time)
65
+ .filter(([v]) => v !== "created" && v !== "modified")
66
+ .map(([version, publishedAt]) => ({
67
+ version,
68
+ publishedAt,
69
+ current: version === currentVersion,
70
+ }))
71
+ .sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
72
+ .slice(0, limit);
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Drift guard manifest.
3
+ *
4
+ * Each entry names a module a site is expected to keep as a thin wrapper, and
5
+ * the package export it must delegate to. A site test walks this manifest and
6
+ * fails the build when a local file stops importing the package module — the
7
+ * failure mode this whole extraction exists to prevent, where a site quietly
8
+ * reimplements platform behaviour and then drifts away from every other site.
9
+ */
10
+
11
+ export type OwnedModule = {
12
+ /** Path relative to the site's project root. */
13
+ site: string;
14
+ /** Package specifier the site file must reference. */
15
+ packageModule: string;
16
+ /** Whether the site is allowed not to have the file at all. */
17
+ optional?: boolean;
18
+ };
19
+
20
+ export const PACKAGE_OWNED_MODULES: OwnedModule[] = [
21
+ { site: "src/lib/i18n/routes.ts", packageModule: "@bygghammar/cms-platform/site/routes" },
22
+ {
23
+ site: "src/lib/i18n/page-context.ts",
24
+ packageModule: "@bygghammar/cms-platform/site/page-context",
25
+ },
26
+ {
27
+ site: "src/lib/site/public-origin.ts",
28
+ packageModule: "@bygghammar/cms-platform/site/public-origin",
29
+ },
30
+ { site: "src/lib/site/site-url.ts", packageModule: "@bygghammar/cms-platform/site/site-url" },
31
+ { site: "src/lib/fonts/bundle.ts", packageModule: "@bygghammar/cms-platform/site/font-bundle" },
32
+ {
33
+ site: "src/lib/admin/version.server.ts",
34
+ packageModule: "@bygghammar/cms-platform/admin/version-core",
35
+ },
36
+ {
37
+ site: "src/lib/backup/backup.server.ts",
38
+ packageModule: "@bygghammar/cms-platform/server/backup-core",
39
+ },
40
+ {
41
+ site: "src/components/site/SiteHeader.tsx",
42
+ packageModule: "@bygghammar/cms-platform/site/SiteHeader",
43
+ },
44
+ {
45
+ site: "src/components/site/SiteFooter.tsx",
46
+ packageModule: "@bygghammar/cms-platform/site/SiteFooter",
47
+ },
48
+ {
49
+ site: "src/components/site/PageRenderer.tsx",
50
+ packageModule: "@bygghammar/cms-platform/site/PageRenderer",
51
+ },
52
+ {
53
+ site: "src/components/site/blocks/FormBlock.tsx",
54
+ packageModule: "@bygghammar/cms-platform/blocks/components/FormBlock",
55
+ },
56
+ {
57
+ site: "src/components/site/blocks/CourseListBlock.tsx",
58
+ packageModule: "@bygghammar/cms-platform/blocks/components/TripListBlock",
59
+ optional: true,
60
+ },
61
+ { site: "src/lib/blocks/registry.tsx", packageModule: "@bygghammar/cms-platform/blocks/registry" },
62
+ { site: "src/lib/site/definition.ts", packageModule: "@bygghammar/cms-platform/config/define-site" },
63
+ ];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Self-hosted WOFF2 payloads, bundled into the server build.
3
+ *
4
+ * Fonts deliberately do NOT live in `public/`: files served by the framework's
5
+ * static-asset handler bypass every application layer, so they come back without
6
+ * `Cache-Control` and without `Expires`. Serving them from a real route puts them
7
+ * back under the deployment's caching policy in both runtimes.
8
+ *
9
+ * The site imports its own `.woff2?inline` assets (they are brand assets) and
10
+ * hands the resulting data URIs to this factory; decoding and caching are shared.
11
+ */
12
+
13
+ export type FontBundle = {
14
+ /** Every filename this bundle can serve. */
15
+ filenames: string[];
16
+ /** Font bytes, decoded once per process. `undefined` for unknown names. */
17
+ fontBytes: (name: string) => Uint8Array | undefined;
18
+ };
19
+
20
+ function fromDataUri(uri: string): Uint8Array {
21
+ const base64 = uri.slice(uri.indexOf(",") + 1);
22
+ const binary = atob(base64);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
25
+ return bytes;
26
+ }
27
+
28
+ /** Build a font bundle from a `filename -> data URI` map. */
29
+ export function createFontBundle(dataUris: Record<string, string>): FontBundle {
30
+ const decoded = new Map<string, Uint8Array>();
31
+ return {
32
+ filenames: Object.keys(dataUris),
33
+ fontBytes(name: string): Uint8Array | undefined {
34
+ const cached = decoded.get(name);
35
+ if (cached) return cached;
36
+ const uri = dataUris[name];
37
+ if (!uri) return undefined;
38
+ const bytes = fromDataUri(uri);
39
+ decoded.set(name, bytes);
40
+ return bytes;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Public-page context: the single server round-trip every public route needs.
3
+ *
4
+ * A public page needs four facts before it can render its `<head>`: the CMS SEO
5
+ * overrides, the locale implied by the request domain, the request origin, and
6
+ * the locale/domain map used for cross-domain hreflang. Fetching them one by one
7
+ * per route is what made this glue site-local; the engine is identical for every
8
+ * site, so it lives here and the site supplies only its data sources.
9
+ */
10
+ import { queryOptions } from "@tanstack/react-query";
11
+
12
+ import type { Locale } from "../i18n/locales";
13
+ import type { PageHead, PageSeoOverrides, SiteRoutes } from "./routes";
14
+
15
+ /** One `locale -> domain` mapping row, as stored by the domains module. */
16
+ export type PageContextDomain = { locale: string; domain: string };
17
+
18
+ export type PageContext<Row = unknown> = {
19
+ seoRows: Row[];
20
+ locale: Locale;
21
+ origin: string;
22
+ domains: PageContextDomain[];
23
+ };
24
+
25
+ export type PageContextDeps<Row> = {
26
+ /** Raw `page_seo` rows for this site. */
27
+ getSeoRows: () => Promise<Row[]>;
28
+ /** Locale + origin derived from the incoming request. */
29
+ getRequestLocale: () => Promise<{ locale: Locale; origin: string }>;
30
+ /** Configured locale domains. */
31
+ getLocaleDomains: () => Promise<PageContextDomain[]>;
32
+ /** Convert stored rows into the routing engine's override shape. */
33
+ rowsToOverrides: (rows: Row[] | undefined) => PageSeoOverrides;
34
+ /** The site's routing engine (from `createSiteRoutes`). */
35
+ routes: Pick<SiteRoutes, "resolveRoute" | "pageHeadMeta">;
36
+ /** Locale used when nothing else is known. */
37
+ defaultLocale: Locale;
38
+ /** Query key; defaults to `["page-context"]`. */
39
+ queryKey?: readonly unknown[];
40
+ /** Cache lifetime in ms; defaults to one minute. */
41
+ staleTime?: number;
42
+ };
43
+
44
+ export type SitePageContext<Row> = {
45
+ pageContextQueryOptions: ReturnType<typeof queryOptions<PageContext<Row>>>;
46
+ buildPageHead: (pageKey: string, context: PageContext<Row> | undefined) => PageHead;
47
+ canonicalPathFor: (pageKey: string, context: PageContext<Row> | undefined) => string;
48
+ };
49
+
50
+ /** Build the page-context loader + head helpers for one site. */
51
+ export function createPageContext<Row>(deps: PageContextDeps<Row>): SitePageContext<Row> {
52
+ async function fetchPageContext(): Promise<PageContext<Row>> {
53
+ const [seoRows, localeInfo, domains] = await Promise.all([
54
+ deps.getSeoRows(),
55
+ deps.getRequestLocale(),
56
+ deps.getLocaleDomains(),
57
+ ]);
58
+ return {
59
+ seoRows,
60
+ locale: localeInfo.locale,
61
+ origin: localeInfo.origin,
62
+ domains,
63
+ };
64
+ }
65
+
66
+ const options = queryOptions<PageContext<Row>>({
67
+ queryKey: deps.queryKey ?? ["page-context"],
68
+ queryFn: fetchPageContext,
69
+ staleTime: deps.staleTime ?? 60_000,
70
+ });
71
+
72
+ function buildPageHead(pageKey: string, context: PageContext<Row> | undefined): PageHead {
73
+ const locale = context?.locale ?? deps.defaultLocale;
74
+ const origin = context?.origin ?? "";
75
+ const overrides = deps.rowsToOverrides(context?.seoRows);
76
+ const domains = context?.domains ?? [];
77
+ return deps.routes.pageHeadMeta(pageKey, locale, origin, overrides, domains);
78
+ }
79
+
80
+ function canonicalPathFor(pageKey: string, context: PageContext<Row> | undefined): string {
81
+ const locale = context?.locale ?? deps.defaultLocale;
82
+ const overrides = deps.rowsToOverrides(context?.seoRows);
83
+ return deps.routes.resolveRoute(pageKey, locale, overrides);
84
+ }
85
+
86
+ return { pageContextQueryOptions: options, buildPageHead, canonicalPathFor };
87
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The canonical public origin for a deployment.
3
+ *
4
+ * The origin is a deployment fact, not editable content: preview, staging and
5
+ * production are different hosts. It is read from the environment, with the
6
+ * origin compiled into the site's source as the fallback, and is deliberately
7
+ * never stored as a database override — canonical URLs, sitemaps and auth
8
+ * redirects must be right before any database read happens.
9
+ */
10
+ import { originIsFromEnvironment, resolveSiteOrigin } from "../config/site-config";
11
+
12
+ export type PublicOriginDeps = {
13
+ /** Origin compiled into the build; used whenever no environment value is set. */
14
+ codeOrigin: string;
15
+ /** Reads the configured origin from the environment, or `undefined`. */
16
+ readEnvOrigin: () => string | undefined;
17
+ };
18
+
19
+ export type PublicOrigin = {
20
+ /** The origin this deployment should advertise. Always returns something. */
21
+ publicOrigin: () => string;
22
+ /**
23
+ * The origin this deployment was EXPLICITLY configured with, or `null`.
24
+ *
25
+ * Distinct from `publicOrigin()`: a deployment with no configuration is not
26
+ * the live site, it is a deployment whose configuration is missing. Answering
27
+ * that from a constant would make every unconfigured build claim to be
28
+ * production — the one answer that must never be reached by default.
29
+ */
30
+ configuredPublicOrigin: () => string | null;
31
+ /** Whether the running origin came from the environment rather than code. */
32
+ publicOriginFromEnv: () => boolean;
33
+ };
34
+
35
+ /** Build the origin accessors for one deployment. */
36
+ export function createPublicOrigin(deps: PublicOriginDeps): PublicOrigin {
37
+ return {
38
+ publicOrigin: () => resolveSiteOrigin(deps.codeOrigin, deps.readEnvOrigin()),
39
+ configuredPublicOrigin: () => {
40
+ const value = deps.readEnvOrigin();
41
+ if (typeof value !== "string") return null;
42
+ const trimmed = value.trim();
43
+ return trimmed ? trimmed : null;
44
+ },
45
+ publicOriginFromEnv: () => originIsFromEnvironment(deps.codeOrigin, deps.readEnvOrigin()),
46
+ };
47
+ }