From 77a110461dffacd1d3ee3b8934fd48b20111f3c4 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Wed, 16 Apr 2025 11:28:11 +0200 Subject: Fix image metadata validation in StarlightPage schema (#3118) --- .changeset/warm-adults-wash.md | 5 ++ .../basics/starlight-page-route-data.test.ts | 91 +++++++++++++++++++++- packages/starlight/utils/starlight-page.ts | 35 ++++----- 3 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 .changeset/warm-adults-wash.md diff --git a/.changeset/warm-adults-wash.md b/.changeset/warm-adults-wash.md new file mode 100644 index 00000000..15bf8ddb --- /dev/null +++ b/.changeset/warm-adults-wash.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Fixes passing imported SVGs to the `frontmatter` prop of the `` component in Astro ≥5.7.0 diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts index 950eeab4..2c5f5363 100644 --- a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts +++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts @@ -1,11 +1,12 @@ +import type { ImageMetadata } from 'astro'; import { expect, test, vi } from 'vitest'; -import { getRouteDataTestContext } from '../test-utils'; -import { generateRouteData } from '../../utils/routing/data'; import { routes } from '../../utils/routing'; +import { generateRouteData } from '../../utils/routing/data'; import { generateStarlightPageRouteData, type StarlightPageProps, } from '../../utils/starlight-page'; +import { getRouteDataTestContext } from '../test-utils'; vi.mock('virtual:starlight/collection-config', async () => (await import('../test-utils')).mockedCollectionConfig() @@ -523,3 +524,89 @@ test('generates data with a similar root shape to regular route data', async () expect(Object.keys(data).sort()).toEqual(Object.keys(starlightPageData).sort()); }); + +test('parses an ImageMetadata object successfully', async () => { + const fakeImportedImage: ImageMetadata = { + src: '/image-src.png', + width: 100, + height: 100, + format: 'png', + }; + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + hero: { + image: { file: fakeImportedImage }, + }, + }, + }, + context: getRouteDataTestContext(starlightPagePathname), + }); + expect(data.entry.data.hero?.image).toBeDefined(); + // @ts-expect-error — image’s type can be different shapes but we know it’s this one here + expect(data.entry.data.hero?.image!['file']).toMatchInlineSnapshot(` + { + "format": "png", + "height": 100, + "src": "/image-src.png", + "width": 100, + } + `); +}); + +test('parses an image that is also a function successfully', async () => { + const fakeImportedSvg = (() => {}) as unknown as ImageMetadata; + Object.assign(fakeImportedSvg, { src: '/image-src.svg', width: 100, height: 100, format: 'svg' }); + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + hero: { + image: { file: fakeImportedSvg }, + }, + }, + }, + context: getRouteDataTestContext(starlightPagePathname), + }); + expect(data.entry.data.hero?.image).toBeDefined(); + // @ts-expect-error — image’s type can be different shapes but we know it’s this one here + expect(data.entry.data.hero?.image!['file']).toMatchInlineSnapshot(`[Function]`); + // @ts-expect-error + expect(data.entry.data.hero?.image!['file']).toHaveProperty('src'); + // @ts-expect-error + expect(data.entry.data.hero?.image!['file']).toHaveProperty('width'); + // @ts-expect-error + expect(data.entry.data.hero?.image!['file']).toHaveProperty('height'); + // @ts-expect-error + expect(data.entry.data.hero?.image!['file']).toHaveProperty('format'); +}); + +test('fails to parse an image without the expected metadata properties', async () => { + await expect(() => + generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + hero: { + image: { + // @ts-expect-error intentionally incorrect input + file: () => {}, + }, + }, + }, + }, + context: getRouteDataTestContext(starlightPagePathname), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Invalid frontmatter props passed to the \`\` component. + Hint: + **hero.image**: Did not match union. + > Expected type \`file | { dark; light } | { html: string }\` + > Received \`{}\`" + `); +}); diff --git a/packages/starlight/utils/starlight-page.ts b/packages/starlight/utils/starlight-page.ts index 7a297bf3..01fe7dd7 100644 --- a/packages/starlight/utils/starlight-page.ts +++ b/packages/starlight/utils/starlight-page.ts @@ -1,5 +1,5 @@ import { z } from 'astro/zod'; -import { type ContentConfig, type SchemaContext } from 'astro:content'; +import { type ContentConfig, type ImageFunction, type SchemaContext } from 'astro:content'; import project from 'virtual:starlight/project-context'; import config from 'virtual:starlight/user-config'; import { getCollectionPathFromRoot } from './collection'; @@ -179,25 +179,22 @@ export async function generateStarlightPageRouteData({ /** Validates the Starlight page frontmatter properties from the props received by a Starlight page. */ async function getStarlightPageFrontmatter(frontmatter: StarlightPageFrontmatter) { - // This needs to be in sync with ImageMetadata. - // https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32 const schema = await StarlightPageFrontmatterSchema({ - image: () => - z.object({ - src: z.string(), - width: z.number(), - height: z.number(), - format: z.union([ - z.literal('png'), - z.literal('jpg'), - z.literal('jpeg'), - z.literal('tiff'), - z.literal('webp'), - z.literal('gif'), - z.literal('svg'), - z.literal('avif'), - ]), - }), + image: (() => + // Mock validator for ImageMetadata. + // https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32 + // It uses a custom validation approach because imported SVGs have a type of `function` as + // well as containing the metadata properties and this ensures we handle those correctly. + z.custom( + (value) => + value && + (typeof value === 'function' || typeof value === 'object') && + 'src' in value && + 'width' in value && + 'height' in value && + 'format' in value, + 'Invalid image passed to `` component. Expected imported `ImageMetadata` object.' + )) as ImageFunction, }); // Starting with Astro 4.14.0, a frontmatter schema that contains collection references will -- cgit