diff --git a/apps/nextjs/gql/fragment-masking.ts b/apps/nextjs/gql/fragment-masking.ts index af0fecab..0df1ecfb 100644 --- a/apps/nextjs/gql/fragment-masking.ts +++ b/apps/nextjs/gql/fragment-masking.ts @@ -1,4 +1,4 @@ -import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +import { TypedDocumentNode as DocumentNode, ResultOf } from '@graphql-typed-document-node/core'; export type FragmentType> = TDocumentType extends DocumentNode< @@ -38,3 +38,11 @@ export function useFragment( ): TType | ReadonlyArray | null | undefined { return fragmentType as any } + + +export function makeFragmentData< + F extends DocumentNode, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} \ No newline at end of file diff --git a/apps/nextjs/gql/gql.ts b/apps/nextjs/gql/gql.ts index 257616a8..72313fce 100644 --- a/apps/nextjs/gql/gql.ts +++ b/apps/nextjs/gql/gql.ts @@ -2,13 +2,39 @@ import * as types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel-plugin for production. + */ const documents = { "\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n": types.IndexQueryDocument, }; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ export function graphql(source: "\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = gql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. +**/ export function graphql(source: string): unknown; + export function graphql(source: string) { return (documents as any)[source] ?? {}; } diff --git a/packages/react/src/ExternalVideo.test.helpers.ts b/packages/react/src/ExternalVideo.test.helpers.ts index 1c353696..c2ba1f11 100644 --- a/packages/react/src/ExternalVideo.test.helpers.ts +++ b/packages/react/src/ExternalVideo.test.helpers.ts @@ -1,7 +1,7 @@ import {PartialDeep} from 'type-fest'; import type {ExternalVideo as ExternalVideoType} from './storefront-api-types.js'; import {faker} from '@faker-js/faker'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; export function getExternalVideoData( externalVideo: Partial = {} diff --git a/packages/react/src/Image.stories.tsx b/packages/react/src/Image.stories.tsx index 33648fcd..393d54a1 100644 --- a/packages/react/src/Image.stories.tsx +++ b/packages/react/src/Image.stories.tsx @@ -1,45 +1,77 @@ import * as React from 'react'; import type {Story} from '@ladle/react'; -import {Image, type ShopifyImageProps} from './Image.js'; -import {IMG_SRC_SET_SIZES} from './image-size.js'; +import {Image, ShopifyLoaderOptions, ShopifyLoaderParams} from './Image.js'; +import type {PartialDeep} from 'type-fest'; +import type {Image as ImageType} from './storefront-api-types.js'; + +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; + +type ImageConfig = { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +}; + +type HtmlImageProps = React.ImgHTMLAttributes; const Template: Story<{ - 'data.url': ShopifyImageProps['data']['url']; - 'data.width': ShopifyImageProps['data']['width']; - 'data.height': ShopifyImageProps['data']['height']; - width: ShopifyImageProps['width']; - height: ShopifyImageProps['height']; - widths: ShopifyImageProps['widths']; - loaderOptions: ShopifyImageProps['loaderOptions']; + as?: 'img' | 'source'; + data?: PartialDeep; + loader?: (params: ShopifyLoaderParams) => string; + src: string; + width?: string | number; + height?: string | number; + crop?: Crop; + sizes?: string; + aspectRatio?: string; + config?: ImageConfig; + alt?: string; + loading?: 'lazy' | 'eager'; + loaderOptions?: ShopifyLoaderOptions; + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; }> = (props) => { - const finalProps: ShopifyImageProps = { - data: { - url: props['data.url'], - width: props['data.width'], - height: props['data.height'], - id: 'testing', - }, - width: props.width, - height: props.height, - widths: props.widths, - loaderOptions: props.loaderOptions, - }; - return ; + return ( + <> + {/* Standard Usage */} + + {/* */} + + + + + + + + + + ); }; export const Default = Template.bind({}); Default.args = { - 'data.url': - 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', - 'data.width': 100, - 'data.height': 100, - width: 500, - height: 500, - widths: IMG_SRC_SET_SIZES, - loaderOptions: { - crop: 'center', - scale: 2, - width: 500, - height: 500, + data: { + url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', + altText: 'alt text', + width: 3908, + height: 3908, }, }; diff --git a/packages/react/src/Image.test.tsx b/packages/react/src/Image.test.tsx index c8d9ee12..5b80fb7f 100644 --- a/packages/react/src/Image.test.tsx +++ b/packages/react/src/Image.test.tsx @@ -1,337 +1,67 @@ -import {vi} from 'vitest'; import {render, screen} from '@testing-library/react'; import {Image} from './Image.js'; -import * as utilities from './image-size.js'; -import {getPreviewImage} from './Image.test.helpers.js'; -describe('', () => { - beforeAll(() => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); +const defaultProps = { + src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', +}; +describe('', () => { it('renders an `img` element', () => { - const previewImage = getPreviewImage(); - const {url: src, altText, id, width, height} = previewImage; - render(); + render(); const image = screen.getByRole('img'); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', src); - expect(image).toHaveAttribute('id', id); - expect(image).toHaveAttribute('alt', altText); - expect(image).toHaveAttribute('width', `${width}`); - expect(image).toHaveAttribute('height', `${height}`); expect(image).toHaveAttribute('loading', 'lazy'); }); it('renders an `img` element with provided `id`', () => { - const previewImage = getPreviewImage(); - const id = 'catImage'; - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('id', id); }); it('renders an `img` element with provided `loading` value', () => { - const previewImage = getPreviewImage(); - const loading = 'eager'; - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('loading', loading); }); it('renders an `img` with `width` and `height` values', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - const options = {scale: 2 as const}; - const mockDimensions = { - width: 200, - height: 100, - }; - - vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( - mockDimensions - ); - - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('width', `${mockDimensions.width}`); - expect(image).toHaveAttribute('height', `${mockDimensions.height}`); }); it('renders an `img` element without `width` and `height` attributes when invalid dimensions are provided', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - const options = {scale: 2 as const}; - const mockDimensions = { - width: null, - height: null, - }; - - vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( - mockDimensions - ); - - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).not.toHaveAttribute('width'); - expect(image).not.toHaveAttribute('height'); }); describe('Loaders', () => { it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - - const transformedSrc = 'https://cdn.shopify.com/someimage_100x200@2x.jpg'; - - const options = {width: 100, height: 200, scale: 2 as const}; - - const shopifyImageLoaderSpy = vi - .spyOn(utilities, 'shopifyImageLoader') - .mockReturnValue(transformedSrc); - - render(); - - expect(shopifyImageLoaderSpy).toHaveBeenCalledWith({ - src: previewImage.url, - ...options, - }); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', transformedSrc); }); }); it('allows passthrough props', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - - render( - Fancy image - ); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveClass('fancyImage'); - expect(image).toHaveAttribute('id', '123'); - expect(image).toHaveAttribute('alt', 'Fancy image'); }); it('generates a default srcset', () => { - const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; - const sizes = [352, 832, 1200, 1920, 2560]; - const expectedSrcset = sizes - .map((size) => `${mockUrl}?width=${size} ${size}w`) - .join(', '); - const previewImage = getPreviewImage({ - url: mockUrl, - width: 2560, - height: 2560, - }); - - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('srcSet', expectedSrcset); }); it('generates a default srcset up to the image height and width', () => { - const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; - const sizes = [352, 832]; - const expectedSrcset = sizes - .map((size) => `${mockUrl}?width=${size} ${size}w`) - .join(', '); - const previewImage = getPreviewImage({ - url: mockUrl, - width: 832, - height: 832, - }); - - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('srcSet', expectedSrcset); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is missing, does not include height in srcset`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 500, - }); - - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is not applied if there is no crop - // width is not doulbe of the passed width, but instead double of the value in 'sizes_array' / '[number]w' - `${previewImage.url}?width=704 352w` - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '500'); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 500, - }); - - render( - - ); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is the aspect ratio (of width + height) * srcSet width, so in this case it should be half of width - `${previewImage.url}?width=704&height=352&crop=bottom 352w` - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '250'); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset using data.width / data.height for the aspect ratio`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 500, - }); - - render( - - ); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be the same as width - `${previewImage.url}?width=704&height=704&crop=bottom 352w` - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '500'); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is there, calculates height based on aspect ratio in srcset`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 1000, - }); - - render( - - ); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be double the width - `${previewImage.url}?width=704&height=1408&crop=bottom 352w` - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '1000'); - }); - - it(`should pass through width (as an inline prop) when it's a string, and use the first size in the size array for the URL width`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 100, - height: 100, - }); - - render(); - const image = screen.getByRole('img'); - - console.log(image.getAttribute('srcSet')); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); - expect(image).toHaveAttribute('width', '100%'); - expect(image).not.toHaveAttribute('height'); - }); - - it(`should pass through width (as part of loaderOptions) when it's a string, and use the first size in the size array for the URL width`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 100, - height: 100, - }); - - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); - expect(image).toHaveAttribute('width', '100%'); - expect(image).not.toHaveAttribute('height'); - }); - - it(`throws an error if you don't have data.url`, () => { - expect(() => render()).toThrow(); - }); - - // eslint-disable-next-line jest/expect-expect - it.skip(`typescript types`, () => { - // this test is actually just using //@ts-expect-error as the assertion, and don't need to execute in order to have TS validation on them - // I don't love this idea, but at the moment I also don't have other great ideas for how to easily test our component TS types - - // no errors in these situations - ; - - // @ts-expect-error data and src - ; - - // @ts-expect-error foo is invalid - ; }); }); diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index ff3974a1..b2663a99 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -1,12 +1,17 @@ import * as React from 'react'; -import { - getShopifyImageDimensions, - shopifyImageLoader, - addImageSizeParametersToUrl, - IMG_SRC_SET_SIZES, -} from './image-size.js'; -import type {Image as ImageType} from './storefront-api-types.js'; import type {PartialDeep, Simplify} from 'type-fest'; +import type {Image as ImageType} from './storefront-api-types.js'; + +/* + * An optional prop you can use to change the + * default srcSet generation behaviour + */ +interface SrcSetOptions { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +} type HtmlImageProps = React.ImgHTMLAttributes; @@ -16,201 +21,492 @@ export type ShopifyLoaderOptions = { width?: HtmlImageProps['width'] | ImageType['width']; height?: HtmlImageProps['height'] | ImageType['height']; }; + export type ShopifyLoaderParams = Simplify< ShopifyLoaderOptions & { - src: ImageType['url']; + src?: ImageType['url']; + width?: number; + height?: number; + crop?: Crop; } >; -export type ShopifyImageProps = Omit & { + +/* + * TODO: Expand to include focal point support; + * or switch this to be an SF API type + */ + +type Crop = + | 'center' + | 'top' + | 'bottom' + | 'left' + | 'right' + | {top: number; left: number; width: number; height: number} + | undefined; + +export function Image({ /** An object with fields that correspond to the Storefront API's * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). * The `data` prop is required. */ - data: PartialDeep; - /** A custom function that generates the image URL. Parameters passed in - * are `ShopifyLoaderParams` - */ - loader?: (params: ShopifyLoaderParams) => string; - /** An object of `loader` function options. For example, if the `loader` function - * requires a `scale` option, then the value can be a property of the - * `loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`. - */ - loaderOptions?: ShopifyLoaderOptions; - /** - * `src` isn't used, and should instead be passed as part of the `data` object + data, + as: Component = 'img', + src, + /* + * Supports third party loaders, which are expected to provide + * a function that can generate a URL string */ - src?: never; - /** - * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. + loader = shopifyLoader, + /* + * The default behaviour is a responsive image, set to 100%, that fills + * the width of its container. It’s not declared in the props. */ - widths?: (HtmlImageProps['width'] | ImageType['width'])[]; -}; - -/** - * The `Image` component renders an image for the Storefront API's - * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props. - * - * An image's width and height are determined using the following priority list: - * 1. The width and height values for the `loaderOptions` prop - * 2. The width and height values for bare props - * 3. The width and height values for the `data` prop - * - * If only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio, - * provided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing - * value will remain as `null` - */ -export function Image({ - data, width, height, - loading, - loader = shopifyImageLoader, + /* + * The default crop is center, in the event that AspectRatio is set, + * without specifying a crop, Imagery won't return the expected image. + */ + crop = 'center', + sizes, + /* + * aspectRatio is a string in the format of 'width/height' + * it's used to generate the srcSet URLs, and to set the + * aspect ratio of the image element to prevent CLS. + */ + aspectRatio, + /* + * An optional prop you can use to change + * the default srcSet generation behaviour + */ + srcSetOptions = { + intervals: 10, + startingWidth: 300, + incrementSize: 300, + placeholderWidth: 100, + }, + alt, + loading = 'lazy', + /* + * Deprecated property from original Image component, + * you can now use the flat `crop`, `width`, and `height` props + * as well as `src` and `data` to achieve the same result. + */ loaderOptions, + /* + * Deprecated property from original Image component, + * widths are now calculated automatically based on the + * config and width props. + */ widths, - decoding = 'async', - ...rest -}: ShopifyImageProps) { - if (!data.url) { - const missingUrlError = `: the 'data' prop requires the 'url' property. Image: ${ - data.id ?? 'no ID provided' - }`; - - if (__HYDROGEN_DEV__) { - throw new Error(missingUrlError); - } else { - console.error(missingUrlError); - } - - return null; + ...passthroughProps +}: { + as?: 'img' | 'source'; + data?: PartialDeep; + src?: string; + loader?: (params: ShopifyLoaderParams) => string; + width?: string | number; + height?: string | number; + crop?: Crop; + sizes?: string; + aspectRatio?: string; + srcSetOptions?: SrcSetOptions; + alt?: string; + loading?: 'lazy' | 'eager'; + loaderOptions?: ShopifyLoaderOptions; + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; +}) { + /* + * Deprecated Props from original Image component + */ + if (loaderOptions) { + console.warn( + `Deprecated property from original Image component in use: ` + + `Use the \`crop\`, \`width\`, \`height\`, and src props, or` + + `the \`data\` prop to achieve the same result. Image used is ${ + src || data?.url + }` + ); } - if (__HYDROGEN_DEV__ && !data.altText && !rest.alt) { + if (widths) { console.warn( - `: the 'data' prop should have the 'altText' property, or the 'alt' prop, and one of them should not be empty. Image: ${ - data.id ?? data.url - }` + `Deprecated property from original Image component in use: ` + + `\`widths\` are now calculated automatically based on the ` + + `config and width props. Image used is ${src || data?.url}` ); } - const {width: imgElementWidth, height: imgElementHeight} = - getShopifyImageDimensions({ - data, - loaderOptions, - elementProps: { - width, - height, - }, - }); - - if (__HYDROGEN_DEV__ && (!imgElementWidth || !imgElementHeight)) { + if (!sizes) { console.warn( - `: the 'data' prop requires either 'width' or 'data.width', and 'height' or 'data.height' properties. Image: ${ - data.id ?? data.url - }` + 'No sizes prop provided to Image component, ' + + 'you may be loading unnecessarily large images. ' + + `Image used is ${src || data?.url}` ); } - let finalSrc = data.url; + /* Only use data width if height is also set */ - if (loader) { - finalSrc = loader({ - ...loaderOptions, - src: data.url, - width: imgElementWidth, - height: imgElementHeight, - }); - if (typeof finalSrc !== 'string' || !finalSrc) { - throw new Error( - `: 'loader' did not return a valid string. Image: ${ - data.id ?? data.url - }` - ); - } + const dataWidth: number | undefined = + data?.width && data?.height ? data?.width : undefined; + + const dataHeight: number | undefined = + data?.width && data?.height ? data?.height : undefined; + + const dataUnitsMatch: boolean = unitsMatch(dataWidth, dataHeight); + + /* + * Gets normalized values for width, height, src, alt, and aspectRatio props + * supporting the presence of `data` in addition to flat props. + */ + + const normalizedWidthProp: string | number = width || '100%'; + + const normalizedWidth: string = + getUnitValueParts(normalizedWidthProp.toString()).number + + getUnitValueParts(normalizedWidthProp.toString()).unit; + + const normalizedHeight: string = + height === undefined + ? 'auto' + : getUnitValueParts(height.toString()).number + + getUnitValueParts(height.toString()).unit; + + const normalizedSrc: string | undefined = src || data?.url; + + if (!normalizedSrc) { + console.error(`No src or data.url provided to Image component.`); } - // determining what the intended width of the image is. For example, if the width is specified and lower than the image width, then that is the maximum image width - // to prevent generating a srcset with widths bigger than needed or to generate images that would distort because of being larger than original - const maxWidth = - width && imgElementWidth && width < imgElementWidth - ? width - : imgElementWidth; - const finalSrcset = - rest.srcSet ?? - internalImageSrcSet({ - ...loaderOptions, - widths, - src: data.url, - width: maxWidth, - height: imgElementHeight, - loader, + const normalizedAlt: string = + data?.altText && !alt ? data?.altText : alt || ''; + + const normalizedAspectRatio: string | undefined = aspectRatio + ? aspectRatio + : dataUnitsMatch + ? `${getNormalizedFixedUnit(dataWidth)}/${getNormalizedFixedUnit( + dataHeight + )}` + : undefined; + + const {intervals, startingWidth, incrementSize, placeholderWidth} = + srcSetOptions; + + /* + * This function creates an array of widths to be used in srcSet + */ + const imageWidths = generateImageWidths( + width, + intervals, + startingWidth, + incrementSize + ); + + /* + * We check to see whether the image is fixed width or not, + * if fixed, we still provide a srcSet, but only to account for + * different pixel densities. + */ + if (isFixedWidth(normalizedWidth)) { + const intWidth: number | undefined = getNormalizedFixedUnit(width); + const intHeight: number | undefined = getNormalizedFixedUnit(height); + + /* + * The aspect ratio for fixed with images is taken from the explicitly + * set prop, but if that's not present, and both width and height are + * set, we calculate the aspect ratio from the width and height—as + * long as they share the same unit type (e.g. both are 'px'). + */ + const fixedAspectRatio = aspectRatio + ? aspectRatio + : unitsMatch(normalizedWidth, normalizedHeight) + ? `${intWidth}/${intHeight}` + : normalizedAspectRatio + ? normalizedAspectRatio + : undefined; + + /* + * The Sizes Array generates an array of all of the parts + * that make up the srcSet, including the width, height, and crop + */ + const sizesArray = + imageWidths === undefined + ? undefined + : generateSizes(imageWidths, fixedAspectRatio, crop); + + return React.createElement(Component, { + srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), + src: loader({ + src: normalizedSrc, + width: intWidth, + height: intHeight + ? intHeight + : fixedAspectRatio && intWidth + ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) + : undefined, + crop: normalizedHeight === 'auto' ? undefined : crop, + }), + alt: normalizedAlt, + sizes: sizes || normalizedWidth, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio: fixedAspectRatio, + }, + loading, + ...passthroughProps, + }); + } else { + const sizesArray = + imageWidths === undefined + ? undefined + : generateSizes(imageWidths, normalizedAspectRatio, crop); + + return React.createElement(Component, { + srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), + src: loader({ + src: normalizedSrc, + width: placeholderWidth, + height: + normalizedAspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) + : undefined, + }), + alt: normalizedAlt, + sizes, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio: normalizedAspectRatio, + }, + loading, + ...passthroughProps, }); + } +} - /* eslint-disable hydrogen/prefer-image-component */ +function unitsMatch( + width: string | number = '100%', + height: string | number = 'auto' +) { return ( - {data.altText + getUnitValueParts(width.toString()).unit === + getUnitValueParts(height.toString()).unit ); - /* eslint-enable hydrogen/prefer-image-component */ + /* + Given: + width = '100px' + height = 'auto' + Returns: + false + + Given: + width = '100px' + height = '50px' + Returns: + true + */ } -type InternalShopifySrcSetGeneratorsParams = Simplify< - ShopifyLoaderOptions & { - src: ImageType['url']; - widths?: (HtmlImageProps['width'] | ImageType['width'])[]; - loader?: (params: ShopifyLoaderParams) => string; +function getUnitValueParts(value: string) { + const unit = value.replace(/[0-9.]/g, ''); + const number = parseFloat(value.replace(unit, '')); + + return { + unit: unit === '' ? (number === undefined ? 'auto' : 'px') : unit, + number, + }; + /* + Given: + value = '100px' + Returns: + { + unit: 'px', + number: 100 + } + */ +} + +function getNormalizedFixedUnit(value?: string | number) { + if (value === undefined) { + return; } ->; -function internalImageSrcSet({ - src, - width, - crop, - scale, - widths, - loader, - height, -}: InternalShopifySrcSetGeneratorsParams) { - const hasCustomWidths = widths && Array.isArray(widths); - if (hasCustomWidths && widths.some((size) => isNaN(size as number))) { - throw new Error( - `: the 'widths' must be an array of numbers. Image: ${src}` - ); + + const {unit, number} = getUnitValueParts(value.toString()); + + switch (unit) { + case 'em': + return number * 16; + case 'rem': + return number * 16; + case 'px': + return number; + case '': + return number; + default: + return; } + /* + Given: + value = 16px | 1rem | 1em | 16 + Returns: + 16 + + Given: + value = 100% + Returns: + undefined + */ +} - let aspectRatio = 1; - if (width && height) { - aspectRatio = Number(height) / Number(width); +function isFixedWidth(width: string | number) { + const fixedEndings = /\d(px|em|rem)$/; + return ( + typeof width === 'number' || + (typeof width === 'string' && fixedEndings.test(width)) + ); + /* + Given: + width = 100 | '100px' | '100em' | '100rem' + Returns: + true + */ +} + +export function generateShopifySrcSet( + src?: string, + sizesArray?: Array<{width?: number; height?: number; crop?: Crop}> +) { + if (!src) { + return ''; } - let setSizes = hasCustomWidths ? widths : IMG_SRC_SET_SIZES; - if ( - !hasCustomWidths && - width && - width < IMG_SRC_SET_SIZES[IMG_SRC_SET_SIZES.length - 1] - ) { - setSizes = IMG_SRC_SET_SIZES.filter((size) => size <= width); + if (sizesArray?.length === 0 || !sizesArray) { + return src; } - const srcGenerator = loader ? loader : addImageSizeParametersToUrl; - return setSizes + + return sizesArray .map( (size) => - `${srcGenerator({ + shopifyLoader({ src, - width: size, - // height is not applied if there is no crop - // if there is crop, then height is applied as a ratio of the original width + height aspect ratio * size - height: crop ? Number(size) * aspectRatio : undefined, - crop, - scale, - })} ${size}w` + width: size.width, + height: size.height, + crop: size.crop, + }) + + ' ' + + size.width + + 'w' ) - .join(', '); + .join(`, `); + /* + Given: + src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg' + sizesArray = [ + {width: 200, height: 200, crop: 'center'}, + {width: 400, height: 400, crop: 'center'}, + ] + Returns: + 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=200&height=200&crop=center 200w, https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=400&height=400&crop=center 400w' + */ +} + +export function generateImageWidths( + width: string | number = '100%', + intervals = 20, + startingWidth = 200, + incrementSize = 100 +) { + const responsive = Array.from( + {length: intervals}, + (_, i) => i * incrementSize + startingWidth + ); + + const fixed = Array.from( + {length: 3}, + (_, i) => (i + 1) * (getNormalizedFixedUnit(width) ?? 0) + ); + + return isFixedWidth(width) ? fixed : responsive; +} + +// Simple utility function to convert 1/1 to [1, 1] +export function parseAspectRatio(aspectRatio?: string) { + if (!aspectRatio) return; + const [width, height] = aspectRatio.split('/'); + return 1 / (Number(width) / Number(height)); + /* + Given: + '1/1' + Returns: + 0.5, + Given: + '4/3' + Returns: + 0.75 + */ +} + +// Generate data needed for Imagery loader +export function generateSizes( + imageWidths?: number[], + aspectRatio?: string, + crop: Crop = 'center' +) { + if (!imageWidths) return; + const sizes = imageWidths.map((width: number) => { + return { + width, + height: aspectRatio + ? width * (parseAspectRatio(aspectRatio) ?? 1) + : undefined, + crop, + }; + }); + return sizes; + /* + Given: + ([100, 200], 1/1, 'center') + Returns: + [{width: 100, height: 100, crop: 'center'}, + {width: 200, height: 200, crop: 'center'}] + */ +} + +/* + * The shopifyLoader function is a simple utility function that takes a src, width, + * height, and crop and returns a string that can be used as the src for an image. + * It can be used with the Hydrogen Image component or with the next/image component. + * (or any others that accept equivalent configuration) + */ +export function shopifyLoader({ + src, + width, + height, + crop, +}: { + src?: string; + width?: number; + height?: number; + crop?: Crop; +}): string { + if (!src) { + return ''; + } + + const url = new URL(src); + width && url.searchParams.append('width', Math.round(width).toString()); + height && url.searchParams.append('height', Math.round(height).toString()); + crop && url.searchParams.append('crop', crop); + return url.href; + /* + Given: + src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg' + width = 100 + height = 100 + crop = 'center' + Returns: + 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center' + */ } diff --git a/packages/react/src/ImageLegacy.stories.tsx b/packages/react/src/ImageLegacy.stories.tsx new file mode 100644 index 00000000..2801085a --- /dev/null +++ b/packages/react/src/ImageLegacy.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import type {Story} from '@ladle/react'; +import {Image, type ShopifyImageProps} from './ImageLegacy.js'; +import {IMG_SRC_SET_SIZES} from './image-size.js'; + +const Template: Story<{ + 'data.url': ShopifyImageProps['data']['url']; + 'data.width': ShopifyImageProps['data']['width']; + 'data.height': ShopifyImageProps['data']['height']; + width: ShopifyImageProps['width']; + height: ShopifyImageProps['height']; + widths: ShopifyImageProps['widths']; + loaderOptions: ShopifyImageProps['loaderOptions']; +}> = (props) => { + const finalProps: ShopifyImageProps = { + data: { + url: props['data.url'], + width: props['data.width'], + height: props['data.height'], + id: 'testing', + }, + width: props.width, + height: props.height, + widths: props.widths, + loaderOptions: props.loaderOptions, + }; + return ; +}; + +export const Default = Template.bind({}); +Default.args = { + 'data.url': + 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', + 'data.width': 100, + 'data.height': 100, + width: 500, + height: 500, + widths: IMG_SRC_SET_SIZES, + loaderOptions: { + crop: 'center', + scale: 2, + width: 500, + height: 500, + }, +}; diff --git a/packages/react/src/Image.test.helpers.ts b/packages/react/src/ImageLegacy.test.helpers.ts similarity index 100% rename from packages/react/src/Image.test.helpers.ts rename to packages/react/src/ImageLegacy.test.helpers.ts diff --git a/packages/react/src/ImageLegacy.test.tsx b/packages/react/src/ImageLegacy.test.tsx new file mode 100644 index 00000000..e31c58bc --- /dev/null +++ b/packages/react/src/ImageLegacy.test.tsx @@ -0,0 +1,337 @@ +import {vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import {Image} from './ImageLegacy.js'; +import * as utilities from './image-size.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; + +describe('', () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('renders an `img` element', () => { + const previewImage = getPreviewImage(); + const {url: src, altText, id, width, height} = previewImage; + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', src); + expect(image).toHaveAttribute('id', id); + expect(image).toHaveAttribute('alt', altText); + expect(image).toHaveAttribute('width', `${width}`); + expect(image).toHaveAttribute('height', `${height}`); + expect(image).toHaveAttribute('loading', 'lazy'); + }); + + it('renders an `img` element with provided `id`', () => { + const previewImage = getPreviewImage(); + const id = 'catImage'; + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('id', id); + }); + + it('renders an `img` element with provided `loading` value', () => { + const previewImage = getPreviewImage(); + const loading = 'eager'; + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('loading', loading); + }); + + it('renders an `img` with `width` and `height` values', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + const options = {scale: 2 as const}; + const mockDimensions = { + width: 200, + height: 100, + }; + + vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( + mockDimensions + ); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('width', `${mockDimensions.width}`); + expect(image).toHaveAttribute('height', `${mockDimensions.height}`); + }); + + it('renders an `img` element without `width` and `height` attributes when invalid dimensions are provided', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + const options = {scale: 2 as const}; + const mockDimensions = { + width: null, + height: null, + }; + + vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( + mockDimensions + ); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).not.toHaveAttribute('width'); + expect(image).not.toHaveAttribute('height'); + }); + + describe('Loaders', () => { + it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + + const transformedSrc = 'https://cdn.shopify.com/someimage_100x200@2x.jpg'; + + const options = {width: 100, height: 200, scale: 2 as const}; + + const shopifyImageLoaderSpy = vi + .spyOn(utilities, 'shopifyImageLoader') + .mockReturnValue(transformedSrc); + + render(); + + expect(shopifyImageLoaderSpy).toHaveBeenCalledWith({ + src: previewImage.url, + ...options, + }); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', transformedSrc); + }); + }); + + it('allows passthrough props', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + + render( + Fancy image + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveClass('fancyImage'); + expect(image).toHaveAttribute('id', '123'); + expect(image).toHaveAttribute('alt', 'Fancy image'); + }); + + it('generates a default srcset', () => { + const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; + const sizes = [352, 832, 1200, 1920, 2560]; + const expectedSrcset = sizes + .map((size) => `${mockUrl}?width=${size} ${size}w`) + .join(', '); + const previewImage = getPreviewImage({ + url: mockUrl, + width: 2560, + height: 2560, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('srcSet', expectedSrcset); + }); + + it('generates a default srcset up to the image height and width', () => { + const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; + const sizes = [352, 832]; + const expectedSrcset = sizes + .map((size) => `${mockUrl}?width=${size} ${size}w`) + .join(', '); + const previewImage = getPreviewImage({ + url: mockUrl, + width: 832, + height: 832, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('srcSet', expectedSrcset); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is missing, does not include height in srcset`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 500, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is not applied if there is no crop + // width is not doulbe of the passed width, but instead double of the value in 'sizes_array' / '[number]w' + `${previewImage.url}?width=704 352w` + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '500'); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 500, + }); + + render( + + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is the aspect ratio (of width + height) * srcSet width, so in this case it should be half of width + `${previewImage.url}?width=704&height=352&crop=bottom 352w` + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '250'); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset using data.width / data.height for the aspect ratio`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 500, + }); + + render( + + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be the same as width + `${previewImage.url}?width=704&height=704&crop=bottom 352w` + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '500'); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is there, calculates height based on aspect ratio in srcset`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 1000, + }); + + render( + + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be double the width + `${previewImage.url}?width=704&height=1408&crop=bottom 352w` + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '1000'); + }); + + it(`should pass through width (as an inline prop) when it's a string, and use the first size in the size array for the URL width`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 100, + height: 100, + }); + + render(); + + const image = screen.getByRole('img'); + + console.log(image.getAttribute('srcSet')); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); + expect(image).toHaveAttribute('width', '100%'); + expect(image).not.toHaveAttribute('height'); + }); + + it(`should pass through width (as part of loaderOptions) when it's a string, and use the first size in the size array for the URL width`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 100, + height: 100, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); + expect(image).toHaveAttribute('width', '100%'); + expect(image).not.toHaveAttribute('height'); + }); + + it(`throws an error if you don't have data.url`, () => { + expect(() => render()).toThrow(); + }); + + // eslint-disable-next-line jest/expect-expect + it.skip(`typescript types`, () => { + // this test is actually just using //@ts-expect-error as the assertion, and don't need to execute in order to have TS validation on them + // I don't love this idea, but at the moment I also don't have other great ideas for how to easily test our component TS types + + // no errors in these situations + ; + + // @ts-expect-error data and src + ; + + // @ts-expect-error foo is invalid + ; + }); +}); diff --git a/packages/react/src/ImageLegacy.tsx b/packages/react/src/ImageLegacy.tsx new file mode 100644 index 00000000..ff3974a1 --- /dev/null +++ b/packages/react/src/ImageLegacy.tsx @@ -0,0 +1,216 @@ +import * as React from 'react'; +import { + getShopifyImageDimensions, + shopifyImageLoader, + addImageSizeParametersToUrl, + IMG_SRC_SET_SIZES, +} from './image-size.js'; +import type {Image as ImageType} from './storefront-api-types.js'; +import type {PartialDeep, Simplify} from 'type-fest'; + +type HtmlImageProps = React.ImgHTMLAttributes; + +export type ShopifyLoaderOptions = { + crop?: 'top' | 'bottom' | 'left' | 'right' | 'center'; + scale?: 2 | 3; + width?: HtmlImageProps['width'] | ImageType['width']; + height?: HtmlImageProps['height'] | ImageType['height']; +}; +export type ShopifyLoaderParams = Simplify< + ShopifyLoaderOptions & { + src: ImageType['url']; + } +>; +export type ShopifyImageProps = Omit & { + /** An object with fields that correspond to the Storefront API's + * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). + * The `data` prop is required. + */ + data: PartialDeep; + /** A custom function that generates the image URL. Parameters passed in + * are `ShopifyLoaderParams` + */ + loader?: (params: ShopifyLoaderParams) => string; + /** An object of `loader` function options. For example, if the `loader` function + * requires a `scale` option, then the value can be a property of the + * `loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`. + */ + loaderOptions?: ShopifyLoaderOptions; + /** + * `src` isn't used, and should instead be passed as part of the `data` object + */ + src?: never; + /** + * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. + */ + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; +}; + +/** + * The `Image` component renders an image for the Storefront API's + * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props. + * + * An image's width and height are determined using the following priority list: + * 1. The width and height values for the `loaderOptions` prop + * 2. The width and height values for bare props + * 3. The width and height values for the `data` prop + * + * If only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio, + * provided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing + * value will remain as `null` + */ +export function Image({ + data, + width, + height, + loading, + loader = shopifyImageLoader, + loaderOptions, + widths, + decoding = 'async', + ...rest +}: ShopifyImageProps) { + if (!data.url) { + const missingUrlError = `: the 'data' prop requires the 'url' property. Image: ${ + data.id ?? 'no ID provided' + }`; + + if (__HYDROGEN_DEV__) { + throw new Error(missingUrlError); + } else { + console.error(missingUrlError); + } + + return null; + } + + if (__HYDROGEN_DEV__ && !data.altText && !rest.alt) { + console.warn( + `: the 'data' prop should have the 'altText' property, or the 'alt' prop, and one of them should not be empty. Image: ${ + data.id ?? data.url + }` + ); + } + + const {width: imgElementWidth, height: imgElementHeight} = + getShopifyImageDimensions({ + data, + loaderOptions, + elementProps: { + width, + height, + }, + }); + + if (__HYDROGEN_DEV__ && (!imgElementWidth || !imgElementHeight)) { + console.warn( + `: the 'data' prop requires either 'width' or 'data.width', and 'height' or 'data.height' properties. Image: ${ + data.id ?? data.url + }` + ); + } + + let finalSrc = data.url; + + if (loader) { + finalSrc = loader({ + ...loaderOptions, + src: data.url, + width: imgElementWidth, + height: imgElementHeight, + }); + if (typeof finalSrc !== 'string' || !finalSrc) { + throw new Error( + `: 'loader' did not return a valid string. Image: ${ + data.id ?? data.url + }` + ); + } + } + + // determining what the intended width of the image is. For example, if the width is specified and lower than the image width, then that is the maximum image width + // to prevent generating a srcset with widths bigger than needed or to generate images that would distort because of being larger than original + const maxWidth = + width && imgElementWidth && width < imgElementWidth + ? width + : imgElementWidth; + const finalSrcset = + rest.srcSet ?? + internalImageSrcSet({ + ...loaderOptions, + widths, + src: data.url, + width: maxWidth, + height: imgElementHeight, + loader, + }); + + /* eslint-disable hydrogen/prefer-image-component */ + return ( + {data.altText + ); + /* eslint-enable hydrogen/prefer-image-component */ +} + +type InternalShopifySrcSetGeneratorsParams = Simplify< + ShopifyLoaderOptions & { + src: ImageType['url']; + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; + loader?: (params: ShopifyLoaderParams) => string; + } +>; +function internalImageSrcSet({ + src, + width, + crop, + scale, + widths, + loader, + height, +}: InternalShopifySrcSetGeneratorsParams) { + const hasCustomWidths = widths && Array.isArray(widths); + if (hasCustomWidths && widths.some((size) => isNaN(size as number))) { + throw new Error( + `: the 'widths' must be an array of numbers. Image: ${src}` + ); + } + + let aspectRatio = 1; + if (width && height) { + aspectRatio = Number(height) / Number(width); + } + + let setSizes = hasCustomWidths ? widths : IMG_SRC_SET_SIZES; + if ( + !hasCustomWidths && + width && + width < IMG_SRC_SET_SIZES[IMG_SRC_SET_SIZES.length - 1] + ) { + setSizes = IMG_SRC_SET_SIZES.filter((size) => size <= width); + } + const srcGenerator = loader ? loader : addImageSizeParametersToUrl; + return setSizes + .map( + (size) => + `${srcGenerator({ + src, + width: size, + // height is not applied if there is no crop + // if there is crop, then height is applied as a ratio of the original width + height aspect ratio * size + height: crop ? Number(size) * aspectRatio : undefined, + crop, + scale, + })} ${size}w` + ) + .join(', '); +} diff --git a/packages/react/src/MediaFile.test.helpers.ts b/packages/react/src/MediaFile.test.helpers.ts index 1d6ad4e0..27b74552 100644 --- a/packages/react/src/MediaFile.test.helpers.ts +++ b/packages/react/src/MediaFile.test.helpers.ts @@ -3,7 +3,7 @@ import {getExternalVideoData} from './ExternalVideo.test.helpers.js'; import {faker} from '@faker-js/faker'; import type {PartialDeep} from 'type-fest'; import type {MediaImage, MediaEdge} from './storefront-api-types.js'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; import {getModel3d} from './ModelViewer.test.helpers.js'; export function getMedia( diff --git a/packages/react/src/MediaFile.tsx b/packages/react/src/MediaFile.tsx index 40d097da..4895ac2f 100644 --- a/packages/react/src/MediaFile.tsx +++ b/packages/react/src/MediaFile.tsx @@ -1,4 +1,4 @@ -import {Image, type ShopifyImageProps} from './Image.js'; +import {Image, type ShopifyImageProps} from './ImageLegacy.js'; import {Video} from './Video.js'; import {ExternalVideo} from './ExternalVideo.js'; import {ModelViewer} from './ModelViewer.js'; diff --git a/packages/react/src/Metafield.tsx b/packages/react/src/Metafield.tsx index b26d9e33..abcc49d4 100644 --- a/packages/react/src/Metafield.tsx +++ b/packages/react/src/Metafield.tsx @@ -1,6 +1,6 @@ import {type ElementType, useMemo, type ComponentPropsWithoutRef} from 'react'; import {useShop} from './ShopifyProvider.js'; -import {Image} from './Image.js'; +import {Image} from './ImageLegacy.js'; import type { MediaImage, Page, diff --git a/packages/react/src/ModelViewer.test.helpers.ts b/packages/react/src/ModelViewer.test.helpers.ts index ae3201da..a3de7586 100644 --- a/packages/react/src/ModelViewer.test.helpers.ts +++ b/packages/react/src/ModelViewer.test.helpers.ts @@ -1,7 +1,7 @@ import type {Model3d} from './storefront-api-types.js'; import type {PartialDeep} from 'type-fest'; import {faker} from '@faker-js/faker'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; export function getModel3d( model: PartialDeep = {} diff --git a/packages/react/src/ProductProvider.test.helpers.ts b/packages/react/src/ProductProvider.test.helpers.ts index 1e8bca4d..d1022b6a 100644 --- a/packages/react/src/ProductProvider.test.helpers.ts +++ b/packages/react/src/ProductProvider.test.helpers.ts @@ -10,7 +10,7 @@ import type {PartialDeep} from 'type-fest'; import {faker} from '@faker-js/faker'; import {getRawMetafield} from './Metafield.test.helpers.js'; import {getUnitPriceMeasurement, getPrice} from './Money.test.helpers.js'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; import {getMedia} from './MediaFile.test.helpers.js'; export function getProduct( diff --git a/packages/react/src/Video.test.helpers.ts b/packages/react/src/Video.test.helpers.ts index 4d562dc8..973e828a 100644 --- a/packages/react/src/Video.test.helpers.ts +++ b/packages/react/src/Video.test.helpers.ts @@ -1,7 +1,7 @@ import type {Video as VideoType, VideoSource} from './storefront-api-types.js'; import {faker} from '@faker-js/faker'; import type {PartialDeep} from 'type-fest'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; export function getVideoData( video: PartialDeep = {} diff --git a/packages/react/src/image-size.ts b/packages/react/src/image-size.ts index 974964d5..284de65f 100644 --- a/packages/react/src/image-size.ts +++ b/packages/react/src/image-size.ts @@ -1,6 +1,6 @@ import type {Image as ImageType} from './storefront-api-types.js'; import type {PartialDeep} from 'type-fest'; -import type {ShopifyLoaderOptions, ShopifyLoaderParams} from './Image.js'; +import type {ShopifyLoaderOptions, ShopifyLoaderParams} from './ImageLegacy.js'; // TODO: Are there other CDNs missing from here? const PRODUCTION_CDN_HOSTNAMES = [ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 64cb4ff2..46dfb7e8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,7 +12,7 @@ export {CartProvider, useCart} from './CartProvider.js'; export {storefrontApiCustomScalars} from './codegen.helpers.js'; export {ExternalVideo} from './ExternalVideo.js'; export {flattenConnection} from './flatten-connection.js'; -export {Image} from './Image.js'; +export {Image} from './ImageLegacy.js'; export {MediaFile} from './MediaFile.js'; export {metafieldParser, type ParsedMetafields} from './metafield-parser.js'; export {Metafield, parseMetafield, parseMetafieldValue} from './Metafield.js';