Compare commits
13 commits
main
...
feat/pictu
Author | SHA1 | Date | |
---|---|---|---|
|
bfe8e09ba6 | ||
|
fc86fdf0c3 | ||
|
73a06292bc | ||
|
2022ce5b87 | ||
|
dc21d085ec | ||
|
db0c862440 | ||
|
7398d63331 | ||
|
3809a452ae | ||
|
050143da64 | ||
|
207edd65ec | ||
|
83eac94b38 | ||
|
cccd165d75 | ||
|
77f7774010 |
18 changed files with 407 additions and 41 deletions
49
.changeset/smooth-goats-agree.md
Normal file
49
.changeset/smooth-goats-agree.md
Normal 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.
|
12
packages/astro/client.d.ts
vendored
12
packages/astro/client.d.ts
vendored
|
@ -53,6 +53,7 @@ declare module 'astro:assets' {
|
||||||
imageConfig: import('./dist/@types/astro.js').AstroConfig['image'];
|
imageConfig: import('./dist/@types/astro.js').AstroConfig['image'];
|
||||||
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
|
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
|
||||||
Image: typeof import('./components/Image.astro').default;
|
Image: typeof import('./components/Image.astro').default;
|
||||||
|
Picture: typeof import('./components/Picture.astro').default;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImgAttributes = import('./dist/type-utils.js').WithRequired<
|
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<
|
export type RemoteImageProps = import('./dist/type-utils.js').Simplify<
|
||||||
import('./dist/assets/types.js').RemoteImageProps<ImgAttributes>
|
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;
|
type ImageMetadata = import('./dist/assets/types.js').ImageMetadata;
|
||||||
|
|
||||||
interface ImageMetadata {
|
|
||||||
src: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
format: InputFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.gif' {
|
declare module '*.gif' {
|
||||||
const metadata: ImageMetadata;
|
const metadata: ImageMetadata;
|
||||||
|
|
|
@ -23,6 +23,12 @@ if (typeof props.height === 'string') {
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = await getImage(props);
|
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} />
|
||||||
|
|
57
packages/astro/components/Picture.astro
Normal file
57
packages/astro/components/Picture.astro
Normal 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>
|
|
@ -213,6 +213,7 @@
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"network-information-types": "^0.1.1",
|
"network-information-types": "^0.1.1",
|
||||||
"node-mocks-http": "^1.13.0",
|
"node-mocks-http": "^1.13.0",
|
||||||
|
"parse-srcset": "^1.0.2",
|
||||||
"rehype-autolink-headings": "^6.1.1",
|
"rehype-autolink-headings": "^6.1.1",
|
||||||
"rehype-slug": "^5.0.1",
|
"rehype-slug": "^5.0.1",
|
||||||
"rehype-toc": "^3.0.2",
|
"rehype-toc": "^3.0.2",
|
||||||
|
|
|
@ -24,4 +24,5 @@ export const VALID_SUPPORTED_FORMATS = [
|
||||||
'svg',
|
'svg',
|
||||||
'avif',
|
'avif',
|
||||||
] as const;
|
] as const;
|
||||||
|
export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
|
||||||
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
|
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
|
||||||
|
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
GetImageResult,
|
GetImageResult,
|
||||||
ImageMetadata,
|
ImageMetadata,
|
||||||
ImageTransform,
|
ImageTransform,
|
||||||
|
SrcSetValue,
|
||||||
UnresolvedImageTransform,
|
UnresolvedImageTransform,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { matchHostname, matchPattern } from './utils/remotePattern.js';
|
import { matchHostname, matchPattern } from './utils/remotePattern.js';
|
||||||
|
@ -93,22 +94,41 @@ export async function getImage(
|
||||||
? await service.validateOptions(resolvedOptions, imageConfig)
|
? await service.validateOptions(resolvedOptions, imageConfig)
|
||||||
: resolvedOptions;
|
: 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 (
|
if (
|
||||||
isLocalService(service) &&
|
isLocalService(service) &&
|
||||||
globalThis.astroAsset.addStaticImage &&
|
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)
|
!(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
|
||||||
) {
|
) {
|
||||||
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
|
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
|
||||||
|
srcSets = srcSetTransforms.map((srcSet) => ({
|
||||||
|
url: globalThis.astroAsset.addStaticImage!(srcSet.transform),
|
||||||
|
descriptor: srcSet.descriptor,
|
||||||
|
attributes: srcSet.attributes,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawOptions: resolvedOptions,
|
rawOptions: resolvedOptions,
|
||||||
options: validatedOptions,
|
options: validatedOptions,
|
||||||
src: imageURL,
|
src: imageURL,
|
||||||
|
srcSet: {
|
||||||
|
values: srcSets,
|
||||||
|
attribute: srcSets.map((srcSet) => `${srcSet.url} ${srcSet.descriptor}`).join(', '),
|
||||||
|
},
|
||||||
attributes:
|
attributes:
|
||||||
service.getHTMLAttributes !== undefined
|
service.getHTMLAttributes !== undefined
|
||||||
? service.getHTMLAttributes(validatedOptions, imageConfig)
|
? service.getHTMLAttributes(validatedOptions, imageConfig)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { AstroConfig } from '../../@types/astro.js';
|
import type { AstroConfig } from '../../@types/astro.js';
|
||||||
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
||||||
import { isRemotePath, joinPaths } from '../../core/path.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 { isESMImportedImage, isRemoteAllowed } from '../internal.js';
|
||||||
import type { ImageOutputFormat, ImageTransform } from '../types.js';
|
import type { ImageOutputFormat, ImageTransform } from '../types.js';
|
||||||
|
|
||||||
|
@ -28,6 +28,12 @@ type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & {
|
||||||
service: { entrypoint: string; config: T };
|
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>> {
|
interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> {
|
||||||
/**
|
/**
|
||||||
* Return the URL to the endpoint or URL your images are generated from.
|
* 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>;
|
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.
|
* 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
|
// 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') {
|
if (options.src.format === 'svg') {
|
||||||
options.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
|
// 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
|
// In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif
|
||||||
if (!options.format) {
|
if (!options.format) {
|
||||||
options.format = 'webp';
|
options.format = DEFAULT_OUTPUT_FORMAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
},
|
},
|
||||||
getHTMLAttributes(options) {
|
getHTMLAttributes(options) {
|
||||||
let targetWidth = options.width;
|
const { targetWidth, targetHeight } = getTargetDimensions(options);
|
||||||
let targetHeight = options.height;
|
const { src, width, height, format, quality, densities, widths, formats, ...attributes } =
|
||||||
if (isESMImportedImage(options.src)) {
|
options;
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...attributes,
|
...attributes,
|
||||||
|
@ -216,6 +221,89 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||||
decoding: attributes.decoding ?? 'async',
|
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) {
|
getURL(options, imageConfig) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
@ -260,3 +348,28 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||||
return 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,
|
getURL: baseService.getURL,
|
||||||
parseURL: baseService.parseURL,
|
parseURL: baseService.parseURL,
|
||||||
getHTMLAttributes: baseService.getHTMLAttributes,
|
getHTMLAttributes: baseService.getHTMLAttributes,
|
||||||
|
getSrcSet: baseService.getSrcSet,
|
||||||
async transform(inputBuffer, transformOptions) {
|
async transform(inputBuffer, transformOptions) {
|
||||||
if (!sharp) sharp = await loadSharp();
|
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.
|
// Never resize using both width and height at the same time, prioritizing width.
|
||||||
if (transform.height && !transform.width) {
|
if (transform.height && !transform.width) {
|
||||||
result.resize({ height: transform.height });
|
result.resize({ height: Math.round(transform.height) });
|
||||||
} else if (transform.width) {
|
} else if (transform.width) {
|
||||||
result.resize({ width: transform.width });
|
result.resize({ width: Math.round(transform.width) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transform.format) {
|
if (transform.format) {
|
||||||
|
|
|
@ -56,6 +56,7 @@ const service: LocalImageService = {
|
||||||
getURL: baseService.getURL,
|
getURL: baseService.getURL,
|
||||||
parseURL: baseService.parseURL,
|
parseURL: baseService.parseURL,
|
||||||
getHTMLAttributes: baseService.getHTMLAttributes,
|
getHTMLAttributes: baseService.getHTMLAttributes,
|
||||||
|
getSrcSet: baseService.getSrcSet,
|
||||||
async transform(inputBuffer, transformOptions) {
|
async transform(inputBuffer, transformOptions) {
|
||||||
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
||||||
|
|
||||||
|
@ -76,12 +77,12 @@ const service: LocalImageService = {
|
||||||
if (transform.height && !transform.width) {
|
if (transform.height && !transform.width) {
|
||||||
operations.push({
|
operations.push({
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
height: transform.height,
|
height: Math.round(transform.height),
|
||||||
});
|
});
|
||||||
} else if (transform.width) {
|
} else if (transform.width) {
|
||||||
operations.push({
|
operations.push({
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
width: transform.width,
|
width: Math.round(transform.width),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,12 @@ export interface ImageMetadata {
|
||||||
orientation?: number;
|
orientation?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SrcSetValue {
|
||||||
|
url: string;
|
||||||
|
descriptor?: string;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A yet to be resolved image transform. Used by `getImage`
|
* A yet to be resolved image transform. Used by `getImage`
|
||||||
*/
|
*/
|
||||||
|
@ -41,6 +47,8 @@ export type UnresolvedImageTransform = Omit<ImageTransform, 'src'> & {
|
||||||
export type ImageTransform = {
|
export type ImageTransform = {
|
||||||
src: ImageMetadata | string;
|
src: ImageMetadata | string;
|
||||||
width?: number | undefined;
|
width?: number | undefined;
|
||||||
|
widths?: number[] | undefined;
|
||||||
|
densities?: (number | `${number}x`)[] | undefined;
|
||||||
height?: number | undefined;
|
height?: number | undefined;
|
||||||
quality?: ImageQuality | undefined;
|
quality?: ImageQuality | undefined;
|
||||||
format?: ImageOutputFormat | undefined;
|
format?: ImageOutputFormat | undefined;
|
||||||
|
@ -51,6 +59,10 @@ export interface GetImageResult {
|
||||||
rawOptions: ImageTransform;
|
rawOptions: ImageTransform;
|
||||||
options: ImageTransform;
|
options: ImageTransform;
|
||||||
src: string;
|
src: string;
|
||||||
|
srcSet: {
|
||||||
|
values: SrcSetValue[];
|
||||||
|
attribute: string;
|
||||||
|
};
|
||||||
attributes: Record<string, any>;
|
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.
|
* 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**:
|
* **Example**:
|
||||||
* ```astro
|
* ```astro
|
||||||
|
@ -85,7 +97,26 @@ type ImageSharedProps<T> = T & {
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
height?: number | `${number}`;
|
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> & {
|
export type LocalImageProps<T> = ImageSharedProps<T> & {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,8 +16,8 @@ export function propsToFilename(transform: ImageTransform, hash: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashTransform(transform: ImageTransform, imageService: string) {
|
export function hashTransform(transform: ImageTransform, imageService: string) {
|
||||||
// take everything from transform except alt, which is not used in the hash
|
// Extract the fields we want to hash
|
||||||
const { alt, ...rest } = transform;
|
const { alt, class: className, style, widths, densities, ...rest } = transform;
|
||||||
const hashFields = { ...rest, imageService };
|
const hashFields = { ...rest, imageService };
|
||||||
return shorthash(JSON.stringify(hashFields));
|
return shorthash(JSON.stringify(hashFields));
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,6 +57,7 @@ export default function assets({
|
||||||
export { getConfiguredImageService, isLocalService } from "astro/assets";
|
export { getConfiguredImageService, isLocalService } from "astro/assets";
|
||||||
import { getImage as getImageInternal } from "astro/assets";
|
import { getImage as getImageInternal } from "astro/assets";
|
||||||
export { default as Image } from "astro/components/Image.astro";
|
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 imageConfig = ${JSON.stringify(settings.config.image)};
|
||||||
export const assetsDir = new URL(${JSON.stringify(
|
export const assetsDir = new URL(${JSON.stringify(
|
||||||
|
|
|
@ -621,6 +621,21 @@ export const ExpectedImageOptions = {
|
||||||
`Expected getImage() parameter to be an object. Received \`${options}\`.`,
|
`Expected getImage() parameter to be an object. Received \`${options}\`.`,
|
||||||
} satisfies ErrorData;
|
} 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
|
* @docs
|
||||||
* @see
|
* @see
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { expect } from 'chai';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
|
import parseSrcset from 'parse-srcset';
|
||||||
import { removeDir } from '../dist/core/fs/index.js';
|
import { removeDir } from '../dist/core/fs/index.js';
|
||||||
import { Logger } from '../dist/core/logger/core.js';
|
import { Logger } from '../dist/core/logger/core.js';
|
||||||
import testAdapter from './test-adapter.js';
|
import testAdapter from './test-adapter.js';
|
||||||
|
@ -188,6 +189,36 @@ describe('astro:image', () => {
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.headers.get('content-type')).to.equal('image/avif');
|
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', () => {
|
describe('vite-isms', () => {
|
||||||
|
@ -702,6 +733,26 @@ describe('astro:image', () => {
|
||||||
expect(data).to.be.an.instanceOf(Buffer);
|
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 () => {
|
it('markdown images are written', async () => {
|
||||||
const html = await fixture.readFile('/post/index.html');
|
const html = await fixture.readFile('/post/index.html');
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
6
packages/astro/test/fixtures/core-image-ssg/src/pages/picturecomponent.astro
vendored
Normal file
6
packages/astro/test/fixtures/core-image-ssg/src/pages/picturecomponent.astro
vendored
Normal 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']} />
|
12
packages/astro/test/fixtures/core-image/src/pages/picturecomponent.astro
vendored
Normal file
12
packages/astro/test/fixtures/core-image/src/pages/picturecomponent.astro
vendored
Normal 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>
|
|
@ -753,6 +753,9 @@ importers:
|
||||||
node-mocks-http:
|
node-mocks-http:
|
||||||
specifier: ^1.13.0
|
specifier: ^1.13.0
|
||||||
version: 1.13.0
|
version: 1.13.0
|
||||||
|
parse-srcset:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
rehype-autolink-headings:
|
rehype-autolink-headings:
|
||||||
specifier: ^6.1.1
|
specifier: ^6.1.1
|
||||||
version: 6.1.1
|
version: 6.1.1
|
||||||
|
@ -14738,6 +14741,10 @@ packages:
|
||||||
resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==}
|
resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/parse-srcset@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/parse5-htmlparser2-tree-adapter@7.0.0:
|
/parse5-htmlparser2-tree-adapter@7.0.0:
|
||||||
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
|
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Reference in a new issue