8000
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jolly-lilies-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a regression in the content collections that could cause images to not be resolved
26 changes: 24 additions & 2 deletions packages/astro/src/content/mutable-data-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand All @@ -376,6 +386,18 @@ export default new Map([\n${lines.join(',\n')}]);
}
const foundAssets = new Set<string>(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
Expand All @@ -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);
}
});
Expand Down
149 changes: 147 additions & 2 deletions packages/astro/test/units/content-layer/asset-imports.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<any>) {
const map = new Map<string, any>();
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<any>) {
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 () => {
Expand Down Expand Up @@ -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)) {
A75D 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);
});
});
Loading
0