Adds a new <Picture>
component to the image integration (#3866)
* 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 6e5f578da8
.
* 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 <natemoo-re@users.noreply.github.com>
* 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 <natemoo-re@users.noreply.github.com>
This commit is contained in:
parent
ec392589f6
commit
89d76753a0
28 changed files with 1053 additions and 165 deletions
5
.changeset/bright-starfishes-clap.md
Normal file
5
.changeset/bright-starfishes-clap.md
Normal file
|
@ -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:
|
|
@ -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.
|
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
|
## Installation
|
||||||
|
|
||||||
|
@ -124,6 +124,9 @@ import heroImage from '../assets/hero.png';
|
||||||
|
|
||||||
// cropping to a specific aspect ratio and converting to an avif format
|
// cropping to a specific aspect ratio and converting to an avif format
|
||||||
<Image src={heroImage} aspectRatio="16:9" format="avif" />
|
<Image src={heroImage} aspectRatio="16:9" format="avif" />
|
||||||
|
|
||||||
|
// image imports can also be inlined directly
|
||||||
|
<Image src={import('../assets/hero.png')} />
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
@ -176,6 +179,37 @@ description: Just a Hello World Post!
|
||||||
```
|
```
|
||||||
</details>
|
</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
|
## Troubleshooting
|
||||||
- If your installation doesn't seem to be working, make sure to restart the dev server.
|
- 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.
|
- If you edit and save a file and don't see your site update accordingly, try refreshing the page.
|
||||||
|
|
|
@ -4,7 +4,7 @@ import loader from 'virtual:image-loader';
|
||||||
import { getImage } from '../src/index.js';
|
import { getImage } from '../src/index.js';
|
||||||
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.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 }>;
|
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,109 +17,15 @@ export interface RemoteImageProps extends TransformOptions, ImageAttributes {
|
||||||
|
|
||||||
export type Props = LocalImageProps | RemoteImageProps;
|
export type Props = LocalImageProps | RemoteImageProps;
|
||||||
|
|
||||||
function isLocalImage(props: Props): props is LocalImageProps {
|
const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props;
|
||||||
// vite-plugin-astro-image resolves ESM imported images
|
|
||||||
// to a metadata object
|
|
||||||
return typeof props.src !== 'string';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
|
const attrs = await getImage(loader, props);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = Astro.props as Props;
|
|
||||||
|
|
||||||
const imageProps = await resolveProps(props);
|
|
||||||
|
|
||||||
const attrs = await getImage(loader, imageProps);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<img {...attrs} />
|
<img {...attrs} {loading} {decoding} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
img {
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
39
packages/integrations/image/components/Picture.astro
Normal file
39
packages/integrations/image/components/Picture.astro
Normal file
|
@ -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 +1,2 @@
|
||||||
export { default as Image } from './Image.astro';
|
export { default as Image } from './Image.astro';
|
||||||
|
export { default as Picture } from './Picture.astro';
|
||||||
|
|
|
@ -33,7 +33,8 @@
|
||||||
"files": [
|
"files": [
|
||||||
"components",
|
"components",
|
||||||
"dist",
|
"dist",
|
||||||
"src"
|
"src",
|
||||||
|
"types"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
||||||
|
|
3
packages/integrations/image/src/constants.ts
Normal file
3
packages/integrations/image/src/constants.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const PKG_NAME = '@astrojs/image';
|
||||||
|
export const ROUTE_PATTERN = '/_image';
|
||||||
|
export const OUTPUT_DIR = '/_image';
|
128
packages/integrations/image/src/get-image.ts
Normal file
128
packages/integrations/image/src/get-image.ts
Normal file
|
@ -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;
|
||||||
|
}
|
79
packages/integrations/image/src/get-picture.ts
Normal file
79
packages/integrations/image/src/get-picture.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { lookup } from 'mrmime';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import { getImage } from './get-image.js';
|
||||||
|
import { ImageAttributes, ImageMetadata, ImageService, OutputFormat, TransformOptions } from './types.js';
|
||||||
|
import { parseAspectRatio } from './utils.js';
|
||||||
|
|
||||||
|
export interface GetPictureParams {
|
||||||
|
loader: ImageService;
|
||||||
|
src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
|
||||||
|
widths: number[];
|
||||||
|
formats: OutputFormat[];
|
||||||
|
aspectRatio?: TransformOptions['aspectRatio'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPictureResult {
|
||||||
|
image: ImageAttributes;
|
||||||
|
sources: { type: string; srcset: string; }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAspectRatio({ src, aspectRatio }: GetPictureParams) {
|
||||||
|
if (typeof src === 'string') {
|
||||||
|
return parseAspectRatio(aspectRatio);
|
||||||
|
} else {
|
||||||
|
const metadata = 'then' in src ? (await src).default : src;
|
||||||
|
return parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFormats({ src, formats }: GetPictureParams) {
|
||||||
|
const unique = new Set(formats);
|
||||||
|
|
||||||
|
if (typeof src === 'string') {
|
||||||
|
unique.add(extname(src).replace('.', '') as OutputFormat);
|
||||||
|
} else {
|
||||||
|
const metadata = 'then' in src ? (await src).default : src;
|
||||||
|
unique.add(extname(metadata.src).replace('.', '') as OutputFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...unique];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> {
|
||||||
|
const { loader, src, widths, formats } = params;
|
||||||
|
|
||||||
|
const aspectRatio = await resolveAspectRatio(params);
|
||||||
|
|
||||||
|
if (!aspectRatio) {
|
||||||
|
throw new Error('`aspectRatio` must be provided for remote images');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSource(format: OutputFormat) {
|
||||||
|
const imgs = await Promise.all(widths.map(async (width) => {
|
||||||
|
const img = await getImage(loader, { src, format, width, height: Math.round(width / aspectRatio!) });
|
||||||
|
return `${img.src} ${width}w`;
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: lookup(format) || format,
|
||||||
|
srcset: imgs.join(',')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// always include the original image format
|
||||||
|
const allFormats = await resolveFormats(params);
|
||||||
|
|
||||||
|
const image = await getImage(loader, {
|
||||||
|
src,
|
||||||
|
width: Math.max(...widths),
|
||||||
|
aspectRatio,
|
||||||
|
format: allFormats[allFormats.length - 1]
|
||||||
|
});
|
||||||
|
|
||||||
|
const sources = await Promise.all(allFormats.map(format => getSource(format)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources,
|
||||||
|
image
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,11 @@
|
||||||
import type { AstroConfig, AstroIntegration } from 'astro';
|
import type { AstroConfig, AstroIntegration } from 'astro';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import slash from 'slash';
|
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import type {
|
import { OUTPUT_DIR, PKG_NAME, ROUTE_PATTERN } from './constants.js';
|
||||||
ImageAttributes,
|
export * from './get-image.js';
|
||||||
IntegrationOptions,
|
export * from './get-picture.js';
|
||||||
SSRImageService,
|
import { IntegrationOptions, TransformOptions } from './types.js';
|
||||||
TransformOptions,
|
|
||||||
} from './types';
|
|
||||||
import {
|
import {
|
||||||
ensureDir,
|
ensureDir,
|
||||||
isRemoteImage,
|
isRemoteImage,
|
||||||
|
@ -18,49 +15,6 @@ import {
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { createPlugin } from './vite-plugin-astro-image.js';
|
import { createPlugin } from './vite-plugin-astro-image.js';
|
||||||
|
|
||||||
const PKG_NAME = '@astrojs/image';
|
|
||||||
const ROUTE_PATTERN = '/_image';
|
|
||||||
const OUTPUT_DIR = '/_image';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: SSRImageService,
|
|
||||||
transform: TransformOptions
|
|
||||||
): Promise<ImageAttributes> {
|
|
||||||
(globalThis as any).loader = loader;
|
|
||||||
|
|
||||||
const attributes = await loader.getImageAttributes(transform);
|
|
||||||
|
|
||||||
// For SSR services, build URLs for the injected route
|
|
||||||
if (typeof loader.transform === 'function') {
|
|
||||||
const { searchParams } = loader.serializeTransform(transform);
|
|
||||||
|
|
||||||
// cache all images rendered to HTML
|
|
||||||
if (globalThis && (globalThis as any).addStaticImage) {
|
|
||||||
(globalThis as any)?.addStaticImage(transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
const src =
|
|
||||||
globalThis && (globalThis as any).filenameFormat
|
|
||||||
? (globalThis as any).filenameFormat(transform, searchParams)
|
|
||||||
: `${ROUTE_PATTERN}?${searchParams.toString()}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...attributes,
|
|
||||||
src: slash(src), // Windows compat
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For hosted services, return the <img /> attributes as-is
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => {
|
const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => {
|
||||||
const resolvedOptions = {
|
const resolvedOptions = {
|
||||||
serviceEntryPoint: '@astrojs/image/sharp',
|
serviceEntryPoint: '@astrojs/image/sharp',
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { isAspectRatioString, isOutputFormat } from '../utils.js';
|
||||||
|
|
||||||
class SharpService implements SSRImageService {
|
class SharpService implements SSRImageService {
|
||||||
async getImageAttributes(transform: TransformOptions) {
|
async getImageAttributes(transform: TransformOptions) {
|
||||||
|
// strip off the known attributes
|
||||||
const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
|
const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export type { Image } from '../components/index';
|
/// <reference types="astro/astro-jsx" />
|
||||||
export * from './index';
|
export type { Image, Picture } from '../components/index.js';
|
||||||
|
export * from './index.js';
|
||||||
|
|
||||||
export type InputFormat =
|
export type InputFormat =
|
||||||
| 'heic'
|
| 'heic'
|
||||||
|
@ -72,7 +73,8 @@ export interface TransformOptions {
|
||||||
aspectRatio?: number | `${number}:${number}`;
|
aspectRatio?: number | `${number}:${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageAttributes = Partial<HTMLImageElement>;
|
export type ImageAttributes = astroHTML.JSX.ImgHTMLAttributes;
|
||||||
|
export type PictureAttributes = astroHTML.JSX.HTMLAttributes;
|
||||||
|
|
||||||
export interface HostedImageService<T extends TransformOptions = TransformOptions> {
|
export interface HostedImageService<T extends TransformOptions = TransformOptions> {
|
||||||
/**
|
/**
|
||||||
|
@ -81,10 +83,9 @@ export interface HostedImageService<T extends TransformOptions = TransformOption
|
||||||
getImageAttributes(transform: T): Promise<ImageAttributes>;
|
getImageAttributes(transform: T): Promise<ImageAttributes>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSRImageService<T extends TransformOptions = TransformOptions>
|
export interface SSRImageService<T extends TransformOptions = TransformOptions> extends HostedImageService<T> {
|
||||||
extends HostedImageService<T> {
|
|
||||||
/**
|
/**
|
||||||
* Gets the HTML attributes needed for the server rendered `<img />` element.
|
* Gets tthe HTML attributes needed for the server rendered `<img />` element.
|
||||||
*/
|
*/
|
||||||
getImageAttributes(transform: T): Promise<Exclude<ImageAttributes, 'src'>>;
|
getImageAttributes(transform: T): Promise<Exclude<ImageAttributes, 'src'>>;
|
||||||
/**
|
/**
|
||||||
|
@ -115,6 +116,14 @@ export type ImageService<T extends TransformOptions = TransformOptions> =
|
||||||
| HostedImageService<T>
|
| HostedImageService<T>
|
||||||
| SSRImageService<T>;
|
| SSRImageService<T>;
|
||||||
|
|
||||||
|
export function isHostedService(service: ImageService): service is ImageService {
|
||||||
|
return 'getImageSrc' in service;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSSRService(service: ImageService): service is SSRImageService {
|
||||||
|
return 'transform' in service;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImageMetadata {
|
export interface ImageMetadata {
|
||||||
src: string;
|
src: string;
|
||||||
width: number;
|
width: number;
|
||||||
|
|
|
@ -58,3 +58,17 @@ export function propsToFilename({ src, width, height, format }: TransformOptions
|
||||||
|
|
||||||
return format ? src.replace(ext, format) : src;
|
return format ? src.replace(ext, format) : src;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
|
||||||
|
if (!aspectRatio) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse aspect ratio strings, if required (ex: "16:9")
|
||||||
|
if (typeof aspectRatio === 'number') {
|
||||||
|
return aspectRatio;
|
||||||
|
} else {
|
||||||
|
const [width, height] = aspectRatio.split(':');
|
||||||
|
return parseInt(width) / parseInt(height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@test/sharp",
|
"name": "@test/basic-image",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -12,6 +12,6 @@ import { Image } from '@astrojs/image';
|
||||||
<br />
|
<br />
|
||||||
<Image id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="webp" />
|
<Image id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="webp" />
|
||||||
<br />
|
<br />
|
||||||
<Image id='testing' src={import('../assets/social.jpg')} width={506} format="avif" />
|
<Image id='inline' src={import('../assets/social.jpg')} width={506} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
8
packages/integrations/image/test/fixtures/basic-picture/astro.config.mjs
vendored
Normal file
8
packages/integrations/image/test/fixtures/basic-picture/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import image from '@astrojs/image';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'http://localhost:3000',
|
||||||
|
integrations: [image()]
|
||||||
|
});
|
10
packages/integrations/image/test/fixtures/basic-picture/package.json
vendored
Normal file
10
packages/integrations/image/test/fixtures/basic-picture/package.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "@test/basic-picture",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/image": "workspace:*",
|
||||||
|
"@astrojs/node": "workspace:*",
|
||||||
|
"astro": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
BIN
packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-picture/public/favicon.ico
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
44
packages/integrations/image/test/fixtures/basic-picture/server/server.mjs
vendored
Normal file
44
packages/integrations/image/test/fixtures/basic-picture/server/server.mjs
vendored
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import fs from 'fs';
|
||||||
|
import mime from 'mime';
|
||||||
|
import { handler as ssrHandler } from '../dist/server/entry.mjs';
|
||||||
|
|
||||||
|
const clientRoot = new URL('../dist/client/', import.meta.url);
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
ssrHandler(req, res, async (err) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.stack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local = new URL('.' + req.url, clientRoot);
|
||||||
|
try {
|
||||||
|
const data = await fs.promises.readFile(local);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': mime.getType(req.url),
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
handle(req, res).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
res.writeHead(500, {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
});
|
||||||
|
res.end(err.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8085);
|
||||||
|
console.log('Serving at http://localhost:8085');
|
||||||
|
|
||||||
|
// Silence weird <time> warning
|
||||||
|
console.error = () => {};
|
BIN
packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpg
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-picture/src/assets/blog/introducing-astro.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 270 KiB |
BIN
packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpg
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-picture/src/assets/social.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
packages/integrations/image/test/fixtures/basic-picture/src/assets/social.png
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-picture/src/assets/social.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 MiB |
17
packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro
vendored
Normal file
17
packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
import socialJpg from '../assets/social.jpg';
|
||||||
|
import { Picture } from '@astrojs/image';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!-- Head Stuff -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Picture id="social-jpg" src={socialJpg} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} />
|
||||||
|
<br />
|
||||||
|
<Picture id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" sizes="(min-width: 640px) 50vw, 100vw" widths={[272, 544]} aspectRatio={544/184} />
|
||||||
|
<br />
|
||||||
|
<Picture id='inline' src={import('../assets/social.jpg')} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} />
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,6 +1,5 @@
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import path from 'path';
|
|
||||||
import sizeOf from 'image-size';
|
import sizeOf from 'image-size';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { loadFixture } from './test-utils.js';
|
import { loadFixture } from './test-utils.js';
|
||||||
|
@ -38,6 +37,16 @@ describe('SSG images', function () {
|
||||||
expect(image.attr('width')).to.equal('506');
|
expect(image.attr('width')).to.equal('506');
|
||||||
expect(image.attr('height')).to.equal('253');
|
expect(image.attr('height')).to.equal('253');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inline imports', () => {
|
||||||
|
it ('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#inline');
|
||||||
|
|
||||||
|
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
||||||
|
expect(image.attr('width')).to.equal('506');
|
||||||
|
expect(image.attr('height')).to.equal('253');
|
||||||
|
});
|
||||||
|
|
||||||
it('built the optimized image', () => {
|
it('built the optimized image', () => {
|
||||||
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
||||||
|
@ -111,6 +120,36 @@ describe('SSG images', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Local images with inline imports', () => {
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#inline');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the optimized image', async () => {
|
||||||
|
const image = $('#inline');
|
||||||
|
|
||||||
|
const res = await fixture.fetch(image.attr('src'));
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Remote images', () => {
|
describe('Remote images', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes src, width, and height attributes', () => {
|
||||||
const image = $('#google');
|
const image = $('#google');
|
||||||
|
|
|
@ -62,6 +62,32 @@ describe('SSR images - build', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Inline imports', () => {
|
||||||
|
it('includes src, width, and height attributes', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const image = $('#inline');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Remote images', () => {
|
describe('Remote images', () => {
|
||||||
it('includes src, width, and height attributes', async () => {
|
it('includes src, width, and height attributes', async () => {
|
||||||
const app = await fixture.loadTestAdapterApp();
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
@ -142,6 +168,25 @@ describe('SSR images - dev', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Inline imports', () => {
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#inline');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Remote images', () => {
|
describe('Remote images', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes src, width, and height attributes', () => {
|
||||||
const image = $('#google');
|
const image = $('#google');
|
||||||
|
|
263
packages/integrations/image/test/picture-ssg.test.js
Normal file
263
packages/integrations/image/test/picture-ssg.test.js
Normal file
|
@ -0,0 +1,263 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import fs from 'fs';
|
||||||
|
import sizeOf from 'image-size';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { loadFixture } from './test-utils.js';
|
||||||
|
|
||||||
|
let fixture;
|
||||||
|
|
||||||
|
describe('SSG pictures', function () {
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({ root: './fixtures/basic-picture/' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function verifyImage(pathname, expected) {
|
||||||
|
const url = new URL('./fixtures/basic-picture/dist/' + pathname, import.meta.url);
|
||||||
|
const dist = fileURLToPath(url);
|
||||||
|
|
||||||
|
// image-size doesn't support AVIF files
|
||||||
|
if (expected.type !== 'avif') {
|
||||||
|
const result = sizeOf(dist);
|
||||||
|
expect(result).to.deep.equal(expected);
|
||||||
|
} else {
|
||||||
|
expect(fs.statSync(dist)).not.to.be.undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('build', () => {
|
||||||
|
let $;
|
||||||
|
let html;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await fixture.build();
|
||||||
|
|
||||||
|
html = await fixture.readFile('/index.html');
|
||||||
|
$ = cheerio.load(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Local images', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#social-jpg source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
||||||
|
expect(image.attr('width')).to.equal('506');
|
||||||
|
expect(image.attr('height')).to.equal('253');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built the optimized image', () => {
|
||||||
|
verifyImage('_image/assets/social_253x127.avif', { width: 253, height: 127, type: 'avif' });
|
||||||
|
verifyImage('_image/assets/social_253x127.webp', { width: 253, height: 127, type: 'webp' });
|
||||||
|
verifyImage('_image/assets/social_253x127.jpg', { width: 253, height: 127, type: 'jpg' });
|
||||||
|
verifyImage('_image/assets/social_506x253.avif', { width: 506, height: 253, type: 'avif' });
|
||||||
|
verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' });
|
||||||
|
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inline imports', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#inline source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#inline img');
|
||||||
|
|
||||||
|
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
||||||
|
expect(image.attr('width')).to.equal('506');
|
||||||
|
expect(image.attr('height')).to.equal('253');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built the optimized image', () => {
|
||||||
|
verifyImage('_image/assets/social_253x127.avif', { width: 253, height: 127, type: 'avif' });
|
||||||
|
verifyImage('_image/assets/social_253x127.webp', { width: 253, height: 127, type: 'webp' });
|
||||||
|
verifyImage('_image/assets/social_253x127.jpg', { width: 253, height: 127, type: 'jpg' });
|
||||||
|
verifyImage('_image/assets/social_506x253.avif', { width: 506, height: 253, type: 'avif' });
|
||||||
|
verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' });
|
||||||
|
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Remote images', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#google source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#google img');
|
||||||
|
|
||||||
|
expect(image.attr('src')).to.equal('/_image/googlelogo_color_272x92dp_544x184.png');
|
||||||
|
expect(image.attr('width')).to.equal('544');
|
||||||
|
expect(image.attr('height')).to.equal('184');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built the optimized image', () => {
|
||||||
|
verifyImage('_image/googlelogo_color_272x92dp_272x92.avif', {
|
||||||
|
width: 272,
|
||||||
|
height: 92,
|
||||||
|
type: 'avif',
|
||||||
|
});
|
||||||
|
verifyImage('_image/googlelogo_color_272x92dp_272x92.webp', {
|
||||||
|
width: 272,
|
||||||
|
height: 92,
|
||||||
|
type: 'webp',
|
||||||
|
});
|
||||||
|
verifyImage('_image/googlelogo_color_272x92dp_272x92.png', {
|
||||||
|
width: 272,
|
||||||
|
height: 92,
|
||||||
|
type: 'png',
|
||||||
|
});
|
||||||
|
verifyImage('_image/googlelogo_color_272x92dp_544x184.avif', {
|
||||||
|
width: 544,
|
||||||
|
height: 184,
|
||||||
|
type: 'avif',
|
||||||
|
});
|
||||||
|
verifyImage('_image/googlelogo_color_272x92dp_544x184.webp', {
|
||||||
|
width: 544,
|
||||||
|
height: 184,
|
||||||
|
type: 'webp',
|
||||||
|
});
|
||||||
|
verifyImage('_image/googlelogo_color_272x92dp_544x184.png', {
|
||||||
|
width: 544,
|
||||||
|
height: 184,
|
||||||
|
type: 'png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dev', () => {
|
||||||
|
let devServer;
|
||||||
|
let $;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
devServer = await fixture.startDevServer();
|
||||||
|
const html = await fixture.fetch('/').then((res) => res.text());
|
||||||
|
$ = cheerio.load(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await devServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Local images', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#social-jpg source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the optimized image', async () => {
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
const res = await fixture.fetch(image.attr('src'));
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Local images with inline imports', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#inline source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#inline img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the optimized image', async () => {
|
||||||
|
const image = $('#inline img');
|
||||||
|
|
||||||
|
const res = await fixture.fetch(image.attr('src'));
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Remote images', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#google source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#google img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('png');
|
||||||
|
expect(searchParams.get('w')).to.equal('544');
|
||||||
|
expect(searchParams.get('h')).to.equal('184');
|
||||||
|
expect(searchParams.get('href')).to.equal(
|
||||||
|
'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
278
packages/integrations/image/test/picture-ssr.test.js
Normal file
278
packages/integrations/image/test/picture-ssr.test.js
Normal file
|
@ -0,0 +1,278 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { loadFixture } from './test-utils.js';
|
||||||
|
import testAdapter from '../../../astro/test/test-adapter.js';
|
||||||
|
|
||||||
|
describe('SSR pictures - build', function () {
|
||||||
|
let fixture;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({
|
||||||
|
root: './fixtures/basic-picture/',
|
||||||
|
adapter: testAdapter(),
|
||||||
|
experimental: {
|
||||||
|
ssr: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fixture.build();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Local images', () => {
|
||||||
|
it('includes sources', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const sources = $('#social-jpg source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Track down why the fixture.fetch is failing with the test adapter
|
||||||
|
it.skip('built the optimized image', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
const res = await fixture.fetch(image.attr('src'));
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inline imports', () => {
|
||||||
|
it('includes sources', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const sources = $('#inline source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const image = $('#inline img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Remote images', () => {
|
||||||
|
it('includes sources', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const sources = $('#google source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
|
const request = new Request('http://example.com/');
|
||||||
|
const response = await app.render(request);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const image = $('#google img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('png');
|
||||||
|
expect(searchParams.get('w')).to.equal('544');
|
||||||
|
expect(searchParams.get('h')).to.equal('184');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('googlelogo_color_272x92dp.png')).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSR images - dev', function () {
|
||||||
|
let fixture;
|
||||||
|
let devServer;
|
||||||
|
let $;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({
|
||||||
|
root: './fixtures/basic-picture/',
|
||||||
|
adapter: testAdapter(),
|
||||||
|
experimental: {
|
||||||
|
ssr: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
devServer = await fixture.startDevServer();
|
||||||
|
const html = await fixture.fetch('/').then((res) => res.text());
|
||||||
|
$ = cheerio.load(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await devServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Local images', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#social-jpg source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the optimized image', async () => {
|
||||||
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
|
const res = await fixture.fetch(image.attr('src'));
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inline imports', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#inline source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#inline img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('jpg');
|
||||||
|
expect(searchParams.get('w')).to.equal('506');
|
||||||
|
expect(searchParams.get('h')).to.equal('253');
|
||||||
|
// TODO: possible to avoid encoding the full image path?
|
||||||
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Remote images', () => {
|
||||||
|
it('includes sources', () => {
|
||||||
|
const sources = $('#google source');
|
||||||
|
|
||||||
|
expect(sources.length).to.equal(3);
|
||||||
|
|
||||||
|
// TODO: better coverage to verify source props
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes src, width, and height attributes', () => {
|
||||||
|
const image = $('#google img');
|
||||||
|
|
||||||
|
const src = image.attr('src');
|
||||||
|
const [route, params] = src.split('?');
|
||||||
|
|
||||||
|
expect(route).to.equal('/_image');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
expect(searchParams.get('f')).to.equal('png');
|
||||||
|
expect(searchParams.get('w')).to.equal('544');
|
||||||
|
expect(searchParams.get('h')).to.equal('184');
|
||||||
|
expect(searchParams.get('href')).to.equal(
|
||||||
|
'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1966,6 +1966,16 @@ importers:
|
||||||
'@astrojs/node': link:../../../../node
|
'@astrojs/node': link:../../../../node
|
||||||
astro: link:../../../../../astro
|
astro: link:../../../../../astro
|
||||||
|
|
||||||
|
packages/integrations/image/test/fixtures/basic-picture:
|
||||||
|
specifiers:
|
||||||
|
'@astrojs/image': workspace:*
|
||||||
|
'@astrojs/node': workspace:*
|
||||||
|
astro: workspace:*
|
||||||
|
dependencies:
|
||||||
|
'@astrojs/image': link:../../..
|
||||||
|
'@astrojs/node': link:../../../../node
|
||||||
|
astro: link:../../../../../astro
|
||||||
|
|
||||||
packages/integrations/lit:
|
packages/integrations/lit:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@lit-labs/ssr': ^2.2.0
|
'@lit-labs/ssr': ^2.2.0
|
||||||
|
|
Loading…
Reference in a new issue