diff --git a/.changeset/bright-starfishes-clap.md b/.changeset/bright-starfishes-clap.md
new file mode 100644
index 000000000000..2fbedbe23f63
--- /dev/null
+++ b/.changeset/bright-starfishes-clap.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/image': minor
+---
+
+The new ` ` component adds art direction support for building responsive images with multiple sizes and file types :tada:
diff --git a/packages/integrations/image/README.md b/packages/integrations/image/README.md
index be1abc65676e..7f7a67daaefc 100644
--- a/packages/integrations/image/README.md
+++ b/packages/integrations/image/README.md
@@ -17,7 +17,7 @@ This **[Astro integration][astro-integration]** makes it easy to optimize images
Images play a big role in overall site performance and usability. Serving properly sized images makes all the difference but is often tricky to automate.
-This integration provides a basic ` ` component and image transformer powered by [sharp](https://sharp.pixelplumbing.com/), with full support for static sites and server-side rendering. The built-in `sharp` transformer is also replacable, opening the door for future integrations that work with your favorite hosted image service.
+This integration provides ` ` and `` components as well as a basic image transformer powered by [sharp](https://sharp.pixelplumbing.com/), with full support for static sites and server-side rendering. The built-in `sharp` transformer is also replacable, opening the door for future integrations that work with your favorite hosted image service.
## Installation
@@ -124,6 +124,9 @@ import heroImage from '../assets/hero.png';
// cropping to a specific aspect ratio and converting to an avif format
+
+// image imports can also be inlined directly
+
```
@@ -176,6 +179,37 @@ description: Just a Hello World Post!
```
+
+Responsive pictures
+
+
+
+ The ` ` component can be used to automatically build a `` with multiple sizes and formats. Check out [MDN](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images#art_direction) for a deep dive into responsive images and art direction.
+
+ By default, the picture will include formats for `avif` and `webp` in addition to the image's original format.
+
+ For remote images, an `aspectRatio` is required to ensure the correct `height` can be calculated at build time.
+
+```html
+---
+import { Picture } from '@astrojs/image';
+import hero from '../assets/hero.png';
+
+const imageUrl = 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png';
+---
+
+// Local image with multiple sizes
+
+
+// Remote image (aspect ratio is required)
+
+
+// Inlined imports are supported
+
+```
+
+
+
## Troubleshooting
- If your installation doesn't seem to be working, make sure to restart the dev server.
- If you edit and save a file and don't see your site update accordingly, try refreshing the page.
diff --git a/packages/integrations/image/components/Image.astro b/packages/integrations/image/components/Image.astro
index 51d4182a2fc9..326c1bc6c21c 100644
--- a/packages/integrations/image/components/Image.astro
+++ b/packages/integrations/image/components/Image.astro
@@ -4,7 +4,7 @@ import loader from 'virtual:image-loader';
import { getImage } from '../src/index.js';
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.js';
-export interface LocalImageProps extends Omit, Omit {
+export interface LocalImageProps extends Omit, Omit {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
}
@@ -17,109 +17,15 @@ export interface RemoteImageProps extends TransformOptions, ImageAttributes {
export type Props = LocalImageProps | RemoteImageProps;
-function isLocalImage(props: Props): props is LocalImageProps {
- // vite-plugin-astro-image resolves ESM imported images
- // to a metadata object
- return typeof props.src !== 'string';
-}
-
-function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
- if (!aspectRatio) {
- return undefined;
- }
-
- // parse aspect ratio strings, if required (ex: "16:9")
- if (typeof aspectRatio === 'number') {
- aspectRatio = aspectRatio;
- } else {
- const [width, height] = aspectRatio.split(':');
- aspectRatio = parseInt(width) / parseInt(height);
- }
-}
-
-async function resolveProps(props: Props): Promise {
- // For remote images, just check the width/height provided
- if (!isLocalImage(props)) {
- return calculateSize(props);
- }
+const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props;
- let { width, height, aspectRatio, format, ...rest } = props;
-
- // if a Promise was provided, unwrap it first
- const { src, ...metadata } = 'then' in props.src ? (await props.src).default : props.src;
-
- if (!width && !height) {
- // neither dimension was provided, use the file metadata
- width = metadata.width;
- height = metadata.height;
- } else if (width) {
- // one dimension was provided, calculate the other
- let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
- height = height || width / ratio;
- } else if (height) {
- // one dimension was provided, calculate the other
- let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
- width = width || height * ratio;
- }
-
- return {
- ...rest,
- width,
- height,
- aspectRatio,
- src,
- format: format || metadata.format as OutputFormat,
- }
-}
-
-function calculateSize(transform: TransformOptions): TransformOptions {
- // keep width & height as provided
- if (transform.width && transform.height) {
- return transform;
- }
-
- if (!transform.width && !transform.height) {
- throw new Error(`"width" and "height" cannot both be undefined`);
- }
-
- if (!transform.aspectRatio) {
- throw new Error(`"aspectRatio" must be included if only "${transform.width ? "width": "height"}" is provided`)
- }
-
- let aspectRatio: number;
+const attrs = await getImage(loader, props);
+---
- // parse aspect ratio strings, if required (ex: "16:9")
- if (typeof transform.aspectRatio === 'number') {
- aspectRatio = transform.aspectRatio;
- } else {
- const [width, height] = transform.aspectRatio.split(':');
- aspectRatio = parseInt(width) / parseInt(height);
- }
+
- if (transform.width) {
- // only width was provided, calculate height
- return {
- ...transform,
- width: transform.width,
- height: transform.width / aspectRatio
- };
- } else if (transform.height) {
- // only height was provided, calculate width
- return {
- ...transform,
- width: transform.height * aspectRatio,
- height: transform.height
- }
+
diff --git a/packages/integrations/image/components/Picture.astro b/packages/integrations/image/components/Picture.astro
new file mode 100644
index 000000000000..ed2cfd49e387
--- /dev/null
+++ b/packages/integrations/image/components/Picture.astro
@@ -0,0 +1,39 @@
+---
+// @ts-ignore
+import loader from 'virtual:image-loader';
+import { getPicture } from '../src/get-picture.js';
+import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../src/types.js';
+
+export interface LocalImageProps extends Omit, Omit, Omit {
+ src: ImageMetadata | Promise<{ default: ImageMetadata }>;
+ sizes: HTMLImageElement['sizes'];
+ widths: number[];
+ formats?: OutputFormat[];
+}
+
+export interface RemoteImageProps extends Omit, TransformOptions, Omit {
+ src: string;
+ sizes: HTMLImageElement['sizes'];
+ widths: number[];
+ aspectRatio: TransformOptions['aspectRatio'];
+ formats?: OutputFormat[];
+}
+
+export type Props = LocalImageProps | RemoteImageProps;
+
+const { src, sizes, widths, aspectRatio, formats = ['avif', 'webp'], loading = 'lazy', decoding = 'eager', ...attrs } = Astro.props as Props;
+
+const { image, sources } = await getPicture({ loader, src, widths, formats, aspectRatio });
+---
+
+
+ {sources.map(attrs => (
+ ))}
+
+
+
+
diff --git a/packages/integrations/image/components/index.js b/packages/integrations/image/components/index.js
index fa9809650db1..be0e10130ae5 100644
--- a/packages/integrations/image/components/index.js
+++ b/packages/integrations/image/components/index.js
@@ -1 +1,2 @@
export { default as Image } from './Image.astro';
+export { default as Picture } from './Picture.astro';
diff --git a/packages/integrations/image/package.json b/packages/integrations/image/package.json
index 1c3bec67ef95..5f22199dcdbb 100644
--- a/packages/integrations/image/package.json
+++ b/packages/integrations/image/package.json
@@ -32,7 +32,8 @@
"files": [
"components",
"dist",
- "src"
+ "src",
+ "types"
],
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
diff --git a/packages/integrations/image/src/constants.ts b/packages/integrations/image/src/constants.ts
new file mode 100644
index 000000000000..db52614c53f1
--- /dev/null
+++ b/packages/integrations/image/src/constants.ts
@@ -0,0 +1,3 @@
+export const PKG_NAME = '@astrojs/image';
+export const ROUTE_PATTERN = '/_image';
+export const OUTPUT_DIR = '/_image';
diff --git a/packages/integrations/image/src/get-image.ts b/packages/integrations/image/src/get-image.ts
new file mode 100644
index 000000000000..ae423c3dec08
--- /dev/null
+++ b/packages/integrations/image/src/get-image.ts
@@ -0,0 +1,128 @@
+import slash from 'slash';
+import { ROUTE_PATTERN } from './constants.js';
+import { ImageAttributes, ImageMetadata, ImageService, isSSRService, OutputFormat, TransformOptions } from './types.js';
+import { parseAspectRatio } from './utils.js';
+
+export interface GetImageTransform extends Omit {
+ src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
+}
+
+function resolveSize(transform: TransformOptions): TransformOptions {
+ // keep width & height as provided
+ if (transform.width && transform.height) {
+ return transform;
+ }
+
+ if (!transform.width && !transform.height) {
+ throw new Error(`"width" and "height" cannot both be undefined`);
+ }
+
+ if (!transform.aspectRatio) {
+ throw new Error(`"aspectRatio" must be included if only "${transform.width ? "width": "height"}" is provided`)
+ }
+
+ let aspectRatio: number;
+
+ // parse aspect ratio strings, if required (ex: "16:9")
+ if (typeof transform.aspectRatio === 'number') {
+ aspectRatio = transform.aspectRatio;
+ } else {
+ const [width, height] = transform.aspectRatio.split(':');
+ aspectRatio = Number.parseInt(width) / Number.parseInt(height);
+ }
+
+ if (transform.width) {
+ // only width was provided, calculate height
+ return {
+ ...transform,
+ width: transform.width,
+ height: Math.round(transform.width / aspectRatio)
+ } as TransformOptions;
+ } else if (transform.height) {
+ // only height was provided, calculate width
+ return {
+ ...transform,
+ width: Math.round(transform.height * aspectRatio),
+ height: transform.height
+ };
+ }
+
+ return transform;
+}
+
+async function resolveTransform(input: GetImageTransform): Promise {
+ // for remote images, only validate the width and height props
+ if (typeof input.src === 'string') {
+ return resolveSize(input as TransformOptions);
+ }
+
+ // resolve the metadata promise, usually when the ESM import is inlined
+ const metadata = 'then' in input.src
+ ? (await input.src).default
+ : input.src;
+
+ let { width, height, aspectRatio, format = metadata.format, ...rest } = input;
+
+ if (!width && !height) {
+ // neither dimension was provided, use the file metadata
+ width = metadata.width;
+ height = metadata.height;
+ } else if (width) {
+ // one dimension was provided, calculate the other
+ let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
+ height = height || Math.round(width / ratio);
+ } else if (height) {
+ // one dimension was provided, calculate the other
+ let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
+ width = width || Math.round(height * ratio);
+ }
+
+ return {
+ ...rest,
+ src: metadata.src,
+ width,
+ height,
+ aspectRatio,
+ format: format as OutputFormat,
+ }
+}
+
+/**
+ * Gets the HTML attributes required to build an ` ` for the transformed image.
+ *
+ * @param loader @type {ImageService} The image service used for transforming images.
+ * @param transform @type {TransformOptions} The transformations requested for the optimized image.
+ * @returns @type {ImageAttributes} The HTML attributes to be included on the built ` ` element.
+ */
+ export async function getImage(
+ loader: ImageService,
+ transform: GetImageTransform
+): Promise {
+ (globalThis as any).loader = loader;
+
+ const resolved = await resolveTransform(transform);
+ const attributes = await loader.getImageAttributes(resolved);
+
+ // For SSR services, build URLs for the injected route
+ if (isSSRService(loader)) {
+ const { searchParams } = loader.serializeTransform(resolved);
+
+ // cache all images rendered to HTML
+ if (globalThis && (globalThis as any).addStaticImage) {
+ (globalThis as any)?.addStaticImage(resolved);
+ }
+
+ const src =
+ globalThis && (globalThis as any).filenameFormat
+ ? (globalThis as any).filenameFormat(resolved, searchParams)
+ : `${ROUTE_PATTERN}?${searchParams.toString()}`;
+
+ return {
+ ...attributes,
+ src: slash(src), // Windows compat
+ };
+ }
+
+ // For hosted services, return the ` ` attributes as-is
+ return attributes;
+}
diff --git a/packages/integrations/image/src/get-picture.ts b/packages/integrations/image/src/get-picture.ts
new file mode 100644
index 000000000000..370da0678504
--- /dev/null
+++ b/packages/integrations/image/src/get-picture.ts
@@ -0,0 +1,79 @@
+import { lookup } from 'mrmime';
+import { extname } from 'path';
+import { getImage } from './get-image.js';
+import { ImageAttributes, ImageMetadata, ImageService, OutputFormat, TransformOptions } from './types.js';
+import { parseAspectRatio } from './utils.js';
+
+export interface GetPictureParams {
+ loader: ImageService;
+ src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
+ widths: number[];
+ formats: OutputFormat[];
+ aspectRatio?: TransformOptions['aspectRatio'];
+}
+
+export interface GetPictureResult {
+ image: ImageAttributes;
+ sources: { type: string; srcset: string; }[];
+}
+
+async function resolveAspectRatio({ src, aspectRatio }: GetPictureParams) {
+ if (typeof src === 'string') {
+ return parseAspectRatio(aspectRatio);
+ } else {
+ const metadata = 'then' in src ? (await src).default : src;
+ return parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
+ }
+}
+
+async function resolveFormats({ src, formats }: GetPictureParams) {
+ const unique = new Set(formats);
+
+ if (typeof src === 'string') {
+ unique.add(extname(src).replace('.', '') as OutputFormat);
+ } else {
+ const metadata = 'then' in src ? (await src).default : src;
+ unique.add(extname(metadata.src).replace('.', '') as OutputFormat);
+ }
+
+ return [...unique];
+}
+
+export async function getPicture(params: GetPictureParams): Promise {
+ const { loader, src, widths, formats } = params;
+
+ const aspectRatio = await resolveAspectRatio(params);
+
+ if (!aspectRatio) {
+ throw new Error('`aspectRatio` must be provided for remote images');
+ }
+
+ async function getSource(format: OutputFormat) {
+ const imgs = await Promise.all(widths.map(async (width) => {
+ const img = await getImage(loader, { src, format, width, height: Math.round(width / aspectRatio!) });
+ return `${img.src} ${width}w`;
+ }))
+
+ return {
+ type: lookup(format) || format,
+ srcset: imgs.join(',')
+ };
+ }
+
+ // always include the original image format
+ const allFormats = await resolveFormats(params);
+
+ const image = await getImage(loader, {
+ src,
+ width: Math.max(...widths),
+ aspectRatio,
+ format: allFormats[allFormats.length - 1]
+ });
+
+ const sources = await Promise.all(allFormats.map(format => getSource(format)));
+
+ return {
+ sources,
+ image
+ }
+}
diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts
index 6d59e3e98440..0d0d6b1ae8ad 100644
--- a/packages/integrations/image/src/index.ts
+++ b/packages/integrations/image/src/index.ts
@@ -1,14 +1,11 @@
import type { AstroConfig, AstroIntegration } from 'astro';
import fs from 'fs/promises';
import path from 'path';
-import slash from 'slash';
import { fileURLToPath } from 'url';
-import type {
- ImageAttributes,
- IntegrationOptions,
- SSRImageService,
- TransformOptions,
-} from './types';
+import { OUTPUT_DIR, PKG_NAME, ROUTE_PATTERN } from './constants.js';
+export * from './get-image.js';
+export * from './get-picture.js';
+import { IntegrationOptions, TransformOptions } from './types.js';
import {
ensureDir,
isRemoteImage,
@@ -18,49 +15,6 @@ import {
} from './utils.js';
import { createPlugin } from './vite-plugin-astro-image.js';
-const PKG_NAME = '@astrojs/image';
-const ROUTE_PATTERN = '/_image';
-const OUTPUT_DIR = '/_image';
-
-/**
- * Gets the HTML attributes required to build an ` ` for the transformed image.
- *
- * @param loader @type {ImageService} The image service used for transforming images.
- * @param transform @type {TransformOptions} The transformations requested for the optimized image.
- * @returns @type {ImageAttributes} The HTML attributes to be included on the built ` ` element.
- */
-export async function getImage(
- loader: SSRImageService,
- transform: TransformOptions
-): Promise {
- (globalThis as any).loader = loader;
-
- const attributes = await loader.getImageAttributes(transform);
-
- // For SSR services, build URLs for the injected route
- if (typeof loader.transform === 'function') {
- const { searchParams } = loader.serializeTransform(transform);
-
- // cache all images rendered to HTML
- if (globalThis && (globalThis as any).addStaticImage) {
- (globalThis as any)?.addStaticImage(transform);
- }
-
- const src =
- globalThis && (globalThis as any).filenameFormat
- ? (globalThis as any).filenameFormat(transform, searchParams)
- : `${ROUTE_PATTERN}?${searchParams.toString()}`;
-
- return {
- ...attributes,
- src: slash(src), // Windows compat
- };
- }
-
- // For hosted services, return the attributes as-is
- return attributes;
-}
-
const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => {
const resolvedOptions = {
serviceEntryPoint: '@astrojs/image/sharp',
diff --git a/packages/integrations/image/src/loaders/sharp.ts b/packages/integrations/image/src/loaders/sharp.ts
index b82a75044d04..86c18839d086 100644
--- a/packages/integrations/image/src/loaders/sharp.ts
+++ b/packages/integrations/image/src/loaders/sharp.ts
@@ -4,6 +4,7 @@ import { isAspectRatioString, isOutputFormat } from '../utils.js';
class SharpService implements SSRImageService {
async getImageAttributes(transform: TransformOptions) {
+ // strip off the known attributes
const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
return {
diff --git a/packages/integrations/image/src/types.ts b/packages/integrations/image/src/types.ts
index b55feb7c5b93..427aaf7cf162 100644
--- a/packages/integrations/image/src/types.ts
+++ b/packages/integrations/image/src/types.ts
@@ -1,5 +1,6 @@
-export type { Image } from '../components/index';
-export * from './index';
+///
+export type { Image, Picture } from '../components/index.js';
+export * from './index.js';
export type InputFormat =
| 'heic'
@@ -72,7 +73,8 @@ export interface TransformOptions {
aspectRatio?: number | `${number}:${number}`;
}
-export type ImageAttributes = Partial;
+export type ImageAttributes = astroHTML.JSX.ImgHTMLAttributes;
+export type PictureAttributes = astroHTML.JSX.HTMLAttributes;
export interface HostedImageService {
/**
@@ -81,10 +83,9 @@ export interface HostedImageService;
}
-export interface SSRImageService
- extends HostedImageService {
+export interface SSRImageService extends HostedImageService {
/**
- * Gets the HTML attributes needed for the server rendered ` ` element.
+ * Gets tthe HTML attributes needed for the server rendered ` ` element.
*/
getImageAttributes(transform: T): Promise>;
/**
@@ -115,6 +116,14 @@ export type ImageService =
| HostedImageService
| SSRImageService;
+export function isHostedService(service: ImageService): service is ImageService {
+ return 'getImageSrc' in service;
+}
+
+export function isSSRService(service: ImageService): service is SSRImageService {
+ return 'transform' in service;
+}
+
export interface ImageMetadata {
src: string;
width: number;
diff --git a/packages/integrations/image/src/utils.ts b/packages/integrations/image/src/utils.ts
index 95e0fb2a11e4..44c338cf4100 100644
--- a/packages/integrations/image/src/utils.ts
+++ b/packages/integrations/image/src/utils.ts
@@ -58,3 +58,17 @@ export function propsToFilename({ src, width, height, format }: TransformOptions
return format ? src.replace(ext, format) : src;
}
+
+export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
+ if (!aspectRatio) {
+ return undefined;
+ }
+
+ // parse aspect ratio strings, if required (ex: "16:9")
+ if (typeof aspectRatio === 'number') {
+ return aspectRatio;
+ } else {
+ const [width, height] = aspectRatio.split(':');
+ return parseInt(width) / parseInt(height);
+ }
+}
diff --git a/packages/integrations/image/test/fixtures/basic-image/package.json b/packages/integrations/image/test/fixtures/basic-image/package.json
index 42b4411a4d7d..502e42c96a85 100644
--- a/packages/integrations/image/test/fixtures/basic-image/package.json
+++ b/packages/integrations/image/test/fixtures/basic-image/package.json
@@ -1,5 +1,5 @@
{
- "name": "@test/sharp",
+ "name": "@test/basic-image",
"version": "0.0.0",
"private": true,
"dependencies": {
diff --git a/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro b/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro
index 6ee02360b72e..34deda90e38f 100644
--- a/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro
+++ b/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro
@@ -12,6 +12,6 @@ import { Image } from '@astrojs/image';
-
+