Compare commits

...

13 commits

Author SHA1 Message Date
Princesseuh
bfe8e09ba6
fix(assets): Don't include asked width in srcset request 2023-10-10 12:18:41 +02:00
Princesseuh
fc86fdf0c3
fix: actually push the density srcset 2023-10-08 11:55:17 +02:00
Erika
73a06292bc
Merge branch 'main' into feat/picture-component 2023-10-06 19:09:36 +02:00
Princesseuh
2022ce5b87
nit: improve types 2023-10-06 18:58:07 +02:00
Princesseuh
dc21d085ec
fix: properly never upscale 2023-10-06 18:45:22 +02:00
Princesseuh
db0c862440
test: add tests 2023-10-06 15:40:55 +02:00
Princesseuh
7398d63331
feat: update with RFC feedback 2023-09-28 15:39:41 +02:00
Erika
3809a452ae
Merge branch 'main' into feat/picture-component 2023-09-22 12:06:17 +02:00
Princesseuh
050143da64
chore: changeset 2023-09-22 11:57:04 +02:00
Princesseuh
207edd65ec
fix: build 2023-09-22 11:43:48 +02:00
Princesseuh
83eac94b38
nit: remove unused type 2023-09-21 22:21:58 +02:00
Princesseuh
cccd165d75
feat(assets): Add a Picture component 2023-09-21 19:05:45 +02:00
Princesseuh
77f7774010
feat(assets): Add support for generating a srcset when using Image and getImage 2023-09-21 19:05:34 +02:00
18 changed files with 407 additions and 41 deletions

View file

@ -0,0 +1,49 @@
---
'astro': minor
---
Add support for generating multiple widths when using the Image component and a Picture component for supporting multiple formats.
## `srcset` support
Two new properties have been added to `Image` and `getImage`: `densities` and `widths`.
These props can be used to generate a `srcset` attribute with multiple sources. For example:
```astro
---
import { Image } from "astro";
import myImage from "./my-image.jpg";
---
<Image src={myImage} width={myImage.width / 2} densities={[2]} alt="My cool image" />
```
```html
<img src="..." srcset="... 2x, ... 3x" alt="My cool image" />
```
## Picture component
The `Picture` component can be used to generate a `<picture>` element with multiple sources. It can be used as follow:
```astro
---
import { Picture } from "astro:assets";
import myImage from "./my-image.jpg";
---
<Picture src={myImage} formats={["avif", "webp"]} alt="My super image in multiple formats!" />
```
The above code will generate the following:
```html
<picture>
<source srcset="..." type="image/avif" />
<source srcset="..." type="image/webp" />
<img src="..." alt="My super image in multiple formats!" />
</picture>
```
The `Picture` component takes all the same props as the `Image` component, including the new `densities` and `widths` properties.

View file

