@brightsideoy/kama 0.1.0-alpha.155 → 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 +229 -231
  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 +100 -28
  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 +131 -18
  29. package/templates/astro-starter/src/pages/[...slug].astro +10 -9
  30. package/definitions/_page.yaml +0 -52
  31. package/dist/assets/index-DGHRgyms.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
  }