@bygghammar/cms-platform 0.93.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +9 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/admin/module-inventory.ts +67 -0
  6. package/src/admin/registry-metadata.ts +256 -0
  7. package/src/admin/semver.ts +111 -0
  8. package/src/admin/version-core.ts +233 -25
  9. package/src/admin/version-report.ts +295 -0
  10. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  11. package/src/backoffice/NavigationView.tsx +38 -0
  12. package/src/backoffice/PageBuilderView.tsx +149 -3
  13. package/src/backoffice/PagesListView.tsx +173 -57
  14. package/src/backoffice/SiteSettingsView.tsx +4 -0
  15. package/src/backoffice/StatusBadge.tsx +38 -2
  16. package/src/backoffice/VersionView.tsx +747 -66
  17. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  18. package/src/backoffice/routes/host.ts +24 -0
  19. package/src/backoffice/routes/index.tsx +148 -9
  20. package/src/backoffice/routes/pages.tsx +13 -0
  21. package/src/backoffice/shell/use-resolved-config.tsx +30 -5
  22. package/src/cms/booking-links.ts +176 -0
  23. package/src/cms/page-publication.ts +11 -6
  24. package/src/cms/page-visibility.ts +206 -0
  25. package/src/config/modules.ts +11 -0
  26. package/src/html/sanitize-html.ts +1 -1
  27. package/src/navigation/visibility.ts +235 -0
  28. package/src/popups/package-name.ts +8 -0
  29. package/src/popups/runtime.tsx +155 -4
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/published-cache.ts +23 -3
  33. package/src/server/schemas.ts +17 -0
  34. package/src/site/PageRenderer.tsx +34 -5
  35. package/src/site/SiteFooter.tsx +11 -2
  36. package/src/site/SiteHeader.tsx +41 -21
  37. package/src/site/link-visibility.tsx +28 -0
  38. package/src/site/route-aliases.ts +74 -0
  39. package/src/site/routes/preview-page.tsx +39 -3
  40. package/src/site/routes/public-page.tsx +81 -9
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Semantic Versioning 2.0.0 parsing and precedence.
3
+ *
4
+ * Package versions are compared by semver precedence, never as strings and
5
+ * never by publication date: `0.10.0` is newer than `0.9.0`, and a prerelease
6
+ * published after a stable release is not thereby newer than it. Build metadata
7
+ * (`+…`) is ignored for precedence, as the specification requires.
8
+ *
9
+ * Deliberately small and dependency-free: the version screen only needs to
10
+ * parse exact versions and order them. Ranges (`^1.2.0`, `>=0.2.0-m7.8`) are not
11
+ * evaluated here; they are shown to people as declared, not resolved.
12
+ */
13
+
14
+ export type SemVer = {
15
+ readonly major: number;
16
+ readonly minor: number;
17
+ readonly patch: number;
18
+ /** Dot-separated prerelease identifiers; empty for a release version. */
19
+ readonly prerelease: readonly (string | number)[];
20
+ /** The normalized `major.minor.patch[-prerelease]`, without build metadata. */
21
+ readonly version: string;
22
+ };
23
+
24
+ const NUMERIC = /^(0|[1-9]\d*)$/;
25
+ const IDENTIFIER = /^[0-9A-Za-z-]+$/;
26
+ const PATTERN =
27
+ /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
28
+
29
+ /** Parse an exact version. Returns `null` for anything that is not valid semver. */
30
+ export function parseSemver(input: unknown): SemVer | null {
31
+ if (typeof input !== "string") return null;
32
+ const value = input.trim();
33
+ if (value.length === 0 || value.length > 256) return null;
34
+ const match = PATTERN.exec(value);
35
+ if (!match) return null;
36
+ const [, major, minor, patch, pre, build] = match;
37
+ const nums = [major, minor, patch].map(Number);
38
+ if (nums.some((n) => !Number.isSafeInteger(n))) return null;
39
+
40
+ const prerelease: (string | number)[] = [];
41
+ if (pre !== undefined) {
42
+ for (const part of pre.split(".")) {
43
+ if (!IDENTIFIER.test(part)) return null;
44
+ if (/^\d+$/.test(part)) {
45
+ // Numeric identifiers must not have leading zeroes.
46
+ if (!NUMERIC.test(part)) return null;
47
+ const n = Number(part);
48
+ if (!Number.isSafeInteger(n)) return null;
49
+ prerelease.push(n);
50
+ } else {
51
+ prerelease.push(part);
52
+ }
53
+ }
54
+ }
55
+ if (build !== undefined && build.split(".").some((part) => !IDENTIFIER.test(part))) return null;
56
+
57
+ const [ma, mi, pa] = nums as [number, number, number];
58
+ return {
59
+ major: ma,
60
+ minor: mi,
61
+ patch: pa,
62
+ prerelease,
63
+ version: `${ma}.${mi}.${pa}${prerelease.length ? `-${prerelease.join(".")}` : ""}`,
64
+ };
65
+ }
66
+
67
+ function compareIdentifiers(a: string | number, b: string | number): number {
68
+ const an = typeof a === "number";
69
+ const bn = typeof b === "number";
70
+ if (an && bn) return a === b ? 0 : a < b ? -1 : 1;
71
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
72
+ if (an) return -1;
73
+ if (bn) return 1;
74
+ return a === b ? 0 : a < b ? -1 : 1;
75
+ }
76
+
77
+ /** Semver precedence: negative when `a` is older than `b`, positive when newer. */
78
+ export function compareSemver(a: SemVer, b: SemVer): number {
79
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1;
80
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
81
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
82
+ const ap = a.prerelease;
83
+ const bp = b.prerelease;
84
+ // A release version has higher precedence than any of its prereleases.
85
+ if (ap.length === 0 || bp.length === 0) {
86
+ if (ap.length === bp.length) return 0;
87
+ return ap.length === 0 ? 1 : -1;
88
+ }
89
+ for (let i = 0; i < Math.max(ap.length, bp.length); i += 1) {
90
+ if (i >= ap.length) return -1;
91
+ if (i >= bp.length) return 1;
92
+ const c = compareIdentifiers(ap[i] as string | number, bp[i] as string | number);
93
+ if (c !== 0) return c;
94
+ }
95
+ return 0;
96
+ }
97
+
98
+ /** True when the version carries prerelease identifiers (`1.0.0-rc.1`). */
99
+ export function isPrerelease(version: SemVer): boolean {
100
+ return version.prerelease.length > 0;
101
+ }
102
+
103
+ /**
104
+ * The prerelease line a version belongs to: its `major.minor.patch` and first
105
+ * prerelease identifier, for example `0.2.0-m7` for `0.2.0-m7.9`. `null` for a
106
+ * release version.
107
+ */
108
+ export function prereleaseLine(version: SemVer): string | null {
109
+ if (!isPrerelease(version)) return null;
110
+ return `${version.major}.${version.minor}.${version.patch}-${String(version.prerelease[0])}`;
111
+ }
@@ -1,10 +1,23 @@
1
1
  /**
2
- * Build metadata and platform release history for the version screen.
2
+ * Build metadata, release identity and package update checks for the version
3
+ * screen — the server half.
3
4
  *
4
5
  * 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.
6
+ * values only exist at build time); reading them defensively, reading the
7
+ * release identity from the runtime, and comparing installed first-party
8
+ * packages with the public registry is identical everywhere, so it lives here.
9
+ * The data contract and update policy are in `./version-report`.
7
10
  */
