diff --git a/.changeset/lemon-snails-kiss.md b/.changeset/lemon-snails-kiss.md new file mode 100644 index 000000000000..b287620fda50 --- /dev/null +++ b/.changeset/lemon-snails-kiss.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Improve error handling when using `astro:assets` diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index db02b1ea18ec..e89b9d5da097 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -11,7 +11,7 @@ export function isESMImportedImage(src: ImageMetadata | string): src is ImageMet } export async function getConfiguredImageService(): Promise { - 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 { 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 `` component, as the component calls this function internally. */ export async function getImage(options: ImageTransform): Promise { + 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; diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index 5b1a1b80e34a..693ced00f7ab 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -104,6 +104,13 @@ export type BaseServiceTransform = { */ export const baseService: Omit = { 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; diff --git a/packages/astro/src/content/runtime-assets.ts b/packages/astro/src/content/runtime-assets.ts index 6aa0c71fd277..99a83d143df6 100644 --- a/packages/astro/src/content/runtime-assets.ts +++ b/packages/astro/src/content/runtime-assets.ts @@ -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; diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 5e8a983c6573..e9c45c5bb1aa 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -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); diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 760cade33fae..ff64c8150b6e 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -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"; + * --- + * + * ... + * ... + * ``` + * + * 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 /** diff --git a/packages/astro/src/core/errors/overlay.ts b/packages/astro/src/core/errors/overlay.ts index c2ff044f4a64..c771547b1777 100644 --- a/packages/astro/src/core/errors/overlay.ts +++ b/packages/astro/src/core/errors/overlay.ts @@ -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 { diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 3e22c59ec2a4..ed2779da92a4 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -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, diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index 997c9c10d99d..8c11baa8abb9 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -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({ diff --git a/packages/astro/test/fixtures/core-image-errors/package.json b/packages/astro/test/fixtures/core-image-errors/package.json new file mode 100644 index 000000000000..fec3860d40a9 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/core-image", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + }, + "scripts": { + "dev": "astro dev" + } +} diff --git a/packages/astro/test/fixtures/core-image-errors/src/content/blog/one.md b/packages/astro/test/fixtures/core-image-errors/src/content/blog/one.md new file mode 100644 index 000000000000..36639873e680 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/src/content/blog/one.md @@ -0,0 +1,8 @@ +--- +title: One +image: ./does-not-exist.jpg +--- + +# A post + +text here diff --git a/packages/astro/test/fixtures/core-image-errors/src/content/config.ts b/packages/astro/test/fixtures/core-image-errors/src/content/config.ts new file mode 100644 index 000000000000..b38ad070e100 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/src/content/config.ts @@ -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 +}; diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/core-image-errors/src/pages/blog/[...slug].astro new file mode 100644 index 000000000000..b9e8895ff4bb --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/src/pages/blog/[...slug].astro @@ -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(); +--- + + + Testing + + + + + diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/get-image-empty.astro b/packages/astro/test/fixtures/core-image-errors/src/pages/get-image-empty.astro new file mode 100644 index 000000000000..6f84486517b9 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/src/pages/get-image-empty.astro @@ -0,0 +1,5 @@ +--- +import { getImage } from "astro:assets"; + +const myImage = await getImage(); +--- diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/get-image-undefined.astro b/packages/astro/test/fixtures/core-image-errors/src/pages/get-image-undefined.astro new file mode 100644 index 000000000000..c75fd10d2b4e --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/src/pages/get-image-undefined.astro @@ -0,0 +1,5 @@ +--- +import { getImage } from "astro:assets"; + +const myImage = getImage({src: undefined}); +--- diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/post.md b/packages/astro/test/fixtures/core-image-errors/src/pages/post.md new file mode 100644 index 000000000000..c14909b323a9 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-errors/src/pages/post.md @@ -0,0 +1 @@ +![My article cover](../assets/does-not-exist.jpg) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5adbfa94a77..43a80d0157b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:*