0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: experimental responsive images (#12377)

* chore: changeset

* feat: add experimental responsive images config option (#12378)

* feat: add experimental responsive images config option

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update config types

* Move config into `images`

* Move jsdocs

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* feat: add responsive image component (#12381)

* feat: add experimental responsive images config option

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update config types

* Move config into `images`

* Move jsdocs

* wip: responsive image component

* Improve srcset logic

* Improve fixture

* Lock

* Update styling

* Fix style prop handling

* Update test (there's an extra style for images now)

* Safely access the src props

* Remove unused export

* Handle priority images

* Consolidate styles

* Update tests

* Comment

* Refactor srcset

* Avoid dupes of original image

* Calculate missing dimensions

* Bugfixes

* Add tests

* Fix test

* Correct order

* Lint

* Fix fspath

* Update test

* Fix test

* Conditional component per flag

* Fix class concatenation

* Remove logger

* Rename helper

* Add comments

* Format

* Fix markdoc tests

* Add test for style tag

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* feat: add crop support to image services (#12414)

* wip: add crop support to image services

* Add tests

* Strip crop attributes

* Don't upscale

* Format

* Get build working properly

* Changes from review

* Fix jsdoc

* feat: add responsive support to picture component (#12423)

* feat: add responsive support to picture component

* Add picture tests

* Fix test

* Implement own define vars

* Share logic

* Prevent extra astro-* class

* Use dataset scoping

* Revert unit test

* Revert "Fix test"

This reverts commit f9ec6e2938.

* Changes from review

* docs: add docs for responsive images (#12429)

* docs: add responsive images flag docs

* docs: adds jsdoc for image components

* chore: updates from review

* Fix jsdoc

* Changes from review

* Add breakpoints option

* typo

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Matt Kane 2024-11-15 13:29:52 +00:00 committed by GitHub
parent c8f877cad2
commit af867f3910
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1845 additions and 107 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Adds experimental reponsive image support

View file

@ -47,7 +47,9 @@ declare module 'astro:assets' {
getImage: (
options: import('./dist/assets/types.js').UnresolvedImageTransform,
) => Promise<import('./dist/assets/types.js').GetImageResult>;
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;

View file

@ -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 };
---
<img src={image.src} {...additionalAttributes} {...image.attributes} />
{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}
<img src={image.src} {...attributes} class={attributes.class} />

View file

@ -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) {
);
})
}
<img src={fallbackImage.src} {...imgAdditionalAttributes} {...fallbackImage.attributes} />
{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}
<img src={fallbackImage.src} {...attributes} class={attributes.class} />
</picture>

View file

@ -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);
}

View file

@ -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',
];

View file

@ -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<ImageService> {
return globalThis.astroAsset.imageService;
}
type ImageConfig = AstroConfig['image'] & {
experimentalResponsiveImages: boolean;
};
export async function getImage(
options: UnresolvedImageTransform,
imageConfig: AstroConfig['image'],
imageConfig: ImageConfig,
): Promise<GetImageResult> {
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 {

View file

@ -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<number>;
originalWidth?: number;
}): Array<number> => {
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;
}
};

View file

