@brightsideoy/kama 0.1.0-alpha.98 → 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.
- package/README.md +169 -0
- package/bin/cli.js +646 -240
- package/content/definitions/blocks/button.yaml +24 -0
- package/content/definitions/blocks/header.yaml +13 -0
- package/content/definitions/blocks/header_image.yaml +21 -0
- package/content/definitions/blocks/hero.yaml +28 -0
- package/content/definitions/blocks/html.yaml +13 -0
- package/content/definitions/blocks/image_card.yaml +56 -0
- package/content/definitions/blocks/single_image.yaml +27 -0
- package/content/definitions/blocks/text.yaml +15 -0
- package/content/definitions/channels/ecommerce.yaml +28 -0
- package/content/definitions/channels/website.yaml +41 -0
- package/{defaults → content}/definitions/field-sets/access.yaml +3 -5
- package/content/definitions/field-sets/channel-base.yaml +50 -0
- package/{defaults/definitions/field-sets/page-meta.yaml → content/definitions/field-sets/channel-seo.yaml} +3 -11
- package/content/definitions/field-sets/content-block.yaml +41 -0
- package/content/definitions/field-sets/page-base.yaml +44 -0
- package/content/definitions/field-sets/page-seo.yaml +34 -0
- package/{defaults → content}/definitions/field-sets/page-social.yaml +6 -8
- package/content/definitions/field-sets/schema-type.yaml +43 -0
- package/content/definitions/field-sets/tree-base.yaml +31 -0
- package/content/definitions/node-layouts/content.yml +12 -0
- package/{defaults → content}/definitions/node-layouts/landing-page.yml +8 -3
- package/{defaults → content}/definitions/node-layouts/single-column.yml +10 -5
- package/{defaults → content}/definitions/node-layouts/two-column.yml +14 -6
- package/content/definitions/nodes/address.yaml +40 -0
- package/content/definitions/nodes/article.yaml +49 -0
- package/content/definitions/nodes/event.yaml +55 -0
- package/content/definitions/nodes/external-link.yaml +29 -0
- package/content/definitions/nodes/page-skeleton.yaml +48 -0
- package/{defaults → content}/definitions/nodes/page.yaml +9 -20
- package/content/definitions/nodes/person.yaml +37 -0
- package/content/definitions/nodes/product.yaml +89 -0
- package/content/definitions/taxonomies/categories.yaml +15 -0
- package/content/definitions/taxonomies/tags.yaml +12 -0
- package/content/definitions/trees/navigation.yaml +24 -0
- package/definitions/editor/default.ts +1 -0
- package/dist/assets/index-F2rfUymo.css +1 -0
- package/dist/assets/index-lXxH3VFK.js +164 -0
- package/dist/dist-Dy7-HUEG.js +26302 -0
- package/dist/index.html +4 -2
- package/dist/index.js +25205 -8461
- package/dist/schema-compiler.js +48 -0
- package/documentation/SCHEMAS.md +182 -0
- package/package.json +19 -24
- package/schema.sql +36 -22
- package/templates/astro-starter/astro.config.mjs +27 -0
- package/templates/astro-starter/package.json.template +20 -0
- package/templates/astro-starter/public/robots.txt +23 -0
- package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +134 -0
- package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +67 -0
- package/templates/astro-starter/src/components/blocks/HeaderImage.astro +132 -0
- package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
- package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +25 -0
- package/templates/astro-starter/src/components/blocks/ImageBlock.astro +92 -0
- package/templates/astro-starter/src/components/blocks/TextBlock.astro +15 -0
- package/templates/astro-starter/src/components/global/Breadcrumb.astro +67 -0
- package/templates/astro-starter/src/components/global/Footer.astro +89 -0
- package/templates/astro-starter/src/components/global/Header.astro +177 -0
- package/templates/astro-starter/src/components/global/MetaHead.astro +266 -0
- package/templates/astro-starter/src/components/global/NavigationMenu.astro +402 -0
- package/templates/astro-starter/src/components/ui/KamaImage.astro +172 -0
- package/templates/astro-starter/src/layouts/SkeletonLayout.astro +56 -0
- package/templates/astro-starter/src/layouts/StandardLayout.astro +62 -0
- package/templates/astro-starter/src/lib/cms.ts +197 -0
- package/templates/astro-starter/src/lib/media/kama-image-service.ts +56 -0
- package/templates/astro-starter/src/lib/media/media-proxy.ts +129 -0
- package/templates/astro-starter/src/lib/media/media-url.ts +98 -0
- package/templates/astro-starter/src/middleware.ts +478 -0
- package/templates/astro-starter/src/pages/404.astro +46 -0
- package/templates/astro-starter/src/pages/[...slug].astro +237 -0
- package/templates/astro-starter/src/pages/llms-full.txt.ts +171 -0
- package/templates/astro-starter/src/pages/llms.txt.ts +113 -0
- package/templates/astro-starter/src/pages/media/[...path].ts +14 -0
- package/templates/astro-starter/src/pages/partials/footer.astro +69 -0
- package/templates/astro-starter/src/pages/partials/nav.astro +141 -0
- package/templates/astro-starter/src/styles/standard-theme.css +157 -0
- package/templates/astro-starter/src/utils/tiptap.ts +113 -0
- package/templates/astro-starter/tsconfig.json +0 -0
- package/templates/kama-worker/index.js +19 -0
- package/templates/kama-worker/server.js +98 -0
- package/wrangler.json +22 -0
- package/defaults/definitions/blocks/button.yaml +0 -15
- package/defaults/definitions/blocks/header.yaml +0 -15
- package/defaults/definitions/blocks/header_image.yaml +0 -22
- package/defaults/definitions/blocks/hero.yaml +0 -27
- package/defaults/definitions/blocks/image_card.yaml +0 -57
- package/defaults/definitions/blocks/single_image.yaml +0 -28
- package/defaults/definitions/blocks/text.yaml +0 -16
- package/defaults/definitions/channels/ecommerce.yaml +0 -24
- package/defaults/definitions/channels/website.yaml +0 -32
- package/defaults/definitions/field-sets/channel-base.yaml +0 -31
- package/defaults/definitions/field-sets/content-block.yaml +0 -46
- package/defaults/definitions/field-sets/page-base.yaml +0 -49
- package/defaults/definitions/field-sets/tree-base.yaml +0 -31
- package/defaults/definitions/node-layouts/three-column.yml +0 -41
- package/defaults/definitions/nodes/address.yaml +0 -32
- package/defaults/definitions/nodes/article.yaml +0 -43
- package/defaults/definitions/nodes/event.yaml +0 -49
- package/defaults/definitions/nodes/external-link.yaml +0 -26
- package/defaults/definitions/nodes/person.yaml +0 -37
- package/defaults/definitions/nodes/product.yaml +0 -86
- package/defaults/definitions/trees/navigation.yaml +0 -29
- package/defaults/definitions/trees/social-links.yaml +0 -9
- package/definitions/_page.yaml +0 -52
- package/dist/assets/index-TdW5F0pX.css +0 -1
- package/dist/assets/index-u9OkBauM.js +0 -1392
- package/dist/media/kama/2026/08/17a098c92bc54ea4b4441e531a34575b.jpg +0 -0
- package/dist/media/kama/2026/08/3e9ae457dd23461680cc699f42d07cf0.jpg +0 -0
- package/dist/media/kama/2026/08/8e5b3a2fd194459f9b962f8e12928bf0.jpg +0 -0
- package/dist/media/kama/2026/08/8ea5c21026cd4944bdf78c99fe2544a9.jpg +0 -0
- package/scripts/bundle-definitions.js +0 -176
- /package/{defaults → content}/definitions/block-layouts/three-column.yaml +0 -0
- /package/{defaults → content}/definitions/block-layouts/two-column.yaml +0 -0
- /package/{defaults → content}/definitions/field-sets/comments.yaml +0 -0
- /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
|
+
}
|