@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.
- package/bin/cli.js +229 -231
- package/dist/assets/index-BesA3e7A.js +164 -0
- package/dist/assets/index-D6yHZ_u1.css +1 -0
- package/dist/{dist-C-WAK7EF.js → dist-mic4UZBb.js} +691 -439
- package/dist/index.html +2 -2
- package/dist/index.js +693 -439
- package/dist/schema-compiler.js +1 -1
- package/package.json +1 -1
- package/schema.sql +2 -2
- package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +1 -0
- package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +1 -0
- package/templates/astro-starter/src/components/blocks/HeaderImage.astro +1 -0
- package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +2 -1
- package/templates/astro-starter/src/components/blocks/ImageBlock.astro +39 -71
- package/templates/astro-starter/src/components/blocks/TextBlock.astro +2 -0
- package/templates/astro-starter/src/components/global/Breadcrumb.astro +2 -0
- package/templates/astro-starter/src/components/global/Footer.astro +2 -0
- package/templates/astro-starter/src/components/global/Header.astro +100 -28
- package/templates/astro-starter/src/components/{seo/SeoHead.astro → global/MetaHead.astro} +101 -49
- package/templates/astro-starter/src/components/global/NavigationMenu.astro +1 -0
- package/templates/astro-starter/src/components/ui/KamaImage.astro +114 -67
- package/templates/astro-starter/src/layouts/SkeletonLayout.astro +5 -3
- package/templates/astro-starter/src/layouts/StandardLayout.astro +14 -5
- package/templates/astro-starter/src/lib/cms.ts +48 -0
- package/templates/astro-starter/src/lib/media/kama-image-service.ts +16 -1
- package/templates/astro-starter/src/lib/media/media-proxy.ts +8 -6
- package/templates/astro-starter/src/lib/media/media-url.ts +65 -1
- package/templates/astro-starter/src/middleware.ts +131 -18
- package/templates/astro-starter/src/pages/[...slug].astro +10 -9
- package/definitions/_page.yaml +0 -52
- package/dist/assets/index-DGHRgyms.js +0 -164
- package/dist/assets/index-EaDn3OUJ.css +0 -1
- /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
|
-
|
|
4
|
-
import {
|
|
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
|
-
|
|
35
|
+
targetImage &&
|
|
36
|
+
(typeof targetImage === "string" ||
|
|
37
|
+
targetImage.url ||
|
|
38
|
+
targetImage.path ||
|
|
39
|
+
targetImage.variants),
|
|
28
40
|
);
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
: null;
|
|
67
|
-
|
|
68
|
-
const tabletSrcset =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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={
|
|
115
|
-
alt={
|
|
116
|
-
width={
|
|
117
|
-
height={
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
23
|
+
<html lang={pageLang}>
|
|
22
24
|
<head>
|
|
23
25
|
<!-- 🎯 1. SEO & Meta Tags -->
|
|
24
|
-
<
|
|
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
|
|
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
|
-
|
|
38
|
+
<!doctype html>
|
|
39
|
+
<html lang={pageLang}>
|
|
30
40
|
<head>
|
|
31
|
-
<
|
|
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
|
|
24
|
-
const
|
|
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 =
|
|
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 (
|
|
86
|
-
backendResponse = await
|
|
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
|
}
|