@brightsideoy/kama 1.0.0-alpha.13 → 1.0.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/bin/cli.js +282 -158
  2. package/content/definitions/taxonomies/categories.yaml +5 -2
  3. package/content/definitions/taxonomies/tags.yaml +5 -2
  4. package/dist/assets/AssetEditor-CCNDGlQ_.js +1 -0
  5. package/dist/assets/AssetEditor-D5j7pwcK.css +1 -0
  6. package/dist/assets/CmsHelp-BO_vWABq.css +1 -0
  7. package/dist/assets/CmsHelp-m9EOY_nE.js +1 -0
  8. package/dist/assets/CmsPublisher-Cs_ys68V.js +3 -0
  9. package/dist/assets/CmsPublisher-CugEqTD8.css +1 -0
  10. package/dist/assets/CmsUserCenter-Ac0GywlR.css +1 -0
  11. package/dist/assets/CmsUserCenter-D-T6HyWF.js +1 -0
  12. package/dist/assets/EditorSplitPreview-BzKh4wFB.css +1 -0
  13. package/dist/assets/EditorSplitPreview-CnWpXZWQ.js +1 -0
  14. package/dist/assets/NodeEditor-B1TvB2eW.css +1 -0
  15. package/dist/assets/NodeEditor-CZGnkwvf.js +147 -0
  16. package/dist/assets/TrashWorkspace-CERX58Ek.js +1 -0
  17. package/dist/assets/TrashWorkspace-DtMgxg13.css +1 -0
  18. package/dist/assets/_plugin-vue_export-helper-CTn6xl4w.js +5 -0
  19. package/dist/assets/history-XvWNE5zO.js +1 -0
  20. package/dist/assets/index-BpOAL4tE.js +14 -0
  21. package/dist/assets/index-C-EkPNvq.css +1 -0
  22. package/dist/assets/layers-D0DLWjff.js +1 -0
  23. package/dist/assets/link-Bs_SUS0O.js +1 -0
  24. package/dist/assets/rotate-ccw-CvD7yNQj.js +1 -0
  25. package/dist/assets/save-C8e-QRkP.js +1 -0
  26. package/dist/assets/smartphone-Wy6xQi_i.js +1 -0
  27. package/dist/{dist-kIJlDMvm.js → dist-BGv2bzzF.js} +633 -539
  28. package/dist/index.html +3 -2
  29. package/dist/index.js +633 -539
  30. package/dist/schema-compiler.js +1 -1
  31. package/package.json +1 -1
  32. package/schema.sql +3 -0
  33. package/templates/astro-starter/src/components/blocks/NodeList.astro +59 -63
  34. package/templates/astro-starter/src/middleware.ts +63 -35
  35. package/templates/astro-starter/src/pages/[...slug].astro +5 -12
  36. package/templates/astro-starter/src/pages/api/load-nodes.ts +42 -14
  37. package/templates/astro-starter/src/pages/api/partials/node-cards.astro +101 -0
  38. package/templates/kama-worker/setup-local-db.mjs +17 -0
  39. package/dist/assets/index-B1ogCRCg.js +0 -164
  40. package/dist/assets/index-CGLx9oqF.css +0 -1
