Skip to content

Commit

Permalink
Adds a new <Picture> component to the image integration (#3866)
Browse files Browse the repository at this point in the history
* moving all normalization logic out of the Image component

* refactor: only require loaders to provide the image src

* Adding a `<Picture />` component

* fixing types.ts imports

* refactor: moving getImage to it's own file

* updating component types to use astroHTML.JSX

* Revert "updating component types to use astroHTML.JSX"

This reverts commit 6e5f578.

* going back to letting loaders add extra HTML attributes

* Always use lazy loading and async decoding

* Cleaning up the Picture component

* Adding test coverage for <Picture>

* updating the README

* using JSX types for the Image and Picture elements

* chore: adding changeset

* Update packages/integrations/image/src/get-image.ts

Co-authored-by: Nate Moore <[email protected]>

* allow users to override loading and async on the <img>

* renaming config to constants, exporting getPicture()

* found the right syntax to import astro-jsx

Co-authored-by: Nate Moore <[email protected]>
  • Loading branch information
Tony Sullivan and natemoo-re committed Jul 8, 2022
1 parent ec39258 commit 89d7675
Show file tree
Hide file tree
Showing 28 changed files with 1,052 additions and 164 deletions.
5 changes: 5 additions & 0 deletions .changeset/bright-starfishes-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/image': minor
---

The new `<Picture />` component adds art direction support for building responsive images with multiple sizes and file types :tada:
36 changes: 35 additions & 1 deletion packages/integrations/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This **[Astro integration][astro-integration]** makes it easy to optimize images

Images play a big role in overall site performance and usability. Serving properly sized images makes all the difference but is often tricky to automate.

This integration provides a basic `<Image />` component and image transformer powered by [sharp](https://sharp.pixelplumbing.com/), with full support for static sites and server-side rendering. The built-in `sharp` transformer is also replacable, opening the door for future integrations that work with your favorite hosted image service.
This integration provides `<Image />` and `<Picture>` components as well as a basic image transformer powered by [sharp](https://sharp.pixelplumbing.com/), with full support for static sites and server-side rendering. The built-in `sharp` transformer is also replacable, opening the door for future integrations that work with your favorite hosted image service.

## Installation

Expand Down Expand Up @@ -124,6 +124,9 @@ import heroImage from '../assets/hero.png';

// cropping to a specific aspect ratio and converting to an avif format
<Image src={heroImage} aspectRatio="16:9" format="avif" />

// image imports can also be inlined directly
<Image src={import('../assets/hero.png')} />
```
</details>

Expand Down Expand Up @@ -176,6 +179,37 @@ description: Just a Hello World Post!
```
</details>

<details>
<summary><strong>Responsive pictures</strong></summary>

<br />

The `<Picture />` component can be used to automatically build a `<picture>` with multiple sizes and formats. Check out [MDN](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images#art_direction) for a deep dive into responsive images and art direction.

By default, the picture will include formats for `avif` and `webp` in addition to the image's original format.

For remote images, an `aspectRatio` is required to ensure the correct `height` can be calculated at build time.

```html
---
import { Picture } from '@astrojs/image';
import hero from '../assets/hero.png';

const imageUrl = 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png';
---

// Local image with multiple sizes
<Picture src={hero} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" />

// Remote image (aspect ratio is required)
<Picture src={imageUrl} widths={[200, 400, 800]} aspectRatio="4:3" sizes="(max-width: 800px) 100vw, 800px" />

// Inlined imports are supported
<Picture src={import("../assets/hero.png")} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" />
```

</details>

## Troubleshooting
- If your installation doesn't seem to be working, make sure to restart the dev server.
- If you edit and save a file and don't see your site update accordingly, try refreshing the page.
Expand Down
112 changes: 9 additions & 103 deletions packages/integrations/image/components/Image.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import loader from 'virtual:image-loader';
import { getImage } from '../src/index.js';
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.js';
export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src'> {
export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
}
Expand All @@ -17,109 +17,15 @@ export interface RemoteImageProps extends TransformOptions, ImageAttributes {
export type Props = LocalImageProps | RemoteImageProps;
function isLocalImage(props: Props): props is LocalImageProps {
// vite-plugin-astro-image resolves ESM imported images
// to a metadata object
return typeof props.src !== 'string';
}
function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
if (!aspectRatio) {
return undefined;
}
// parse aspect ratio strings, if required (ex: "16:9")
if (typeof aspectRatio === 'number') {
aspectRatio = aspectRatio;
} else {
const [width, height] = aspectRatio.split(':');
aspectRatio = parseInt(width) / parseInt(height);
}
}
async function resolveProps(props: Props): Promise<TransformOptions> {
// For remote images, just check the width/height provided
if (!isLocalImage(props)) {
return calculateSize(props);
}
const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props;
let { width, height, aspectRatio, format, ...rest } = props;
// if a Promise<ImageMetadata> was provided, unwrap it first
const { src, ...metadata } = 'then' in props.src ? (await props.src).default : props.src;
if (!width && !height) {
// neither dimension was provided, use the file metadata
width = metadata.width;
height = metadata.height;
} else if (width) {
// one dimension was provided, calculate the other
let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
height = height || width / ratio;
} else if (height) {
// one dimension was provided, calculate the other
let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
width = width || height * ratio;
}
return {
...rest,
width,
height,
aspectRatio,
src,
format: format || metadata.format as OutputFormat,
}
}
function calculateSize(transform: TransformOptions): TransformOptions {
// keep width & height as provided
if (transform.width && transform.height) {
return transform;
}
if (!transform.width && !transform.height) {
throw new Error(`"width" and "height" cannot both be undefined`);
}
if (!transform.aspectRatio) {
throw new Error(`"aspectRatio" must be included if only "${transform.width ? "width": "height"}" is provided`)
}
let aspectRatio: number;
const attrs = await getImage(loader, props);
---

// parse aspect ratio strings, if required (ex: "16:9")
if (typeof transform.aspectRatio === 'number') {
aspectRatio = transform.aspectRatio;
} else {
const [width, height] = transform.aspectRatio.split(':');
aspectRatio = parseInt(width) / parseInt(height);
}
<img {...attrs} {loading} {decoding} />

if (transform.width) {
// only width was provided, calculate height
return {
...transform,
width: transform.width,
height: transform.width / aspectRatio
};
} else if (transform.height) {
// only height was provided, calculate width
return {
...transform,
width: transform.height * aspectRatio,
height: transform.height
}
<style>
img {
content-visibility: auto;
}
return transform;
}
const props = Astro.props as Props;
const imageProps = await resolveProps(props);
const attrs = await getImage(loader, imageProps);
---

<img {...attrs} />
</style>
39 changes: 39 additions & 0 deletions packages/integrations/image/components/Picture.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
// @ts-ignore
import loader from 'virtual:image-loader';
import { getPicture } from '../src/get-picture.js';
import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../src/types.js';
export interface LocalImageProps extends Omit<PictureAttributes, 'src' | 'width' | 'height'>, Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
sizes: HTMLImageElement['sizes'];
widths: number[];
formats?: OutputFormat[];
}
export interface RemoteImageProps extends Omit<PictureAttributes, 'src' | 'width' | 'height'>, TransformOptions, Omit<ImageAttributes, 'src' | 'width' | 'height'> {
src: string;
sizes: HTMLImageElement['sizes'];
widths: number[];
aspectRatio: TransformOptions['aspectRatio'];
formats?: OutputFormat[];
}
export type Props = LocalImageProps | RemoteImageProps;
const { src, sizes, widths, aspectRatio, formats = ['avif', 'webp'], loading = 'lazy', decoding = 'eager', ...attrs } = Astro.props as Props;
const { image, sources } = await getPicture({ loader, src, widths, formats, aspectRatio });
---

<picture {...attrs}>
{sources.map(attrs => (
<source {...attrs} {sizes}>))}
<img {...image} {loading} {decoding} />
</picture>

<style>
img {
content-visibility: auto;
}
</style>
1 change: 1 addition & 0 deletions packages/integrations/image/components/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as Image } from './Image.astro';
export { default as Picture } from './Picture.astro';
3 changes: 2 additions & 1 deletion packages/integrations/image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"files": [
"components",
"dist",
"src"
"src",
"types"
],
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
Expand Down
3 changes: 3 additions & 0 deletions packages/integrations/image/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PKG_NAME = '@astrojs/image';
export const ROUTE_PATTERN = '/_image';
export const OUTPUT_DIR = '/_image';
128 changes: 128 additions & 0 deletions packages/integrations/image/src/get-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import slash from 'slash';
import { ROUTE_PATTERN } from './constants.js';
import { ImageAttributes, ImageMetadata, ImageService, isSSRService, OutputFormat, TransformOptions } from './types.js';
import { parseAspectRatio } from './utils.js';

export interface GetImageTransform extends Omit<TransformOptions, 'src'> {
src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
}

function resolveSize(transform: TransformOptions): TransformOptions {
// keep width & height as provided
if (transform.width && transform.height) {
return transform;
}

if (!transform.width && !transform.height) {
throw new Error(`"width" and "height" cannot both be undefined`);
}

if (!transform.aspectRatio) {
throw new Error(`"aspectRatio" must be included if only "${transform.width ? "width": "height"}" is provided`)
}

let aspectRatio: number;

// parse aspect ratio strings, if required (ex: "16:9")
if (typeof transform.aspectRatio === 'number') {
aspectRatio = transform.aspectRatio;
} else {
const [width, height] = transform.aspectRatio.split(':');
aspectRatio = Number.parseInt(width) / Number.parseInt(height);
}

if (transform.width) {
// only width was provided, calculate height
return {
...transform,
width: transform.width,
height: Math.round(transform.width / aspectRatio)
} as TransformOptions;
} else if (transform.height) {
// only height was provided, calculate width
return {
...transform,
width: Math.round(transform.height * aspectRatio),
height: transform.height
};
}

return transform;
}

async function resolveTransform(input: GetImageTransform): Promise<TransformOptions> {
// for remote images, only validate the width and height props
if (typeof input.src === 'string') {
return resolveSize(input as TransformOptions);
}

// resolve the metadata promise, usually when the ESM import is inlined
const metadata = 'then' in input.src
? (await input.src).default
: input.src;

let { width, height, aspectRatio, format = metadata.format, ...rest } = input;

if (!width && !height) {
// neither dimension was provided, use the file metadata
width = metadata.width;
height = metadata.height;
} else if (width) {
// one dimension was provided, calculate the other
let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
height = height || Math.round(width / ratio);
} else if (height) {
// one dimension was provided, calculate the other
let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
width = width || Math.round(height * ratio);
}

return {
...rest,
src: metadata.src,
width,
height,
aspectRatio,
format: format as OutputFormat,
}
}

/**
* Gets the HTML attributes required to build an `<img />` for the transformed image.
*
* @param loader @type {ImageService} The image service used for transforming images.
* @param transform @type {TransformOptions} The transformations requested for the optimized image.
* @returns @type {ImageAttributes} The HTML attributes to be included on the built `<img />` element.
*/
export async function getImage(
loader: ImageService,
transform: GetImageTransform
): Promise<ImageAttributes> {
(globalThis as any).loader = loader;

const resolved = await resolveTransform(transform);
const attributes = await loader.getImageAttributes(resolved);

// For SSR services, build URLs for the injected route
if (isSSRService(loader)) {
const { searchParams } = loader.serializeTransform(resolved);

// cache all images rendered to HTML
if (globalThis && (globalThis as any).addStaticImage) {
(globalThis as any)?.addStaticImage(resolved);
}

const src =
globalThis && (globalThis as any).filenameFormat
? (globalThis as any).filenameFormat(resolved, searchParams)
: `${ROUTE_PATTERN}?${searchParams.toString()}`;

return {
...attributes,
src: slash(src), // Windows compat
};
}

// For hosted services, return the `<img />` attributes as-is
return attributes;
}
Loading

0 comments on commit 89d7675

Please sign in to comment.