@bygghammar/cms-platform 0.1.1 → 0.3.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/README.md CHANGED
@@ -9,6 +9,13 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
9
9
  - `src/blocks/icons.ts` — the icon set blocks may reference
10
10
  - `src/blocks/block-props-context.tsx` — block prop plumbing for inline editing
11
11
  - `src/i18n/*` — the localization core (locales, types, resolver, context)
12
+ - `src/backup/*` — archive/media safety, browser-side large-media export and
13
+ provider-neutral verification helpers
14
+ - `src/trips/*` — reusable trips data contract, editor and legacy course-data
15
+ compatibility. Each host supplies its own terminology (Courses, Cruises or
16
+ Trips).
17
+ - `src/navigation/*` — public and backoffice navigation contracts plus shared
18
+ active-state helpers. Menu content and visual headers remain per-site.
12
19
  - `src/theme.css` — the design-token contract + neutral starter theme
13
20
 
14
21
  ## What must never live here
@@ -17,6 +24,28 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
17
24
  - page content, SEO copy, media, CMS page JSON
18
25
  - Supabase generated types, auth users, secrets, API keys
19
26
  - route files and per-site deployment config
27
+ - actual navigation links, public slugs, brand-specific headers or menu styling
28
+ - site table lists, import order, database clients, authentication and RLS
29
+ policies; each host supplies those adapters to the shared backup core
30
+
31
+ ## Backup core
32
+
33
+ The package owns the CMS-wide, provider-neutral parts of backup and restore:
34
+
35
+ - media signature/path validation and secret sanitising
36
+ - streaming large media files into an archive in the authenticated browser
37
+ - archive progress and manifest contracts
38
+ - row-count, user/role and media verification helpers
39
+
40
+ The browser media exporter accepts a host storage adapter with only `list()`
41
+ and `download()` methods plus the host's expected archive format/schema
42
+ versions. Keep the dynamic import behind an `import.meta.env.SSR` guard in SSR
43
+ applications so the browser-only ZIP code cannot enter the optimized server
44
+ chunk graph.
45
+
46
+ The consuming site remains responsible for its dataset groups, table import
47
+ order, Supabase clients, superadmin authorization, HTTP routes, audit records
48
+ and database migrations. The package never contains site data or credentials.
20
49
 
21
50
  ## Per-site wiring
22
51
 
@@ -34,10 +63,7 @@ remove that module when the application's `package.json` declares
34
63
  `"sideEffects": false`.
35
64
 
36
65
  ```tsx
37
- import {
38
- initializeHostComponents,
39
- type HostComponents,
40
- } from "@bygghammar/cms-platform/host";
66
+ import { initializeHostComponents, type HostComponents } from "@bygghammar/cms-platform/host";
41
67
 
42
68
  const HOST_COMPONENTS = {
43
69
  FocalImage,
@@ -48,9 +74,8 @@ const HOST_COMPONENTS = {
48
74
  useMediaUpload,
49
75
  } satisfies Partial<HostComponents>;
50
76
 
51
- export const BLOCK_REGISTRY = initializeHostComponents(
52
- HOST_COMPONENTS,
53
- () => createBlockRegistry(),
77
+ export const BLOCK_REGISTRY = initializeHostComponents(HOST_COMPONENTS, () =>
78
+ createBlockRegistry(),
54
79
  );
55
80
  ```
56
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.1.1",
3
+ "version": "0.3.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": false,
@@ -34,10 +34,20 @@
34
34
  "./ui/Lightbox": "./src/ui/Lightbox.tsx",
35
35
  "./util/scroll-lock": "./src/util/scroll-lock.ts",
36
36
  "./html/sanitize-html": "./src/html/sanitize-html.ts",
37
+ "./backup": "./src/backup/index.ts",
38
+ "./backup/safety": "./src/backup/safety.ts",
39
+ "./backup/browser-media": "./src/backup/browser-media.ts",
40
+ "./backup/verification": "./src/backup/verification.ts",
37
41
  "./backoffice/inspectors/fields": "./src/backoffice/inspectors/fields.tsx",
38
42
  "./theme.css": "./src/theme.css",
39
43
  "./backoffice/inspectors/blocks": "./src/backoffice/inspectors/blocks.tsx",
40
- "./blocks/registry": "./src/blocks/registry.tsx"
44
+ "./blocks/registry": "./src/blocks/registry.tsx",
45
+ "./trips": "./src/trips/index.ts",
46
+ "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
47
+ "./navigation": "./src/navigation/index.ts"
48
+ },
49
+ "dependencies": {
50
+ "fflate": "^0.8.3"
41
51
  },
