diff --git a/.changeset/jolly-lilies-shake.md b/.changeset/jolly-lilies-shake.md new file mode 100644 index 000000000000..a8e485b85726 --- /dev/null +++ b/.changeset/jolly-lilies-shake.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a regression in the content collections that could cause images to not be resolved diff --git a/packages/astro/src/content/mutable-data-store.ts b/packages/astro/src/content/mutable-data-store.ts index ef6c0878ec5d..f843ed122958 100644 --- a/packages/astro/src/content/mutable-data-store.ts +++ b/packages/astro/src/content/mutable-data-store.ts @@ -363,7 +363,17 @@ export default new Map([\n${lines.join(',\n')}]); entries: () => this.entries(collectionName), values: () => this.values(collectionName), keys: () => this.keys(collectionName), - set: ({ id: key, data, body, filePath, deferredRender, digest, rendered, assetImports }) => { + set: ({ + id: key, + data, + body, + filePath, + deferredRender, + digest, + rendered, + assetImports, + imageImports: incomingImageImports, + }) => { if (!key) { throw new Error(`ID must be a non-empty string`); } @@ -376,6 +386,18 @@ export default new Map([\n${lines.join(',\n')}]); } const foundAssets = new Set(assetImports); const imageImports: (string | number)[][] = []; + const seenImageImportPaths = new Set(); + const recordImageImport = (imagePath: (string | number)[]) => { + const pathKey = JSON.stringify(imagePath); + if (seenImageImportPaths.has(pathKey)) { + return; + } + seenImageImportPaths.add(pathKey); + imageImports.push(imagePath); + }; + for (const existingImagePath of incomingImageImports ?? []) { + recordImageImport([...existingImagePath]); + } // Image fields are prefixed during schema parsing. Record their locations and // strip the prefix so the stored data holds a plain, devalue-serializable src // string. The recorded paths let read-time resolution rewrite only these fields @@ -384,7 +406,7 @@ export default new Map([\n${lines.join(',\n')}]); if (typeof val === 'string' && val.startsWith(IMAGE_IMPORT_PREFIX)) { const src = val.replace(IMAGE_IMPORT_PREFIX, ''); foundAssets.add(src); - imageImports.push(ctx.path.map((segment) => segment as string | number)); + recordImageImport(ctx.path.map((segment) => segment as string | number)); ctx.update(src); } }); diff --git a/packages/astro/test/units/content-layer/asset-imports.test.ts b/packages/astro/test/units/content-layer/asset-imports.test.ts index e4024e324f25..8781ad7b65ff 100644 --- a/packages/astro/test/units/content-layer/asset-imports.test.ts +++ b/packages/astro/test/units/content-layer/asset-imports.test.ts @@ -1,8 +1,102 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; import fs from 'node:fs/promises'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { imageSrcToImportId } from '../../../dist/assets/utils/resolveImports.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { createTempDir } from './test-helpers.ts'; +import { resolveEntryData } from '../../../dist/content/runtime.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { createMinimalSettings, createTempDir, createTestConfigObserver } from './test-helpers.ts'; + +const FILE_PATH = 'src/data/posts/shuttle/index.md'; + +const SRCS: Record = { + dotSlash: './shuttle.jpg', + dotDot: '../data/shuttle.jpg', + bare: 'shuttle.jpg', + bareNested: 'nested/shuttle.jpg', + absolute: '/src/data/shuttle.jpg', + alias: '@images/shuttle.jpg', +}; + +// Both a top-level and a nested image field, so the recorded paths cover both +// shapes: `['image']` and `['banner', 'image']`. +const EXPECTED_IMAGE_PATHS = [['image'], ['banner', 'image']]; + +const RESOLVED: any = { src: '/_astro/shuttle.hash.jpg', width: 100, height: 100, format: 'jpg' }; + +const imageSchema = ({ image }: any) => + z.object({ + id: z.string(), + image: image(), + banner: z.object({ image: image(), alt: z.string() }), + enriched: z.boolean().optional(), + }); + +function makeEntryData(id: string, image: string) { + return { id, image, banner: { image, alt: `${id} banner` } }; +} + +async function syncCollection(loader: any) { + const root = new URL('../../fixtures/content-layer/', import.meta.url); + const store = new MutableDataStore(); + const contentLayer = new ContentLayer({ + settings: createMinimalSettings(root), + logger: new AstroLogger({ destination: { write: () => true }, level: 'silent' }), + store, + contentConfigObserver: createTestConfigObserver({ + imgs: defineCollection({ loader, schema: imageSchema }), + }), + }); + await contentLayer.sync(); + return store.values('imgs'); +} + +/** + * Mirrors what the build does: every `assetImports` src becomes a Vite import id + * that resolves to the built `ImageMetadata`. + */ +function buildAssetMap(entries: Array) { + const map = new Map(); + for (const entry of entries) { + for (const src of entry.assetImports ?? []) { + const id = imageSrcToImportId(src, entry.filePath); + if (id) map.set(id, RESOLVED); + } + } + return map; +} + +function assertImagesResolve(entries: Array) { + assert.equal(entries.length, Object.keys(SRCS).length); + const map = buildAssetMap(entries); + + for (const entry of entries) { + const src = SRCS[entry.id]; + + // 7.2.3 stores the plain src and records where the image fields live, + // instead of keeping an `__ASTRO_IMAGE_`-prefixed string in the data. + assert.equal(entry.data.image, src, `${entry.id}: stored src`); + assert.equal(entry.data.banner.image, src, `${entry.id}: stored nested src`); + assert.deepEqual( + entry.imageImports, + EXPECTED_IMAGE_PATHS, + `${entry.id}: image field paths must be recorded on the entry`, + ); + + const data: any = resolveEntryData(entry, map); + assert.equal(data.image, RESOLVED, `${entry.id}: image must resolve to ImageMetadata`); + assert.equal( + data.banner.image, + RESOLVED, + `${entry.id}: nested image must resolve to ImageMetadata`, + ); + // Untouched siblings are passed through. + assert.equal(data.banner.alt, `${entry.id} banner`); + } +} describe('Content Layer - Asset Imports', () => { it('generates unique symbol names for imports with colliding shorthashes', async () => { @@ -33,4 +127,55 @@ describe('Content Layer - Asset Imports', () => { assert.equal(importNames.length, 2, 'should have exactly 2 imports'); assert.notEqual(importNames[0], importNames[1], 'import identifiers must be unique'); }); + + it('resolves images for entries the loader stores once', async () => { + const entries = await syncCollection({ + name: 'store-once', + async load(context: any) { + for (const [id, image] of Object.entries(SRCS)) { + const data = await context.parseData({ + id, + data: makeEntryData(id, image), + filePath: FILE_PATH, + }); + context.store.set({ id, data, filePath: FILE_PATH }); + } + }, + }); + + assertImagesResolve(entries); + }); + + it('resolves images for entries the loader reads back and re-stores', async () => { + const entries = await syncCollection({ + name: 'read-modify-write', + async load(context: any) { + for (const [id, image] of Object.entries(SRCS)) { + const data = await context.parseData({ + id, + data: makeEntryData(id, image), + filePath: FILE_PATH, + }); + context.store.set({ id, data, filePath: FILE_PATH }); + } + + // Second pass: a loader that enriches an entry after the initial store + // (attaching a sibling file's contents, a computed field, ...) reads the + // stored entry back and re-stores it. `set()` strips the image prefix in + // place on the first pass, so on this pass there is no prefix left to + // re-discover — the entry's recorded `imageImports` are the only record + // of where the images live, and must survive the round-trip. + for (const entry of context.store.values()) { + context.store.set({ ...entry, data: { ...entry.data, enriched: true } }); + } + }, + }); + + // Guard: the second pass really did re-store every entry. + for (const entry of entries) { + assert.equal(entry.data.enriched, true, `${entry.id}: entry was re-stored`); + } + + assertImagesResolve(entries); + }); });