summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Erika <3019731+Princesseuh@users.noreply.github.com> 2023-10-11 17:53:58 +0200
committerGravatar GitHub <noreply@github.com> 2023-10-11 17:53:58 +0200
commitb2ae9ee0c42b11ffc1d3f070d1d5ac881aef84ed (patch)
treeab6bf7933fc96f2bf0c49842e527b26180611041
parent3f231cefed58c46315dda2e99f9d82a2678337d2 (diff)
downloadastro-b2ae9ee0c42b11ffc1d3f070d1d5ac881aef84ed.tar.gz
astro-b2ae9ee0c42b11ffc1d3f070d1d5ac881aef84ed.tar.zst
astro-b2ae9ee0c42b11ffc1d3f070d1d5ac881aef84ed.zip
feat(assets): Add support for `srcset` and a Picture component (#8620)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
-rw-r--r--.changeset/smooth-goats-agree.md56
-rw-r--r--packages/astro/client.d.ts12
-rw-r--r--packages/astro/components/Image.astro8
-rw-r--r--packages/astro/components/Picture.astro57
-rw-r--r--packages/astro/package.json1
-rw-r--r--packages/astro/src/assets/consts.ts1
-rw-r--r--packages/astro/src/assets/internal.ts24
-rw-r--r--packages/astro/src/assets/services/service.ts153
-rw-r--r--packages/astro/src/assets/services/sharp.ts5
-rw-r--r--packages/astro/src/assets/services/squoosh.ts5
-rw-r--r--packages/astro/src/assets/types.ts35
-rw-r--r--packages/astro/src/assets/utils/transformToPath.ts4
-rw-r--r--packages/astro/src/assets/vite-plugin-assets.ts1
-rw-r--r--packages/astro/src/core/errors/errors-data.ts15
-rw-r--r--packages/astro/test/core-image.test.js51
-rw-r--r--packages/astro/test/fixtures/core-image-ssg/src/pages/picturecomponent.astro6
-rw-r--r--packages/astro/test/fixtures/core-image/src/pages/picturecomponent.astro12
-rw-r--r--pnpm-lock.yaml7
18 files changed, 413 insertions, 40 deletions
diff --git a/.changeset/smooth-goats-agree.md b/.changeset/smooth-goats-agree.md
new file mode 100644
index 000000000..3c72235c3
--- /dev/null
+++ b/.changeset/smooth-goats-agree.md
@@ -0,0 +1,56 @@
+---
+'astro': minor
+---
+
+Adds experimental support for generating `srcset` attributes and a new `<Picture />` component.
+
+## `srcset` support
+
+Two new properties have been added to `Image` and `getImage()`: `densities` and `widths`.
+
+These properties can be used to generate a `srcset` attribute, either based on absolute widths in pixels (e.g. [300, 600, 900]) or pixel density descriptors (e.g. `["2x"]` or `[1.5, 2]`).
+
+
+```astro
+---
+import { Image } from "astro";
+import myImage from "./my-image.jpg";
+---
+
+<Image src={myImage} width={myImage.width / 2} densities={[1.5, 2]} alt="My cool image" />
+```
+
+```html
+<img
+ src="/_astro/my_image.hash.webp"
+ srcset="/_astro/my_image.hash.webp 1.5x, /_astro/my_image.hash.webp 2x"
+ alt="My cool image"
+/>
+```
+
+## Picture component
+
+The experimental `<Picture />` component can be used to generate a `<picture>` element with multiple `<source>` elements.
+
+The example below uses the `format` property to generate a `<source>` in each of the specified image formats:
+
+```astro
+---
+import { Picture } from "astro:assets";
+import myImage from "./my-image.jpg";
+---
+
+<Picture src={myImage} formats={["avif", "webp"]} alt="My super image in multiple formats!" />
+```
+
+The above code will generate the following HTML, and allow the browser to determine the best image to display:
+
+```html
+<picture>
+ <source srcset="..." type="image/avif" />
+ <source srcset="..." type="image/webp" />
+ <img src="..." alt="My super image in multiple formats!" />
+</picture>
+```
+
+The `Picture` component takes all the same props as the `Image` component, including the new `densities` and `widths` properties.
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index c75ae7971..f20c3d089 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -53,6 +53,7 @@ declare module 'astro:assets' {
imageConfig: import('./dist/@types/astro.js').AstroConfig['image'];
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
Image: typeof import('./components/Image.astro').default;
+ Picture: typeof import('./components/Picture.astro').default;
};
type ImgAttributes = import('./dist/type-utils.js').WithRequired<
@@ -66,17 +67,10 @@ declare module 'astro:assets' {
export type RemoteImageProps = import('./dist/type-utils.js').Simplify<
import('./dist/assets/types.js').RemoteImageProps<ImgAttributes>
>;
- export const { getImage, getConfiguredImageService, imageConfig, Image }: AstroAssets;
+ export const { getImage, getConfiguredImageService, imageConfig, Image, Picture }: AstroAssets;
}
-type InputFormat = import('./dist/assets/types.js').ImageInputFormat;
-
-interface ImageMetadata {
- src: string;
- width: number;
- height: number;
- format: InputFormat;
-}
+type ImageMetadata = import('./dist/assets/types.js').ImageMetadata;
declare module '*.gif' {
const metadata: ImageMetadata;
diff --git a/packages/astro/components/Image.astro b/packages/astro/components/Image.astro
index 00b0cc5fa..a11efd4f9 100644
--- a/packages/astro/components/Image.astro
+++ b/packages/astro/components/Image.astro
@@ -23,6 +23,12 @@ if (typeof props.height === 'string') {
}
const image = await getImage(props);
+
+const additionalAttributes: Record<string, any> = {};
+
+if (image.srcSet.values.length > 0) {
+ additionalAttributes.srcset = image.srcSet.attribute;
+}
---
-<img src={image.src} {...image.attributes} />
+<img src={image.src} {...additionalAttributes} {...image.attributes} />
diff --git a/packages/astro/components/Picture.astro b/packages/astro/components/Picture.astro
new file mode 100644
index 000000000..5ea2516d6
--- /dev/null
+++ b/packages/astro/components/Picture.astro
@@ -0,0 +1,57 @@
+---
+import { getImage, type LocalImageProps, type RemoteImageProps } from 'astro:assets';
+import type { GetImageResult, ImageOutputFormat } from '../dist/@types/astro';
+import { isESMImportedImage } from '../dist/assets/internal';
+import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
+import type { HTMLAttributes } from '../types';
+
+type Props = (LocalImageProps | RemoteImageProps) & {
+ formats?: ImageOutputFormat[];
+ fallbackFormat?: ImageOutputFormat;
+ pictureAttributes?: HTMLAttributes<'picture'>;
+};
+
+const { formats = ['webp'], pictureAttributes = {}, ...props } = Astro.props;
+
+if (props.alt === undefined || props.alt === null) {
+ throw new AstroError(AstroErrorData.ImageMissingAlt);
+}
+
+const optimizedImages: GetImageResult[] = await Promise.all(
+ formats.map(
+ async (format) =>
+ await getImage({ ...props, format: format, widths: props.widths, densities: props.densities })
+ )
+);
+
+const fallbackFormat =
+ props.fallbackFormat ?? isESMImportedImage(props.src)
+ ? ['svg', 'gif'].includes(props.src.format)
+ ? props.src.format
+ : 'png'
+ : 'png';
+
+const fallbackImage = await getImage({
+ ...props,
+ format: fallbackFormat,
+ widths: props.widths,
+ densities: props.densities,
+});
+
+const additionalAttributes: Record<string, any> = {};
+if (fallbackImage.srcSet.values.length > 0) {
+ additionalAttributes.srcset = fallbackImage.srcSet.attribute;
+}
+---
+
+<picture {...pictureAttributes}>
+ {
+ Object.entries(optimizedImages).map(([_, image]) => (
+ <source
+ srcset={`${image.src}${image.srcSet.values.length > 0 ? ' , ' + image.srcSet.attribute : ''}`}
+ type={"image/" + image.options.format}
+ />
+ ))
+ }
+ <img src={fallbackImage.src} {...additionalAttributes} {...fallbackImage.attributes} />
+</picture>
diff --git a/packages/astro/package.json b/packages/astro/package.json
index f64cf1c8a..8c55980e3 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -213,6 +213,7 @@
"mocha": "^10.2.0",
"network-information-types": "^0.1.1",
"node-mocks-http": "^1.13.0",
+ "parse-srcset": "^1.0.2",
"rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.0.1",
"rehype-toc": "^3.0.2",
diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts
index 90dfa599c..687582b8e 100644
--- a/packages/astro/src/assets/consts.ts
+++ b/packages/astro/src/assets/consts.ts
@@ -24,4 +24,5 @@ export const VALID_SUPPORTED_FORMATS = [
'svg',
'avif',
] as const;
+export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts
index 310b746a8..f7df11f69 100644
--- a/packages/astro/src/assets/internal.ts
+++ b/packages/astro/src/assets/internal.ts
@@ -6,6 +6,7 @@ import type {
GetImageResult,
ImageMetadata,
ImageTransform,
+ SrcSetValue,
UnresolvedImageTransform,
} from './types.js';
import { matchHostname, matchPattern } from './utils/remotePattern.js';
@@ -93,22 +94,41 @@ export async function getImage(
? await service.validateOptions(resolvedOptions, imageConfig)
: resolvedOptions;
+ // Get all the options for the different srcSets
+ const srcSetTransforms = service.getSrcSet
+ ? await service.getSrcSet(validatedOptions, imageConfig)
+ : [];
+
let imageURL = await service.getURL(validatedOptions, imageConfig);
+ let srcSets: SrcSetValue[] = await Promise.all(
+ srcSetTransforms.map(async (srcSet) => ({
+ url: await service.getURL(srcSet.transform, imageConfig),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ }))
+ );
- // In build and for local services, we need to collect the requested parameters so we can generate the final images
if (
isLocalService(service) &&
globalThis.astroAsset.addStaticImage &&
- // If `getURL` returned the same URL as the user provided, it means the service doesn't need to do anything
!(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
) {
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
+ srcSets = srcSetTransforms.map((srcSet) => ({
+ url: globalThis.astroAsset.addStaticImage!(srcSet.transform),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ }));
}
return {
rawOptions: resolvedOptions,
options: validatedOptions,
src: imageURL,
+ srcSet: {
+ values: srcSets,
+ attribute: srcSets.map((srcSet) => `${srcSet.url} ${srcSet.descriptor}`).join(', '),
+ },
attributes:
service.getHTMLAttributes !== undefined
? await service.getHTMLAttributes(validatedOptions, imageConfig)
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
index 69894aa0c..9812c95c3 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -1,7 +1,7 @@
import type { AstroConfig } from '../../@types/astro.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { isRemotePath, joinPaths } from '../../core/path.js';
-import { VALID_SUPPORTED_FORMATS } from '../consts.js';
+import { DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
import { isESMImportedImage, isRemoteAllowed } from '../internal.js';
import type { ImageOutputFormat, ImageTransform } from '../types.js';
@@ -28,6 +28,12 @@ type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & {
service: { entrypoint: string; config: T };
};
+type SrcSetValue = {
+ transform: ImageTransform;
+ descriptor?: string;
+ attributes?: Record<string, any>;
+};
+
interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> {
/**
* Return the URL to the endpoint or URL your images are generated from.
@@ -39,6 +45,16 @@ interface SharedServiceProps<T extends Record<string, any> = Record<string, any>
*/
getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>;
/**
+ * Generate additional `srcset` values for the image.
+ *
+ * While in most cases this is exclusively used for `srcset`, it can also be used in a more generic way to generate
+ * multiple variants of the same image. For instance, you can use this to generate multiple aspect ratios or multiple formats.
+ */
+ getSrcSet?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>
+ ) => SrcSetValue[] | Promise<SrcSetValue[]>;
+ /**
* Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
*
* For example, you might want to return the `width` and `height` to avoid CLS, or a particular `class` or `style`.
@@ -174,6 +190,10 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
});
}
+ if (options.widths && options.densities) {
+ throw new AstroError(AstroErrorData.IncompatibleDescriptorOptions);
+ }
+
// We currently do not support processing SVGs, so whenever the input format is a SVG, force the output to also be one
if (options.src.format === 'svg') {
options.format = 'svg';
@@ -183,30 +203,15 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
// If the user didn't specify a format, we'll default to `webp`. It offers the best ratio of compatibility / quality
// In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif
if (!options.format) {
- options.format = 'webp';
+ options.format = DEFAULT_OUTPUT_FORMAT;
}
return options;
},
getHTMLAttributes(options) {
- let targetWidth = options.width;
- let targetHeight = options.height;
- if (isESMImportedImage(options.src)) {
- const aspectRatio = options.src.width / options.src.height;
- if (targetHeight && !targetWidth) {
- // If we have a height but no width, use height to calculate the width
- targetWidth = Math.round(targetHeight * aspectRatio);
- } else if (targetWidth && !targetHeight) {
- // If we have a width but no height, use width to calculate the height
- targetHeight = Math.round(targetWidth / aspectRatio);
- } else if (!targetWidth && !targetHeight) {
- // If we have neither width or height, use the original image's dimensions
- targetWidth = options.src.width;
- targetHeight = options.src.height;
- }
- }
-
- const { src, width, height, format, quality, ...attributes } = options;
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const { src, width, height, format, quality, densities, widths, formats, ...attributes } =
+ options;
return {
...attributes,
@@ -216,6 +221,89 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
decoding: attributes.decoding ?? 'async',
};
},
+ getSrcSet(options) {
+ const srcSet: SrcSetValue[] = [];
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const { widths, densities } = options;
+ const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;
+
+ const aspectRatio = targetWidth / targetHeight;
+ const imageWidth = isESMImportedImage(options.src) ? options.src.width : options.width;
+ const maxWidth = imageWidth ?? Infinity;
+
+ // REFACTOR: Could we merge these two blocks?
+ if (densities) {
+ const densityValues = densities.map((density) => {
+ if (typeof density === 'number') {
+ return density;
+ } else {
+ return parseFloat(density);
+ }
+ });
+
+ const densityWidths = densityValues
+ .sort()
+ .map((density) => Math.round(targetWidth * density));
+
+ densityWidths.forEach((width, index) => {
+ const maxTargetWidth = Math.min(width, maxWidth);
+
+ // If the user passed dimensions, we don't want to add it to the srcset
+ const { width: transformWidth, height: transformHeight, ...rest } = options;
+
+ const srcSetValue = {
+ transform: {
+ ...rest,
+ },
+ descriptor: `${densityValues[index]}x`,
+ attributes: {
+ type: `image/${targetFormat}`,
+ },
+ };
+
+ // Only set width and height if they are different from the original image, to avoid duplicated final images
+ if (maxTargetWidth !== imageWidth) {
+ srcSetValue.transform.width = maxTargetWidth;
+ srcSetValue.transform.height = Math.round(maxTargetWidth / aspectRatio);
+ }
+
+ if (targetFormat !== options.format) {
+ srcSetValue.transform.format = targetFormat;
+ }
+
+ srcSet.push(srcSetValue);
+ });
+ } else if (widths) {
+ widths.forEach((width) => {
+ const maxTargetWidth = Math.min(width, maxWidth);
+
+ const { width: transformWidth, height: transformHeight, ...rest } = options;
+
+ const srcSetValue = {
+ transform: {
+ ...rest,
+ },
+ descriptor: `${width}w`,
+ attributes: {
+ type: `image/${targetFormat}`,
+ },
+ };
+
+ if (maxTargetWidth !== imageWidth) {
+ srcSetValue.transform.width = maxTargetWidth;
+ srcSetValue.transform.height = Math.round(maxTargetWidth / aspectRatio);
+ }
+
+ if (targetFormat !== options.format) {
+ srcSetValue.transform.format = targetFormat;
+ }
+
+ srcSet.push(srcSetValue);
+ });
+ }
+
+ return srcSet;
+ },
getURL(options, imageConfig) {
const searchParams = new URLSearchParams();
@@ -260,3 +348,28 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
return transform;
},
};
+
+function getTargetDimensions(options: ImageTransform) {
+ let targetWidth = options.width;
+ let targetHeight = options.height;
+ if (isESMImportedImage(options.src)) {
+ const aspectRatio = options.src.width / options.src.height;
+ if (targetHeight && !targetWidth) {
+ // If we have a height but no width, use height to calculate the width
+ targetWidth = Math.round(targetHeight * aspectRatio);
+ } else if (targetWidth && !targetHeight) {
+ // If we have a width but no height, use width to calculate the height
+ targetHeight = Math.round(targetWidth / aspectRatio);
+ } else if (!targetWidth && !targetHeight) {
+ // If we have neither width or height, use the original image's dimensions
+ targetWidth = options.src.width;
+ targetHeight = options.src.height;
+ }
+ }
+
+ // TypeScript doesn't know this, but because of previous hooks we always know that targetWidth and targetHeight are defined
+ return {
+ targetWidth: targetWidth!,
+ targetHeight: targetHeight!,
+ };
+}
diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts
index 0819ffcd1..215299138 100644
--- a/packages/astro/src/assets/services/sharp.ts
+++ b/packages/astro/src/assets/services/sharp.ts
@@ -33,6 +33,7 @@ const sharpService: LocalImageService = {
getURL: baseService.getURL,
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
+ getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions) {
if (!sharp) sharp = await loadSharp();
@@ -49,9 +50,9 @@ const sharpService: LocalImageService = {
// Never resize using both width and height at the same time, prioritizing width.
if (transform.height && !transform.width) {
- result.resize({ height: transform.height });
+ result.resize({ height: Math.round(transform.height) });
} else if (transform.width) {
- result.resize({ width: transform.width });
+ result.resize({ width: Math.round(transform.width) });
}
if (transform.format) {
diff --git a/packages/astro/src/assets/services/squoosh.ts b/packages/astro/src/assets/services/squoosh.ts
index 95c16b8d8..5be5d4077 100644
--- a/packages/astro/src/assets/services/squoosh.ts
+++ b/packages/astro/src/assets/services/squoosh.ts
@@ -56,6 +56,7 @@ const service: LocalImageService = {
getURL: baseService.getURL,
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
+ getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions) {
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
@@ -76,12 +77,12 @@ const service: LocalImageService = {
if (transform.height && !transform.width) {
operations.push({
type: 'resize',
- height: transform.height,
+ height: Math.round(transform.height),
});
} else if (transform.width) {
operations.push({
type: 'resize',
- width: transform.width,
+ width: Math.round(transform.width),
});
}
diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts
index 1712cda1c..43bfe0f53 100644
--- a/packages/astro/src/assets/types.ts
+++ b/packages/astro/src/assets/types.ts
@@ -28,6 +28,12 @@ export interface ImageMetadata {
orientation?: number;
}
+export interface SrcSetValue {
+ url: string;
+ descriptor?: string;
+ attributes?: Record<string, string>;
+}
+
/**
* A yet to be resolved image transform. Used by `getImage`
*/
@@ -41,6 +47,8 @@ export type UnresolvedImageTransform = Omit<ImageTransform, 'src'> & {
export type ImageTransform = {
src: ImageMetadata | string;
width?: number | undefined;
+ widths?: number[] | undefined;
+ densities?: (number | `${number}x`)[] | undefined;
height?: number | undefined;
quality?: ImageQuality | undefined;
format?: ImageOutputFormat | undefined;
@@ -51,6 +59,10 @@ export interface GetImageResult {
rawOptions: ImageTransform;
options: ImageTransform;
src: string;
+ srcSet: {
+ values: SrcSetValue[];
+ attribute: string;
+ };
attributes: Record<string, any>;
}
@@ -58,7 +70,7 @@ type ImageSharedProps<T> = T & {
/**
* Width of the image, the value of this property will be used to assign the `width` property on the final `img` element.
*
- * For local images, this value will additionally be used to resize the image to the desired width, taking into account the original aspect ratio of the image.
+ * This value will additionally be used to resize the image to the desired width, taking into account the original aspect ratio of the image.
*
* **Example**:
* ```astro
@@ -85,7 +97,26 @@ type ImageSharedProps<T> = T & {
* ```
*/
height?: number | `${number}`;
-};
+} & (
+ | {
+ /**
+ * 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.
+ *
+ * This attribute is incompatible with `densities`.
+ */
+ widths?: number[];
+ densities?: never;
+ }
+ | {
+ /**
+ * A list of pixel densities to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
+ *
+ * This attribute is incompatible with `widths`.
+ */
+ densities?: (number | `${number}x`)[];
+ widths?: never;
+ }
+ );
export type LocalImageProps<T> = ImageSharedProps<T> & {
/**
diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts
index d5535137b..d9e11f322 100644
--- a/packages/astro/src/assets/utils/transformToPath.ts
+++ b/packages/astro/src/assets/utils/transformToPath.ts
@@ -16,8 +16,8 @@ export function propsToFilename(transform: ImageTransform, hash: string) {
}
export function hashTransform(transform: ImageTransform, imageService: string) {
- // take everything from transform except alt, which is not used in the hash
- const { alt, ...rest } = transform;
+ // Extract the fields we want to hash
+ const { alt, class: className, style, widths, densities, ...rest } = transform;
const hashFields = { ...rest, imageService };
return shorthash(JSON.stringify(hashFields));
}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
index fd3ca2c32..ea576fb1a 100644
--- a/packages/astro/src/assets/vite-plugin-assets.ts
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -57,6 +57,7 @@ export default function assets({
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 const imageConfig = ${JSON.stringify(settings.config.image)};
export const assetsDir = new URL(${JSON.stringify(
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
index d805bb6ff..05ccc1125 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -626,6 +626,21 @@ export const ExpectedImageOptions = {
* @see
* - [Images](https://docs.astro.build/en/guides/images/)
* @description
+ * Only one of `densities` or `widths` can be specified. Those attributes are used to construct a `srcset` attribute, which cannot have both `x` and `w` descriptors.
+ */
+export const IncompatibleDescriptorOptions = {
+ name: 'IncompatibleDescriptorOptions',
+ title: 'Cannot set both `densities` and `widths`',
+ message:
+ "Only one of `densities` or `widths` can be specified. In most cases, you'll probably want to use only `widths` if you require specific widths.",
+ hint: 'Those attributes are used to construct a `srcset` attribute, which cannot have both `x` and `w` descriptors.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
* Astro could not find an image you imported. Often, this is simply caused by a typo in the path.
*
* Images in Markdown are relative to the current file. To refer to an image that is located in the same folder as the `.md` file, the path should start with `./`
diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js
index 653ad5dfd..93c4fd023 100644
--- a/packages/astro/test/core-image.test.js
+++ b/packages/astro/test/core-image.test.js
@@ -2,6 +2,7 @@ import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { basename } from 'node:path';
import { Writable } from 'node:stream';
+import parseSrcset from 'parse-srcset';
import { removeDir } from '../dist/core/fs/index.js';
import { Logger } from '../dist/core/logger/core.js';
import testAdapter from './test-adapter.js';
@@ -188,6 +189,36 @@ describe('astro:image', () => {
expect(res.status).to.equal(200);
expect(res.headers.get('content-type')).to.equal('image/avif');
});
+
+ it('has a working Picture component', async () => {
+ let res = await fixture.fetch('/picturecomponent');
+ let html = await res.text();
+ $ = cheerio.load(html);
+
+ // Densities
+ let $img = $('#picture-density-2-format img');
+ let $picture = $('#picture-density-2-format picture');
+ let $source = $('#picture-density-2-format source');
+ expect($img).to.have.a.lengthOf(1);
+ expect($picture).to.have.a.lengthOf(1);
+ expect($source).to.have.a.lengthOf(2);
+
+ const srcset = parseSrcset($source.attr('srcset'));
+ expect(srcset.every((src) => src.url.startsWith('/_image'))).to.equal(true);
+ expect(srcset.map((src) => src.d)).to.deep.equal([undefined, 2]);
+
+ // Widths
+ $img = $('#picture-widths img');
+ $picture = $('#picture-widths picture');
+ $source = $('#picture-widths source');
+ expect($img).to.have.a.lengthOf(1);
+ expect($picture).to.have.a.lengthOf(1);
+ expect($source).to.have.a.lengthOf(1);
+
+ const srcset2 = parseSrcset($source.attr('srcset'));
+ expect(srcset2.every((src) => src.url.startsWith('/_image'))).to.equal(true);
+ expect(srcset2.map((src) => src.w)).to.deep.equal([undefined, 207]);
+ });
});
describe('vite-isms', () => {
@@ -702,6 +733,26 @@ describe('astro:image', () => {
expect(data).to.be.an.instanceOf(Buffer);
});
+ it('Picture component images are written', async () => {
+ const html = await fixture.readFile('/picturecomponent/index.html');
+ const $ = cheerio.load(html);
+ let $img = $('img');
+ let $source = $('source');
+
+ expect($img).to.have.a.lengthOf(1);
+ expect($source).to.have.a.lengthOf(2);
+
+ const srcset = parseSrcset($source.attr('srcset'));
+ let hasExistingSrc = await Promise.all(
+ srcset.map(async (src) => {
+ const data = await fixture.readFile(src.url, null);
+ return data instanceof Buffer;
+ })
+ );
+
+ expect(hasExistingSrc.every((src) => src === true)).to.deep.equal(true);
+ });
+
it('markdown images are written', async () => {
const html = await fixture.readFile('/post/index.html');
const $ = cheerio.load(html);
diff --git a/packages/astro/test/fixtures/core-image-ssg/src/pages/picturecomponent.astro b/packages/astro/test/fixtures/core-image-ssg/src/pages/picturecomponent.astro
new file mode 100644
index 000000000..8515538d4
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-ssg/src/pages/picturecomponent.astro
@@ -0,0 +1,6 @@
+---
+import { Picture } from "astro:assets";
+import myImage from "../assets/penguin1.jpg";
+---
+
+<Picture src={myImage} width={Math.round(myImage.width / 2)} alt="A penguin" densities={[2]} formats={['avif', 'webp']} />
diff --git a/packages/astro/test/fixtures/core-image/src/pages/picturecomponent.astro b/packages/astro/test/fixtures/core-image/src/pages/picturecomponent.astro
new file mode 100644
index 000000000..a849964bf
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image/src/pages/picturecomponent.astro
@@ -0,0 +1,12 @@
+---
+import { Picture } from "astro:assets";
+import myImage from "../assets/penguin1.jpg";
+---
+
+<div id="picture-density-2-format">
+<Picture src={myImage} width={Math.round(myImage.width / 2)} alt="A penguin" densities={[2]} formats={['avif', 'webp']} />
+</div>
+
+<div id="picture-widths">
+<Picture src={myImage} width={Math.round(myImage.width / 2)} alt="A penguin" widths={[myImage.width]} />
+</div>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d662e17b..a2ac5d3f1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -753,6 +753,9 @@ importers:
node-mocks-http:
specifier: ^1.13.0
version: 1.13.0
+ parse-srcset:
+ specifier: ^1.0.2
+ version: 1.0.2
rehype-autolink-headings:
specifier: ^6.1.1
version: 6.1.1
@@ -14747,6 +14750,10 @@ packages:
resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==}
dev: false
+ /parse-srcset@1.0.2:
+ resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+ dev: true
+
/parse5-htmlparser2-tree-adapter@7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies: