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

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 (116) hide show
  1. package/README.md +169 -0
  2. package/bin/cli.js +646 -240
  3. package/content/definitions/blocks/button.yaml +24 -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/single_image.yaml +27 -0
  10. package/content/definitions/blocks/text.yaml +15 -0
  11. package/content/definitions/channels/ecommerce.yaml +28 -0
  12. package/content/definitions/channels/website.yaml +41 -0
  13. package/{defaults → content}/definitions/field-sets/access.yaml +3 -5
  14. package/content/definitions/field-sets/channel-base.yaml +50 -0
  15. package/{defaults/definitions/field-sets/page-meta.yaml → content/definitions/field-sets/channel-seo.yaml} +3 -11
  16. package/content/definitions/field-sets/content-block.yaml +41 -0
  17. package/content/definitions/field-sets/page-base.yaml +44 -0
  18. package/content/definitions/field-sets/page-seo.yaml +34 -0
  19. package/{defaults → content}/definitions/field-sets/page-social.yaml +6 -8
  20. package/content/definitions/field-sets/schema-type.yaml +43 -0
  21. package/content/definitions/field-sets/tree-base.yaml +31 -0
  22. package/content/definitions/node-layouts/content.yml +12 -0
  23. package/{defaults → content}/definitions/node-layouts/landing-page.yml +8 -3
  24. package/{defaults → content}/definitions/node-layouts/single-column.yml +10 -5
  25. package/{defaults → content}/definitions/node-layouts/two-column.yml +14 -6
  26. package/content/definitions/nodes/address.yaml +40 -0
  27. package/content/definitions/nodes/article.yaml +49 -0
  28. package/content/definitions/nodes/event.yaml +55 -0
  29. package/content/definitions/nodes/external-link.yaml +29 -0
  30. package/content/definitions/nodes/page-skeleton.yaml +48 -0
  31. package/{defaults → content}/definitions/nodes/page.yaml +9 -20
  32. package/content/definitions/nodes/person.yaml +37 -0
  33. package/content/definitions/nodes/product.yaml +89 -0
  34. package/content/definitions/taxonomies/categories.yaml +15 -0
  35. package/content/definitions/taxonomies/tags.yaml +12 -0
  36. package/content/definitions/trees/navigation.yaml +24 -0
  37. package/definitions/editor/default.ts +1 -0
  38. package/dist/assets/index-F2rfUymo.css +1 -0
  39. package/dist/assets/index-lXxH3VFK.js +164 -0
  40. package/dist/dist-Dy7-HUEG.js +26302 -0
  41. package/dist/index.html +4 -2
  42. package/dist/index.js +25205 -8461
  43. package/dist/schema-compiler.js +48 -0
  44. package/documentation/SCHEMAS.md +182 -0
  45. package/package.json +19 -24
  46. package/schema.sql +36 -22
  47. package/templates/astro-starter/astro.config.mjs +27 -0
  48. package/templates/astro-starter/package.json.template +20 -0
  49. package/templates/astro-starter/public/robots.txt +23 -0
  50. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +134 -0
  51. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +67 -0
  52. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +132 -0
  53. package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
  54. package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +25 -0
  55. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +92 -0
  56. package/templates/astro-starter/src/components/blocks/TextBlock.astro +15 -0
  57. package/templates/astro-starter/src/components/global/Breadcrumb.astro +67 -0
  58. package/templates/astro-starter/src/components/global/Footer.astro +89 -0
  59. package/templates/astro-starter/src/components/global/Header.astro +177 -0
  60. package/templates/astro-starter/src/components/global/MetaHead.astro +266 -0
  61. package/templates/astro-starter/src/components/global/NavigationMenu.astro +402 -0
  62. package/templates/astro-starter/src/components/ui/KamaImage.astro +172 -0
  63. package/templates/astro-starter/src/layouts/SkeletonLayout.astro +56 -0
  64. package/templates/astro-starter/src/layouts/StandardLayout.astro +62 -0
  65. package/templates/astro-starter/src/lib/cms.ts +197 -0
  66. package/templates/astro-starter/src/lib/media/kama-image-service.ts +56 -0
  67. package/templates/astro-starter/src/lib/media/media-proxy.ts +129 -0
  68. package/templates/astro-starter/src/lib/media/media-url.ts +98 -0
  69. package/templates/astro-starter/src/middleware.ts +478 -0
  70. package/templates/astro-starter/src/pages/404.astro +46 -0
  71. package/templates/astro-starter/src/pages/[...slug].astro +237 -0
  72. package/templates/astro-starter/src/pages/llms-full.txt.ts +171 -0
  73. package/templates/astro-starter/src/pages/llms.txt.ts +113 -0
  74. package/templates/astro-starter/src/pages/media/[...path].ts +14 -0
  75. package/templates/astro-starter/src/pages/partials/footer.astro +69 -0
  76. package/templates/astro-starter/src/pages/partials/nav.astro +141 -0
  77. package/templates/astro-starter/src/styles/standard-theme.css +157 -0
  78. package/templates/astro-starter/src/utils/tiptap.ts +113 -0
  79. package/templates/astro-starter/tsconfig.json +0 -0
  80. package/templates/kama-worker/index.js +19 -0
  81. package/templates/kama-worker/server.js +98 -0
  82. package/wrangler.json +22 -0
  83. package/defaults/definitions/blocks/button.yaml +0 -15
  84. package/defaults/definitions/blocks/header.yaml +0 -15
  85. package/defaults/definitions/blocks/header_image.yaml +0 -22
  86. package/defaults/definitions/blocks/hero.yaml +0 -27
  87. package/defaults/definitions/blocks/image_card.yaml +0 -57
  88. package/defaults/definitions/blocks/single_image.yaml +0 -28
  89. package/defaults/definitions/blocks/text.yaml +0 -16
  90. package/defaults/definitions/channels/ecommerce.yaml +0 -24
  91. package/defaults/definitions/channels/website.yaml +0 -32
  92. package/defaults/definitions/field-sets/channel-base.yaml +0 -31
  93. package/defaults/definitions/field-sets/content-block.yaml +0 -46
  94. package/defaults/definitions/field-sets/page-base.yaml +0 -49
  95. package/defaults/definitions/field-sets/tree-base.yaml +0 -31
  96. package/defaults/definitions/node-layouts/three-column.yml +0 -41
  97. package/defaults/definitions/nodes/address.yaml +0 -32
  98. package/defaults/definitions/nodes/article.yaml +0 -43
  99. package/defaults/definitions/nodes/event.yaml +0 -49
  100. package/defaults/definitions/nodes/external-link.yaml +0 -26
  101. package/defaults/definitions/nodes/person.yaml +0 -37
  102. package/defaults/definitions/nodes/product.yaml +0 -86
  103. package/defaults/definitions/trees/navigation.yaml +0 -29
  104. package/defaults/definitions/trees/social-links.yaml +0 -9
  105. package/definitions/_page.yaml +0 -52
  106. package/dist/assets/index-D6qQsWOj.js +0 -1392
  107. package/dist/assets/index-TdW5F0pX.css +0 -1
  108. package/dist/media/kama/2026/08/17a098c92bc54ea4b4441e531a34575b.jpg +0 -0
  109. package/dist/media/kama/2026/08/3e9ae457dd23461680cc699f42d07cf0.jpg +0 -0
  110. package/dist/media/kama/2026/08/8e5b3a2fd194459f9b962f8e12928bf0.jpg +0 -0
  111. package/dist/media/kama/2026/08/8ea5c21026cd4944bdf78c99fe2544a9.jpg +0 -0
  112. package/scripts/bundle-definitions.js +0 -176
  113. /package/{defaults → content}/definitions/block-layouts/three-column.yaml +0 -0
  114. /package/{defaults → content}/definitions/block-layouts/two-column.yaml +0 -0
  115. /package/{defaults → content}/definitions/field-sets/comments.yaml +0 -0
  116. /package/{defaults → content}/definitions/field-sets/visibility.yaml +0 -0
