@adobe-commerce/elsie 1.3.1-alpha013 → 1.3.1-alpha014

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe-commerce/elsie",
3
- "version": "1.3.1-alpha013",
3
+ "version": "1.3.1-alpha014",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "description": "Domain Package SDK",
6
6
  "engines": {
@@ -27,10 +27,10 @@
27
27
  },
28
28
  "devDependencies": {
29
29
  "@adobe-commerce/event-bus": "~1.0.0",
30
- "@adobe-commerce/fetch-graphql": "1.1.0-beta1",
31
- "@adobe-commerce/recaptcha": "1.0.1-beta2",
30
+ "@adobe-commerce/fetch-graphql": "~1.1.0",
31
+ "@adobe-commerce/recaptcha": "~1.0.1",
32
32
  "@adobe-commerce/storefront-design": "~1.0.0",
33
- "@dropins/build-tools": "1.0.1-beta1",
33
+ "@dropins/build-tools": "~1.0.1",
34
34
  "preact": "~10.22.1",
35
35
  "vite-plugin-banner": "^0.8.0"
36
36
  },
@@ -55,8 +55,8 @@
55
55
  }
56
56
 
57
57
  /* Primary */
58
- button.dropin-button.dropin-button--primary,
59
- a.dropin-button.dropin-button--primary,
58
+ .dropin-button--primary,
59
+ a.dropin-button--primary,
60
60
  .dropin-iconButton--primary {
61
61
  border: none;
62
62
  background: var(--color-brand-500) 0 0% no-repeat padding-box;
@@ -72,8 +72,8 @@ a.dropin-button.dropin-button--primary,
72
72
  padding: var(--spacing-xsmall);
73
73
  }
74
74
 
75
- button.dropin-button.dropin-button--primary--disabled,
76
- a.dropin-button.dropin-button--primary--disabled,
75
+ .dropin-button--primary--disabled,
76
+ a.dropin-button--primary--disabled,
77
77
  .dropin-iconButton--primary--disabled {
78
78
  background: var(--color-neutral-300) 0 0% no-repeat padding-box;
79
79
  color: var(--color-neutral-500);
@@ -82,21 +82,21 @@ a.dropin-button.dropin-button--primary--disabled,
82
82
  user-select: none;
83
83
  }
84
84
 
85
- button.dropin-button.dropin-button--primary:hover,
86
- a.dropin-button.dropin-button--primary:hover,
85
+ .dropin-button--primary:hover,
86
+ a.dropin-button--primary:hover,
87
87
  .dropin-iconButton--primary:hover,
88
- button.dropin-button.dropin-button--primary:focus:hover,
88
+ .dropin-button--primary:focus:hover,
89
89
  .dropin-iconButton--primary:focus:hover {
90
90
  background-color: var(--color-button-hover);
91
91
  text-decoration: none;
92
92
  }
93
93
 
94
- button.dropin-button.dropin-button--primary:focus,
94
+ .dropin-button--primary:focus,
95
95
  .dropin-iconButton--primary:focus {
96
96
  background-color: var(--color-brand-500);
97
97
  }
98
98
 
99
- button.dropin-button.dropin-button--primary:hover:active,
99
+ .dropin-button--primary:hover:active,
100
100
  .dropin-iconButton--primary:hover:active {
101
101
  background-color: var(--color-button-active);
102
102
  }
@@ -14,8 +14,13 @@ import * as Icons from '@adobe-commerce/elsie/icons';
14
14
 
15
15
  /**
16
16
  * Use Icons as symbols or metaphors to communicate and enhance the user experience.
17
+ *
18
+ * The Icon component supports three source types:
19
+ * - Direct component imports
20
+ * - Icon names from the built-in icon set
21
+ * - SVGs from URLs (supports URLs that match the host domain)
17
22
  */
