@brightsideoy/kama 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (101) hide show
  1. package/bin/cli.js +604 -364
  2. package/content/definitions/block-kama-forms.yaml +5 -0
  3. package/content/definitions/blocks/button.yaml +15 -5
  4. package/content/definitions/blocks/image-gallery.yaml +34 -0
  5. package/content/definitions/blocks/node-list.yaml +75 -0
  6. package/content/definitions/blocks/youtube.yaml +8 -0
  7. package/content/definitions/field-sets/access.yaml +10 -0
  8. package/content/definitions/field-sets/channel-base.yaml +7 -0
  9. package/content/definitions/field-sets/page-base.yaml +16 -1
  10. package/content/definitions/nodes/article.yaml +17 -1
  11. package/content/definitions/taxonomies/categories.yaml +5 -2
  12. package/content/definitions/taxonomies/media-tag.yaml +15 -0
  13. package/content/definitions/taxonomies/tags.yaml +5 -2
  14. package/content/forms/contact.yaml +44 -0
  15. package/content/forms/subscription.yaml +32 -0
  16. package/definitions/editor/default.ts +15 -1
  17. package/dist/assets/AssetEditor-B4W7tV1a.js +1 -0
  18. package/dist/assets/AssetEditor-D9sz6bfN.css +1 -0
  19. package/dist/assets/CmsBadge-BNsgxyyB.css +1 -0
  20. package/dist/assets/CmsBadge-D0Ob9qPU.js +1 -0
  21. package/dist/assets/CmsButton-BQ85-YXn.css +1 -0
  22. package/dist/assets/CmsButton-CbOfxvjr.js +1 -0
  23. package/dist/assets/CmsDataRow--H4iI_u_.js +1 -0
  24. package/dist/assets/CmsDataRow-CYNxfVaZ.css +1 -0
  25. package/dist/assets/CmsFieldTaxonomy-CVlKL-l2.css +1 -0
  26. package/dist/assets/CmsFieldTaxonomy-C_AwHDwY.js +1 -0
  27. package/dist/assets/CmsHelp-BO_vWABq.css +1 -0
  28. package/dist/assets/CmsHelp-BQkr9KyU.js +1 -0
  29. package/dist/assets/CmsImagePreview-CujwG8r_.css +1 -0
  30. package/dist/assets/CmsImagePreview-DuHPBzml.js +1 -0
  31. package/dist/assets/CmsPaneResizer-B62ZqsnE.js +1 -0
  32. package/dist/assets/CmsPaneResizer-Dkidd4pI.css +1 -0
  33. package/dist/assets/CmsPublisher-CugEqTD8.css +1 -0
  34. package/dist/assets/CmsPublisher-XSCPrp5L.js +3 -0
  35. package/dist/assets/CmsUserCenter-Ac0GywlR.css +1 -0
  36. package/dist/assets/CmsUserCenter-D3XDzKDC.js +1 -0
  37. package/dist/assets/EditorSplitPreview-BzKh4wFB.css +1 -0
  38. package/dist/assets/EditorSplitPreview-Cp2x0O_P.js +1 -0
  39. package/dist/assets/NodeContextMenu-BYeWzmQ5.css +1 -0
  40. package/dist/assets/NodeContextMenu-C_HWBGeb.js +1 -0
  41. package/dist/assets/NodeEditor-4TsNvk2f.css +1 -0
  42. package/dist/assets/NodeEditor-DA5GRtRP.js +161 -0
  43. package/dist/assets/StatusPill-CbeW5SMT.js +1 -0
  44. package/dist/assets/StatusPill-Dlm6uvGe.css +1 -0
  45. package/dist/assets/TrashWorkspace-CATe46WF.js +1 -0
  46. package/dist/assets/TrashWorkspace-DtMgxg13.css +1 -0
  47. package/dist/assets/_plugin-vue_export-helper-CQxWhVtr.js +5 -0
  48. package/dist/assets/circle-check-A-QeFqt3.js +1 -0
  49. package/dist/assets/database-hg5SqKLG.js +1 -0
  50. package/dist/assets/folder-tree-BWYot72H.js +1 -0
  51. package/dist/assets/globe-BraoljaN.js +1 -0
  52. package/dist/assets/history-pqIbKK9u.js +1 -0
  53. package/dist/assets/index-CY0VYSyt.css +1 -0
  54. package/dist/assets/index-DdG9Pozx.js +14 -0
  55. package/dist/assets/layers-B6RgfpPR.js +1 -0
  56. package/dist/assets/loader-circle-BoUkhDjq.js +1 -0
  57. package/dist/assets/monitor-CrpvInqG.js +1 -0
  58. package/dist/assets/panel-left-close-CVFakVaA.js +1 -0
  59. package/dist/assets/plus-CX_GHzXs.js +1 -0
  60. package/dist/assets/rotate-ccw-CWE4pOjs.js +1 -0
  61. package/dist/assets/save-Ctv8vW9H.js +1 -0
  62. package/dist/assets/schema-helpers-DvficKko.js +1 -0
  63. package/dist/assets/send-F-mJeL54.js +1 -0
  64. package/dist/assets/smartphone-EPce6VdP.js +1 -0
  65. package/dist/assets/trash-2-CAUF1KtI.js +1 -0
  66. package/dist/assets/useLocalStorage-C805uplI.js +1 -0
  67. package/dist/assets/useNodes-CqFlc72m.js +1 -0
  68. package/dist/assets/user-IFlT6mYq.js +1 -0
  69. package/dist/build-seed.js +495 -0
  70. package/dist/{dist-Dy7-HUEG.js → dist-3Xc2TTZw.js} +3189 -1394
  71. package/dist/index.html +34 -2
  72. package/dist/index.js +3116 -1315
  73. package/dist/schema-compiler.js +5 -5
  74. package/documentation/EXTENSIONS.md +204 -0
  75. package/extension-registry.js +5 -0
  76. package/package.json +13 -6
  77. package/schema.sql +44 -1
  78. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +9 -0
  79. package/templates/astro-starter/src/components/blocks/Form.astro +143 -0
  80. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +6 -12
  81. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +5 -6
  82. package/templates/astro-starter/src/components/blocks/NodeList.astro +274 -0
  83. package/templates/astro-starter/src/components/global/Breadcrumb.astro +50 -9
  84. package/templates/astro-starter/src/components/global/Footer.astro +7 -46
  85. package/templates/astro-starter/src/components/global/Header.astro +10 -16
  86. package/templates/astro-starter/src/components/global/NavigationMenu.astro +68 -49
  87. package/templates/astro-starter/src/components/ui/KamaImage.astro +13 -5
  88. package/templates/astro-starter/src/layouts/StandardLayout.astro +4 -3
  89. package/templates/astro-starter/src/lib/cms.ts +33 -11
  90. package/templates/astro-starter/src/lib/media/kama-image-service.ts +15 -4
  91. package/templates/astro-starter/src/lib/media/media-proxy.ts +22 -3
  92. package/templates/astro-starter/src/lib/media/media-url.ts +17 -3
  93. package/templates/astro-starter/src/middleware.ts +158 -100
  94. package/templates/astro-starter/src/pages/[...slug].astro +77 -3
  95. package/templates/astro-starter/src/pages/api/load-nodes.ts +68 -0
  96. package/templates/astro-starter/src/pages/api/partials/node-cards.astro +101 -0
  97. package/templates/kama-worker/index.js +38 -15
  98. package/templates/kama-worker/server.js +13 -5
  99. package/templates/kama-worker/setup-local-db.mjs +17 -0
  100. package/dist/assets/index-F2rfUymo.css +0 -1
  101. package/dist/assets/index-lXxH3VFK.js +0 -164