@@ -0,0 +1,197 @@
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
+ /**
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
+
123
+ /**
124
+ * Fetch tree navigation hierarchy by tree key/ID and channel.
125
+ * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
126
+ */
127
+ export async function getTree(
128
+ treeKeyOrId: string = getTreeId(),
129
+ channel: string | number = getChannelId(),
130
+ customFetch: typeof fetch = fetch
131
+ ): Promise<TreeNode[]> {
132
+ const baseUrl = getCmsUrl();
133
+ const requestUrl = `${baseUrl}/api/v1/tree/${treeKeyOrId}?channelId=${channel}`;
134
+
135
+ try {
136
+ const res = await customFetch(requestUrl, {
137
+ headers: {
138
+ Accept: 'application/json',
139
+ },
140
+ });
141
+
142
+ if (!res.ok) return [];
143
+
144
+ const data = await res.json();
145
+ return data.items || [];
146
+ } catch (err) {
147
+ console.error('[CMS Client] Failed to fetch public tree:', err);
148
+ return [];
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Fetch single content node payload by its full URL path.
154
+ * Supports passing Cloudflare Service Bindings (e.g. env.CMS_WORKER.fetch)
155
+ */
156
+ export async function getNodeByPath(
157
+ path: string,
158
+ channel: string | number = getChannelId(),
159
+ preview = false,
160
+ customFetch: typeof fetch = fetch
161
+ ): Promise<NodeResponse | null> {
162
+ const cleanPath = path.replace(/^\/+|\/+$/g, '') || 'home';
163
+ const baseUrl = getCmsUrl();
164
+ const requestUrl = `${baseUrl}/api/v1/node/${cleanPath}?channelId=${channel}&preview=${preview}`;
165
+
166
+ try {
167
+ const res = await customFetch(requestUrl, {
168
+ headers: {
169
+ Accept: 'application/json',
170
+ },
171
+ });
172
+
173
+ if (!res.ok) return null;
174
+
175
+ return (await res.json()) as NodeResponse;
176
+ } catch (err) {
177
+ console.error('[CMS Client] Failed to fetch node by path:', err);
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Resolves relative media URLs against base CMS URL
184
+ */
185
+ export function getMediaUrl(url?: string): string {
186
+ if (!url) return '';
187
+
188
+ const baseUrl = getCmsUrl();
189
+ let cleanUrl = url.replace(/^http:\/\/localhost:\d+/, baseUrl);
190
+
191
+ if (cleanUrl.startsWith('http://') || cleanUrl.startsWith('https://')) {
192
+ return cleanUrl;
193
+ }
194
+
195
+ const cleanPath = cleanUrl.replace(/^\/+/, '');
196
+ return `${baseUrl}/${cleanPath}`;
197
+ }
@@ -0,0 +1,56 @@
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
+ if (options.width) url.searchParams.set('w', Math.round(options.width).toString());
32
+ if (options.height) url.searchParams.set('h', Math.round(options.height).toString());
33
+ if (options.quality) url.searchParams.set('q', options.quality.toString());
34
+ if (options.format) url.searchParams.set('f', options.format.toString());
35
+
36
+ return src.startsWith('http') ? url.toString() : (url.pathname + url.search);
37
+ } catch (e) {
38
+ return src;
39
+ }
40
+ }
41
+ return String(src);
42
+ },
43
+ parseURL(url) {
44
+ return { src: url.pathname + url.search };
45
+ },
46
+ getHTMLAttributes(options) {
47
+ return {
48
+ width: options.width,
49
+ height: options.height,
50
+ loading: options.loading || 'lazy',
51
+ decoding: options.decoding || 'async',
52
+ };
53
+ }
54
+ };
55
+
56
+ export default kamaImageService;
@@ -0,0 +1,129 @@
1
+ // src/lib/media/media-proxy.ts
2
+
3
+ import type { APIContext } from "astro";
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 dual-environment
24
+ const runtime = locals.runtime || (locals as any).cfContext;
25
+ const workerEnv = runtime?.env || {};
26
+ const cfContext = runtime?.ctx || {};
27
+
28
+ // 🎯 Detect if Astro is requesting a resized variant
29
+ const hasResizeParams = url.searchParams.has('w') || url.searchParams.has('h') || url.searchParams.has('q') || url.searchParams.has('f');
30
+
31
+ // 1. Cloudflare Edge Cache API
32
+ let edgeCache: Cache | null = null;
33
+ try {
34
+ if (typeof caches !== "undefined" && caches.default) {
35
+ edgeCache = caches.default;
36
+ const cachedResponse = await edgeCache.match(request);
37
+ if (cachedResponse) return cachedResponse;
38
+ }
39
+ } catch (err) {
40
+ console.warn("[Media Proxy] Edge cache match failed:", err);
41
+ }
42
+
43
+ const rawPath = params.path || "";
44
+ const cleanPath = rawPath.replace(/^\/+|\/+$/g, "");
45
+
46
+ // 2. Direct R2 Lookup (ONLY IF NO RESIZING PARAMS ARE PRESENT AND WE ARE IN PROD)
47
+ const mediaBucket = workerEnv.MEDIA_BUCKET;
48
+ if (mediaBucket && cleanPath && !hasResizeParams) {
49
+ try {
50
+ let r2Object = await mediaBucket.get(cleanPath);
51
+ if (!r2Object) r2Object = await mediaBucket.get(cleanPath.startsWith("media/") ? cleanPath.replace(/^media\//, "") : `media/${cleanPath}`);
52
+
53
+ if (r2Object) {
54
+ if (request.headers.get("if-none-match") === r2Object.httpEtag) {
55
+ return new Response(null, { status: 304 });
56
+ }
57
+
58
+ const headers = new Headers();
59
+ r2Object.writeHttpMetadata(headers);
60
+ headers.set("etag", r2Object.httpEtag);
61
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
62
+ headers.set("Access-Control-Allow-Origin", "*");
63
+ if (!headers.get("content-type") || headers.get("content-type") === "application/octet-stream") {
64
+ headers.set("content-type", getMimeType(cleanPath));
65
+ }
66
+
67
+ const r2Response = new Response(r2Object.body, { status: 200, headers });
68
+
69
+ if (edgeCache) {
70
+ const cachePromise = edgeCache.put(request, r2Response.clone());
71
+ if (cfContext?.waitUntil) cfContext.waitUntil(cachePromise);
72
+ else cachePromise.catch(e => console.warn(e));
73
+ }
74
+
75
+ return r2Response;
76
+ }
77
+ } catch (r2Err) {
78
+ console.warn("[Media Proxy] R2 lookup error:", r2Err);
79
+ }
80
+ }
81
+
82
+ // 3. Forward to Backend (Handles Local Sharp Resizing AND Cloudflare Images Resizing)
83
+ try {
84
+ let backendResponse: Response;
85
+
86
+ // A. Production Mode (Cloudflare Service Binding)
87
+ if (workerEnv.CMS_WORKER) {
88
+ backendResponse = await workerEnv.CMS_WORKER.fetch(request);
89
+ }
90
+ // B. Local Dev Mode (Standard HTTP Fetch to local Hono Server)
91
+ else {
92
+ const cmsUrl = import.meta.env.PUBLIC_CMS_URL || 'http://localhost:8787';
93
+ const targetUrl = `${cmsUrl}${url.pathname}${url.search}`;
94
+
95
+ // Forward request headers (like Accept for WebP/AVIF negotiation)
96
+ backendResponse = await fetch(targetUrl, {
97
+ headers: {
98
+ 'Accept': request.headers.get('Accept') || 'image/webp,image/avif,image/*,*/*;q=0.8'
99
+ }
100
+ });
101
+ }
102
+
103
+ if (backendResponse.ok) {
104
+ const newHeaders = new Headers(backendResponse.headers);
105
+ newHeaders.set("Cache-Control", "public, max-age=31536000, immutable");
106
+ newHeaders.set("Access-Control-Allow-Origin", "*");
107
+
108
+ const proxyResponse = new Response(backendResponse.body, {
109
+ status: backendResponse.status,
110
+ statusText: backendResponse.statusText,
111
+ headers: newHeaders,
112
+ });
113
+
114
+ if (edgeCache) {
115
+ const cachePromise = edgeCache.put(request, proxyResponse.clone());
116
+ if (cfContext?.waitUntil) cfContext.waitUntil(cachePromise);
117
+ else cachePromise.catch(e => console.warn(e));
118
+ }
119
+
120
+ return proxyResponse;
121
+ }
122
+
123
+ return backendResponse;
124
+ } catch (error) {
125
+ console.error("❌ Media Proxy Forwarding Error:", error);
126
+ }
127
+
128
+ return new Response("Not found", { status: 404 });
129
+ }
@@ -0,0 +1,98 @@
1
+ // src/lib/media/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
+ /**
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
+
29
+ export function buildAgnosticImageUrl(
30
+ url: string,
31
+ width: number,
32
+ quality = 75,
33
+ ): string {
34
+ const cleanPath = toRelativeMediaPath(url);
35
+ if (!cleanPath) return "";
36
+
37
+ // 🎯 Vector Guard: Return pristine path for SVGs without width/quality query parameters
38
+ if (isSvgImage(cleanPath)) {
39
+ return cleanPath;
40
+ }
41
+
42
+ const urlObj = new URL(cleanPath, "http://localhost");
43
+ urlObj.searchParams.set("w", String(width));
44
+ urlObj.searchParams.set("q", String(quality));
45
+ urlObj.searchParams.set("f", "auto");
46
+
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;
98
+ }