@decocms/apps 0.21.2 → 0.21.3

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 ADDED
@@ -0,0 +1,75 @@
1
+ # @decocms/apps
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@decocms/apps.svg)](https://www.npmjs.com/package/@decocms/apps)
4
+ [![license](https://img.shields.io/npm/l/@decocms/apps.svg)](https://github.com/decocms/apps-start/blob/main/LICENSE)
5
+
6
+ Commerce integrations for [Deco](https://deco.cx) storefronts on **TanStack Start + React 19 + Cloudflare Workers**.
7
+
8
+ Provides VTEX and Shopify loaders, actions, hooks, and shared commerce types based on schema.org. Built on top of [`@decocms/start`](https://www.npmjs.com/package/@decocms/start).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @decocms/apps
14
+ ```
15
+
16
+ ## Integrations
17
+
18
+ ### VTEX
19
+
20
+ Full VTEX Intelligent Search and Checkout integration.
21
+
22
+ | Import | Purpose |
23
+ |--------|---------|
24
+ | `@decocms/apps/vtex` | Configuration and setup |
25
+ | `@decocms/apps/vtex/client` | VTEX API client with SWR caching |
26
+ | `@decocms/apps/vtex/loaders/*` | Product, cart, search, catalog, session, wishlist |
27
+ | `@decocms/apps/vtex/actions/*` | Checkout, auth, newsletter, profile, wishlist |
28
+ | `@decocms/apps/vtex/hooks` | useCart, useUser, useWishlist, useAutocomplete |
29
+ | `@decocms/apps/vtex/inline-loaders/*` | PDP, PLP, product list, suggestions |
30
+ | `@decocms/apps/vtex/middleware` | Cookie propagation and session handling |
31
+ | `@decocms/apps/vtex/invoke` | Server function wrappers |
32
+ | `@decocms/apps/vtex/utils/*` | Transform, enrichment, segment, cookies |
33
+
34
+ ### Shopify
35
+
36
+ Storefront API integration via GraphQL.
37
+
38
+ | Import | Purpose |
39
+ |--------|---------|
40
+ | `@decocms/apps/shopify` | Configuration and setup |
41
+ | `@decocms/apps/shopify/client` | Storefront GraphQL client |
42
+ | `@decocms/apps/shopify/loaders/*` | PDP, PLP, product list, cart, user |
43
+ | `@decocms/apps/shopify/actions/cart/*` | Add, update items, coupons |
44
+ | `@decocms/apps/shopify/actions/user/*` | Sign in, sign up |
45
+ | `@decocms/apps/shopify/utils/*` | Transform, cookies, GraphQL queries |
46
+
47
+ ### Shared Commerce
48
+
49
+ Platform-agnostic types and utilities.
50
+
51
+ | Import | Purpose |
52
+ |--------|---------|
53
+ | `@decocms/apps/commerce/types` | schema.org Product, Offer, BreadcrumbList, etc. |
54
+ | `@decocms/apps/commerce/components/Image` | Optimized commerce image component |
55
+ | `@decocms/apps/commerce/components/JsonLd` | Structured data for SEO |
56
+ | `@decocms/apps/commerce/sdk/*` | useOffer, formatPrice, analytics, URL utils |
57
+ | `@decocms/apps/commerce/utils/*` | productToAnalyticsItem, canonical, stateByZip |
58
+
59
+ ## Peer Dependencies
60
+
61
+ - `@decocms/start` >= 0.19.0
62
+ - `@tanstack/react-query` >= 5
63
+ - `react` >= 18
64
+ - `react-dom` >= 18
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ npm run typecheck # tsc --noEmit
70
+ npm run check # typecheck + unused export detection
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
@@ -1,144 +1,201 @@
1
+ import type { ImgHTMLAttributes } from "react";
2
+
3
+ // -------------------------------------------------------------------------
4
+ // Known asset prefixes that get stripped to produce a relative src path
5
+ // -------------------------------------------------------------------------
6
+
7
+ const DECO_CACHE_URL = "https://assets.decocache.com/";
8
+ const S3_URL = "https://deco-sites-assets.s3.sa-east-1.amazonaws.com/";
9
+
10
+ // -------------------------------------------------------------------------
11
+ // Configurable CDN domain
12
+ // -------------------------------------------------------------------------
13
+
14
+ let imageCdnDomain = "decoims.com";
15
+
1
16
  /**
2
- * Optimized Image component with CDN-aware transforms.
17
+ * Register the image CDN domain used by `getOptimizedMediaUrl`.
18
+ * Call once in your site's setup.ts before any page loads.
3
19
  *
4
- * Generates responsive srcset, enforces width/height for CLS prevention,
5
- * and builds optimized URLs for different image CDNs (VTEX, Shopify,
6
- * Deco, Cloudflare).
7
- *
8
- * @example
9
- * ```tsx
10
- * <Image
11
- * src="https://store.vteximg.com.br/products/123.jpg"
12
- * width={400}
13
- * height={400}
14
- * alt="Product name"
15
- * cdn="vtex"
16
- * />
17
- * ```
20
+ * Available domains:
21
+ * - `decoims.com` (Cloudflare, default best compression, same edge as Workers)
22
+ * - `deco-assets.edgedeco.com` (Azion IMS)
23
+ * - `deco-assets.decoazn.com` (Azion IMS, legacy)
18
24
  */
25
+ export function registerImageCdnDomain(domain: string) {
26
+ imageCdnDomain = domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
27
+ }
19
28
 
20
- import type { ImgHTMLAttributes } from "react";
29
+ export function getImageCdnDomain(): string {
30
+ return imageCdnDomain;
31
+ }
21
32
 
22
- export type ImageCDN = "vtex" | "shopify" | "deco" | "cloudflare" | "none";
33
+ // -------------------------------------------------------------------------
34
+ // Fit options & optimization types
35
+ // -------------------------------------------------------------------------
23
36
 
24
- export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "width" | "height"> {
25
- src: string;
37
+ export type FitOptions = "contain" | "cover";
38
+
39
+ export const FACTORS = [1, 2];
40
+
41
+ interface OptimizationOptions {
42
+ originalSrc: string;
26
43
  width: number;
27
- height: number;
28
- /** Image CDN to use for URL transforms. @default "none" */
29
- cdn?: ImageCDN;
30
- /**
31
- * Responsive sizes descriptor.
32
- * @default "(max-width: 768px) 100vw, 50vw"
33
- */
34
- sizes?: string;
35
- /**
36
- * Multipliers for srcset generation.
37
- * @default [1, 2]
38
- */
39
- srcSetMultipliers?: number[];
40
- /** Preload the image (adds fetchPriority="high"). */
41
- preload?: boolean;
44
+ height?: number;
45
+ fit: FitOptions;
42
46
  }
43
47
 
44
48
  // -------------------------------------------------------------------------
45
- // CDN URL builders
49
+ // Platform-specific URL optimizers (fallbacks when the CDN can handle
50
+ // the native platform's resize syntax directly)
46
51
  // -------------------------------------------------------------------------
47
52
 
48
- function vtexImageUrl(src: string, width: number, height: number): string {
49
- if (src.includes("vteximg.com.br") || src.includes("vtexassets.com")) {
50
- return src.replace(
51
- /(-\d+-\d+)(\.\w+)$/,
52
- `-${width}-${height}$2`,
53
- );
54
- }
53
+ function optimizeVTEX(originalSrc: string, width: number, height?: number): string {
54
+ const src = new URL(originalSrc);
55
+ const [slash, arquivos, ids, rawId, ...rest] = src.pathname.split("/");
56
+ const [trueId] = rawId.split("-");
55
57
 
56
- const url = new URL(src, "https://placeholder.com");
57
- url.searchParams.set("width", String(width));
58
- url.searchParams.set("height", String(height));
59
- return url.toString();
58
+ src.pathname = [
59
+ slash,
60
+ arquivos,
61
+ ids,
62
+ `${trueId}-${width}-${height ?? width}`,
63
+ ...rest,
64
+ ].join("/");
65
+
66
+ return src.href;
60
67
  }
61
68
 
62
- function shopifyImageUrl(src: string, width: number): string {
63
- if (src.includes("cdn.shopify.com")) {
64
- return src.replace(/(\.\w+)(\?.*)?$/, `_${width}x$1$2`);
65
- }
66
- return src;
69
+ function optimizeShopify(originalSrc: string, width: number, height?: number): string {
70
+ const url = new URL(originalSrc);
71
+ url.searchParams.set("width", `${width}`);
72
+ if (height) url.searchParams.set("height", `${height}`);
73
+ url.searchParams.set("crop", "center");
74
+ return url.href;
67
75
  }
68
76
 
69
- function decoImageUrl(src: string, width: number, height: number): string {
70
- if (src.includes("decocache.com") || src.includes("ozksgdmyrqcxcwhnbepg")) {
71
- const url = new URL(src);
72
- url.searchParams.set("width", String(width));
73
- url.searchParams.set("height", String(height));
74
- url.searchParams.set("fit", "cover");
75
- return url.toString();
77
+ // -------------------------------------------------------------------------
78
+ // Core optimization function
79
+ // Ported from deco-cx/apps website/components/Image.tsx
80
+ // -------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Builds an optimized image URL.
84
+ *
85
+ * For Deco-hosted images (decocache / S3), strips the known prefix and
86
+ * routes through the Deco image CDN for edge resize + format conversion.
87
+ *
88
+ * For platform-specific images (VTEX, Shopify), rewrites the URL using
89
+ * the platform's native resize params — no CDN proxy needed.
90
+ *
91
+ * Data URIs are returned as-is.
92
+ */
93
+ export function getOptimizedMediaUrl(opts: OptimizationOptions): string {
94
+ const { originalSrc, width, height, fit } = opts;
95
+
96
+ if (originalSrc.startsWith("data:")) {
97
+ return originalSrc;
76
98
  }
77
- return src;
78
- }
79
99
 
80
- function cloudflareImageUrl(src: string, width: number, height: number): string {
81
- return `/cdn-cgi/image/width=${width},height=${height},fit=cover,format=auto,quality=80/${src}`;
82
- }
100
+ if (
101
+ /(vteximg\.com\.br|vtexassets\.com|myvtex\.com)\/arquivos\/ids\/\d+/.test(originalSrc)
102
+ ) {
103
+ return optimizeVTEX(originalSrc, width, height);
104
+ }
83
105
 
84
- function buildUrl(src: string, width: number, height: number, cdn: ImageCDN): string {
85
- switch (cdn) {
86
- case "vtex": return vtexImageUrl(src, width, height);
87
- case "shopify": return shopifyImageUrl(src, width);
88
- case "deco": return decoImageUrl(src, width, height);
89
- case "cloudflare": return cloudflareImageUrl(src, width, height);
90
- default: return src;
106
+ if (originalSrc.startsWith("https://cdn.shopify.com")) {
107
+ return optimizeShopify(originalSrc, width, height);
91
108
  }
109
+
110
+ const imageSource = originalSrc
111
+ .replace(DECO_CACHE_URL, "")
112
+ .replace(S3_URL, "")
113
+ .split("?")[0];
114
+
115
+ const params = new URLSearchParams();
116
+ params.set("fit", fit);
117
+ params.set("width", `${width}`);
118
+ if (height) params.set("height", `${height}`);
119
+
120
+ return `https://${imageCdnDomain}/image?${params}&src=${imageSource}`;
92
121
  }
93
122
 
94
- function buildSrcSet(
95
- src: string,
123
+ /**
124
+ * Generates a srcset string with responsive multipliers.
125
+ */
126
+ export function getSrcSet(
127
+ originalSrc: string,
96
128
  width: number,
97
- height: number,
98
- cdn: ImageCDN,
99
- multipliers: number[],
100
- ): string {
101
- return multipliers
102
- .map((m) => {
103
- const w = Math.round(width * m);
104
- const h = Math.round(height * m);
105
- return `${buildUrl(src, w, h, cdn)} ${w}w`;
106
- })
107
- .join(", ");
129
+ height?: number,
130
+ fit?: FitOptions,
131
+ factors: number[] = FACTORS,
132
+ ): string | undefined {
133
+ const entries: string[] = [];
134
+
135
+ for (const factor of factors) {
136
+ const w = Math.trunc(factor * width);
137
+ const h = height ? Math.trunc(factor * height) : undefined;
138
+
139
+ const src = getOptimizedMediaUrl({
140
+ originalSrc,
141
+ width: w,
142
+ height: h,
143
+ fit: fit ?? "cover",
144
+ });
145
+
146
+ if (src) {
147
+ entries.push(`${src} ${w}w`);
148
+ }
149
+ }
150
+
151
+ return entries.length > 0 ? entries.join(", ") : undefined;
108
152
  }
109
153
 
110
154
  // -------------------------------------------------------------------------
111
- // Component
155
+ // Image component
112
156
  // -------------------------------------------------------------------------
113
157
 
158
+ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "width" | "height"> {
159
+ src: string;
160
+ /** @description Improves Web Vitals (CLS/LCP) */
161
+ width: number;
162
+ /** @description Improves Web Vitals (CLS/LCP) */
163
+ height?: number;
164
+ /** @description Object-fit */
165
+ fit?: FitOptions;
166
+ /** @description Preload the image (adds fetchPriority="high") */
167
+ preload?: boolean;
168
+ }
169
+
114
170
  export function Image({
115
171
  src,
116
172
  width,
117
173
  height,
118
- cdn = "none",
119
- sizes = "(max-width: 768px) 100vw, 50vw",
120
- srcSetMultipliers = [1, 2],
174
+ fit = "cover",
121
175
  preload,
122
176
  loading,
123
177
  decoding,
178
+ srcSet: srcSetProp,
179
+ sizes,
124
180
  ...rest
125
181
  }: ImageProps) {
126
- const optimizedSrc = buildUrl(src, width, height, cdn);
127
- const srcSet = cdn !== "none"
128
- ? buildSrcSet(src, width, height, cdn, srcSetMultipliers)
129
- : undefined;
182
+ if (!height && typeof process !== "undefined") {
183
+ console.warn(`Missing height. This image will NOT be optimized: ${src}`);
184
+ }
185
+
186
+ const srcSet = srcSetProp ?? getSrcSet(src, width, height, fit);
130
187
 
131
188
  return (
132
189
  <img
133
- src={optimizedSrc}
190
+ {...rest}
191
+ src={src}
134
192
  srcSet={srcSet}
135
- sizes={srcSet ? sizes : undefined}
193
+ sizes={srcSet ? (sizes ?? "(max-width: 768px) 100vw, 50vw") : undefined}
136
194
  width={width}
137
195
  height={height}
138
196
  loading={loading ?? (preload ? "eager" : "lazy")}
139
197
  decoding={decoding ?? "async"}
140
198
  fetchPriority={preload ? "high" : undefined}
141
- {...rest}
142
199
  />
143
200
  );
144
201
  }
@@ -147,63 +204,51 @@ export function Image({
147
204
  // Picture (responsive art direction)
148
205
  // -------------------------------------------------------------------------
149
206
 
150
- export interface PictureSource {
207
+ export interface PictureSourceProps {
151
208
  src: string;
152
209
  width: number;
153
- height: number;
210
+ height?: number;
154
211
  media: string;
155
- cdn?: ImageCDN;
212
+ fit?: FitOptions;
156
213
  }
157
214
 
158
215
  export interface PictureProps extends Omit<ImageProps, "sizes"> {
159
- sources: PictureSource[];
216
+ sources: PictureSourceProps[];
160
217
  }
161
218
 
162
- /**
163
- * Picture component for responsive art direction.
164
- *
165
- * @example
166
- * ```tsx
167
- * <Picture
168
- * sources={[
169
- * { src: mobileSrc, width: 375, height: 200, media: "(max-width: 767px)", cdn: "vtex" },
170
- * { src: desktopSrc, width: 1200, height: 400, media: "(min-width: 768px)", cdn: "vtex" },
171
- * ]}
172
- * src={desktopSrc}
173
- * width={1200}
174
- * height={400}
175
- * alt="Banner"
176
- * />
177
- * ```
178
- */
179
219
  export function Picture({
180
220
  sources,
181
221
  src,
182
222
  width,
183
223
  height,
184
- cdn = "none",
224
+ fit = "cover",
185
225
  preload,
186
226
  ...rest
187
227
  }: PictureProps) {
188
228
  return (
189
229
  <picture>
190
- {sources.map((source, i) => (
191
- <source
192
- key={i}
193
- srcSet={buildUrl(source.src, source.width, source.height, source.cdn ?? cdn)}
194
- media={source.media}
195
- width={source.width}
196
- height={source.height}
197
- />
198
- ))}
230
+ {sources.map((source, i) => {
231
+ const srcSet = getSrcSet(source.src, source.width, source.height, source.fit ?? fit);
232
+ return (
233
+ <source
234
+ key={i}
235
+ srcSet={srcSet}
236
+ media={source.media}
237
+ width={source.width}
238
+ height={source.height}
239
+ />
240
+ );
241
+ })}
199
242
  <Image
200
243
  src={src}
201
244
  width={width}
202
245
  height={height}
203
- cdn={cdn}
246
+ fit={fit}
204
247
  preload={preload}
205
248
  {...rest}
206
249
  />
207
250
  </picture>
208
251
  );
209
252
  }
253
+
254
+ export default Image;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {