diff options
28 files changed, 1052 insertions, 164 deletions
diff --git a/.changeset/bright-starfishes-clap.md b/.changeset/bright-starfishes-clap.md new file mode 100644 index 000000000..2fbedbe23 --- /dev/null +++ b/.changeset/bright-starfishes-clap.md @@ -0,0 +1,5 @@ +--- +'@astrojs/image': minor +--- + +The new `<Picture />` 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 be1abc656..7f7a67daa 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 `<Image />` 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 `<Image />` and `<Picture>` 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 src={heroImage} aspectRatio="16:9" format="avif" /> + +// image imports can also be inlined directly +<Image src={import('../assets/hero.png')} /> ``` </details> @@ -176,6 +179,37 @@ description: Just a Hello World Post! ``` </details> +<details> +<summary><strong>Responsive pictures</strong></summary> + + <br /> + + The `<Picture />` component can be used to automatically build a `<picture>` 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 +<Picture src={hero} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" /> + +// Remote image (aspect ratio is required) +<Picture src={imageUrl} widths={[200, 400, 800]} aspectRatio="4:3" sizes="(max-width: 800px) 100vw, 800px" /> + +// Inlined imports are supported +<Picture src={import("../assets/hero.png")} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" /> +``` + +</details> + ## 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 51d4182a2..326c1bc6c 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<TransformOptions, 'src'>, Omit<ImageAttributes, 'src'> { +export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> { 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<TransformOptions> { - // 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<ImageMetadata> 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); - } +<img {...attrs} {loading} {decoding} /> - 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 - } +<style> + img { + content-visibility: auto; } - - return transform; -} - -const props = Astro.props as Props; - -const imageProps = await resolveProps(props); - -const attrs = await getImage(loader, imageProps); ---- - -<img {...attrs} /> +</style> diff --git a/packages/integrations/image/components/Picture.astro b/packages/integrations/image/components/Picture.astro new file mode 100644 index 000000000..ed2cfd49e --- /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<PictureAttributes, 'src' | 'width' | 'height'>, Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> { + src: ImageMetadata | Promise<{ default: ImageMetadata }>; + sizes: HTMLImageElement['sizes']; + widths: number[]; + formats?: OutputFormat[]; +} + +export interface RemoteImageProps extends Omit<PictureAttributes, 'src' | 'width' | 'height'>, TransformOptions, Omit<ImageAttributes, 'src' | 'width' | 'height'> { + 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 }); +--- + +<picture {...attrs}> + {sources.map(attrs => ( + <source {...attrs} {sizes}>))} + <img {...image} {loading} {decoding} /> +</picture> + +<style> + img { + content-visibility: auto; + } +</style> diff --git a/packages/integrations/image/components/index.js b/packages/integrations/image/components/index.js index fa9809650..be0e10130 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 5aeb3bb17..9f4fbd45c 100644 --- a/packages/integrations/image/package.json +++ b/packages/integrations/image/package.json @@ -33,7 +33,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 000000000..db52614c5 --- /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 000000000..ae423c3de --- /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<TransformOptions, 'src'> { + 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<TransformOptions> { + // 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 `<img />` 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 `<img />` element. + */ + export async function getImage( + loader: ImageService, + transform: GetImageTransform +): Promise<ImageAttributes> { + (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 `<img />` 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 000000000..370da0678 --- /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<GetPictureResult> { + 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 f87fcd4b2..8b06484e7 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 `<img />` 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 `<img />` element. - */ -export async function getImage( - loader: SSRImageService, - transform: TransformOptions -): Promise<ImageAttributes> { - (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 <img /> 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 b82a75044..86c18839d 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 b55feb7c5..427aaf7cf 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'; +/// <reference types="astro/astro-jsx" /> +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<HTMLImageElement>; +export type ImageAttributes = astroHTML.JSX.ImgHTMLAttributes; +export type PictureAttributes = astroHTML.JSX.HTMLAttributes; export interface HostedImageService<T extends TransformOptions = TransformOptions> { /** @@ -81,10 +83,9 @@ export interface HostedImageService<T extends TransformOptions = TransformOption getImageAttributes(transform: T): Promise<ImageAttributes>; } -export interface SSRImageService<T extends TransformOptions = TransformOptions> - extends HostedImageService<T> { +export interface SSRImageService<T extends TransformOptions = TransformOptions> extends HostedImageService<T> { /** - * Gets the HTML attributes needed for the server rendered `<img />` element. + * Gets tthe HTML attributes needed for the server rendered `<img />` element. */ getImageAttributes(transform: T): Promise<Exclude<ImageAttributes, 'src'>>; /** @@ -115,6 +116,14 @@ export type ImageService<T extends TransformOptions = TransformOptions> = | HostedImageService<T> | SSRImageService<T>; +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 95e0fb2a1..44c338cf4 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 42b4411a4..502e42c96 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 6ee02360b..34deda90e 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'; <br /> <Image id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="webp" /> <br /> - <Image id='testing' src={import('../assets/social.jpg')} width={506} format="avif" /> + <Image id='inline' src={import('../assets/social.jpg')} width={506} /> </body> </html> 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 000000000..45a11dc9d --- /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 000000000..23c91f009 --- /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 Binary files differnew file mode 100644 index 000000000..578ad458b --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico 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 000000000..d7a0a7a40 --- /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 <time> warning +console.error = () => {}; diff --git a/packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpg b/packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpg Binary files differnew file mode 100644 index 000000000..c58aacf66 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpg diff --git a/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpg b/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpg Binary files differnew file mode 100644 index 000000000..906c76144 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpg diff --git a/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.png b/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.png Binary files differnew file mode 100644 index 000000000..1399856f1 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.png diff --git a/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro b/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro new file mode 100644 index 000000000..e3e0ade30 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro @@ -0,0 +1,17 @@ +--- +import socialJpg from '../assets/social.jpg'; +import { Picture } from '@astrojs/image'; +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <Picture id="social-jpg" src={socialJpg} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} /> + <br /> + <Picture id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" sizes="(min-width: 640px) 50vw, 100vw" widths={[272, 544]} aspectRatio={544/184} /> + <br /> + <Picture id='inline' src={import('../assets/social.jpg')} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} /> + </body> +</html> diff --git a/packages/integrations/image/test/image-ssg.test.js b/packages/integrations/image/test/image-ssg.test.js index 7df097d41..b314844b6 100644 --- a/packages/integrations/image/test/image-ssg.test.js +++ b/packages/integrations/image/test/image-ssg.test.js @@ -1,6 +1,5 @@ import { expect } from 'chai'; import * as cheerio from 'cheerio'; -import path from 'path'; import sizeOf from 'image-size'; import { fileURLToPath } from 'url'; import { loadFixture } from './test-utils.js'; @@ -38,6 +37,16 @@ describe('SSG images', function () { expect(image.attr('width')).to.equal('506'); expect(image.attr('height')).to.equal('253'); }); + }); + + describe('Inline imports', () => { + it ('includes src, width, and height attributes', () => { + const image = $('#inline'); + + expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); it('built the optimized image', () => { verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' }); @@ -111,6 +120,36 @@ describe('SSG images', function () { }); }); + describe('Local images with inline imports', () => { + it('includes src, width, and height attributes', () => { + const image = $('#inline'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + + it('returns the optimized image', async () => { + const image = $('#inline'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + describe('Remote images', () => { it('includes src, width, and height attributes', () => { const image = $('#google'); diff --git a/packages/integrations/image/test/image-ssr.test.js b/packages/integrations/image/test/image-ssr.test.js index 9881d090a..784a92e53 100644 --- a/packages/integrations/image/test/image-ssr.test.js +++ b/packages/integrations/image/test/image-ssr.test.js @@ -62,6 +62,32 @@ describe('SSR images - build', function () { }); }); + describe('Inline imports', () => { + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#inline'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + }); + describe('Remote images', () => { it('includes src, width, and height attributes', async () => { const app = await fixture.loadTestAdapterApp(); @@ -142,6 +168,25 @@ describe('SSR images - dev', function () { }); }); + describe('Inline imports', () => { + it('includes src, width, and height attributes', () => { + const image = $('#inline'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + }); + describe('Remote images', () => { it('includes src, width, and height attributes', () => { const image = $('#google'); diff --git a/packages/integrations/image/test/picture-ssg.test.js b/packages/integrations/image/test/picture-ssg.test.js new file mode 100644 index 000000000..084c4d95b --- /dev/null +++ b/packages/integrations/image/test/picture-ssg.test.js @@ -0,0 +1,263 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import fs from 'fs'; +import sizeOf from 'image-size'; +import { fileURLToPath } from 'url'; +import { loadFixture } from './test-utils.js'; + +let fixture; + +describe('SSG pictures', function () { + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-picture/' }); + }); + + function verifyImage(pathname, expected) { + const url = new URL('./fixtures/basic-picture/dist/' + pathname, import.meta.url); + const dist = fileURLToPath(url); + + // image-size doesn't support AVIF files + if (expected.type !== 'avif') { + const result = sizeOf(dist); + expect(result).to.deep.equal(expected); + } else { + expect(fs.statSync(dist)).not.to.be.undefined; + } + } + + describe('build', () => { + let $; + let html; + + before(async () => { + await fixture.build(); + + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg img'); + + expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); + + it('built the optimized image', () => { + verifyImage('_image/assets/social_253x127.avif', { width: 253, height: 127, type: 'avif' }); + verifyImage('_image/assets/social_253x127.webp', { width: 253, height: 127, type: 'webp' }); + verifyImage('_image/assets/social_253x127.jpg', { width: 253, height: 127, type: 'jpg' }); + verifyImage('_image/assets/social_506x253.avif', { width: 506, height: 253, type: 'avif' }); + verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' }); + verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); + + describe('Inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#inline img'); + + expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); + + it('built the optimized image', () => { + verifyImage('_image/assets/social_253x127.avif', { width: 253, height: 127, type: 'avif' }); + verifyImage('_image/assets/social_253x127.webp', { width: 253, height: 127, type: 'webp' }); + verifyImage('_image/assets/social_253x127.jpg', { width: 253, height: 127, type: 'jpg' }); + verifyImage('_image/assets/social_506x253.avif', { width: 506, height: 253, type: 'avif' }); + verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' }); + verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#google img'); + + expect(image.attr('src')).to.equal('/_image/googlelogo_color_272x92dp_544x184.png'); + expect(image.attr('width')).to.equal('544'); + expect(image.attr('height')).to.equal('184'); + }); + + it('built the optimized image', () => { + verifyImage('_image/googlelogo_color_272x92dp_272x92.avif', { + width: 272, + height: 92, + type: 'avif', + }); + verifyImage('_image/googlelogo_color_272x92dp_272x92.webp', { + width: 272, + height: 92, + type: 'webp', + }); + verifyImage('_image/googlelogo_color_272x92dp_272x92.png', { + width: 272, + height: 92, + type: 'png', + }); + verifyImage('_image/googlelogo_color_272x92dp_544x184.avif', { + width: 544, + height: 184, + type: 'avif', + }); + verifyImage('_image/googlelogo_color_272x92dp_544x184.webp', { + width: 544, + height: 184, + type: 'webp', + }); + verifyImage('_image/googlelogo_color_272x92dp_544x184.png', { + width: 544, + height: 184, + type: 'png', + }); + }); + }); + }); + + describe('dev', () => { + let devServer; + let $; + + before(async () => { + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + + it('returns the optimized image', async () => { + const image = $('#social-jpg img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Local images with inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + + it('returns the optimized image', async () => { + const image = $('#inline img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); + }); + }); + }); +}); diff --git a/packages/integrations/image/test/picture-ssr.test.js b/packages/integrations/image/test/picture-ssr.test.js new file mode 100644 index 000000000..ebef4249b --- /dev/null +++ b/packages/integrations/image/test/picture-ssr.test.js @@ -0,0 +1,278 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from '../../../astro/test/test-adapter.js'; + +describe('SSR pictures - build', function () { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basic-picture/', + adapter: testAdapter(), + experimental: { + ssr: true, + }, + }); + await fixture.build(); + }); + + describe('Local images', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + + // TODO: Track down why the fixture.fetch is failing with the test adapter + it.skip('built the optimized image', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#social-jpg img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Inline imports', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + }); + + describe('Remote images', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('googlelogo_color_272x92dp.png')).to.equal(true); + }); + }); +}); + +describe('SSR images - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basic-picture/', + adapter: testAdapter(), + experimental: { + ssr: true, + }, + }); + + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + + it('returns the optimized image', async () => { + const image = $('#social-jpg img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21cc060a4..d62ab659b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1966,6 +1966,16 @@ importers: '@astrojs/node': link:../../../../node astro: link:../../../../../astro + packages/integrations/image/test/fixtures/basic-picture: + specifiers: + '@astrojs/image': workspace:* + '@astrojs/node': workspace:* + astro: workspace:* + dependencies: + '@astrojs/image': link:../../.. + '@astrojs/node': link:../../../../node + astro: link:../../../../../astro + packages/integrations/lit: specifiers: '@lit-labs/ssr': ^2.2.0 |