@bygghammar/cms-platform 0.19.1 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.19.1",
3
+ "version": "0.20.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,
@@ -107,7 +107,8 @@
107
107
  "./server/guards": "./src/server/guards.ts",
108
108
  "./server/redirects-core": "./src/server/redirects-core.ts",
109
109
  "./server/page-seo-core": "./src/server/page-seo-core.ts",
110
- "./server/media-cleanup-core": "./src/server/media-cleanup-core.ts"
110
+ "./server/media-cleanup-core": "./src/server/media-cleanup-core.ts",
111
+ "./server/media-usage-core": "./src/server/media-usage-core.ts"
111
112
  },
112
113
  "dependencies": {
113
114
  "fflate": "^0.8.3"
@@ -180,4 +181,4 @@
180
181
  "optional": true
181
182
  }
182
183
  }
183
- }
184
+ }
@@ -10,7 +10,7 @@
10
10
  /** Minimal shape of an authenticated request context a host must provide. */
11
11
  export type PlatformAuthContext = {
12
12
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
13
- supabase: { rpc: (fn: any, args: any) => Promise<{ data: any; error: any }> };
13
+ supabase: { rpc: (fn: any, args: any) => PromiseLike<{ data: any; error: any }> };
14
14
  userId: string;
15
15
  claims?: { email?: string | null } | null;
16
16
  };
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Host-agnostic media usage scanning, external-media import and reference
3
+ * rewriting.
4
+ *
5
+ * The scan walks every CMS-owned JSON column, matches the URLs it finds against
6
+ * the media library listing, and reports both library usage and importable
7
+ * external media. The host injects its clients, its storage provider and the
8
+ * facts only a site knows: the backoffice route prefix and the public path of a
9
+ * page key.
10
+ */
11
+ import { variantSiblings } from "../media/srcset";
12
+ import { cleanFileName, splitExt, uniqueFileName } from "../media/filename";
13
+ import type { MediaImportResult, MediaUsage, MediaUsageReport } from "../media/usage-types";
14
+
15
+ /* eslint-disable @typescript-eslint/no-explicit-any */
16
+
17
+ /** Storage surface the usage tools need — satisfied by `getMediaStorage()`. */
18
+ export type UsageStorage = {
19
+ list: () => Promise<Array<{ name: string; size?: number }>>;
20
+ download: (path: string) => Promise<Uint8Array>;
21
+ upload: (
22
+ path: string,
23
+ body: Uint8Array,
24
+ contentType: string,
25
+ options?: { upsert?: boolean },
26
+ ) => Promise<unknown>;
27
+ publicUrl: (path: string) => string;
28
+ };
29
+
30
+ export type MediaUsageHost = {
31
+ /** Reader client; RLS applies as the signed-in user. */
32
+ db: any;
33
+ storage: UsageStorage;
34
+ /** Backoffice route prefix, e.g. `/kontoret`. */
35
+ backofficePrefix?: string;
36
+ /** Public URL path for a CMS page key, when the site has one. */
37
+ pagePublicPath?: (pageKey: string) => string | undefined;
38
+ };
39
+
40
+ export type MediaImportHost = MediaUsageHost & {
41
+ /** Privileged client used to rewrite references across CMS content. */
42
+ admin: any;
43
+ /** User-Agent sent when fetching remote media. */
44
+ userAgent?: string;
45
+ };
46
+
47
+ const URL_RE = /https?:\/\/[^\s"'<>)]+/gi;
48
+ const MEDIA_EXT_RE =
49
+ /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov|m4v|mp3|wav|ogg|pdf)(\?[^\s"'<>)]*)?$/i;
50
+
51
+ function walk(value: unknown, onString: (s: string, path: string) => void, path = "") {
52
+ if (!value) return;
53
+ if (typeof value === "string") {
54
+ if (
55
+ value.length > 4 &&
56
+ (value.startsWith("http") || value.startsWith("/") || MEDIA_EXT_RE.test(value))
57
+ ) {
58
+ onString(value, path);
59
+ }
60
+ return;
61
+ }
62
+ if (Array.isArray(value)) {
63
+ value.forEach((v, i) => walk(v, onString, `${path}[${i}]`));
64
+ return;
65
+ }
66
+ if (typeof value === "object") {
67
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
68
+ walk(v, onString, path ? `${path}.${k}` : k);
69
+ }
70
+ }
71
+ }
72
+
73
+ function extractUrlsFromString(s: string): string[] {
74
+ if (s.startsWith("/") && !s.startsWith("//")) return [s];
75
+ const matches = s.match(URL_RE);
76
+ return matches ?? (MEDIA_EXT_RE.test(s) ? [s] : []);
77
+ }
78
+
79
+ function safeDecodeURIComponent(value: string) {
80
+ try {
81
+ return decodeURIComponent(value);
82
+ } catch {
83
+ return value;
84
+ }
85
+ }
86
+
87
+ function normalizeUrlPath(rawUrl: string, form: "NFC" | "NFD") {
88
+ try {
89
+ const url = new URL(rawUrl);
90
+ const pathname = url.pathname
91
+ .split("/")
92
+ .map((part) => encodeURIComponent(safeDecodeURIComponent(part).normalize(form)))
93
+ .join("/");
94
+ return `${url.origin}${pathname}${url.search}${url.hash}`;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function encodeUrlPath(rawUrl: string) {
101
+ try {
102
+ const url = new URL(rawUrl);
103
+ const pathname = url.pathname
104
+ .split("/")
105
+ .map((part) => encodeURIComponent(safeDecodeURIComponent(part)))
106
+ .join("/");
107
+ return `${url.origin}${pathname}${url.search}${url.hash}`;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** WordPress exports link resized copies; the original is the same name without `-WxH`. */
114
+ function removeWordPressSizeSuffix(rawUrl: string) {
115
+ try {
116
+ const url = new URL(rawUrl);
117
+ const decodedPath = url.pathname
118
+ .split("/")
119
+ .map((part) => safeDecodeURIComponent(part))
120
+ .join("/");
121
+ const originalPath = decodedPath.replace(/-\d+x\d+(\.[a-z0-9]+)$/i, "$1");
122
+ if (originalPath === decodedPath) return null;
123
+ const encodedPath = originalPath
124
+ .split("/")
125
+ .map((part) => encodeURIComponent(part))
126
+ .join("/");
127
+ return `${url.origin}${encodedPath}${url.search}${url.hash}`;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /** Every spelling of a remote URL worth trying: encoding, unicode form, original size. */
134
+ export function remoteUrlCandidates(rawUrl: string): string[] {
135
+ const candidates = new Set<string>();
136
+ const add = (candidate: string | null) => {
137
+ if (candidate) candidates.add(candidate);
138
+ };
139
+
140
+ const trimmed = rawUrl.trim();
141
+ add(trimmed);
142
+ add(encodeUrlPath(trimmed));
143
+ add(normalizeUrlPath(trimmed, "NFD"));
144
+ add(normalizeUrlPath(trimmed, "NFC"));
145
+
146
+ const original = removeWordPressSizeSuffix(trimmed);
147
+ add(original);
148
+ if (original) {
149
+ add(encodeUrlPath(original));
150
+ add(normalizeUrlPath(original, "NFD"));
151
+ add(normalizeUrlPath(original, "NFC"));
152
+ }
153
+
154
+ return Array.from(candidates);
155
+ }
156
+
157
+ async function fetchRemoteMedia(rawUrl: string, userAgent: string) {
158
+ let lastStatus: number | null = null;
159
+ let lastError: string | null = null;
160
+
161
+ const headers = {
162
+ "User-Agent": userAgent,
163
+ Accept:
164
+ "image/avif,image/webp,image/apng,image/svg+xml,image/*,video/*,application/pdf,*/*;q=0.8",
165
+ };
166
+
167
+ for (const url of remoteUrlCandidates(rawUrl)) {
168
+ try {
169
+ const response = await fetch(url, { headers, redirect: "follow" });
170
+ if (response.ok) return { response, resolvedUrl: url };
171
+ lastStatus = response.status;
172
+ } catch (error) {
173
+ lastError = error instanceof Error ? error.message : "Network error";
174
+ }
175
+ }
176
+
177
+ const reason = lastStatus ? `Fetch failed: ${lastStatus}` : (lastError ?? "Fetch failed");
178
+ return { error: `${reason} for ${rawUrl}` };
179
+ }
180
+
181
+ /** Scan every CMS content column and report which library files are used where. */
182
+ export async function scanMediaUsageCore(host: MediaUsageHost): Promise<MediaUsageReport> {
183
+ const sb = host.db;
184
+ const prefix = host.backofficePrefix ?? "/kontoret";
185
+ const publicPathOf = host.pagePublicPath ?? (() => undefined);
186
+
187
+ const libPaths = new Set<string>();
188
+ for (const file of await host.storage.list()) libPaths.add(file.name);
189
+
190
+ const [sectionsR, pagesR, versionsR, gSectionsR, gVersionsR, seoR, templatesR] =
191
+ await Promise.all([
192
+ sb.from("content_sections").select("key, data"),
193
+ sb.from("pages").select("id, key, name, current_version_id"),
194
+ sb.from("page_versions").select("id, page_id, blocks"),
195
+ sb.from("global_sections").select("id, key, name, current_version_id"),
196
+ sb.from("global_section_versions").select("id, section_id, blocks"),
197
+ sb.from("page_seo").select("page_key, slugs, seo"),
198
+ sb.from("block_templates").select("id, name, block"),
199
+ ]);
200
+
201
+ const pageById = new Map<string, { key: string; name: string; current: string | null }>();
202
+ for (const page of pagesR.data ?? []) {
203
+ pageById.set(page.id, { key: page.key, name: page.name, current: page.current_version_id });
204
+ }
205
+
206
+ const globalSectionById = new Map<
207
+ string,
208
+ { key: string; name: string; current: string | null }
209
+ >();
210
+ for (const section of gSectionsR.data ?? []) {
211
+ globalSectionById.set(section.id, {
212
+ key: section.key,
213
+ name: section.name,
214
+ current: section.current_version_id,
215
+ });
216
+ }
217
+
218
+ const byPath: Record<string, MediaUsage[]> = {};
219
+ const externalMap = new Map<string, MediaUsage[]>();
220
+ const seen = new Set<string>();
221
+
222
+ /**
223
+ * The library only lists the primary variant of a responsive bundle, so a
224
+ * reference to any sibling width (`…-1200.webp`) must count on that primary.
225
+ */
226
+ const canonicalPath = (path: string) => {
227
+ const siblings = variantSiblings(path);
228
+ if (!siblings.length) return path;
229
+ const suffix = path.match(/-(\d+)\.webp$/i);
230
+ if (!suffix) return path;
231
+ const base = path.slice(0, path.length - suffix[0].length);
232
+ for (const width of [2560, 1920]) {
233
+ const candidate = `${base}-${width}.webp`;
234
+ if (libPaths.has(candidate)) return candidate;
235
+ }
236
+ return path;
237
+ };
238
+
239
+ const record = (rawUrl: string, jsonPath: string, usage: Omit<MediaUsage, "jsonPath">) => {
240
+ const url = rawUrl.trim();
241
+ if (!url) return;
242
+
243
+ let matched: string | null = null;
244
+ for (const path of libPaths) {
245
+ if (url.endsWith(`/${path}`) || url === path || url.endsWith(`/media/${path}`)) {
246
+ matched = path;
247
+ break;
248
+ }
249
+ }
250
+
251
+ const rec: MediaUsage = { ...usage, jsonPath };
252
+ if (matched) {
253
+ const target = canonicalPath(matched);
254
+ // One srcSet string references every width — count that as a single use.
255
+ const dedupeKey = `${target}|${usage.entity}|${usage.entityKey}|${jsonPath}`;
256
+ if (seen.has(dedupeKey)) return;
257
+ seen.add(dedupeKey);
258
+ (byPath[target] ||= []).push(rec);
259
+ } else if (MEDIA_EXT_RE.test(url.split("?")[0]!) || MEDIA_EXT_RE.test(url)) {
260
+ // Only real media files are importable — never link URLs (booking, pages…).
261
+ const usages = externalMap.get(url) ?? [];
262
+ usages.push(rec);
263
+ externalMap.set(url, usages);
264
+ }
265
+ };
266
+
267
+ for (const row of sectionsR.data ?? []) {
268
+ walk(row.data, (s, path) => {
269
+ for (const url of extractUrlsFromString(s)) {
270
+ record(url, path, {
271
+ entity: "section",
272
+ entityKey: row.key,
273
+ label: `Section: ${row.key}`,
274
+ editPath: `${prefix}/sections/${row.key}`,
275
+ publicPath: "/",
276
+ });
277
+ }
278
+ });
279
+ }
280
+
281
+ for (const version of versionsR.data ?? []) {
282
+ const page = pageById.get(version.page_id);
283
+ if (!page || page.current !== version.id) continue;
284
+ walk(version.blocks, (s, path) => {
285
+ for (const url of extractUrlsFromString(s)) {
286
+ record(url, path, {
287
+ entity: "page",
288
+ entityKey: page.key,
289
+ label: `Page: ${page.name}`,
290
+ editPath: `${prefix}/pages-builder/${page.key}`,
291
+ publicPath: publicPathOf(page.key),
292
+ });
293
+ }
294
+ });
295
+ }
296
+
297
+ for (const version of gVersionsR.data ?? []) {
298
+ const section = globalSectionById.get(version.section_id);
299
+ if (!section || section.current !== version.id) continue;
300
+ walk(version.blocks, (s, path) => {
301
+ for (const url of extractUrlsFromString(s)) {
302
+ record(url, path, {
303
+ entity: "global_section",
304
+ entityKey: section.key,
305
+ label: `Global block: ${section.name}`,
306
+ editPath: `${prefix}/global-sections/${section.key}`,
307
+ });
308
+ }
309
+ });
310
+ }
311
+
312
+ for (const row of seoR.data ?? []) {
313
+ walk(row.seo, (s, path) => {
314
+ for (const url of extractUrlsFromString(s)) {
315
+ record(url, `seo.${path}`, {
316
+ entity: "page_seo",
317
+ entityKey: row.page_key,
318
+ label: `SEO: ${row.page_key}`,
319
+ editPath: `${prefix}/seo/${row.page_key}`,
320
+ publicPath: publicPathOf(row.page_key),
321
+ });
322
+ }
323
+ });
324
+ }
325
+
326
+ for (const template of templatesR.data ?? []) {
327
+ walk(template.block, (s, path) => {
328
+ for (const url of extractUrlsFromString(s)) {
329
+ record(url, path, {
330
+ entity: "block_template",
331
+ entityKey: template.id,
332
+ label: `Template: ${template.name}`,
333
+ editPath: `${prefix}/pages-builder`,
334
+ });
335
+ }
336
+ });
337
+ }
338
+
339
+ const external = Array.from(externalMap.entries())
340
+ .map(([url, usages]) => ({ url, usages }))
341
+ .sort((a, b) => b.usages.length - a.usages.length);
342
+
343
+ return { byPath, external };
344
+ }
345
+
346
+ /** Replace every stored reference to `oldUrls` with `newUrl` across CMS content. */
347
+ export async function rewriteMediaReferencesCore(
348
+ sb: any,
349
+ oldUrls: string[],
350
+ newUrl: string,
351
+ ): Promise<number> {
352
+ const targets = Array.from(new Set(oldUrls.map((u) => u.trim()).filter(Boolean)));
353
+ if (!targets.length) return 0;
354
+
355
+ let changes = 0;
356
+ const replaceDeep = (value: any): any => {
357
+ if (typeof value === "string") {
358
+ let out = value;
359
+ for (const t of targets) {
360
+ if (out.includes(t)) {
361
+ out = out.split(t).join(newUrl);
362
+ changes++;
363
+ }
364
+ }
365
+ return out;
366
+ }
367
+ if (Array.isArray(value)) return value.map(replaceDeep);
368
+ if (value && typeof value === "object") {
369
+ const next: Record<string, any> = {};
370
+ for (const [k, v] of Object.entries(value)) next[k] = replaceDeep(v);
371
+ return next;
372
+ }
373
+ return value;
374
+ };
375
+
376
+ const tables: Array<{ table: string; column: string; idColumn: string }> = [
377
+ { table: "content_sections", column: "data", idColumn: "key" },
378
+ { table: "page_versions", column: "blocks", idColumn: "id" },
379
+ { table: "global_section_versions", column: "blocks", idColumn: "id" },
380
+ { table: "page_seo", column: "seo", idColumn: "page_key" },
381
+ { table: "block_templates", column: "block", idColumn: "id" },
382
+ ];
383
+
384
+ for (const { table, column, idColumn } of tables) {
385
+ const { data: rows } = await sb.from(table).select(`${idColumn}, ${column}`);
386
+ for (const row of rows ?? []) {
387
+ const before = JSON.stringify(row[column] ?? null);
388
+ const updated = replaceDeep(row[column]);
389
+ const after = JSON.stringify(updated ?? null);
390
+ if (before === after) continue;
391
+ await sb
392
+ .from(table)
393
+ .update({ [column]: updated })
394
+ .eq(idColumn, row[idColumn]);
395
+ }
396
+ }
397
+
398
+ return changes;
399
+ }
400
+
401
+ async function sha256Hex(bytes: Uint8Array) {
402
+ const hash = await crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer);
403
+ return Array.from(new Uint8Array(hash))
404
+ .map((b) => b.toString(16).padStart(2, "0"))
405
+ .join("");
406
+ }
407
+
408
+ /**
409
+ * Download a remote media file into the library under `imported/`, keeping its
410
+ * original filename for SEO, then repoint CMS references at the local copy.
411
+ * Identical bytes are never stored twice.
412
+ */
413
+ export async function importRemoteUrlCore(
414
+ data: { url: string; filename?: string },
415
+ host: MediaImportHost,
416
+ ): Promise<MediaImportResult> {
417
+ const remote = await fetchRemoteMedia(
418
+ data.url,
419
+ host.userAgent ?? "Mozilla/5.0 (compatible; CmsMediaImporter/1.0)",
420
+ );
421
+ if ("error" in remote) {
422
+ return { path: "", url: "", skipped: true, error: remote.error ?? "Fetch failed" };
423
+ }
424
+
425
+ const contentType = remote.response.headers.get("content-type") ?? "application/octet-stream";
426
+ const buffer = new Uint8Array(await remote.response.arrayBuffer());
427
+ const urlObj = new URL(remote.resolvedUrl);
428
+ const rawName =
429
+ data.filename ||
430
+ urlObj.pathname.split("/").filter(Boolean).map(safeDecodeURIComponent).pop() ||
431
+ "imported-file";
432
+ const clean = cleanFileName(rawName, "imported-file");
433
+
434
+ const all = await host.storage.list();
435
+ const existing = all
436
+ .filter((f) => f.name.startsWith("imported/"))
437
+ .map((f) => ({ name: f.name.slice("imported/".length), size: f.size }));
438
+ const taken = new Set<string>(existing.map((f) => f.name));
439
+
440
+ const incomingHash = await sha256Hex(buffer);
441
+ const { base, ext } = splitExt(clean);
442
+ const family = new RegExp(
443
+ `^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(-\\d+)?${ext.replace(/\./g, "\\.")}$`,
444
+ );
445
+ let duplicateOf: string | null = null;
446
+ for (const file of existing) {
447
+ if (!family.test(file.name)) continue;
448
+ if (typeof file.size === "number" && file.size > 0 && file.size !== buffer.byteLength) continue;
449
+ let bytes: Uint8Array;
450
+ try {
451
+ bytes = await host.storage.download(`imported/${file.name}`);
452
+ } catch {
453
+ continue;
454
+ }
455
+ if ((await sha256Hex(bytes)) === incomingHash) {
456
+ duplicateOf = `imported/${file.name}`;
457
+ break;
458
+ }
459
+ }
460
+
461
+ if (duplicateOf) {
462
+ const existingUrl = host.storage.publicUrl(duplicateOf);
463
+ const rewrittenDup = existingUrl
464
+ ? await rewriteMediaReferencesCore(host.admin, remoteUrlCandidates(data.url), existingUrl)
465
+ : 0;
466
+ return { path: duplicateOf, url: existingUrl, rewritten: rewrittenDup, deduped: true };
467
+ }
468
+
469
+ const path = `imported/${uniqueFileName(clean, taken)}`;
470
+ await host.storage.upload(path, buffer, contentType, { upsert: false });
471
+
472
+ const newUrl = host.storage.publicUrl(path);
473
+ const rewritten = newUrl
474
+ ? await rewriteMediaReferencesCore(host.admin, remoteUrlCandidates(data.url), newUrl)
475
+ : 0;
476
+
477
+ return { path, url: newUrl, rewritten };
478
+ }