fix(images): Improve error handling around the new assets feature (#6649)
* fix(images): Improve error handling around the new assets feature * fix: add missing error message * fix(images): Fix schema preprocessing logic returning undefined when the file doesn't exist * test: add tests * chore: lockfile * chore: changeset * Apply suggestions from code review Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com> * test: remove console.logs * fix(images): properly join with path * fix: attempt a fix * test: remove console.log * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * fix: add undefined test for global for SSR --------- Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
9e16860f61
commit
f0b732d326
17 changed files with 290 additions and 21 deletions
5
.changeset/lemon-snails-kiss.md
Normal file
5
.changeset/lemon-snails-kiss.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Improve error handling when using `astro:assets`
|
|
@ -11,7 +11,7 @@ export function isESMImportedImage(src: ImageMetadata | string): src is ImageMet
|
|||
}
|
||||
|
||||
export async function getConfiguredImageService(): Promise<ImageService> {
|
||||
if (!globalThis.astroAsset.imageService) {
|
||||
if (!globalThis?.astroAsset?.imageService) {
|
||||
const { default: service }: { default: ImageService } = await import(
|
||||
// @ts-expect-error
|
||||
'virtual:image-service'
|
||||
|
@ -21,6 +21,7 @@ export async function getConfiguredImageService(): Promise<ImageService> {
|
|||
throw error;
|
||||
});
|
||||
|
||||
if (!globalThis.astroAsset) globalThis.astroAsset = {};
|
||||
globalThis.astroAsset.imageService = service;
|
||||
return service;
|
||||
}
|
||||
|
@ -52,6 +53,13 @@ interface GetImageResult {
|
|||
* This is functionally equivalent to using the `<Image />` component, as the component calls this function internally.
|
||||
*/
|
||||
export async function getImage(options: ImageTransform): Promise<GetImageResult> {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.ExpectedImageOptions,
|
||||
message: AstroErrorData.ExpectedImageOptions.message(JSON.stringify(options)),
|
||||
});
|
||||
}
|
||||
|
||||
const service = await getConfiguredImageService();
|
||||
const validatedOptions = service.validateOptions ? service.validateOptions(options) : options;
|
||||
|
||||
|
|
|
@ -104,6 +104,13 @@ export type BaseServiceTransform = {
|
|||
*/
|
||||
export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||
validateOptions(options) {
|
||||
if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.ExpectedImage,
|
||||
message: AstroErrorData.ExpectedImage.message(JSON.stringify(options.src)),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isESMImportedImage(options.src)) {
|
||||
// For remote images, width and height are explicitly required as we can't infer them from the file
|
||||
let missingDimension: 'width' | 'height' | 'both' | undefined;
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { pathToFileURL } from 'url';
|
||||
import { z } from 'zod';
|
||||
import { imageMetadata, type Metadata } from '../assets/utils/metadata.js';
|
||||
import {
|
||||
imageMetadata as internalGetImageMetadata,
|
||||
type Metadata,
|
||||
} from '../assets/utils/metadata.js';
|
||||
|
||||
export function createImage(options: { assetsDir: string; relAssetsDir: string }) {
|
||||
return () => {
|
||||
|
@ -8,8 +11,20 @@ export function createImage(options: { assetsDir: string; relAssetsDir: string }
|
|||
throw new Error('Enable `experimental.assets` in your Astro config to use image()');
|
||||
}
|
||||
|
||||
return z.string({ description: '__image' }).transform(async (imagePath) => {
|
||||
return await getImageMetadata(pathToFileURL(imagePath));
|
||||
return z.string({ description: '__image' }).transform(async (imagePath, ctx) => {
|
||||
const imageMetadata = await getImageMetadata(pathToFileURL(imagePath));
|
||||
|
||||
if (!imageMetadata) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Image ${imagePath} does not exist. Is the path correct?`,
|
||||
fatal: true,
|
||||
});
|
||||
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return imageMetadata;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -17,7 +32,7 @@ export function createImage(options: { assetsDir: string; relAssetsDir: string }
|
|||
async function getImageMetadata(
|
||||
imagePath: URL
|
||||
): Promise<(Metadata & { __astro_asset: true }) | undefined> {
|
||||
const meta = await imageMetadata(imagePath);
|
||||
const meta = await internalGetImageMetadata(imagePath);
|
||||
|
||||
if (!meta) {
|
||||
return undefined;
|
||||
|
|
|
@ -130,10 +130,13 @@ export async function getEntryData(
|
|||
object[schemaName] = z.preprocess(
|
||||
async (value: unknown) => {
|
||||
if (!value || typeof value !== 'string') return value;
|
||||
return (await resolver(value))?.id;
|
||||
return (
|
||||
(await resolver(value))?.id ??
|
||||
path.join(path.dirname(entry._internal.filePath), value)
|
||||
);
|
||||
},
|
||||
schema,
|
||||
{ description: undefined }
|
||||
{ description: '__image' }
|
||||
);
|
||||
} else if ('shape' in schema) {
|
||||
await preprocessAssetPaths(schema.shape);
|
||||
|
|
|
@ -464,7 +464,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
|
|||
* If the image is merely decorative (i.e. doesn’t contribute to the understanding of the page), set `alt=""` so that screen readers know to ignore the image.
|
||||
*/
|
||||
ImageMissingAlt: {
|
||||
title: 'Missing alt property',
|
||||
title: 'Missing alt property.',
|
||||
code: 3022,
|
||||
message: 'The alt property is required.',
|
||||
hint: "The `alt` property is important for the purpose of accessibility, without it users using screen readers or other assistive technologies won't be able to understand what your image is supposed to represent. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-alt for more information.",
|
||||
|
@ -479,7 +479,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
|
|||
* If you believe that your service is properly configured and this error is wrong, please [open an issue](https://astro.build/issues/).
|
||||
*/
|
||||
InvalidImageService: {
|
||||
title: 'Error while loading image service',
|
||||
title: 'Error while loading image service.',
|
||||
code: 3023,
|
||||
message:
|
||||
'There was an error loading the configured image service. Please see the stack trace for more information.',
|
||||
|
@ -550,7 +550,72 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
|
|||
hint: (filename: string) =>
|
||||
`Rename \`${filename}\` to \`${filename.replace(/\.(js|ts)/, (m) => `.json` + m)}\``,
|
||||
},
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Assets (Experimental)](https://docs.astro.build/en/guides/assets/)
|
||||
* @description
|
||||
* An image's `src` property is not valid. The Image component requires the `src` attribute to be either an image that has been ESM imported or a string. This is also true for the first parameter of `getImage()`.
|
||||
*
|
||||
* ```astro
|
||||
* ---
|
||||
* import { Image } from "astro:assets";
|
||||
* import myImage from "../assets/my_image.png";
|
||||
* ---
|
||||
*
|
||||
* <Image src={myImage} alt="..." />
|
||||
* <Image src="https://example.com/logo.png" width={300} height={300} alt="..." />
|
||||
* ```
|
||||
*
|
||||
* In most cases, this error happens when the value passed to `src` is undefined.
|
||||
*/
|
||||
ExpectedImage: {
|
||||
title: 'Expected src to be an image.',
|
||||
code: 3027,
|
||||
message: (options: string) =>
|
||||
`Expected \`src\` property to be either an ESM imported image or a string with the path of a remote image. Received \`${options}\`.`,
|
||||
hint: 'This error can often happen because of a wrong path. Make sure the path to your image is correct.',
|
||||
},
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Assets (Experimental)](https://docs.astro.build/en/guides/assets/)
|
||||
* @description
|
||||
* `getImage()`'s first parameter should be an object with the different properties to apply to your image.
|
||||
*
|
||||
* ```ts
|
||||
* import { getImage } from "astro:assets";
|
||||
* import myImage from "../assets/my_image.png";
|
||||
*
|
||||
* const optimizedImage = await getImage({src: myImage, width: 300, height: 300});
|
||||
* ```
|
||||
*
|
||||
* In most cases, this error happens because parameters were passed directly instead of inside an object.
|
||||
*/
|
||||
ExpectedImageOptions: {
|
||||
title: 'Expected image options.',
|
||||
code: 3028,
|
||||
message: (options: string) =>
|
||||
`Expected getImage() parameter to be an object. Received \`${options}\`.`,
|
||||
},
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Assets (Experimental)](https://docs.astro.build/en/guides/assets/)
|
||||
* @description
|
||||
* Astro could not find an image you included in your Markdown content. Usually, this is simply caused by a typo in the path.
|
||||
*
|
||||
* Images in Markdown are relative to the current file. To refer to an image that is located in the same folder as the `.md` file, the path should start with `./`
|
||||
*/
|
||||
MarkdownImageNotFound: {
|
||||
title: 'Image not found.',
|
||||
code: 3029,
|
||||
message: (imagePath: string, fullImagePath: string | undefined) =>
|
||||
`Could not find requested image \`${imagePath}\`${
|
||||
fullImagePath ? ` at \`${fullImagePath}\`.` : '.'
|
||||
}`,
|
||||
hint: 'This is often caused by a typo in the image path. Please make sure the file exists, and is spelled correctly.',
|
||||
},
|
||||
// No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
|
||||
// Vite Errors - 4xxx
|
||||
/**
|
||||
|
|
|
@ -78,7 +78,7 @@ const style = /* css */ `
|
|||
--shiki-token-function: #4ca48f;
|
||||
--shiki-token-string-expression: #9f722a;
|
||||
--shiki-token-punctuation: #ffffff;
|
||||
--shiki-token-link: #ee0000;
|
||||
--shiki-token-link: #9f722a;
|
||||
}
|
||||
|
||||
:host(.astro-dark) {
|
||||
|
@ -131,9 +131,9 @@ const style = /* css */ `
|
|||
--shiki-token-function: #90f4e3;
|
||||
--shiki-token-string-expression: #f4cf90;
|
||||
--shiki-token-punctuation: #ffffff;
|
||||
--shiki-token-link: #ee0000;
|
||||
--shiki-token-link: #f4cf90;
|
||||
}
|
||||
|
||||
|
||||
#theme-toggle-wrapper{
|
||||
position: relative;
|
||||
display: inline-block
|
||||
|
@ -144,7 +144,7 @@ const style = /* css */ `
|
|||
right: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
|
||||
.theme-toggle-checkbox {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
|
@ -250,7 +250,7 @@ const style = /* css */ `
|
|||
padding: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
|
||||
#theme-toggle-wrapper > div{
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
|
@ -372,6 +372,7 @@ const style = /* css */ `
|
|||
background-color: var(--border);
|
||||
padding: 4px;
|
||||
border-radius: var(--roundiness);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
|
|
|
@ -3,8 +3,9 @@ import {
|
|||
InvalidAstroDataError,
|
||||
safelyGetAstroData,
|
||||
} from '@astrojs/markdown-remark/dist/internal.js';
|
||||
import fs from 'fs';
|
||||
import matter from 'gray-matter';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Plugin } from 'vite';
|
||||
import { normalizePath } from 'vite';
|
||||
|
@ -12,7 +13,7 @@ import type { AstroSettings } from '../@types/astro';
|
|||
import { AstroError, AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
||||
import type { LogOptions } from '../core/logger/core.js';
|
||||
import { warn } from '../core/logger/core.js';
|
||||
import { isMarkdownFile } from '../core/util.js';
|
||||
import { isMarkdownFile, rootRelativePath } from '../core/util.js';
|
||||
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
|
||||
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
|
||||
|
||||
|
@ -58,6 +59,10 @@ const astroServerRuntimeModulePath = normalizePath(
|
|||
fileURLToPath(new URL('../runtime/server/index.js', import.meta.url))
|
||||
);
|
||||
|
||||
const astroErrorModulePath = normalizePath(
|
||||
fileURLToPath(new URL('../core/errors/index.js', import.meta.url))
|
||||
);
|
||||
|
||||
export default function markdown({ settings, logging }: AstroPluginOptions): Plugin {
|
||||
return {
|
||||
enforce: 'pre',
|
||||
|
@ -81,14 +86,15 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
|||
|
||||
let html = renderResult.code;
|
||||
const { headings } = renderResult.metadata;
|
||||
let imagePaths: { raw: string; absolute: string }[] = [];
|
||||
let imagePaths: { raw: string; resolved: string }[] = [];
|
||||
if (settings.config.experimental.assets) {
|
||||
let paths = (renderResult.vfile.data.imagePaths as string[]) ?? [];
|
||||
imagePaths = await Promise.all(
|
||||
paths.map(async (imagePath) => {
|
||||
return {
|
||||
raw: imagePath,
|
||||
absolute: (await this.resolve(imagePath, id))?.id ?? imagePath,
|
||||
resolved:
|
||||
(await this.resolve(imagePath, id))?.id ?? path.join(path.dirname(id), imagePath),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
@ -113,6 +119,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
|||
const code = escapeViteEnvReferences(`
|
||||
import { Fragment, jsx as h } from ${JSON.stringify(astroJsxRuntimeModulePath)};
|
||||
import { spreadAttributes } from ${JSON.stringify(astroServerRuntimeModulePath)};
|
||||
import { AstroError, AstroErrorData } from ${JSON.stringify(astroErrorModulePath)};
|
||||
|
||||
${layout ? `import Layout from ${JSON.stringify(layout)};` : ''}
|
||||
${settings.config.experimental.assets ? 'import { getImage } from "astro:assets";' : ''}
|
||||
|
@ -120,10 +127,27 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
|||
export const images = {
|
||||
${imagePaths.map(
|
||||
(entry) =>
|
||||
`'${entry.raw}': await getImage({src: (await import("${entry.absolute}")).default})`
|
||||
`'${entry.raw}': await getImageSafely((await import("${entry.raw}")).default, "${
|
||||
entry.raw
|
||||
}", "${rootRelativePath(settings.config, entry.resolved)}")`
|
||||
)}
|
||||
}
|
||||
|
||||
async function getImageSafely(imageSrc, imagePath, resolvedImagePath) {
|
||||
if (!imageSrc) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.MarkdownImageNotFound,
|
||||
message: AstroErrorData.MarkdownImageNotFound.message(
|
||||
imagePath,
|
||||
resolvedImagePath
|
||||
),
|
||||
location: { file: "${id}" },
|
||||
});
|
||||
}
|
||||
|
||||
return await getImage({src: imageSrc})
|
||||
}
|
||||
|
||||
function updateImageReferences(html) {
|
||||
return html.replaceAll(
|
||||
/__ASTRO_IMAGE_=\"(.+)\"/gm,
|
||||
|
|
|
@ -103,7 +103,6 @@ describe('astro:image', () => {
|
|||
await res.text();
|
||||
|
||||
expect(logs).to.have.a.lengthOf(1);
|
||||
console.log(logs[0].message);
|
||||
expect(logs[0].message).to.contain('Received unsupported format');
|
||||
});
|
||||
});
|
||||
|
@ -292,6 +291,76 @@ describe('astro:image', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('proper errors', () => {
|
||||
/** @type {import('./test-utils').DevServer} */
|
||||
let devServer;
|
||||
/** @type {Array<{ type: any, level: 'error', message: string; }>} */
|
||||
let logs = [];
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/core-image-errors/',
|
||||
experimental: {
|
||||
assets: true,
|
||||
},
|
||||
});
|
||||
|
||||
devServer = await fixture.startDevServer({
|
||||
logging: {
|
||||
level: 'error',
|
||||
dest: new Writable({
|
||||
objectMode: true,
|
||||
write(event, _, callback) {
|
||||
logs.push(event);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
it("properly error when getImage's first parameter isn't filled", async () => {
|
||||
logs.length = 0;
|
||||
let res = await fixture.fetch('/get-image-empty');
|
||||
await res.text();
|
||||
|
||||
expect(logs).to.have.a.lengthOf(1);
|
||||
expect(logs[0].message).to.contain('Expected getImage() parameter');
|
||||
});
|
||||
|
||||
// TODO: For some reason, this error crashes the dev server?
|
||||
it.skip('properly error when src is undefined', async () => {
|
||||
logs.length = 0;
|
||||
let res = await fixture.fetch('/get-image-undefined');
|
||||
await res.text();
|
||||
|
||||
expect(logs).to.have.a.lengthOf(1);
|
||||
expect(logs[0].message).to.contain('Expected src to be an image.');
|
||||
});
|
||||
|
||||
it('properly error image in Markdown frontmatter is not found', async () => {
|
||||
logs.length = 0;
|
||||
let res = await fixture.fetch('/blog/one');
|
||||
const text = await res.text();
|
||||
|
||||
expect(logs).to.have.a.lengthOf(1);
|
||||
expect(logs[0].message).to.contain('does not exist. Is the path correct?');
|
||||
});
|
||||
|
||||
it('properly error image in Markdown content is not found', async () => {
|
||||
logs.length = 0;
|
||||
let res = await fixture.fetch('/post');
|
||||
const text = await res.text();
|
||||
|
||||
expect(logs).to.have.a.lengthOf(1);
|
||||
expect(logs[0].message).to.contain('Could not find requested image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('support base option correctly', () => {
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
|
|
11
packages/astro/test/fixtures/core-image-errors/package.json
vendored
Normal file
11
packages/astro/test/fixtures/core-image-errors/package.json
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "@test/core-image",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev"
|
||||
}
|
||||
}
|
8
packages/astro/test/fixtures/core-image-errors/src/content/blog/one.md
vendored
Normal file
8
packages/astro/test/fixtures/core-image-errors/src/content/blog/one.md
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: One
|
||||
image: ./does-not-exist.jpg
|
||||
---
|
||||
|
||||
# A post
|
||||
|
||||
text here
|
15
packages/astro/test/fixtures/core-image-errors/src/content/config.ts
vendored
Normal file
15
packages/astro/test/fixtures/core-image-errors/src/content/config.ts
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { defineCollection, image, z } from "astro:content";
|
||||
|
||||
const blogCollection = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
image: image(),
|
||||
cover: z.object({
|
||||
image: image()
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
blog: blogCollection
|
||||
};
|
21
packages/astro/test/fixtures/core-image-errors/src/pages/blog/[...slug].astro
vendored
Normal file
21
packages/astro/test/fixtures/core-image-errors/src/pages/blog/[...slug].astro
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const blogEntries = await getCollection('blog');
|
||||
return blogEntries.map(entry => ({
|
||||
params: { slug: entry.slug }, props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Testing</title>
|
||||
</head>
|
||||
<body>
|
||||
<Content />
|
||||
</body>
|
||||
</html>
|
5
packages/astro/test/fixtures/core-image-errors/src/pages/get-image-empty.astro
vendored
Normal file
5
packages/astro/test/fixtures/core-image-errors/src/pages/get-image-empty.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import { getImage } from "astro:assets";
|
||||
|
||||
const myImage = await getImage();
|
||||
---
|
5
packages/astro/test/fixtures/core-image-errors/src/pages/get-image-undefined.astro
vendored
Normal file
5
packages/astro/test/fixtures/core-image-errors/src/pages/get-image-undefined.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import { getImage } from "astro:assets";
|
||||
|
||||
const myImage = getImage({src: undefined});
|
||||
---
|
1
packages/astro/test/fixtures/core-image-errors/src/pages/post.md
vendored
Normal file
1
packages/astro/test/fixtures/core-image-errors/src/pages/post.md
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
![My article cover](../assets/does-not-exist.jpg)
|
|
@ -1877,6 +1877,12 @@ importers:
|
|||
dependencies:
|
||||
astro: link:../../..
|
||||
|
||||
packages/astro/test/fixtures/core-image-errors:
|
||||
specifiers:
|
||||
astro: workspace:*
|
||||
dependencies:
|
||||
astro: link:../../..
|
||||
|
||||
packages/astro/test/fixtures/core-image-ssg:
|
||||
specifiers:
|
||||
astro: workspace:*
|
||||
|
|
Loading…
Reference in a new issue