@wocart/storefront 1.23.0 → 2.0.0
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/CHANGELOG.md +41 -0
- package/README.md +22 -117
- package/USAGE.md +31 -70
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/schemas.d.ts +84 -35
- package/dist/schemas.js +17 -12
- package/dist/seo.js +1 -1
- package/dist/types.d.ts +7 -7
- package/dist/utils/image.d.ts +50 -169
- package/dist/utils/image.js +72 -165
- package/dist/utils/product-image.d.ts +6 -6
- package/dist/utils/product-image.js +7 -7
- package/package.json +1 -1
package/dist/utils/image.js
CHANGED
|
@@ -1,119 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The store's images: where a key is served, and how a photo is asked for at
|
|
3
|
+
* the size a page shows it.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* negotiation, and no-op fallback logic behind a simple interface.
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* Generate an optimized image URL using Cloudflare Image Resizing
|
|
12
|
-
*
|
|
13
|
-
* @param originalUrl - The original image URL from R2
|
|
14
|
-
* @param options - Resize and optimization options
|
|
15
|
-
* @param mediaDomain - Media domain (e.g., "media.example.com" or "https://media.example.com")
|
|
16
|
-
* @returns Optimized image URL
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```ts
|
|
20
|
-
* const url = optimizeImage(
|
|
21
|
-
* 'https://media.example.com/products/image.jpg',
|
|
22
|
-
* { width: 400, height: 400 },
|
|
23
|
-
* 'media.example.com'
|
|
24
|
-
* );
|
|
25
|
-
* // Returns: https://media.example.com/cdn-cgi/image/onerror=redirect,width=400,height=400,format=webp,quality=85,fit=cover/products/image.jpg
|
|
26
|
-
* ```
|
|
5
|
+
* Every image the store's server answers is a `MediaImage` — its key and its
|
|
6
|
+
* URL — and the store's config carries its `MediaDelivery`: the origin every
|
|
7
|
+
* key is served from and how that origin resizes. These helpers take that
|
|
8
|
+
* delivery, so one theme works on every host a store can use.
|
|
27
9
|
*/
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
10
|
+
export const MEDIA_DELIVERY_KINDS = ["imagekit", "cloudflare", "direct"];
|
|
11
|
+
const MEDIA_FOLDERS = ["products", "landing", "reviews"];
|
|
12
|
+
const KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
13
|
+
/** Whether `value` can be a Media Key: a store folder, then segments of letters, digits, `.`, `_` and `-` that start with a letter or digit. */
|
|
14
|
+
export function isMediaKey(value) {
|
|
15
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 512)
|
|
16
|
+
return false;
|
|
17
|
+
const [folder, ...rest] = value.split("/");
|
|
18
|
+
return MEDIA_FOLDERS.includes(folder) && rest.length > 0 && rest.every((segment) => KEY_SEGMENT.test(segment));
|
|
31
19
|
}
|
|
32
20
|
/**
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* domain's zone has Image Transformations on.
|
|
21
|
+
* The URL of a key in the store's media — what a theme's `image` field
|
|
22
|
+
* stores (`products/3f2a9c.webp`). The key is the image's identity; its URL
|
|
23
|
+
* depends on the store's delivery, so it is derived at render, never stored.
|
|
37
24
|
*
|
|
38
|
-
* `
|
|
39
|
-
* the original image instead of answering with an error.
|
|
40
|
-
*
|
|
41
|
-
* @returns `null` when there is no media domain, or the URL is not on it.
|
|
25
|
+
* @returns `null` without a delivery, or for a value that is not a Media Key.
|
|
42
26
|
*/
|
|
43
|
-
export function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
27
|
+
export function mediaUrl(key, delivery) {
|
|
28
|
+
return delivery && isMediaKey(key) ? `${delivery.origin}/${key}` : null;
|
|
29
|
+
}
|
|
30
|
+
const IMAGEKIT_CROP = {
|
|
31
|
+
cover: "c-maintain_ratio",
|
|
32
|
+
crop: "c-maintain_ratio",
|
|
33
|
+
"scale-down": "c-at_max",
|
|
34
|
+
contain: "c-at_max_enlarge",
|
|
35
|
+
squeeze: "c-force",
|
|
36
|
+
pad: "cm-pad_resize",
|
|
37
|
+
};
|
|
38
|
+
function imagekitUrl(url, params) {
|
|
39
|
+
const options = [];
|
|
40
|
+
if (params.width)
|
|
41
|
+
options.push(`w-${params.width}`);
|
|
42
|
+
if (params.height)
|
|
43
|
+
options.push(`h-${params.height}`);
|
|
44
|
+
if (params.fit)
|
|
45
|
+
options.push(IMAGEKIT_CROP[params.fit]);
|
|
46
|
+
if (params.quality)
|
|
47
|
+
options.push(`q-${params.quality}`);
|
|
48
|
+
if (params.format)
|
|
49
|
+
options.push(`f-${params.format === "jpeg" ? "jpg" : params.format}`);
|
|
50
|
+
if (options.length === 0)
|
|
51
|
+
return url;
|
|
52
|
+
return `${url}${url.includes("?") ? "&" : "?"}tr=${options.join(",")}`;
|
|
53
|
+
}
|
|
54
|
+
function cloudflareUrl(url, params) {
|
|
56
55
|
const options = ["onerror=redirect"];
|
|
57
|
-
for (const
|
|
58
|
-
const value = params[
|
|
56
|
+
for (const name of ["width", "height", "format", "quality", "fit"]) {
|
|
57
|
+
const value = params[name];
|
|
59
58
|
if (value !== undefined && value !== "")
|
|
60
|
-
options.push(`${
|
|
61
|
-
}
|
|
62
|
-
const imagePath = `${original.pathname.replace(/^\/+/, "")}${original.search}${original.hash}`;
|
|
63
|
-
return `${mediaOrigin}/cdn-cgi/image/${options.join(",")}/${imagePath}`;
|
|
64
|
-
}
|
|
65
|
-
/** The media domain (`media.example.com`, with or without a scheme) as an HTTPS origin, or `null`. */
|
|
66
|
-
function mediaOriginOf(mediaDomain) {
|
|
67
|
-
try {
|
|
68
|
-
return new URL(`https://${mediaDomain.replace(/^https?:\/\//, "")}`).origin;
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
return null;
|
|
59
|
+
options.push(`${name}=${value}`);
|
|
72
60
|
}
|
|
61
|
+
const { origin, pathname, search } = new URL(url);
|
|
62
|
+
return `${origin}/cdn-cgi/image/${options.join(",")}/${pathname.replace(/^\/+/, "")}${search}`;
|
|
73
63
|
}
|
|
74
64
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* so it is derived here, at render, never stored. Pass the result to
|
|
79
|
-
* `mediaImageUrl` or Astro's `<Image>` to resize it.
|
|
65
|
+
* A photo of the store's media at the size and format a page shows it, from
|
|
66
|
+
* the delivery's own resizer: ImageKit's `?tr=` or Cloudflare's
|
|
67
|
+
* `/cdn-cgi/image/` (which falls back to the original when a resize fails).
|
|
80
68
|
*
|
|
81
|
-
* @returns `null`
|
|
82
|
-
*
|
|
83
|
-
* makes an empty one), whitespace, control characters, a backslash, `?` or `#`.
|
|
69
|
+
* @returns `null` without a delivery, for a URL not served by it, or for a
|
|
70
|
+
* `direct` delivery, which does not resize — show the original then.
|
|
84
71
|
*/
|
|
85
|
-
export function
|
|
86
|
-
if (!
|
|
87
|
-
return null;
|
|
88
|
-
if (/[\s\\?#\u0000-\u001f\u007f]/.test(key))
|
|
89
|
-
return null;
|
|
90
|
-
const segments = key.split("/");
|
|
91
|
-
if (segments.some((segment) => segment === "" || segment === "." || segment === ".."))
|
|
72
|
+
export function mediaImageUrl(url, params, delivery) {
|
|
73
|
+
if (!delivery || delivery.kind === "direct" || typeof url !== "string" || !url.startsWith(`${delivery.origin}/`))
|
|
92
74
|
return null;
|
|
93
|
-
|
|
94
|
-
return origin === null ? null : `${origin}/${segments.map(encodeURIComponent).join("/")}`;
|
|
75
|
+
return delivery.kind === "imagekit" ? imagekitUrl(url, params) : cloudflareUrl(url, params);
|
|
95
76
|
}
|
|
96
77
|
/**
|
|
97
78
|
* The same photo at several widths, with width descriptors
|
|
98
79
|
* (`… 360w, … 720w`), for an `<img>` whose `sizes` says how wide it shows —
|
|
99
|
-
* a phone then downloads a phone-sized file
|
|
100
|
-
* best format the browser takes (`format=auto`).
|
|
80
|
+
* a phone then downloads a phone-sized file, in the best format it takes.
|
|
101
81
|
*
|
|
102
|
-
* @returns `null` when
|
|
82
|
+
* @returns `null` when the photo cannot be resized (see `mediaImageUrl`) or no
|
|
103
83
|
* width is a positive whole number.
|
|
104
84
|
*/
|
|
105
|
-
export function mediaImageSrcset(src, widths, options
|
|
85
|
+
export function mediaImageSrcset(src, widths, options) {
|
|
106
86
|
const sizes = [...new Set(widths.filter((width) => Number.isInteger(width) && width > 0))].sort((a, b) => a - b);
|
|
107
87
|
if (sizes.length === 0)
|
|
108
88
|
return null;
|
|
109
|
-
const { ratio, quality,
|
|
89
|
+
const { ratio, quality, delivery } = options;
|
|
110
90
|
const cropped = ratio !== undefined && Number.isFinite(ratio) && ratio > 0;
|
|
111
91
|
const encoding = quality === undefined ? {} : { quality };
|
|
112
92
|
const entries = [];
|
|
113
93
|
for (const width of sizes) {
|
|
114
94
|
const url = mediaImageUrl(src, cropped
|
|
115
95
|
? { width, height: Math.max(1, Math.round(width * ratio)), fit: "cover", format: "auto", ...encoding }
|
|
116
|
-
: { width, fit: "scale-down", format: "auto", ...encoding },
|
|
96
|
+
: { width, fit: "scale-down", format: "auto", ...encoding }, delivery);
|
|
117
97
|
if (url === null)
|
|
118
98
|
return null;
|
|
119
99
|
entries.push(`${url} ${width}w`);
|
|
@@ -121,17 +101,17 @@ export function mediaImageSrcset(src, widths, options = {}) {
|
|
|
121
101
|
return entries.join(", ");
|
|
122
102
|
}
|
|
123
103
|
/**
|
|
124
|
-
* A picked image as a theme draws it: its URL
|
|
125
|
-
*
|
|
126
|
-
*
|
|
104
|
+
* A picked image as a theme draws it: its URL in the store's media, its alt
|
|
105
|
+
* text (empty when the merchant wrote none), its size, and its focal point as
|
|
106
|
+
* `object-position`, so a cropped photo keeps what matters in view.
|
|
127
107
|
*
|
|
128
|
-
* @returns `null` when no image is picked, the key
|
|
129
|
-
*
|
|
108
|
+
* @returns `null` when no image is picked, the key is not a Media Key, or there
|
|
109
|
+
* is no delivery.
|
|
130
110
|
*/
|
|
131
|
-
export function pictureOf(image,
|
|
111
|
+
export function pictureOf(image, delivery) {
|
|
132
112
|
if (!image)
|
|
133
113
|
return null;
|
|
134
|
-
const src = mediaUrl(image.key,
|
|
114
|
+
const src = mediaUrl(image.key, delivery);
|
|
135
115
|
if (src === null)
|
|
136
116
|
return null;
|
|
137
117
|
const percent = (value) => `${Math.round(Math.min(1, Math.max(0, value)) * 100)}%`;
|
|
@@ -144,76 +124,3 @@ export function pictureOf(image, mediaDomain) {
|
|
|
144
124
|
position: focal && Number.isFinite(focal.x) && Number.isFinite(focal.y) ? `${percent(focal.x)} ${percent(focal.y)}` : undefined,
|
|
145
125
|
};
|
|
146
126
|
}
|
|
147
|
-
/**
|
|
148
|
-
* Predefined image sizes for common use cases
|
|
149
|
-
*/
|
|
150
|
-
export const IMAGE_SIZES = {
|
|
151
|
-
// Product cards (grid view)
|
|
152
|
-
CARD: { width: 400, height: 400, quality: 85 },
|
|
153
|
-
// Product gallery main image
|
|
154
|
-
GALLERY_MAIN: { width: 800, height: 800, quality: 90 },
|
|
155
|
-
// Product gallery thumbnails
|
|
156
|
-
GALLERY_THUMB: { width: 80, height: 80, quality: 80 },
|
|
157
|
-
// Hero/banner images
|
|
158
|
-
HERO: { width: 1200, height: 600, quality: 90 },
|
|
159
|
-
// Category images
|
|
160
|
-
CATEGORY: { width: 300, height: 200, quality: 85 },
|
|
161
|
-
// Logo/brand images
|
|
162
|
-
LOGO: { width: 200, height: 80, quality: 90, fit: "contain" },
|
|
163
|
-
};
|
|
164
|
-
/**
|
|
165
|
-
* Quick helper functions for common image sizes
|
|
166
|
-
*
|
|
167
|
-
* These take the media domain per call. Theme code usually binds it once with
|
|
168
|
-
* `createImageHelpers(mediaDomain)` and uses the returned helpers, so a call
|
|
169
|
-
* site can never forget the domain and silently ship an unoptimized image.
|
|
170
|
-
*/
|
|
171
|
-
export const getCardImage = (url, mediaDomain) => optimizeImage(url, IMAGE_SIZES.CARD, mediaDomain);
|
|
172
|
-
export const getGalleryImage = (url, mediaDomain) => optimizeImage(url, IMAGE_SIZES.GALLERY_MAIN, mediaDomain);
|
|
173
|
-
export const getThumbnailImage = (url, mediaDomain) => optimizeImage(url, IMAGE_SIZES.GALLERY_THUMB, mediaDomain);
|
|
174
|
-
export const getHeroImage = (url, mediaDomain) => optimizeImage(url, IMAGE_SIZES.HERO, mediaDomain);
|
|
175
|
-
export const getCategoryImage = (url, mediaDomain) => optimizeImage(url, IMAGE_SIZES.CATEGORY, mediaDomain);
|
|
176
|
-
export const getLogoImage = (url, mediaDomain) => optimizeImage(url, IMAGE_SIZES.LOGO, mediaDomain);
|
|
177
|
-
/**
|
|
178
|
-
* Generate responsive image srcset for different screen sizes
|
|
179
|
-
*
|
|
180
|
-
* @param originalUrl - The original image URL
|
|
181
|
-
* @param baseWidth - Base width for 1x display
|
|
182
|
-
* @param baseHeight - Base height for 1x display
|
|
183
|
-
* @param mediaDomain - Media domain
|
|
184
|
-
* @returns srcset string for responsive images
|
|
185
|
-
*
|
|
186
|
-
* @example
|
|
187
|
-
* ```ts
|
|
188
|
-
* const srcset = generateSrcSet(
|
|
189
|
-
* 'https://media.example.com/products/image.jpg',
|
|
190
|
-
* 400,
|
|
191
|
-
* 400,
|
|
192
|
-
* 'media.example.com'
|
|
193
|
-
* );
|
|
194
|
-
* // Returns: "url 1x, url 1.5x, url 2x"
|
|
195
|
-
* ```
|
|
196
|
-
*/
|
|
197
|
-
export function generateSrcSet(originalUrl, baseWidth, baseHeight, mediaDomain) {
|
|
198
|
-
const sizes = [
|
|
199
|
-
{ width: baseWidth, height: baseHeight, density: "1x" },
|
|
200
|
-
{ width: Math.round(baseWidth * 1.5), height: Math.round(baseHeight * 1.5), density: "1.5x" },
|
|
201
|
-
{ width: baseWidth * 2, height: baseHeight * 2, density: "2x" },
|
|
202
|
-
];
|
|
203
|
-
return sizes
|
|
204
|
-
.map((size) => `${optimizeImage(originalUrl, size, mediaDomain)} ${size.density}`)
|
|
205
|
-
.join(", ");
|
|
206
|
-
}
|
|
207
|
-
export function createImageHelpers(mediaDomain) {
|
|
208
|
-
return {
|
|
209
|
-
optimizeImage: (originalUrl, options, domain = mediaDomain) => optimizeImage(originalUrl, options, domain),
|
|
210
|
-
generateSrcSet: (originalUrl, baseWidth, baseHeight, domain = mediaDomain) => generateSrcSet(originalUrl, baseWidth, baseHeight, domain),
|
|
211
|
-
IMAGE_SIZES,
|
|
212
|
-
getCardImage: (url) => getCardImage(url, mediaDomain),
|
|
213
|
-
getGalleryImage: (url) => getGalleryImage(url, mediaDomain),
|
|
214
|
-
getThumbnailImage: (url) => getThumbnailImage(url, mediaDomain),
|
|
215
|
-
getHeroImage: (url) => getHeroImage(url, mediaDomain),
|
|
216
|
-
getCategoryImage: (url) => getCategoryImage(url, mediaDomain),
|
|
217
|
-
getLogoImage: (url) => getLogoImage(url, mediaDomain),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
@@ -9,29 +9,29 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Kept as a function rather than an inline fallback so it is one rule with one
|
|
11
11
|
* test, and so a cart, a checkout summary and a product card cannot disagree
|
|
12
|
-
* about which photo a product "is". Pair it with `
|
|
13
|
-
*
|
|
12
|
+
* about which photo a product "is". Pair it with `mediaImageUrl` to turn the
|
|
13
|
+
* answer into a resized URL.
|
|
14
14
|
*/
|
|
15
15
|
/** The least an uploaded image must carry to be usable as a source. */
|
|
16
16
|
export interface ProductImageSourceInput {
|
|
17
17
|
coverImage?: {
|
|
18
|
-
|
|
18
|
+
url?: string | null;
|
|
19
19
|
} | null;
|
|
20
20
|
images?: Array<{
|
|
21
|
-
|
|
21
|
+
url?: string | null;
|
|
22
22
|
}> | null;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* The product's representative image URL, or `null` when it has none.
|
|
26
26
|
*
|
|
27
27
|
* Prefers the merchant's chosen cover, falls back to the first usable gallery
|
|
28
|
-
* image, and treats a blank `
|
|
28
|
+
* image, and treats a blank `url` as no image at all — so a caller renders its
|
|
29
29
|
* placeholder exactly when the product genuinely has no photograph.
|
|
30
30
|
*
|
|
31
31
|
* @example
|
|
32
32
|
* ```ts
|
|
33
33
|
* const src = productImageSource(product); // Product | ProductList
|
|
34
|
-
* const thumbnail = src ?
|
|
34
|
+
* const thumbnail = src ? mediaImageUrl(src, { width: 160, height: 160, fit: "cover" }, config.media) : null;
|
|
35
35
|
* ```
|
|
36
36
|
*/
|
|
37
37
|
export declare function productImageSource(product: ProductImageSourceInput | null | undefined): string | null;
|
|
@@ -9,20 +9,20 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Kept as a function rather than an inline fallback so it is one rule with one
|
|
11
11
|
* test, and so a cart, a checkout summary and a product card cannot disagree
|
|
12
|
-
* about which photo a product "is". Pair it with `
|
|
13
|
-
*
|
|
12
|
+
* about which photo a product "is". Pair it with `mediaImageUrl` to turn the
|
|
13
|
+
* answer into a resized URL.
|
|
14
14
|
*/
|
|
15
15
|
/**
|
|
16
16
|
* The product's representative image URL, or `null` when it has none.
|
|
17
17
|
*
|
|
18
18
|
* Prefers the merchant's chosen cover, falls back to the first usable gallery
|
|
19
|
-
* image, and treats a blank `
|
|
19
|
+
* image, and treats a blank `url` as no image at all — so a caller renders its
|
|
20
20
|
* placeholder exactly when the product genuinely has no photograph.
|
|
21
21
|
*
|
|
22
22
|
* @example
|
|
23
23
|
* ```ts
|
|
24
24
|
* const src = productImageSource(product); // Product | ProductList
|
|
25
|
-
* const thumbnail = src ?
|
|
25
|
+
* const thumbnail = src ? mediaImageUrl(src, { width: 160, height: 160, fit: "cover" }, config.media) : null;
|
|
26
26
|
* ```
|
|
27
27
|
*/
|
|
28
28
|
export function productImageSource(product) {
|
|
@@ -30,9 +30,9 @@ export function productImageSource(product) {
|
|
|
30
30
|
return null;
|
|
31
31
|
const candidates = [product.coverImage, ...(product.images ?? [])];
|
|
32
32
|
for (const candidate of candidates) {
|
|
33
|
-
const
|
|
34
|
-
if (typeof
|
|
35
|
-
return
|
|
33
|
+
const url = candidate?.url;
|
|
34
|
+
if (typeof url === "string" && url.trim().length > 0)
|
|
35
|
+
return url;
|
|
36
36
|
}
|
|
37
37
|
return null;
|
|
38
38
|
}
|
package/package.json
CHANGED