@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.
@@ -1,119 +1,99 @@
1
1
  /**
2
- * Cloudflare Image Resizing Utility
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
- * Generates optimized image URLs using Cloudflare's Image Resizing service.
5
- * Automatically resizes images to exact dimensions and converts to WebP format.
6
- *
7
- * Deep module: hides Cloudflare CDN-CGI URL construction, fit modes, format
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 function optimizeImage(originalUrl, options, mediaDomain) {
29
- const { width, height, quality = 85, format = "webp", fit = "cover" } = options;
30
- return mediaImageUrl(originalUrl, { width, height, format, quality, fit }, mediaDomain) ?? originalUrl;
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 Cloudflare Image Resizing URL of an image on the media domain, served
34
- * by the media domain itself (`https://<media>/cdn-cgi/image/<options>/<path>`)
35
- * — so it works wherever the storefront is hosted, as long as the media
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
- * `onerror=redirect` comes first: when a resize fails, Cloudflare redirects to
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 mediaImageUrl(originalUrl, params, mediaDomain) {
44
- if (!mediaDomain || !originalUrl)
45
- return null;
46
- const mediaOrigin = mediaOriginOf(mediaDomain);
47
- let original;
48
- try {
49
- original = new URL(originalUrl);
50
- }
51
- catch {
52
- return null;
53
- }
54
- if (mediaOrigin === null || original.origin !== mediaOrigin)
55
- return null;
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 key of ["width", "height", "format", "quality", "fit"]) {
58
- const value = params[key];
56
+ for (const name of ["width", "height", "format", "quality", "fit"]) {
57
+ const value = params[name];
59
58
  if (value !== undefined && value !== "")
60
- options.push(`${key}=${value}`);
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
- * The public URL of an object in the store's media bucket, from its key —
76
- * what a theme's `image` field stores (`products/3f2a9c.webp`). The key is
77
- * the image's identity; the URL depends on where the store serves its media,
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` when there is no media domain, or the key cannot name an
82
- * object: empty, too long, an empty, `.` or `..` segment (a leading slash
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 mediaUrl(key, mediaDomain) {
86
- if (!mediaDomain || typeof key !== "string" || key.length === 0 || key.length > 512)
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
- const origin = mediaOriginOf(mediaDomain);
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. Each size is converted to the
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 there is no media domain, the URL is not on it, or no
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, mediaDomain } = options;
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 }, mediaDomain);
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 on the store's media domain,
125
- * its alt text (empty when the merchant wrote none), its size, and its focal
126
- * point as `object-position`, so a cropped photo keeps what matters in view.
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 cannot name an object, or
129
- * the store has no media domain.
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, mediaDomain) {
111
+ export function pictureOf(image, delivery) {
132
112
  if (!image)
133
113
  return null;
134
- const src = mediaUrl(image.key, mediaDomain);
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 `getThumbnailImage` /
13
- * `getCardImage` to turn the answer into a resized URL.
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
- src?: string | null;
18
+ url?: string | null;
19
19
  } | null;
20
20
  images?: Array<{
21
- src?: string | null;
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 `src` as no image at all — so a caller renders its
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 ? getThumbnailImage(src) : null;
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 `getThumbnailImage` /
13
- * `getCardImage` to turn the answer into a resized URL.
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 `src` as no image at all — so a caller renders its
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 ? getThumbnailImage(src) : null;
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 src = candidate?.src;
34
- if (typeof src === "string" && src.trim().length > 0)
35
- return src;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.23.0",
3
+ "version": "2.0.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",