42
52
  "peerDependencies": {
43
53
  "@tanstack/react-query": ">=5",
@@ -0,0 +1,205 @@
1
+ import { strFromU8, strToU8, unzipSync, Zip, ZipDeflate, ZipPassThrough } from "fflate";
2
+
3
+ import { hasAllowedMediaExtension, isSafeArchivePath, verifyMediaFile } from "./safety";
4
+
5
+ export type BackupArchiveManifest = {
6
+ format: number | string;
7
+ schemaVersion: number | string;
8
+ groups: string[];
9
+ mediaFiles: number;
10
+ mediaBytes: number;
11
+ quarantinedMedia?: string[];
12
+ };
13
+
14
+ export type BackupStorageFile = {
15
+ name: string;
16
+ size: number;
17
+ contentType?: string | null;
18
+ updatedAt?: string | null;
19
+ };
20
+
21
+ export type BrowserBackupStorage = {
22
+ list(): Promise<BackupStorageFile[]>;
23
+ download(path: string): Promise<Uint8Array>;
24
+ };
25
+
26
+ export type MediaExportProgress = {
27
+ phase: "listing" | "downloading" | "finalizing";
28
+ completedFiles: number;
29
+ totalFiles: number;
30
+ completedBytes: number;
31
+ totalBytes: number;
32
+ currentFile?: string;
33
+ };
34
+
35
+ export type BrowserMediaExportOptions = {
36
+ baseArchive: Uint8Array;
37
+ groups: string[];
38
+ storage: BrowserBackupStorage;
39
+ expectedFormat: number | string;
40
+ expectedSchemaVersion: number | string;
41
+ onProgress?: (progress: MediaExportProgress) => void;
42
+ };
43
+
44
+ function readManifest<M extends BackupArchiveManifest>(
45
+ files: Record<string, Uint8Array>,
46
+ expectedFormat: number | string,
47
+ expectedSchemaVersion: number | string,
48
+ ): M {
49
+ const raw = files["manifest.json"];
50
+ if (!raw) throw new Error("The server metadata archive has no manifest.");
51
+
52
+ let manifest: M;
53
+ try {
54
+ manifest = JSON.parse(strFromU8(raw)) as M;
55
+ } catch {
56
+ throw new Error("The server metadata archive has an invalid manifest.");
57
+ }
58
+
59
+ if (
60
+ manifest.format !== expectedFormat ||
61
+ manifest.schemaVersion !== expectedSchemaVersion ||
62
+ !Array.isArray(manifest.groups)
63
+ ) {
64
+ throw new Error("The server metadata archive is not compatible with this version.");
65
+ }
66
+ return manifest;
67
+ }
68
+
69
+ function addEntry(zip: Zip, name: string, data: Uint8Array, compress: boolean): void {
70
+ const entry = compress ? new ZipDeflate(name, { level: 6 }) : new ZipPassThrough(name);
71
+ zip.add(entry);
72
+ entry.push(data, true);
73
+ }
74
+
75
+ function candidateMedia(files: BackupStorageFile[], quarantined: string[]): BackupStorageFile[] {
76
+ return files.filter((file) => {
77
+ if (!hasAllowedMediaExtension(file.name) || !isSafeArchivePath(file.name)) {
78
+ quarantined.push(file.name);
79
+ return false;
80
+ }
81
+ return true;
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Add large media to a small server-generated metadata archive in the
87
+ * authenticated browser. Objects are downloaded and verified one at a time,
88
+ * avoiding request timeouts and all-media-in-server-memory failures.
89
+ */
90
+ export async function buildBrowserMediaArchive<M extends BackupArchiveManifest>({
91
+ baseArchive,
92
+ groups,
93
+ storage,
94
+ expectedFormat,
95
+ expectedSchemaVersion,
96
+ onProgress,
97
+ }: BrowserMediaExportOptions): Promise<{ blob: Blob; manifest: M }> {
98
+ const baseFiles = unzipSync(baseArchive);
99
+ const baseManifest = readManifest<M>(baseFiles, expectedFormat, expectedSchemaVersion);
100
+ const quarantined = [...(baseManifest.quarantinedMedia ?? [])];
101
+
102
+ onProgress?.({
103
+ phase: "listing",
104
+ completedFiles: 0,
105
+ totalFiles: 0,
106
+ completedBytes: 0,
107
+ totalBytes: 0,
108
+ });
109
+
110
+ const listed = await storage.list();
111
+ const candidates = candidateMedia(listed, quarantined);
112
+ const totalBytes = candidates.reduce((sum, file) => sum + Math.max(0, file.size), 0);
113
+ let completedFiles = 0;
114
+ let completedBytes = 0;
115
+ let archivedFiles = 0;
116
+ let archivedBytes = 0;
117
+
118
+ const chunks: BlobPart[] = [];
119
+ let resolveZip!: () => void;
120
+ let rejectZip!: (error: Error) => void;
121
+ const zipFinished = new Promise<void>((resolve, reject) => {
122
+ resolveZip = resolve;
123
+ rejectZip = reject;
124
+ });
125
+ const zip = new Zip((error, data, final) => {
126
+ if (error) {
127
+ rejectZip(error instanceof Error ? error : new Error(String(error)));
128
+ return;
129
+ }
130
+ if (data?.byteLength) chunks.push(data as unknown as BlobPart);
131
+ if (final) resolveZip();
132
+ });
133
+
134
+ try {
135
+ for (const [name, data] of Object.entries(baseFiles)) {
136
+ if (name === "manifest.json" || name.startsWith("media/")) continue;
137
+ if (!isSafeArchivePath(name)) throw new Error(`Unsafe metadata archive entry: ${name}`);
138
+ addEntry(zip, name, data, name.endsWith(".json"));
139
+ }
140
+
141
+ onProgress?.({
142
+ phase: "downloading",
143
+ completedFiles,
144
+ totalFiles: candidates.length,
145
+ completedBytes,
146
+ totalBytes,
147
+ });
148
+
149
+ for (const file of candidates) {
150
+ let data: Uint8Array;
151
+ try {
152
+ data = await storage.download(file.name);
153
+ } catch (error) {
154
+ throw new Error(
155
+ `Could not download media file “${file.name}”: ${
156
+ error instanceof Error ? error.message : "unknown storage error"
157
+ }`,
158
+ );
159
+ }
160
+
161
+ const verdict = verifyMediaFile(file.name, data);
162
+ if (verdict.ok) {
163
+ addEntry(zip, `media/${file.name}`, data, false);
164
+ archivedFiles += 1;
165
+ archivedBytes += data.byteLength;
166
+ } else {
167
+ quarantined.push(`${file.name} (${verdict.reason})`);
168
+ }
169
+
170
+ completedFiles += 1;
171
+ completedBytes += file.size > 0 ? file.size : data.byteLength;
172
+ onProgress?.({
173
+ phase: "downloading",
174
+ completedFiles,
175
+ totalFiles: candidates.length,
176
+ completedBytes,
177
+ totalBytes,
178
+ currentFile: file.name,
179
+ });
180
+ }
181
+
182
+ const manifest = {
183
+ ...baseManifest,
184
+ groups: [...groups],
185
+ mediaFiles: archivedFiles,
186
+ mediaBytes: archivedBytes,
187
+ quarantinedMedia: quarantined,
188
+ } as M;
189
+ addEntry(zip, "manifest.json", strToU8(JSON.stringify(manifest, null, 2)), true);
190
+ onProgress?.({
191
+ phase: "finalizing",
192
+ completedFiles,
193
+ totalFiles: candidates.length,
194
+ completedBytes,
195
+ totalBytes,
196
+ });
197
+ zip.end();
198
+ await zipFinished;
199
+
200
+ return { blob: new Blob(chunks, { type: "application/zip" }), manifest };
201
+ } catch (error) {
202
+ zip.terminate();
203
+ throw error;
204
+ }
205
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./browser-media";
2
+ export * from "./safety";
3
+ export * from "./verification";
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Provider-neutral backup safety helpers. Nothing in this module opens a
3
+ * connection or reads the filesystem, so hosts can use it in the browser,
4
+ * server functions and restore tools.
5
+ */
6
+
7
+ export const ALLOWED_MEDIA_EXTENSIONS = [
8
+ ".webp",
9
+ ".jpg",
10
+ ".jpeg",
11
+ ".png",
12
+ ".gif",
13
+ ".avif",
14
+ ".mp4",
15
+ ".webm",
16
+ ".mov",
17
+ ".pdf",
18
+ ] as const;
19
+
20
+ export const MAX_MEDIA_ENTRY_BYTES = 250 * 1024 * 1024;
21
+ export const MAX_TOTAL_UNCOMPRESSED_BYTES = 8 * 1024 * 1024 * 1024;
22
+
23
+ export function mediaExtension(name: string): string {
24
+ const dot = name.lastIndexOf(".");
25
+ return dot < 0 ? "" : name.slice(dot).toLowerCase();
26
+ }
27
+
28
+ export function hasAllowedMediaExtension(name: string): boolean {
29
+ return (ALLOWED_MEDIA_EXTENSIONS as readonly string[]).includes(mediaExtension(name));
30
+ }
31
+
32
+ export const isAllowedMediaFile = hasAllowedMediaExtension;
33
+
34
+ function startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean {
35
+ if (bytes.length < offset + signature.length) return false;
36
+ for (let i = 0; i < signature.length; i += 1) {
37
+ if (bytes[offset + i] !== signature[i]) return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ function ascii(bytes: Uint8Array, from: number, length: number): string {
43
+ let out = "";
44
+ for (let i = from; i < Math.min(bytes.length, from + length); i += 1) {
45
+ out += String.fromCharCode(bytes[i]!);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export function looksLikeMarkup(bytes: Uint8Array): boolean {
51
+ let index = startsWith(bytes, [0xef, 0xbb, 0xbf]) ? 3 : 0;
52
+ while (index < bytes.length && [0x09, 0x0a, 0x0d, 0x20].includes(bytes[index]!)) index += 1;
53
+ const head = ascii(bytes, index, 512).toLowerCase();
54
+ if (!head.startsWith("<")) return false;
55
+ return (
56
+ head.startsWith("<!doctype") ||
57
+ head.startsWith("<html") ||
58
+ head.startsWith("<head") ||
59
+ head.startsWith("<body") ||
60
+ head.startsWith("<script") ||
61
+ head.startsWith("<svg") ||
62
+ head.startsWith("<?xml") ||
63
+ head.startsWith("<!--")
64
+ );
65
+ }
66
+
67
+ export function sniffMediaType(bytes: Uint8Array): string | null {
68
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
69
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
70
+ if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a") return "image/gif";
71
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") return "image/webp";
72
+ if (ascii(bytes, 0, 4) === "%PDF") return "application/pdf";
73
+ if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "video/webm";
74
+ if (ascii(bytes, 4, 4) === "ftyp") {
75
+ const brand = ascii(bytes, 8, 4).toLowerCase();
76
+ if (brand.startsWith("avif") || brand.startsWith("avis")) return "image/avif";
77
+ if (brand.startsWith("qt")) return "video/quicktime";
78
+ return "video/mp4";
79
+ }
80
+ return null;
81
+ }
82
+
83
+ const TYPE_EXTENSIONS: Record<string, string[]> = {
84
+ "image/jpeg": [".jpg", ".jpeg"],
85
+ "image/png": [".png"],
86
+ "image/gif": [".gif"],
87
+ "image/webp": [".webp"],
88
+ "image/avif": [".avif"],
89
+ "application/pdf": [".pdf"],
90
+ "video/webm": [".webm"],
91
+ "video/mp4": [".mp4", ".mov", ".webm"],
92
+ "video/quicktime": [".mov", ".mp4"],
93
+ };
94
+
95
+ export type MediaVerdict = { ok: true; type: string } | { ok: false; reason: string };
96
+
97
+ export function verifyMediaFile(name: string, bytes: Uint8Array): MediaVerdict {
98
+ const extension = mediaExtension(name);
99
+ if (!extension) return { ok: false, reason: "no file extension" };
100
+ if (!hasAllowedMediaExtension(name)) {
101
+ return { ok: false, reason: `extension ${extension} is not media` };
102
+ }
103
+ if (!bytes.byteLength) return { ok: false, reason: "empty file" };
104
+ if (bytes.byteLength > MAX_MEDIA_ENTRY_BYTES) return { ok: false, reason: "file too large" };
105
+ if (looksLikeMarkup(bytes)) return { ok: false, reason: "content is markup, not media" };
106
+ const type = sniffMediaType(bytes);
107
+ if (!type) return { ok: false, reason: "unrecognised file signature" };
108
+ if (!(TYPE_EXTENSIONS[type] ?? []).includes(extension)) {
109
+ return { ok: false, reason: `content is ${type} but named ${extension}` };
110
+ }
111
+ return { ok: true, type };
112
+ }
113
+
114
+ export function isSafeArchivePath(name: string): boolean {
115
+ if (!name || name.length > 1024) return false;
116
+ if (name.startsWith("/") || name.startsWith("\\")) return false;
117
+ if (/^[a-zA-Z]:[\\/]/.test(name)) return false;
118
+ if (name.includes("\\") || name.includes("\0")) return false;
119
+ // eslint-disable-next-line no-control-regex
120
+ if (/[\u0000-\u001f]/.test(name)) return false;
121
+ if (name.split("/").some((segment) => segment === ".." || segment === "." || segment === "")) {
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+
127
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
128
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
129
+ const prototype = Object.getPrototypeOf(value) as object | null;
130
+ return prototype === Object.prototype || prototype === null;
131
+ }
132
+
133
+ function bytesToBase64(bytes: Uint8Array): string {
134
+ let binary = "";
135
+ const chunkSize = 0x8000;
136
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
137
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
138
+ }
139
+ return btoa(binary);
140
+ }
141
+
142
+ export function toJsonSafe<T>(value: T): T {
143
+ const walk = (input: unknown): unknown => {
144
+ if (input === null || input === undefined) return input;
145
+ if (input instanceof Date) return input.toISOString();
146
+ if (typeof input === "bigint") return input.toString();
147
+ if (Array.isArray(input)) return input.map(walk);
148
+ if (typeof input === "object") {
149
+ if (input instanceof ArrayBuffer) return bytesToBase64(new Uint8Array(input));
150
+ if (ArrayBuffer.isView(input)) {
151
+ return bytesToBase64(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
152
+ }
153
+ if (!isPlainObject(input)) {
154
+ const maybeJson = input as { toJSON?: () => unknown };
155
+ if (typeof maybeJson.toJSON === "function") return walk(maybeJson.toJSON());
156
+ return String(input);
157
+ }
158
+ const out: Record<string, unknown> = {};
159
+ for (const [key, item] of Object.entries(input)) out[key] = walk(item);
160
+ return out;
161
+ }
162
+ if (typeof input === "number" && !Number.isFinite(input)) return null;
163
+ return input;
164
+ };
165
+ return walk(value) as T;
166
+ }
167
+
168
+ export function nonJsonSafePaths(value: unknown, prefix = ""): string[] {
169
+ const out: string[] = [];
170
+ const walk = (input: unknown, path: string): void => {
171
+ if (input === null || input === undefined) return;
172
+ if (input instanceof Date || typeof input === "bigint") {
173
+ out.push(path || "(root)");
174
+ return;
175
+ }
176
+ if (Array.isArray(input)) {
177
+ input.forEach((item, index) => walk(item, `${path}[${index}]`));
178
+ return;
179
+ }
180
+ if (typeof input === "object") {
181
+ if (!isPlainObject(input)) {
182
+ out.push(path || "(root)");
183
+ return;
184
+ }
185
+ for (const [key, item] of Object.entries(input)) walk(item, path ? `${path}.${key}` : key);
186
+ }
187
+ };
188
+ walk(value, prefix);
189
+ return out;
190
+ }
191
+
192
+ const SECRET_KEY_RE =
193
+ /(password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|credential|client[_-]?secret|access[_-]?key|authorization)/i;
194
+ const SECRET_NAME_KEY_RE = /(secret_name|secret_ref_name|_secret_ref|ref_name|key_id|_name)$/i;
195
+
196
+ export function sanitizeSecrets<T>(value: T): T {
197
+ const walk = (input: unknown): unknown => {
198
+ if (input instanceof Date) return input.toISOString();
199
+ if (Array.isArray(input)) return input.map(walk);
200
+ if (input && typeof input === "object") {
201
+ if (!isPlainObject(input)) return toJsonSafe(input);
202
+ const out: Record<string, unknown> = {};
203
+ for (const [key, item] of Object.entries(input)) {
204
+ out[key] =
205
+ SECRET_KEY_RE.test(key) && !SECRET_NAME_KEY_RE.test(key) && item != null
206
+ ? null
207
+ : walk(item);
208
+ }
209
+ return out;
210
+ }
211
+ return toJsonSafe(input);
212
+ };
213
+ return walk(value) as T;
214
+ }
215
+
216
+ export const HOSTED_MEDIA_URL_RE =
217
+ /https?:\/\/[a-z0-9-]+\.supabase\.co\/storage\/v1\/object\/public\/media\//gi;
218
+
219
+ export function rewriteMediaUrls<T>(value: T, base: string): T {
220
+ const prefix = base.endsWith("/") ? base : `${base}/`;
221
+ const walk = (input: unknown): unknown => {
222
+ if (typeof input === "string") return input.replace(HOSTED_MEDIA_URL_RE, prefix);
223
+ if (Array.isArray(input)) return input.map(walk);
224
+ if (input && typeof input === "object") {
225
+ const out: Record<string, unknown> = {};
226
+ for (const [key, item] of Object.entries(input as Record<string, unknown>)) {
227
+ out[key] = walk(item);
228
+ }
229
+ return out;
230
+ }
231
+ return input;
232
+ };
233
+ return walk(value) as T;
234
+ }
235
+
236
+ export function neutralizeInjection<T extends Record<string, unknown>>(
237
+ row: T,
238
+ keepEnabled: boolean,
239
+ ): T {
240
+ return keepEnabled ? row : { ...row, enabled: false };
241
+ }
@@ -0,0 +1,80 @@
1
+ import { HOSTED_MEDIA_URL_RE } from "./safety";
2
+
3
+ export type UserRow = { id: string; email: string | null };
4
+ export type RoleRow = { user_id: string; role: string };
5
+
6
+ export function compareCounts(
7
+ expected: Record<string, number>,
8
+ actual: Record<string, number>,
9
+ insertOnly: string[] = [],
10
+ ): string[] {
11
+ const problems: string[] = [];
12
+ for (const [table, wanted] of Object.entries(expected)) {
13
+ const found = actual[table];
14
+ if (found == null) problems.push(`${table}: table missing in target`);
15
+ else if (found !== wanted && !(insertOnly.includes(table) && found > wanted)) {
16
+ problems.push(`${table}: expected ${wanted} rows, found ${found}`);
17
+ }
18
+ }
19
+ return problems;
20
+ }
21
+
22
+ export function compareUsersAndRoles(
23
+ expectedUsers: UserRow[],
24
+ expectedRoles: RoleRow[],
25
+ actualUsers: UserRow[],
26
+ actualRoles: RoleRow[],
27
+ ): string[] {
28
+ const problems: string[] = [];
29
+ const actualById = new Map(actualUsers.map((user) => [user.id, user]));
30
+ for (const user of expectedUsers) {
31
+ const found = actualById.get(user.id);
32
+ if (!found) problems.push(`user ${user.id} is missing`);
33
+ else if ((found.email ?? null) !== (user.email ?? null)) {
34
+ problems.push(`user ${user.id} has a different email`);
35
+ }
36
+ }
37
+ const key = (role: RoleRow) => `${role.user_id}:${role.role}`;
38
+ const actualRoleSet = new Set(actualRoles.map(key));
39
+ for (const role of expectedRoles) {
40
+ if (!actualRoleSet.has(key(role))) {
41
+ problems.push(`role ${role.role} for ${role.user_id} is missing`);
42
+ }
43
+ }
44
+ for (const role of actualRoles.filter((item) => !actualById.has(item.user_id))) {
45
+ problems.push(`role ${role.role} references unknown user ${role.user_id}`);
46
+ }
47
+ return problems;
48
+ }
49
+
50
+ export function findHostedMediaUrls(rows: { table: string; json: string }[]): string[] {
51
+ const problems: string[] = [];
52
+ for (const row of rows) {
53
+ const expression = new RegExp(HOSTED_MEDIA_URL_RE.source, "gi");
54
+ const match = expression.exec(row.json);
55
+ if (match) problems.push(`${row.table}: still references ${match[0]}`);
56
+ }
57
+ return problems;
58
+ }
59
+
60
+ export function findMissingMedia(expected: string[], present: Set<string>): string[] {
61
+ return expected
62
+ .filter((path) => !present.has(path))
63
+ .map((path) => `media file missing on disk: ${path}`);
64
+ }
65
+
66
+ export type VerifyReport = { checks: { name: string; problems: string[] }[] };
67
+
68
+ export function reportOk(report: VerifyReport): boolean {
69
+ return report.checks.every((check) => check.problems.length === 0);
70
+ }
71
+
72
+ export function formatReport(report: VerifyReport): string {
73
+ const lines: string[] = [];
74
+ for (const check of report.checks) {
75
+ lines.push(`[${check.problems.length === 0 ? "PASS" : "FAIL"}] ${check.name}`);
76
+ for (const problem of check.problems) lines.push(` - ${problem}`);
77
+ }
78
+ lines.push(reportOk(report) ? "\nVerification passed." : "\nVerification FAILED.");
79
+ return lines.join("\n");
80
+ }
@@ -113,7 +113,6 @@ export type ImageMarqueeProps = {
113
113
  items?: ImageMarqueeItem[];
114
114
  };
115
115
 
116
-
117
116
  export type GlobalRefProps = {
118
117
  sectionKey: string;
119
118
  };
@@ -182,7 +181,6 @@ export type HeroFullProps = {
182
181
  anchor?: string;
183
182
  };
184
183
 
185
-
186
184
  export type IntroProps = {
187
185
  eyebrow?: Partial<LocalizedString>;
188
186
  title?: Partial<LocalizedString>;
@@ -196,18 +194,10 @@ export type IntroProps = {
196
194
  imageWidth?: number;
197
195
  };
198
196
 
199
- export const INTRO_IMAGE_ASPECTS = [
200
- "4/5",
201
- "1/1",
202
- "3/4",
203
- "3/2",
204
- "16/9",
205
- "auto",
206
- ] as const;
197
+ export const INTRO_IMAGE_ASPECTS = ["4/5", "1/1", "3/4", "3/2", "16/9", "auto"] as const;
207
198
 
208
199
  export type IntroImageAspect = (typeof INTRO_IMAGE_ASPECTS)[number];
209
200
 
210
-
211
201
  /** ---- Long-form editorial blocks ---- */
212
202
 
213
203
  export type ProseItem = {
@@ -244,43 +234,9 @@ export type ChecklistProps = {
244
234
  columns?: ChecklistColumn[];
245
235
  };
246
236
 
247
- /** ---- Bookable course dates (inline, per page instance) ---- */
237
+ /** ---- Bookable trips (inline or sourced from the shared trips section) ---- */
248
238
 
249
- export type CourseListItem = {
250
- id: string;
251
- /** Season/year used to group and filter courses (e.g. "2026"). */
252
- year?: string;
253
- courseNo?: Partial<LocalizedString>;
254
- location?: Partial<LocalizedString>;
255
- dates?: Partial<LocalizedString>;
256
- accommodation?: Partial<LocalizedString>;
257
- price?: Partial<LocalizedString>;
258
- oldPrice?: Partial<LocalizedString>;
259
- badge?: Partial<LocalizedString>;
260
- bookLabel?: Partial<LocalizedString>;
261
- bookUrl?: string;
262
- featured?: boolean;
263
- };
264
-
265
- export type CourseListProps = {
266
- eyebrow?: Partial<LocalizedString>;
267
- title?: Partial<LocalizedString>;
268
- lead?: Partial<LocalizedString>;
269
- note?: Partial<LocalizedString>;
270
- tone?: "white" | "paper";
271
- columns?: 2 | 3 | 4;
272
- anchor?: string;
273
- /**
274
- * Where the courses come from.
275
- * "global" (default) = the single source of truth in the backoffice
276
- * (Courses section), optionally filtered by `year`.
277
- * "custom" = items edited inline on this block only (legacy).
278
- */
279
- source?: "global" | "custom";
280
- /** Only used with source="global": show courses of this year. */
281
- year?: string;
282
- items?: CourseListItem[];
283
- };
239
+ export type { CourseListItem, CourseListProps, TripItem, TripListProps } from "../trips";
284
240
 
285
241
  /** ---- Simple call-to-action banner ---- */
286
242
 
@@ -358,7 +314,6 @@ export type MediaSplitProps = {
358
314
  videoControls?: boolean;
359
315
  };
360
316
 
361
-
362
317
  /** ---- Rich text (WYSIWYG) ---- */
363
318
 
364
319
  export type RichTextProps = {
package/src/index.ts CHANGED
@@ -3,3 +3,5 @@ export * from "./i18n";
3
3
  export * from "./blocks/types";
4
4
  export * from "./blocks/icons";
5
5
  export * from "./blocks/block-props-context";
6
+ export * from "./trips";
7
+ export * from "./navigation";
@@ -0,0 +1,41 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ import type { LocalizedString } from "../i18n/types";
4
+
5
+ export type NavigationItem = {
6
+ label: LocalizedString;
7
+ href: string;
8
+ children?: NavigationItem[];
9
+ };
10
+
11
+ export type BackofficeNavigationItem<TIconProps = { className?: string }> = {
12
+ key: string;
13
+ to: string;
14
+ label: string;
15
+ icon: ComponentType<TIconProps>;
16
+ exact?: boolean;
17
+ };
18
+
19
+ export function defineNavigation<const T extends readonly NavigationItem[]>(items: T): T {
20
+ return items;
21
+ }
22
+
23
+ export function defineBackofficeNavigation<
24
+ TIconProps,
25
+ const T extends readonly BackofficeNavigationItem<TIconProps>[],
26
+ >(items: T): T {
27
+ return items;
28
+ }
29
+
30
+ export function isBackofficeNavigationActive(
31
+ pathname: string,
32
+ item: Pick<BackofficeNavigationItem, "to" | "exact">,
33
+ ): boolean {
34
+ return item.exact
35
+ ? pathname === item.to
36
+ : pathname === item.to || pathname.startsWith(`${item.to}/`);
37
+ }
38
+
39
+ export function navigationPanelId(href: string): string {
40
+ return `nav-sub-${href.replace(/\W+/g, "-")}`;
41
+ }
@@ -0,0 +1,279 @@
1
+ import { ArrowDown, ArrowUp, Plus, Save, Star, StarOff, Trash2 } from "lucide-react";
2
+
3
+ import type { Locale } from "../i18n/locales";
4
+ import type { LocalizedString } from "../i18n/types";
5
+ import type { TripItem, TripSection, TripTerminology } from ".";
6
+ import { tripNumber, withTripNumber } from ".";
7
+
8
+ export type TripSectionEditorProps = {
9
+ value: TripSection;
10
+ terminology: TripTerminology;
11
+ onChange: (value: TripSection) => void;
12
+ onSave: () => void | Promise<void>;
13
+ saving?: boolean;
14
+ locale?: Locale;
15
+ };
16
+
17
+ function localizedValue(value: Partial<LocalizedString> | undefined, locale: Locale): string {
18
+ return value?.[locale] ?? "";
19
+ }
20
+
21
+ function localizedPatch(
22
+ value: Partial<LocalizedString> | undefined,
23
+ locale: Locale,
24
+ next: string,
25
+ ): Partial<LocalizedString> {
26
+ return { ...value, [locale]: next };
27
+ }
28
+
29
+ export function TripSectionEditor({
30
+ value,
31
+ terminology,
32
+ onChange,
33
+ onSave,
34
+ saving = false,
35
+ locale = "sv",
36
+ }: TripSectionEditorProps) {
37
+ const update = (patch: Partial<TripSection>) => onChange({ ...value, ...patch });
38
+ const updateItem = (index: number, patch: Partial<TripItem>) => {
39
+ const items = value.items.slice();
40
+ items[index] = { ...items[index], ...patch };
41
+ update({ items });
42
+ };
43
+ const removeItem = (index: number) => {
44
+ update({ items: value.items.filter((_, itemIndex) => itemIndex !== index) });
45
+ };
46
+ const move = (index: number, direction: -1 | 1) => {
47
+ const target = index + direction;
48
+ if (target < 0 || target >= value.items.length) return;
49
+ const items = value.items.slice();
50
+ [items[index], items[target]] = [items[target], items[index]];
51
+ update({ items });
52
+ };
53
+ const addItem = () => {
54
+ const number = value.items.length + 1;
55
+ const year = String(new Date().getFullYear());
56
+ update({
57
+ items: [
58
+ ...value.items,
59
+ {
60
+ id: `t${year}-${String(number).padStart(2, "0")}`,
61
+ year,
62
+ tripNo: { [locale]: `${terminology.singular} ${number}` },
63
+ location: { [locale]: terminology.defaultLocation ?? "" },
64
+ dates: { [locale]: "" },
65
+ accommodation: { [locale]: terminology.defaultAccommodation ?? "" },
66
+ price: { [locale]: "" },
67
+ oldPrice: { [locale]: "" },
68
+ bookUrl: "",
69
+ },
70
+ ],
71
+ });
72
+ };
73
+
74
+ return (
75
+ <div className="w-full">
76
+ <h1 className="font-serif text-3xl text-ink md:text-4xl">{terminology.plural}</h1>
77
+ <p className="mt-3 max-w-2xl text-ink/65">{terminology.description}</p>
78
+
79
+ <div className="mt-8 grid items-start gap-4 md:grid-cols-2">
80
+ <EditorField
81
+ label="Title"
82
+ value={localizedValue(value.title, locale)}
83
+ onChange={(next) => update({ title: localizedPatch(value.title, locale, next) })}
84
+ />
85
+ <EditorField
86
+ label="Eyebrow"
87
+ value={localizedValue(value.eyebrow, locale)}
88
+ onChange={(next) => update({ eyebrow: localizedPatch(value.eyebrow, locale, next) })}
89
+ />
90
+ <label className="block md:col-span-2">
91
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Intro</span>
92
+ <textarea
93
+ className="mt-2 min-h-[80px] w-full border border-ink/15 bg-white px-4 py-3"
94
+ value={localizedValue(value.body, locale)}
95
+ onChange={(event) =>
96
+ update({ body: localizedPatch(value.body, locale, event.target.value) })
97
+ }
98
+ />
99
+ </label>
100
+ <EditorField
101
+ label="Autoplay (ms)"
102
+ type="number"
103
+ value={String(value.autoplayMs)}
104
+ onChange={(next) => update({ autoplayMs: Number(next) || 0 })}
105
+ />
106
+ </div>
107
+
108
+ <div className="mt-10 space-y-4">
109
+ {value.items.map((item, index) => (
110
+ <div key={item.id ?? index} className="border border-ink/10 bg-white p-5">
111
+ <div className="flex items-center justify-between gap-3">
112
+ <div className="flex items-center gap-2 text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
113
+ <span>#{index + 1}</span>
114
+ {item.id ? (
115
+ <span className="border border-ink/15 px-1.5 py-0.5 normal-case tracking-normal text-ink/70">
116
+ {item.id}
117
+ </span>
118
+ ) : null}
119
+ </div>
120
+ <div className="flex items-center gap-1">
121
+ <IconButton label="Move up" disabled={index === 0} onClick={() => move(index, -1)}>
122
+ <ArrowUp className="size-4" />
123
+ </IconButton>
124
+ <IconButton
125
+ label="Move down"
126
+ disabled={index === value.items.length - 1}
127
+ onClick={() => move(index, 1)}
128
+ >
129
+ <ArrowDown className="size-4" />
130
+ </IconButton>
131
+ <IconButton
132
+ label={`Mark ${terminology.singular.toLowerCase()} as featured`}
133
+ onClick={() => updateItem(index, { featured: !item.featured })}
134
+ className={item.featured ? "text-sea" : "text-ink/40"}
135
+ >
136
+ {item.featured ? <Star className="size-4" /> : <StarOff className="size-4" />}
137
+ </IconButton>
138
+ <IconButton
139
+ label={`Delete ${terminology.singular.toLowerCase()}`}
140
+ onClick={() => removeItem(index)}
141
+ className="text-red-600 hover:bg-red-50"
142
+ >
143
+ <Trash2 className="size-4" />
144
+ </IconButton>
145
+ </div>
146
+ </div>
147
+
148
+ <div className="mt-4 grid gap-3 md:grid-cols-3">
149
+ <EditorField
150
+ label="ID (stable key)"
151
+ value={item.id ?? ""}
152
+ onChange={(next) => updateItem(index, { id: next })}
153
+ />
154
+ <EditorField
155
+ label="Year"
156
+ value={item.year ?? ""}
157
+ onChange={(next) => updateItem(index, { year: next })}
158
+ />
159
+ <EditorField
160
+ label={terminology.number}
161
+ value={localizedValue(tripNumber(item), locale)}
162
+ onChange={(next) =>
163
+ updateItem(
164
+ index,
165
+ withTripNumber(item, localizedPatch(tripNumber(item), locale, next)),
166
+ )
167
+ }
168
+ />
169
+ <EditorField
170
+ label={terminology.location}
171
+ value={localizedValue(item.location, locale)}
172
+ onChange={(next) =>
173
+ updateItem(index, { location: localizedPatch(item.location, locale, next) })
174
+ }
175
+ />
176
+ <EditorField
177
+ label={terminology.dates}
178
+ value={localizedValue(item.dates, locale)}
179
+ onChange={(next) =>
180
+ updateItem(index, { dates: localizedPatch(item.dates, locale, next) })
181
+ }
182
+ />
183
+ <EditorField
184
+ label={terminology.accommodation}
185
+ value={localizedValue(item.accommodation, locale)}
186
+ onChange={(next) =>
187
+ updateItem(index, {
188
+ accommodation: localizedPatch(item.accommodation, locale, next),
189
+ })
190
+ }
191
+ />
192
+ <EditorField
193
+ label="Price"
194
+ value={localizedValue(item.price, locale)}
195
+ onChange={(next) =>
196
+ updateItem(index, { price: localizedPatch(item.price, locale, next) })
197
+ }
198
+ />
199
+ <EditorField
200
+ label="Regular price"
201
+ value={localizedValue(item.oldPrice, locale)}
202
+ onChange={(next) =>
203
+ updateItem(index, { oldPrice: localizedPatch(item.oldPrice, locale, next) })
204
+ }
205
+ />
206
+ <EditorField
207
+ label="Booking URL"
208
+ value={item.bookUrl ?? ""}
209
+ onChange={(next) => updateItem(index, { bookUrl: next })}
210
+ span={3}
211
+ />
212
+ </div>
213
+ </div>
214
+ ))}
215
+ </div>
216
+
217
+ <div className="mt-6 flex flex-wrap items-center gap-3">
218
+ <button
219
+ type="button"
220
+ onClick={addItem}
221
+ className="inline-flex items-center gap-2 border border-ink/20 px-5 py-2.5 text-xs uppercase tracking-[0.22em] hover:border-ink"
222
+ >
223
+ <Plus className="size-4" /> Add {terminology.singular.toLowerCase()}
224
+ </button>
225
+ <button
226
+ type="button"
227
+ onClick={() => void onSave()}
228
+ disabled={saving}
229
+ className="inline-flex items-center gap-2 bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper hover:bg-ink/85 disabled:opacity-50"
230
+ >
231
+ <Save className="size-4" /> {saving ? "Saving…" : "Save all changes"}
232
+ </button>
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ function IconButton({
239
+ label,
240
+ className = "",
241
+ ...props
242
+ }: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
243
+ return (
244
+ <button
245
+ type="button"
246
+ aria-label={label}
247
+ className={`p-2 hover:bg-ink/5 disabled:opacity-30 ${className}`}
248
+ {...props}
249
+ />
250
+ );
251
+ }
252
+
253
+ function EditorField({
254
+ label,
255
+ value,
256
+ onChange,
257
+ span,
258
+ type = "text",
259
+ }: {
260
+ label: string;
261
+ value: string;
262
+ onChange: (value: string) => void;
263
+ span?: 1 | 2 | 3;
264
+ type?: "text" | "number";
265
+ }) {
266
+ const className = span === 3 ? "md:col-span-3" : span === 2 ? "md:col-span-2" : "";
267
+ return (
268
+ <label className={`block ${className}`}>
269
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">{label}</span>
270
+ <input
271
+ type={type}
272
+ min={type === "number" ? 0 : undefined}
273
+ className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
274
+ value={value}
275
+ onChange={(event) => onChange(event.target.value)}
276
+ />
277
+ </label>
278
+ );
279
+ }
@@ -0,0 +1,90 @@
1
+ import type { LocalizedString } from "../i18n/types";
2
+
3
+ export const TRIPS_SECTION_KEY = "trips" as const;
4
+ export const LEGACY_TRIPS_SECTION_KEY = "packages" as const;
5
+
6
+ export type TripItem = {
7
+ id?: string;
8
+ year?: string;
9
+ tripNo?: Partial<LocalizedString>;
10
+ /** @deprecated Read-only compatibility with CMS data created before the trips module. */
11
+ courseNo?: Partial<LocalizedString>;
12
+ location?: Partial<LocalizedString>;
13
+ dates?: Partial<LocalizedString>;
14
+ accommodation?: Partial<LocalizedString>;
15
+ price?: Partial<LocalizedString>;
16
+ oldPrice?: Partial<LocalizedString>;
17
+ badge?: Partial<LocalizedString>;
18
+ bookLabel?: Partial<LocalizedString>;
19
+ bookUrl?: string;
20
+ featured?: boolean;
21
+ };
22
+
23
+ export type TripSection = {
24
+ eyebrow: Partial<LocalizedString>;
25
+ id: string;
26
+ title: Partial<LocalizedString>;
27
+ body: Partial<LocalizedString>;
28
+ autoplayMs: number;
29
+ items: TripItem[];
30
+ };
31
+
32
+ export type TripListProps = {
33
+ eyebrow?: Partial<LocalizedString>;
34
+ title?: Partial<LocalizedString>;
35
+ lead?: Partial<LocalizedString>;
36
+ note?: Partial<LocalizedString>;
37
+ tone?: "white" | "paper";
38
+ columns?: 2 | 3 | 4;
39
+ anchor?: string;
40
+ source?: "global" | "custom";
41
+ year?: string;
42
+ items?: TripItem[];
43
+ };
44
+
45
+ export type TripTerminology = {
46
+ singular: string;
47
+ plural: string;
48
+ number: string;
49
+ dates: string;
50
+ location: string;
51
+ accommodation: string;
52
+ description: string;
53
+ defaultLocation?: string;
54
+ defaultAccommodation?: string;
55
+ };
56
+
57
+ export function defineTripTerminology(terminology: TripTerminology): TripTerminology {
58
+ return terminology;
59
+ }
60
+
61
+ export function tripNumber(item: Pick<TripItem, "tripNo" | "courseNo">): Partial<LocalizedString> {
62
+ return item.tripNo ?? item.courseNo ?? {};
63
+ }
64
+
65
+ export function withTripNumber(item: TripItem, value: Partial<LocalizedString>): TripItem {
66
+ return { ...item, tripNo: value };
67
+ }
68
+
69
+ type SectionSources = Record<string, unknown> | null | undefined;
70
+
71
+ /**
72
+ * Resolve the new trips key first, then the legacy packages key. This keeps
73
+ * existing CMS rows readable while every new save is written as `trips`.
74
+ */
75
+ export function resolveTripSection<T extends TripSection>(
76
+ overrides: SectionSources,
77
+ site: SectionSources,
78
+ fallback: T,
79
+ ): T {
80
+ return (overrides?.[TRIPS_SECTION_KEY] ??
81
+ overrides?.[LEGACY_TRIPS_SECTION_KEY] ??
82
+ site?.[TRIPS_SECTION_KEY] ??
83
+ site?.[LEGACY_TRIPS_SECTION_KEY] ??
84
+ fallback) as T;
85
+ }
86
+
87
+ /** @deprecated Use TripItem. */
88
+ export type CourseListItem = TripItem;
89
+ /** @deprecated Use TripListProps. */
90
+ export type CourseListProps = TripListProps;