@ -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<LocalImageService, 'transform'> = {
// 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<LocalImageService, 'transform'> = {
decoding: attributes.decoding ?? 'async',
};
},
getSrcSet(options) {
const srcSet: UnresolvedSrcSetValue[] = [];
const { targetWidth } = getTargetDimensions(options);
getSrcSet(options): Array<UnresolvedSrcSetValue> {
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<LocalImageService, 'transform'> = {
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<LocalImageService, 'transform'> = {
} = 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<LocalImageService, 'transform'> = {
// 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<LocalImageService, 'transform'> = {
h: 'height',
q: 'quality',
f: 'format',
fit: 'fit',
position: 'position',
};
Object.entries(params).forEach(([param, key]) => {
@ -359,6 +383,8 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
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;

View file

@ -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<ImageFit, keyof FitEnum> = {
fill: 'fill',
contain: 'inside',
cover: 'cover',
none: 'outside',
'scale-down': 'inside',
outside: 'outside',
inside: 'inside',
};
const sharpService: LocalImageService<SharpImageServiceConfig> = {
validateOptions: baseService.validateOptions,
getURL: baseService.getURL,
@ -46,7 +56,6 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
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<SharpImageServiceConfig> = {
// 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) {

View file

@ -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> = 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
* <Image src={...} layout="responsive" alt="..." />
* ```
*/
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
* <Image src={...} fit="contain" alt="..." />
* ```
*/
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
* <Image src={...} position="center top" alt="..." />
* ```
*/
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
* <Image src={...} priority alt="..." />
* ```
*/
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> = T & {
*/
densities?: (number | `${number}x`)[];
widths?: never;
layout?: never;
fit?: never;
position?: never;
}
);

View file

@ -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<string, string | false | undefined>,
styles?: string | Record<string, any>,
) {
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<unknown> | RemoteImageProps<unknown>,
>({
layout,
image,
props,
additionalAttributes,
}: {
layout: Exclude<ImageLayout, 'none'>;
image: GetImageResult;
additionalAttributes: Record<string, any>;
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;
}

View file

@ -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

View file

@ -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;

View file

@ -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<string, any>) =>
export const toStyleString = (obj: Record<string, any>) =>
Object.entries(obj)
.filter(([_, v]) => (typeof v === 'string' && v.trim()) || typeof v === 'number')
.map(([k, v]) => {

View file

@ -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<RemotePattern>[];
/**
* @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 `<Image />` or `<Picture />` 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';
* ---
* <Image src={myImage} alt="A description of my image." layout='responsive' width={800} height={600} />
* <Picture src={myImage} alt="A description of my image." layout='full-width' formats={['avif', 'webp', 'jpeg']} />
* ```
* This `<Image />` component will generate the following HTML output:
* ```html title=Output
*
* <img
* src="/_astro/my_image.hash3.webp"
* srcset="/_astro/my_image.hash1.webp 640w,
* /_astro/my_image.hash2.webp 750w,
* /_astro/my_image.hash3.webp 800w,
* /_astro/my_image.hash4.webp 828w,
* /_astro/my_image.hash5.webp 1080w,
* /_astro/my_image.hash6.webp 1280w,
* /_astro/my_image.hash7.webp 1600w"
* alt="A description of my image"
* sizes="(min-width: 800px) 800px, 100vw"
* loading="lazy"
* decoding="async"
* fetchpriority="auto"
* width="800"
* height="600"
* style="--w: 800; --h: 600; --fit: cover; --pos: center;"
* data-astro-image="responsive"
* >
* ```
*
* 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 `<Image />` and `<Picture />` 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 `<Image />` and `<Picture />` components unless overridden
* experimentalLayout: 'responsive',
* },
* experimental: {
* responsiveImages: true,
* },
* }
* ```
*
* ```astro title=MyComponent.astro
* ---
* import { Image } from 'astro:assets';
* import myImage from '../assets/my_image.png';
* ---
*
* <Image src={myImage} alt="This will use responsive layout" width={800} height={600} />
*
* <Image src={myImage} alt="This will use full-width layout" layout="full-width" />
*
* <Image src={myImage} alt="This will disable responsive images" layout="none" />
* ```
*
* #### Responsive image properties
*
* These are additional properties available to the `<Image />` and `<Picture />` 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 `<Image />` component properties should not be used with responsive images as these are automatically generated:
*
* - `densities`
* - `widths`
* - `sizes`
*/
responsiveImages?: boolean;
};
}

View file

@ -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'"));
});

View file

@ -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 <img> 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],
);
});
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
experimentalLayout: 'responsive',
},
experimental: {
responsiveImages: true
},
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/core-image-layout",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,19 @@
---
import { Image, Picture } from "astro:assets";
import penguin from "../assets/penguin.jpg";
import walrus from "../assets/walrus.jpg";
---
<div id="image">
<Image src={penguin} alt="a penguin" />
</div>
<div id="picture">
<Picture src={walrus} alt="a walrus" formats={['webp', 'jpeg']}/>
</div>
<style>
.green {
border: 2px green solid;
}
</style>

View file

@ -0,0 +1,66 @@
---
import { Image } from "astro:assets";
import penguin from "../assets/penguin.jpg";
---
<div class="both">
<Image src={penguin} alt="a penguin" width={300} height={400}/>
</div>
<div class="width-only">
<Image src={penguin} alt="a penguin" width={300}/>
</div>
<div class="height-only">
<Image src={penguin} alt="a penguin" height={400}/>
</div>
<div class="fit-contain">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="contain"
/>
</div>
<div class="fit-scale-down">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="scale-down"
/>
</div>
<div class="fit-outside">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="outside"
/>
</div>
<div class="fit-inside">
<Image
src={penguin}
alt="a penguin"
width={300}
height={400}
fit="inside"
/>
</div>
<div class="too-large">
<Image
src={penguin}
alt="a penguin"
width={3000}
height={2000}
fit="cover"
/>
</div>

View file

@ -0,0 +1,35 @@
---
import { Image } from "astro:assets";
import penguin from "../assets/penguin.jpg";
---
<div id="local-both">
<Image src={penguin} alt="a penguin" width={300} height={400}/>
</div>
<div id="fit-default">
<Image src={penguin} alt="a penguin" />
</div>
<div id="fit-fill">
<Image src={penguin} alt="a penguin" fit="fill" />
</div>
<div id="fit-contain">
<Image src={penguin} alt="a penguin" fit="contain" />
</div>
<div id="fit-cover">
<Image src={penguin} alt="a penguin" fit="cover" />
</div>
<div id="fit-scale-down">
<Image src={penguin} alt="a penguin" fit="scale-down" />
</div>
<div id="fit-inside">
<Image src={penguin} alt="a penguin" fit="inside" />
</div>
<div id="fit-none">
<Image src={penguin} alt="a penguin" fit="none" />
</div>
<div id="fit-unsupported">
<Image src={penguin} alt="a penguin" fit="unsupported" />
</div>
<div id="position">
<Image src={penguin} alt="a penguin" position="right top" />
</div>

