@brightsideoy/kama 0.1.0-alpha.12 → 0.1.0-alpha.121

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 (86) hide show
  1. package/README.md +195 -0
  2. package/bin/cli.js +602 -0
  3. package/content/definitions/block-layouts/three-column.yaml +20 -0
  4. package/content/definitions/block-layouts/two-column.yaml +29 -0
  5. package/content/definitions/blocks/button.yaml +24 -0
  6. package/content/definitions/blocks/header.yaml +13 -0
  7. package/content/definitions/blocks/header_image.yaml +21 -0
  8. package/content/definitions/blocks/hero.yaml +28 -0
  9. package/content/definitions/blocks/html.yaml +12 -0
  10. package/content/definitions/blocks/image_card.yaml +56 -0
  11. package/content/definitions/blocks/single_image.yaml +27 -0
  12. package/content/definitions/blocks/text.yaml +15 -0
  13. package/content/definitions/channels/ecommerce.yaml +28 -0
  14. package/content/definitions/channels/website.yaml +17 -0
  15. package/content/definitions/field-sets/access.yaml +27 -0
  16. package/content/definitions/field-sets/channel-base.yaml +50 -0
  17. package/content/definitions/field-sets/channel-seo.yaml +21 -0
  18. package/content/definitions/field-sets/comments.yaml +11 -0
  19. package/content/definitions/field-sets/content-block.yaml +41 -0
  20. package/content/definitions/field-sets/page-base.yaml +44 -0
  21. package/content/definitions/field-sets/page-seo.yaml +34 -0
  22. package/content/definitions/field-sets/page-social.yaml +78 -0
  23. package/content/definitions/field-sets/schema-type.yaml +43 -0
  24. package/content/definitions/field-sets/tree-base.yaml +31 -0
  25. package/content/definitions/field-sets/visibility.yaml +8 -0
  26. package/content/definitions/node-layouts/content.yml +12 -0
  27. package/content/definitions/node-layouts/landing-page.yml +25 -0
  28. package/content/definitions/node-layouts/single-column.yml +44 -0
  29. package/content/definitions/node-layouts/two-column.yml +49 -0
  30. package/content/definitions/nodes/address.yaml +40 -0
  31. package/content/definitions/nodes/article.yaml +48 -0
  32. package/content/definitions/nodes/event.yaml +55 -0
  33. package/content/definitions/nodes/external-link.yaml +29 -0
  34. package/content/definitions/nodes/page-skeleton.yaml +48 -0
  35. package/content/definitions/nodes/page.yaml +57 -0
  36. package/content/definitions/nodes/person.yaml +41 -0
  37. package/content/definitions/nodes/product.yaml +89 -0
  38. package/content/definitions/trees/navigation.yaml +24 -0
  39. package/definitions/editor/button.ts +11 -0
  40. package/definitions/editor/default.ts +21 -0
  41. package/definitions/editor/header.ts +36 -0
  42. package/definitions/editor/minimal.ts +15 -0
  43. package/definitions/validators/iban.ts +12 -0
  44. package/definitions/validators/magic-word.ts +7 -0
  45. package/dist/assets/index-C6GoXL-R.js +162 -0
  46. package/dist/assets/index-Drmp3h6P.css +1 -0
  47. package/dist/index.html +13 -0
  48. package/dist/index.js +12209 -2045
  49. package/documentation/SCHEMAS.md +182 -0
  50. package/package.json +14 -11
  51. package/schema.sql +204 -0
  52. package/templates/astro-starter/astro.config.mjs +40 -0
  53. package/templates/astro-starter/middleware/api-purge.ts +130 -0
  54. package/templates/astro-starter/middleware/index.ts +10 -0
  55. package/templates/astro-starter/middleware/kv-cache.ts +76 -0
  56. package/templates/astro-starter/middleware/response-headers.ts +43 -0
  57. package/templates/astro-starter/package.json.template +20 -0
  58. package/templates/astro-starter/public/css/standard-theme.css +157 -0
  59. package/templates/astro-starter/public/robots.txt +23 -0
  60. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +133 -0
  61. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +66 -0
  62. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +131 -0
  63. package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
  64. package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +24 -0
  65. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +160 -0
  66. package/templates/astro-starter/src/components/blocks/TextBlock.astro +13 -0
  67. package/templates/astro-starter/src/components/global/Breadcrumb.astro +62 -0
  68. package/templates/astro-starter/src/components/global/Footer.astro +48 -0
  69. package/templates/astro-starter/src/components/global/Header.astro +92 -0
  70. package/templates/astro-starter/src/components/global/NavigationMenu.astro +450 -0
  71. package/templates/astro-starter/src/components/seo/SeoHead.astro +150 -0
  72. package/templates/astro-starter/src/components/ui/KamaImage.astro +149 -0
  73. package/templates/astro-starter/src/layouts/SkeletonLayout.astro +45 -0
  74. package/templates/astro-starter/src/layouts/StandardLayout.astro +52 -0
  75. package/templates/astro-starter/src/lib/cms.ts +149 -0
  76. package/templates/astro-starter/src/lib/media/kama-image-service.ts +41 -0
  77. package/templates/astro-starter/src/lib/media/media-proxy.ts +127 -0
  78. package/templates/astro-starter/src/lib/media/media-url.ts +34 -0
  79. package/templates/astro-starter/src/pages/404.astro +46 -0
  80. package/templates/astro-starter/src/pages/[...slug].astro +201 -0
  81. package/templates/astro-starter/src/pages/llms-full.txt.ts +161 -0
  82. package/templates/astro-starter/src/pages/llms.txt.ts +107 -0
  83. package/templates/astro-starter/src/pages/media/[...path].ts +7 -0
  84. package/templates/astro-starter/src/utils/tiptap.ts +113 -0
  85. package/templates/astro-starter/tsconfig.json +0 -0
  86. package/test-local.sh +76 -0
@@ -0,0 +1,149 @@
1
+ ---
2
+ // src/components/ui/KamaImage.astro
3
+
4
+ import { getMediaUrl } from "@/lib/cms";
5
+ // 🎯 FIX: Updated import path to match our new folder structure
6
+ import { buildAgnosticImageUrl } from "@/lib/media/media-url";
7
+
8
+ interface Props {
9
+ image: any;
10
+ sizes?: string;
11
+ desktopWidths?: number[];
12
+ idealWidth?: number;
13
+ class?: string;
14
+ loading?: "lazy" | "eager";
15
+ fetchpriority?: "auto" | "high" | "low";
16
+ }
17
+
18
+ const {
19
+ image = {},
20
+ sizes = "100vw",
21
+ desktopWidths = [768, 1352],
22
+ idealWidth = 1352,
23
+ class: className = "",
24
+ loading = "lazy",
25
+ fetchpriority = "auto",
26
+ } = Astro.props;
27
+
28
+ if (!image || (!image.url && !image.path && !image.variants)) {
29
+ return null;
30
+ }
31
+
32
+ const variants = image.variants || {};
33
+
34
+ /**
35
+ * Resolve CMS media URLs.
36
+ */
37
+ const rawMobileUrl = getMediaUrl(variants.mobile?.url);
38
+ const rawTabletUrl = getMediaUrl(variants.tablet?.url);
39
+ const rawDesktopUrl = getMediaUrl(
40
+ variants.standard?.url || image.url || image.path,
41
+ );
42
+
43
+ /**
44
+ * Resolve dimensions.
45
+ * Crop dimensions take precedence over original image dimensions.
46
+ */
47
+ const mobileW = Number(variants.mobile?.cropCoordinates?.width || 768);
48
+ const mobileH = Number(variants.mobile?.cropCoordinates?.height || 576);
49
+
50
+ const tabletW = Number(variants.tablet?.cropCoordinates?.width || 1024);
51
+ const tabletH = Number(variants.tablet?.cropCoordinates?.height || 512);
52
+
53
+ const baseWidth = Number(
54
+ variants.standard?.cropCoordinates?.width || image.width || idealWidth,
55
+ );
56
+ const baseHeight = Number(
57
+ variants.standard?.cropCoordinates?.height ||
58
+ image.height ||
59
+ Math.round(idealWidth * 0.5),
60
+ );
61
+
62
+ /**
63
+ * Build responsive srcset using query-based media proxy URLs (/media/foo.jpg?w=768&q=75&f=auto)
64
+ */
65
+ function buildAgnosticSrcset(
66
+ url: string,
67
+ targetWidths: number[],
68
+ quality = 75,
69
+ ): string {
70
+ if (!url) return "";
71
+
72
+ return targetWidths
73
+ .map(width => `${buildAgnosticImageUrl(url, width, quality)} ${width}w`)
74
+ .join(", ");
75
+ }
76
+
77
+ /**
78
+ * Build srcsets.
79
+ */
80
+ const mobileSrcset = rawMobileUrl
81
+ ? buildAgnosticSrcset(rawMobileUrl, [400, 768])
82
+ : null;
83
+
84
+ const tabletSrcset = rawTabletUrl
85
+ ? buildAgnosticSrcset(rawTabletUrl, [768, 1024])
86
+ : null;
87
+
88
+ const desktopSrcset = rawDesktopUrl
89
+ ? buildAgnosticSrcset(rawDesktopUrl, desktopWidths)
90
+ : null;
91
+
92
+ /**
93
+ * Fallback image URL: /media/foo.jpg?w=1352&q=75&f=auto
94
+ */
95
+ const absoluteFallback = rawDesktopUrl
96
+ ? buildAgnosticImageUrl(rawDesktopUrl, idealWidth, 75)
97
+ : "";
98
+ ---
99
+
100
+ <picture>
101
+ {/* Mobile Variant (< 768px) */}
102
+ {
103
+ mobileSrcset && (
104
+ <source
105
+ media="(max-width: 767px)"
106
+ srcset={mobileSrcset}
107
+ sizes="(max-width: 767px) calc(100vw - 48px), 768px"
108
+ width={mobileW}
109
+ height={mobileH}
110
+ />
111
+ )
112
+ }
113
+
114
+ {/* Tablet Variant (768px - 1024px) */}
115
+ {
116
+ tabletSrcset && (
117
+ <source
118
+ media="(min-width: 768px) and (max-width: 1024px)"
119
+ srcset={tabletSrcset}
120
+ sizes="(max-width: 1024px) calc(100vw - 48px), 1024px"
121
+ width={tabletW}
122
+ height={tabletH}
123
+ />
124
+ )
125
+ }
126
+
127
+ {/* Desktop Variant (> 1024px) */}
128
+ {
129
+ desktopSrcset && (
130
+ <source
131
+ srcset={desktopSrcset}
132
+ sizes={sizes}
133
+ width={baseWidth}
134
+ height={baseHeight}
135
+ />
136
+ )
137
+ }
138
+
139
+ <img
140
+ src={absoluteFallback}
141
+ alt={image.alt || image.filename || ""}
142
+ width={baseWidth}
143
+ height={baseHeight}
144
+ loading={loading}
145
+ fetchpriority={fetchpriority}
146
+ decoding={loading === "eager" ? "sync" : "async"}
147
+ class={className}
148
+ />
149
+ </picture>
@@ -0,0 +1,45 @@
1
+ ---
2
+ // src/layouts/SkeletonLayout.astro
3
+ import SeoHead from "@/components/seo/SeoHead.astro";
4
+
5
+ interface Props {
6
+ node: Record<string, any>;
7
+ content: Record<string, any>;
8
+ normalizedSlug: string;
9
+ isHomePage: boolean;
10
+ }
11
+
12
+ const { node = {}, content = {}, normalizedSlug, isHomePage } = Astro.props;
13
+
14
+ // 1. Extract raw custom code fields from the node payload
15
+ const customCss = node.css || "";
16
+ const customJsHeader = node.jsHeader || "";
17
+ const customJsFooter = node.jsFooter || "";
18
+ ---
19
+
20
+ <!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <!-- 🎯 1. SEO & Meta Tags -->
24
+ <SeoHead
25
+ node={node}
26
+ content={content}
27
+ normalizedSlug={normalizedSlug}
28
+ isHomePage={isHomePage}
29
+ />
30
+
31
+ <!-- 🎯 2. Custom CSS injected into <head> -->
32
+ {customCss && <style set:html={customCss} />}
33
+
34
+ <!-- 🎯 3. Custom Header JS injected into <head> -->
35
+ {customJsHeader && <script set:html={customJsHeader} />}
36
+ </head>
37
+
38
+ <body>
39
+ <!-- 🎯 4. Renders your HTML Block natively (No wrappers, no headers) -->
40
+ <slot />
41
+
42
+ <!-- 🎯 5. Custom Footer JS injected before closing body -->
43
+ {customJsFooter && <script set:html={customJsFooter} />}
44
+ </body>
45
+ </html>
@@ -0,0 +1,52 @@
1
+ ---
2
+ import Header from "@/components/global/Header.astro";
3
+ import Footer from "@/components/global/Footer.astro";
4
+ import SeoHead from "@/components/seo/SeoHead.astro";
5
+ import type { TreeNode, ContentBlock } from "@/lib/cms";
6
+
7
+ interface Props {
8
+ node: Record<string, any>;
9
+ content: Record<string, ContentBlock[]>;
10
+ normalizedSlug: string;
11
+ isHomePage: boolean;
12
+ hasSidebar?: boolean;
13
+ headerBlocks?: ContentBlock[];
14
+ tree?: TreeNode[];
15
+ }
16
+
17
+ const {
18
+ node = {},
19
+ content = {},
20
+ normalizedSlug,
21
+ isHomePage,
22
+ headerBlocks = [],
23
+ tree,
24
+ } = Astro.props;
25
+
26
+ const footerBlocks = content?.footer || [];
27
+ ---
28
+
29
+ <html lang="en">
30
+ <head>
31
+ <SeoHead
32
+ node={node}
33
+ content={content}
34
+ normalizedSlug={normalizedSlug}
35
+ isHomePage={isHomePage}
36
+ headerBlocks={headerBlocks}
37
+ />
38
+ <!-- 🎯 Standard CSS is decoupled from the bundler -->
39
+ <link rel="stylesheet" href="/css/standard-theme.css" />
40
+ </head>
41
+ <body>
42
+ <Header node={node} tree={tree} />
43
+
44
+ <main class="content-block">
45
+ <div class="content-container">
46
+ <slot />
47
+ </div>
48
+ </main>
49
+
50
+ <Footer blocks={footerBlocks} contentMap={content} />
51
+ </body>
52
+ </html>
@@ -0,0 +1,149 @@
1
+ // src/lib/cms.ts
2
+
3
+ export interface TreeNode {
4
+ id: number;
5
+ treeNodeId: number;
6
+ parentId: number | null;
7
+ title: string;
8
+ slug: string;
9
+ path: string;
10
+ nodeType: string;
11
+ hidden?: number;
12
+ summary?: string;
13
+ url?: string;
14
+ target?: string;
15
+ children?: TreeNode[];
16
+ }
17
+
18
+ export interface ContentBlock {
19
+ instanceId: number;
20
+ componentId: string;
21
+ data: Record<string, any>;
22
+ }
23
+
24
+ export interface RegionMeta {
25
+ isInherited: boolean;
26
+ inheritedFromId?: string | number;
27
+ inheritedFromTitle?: string;
28
+ }
29
+
30
+ export interface NodeResponse {
31
+ channelId: string | number;
32
+ treeId: string | number;
33
+ language: string;
34
+ node: {
35
+ id: number;
36
+ title: string;
37
+ slug: string;
38
+ nodeType: string;
39
+ path?: string;
40
+ createdAt?: string;
41
+ updatedAt?: string;
42
+ summary?: string;
43
+ children?: any[];
44
+ [key: string]: any;
45
+ };
46
+ content: Record<string, ContentBlock[]>;
47
+ regionMeta?: Record<string, RegionMeta>;
48
+ }
49
+
50
+ /**
51
+ * Resolves base CMS URL across runtime environments safely via Astro
52
+ */
53
+ export function getCmsUrl(): string {
54
+ const url =
55
+ import.meta.env.PUBLIC_API_URL ||
56
+ import.meta.env.PUBLIC_CMS_URL ||
57
+ 'http://localhost:8787';
58
+ return url.replace(/\/+$/, '');
59
+ }
60
+
61
+ /**
62
+ * Resolves Default Channel ID across environments
63
+ */
64
+ export function getChannelId(): string {
65
+ return import.meta.env.PUBLIC_CHANNEL_ID || 'website';
66
+ }
67
+
68
+ /**
69
+ * Resolves Default Tree ID across environments
70
+ */
71
+ export function getTreeId(): string {
72
+ return import.meta.env.PUBLIC_TREE_ID || 'navigation';
73
+ }
74
+
75
+ /**
76
+ * Fetch tree navigation hierarchy by tree key/ID and channel.
77
+ * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
78
+ */
79
+ export async function getTree(
80
+ treeKeyOrId: string = getTreeId(),
81
+ channel: string | number = getChannelId(),
82
+ customFetch: typeof fetch = fetch
83
+ ): Promise<TreeNode[]> {
84
+ const baseUrl = getCmsUrl();
85
+ const requestUrl = `${baseUrl}/api/v1/tree/${treeKeyOrId}?channelId=${channel}`;
86
+
87
+ try {
88
+ const res = await customFetch(requestUrl, {
89
+ headers: {
90
+ Accept: 'application/json',
91
+ },
92
+ });
93
+
94
+ if (!res.ok) return [];
95
+
96
+ const data = await res.json();
97
+ return data.items || [];
98
+ } catch (err) {
99
+ console.error('[CMS Client] Failed to fetch public tree:', err);
100
+ return [];
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Fetch single content node payload by its full URL path.
106
+ * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
107
+ */
108
+ export async function getNodeByPath(
109
+ path: string,
110
+ channel: string | number = getChannelId(),
111
+ preview = false,
112
+ customFetch: typeof fetch = fetch
113
+ ): Promise<NodeResponse | null> {
114
+ const cleanPath = path.replace(/^\/+|\/+$/g, '') || 'home';
115
+ const baseUrl = getCmsUrl();
116
+ const requestUrl = `${baseUrl}/api/v1/node/${cleanPath}?channelId=${channel}&preview=${preview}`;
117
+
118
+ try {
119
+ const res = await customFetch(requestUrl, {
120
+ headers: {
121
+ Accept: 'application/json',
122
+ },
123
+ });
124
+
125
+ if (!res.ok) return null;
126
+
127
+ return (await res.json()) as NodeResponse;
128
+ } catch (err) {
129
+ console.error('[CMS Client] Failed to fetch node by path:', err);
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Resolves relative media URLs against base CMS URL
136
+ */
137
+ export function getMediaUrl(url?: string): string {
138
+ if (!url) return '';
139
+
140
+ const baseUrl = getCmsUrl();
141
+ let cleanUrl = url.replace(/^http:\/\/localhost:\d+/, baseUrl);
142
+
143
+ if (cleanUrl.startsWith('http://') || cleanUrl.startsWith('https://')) {
144
+ return cleanUrl;
145
+ }
146
+
147
+ const cleanPath = cleanUrl.replace(/^\/+/, '');
148
+ return `${baseUrl}/${cleanPath}`;
149
+ }
@@ -0,0 +1,41 @@
1
+ // src/kama-image-service.ts
2
+ import type { LocalImageService } from "astro";
3
+
4
+ const kamaImageService: LocalImageService = {
5
+ getURL(options) {
6
+ let src = options.src;
7
+
8
+ if (typeof src === 'object' && 'src' in src) {
9
+ src = src.src;
10
+ }
11
+
12
+ if (typeof src === 'string') {
13
+ try {
14
+ const url = new URL(src, 'http://localhost');
15
+
16
+ if (options.width) url.searchParams.set('w', Math.round(options.width).toString());
17
+ if (options.height) url.searchParams.set('h', Math.round(options.height).toString());
18
+ if (options.quality) url.searchParams.set('q', options.quality.toString());
19
+ if (options.format) url.searchParams.set('f', options.format.toString());
20
+
21
+ return src.startsWith('http') ? url.toString() : (url.pathname + url.search);
22
+ } catch (e) {
23
+ return src;
24
+ }
25
+ }
26
+ return String(src);
27
+ },
28
+ parseURL(url) {
29
+ return { src: url.pathname + url.search };
30
+ },
31
+ getHTMLAttributes(options) {
32
+ return {
33
+ width: options.width,
34
+ height: options.height,
35
+ loading: options.loading || 'lazy',
36
+ decoding: options.decoding || 'async',
37
+ };
38
+ }
39
+ };
40
+
41
+ export default kamaImageService;
@@ -0,0 +1,127 @@
1
+ // src/lib/media/media-proxy.ts
2
+ import type { APIContext } from "astro";
3
+ import { env } from "cloudflare:workers";
4
+
5
+ export 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 async function handleMediaProxy({ request, params, url, locals }: APIContext): Promise<Response> {
23
+ // Safely extract Cloudflare context from Astro locals (fixes the Hono "c." typo)
24
+ const cfContext = (locals as any).cfContext || (locals as any).runtime?.ctx;
25
+
26
+ // 🎯 Detect if Astro is requesting a resized variant
27
+ const hasResizeParams = url.searchParams.has('w') || url.searchParams.has('h') || url.searchParams.has('q') || url.searchParams.has('f');
28
+
29
+ // 1. Cloudflare Edge Cache API
30
+ let edgeCache: Cache | null = null;
31
+ try {
32
+ if (typeof caches !== "undefined" && caches.default) {
33
+ edgeCache = caches.default;
34
+ const cachedResponse = await edgeCache.match(request);
35
+ if (cachedResponse) return cachedResponse;
36
+ }
37
+ } catch (err) {
38
+ console.warn("[Media Proxy] Edge cache match failed:", err);
39
+ }
40
+
41
+ const rawPath = params.path || "";
42
+ const cleanPath = rawPath.replace(/^\/+|\/+$/g, "");
43
+
44
+ // 2. Direct R2 Lookup (ONLY IF NO RESIZING PARAMS ARE PRESENT AND WE ARE IN PROD)
45
+ const mediaBucket = env.MEDIA_BUCKET;
46
+ if (mediaBucket && cleanPath && !hasResizeParams) {
47
+ try {
48
+ let r2Object = await mediaBucket.get(cleanPath);
49
+ if (!r2Object) r2Object = await mediaBucket.get(cleanPath.startsWith("media/") ? cleanPath.replace(/^media\//, "") : `media/${cleanPath}`);
50
+
51
+ if (r2Object) {
52
+ if (request.headers.get("if-none-match") === r2Object.httpEtag) {
53
+ return new Response(null, { status: 304 });
54
+ }
55
+
56
+ const headers = new Headers();
57
+ r2Object.writeHttpMetadata(headers);
58
+ headers.set("etag", r2Object.httpEtag);
59
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
60
+ headers.set("Access-Control-Allow-Origin", "*");
61
+ if (!headers.get("content-type") || headers.get("content-type") === "application/octet-stream") {
62
+ headers.set("content-type", getMimeType(cleanPath));
63
+ }
64
+
65
+ const r2Response = new Response(r2Object.body, { status: 200, headers });
66
+
67
+ if (edgeCache) {
68
+ const cachePromise = edgeCache.put(request, r2Response.clone());
69
+ if (cfContext?.waitUntil) cfContext.waitUntil(cachePromise);
70
+ else cachePromise.catch(e => console.warn(e));
71
+ }
72
+
73
+ return r2Response;
74
+ }
75
+ } catch (r2Err) {
76
+ console.warn("[Media Proxy] R2 lookup error:", r2Err);
77
+ }
78
+ }
79
+
80
+ // 3. Forward to Backend (Handles Local Sharp Resizing AND Cloudflare Images Resizing)
81
+ try {
82
+ let backendResponse: Response;
83
+
84
+ // A. Production Mode (Cloudflare Service Binding)
85
+ if (env.CMS_WORKER) {
86
+ backendResponse = await env.CMS_WORKER.fetch(request);
87
+ }
88
+ // B. Local Dev Mode (Standard HTTP Fetch to local Hono Server)
89
+ else {
90
+ const cmsUrl = import.meta.env.PUBLIC_CMS_URL || 'http://localhost:8787';
91
+ const targetUrl = `${cmsUrl}${url.pathname}${url.search}`;
92
+
93
+ // Forward request headers (like Accept for WebP/AVIF negotiation)
94
+ backendResponse = await fetch(targetUrl, {
95
+ headers: {
96
+ 'Accept': request.headers.get('Accept') || 'image/webp,image/avif,image/*,*/*;q=0.8'
97
+ }
98
+ });
99
+ }
100
+
101
+ if (backendResponse.ok) {
102
+ const newHeaders = new Headers(backendResponse.headers);
103
+ newHeaders.set("Cache-Control", "public, max-age=31536000, immutable");
104
+ newHeaders.set("Access-Control-Allow-Origin", "*");
105
+
106
+ const proxyResponse = new Response(backendResponse.body, {
107
+ status: backendResponse.status,
108
+ statusText: backendResponse.statusText,
109
+ headers: newHeaders,
110
+ });
111
+
112
+ if (edgeCache) {
113
+ const cachePromise = edgeCache.put(request, proxyResponse.clone());
114
+ if (cfContext?.waitUntil) cfContext.waitUntil(cachePromise);
115
+ else cachePromise.catch(e => console.warn(e));
116
+ }
117
+
118
+ return proxyResponse;
119
+ }
120
+
121
+ return backendResponse;
122
+ } catch (error) {
123
+ console.error("❌ Media Proxy Forwarding Error:", error);
124
+ }
125
+
126
+ return new Response("Not found", { status: 404 });
127
+ }
@@ -0,0 +1,34 @@
1
+ // src/lib/media-url.ts
2
+
3
+ export function toRelativeMediaPath(rawUrl: string): string {
4
+ if (!rawUrl) return "";
5
+
6
+ let clean = String(rawUrl);
7
+ clean = clean.replace(/^https?:\/\/[^/]+/, "");
8
+ clean = clean.split("?")[0].split("#")[0];
9
+
10
+ const mediaIndex = clean.indexOf("/media/");
11
+ if (mediaIndex !== -1) {
12
+ return `/media/${clean
13
+ .slice(mediaIndex + "/media/".length)
14
+ .replace(/^\/+/, "")}`;
15
+ }
16
+
17
+ return clean.startsWith("/") ? clean : `/${clean}`;
18
+ }
19
+
20
+ export function buildAgnosticImageUrl(
21
+ url: string,
22
+ width: number,
23
+ quality = 75,
24
+ ): string {
25
+ const cleanPath = toRelativeMediaPath(url);
26
+ if (!cleanPath) return "";
27
+
28
+ const urlObj = new URL(cleanPath, "http://localhost");
29
+ urlObj.searchParams.set("w", String(width));
30
+ urlObj.searchParams.set("q", String(quality));
31
+ urlObj.searchParams.set("f", "auto");
32
+
33
+ return urlObj.pathname + urlObj.search;
34
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ // src/pages/404.astro
3
+ import StandardLayout from "../layouts/StandardLayout.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
+ <StandardLayout
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
+ </StandardLayout>
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>