Skip to content

Commit

Permalink
feat(assets): Add support for srcset and a Picture component (#8620)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
Princesseuh and sarah11918 committed Oct 11, 2023
1 parent 3f231ce commit b2ae9ee
Show file tree
Hide file tree
Showing 18 changed files with 413 additions and 40 deletions.
56 changes: 56 additions & 0 deletions .changeset/smooth-goats-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'astro': minor
---

Adds experimental support for generating `srcset` attributes and a new `<Picture />` component.

## `srcset` support

Two new properties have been added to `Image` and `getImage()`: `densities` and `widths`.

These properties can be used to generate a `srcset` attribute, either based on absolute widths in pixels (e.g. [300, 600, 900]) or pixel density descriptors (e.g. `["2x"]` or `[1.5, 2]`).


```astro
---
import { Image } from "astro";
import myImage from "./my-image.jpg";
---
<Image src={myImage} width={myImage.width / 2} densities={[1.5, 2]} alt="My cool image" />
```

```html
<img
src="/_astro/my_image.hash.webp"
srcset="/_astro/my_image.hash.webp 1.5x, /_astro/my_image.hash.webp 2x"
alt="My cool image"
/>
```

## Picture component

The experimental `<Picture />` component can be used to generate a `<picture>` element with multiple `<source>` elements.

The example below uses the `format` property to generate a `<source>` in each of the specified image formats:

```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, and allow the browser to determine the best image to display:

```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 changes: 3 additions & 9 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/components/Image.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 57 additions & 0 deletions packages/astro/components/Picture.astro
Original file line number Diff line number Diff line change
@@ -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.values.length > 0 ? ' , ' + image.srcSet.attribute : ''}`}
type={"image/" + image.options.format}
/>
))
}
<img src={fallbackImage.src} {...additionalAttributes} {...fallbackImage.attributes} />
</picture>
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
24 changes: 22 additions & 2 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
GetImageResult,
ImageMetadata,
ImageTransform,
SrcSetValue,
UnresolvedImageTransform,
} from './types.js';
import { matchHostname, matchPattern } from './utils/remotePattern.js';
Expand Down Expand Up @@ -93,22 +94,41 @@ export async function getImage(
? await service.validateOptions(resolvedOptions, imageConfig)
: resolvedOptions;

// 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
? await service.getHTMLAttributes(validatedOptions, imageConfig)
Expand Down

0 comments on commit b2ae9ee

Please sign in to comment.