@@ -84,8 +84,25 @@ 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
87
+ // 🎯 0B. DIRECT MEDIA PROXY VIA SERVICE BINDING WITH SURGICAL WORKER CACHING
88
88
  if (pathname.startsWith("/media/")) {
89
+ const cache = typeof caches !== "undefined" && caches.default ? caches.default : null;
90
+ // Construct a clean cache key ignoring session headers
91
+ const cacheKey = new Request(context.url.toString(), { method: "GET" });
92
+
93
+ // 1. Check surgical Worker Edge Cache for pre-encoded media (15ms HIT)
94
+ if (!isDev && cache) {
95
+ try {
96
+ const cachedResponse = await cache.match(cacheKey);
97
+ if (cachedResponse) {
98
+ return cachedResponse;
99
+ }
100
+ } catch (e) {
101
+ // Fall back to fetch if cache read fails
102
+ }
103
+ }
104
+
105
+ // 2. Cold Fetch: Pass to CMS worker for AVIF transformation
89
106
  const cmsWorker = workerEnv?.CMS_WORKER;
90
107
  const envApiUrl = import.meta.env?.PUBLIC_API_URL;
91
108
  const workerApiUrl = workerEnv?.PUBLIC_API_URL;
@@ -93,12 +110,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
93
110
  const cleanApiUrl = baseApiUrl.endsWith("/") ? baseApiUrl.slice(0, -1) : baseApiUrl;
94
111
 
95
112
  const mediaUrl = `${cleanApiUrl}${pathname}${context.url.search}`;
96
- const mediaReq = new Request(mediaUrl, {
97
- headers: context.request.headers
98
- });
113
+ const mediaReq = new Request(mediaUrl, { headers: context.request.headers });
99
114
 
100
115
  let mediaResponse: Response;
101
-
102
116
  if (cmsWorker && typeof cmsWorker.fetch === "function") {
103
117
  mediaResponse = await cmsWorker.fetch(mediaReq);
104
118
  } else {
@@ -107,17 +121,27 @@ export const onRequest = defineMiddleware(async (context, next) => {
107
121
 
108
122
  const newHeaders = new Headers(mediaResponse.headers);
109
123
 
110
- // 🎯 DEV FIX: Only apply 1-year cache on media in Production
111
124
  if (!isDev) {
112
125
  newHeaders.set("Cache-Control", "public, max-age=31536000, s-maxage=31536000, immutable");
113
126
  }
114
127
  newHeaders.set("Access-Control-Allow-Origin", "*");
115
128
 
116
- return new Response(mediaResponse.body, {
129
+ const finalResponse = new Response(mediaResponse.body, {
117
130
  status: mediaResponse.status,
118
131
  statusText: mediaResponse.statusText,
119
132
  headers: newHeaders
120
133
  });
134
+
135
+ // 3. Store ONLY successful image transformations in Worker Edge Cache
136
+ if (!isDev && cache && mediaResponse.status === 200) {
137
+ if (cfContext && typeof cfContext.waitUntil === 'function') {
138
+ cfContext.waitUntil(cache.put(cacheKey, finalResponse.clone()));
139
+ } else {
140
+ cache.put(cacheKey, finalResponse.clone()).catch(() => { });
141
+ }
142
+ }
143
+
144
+ return finalResponse;
121
145
  }
122
146
 
123
147
  // 🎯 0C. DIRECT EDGE INTERCEPT FOR CACHE PURGE ENDPOINT
@@ -130,7 +154,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
130
154
  }
131
155
 
132
156
  const authHeader = context.request.headers.get("Authorization");
133
- const expectedToken = workerEnv.CLOUDFLARE_API_TOKEN;
157
+ const expectedToken = workerEnv.FRONTEND_API_SECRET || workerEnv.CLOUDFLARE_API_TOKEN;
134
158
 
135
159
  if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
136
160
  return new Response(JSON.stringify({ error: "Unauthorized Token Mismatch" }), {
@@ -144,11 +168,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
144
168
  const cache = typeof caches !== "undefined" && caches.default ? caches.default : null;
145
169
  const origin = context.url.origin;
146
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
+
147
178
  // A. Purge Workers Caching Layer (Astro cfContext)
148
179
  if (typeof cfContext?.cache?.purge === 'function') {
149
180
  try {
150
- await cfContext.cache.purge({ purgeEverything: true });
151
- console.log("✅ New Cloudflare Workers Cache Engine Purged");
181
+ // 🎯 FIX: Only execute global purge if the CMS explicitly requested it!
182
+ if (body.purge_everything) {
183
+ await cfContext.cache.purge({ purgeEverything: true });
184
+ console.log("✅ New Cloudflare Workers Cache Engine Purged (Global)");
185
+ }
152
186
  } catch (e) {
153
187
  console.error("❌ Failed to purge new Workers Cache Engine", e);
154
188
  }
@@ -161,30 +195,34 @@ export const onRequest = defineMiddleware(async (context, next) => {
161
195
 
162
196
  if (body.purge_everything) {
163
197
  // 🎯 GLOBAL PURGE: Bumping site_version instantly invalidates ALL KV pages globally!
198
+ try {
199
+ await HTML_CACHE.put("site_version", now);
200
+ await HTML_CACHE.put("v:/", now);
201
+ console.log(`✅ [Version Tagging] Global site_version updated to ${now}`);
202
+ } catch (kvLimitErr: any) {
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
+ });
208
+ }
209
+ } else if (Array.isArray(body.urls) && body.urls.length > 0) {
210
+ // Shared fragments and cross-page content depend on the site version too.
164
211
  await HTML_CACHE.put("site_version", now);
165
212
 
166
- // 🎯 FIX: Force clear alias keys during global purge
167
- await HTML_CACHE.put("v:/", now);
168
- await HTML_CACHE.put("v:/index", now);
169
-
170
- console.log(`✅ [Version Tagging] Global site_version updated to ${now}`);
171
- } else if (Array.isArray(body.urls) && body.urls.length > 0) {
172
- // 🎯 SURGICAL PURGE: Parallel execution for page version bumps
173
- const purgeTasks = [];
174
- for (const targetUrl of body.urls) {
213
+ // 🎯 SURGICAL PURGE: Parallel execution for page version bumps safely wrapped for quota
214
+ const purgeTasks = body.urls.map(async (targetUrl: string) => {
175
215
  const urlPath = new URL(targetUrl).pathname;
176
216
  const cleanPath = urlPath.replace(/\/$/, "") || "/";
177
-
178
- purgeTasks.push(HTML_CACHE.put(`v:${cleanPath}`, now));
179
-
180
- // 🎯 FIX: Always sync / and /index aliases together
181
- if (cleanPath === "/" || cleanPath === "/index") {
182
- purgeTasks.push(HTML_CACHE.put("v:/", now));
183
- purgeTasks.push(HTML_CACHE.put("v:/index", now));
217
+ try {
218
+ await HTML_CACHE.put(`v:${cleanPath}`, now);
219
+ } catch (kvLimitErr: any) {
220
+ console.warn(`[KV Warning] Skipped version tag for ${cleanPath}: ${kvLimitErr.message}`);
184
221
  }
185
- }
222
+ });
186
223
  await Promise.all(purgeTasks);
187
224
  console.log(`✅ [Version Tagging] Updated versions for ${body.urls.length} URLs`);
225
+ // CMS CacheManager will automatically send the X-Cache-Warmer requests now!
188
226
  }
189
227
  } catch (kvErr) {
190
228
  console.error("❌ KV Version Tagging Error:", kvErr);
@@ -196,11 +234,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
196
234
  if (cache) {
197
235
  const rootUrls = [
198
236
  origin,
199
- `${origin}/`,
200
- `${origin}/index`,
201
- `${origin}/index/`
237
+ `${origin}/`
202
238
  ];
203
-
204
239
  for (const url of rootUrls) {
205
240
  await cache.delete(new Request(url));
206
241
  }
@@ -215,7 +250,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
215
250
  });
216
251
  }
217
252
 
218
- // D. Surgical Legacy Worker Cache Purge (For Updated Pages)
253
+ // D. Surgical Legacy Worker Cache Purge
219
254
  if (Array.isArray(body.urls) && body.urls.length > 0) {
220
255
  const results = [];
221
256
 
@@ -223,17 +258,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
223
258
  const cleanUrl = targetUrl.split('?')[0];
224
259
  const urlNoSlash = cleanUrl.replace(/\/$/, "");
225
260
  const urlWithSlash = urlNoSlash + "/";
226
-
227
261
  const pathsToClear = [urlNoSlash, urlWithSlash];
228
- const urlPath = new URL(targetUrl).pathname;
229
-
230
- // 🎯 FIX: Clear legacy cache alias if it's the home page
231
- if (urlPath === "/" || urlPath === "/index") {
232
- pathsToClear.push(`${origin}/`);
233
- pathsToClear.push(`${origin}/index`);
234
- pathsToClear.push(`${origin}/index/`);
235
- pathsToClear.push(`${origin}`);
236
- }
237
262
 
238
263
  let purged = false;
239
264
  if (cache) {
@@ -267,37 +292,27 @@ export const onRequest = defineMiddleware(async (context, next) => {
267
292
  let purgedKv = false;
268
293
 
269
294
  if (HTML_CACHE) {
270
- const [siteVer, pageVer] = await Promise.all([
271
- HTML_CACHE.get("site_version"),
272
- HTML_CACHE.get(`v:${cleanPath}`)
273
- ]);
274
-
275
- if (siteVer || pageVer) {
276
- const activeCacheKey = `${cleanPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
277
- await HTML_CACHE.delete(activeCacheKey);
278
- purgedKv = true;
279
- }
280
-
281
- await HTML_CACHE.delete(`v:${cleanPath}`);
282
-
283
- // 🎯 FIX: Delete aliases from KV if applicable
284
- if (cleanPath === "/" || cleanPath === "/index") {
285
- await HTML_CACHE.delete("v:/");
286
- await HTML_CACHE.delete("v:/index");
295
+ try {
296
+ const [siteVer, pageVer] = await Promise.all([
297
+ HTML_CACHE.get("site_version"),
298
+ HTML_CACHE.get(`v:${cleanPath}`)
299
+ ]);
300
+
301
+ if (siteVer || pageVer) {
302
+ const activeCacheKey = `${cleanPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
303
+ await HTML_CACHE.delete(activeCacheKey);
304
+ purgedKv = true;
305
+ }
306
+ await HTML_CACHE.delete(`v:${cleanPath}`);
307
+ } catch (kvLimitErr: any) {
308
+ console.warn(`[KV Warning] Skipped deletion for ${cleanPath}: ${kvLimitErr.message}`);
287
309
  }
288
310
  }
289
311
 
290
312
  const cleanUrl = targetUrl.split('?')[0];
291
313
  const urlNoSlash = cleanUrl.replace(/\/$/, "");
292
314
  const urlWithSlash = urlNoSlash + "/";
293
-
294
315
  const pathsToClear = [urlNoSlash, urlWithSlash];
295
- if (urlPath === "/" || urlPath === "/index") {
296
- pathsToClear.push(`${origin}/`);
297
- pathsToClear.push(`${origin}/index`);
298
- pathsToClear.push(`${origin}/index/`);
299
- pathsToClear.push(`${origin}`);
300
- }
301
316
 
302
317
  let purgedLegacy = false;
303
318
  if (cache) {
@@ -337,32 +352,60 @@ export const onRequest = defineMiddleware(async (context, next) => {
337
352
 
338
353
  // 🎯 1. KV STATIC "FILE" CACHE READ WITH VERSION TAGGING
339
354
  const isWarmer = context.request.headers.get("X-Cache-Warmer") === "true";
340
- const isHtmlPage = !pathname.includes('.') && !pathname.startsWith('/api/');
341
- const cleanLookupPath = pathname.replace(/\/$/, "") || "/";
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
360
+ const cleanLookupPath = pathname === "/index" || pathname === "/index/"
361
+ ? "/"
362
+ : (pathname.replace(/\/$/, "") || "/") + (pathname.startsWith('/api/partials/') ? context.url.search : "");
342
363
 
343
364
  let activeCacheKey = cleanLookupPath;
344
365
 
345
- // 🎯 FIX: Bypass KV Read in local dev OR if we are time traveling (previewing drafts/snapshots)
346
- 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) {
347
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
+
348
372
  const [siteVer, pageVer] = await Promise.all([
349
373
  HTML_CACHE.get("site_version"),
350
- HTML_CACHE.get(`v:${cleanLookupPath}`)
374
+ HTML_CACHE.get(pageVersionKey)
351
375
  ]);
352
376
 
377
+ // 🎯 Combine versions deterministically
353
378
  activeCacheKey = `${cleanLookupPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
354
379
 
355
- const cachedHtml = await HTML_CACHE.get(activeCacheKey);
356
- if (cachedHtml) {
357
- return new Response(cachedHtml, {
358
- status: 200,
359
- headers: {
360
- "Content-Type": "text/html; charset=utf-8",
361
- "X-Cache-Status": "HIT-KV-STATICFILE",
362
- "X-Cache-Key": activeCacheKey,
363
- "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate"
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
+ }
392
+ }
393
+ } catch (jsonErr) {
394
+ // Fallback to raw HTML
364
395
  }
365
- });
396
+
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
+ }
366
409
  }
367
410
  } catch (e) {
368
411
  console.warn("[Middleware] KV lookup bypass:", e);
@@ -373,36 +416,54 @@ export const onRequest = defineMiddleware(async (context, next) => {
373
416
  const response = await next();
374
417
 
375
418
  // 🎯 2. KV STATIC "FILE" CACHE WRITE (Save HTML under active Version Key)
376
- // 🎯 FIX: Bypass KV Write in local dev OR if we are time traveling (never save drafts to public cache)
377
419
  if (
378
420
  !isDev &&
379
- isHtmlPage &&
380
- HTML_CACHE &&
421
+ isHtmlPage && HTML_CACHE &&
381
422
  !isTimeTraveling &&
382
423
  response.status === 200 &&
383
424
  response.headers.get("content-type")?.includes("text/html")
384
425
  ) {
385
426
  const htmlText = await response.clone().text();
386
427
 
387
- const writeTask = (async () => {
388
- if (activeCacheKey === cleanLookupPath || isWarmer) {
389
- const forcedVersion = Date.now().toString();
428
+ // Extract the TTL injected by Astro SSR
429
+ const kvTtlHeader = response.headers.get("X-KV-Expiration");
430
+ const kvTtl = kvTtlHeader ? parseInt(kvTtlHeader, 10) : undefined;
390
431
 
391
- await HTML_CACHE.put(`v:${cleanLookupPath}`, forcedVersion);
432
+ // Clean up the internal header so it doesn't leak to the public browser
433
+ response.headers.delete("X-KV-Expiration");
392
434
 
393
- // 🎯 FIX: Sync both aliases when saving the homepage
394
- if (cleanLookupPath === "/" || cleanLookupPath === "/index") {
395
- await HTML_CACHE.put(`v:/`, forcedVersion);
396
- await HTML_CACHE.put(`v:/index`, forcedVersion);
435
+ const writeTask = (async () => {
436
+ // 🎯 FIX: Removed `|| isWarmer` here so warmer requests safely write to the existing activeCacheKey
437
+ if (activeCacheKey === cleanLookupPath) {
438
+ const forcedVersion = Date.now().toString();
439
+ try {
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
+ }
444
+ const siteVer = await HTML_CACHE.get("site_version") || "1";
445
+ const pageVer = pathname.startsWith('/api/partials/') ? siteVer : forcedVersion;
446
+ activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${pageVer}`;
447
+ } catch (kvLimitErr: any) {
448
+ console.warn(`[KV Warning] Skipped prewarm version bump: ${kvLimitErr.message}`);
397
449
  }
450
+ }
398
451
 
399
- const siteVer = await HTML_CACHE.get("site_version") || "1";
452
+ // Save TTL alongside HTML so CDN can properly cache reads
453
+ const storedTtl = kvTtl !== undefined ? Math.max(60, kvTtl) : 31536000;
454
+ const payload = JSON.stringify({ html: htmlText, ttl: storedTtl });
400
455
 
401
- activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${forcedVersion}`;
456
+ try {
457
+ if (kvTtl !== undefined) {
458
+ await HTML_CACHE.put(activeCacheKey, payload, { expirationTtl: storedTtl });
459
+ console.log(`✅ [Middleware] Saved HTML to KV under key: ${activeCacheKey} (Expires in ${storedTtl}s)`);
460
+ } else {
461
+ await HTML_CACHE.put(activeCacheKey, payload);
462
+ console.log(`✅ [Middleware] Saved HTML to KV under key: ${activeCacheKey} (Infinite TTL)`);
463
+ }
464
+ } catch (kvLimitErr: any) {
465
+ console.warn(`[KV Warning] Skipped HTML cache save (Quota limit exceeded): ${kvLimitErr.message}`);
402
466
  }
403
-
404
- await HTML_CACHE.put(activeCacheKey, htmlText);
405
- console.log(`✅ [Middleware] Saved HTML to KV under key: ${activeCacheKey}`);
406
467
  })();
407
468
 
408
469
  if (cfContext && typeof cfContext.waitUntil === 'function') {
@@ -428,7 +489,6 @@ export const onRequest = defineMiddleware(async (context, next) => {
428
489
  }
429
490
 
430
491
  // 🎯 4. STATIC ASSET CACHING FOR COMPRESSED BUNDLES / ASSETS
431
- // 🎯 DEV FIX: Do not apply max-age=31536000 locally to prevent breaking Vite HMR
432
492
  if (!isDev) {
433
493
  if (
434
494
  pathname.startsWith("/_image") ||
@@ -465,8 +525,6 @@ export const onRequest = defineMiddleware(async (context, next) => {
465
525
  } else {
466
526
  // Production headers
467
527
  response.headers.set("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
468
- // 🎯 FIX: Remove X-Frame-Options entirely since it breaks cross-subdomain framing.
469
- // Rely completely on CSP frame-ancestors to whitelist ALL kamaengine subdomains + your env URL.
470
528
  response.headers.set(
471
529
  "Content-Security-Policy",
472
530
  `default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data: blob:; connect-src 'self' https:; frame-ancestors 'self' https://*.kamaengine.com https://kama.kamaengine.com ${cmsOrigin};`
@@ -59,6 +59,10 @@ 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
+ // 🎯 Safely extract the secret across both environments
63
+ const frontendApiSecret =
64
+ import.meta.env.FRONTEND_API_SECRET || safeEnv.FRONTEND_API_SECRET;
65
+
62
66
  const cleanApiUrl = baseApiUrl.endsWith("/")
63
67
  ? baseApiUrl.slice(0, -1)
64
68
  : baseApiUrl;
@@ -83,10 +87,13 @@ const customFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
83
87
  ...init?.headers,
84
88
  "Cache-Control": "no-cache, no-store, must-revalidate",
85
89
  Pragma: "no-cache",
86
- // 🎯 Direct Service Bindings require explicit header flags to bypass CMS KV cache!
87
90
  "X-Bypass-Cache": "true",
88
91
  };
89
92
 
93
+ if (frontendApiSecret) {
94
+ reqHeaders["Authorization"] = `Bearer ${frontendApiSecret}`;
95
+ }
96
+
90
97
  const fetchOpts: RequestInit = {
91
98
  ...init,
92
99
  headers: reqHeaders,
@@ -107,7 +114,9 @@ const treeId = getTreeId();
107
114
  const rawSlug = Array.isArray(slug) ? slug.join("/") : slugStr;
108
115
  const pathParts = rawSlug.split("/").filter(part => part.trim() !== "");
109
116
  const requestedSlug = pathParts.join("/");
110
- const targetSlug = requestedSlug || homeSlug;
117
+
118
+ // 🎯 ONLY FIX NEEDED HERE: Pass '/' if empty so home route resolves, otherwise requestedSlug
119
+ const targetSlug = requestedSlug || "/";
111
120
 
112
121
  let pageData: NodeResponse | null = null;
113
122
  let navTree: TreeNode[] = [];
@@ -129,7 +138,7 @@ try {
129
138
  ]);
130
139
 
131
140
  navTree = treeResult || [];
132
- pageData = nodeResult;
141
+ pageData = nodeResult as NodeResponse | null;
133
142
  } catch (err) {
134
143
  console.error("❌ [SSR Error] Failed concurrent fetch execution:", err);
135
144
  }
@@ -141,12 +150,35 @@ if (!pageData || !pageData.node) {
141
150
  const node = pageData.node;
142
151
  const content = pageData.content || {};
143
152
 
153
+ // 🎯 SMART EDGE CACHING LOGIC (API-Driven TTL)
154
+ let cacheTtl = pageData?.meta?.cacheTtl;
155
+
156
+ if (cacheTtl === undefined || cacheTtl === null) {
157
+ cacheTtl = 31536000; // Default: 1 Year (Forever)
158
+ }
159
+
160
+ cacheTtl = Math.max(60, cacheTtl);
161
+
162
+ if (isTimeTraveling) {
163
+ Astro.response.headers.set(
164
+ "Cache-Control",
165
+ "no-cache, no-store, must-revalidate",
166
+ );
167
+ } else {
168
+ Astro.response.headers.set(
169
+ "Cache-Control",
170
+ `public, max-age=0, s-maxage=${cacheTtl}, must-revalidate`,
171
+ );
172
+ Astro.response.headers.set("X-KV-Expiration", cacheTtl.toString());
173
+ }
174
+
144
175
  const rootTreeSlug =
145
176
  navTree && navTree.length > 0
146
177
  ? navTree[0].slug || (navTree[0] as any).key
147
178
  : "";
148
179
 
149
180
  const isHomePage =
181
+ targetSlug === "/" ||
150
182
  targetSlug === homeSlug ||
151
183
  (rootTreeSlug && targetSlug === rootTreeSlug) ||
152
184
  node?.path === "/" ||
@@ -161,6 +193,44 @@ const hasSidebar = sidebarBlocks.length > 0;
161
193
 
162
194
  // Detect if this node is a standalone Page Skeleton
163
195
  const isSkeleton = node?.nodeType === "page-skeleton";
196
+
197
+ const hotSwapScript = `
198
+ if (window !== window.top) {
199
+ window.addEventListener("message", async event => {
200
+ if (event.data && event.data.type === "kama-preview-update") {
201
+ try {
202
+ const currentScrollY = window.scrollY;
203
+ const res = await fetch(event.data.url);
204
+ const newHtml = await res.text();
205
+ const newDoc = new DOMParser().parseFromString(newHtml, "text/html");
206
+
207
+ newDoc.body
208
+ .querySelectorAll("script, style, link, meta, noscript")
209
+ .forEach(el => el.remove());
210
+
211
+ const preserveTags = ["SCRIPT", "STYLE", "LINK", "META", "NOSCRIPT"];
212
+
213
+ Array.from(document.body.children).forEach(child => {
214
+ if (!preserveTags.includes(child.tagName.toUpperCase())) {
215
+ child.remove();
216
+ }
217
+ });
218
+
219
+ const referenceNode = document.body.firstChild;
220
+ Array.from(newDoc.body.children).forEach(child => {
221
+ if (!preserveTags.includes(child.tagName.toUpperCase())) {
222
+ document.body.insertBefore(child.cloneNode(true), referenceNode);
223
+ }
224
+ });
225
+
226
+ window.scrollTo(0, currentScrollY);
227
+ } catch (err) {
228
+ console.error("[KAMA Preview] Failed to hot-swap HTML:", err);
229
+ }
230
+ }
231
+ });
232
+ }
233
+ `;
164
234
  ---
165
235
 
166
236
  {
@@ -173,6 +243,8 @@ const isSkeleton = node?.nodeType === "page-skeleton";
173
243
  isHomePage={isHomePage}
174
244
  >
175
245
  <BlockRenderer blocks={mainBlocks} contentMap={content} region="main" />
246
+
247
+ {isPreview && <script is:inline set:html={hotSwapScript} />}
176
248
  </SkeletonLayout>
177
249
  ) : (
178
250
  <StandardLayout
@@ -232,6 +304,8 @@ const isSkeleton = node?.nodeType === "page-skeleton";
232
304
  </aside>
233
305
  )}
234
306
  </div>
307
+
308
+ {isPreview && <script is:inline set:html={hotSwapScript} />}
235
309
  </StandardLayout>
236
310
  )
237
311
  }
@@ -0,0 +1,68 @@
1
+ // src/pages/api/load-nodes.ts
2
+ import type { APIRoute } from "astro";
3
+
4
+ export const prerender = false; // Required to ensure this runs server-side on Cloudflare
5
+
6
+ export const GET: APIRoute = async ({ request, locals }) => {
7
+ const url = new URL(request.url);
8
+ const searchParams = url.searchParams;
9
+
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
+ }
20
+
21
+ // 2. Construct the actual backend URL
22
+ const cleanApiUrl = apiUrl.replace(/\/$/, "");
23
+ const backendUrl = `${cleanApiUrl}/api/v1/nodes?${searchParams.toString()}`;
24
+
25
+ try {
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
38
+ });
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
+
48
+ if (!response.ok) {
49
+ console.error(`[Load Nodes Proxy] Backend rejected request with status: ${response.status}`);
50
+ return new Response(JSON.stringify({ error: "Failed to fetch nodes" }), { status: response.status });
51
+ }
52
+
53
+ const data = await response.json();
54
+
55
+ // 5. Return to the browser
56
+ return new Response(JSON.stringify(data), {
57
+ status: 200,
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
+ }
63
+ });
64
+ } catch (err) {
65
+ console.error("[Load Nodes Proxy] Failed to load nodes:", err);
66
+ return new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500 });
67
+ }
68
+ };