feat(assets): Add support for generating a srcset when using Image and getImage

This commit is contained in:
Princesseuh 2023-09-21 19:05:34 +02:00
parent 1f1c47d909
commit 77f7774010
No known key found for this signature in database
GPG key ID: 105BBD6D57F2B0C0
8 changed files with 161 additions and 32 deletions

View file

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

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';
@ -13,7 +14,7 @@ import { matchHostname, matchPattern } from './utils/remotePattern.js';
export function injectImageEndpoint(settings: AstroSettings) {
const endpointEntrypoint = settings.config.image.endpoint ?? 'astro/assets/image-endpoint';
// TODO: Add a setting to disable the image endpoint
// TODO: Add a setting to disable the image endpoint fully
settings.injectedRoutes.push({
pattern: '/_image',
entryPoint: endpointEntrypoint,
@ -92,22 +93,40 @@ 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)
: [];
// 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);
let imageURL = await getFinalURL(validatedOptions);
let srcSets: SrcSetValue[] = await Promise.all(
srcSetTransforms.map(async (srcSet) => ({
url: await getFinalURL(srcSet.transform),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
}))
);
async function getFinalURL(transform: ImageTransform) {
// 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(transform.src) && imageURL === transform.src)
) {
return globalThis.astroAsset.addStaticImage(transform);
} else {
return await service.getURL(transform, imageConfig);
}
}
return {
rawOptions: resolvedOptions,
options: validatedOptions,
src: imageURL,
srcSet: srcSets,
srcSetValue: 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,13 @@ interface SharedServiceProps<T extends Record<string, any> = Record<string, any>
*
*/
getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>;
/**
* TODO: Document
*/
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 +187,11 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
});
}
if (options.widths && options.densities) {
console.warn('Cannot use `widths` and `densities` at the same time. Using `densities`.');
options.widths = undefined;
}
// 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 +201,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 +219,57 @@ 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;
if (densities) {
const densityValues = densities.map((density) => {
if (typeof density === 'number') {
return density;
} else {
return parseFloat(density);
}
});
const densityWidths = densityValues.map((density) => Math.round(targetWidth * density));
densityWidths.forEach((width, index) => {
srcSet.push({
transform: {
...options,
width,
height: Math.round(width / aspectRatio),
format: targetFormat,
},
descriptor: `${densityValues[index]}x`,
attributes: {
type: `image/${targetFormat}`,
},
});
});
} else if (widths) {
widths.forEach((width) => {
srcSet.push({
transform: {
...options,
width,
height: Math.round(width / aspectRatio),
format: targetFormat,
},
descriptor: `${width}w`,
attributes: {
type: `image/${targetFormat}`,
},
});
});
}
return srcSet;
},
getURL(options, imageConfig) {
const searchParams = new URLSearchParams();
@ -260,3 +314,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();

View file

@ -53,6 +53,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;

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,8 @@ export interface GetImageResult {
rawOptions: ImageTransform;
options: ImageTransform;
src: string;
srcSet: SrcSetValue[];
srcSetValue: string;
attributes: Record<string, any>;
}
@ -58,7 +68,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,6 +95,18 @@ 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[];
/**
* 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`)[];
};
export type LocalImageProps<T> = ImageSharedProps<T> & {

View file

@ -42,6 +42,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 getImage = async (options) => await getImageInternal(options, imageConfig);