Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a new <Picture> component to the image integration #3866

Merged
merged 22 commits into from
Jul 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -32,7 +32,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