diff --git a/.changeset/five-coats-tie.md b/.changeset/five-coats-tie.md new file mode 100644 index 000000000000..b358aa22c61b --- /dev/null +++ b/.changeset/five-coats-tie.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix images defined in content collections schemas not working diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index aa69f7183260..0202e2359ddc 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -1,8 +1,13 @@ import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { AstroSettings } from '../@types/astro.js'; import { StaticBuildOptions } from '../core/build/types.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; +import { rootRelativePath } from '../core/util.js'; import { ImageService, isLocalService, LocalImageService } from './services/service.js'; import type { ImageMetadata, ImageTransform } from './types.js'; +import { imageMetadata } from './utils/metadata.js'; export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { return typeof src === 'object'; @@ -115,3 +120,40 @@ export async function generateImage( }, }; } + +export async function emitESMImage( + id: string, + watchMode: boolean, + fileEmitter: any, + settings: AstroSettings +) { + const url = pathToFileURL(id); + const meta = await imageMetadata(url); + + if (!meta) { + return; + } + + // Build + if (!watchMode) { + const pathname = decodeURI(url.pathname); + const filename = path.basename(pathname, path.extname(pathname) + `.${meta.format}`); + + const handle = fileEmitter({ + name: filename, + source: await fs.promises.readFile(url), + type: 'asset', + }); + + meta.src = `__ASTRO_ASSET_IMAGE__${handle}__`; + } else { + // Pass the original file information through query params so we don't have to load the file twice + url.searchParams.append('origWidth', meta.width.toString()); + url.searchParams.append('origHeight', meta.height.toString()); + url.searchParams.append('origFormat', meta.format); + + meta.src = rootRelativePath(settings.config, url); + } + + return meta; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 1e84b2271803..9af9c6073b36 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -1,17 +1,15 @@ import MagicString from 'magic-string'; import mime from 'mime'; import fs from 'node:fs/promises'; -import path from 'node:path'; import { Readable } from 'node:stream'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import type * as vite from 'vite'; import { normalizePath } from 'vite'; import { AstroPluginOptions, ImageTransform } from '../@types/astro'; import { error } from '../core/logger/core.js'; import { joinPaths, prependForwardSlash } from '../core/path.js'; -import { rootRelativePath } from '../core/util.js'; import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js'; -import { isESMImportedImage } from './internal.js'; +import { emitESMImage, isESMImportedImage } from './internal.js'; import { isLocalService } from './services/service.js'; import { copyWasmFiles } from './services/vendor/squoosh/copy-wasm.js'; import { imageMetadata } from './utils/metadata.js'; @@ -202,34 +200,7 @@ export default function assets({ }, async load(id) { if (/\.(jpeg|jpg|png|tiff|webp|gif|svg)$/.test(id)) { - const url = pathToFileURL(id); - const meta = await imageMetadata(url); - - if (!meta) { - return; - } - - // Build - if (!this.meta.watchMode) { - const pathname = decodeURI(url.pathname); - const filename = path.basename(pathname, path.extname(pathname) + `.${meta.format}`); - - const handle = this.emitFile({ - name: filename, - source: await fs.readFile(url), - type: 'asset', - }); - - meta.src = `__ASTRO_ASSET_IMAGE__${handle}__`; - } else { - // Pass the original file information through query params so we don't have to load the file twice - url.searchParams.append('origWidth', meta.width.toString()); - url.searchParams.append('origHeight', meta.height.toString()); - url.searchParams.append('origFormat', meta.format); - - meta.src = rootRelativePath(settings.config, url); - } - + const meta = await emitESMImage(id, this.meta.watchMode, this.emitFile, settings); return `export default ${JSON.stringify(meta)}`; } }, diff --git a/packages/astro/src/content/internal.ts b/packages/astro/src/content/internal.ts index 2fdfe74545ab..bdc99bbc7414 100644 --- a/packages/astro/src/content/internal.ts +++ b/packages/astro/src/content/internal.ts @@ -191,7 +191,7 @@ async function render({ }; } -export function createImage(options: { assetsDir: string }) { +export function createImage(options: { assetsDir: string; relAssetsDir: string }) { return () => { if (options.assetsDir === 'undefined') { throw new Error('Enable `experimental.assets` in your Astro config to use image()'); diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 011ade19f89f..22b71e0afe6d 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -3,10 +3,11 @@ import matter from 'gray-matter'; import fsMod from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { EmitFile } from 'rollup'; import { ErrorPayload as ViteErrorPayload, normalizePath, ViteDevServer } from 'vite'; import { z } from 'zod'; import { AstroConfig, AstroSettings } from '../@types/astro.js'; -import type { ImageMetadata } from '../assets/types.js'; +import { emitESMImage } from '../assets/internal.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { CONTENT_TYPES_FILE } from './consts.js'; @@ -43,21 +44,29 @@ export const msg = { `${collection} does not have a config. We suggest adding one for type safety!`, }; -export function extractFrontmatterAssets(data: Record): string[] { - function findAssets(potentialAssets: Record): ImageMetadata[] { - return Object.values(potentialAssets).reduce((acc, curr) => { - if (typeof curr === 'object') { - if (curr.__astro === true) { - acc.push(curr); - } else { - acc.push(...findAssets(curr)); - } +/** + * Mutate (arf) the entryData to reroute assets to their final paths + */ +export async function patchAssets( + frontmatterEntry: Record, + watchMode: boolean, + fileEmitter: EmitFile, + astroSettings: AstroSettings +) { + for (const key of Object.keys(frontmatterEntry)) { + if (typeof frontmatterEntry[key] === 'object' && frontmatterEntry[key] !== null) { + if (frontmatterEntry[key]['__astro_asset']) { + frontmatterEntry[key] = await emitESMImage( + frontmatterEntry[key].src, + watchMode, + fileEmitter, + astroSettings + ); + } else { + await patchAssets(frontmatterEntry[key], watchMode, fileEmitter, astroSettings); } - return acc; - }, []); + } } - - return findAssets(data).map((asset) => asset.src); } export function getEntrySlug({ diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index 7c335e112956..a81d1febaa76 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -3,7 +3,6 @@ import type fsMod from 'node:fs'; import { extname } from 'node:path'; import { pathToFileURL } from 'url'; import type { Plugin } from 'vite'; -import { normalizePath } from 'vite'; import { AstroSettings, ContentEntryType } from '../@types/astro.js'; import { AstroErrorData } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/errors.js'; @@ -11,7 +10,6 @@ import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index import { CONTENT_FLAG } from './consts.js'; import { ContentConfig, - extractFrontmatterAssets, getContentEntryExts, getContentPaths, getEntryData, @@ -19,8 +17,8 @@ import { getEntrySlug, getEntryType, globalContentConfigObserver, + patchAssets, } from './utils.js'; - function isContentFlagImport(viteId: string, contentEntryExts: string[]) { const { searchParams, pathname } = new URL(viteId, 'file://'); return searchParams.has(CONTENT_FLAG) && contentEntryExts.some((ext) => pathname.endsWith(ext)); @@ -106,25 +104,20 @@ export function astroContentImportPlugin({ const slug = getEntrySlug({ ...generatedInfo, unvalidatedSlug: info.slug }); const collectionConfig = contentConfig?.collections[generatedInfo.collection]; - const data = collectionConfig + let data = collectionConfig ? await getEntryData( { ...generatedInfo, _internal, unvalidatedData: info.data }, collectionConfig ) : info.data; - const images = extractFrontmatterAssets(data).map( - (image) => `'${image}': await import('${normalizePath(image)}'),` - ); + await patchAssets(data, this.meta.watchMode, this.emitFile, settings); const code = escapeViteEnvReferences(` export const id = ${JSON.stringify(generatedInfo.id)}; export const collection = ${JSON.stringify(generatedInfo.collection)}; export const slug = ${JSON.stringify(slug)}; export const body = ${JSON.stringify(info.body)}; -const frontmatterImages = { - ${images.join('\n')} -} export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */}; export const _internal = { filePath: ${JSON.stringify(_internal.filePath)}, diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index 81a793ffc1f2..e0af4f1aab97 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -213,10 +213,34 @@ describe('astro:image', () => { $ = cheerio.load(html); }); - it('Adds the tag', () => { + it('Adds the tags', () => { let $img = $('img'); - expect($img).to.have.a.lengthOf(1); + expect($img).to.have.a.lengthOf(4); + }); + + it('has proper source for directly used image', () => { + let $img = $('#direct-image img'); + expect($img.attr('src').startsWith('/src/')).to.equal(true); + }); + + it('has proper attributes for optimized image through getImage', () => { + let $img = $('#optimized-image-get-image img'); + expect($img.attr('src').startsWith('/_image')).to.equal(true); + expect($img.attr('width')).to.equal('207'); + expect($img.attr('height')).to.equal('243'); + }); + + it('has proper attributes for optimized image through Image component', () => { + let $img = $('#optimized-image-component img'); expect($img.attr('src').startsWith('/_image')).to.equal(true); + expect($img.attr('width')).to.equal('207'); + expect($img.attr('height')).to.equal('243'); + expect($img.attr('alt')).to.equal('A penguin!'); + }); + + it('properly handles nested images', () => { + let $img = $('#nested-image img'); + expect($img.attr('src').startsWith('/src/')).to.equal(true); }); }); @@ -306,6 +330,22 @@ describe('astro:image', () => { expect(data).to.be.an.instanceOf(Buffer); }); + it('output files for content collections images', async () => { + const html = await fixture.readFile('/blog/one/index.html'); + + const $ = cheerio.load(html); + let $img = $('img'); + expect($img).to.have.a.lengthOf(2); + + const srcdirect = $('#direct-image img').attr('src'); + const datadirect = await fixture.readFile(srcdirect, null); + expect(datadirect).to.be.an.instanceOf(Buffer); + + const srcnested = $('#nested-image img').attr('src'); + const datanested = await fixture.readFile(srcnested, null); + expect(datanested).to.be.an.instanceOf(Buffer); + }); + it('quality attribute produces a different file', async () => { const html = await fixture.readFile('/quality/index.html'); const $ = cheerio.load(html); diff --git a/packages/astro/test/fixtures/core-image-ssg/src/content/blog/one.md b/packages/astro/test/fixtures/core-image-ssg/src/content/blog/one.md new file mode 100644 index 000000000000..88a210b75504 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-ssg/src/content/blog/one.md @@ -0,0 +1,10 @@ +--- +title: One +image: penguin2.jpg +cover: + image: penguin1.jpg +--- + +# A post + +text here diff --git a/packages/astro/test/fixtures/core-image-ssg/src/content/config.ts b/packages/astro/test/fixtures/core-image-ssg/src/content/config.ts new file mode 100644 index 000000000000..b38ad070e100 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-ssg/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-ssg/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/core-image-ssg/src/pages/blog/[...slug].astro new file mode 100644 index 000000000000..dc25493e854a --- /dev/null +++ b/packages/astro/test/fixtures/core-image-ssg/src/pages/blog/[...slug].astro @@ -0,0 +1,33 @@ +--- +import { getImage } from 'astro:assets'; +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(); +const myImage = await getImage(entry.data.image); +--- + + + Testing + + +

Testing

+ +
+ +
+ +
+ +
+ + + + diff --git a/packages/astro/test/fixtures/core-image/src/content/blog/one.md b/packages/astro/test/fixtures/core-image/src/content/blog/one.md index 00e43a6a214d..88a210b75504 100644 --- a/packages/astro/test/fixtures/core-image/src/content/blog/one.md +++ b/packages/astro/test/fixtures/core-image/src/content/blog/one.md @@ -1,6 +1,8 @@ --- title: One image: penguin2.jpg +cover: + image: penguin1.jpg --- # A post diff --git a/packages/astro/test/fixtures/core-image/src/content/config.ts b/packages/astro/test/fixtures/core-image/src/content/config.ts index bd101a4aa383..b38ad070e100 100644 --- a/packages/astro/test/fixtures/core-image/src/content/config.ts +++ b/packages/astro/test/fixtures/core-image/src/content/config.ts @@ -1,9 +1,12 @@ -import { image, defineCollection, z } from "astro:content"; +import { defineCollection, image, z } from "astro:content"; const blogCollection = defineCollection({ schema: z.object({ title: z.string(), image: image(), + cover: z.object({ + image: image() + }) }), }); diff --git a/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro index 4f6e5003c114..b9667968835f 100644 --- a/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro +++ b/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro @@ -1,6 +1,6 @@ --- +import { getImage,Image } from 'astro:assets'; import { getCollection } from 'astro:content'; -import { getImage } from 'astro:assets'; export async function getStaticPaths() { const blogEntries = await getCollection('blog'); @@ -20,7 +20,21 @@ const myImage = await getImage(entry.data.image);

Testing

- +
+ +
+ +
+ +
+ +
+ +
+ +
+ A penguin! +