@brightsideoy/kama 1.0.0-alpha.7 → 1.0.0-alpha.9

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.
@@ -45,13 +45,13 @@ if (region.includes("_")) {
45
45
  }
46
46
 
47
47
  /**
48
- * Default single-column layout.
48
+ * Default single-column layout (aligned to backend CDN buckets).
49
49
  */
50
50
  let maxWidth = "1352";
51
51
  let targetColumnWidth = 1352;
52
52
 
53
- // 1-col: Base mobile (~400), 2x mobile/tablet (~800), 1x desktop (1352), 2x desktop (2704)
54
- let optimizedWidths = [400, 800, 1352, 2048, 2704];
53
+ // 1-col: 1x/2x desktop and retina buckets
54
+ let optimizedWidths = [768, 1352, 1920, 2560];
55
55
  let sizes = "(max-width: 1399px) calc(100vw - 48px), 1352px";
56
56
 
57
57
  /**
@@ -61,8 +61,7 @@ if (columnCount === 2) {
61
61
  maxWidth = "660";
62
62
  targetColumnWidth = 660;
63
63
 
64
- // 2-col: 1x desktop (660), 2x mobile stacked (~800), 2x desktop (1320)
65
- optimizedWidths = [400, 660, 800, 1320];
64
+ optimizedWidths = [640, 768, 1024, 1352];
66
65
  sizes =
67
66
  "(max-width: 767px) calc(100vw - 48px), " +
68
67
  "(max-width: 1399px) calc((100vw - 80px) / 2), " +
@@ -76,8 +75,7 @@ else if (columnCount === 3) {
76
75
  maxWidth = "429";
77
76
  targetColumnWidth = 429;
78
77
 
79
- // 3-col: 1x desktop (429), 2x desktop/tablet (~858), 2x/3x mobile stacked (~1000)
80
- optimizedWidths = [300, 429, 858, 1024];
78
+ optimizedWidths = [320, 640, 768, 1024];
81
79
  sizes =
82
80
  "(max-width: 767px) calc(100vw - 48px), " +
83
81
  "(max-width: 1399px) calc((100vw - 112px) / 3), " +
@@ -91,8 +89,7 @@ else if (columnCount === 4) {
91
89
  maxWidth = "314";
92
90
  targetColumnWidth = 314;
93
91
 
94
- // 4-col: 1x desktop (314), 2x desktop (628), 2x/3x mobile stacked (~1000)
95
- optimizedWidths = [264, 314, 628, 1024];
92
+ optimizedWidths = [320, 640, 768];
96
93
  sizes =
97
94
  "(max-width: 767px) calc(100vw - 48px), " +
98
95
  "(max-width: 1399px) calc((100vw - 144px) / 4), " +
@@ -106,12 +103,9 @@ else if (columnCount === 4) {
106
103
  sizes={sizes}
107
104
  desktopWidths={optimizedWidths}
108
105
  idealWidth={targetColumnWidth}
109
- width={data.width || data.image_meta?.width}
110
- height={data.height || data.image_meta?.height}
111
106
  class="responsive-header-image"
112
107
  loading={isLcp ? "eager" : "lazy"}
113
108
  fetchpriority={isLcp ? "high" : "auto"}
114
- decoding={isLcp ? "sync" : "async"}
115
109
  />
116
110
  </div>
117
111
 
@@ -26,31 +26,31 @@ if (region.includes("_")) {
26
26
  if (region.includes("_col4")) columnCount = 4;
27
27
  }
28
28
 
29
- // 🎯 Column Config Lookup (Max width, breakpoint widths, sizes)
29
+ // 🎯 Column Config Lookup (Target width, aligned CDN breakpoint widths, sizes)
30
30
  const COLUMN_CONFIGS: Record<
31
31
  number,
32
32
  { targetWidth: number; widths: number[]; sizes: string }
33
33
  > = {
34
34
  1: {
35
35
  targetWidth: 1352,
36
- widths: [768, 1352],
36
+ widths: [768, 1352, 1920, 2560],
37
37
  sizes: "(max-width: 1399px) calc(100vw - 48px), 1352px",
38
38
  },
39
39
  2: {
40
40
  targetWidth: 660,
41
- widths: [400, 660],
41
+ widths: [640, 768, 1024, 1352],
42
42
  sizes:
43
43
  "(max-width: 767px) calc(100vw - 48px), (max-width: 1399px) calc((100vw - 80px) / 2), 660px",
44
44
  },
45
45
  3: {
46
46
  targetWidth: 429,
47
- widths: [300, 429],
47
+ widths: [320, 640, 768, 1024],
48
48
  sizes:
49
49
  "(max-width: 767px) calc(100vw - 48px), (max-width: 1399px) calc((100vw - 112px) / 3), 429px",
50
50
  },
51
51
  4: {
52
52
  targetWidth: 314,
53
- widths: [264, 314],
53
+ widths: [320, 640, 768],
54
54
  sizes:
55
55
  "(max-width: 767px) calc(100vw - 48px), (max-width: 1399px) calc((100vw - 144px) / 4), 314px",
56
56
  },
@@ -67,7 +67,6 @@ const layout = COLUMN_CONFIGS[columnCount] || COLUMN_CONFIGS[1];
67
67
  sizes={layout.sizes}
68
68
  desktopWidths={layout.widths}
69
69
  idealWidth={layout.targetWidth}
70
- maxWidth={layout.targetWidth}
71
70
  class="free-crop-image"
72
71
  loading={isLcp ? "eager" : "lazy"}
73
72
  fetchpriority={isLcp ? "high" : "auto"}
@@ -20,7 +20,7 @@ interface Props {
20
20
  const {
21
21
  image = {},
22
22
  sizes = "100vw",
23
- desktopWidths = [768, 1352],
23
+ desktopWidths = [768, 1352, 1920, 2560],
24
24
  idealWidth = 1352,
25
25
  class: className = "",
26
26
  loading = "lazy",
@@ -90,18 +90,26 @@ function buildAgnosticSrcset(
90
90
  .join(", ");
91
91
  }
92
92
 
93
- // Generate srcsets only for raster images
93
+ // 🎯 High-DPI / Retina Optimization:
94
+ // - Mobile: Includes 320, 640, 768 (covers 2x and 3x Retina mobile displays)
95
+ // - Tablet: Includes 768, 1024, 1352 (covers iPad Pro Retina density)
96
+ // - Desktop: Uses desktopWidths (defaults to 768, 1352, 1920, 2560 for 1440p and 4K)
94
97
  const mobileSrcset =
95
- !isSvg && rawMobileUrl ? buildAgnosticSrcset(rawMobileUrl, [400, 768]) : null;
98
+ !isSvg && rawMobileUrl
99
+ ? buildAgnosticSrcset(rawMobileUrl, [320, 640, 768])
100
+ : null;
96
101
 
97
102
  const tabletSrcset =
98
103
  !isSvg && rawTabletUrl
99
- ? buildAgnosticSrcset(rawTabletUrl, [768, 1024])
104
+ ? buildAgnosticSrcset(rawTabletUrl, [768, 1024, 1352])
100
105
  : null;
101
106
 
102
107
  const desktopSrcset =
103
108
  !isSvg && rawDesktopUrl
104
- ? buildAgnosticSrcset(rawDesktopUrl, desktopWidths)
109
+ ? buildAgnosticSrcset(
110
+ rawDesktopUrl,
111
+ desktopWidths.length ? desktopWidths : [768, 1352, 1920, 2560],
112
+ )
105
113
  : null;
106
114
 
107
115
  const fallbackUrl =
@@ -28,10 +28,21 @@ const kamaImageService: LocalImageService = {
28
28
 
29
29
  const url = new URL(src, 'http://localhost');
30
30
 
31
- if (options.width) url.searchParams.set('w', Math.round(options.width).toString());
32
- if (options.height) url.searchParams.set('h', Math.round(options.height).toString());
33
- if (options.quality) url.searchParams.set('q', options.quality.toString());
34
- if (options.format) url.searchParams.set('f', options.format.toString());
31
+ // 🎯 Pass width if defined (snapped on backend by assets.ts)
32
+ if (options.width && !isNaN(options.width)) {
33
+ url.searchParams.set('w', Math.round(options.width).toString());
34
+ }
35
+
36
+ // 🎯 Pass height only if explicitly provided to maintain intrinsic aspect ratios
37
+ if (options.height && !isNaN(options.height)) {
38
+ url.searchParams.set('h', Math.round(options.height).toString());
39
+ }
40
+
41
+ // 🎯 Default quality to 75 if unsupplied
42
+ url.searchParams.set('q', (options.quality || 75).toString());
43
+
44
+ // 🎯 Default format to 'auto' for backend WebP/AVIF negotiation
45
+ url.searchParams.set('f', options.format ? options.format.toString() : 'auto');
35
46
 
36
47
  return src.startsWith('http') ? url.toString() : (url.pathname + url.search);
37
48
  } catch (e) {
@@ -28,12 +28,31 @@ export async function handleMediaProxy({ request, params, url, locals }: APICont
28
28
  // 🎯 Detect if Astro is requesting a resized variant
29
29
  const hasResizeParams = url.searchParams.has('w') || url.searchParams.has('h') || url.searchParams.has('q') || url.searchParams.has('f');
30
30
 
31
+ // 🎯 Resolve Negotiated Format for Edge Cache Key
32
+ const acceptHeader = request.headers.get('Accept') || '';
33
+ const fParam = url.searchParams.get('f');
34
+ let resolvedFormat = fParam && fParam !== 'auto' ? fParam : '';
35
+
36
+ if (!resolvedFormat && hasResizeParams) {
37
+ if (acceptHeader.includes('image/avif')) resolvedFormat = 'avif';
38
+ else if (acceptHeader.includes('image/webp')) resolvedFormat = 'webp';
39
+ }
40
+
41
+ // 🎯 Construct a safe Cache Key that accounts for format negotiation
42
+ let cacheKeyUrl = request.url;
43
+ if (hasResizeParams && resolvedFormat && !fParam) {
44
+ const cacheUrlObj = new URL(cacheKeyUrl);
45
+ cacheUrlObj.searchParams.set('_resolvedFormat', resolvedFormat);
46
+ cacheKeyUrl = cacheUrlObj.toString();
47
+ }
48
+ const cacheKey = new Request(cacheKeyUrl, request);
49
+
31
50
  // 1. Cloudflare Edge Cache API
32
51
  let edgeCache: Cache | null = null;
33
52
  try {
34
53
  if (typeof caches !== "undefined" && caches.default) {
35
54
  edgeCache = caches.default;
36
- const cachedResponse = await edgeCache.match(request);
55
+ const cachedResponse = await edgeCache.match(cacheKey);
37
56
  if (cachedResponse) return cachedResponse;
38
57
  }
39
58
  } catch (err) {
@@ -67,7 +86,7 @@ export async function handleMediaProxy({ request, params, url, locals }: APICont
67
86
  const r2Response = new Response(r2Object.body, { status: 200, headers });
68
87
 
69
88
  if (edgeCache) {
70
- const cachePromise = edgeCache.put(request, r2Response.clone());
89
+ const cachePromise = edgeCache.put(cacheKey, r2Response.clone());
71
90
  if (cfContext?.waitUntil) cfContext.waitUntil(cachePromise);
72
91
  else cachePromise.catch(e => console.warn(e));
73
92
  }
@@ -112,7 +131,7 @@ export async function handleMediaProxy({ request, params, url, locals }: APICont
112
131
  });
113
132
 
114
133
  if (edgeCache) {
115
- const cachePromise = edgeCache.put(request, proxyResponse.clone());
134
+ const cachePromise = edgeCache.put(cacheKey, proxyResponse.clone());
116
135
  if (cfContext?.waitUntil) cfContext.waitUntil(cachePromise);
117
136
  else cachePromise.catch(e => console.warn(e));
118
137
  }
@@ -1,11 +1,22 @@
1
1
  // src/lib/media/media-url.ts
2
2
 
3
+ /**
4
+ * Normalizes absolute CMS URLs into clean relative /media/... paths.
5
+ * Ensures CSP compliance by routing requests through the Astro frontend proxy.
6
+ */
3
7
  export function toRelativeMediaPath(rawUrl: string): string {
4
8
  if (!rawUrl) return "";
5
9
 
6
- let clean = String(rawUrl);
7
- clean = clean.replace(/^https?:\/\/[^/]+/, "");
8
- clean = clean.split("?")[0].split("#")[0];
10
+ let clean = String(rawUrl).trim();
11
+
12
+ try {
13
+ // Parse absolute or relative URLs safely using a dummy origin fallback
14
+ const urlObj = new URL(clean, "http://localhost");
15
+ clean = urlObj.pathname;
16
+ } catch {
17
+ // Fallback cleanup for malformed strings
18
+ clean = clean.replace(/^https?:\/\/[^/]+/, "").split("?")[0].split("#")[0];
19
+ }
9
20
 
10
21
  const mediaIndex = clean.indexOf("/media/");
11
22
  if (mediaIndex !== -1) {
@@ -26,6 +37,9 @@ export function isSvgImage(urlOrMime: string): boolean {
26
37
  return lower.endsWith('.svg') || lower.includes('image/svg+xml') || lower.includes('.svg?');
27
38
  }
28
39
 
40
+ /**
41
+ * Constructs an agnostic /media/... URL with standard optimization query parameters.
42
+ */
29
43
  export function buildAgnosticImageUrl(
30
44
  url: string,
31
45
  width: number,
@@ -187,6 +187,46 @@ const hasSidebar = sidebarBlocks.length > 0;
187
187
 
188
188
  // Detect if this node is a standalone Page Skeleton
189
189
  const isSkeleton = node?.nodeType === "page-skeleton";
190
+
191
+ // 🎯 HTML-OVER-THE-WIRE HOT SWAP LOGIC
192
+ // Isolated as a string so we don't violate DRY when injecting into multiple layouts
193
+ const hotSwapScript = `
194
+ if (window !== window.top) {
195
+ window.addEventListener("message", async event => {
196
+ if (event.data && event.data.type === "kama-preview-update") {
197
+ try {
198
+ const currentScrollY = window.scrollY;
199
+ const res = await fetch(event.data.url);
200
+ const newHtml = await res.text();
201
+ const newDoc = new DOMParser().parseFromString(newHtml, "text/html");
202
+
203
+ newDoc.body
204
+ .querySelectorAll("script, style, link, meta, noscript")
205
+ .forEach(el => el.remove());
206
+
207
+ const preserveTags = ["SCRIPT", "STYLE", "LINK", "META", "NOSCRIPT"];
208
+
209
+ Array.from(document.body.children).forEach(child => {
210
+ if (!preserveTags.includes(child.tagName.toUpperCase())) {
211
+ child.remove();
212
+ }
213
+ });
214
+
215
+ const referenceNode = document.body.firstChild;
216
+ Array.from(newDoc.body.children).forEach(child => {
217
+ if (!preserveTags.includes(child.tagName.toUpperCase())) {
218
+ document.body.insertBefore(child.cloneNode(true), referenceNode);
219
+ }
220
+ });
221
+
222
+ window.scrollTo(0, currentScrollY);
223
+ } catch (err) {
224
+ console.error("[KAMA Preview] Failed to hot-swap HTML:", err);
225
+ }
226
+ }
227
+ });
228
+ }
229
+ `;
190
230
  ---
191
231
 
192
232
  {
@@ -199,6 +239,9 @@ const isSkeleton = node?.nodeType === "page-skeleton";
199
239
  isHomePage={isHomePage}
200
240
  >
201
241
  <BlockRenderer blocks={mainBlocks} contentMap={content} region="main" />
242
+
243
+ {/* 🎯 FIX: Script injected securely inside the layout ONLY during preview mode */}
244
+ {isPreview && <script is:inline set:html={hotSwapScript} />}
202
245
  </SkeletonLayout>
203
246
  ) : (
204
247
  <StandardLayout
@@ -258,6 +301,9 @@ const isSkeleton = node?.nodeType === "page-skeleton";
258
301
  </aside>
259
302
  )}
260
303
  </div>
304
+
305
+ {/* 🎯 FIX: Script injected securely inside the layout ONLY during preview mode */}
306
+ {isPreview && <script is:inline set:html={hotSwapScript} />}
261
307
  </StandardLayout>
262
308
  )
263
309
  }