11
+ import { CMS_MODULE_LIST } from "../config/modules";
12
+ import { createRegistryClient, type RegistryClient } from "./registry-metadata";
13
+ import { isPrerelease, parseSemver } from "./semver";
14
+ import {
15
+ evaluatePackageUpdate,
16
+ type PackageReport,
17
+ type ReleaseHistoryEntry,
18
+ type ReleaseIdentity,
19
+ type VersionReport,
20
+ } from "./version-report";
8
21
 
9
22
  export type BuildInfo = {
10
23
  appVersion: string;
@@ -25,12 +38,22 @@ export type BuildConstants = {
25
38
  buildTime?: unknown;
26
39
  platformVersion?: unknown;
27
40
  platformRange?: unknown;
41
+ /**
42
+ * Installed first-party packages, read by the host build from its installed
43
+ * dependency tree: `[{ name, installed, declared, requiredByCms }]`.
44
+ */
45
+ packages?: unknown;
28
46
  };
29
47
 
30
48
  const text = (value: unknown, fallback: string): string =>
31
49
  typeof value === "string" && value ? value : fallback;
32
50
 
33
- /** Normalize the injected build constants into a complete `BuildInfo`. */
51
+ /**
52
+ * Normalize the injected build constants into a complete `BuildInfo`.
53
+ *
54
+ * Kept for hosts that still read the original shape. Its fallbacks fill gaps
55
+ * with placeholders, so the version screen itself reads `buildVersionReport`.
56
+ */
34
57
  export function readBuildInfoFrom(constants: BuildConstants): BuildInfo {
35
58
  return {
36
59
  appVersion: text(constants.appVersion, "0.0.0"),
@@ -43,34 +66,219 @@ export function readBuildInfoFrom(constants: BuildConstants): BuildInfo {
43
66
  /** The npm package name of the shared CMS platform. */
44
67
  export const PLATFORM_PACKAGE = "@bygghammar/cms-platform";
45
68
 
69
+ /**
70
+ * Every package the server may look up in the public registry: the CMS
71
+ * package and the first-party packages modules declare. Fixed in code; a
72
+ * request can never add to it.
73
+ */
74
+ export const FIRST_PARTY_PACKAGES: readonly string[] = [
75
+ PLATFORM_PACKAGE,
76
+ ...new Set(CMS_MODULE_LIST.flatMap((m) => (m.package ? [m.package] : []))),
77
+ ];
78
+
79
+ let sharedClient: RegistryClient | null = null;
80
+
81
+ /** The process-wide registry client, created on first use. */
82
+ export function platformRegistryClient(): RegistryClient {
83
+ sharedClient ??= createRegistryClient({ allowlist: FIRST_PARTY_PACKAGES });
84
+ return sharedClient;
85
+ }
86
+
87
+ type Env = Record<string, string | undefined>;
88
+
89
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
90
+ const RELEASE_LABEL = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/;
91
+
92
+ /**
93
+ * The deployed release, from the same runtime variables the health endpoint
94
+ * reports (`APP_VERSION`, then `VITE_PUBLIC_APP_VERSION`). The site's
95
+ * `package.json` version is not a release identity and is never used here.
96
+ */
97
+ export function readReleaseIdentity(env: Env = process.env): ReleaseIdentity {
98
+ for (const source of ["APP_VERSION", "VITE_PUBLIC_APP_VERSION"]) {
99
+ const raw = (env[source] ?? "").trim();
100
+ if (!raw || raw.toLowerCase() === "unknown") continue;
101
+ if (COMMIT_SHA.test(raw)) return { value: raw, kind: "commit", source, unrecognized: false };
102
+ if (RELEASE_LABEL.test(raw)) return { value: raw, kind: "label", source, unrecognized: false };
103
+ return { value: null, kind: null, source, unrecognized: true };
104
+ }
105
+ return { value: null, kind: null, source: null, unrecognized: false };
106
+ }
107
+
108
+ /**
109
+ * The environment name (`SITE_ENV`) and public hostname (`SITE_URL`), each only
110
+ * when it has a plain, expected form. Nothing else from the environment is read.
111
+ */
112
+ export function readEnvironment(env: Env = process.env): VersionReport["environment"] {
113
+ const rawName = (env["SITE_ENV"] ?? "").trim().toLowerCase();
114
+ const name = /^[a-z][a-z0-9_-]{0,31}$/.test(rawName) ? rawName : null;
115
+ let origin: string | null = null;
116
+ try {
117
+ const url = new URL((env["SITE_URL"] ?? "").trim());
118
+ if (url.protocol === "https:" || url.protocol === "http:") origin = url.hostname || null;
119
+ } catch {
120
+ origin = null;
121
+ }
122
+ return { name, origin };
123
+ }
124
+
125
+ function stringOrNull(value: unknown, max = 128): string | null {
126
+ return typeof value === "string" && value.trim() && value.length <= max ? value.trim() : null;
127
+ }
128
+
129
+ function isoOrNull(value: unknown): string | null {
130
+ if (typeof value !== "string" || !value) return null;
131
+ const ms = Date.parse(value);
132
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
133
+ }
134
+
135
+ type InstalledEntry = {
136
+ installed: string | null;
137
+ declared: string | null;
138
+ requiredByCms: string | null;
139
+ };
140
+
141
+ /** The injected package list, keyed by name, allowlisted packages only. */
142
+ export function readInstalledPackages(constants: BuildConstants): Map<string, InstalledEntry> {
143
+ const out = new Map<string, InstalledEntry>();
144
+ if (Array.isArray(constants.packages)) {
145
+ for (const item of constants.packages as unknown[]) {
146
+ if (typeof item !== "object" || item === null) continue;
147
+ const rec = item as Record<string, unknown>;
148
+ const name = rec["name"];
149
+ if (typeof name !== "string" || !FIRST_PARTY_PACKAGES.includes(name)) continue;
150
+ out.set(name, {
151
+ installed: stringOrNull(rec["installed"]),
152
+ declared: stringOrNull(rec["declared"]),
153
+ requiredByCms: stringOrNull(rec["requiredByCms"]),
154
+ });
155
+ }
156
+ }
157
+ // Hosts built before the package list existed still inject the CMS version.
158
+ if (!out.has(PLATFORM_PACKAGE)) {
159
+ const version = stringOrNull(constants.platformVersion);
160
+ out.set(PLATFORM_PACKAGE, {
161
+ installed: version === "unknown" ? null : version,
162
+ declared: stringOrNull(constants.platformRange) === "unknown" ? null : stringOrNull(constants.platformRange),
163
+ requiredByCms: null,
164
+ });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ export type VersionReportOptions = {
170
+ constants: BuildConstants;
171
+ /** Runtime environment; defaults to `process.env`. */
172
+ env?: Env;
173
+ /** Ask the registry again now, subject to the client's minimum interval. */
174
+ refresh?: boolean;
175
+ /**
176
+ * When this release went live — only from real evidence the host holds.
177
+ * Omit when nothing records it; the screen then says it was not recorded.
178
+ */
179
+ deployedAt?: string | null;
180
+ /** How many recent CMS publications to list. Default 5. */
181
+ historyLimit?: number;
182
+ registry?: RegistryClient;
183
+ now?: () => Date;
184
+ };
185
+
186
+ /** Assemble the version screen's report. Never throws for registry trouble. */
187
+ export async function buildVersionReport(options: VersionReportOptions): Promise<VersionReport> {
188
+ const registry = options.registry ?? platformRegistryClient();
189
+ const now = options.now ?? (() => new Date());
190
+ const installed = readInstalledPackages(options.constants);
191
+
192
+ const rows: { name: string; role: PackageReport["role"] }[] = [
193
+ { name: PLATFORM_PACKAGE, role: "cms" },
194
+ ...FIRST_PARTY_PACKAGES.filter((n) => n !== PLATFORM_PACKAGE).map((name) => ({
195
+ name,
196
+ role: "module" as const,
197
+ })),
198
+ ];
199
+
200
+ const lookups = await Promise.all(
201
+ rows.map((row) => registry.lookup(row.name, { refresh: options.refresh === true })),
202
+ );
203
+
204
+ const packages: PackageReport[] = rows.map((row, i) => {
205
+ const entry = installed.get(row.name);
206
+ const lookup = lookups[i]!;
207
+ return {
208
+ name: row.name,
209
+ role: row.role,
210
+ installed: entry?.installed ?? null,
211
+ declared: entry?.declared ?? null,
212
+ requiredByCms: row.role === "module" ? (entry?.requiredByCms ?? null) : null,
213
+ registry: lookup.check,
214
+ update: evaluatePackageUpdate(entry?.installed ?? null, lookup.metadata),
215
+ };
216
+ });
217
+
218
+ const cms = packages[0]!;
219
+ const cmsMetadata = lookups[0]!.metadata;
220
+ const cmsInstalled = parseSemver(cms.installed);
221
+ const releaseHistory: ReleaseHistoryEntry[] = cmsMetadata
222
+ ? Object.entries(cmsMetadata.versions)
223
+ .filter(([, v]) => v.publishedAt !== null)
224
+ .map(([version, v]) => {
225
+ const sv = parseSemver(version);
226
+ return {
227
+ version,
228
+ publishedAt: v.publishedAt,
229
+ prerelease: sv ? isPrerelease(sv) : false,
230
+ current: cmsInstalled !== null && cmsInstalled.version === version,
231
+ };
232
+ })
233
+ .sort((a, b) => ((a.publishedAt ?? "") < (b.publishedAt ?? "") ? 1 : -1))
234
+ .slice(0, options.historyLimit ?? 5)
235
+ : [];
236
+
237
+ const nextAttempts = rows
238
+ .map((row) => registry.nextAttemptAt(row.name))
239
+ .filter((t): t is number => t !== null);
240
+
241
+ return {
242
+ schemaVersion: 1,
243
+ generatedAt: now().toISOString(),
244
+ release: readReleaseIdentity(options.env),
245
+ environment: readEnvironment(options.env),
246
+ builtAt: isoOrNull(options.constants.buildTime),
247
+ deployedAt: isoOrNull(options.deployedAt),
248
+ packages,
249
+ releaseHistory,
250
+ nextCheckAllowedAt: nextAttempts.length ? new Date(Math.max(...nextAttempts)).toISOString() : null,
251
+ };
252
+ }
253
+
254
+ /** The legacy release list, derived from a report's history (newest publication first). */
255
+ export function releasesFromReport(report: VersionReport, limit = 3): PlatformRelease[] {
256
+ return report.releaseHistory.slice(0, limit).map((r) => ({
257
+ version: r.version,
258
+ publishedAt: r.publishedAt,
259
+ current: r.current,
260
+ }));
261
+ }
262
+
46
263
  /**
47
264
  * The most recent published platform releases, newest first.
48
265
  *
49
266
  * Network failures are not an error worth surfacing on an information screen —
50
- * the screen still shows the running version from the build constants.
267
+ * the screen still shows the running version from the build constants. Reads
268
+ * through the shared bounded, cached registry client.
51
269
  */
52
270
  export async function fetchPlatformReleases(
53
271
  currentVersion: string,
54
272
  limit = 3,
55
273
  ): 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
- }
274
+ const { metadata } = await platformRegistryClient().lookup(PLATFORM_PACKAGE);
275
+ if (!metadata) return [];
276
+ return Object.entries(metadata.versions)
277
+ .map(([version, v]) => ({
278
+ version,
279
+ publishedAt: v.publishedAt,
280
+ current: version === currentVersion,
281
+ }))
282
+ .sort((a, b) => ((a.publishedAt ?? "") < (b.publishedAt ?? "") ? 1 : -1))
283
+ .slice(0, limit);
76
284
  }