18
- const meta: Meta<IconProps> = {
23
+ const meta: Meta<StoryIconProps> = {
19
24
  title: 'Components/Icon',
20
25
  component: Icon,
21
26
  argTypes: {
@@ -25,6 +30,13 @@ const meta: Meta<IconProps> = {
25
30
  control: {
26
31
  type: 'select',
27
32
  },
33
+ description: 'Select a built-in icon',
34
+ },
35
+ url: {
36
+ control: {
37
+ type: 'text',
38
+ },
39
+ description: 'Or enter a URL to an external SVG (this takes priority over icon selection)',
28
40
  },
29
41
  size: {
30
42
  control: 'select',
@@ -33,6 +45,7 @@ const meta: Meta<IconProps> = {
33
45
  stroke: {
34
46
  control: 'select',
35
47
  options: ['1', '2', '3', '4'],
48
+ description: 'Stroke width. Works only for stroke-based icons.',
36
49
  },
37
50
  title: {
38
51
  control: 'text',
@@ -43,7 +56,11 @@ const meta: Meta<IconProps> = {
43
56
 
44
57
  export default meta;
45
58
 
46
- type Story = StoryObj<IconProps>;
59
+ type Story = StoryObj<StoryIconProps>;
60
+
61
+ interface StoryIconProps extends IconProps {
62
+ url?: string;
63
+ }
47
64
 
48
65
  /**
49
66
  * ```ts
@@ -52,23 +69,26 @@ type Story = StoryObj<IconProps>;
52
69
  */
53
70
 
54
71
  export const Primary: Story = {
72
+ render: ({ url, source, ...args }: StoryIconProps) => {
73
+ const iconSource = url || source;
74
+ return <Icon {...args} source={iconSource as any} />;
75
+ },
55
76
  args: {
56
77
  source: Cart,
57
78
  },
58
79
  };
59
80
 
60
81
  export const Lazy: Story = {
61
- argTypes: {
62
- source: {
63
- mapping: Object.keys(Icons),
64
- },
82
+ render: ({ url, source, ...args }: StoryIconProps) => {
83
+ const iconSource = url || source;
84
+ return <Icon {...args} source={iconSource as any} />;
65
85
  },
66
86
  args: {
67
87
  source: 'Cart',
68
88
  },
69
89
  };
70
90
 
71
- export const AllIcons: Story = {
91
+ export const AllBuiltInIcons: Story = {
72
92
  argTypes: {
73
93
  style: Object
74
94
  },
@@ -96,3 +116,72 @@ export const AllIcons: Story = {
96
116
  </div>
97
117
  ),
98
118
  };
119
+
120
+
121
+ export const UrlExamples: Story = {
122
+ render: ({ url, ...args }: StoryIconProps) => (
123
+ <div style={{
124
+ display: 'grid',
125
+ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
126
+ gap: '2rem',
127
+ padding: '1rem'
128
+ }}>
129
+ <div style={{
130
+ border: '1px solid #e1e5e9',
131
+ borderRadius: '8px',
132
+ padding: '1.5rem',
133
+ textAlign: 'center',
134
+ backgroundColor: '#f8f9fa'
135
+ }}>
136
+ <h3 style={{ margin: '0 0 1rem 0', color: '#2c3e50' }}>✅ Valid URL</h3>
137
+ <Icon
138
+ source={url || `${window.location.origin}/favicon.svg`}
139
+ size="12"
140
+ title="logo icon from common domain"
141
+ aria-label="Star icon loaded from external URL"
142
+ {...args}
143
+ />
144
+ <p style={{
145
+ fontSize: '12px',
146
+ color: '#495057',
147
+ margin: '0.5rem 0 0 0',
148
+ wordBreak: 'break-all'
149
+ }}>
150
+ {url ? `Displays icon from: ${url}` : 'Displays icon from SVG'}
151
+ </p>
152
+ </div>
153
+
154
+ <div style={{
155
+ border: '1px solid #f8d7da',
156
+ borderRadius: '8px',
157
+ padding: '1.5rem',
158
+ textAlign: 'center',
159
+ backgroundColor: '#f8d7da'
160
+ }}>
161
+ <h3 style={{ margin: '0 0 1rem 0', color: '#721c24' }}>❌ Invalid URL</h3>
162
+ <Icon
163
+ source="https://invalid-url.com/icon.svg"
164
+ size="32"
165
+ title="Failed to load icon"
166
+ aria-label="Icon that failed to load"
167
+ />
168
+ <p style={{
169
+ fontSize: '12px',
170
+ color: '#721c24',
171
+ margin: '0.5rem 0 0 0',
172
+ wordBreak: 'break-all'
173
+ }}>
174
+ Shows empty SVG
175
+ </p>
176
+ </div>
177
+
178
+ </div>
179
+ ),
180
+ parameters: {
181
+ docs: {
182
+ description: {
183
+ story: 'Examples of different URL formats supported by the Icon component.',
184
+ },
185
+ },
186
+ },
187
+ };
@@ -10,6 +10,7 @@
10
10
  import { FunctionComponent } from 'preact';
11
11
  import { classes } from '@adobe-commerce/elsie/lib/classes';
12
12
  import { lazy, Suspense, SVGProps } from 'preact/compat';
13
+ import { useState, useEffect } from 'preact/hooks';
13
14
 
14
15
  import '@adobe-commerce/elsie/components/Icon/Icon.css';
15
16
 
@@ -66,9 +67,10 @@ const lazyIcons = {
66
67
  };
67
68
 
68
69
  export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'size'> {
69
- source:
70
- | FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
71
- | IconType;
70
+ source?:
71
+ | FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
72
+ | IconType
73
+ | string;
72
74
  size?: '12' | '16' | '24' | '32' | '64' | '80';
73
75
  stroke?: '1' | '2' | '3' | '4';
74
76
  className?: string;
@@ -79,6 +81,115 @@ export type IconNode = FunctionComponent<
79
81
  SVGProps<SVGSVGElement> & { title?: string }
80
82
  >;
81
83
 
84
+ function isValidUrl(source: string): boolean { // check for URL from same domain
85
+ try {
86
+ if (source.startsWith('//')) {
87
+ const absoluteUrl = `${window.location.protocol}${source}`;
88
+ const url = new URL(absoluteUrl);
89
+ return url.hostname === window.location.hostname;
90
+ }
91
+ const url = new URL(source);
92
+
93
+ if (url.hostname !== window.location.hostname) {
94
+ console.error(`[Icon] External URL rejected for security: ${source} - Only same-domain URLs are allowed`);
95
+ return false;
96
+ }
97
+
98
+ return true;
99
+ } catch {
100
+ console.error(`[Icon] Invalid URL format: ${source}`);
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function UrlSvgLoader({
106
+ url,
107
+ ...props
108
+ }: SVGProps<SVGSVGElement> & { url: string }) {
109
+ const [svgContent, setSvgContent] = useState<string>('');
110
+ const [loading, setLoading] = useState(true);
111
+ const [error, setError] = useState(false);
112
+
113
+ useEffect(() => {
114
+ fetch(url)
115
+ .then(response => {
116
+ if (!response.ok) {
117
+ console.error(`[Icon] Failed to fetch SVG: ${response.status} ${response.statusText}`);
118
+ throw error;
119
+ }
120
+ return response.text();
121
+ })
122
+ .then(content => {
123
+ // Check if content is valid SVG
124
+ if (!content.trim().toLowerCase().startsWith('<?xml') &&
125
+ !content.trim().toLowerCase().startsWith('<svg')) {
126
+ console.error(`[Icon] Invalid SVG content from ${url} - Content must be a valid SVG file`);
127
+ setError(true);
128
+ setLoading(false);
129
+ return;
130
+ }
131
+
132
+ // Process SVG content to ensure proper sizing and accessibility
133
+ let processedContent = content;
134
+
135
+ if (props.width) {
136
+ processedContent = processedContent.replace(
137
+ /<svg([^>]*)\s+width\s*=\s*["'][^"']*["']/gi,
138
+ '<svg$1'
139
+ );
140
+ processedContent = processedContent.replace(
141
+ /<svg/i,
142
+ `<svg width="${props.width}"`
143
+ );
144
+ }
145
+
146
+ if (props.height) {
147
+ processedContent = processedContent.replace(
148
+ /<svg([^>]*)\s+height\s*=\s*["'][^"']*["']/gi,
149
+ '<svg$1'
150
+ );
151
+ processedContent = processedContent.replace(
152
+ /<svg/i,
153
+ `<svg height="${props.height}"`
154
+ );
155
+ }
156
+
157
+ if (props.title) {
158
+ processedContent = processedContent.replace(/<title[^>]*>.*?<\/title>/gi, '');
159
+ processedContent = processedContent.replace(
160
+ /<svg([^>]*)>/i,
161
+ `<svg$1><title>${props.title}</title>`
162
+ );
163
+ }
164
+
165
+ setSvgContent(processedContent);
166
+ setLoading(false);
167
+ })
168
+ .catch((error) => {
169
+ console.error(`[Icon] ${error.message}`);
170
+ setError(true);
171
+ setLoading(false);
172
+ });
173
+ }, [url, props.width, props.height, props.title]);
174
+
175
+ if (loading || error) {
176
+ return <svg {...props} />;
177
+ }
178
+
179
+ return (
180
+ <div
181
+ className={props.className}
182
+ style={{
183
+ width: String(props.width),
184
+ height: String(props.height),
185
+ display: 'inline-block',
186
+ lineHeight: 0,
187
+ }}
188
+ dangerouslySetInnerHTML={{ __html: svgContent }}
189
+ />
190
+ );
191
+ }
192
+
82
193
  export function Icon({
83
194
  source: Source,
84
195
  size = '24',
@@ -87,7 +198,6 @@ export function Icon({
87
198
  className,
88
199
  ...props
89
200
  }: IconProps) {
90
- const LazyIcon = typeof Source === 'string' ? lazyIcons[Source] : null;
91
201
 
92
202
  const defaultProps = {
93
203
  className: classes([
@@ -100,10 +210,27 @@ export function Icon({
100
210
  viewBox,
101
211
  };
102
212
 
213
+ if (typeof Source === 'string' && isValidUrl(Source)) {
214
+ return (
215
+ <Suspense fallback={<svg {...props} {...defaultProps} />}>
216
+ <UrlSvgLoader url={Source} {...props} {...defaultProps}/>
217
+ </Suspense>
218
+ );
219
+ }
220
+
221
+ const LazyIcon = typeof Source === 'string' && Source in lazyIcons
222
+ ? lazyIcons[Source as IconType]
223
+ : null;
224
+
225
+ const isRejectedUrl = typeof Source === 'string' &&
226
+ (Source.startsWith('http') || Source.startsWith('//') || Source.startsWith('/'));
227
+
103
228
  return (
104
229
  <Suspense fallback={<svg {...props} {...defaultProps} />}>
105
230
  {LazyIcon ? (
106
231
  <LazyIcon {...props} {...defaultProps} />
232
+ ) : isRejectedUrl ? (
233
+ <svg {...props} {...defaultProps} />
107
234
  ) : (
108
235
  // @ts-ignore
109
236
  <Source {...props} {...defaultProps} />
@@ -12,7 +12,6 @@ import { Modal as component, ModalProps } from './Modal';
12
12
  import { useState } from 'preact/hooks';
13
13
  import { Button } from '../Button';
14
14
  import { expect, userEvent, within, waitFor } from '@storybook/test';
15
- import { useText } from '@adobe-commerce/elsie/i18n';
16
15
 
17
16
  const meta: Meta<ModalProps> = {
18
17
  title: 'Components/Modal',
@@ -270,65 +269,4 @@ export const OverflowingTitle: Story = {
270
269
  },
271
270
  };
272
271
 
273
- const LocalizedContent = () => {
274
- const translations = useText({
275
- label: 'Dropin.ExampleComponentName.item.label',
276
- });
277
-
278
- console.log(translations.label);
279
- return <div>{translations.label}</div>;
280
- };
281
-
282
- /**
283
- * ```ts
284
- * import { Modal } from '@/elsie/components/Modal';
285
- * import { useText } from '@adobe-commerce/elsie/i18n';
286
- *
287
- * const label = useText(`Dropin.ExampleComponentName.item.label`).label;
288
- *
289
- * <Modal size="medium" title={<h3>Localized Modal</h3>}>
290
- * <div>{label}</div>
291
- * </Modal>
292
- * ```
293
- */
294
-
295
- export const LocalizedModal: Story = {
296
- args: {
297
- size: 'medium',
298
- children: <LocalizedContent />,
299
- title: <h3>Localized Modal</h3>,
300
- },
301
- play: async ({ canvasElement }) => {
302
- const canvas = within(canvasElement);
303
- await userEvent.click(canvas.getByRole('button'));
304
-
305
- const portalRoot = await waitFor(() => {
306
- const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
307
- expect(root).toBeTruthy();
308
- return root;
309
- });
310
-
311
- await expect(portalRoot).toBeVisible();
312
-
313
- const modal = document.querySelector(
314
- '.dropin-modal__body'
315
- ) as HTMLDivElement;
316
-
317
- await expect(modal).toBeVisible();
318
-
319
- expect(portalRoot.querySelector('h3')?.innerText).toBe('Localized Modal');
320
- expect((portalRoot.querySelector('.dropin-modal__body') as HTMLElement)?.innerText).toContain('string');
321
-
322
- const closeButton = document.querySelector(
323
- '.dropin-modal__header-close-button'
324
- ) as HTMLButtonElement;
325
-
326
- await userEvent.click(closeButton);
327
-
328
- await expect(modal).not.toBeVisible();
329
-
330
- await expect(canvas.getByText('Open Modal')).toBeVisible();
331
- },
332
- };
333
-
334
272
  export default meta;
@@ -7,8 +7,8 @@
7
7
  * accompanying it.
8
8
  *******************************************************************/
9
9
 
10
- import { ComponentChildren } from 'preact';
11
- import { FunctionComponent, useLayoutEffect, useRef } from 'preact/compat';
10
+ import { ComponentChildren, render } from 'preact';
11
+ import { FunctionComponent, useEffect, useRef } from 'preact/compat';
12
12
 
13
13
  interface PortalProps {
14
14
  children: ComponentChildren;
@@ -16,34 +16,28 @@ interface PortalProps {
16
16
 
17
17
  export const Portal: FunctionComponent<PortalProps> = ({ children }) => {
18
18
  const portalRoot = useRef<HTMLDivElement | null>(null);
19
- const contentRef = useRef<HTMLDivElement | null>(null);
20
19
 
21
- useLayoutEffect(() => {
20
+ useEffect(() => {
22
21
  // Create portal root if it doesn't exist
23
22
  if (!portalRoot.current) {
24
23
  portalRoot.current = document.createElement('div');
25
24
  portalRoot.current.setAttribute('data-portal-root', '');
25
+ portalRoot.current.classList.add('dropin-design');
26
26
  document.body.appendChild(portalRoot.current);
27
27
  }
28
28
 
29
- // Move content to portal root
30
- if (contentRef.current && portalRoot.current) {
31
- portalRoot.current.appendChild(contentRef.current);
32
- }
29
+ // Render children into portal root
30
+ render(children, portalRoot.current);
33
31
 
34
32
  // Cleanup
35
33
  return () => {
36
34
  if (portalRoot.current) {
35
+ render(null, portalRoot.current);
37
36
  portalRoot.current.remove();
38
37
  portalRoot.current = null;
39
38
  }
40
39
  };
41
- }, []);
40
+ }, [children]);
42
41
 
43
- // Return a div that contains the children
44
- return (
45
- <div ref={contentRef} className="dropin-design">
46
- {children}
47
- </div>
48
- );
42
+ return null;
49
43
  };
@@ -1,104 +1,58 @@
1
- import {
2
- provider as UI,
3
- Image,
4
- type ImageProps,
5
- } from '@adobe-commerce/elsie/components';
6
-
1
+ import { provider as UI, Image } from '@adobe-commerce/elsie/components';
7
2
  import { getConfigValue } from '@adobe-commerce/elsie/lib/aem/configs';
8
- import type { ResolveImageUrlOptions } from '../resolve-image';
9
-
10
- const AEM_ASSETS_FORMATS = ['gif', 'jpg', 'jpeg', 'png', 'webp'] as const;
11
- const AEM_ASSETS_ALLOWED_ROTATIONS = [90, 180, 270] as const;
12
- const AEM_ASSETS_ALLOWED_FLIPS = ['h', 'v', 'hv'] as const;
13
-
14
- /** The allowed formats for the `AEM Assets` image optimization API. */
15
- export type AemAssetsFormat = (typeof AEM_ASSETS_FORMATS)[number];
16
3
 
17
- /** The allowed rotations for the `AEM Assets` image optimization API. */
18
- export type AemAssetsRotation = (typeof AEM_ASSETS_ALLOWED_ROTATIONS)[number];
19
-
20
- /** The allowed flips for the `AEM Assets` image optimization API. */
21
- export type AemAssetsFlip = (typeof AEM_ASSETS_ALLOWED_FLIPS)[number];
22
-
23
- /**
24
- * Defines a crop region of an image.
25
- * @example
26
- * ```ts
27
- * // Crop the image to a 80% width and height, starting at 10% from the top and left.
28
- * const cropSettings: AemAssetsCropSettings = {
29
- * xOrigin: 10,
30
- * yOrigin: 10,
31
- * width: 80,
32
- * height: 80,
33
- * };
34
- */
35
- export interface AemAssetsCropSettings {
36
- /** The (relative) x origin of the crop (between 0 and 100) */
37
- xOrigin?: number;
38
-
39
- /** The (relative) y origin of the crop (between 0 and 100) */
40
- yOrigin?: number;
41
-
42
- /** The width of the crop (between 0 and 100) */
4
+ interface AemAssetsParams {
5
+ quality?: number;
6
+ format?: string;
7
+ crop?: {
8
+ xOrigin?: number;
9
+ yOrigin?: number;
10
+ width?: number;
11
+ height?: number;
12
+ };
13
+ size?: {
14
+ width?: number;
15
+ height?: number;
16
+ };
43
17
  width?: number;
44
-
45
- /** The height of the crop (between 0 and 100) */
46
18
  height?: number;
19
+ [key: string]: any;
47
20
  }
48
21
 
49
- /**
50
- * The parameters accepted by the `AEM Assets` image optimization API.
51
- * @see https://adobe-aem-assets-delivery-experimental.redoc.ly/
52
- */
53
- export interface AemAssetsParams {
54
- format: AemAssetsFormat;
55
- rotate?: AemAssetsRotation;
56
- flip?: AemAssetsFlip;
57
- crop?: AemAssetsCropSettings;
58
-
59
- width?: number;
60
- height?: number;
61
- quality?: number;
62
-
63
- attachment?: boolean;
64
- sharpen?: boolean;
65
- blur?: number;
66
- dpr?: number;
67
- smartCrop?: string;
68
-
69
- // For future updates we may miss.
70
- [key: string]: unknown;
71
- }
72
-
73
- type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
74
-
75
- /** The parameters to be applied to the asset (known width required when using a slot) */
76
- export type AemAssetsImageSlotConfigParams = WithRequired<
77
- Partial<AemAssetsParams>,
78
- 'width'
79
- >;
80
-
81
- /** The configuration for an image slot. */
82
- export interface AemAssetsImageSlotConfig {
83
- /** The alias (i.e. seoName) of the image */
22
+ interface AemAssetsImageConfig {
23
+ wrapper?: HTMLElement;
84
24
  alias: string;
85
-
86
- /** The props to be applied to the underlying {@link Image} component */
87
- imageProps: Partial<Omit<ImageProps, 'params' | 'width' | 'height'>> & {
25
+ params: AemAssetsParams;
26
+ imageProps: {
88
27
  src: string;
28
+ width?: number;
29
+ height?: number;
30
+ [key: string]: any;
89
31
  };
90
-
91
- /** The parameters to be applied to the asset (known width required when using a slot) */
92
- params: AemAssetsImageSlotConfigParams;
93
-
94
- /** The element that will contain the image in the slot */
95
- wrapper?: HTMLElement;
32
+ src?: string;
96
33
  }
97
34
 
98
35
  interface RenderContext {
99
36
  replaceWith: (element: HTMLElement) => void;
100
37
  }
101
38
 
39
+ export function isAemAssetsEnabled(): boolean {
40
+ const config = getConfigValue('commerce-assets-enabled');
41
+
42
+ return config && (
43
+ (typeof config === 'string' && config.toLowerCase() === 'true')
44
+ || (typeof config === 'boolean' && config === true)
45
+ );
46
+ }
47
+
48
+ export function getDefaultAemAssetsOptimizationParams(): { quality: number; format: string } {
49
+ // See: https://adobe-aem-assets-delivery-experimental.redoc.ly/
50
+ return {
51
+ quality: 80,
52
+ format: 'webp',
53
+ };
54
+ }
55
+
102
56
  /**
103
57
  * Normalizes the given URL to ensure it is a valid URL.
104
58
  * @param {string} url - The URL to normalize.
@@ -110,59 +64,13 @@ function normalizeUrl(url: string): string {
110
64
  if (imageUrl.startsWith('//')) {
111
65
  // Use current window's protocol.
112
66
  const { protocol } = window.location;
67
+ console.log('protocol', protocol);
113
68
  imageUrl = protocol + imageUrl;
114
69
  }
115
70
 
116
71
  return imageUrl;
117
72
  }
118
73
 
119
- /** Returns whether the given value is a valid flip. */
120
- function isValidFlip(flip: unknown): flip is AemAssetsFlip {
121
- return AEM_ASSETS_ALLOWED_FLIPS.includes(flip as AemAssetsFlip);
122
- }
123
-
124
- /** Returns whether the given value is a valid rotation. */
125
- function isValidRotation(rotation: unknown): rotation is AemAssetsRotation {
126
- return AEM_ASSETS_ALLOWED_ROTATIONS.includes(rotation as AemAssetsRotation);
127
- }
128
-
129
- /** Returns whether the given value is a valid format. */
130
- function isValidFormat(format: unknown): format is AemAssetsFormat {
131
- return AEM_ASSETS_FORMATS.includes(format as AemAssetsFormat);
132
- }
133
-
134
- /** Asserts that the given value is valid. */
135
- function assertUnionParameter(
136
- value: unknown,
137
- validator: (value: unknown) => boolean,
138
- errorMessage: string
139
- ): void {
140
- if (value !== undefined && !validator(value)) {
141
- throw new Error(errorMessage);
142
- }
143
- }
144
-
145
- /** Returns whether AEM Assets is enabled in the Storefront. */
146
- export function isAemAssetsEnabled(): boolean {
147
- const config = getConfigValue('commerce-assets-enabled');
148
-
149
- return (
150
- config &&
151
- ((typeof config === 'string' && config.toLowerCase() === 'true') ||
152
- (typeof config === 'boolean' && config === true))
153
- );
154
- }
155
-
156
- /** The default optimization parameters used globally, unless overriden (per use). */
157
- export function getDefaultAemAssetsOptimizationParams(): AemAssetsParams {
158
- // See: https://adobe-aem-assets-delivery-experimental.redoc.ly/
159
- return {
160
- quality: 80,
161
- format: 'webp',
162
- };
163
- }
164
-
165
- /** Returns true if the given URL is an AEM Assets URL. */
166
74
  export function isAemAssetsUrl(url: string | URL): boolean {
167
75
  const assetsUrl = typeof url === 'string' ? new URL(normalizeUrl(url)) : url;
168
76
 
@@ -173,30 +81,19 @@ export function isAemAssetsUrl(url: string | URL): boolean {
173
81
  return true;
174
82
  }
175
83
 
176
- /** Generates an optimized URL for AEM Assets. */
177
- export function generateAemAssetsOptimizedUrl(
178
- assetUrl: string,
179
- alias: string,
180
- params: Partial<AemAssetsParams> = {}
181
- ): string {
84
+ export function generateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
182
85
  const defaultParams = getDefaultAemAssetsOptimizationParams();
183
86
  const mergedParams: AemAssetsParams = { ...defaultParams, ...params };
184
87
 
185
- // Destructure the ones that need special handling/validation.
186
- const { format, crop, ...optimizedParams } = mergedParams;
187
- assertUnionParameter(format, isValidFormat, 'Invalid format');
188
- assertUnionParameter(optimizedParams.flip, isValidFlip, 'Invalid flip');
189
- assertUnionParameter(
190
- optimizedParams.rotate,
191
- isValidRotation,
192
- 'Invalid rotation'
193
- );
194
-
195
- const stringifiedParams = Object.fromEntries(
196
- Object.entries(optimizedParams).map(([key, value]) => [key, String(value)])
197
- );
88
+ // Destructure the ones that need special handling
89
+ const {
90
+ format,
91
+ crop,
92
+ size,
93
+ ...optimizedParams
94
+ } = mergedParams;
198
95
 
199
- const searchParams = new URLSearchParams(stringifiedParams);
96
+ const searchParams = new URLSearchParams(optimizedParams);
200
97
 
201
98
  if (crop) {
202
99
  const [xOrigin, yOrigin] = [crop.xOrigin || 0, crop.yOrigin || 0];
@@ -206,112 +103,87 @@ export function generateAemAssetsOptimizedUrl(
206
103
  searchParams.set('crop', cropTransform);
207
104
  }
208
105
 
209
- return `${assetUrl}/as/${alias}.${format}?${searchParams.toString()}`;
106
+ // Both values must be present
107
+ if (size && size.width && size.height) {
108
+ searchParams.set('size', `${size.width},${size.height}`);
109
+ }
110
+
111
+ return `${url}/as/${alias}.${format}?${searchParams.toString()}`;
210
112
  }
211
113
 
212
- /**
213
- * Tries to generate an optimized URL for AEM Assets. Returns the given
214
- * url if AEM Assets is not enabled or is not an AEM Assets URL.
215
- */
216
- export function tryGenerateAemAssetsOptimizedUrl(
217
- assetUrl: string,
218
- alias: string,
219
- params: Partial<AemAssetsParams> = {}
220
- ): string {
114
+ export function tryGenerateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
221
115
  const assetsEnabled = isAemAssetsEnabled();
222
116
 
223
- if (!assetsEnabled) {
117
+ if (!(assetsEnabled)) {
224
118
  // No-op, doesn't do anything.
225
- return assetUrl;
119
+ return url;
226
120
  }
227
121
 
228
- const assetsUrl = new URL(normalizeUrl(assetUrl));
122
+ const assetsUrl = new URL(normalizeUrl(url));
229
123
 
230
124
  if (!isAemAssetsUrl(assetsUrl)) {
231
125
  // Not an AEM Assets URL, so no-op.
232
- return assetUrl;
126
+ return url;
233
127
  }
234
128
 
235
129
  const base = assetsUrl.origin + assetsUrl.pathname;
236
130
  return generateAemAssetsOptimizedUrl(base, alias, params);
237
131
  }
238
132
 
239
- /** Creates a slot that renders an AEM Assets image. */
240
- export function makeAemAssetsImageSlot(config: AemAssetsImageSlotConfig) {
133
+ export function makeAemAssetsImageSlot(
134
+ config: AemAssetsImageConfig,
135
+ ) {
241
136
  return (ctx: RenderContext) => {
242
- const { wrapper, alias, params, imageProps } = config;
243
-
244
- if (!imageProps.src) {
245
- throw new Error(
246
- 'An image source is required. Please provide a `src` or `imageProps.src`.'
247
- );
248
- }
249
-
250
- const container = wrapper ?? document.createElement('div');
251
- const imageSrc = generateAemAssetsOptimizedUrl(
252
- imageProps.src,
137
+ const {
138
+ wrapper,
253
139
  alias,
254
- params
255
- );
256
-
257
- const imageComponentParams: ResolveImageUrlOptions = {
258
- width: params.width,
259
- height: params.height,
140
+ params,
141
+ imageProps,
142
+ src,
143
+ } = config;
260
144
 
261
- // If this is not done, they will be applied by default.
262
- // And they are not compatible with the AEM Assets API.
263
- crop: undefined,
264
- fit: undefined,
265
- auto: undefined,
266
- };
145
+ const container = wrapper ?? document.createElement('div');
146
+ const imageSrc = generateAemAssetsOptimizedUrl(src || imageProps.src, alias, params);
267
147
 
268
- const imageComponentProps: ImageProps = {
148
+ UI.render(Image as any, {
269
149
  ...imageProps,
270
- width: params.width,
271
- height: params.height,
272
150
 
273
151
  src: imageSrc,
274
- params: imageComponentParams,
275
- };
152
+ params: {
153
+ width: params.width,
154
+
155
+ // If not null, they will be applied by default.
156
+ // And they are not compatible with the AEM Assets API.
157
+ crop: null,
158
+ fit: null,
159
+ auto: null,
160
+ },
161
+ })(container);
276
162
 
277
- UI.render(Image, imageComponentProps)(container);
278
163
  ctx.replaceWith(container);
279
164
  };
280
165
  }
281
166
 
282
- export function tryRenderAemAssetsImage(
283
- ctx: RenderContext,
284
- config: AemAssetsImageSlotConfig
285
- ): void {
167
+ export function tryRenderAemAssetsImage(ctx: RenderContext, config: AemAssetsImageConfig): void {
286
168
  // Renders an equivalent of the default image.
287
169
  function renderDefaultImage(): void {
288
170
  const container = config.wrapper ?? document.createElement('div');
289
- const { imageProps, params } = config;
290
- const imageComponentProps: ImageProps = {
291
- ...imageProps,
292
- width: params.width,
293
- height: params.height,
294
- };
171
+ const { imageProps } = config;
295
172
 
296
- UI.render(Image, imageComponentProps)(container);
173
+ (UI.render as any)(Image, imageProps)(container);
297
174
  ctx.replaceWith(container);
298
175
  }
299
176
 
300
177
  const assetsEnabled = isAemAssetsEnabled();
301
178
 
302
- if (!assetsEnabled) {
179
+ if (!(assetsEnabled)) {
303
180
  // No-op, render the default image.
304
181
  renderDefaultImage();
305
182
  return;
306
183
  }
307
184
 
308
- if (!config.imageProps.src) {
309
- throw new Error(
310
- 'An image source is required. Please provide a `src` or `imageProps.src`.'
311
- );
312
- }
313
-
314
- const assetsUrl = new URL(normalizeUrl(config.imageProps.src));
185
+ const { imageProps, src, ...slotConfig } = config;
186
+ const assetsUrl = new URL(normalizeUrl(src ?? imageProps.src));
315
187
 
316
188
  if (!isAemAssetsUrl(assetsUrl)) {
317
189
  // Not an AEM Assets URL, so render the default image.
@@ -319,5 +191,17 @@ export function tryRenderAemAssetsImage(
319
191
  return;
320
192
  }
321
193
 
322
- makeAemAssetsImageSlot(config)(ctx);
194
+ makeAemAssetsImageSlot({
195
+ // Use the default image props for params and src.
196
+ // Unless overriden by the slot config.
197
+ src: assetsUrl.toString(),
198
+ params: {
199
+ width: imageProps.width,
200
+ height: imageProps.height,
201
+ ...slotConfig.params,
202
+ },
203
+ imageProps,
204
+ alias: slotConfig.alias,
205
+ wrapper: slotConfig.wrapper,
206
+ })(ctx);
323
207
  }