@bygghammar/cms-platform 0.3.0 → 0.4.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Client-side preparation of review (testimonial) images.
3
+ *
4
+ * Uploaded review images keep their original aspect ratio (no crop at upload
5
+ * time — cropping happens at display time around the editor-chosen focus point)
6
+ * and are re-encoded to WebP at the four standard responsive widths so review
7
+ * cards stay fast. The server re-validates every produced variant
8
+ * (see `review-image.server.ts`) — this step is an optimisation, not a trust boundary.
9
+ *
10
+ * Browser-only (uses Canvas). Do NOT import from server code.
11
+ */
12
+
13
+ import { RESPONSIVE_WIDTHS } from "./srcset";
14
+
15
+ export const REVIEW_ASPECT = 16 / 10;
16
+ /** Safety ceiling for the stored image height (very tall panoramas). */
17
+ export const MAX_STORED_HEIGHT = 2400;
18
+ export const REVIEW_QUALITY = 0.8;
19
+ /** Hard client-side guard before we even decode the file. */
20
+ export const MAX_SOURCE_BYTES = 15 * 1024 * 1024;
21
+ export const ACCEPTED_SOURCE_TYPES = /^image\/(jpeg|jpg|png|webp|avif|gif|bmp|tiff?)$/i;
22
+
23
+ export type PreparedVariant = {
24
+ width: number;
25
+ height: number;
26
+ /** Base64 (no data: prefix) WebP payload. */
27
+ data: string;
28
+ };
29
+
30
+ export type PreparedReviewImage = {
31
+ slug: string;
32
+ variants: PreparedVariant[];
33
+ sourceBytes: number;
34
+ optimizedBytes: number;
35
+ };
36
+
37
+ export function reviewImageSlug(fileName: string): string {
38
+ const dot = fileName.lastIndexOf(".");
39
+ const base = dot > 0 ? fileName.slice(0, dot) : fileName;
40
+ const cleaned = base
41
+ .toLowerCase()
42
+ .normalize("NFD")
43
+ .replace(/[\u0300-\u036f]/g, "")
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, 60);
47
+ // Keep the uploaded filename — no timestamps (SEO-friendly URLs).
48
+ return cleaned || "review-image";
49
+ }
50
+
51
+ export async function prepareReviewImage(file: File): Promise<PreparedReviewImage> {
52
+ if (!ACCEPTED_SOURCE_TYPES.test(file.type)) {
53
+ throw new Error("Unsupported image type. Use JPEG, PNG, WebP, AVIF or GIF.");
54
+ }
55
+ if (file.size > MAX_SOURCE_BYTES) {
56
+ throw new Error("Image is too large (max 15 MB).");
57
+ }
58
+
59
+ const bmp = await createImageBitmap(file);
60
+ try {
61
+ if (bmp.width < 320 || bmp.height < 200) {
62
+ throw new Error("Image is too small — use at least 320×200 px.");
63
+ }
64
+ const variants: PreparedVariant[] = [];
65
+ let optimizedBytes = 0;
66
+ for (const targetW of RESPONSIVE_WIDTHS) {
67
+ const width = Math.min(targetW, Math.max(320, bmp.width));
68
+ let height = Math.max(1, Math.round((width * bmp.height) / bmp.width));
69
+ if (height > MAX_STORED_HEIGHT) height = MAX_STORED_HEIGHT;
70
+ const blob = await encodeCoverWebp(bmp, width, height);
71
+ optimizedBytes += blob.size;
72
+ variants.push({ width: targetW, height, data: await blobToBase64(blob) });
73
+ }
74
+ return { slug: "", variants, sourceBytes: file.size, optimizedBytes };
75
+ } finally {
76
+ bmp.close?.();
77
+ }
78
+ }
79
+
80
+ async function encodeCoverWebp(bmp: ImageBitmap, w: number, h: number): Promise<Blob> {
81
+ const canvas = document.createElement("canvas");
82
+ canvas.width = w;
83
+ canvas.height = h;
84
+ const ctx = canvas.getContext("2d");
85
+ if (!ctx) throw new Error("Canvas is unavailable in this browser.");
86
+ // Scale to fill; with an aspect-preserving target this is a plain resize.
87
+ const scale = Math.max(w / bmp.width, h / bmp.height);
88
+ const dw = bmp.width * scale;
89
+ const dh = bmp.height * scale;
90
+ ctx.imageSmoothingQuality = "high";
91
+ ctx.drawImage(bmp, (w - dw) / 2, (h - dh) / 2, dw, dh);
92
+ const blob = await new Promise<Blob | null>((resolve) =>
93
+ canvas.toBlob((b) => resolve(b), "image/webp", REVIEW_QUALITY),
94
+ );
95
+ if (!blob) throw new Error("Could not encode the image.");
96
+ return blob;
97
+ }
98
+
99
+ async function blobToBase64(blob: Blob): Promise<string> {
100
+ const buf = new Uint8Array(await blob.arrayBuffer());
101
+ let binary = "";
102
+ const chunk = 0x8000;
103
+ for (let i = 0; i < buf.length; i += chunk) {
104
+ binary += String.fromCharCode(...buf.subarray(i, i + chunk));
105
+ }
106
+ return btoa(binary);
107
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Responsive image helpers. Works with files produced by `optimizeForUpload`,
3
+ * which uploads a base slug plus WebP variants: `-480`, `-768`, `-1200`, `-1920`
4
+ * and (for assets uploaded after the HiDPI change) `-2560`. Transparent uploads
5
+ * also produce a single `-fallback.png` for older browsers.
6
+ *
7
+ * Given the largest variant URL (`…-1920.webp`), derives sibling URLs and returns
8
+ * a ready-to-use `srcSet`/`sizes` pair plus a PNG fallback when one exists. For
9
+ * any other URL shape, returns the original `src` so legacy assets keep working
10
+ * with no responsive scaling.
11
+ */
12
+
13
+ import { resolveMediaSrc } from "./urls";
14
+
15
+ export const RESPONSIVE_WIDTHS = [480, 768, 1200, 1920, 2560] as const;
16
+ export type ResponsiveWidth = (typeof RESPONSIVE_WIDTHS)[number];
17
+
18
+ const VARIANT_RE = /-(480|768|1200|1920|2560)\.webp(\?.*)?$/i;
19
+
20
+ export type ResponsiveImage = {
21
+ src: string;
22
+ srcSet?: string;
23
+ sizes?: string;
24
+ /** MIME type of the responsive srcSet (`image/webp`). */
25
+ type?: string;
26
+ /** PNG fallback URL for browsers that do not support WebP or need alpha. */
27
+ fallbackSrc?: string;
28
+ };
29
+
30
+ /**
31
+ * Build a responsive srcSet from any variant URL (…-1920.webp / -1200 / -768 / -480).
32
+ * Returns just `{ src }` if the URL is not a known responsive variant.
33
+ */
34
+ export function responsiveImage(input: string | undefined, sizes?: string): ResponsiveImage {
35
+ if (!input) return { src: "" };
36
+ const url = resolveMediaSrc(input);
37
+ const m = url.match(VARIANT_RE);
38
+ if (!m) return { src: url };
39
+
40
+ const currentWidth = Number(m[1]);
41
+ const query = m[2] ?? "";
42
+ const base = url.slice(0, url.length - m[0].length); // strip `-<w>.webp[?…]`
43
+ // Legacy assets only have variants up to 1920; only advertise 2560 when the
44
+ // URL we were handed already points at a 2560 primary (i.e. a newer upload).
45
+ const maxWidth = currentWidth >= 2560 ? 2560 : 1920;
46
+ const widths = RESPONSIVE_WIDTHS.filter((w) => w <= maxWidth);
47
+ const parts = widths.map((w) => `${base}-${w}.webp${query} ${w}w`);
48
+ return {
49
+ // The URL we were handed is the one that is actually stored, so it is the
50
+ // only safe plain `src`: small uploads skip the intermediate widths and a
51
+ // derived `-1920.webp` may never have been written.
52
+ src: url,
53
+ srcSet: parts.join(", "),
54
+ sizes: sizes ?? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 1200px",
55
+ type: "image/webp",
56
+ fallbackSrc: `${base}-fallback.png${query}`,
57
+ };
58
+ }
59
+
60
+ /** All sibling variant paths for a given primary path (for deletion cascades). */
61
+ export function variantSiblings(primaryPath: string): string[] {
62
+ const m = primaryPath.match(VARIANT_RE);
63
+ if (!m) return [];
64
+ const base = primaryPath.slice(0, primaryPath.length - m[0].length);
65
+ return RESPONSIVE_WIDTHS.filter((w) => w !== Number(m[1])).map((w) => `${base}-${w}.webp`);
66
+ }
67
+
68
+ /** True when `path` is a smaller variant of a primary (used to hide from library). */
69
+ export function isSecondaryVariant(path: string): boolean {
70
+ const m = path.match(VARIANT_RE);
71
+ if (!m) return false;
72
+ const w = Number(m[1]);
73
+ if (w === 2560) return false;
74
+ if (w !== 1920) return true;
75
+ // A `-1920` file is primary for legacy assets but secondary when a `-2560`
76
+ // sibling exists; the library filters that case via `hasLargerSibling`.
77
+ return false;
78
+ }
@@ -0,0 +1,139 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+
3
+ import { isInternalMediaPath } from "./internal-paths";
4
+ import { mediaBucket } from "./urls";
5
+
6
+ export type StorageProvider = "supabase";
7
+
8
+ export type StorageFileInfo = {
9
+ name: string;
10
+ size: number;
11
+ contentType: string | null;
12
+ updatedAt: string | null;
13
+ };
14
+
15
+ export type UploadOptions = {
16
+ upsert?: boolean;
17
+ };
18
+
19
+ export interface MediaStorage {
20
+ readonly provider: StorageProvider;
21
+ upload(
22
+ path: string,
23
+ data: Uint8Array,
24
+ contentType: string,
25
+ options?: UploadOptions,
26
+ ): Promise<void>;
27
+ download(path: string): Promise<Uint8Array>;
28
+ remove(paths: string[]): Promise<void>;
29
+ move(source: string, destination: string): Promise<void>;
30
+ list(): Promise<StorageFileInfo[]>;
31
+ publicUrl(filePath: string): string;
32
+ }
33
+
34
+ /**
35
+ * Return the media storage client.
36
+ *
37
+ * Pass the authenticated client from the request context so RLS applies.
38
+ */
39
+ export function getMediaStorage(supabaseClient?: SupabaseClient): MediaStorage {
40
+ return new SupabaseStorage(supabaseClient);
41
+ }
42
+
43
+ class SupabaseStorage implements MediaStorage {
44
+ readonly provider = "supabase" as const;
45
+
46
+ constructor(private readonly client: SupabaseClient | undefined) {}
47
+
48
+ private bucket() {
49
+ if (!this.client) throw new Error("Supabase storage requires an authenticated client");
50
+ return this.client.storage.from(mediaBucket());
51
+ }
52
+
53
+ async upload(
54
+ filePath: string,
55
+ data: Uint8Array,
56
+ contentType: string,
57
+ options?: UploadOptions,
58
+ ): Promise<void> {
59
+ const blob = new Blob([data as unknown as BlobPart], { type: contentType });
60
+ const { error } = await this.bucket().upload(filePath, blob, {
61
+ contentType,
62
+ upsert: options?.upsert ?? false,
63
+ });
64
+ if (error) throw new Error(error.message);
65
+ }
66
+
67
+ async download(filePath: string): Promise<Uint8Array> {
68
+ const { data, error } = await this.bucket().download(filePath);
69
+ if (!error) return new Uint8Array(await data.arrayBuffer());
70
+
71
+ // The media bucket is deliberately public. Large authenticated Storage
72
+ // downloads can occasionally fail in browsers even while the CDN object
73
+ // is healthy, so retry through the public object URL before aborting the
74
+ // complete archive.
75
+ const publicUrl = this.publicUrl(filePath);
76
+ try {
77
+ const response = await fetch(publicUrl, { cache: "no-store" });
78
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
79
+ return new Uint8Array(await response.arrayBuffer());
80
+ } catch (fallbackError) {
81
+ throw new Error(
82
+ `${error.message}; public media fallback failed: ${
83
+ fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
84
+ }`,
85
+ );
86
+ }
87
+ }
88
+
89
+ async remove(paths: string[]): Promise<void> {
90
+ if (!paths.length) return;
91
+ const { error } = await this.bucket().remove(paths);
92
+ if (error) throw new Error(error.message);
93
+ }
94
+
95
+ async move(source: string, destination: string): Promise<void> {
96
+ const { error } = await this.bucket().move(source, destination);
97
+ if (error) throw new Error(error.message);
98
+ }
99
+
100
+ /** Recursively list every object in the bucket, paginating through folders. */
101
+ async list(): Promise<StorageFileInfo[]> {
102
+ const out: StorageFileInfo[] = [];
103
+ const walk = async (prefix: string): Promise<void> => {
104
+ const PAGE = 1000;
105
+ for (let offset = 0; ; offset += PAGE) {
106
+ const { data, error } = await this.bucket().list(prefix, {
107
+ limit: PAGE,
108
+ offset,
109
+ sortBy: { column: "name", order: "asc" },
110
+ });
111
+ if (error) throw new Error(error.message);
112
+ const entries = data ?? [];
113
+ for (const f of entries as any[]) {
114
+ const name = prefix ? `${prefix}/${f.name}` : (f.name as string);
115
+ if (isInternalMediaPath(name)) continue;
116
+ // Supabase returns folders as rows without an id/metadata.
117
+ if (!f.id && !f.metadata) {
118
+ await walk(name);
119
+ continue;
120
+ }
121
+ out.push({
122
+ name,
123
+ size: f.metadata?.size ?? 0,
124
+ contentType: f.metadata?.mimetype ?? null,
125
+ updatedAt: f.updated_at ?? f.created_at ?? null,
126
+ });
127
+ }
128
+ if (entries.length < PAGE) break;
129
+ }
130
+ };
131
+ await walk("");
132
+ return out;
133
+ }
134
+
135
+ publicUrl(filePath: string): string {
136
+ const { data } = this.bucket().getPublicUrl(filePath);
137
+ return data.publicUrl ?? "";
138
+ }
139
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Public URLs for files stored in a site's media library.
3
+ *
4
+ * The URL shape is platform behaviour and identical on every site; only the
5
+ * origins differ, so each site configures them once at startup instead of the
6
+ * package reading any site's environment variables.
7
+ */
8
+
9
+ export type MediaUrlConfig = {
10
+ /** Supabase project URL, when media is served from Supabase Storage. */
11
+ supabaseUrl?: string | undefined;
12
+ /** Explicit public origin, used by self-hosted installs. */
13
+ mediaPublicUrl?: string | undefined;
14
+ /** Storage bucket holding the media library. */
15
+ bucket?: string;
16
+ };
17
+
18
+ let config: Required<Pick<MediaUrlConfig, "bucket">> & MediaUrlConfig = { bucket: "media" };
19
+
20
+ /** Register the origins this install serves media from. */
21
+ export function configureMediaUrls(next: MediaUrlConfig): void {
22
+ config = {
23
+ ...config,
24
+ ...next,
25
+ mediaPublicUrl: next.mediaPublicUrl?.replace(/\/$/, "") ?? config.mediaPublicUrl,
26
+ bucket: next.bucket ?? config.bucket,
27
+ };
28
+ }
29
+
30
+ export function mediaBucket(): string {
31
+ return config.bucket;
32
+ }
33
+
34
+ export function mediaUrl(path: string): string {
35
+ const clean = path.replace(/^\/+/, "");
36
+ if (config.mediaPublicUrl) return `${config.mediaPublicUrl}/${clean}`;
37
+ if (config.supabaseUrl) {
38
+ return `${config.supabaseUrl}/storage/v1/object/public/${config.bucket}/${clean}`;
39
+ }
40
+ // Self-hosted default: same-origin, served by the /media/* route.
41
+ return `/media/${clean}`;
42
+ }
43
+
44
+ /**
45
+ * Resolve any stored media reference to a URL that works on this install.
46
+ *
47
+ * Content written while media was served from the app's own origin still holds
48
+ * `/media/<file>` paths, so rewrite that legacy shape. Absolute URLs, data URIs
49
+ * and non-media paths are returned untouched.
50
+ */
51
+ export function resolveMediaSrc<T extends string | undefined>(src: T): T {
52
+ if (!src) return src;
53
+ if (!src.startsWith("/media/")) return src;
54
+ return mediaUrl(src.slice("/media/".length)) as T;
55
+ }
@@ -0,0 +1,24 @@
1
+ export type MediaUsage = {
2
+ entity: "section" | "page" | "global_section" | "page_seo" | "block_template";
3
+ entityKey: string;
4
+ label: string;
5
+ editPath: string;
6
+ publicPath?: string;
7
+ jsonPath: string;
8
+ };
9
+
10
+ export type MediaUsageReport = {
11
+ byPath: Record<string, MediaUsage[]>;
12
+ external: Array<{ url: string; usages: MediaUsage[] }>;
13
+ };
14
+
15
+ export type MediaImportResult =
16
+ | {
17
+ path: string;
18
+ url: string;
19
+ skipped?: false;
20
+ error?: never;
21
+ rewritten?: number;
22
+ deduped?: boolean;
23
+ }
24
+ | { path: ""; url: ""; skipped: true; error: string; rewritten?: never; deduped?: never };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Host platform contract.
3
+ *
4
+ * Blocks and inspectors already reach the host through `../host`. Backoffice
5
+ * *screens* additionally need data: pages, forms, media, SEO and audit access.
6
+ * Those calls are per-site (each site owns its own database, server functions
7
+ * and auth), while the screens themselves are identical everywhere — so the
8
+ * package declares the contract here and each site registers an adapter at
9
+ * startup.
10
+ *
11
+ * Adapters return plain data. No Supabase client, request or response object
12
+ * ever crosses this boundary, which keeps the package usable by a site that is
13
+ * not backed by Supabase at all.
14
+ */
15
+
16
+ import type { Json } from "../i18n/json";
17
+
18
+ export type PageSummary = {
19
+ key: string;
20
+ title: string;
21
+ updatedAt: string | null;
22
+ published: boolean;
23
+ };
24
+
25
+ export type PageDraft = {
26
+ key: string;
27
+ versionNo: number;
28
+ blocks: Json;
29
+ };
30
+
31
+ export type MediaEntry = {
32
+ path: string;
33
+ url: string;
34
+ size: number;
35
+ contentType: string | null;
36
+ updatedAt: string | null;
37
+ };
38
+
39
+ /** Data access the shared backoffice screens need from the host site. */
40
+ export type PlatformData = {
41
+ pages: {
42
+ list: () => Promise<PageSummary[]>;
43
+ loadDraft: (key: string) => Promise<PageDraft | null>;
44
+ saveDraft: (input: PageDraft) => Promise<{ versionNo: number }>;
45
+ publish: (key: string, versionNo: number) => Promise<void>;
46
+ };
47
+ media: {
48
+ list: () => Promise<MediaEntry[]>;
49
+ remove: (paths: string[]) => Promise<void>;
50
+ };
51
+ audit?: {
52
+ log: (event: { action: string; target: string; detail?: Json }) => Promise<void>;
53
+ };
54
+ };
55
+
56
+ /** Non-data facts about the host install that shared screens display. */
57
+ export type PlatformSite = {
58
+ /** Human name shown in the backoffice chrome. */
59
+ name: string;
60
+ /** Backoffice base path, e.g. `/kontoret`. */
61
+ backofficeBasePath: string;
62
+ /** Public site origin, used for preview links. */
63
+ publicOrigin?: string;
64
+ };
65
+
66
+ export type HostPlatform = {
67
+ site: PlatformSite;
68
+ data: PlatformData;
69
+ };
70
+
71
+ let platform: HostPlatform | null = null;
72
+
73
+ /** Register the host adapter. Call once during site startup. */
74
+ export function registerHostPlatform(next: HostPlatform): HostPlatform {
75
+ platform = next;
76
+ return next;
77
+ }
78
+
79
+ export function getHostPlatform(): HostPlatform {
80
+ if (!platform) {
81
+ throw new Error(
82
+ "@bygghammar/cms-platform: no host platform registered. Call registerHostPlatform() during site startup.",
83
+ );
84
+ }
85
+ return platform;
86
+ }
87
+
88
+ export function hasHostPlatform(): boolean {
89
+ return platform !== null;
90
+ }
@@ -58,6 +58,33 @@ export function defineTripTerminology(terminology: TripTerminology): TripTermino
58
58
  return terminology;
59
59
  }
60
60
 
61
+ /** Neutral fallback used until a site registers its own wording. */
62
+ export const DEFAULT_TRIP_TERMINOLOGY: TripTerminology = {
63
+ singular: "Trip",
64
+ plural: "Trips",
65
+ number: "Trip number",
66
+ dates: "Trip dates",
67
+ location: "Route",
68
+ accommodation: "Accommodation",
69
+ description:
70
+ "This is the single source of truth for trips and departures across the whole site.",
71
+ };
72
+
73
+ let terminology: TripTerminology = DEFAULT_TRIP_TERMINOLOGY;
74
+
75
+ /**
76
+ * Register the site's trip wording so shared platform screens can label
77
+ * trip-related fields without hardcoding any site's vocabulary.
78
+ */
79
+ export function registerTripTerminology(value: TripTerminology): TripTerminology {
80
+ terminology = value;
81
+ return value;
82
+ }
83
+
84
+ export function getTripTerminology(): TripTerminology {
85
+ return terminology;
86
+ }
87
+
61
88
  export function tripNumber(item: Pick<TripItem, "tripNo" | "courseNo">): Partial<LocalizedString> {
62
89
  return item.tripNo ?? item.courseNo ?? {};
63
90
  }