@brightsideoy/kama 1.0.0-alpha.13 → 1.0.0-alpha.16
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/bin/cli.js +282 -158
- package/dist/assets/AssetEditor-CEHxvQj0.js +1 -0
- package/dist/assets/AssetEditor-D5j7pwcK.css +1 -0
- package/dist/assets/CmsHelp-BO_vWABq.css +1 -0
- package/dist/assets/CmsHelp-Cnr801lx.js +1 -0
- package/dist/assets/CmsPublisher-C3fRoBte.js +3 -0
- package/dist/assets/CmsPublisher-CugEqTD8.css +1 -0
- package/dist/assets/CmsUserCenter-Ac0GywlR.css +1 -0
- package/dist/assets/CmsUserCenter-DiuXfjMc.js +1 -0
- package/dist/assets/EditorSplitPreview-BzKh4wFB.css +1 -0
- package/dist/assets/EditorSplitPreview-tpZZF1o5.js +1 -0
- package/dist/assets/NodeEditor-Bd89OptF.css +1 -0
- package/dist/assets/NodeEditor-DaCIVPGT.js +147 -0
- package/dist/assets/TrashWorkspace-CTq0OFA-.js +1 -0
- package/dist/assets/TrashWorkspace-DtMgxg13.css +1 -0
- package/dist/assets/_plugin-vue_export-helper-CTn6xl4w.js +5 -0
- package/dist/assets/history-XvWNE5zO.js +1 -0
- package/dist/assets/index-BbV9FaG-.css +1 -0
- package/dist/assets/index-CHczMewv.js +14 -0
- package/dist/assets/layers-D0DLWjff.js +1 -0
- package/dist/assets/link-Bs_SUS0O.js +1 -0
- package/dist/assets/rotate-ccw-CvD7yNQj.js +1 -0
- package/dist/assets/save-C8e-QRkP.js +1 -0
- package/dist/assets/smartphone-Wy6xQi_i.js +1 -0
- package/dist/{dist-kIJlDMvm.js → dist-Ca4GRlV3.js} +619 -535
- package/dist/index.html +3 -2
- package/dist/index.js +619 -535
- package/dist/schema-compiler.js +1 -1
- package/package.json +1 -1
- package/schema.sql +3 -0
- package/templates/astro-starter/src/components/blocks/NodeList.astro +59 -63
- package/templates/astro-starter/src/middleware.ts +63 -35
- package/templates/astro-starter/src/pages/[...slug].astro +5 -12
- package/templates/astro-starter/src/pages/api/load-nodes.ts +42 -14
- package/templates/astro-starter/src/pages/api/partials/node-cards.astro +101 -0
- package/templates/kama-worker/setup-local-db.mjs +17 -0
- package/dist/assets/index-B1ogCRCg.js +0 -164
- package/dist/assets/index-CGLx9oqF.css +0 -1
package/dist/schema-compiler.js
CHANGED
|
@@ -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-
|
|
33
|
+
l = (await import("./dist-Ca4GRlV3.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
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
-
// 🎯
|
|
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
|
|
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(
|
|
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
|
-
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
// 🎯
|
|
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
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
const
|
|
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
|
|
22
|
+
const cleanApiUrl = apiUrl.replace(/\/$/, "");
|
|
23
|
+
const backendUrl = `${cleanApiUrl}/api/v1/nodes?${searchParams.toString()}`;
|
|
15
24
|
|
|
16
25
|
try {
|
|
17
|
-
// 3.
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
55
|
+
// 5. Return to the browser
|
|
32
56
|
return new Response(JSON.stringify(data), {
|
|
33
57
|
status: 200,
|
|
34
|
-
headers: {
|
|
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("[
|
|
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
|
+
}
|