diff --git a/.changeset/proud-terms-swim.md b/.changeset/proud-terms-swim.md new file mode 100644 index 000000000000..e33b3d1aff2b --- /dev/null +++ b/.changeset/proud-terms-swim.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Adds experimental reponsive image support diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 19e10694f082..a2e4cf0eb9be 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -47,7 +47,9 @@ declare module 'astro:assets' { getImage: ( options: import('./dist/assets/types.js').UnresolvedImageTransform, ) => Promise; - imageConfig: import('./dist/types/public/config.js').AstroConfig['image']; + imageConfig: import('./dist/types/public/config.js').AstroConfig['image'] & { + experimentalResponsiveImages: boolean; + }; getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService; inferRemoteSize: typeof import('./dist/assets/utils/index.js').inferRemoteSize; Image: typeof import('./components/Image.astro').default; diff --git a/packages/astro/components/Image.astro b/packages/astro/components/Image.astro index 4e55f5608b8e..6e7a83751689 100644 --- a/packages/astro/components/Image.astro +++ b/packages/astro/components/Image.astro @@ -1,7 +1,10 @@ --- -import { type LocalImageProps, type RemoteImageProps, getImage } from 'astro:assets'; +import { type LocalImageProps, type RemoteImageProps, getImage, imageConfig } from 'astro:assets'; +import type { UnresolvedImageTransform } from '../dist/assets/types'; +import { applyResponsiveAttributes } from '../dist/assets/utils/imageAttributes.js'; import { AstroError, AstroErrorData } from '../dist/core/errors/index.js'; import type { HTMLAttributes } from '../types'; +import './image.css'; // The TypeScript diagnostic for JSX props uses the last member of the union to suggest props, so it would be better for // LocalImageProps to be last. Unfortunately, when we do this the error messages that remote images get are complete nonsense @@ -23,7 +26,17 @@ if (typeof props.height === 'string') { props.height = parseInt(props.height); } -const image = await getImage(props); +const layout = props.layout ?? imageConfig.experimentalLayout ?? 'none'; +const useResponsive = imageConfig.experimentalResponsiveImages && layout !== 'none'; + +if (useResponsive) { + // Apply defaults from imageConfig if not provided + props.layout ??= imageConfig.experimentalLayout; + props.fit ??= imageConfig.experimentalObjectFit ?? 'cover'; + props.position ??= imageConfig.experimentalObjectPosition ?? 'center'; +} + +const image = await getImage(props as UnresolvedImageTransform); const additionalAttributes: HTMLAttributes<'img'> = {}; if (image.srcSet.values.length > 0) { @@ -33,6 +46,16 @@ if (image.srcSet.values.length > 0) { if (import.meta.env.DEV) { additionalAttributes['data-image-component'] = 'true'; } + +const attributes = useResponsive + ? applyResponsiveAttributes({ + layout, + image, + props, + additionalAttributes, + }) + : { ...additionalAttributes, ...image.attributes }; --- - +{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */} + diff --git a/packages/astro/components/Picture.astro b/packages/astro/components/Picture.astro index 73459db04f37..5d03823790d3 100644 --- a/packages/astro/components/Picture.astro +++ b/packages/astro/components/Picture.astro @@ -1,10 +1,16 @@ --- -import { type LocalImageProps, type RemoteImageProps, getImage } from 'astro:assets'; +import { type LocalImageProps, type RemoteImageProps, getImage, imageConfig } from 'astro:assets'; import * as mime from 'mrmime'; +import { applyResponsiveAttributes } from '../dist/assets/utils/imageAttributes'; import { isESMImportedImage, resolveSrc } from '../dist/assets/utils/imageKind'; import { AstroError, AstroErrorData } from '../dist/core/errors/index.js'; -import type { GetImageResult, ImageOutputFormat } from '../dist/types/public/index.js'; +import type { + GetImageResult, + ImageOutputFormat, + UnresolvedImageTransform, +} from '../dist/types/public/index.js'; import type { HTMLAttributes } from '../types'; +import './image.css'; type Props = (LocalImageProps | RemoteImageProps) & { formats?: ImageOutputFormat[]; @@ -37,6 +43,17 @@ if (scopedStyleClass) { pictureAttributes.class = scopedStyleClass; } } + +const layout = props.layout ?? imageConfig.experimentalLayout ?? 'none'; +const useResponsive = imageConfig.experimentalResponsiveImages && layout !== 'none'; + +if (useResponsive) { + // Apply defaults from imageConfig if not provided + props.layout ??= imageConfig.experimentalLayout; + props.fit ??= imageConfig.experimentalObjectFit ?? 'cover'; + props.position ??= imageConfig.experimentalObjectPosition ?? 'center'; +} + for (const key in props) { if (key.startsWith('data-astro-cid')) { pictureAttributes[key] = props[key]; @@ -53,7 +70,7 @@ const optimizedImages: GetImageResult[] = await Promise.all( format: format, widths: props.widths, densities: props.densities, - }), + } as UnresolvedImageTransform), ), ); @@ -71,7 +88,7 @@ const fallbackImage = await getImage({ format: resultFallbackFormat, widths: props.widths, densities: props.densities, -}); +} as UnresolvedImageTransform); const imgAdditionalAttributes: HTMLAttributes<'img'> = {}; const sourceAdditionalAttributes: HTMLAttributes<'source'> = {}; @@ -85,6 +102,15 @@ if (fallbackImage.srcSet.values.length > 0) { imgAdditionalAttributes.srcset = fallbackImage.srcSet.attribute; } +const attributes = useResponsive + ? applyResponsiveAttributes({ + layout, + image: fallbackImage, + props, + additionalAttributes: imgAdditionalAttributes, + }) + : { ...imgAdditionalAttributes, ...fallbackImage.attributes }; + if (import.meta.env.DEV) { imgAdditionalAttributes['data-image-component'] = 'true'; } @@ -94,7 +120,7 @@ if (import.meta.env.DEV) { { Object.entries(optimizedImages).map(([_, image]) => { const srcsetAttribute = - props.densities || (!props.densities && !props.widths) + props.densities || (!props.densities && !props.widths && !useResponsive) ? `${image.src}${image.srcSet.values.length > 0 ? ', ' + image.srcSet.attribute : ''}` : image.srcSet.attribute; return ( @@ -106,5 +132,6 @@ if (import.meta.env.DEV) { ); }) } - + {/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */} + diff --git a/packages/astro/components/image.css b/packages/astro/components/image.css new file mode 100644 index 000000000000..d748ba7d5d4f --- /dev/null +++ b/packages/astro/components/image.css @@ -0,0 +1,17 @@ +[data-astro-image] { + width: 100%; + height: auto; + object-fit: var(--fit); + object-position: var(--pos); + aspect-ratio: var(--w) / var(--h); +} +/* Styles for responsive layout */ +[data-astro-image='responsive'] { + max-width: calc(var(--w) * 1px); + max-height: calc(var(--h) * 1px); +} +/* Styles for fixed layout */ +[data-astro-image='fixed'] { + width: calc(var(--w) * 1px); + height: calc(var(--h) * 1px); +} diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts index 15f9fe46fb24..5fae641ae462 100644 --- a/packages/astro/src/assets/consts.ts +++ b/packages/astro/src/assets/consts.ts @@ -26,4 +26,12 @@ export const VALID_SUPPORTED_FORMATS = [ ] as const; export const DEFAULT_OUTPUT_FORMAT = 'webp' as const; export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const; -export const DEFAULT_HASH_PROPS = ['src', 'width', 'height', 'format', 'quality']; +export const DEFAULT_HASH_PROPS = [ + 'src', + 'width', + 'height', + 'format', + 'quality', + 'fit', + 'position', +]; diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index 07584c4e5f3f..3363a5648d3d 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -2,6 +2,12 @@ import { isRemotePath } from '@astrojs/internal-helpers/path'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import type { AstroConfig } from '../types/public/config.js'; import { DEFAULT_HASH_PROPS } from './consts.js'; +import { + DEFAULT_RESOLUTIONS, + LIMITED_RESOLUTIONS, + getSizesAttribute, + getWidths, +} from './layout.js'; import { type ImageService, isLocalService } from './services/service.js'; import { type GetImageResult, @@ -32,9 +38,13 @@ export async function getConfiguredImageService(): Promise { return globalThis.astroAsset.imageService; } +type ImageConfig = AstroConfig['image'] & { + experimentalResponsiveImages: boolean; +}; + export async function getImage( options: UnresolvedImageTransform, - imageConfig: AstroConfig['image'], + imageConfig: ImageConfig, ): Promise { if (!options || typeof options !== 'object') { throw new AstroError({ @@ -65,6 +75,10 @@ export async function getImage( src: await resolveSrc(options.src), }; + let originalWidth: number | undefined; + let originalHeight: number | undefined; + let originalFormat: string | undefined; + // Infer size for remote images if inferSize is true if ( options.inferSize && @@ -74,6 +88,9 @@ export async function getImage( const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL resolvedOptions.width ??= result.width; resolvedOptions.height ??= result.height; + originalWidth = result.width; + originalHeight = result.height; + originalFormat = result.format; delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes } @@ -88,8 +105,53 @@ export async function getImage( (resolvedOptions.src.clone ?? resolvedOptions.src) : resolvedOptions.src; + if (isESMImportedImage(clonedSrc)) { + originalWidth = clonedSrc.width; + originalHeight = clonedSrc.height; + originalFormat = clonedSrc.format; + } + + if (originalWidth && originalHeight) { + // Calculate any missing dimensions from the aspect ratio, if available + const aspectRatio = originalWidth / originalHeight; + if (resolvedOptions.height && !resolvedOptions.width) { + resolvedOptions.width = Math.round(resolvedOptions.height * aspectRatio); + } else if (resolvedOptions.width && !resolvedOptions.height) { + resolvedOptions.height = Math.round(resolvedOptions.width / aspectRatio); + } else if (!resolvedOptions.width && !resolvedOptions.height) { + resolvedOptions.width = originalWidth; + resolvedOptions.height = originalHeight; + } + } resolvedOptions.src = clonedSrc; + const layout = options.layout ?? imageConfig.experimentalLayout; + + if (imageConfig.experimentalResponsiveImages && layout) { + resolvedOptions.widths ||= getWidths({ + width: resolvedOptions.width, + layout, + originalWidth, + breakpoints: imageConfig.experimentalBreakpoints?.length + ? imageConfig.experimentalBreakpoints + : isLocalService(service) + ? LIMITED_RESOLUTIONS + : DEFAULT_RESOLUTIONS, + }); + resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout }); + + if (resolvedOptions.priority) { + resolvedOptions.loading ??= 'eager'; + resolvedOptions.decoding ??= 'sync'; + resolvedOptions.fetchpriority ??= 'high'; + } else { + resolvedOptions.loading ??= 'lazy'; + resolvedOptions.decoding ??= 'async'; + resolvedOptions.fetchpriority ??= 'auto'; + } + delete resolvedOptions.priority; + } + const validatedOptions = service.validateOptions ? await service.validateOptions(resolvedOptions, imageConfig) : resolvedOptions; @@ -100,13 +162,23 @@ export async function getImage( : []; let imageURL = await service.getURL(validatedOptions, imageConfig); + + const matchesOriginal = (transform: ImageTransform) => + transform.width === originalWidth && + transform.height === originalHeight && + transform.format === originalFormat; + let srcSets: SrcSetValue[] = await Promise.all( - srcSetTransforms.map(async (srcSet) => ({ - transform: srcSet.transform, - url: await service.getURL(srcSet.transform, imageConfig), - descriptor: srcSet.descriptor, - attributes: srcSet.attributes, - })), + srcSetTransforms.map(async (srcSet) => { + return { + transform: srcSet.transform, + url: matchesOriginal(srcSet.transform) + ? imageURL + : await service.getURL(srcSet.transform, imageConfig), + descriptor: srcSet.descriptor, + attributes: srcSet.attributes, + }; + }), ); if ( @@ -120,12 +192,16 @@ export async function getImage( propsToHash, originalFilePath, ); - srcSets = srcSetTransforms.map((srcSet) => ({ - transform: srcSet.transform, - url: globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath), - descriptor: srcSet.descriptor, - attributes: srcSet.attributes, - })); + srcSets = srcSetTransforms.map((srcSet) => { + return { + transform: srcSet.transform, + url: matchesOriginal(srcSet.transform) + ? imageURL + : globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath), + descriptor: srcSet.descriptor, + attributes: srcSet.attributes, + }; + }); } return { diff --git a/packages/astro/src/assets/layout.ts b/packages/astro/src/assets/layout.ts new file mode 100644 index 000000000000..adc117f39967 --- /dev/null +++ b/packages/astro/src/assets/layout.ts @@ -0,0 +1,118 @@ +import type { ImageLayout } from './types.js'; + +// Common screen widths. These will be filtered according to the image size and layout +export const DEFAULT_RESOLUTIONS = [ + 640, // older and lower-end phones + 750, // iPhone 6-8 + 828, // iPhone XR/11 + 960, // older horizontal phones + 1080, // iPhone 6-8 Plus + 1280, // 720p + 1668, // Various iPads + 1920, // 1080p + 2048, // QXGA + 2560, // WQXGA + 3200, // QHD+ + 3840, // 4K + 4480, // 4.5K + 5120, // 5K + 6016, // 6K +]; + +// A more limited set of screen widths, for statically generated images +export const LIMITED_RESOLUTIONS = [ + 640, // older and lower-end phones + 750, // iPhone 6-8 + 828, // iPhone XR/11 + 1080, // iPhone 6-8 Plus + 1280, // 720p + 1668, // Various iPads + 2048, // QXGA + 2560, // WQXGA +]; + +/** + * Gets the breakpoints for an image, based on the layout and width + * + * The rules are as follows: + * + * - For full-width layout we return all breakpoints smaller than the original image width + * - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that. + * - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that. + */ +export const getWidths = ({ + width, + layout, + breakpoints = DEFAULT_RESOLUTIONS, + originalWidth, +}: { + width?: number; + layout: ImageLayout; + breakpoints?: Array; + originalWidth?: number; +}): Array => { + const smallerThanOriginal = (w: number) => !originalWidth || w <= originalWidth; + + // For full-width layout we return all breakpoints smaller than the original image width + if (layout === 'full-width') { + return breakpoints.filter(smallerThanOriginal); + } + // For other layouts we need a width to generate breakpoints. If no width is provided, we return an empty array + if (!width) { + return []; + } + const doubleWidth = width * 2; + // For fixed layout we want to return the 1x and 2x widths. We only do this if the original image is large enough to do this though. + const maxSize = originalWidth ? Math.min(doubleWidth, originalWidth) : doubleWidth; + if (layout === 'fixed') { + return originalWidth && width > originalWidth ? [originalWidth] : [width, maxSize]; + } + + // For responsive layout we want to return all breakpoints smaller than 2x requested width. + if (layout === 'responsive') { + return ( + [ + // Always include the image at 1x and 2x the specified width + width, + doubleWidth, + ...breakpoints, + ] + // Filter out any resolutions that are larger than the double-resolution image or source image + .filter((w) => w <= maxSize) + // Sort the resolutions in ascending order + .sort((a, b) => a - b) + ); + } + + return []; +}; + +/** + * Gets the `sizes` attribute for an image, based on the layout and width + */ +export const getSizesAttribute = ({ + width, + layout, +}: { width?: number; layout?: ImageLayout }): string | undefined => { + if (!width || !layout) { + return undefined; + } + switch (layout) { + // If screen is wider than the max size then image width is the max size, + // otherwise it's the width of the screen + case `responsive`: + return `(min-width: ${width}px) ${width}px, 100vw`; + + // Image is always the same width, whatever the size of the screen + case `fixed`: + return `${width}px`; + + // Image is always the width of the screen + case `full-width`: + return `100vw`; + + case 'none': + default: + return undefined; + } +}; diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index e22bada898e4..d84ec1728e8e 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -2,7 +2,12 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { isRemotePath, joinPaths } from '../../core/path.js'; import type { AstroConfig } from '../../types/public/config.js'; import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js'; -import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js'; +import type { + ImageFit, + ImageOutputFormat, + ImageTransform, + UnresolvedSrcSetValue, +} from '../types.js'; import { isESMImportedImage } from '../utils/imageKind.js'; import { isRemoteAllowed } from '../utils/remotePattern.js'; @@ -116,8 +121,12 @@ export type BaseServiceTransform = { height?: number; format: string; quality?: string | null; + fit?: ImageFit; + position?: string; }; +const sortNumeric = (a: number, b: number) => a - b; + /** * Basic local service using the included `_image` endpoint. * This service intentionally does not implement `transform`. @@ -219,14 +228,32 @@ export const baseService: Omit = { // Sometimes users will pass number generated from division, which can result in floating point numbers if (options.width) options.width = Math.round(options.width); if (options.height) options.height = Math.round(options.height); - + if (options.layout && options.width && options.height) { + options.fit ??= 'cover'; + delete options.layout; + } + if (options.fit === 'none') { + delete options.fit; + } return options; }, getHTMLAttributes(options) { const { targetWidth, targetHeight } = getTargetDimensions(options); - const { src, width, height, format, quality, densities, widths, formats, ...attributes } = - options; - + const { + src, + width, + height, + format, + quality, + densities, + widths, + formats, + layout, + priority, + fit, + position, + ...attributes + } = options; return { ...attributes, width: targetWidth, @@ -235,12 +262,14 @@ export const baseService: Omit = { decoding: attributes.decoding ?? 'async', }; }, - getSrcSet(options) { - const srcSet: UnresolvedSrcSetValue[] = []; - const { targetWidth } = getTargetDimensions(options); + getSrcSet(options): Array { + const { targetWidth, targetHeight } = getTargetDimensions(options); + const aspectRatio = targetWidth / targetHeight; const { widths, densities } = options; const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT; + let transformedWidths = (widths ?? []).sort(sortNumeric); + // For remote images, we don't know the original image's dimensions, so we cannot know the maximum width // It is ultimately the user's responsibility to make sure they don't request images larger than the original let imageWidth = options.width; @@ -250,8 +279,18 @@ export const baseService: Omit = { if (isESMImportedImage(options.src)) { imageWidth = options.src.width; maxWidth = imageWidth; + + // We've already sorted the widths, so we'll remove any that are larger than the original image's width + if (transformedWidths.length > 0 && transformedWidths.at(-1)! > maxWidth) { + transformedWidths = transformedWidths.filter((width) => width <= maxWidth); + // If we've had to remove some widths, we'll add the maximum width back in + transformedWidths.push(maxWidth); + } } + // Dedupe the widths + transformedWidths = Array.from(new Set(transformedWidths)); + // Since `widths` and `densities` ultimately control the width and height of the image, // we don't want the dimensions the user specified, we'll create those ourselves. const { @@ -261,7 +300,10 @@ export const baseService: Omit = { } = options; // Collect widths to generate from specified densities or widths - const allWidths: { maxTargetWidth: number; descriptor: `${number}x` | `${number}w` }[] = []; + let allWidths: Array<{ + width: number; + descriptor: `${number}x` | `${number}w`; + }> = []; if (densities) { // Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers const densityValues = densities.map((density) => { @@ -274,51 +316,31 @@ export const baseService: Omit = { // Calculate the widths for each density, rounding to avoid floats. const densityWidths = densityValues - .sort() + .sort(sortNumeric) .map((density) => Math.round(targetWidth * density)); - allWidths.push( - ...densityWidths.map((width, index) => ({ - maxTargetWidth: Math.min(width, maxWidth), - descriptor: `${densityValues[index]}x` as const, - })), - ); - } else if (widths) { - allWidths.push( - ...widths.map((width) => ({ - maxTargetWidth: Math.min(width, maxWidth), - descriptor: `${width}w` as const, - })), - ); + allWidths = densityWidths.map((width, index) => ({ + width, + descriptor: `${densityValues[index]}x`, + })); + } else if (transformedWidths.length > 0) { + allWidths = transformedWidths.map((width) => ({ + width, + descriptor: `${width}w`, + })); } - // Caution: The logic below is a bit tricky, as we need to make sure we don't generate the same image multiple times - // When making changes, make sure to test with different combinations of local/remote images widths, densities, and dimensions etc. - for (const { maxTargetWidth, descriptor } of allWidths) { - const srcSetTransform: ImageTransform = { ...transformWithoutDimensions }; - - // Only set the width if it's different from the original image's width, to avoid generating the same image multiple times - if (maxTargetWidth !== imageWidth) { - srcSetTransform.width = maxTargetWidth; - } else { - // If the width is the same as the original image's width, and we have both dimensions, it probably means - // it's a remote image, so we'll use the user's specified dimensions to avoid recreating the original image unnecessarily - if (options.width && options.height) { - srcSetTransform.width = options.width; - srcSetTransform.height = options.height; - } - } - - srcSet.push({ - transform: srcSetTransform, + return allWidths.map(({ width, descriptor }) => { + const height = Math.round(width / aspectRatio); + const transform = { ...transformWithoutDimensions, width, height }; + return { + transform, descriptor, attributes: { type: `image/${targetFormat}`, }, - }); - } - - return srcSet; + }; + }); }, getURL(options, imageConfig) { const searchParams = new URLSearchParams(); @@ -337,6 +359,8 @@ export const baseService: Omit = { h: 'height', q: 'quality', f: 'format', + fit: 'fit', + position: 'position', }; Object.entries(params).forEach(([param, key]) => { @@ -359,6 +383,8 @@ export const baseService: Omit = { height: params.has('h') ? parseInt(params.get('h')!) : undefined, format: params.get('f') as ImageOutputFormat, quality: params.get('q'), + fit: params.get('fit') as ImageFit, + position: params.get('position') ?? undefined, }; return transform; diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts index c9df4c269ac6..bbae39eb093a 100644 --- a/packages/astro/src/assets/services/sharp.ts +++ b/packages/astro/src/assets/services/sharp.ts @@ -1,6 +1,6 @@ -import type { FormatEnum, SharpOptions } from 'sharp'; +import type { FitEnum, FormatEnum, SharpOptions } from 'sharp'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import type { ImageOutputFormat, ImageQualityPreset } from '../types.js'; +import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js'; import { type BaseServiceTransform, type LocalImageService, @@ -38,6 +38,16 @@ async function loadSharp() { return sharpImport; } +const fitMap: Record = { + fill: 'fill', + contain: 'inside', + cover: 'cover', + none: 'outside', + 'scale-down': 'inside', + outside: 'outside', + inside: 'inside', +}; + const sharpService: LocalImageService = { validateOptions: baseService.validateOptions, getURL: baseService.getURL, @@ -46,7 +56,6 @@ const sharpService: LocalImageService = { getSrcSet: baseService.getSrcSet, async transform(inputBuffer, transformOptions, config) { if (!sharp) sharp = await loadSharp(); - const transform: BaseServiceTransform = transformOptions as BaseServiceTransform; // Return SVGs as-is @@ -62,11 +71,30 @@ const sharpService: LocalImageService = { // always call rotate to adjust for EXIF data orientation result.rotate(); - // Never resize using both width and height at the same time, prioritizing width. - if (transform.height && !transform.width) { - result.resize({ height: Math.round(transform.height) }); + // If `fit` isn't set then use old behavior: + // - Do not use both width and height for resizing, and prioritize width over height + // - Allow enlarging images + + const withoutEnlargement = Boolean(transform.fit); + if (transform.width && transform.height && transform.fit) { + const fit: keyof FitEnum = fitMap[transform.fit] ?? 'inside'; + result.resize({ + width: Math.round(transform.width), + height: Math.round(transform.height), + fit, + position: transform.position, + withoutEnlargement, + }); + } else if (transform.height && !transform.width) { + result.resize({ + height: Math.round(transform.height), + withoutEnlargement, + }); } else if (transform.width) { - result.resize({ width: Math.round(transform.width) }); + result.resize({ + width: Math.round(transform.width), + withoutEnlargement, + }); } if (transform.format) { diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts index 8bf7a5959380..ac6df6799158 100644 --- a/packages/astro/src/assets/types.ts +++ b/packages/astro/src/assets/types.ts @@ -6,6 +6,8 @@ export type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {}); export type ImageQuality = ImageQualityPreset | number; export type ImageInputFormat = (typeof VALID_INPUT_FORMATS)[number]; export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string & {}); +export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none'; +export type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {}); export type AssetsGlobalStaticImagesList = Map< string, @@ -86,6 +88,8 @@ export type ImageTransform = { height?: number | undefined; quality?: ImageQuality | undefined; format?: ImageOutputFormat | undefined; + fit?: ImageFit | undefined; + position?: string | undefined; [key: string]: any; }; @@ -155,6 +159,58 @@ type ImageSharedProps = T & { quality?: ImageQuality; } & ( | { + /** + * The layout type for responsive images. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config. + * + * Allowed values are `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`. + * + * - `responsive` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions. + * - `fixed` - The image will maintain its original dimensions. + * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio, even if that means the image will exceed its original dimensions. + * + * **Example**: + * ```astro + * ... + * ``` + */ + + layout?: ImageLayout; + + /** + * Defines how the image should be cropped if the aspect ratio is changed. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config. + * + * Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service. + * + * **Example**: + * ```astro + * ... + * ``` + */ + + fit?: ImageFit; + + /** + * Defines the position of the image when cropping. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config. + * + * The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service. + * + * **Example**: + * ```astro + * ... + * ``` + */ + + position?: string; + /** + * If true, the image will be loaded with a higher priority. This can be useful for images that are visible above the fold. There should usually be only one image with `priority` set to `true` per page. + * All other images will be lazy-loaded according to when they are in the viewport. + * **Example**: + * ```astro + * ... + * ``` + */ + priority?: boolean; + /** * A list of widths to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element. * @@ -171,6 +227,9 @@ type ImageSharedProps = T & { */ densities?: (number | `${number}x`)[]; widths?: never; + layout?: never; + fit?: never; + position?: never; } ); diff --git a/packages/astro/src/assets/utils/imageAttributes.ts b/packages/astro/src/assets/utils/imageAttributes.ts new file mode 100644 index 000000000000..1b17e11b6320 --- /dev/null +++ b/packages/astro/src/assets/utils/imageAttributes.ts @@ -0,0 +1,49 @@ +import { toStyleString } from '../../runtime/server/render/util.js'; +import type { AstroConfig } from '../../types/public/config.js'; +import type { GetImageResult, ImageLayout, LocalImageProps, RemoteImageProps } from '../types.js'; + +export function addCSSVarsToStyle( + vars: Record, + styles?: string | Record, +) { + const cssVars = Object.entries(vars) + .filter(([_, value]) => value !== undefined && value !== false) + .map(([key, value]) => `--${key}: ${value};`) + .join(' '); + + if (!styles) { + return cssVars; + } + const style = typeof styles === 'string' ? styles : toStyleString(styles); + + return `${cssVars} ${style}`; +} + +const cssFitValues = ['fill', 'contain', 'cover', 'scale-down']; + +export function applyResponsiveAttributes< + T extends LocalImageProps | RemoteImageProps, +>({ + layout, + image, + props, + additionalAttributes, +}: { + layout: Exclude; + image: GetImageResult; + additionalAttributes: Record; + props: T; +}) { + const attributes = { ...additionalAttributes, ...image.attributes }; + attributes.style = addCSSVarsToStyle( + { + w: image.attributes.width ?? props.width ?? image.options.width, + h: image.attributes.height ?? props.height ?? image.options.height, + fit: cssFitValues.includes(props.fit ?? '') && props.fit, + pos: props.position, + }, + attributes.style, + ); + attributes['data-astro-image'] = layout; + return attributes; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 037fae725a97..8214ce6657f6 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -115,14 +115,14 @@ export default function assets({ settings }: { settings: AstroSettings }): vite. }, load(id) { if (id === resolvedVirtualModuleId) { - return ` + return /* ts */ ` export { getConfiguredImageService, isLocalService } from "astro/assets"; import { getImage as getImageInternal } from "astro/assets"; export { default as Image } from "astro/components/Image.astro"; export { default as Picture } from "astro/components/Picture.astro"; export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js"; - export const imageConfig = ${JSON.stringify(settings.config.image)}; + export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })}; // This is used by the @astrojs/node integration to locate images. // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel) // new URL("dist/...") is interpreted by the bundler as a signal to include that directory diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 48af43339c96..67228cb0976c 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -95,6 +95,7 @@ export const ASTRO_CONFIG_DEFAULTS = { experimental: { clientPrerender: false, contentIntellisense: false, + responsiveImages: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -284,6 +285,10 @@ export const AstroConfigSchema = z.object({ }), ) .default([]), + experimentalLayout: z.enum(['responsive', 'fixed', 'full-width', 'none']).optional(), + experimentalObjectFit: z.string().optional(), + experimentalObjectPosition: z.string().optional(), + experimentalBreakpoints: z.array(z.number()).optional(), }) .default(ASTRO_CONFIG_DEFAULTS.image), devToolbar: z @@ -525,6 +530,10 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense), + responsiveImages: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`, @@ -688,7 +697,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) { 'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop', }) .superRefine((configuration, ctx) => { - const { site, i18n, output } = configuration; + const { site, i18n, output, image, experimental } = configuration; const hasDomains = i18n?.domains ? Object.keys(i18n.domains).length > 0 : false; if (hasDomains) { if (!site) { @@ -705,6 +714,19 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) { }); } } + if ( + !experimental.responsiveImages && + (image.experimentalLayout || + image.experimentalObjectFit || + image.experimentalObjectPosition || + image.experimentalBreakpoints) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'The `experimentalLayout`, `experimentalObjectFit`, `experimentalObjectPosition` and `experimentalBreakpoints` options are only available when `experimental.responsiveImages` is enabled.', + }); + } }); return AstroConfigRelativeSchema; diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index 45c0345e507d..9c771a0de02b 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -28,7 +28,8 @@ export const toAttributeString = (value: any, shouldEscape = true) => const kebab = (k: string) => k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); -const toStyleString = (obj: Record) => + +export const toStyleString = (obj: Record) => Object.entries(obj) .filter(([_, v]) => (typeof v === 'string' && v.trim()) || typeof v === 'number') .map(([k, v]) => { diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 1f8318726b19..47d6d2860285 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -6,6 +6,7 @@ import type { ShikiConfig, } from '@astrojs/markdown-remark'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; +import type { ImageFit, ImageLayout } from '../../assets/types.js'; import type { RemotePattern } from '../../assets/utils/remotePattern.js'; import type { AssetsPrefix } from '../../core/app/types.js'; import type { AstroConfigType } from '../../core/config/schema.js'; @@ -1070,6 +1071,51 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ remotePatterns?: Partial[]; + + /** + * @docs + * @name image.experimentalLayout + * @type {ImageLayout} + * @default `undefined` + * @description + * The default layout type for responsive images. Can be overridden by the `layout` prop on the image component. + * Requires the `experimental.responsiveImages` flag to be enabled. + * - `responsive` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions. + * - `fixed` - The image will maintain its original dimensions. + * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio. + */ + experimentalLayout?: ImageLayout | undefined; + /** + * @docs + * @name image.experimentalObjectFit + * @type {ImageFit} + * @default `"cover"` + * @description + * The default object-fit value for responsive images. Can be overridden by the `fit` prop on the image component. + * Requires the `experimental.responsiveImages` flag to be enabled. + */ + experimentalObjectFit?: ImageFit; + /** + * @docs + * @name image.experimentalObjectPosition + * @type {string} + * @default `"center"` + * @description + * The default object-position value for responsive images. Can be overridden by the `position` prop on the image component. + * Requires the `experimental.responsiveImages` flag to be enabled. + */ + experimentalObjectPosition?: string; + /** + * @docs + * @name image.experimentalBreakpoints + * @type {number[]} + * @default `[640, 750, 828, 1080, 1280, 1668, 2048, 2560] | [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2560, 3200, 3840, 4480, 5120, 6016]` + * @description + * The breakpoints used to generate responsive images. Requires the `experimental.responsiveImages` flag to be enabled. The full list is not normally used, + * but is filtered according to the source and output size. The defaults used depend on whether a local or remote image service is used. For remote services + * the more comprehensive list is used, because only the required sizes are generated. For local services, the list is shorter to reduce the number of images generated. + */ + experimentalBreakpoints?: number[]; }; /** @@ -1699,6 +1745,125 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature. */ contentIntellisense?: boolean; + + /** + * @docs + * @name experimental.responsiveImages + * @type {boolean} + * @default `undefined` + * @version 5.0.0 + * @description + * + * Enables automatic responsive images in your project. + * + * ```js title=astro.config.mjs + * { + * experimental: { + * responsiveImages: true, + * }, + * } + * ``` + * + * When enabled, you can pass a `layout` props to any `` or `` component to create a responsive image. When a layout is set, images have automatically generated `srcset` and `sizes` attributes based on the image's dimensions and the layout type. Images with `responsive` and `full-width` layouts will have styles applied to ensure they resize according to their container. + * + * ```astro title=MyComponent.astro + * --- + * import { Image, Picture } from 'astro:assets'; + * import myImage from '../assets/my_image.png'; + * --- + * A description of my image. + * + * ``` + * This `` component will generate the following HTML output: + * ```html title=Output + * + * A description of my image + * ``` + * + * The following styles are applied to ensure the images resize correctly: + * + * ```css title="Responsive Image Styles" + * [data-astro-image] { + * width: 100%; + * height: auto; + * object-fit: var(--fit); + * object-position: var(--pos); + * aspect-ratio: var(--w) / var(--h) + * } + * + * [data-astro-image=responsive] { + * max-width: calc(var(--w) * 1px); + * max-height: calc(var(--h) * 1px) + * } + * + * [data-astro-image=fixed] { + * width: calc(var(--w) * 1px); + * height: calc(var(--h) * 1px) + * } + * ``` + * You can enable responsive images for all `` and `` components by setting `image.experimentalLayout` with a default value. This can be overridden by the `layout` prop on each component. + * + * **Example:** + * ```js title=astro.config.mjs + * { + * image: { + * // Used for all `` and `` components unless overridden + * experimentalLayout: 'responsive', + * }, + * experimental: { + * responsiveImages: true, + * }, + * } + * ``` + * + * ```astro title=MyComponent.astro + * --- + * import { Image } from 'astro:assets'; + * import myImage from '../assets/my_image.png'; + * --- + * + * This will use responsive layout + * + * This will use full-width layout + * + * This will disable responsive images + * ``` + * + * #### Responsive image properties + * + * These are additional properties available to the `` and `` components when responsive images are enabled: + * + * - `layout`: The layout type for the image. Can be `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`. + * - `fit`: Defines how the image should be cropped if the aspect ratio is changed. Values match those of CSS `object-fit`. Defaults to `cover`, or the value of `image.experimentalObjectFit` if set. + * - `position`: Defines the position of the image crop if the aspect ratio is changed. Values match those of CSS `object-position`. Defaults to `center`, or the value of `image.experimentalObjectPosition` if set. + * - `priority`: If set, eagerly loads the image. Otherwise images will be lazy-loaded. Use this for your largest above-the-fold image. Defaults to `false`. + * + * The following `` component properties should not be used with responsive images as these are automatically generated: + * + * - `densities` + * - `widths` + * - `sizes` + */ + + responsiveImages?: boolean; }; } diff --git a/packages/astro/test/content-collections-render.test.js b/packages/astro/test/content-collections-render.test.js index 31ed04a15ae5..972e4313a537 100644 --- a/packages/astro/test/content-collections-render.test.js +++ b/packages/astro/test/content-collections-render.test.js @@ -26,7 +26,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Includes styles - assert.equal($('link[rel=stylesheet]').length, 1); + assert.equal($('link[rel=stylesheet]').length, 2); }); it('Excludes CSS for non-rendered entries', async () => { @@ -34,7 +34,7 @@ describe('Content Collections - render()', () => { const $ = cheerio.load(html); // Excludes styles - assert.equal($('link[rel=stylesheet]').length, 0); + assert.equal($('link[rel=stylesheet]').length, 1); }); it('De-duplicates CSS used both in layout and directly in target page', async () => { @@ -110,7 +110,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Includes styles - assert.equal($('link[rel=stylesheet]').length, 1); + assert.equal($('link[rel=stylesheet]').length, 2); }); it('Exclude CSS for non-rendered entries', async () => { @@ -121,7 +121,7 @@ describe('Content Collections - render()', () => { const $ = cheerio.load(html); // Includes styles - assert.equal($('link[rel=stylesheet]').length, 0); + assert.equal($('link[rel=stylesheet]').length, 1); }); it('De-duplicates CSS used both in layout and directly in target page', async () => { @@ -202,7 +202,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Includes styles - assert.equal($('head > style').length, 1); + assert.equal($('head > style').length, 2); assert.ok($('head > style').text().includes("font-family: 'Comic Sans MS'")); }); diff --git a/packages/astro/test/core-image-layout.test.js b/packages/astro/test/core-image-layout.test.js new file mode 100644 index 000000000000..8cbcb8b20fda --- /dev/null +++ b/packages/astro/test/core-image-layout.test.js @@ -0,0 +1,579 @@ +import assert from 'node:assert/strict'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import parseSrcset from 'parse-srcset'; +import { Logger } from '../dist/core/logger/core.js'; +import { testImageService } from './test-image-service.js'; +import { testRemoteImageService } from './test-remote-image-service.js'; +import { loadFixture } from './test-utils.js'; + +describe('astro:image:layout', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + describe('local image service', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-layout/', + image: { + service: testImageService({ foo: 'bar' }), + domains: ['avatars.githubusercontent.com'], + }, + }); + + devServer = await fixture.startDevServer({}); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('basics', () => { + let $; + before(async () => { + let res = await fixture.fetch('/'); + let html = await res.text(); + $ = cheerio.load(html); + }); + + it('Adds the tag', () => { + let $img = $('#local img'); + assert.equal($img.length, 1); + assert.equal($img.attr('src').startsWith('/_image'), true); + }); + + it('includes lazy loading attributes', () => { + let $img = $('#local img'); + assert.equal($img.attr('loading'), 'lazy'); + assert.equal($img.attr('decoding'), 'async'); + assert.equal($img.attr('fetchpriority'), 'auto'); + }); + + it('includes priority loading attributes', () => { + let $img = $('#local-priority img'); + assert.equal($img.attr('loading'), 'eager'); + assert.equal($img.attr('decoding'), 'sync'); + assert.equal($img.attr('fetchpriority'), 'high'); + }); + + it('has width and height - no dimensions set', () => { + let $img = $('#local img'); + assert.equal($img.attr('width'), '2316'); + assert.equal($img.attr('height'), '1544'); + }); + + it('has proper width and height - only width', () => { + let $img = $('#local-width img'); + assert.equal($img.attr('width'), '350'); + assert.equal($img.attr('height'), '233'); + }); + + it('has proper width and height - only height', () => { + let $img = $('#local-height img'); + assert.equal($img.attr('width'), '300'); + assert.equal($img.attr('height'), '200'); + }); + + it('has proper width and height - has both width and height', () => { + let $img = $('#local-both img'); + assert.equal($img.attr('width'), '300'); + assert.equal($img.attr('height'), '400'); + }); + + it('sets the style', () => { + let $img = $('#local-both img'); + assert.match($img.attr('style'), /--w: 300/); + assert.match($img.attr('style'), /--h: 400/); + assert.equal($img.data('astro-image'), 'responsive'); + }); + + it('sets the style when no dimensions set', () => { + let $img = $('#local img'); + assert.match($img.attr('style'), /--w: 2316/); + assert.match($img.attr('style'), /--h: 1544/); + assert.equal($img.data('astro-image'), 'responsive'); + }); + + it('sets style for fixed image', () => { + let $img = $('#local-fixed img'); + assert.match($img.attr('style'), /--w: 800/); + assert.match($img.attr('style'), /--h: 600/); + assert.equal($img.data('astro-image'), 'fixed'); + }); + + it('sets style for full-width image', () => { + let $img = $('#local-full-width img'); + assert.equal($img.data('astro-image'), 'full-width'); + }); + + it('passes in a parent class', () => { + let $img = $('#local-class img'); + assert.match($img.attr('class'), /green/); + }); + + it('passes in a parent style', () => { + let $img = $('#local-style img'); + assert.match($img.attr('style'), /border: 2px red solid/); + }); + + it('passes in a parent style as an object', () => { + let $img = $('#local-style-object img'); + assert.match($img.attr('style'), /border:2px red solid/); + }); + + it('injects a style tag', () => { + const style = $('style').text(); + assert.match(style, /\[data-astro-image\]/); + }); + }); + + describe('srcsets', () => { + let $; + before(async () => { + let res = await fixture.fetch('/'); + let html = await res.text(); + $ = cheerio.load(html); + }); + + it('has srcset', () => { + let $img = $('#local img'); + assert.ok($img.attr('srcset')); + const srcset = parseSrcset($img.attr('srcset')); + assert.equal(srcset.length, 8); + assert.equal(srcset[0].url.startsWith('/_image'), true); + const widths = srcset.map((x) => x.w); + assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048, 2316]); + }); + + it('constrained - has max of 2x requested size', () => { + let $img = $('#local-constrained img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.equal(widths.at(-1), 1600); + }); + + it('constrained - just has 1x and 2x when smaller than min breakpoint', () => { + let $img = $('#local-both img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [300, 600]); + }); + + it('fixed - has just 1x and 2x', () => { + let $img = $('#local-fixed img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [800, 1600]); + }); + + it('full-width: has all breakpoints below image size, ignoring dimensions', () => { + let $img = $('#local-full-width img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048]); + }); + }); + + describe('generated URLs', () => { + let $; + before(async () => { + let res = await fixture.fetch('/fit'); + let html = await res.text(); + $ = cheerio.load(html); + }); + it('generates width and height in image URLs when both are provided', () => { + let $img = $('#local-both img'); + const aspectRatio = 300 / 400; + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + const width = parseInt(params.get('w')); + const height = parseInt(params.get('h')); + assert.equal(width / height, aspectRatio); + } + }); + + it('does not pass through fit and position', async () => { + const fit = $('#fit-cover img'); + assert.ok(!fit.attr('fit')); + const position = $('#position img'); + assert.ok(!position.attr('position')); + }); + + it('sets a default fit of "cover" when no fit is provided', () => { + let $img = $('#fit-default img'); + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + assert.equal(params.get('fit'), 'cover'); + } + }); + + it('sets a fit of "contain" when fit="contain" is provided', () => { + let $img = $('#fit-contain img'); + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + assert.equal(params.get('fit'), 'contain'); + } + }); + + it('sets no fit when fit="none" is provided', () => { + let $img = $('#fit-none img'); + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + assert.ok(!params.has('fit')); + } + }); + }); + + describe('remote images', () => { + describe('srcset', () => { + let $; + before(async () => { + let res = await fixture.fetch('/remote'); + let html = await res.text(); + $ = cheerio.load(html); + }); + it('has srcset', () => { + let $img = $('#constrained img'); + assert.ok($img.attr('srcset')); + const srcset = parseSrcset($img.attr('srcset')); + const widths = srcset.map((x) => x.w); + assert.deepEqual(widths, [640, 750, 800, 828, 1080, 1280, 1600]); + }); + + it('constrained - has max of 2x requested size', () => { + let $img = $('#constrained img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.equal(widths.at(-1), 1600); + }); + + it('constrained - just has 1x and 2x when smaller than min breakpoint', () => { + let $img = $('#small img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [300, 600]); + }); + + it('fixed - has just 1x and 2x', () => { + let $img = $('#fixed img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [800, 1600]); + }); + + it('full-width: has all breakpoints', () => { + let $img = $('#full-width img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048, 2560]); + }); + }); + }); + + describe('picture component', () => { + /** Original image dimensions */ + const originalWidth = 2316; + const originalHeight = 1544; + + /** @type {import("cheerio").CheerioAPI} */ + let $; + before(async () => { + let res = await fixture.fetch('/picture'); + let html = await res.text(); + $ = cheerio.load(html); + }); + + describe('basics', () => { + it('creates picture and img elements', () => { + let $picture = $('#picture-density-2-format picture'); + let $img = $('#picture-density-2-format img'); + assert.equal($picture.length, 1); + assert.equal($img.length, 1); + }); + + it('includes source elements for each format', () => { + let $sources = $('#picture-density-2-format source'); + assert.equal($sources.length, 2); // avif and webp formats + + const types = $sources.map((_, el) => $(el).attr('type')).get(); + assert.deepEqual(types.sort(), ['image/avif', 'image/webp']); + }); + + it('generates responsive srcset matching layout breakpoints', () => { + let $source = $('#picture-density-2-format source').first(); + const srcset = parseSrcset($source.attr('srcset')); + + const widths = srcset.map((s) => s.w); + assert.deepEqual(widths, [640, 750, 828, 1080, 1158, 1280, 1668, 2048, 2316]); + }); + + it('has proper width and height attributes', () => { + let $img = $('#picture-density-2-format img'); + // Width is set to half of original in the component + const expectedWidth = Math.round(originalWidth / 2); + const expectedHeight = Math.round(originalHeight / 2); + + assert.equal($img.attr('width'), expectedWidth.toString()); + assert.equal($img.attr('height'), expectedHeight.toString()); + }); + }); + + describe('responsive variants', () => { + it('constrained - has max of 2x requested size', () => { + let $source = $('#picture-constrained source').first(); + const widths = parseSrcset($source.attr('srcset')).map((s) => s.w); + assert.equal(widths.at(-1), 1600); // Max should be 2x the 800px width + + let $img = $('#picture-constrained img'); + const aspectRatio = originalWidth / originalHeight; + assert.equal($img.attr('width'), '800'); + assert.equal($img.attr('height'), Math.round(800 / aspectRatio).toString()); + }); + + it('constrained - just has 1x and 2x when smaller than min breakpoint', () => { + let $source = $('#picture-both source').first(); + const widths = parseSrcset($source.attr('srcset')).map((s) => s.w); + assert.deepEqual(widths, [300, 600]); // Just 1x and 2x for small images + + let $img = $('#picture-both img'); + assert.equal($img.attr('width'), '300'); + assert.equal($img.attr('height'), '400'); + }); + + it('fixed - has just 1x and 2x', () => { + let $source = $('#picture-fixed source').first(); + const widths = parseSrcset($source.attr('srcset')).map((s) => s.w); + assert.deepEqual(widths, [400, 800]); // Fixed layout only needs 1x and 2x + + let $img = $('#picture-fixed img'); + assert.equal($img.attr('width'), '400'); + assert.equal($img.attr('height'), '300'); + }); + + it('full-width: has all breakpoints below image size', () => { + let $source = $('#picture-full-width source').first(); + const widths = parseSrcset($source.attr('srcset')).map((s) => s.w); + assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048]); + }); + }); + + describe('fallback format', () => { + it('uses specified fallback format', () => { + let $img = $('#picture-fallback img'); + const imageURL = new URL($img.attr('src'), 'http://localhost'); + assert.equal(imageURL.searchParams.get('f'), 'jpeg'); + }); + + it('does not add fallbackFormat as an attribute', () => { + let $img = $('#picture-fallback img'); + assert.equal($img.attr('fallbackformat'), undefined); + }); + + it('maintains original aspect ratio', () => { + let $img = $('#picture-fallback img'); + const width = parseInt($img.attr('width')); + const height = parseInt($img.attr('height')); + const imageAspectRatio = width / height; + const originalAspectRatio = originalWidth / originalHeight; + + // Allow for small rounding differences + assert.ok(Math.abs(imageAspectRatio - originalAspectRatio) < 0.01); + }); + }); + + describe('attributes', () => { + it('applies class to img element', () => { + let $img = $('#picture-attributes img'); + assert.ok($img.attr('class').includes('img-comp')); + }); + + it('applies pictureAttributes to picture element', () => { + let $picture = $('#picture-attributes picture'); + assert.ok($picture.attr('class').includes('picture-comp')); + }); + + it('adds inline style attributes', () => { + let $img = $('#picture-attributes img'); + const style = $img.attr('style'); + assert.match(style, /--w:/); + assert.match(style, /--h:/); + }); + + it('passing in style as an object', () => { + let $img = $('#picture-style-object img'); + const style = $img.attr('style'); + assert.match(style, /border:2px red solid/); + }); + + it('passing in style as a string', () => { + let $img = $('#picture-style img'); + const style = $img.attr('style'); + assert.match(style, /border: 2px red solid/); + }); + }); + + describe('MIME types', () => { + it('creates source elements with correct MIME types', () => { + const $sources = $('#picture-mime-types source'); + const types = $sources.map((_, el) => $(el).attr('type')).get(); + + // Should have all specified formats in correct MIME type format + const expectedTypes = [ + // Included twice because we pass jpg and jpeg + 'image/jpeg', + 'image/jpeg', + 'image/png', + 'image/avif', + 'image/webp', + ]; + + assert.deepEqual(types.sort(), expectedTypes.sort()); + }); + + it('uses valid MIME type format', () => { + const $sources = $('#picture-mime-types source'); + const validMimeTypes = [ + 'image/webp', + 'image/jpeg', + 'image/avif', + 'image/png', + 'image/gif', + 'image/svg+xml', + ]; + + $sources.each((_, source) => { + const type = $(source).attr('type'); + assert.ok( + validMimeTypes.includes(type), + `Expected type attribute value to be a valid MIME type: ${type}`, + ); + }); + }); + }); + }); + }); + + describe('remote image service', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + /** @type {Array<{ type: any, level: 'error', message: string; }>} */ + let logs = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-layout/', + image: { + service: testRemoteImageService({ foo: 'bar' }), + domains: ['images.unsplash.com'], + }, + }); + + devServer = await fixture.startDevServer({ + logger: new Logger({ + level: 'error', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }), + }); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('srcsets', () => { + let $; + before(async () => { + let res = await fixture.fetch('/'); + let html = await res.text(); + $ = cheerio.load(html); + }); + + it('has full srcset', () => { + let $img = $('#local img'); + assert.ok($img.attr('srcset')); + const srcset = parseSrcset($img.attr('srcset')); + assert.equal(srcset.length, 10); + assert.equal(srcset[0].url.startsWith('/_image'), true); + const widths = srcset.map((x) => x.w); + assert.deepEqual(widths, [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2316]); + }); + + it('constrained - has max of 2x requested size', () => { + let $img = $('#local-constrained img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.equal(widths.at(-1), 1600); + }); + + it('constrained - just has 1x and 2x when smaller than min breakpoint', () => { + let $img = $('#local-both img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [300, 600]); + }); + + it('fixed - has just 1x and 2x', () => { + let $img = $('#local-fixed img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [800, 1600]); + }); + + it('full-width: has all breakpoints below image size, ignoring dimensions', () => { + let $img = $('#local-full-width img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048]); + }); + }); + + describe('remote', () => { + describe('srcset', () => { + let $; + before(async () => { + let res = await fixture.fetch('/remote'); + let html = await res.text(); + $ = cheerio.load(html); + }); + it('has srcset', () => { + let $img = $('#constrained img'); + assert.ok($img.attr('srcset')); + const srcset = parseSrcset($img.attr('srcset')); + assert.equal(srcset.length, 8); + assert.equal(srcset[0].url.startsWith('/_image'), true); + const widths = srcset.map((x) => x.w); + assert.deepEqual(widths, [640, 750, 800, 828, 960, 1080, 1280, 1600]); + }); + + it('constrained - has max of 2x requested size', () => { + let $img = $('#constrained img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.equal(widths.at(-1), 1600); + }); + + it('constrained - just has 1x and 2x when smaller than min breakpoint', () => { + let $img = $('#small img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [300, 600]); + }); + + it('fixed - has just 1x and 2x', () => { + let $img = $('#fixed img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual(widths, [800, 1600]); + }); + + it('full-width: has all breakpoints', () => { + let $img = $('#full-width img'); + const widths = parseSrcset($img.attr('srcset')).map((x) => x.w); + assert.deepEqual( + widths, + [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2560, 3200, 3840, 4480, 5120, 6016], + ); + }); + }); + }); + }); +}); diff --git a/packages/astro/test/core-image-service.test.js b/packages/astro/test/core-image-service.test.js new file mode 100644 index 000000000000..0c75ed484f69 --- /dev/null +++ b/packages/astro/test/core-image-service.test.js @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { removeDir } from '@astrojs/internal-helpers/fs'; +import * as cheerio from 'cheerio'; +import { lookup as probe } from '../dist/assets/utils/vendor/image-size/lookup.js'; +import { loadFixture } from './test-utils.js'; + +async function getImageDimensionsFromFixture(fixture, path) { + /** @type { Response } */ + const res = await fixture.fetch(path instanceof URL ? path.pathname + path.search : path); + const buffer = await res.arrayBuffer(); + const { width, height } = await probe(new Uint8Array(buffer)); + return { width, height }; +} + +async function getImageDimensionsFromLocalFile(fixture, path) { + const buffer = await fixture.readFile(path, null); + const { width, height } = await probe(new Uint8Array(buffer)); + return { width, height }; +} + +describe('astro image service', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + describe('dev image service', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-layout/', + image: { + domains: ['unsplash.com'], + }, + }); + + devServer = await fixture.startDevServer({}); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('generated images', () => { + let $; + let src; + before(async () => { + const res = await fixture.fetch('/fit'); + const html = await res.text(); + $ = cheerio.load(html); + let $img = $('#local-both img'); + src = new URL($img.attr('src'), 'http://localhost').href; + }); + + it('generates correct width and height when both are provided', async () => { + const url = new URL(src); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 400); + }); + + it('generates correct height when only width is provided', async () => { + const url = new URL(src); + url.searchParams.delete('h'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('generates correct width when only height is provided', async () => { + const url = new URL(src); + url.searchParams.delete('w'); + url.searchParams.set('h', '400'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 600); + assert.equal(height, 400); + }); + + it('preserves aspect ratio when fit=inside', async () => { + const url = new URL(src); + url.searchParams.set('fit', 'inside'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=contain', async () => { + const url = new URL(src); + url.searchParams.set('fit', 'contain'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=outside', async () => { + const url = new URL(src); + url.searchParams.set('fit', 'outside'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 600); + assert.equal(height, 400); + }); + const originalWidth = 2316; + const originalHeight = 1544; + it('does not upscale image if requested size is larger than original', async () => { + const url = new URL(src); + url.searchParams.set('w', '3000'); + url.searchParams.set('h', '2000'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, originalWidth); + assert.equal(height, originalHeight); + }); + + // To match old behavior, we should upscale if the requested size is larger than the original + it('does upscale image if requested size is larger than original and fit is unset', async () => { + const url = new URL(src); + url.searchParams.set('w', '3000'); + url.searchParams.set('h', '2000'); + url.searchParams.delete('fit'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 3000); + assert.equal(height, 2000); + }); + + // To match old behavior, we should upscale if the requested size is larger than the original + it('does not upscale is only one dimension is provided and fit is set', async () => { + const url = new URL(src); + url.searchParams.set('w', '3000'); + url.searchParams.delete('h'); + url.searchParams.set('fit', 'cover'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, originalWidth); + assert.equal(height, originalHeight); + }); + }); + }); + + describe('build image service', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-layout/', + }); + removeDir(new URL('./fixtures/core-image-ssg/node_modules/.astro', import.meta.url)); + + await fixture.build(); + }); + + describe('generated images', () => { + let $; + before(async () => { + const html = await fixture.readFile('/build/index.html'); + $ = cheerio.load(html); + }); + + it('generates correct width and height when both are provided', async () => { + const path = $('.both img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 400); + }); + + it('generates correct height when only width is provided', async () => { + const path = $('.width-only img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('generates correct width when only height is provided', async () => { + const path = $('.height-only img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 600); + assert.equal(height, 400); + }); + + it('preserves aspect ratio when fit=inside', async () => { + const path = $('.fit-inside img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=contain', async () => { + const path = $('.fit-contain img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=outside', async () => { + const path = $('.fit-outside img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 600); + assert.equal(height, 400); + }); + const originalWidth = 2316; + const originalHeight = 1544; + it('does not upscale image if requested size is larger than original', async () => { + const path = $('.too-large img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, originalWidth); + assert.equal(height, originalHeight); + }); + }); + }); +}); diff --git a/packages/astro/test/fixtures/core-image-layout/astro.config.mjs b/packages/astro/test/fixtures/core-image-layout/astro.config.mjs new file mode 100644 index 000000000000..b32208e5f67a --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/astro.config.mjs @@ -0,0 +1,12 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + image: { + experimentalLayout: 'responsive', + }, + + experimental: { + responsiveImages: true + }, +}); diff --git a/packages/astro/test/fixtures/core-image-layout/package.json b/packages/astro/test/fixtures/core-image-layout/package.json new file mode 100644 index 000000000000..ce5b0f966de4 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/core-image-layout", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg b/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg new file mode 100644 index 000000000000..73f0ee316c01 Binary files /dev/null and b/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg differ diff --git a/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg b/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg new file mode 100644 index 000000000000..6479e9212665 Binary files /dev/null and b/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg differ diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/both.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/both.astro new file mode 100644 index 000000000000..b729b6a195e0 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/both.astro @@ -0,0 +1,19 @@ +--- +import { Image, Picture } from "astro:assets"; +import penguin from "../assets/penguin.jpg"; +import walrus from "../assets/walrus.jpg"; +--- + + +
+ a penguin +
+
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro new file mode 100644 index 000000000000..a4a0cc9083da --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro @@ -0,0 +1,66 @@ +--- +import { Image } from "astro:assets"; +import penguin from "../assets/penguin.jpg"; +--- + +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro new file mode 100644 index 000000000000..442f4ffb0e17 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro @@ -0,0 +1,35 @@ +--- +import { Image } from "astro:assets"; +import penguin from "../assets/penguin.jpg"; +--- + +
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro new file mode 100644 index 000000000000..7fe5b5626e17 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro @@ -0,0 +1,56 @@ +--- +import { Image, Picture } from "astro:assets"; +import penguin from "../assets/penguin.jpg"; +--- + + +
+ a penguin +
+
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ + diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro new file mode 100644 index 000000000000..88d0310ef7a3 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro @@ -0,0 +1,63 @@ +--- +import { Picture } from "astro:assets"; +import myImage from "../assets/penguin.jpg"; +--- + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro new file mode 100644 index 000000000000..60aa916c818e --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro @@ -0,0 +1,25 @@ +--- +import { Image, Picture } from "astro:assets"; + +const penguin = "https://images.unsplash.com/photo-1670392957807-b0504fc5160a?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + +--- + + + +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ diff --git a/packages/astro/test/fixtures/core-image-layout/tsconfig.json b/packages/astro/test/fixtures/core-image-layout/tsconfig.json new file mode 100644 index 000000000000..c193287fccd6 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/assets/*": ["src/assets/*"] + }, + }, + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/packages/astro/test/ssr-assets.test.js b/packages/astro/test/ssr-assets.test.js index d56ad1686b32..d11fc8673d6c 100644 --- a/packages/astro/test/ssr-assets.test.js +++ b/packages/astro/test/ssr-assets.test.js @@ -22,7 +22,7 @@ describe('SSR Assets', () => { const app = await fixture.loadTestAdapterApp(); /** @type {Set} */ const assets = app.manifest.assets; - assert.equal(assets.size, 1); + assert.equal(assets.size, 2); assert.equal(Array.from(assets)[0].endsWith('.css'), true); }); }); diff --git a/packages/astro/test/test-remote-image-service.js b/packages/astro/test/test-remote-image-service.js new file mode 100644 index 000000000000..2534b4085ec5 --- /dev/null +++ b/packages/astro/test/test-remote-image-service.js @@ -0,0 +1,26 @@ +import { fileURLToPath } from 'node:url'; +import { baseService } from '../dist/assets/services/service.js'; + +/** + * stub remote image service + * @param {{ foo?: string }} [config] + */ +export function testRemoteImageService(config = {}) { + return { + entrypoint: fileURLToPath(import.meta.url), + config, + }; +} + +/** @type {import("../dist/types/public/index.js").LocalImageService} */ +export default { + ...baseService, + propertiesToHash: [...baseService.propertiesToHash, 'data-custom'], + getHTMLAttributes(options, serviceConfig) { + options['data-service'] = 'my-custom-service'; + if (serviceConfig.service.config.foo) { + options['data-service-config'] = serviceConfig.service.config.foo; + } + return baseService.getHTMLAttributes(options); + }, +}; diff --git a/packages/astro/test/units/dev/collections-renderentry.test.js b/packages/astro/test/units/dev/collections-renderentry.test.js index 5af4a1b1d105..42e11c2a224f 100644 --- a/packages/astro/test/units/dev/collections-renderentry.test.js +++ b/packages/astro/test/units/dev/collections-renderentry.test.js @@ -101,7 +101,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Rendered the styles - assert.equal($('style').length, 1); + assert.equal($('style').length, 2); }, ); }); @@ -158,7 +158,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Rendered the styles - assert.equal($('style').length, 1); + assert.equal($('style').length, 2); }, ); }); @@ -225,7 +225,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Rendered the styles - assert.equal($('style').length, 1); + assert.equal($('style').length, 2); }, ); }); @@ -291,7 +291,7 @@ describe('Content Collections - render()', () => { assert.equal($('ul li').length, 3); // Rendered the styles - assert.equal($('style').length, 1); + assert.equal($('style').length, 2); }, ); }); diff --git a/packages/integrations/markdoc/test/image-assets.test.js b/packages/integrations/markdoc/test/image-assets.test.js index 0f98af4f1677..793bf1be6de2 100644 --- a/packages/integrations/markdoc/test/image-assets.test.js +++ b/packages/integrations/markdoc/test/image-assets.test.js @@ -38,7 +38,7 @@ describe('Markdoc - Image assets', () => { const { document } = parseHTML(html); assert.match( document.querySelector('#relative > img')?.src, - /\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp/, + /\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&w=420&h=630&f=webp/, ); }); @@ -48,7 +48,7 @@ describe('Markdoc - Image assets', () => { const { document } = parseHTML(html); assert.match( document.querySelector('#alias > img')?.src, - /\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp/, + /\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&w=420&h=280&f=webp/, ); }); diff --git a/packages/integrations/markdoc/test/propagated-assets.test.js b/packages/integrations/markdoc/test/propagated-assets.test.js index a0768448f1d9..5fe7369ceb52 100644 --- a/packages/integrations/markdoc/test/propagated-assets.test.js +++ b/packages/integrations/markdoc/test/propagated-assets.test.js @@ -45,12 +45,12 @@ describe('Markdoc - propagated assets', () => { let styleContents; if (mode === 'dev') { const styles = stylesDocument.querySelectorAll('style'); - assert.equal(styles.length, 1); - styleContents = styles[0].textContent; + assert.equal(styles.length, 2); + styleContents = styles[1].textContent; } else { const links = stylesDocument.querySelectorAll('link[rel="stylesheet"]'); - assert.equal(links.length, 1); - styleContents = await fixture.readFile(links[0].href); + assert.equal(links.length, 2); + styleContents = await fixture.readFile(links[1].href); } assert.equal(styleContents.includes('--color-base-purple: 269, 79%;'), true); }); @@ -58,10 +58,10 @@ describe('Markdoc - propagated assets', () => { it('[fails] Does not bleed styles to other page', async () => { if (mode === 'dev') { const styles = scriptsDocument.querySelectorAll('style'); - assert.equal(styles.length, 0); + assert.equal(styles.length, 1); } else { const links = scriptsDocument.querySelectorAll('link[rel="stylesheet"]'); - assert.equal(links.length, 0); + assert.equal(links.length, 1); } }); }); diff --git a/packages/integrations/mdx/test/css-head-mdx.test.js b/packages/integrations/mdx/test/css-head-mdx.test.js index 96ee7c9001b3..d55e2f52ac4e 100644 --- a/packages/integrations/mdx/test/css-head-mdx.test.js +++ b/packages/integrations/mdx/test/css-head-mdx.test.js @@ -28,7 +28,7 @@ describe('Head injection w/ MDX', () => { const { document } = parseHTML(html); const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); + assert.equal(links.length, 2); const scripts = document.querySelectorAll('script[type=module]'); assert.equal(scripts.length, 1); @@ -39,7 +39,7 @@ describe('Head injection w/ MDX', () => { const { document } = parseHTML(html); const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); + assert.equal(links.length, 2); }); it('injects content from a component using Content#render()', async () => { @@ -47,7 +47,7 @@ describe('Head injection w/ MDX', () => { const { document } = parseHTML(html); const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); + assert.equal(links.length, 2); const scripts = document.querySelectorAll('script[type=module]'); assert.equal(scripts.length, 1); @@ -67,7 +67,7 @@ describe('Head injection w/ MDX', () => { const $ = cheerio.load(html); const headLinks = $('head link[rel=stylesheet]'); - assert.equal(headLinks.length, 1); + assert.equal(headLinks.length, 2); const bodyLinks = $('body link[rel=stylesheet]'); assert.equal(bodyLinks.length, 0); @@ -79,7 +79,7 @@ describe('Head injection w/ MDX', () => { const $ = cheerio.load(html); const headLinks = $('head link[rel=stylesheet]'); - assert.equal(headLinks.length, 1); + assert.equal(headLinks.length, 2); const bodyLinks = $('body link[rel=stylesheet]'); assert.equal(bodyLinks.length, 0); @@ -92,7 +92,7 @@ describe('Head injection w/ MDX', () => { const $ = cheerio.load(html); const headLinks = $('head link[rel=stylesheet]'); - assert.equal(headLinks.length, 1); + assert.equal(headLinks.length, 2); const bodyLinks = $('body link[rel=stylesheet]'); assert.equal(bodyLinks.length, 0); diff --git a/packages/integrations/mdx/test/mdx-math.test.js b/packages/integrations/mdx/test/mdx-math.test.js index 5352eca68c8b..a68c5cbe745c 100644 --- a/packages/integrations/mdx/test/mdx-math.test.js +++ b/packages/integrations/mdx/test/mdx-math.test.js @@ -28,7 +28,7 @@ describe('MDX math', () => { const mjxContainer = document.querySelector('mjx-container[jax="SVG"]'); assert.notEqual(mjxContainer, null); - const mjxStyle = document.querySelector('style').innerHTML; + const mjxStyle = document.querySelectorAll('style')[1].innerHTML; assert.equal( mjxStyle.includes('mjx-container[jax="SVG"]'), true, @@ -62,7 +62,7 @@ describe('MDX math', () => { const mjxContainer = document.querySelector('mjx-container[jax="CHTML"]'); assert.notEqual(mjxContainer, null); - const mjxStyle = document.querySelector('style').innerHTML; + const mjxStyle = document.querySelectorAll('style')[1].innerHTML; assert.equal( mjxStyle.includes('mjx-container[jax="CHTML"]'), true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bae575df25a4..dfe8af20346a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2719,6 +2719,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/core-image-layout: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/core-image-remark-imgattr: dependencies: astro: