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

- + diff --git a/packages/integrations/image/test/fixtures/basic-picture/astro.config.mjs b/packages/integrations/image/test/fixtures/basic-picture/astro.config.mjs new file mode 100644 index 000000000000..45a11dc9dda8 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; +import image from '@astrojs/image'; + +// https://astro.build/config +export default defineConfig({ + site: 'http://localhost:3000', + integrations: [image()] +}); diff --git a/packages/integrations/image/test/fixtures/basic-picture/package.json b/packages/integrations/image/test/fixtures/basic-picture/package.json new file mode 100644 index 000000000000..23c91f0095b4 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/basic-picture", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/image": "workspace:*", + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico b/packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico new file mode 100644 index 000000000000..578ad458b890 Binary files /dev/null and b/packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico differ diff --git a/packages/integrations/image/test/fixtures/basic-picture/server/server.mjs b/packages/integrations/image/test/fixtures/basic-picture/server/server.mjs new file mode 100644 index 000000000000..d7a0a7a40f77 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/server/server.mjs @@ -0,0 +1,44 @@ +import { createServer } from 'http'; +import fs from 'fs'; +import mime from 'mime'; +import { handler as ssrHandler } from '../dist/server/entry.mjs'; + +const clientRoot = new URL('../dist/client/', import.meta.url); + +async function handle(req, res) { + ssrHandler(req, res, async (err) => { + if (err) { + res.writeHead(500); + res.end(err.stack); + return; + } + + let local = new URL('.' + req.url, clientRoot); + try { + const data = await fs.promises.readFile(local); + res.writeHead(200, { + 'Content-Type': mime.getType(req.url), + }); + res.end(data); + } catch { + res.writeHead(404); + res.end(); + } + }); +} + +const server = createServer((req, res) => { + handle(req, res).catch((err) => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain', + }); + res.end(err.toString()); + }); +}); + +server.listen(8085); +console.log('Serving at http://localhost:8085'); + +// Silence weird