@brightsideoy/kama 0.1.0-alpha.156 → 0.1.0-alpha.157

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 (33) hide show
  1. package/bin/cli.js +222 -253
  2. package/dist/assets/index-BesA3e7A.js +164 -0
  3. package/dist/assets/index-D6yHZ_u1.css +1 -0
  4. package/dist/{dist-C-WAK7EF.js → dist-mic4UZBb.js} +691 -439
  5. package/dist/index.html +2 -2
  6. package/dist/index.js +693 -439
  7. package/dist/schema-compiler.js +1 -1
  8. package/package.json +1 -1
  9. package/schema.sql +2 -2
  10. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +1 -0
  11. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +1 -0
  12. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +1 -0
  13. package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +2 -1
  14. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +39 -71
  15. package/templates/astro-starter/src/components/blocks/TextBlock.astro +2 -0
  16. package/templates/astro-starter/src/components/global/Breadcrumb.astro +2 -0
  17. package/templates/astro-starter/src/components/global/Footer.astro +2 -0
  18. package/templates/astro-starter/src/components/global/Header.astro +99 -33
  19. package/templates/astro-starter/src/components/{seo/SeoHead.astro → global/MetaHead.astro} +101 -49
  20. package/templates/astro-starter/src/components/global/NavigationMenu.astro +1 -0
  21. package/templates/astro-starter/src/components/ui/KamaImage.astro +114 -67
  22. package/templates/astro-starter/src/layouts/SkeletonLayout.astro +5 -3
  23. package/templates/astro-starter/src/layouts/StandardLayout.astro +14 -5
  24. package/templates/astro-starter/src/lib/cms.ts +48 -0
  25. package/templates/astro-starter/src/lib/media/kama-image-service.ts +16 -1
  26. package/templates/astro-starter/src/lib/media/media-proxy.ts +8 -6
  27. package/templates/astro-starter/src/lib/media/media-url.ts +65 -1
  28. package/templates/astro-starter/src/middleware.ts +86 -17
  29. package/templates/astro-starter/src/pages/[...slug].astro +10 -22
  30. package/definitions/_page.yaml +0 -52
  31. package/dist/assets/index-DnRQYNN4.js +0 -164
  32. package/dist/assets/index-EaDn3OUJ.css +0 -1
  33. /package/templates/astro-starter/{public/css → src/styles}/standard-theme.css +0 -0
@@ -1,7 +1,11 @@
1
1
  ---
2
2
  // src/components/ui/KamaImage.astro
3
- import { getMediaUrl } from "@/lib/cms";
4
- import { buildAgnosticImageUrl } from "@/lib/media/media-url";
3
+
4
+ import {
5
+ buildAgnosticImageUrl,
6
+ isSvgImage,
7
+ getImageUri,
8
+ } from "@/lib/media/media-url";
5
9
 
6
10
  interface Props {
7
11
  image: any;
@@ -23,16 +27,35 @@ const {
23
27
  fetchpriority = "auto",
24
28
  } = Astro.props;
25
29
 
30
+ // Unpack array wrappers if passed
31
+ const targetImage = Array.isArray(image) ? image[0] : image;
32
+
33
+ // 🎯 FIX 1: Support string paths in addition to object payloads
26
34
  const isValidImage = Boolean(
27
- image && (image.url || image.path || image.variants),
35
+ targetImage &&
36
+ (typeof targetImage === "string" ||
37
+ targetImage.url ||
38
+ targetImage.path ||
39
+ targetImage.variants),
28
40
  );
29
41
 
30
- const variants = image?.variants || {};
31
-
32
- const rawMobileUrl = getMediaUrl(variants.mobile?.url);
33
- const rawTabletUrl = getMediaUrl(variants.tablet?.url);
34
- const rawDesktopUrl = getMediaUrl(
35
- variants.standard?.url || image?.url || image?.path,
42
+ // Detect if this asset is a vector SVG
43
+ const isSvg =
44
+ isSvgImage(targetImage?.mimeType || "") ||
45
+ isSvgImage(targetImage?.filename || "") ||
46
+ isSvgImage(targetImage?.url || targetImage?.path || "") ||
47
+ (typeof targetImage === "string" && isSvgImage(targetImage));
48
+
49
+ const variants = targetImage?.variants || {};
50
+
51
+ // Resolve base paths using getImageUri to ensure CSP compliance & relativity
52
+ const rawMobileUrl = getImageUri(variants.mobile?.url);
53
+ const rawTabletUrl = getImageUri(variants.tablet?.url);
54
+ const rawDesktopUrl = getImageUri(
55
+ variants.standard?.url ||
56
+ targetImage?.url ||
57
+ targetImage?.path ||
58
+ targetImage,
36
59
  );
37
60
 
38
61
  const mobileW = Number(variants.mobile?.cropCoordinates?.width || 768);
@@ -41,15 +64,21 @@ const mobileH = Number(variants.mobile?.cropCoordinates?.height || 576);
41
64
  const tabletW = Number(variants.tablet?.cropCoordinates?.width || 1024);
42
65
  const tabletH = Number(variants.tablet?.cropCoordinates?.height || 512);
43
66
 
44
- const baseWidth = Number(
45
- variants.standard?.cropCoordinates?.width || image?.width || idealWidth,
67
+ // 🎯 FIX 2: Omit explicit width/height attributes on SVGs if intrinsic dimensions are missing/0
68
+ const rawWidth = Number(
69
+ variants.standard?.cropCoordinates?.width || targetImage?.width,
46
70
  );
47
- const baseHeight = Number(
48
- variants.standard?.cropCoordinates?.height ||
49
- image?.height ||
50
- Math.round(idealWidth * 0.5),
71
+ const rawHeight = Number(
72
+ variants.standard?.cropCoordinates?.height || targetImage?.height,
51
73
  );
52
74
 
75
+ const baseWidth =
76
+ rawWidth && !isNaN(rawWidth) && rawWidth > 0 ? rawWidth : idealWidth;
77
+ const baseHeight =
78
+ rawHeight && !isNaN(rawHeight) && rawHeight > 0
79
+ ? rawHeight
80
+ : Math.round(idealWidth * 0.5);
81
+
53
82
  function buildAgnosticSrcset(
54
83
  url: string,
55
84
  targetWidths: number[],
@@ -61,65 +90,83 @@ function buildAgnosticSrcset(
61
90
  .join(", ");
62
91
  }
63
92
 
64
- const mobileSrcset = rawMobileUrl
65
- ? buildAgnosticSrcset(rawMobileUrl, [400, 768])
66
- : null;
67
-
68
- const tabletSrcset = rawTabletUrl
69
- ? buildAgnosticSrcset(rawTabletUrl, [768, 1024])
70
- : null;
71
-
72
- const desktopSrcset = rawDesktopUrl
73
- ? buildAgnosticSrcset(rawDesktopUrl, desktopWidths)
74
- : null;
75
-
76
- const absoluteFallback = rawDesktopUrl
77
- ? buildAgnosticImageUrl(rawDesktopUrl, idealWidth, 75)
78
- : "";
93
+ // Generate srcsets only for raster images
94
+ const mobileSrcset =
95
+ !isSvg && rawMobileUrl ? buildAgnosticSrcset(rawMobileUrl, [400, 768]) : null;
96
+
97
+ const tabletSrcset =
98
+ !isSvg && rawTabletUrl
99
+ ? buildAgnosticSrcset(rawTabletUrl, [768, 1024])
100
+ : null;
101
+
102
+ const desktopSrcset =
103
+ !isSvg && rawDesktopUrl
104
+ ? buildAgnosticSrcset(rawDesktopUrl, desktopWidths)
105
+ : null;
106
+
107
+ const fallbackUrl =
108
+ getImageUri(rawDesktopUrl, {
109
+ width: isSvg ? undefined : baseWidth,
110
+ quality: 75,
111
+ }) || "";
79
112
  ---
80
113
 
81
114
  {
82
- isValidImage && (
83
- <picture>
84
- {Boolean(mobileSrcset) && (
85
- <source
86
- media="(max-width: 767px)"
87
- srcset={mobileSrcset as string}
88
- sizes="(max-width: 767px) calc(100vw - 48px), 768px"
89
- width={mobileW}
90
- height={mobileH}
91
- />
92
- )}
93
-
94
- {Boolean(tabletSrcset) && (
95
- <source
96
- media="(min-width: 768px) and (max-width: 1024px)"
97
- srcset={tabletSrcset as string}
98
- sizes="(max-width: 1024px) calc(100vw - 48px), 1024px"
99
- width={tabletW}
100
- height={tabletH}
101
- />
102
- )}
103
-
104
- {Boolean(desktopSrcset) && (
105
- <source
106
- srcset={desktopSrcset as string}
107
- sizes={sizes}
108
- width={baseWidth}
109
- height={baseHeight}
110
- />
111
- )}
112
-
115
+ isValidImage &&
116
+ (isSvg ? (
117
+ /* 🎯 Clean single <img> for SVG Vectors */
113
118
  <img
114
- src={absoluteFallback}
115
- alt={image.alt || image.filename || ""}
116
- width={baseWidth}
117
- height={baseHeight}
119
+ src={fallbackUrl}
120
+ alt={targetImage.alt || targetImage.filename || ""}
121
+ width={rawWidth > 0 ? rawWidth : undefined}
122
+ height={rawHeight > 0 ? rawHeight : undefined}
118
123
  loading={loading}
119
124
  fetchpriority={fetchpriority}
120
125
  decoding={loading === "eager" ? "sync" : "async"}
121
126
  class={className}
122
127
  />
123
- </picture>
124
- )
128
+ ) : (
129
+ /* 🎯 Responsive <picture> for Raster Images (.png, .jpg, .webp) */
130
+ <picture>
131
+ {Boolean(mobileSrcset) && (
132
+ <source
133
+ media="(max-width: 767px)"
134
+ srcset={mobileSrcset as string}
135
+ sizes="(max-width: 767px) calc(100vw - 48px), 768px"
136
+ width={mobileW}
137
+ height={mobileH}
138
+ />
139
+ )}
140
+
141
+ {Boolean(tabletSrcset) && (
142
+ <source
143
+ media="(min-width: 768px) and (max-width: 1024px)"
144
+ srcset={tabletSrcset as string}
145
+ sizes="(max-width: 1024px) calc(100vw - 48px), 1024px"
146
+ width={tabletW}
147
+ height={tabletH}
148
+ />
149
+ )}
150
+
151
+ {Boolean(desktopSrcset) && (
152
+ <source
153
+ srcset={desktopSrcset as string}
154
+ sizes={sizes}
155
+ width={baseWidth}
156
+ height={baseHeight}
157
+ />
158
+ )}
159
+
160
+ <img
161
+ src={fallbackUrl}
162
+ alt={targetImage.alt || targetImage.filename || ""}
163
+ width={baseWidth}
164
+ height={baseHeight}
165
+ loading={loading}
166
+ fetchpriority={fetchpriority}
167
+ decoding={loading === "eager" ? "sync" : "async"}
168
+ class={className}
169
+ />
170
+ </picture>
171
+ ))
125
172
  }
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  // src/layouts/SkeletonLayout.astro
3
- import SeoHead from "@/components/seo/SeoHead.astro";
3
+
4
+ import MetaHead from "@/components/global/MetaHead.astro";
4
5
 
5
6
  interface Props {
6
7
  node: Record<string, any>;
@@ -15,13 +16,14 @@ const { node = {}, content = {}, normalizedSlug, isHomePage } = Astro.props;
15
16
  const customCss = node.css || "";
16
17
  const customJsHeader = node.jsHeader || "";
17
18
  const customJsFooter = node.jsFooter || "";
19
+ const pageLang = language || node?.language || "en";
18
20
  ---
19
21
 
20
22
  <!doctype html>
21
- <html lang="en">
23
+ <html lang={pageLang}>
22
24
  <head>
23
25
  <!-- 🎯 1. SEO & Meta Tags -->
24
- <SeoHead
26
+ <MetaHead
25
27
  node={node}
26
28
  content={content}
27
29
  normalizedSlug={normalizedSlug}
@@ -1,7 +1,10 @@
1
1
  ---
2
+ // src/layouts/StandardLayout.astro
3
+
4
+ import "@/styles/standard-theme.css";
2
5
  import Header from "@/components/global/Header.astro";
3
6
  import Footer from "@/components/global/Footer.astro";
4
- import SeoHead from "@/components/seo/SeoHead.astro";
7
+ import MetaHead from "@/components/global/MetaHead.astro";
5
8
  import type { TreeNode, ContentBlock } from "@/lib/cms";
6
9
 
7
10
  interface Props {
@@ -12,6 +15,7 @@ interface Props {
12
15
  hasSidebar?: boolean;
13
16
  headerBlocks?: ContentBlock[];
14
17
  tree?: TreeNode[];
18
+ language?: string;
15
19
  }
16
20
 
17
21
  const {
@@ -21,22 +25,27 @@ const {
21
25
  isHomePage,
22
26
  headerBlocks = [],
23
27
  tree,
28
+ language,
24
29
  } = Astro.props;
25
30
 
31
+ // 🎯 Language Cascade: explicit prop -> node language -> fallback undefined
32
+ // (MetaHead will resolve channel.defaultLanguage if this remains undefined)
33
+ const pageLang = language || node?.language || undefined;
34
+
26
35
  const footerBlocks = content?.footer || [];
27
36
  ---
28
37
 
29
- <html lang="en">
38
+ <!doctype html>
39
+ <html lang={pageLang}>
30
40
  <head>
31
- <SeoHead
41
+ <MetaHead
32
42
  node={node}
33
43
  content={content}
34
44
  normalizedSlug={normalizedSlug}
35
45
  isHomePage={isHomePage}
36
46
  headerBlocks={headerBlocks}
47
+ resolvedLanguage={pageLang}
37
48
  />
38
- <!-- 🎯 Standard CSS is decoupled from the bundler -->
39
- <link rel="stylesheet" href="/css/standard-theme.css" />
40
49
  </head>
41
50
  <body>
42
51
  <Header node={node} tree={tree} />
@@ -13,18 +13,21 @@ export interface TreeNode {
13
13
  url?: string;
14
14
  target?: string;
15
15
  children?: TreeNode[];
16
+ [key: string]: any;
16
17
  }
17
18
 
18
19
  export interface ContentBlock {
19
20
  instanceId: number;
20
21
  componentId: string;
21
22
  data: Record<string, any>;
23
+ [key: string]: any;
22
24
  }
23
25
 
24
26
  export interface RegionMeta {
25
27
  isInherited: boolean;
26
28
  inheritedFromId?: string | number;
27
29
  inheritedFromTitle?: string;
30
+ [key: string]: any;
28
31
  }
29
32
 
30
33
  export interface NodeResponse {
@@ -45,6 +48,24 @@ export interface NodeResponse {
45
48
  };
46
49
  content: Record<string, ContentBlock[]>;
47
50
  regionMeta?: Record<string, RegionMeta>;
51
+ [key: string]: any;
52
+ }
53
+
54
+ export interface ChannelResponse {
55
+ id: number;
56
+ key: string;
57
+ label: string;
58
+ channelType: string;
59
+ defaultLanguage: string;
60
+ // Core branding & SEO properties typed for IDE auto-complete:
61
+ logo?: string;
62
+ logoText?: string;
63
+ favicon?: string;
64
+ siteTitle?: string;
65
+ titleSuffix?: string;
66
+ defaultSeoImage?: string;
67
+ // 🎯 Dynamically captures all public fields defined in YAML channel schemas:
68
+ [key: string]: any;
48
69
  }
49
70
 
50
71
  /**
@@ -72,6 +93,33 @@ export function getTreeId(): string {
72
93
  return import.meta.env.PUBLIC_TREE_ID || 'navigation';
73
94
  }
74
95
 
96
+ /**
97
+ * Fetch channel metadata by key/ID.
98
+ * Serves global site branding, logos, favicons, and schema-defined public fields.
99
+ */
100
+ export async function getChannel(
101
+ channelKeyOrId: string = getChannelId(),
102
+ customFetch: typeof fetch = fetch
103
+ ): Promise<ChannelResponse | null> {
104
+ const baseUrl = getCmsUrl();
105
+ const requestUrl = `${baseUrl}/api/v1/channel/${channelKeyOrId}`;
106
+
107
+ try {
108
+ const res = await customFetch(requestUrl, {
109
+ headers: {
110
+ Accept: 'application/json',
111
+ },
112
+ });
113
+
114
+ if (!res.ok) return null;
115
+
116
+ return (await res.json()) as ChannelResponse;
117
+ } catch (err) {
118
+ console.error('[CMS Client] Failed to fetch public channel:', err);
119
+ return null;
120
+ }
121
+ }
122
+
75
123
  /**
76
124
  * Fetch tree navigation hierarchy by tree key/ID and channel.
77
125
  * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
@@ -1,6 +1,16 @@
1
- // src/kama-image-service.ts
1
+ // src/lib/media/kama-image-service.ts
2
+
2
3
  import type { LocalImageService } from "astro";
3
4
 
5
+ /**
6
+ * Checks whether a given path or URL represents a vector SVG image.
7
+ */
8
+ function isSvgImage(src: string): boolean {
9
+ if (!src) return false;
10
+ const lower = src.toLowerCase();
11
+ return lower.endsWith('.svg') || lower.includes('image/svg+xml') || lower.includes('.svg?');
12
+ }
13
+
4
14
  const kamaImageService: LocalImageService = {
5
15
  getURL(options) {
6
16
  let src = options.src;
@@ -11,6 +21,11 @@ const kamaImageService: LocalImageService = {
11
21
 
12
22
  if (typeof src === 'string') {
13
23
  try {
24
+ // 🎯 SVG Guard: Bypass raster query params for vector graphics
25
+ if (isSvgImage(src)) {
26
+ return src;
27
+ }
28
+
14
29
  const url = new URL(src, 'http://localhost');
15
30
 
16
31
  if (options.width) url.searchParams.set('w', Math.round(options.width).toString());
@@ -1,6 +1,6 @@
1
1
  // src/lib/media/media-proxy.ts
2
+
2
3
  import type { APIContext } from "astro";
3
- import { env } from "cloudflare:workers";
4
4
 
5
5
  export function getMimeType(filePath: string): string {
6
6
  const ext = filePath.split(".").pop()?.toLowerCase();
@@ -20,8 +20,10 @@ export function getMimeType(filePath: string): string {
20
20
  }
21
21
 
22
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;
23
+ // 🎯 Safely extract Cloudflare context from Astro locals dual-environment
24
+ const runtime = locals.runtime || (locals as any).cfContext;
25
+ const workerEnv = runtime?.env || {};
26
+ const cfContext = runtime?.ctx || {};
25
27
 
26
28
  // 🎯 Detect if Astro is requesting a resized variant
27
29
  const hasResizeParams = url.searchParams.has('w') || url.searchParams.has('h') || url.searchParams.has('q') || url.searchParams.has('f');
@@ -42,7 +44,7 @@ export async function handleMediaProxy({ request, params, url, locals }: APICont
42
44
  const cleanPath = rawPath.replace(/^\/+|\/+$/g, "");
43
45
 
44
46
  // 2. Direct R2 Lookup (ONLY IF NO RESIZING PARAMS ARE PRESENT AND WE ARE IN PROD)
45
- const mediaBucket = env.MEDIA_BUCKET;
47
+ const mediaBucket = workerEnv.MEDIA_BUCKET;
46
48
  if (mediaBucket && cleanPath && !hasResizeParams) {
47
49
  try {
48
50
  let r2Object = await mediaBucket.get(cleanPath);
@@ -82,8 +84,8 @@ export async function handleMediaProxy({ request, params, url, locals }: APICont
82
84
  let backendResponse: Response;
83
85
 
84
86
  // A. Production Mode (Cloudflare Service Binding)
85
- if (env.CMS_WORKER) {
86
- backendResponse = await env.CMS_WORKER.fetch(request);
87
+ if (workerEnv.CMS_WORKER) {
88
+ backendResponse = await workerEnv.CMS_WORKER.fetch(request);
87
89
  }
88
90
  // B. Local Dev Mode (Standard HTTP Fetch to local Hono Server)
89
91
  else {
@@ -1,4 +1,4 @@
1
- // src/lib/media-url.ts
1
+ // src/lib/media/media-url.ts
2
2
 
3
3
  export function toRelativeMediaPath(rawUrl: string): string {
4
4
  if (!rawUrl) return "";
@@ -17,6 +17,15 @@ export function toRelativeMediaPath(rawUrl: string): string {
17
17
  return clean.startsWith("/") ? clean : `/${clean}`;
18
18
  }
19
19
 
20
+ /**
21
+ * Checks whether a given path or MIME type represents a vector SVG image.
22
+ */
23
+ export function isSvgImage(urlOrMime: string): boolean {
24
+ if (!urlOrMime) return false;
25
+ const lower = urlOrMime.toLowerCase();
26
+ return lower.endsWith('.svg') || lower.includes('image/svg+xml') || lower.includes('.svg?');
27
+ }
28
+
20
29
  export function buildAgnosticImageUrl(
21
30
  url: string,
22
31
  width: number,
@@ -25,10 +34,65 @@ export function buildAgnosticImageUrl(
25
34
  const cleanPath = toRelativeMediaPath(url);
26
35
  if (!cleanPath) return "";
27
36
 
37
+ // 🎯 Vector Guard: Return pristine path for SVGs without width/quality query parameters
38
+ if (isSvgImage(cleanPath)) {
39
+ return cleanPath;
40
+ }
41
+
28
42
  const urlObj = new URL(cleanPath, "http://localhost");
29
43
  urlObj.searchParams.set("w", String(width));
30
44
  urlObj.searchParams.set("q", String(quality));
31
45
  urlObj.searchParams.set("f", "auto");
32
46
 
33
47
  return urlObj.pathname + urlObj.search;
48
+ }
49
+
50
+ export interface GetImageUriOptions {
51
+ width?: number;
52
+ quality?: number;
53
+ fallback?: string;
54
+ absoluteOrigin?: string; // e.g. siteOrigin for OG/Twitter tags or JSON-LD
55
+ }
56
+
57
+ /**
58
+ * Universal CMS Image Helper
59
+ * Safely resolves strings, asset objects, and array-wrapped media into relative or absolute CSP-compliant URLs.
60
+ */
61
+ export function getImageUri(
62
+ imgObj: any,
63
+ options: GetImageUriOptions = {}
64
+ ): string | null {
65
+ const { width, quality = 75, fallback = null, absoluteOrigin } = options;
66
+
67
+ if (!imgObj) return fallback;
68
+
69
+ // 1. Unpack arrays (e.g. favicon: [{ url: '/media/...' }])
70
+ const target = Array.isArray(imgObj) ? imgObj[0] : imgObj;
71
+ if (!target) return fallback;
72
+
73
+ // 2. Detect mimeType if present on asset object
74
+ const isSvgAsset = isSvgImage(target?.mimeType || "") || isSvgImage(target?.filename || "");
75
+
76
+ // 3. Extract raw URL path across standard CMS field variations
77
+ const rawPath =
78
+ typeof target === "string"
79
+ ? target
80
+ : target?.variants?.standard?.url || target?.url || target?.path || null;
81
+
82
+ if (!rawPath) return fallback;
83
+
84
+ // 4. Process URL through buildAgnosticImageUrl (SVGs bypass raster params automatically)
85
+ let finalPath = (width && !isSvgAsset && !isSvgImage(rawPath))
86
+ ? buildAgnosticImageUrl(rawPath, width, quality)
87
+ : toRelativeMediaPath(rawPath);
88
+
89
+ if (!finalPath) return fallback;
90
+
91
+ // 5. Prepend absolute origin if requested (for Open Graph, Twitter cards, or JSON-LD)
92
+ if (absoluteOrigin) {
93
+ const cleanOrigin = absoluteOrigin.replace(/\/+$/, "");
94
+ return `${cleanOrigin}${finalPath.startsWith("/") ? "" : "/"}${finalPath}`;
95
+ }
96
+
97
+ return finalPath;
34
98
  }
@@ -1,11 +1,71 @@
1
+ // src/middleware.ts
2
+
1
3
  import { defineMiddleware } from "astro:middleware";
2
- import { env } from "cloudflare:workers";
4
+
5
+ /**
6
+ * Safely extracts environment variables across Astro 7, Node.js, and Cloudflare Workers
7
+ * without triggering the `@astrojs/cloudflare` runtime.env getter exception.
8
+ */
9
+ async function getSafeEnv(locals: any): Promise<Record<string, any>> {
10
+ // 1. Try native Cloudflare Workers module (Astro 5+ / workerd)
11
+ try {
12
+ // @ts-ignore
13
+ const cf = await import("cloudflare:workers");
14
+ if (cf?.env) return cf.env;
15
+ } catch (e) {
16
+ // Not running inside a Cloudflare Worker container
17
+ }
18
+
19
+ // 2. Safe check for locals.runtime / cfContext (Local Node.js / Hono)
20
+ const runtime = locals?.runtime || locals?.cfContext;
21
+ if (runtime && typeof runtime === "object") {
22
+ if ("CMS_WORKER" in runtime || "PUBLIC_API_URL" in runtime) {
23
+ return runtime;
24
+ }
25
+
26
+ // Safely check property descriptor to avoid triggering throwing getters
27
+ try {
28
+ const desc = Object.getOwnPropertyDescriptor(runtime, "env");
29
+ if (desc && !desc.get) {
30
+ return runtime.env;
31
+ }
32
+ } catch (e) {
33
+ // Ignore getter throws
34
+ }
35
+ }
36
+
37
+ // 3. Fallback for standalone Node.js mode
38
+ return typeof process !== "undefined" ? process.env || {} : {};
39
+ }
3
40
 
4
41
  export const onRequest = defineMiddleware(async (context, next) => {
5
42
  const pathname = context.url.pathname;
6
43
  const pathLower = pathname.toLowerCase();
7
- const cfContext = (context.locals as any).cfContext;
8
- const workerEnv = cfContext?.env || (env as any);
44
+
45
+ // 🎯 Detect Local Vite Dev Mode to bypass edge caching locally
46
+ const isDev = import.meta.env.DEV;
47
+
48
+ // 🎯 Safe dual-environment context extraction
49
+ const workerEnv = await getSafeEnv(context.locals);
50
+
51
+ // Attach to locals so Astro components (like Header.astro) don't have to re-evaluate it
52
+ (context.locals as any).safeEnv = workerEnv;
53
+
54
+ // 🎯 Safe ctx extraction (Fixing the runtime.ctx crash in Astro 6/7)
55
+ let cfContext = (context.locals as any).cfContext;
56
+ if (!cfContext) {
57
+ const runtime = (context.locals as any).runtime;
58
+ if (runtime && typeof runtime === "object") {
59
+ try {
60
+ const desc = Object.getOwnPropertyDescriptor(runtime, "ctx");
61
+ if (desc && !desc.get) {
62
+ cfContext = runtime.ctx;
63
+ }
64
+ } catch (e) { }
65
+ }
66
+ }
67
+ cfContext = cfContext || {};
68
+
9
69
  const HTML_CACHE = workerEnv?.HTML_CACHE; // KV Namespace binding
10
70
 
11
71
  // 🎯 0. INSTANT EDGE SPAM DROP (Blocks bots before hit to CMS worker/database)
@@ -40,9 +100,12 @@ export const onRequest = defineMiddleware(async (context, next) => {
40
100
  mediaResponse = await fetch(mediaReq);
41
101
  }
42
102
 
43
- // Return with long-term immutable edge caching headers
44
103
  const newHeaders = new Headers(mediaResponse.headers);
45
- newHeaders.set("Cache-Control", "public, max-age=31536000, s-maxage=31536000, immutable");
104
+
105
+ // 🎯 DEV FIX: Only apply 1-year cache on media in Production
106
+ if (!isDev) {
107
+ newHeaders.set("Cache-Control", "public, max-age=31536000, s-maxage=31536000, immutable");
108
+ }
46
109
  newHeaders.set("Access-Control-Allow-Origin", "*");
47
110
 
48
111
  return new Response(mediaResponse.body, {
@@ -232,7 +295,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
232
295
 
233
296
  let activeCacheKey = cleanLookupPath;
234
297
 
235
- if (isHtmlPage && HTML_CACHE && !isWarmer) {
298
+ // 🎯 DEV FIX: Bypass KV Read in local dev
299
+ if (!isDev && isHtmlPage && HTML_CACHE && !isWarmer) {
236
300
  try {
237
301
  const [siteVer, pageVer] = await Promise.all([
238
302
  HTML_CACHE.get("site_version"),
@@ -262,7 +326,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
262
326
  const response = await next();
263
327
 
264
328
  // 🎯 2. KV STATIC "FILE" CACHE WRITE (Save HTML under active Version Key)
329
+ // 🎯 DEV FIX: Bypass KV Write in local dev
265
330
  if (
331
+ !isDev &&
266
332
  isHtmlPage &&
267
333
  HTML_CACHE &&
268
334
  response.status === 200 &&
@@ -308,17 +374,20 @@ export const onRequest = defineMiddleware(async (context, next) => {
308
374
  }
309
375
 
310
376
  // 🎯 4. STATIC ASSET CACHING FOR COMPRESSED BUNDLES / ASSETS
311
- if (
312
- pathname.startsWith("/_image") ||
313
- pathname.startsWith("/cdn-cgi/image/") ||
314
- pathname.match(/\.(png|jpg|jpeg|webp|avif|svg|gif|ico|woff2?|css|js)$/i)
315
- ) {
316
- response.headers.set(
317
- "Cache-Control",
318
- "public, max-age=31536000, s-maxage=31536000, immutable"
319
- );
320
- response.headers.set("Access-Control-Allow-Origin", "*");
321
- return response;
377
+ // 🎯 DEV FIX: Do not apply max-age=31536000 locally to prevent breaking Vite HMR
378
+ if (!isDev) {
379
+ if (
380
+ pathname.startsWith("/_image") ||
381
+ pathname.startsWith("/cdn-cgi/image/") ||
382
+ pathname.match(/\.(png|jpg|jpeg|webp|avif|svg|gif|ico|woff2?|css|js)$/i)
383
+ ) {
384
+ response.headers.set(
385
+ "Cache-Control",
386
+ "public, max-age=31536000, s-maxage=31536000, immutable"
387
+ );
388
+ response.headers.set("Access-Control-Allow-Origin", "*");
389
+ return response;
390
+ }
322
391
  }
323
392
 
324
393
  // 🎯 5. SECURITY HEADERS FOR HTML PAGES