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:
Erika 2023-03-29 14:58:42 +02:00 committed by GitHub
parent 9e16860f61
commit f0b732d326
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 290 additions and 21 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Improve error handling when using `astro:assets`

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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. doesnt 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
/**

View file

@ -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 {

View file

@ -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,

View file

@ -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({

View file

@ -0,0 +1,11 @@
{
"name": "@test/core-image",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
},
"scripts": {
"dev": "astro dev"
}
}

View file

@ -0,0 +1,8 @@
---
title: One
image: ./does-not-exist.jpg
---
# A post
text here

View 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
};

View 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>

View file

@ -0,0 +1,5 @@
---
import { getImage } from "astro:assets";
const myImage = await getImage();
---

View file

@ -0,0 +1,5 @@
---
import { getImage } from "astro:assets";
const myImage = getImage({src: undefined});
---

View file

@ -0,0 +1 @@
![My article cover](../assets/does-not-exist.jpg)

View file

@ -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:*