summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/bright-starfishes-clap.md5
-rw-r--r--packages/integrations/image/README.md36
-rw-r--r--packages/integrations/image/components/Image.astro112
-rw-r--r--packages/integrations/image/components/Picture.astro39
-rw-r--r--packages/integrations/image/components/index.js1
-rw-r--r--packages/integrations/image/package.json3
-rw-r--r--packages/integrations/image/src/constants.ts3
-rw-r--r--packages/integrations/image/src/get-image.ts128
-rw-r--r--packages/integrations/image/src/get-picture.ts79
-rw-r--r--packages/integrations/image/src/index.ts54
-rw-r--r--packages/integrations/image/src/loaders/sharp.ts1
-rw-r--r--packages/integrations/image/src/types.ts21
-rw-r--r--packages/integrations/image/src/utils.ts14
-rw-r--r--packages/integrations/image/test/fixtures/basic-image/package.json2
-rw-r--r--packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro2
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/astro.config.mjs8
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/package.json10
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/public/favicon.icobin0 -> 4286 bytes
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/server/server.mjs44
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpgbin0 -> 276382 bytes
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpgbin0 -> 25266 bytes
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/src/assets/social.pngbin0 -> 1512228 bytes
-rw-r--r--packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro17
-rw-r--r--packages/integrations/image/test/image-ssg.test.js41
-rw-r--r--packages/integrations/image/test/image-ssr.test.js45
-rw-r--r--packages/integrations/image/test/picture-ssg.test.js263
-rw-r--r--packages/integrations/image/test/picture-ssr.test.js278
-rw-r--r--pnpm-lock.yaml10
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
new file mode 100644
index 000000000..578ad458b
--- /dev/null
+++ b/packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico
Binary files 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 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
new file mode 100644
index 000000000..c58aacf66
--- /dev/null
+++ b/packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpg
Binary files differ
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
new file mode 100644
index 000000000..906c76144
--- /dev/null
+++ b/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpg
Binary files differ
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
new file mode 100644
index 000000000..1399856f1
--- /dev/null
+++ b/packages/integrations/image/test/fixtures/basic-picture/src/assets/social.png
Binary files differ
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