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'];
|
||||
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;
|
||||
|
|
|
@ -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} />
|
||||
|
|
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",
|
||||
"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",
|
||||
|
|
|
@ -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';
|
||||
|
@ -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)
|
||||
|
|
|
@ -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!,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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> & {
|
||||
/**
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
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:
|
||||
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:
|
||||
|
|
Loading…
Reference in a new issue