@brightsideoy/kama 0.1.0-alpha.99 → 1.0.0-alpha.10

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 (119) hide show
  1. package/README.md +169 -0
  2. package/bin/cli.js +726 -261
  3. package/content/definitions/blocks/button.yaml +34 -0
  4. package/content/definitions/blocks/header.yaml +13 -0
  5. package/content/definitions/blocks/header_image.yaml +21 -0
  6. package/content/definitions/blocks/hero.yaml +28 -0
  7. package/content/definitions/blocks/html.yaml +13 -0
  8. package/content/definitions/blocks/image_card.yaml +56 -0
  9. package/content/definitions/blocks/node-list.yaml +61 -0
  10. package/content/definitions/blocks/single_image.yaml +27 -0
  11. package/content/definitions/blocks/text.yaml +15 -0
  12. package/content/definitions/channels/ecommerce.yaml +28 -0
  13. package/content/definitions/channels/website.yaml +41 -0
  14. package/{defaults → content}/definitions/field-sets/access.yaml +3 -5
  15. package/content/definitions/field-sets/channel-base.yaml +57 -0
  16. package/{defaults/definitions/field-sets/page-meta.yaml → content/definitions/field-sets/channel-seo.yaml} +3 -11
  17. package/content/definitions/field-sets/content-block.yaml +41 -0
  18. package/content/definitions/field-sets/page-base.yaml +44 -0
  19. package/content/definitions/field-sets/page-seo.yaml +34 -0
  20. package/{defaults → content}/definitions/field-sets/page-social.yaml +6 -8
  21. package/content/definitions/field-sets/schema-type.yaml +43 -0
  22. package/content/definitions/field-sets/tree-base.yaml +31 -0
  23. package/content/definitions/node-layouts/content.yml +12 -0
  24. package/{defaults → content}/definitions/node-layouts/landing-page.yml +8 -3
  25. package/{defaults → content}/definitions/node-layouts/single-column.yml +10 -5
  26. package/{defaults → content}/definitions/node-layouts/two-column.yml +14 -6
  27. package/content/definitions/nodes/address.yaml +40 -0
  28. package/content/definitions/nodes/article.yaml +65 -0
  29. package/content/definitions/nodes/event.yaml +55 -0
  30. package/content/definitions/nodes/external-link.yaml +29 -0
  31. package/content/definitions/nodes/page-skeleton.yaml +48 -0
  32. package/{defaults → content}/definitions/nodes/page.yaml +9 -20
  33. package/content/definitions/nodes/person.yaml +37 -0
  34. package/content/definitions/nodes/product.yaml +89 -0
  35. package/content/definitions/taxonomies/categories.yaml +15 -0
  36. package/content/definitions/taxonomies/tags.yaml +12 -0
  37. package/content/definitions/trees/navigation.yaml +24 -0
  38. package/definitions/editor/default.ts +1 -0
  39. package/dist/assets/index-Bzbme3Ob.css +1 -0
  40. package/dist/assets/index-DX98CiK-.js +164 -0
  41. package/dist/dist-CErcWOwp.js +27136 -0
  42. package/dist/index.html +4 -2
  43. package/dist/index.js +26159 -8575
  44. package/dist/schema-compiler.js +48 -0
  45. package/documentation/SCHEMAS.md +182 -0
  46. package/package.json +23 -25
  47. package/schema.sql +67 -23
  48. package/templates/astro-starter/astro.config.mjs +27 -0
  49. package/templates/astro-starter/package.json.template +20 -0
  50. package/templates/astro-starter/public/robots.txt +23 -0
  51. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +143 -0
  52. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +67 -0
  53. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +126 -0
  54. package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
  55. package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +25 -0
  56. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +91 -0
  57. package/templates/astro-starter/src/components/blocks/NodeList.astro +278 -0
  58. package/templates/astro-starter/src/components/blocks/TextBlock.astro +15 -0
  59. package/templates/astro-starter/src/components/global/Breadcrumb.astro +108 -0
  60. package/templates/astro-starter/src/components/global/Footer.astro +50 -0
  61. package/templates/astro-starter/src/components/global/Header.astro +171 -0
  62. package/templates/astro-starter/src/components/global/MetaHead.astro +266 -0
  63. package/templates/astro-starter/src/components/global/NavigationMenu.astro +413 -0
  64. package/templates/astro-starter/src/components/ui/KamaImage.astro +180 -0
  65. package/templates/astro-starter/src/layouts/SkeletonLayout.astro +56 -0
  66. package/templates/astro-starter/src/layouts/StandardLayout.astro +63 -0
  67. package/templates/astro-starter/src/lib/cms.ts +219 -0
  68. package/templates/astro-starter/src/lib/media/kama-image-service.ts +67 -0
  69. package/templates/astro-starter/src/lib/media/media-proxy.ts +148 -0
  70. package/templates/astro-starter/src/lib/media/media-url.ts +112 -0
  71. package/templates/astro-starter/src/middleware.ts +508 -0
  72. package/templates/astro-starter/src/pages/404.astro +46 -0
  73. package/templates/astro-starter/src/pages/[...slug].astro +318 -0
  74. package/templates/astro-starter/src/pages/api/load-nodes.ts +40 -0
  75. package/templates/astro-starter/src/pages/llms-full.txt.ts +171 -0
  76. package/templates/astro-starter/src/pages/llms.txt.ts +113 -0
  77. package/templates/astro-starter/src/pages/media/[...path].ts +14 -0
  78. package/templates/astro-starter/src/pages/partials/footer.astro +69 -0
  79. package/templates/astro-starter/src/pages/partials/nav.astro +141 -0
  80. package/templates/astro-starter/src/styles/standard-theme.css +157 -0
  81. package/templates/astro-starter/src/utils/tiptap.ts +113 -0
  82. package/templates/astro-starter/tsconfig.json +0 -0
  83. package/templates/kama-worker/index.js +19 -0
  84. package/templates/kama-worker/server.js +104 -0
  85. package/wrangler.json +22 -0
  86. package/defaults/definitions/blocks/button.yaml +0 -15
  87. package/defaults/definitions/blocks/header.yaml +0 -15
  88. package/defaults/definitions/blocks/header_image.yaml +0 -22
  89. package/defaults/definitions/blocks/hero.yaml +0 -27
  90. package/defaults/definitions/blocks/image_card.yaml +0 -57
  91. package/defaults/definitions/blocks/single_image.yaml +0 -28
  92. package/defaults/definitions/blocks/text.yaml +0 -16
  93. package/defaults/definitions/channels/ecommerce.yaml +0 -24
  94. package/defaults/definitions/channels/website.yaml +0 -32
  95. package/defaults/definitions/field-sets/channel-base.yaml +0 -31
  96. package/defaults/definitions/field-sets/content-block.yaml +0 -46
  97. package/defaults/definitions/field-sets/page-base.yaml +0 -49
  98. package/defaults/definitions/field-sets/tree-base.yaml +0 -31
  99. package/defaults/definitions/node-layouts/three-column.yml +0 -41
  100. package/defaults/definitions/nodes/address.yaml +0 -32
  101. package/defaults/definitions/nodes/article.yaml +0 -43
  102. package/defaults/definitions/nodes/event.yaml +0 -49
  103. package/defaults/definitions/nodes/external-link.yaml +0 -26
  104. package/defaults/definitions/nodes/person.yaml +0 -37
  105. package/defaults/definitions/nodes/product.yaml +0 -86
  106. package/defaults/definitions/trees/navigation.yaml +0 -29
  107. package/defaults/definitions/trees/social-links.yaml +0 -9
  108. package/definitions/_page.yaml +0 -52
  109. package/dist/assets/index-D6qQsWOj.js +0 -1392
  110. package/dist/assets/index-TdW5F0pX.css +0 -1
  111. package/dist/media/kama/2026/08/17a098c92bc54ea4b4441e531a34575b.jpg +0 -0
  112. package/dist/media/kama/2026/08/3e9ae457dd23461680cc699f42d07cf0.jpg +0 -0
  113. package/dist/media/kama/2026/08/8e5b3a2fd194459f9b962f8e12928bf0.jpg +0 -0
  114. package/dist/media/kama/2026/08/8ea5c21026cd4944bdf78c99fe2544a9.jpg +0 -0
  115. package/scripts/bundle-definitions.js +0 -176
  116. /package/{defaults → content}/definitions/block-layouts/three-column.yaml +0 -0
  117. /package/{defaults → content}/definitions/block-layouts/two-column.yaml +0 -0
  118. /package/{defaults → content}/definitions/field-sets/comments.yaml +0 -0
  119. /package/{defaults → content}/definitions/field-sets/visibility.yaml +0 -0