View file

@ -0,0 +1,56 @@
---
import { Image, Picture } from "astro:assets";
import penguin from "../assets/penguin.jpg";
---
<div id="local">
<Image src={penguin} alt="a penguin" />
</div>
<div id="local-priority">
<Image src={penguin} alt="a penguin" priority />
</div>
<div id="local-width">
<Image src={penguin} alt="a penguin" width={350} />
</div>
<div id="local-height">
<Image src={penguin} alt="a penguin" height={200}/>
</div>
<div id="local-both">
<Image src={penguin} alt="a penguin" width={300} height={400}/>
</div>
<div id="local-class">
<Image src={penguin} alt="a penguin" width={300} height={400} class="green"/>
</div>
<div id="local-style">
<Image src={penguin} alt="a penguin" width={300} height={400} style="border: 2px red solid"/>
</div>
<div id="local-style-object">
<Image src={penguin} alt="a penguin" width={300} height={400} style={{
border: '2px red solid',
}}/>
</div>
<div id="local-constrained">
<Image src={penguin} alt="a penguin" width={800} height={600} />
</div>
<div id="local-fixed">
<Image src={penguin} alt="a penguin" width={800} height={600} layout="fixed"/>
</div>
<div id="local-full-width">
<Image src={penguin} alt="a penguin" width={800} height={600} layout="full-width"/>
</div>
<style>
.green {
border: 2px green solid;
}
</style>

View file

@ -0,0 +1,63 @@
---
import { Picture } from "astro:assets";
import myImage from "../assets/penguin.jpg";
---
<div id="picture-density-2-format">
<Picture src={myImage} width={Math.floor(myImage.width / 2)} alt="A penguin" formats={['avif', 'webp']} />
</div>
<div id="picture-fallback">
<Picture src={myImage} fallbackFormat="jpeg" alt="A penguin" />
</div>
<div id="picture-attributes">
<Picture src={myImage} fallbackFormat="jpeg" alt="A penguin" class="img-comp" pictureAttributes={{ class: 'picture-comp' }} />
</div>
<div id="picture-mime-types">
<Picture alt="A penguin" src={myImage} formats={['jpg', 'jpeg', 'png', 'avif', 'webp']} />
</div>
<div id="picture-constrained">
<Picture src={myImage} width={800} alt="A penguin" />
</div>
<div id="picture-small">
<Picture src={myImage} width={300} alt="A penguin" />
</div>
<div id="picture-both">
<Picture src={myImage} width={300} height={400} alt="A penguin" />
</div>
<div id="picture-fixed">
<Picture src={myImage} width={400} height={300} layout="fixed" alt="A penguin" />
</div>
<div id="picture-full-width">
<Picture src={myImage} layout="full-width" alt="A penguin" />
</div>
<div id="picture-style">
<Picture src={myImage} alt="a penguin" width={300} height={400} style="border: 2px red solid"/>
</div>
<div id="picture-style-object">
<Picture src={myImage} alt="a penguin" width={300} height={400} style={{
border: '2px red solid',
}}/>
</div>
<style>
.img-comp {
border: 5px solid blue;
}
.picture-comp {
border: 5px solid red;
display: inline-block;
}
</style>

View file

@ -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"
---
<div id="small">
<Image src={penguin} alt="a penguin" width={300} height={400}/>
</div>
<div id="constrained">
<Image src={penguin} alt="a penguin" width={800} height={600} />
</div>
<div id="fixed">
<Image src={penguin} alt="a penguin" width={800} height={600} layout="fixed"/>
</div>
<div id="full-width">
<Image src={penguin} alt="a penguin" width={800} height={600} layout="full-width"/>
</div>

View file

@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/assets/*": ["src/assets/*"]
},
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View file

@ -22,7 +22,7 @@ describe('SSR Assets', () => {
const app = await fixture.loadTestAdapterApp();
/** @type {Set<string>} */
const assets = app.manifest.assets;
assert.equal(assets.size, 1);
assert.equal(assets.size, 2);
assert.equal(Array.from(assets)[0].endsWith('.css'), true);
});
});

View file

@ -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);
},
};

View file

@ -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);
},
);
});

View file

@ -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/,
);
});

View file

@ -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);
}
});
});

View file

@ -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);

View file

@ -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,

View file

@ -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: