feat(image): throw if alt text is missing (#4511)

* feat(image): throw if no `alt` is provided

* chore: add changeset

* docs(image): update README

* updated alt text stuff throughout

* fixing with-mdx test suite

* warn for missing alt text, will throw an error in a future release

* final README tweaks

Co-authored-by: Tony Sullivan <tony.f.sullivan@outlook.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Jan Müller 2022-09-01 23:24:07 +02:00 committed by GitHub
parent df402ddc93
commit 72c760e9b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 391 additions and 50 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/image': minor
---
feat: throw if alt text is missing

View file

@ -18,7 +18,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 `<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.
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 replaceable, opening the door for future integrations that work with your favorite hosted image service.
## Installation
@ -90,6 +90,10 @@ import { Image, Picture } from '@astrojs/image/components';
The included `sharp` transformer supports resizing images and encoding them to different image formats. Third-party image services will be able to add support for custom transformations as well (ex: `blur`, `filter`, `rotate`, etc).
Astros <Image /> and <Picture /> components require the alt attribute which provides descriptive text for images. A warning will be logged if "alt" text is missing, and a future release of the integration will throw an error if no alt text is provided.
If the image is merely decorative (i.e. doesnt contribute to the understanding of the page), set alt="" so that the image is properly understood and ignored by screen readers.
### `<Image />`
The built-in `<Image />` component is used to create an optimized `<img />` for both remote images hosted on other domains as well as local images imported from your project's `src` directory.
@ -112,6 +116,18 @@ For images located in your project's `src`: use the file path relative to the `s
For remote images, provide the full URL. (e.g. `src="https://astro.build/assets/blog/astro-1-release-update.avif"`)
#### alt
<p>
**Type:** `string`<br>
**Required:** `true`
</p>
Defines an alternative text description of the image.
Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel).
#### format
<p>
@ -186,17 +202,23 @@ A `number` can also be provided, useful when the aspect ratio is calculated at b
Source for the original image file.
For images in your project's repository, use the path relative to the `src` or `public` directory. For remote images, provide the full URL.
For images located in your project's `src`: use the file path relative to the `src` directory. (e.g. `src="../assets/source-pic.png"`)
For images located in your `public` directory: use the URL path relative to the `public` directory. (e.g. `src="/images/public-image.jpg"`)
For remote images, provide the full URL. (e.g. `src="https://astro.build/assets/blog/astro-1-release-update.avif"`)
#### alt
<p>
**Type:** `string`<br>
**Default:** `undefined`
**Required:** `true`
</p>
If provided, the `alt` string will be included on the built `<img />` element.
Defines an alternative text description of the image.
Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel).
#### sizes
@ -266,7 +288,7 @@ const { src } = await getImage('../assets/hero.png');
<html>
<head>
<link rel="preload" as="image" href={src}>
<link rel="preload" as="image" href={src} alt="alt text">
</head>
</html>
```
@ -330,19 +352,19 @@ import heroImage from '../assets/hero.png';
---
// optimized image, keeping the original width, height, and image format
<Image src={heroImage} />
<Image src={heroImage} alt="descriptive text" />
// height will be recalculated to match the original aspect ratio
<Image src={heroImage} width={300} />
<Image src={heroImage} width={300} alt="descriptive text" />
// cropping to a specific width and height
<Image src={heroImage} width={300} height={600} />
<Image src={heroImage} width={300} height={600} alt="descriptive text" />
// 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" alt="descriptive text" />
// image imports can also be inlined directly
<Image src={import('../assets/hero.png')} />
<Image src={import('../assets/hero.png')} alt="descriptive text" />
```
#### Images in `/public`
@ -360,7 +382,7 @@ import socialImage from '/social.png';
---
// In static builds: the image will be built and optimized to `/dist`.
// In SSR builds: the image will be optimized by the server when requested by a browser.
<Image src={socialImage} width={1280} aspectRatio="16:9" />
<Image src={socialImage} width={1280} aspectRatio="16:9" alt="descriptive text" />
```
### Remote images
@ -375,13 +397,13 @@ const imageUrl = 'https://www.google.com/images/branding/googlelogo/2x/googlelog
---
// cropping to a specific width and height
<Image src={imageUrl} width={544} height={184} />
<Image src={imageUrl} width={544} height={184} alt="descriptive text" />
// height will be recalculated to match the aspect ratio
<Image src={imageUrl} width={300} aspectRatio={16/9} />
<Image src={imageUrl} width={300} aspectRatio={16/9} alt="descriptive text" />
// cropping to a specific height and aspect ratio and converting to an avif format
<Image src={imageUrl} height={200} aspectRatio="16:9" format="avif" />
<Image src={imageUrl} height={200} aspectRatio="16:9" format="avif" alt="descriptive text" />
```
### Responsive pictures
@ -401,13 +423,13 @@ const imageUrl = 'https://www.google.com/images/branding/googlelogo/2x/googlelog
---
// Local image with multiple sizes
<Picture src={hero} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" alt="My hero image" />
<Picture src={hero} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" alt="descriptive text" />
// Remote image (aspect ratio is required)
<Picture src={imageUrl} widths={[200, 400, 800]} aspectRatio="4:3" sizes="(max-width: 800px) 100vw, 800px" alt="My hero image" />
<Picture src={imageUrl} widths={[200, 400, 800]} aspectRatio="4:3" sizes="(max-width: 800px) 100vw, 800px" alt="descriptive text" />
// Inlined imports are supported
<Picture src={import("../assets/hero.png")} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" alt="My hero image" />
<Picture src={import("../assets/hero.png")} widths={[200, 400, 800]} sizes="(max-width: 800px) 100vw, 800px" alt="descriptive text" />
```
## Troubleshooting

View file

@ -1,6 +1,7 @@
---
// @ts-ignore
import { getImage } from '../dist/index.js';
import { warnForMissingAlt } from './index.js';
import type { ImgHTMLAttributes } from './index.js';
import type { ImageMetadata, TransformOptions, OutputFormat } from '../dist/index.js';
@ -8,10 +9,14 @@ interface LocalImageProps
extends Omit<TransformOptions, 'src'>,
Omit<ImgHTMLAttributes, 'src' | 'width' | 'height'> {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
/** Defines an alternative text description of the image. Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel). */
alt: string;
}
interface RemoteImageProps extends TransformOptions, astroHTML.JSX.ImgHTMLAttributes {
src: string;
/** Defines an alternative text description of the image. Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel). */
alt: string;
format: OutputFormat;
width: number;
height: number;
@ -21,6 +26,10 @@ export type Props = LocalImageProps | RemoteImageProps;
const { loading = 'lazy', decoding = 'async', ...props } = Astro.props as Props;
if (props.alt === undefined || props.alt === null) {
warnForMissingAlt();
}
const attrs = await getImage(props);
---

View file

@ -1,5 +1,6 @@
---
import { getPicture } from '../dist/index.js';
import { warnForMissingAlt } from './index.js';
import type { ImgHTMLAttributes, HTMLAttributes } from './index.js';
import type { ImageMetadata, OutputFormat, TransformOptions } from '../dist/index.js';
@ -8,7 +9,8 @@ interface LocalImageProps
Omit<TransformOptions, 'src'>,
Pick<astroHTML.JSX.ImgHTMLAttributes, 'loading' | 'decoding'> {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
alt?: string;
/** Defines an alternative text description of the image. Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel). */
alt: string;
sizes: HTMLImageElement['sizes'];
widths: number[];
formats?: OutputFormat[];
@ -19,7 +21,8 @@ interface RemoteImageProps
TransformOptions,
Pick<ImgHTMLAttributes, 'loading' | 'decoding'> {
src: string;
alt?: string;
/** Defines an alternative text description of the image. Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel). */
alt: string;
sizes: HTMLImageElement['sizes'];
widths: number[];
aspectRatio: TransformOptions['aspectRatio'];
@ -40,6 +43,10 @@ const {
...attrs
} = Astro.props as Props;
if (alt === undefined || alt === null) {
warnForMissingAlt();
}
const { image, sources } = await getPicture({ src, widths, formats, aspectRatio });
---

View file

@ -11,3 +11,17 @@ export type HTMLAttributes = Omit<
astroHTML.JSX.HTMLAttributes,
'client:list' | 'set:text' | 'set:html' | 'is:raw'
>;
let altWarningShown = false;
export function warnForMissingAlt() {
if (altWarningShown === true) { return }
altWarningShown = true;
console.warn(`\n[@astrojs/image] "alt" text was not provided for an <Image> or <Picture> component.
A future release of @astrojs/image may throw a build error when "alt" text is missing.
The "alt" attribute holds a text description of the image, which isn't mandatory but is incredibly useful for accessibility. Set to an empty string (alt="") if the image is not a key part of the content (it's decoration or a tracking pixel).\n`);
}

View file

@ -9,16 +9,16 @@ import { Image } from '@astrojs/image/components';
<!-- Head Stuff -->
</head>
<body>
<Image id="hero" src="/hero.jpg" width={768} height={414} format="webp" />
<Image id="hero" src="/hero.jpg" width={768} height={414} format="webp" alt="hero" />
<br />
<Image id="spaces" src={introJpg} width={768} height={414} format="webp" />
<Image id="spaces" src={introJpg} width={768} height={414} format="webp" alt="spaces" />
<br />
<Image id="social-jpg" src={socialJpg} width={506} height={253} />
<Image id="social-jpg" src={socialJpg} width={506} height={253} alt="social-jpg" />
<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" alt="Google" />
<br />
<Image id="inline" src={import('../assets/social.jpg')} width={506} />
<Image id="inline" src={import('../assets/social.jpg')} width={506} alt="inline" />
<br />
<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" />
<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" alt="query" />
</body>
</html>

View 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({ logLevel: 'silent' })]
});

View file

@ -0,0 +1,10 @@
{
"name": "@test/no-alt-text-image",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/image": "workspace:*",
"@astrojs/node": "workspace:*",
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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 = () => {};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,13 @@
---
import socialJpg from '../assets/social.jpg';
import { Image } from '@astrojs/image/components';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Image id="social-jpg" src={socialJpg} width={506} height={253} />
</body>
</html>

View 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({ logLevel: 'silent' })]
});

View file

@ -0,0 +1,10 @@
{
"name": "@test/no-alt-text-picture",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/image": "workspace:*",
"@astrojs/node": "workspace:*",
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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 = () => {};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,13 @@
---
import socialJpg from '../assets/social.jpg';
import { Picture } from '@astrojs/image/components';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Picture id="social-jpg" src={socialJpg} sizes="(min-width: 640px) 50vw, 100vw" />
</body>
</html>

View file

@ -7,42 +7,42 @@ import { Image } from '@astrojs/image/components';
<!-- Head Stuff -->
</head>
<body>
<Image id='landscape-0' src={import('../assets/Landscape_0.jpg')} />
<Image id='landscape-0' src={import('../assets/Landscape_0.jpg')} alt="landscape-0" />
<br />
<Image id='landscape-1' src={import('../assets/Landscape_1.jpg')} />
<Image id='landscape-1' src={import('../assets/Landscape_1.jpg')} alt="landscape-1" />
<br />
<Image id='landscape-2' src={import('../assets/Landscape_2.jpg')} />
<Image id='landscape-2' src={import('../assets/Landscape_2.jpg')} alt="landscape-2" />
<br />
<Image id='landscape-3' src={import('../assets/Landscape_3.jpg')} />
<Image id='landscape-3' src={import('../assets/Landscape_3.jpg')} alt="landscape-3" />
<br />
<Image id='landscape-4' src={import('../assets/Landscape_4.jpg')} />
<Image id='landscape-4' src={import('../assets/Landscape_4.jpg')} alt="landscape-4" />
<br />
<Image id='landscape-5' src={import('../assets/Landscape_5.jpg')} />
<Image id='landscape-5' src={import('../assets/Landscape_5.jpg')} alt="landscape-5" />
<br />
<Image id='landscape-6' src={import('../assets/Landscape_6.jpg')} />
<Image id='landscape-6' src={import('../assets/Landscape_6.jpg')} alt="landscape-6" />
<br />
<Image id='landscape-7' src={import('../assets/Landscape_7.jpg')} />
<Image id='landscape-7' src={import('../assets/Landscape_7.jpg')} alt="landscape-7" />
<br />
<Image id='landscape-8' src={import('../assets/Landscape_8.jpg')} />
<Image id='landscape-8' src={import('../assets/Landscape_8.jpg')} alt="landscape-8" />
<br />
<Image id='portrait-0' src={import('../assets/Portrait_0.jpg')} />
<Image id='portrait-0' src={import('../assets/Portrait_0.jpg')} alt="portrait-0" />
<br />
<Image id='portrait-1' src={import('../assets/Portrait_1.jpg')} />
<Image id='portrait-1' src={import('../assets/Portrait_1.jpg')} alt="portrait-1" />
<br />
<Image id='portrait-2' src={import('../assets/Portrait_2.jpg')} />
<Image id='portrait-2' src={import('../assets/Portrait_2.jpg')} alt="portrait-2" />
<br />
<Image id='portrait-3' src={import('../assets/Portrait_3.jpg')} />
<Image id='portrait-3' src={import('../assets/Portrait_3.jpg')} alt="portrait-3" />
<br />
<Image id='portrait-4' src={import('../assets/Portrait_4.jpg')} />
<Image id='portrait-4' src={import('../assets/Portrait_4.jpg')} alt="portrait-4" />
<br />
<Image id='portrait-5' src={import('../assets/Portrait_5.jpg')} />
<Image id='portrait-5' src={import('../assets/Portrait_5.jpg')} alt="portrait-5" />
<br />
<Image id='portrait-6' src={import('../assets/Portrait_6.jpg')} />
<Image id='portrait-6' src={import('../assets/Portrait_6.jpg')} alt="portrait-6" />
<br />
<Image id='portrait-7' src={import('../assets/Portrait_7.jpg')} />
<Image id='portrait-7' src={import('../assets/Portrait_7.jpg')} alt="portrait-7" />
<br />
<Image id='portrait-8' src={import('../assets/Portrait_8.jpg')} />
<Image id='portrait-8' src={import('../assets/Portrait_8.jpg')} alt="portrait-8" />
<br />
</body>
</html>

View file

@ -6,12 +6,12 @@ import socialJpg from '../assets/social.jpg';
import { Image } from '@astrojs/image/components';
<Image id="hero" src="/hero.jpg" width={768} height={414} format="webp" />
<Image id="hero" src="/hero.jpg" width={768} height={414} format="webp" alt="hero" />
<br />
<Image id="social-jpg" src={socialJpg} width={506} height={253} />
<Image id="social-jpg" src={socialJpg} width={506} height={253} alt="social-jpg" />
<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" alt="Google" />
<br />
<Image id="inline" src={import('../assets/social.jpg')} width={506} />
<Image id="inline" src={import('../assets/social.jpg')} width={506} alt="inline" />
<br />
<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" />
<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" alt="query" />

View file

@ -0,0 +1,24 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
let fixture;
const errorMessage =
'The <Image> component requires you provide alt text. If this image does not require an accessible label, set alt="".';
/** TODO: enable the test once missing alt text throws an error instead of a console warning */
describe.skip('SSG image without alt text', function () {
before(async () => {
fixture = await loadFixture({ root: './fixtures/no-alt-text-image/' });
});
it('throws during build', async () => {
try {
await fixture.build();
} catch (err) {
expect(err.message).to.equal(errorMessage);
return;
}
expect.fail(0, 1, 'Exception not thrown');
});
});

View file

@ -0,0 +1,33 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from '../../../astro/test/test-adapter.js';
let fixture;
const errorMessage =
'The <Image> component requires you provide alt text. If this image does not require an accessible label, set alt="".';
/** TODO: enable the test once missing alt text throws an error instead of a console warning */
describe.skip('SSR image without alt text', function () {
before(async () => {
fixture = await loadFixture({
root: './fixtures/no-alt-text-image/',
adapter: testAdapter({ streaming: false }),
output: 'server',
});
await fixture.build();
});
it('throws during build', async () => {
try {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
await response.text();
} catch (err) {
expect(err.message).to.equal(errorMessage);
return;
}
expect.fail(0, 1, 'Exception not thrown');
});
});

View file

@ -0,0 +1,24 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
let fixture;
const errorMessage =
'The <Picture> component requires you provide alt text. If this picture does not require an accessible label, set alt="".';
/** TODO: enable the test once missing alt text throws an error instead of a console warning */
describe.skip('SSG picture without alt text', function () {
before(async () => {
fixture = await loadFixture({ root: './fixtures/no-alt-text-picture/' });
});
it('throws during build', async () => {
try {
await fixture.build();
} catch (err) {
expect(err.message).to.equal(errorMessage);
return;
}
expect.fail(0, 1, 'Exception not thrown');
});
});

View file

@ -0,0 +1,33 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from '../../../astro/test/test-adapter.js';
let fixture;
const errorMessage =
'The <Picture> component requires you provide alt text. If this picture does not require an accessible label, set alt="".';
/** TODO: enable the test once missing alt text throws an error instead of a console warning */
describe.skip('SSR picture without alt text', function () {
before(async () => {
fixture = await loadFixture({
root: './fixtures/no-alt-text-picture/',
adapter: testAdapter({ streaming: false }),
output: 'server',
});
await fixture.build();
});
it('throws during build', async () => {
try {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
await response.text();
} catch (err) {
expect(err.message).to.equal(errorMessage);
return;
}
expect.fail(0, 1, 'Exception not thrown');
});
});

View file

@ -2253,6 +2253,26 @@ importers:
'@astrojs/node': link:../../../../node
astro: link:../../../../../astro
packages/integrations/image/test/fixtures/no-alt-text-image:
specifiers:
'@astrojs/image': workspace:*
'@astrojs/node': workspace:*
astro: workspace:*
dependencies:
'@astrojs/image': link:../../..
'@astrojs/node': link:../../../../node
astro: link:../../../../../astro
packages/integrations/image/test/fixtures/no-alt-text-picture:
specifiers:
'@astrojs/image': workspace:*
'@astrojs/node': workspace:*
astro: workspace:*
dependencies:
'@astrojs/image': link:../../..
'@astrojs/node': link:../../../../node
astro: link:../../../../../astro
packages/integrations/image/test/fixtures/rotation:
specifiers:
'@astrojs/image': workspace:*