@@ -0,0 +1,180 @@
1
+ ---
2
+ // src/components/ui/KamaImage.astro
3
+
4
+ import {
5
+ buildAgnosticImageUrl,
6
+ isSvgImage,
7
+ getImageUri,
8
+ } from "@/lib/media/media-url";
9
+
10
+ interface Props {
11
+ image: any;
12
+ sizes?: string;
13
+ desktopWidths?: number[];
14
+ idealWidth?: number;
15
+ class?: string;
16
+ loading?: "lazy" | "eager";
17
+ fetchpriority?: "auto" | "high" | "low";
18
+ }
19
+
20
+ const {
21
+ image = {},
22
+ sizes = "100vw",
23
+ desktopWidths = [768, 1352, 1920, 2560],
24
+ idealWidth = 1352,
25
+ class: className = "",
26
+ loading = "lazy",
27
+ fetchpriority = "auto",
28
+ } = Astro.props;
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
34
+ const isValidImage = Boolean(
35
+ targetImage &&
36
+ (typeof targetImage === "string" ||
37
+ targetImage.url ||
38
+ targetImage.path ||
39
+ targetImage.variants),
40
+ );
41
+
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,
59
+ );
60
+
61
+ const mobileW = Number(variants.mobile?.cropCoordinates?.width || 768);
62
+ const mobileH = Number(variants.mobile?.cropCoordinates?.height || 576);
63
+
64
+ const tabletW = Number(variants.tablet?.cropCoordinates?.width || 1024);
65
+ const tabletH = Number(variants.tablet?.cropCoordinates?.height || 512);
66
+
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,
70
+ );
71
+ const rawHeight = Number(
72
+ variants.standard?.cropCoordinates?.height || targetImage?.height,
73
+ );
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
+
82
+ function buildAgnosticSrcset(
83
+ url: string,
84
+ targetWidths: number[],
85
+ quality = 75,
86
+ ): string {
87
+ if (!url) return "";
88
+ return targetWidths
89
+ .map(width => `${buildAgnosticImageUrl(url, width, quality)} ${width}w`)
90
+ .join(", ");
91
+ }
92
+
93
+ // 🎯 High-DPI / Retina Optimization:
94
+ // - Mobile: Includes 320, 640, 768 (covers 2x and 3x Retina mobile displays)
95
+ // - Tablet: Includes 768, 1024, 1352 (covers iPad Pro Retina density)
96
+ // - Desktop: Uses desktopWidths (defaults to 768, 1352, 1920, 2560 for 1440p and 4K)
97
+ const mobileSrcset =
98
+ !isSvg && rawMobileUrl
99
+ ? buildAgnosticSrcset(rawMobileUrl, [320, 640, 768])
100
+ : null;
101
+
102
+ const tabletSrcset =
103
+ !isSvg && rawTabletUrl
104
+ ? buildAgnosticSrcset(rawTabletUrl, [768, 1024, 1352])
105
+ : null;
106
+
107
+ const desktopSrcset =
108
+ !isSvg && rawDesktopUrl
109
+ ? buildAgnosticSrcset(
110
+ rawDesktopUrl,
111
+ desktopWidths.length ? desktopWidths : [768, 1352, 1920, 2560],
112
+ )
113
+ : null;
114
+
115
+ const fallbackUrl =
116
+ getImageUri(rawDesktopUrl, {
117
+ width: isSvg ? undefined : baseWidth,
118
+ quality: 75,
119
+ }) || "";
120
+ ---
121
+
122
+ {
123
+ isValidImage &&
124
+ (isSvg ? (
125
+ /* 🎯 Clean single <img> for SVG Vectors */
126
+ <img
127
+ src={fallbackUrl}
128
+ alt={targetImage.alt || targetImage.filename || ""}
129
+ width={rawWidth > 0 ? rawWidth : undefined}
130
+ height={rawHeight > 0 ? rawHeight : undefined}
131
+ loading={loading}
132
+ fetchpriority={fetchpriority}
133
+ decoding={loading === "eager" ? "sync" : "async"}
134
+ class={className}
135
+ />
136
+ ) : (
137
+ /* 🎯 Responsive <picture> for Raster Images (.png, .jpg, .webp) */
138
+ <picture>
139
+ {Boolean(mobileSrcset) && (
140
+ <source
141
+ media="(max-width: 767px)"
142
+ srcset={mobileSrcset as string}
143
+ sizes="(max-width: 767px) calc(100vw - 48px), 768px"
144
+ width={mobileW}
145
+ height={mobileH}
146
+ />
147
+ )}
148
+
149
+ {Boolean(tabletSrcset) && (
150
+ <source
151
+ media="(min-width: 768px) and (max-width: 1024px)"
152
+ srcset={tabletSrcset as string}
153
+ sizes="(max-width: 1024px) calc(100vw - 48px), 1024px"
154
+ width={tabletW}
155
+ height={tabletH}
156
+ />
157
+ )}
158
+
159
+ {Boolean(desktopSrcset) && (
160
+ <source
161
+ srcset={desktopSrcset as string}
162
+ sizes={sizes}
163
+ width={baseWidth}
164
+ height={baseHeight}
165
+ />
166
+ )}
167
+
168
+ <img
169
+ src={fallbackUrl}
170
+ alt={targetImage.alt || targetImage.filename || ""}
171
+ width={baseWidth}
172
+ height={baseHeight}
173
+ loading={loading}
174
+ fetchpriority={fetchpriority}
175
+ decoding={loading === "eager" ? "sync" : "async"}
176
+ class={className}
177
+ />
178
+ </picture>
179
+ ))
180
+ }
@@ -0,0 +1,56 @@
1
+ ---
2
+ // src/layouts/SkeletonLayout.astro
3
+
4
+ import MetaHead from "@/components/global/MetaHead.astro";
5
+
6
+ interface Props {
7
+ node: Record<string, any>;
8
+ content: Record<string, any>;
9
+ normalizedSlug: string;
10
+ isHomePage: boolean;
11
+ // 🎯 1. Added language to Props
12
+ language?: string;
13
+ }
14
+
15
+ // 🎯 2. Destructure language
16
+ const {
17
+ node = {},
18
+ content = {},
19
+ normalizedSlug,
20
+ isHomePage,
21
+ language,
22
+ } = Astro.props;
23
+
24
+ const customCss = node.css || "";
25
+ const customJsHeader = node.jsHeader || "";
26
+ const customJsFooter = node.jsFooter || "";
27
+
28
+ // Now this works without crashing!
29
+ const pageLang = language || node?.language || "en";
30
+ ---
31
+
32
+ <!doctype html>
33
+ <html lang={pageLang}>
34
+ <head>
35
+ <MetaHead
36
+ node={node}
37
+ content={content}
38
+ normalizedSlug={normalizedSlug}
39
+ isHomePage={isHomePage}
40
+ />
41
+
42
+ <!-- 🎯 3. Added is:inline so Astro actually renders your database CSS -->
43
+ {customCss && <style is:inline set:html={customCss} />}
44
+
45
+ <!-- 🎯 4. Added is:inline so Astro executes your header JS -->
46
+ {customJsHeader && <script is:inline set:html={customJsHeader} />}
47
+ </head>
48
+
49
+ <body>
50
+ <!-- Renders your Blocks (like the HTML Block) natively -->
51
+ <slot />
52
+
53
+ <!-- 🎯 5. Added is:inline to footer JS -->
54
+ {customJsFooter && <script is:inline set:html={customJsFooter} />}
55
+ </body>
56
+ </html>
@@ -0,0 +1,63 @@
1
+ ---
2
+ // src/layouts/StandardLayout.astro
3
+
4
+ import "@/styles/standard-theme.css";
5
+ import Header from "@/components/global/Header.astro";
6
+ import Footer from "@/components/global/Footer.astro";
7
+ import MetaHead from "@/components/global/MetaHead.astro";
8
+ import type { TreeNode, ContentBlock } from "@/lib/cms";
9
+
10
+ interface Props {
11
+ node: Record<string, any>;
12
+ content: Record<string, ContentBlock[]>;
13
+ normalizedSlug: string;
14
+ isHomePage: boolean;
15
+ hasSidebar?: boolean;
16
+ headerBlocks?: ContentBlock[];
17
+ tree?: TreeNode[];
18
+ language?: string;
19
+ }
20
+
21
+ const {
22
+ node = {},
23
+ content = {},
24
+ normalizedSlug,
25
+ isHomePage,
26
+ headerBlocks = [],
27
+ tree,
28
+ language,
29
+ } = Astro.props;
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
+
35
+ // 🎯 FIX: Extract the footer blocks from the payload again
36
+ const footerBlocks = content?.footer || [];
37
+ ---
38
+
39
+ <!doctype html>
40
+ <html lang={pageLang}>
41
+ <head>
42
+ <MetaHead
43
+ node={node}
44
+ content={content}
45
+ normalizedSlug={normalizedSlug}
46
+ isHomePage={isHomePage}
47
+ headerBlocks={headerBlocks}
48
+ resolvedLanguage={pageLang}
49
+ />
50
+ </head>
51
+ <body>
52
+ <Header node={node} tree={tree} />
53
+
54
+ <main class="content-block">
55
+ <div class="content-container">
56
+ <slot />
57
+ </div>
58
+ </main>
59
+
60
+ {/* 🎯 FIX: Pass the blocks and contentMap into the Footer */}
61
+ <Footer blocks={footerBlocks} contentMap={content} />
62
+ </body>
63
+ </html>
@@ -0,0 +1,219 @@
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
+ [key: string]: any;
17
+ }
18
+
19
+ export interface ContentBlock {
20
+ instanceId: number;
21
+ componentId: string;
22
+ data: Record<string, any>;
23
+ [key: string]: any;
24
+ }
25
+
26
+ export interface RegionMeta {
27
+ isInherited: boolean;
28
+ inheritedFromId?: string | number;
29
+ inheritedFromTitle?: string;
30
+ [key: string]: any;
31
+ }
32
+
33
+ export interface NodeResponse {
34
+ channelId: string | number;
35
+ treeId: string | number;
36
+ language: string;
37
+ node: {
38
+ id: number;
39
+ title: string;
40
+ slug: string;
41
+ nodeType: string;
42
+ path?: string;
43
+ createdAt?: string;
44
+ updatedAt?: string;
45
+ summary?: string;
46
+ children?: any[];
47
+ [key: string]: any;
48
+ };
49
+ content: Record<string, ContentBlock[]>;
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;
69
+ }
70
+
71
+ /**
72
+ * Resolves base CMS URL across runtime environments safely via Astro
73
+ */
74
+ export function getCmsUrl(): string {
75
+ const url =
76
+ import.meta.env.PUBLIC_API_URL ||
77
+ import.meta.env.PUBLIC_CMS_URL ||
78
+ 'http://localhost:8787';
79
+ return url.replace(/\/+$/, '');
80
+ }
81
+
82
+ /**
83
+ * Resolves Default Channel ID across environments
84
+ */
85
+ export function getChannelId(): string {
86
+ return import.meta.env.PUBLIC_CHANNEL_ID || 'website';
87
+ }
88
+
89
+ /**
90
+ * Resolves Default Tree ID across environments
91
+ */
92
+ export function getTreeId(): string {
93
+ return import.meta.env.PUBLIC_TREE_ID || 'navigation';
94
+ }
95
+
96
+ // 🎯 Safely resolve global authorization header for default CMS fetches
97
+ function getAuthHeaders(): Record<string, string> {
98
+ const secret = import.meta.env.FRONTEND_API_SECRET;
99
+ const headers: Record<string, string> = { Accept: 'application/json' };
100
+ if (secret) headers['Authorization'] = `Bearer ${secret}`;
101
+ return headers;
102
+ }
103
+
104
+ /**
105
+ * Fetch channel metadata by key/ID.
106
+ * Serves global site branding, logos, favicons, and schema-defined public fields.
107
+ */
108
+ export async function getChannel(
109
+ channelKeyOrId: string = getChannelId(),
110
+ customFetch: typeof fetch = fetch
111
+ ): Promise<ChannelResponse | null> {
112
+ const baseUrl = getCmsUrl();
113
+ const requestUrl = `${baseUrl}/api/v1/channel/${channelKeyOrId}`;
114
+
115
+ try {
116
+ const res = await customFetch(requestUrl, {
117
+ headers: getAuthHeaders(),
118
+ });
119
+
120
+ if (!res.ok) return null;
121
+
122
+ // 🎯 Defensive HTML check
123
+ if (res.headers.get("content-type")?.includes("text/html")) {
124
+ console.error(`❌ [CMS Client] API returned HTML instead of JSON for Channel: ${requestUrl}`);
125
+ return null;
126
+ }
127
+
128
+ return (await res.json()) as ChannelResponse;
129
+ } catch (err) {
130
+ console.error('[CMS Client] Failed to fetch public channel:', err);
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Fetch tree navigation hierarchy by tree key/ID and channel.
137
+ * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
138
+ */
139
+ export async function getTree(
140
+ treeKeyOrId: string = getTreeId(),
141
+ channel: string | number = getChannelId(),
142
+ customFetch: typeof fetch = fetch
143
+ ): Promise<TreeNode[]> {
144
+ const baseUrl = getCmsUrl();
145
+ const requestUrl = `${baseUrl}/api/v1/tree/${treeKeyOrId}?channelId=${channel}`;
146
+
147
+ try {
148
+ const res = await customFetch(requestUrl, {
149
+ headers: getAuthHeaders(),
150
+ });
151
+
152
+ if (!res.ok) return [];
153
+
154
+ // 🎯 Defensive HTML check
155
+ if (res.headers.get("content-type")?.includes("text/html")) {
156
+ console.error(`❌ [CMS Client] API returned HTML instead of JSON for Tree: ${requestUrl}`);
157
+ return [];
158
+ }
159
+
160
+ const data = await res.json();
161
+ return data.items || [];
162
+ } catch (err) {
163
+ console.error('[CMS Client] Failed to fetch public tree:', err);
164
+ return [];
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Fetch single content node payload by its full URL path.
170
+ * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
171
+ */
172
+ export async function getNodeByPath(
173
+ path: string,
174
+ channel: string | number = getChannelId(),
175
+ preview = false,
176
+ customFetch: typeof fetch = fetch
177
+ ): Promise<NodeResponse | null> {
178
+ // 🎯 We keep 'index' as the fallback string for Hono routing compatibility,
179
+ // because the backend dynamically intercepts 'index' and returns the channelRoot!
180
+ const cleanPath = path.replace(/^\/+|\/+$/g, '') || 'index';
181
+ const baseUrl = getCmsUrl();
182
+ const requestUrl = `${baseUrl}/api/v1/node/${cleanPath}?channelId=${channel}&preview=${preview}`;
183
+
184
+ try {
185
+ const res = await customFetch(requestUrl, {
186
+ headers: getAuthHeaders(),
187
+ });
188
+
189
+ if (!res.ok) return null;
190
+
191
+ // 🎯 Defensive HTML check (Prevents the dreaded "<!DOCTYPE html" JSON parse crash)
192
+ if (res.headers.get("content-type")?.includes("text/html")) {
193
+ console.error(`❌ [CMS Client] API returned HTML instead of JSON for Node: ${requestUrl}`);
194
+ return null;
195
+ }
196
+
197
+ return (await res.json()) as NodeResponse;
198
+ } catch (err) {
199
+ console.error(`[CMS Client] Failed to fetch node by path (${cleanPath}):`, err);
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Resolves relative media URLs against base CMS URL
206
+ */
207
+ export function getMediaUrl(url?: string): string {
208
+ if (!url) return '';
209
+
210
+ const baseUrl = getCmsUrl();
211
+ let cleanUrl = url.replace(/^http:\/\/localhost:\d+/, baseUrl);
212
+
213
+ if (cleanUrl.startsWith('http://') || cleanUrl.startsWith('https://')) {
214
+ return cleanUrl;
215
+ }
216
+
217
+ const cleanPath = cleanUrl.replace(/^\/+/, '');
218
+ return `${baseUrl}/${cleanPath}`;
219
+ }
@@ -0,0 +1,67 @@
1
+ // src/lib/media/kama-image-service.ts
2
+
3
+ import type { LocalImageService } from "astro";
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
+
14
+ const kamaImageService: LocalImageService = {
15
+ getURL(options) {
16
+ let src = options.src;
17
+
18
+ if (typeof src === 'object' && 'src' in src) {
19
+ src = src.src;
20
+ }
21
+
22
+ if (typeof src === 'string') {
23
+ try {
24
+ // 🎯 SVG Guard: Bypass raster query params for vector graphics
25
+ if (isSvgImage(src)) {
26
+ return src;
27
+ }
28
+
29
+ const url = new URL(src, 'http://localhost');
30
+
31
+ // 🎯 Pass width if defined (snapped on backend by assets.ts)
32
+ if (options.width && !isNaN(options.width)) {
33
+ url.searchParams.set('w', Math.round(options.width).toString());
34
+ }
35
+
36
+ // 🎯 Pass height only if explicitly provided to maintain intrinsic aspect ratios
37
+ if (options.height && !isNaN(options.height)) {
38
+ url.searchParams.set('h', Math.round(options.height).toString());
39
+ }
40
+
41
+ // 🎯 Default quality to 75 if unsupplied
42
+ url.searchParams.set('q', (options.quality || 75).toString());
43
+
44
+ // 🎯 Default format to 'auto' for backend WebP/AVIF negotiation
45
+ url.searchParams.set('f', options.format ? options.format.toString() : 'auto');
46
+
47
+ return src.startsWith('http') ? url.toString() : (url.pathname + url.search);
48
+ } catch (e) {
49
+ return src;
50
+ }
51
+ }
52
+ return String(src);
53
+ },
54
+ parseURL(url) {
55
+ return { src: url.pathname + url.search };
56
+ },
57
+ getHTMLAttributes(options) {
58
+ return {
59
+ width: options.width,
60
+ height: options.height,
61
+ loading: options.loading || 'lazy',
62
+ decoding: options.decoding || 'async',
63
+ };
64
+ }
65
+ };
66
+
67
+ export default kamaImageService;