@brightsideoy/kama 0.1.0-alpha.113 → 0.1.0-alpha.115
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 +172 -379
- package/content/definitions/blocks/button.yaml +7 -8
- package/content/definitions/blocks/header.yaml +7 -9
- package/content/definitions/blocks/header_image.yaml +14 -15
- package/content/definitions/blocks/hero.yaml +22 -21
- package/content/definitions/blocks/image_card.yaml +46 -47
- package/content/definitions/blocks/single_image.yaml +19 -20
- package/content/definitions/blocks/text.yaml +8 -9
- package/content/definitions/channels/ecommerce.yaml +14 -16
- package/content/definitions/channels/website.yaml +20 -21
- package/content/definitions/field-sets/access.yaml +3 -5
- package/content/definitions/field-sets/channel-base.yaml +25 -25
- package/content/definitions/field-sets/content-block.yaml +36 -41
- package/content/definitions/field-sets/page-base.yaml +40 -45
- package/content/definitions/field-sets/page-meta.yaml +1 -4
- package/content/definitions/field-sets/page-social.yaml +6 -8
- package/content/definitions/field-sets/tree-base.yaml +25 -25
- package/content/definitions/nodes/address.yaml +29 -21
- package/content/definitions/nodes/article.yaml +24 -19
- package/content/definitions/nodes/event.yaml +32 -26
- package/content/definitions/nodes/external-link.yaml +20 -17
- package/content/definitions/nodes/page.yaml +38 -35
- package/content/definitions/nodes/person.yaml +29 -27
- package/content/definitions/nodes/product.yaml +52 -49
- package/content/definitions/trees/navigation.yaml +26 -21
- package/content/definitions/trees/social-links.yaml +5 -2
- package/dist/assets/index-8T39DEP3.js +162 -0
- package/dist/assets/index-B9oh2_TL.css +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6492 -4249
- package/package.json +5 -6
- package/schema.sql +5 -13
- package/templates/astro-starter/astro.config.mjs +40 -0
- package/templates/astro-starter/package.json.template +20 -0
- package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +125 -0
- package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +66 -0
- package/templates/astro-starter/src/components/blocks/HeaderImage.astro +131 -0
- package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
- package/templates/astro-starter/src/components/blocks/ImageBlock.astro +160 -0
- package/templates/astro-starter/src/components/blocks/TextBlock.astro +13 -0
- package/templates/astro-starter/src/components/global/Breadcrumb.astro +62 -0
- package/templates/astro-starter/src/components/global/Footer.astro +48 -0
- package/templates/astro-starter/src/components/global/Header.astro +92 -0
- package/templates/astro-starter/src/components/global/NavigationMenu.astro +450 -0
- package/templates/astro-starter/src/components/ui/KamaImage.astro +148 -0
- package/templates/astro-starter/src/kama-image-service.ts +41 -0
- package/templates/astro-starter/src/layouts/Layout.astro +280 -0
- package/templates/astro-starter/src/lib/cms.ts +149 -0
- package/templates/astro-starter/src/lib/media-url.ts +34 -0
- package/templates/astro-starter/src/middleware.ts +306 -0
- package/templates/astro-starter/src/pages/404.astro +46 -0
- package/templates/astro-starter/src/pages/[...slug].astro +234 -0
- package/templates/astro-starter/src/pages/media/[...path].ts +113 -0
- package/templates/astro-starter/tsconfig.json +0 -0
- package/test-local.sh +76 -0
- package/dist/assets/index-10GEw0KY.css +0 -1
- package/dist/assets/index-THJ6BqPP.js +0 -1394
- package/kama-worker/bundled-definitions.js +0 -2394
- package/kama-worker/bundled-definitions.ts +0 -2395
- package/scripts/bundle-definitions.js +0 -224
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/middleware.ts
|
|
2
|
+
import { defineMiddleware } from "astro:middleware";
|
|
3
|
+
import { env } from "cloudflare:workers";
|
|
4
|
+
|
|
5
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
6
|
+
const pathname = context.url.pathname;
|
|
7
|
+
const locals = context.locals as any;
|
|
8
|
+
const cfContext = locals.cfContext;
|
|
9
|
+
|
|
10
|
+
// 🎯 Safely extract environment using Cloudflare Workers import or Node process fallback
|
|
11
|
+
const workerEnv = (typeof env !== "undefined" ? env : (process?.env || {})) as any;
|
|
12
|
+
const HTML_CACHE = workerEnv?.HTML_CACHE; // KV Namespace binding
|
|
13
|
+
|
|
14
|
+
// 🎯 0. DIRECT EDGE INTERCEPT FOR CACHE PURGE ENDPOINT
|
|
15
|
+
if (pathname === "/api/purge" || pathname === "/api/purge/") {
|
|
16
|
+
if (context.request.method !== "POST") {
|
|
17
|
+
return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
|
|
18
|
+
status: 405,
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const authHeader = context.request.headers.get("Authorization");
|
|
24
|
+
const expectedToken = workerEnv.CLOUDFLARE_API_TOKEN;
|
|
25
|
+
|
|
26
|
+
if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
|
|
27
|
+
return new Response(JSON.stringify({ error: "Unauthorized Token Mismatch" }), {
|
|
28
|
+
status: 401,
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const body = await context.request.json();
|
|
35
|
+
const edgeCache = typeof caches !== "undefined" && caches.default ? caches.default : null;
|
|
36
|
+
const origin = context.url.origin;
|
|
37
|
+
|
|
38
|
+
// A. Purge Workers Caching Layer (Astro v6 cfContext)
|
|
39
|
+
if (typeof cfContext?.cache?.purge === 'function') {
|
|
40
|
+
try {
|
|
41
|
+
await cfContext.cache.purge({ purgeEverything: true });
|
|
42
|
+
console.log("✅ New Cloudflare Workers Cache Engine Purged");
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error("❌ Failed to purge new Workers Cache Engine", e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// B. Version-Based Instant Invalidation (0ms KV Write)
|
|
49
|
+
if (HTML_CACHE) {
|
|
50
|
+
try {
|
|
51
|
+
const now = Date.now().toString();
|
|
52
|
+
|
|
53
|
+
if (body.purge_everything) {
|
|
54
|
+
// 🎯 GLOBAL PURGE: Bumping site_version instantly invalidates ALL KV pages globally!
|
|
55
|
+
await HTML_CACHE.put("site_version", now);
|
|
56
|
+
console.log(`✅ [Version Tagging] Global site_version updated to ${now}`);
|
|
57
|
+
} else if (Array.isArray(body.urls) && body.urls.length > 0) {
|
|
58
|
+
// 🎯 SURGICAL PURGE: Parallel execution for page version bumps
|
|
59
|
+
const purgeTasks = body.urls.map((targetUrl: string) => {
|
|
60
|
+
const urlPath = new URL(targetUrl).pathname;
|
|
61
|
+
const cleanPath = urlPath.replace(/\/$/, "") || "/";
|
|
62
|
+
return HTML_CACHE.put(`v:${cleanPath}`, now);
|
|
63
|
+
});
|
|
64
|
+
await Promise.all(purgeTasks);
|
|
65
|
+
console.log(`✅ [Version Tagging] Updated versions for ${body.urls.length} URLs`);
|
|
66
|
+
}
|
|
67
|
+
} catch (kvErr) {
|
|
68
|
+
console.error("❌ KV Version Tagging Error:", kvErr);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// C. Global Legacy Worker Cache Purge
|
|
73
|
+
if (body.purge_everything) {
|
|
74
|
+
if (edgeCache) {
|
|
75
|
+
const rootUrls = [
|
|
76
|
+
origin,
|
|
77
|
+
`${origin}/`,
|
|
78
|
+
`${origin}/home`,
|
|
79
|
+
`${origin}/home/`
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
for (const url of rootUrls) {
|
|
83
|
+
await edgeCache.delete(new Request(url));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Response(JSON.stringify({
|
|
88
|
+
success: true,
|
|
89
|
+
message: "All Worker Caches (Workers Cache, KV Version Tag & Legacy) Purged"
|
|
90
|
+
}), {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// D. Surgical Legacy Worker Cache Purge (For Updated Pages)
|
|
97
|
+
if (Array.isArray(body.urls) && body.urls.length > 0) {
|
|
98
|
+
const results = [];
|
|
99
|
+
|
|
100
|
+
for (const targetUrl of body.urls) {
|
|
101
|
+
const cleanUrl = targetUrl.split('?')[0];
|
|
102
|
+
const urlNoSlash = cleanUrl.replace(/\/$/, "");
|
|
103
|
+
const urlWithSlash = urlNoSlash + "/";
|
|
104
|
+
|
|
105
|
+
let p1 = false;
|
|
106
|
+
let p2 = false;
|
|
107
|
+
|
|
108
|
+
if (edgeCache) {
|
|
109
|
+
p1 = await edgeCache.delete(new Request(urlNoSlash));
|
|
110
|
+
p2 = await edgeCache.delete(new Request(urlWithSlash));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
results.push({ url: targetUrl, purged: p1 || p2 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return new Response(JSON.stringify({
|
|
117
|
+
success: true,
|
|
118
|
+
message: "URLs processed and version tags updated",
|
|
119
|
+
results
|
|
120
|
+
}), {
|
|
121
|
+
status: 200,
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// E. SURGICAL DELETION FOR DELETED PAGES
|
|
127
|
+
if (Array.isArray(body.deleted_urls) && body.deleted_urls.length > 0) {
|
|
128
|
+
const results: any[] = [];
|
|
129
|
+
|
|
130
|
+
const deleteTasks = body.deleted_urls.map(async (targetUrl: string) => {
|
|
131
|
+
const urlPath = new URL(targetUrl).pathname;
|
|
132
|
+
const cleanPath = urlPath.replace(/\/$/, "") || "/";
|
|
133
|
+
|
|
134
|
+
let purgedKv = false;
|
|
135
|
+
|
|
136
|
+
if (HTML_CACHE) {
|
|
137
|
+
// 1. Fetch current version keys before removing pointers
|
|
138
|
+
const [siteVer, pageVer] = await Promise.all([
|
|
139
|
+
HTML_CACHE.get("site_version"),
|
|
140
|
+
HTML_CACHE.get(`v:${cleanPath}`)
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
// 2. Delete the active HTML cache entry
|
|
144
|
+
if (siteVer || pageVer) {
|
|
145
|
+
const activeCacheKey = `${cleanPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
|
|
146
|
+
await HTML_CACHE.delete(activeCacheKey);
|
|
147
|
+
purgedKv = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3. Delete the page version pointer key itself
|
|
151
|
+
await HTML_CACHE.delete(`v:${cleanPath}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 4. Purge legacy Worker Cache entries
|
|
155
|
+
const cleanUrl = targetUrl.split('?')[0];
|
|
156
|
+
const urlNoSlash = cleanUrl.replace(/\/$/, "");
|
|
157
|
+
const urlWithSlash = urlNoSlash + "/";
|
|
158
|
+
|
|
159
|
+
let p1 = false;
|
|
160
|
+
let p2 = false;
|
|
161
|
+
|
|
162
|
+
if (edgeCache) {
|
|
163
|
+
p1 = await edgeCache.delete(new Request(urlNoSlash));
|
|
164
|
+
p2 = await edgeCache.delete(new Request(urlWithSlash));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
results.push({ url: targetUrl, purgedKv, purgedLegacy: p1 || p2 });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await Promise.all(deleteTasks);
|
|
171
|
+
console.log(`✅ [KV Deletion] Completely deleted KV keys for ${body.deleted_urls.length} URLs`);
|
|
172
|
+
|
|
173
|
+
return new Response(JSON.stringify({
|
|
174
|
+
success: true,
|
|
175
|
+
message: "Deleted URLs processed and KV keys permanently removed",
|
|
176
|
+
results
|
|
177
|
+
}), {
|
|
178
|
+
status: 200,
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return new Response(JSON.stringify({ error: "Invalid payload format" }), {
|
|
184
|
+
status: 400,
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
} catch (err: any) {
|
|
189
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
190
|
+
status: 500,
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 🎯 1. KV STATIC "FILE" CACHE READ WITH VERSION TAGGING
|
|
197
|
+
const isWarmer = context.request.headers.get("X-Cache-Warmer") === "true";
|
|
198
|
+
const isHtmlPage = !pathname.includes('.') && !pathname.startsWith('/api/');
|
|
199
|
+
const cleanLookupPath = pathname.replace(/\/$/, "") || "/";
|
|
200
|
+
|
|
201
|
+
let activeCacheKey = cleanLookupPath;
|
|
202
|
+
|
|
203
|
+
if (isHtmlPage && HTML_CACHE && !isWarmer) {
|
|
204
|
+
try {
|
|
205
|
+
// Read active versions (Default to "1" if not set yet)
|
|
206
|
+
const [siteVer, pageVer] = await Promise.all([
|
|
207
|
+
HTML_CACHE.get("site_version"),
|
|
208
|
+
HTML_CACHE.get(`v:${cleanLookupPath}`)
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
activeCacheKey = `${cleanLookupPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
|
|
212
|
+
|
|
213
|
+
const cachedHtml = await HTML_CACHE.get(activeCacheKey);
|
|
214
|
+
if (cachedHtml) {
|
|
215
|
+
return new Response(cachedHtml, {
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: {
|
|
218
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
219
|
+
"X-Cache-Status": "HIT-KV-STATICFILE",
|
|
220
|
+
"X-Cache-Key": activeCacheKey,
|
|
221
|
+
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate"
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.warn("[Middleware] KV lookup bypass:", e);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Process downstream SSR routes
|
|
231
|
+
const response = await next();
|
|
232
|
+
|
|
233
|
+
// 🎯 2. KV STATIC "FILE" CACHE WRITE (Save HTML under active Version Key)
|
|
234
|
+
if (
|
|
235
|
+
isHtmlPage &&
|
|
236
|
+
HTML_CACHE &&
|
|
237
|
+
response.status === 200 &&
|
|
238
|
+
response.headers.get("content-type")?.includes("text/html")
|
|
239
|
+
) {
|
|
240
|
+
const htmlText = await response.clone().text();
|
|
241
|
+
|
|
242
|
+
const writeTask = (async () => {
|
|
243
|
+
if (activeCacheKey === cleanLookupPath || isWarmer) {
|
|
244
|
+
const forcedVersion = Date.now().toString();
|
|
245
|
+
|
|
246
|
+
await HTML_CACHE.put(`v:${cleanLookupPath}`, forcedVersion);
|
|
247
|
+
|
|
248
|
+
const siteVer = await HTML_CACHE.get("site_version") || "1";
|
|
249
|
+
|
|
250
|
+
activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${forcedVersion}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await HTML_CACHE.put(activeCacheKey, htmlText);
|
|
254
|
+
console.log(`✅ [Middleware] Saved HTML to KV under key: ${activeCacheKey}`);
|
|
255
|
+
})();
|
|
256
|
+
|
|
257
|
+
if (cfContext && typeof cfContext.waitUntil === 'function') {
|
|
258
|
+
cfContext.waitUntil(writeTask);
|
|
259
|
+
} else {
|
|
260
|
+
writeTask.catch((e: any) => console.error("KV write error:", e));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 🎯 3. SERVE LLMS & LLXML ENDPOINTS
|
|
265
|
+
if (pathname === "/llms.txt" || pathname === "/llms-full.txt") {
|
|
266
|
+
response.headers.set("Content-Type", "text/plain; charset=utf-8");
|
|
267
|
+
response.headers.set("Cache-Control", "public, max-age=3600");
|
|
268
|
+
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
269
|
+
return response;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (pathname === "/llxml.txt") {
|
|
273
|
+
response.headers.set("Content-Type", "application/xml; charset=utf-8");
|
|
274
|
+
response.headers.set("Cache-Control", "public, max-age=3600");
|
|
275
|
+
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
276
|
+
return response;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 🎯 4. IMAGE & STATIC ASSET CACHING (ENVIRONMENT-AGNOSTIC)
|
|
280
|
+
if (
|
|
281
|
+
pathname.startsWith("/media/") ||
|
|
282
|
+
pathname.startsWith("/_image") ||
|
|
283
|
+
pathname.match(/\.(png|jpg|jpeg|webp|avif|svg|gif|ico|woff2?|css|js)$/i)
|
|
284
|
+
) {
|
|
285
|
+
response.headers.set(
|
|
286
|
+
"Cache-Control",
|
|
287
|
+
"public, max-age=31536000, s-maxage=31536000, immutable"
|
|
288
|
+
);
|
|
289
|
+
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
290
|
+
return response;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 🎯 5. SECURITY HEADERS FOR HTML PAGES
|
|
294
|
+
if (response.headers.get("content-type")?.includes("text/html")) {
|
|
295
|
+
response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
|
|
296
|
+
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
297
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
298
|
+
response.headers.set("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
|
|
299
|
+
response.headers.set(
|
|
300
|
+
"Content-Security-Policy",
|
|
301
|
+
"default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data: blob:; connect-src 'self' https:; frame-ancestors 'self';"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return response;
|
|
306
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
// src/pages/404.astro
|
|
3
|
+
import Layout from "../layouts/Layout.astro";
|
|
4
|
+
|
|
5
|
+
const node = {
|
|
6
|
+
title: "Page Not Found",
|
|
7
|
+
summary: "We couldn't find the page you were looking for.",
|
|
8
|
+
};
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<Layout
|
|
12
|
+
node={node}
|
|
13
|
+
isHomePage={false}
|
|
14
|
+
hasSidebar={false}
|
|
15
|
+
content={{}}
|
|
16
|
+
normalizedSlug="404"
|
|
17
|
+
headerBlocks={[]}
|
|
18
|
+
>
|
|
19
|
+
<div class="error-page">
|
|
20
|
+
<h1>404</h1>
|
|
21
|
+
<h2>Page Not Found</h2>
|
|
22
|
+
<p>Sorry, the page you are looking for doesn't exist or has been moved.</p>
|
|
23
|
+
<a href="/" class="button">Go back home</a>
|
|
24
|
+
</div>
|
|
25
|
+
</Layout>
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.error-page {
|
|
29
|
+
text-align: center;
|
|
30
|
+
padding: 100px 20px;
|
|
31
|
+
}
|
|
32
|
+
.error-page h1 {
|
|
33
|
+
font-size: 6rem;
|
|
34
|
+
margin-bottom: 0;
|
|
35
|
+
color: #0284c7;
|
|
36
|
+
}
|
|
37
|
+
.button {
|
|
38
|
+
display: inline-block;
|
|
39
|
+
margin-top: 24px;
|
|
40
|
+
padding: 12px 24px;
|
|
41
|
+
background: #0f172a;
|
|
42
|
+
color: white;
|
|
43
|
+
text-decoration: none;
|
|
44
|
+
border-radius: 4px;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
---
|
|
2
|
+
// src/pages/[...slug].astro
|
|
3
|
+
import Layout from "../layouts/Layout.astro";
|
|
4
|
+
import BlockRenderer from "../components/blocks/BlockRenderer.astro";
|
|
5
|
+
import Breadcrumb from "../components/global/Breadcrumb.astro";
|
|
6
|
+
import { getNodeByPath, getChannelId, getTree, getTreeId } from "../lib/cms";
|
|
7
|
+
import type { NodeResponse, TreeNode } from "../lib/cms";
|
|
8
|
+
import { env } from "cloudflare:workers";
|
|
9
|
+
|
|
10
|
+
export const prerender = false;
|
|
11
|
+
|
|
12
|
+
const { slug } = Astro.params;
|
|
13
|
+
|
|
14
|
+
Astro.response.headers.set(
|
|
15
|
+
"Cache-Control",
|
|
16
|
+
"no-store, no-cache, must-revalidate, proxy-revalidate",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// 1. STATIC ASSET CHECK
|
|
20
|
+
const staticExts = [
|
|
21
|
+
".ico",
|
|
22
|
+
".png",
|
|
23
|
+
".jpg",
|
|
24
|
+
".jpeg",
|
|
25
|
+
".svg",
|
|
26
|
+
".txt",
|
|
27
|
+
".xml",
|
|
28
|
+
".webmanifest",
|
|
29
|
+
];
|
|
30
|
+
const slugStr = String(slug || "");
|
|
31
|
+
const isStaticAsset =
|
|
32
|
+
!slugStr.includes("/") &&
|
|
33
|
+
staticExts.some(ext => slugStr.toLowerCase().endsWith(ext));
|
|
34
|
+
|
|
35
|
+
if (isStaticAsset) {
|
|
36
|
+
return new Response(null, { status: 404 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. PATH RESOLUTION
|
|
40
|
+
const rawSlug = Array.isArray(slug) ? slug.join("/") : slugStr;
|
|
41
|
+
const pathParts = rawSlug.split("/").filter(part => part.trim() !== "");
|
|
42
|
+
const pathToFetch = pathParts.length > 0 ? pathParts.join("/") : "home";
|
|
43
|
+
|
|
44
|
+
// 3. ENV & WORKER RESOLUTION
|
|
45
|
+
const locals = (Astro.locals as any) || {};
|
|
46
|
+
// 🎯 SAFELY EXTRACT WORKER ENV USING THE CLOUDFLARE MODULE (Astro v6 / Adapter v12)
|
|
47
|
+
const workerEnv = env || process.env || {};
|
|
48
|
+
const cmsWorker = locals.cfContext?.env?.CMS_WORKER || workerEnv.CMS_WORKER;
|
|
49
|
+
|
|
50
|
+
// 🎯 CRITICAL FIX: Flattened to single line to bypass Rolldown parser bug
|
|
51
|
+
const envApiUrl = import.meta.env.PUBLIC_API_URL;
|
|
52
|
+
const workerApiUrl = workerEnv.PUBLIC_API_URL;
|
|
53
|
+
const baseApiUrl = String(envApiUrl || workerApiUrl || "http://localhost:8787");
|
|
54
|
+
|
|
55
|
+
const cleanApiUrl = baseApiUrl.endsWith("/")
|
|
56
|
+
? baseApiUrl.slice(0, -1)
|
|
57
|
+
: baseApiUrl;
|
|
58
|
+
|
|
59
|
+
const customFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
60
|
+
let urlStr = input instanceof URL ? input.toString() : String(input);
|
|
61
|
+
if (urlStr.startsWith("/")) urlStr = `${cleanApiUrl}${urlStr}`;
|
|
62
|
+
|
|
63
|
+
const urlObj = new URL(urlStr);
|
|
64
|
+
urlObj.searchParams.set("_t", Date.now().toString());
|
|
65
|
+
urlStr = urlObj.toString();
|
|
66
|
+
|
|
67
|
+
const req = new Request(urlStr, {
|
|
68
|
+
...init,
|
|
69
|
+
cache: "no-store",
|
|
70
|
+
headers: {
|
|
71
|
+
...init?.headers,
|
|
72
|
+
"X-Bypass-Cache": "true",
|
|
73
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (cmsWorker && typeof cmsWorker.fetch === "function") {
|
|
78
|
+
return cmsWorker.fetch(req);
|
|
79
|
+
}
|
|
80
|
+
return fetch(req);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const channelId = getChannelId();
|
|
84
|
+
const treeId = getTreeId();
|
|
85
|
+
|
|
86
|
+
let pageData: NodeResponse | null = null;
|
|
87
|
+
let navTree: TreeNode[] = [];
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const [fetchedPage, fetchedTree] = await Promise.all([
|
|
91
|
+
getNodeByPath(pathToFetch, channelId, false, customFetch),
|
|
92
|
+
getTree(treeId, channelId, customFetch),
|
|
93
|
+
]);
|
|
94
|
+
pageData = fetchedPage;
|
|
95
|
+
navTree = fetchedTree;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(
|
|
98
|
+
`❌ [SSR Error] Failed to fetch data for path "${pathToFetch}":`,
|
|
99
|
+
err,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!pageData || !pageData.node) {
|
|
104
|
+
return new Response("Page Not Found", { status: 404 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const node = pageData.node;
|
|
108
|
+
const content = pageData.content || {};
|
|
109
|
+
|
|
110
|
+
const isHomePage =
|
|
111
|
+
pathToFetch === "home" || node?.path === "/" || pathToFetch === "";
|
|
112
|
+
const normalizedSlug = isHomePage ? "" : pathToFetch;
|
|
113
|
+
|
|
114
|
+
const headerBlocks = content?.header || [];
|
|
115
|
+
const mainBlocks = content?.content || [];
|
|
116
|
+
const sidebarBlocks = content?.sidebar || [];
|
|
117
|
+
const hasSidebar = sidebarBlocks.length > 0;
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
<Layout
|
|
121
|
+
node={node}
|
|
122
|
+
content={content}
|
|
123
|
+
normalizedSlug={normalizedSlug}
|
|
124
|
+
isHomePage={isHomePage}
|
|
125
|
+
hasSidebar={hasSidebar}
|
|
126
|
+
headerBlocks={headerBlocks}
|
|
127
|
+
tree={navTree}
|
|
128
|
+
>
|
|
129
|
+
{
|
|
130
|
+
headerBlocks.length > 0 && (
|
|
131
|
+
<section class="region-header">
|
|
132
|
+
<BlockRenderer
|
|
133
|
+
blocks={headerBlocks}
|
|
134
|
+
contentMap={content}
|
|
135
|
+
region="header"
|
|
136
|
+
/>
|
|
137
|
+
</section>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
!isHomePage && (
|
|
143
|
+
<div class="breadcrumb-wrapper">
|
|
144
|
+
<Breadcrumb title={node?.title || "Page"} slug={normalizedSlug} />
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
<div class:list={["content-layout", { "has-sidebar": hasSidebar }]}>
|
|
150
|
+
<div class="main-column">
|
|
151
|
+
{
|
|
152
|
+
mainBlocks.length > 0 ? (
|
|
153
|
+
<section class="region-main">
|
|
154
|
+
<BlockRenderer
|
|
155
|
+
blocks={mainBlocks}
|
|
156
|
+
contentMap={content}
|
|
157
|
+
region="main"
|
|
158
|
+
/>
|
|
159
|
+
</section>
|
|
160
|
+
) : (
|
|
161
|
+
<div class="empty-content">
|
|
162
|
+
<p>{node?.summary || "No content available for this page."}</p>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{
|
|
169
|
+
hasSidebar && (
|
|
170
|
+
<aside class="sidebar-column">
|
|
171
|
+
<section class="region-sidebar">
|
|
172
|
+
<BlockRenderer
|
|
173
|
+
blocks={sidebarBlocks}
|
|
174
|
+
contentMap={content}
|
|
175
|
+
region="sidebar"
|
|
176
|
+
/>
|
|
177
|
+
</section>
|
|
178
|
+
</aside>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
</div>
|
|
182
|
+
</Layout>
|
|
183
|
+
|
|
184
|
+
<style>
|
|
185
|
+
.region-header {
|
|
186
|
+
margin-bottom: 24px;
|
|
187
|
+
}
|
|
188
|
+
.breadcrumb-wrapper {
|
|
189
|
+
margin-bottom: 24px;
|
|
190
|
+
}
|
|
191
|
+
.content-layout {
|
|
192
|
+
width: 100%;
|
|
193
|
+
display: block;
|
|
194
|
+
}
|
|
195
|
+
.main-column {
|
|
196
|
+
width: 100%;
|
|
197
|
+
min-width: 0;
|
|
198
|
+
}
|
|
199
|
+
@media (min-width: 768px) {
|
|
200
|
+
.content-layout.has-sidebar {
|
|
201
|
+
display: grid !important;
|
|
202
|
+
grid-template-columns: calc(100% - 360px) 310px !important;
|
|
203
|
+
column-gap: 48px !important;
|
|
204
|
+
align-items: start !important;
|
|
205
|
+
}
|
|
206
|
+
.main-column {
|
|
207
|
+
grid-column: 1 / 2 !important;
|
|
208
|
+
}
|
|
209
|
+
.sidebar-column {
|
|
210
|
+
grid-column: 2 / 3 !important;
|
|
211
|
+
position: sticky;
|
|
212
|
+
top: 32px;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
.sidebar-column {
|
|
216
|
+
background: #f8fafc;
|
|
217
|
+
border: 1px solid #e2e8f0;
|
|
218
|
+
padding: 28px;
|
|
219
|
+
box-sizing: border-box;
|
|
220
|
+
}
|
|
221
|
+
@media (max-width: 767px) {
|
|
222
|
+
.sidebar-column {
|
|
223
|
+
margin-top: 40px;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
.empty-content {
|
|
227
|
+
padding: 32px;
|
|
228
|
+
border: 1px dashed #cbd5e1;
|
|
229
|
+
background: #f8fafc;
|
|
230
|
+
color: #64748b;
|
|
231
|
+
font-size: 0.95rem;
|
|
232
|
+
text-align: center;
|
|
233
|
+
}
|
|
234
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// src/pages/media/[...path].ts
|
|
2
|
+
import type { APIRoute } from "astro";
|
|
3
|
+
import { env } from "cloudflare:workers";
|
|
4
|
+
|
|
5
|
+
function getMimeType(filePath: string): string {
|
|
6
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
7
|
+
switch (ext) {
|
|
8
|
+
case "jpg":
|
|
9
|
+
case "jpeg": return "image/jpeg";
|
|
10
|
+
case "png": return "image/png";
|
|
11
|
+
case "webp": return "image/webp";
|
|
12
|
+
case "avif": return "image/avif";
|
|
13
|
+
case "svg": return "image/svg+xml";
|
|
14
|
+
case "gif": return "image/gif";
|
|
15
|
+
case "pdf": return "application/pdf";
|
|
16
|
+
case "mp4": return "video/mp4";
|
|
17
|
+
case "webm": return "video/webm";
|
|
18
|
+
default: return "application/octet-stream";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const GET: APIRoute = async ({ request, params, url }) => {
|
|
23
|
+
// 🎯 Detect if Astro is requesting a resized variant
|
|
24
|
+
const hasResizeParams = url.searchParams.has('w') || url.searchParams.has('h') || url.searchParams.has('q') || url.searchParams.has('f');
|
|
25
|
+
|
|
26
|
+
// 1. Cloudflare Edge Cache API
|
|
27
|
+
let edgeCache: Cache | null = null;
|
|
28
|
+
try {
|
|
29
|
+
if (typeof caches !== "undefined" && caches.default) {
|
|
30
|
+
edgeCache = caches.default;
|
|
31
|
+
const cachedResponse = await edgeCache.match(request);
|
|
32
|
+
if (cachedResponse) return cachedResponse;
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn("[Media Proxy] Edge cache match failed:", err);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const rawPath = params.path || "";
|
|
39
|
+
const cleanPath = rawPath.replace(/^\/+|\/+$/g, "");
|
|
40
|
+
|
|
41
|
+
// 2. Direct R2 Lookup (ONLY IF NO RESIZING PARAMS ARE PRESENT AND WE ARE IN PROD)
|
|
42
|
+
const mediaBucket = env.MEDIA_BUCKET;
|
|
43
|
+
if (mediaBucket && cleanPath && !hasResizeParams) {
|
|
44
|
+
try {
|
|
45
|
+
let r2Object = await mediaBucket.get(cleanPath);
|
|
46
|
+
if (!r2Object) r2Object = await mediaBucket.get(cleanPath.startsWith("media/") ? cleanPath.replace(/^media\//, "") : `media/${cleanPath}`);
|
|
47
|
+
|
|
48
|
+
if (r2Object) {
|
|
49
|
+
if (request.headers.get("if-none-match") === r2Object.httpEtag) {
|
|
50
|
+
return new Response(null, { status: 304 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const headers = new Headers();
|
|
54
|
+
r2Object.writeHttpMetadata(headers);
|
|
55
|
+
headers.set("etag", r2Object.httpEtag);
|
|
56
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
57
|
+
headers.set("Access-Control-Allow-Origin", "*");
|
|
58
|
+
if (!headers.get("content-type") || headers.get("content-type") === "application/octet-stream") {
|
|
59
|
+
headers.set("content-type", getMimeType(cleanPath));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const r2Response = new Response(r2Object.body, { status: 200, headers });
|
|
63
|
+
if (edgeCache) c.executionCtx?.waitUntil(edgeCache.put(request, r2Response.clone()));
|
|
64
|
+
return r2Response;
|
|
65
|
+
}
|
|
66
|
+
} catch (r2Err) {
|
|
67
|
+
console.warn("[Media Proxy] R2 lookup error:", r2Err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Forward to Backend (Handles Local Sharp Resizing AND Cloudflare Images Resizing)
|
|
72
|
+
try {
|
|
73
|
+
let backendResponse: Response;
|
|
74
|
+
|
|
75
|
+
// A. Production Mode (Cloudflare Service Binding)
|
|
76
|
+
if (env.CMS_WORKER) {
|
|
77
|
+
backendResponse = await env.CMS_WORKER.fetch(request);
|
|
78
|
+
}
|
|
79
|
+
// B. Local Dev Mode (Standard HTTP Fetch to local Hono Server)
|
|
80
|
+
else {
|
|
81
|
+
const cmsUrl = import.meta.env.PUBLIC_CMS_URL || 'http://localhost:8787';
|
|
82
|
+
const targetUrl = `${cmsUrl}${url.pathname}${url.search}`;
|
|
83
|
+
|
|
84
|
+
// Forward request headers (like Accept for WebP/AVIF negotiation)
|
|
85
|
+
backendResponse = await fetch(targetUrl, {
|
|
86
|
+
headers: {
|
|
87
|
+
'Accept': request.headers.get('Accept') || 'image/webp,image/avif,image/*,*/*;q=0.8'
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (backendResponse.ok) {
|
|
93
|
+
const newHeaders = new Headers(backendResponse.headers);
|
|
94
|
+
newHeaders.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
95
|
+
newHeaders.set("Access-Control-Allow-Origin", "*");
|
|
96
|
+
|
|
97
|
+
const proxyResponse = new Response(backendResponse.body, {
|
|
98
|
+
status: backendResponse.status,
|
|
99
|
+
statusText: backendResponse.statusText,
|
|
100
|
+
headers: newHeaders,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (edgeCache) edgeCache.put(request, proxyResponse.clone());
|
|
104
|
+
return proxyResponse;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return backendResponse;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error("❌ Media Proxy Forwarding Error:", error);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new Response("Not found", { status: 404 });
|
|
113
|
+
};
|