@@ -30,7 +30,7 @@ async function f() {
30
30
  if (n(f)) l = (await import(t(f).href)).compileDefinitions;
31
31
  else if (n(p)) l = (await import(t(p).href)).compileDefinitions;
32
32
  else try {
33
- l = (await import("./dist-kIJlDMvm.js")).compileDefinitions;
33
+ l = (await import("./dist-BGv2bzzF.js")).compileDefinitions;
34
34
  } catch (e) {
35
35
  console.error("\x1B[31m ✖\x1B[0m \x1B[31mCould not resolve Kama Schema Engine:\x1B[0m", e), process.exit(1);
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brightsideoy/kama",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.17",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/schema.sql CHANGED
@@ -92,6 +92,8 @@ CREATE TABLE IF NOT EXISTS "content_nodes" (
92
92
  language TEXT,
93
93
  isHidden INTEGER NOT NULL DEFAULT 0,
94
94
  status TEXT DEFAULT 'draft',
95
+ startShowingAt TEXT,
96
+ stopShowingAt TEXT,
95
97
  hasUnpublishedChanges INTEGER DEFAULT 0,
96
98
  disabledRegionsJson TEXT DEFAULT '{}',
97
99
  dataJson TEXT,
@@ -263,3 +265,4 @@ CREATE INDEX IF NOT EXISTS idx_node_relations_source ON node_relations(sourceNod
263
265
  CREATE INDEX IF NOT EXISTS idx_node_relations_target ON node_relations(targetNodeId);
264
266
  CREATE INDEX IF NOT EXISTS idx_node_routes_lookup ON node_routes(path, channelId);
265
267
  CREATE INDEX IF NOT EXISTS idx_node_routes_node ON node_routes(nodeId);
268
+ CREATE INDEX IF NOT EXISTS idx_content_nodes_public_feed ON content_nodes(contentType, isHidden, status, startShowingAt, stopShowingAt, updatedAt DESC);
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  // src/components/blocks/NodeList.astro
3
+ import KamaImage from "@/components/ui/KamaImage.astro";
3
4
 
4
5
  interface Props {
5
6
  data: Record<string, any>;
@@ -21,10 +22,13 @@ const listTitle = data.listTitle || "";
21
22
  {items.map((item: any) => (
22
23
  <article class="node-card">
23
24
  <a href={`/${item.slug}`} class="node-card-link">
25
+ {/* 🎯 ARCHITECTURAL FIX: Use KamaImage for initial load optimization */}
24
26
  {item.image ? (
25
- <img
26
- src={item.image.url}
27
- alt={item.image.alt || item.title}
27
+ <KamaImage
28
+ image={item.image}
29
+ sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
30
+ desktopWidths={[320, 640, 768, 1024]}
31
+ idealWidth={429}
28
32
  loading="lazy"
29
33
  />
30
34
  ) : (
@@ -66,6 +70,7 @@ const listTitle = data.listTitle || "";
66
70
  )
67
71
  }
68
72
  </div>
73
+
69
74
  <style is:global>
70
75
  .node-list-wrapper {
71
76
  margin: 2rem 0;
@@ -124,14 +129,18 @@ const listTitle = data.listTitle || "";
124
129
  height: 100%;
125
130
  }
126
131
 
132
+ /* 🎯 Because of is:global, this styling will automatically apply to <KamaImage> outputs! */
127
133
  .node-card img,
128
134
  .node-card .image-placeholder {
129
135
  width: 100%;
130
- aspect-ratio: 16 / 9;
136
+ height: auto; /* 🎯 FIX: Allows tall images to display their natural height */
131
137
  object-fit: cover;
132
138
  background: #f1f5f9;
133
139
  }
134
140
 
141
+ .node-card .image-placeholder {
142
+ aspect-ratio: 16 / 9; /* Keep the placeholder a rectangle if there is no image at all */
143
+ }
135
144
  .node-card-content {
136
145
  padding: 1.25rem;
137
146
  display: flex;
@@ -186,10 +195,12 @@ const listTitle = data.listTitle || "";
186
195
  </style>
187
196
 
188
197
  <script>
189
- document.addEventListener("DOMContentLoaded", () => {
190
- const buttons = document.querySelectorAll(".load-more-btn");
198
+ function initLoadMoreButtons() {
199
+ const buttons = document.querySelectorAll(".load-more-btn:not(.is-bound)");
191
200
 
192
201
  buttons.forEach(btn => {
202
+ btn.classList.add("is-bound");
203
+
193
204
  btn.addEventListener("click", async e => {
194
205
  const targetBtn = e.currentTarget as HTMLButtonElement;
195
206
  const urlString = targetBtn.getAttribute("data-url");
@@ -200,69 +211,47 @@ const listTitle = data.listTitle || "";
200
211
  const grid = document.getElementById(targetId);
201
212
  if (!grid) return;
202
213
 
203
- // Change the original backend URL path to our local Astro proxy path
204
214
  const urlObj = new URL(urlString, window.location.origin);
205
- const proxyUrl = `/api/load-nodes${urlObj.search}`;
215
+ // 🎯 ARCHITECTURAL FIX: Route to the new HTML Partial Endpoint
216
+ const proxyUrl = `/api/partials/node-cards${urlObj.search}`;
206
217
 
207
- const originalText = targetBtn.textContent;
208
218
  targetBtn.textContent = "Loading...";
209
219
  targetBtn.disabled = true;
210
220
 
211
221
  try {
212
222
  const res = await fetch(proxyUrl);
213
- const data = await res.json();
214
-
215
- if (data.items && data.items.length > 0) {
216
- // Append each new item to the grid
217
- data.items.forEach((item: any) => {
218
- const dateStr = item.createdAt
219
- ? new Date(item.createdAt).toLocaleDateString("en-GB", {
220
- day: "numeric",
221
- month: "short",
222
- year: "numeric",
223
- })
224
- : "";
225
-
226
- const imgHtml = item.image
227
- ? `<img src="${item.image.url}" alt="${item.image.alt || item.title}" loading="lazy" />`
228
- : `<div class="image-placeholder"></div>`;
229
-
230
- const articleHtml = `
231
- <article class="node-card">
232
- <a href="/${item.slug}" class="node-card-link">
233
- ${imgHtml}
234
- <div class="node-card-content">
235
- <h3 class="node-card-title">${item.title}</h3>
236
- ${dateStr ? `<time datetime="${item.createdAt}" class="node-card-date">${dateStr}</time>` : ""}
237
- ${item.excerpt ? `<p class="node-card-excerpt">${item.excerpt}</p>` : ""}
238
- </div>
239
- </a>
240
- </article>
241
- `;
242
- grid.insertAdjacentHTML("beforeend", articleHtml);
243
- });
244
-
245
- // If we received a full batch, there might be more. Update the offset.
246
- if (data.items.length === data.limit) {
247
- const currentOffset = parseInt(
248
- urlObj.searchParams.get("offset") || "0",
249
- 10,
250
- );
251
- urlObj.searchParams.set(
252
- "offset",
253
- (currentOffset + data.limit).toString(),
254
- );
255
- targetBtn.setAttribute(
256
- "data-url",
257
- urlObj.pathname + urlObj.search,
258
- );
259
- targetBtn.textContent = "Load More";
260
- targetBtn.disabled = false;
261
- } else {
262
- // Hide the button container if we've reached the end
263
- if (targetBtn.parentElement)
264
- targetBtn.parentElement.style.display = "none";
265
- }
223
+
224
+ if (!res.ok) {
225
+ throw new Error(`HTTP error! status: ${res.status}`);
226
+ }
227
+
228
+ // 1. Read metadata from headers sent by our partial endpoint
229
+ const rawCountStr = res.headers.get("X-Consumed-Rows");
230
+ const limitStr = res.headers.get("X-Limit");
231
+ const consumedRows = parseInt(rawCountStr || "0", 10);
232
+ const limit = parseInt(limitStr || "12", 10);
233
+
234
+ // 2. Extract and render the raw HTML
235
+ const htmlSnippet = await res.text();
236
+
237
+ if (htmlSnippet.trim().length > 0) {
238
+ grid.insertAdjacentHTML("beforeend", htmlSnippet);
239
+ }
240
+
241
+ // 3. Handle Pagination
242
+ if (consumedRows >= limit && limit > 0) {
243
+ const currentOffset = parseInt(
244
+ urlObj.searchParams.get("offset") || "0",
245
+ 10,
246
+ );
247
+ urlObj.searchParams.set(
248
+ "offset",
249
+ (currentOffset + limit).toString(),
250
+ );
251
+
252
+ targetBtn.setAttribute("data-url", urlObj.pathname + urlObj.search);
253
+ targetBtn.textContent = "Load More";
254
+ targetBtn.disabled = false;
266
255
  } else {
267
256
  if (targetBtn.parentElement)
268
257
  targetBtn.parentElement.style.display = "none";
@@ -274,5 +263,12 @@ const listTitle = data.listTitle || "";
274
263
  }
275
264
  });
276
265
  });
277
- });
266
+ }
267
+
268
+ if (document.readyState === "loading") {
269
+ document.addEventListener("DOMContentLoaded", initLoadMoreButtons);
270
+ } else {
271
+ initLoadMoreButtons();
272
+ }
273
+ document.addEventListener("astro:page-load", initLoadMoreButtons);
278
274
  </script>
@@ -84,7 +84,6 @@ export const onRequest = defineMiddleware(async (context, next) => {
84
84
  return new Response("Not Found", { status: 404 });
85
85
  }
86
86
 
87
- // 🎯 0B. DIRECT MEDIA PROXY VIA SERVICE BINDING
88
87
  // 🎯 0B. DIRECT MEDIA PROXY VIA SERVICE BINDING WITH SURGICAL WORKER CACHING
89
88
  if (pathname.startsWith("/media/")) {
90
89
  const cache = typeof caches !== "undefined" && caches.default ? caches.default : null;
@@ -155,7 +154,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
155
154
  }
156
155
 
157
156
  const authHeader = context.request.headers.get("Authorization");
158
- const expectedToken = workerEnv.CLOUDFLARE_API_TOKEN;
157
+ const expectedToken = workerEnv.FRONTEND_API_SECRET || workerEnv.CLOUDFLARE_API_TOKEN;
159
158
 
160
159
  if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
161
160
  return new Response(JSON.stringify({ error: "Unauthorized Token Mismatch" }), {
@@ -169,6 +168,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
169
168
  const cache = typeof caches !== "undefined" && caches.default ? caches.default : null;
170
169
  const origin = context.url.origin;
171
170
 
171
+ if (body.purge_everything && !HTML_CACHE) {
172
+ return new Response(JSON.stringify({ success: false, error: "HTML_CACHE binding is unavailable" }), {
173
+ status: 503,
174
+ headers: { "Content-Type": "application/json" },
175
+ });
176
+ }
177
+
172
178
  // A. Purge Workers Caching Layer (Astro cfContext)
173
179
  if (typeof cfContext?.cache?.purge === 'function') {
174
180
  try {
@@ -194,9 +200,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
194
200
  await HTML_CACHE.put("v:/", now);
195
201
  console.log(`✅ [Version Tagging] Global site_version updated to ${now}`);
196
202
  } catch (kvLimitErr: any) {
197
- console.warn(`[KV Warning] Skipped global version bump: ${kvLimitErr.message}`);
203
+ console.error(`[KV Error] Global version bump failed: ${kvLimitErr.message}`);
204
+ return new Response(JSON.stringify({ success: false, error: "Failed to invalidate HTML KV cache" }), {
205
+ status: 503,
206
+ headers: { "Content-Type": "application/json" },
207
+ });
198
208
  }
199
209
  } else if (Array.isArray(body.urls) && body.urls.length > 0) {
210
+ // Shared fragments and cross-page content depend on the site version too.
211
+ await HTML_CACHE.put("site_version", now);
212
+
200
213
  // 🎯 SURGICAL PURGE: Parallel execution for page version bumps safely wrapped for quota
201
214
  const purgeTasks = body.urls.map(async (targetUrl: string) => {
202
215
  const urlPath = new URL(targetUrl).pathname;
@@ -339,50 +352,60 @@ export const onRequest = defineMiddleware(async (context, next) => {
339
352
 
340
353
  // 🎯 1. KV STATIC "FILE" CACHE READ WITH VERSION TAGGING
341
354
  const isWarmer = context.request.headers.get("X-Cache-Warmer") === "true";
342
- const isHtmlPage = !pathname.includes('.') && !pathname.startsWith('/api/');
343
- // 🎯 The ONLY way we refer to the homepage in KV is "/"
355
+
356
+ // 🎯 ARCHITECTURE FIX: Allow HTML partials to be edge-cached alongside regular HTML
357
+ const isHtmlPage = !pathname.includes('.') && (!pathname.startsWith('/api/') || pathname.startsWith('/api/partials/'));
358
+
359
+ // 🎯 ARCHITECTURE FIX: Include query string for partials so offset=6 and offset=12 have distinct cache keys
344
360
  const cleanLookupPath = pathname === "/index" || pathname === "/index/"
345
361
  ? "/"
346
- : (pathname.replace(/\/$/, "") || "/");
362
+ : (pathname.replace(/\/$/, "") || "/") + (pathname.startsWith('/api/partials/') ? context.url.search : "");
347
363
 
348
364
  let activeCacheKey = cleanLookupPath;
349
365
 
350
- if (!isDev && isHtmlPage && HTML_CACHE && !isWarmer && !isTimeTraveling) {
366
+ // 🎯 FIX: Retrieve the activeCacheKey versions even if it's a warmer request, so it doesn't overwrite them!
367
+ if (!isDev && isHtmlPage && HTML_CACHE && !isTimeTraveling) {
351
368
  try {
369
+ // 🎯 Partials inherit the global site_version so they automatically purge when the CMS publishes
370
+ const pageVersionKey = pathname.startsWith('/api/partials/') ? "site_version" : `v:${pathname.replace(/\/$/, "") || "/"}`;
371
+
352
372
  const [siteVer, pageVer] = await Promise.all([
353
373
  HTML_CACHE.get("site_version"),
354
- HTML_CACHE.get(`v:${cleanLookupPath}`)
374
+ HTML_CACHE.get(pageVersionKey)
355
375
  ]);
356
376
 
357
377
  // 🎯 Combine versions deterministically
358
378
  activeCacheKey = `${cleanLookupPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
359
379
 
360
- const cachedData = await HTML_CACHE.get(activeCacheKey);
361
- if (cachedData) {
362
- let finalHtml = cachedData;
363
-
364
- try {
365
- if (cachedData.startsWith("{")) {
366
- const parsed = JSON.parse(cachedData);
367
- if (parsed.html) {
368
- finalHtml = parsed.html;
380
+ // 🎯 Only serve cached HTML if it is NOT a warmer request
381
+ if (!isWarmer) {
382
+ const cachedData = await HTML_CACHE.get(activeCacheKey);
383
+ if (cachedData) {
384
+ let finalHtml = cachedData;
385
+
386
+ try {
387
+ if (cachedData.startsWith("{")) {
388
+ const parsed = JSON.parse(cachedData);
389
+ if (parsed.html) {
390
+ finalHtml = parsed.html;
391
+ }
369
392
  }
393
+ } catch (jsonErr) {
394
+ // Fallback to raw HTML
370
395
  }
371
- } catch (jsonErr) {
372
- // Fallback to raw HTML
373
- }
374
396
 
375
- return new Response(finalHtml, {
376
- status: 200,
377
- headers: {
378
- "Content-Type": "text/html; charset=utf-8",
379
- "X-Cache-Status": "HIT-KV-STATICFILE",
380
- "X-Cache-Key": activeCacheKey,
381
- // 🎯 ARCHITECTURE FIX: Prevent Cloudflare Edge from locking stale HTML in RAM.
382
- // Force Edge to revalidate with Worker logic while keeping browser cache fresh.
383
- "Cache-Control": "public, max-age=0, must-revalidate"
384
- }
385
- });
397
+ return new Response(finalHtml, {
398
+ status: 200,
399
+ headers: {
400
+ "Content-Type": "text/html; charset=utf-8",
401
+ "X-Cache-Status": "HIT-KV-STATICFILE",
402
+ "X-Cache-Key": activeCacheKey,
403
+ // Prevent Cloudflare Edge from locking stale HTML in RAM.
404
+ // Force Edge to revalidate with Worker logic while keeping browser cache fresh.
405
+ "Cache-Control": "public, max-age=0, must-revalidate"
406
+ }
407
+ });
408
+ }
386
409
  }
387
410
  } catch (e) {
388
411
  console.warn("[Middleware] KV lookup bypass:", e);
@@ -410,18 +433,23 @@ export const onRequest = defineMiddleware(async (context, next) => {
410
433
  response.headers.delete("X-KV-Expiration");
411
434
 
412
435
  const writeTask = (async () => {
413
- if (activeCacheKey === cleanLookupPath || isWarmer) {
436
+ // 🎯 FIX: Removed `|| isWarmer` here so warmer requests safely write to the existing activeCacheKey
437
+ if (activeCacheKey === cleanLookupPath) {
414
438
  const forcedVersion = Date.now().toString();
415
439
  try {
416
- await HTML_CACHE.put(`v:${cleanLookupPath}`, forcedVersion);
440
+ // 🎯 FIX: Do NOT overwrite global site_version from a frontend partial cache miss
441
+ if (!pathname.startsWith('/api/partials/')) {
442
+ await HTML_CACHE.put(`v:${pathname.replace(/\/$/, "") || "/"}`, forcedVersion);
443
+ }
417
444
  const siteVer = await HTML_CACHE.get("site_version") || "1";
418
- activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${forcedVersion}`;
445
+ const pageVer = pathname.startsWith('/api/partials/') ? siteVer : forcedVersion;
446
+ activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${pageVer}`;
419
447
  } catch (kvLimitErr: any) {
420
448
  console.warn(`[KV Warning] Skipped prewarm version bump: ${kvLimitErr.message}`);
421
449
  }
422
450
  }
423
451
 
424
- // 🎯 Architecture Fix: Save TTL alongside HTML so CDN can properly cache reads
452
+ // Save TTL alongside HTML so CDN can properly cache reads
425
453
  const storedTtl = kvTtl !== undefined ? Math.max(60, kvTtl) : 31536000;
426
454
  const payload = JSON.stringify({ html: htmlText, ttl: storedTtl });
427
455
 
@@ -59,7 +59,7 @@ const envApiUrl = import.meta.env.PUBLIC_API_URL;
59
59
  const workerApiUrl = safeEnv.PUBLIC_API_URL;
60
60
  const baseApiUrl = String(envApiUrl || workerApiUrl || "http://localhost:8787");
61
61
 
62
- // 🎯 NEW: Safely extract the secret across both environments
62
+ // 🎯 Safely extract the secret across both environments
63
63
  const frontendApiSecret =
64
64
  import.meta.env.FRONTEND_API_SECRET || safeEnv.FRONTEND_API_SECRET;
65
65
 
@@ -87,11 +87,9 @@ const customFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
87
87
  ...init?.headers,
88
88
  "Cache-Control": "no-cache, no-store, must-revalidate",
89
89
  Pragma: "no-cache",
90
- // 🎯 Direct Service Bindings require explicit header flags to bypass CMS KV cache!
91
90
  "X-Bypass-Cache": "true",
92
91
  };
93
92
 
94
- // 🎯 NEW: Inject the secret securely into every outgoing server request
95
93
  if (frontendApiSecret) {
96
94
  reqHeaders["Authorization"] = `Bearer ${frontendApiSecret}`;
97
95
  }
@@ -116,7 +114,9 @@ const treeId = getTreeId();
116
114
  const rawSlug = Array.isArray(slug) ? slug.join("/") : slugStr;
117
115
  const pathParts = rawSlug.split("/").filter(part => part.trim() !== "");
118
116
  const requestedSlug = pathParts.join("/");
119
- const targetSlug = requestedSlug || homeSlug;
117
+
118
+ // 🎯 ONLY FIX NEEDED HERE: Pass '/' if empty so home route resolves, otherwise requestedSlug
119
+ const targetSlug = requestedSlug || "/";
120
120
 
121
121
  let pageData: NodeResponse | null = null;
122
122
  let navTree: TreeNode[] = [];
@@ -151,14 +151,12 @@ const node = pageData.node;
151
151
  const content = pageData.content || {};
152
152
 
153
153
  // 🎯 SMART EDGE CACHING LOGIC (API-Driven TTL)
154
- // Rely on the backend API's meta.cacheTtl which calculates the closest publishing event for the node AND its blocks.
155
154
  let cacheTtl = pageData?.meta?.cacheTtl;
156
155
 
157
156
  if (cacheTtl === undefined || cacheTtl === null) {
158
157
  cacheTtl = 31536000; // Default: 1 Year (Forever)
159
158
  }
160
159
 
161
- // Clamp to Cloudflare's 60-second minimum for KV expirations
162
160
  cacheTtl = Math.max(60, cacheTtl);
163
161
 
164
162
  if (isTimeTraveling) {
@@ -167,12 +165,10 @@ if (isTimeTraveling) {
167
165
  "no-cache, no-store, must-revalidate",
168
166
  );
169
167
  } else {
170
- // 🎯 FIX: max-age=0 prevents local browser caching. s-maxage handles Cloudflare CDN!
171
168
  Astro.response.headers.set(
172
169
  "Cache-Control",
173
170
  `public, max-age=0, s-maxage=${cacheTtl}, must-revalidate`,
174
171
  );
175
- // Tell middleware how long to store in KV
176
172
  Astro.response.headers.set("X-KV-Expiration", cacheTtl.toString());
177
173
  }
178
174
 
@@ -182,6 +178,7 @@ const rootTreeSlug =
182
178
  : "";
183
179
 
184
180
  const isHomePage =
181
+ targetSlug === "/" ||
185
182
  targetSlug === homeSlug ||
186
183
  (rootTreeSlug && targetSlug === rootTreeSlug) ||
187
184
  node?.path === "/" ||
@@ -197,8 +194,6 @@ const hasSidebar = sidebarBlocks.length > 0;
197
194
  // Detect if this node is a standalone Page Skeleton
198
195
  const isSkeleton = node?.nodeType === "page-skeleton";
199
196
 
200
- // 🎯 HTML-OVER-THE-WIRE HOT SWAP LOGIC
201
- // Isolated as a string so we don't violate DRY when injecting into multiple layouts
202
197
  const hotSwapScript = `
203
198
  if (window !== window.top) {
204
199
  window.addEventListener("message", async event => {
@@ -249,7 +244,6 @@ const hotSwapScript = `
249
244
  >
250
245
  <BlockRenderer blocks={mainBlocks} contentMap={content} region="main" />
251
246
 
252
- {/* 🎯 FIX: Script injected securely inside the layout ONLY during preview mode */}
253
247
  {isPreview && <script is:inline set:html={hotSwapScript} />}
254
248
  </SkeletonLayout>
255
249
  ) : (
@@ -311,7 +305,6 @@ const hotSwapScript = `
311
305
  )}
312
306
  </div>
313
307
 
314
- {/* 🎯 FIX: Script injected securely inside the layout ONLY during preview mode */}
315
308
  {isPreview && <script is:inline set:html={hotSwapScript} />}
316
309
  </StandardLayout>
317
310
  )
@@ -1,40 +1,68 @@
1
1
  // src/pages/api/load-nodes.ts
2
2
  import type { APIRoute } from "astro";
3
3
 
4
+ export const prerender = false; // Required to ensure this runs server-side on Cloudflare
5
+
4
6
  export const GET: APIRoute = async ({ request, locals }) => {
5
7
  const url = new URL(request.url);
6
8
  const searchParams = url.searchParams;
7
9
 
8
- // 1. Get Environment Variables securely
9
- const safeEnv = (locals as any).safeEnv || {};
10
- const apiUrl = import.meta.env.PUBLIC_API_URL || safeEnv.PUBLIC_API_URL || "http://localhost:8787";
11
- const secret = import.meta.env.FRONTEND_API_SECRET || safeEnv.FRONTEND_API_SECRET;
10
+ // 1. Get Environment Variables securely via middleware
11
+ const cfEnv = (locals as any).safeEnv || {};
12
+
13
+ const cmsWorker = cfEnv.CMS_WORKER;
14
+ const apiUrl = cfEnv.PUBLIC_API_URL || import.meta.env.PUBLIC_API_URL || "http://localhost:8787";
15
+ const secret = cfEnv.FRONTEND_API_SECRET || import.meta.env.FRONTEND_API_SECRET;
16
+
17
+ if (!secret) {
18
+ console.error("🚨 [Load Nodes Proxy] Critical Error: FRONTEND_API_SECRET is missing from the environment!");
19
+ }
12
20
 
13
21
  // 2. Construct the actual backend URL
14
- const backendUrl = `${apiUrl.replace(/\/$/, "")}/api/v1/nodes?${searchParams.toString()}`;
22
+ const cleanApiUrl = apiUrl.replace(/\/$/, "");
23
+ const backendUrl = `${cleanApiUrl}/api/v1/nodes?${searchParams.toString()}`;
15
24
 
16
25
  try {
17
- // 3. Fetch from the CMS securely
18
- const response = await fetch(backendUrl, {
19
- headers: {
20
- "Accept": "application/json",
21
- ...(secret ? { "Authorization": `Bearer ${secret}` } : {})
22
- }
26
+ // 3. Prepare the secure headers
27
+ const reqHeaders: Record<string, string> = {
28
+ "Accept": "application/json",
29
+ };
30
+
31
+ if (secret) {
32
+ reqHeaders["Authorization"] = `Bearer ${secret}`;
33
+ }
34
+
35
+ const backendReq = new Request(backendUrl, {
36
+ method: "GET",
37
+ headers: reqHeaders
23
38
  });
24
39
 
40
+ // 4. Fetch from the CMS securely (Bypass Cloudflare Access via Service Binding)
41
+ let response;
42
+ if (cmsWorker && typeof cmsWorker.fetch === "function") {
43
+ response = await cmsWorker.fetch(backendReq);
44
+ } else {
45
+ response = await fetch(backendReq);
46
+ }
47
+
25
48
  if (!response.ok) {
49
+ console.error(`[Load Nodes Proxy] Backend rejected request with status: ${response.status}`);
26
50
  return new Response(JSON.stringify({ error: "Failed to fetch nodes" }), { status: response.status });
27
51
  }
28
52
 
29
53
  const data = await response.json();
30
54
 
31
- // 4. Return to the browser
55
+ // 5. Return to the browser
32
56
  return new Response(JSON.stringify(data), {
33
57
  status: 200,
34
- headers: { "Content-Type": "application/json" }
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ // 🎯 ARCHITECTURE FIX: Cache at the Cloudflare Edge for 60 seconds to eliminate redundant DB queries.
61
+ "Cache-Control": "public, max-age=0, s-maxage=60, stale-while-revalidate=120"
62
+ }
35
63
  });
36
64
  } catch (err) {
37
- console.error("[Astro Proxy] Failed to load nodes:", err);
65
+ console.error("[Load Nodes Proxy] Failed to load nodes:", err);
38
66
  return new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500 });
39
67
  }
40
68
  };
@@ -0,0 +1,101 @@
1
+ ---
2
+ // src/pages/api/partials/node-cards.astro
3
+ export const partial = true; // Tells Astro not to inject DOCTYPE or <head> tags
4
+
5
+ import KamaImage from "@/components/ui/KamaImage.astro";
6
+
7
+ const { request, locals } = Astro;
8
+ const url = new URL(request.url);
9
+ const searchParams = url.searchParams;
10
+
11
+ // 1. Get Environment Variables securely via middleware
12
+ const cfEnv = (locals as any).safeEnv || {};
13
+ const cmsWorker = cfEnv.CMS_WORKER;
14
+ const apiUrl =
15
+ cfEnv.PUBLIC_API_URL ||
16
+ import.meta.env.PUBLIC_API_URL ||
17
+ "http://localhost:8787";
18
+ const secret = cfEnv.FRONTEND_API_SECRET || import.meta.env.FRONTEND_API_SECRET;
19
+
20
+ // 2. Construct the actual backend URL
21
+ const cleanApiUrl = apiUrl.replace(/\/$/, "");
22
+ const backendUrl = `${cleanApiUrl}/api/v1/nodes?${searchParams.toString()}`;
23
+
24
+ // 3. Prepare the secure headers
25
+ const reqHeaders: Record<string, string> = { Accept: "application/json" };
26
+ if (secret) reqHeaders["Authorization"] = `Bearer ${secret}`;
27
+
28
+ const backendReq = new Request(backendUrl, {
29
+ method: "GET",
30
+ headers: reqHeaders,
31
+ });
32
+
33
+ // 4. Fetch securely using Service Binding
34
+ let response;
35
+ if (cmsWorker && typeof cmsWorker.fetch === "function") {
36
+ response = await cmsWorker.fetch(backendReq);
37
+ } else {
38
+ response = await fetch(backendReq);
39
+ }
40
+
41
+ let itemsArray: any[] = [];
42
+ let limit = 12;
43
+ let consumedRows = 0;
44
+
45
+ if (response.ok) {
46
+ const data = await response.json();
47
+ itemsArray = Array.isArray(data)
48
+ ? data
49
+ : data.items || data.nodes || data.data || [];
50
+ limit = data.limit || 12;
51
+ consumedRows =
52
+ data.rawCount !== undefined ? data.rawCount : itemsArray.length;
53
+ }
54
+
55
+ // 5. Send pagination data back to the frontend script via custom HTTP Headers
56
+ Astro.response.headers.set("X-Consumed-Rows", consumedRows.toString());
57
+ Astro.response.headers.set("X-Limit", limit.toString());
58
+ Astro.response.headers.set(
59
+ "Cache-Control",
60
+ "public, max-age=0, s-maxage=60, stale-while-revalidate=120",
61
+ );
62
+ ---
63
+
64
+ {
65
+ itemsArray.map((item: any) => {
66
+ const dateStr = item.createdAt
67
+ ? new Date(item.createdAt).toLocaleDateString("en-GB", {
68
+ day: "numeric",
69
+ month: "short",
70
+ year: "numeric",
71
+ })
72
+ : "";
73
+
74
+ return (
75
+ <article class="node-card">
76
+ <a href={`/${item.slug}`} class="node-card-link">
77
+ {item.image ? (
78
+ <KamaImage
79
+ image={item.image}
80
+ sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
81
+ desktopWidths={[320, 640, 768, 1024]}
82
+ idealWidth={429}
83
+ loading="lazy"
84
+ />
85
+ ) : (
86
+ <div class="image-placeholder" />
87
+ )}
88
+ <div class="node-card-content">
89
+ <h3 class="node-card-title">{item.title}</h3>
90
+ {dateStr && (
91
+ <time datetime={item.createdAt} class="node-card-date">
92
+ {dateStr}
93
+ </time>
94
+ )}
95
+ {item.excerpt && <p class="node-card-excerpt">{item.excerpt}</p>}
96
+ </div>
97
+ </a>
98
+ </article>
99
+ );
100
+ })
101
+ }