Adds a new <Picture> component to the image integration (#3866)

* moving all normalization logic out of the Image component

* refactor: only require loaders to provide the image src

* Adding a `<Picture />` component

* fixing types.ts imports

* refactor: moving getImage to it's own file

* updating component types to use astroHTML.JSX

* Revert "updating component types to use astroHTML.JSX"

This reverts commit 6e5f578da8.

* going back to letting loaders add extra HTML attributes

* Always use lazy loading and async decoding

* Cleaning up the Picture component

* Adding test coverage for <Picture>

* updating the README

* using JSX types for the Image and Picture elements

* chore: adding changeset

* Update packages/integrations/image/src/get-image.ts

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* allow users to override loading and async on the <img>

* renaming config to constants, exporting getPicture()

* found the right syntax to import astro-jsx

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
Tony Sullivan 2022-07-08 21:37:55 +00:00 committed by GitHub
parent ec392589f6
commit 89d76753a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1053 additions and 165 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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';
}
const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props;
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);
}
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;
// parse aspect ratio strings, if required (ex: "16:9")
if (typeof transform.aspectRatio === 'number') {
aspectRatio = transform.aspectRatio;
} else {
const [width, height] = transform.aspectRatio.split(':');
aspectRatio = parseInt(width) / parseInt(height);
}
if (transform.width) {
// only width was provided, calculate height
return {
...transform,
width: transform.width,
height: transform.width / aspectRatio
};
} else if (transform.height) {
// only height was provided, calculate width
return {
...transform,
width: transform.height * aspectRatio,
height: transform.height
}
}
return transform;
}
const props = Astro.props as Props;
const imageProps = await resolveProps(props);
const attrs = await getImage(loader, imageProps);
const attrs = await getImage(loader, props);
---
<img {...attrs} />
<img {...attrs} {loading} {decoding} />
<style>
img {
content-visibility: auto;
}
</style>

View file

@ -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>

View file

@ -1 +1,2 @@
export { default as Image } from './Image.astro';
export { default as Picture } from './Picture.astro';

View file

@ -33,7 +33,8 @@
"files": [
"components",
"dist",
"src"
"src",
"types"
],
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",

View file

@ -0,0 +1,3 @@
export const PKG_NAME = '@astrojs/image';
export const ROUTE_PATTERN = '/_image';
export const OUTPUT_DIR = '/_image';

View file

@ -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;
}

View file

@ -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
}
}

View file

@ -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',

View file

@ -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 {

View file

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

View file

@ -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);
}
}

View file

@ -1,5 +1,5 @@
{
"name": "@test/sharp",
"name": "@test/basic-image",
"version": "0.0.0",
"private": true,
"dependencies": {

View file

@ -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>

View file

@ -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()]
});

View file

@ -0,0 +1,10 @@
{
"name": "@test/basic-picture",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/image": "workspace:*",
"@astrojs/node": "workspace:*",
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -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 = () => {};

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -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>

View file

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

View file

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

View file

@ -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'
);
});
});
});
});

View file

@ -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'
);
});
});
});

View file

@ -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