@ -53,6 +53,7 @@ declare module 'astro:assets' {
imageConfig: import('./dist/@types/astro.js').AstroConfig['image'];
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
Image: typeof import('./components/Image.astro').default;
Picture: typeof import('./components/Picture.astro').default;
};
type ImgAttributes = import('./dist/type-utils.js').WithRequired<
@ -66,17 +67,10 @@ declare module 'astro:assets' {
export type RemoteImageProps = import('./dist/type-utils.js').Simplify<
import('./dist/assets/types.js').RemoteImageProps<ImgAttributes>
>;
export const { getImage, getConfiguredImageService, imageConfig, Image }: AstroAssets;
export const { getImage, getConfiguredImageService, imageConfig, Image, Picture }: AstroAssets;
}
type InputFormat = import('./dist/assets/types.js').ImageInputFormat;
interface ImageMetadata {
src: string;
width: number;
height: number;
format: InputFormat;
}
type ImageMetadata = import('./dist/assets/types.js').ImageMetadata;
declare module '*.gif' {
const metadata: ImageMetadata;

View file

@ -23,6 +23,12 @@ if (typeof props.height === 'string') {
}
const image = await getImage(props);
const additionalAttributes: Record<string, any> = {};
if (image.srcSet.values.length > 0) {
additionalAttributes.srcset = image.srcSet.attribute;
}
---
<img src={image.src} {...image.attributes} />
<img src={image.src} {...additionalAttributes} {...image.attributes} />

View file

@ -0,0 +1,57 @@
---
import { getImage, type LocalImageProps, type RemoteImageProps } from 'astro:assets';
import type { GetImageResult, ImageOutputFormat } from '../dist/@types/astro';
import { isESMImportedImage } from '../dist/assets/internal';
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
import type { HTMLAttributes } from '../types';
type Props = (LocalImageProps | RemoteImageProps) & {
formats?: ImageOutputFormat[];
fallbackFormat?: ImageOutputFormat;
pictureAttributes?: HTMLAttributes<'picture'>;
};
const { formats = ['webp'], pictureAttributes = {}, ...props } = Astro.props;
if (props.alt === undefined || props.alt === null) {
throw new AstroError(AstroErrorData.ImageMissingAlt);
}
const optimizedImages: GetImageResult[] = await Promise.all(
formats.map(
async (format) =>
await getImage({ ...props, format: format, widths: props.widths, densities: props.densities })
)
);
const fallbackFormat =
props.fallbackFormat ?? isESMImportedImage(props.src)
? ['svg', 'gif'].includes(props.src.format)
? props.src.format
: 'png'
: 'png';
const fallbackImage = await getImage({
...props,
format: fallbackFormat,
widths: props.widths,
densities: props.densities,
});
const additionalAttributes: Record<string, any> = {};
if (fallbackImage.srcSet.values.length > 0) {
additionalAttributes.srcset = fallbackImage.srcSet.attribute;
}
---
<picture {...pictureAttributes}>
{
Object.entries(optimizedImages).map(([_, image]) => (
<source
srcset={`${image.src}, ` + image.srcSet.attribute}
type={image.srcSet.values[0].attributes?.type}
/>
))
}
<img src={fallbackImage.src} {...additionalAttributes} {...fallbackImage.attributes} />
</picture>

View file

@ -213,6 +213,7 @@
"mocha": "^10.2.0",
"network-information-types": "^0.1.1",
"node-mocks-http": "^1.13.0",
"parse-srcset": "^1.0.2",
"rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.0.1",
"rehype-toc": "^3.0.2",

View file

@ -24,4 +24,5 @@ export const VALID_SUPPORTED_FORMATS = [
'svg',
'avif',
] as const;
export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;

View file

@ -6,6 +6,7 @@ import type {
GetImageResult,
ImageMetadata,
ImageTransform,
SrcSetValue,
UnresolvedImageTransform,
} from './types.js';
import { matchHostname, matchPattern } from './utils/remotePattern.js';
@ -93,22 +94,41 @@ export async function getImage(
? await service.validateOptions(resolvedOptions, imageConfig)
: resolvedOptions;
let imageURL = await service.getURL(validatedOptions, imageConfig);
// Get all the options for the different srcSets
const srcSetTransforms = service.getSrcSet
? await service.getSrcSet(validatedOptions, imageConfig)
: [];
let imageURL = await service.getURL(validatedOptions, imageConfig);
let srcSets: SrcSetValue[] = await Promise.all(
srcSetTransforms.map(async (srcSet) => ({
url: await service.getURL(srcSet.transform, imageConfig),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
}))
);
// In build and for local services, we need to collect the requested parameters so we can generate the final images
if (
isLocalService(service) &&
globalThis.astroAsset.addStaticImage &&
// If `getURL` returned the same URL as the user provided, it means the service doesn't need to do anything
!(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
) {
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
srcSets = srcSetTransforms.map((srcSet) => ({
url: globalThis.astroAsset.addStaticImage!(srcSet.transform),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
}));
}
return {
rawOptions: resolvedOptions,
options: validatedOptions,
src: imageURL,
srcSet: {
values: srcSets,
attribute: srcSets.map((srcSet) => `${srcSet.url} ${srcSet.descriptor}`).join(', '),
},
attributes:
service.getHTMLAttributes !== undefined
? service.getHTMLAttributes(validatedOptions, imageConfig)

View file

@ -1,7 +1,7 @@
import type { AstroConfig } from '../../@types/astro.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { isRemotePath, joinPaths } from '../../core/path.js';
import { VALID_SUPPORTED_FORMATS } from '../consts.js';
import { DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
import { isESMImportedImage, isRemoteAllowed } from '../internal.js';
import type { ImageOutputFormat, ImageTransform } from '../types.js';
@ -28,6 +28,12 @@ type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & {
service: { entrypoint: string; config: T };
};
type SrcSetValue = {
transform: ImageTransform;
descriptor?: string;
attributes?: Record<string, any>;
};
interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> {
/**
* Return the URL to the endpoint or URL your images are generated from.
@ -38,6 +44,16 @@ interface SharedServiceProps<T extends Record<string, any> = Record<string, any>
*
*/
getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>;
/**
* Generate additional `srcset` values for the image.
*
* While in most cases this is exclusively used for `srcset`, it can also be used in a more generic way to generate
* multiple variants of the same image. For instance, you can use this to generate multiple aspect ratios or multiple formats.
*/
getSrcSet?: (
options: ImageTransform,
imageConfig: ImageConfig<T>
) => SrcSetValue[] | Promise<SrcSetValue[]>;
/**
* Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
*
@ -174,6 +190,10 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
});
}
if (options.widths && options.densities) {
throw new AstroError(AstroErrorData.IncompatibleDescriptorOptions);
}
// We currently do not support processing SVGs, so whenever the input format is a SVG, force the output to also be one
if (options.src.format === 'svg') {
options.format = 'svg';
@ -183,30 +203,15 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
// If the user didn't specify a format, we'll default to `webp`. It offers the best ratio of compatibility / quality
// In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif
if (!options.format) {
options.format = 'webp';
options.format = DEFAULT_OUTPUT_FORMAT;
}
return options;
},
getHTMLAttributes(options) {
let targetWidth = options.width;
let targetHeight = options.height;
if (isESMImportedImage(options.src)) {
const aspectRatio = options.src.width / options.src.height;
if (targetHeight && !targetWidth) {
// If we have a height but no width, use height to calculate the width
targetWidth = Math.round(targetHeight * aspectRatio);
} else if (targetWidth && !targetHeight) {
// If we have a width but no height, use width to calculate the height
targetHeight = Math.round(targetWidth / aspectRatio);
} else if (!targetWidth && !targetHeight) {
// If we have neither width or height, use the original image's dimensions
targetWidth = options.src.width;
targetHeight = options.src.height;
}
}
const { src, width, height, format, quality, ...attributes } = options;
const { targetWidth, targetHeight } = getTargetDimensions(options);
const { src, width, height, format, quality, densities, widths, formats, ...attributes } =
options;
return {
...attributes,
@ -216,6 +221,89 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
decoding: attributes.decoding ?? 'async',
};
},
getSrcSet(options) {
const srcSet: SrcSetValue[] = [];
const { targetWidth, targetHeight } = getTargetDimensions(options);
const { widths, densities } = options;
const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;
const aspectRatio = targetWidth / targetHeight;
const imageWidth = isESMImportedImage(options.src) ? options.src.width : options.width;
const maxWidth = imageWidth ?? Infinity;
// REFACTOR: Could we merge these two blocks?
if (densities) {
const densityValues = densities.map((density) => {
if (typeof density === 'number') {
return density;
} else {
return parseFloat(density);
}
});
const densityWidths = densityValues
.sort()
.map((density) => Math.round(targetWidth * density));
densityWidths.forEach((width, index) => {
const maxTargetWidth = Math.min(width, maxWidth);
// If the user passed dimensions, we don't want to add it to the srcset
const { width: transformWidth, height: transformHeight, ...rest } = options;
const srcSetValue = {
transform: {
...rest,
},
descriptor: `${densityValues[index]}x`,
attributes: {
type: `image/${targetFormat}`,
},
};
// Only set width and height if they are different from the original image, to avoid duplicated final images
if (maxTargetWidth !== imageWidth) {
srcSetValue.transform.width = maxTargetWidth;
srcSetValue.transform.height = Math.round(maxTargetWidth / aspectRatio);
}
if (targetFormat !== options.format) {
srcSetValue.transform.format = targetFormat;
}
srcSet.push(srcSetValue);
});
} else if (widths) {
widths.forEach((width) => {
const maxTargetWidth = Math.min(width, maxWidth);
const { width: transformWidth, height: transformHeight, ...rest } = options;
const srcSetValue = {
transform: {
...rest,
},
descriptor: `${width}w`,
attributes: {
type: `image/${targetFormat}`,
},
};
if (maxTargetWidth !== imageWidth) {
srcSetValue.transform.width = maxTargetWidth;
srcSetValue.transform.height = Math.round(maxTargetWidth / aspectRatio);
}
if (targetFormat !== options.format) {
srcSetValue.transform.format = targetFormat;
}
srcSet.push(srcSetValue);
});
}
return srcSet;
},
getURL(options, imageConfig) {
const searchParams = new URLSearchParams();
@ -260,3 +348,28 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
return transform;
},
};
function getTargetDimensions(options: ImageTransform) {
let targetWidth = options.width;
let targetHeight = options.height;
if (isESMImportedImage(options.src)) {
const aspectRatio = options.src.width / options.src.height;
if (targetHeight && !targetWidth) {
// If we have a height but no width, use height to calculate the width
targetWidth = Math.round(targetHeight * aspectRatio);
} else if (targetWidth && !targetHeight) {
// If we have a width but no height, use width to calculate the height
targetHeight = Math.round(targetWidth / aspectRatio);
} else if (!targetWidth && !targetHeight) {
// If we have neither width or height, use the original image's dimensions
targetWidth = options.src.width;
targetHeight = options.src.height;
}
}
// TypeScript doesn't know this, but because of previous hooks we always know that targetWidth and targetHeight are defined
return {
targetWidth: targetWidth!,
targetHeight: targetHeight!,
};
}

View file

@ -33,6 +33,7 @@ const sharpService: LocalImageService = {
getURL: baseService.getURL,
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions) {
if (!sharp) sharp = await loadSharp();
@ -49,9 +50,9 @@ const sharpService: LocalImageService = {
// Never resize using both width and height at the same time, prioritizing width.
if (transform.height && !transform.width) {
result.resize({ height: transform.height });
result.resize({ height: Math.round(transform.height) });
} else if (transform.width) {
result.resize({ width: transform.width });
result.resize({ width: Math.round(transform.width) });
}
if (transform.format) {

View file

@ -56,6 +56,7 @@ const service: LocalImageService = {
getURL: baseService.getURL,
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions) {
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
@ -76,12 +77,12 @@ const service: LocalImageService = {
if (transform.height && !transform.width) {
operations.push({
type: 'resize',
height: transform.height,
height: Math.round(transform.height),
});
} else if (transform.width) {
operations.push({
type: 'resize',
width: transform.width,
width: Math.round(transform.width),
});
}

View file

@ -28,6 +28,12 @@ export interface ImageMetadata {
orientation?: number;
}
export interface SrcSetValue {
url: string;
descriptor?: string;
attributes?: Record<string, string>;
}
/**
* A yet to be resolved image transform. Used by `getImage`
*/
@ -41,6 +47,8 @@ export type UnresolvedImageTransform = Omit<ImageTransform, 'src'> & {
export type ImageTransform = {
src: ImageMetadata | string;
width?: number | undefined;
widths?: number[] | undefined;
densities?: (number | `${number}x`)[] | undefined;
height?: number | undefined;
quality?: ImageQuality | undefined;
format?: ImageOutputFormat | undefined;
@ -51,6 +59,10 @@ export interface GetImageResult {
rawOptions: ImageTransform;
options: ImageTransform;
src: string;
srcSet: {
values: SrcSetValue[];
attribute: string;
};
attributes: Record<string, any>;
}
@ -58,7 +70,7 @@ type ImageSharedProps<T> = T & {
/**
* Width of the image, the value of this property will be used to assign the `width` property on the final `img` element.
*
* For local images, this value will additionally be used to resize the image to the desired width, taking into account the original aspect ratio of the image.
* This value will additionally be used to resize the image to the desired width, taking into account the original aspect ratio of the image.
*
* **Example**:
* ```astro
@ -85,7 +97,26 @@ type ImageSharedProps<T> = T & {
* ```
*/
height?: number | `${number}`;
};
} & (
| {
/**
* A list of widths to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
*
* This attribute is incompatible with `densities`.
*/
widths?: number[];
densities?: never;
}
| {
/**
* A list of densities to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
*
* This attribute is incompatible with `widths`.
*/
densities?: (number | `${number}x`)[];
widths?: never;
}
);
export type LocalImageProps<T> = ImageSharedProps<T> & {
/**

View file

@ -16,8 +16,8 @@ export function propsToFilename(transform: ImageTransform, hash: string) {
}
export function hashTransform(transform: ImageTransform, imageService: string) {
// take everything from transform except alt, which is not used in the hash
const { alt, ...rest } = transform;
// Extract the fields we want to hash
const { alt, class: className, style, widths, densities, ...rest } = transform;
const hashFields = { ...rest, imageService };
return shorthash(JSON.stringify(hashFields));
}

View file

@ -57,6 +57,7 @@ export default function assets({
export { getConfiguredImageService, isLocalService } from "astro/assets";
import { getImage as getImageInternal } from "astro/assets";
export { default as Image } from "astro/components/Image.astro";
export { default as Picture } from "astro/components/Picture.astro";
export const imageConfig = ${JSON.stringify(settings.config.image)};
export const assetsDir = new URL(${JSON.stringify(

View file

@ -621,6 +621,21 @@ export const ExpectedImageOptions = {
`Expected getImage() parameter to be an object. Received \`${options}\`.`,
} satisfies ErrorData;
/**
* @docs
* @see
* - [Images](https://docs.astro.build/en/guides/images/)
* @description
* Only one of `densities` or `widths` can be specified. Those attributes are used to construct a `srcset` attribute, which cannot have both `x` and `w` descriptors.
*/
export const IncompatibleDescriptorOptions = {
name: 'IncompatibleDescriptorOptions',
title: 'Cannot set both `densities` and `widths`',
message:
"Only one of `densities` or `widths` can be specified. In most cases, you'll probably want to use only `widths` if you require specific widths.",
hint: 'Those attributes are used to construct a `srcset` attribute, which cannot have both `x` and `w` descriptors.',
} satisfies ErrorData;
/**
* @docs
* @see

View file

@ -2,6 +2,7 @@ import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { basename } from 'node:path';
import { Writable } from 'node:stream';
import parseSrcset from 'parse-srcset';
import { removeDir } from '../dist/core/fs/index.js';
import { Logger } from '../dist/core/logger/core.js';
import testAdapter from './test-adapter.js';
@ -188,6 +189,36 @@ describe('astro:image', () => {
expect(res.status).to.equal(200);
expect(res.headers.get('content-type')).to.equal('image/avif');
});
it('has a working Picture component', async () => {
let res = await fixture.fetch('/picturecomponent');
let html = await res.text();
$ = cheerio.load(html);
// Densities
let $img = $('#picture-density-2-format img');
let $picture = $('#picture-density-2-format picture');
let $source = $('#picture-density-2-format source');
expect($img).to.have.a.lengthOf(1);
expect($picture).to.have.a.lengthOf(1);
expect($source).to.have.a.lengthOf(2);
const srcset = parseSrcset($source.attr('srcset'));
expect(srcset.every((src) => src.url.startsWith('/_image'))).to.equal(true);
expect(srcset.map((src) => src.d)).to.deep.equal([undefined, 2]);
// Widths
$img = $('#picture-widths img');
$picture = $('#picture-widths picture');
$source = $('#picture-widths source');
expect($img).to.have.a.lengthOf(1);
expect($picture).to.have.a.lengthOf(1);
expect($source).to.have.a.lengthOf(1);
const srcset2 = parseSrcset($source.attr('srcset'));
expect(srcset2.every((src) => src.url.startsWith('/_image'))).to.equal(true);
expect(srcset2.map((src) => src.w)).to.deep.equal([undefined, 207]);
});
});
describe('vite-isms', () => {
@ -702,6 +733,26 @@ describe('astro:image', () => {
expect(data).to.be.an.instanceOf(Buffer);
});
it('Picture component images are written', async () => {
const html = await fixture.readFile('/picturecomponent/index.html');
const $ = cheerio.load(html);
let $img = $('img');
let $source = $('source');
expect($img).to.have.a.lengthOf(1);
expect($source).to.have.a.lengthOf(2);
const srcset = parseSrcset($source.attr('srcset'));
let hasExistingSrc = await Promise.all(
srcset.map(async (src) => {
const data = await fixture.readFile(src.url, null);
return data instanceof Buffer;
})
);
expect(hasExistingSrc.every((src) => src === true)).to.deep.equal(true);
});
it('markdown images are written', async () => {
const html = await fixture.readFile('/post/index.html');
const $ = cheerio.load(html);

View file

@ -0,0 +1,6 @@
---
import { Picture } from "astro:assets";
import myImage from "../assets/penguin1.jpg";
---
<Picture src={myImage} width={Math.round(myImage.width / 2)} alt="A penguin" densities={[2]} formats={['avif', 'webp']} />

View file

@ -0,0 +1,12 @@
---
import { Picture } from "astro:assets";
import myImage from "../assets/penguin1.jpg";
---
<div id="picture-density-2-format">
<Picture src={myImage} width={Math.round(myImage.width / 2)} alt="A penguin" densities={[2]} formats={['avif', 'webp']} />
</div>
<div id="picture-widths">
<Picture src={myImage} width={Math.round(myImage.width / 2)} alt="A penguin" widths={[myImage.width]} />
</div>

View file

@ -753,6 +753,9 @@ importers:
node-mocks-http:
specifier: ^1.13.0
version: 1.13.0
parse-srcset:
specifier: ^1.0.2
version: 1.0.2
rehype-autolink-headings:
specifier: ^6.1.1
version: 6.1.1
@ -14738,6 +14741,10 @@ packages:
resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==}
dev: false
/parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
dev: true
/parse5-htmlparser2-tree-adapter@7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies: