feat(assets): Add support for generating a srcset
when using Image and getImage
This commit is contained in:
parent
1f1c47d909
commit
77f7774010
8 changed files with 161 additions and 32 deletions
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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> & {
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue