Fix using images in content collections (#6483)

* fix(images): Fix not being able to refer to images in content collections

* fix(images): Normalize path

* fix(images): Do it properly

* chore: changeset
This commit is contained in:
Erika 2023-03-09 20:43:58 +01:00 committed by GitHub
parent 7d1dd510fb
commit a9a6ae2981
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 199 additions and 62 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix images defined in content collections schemas not working

View file

@ -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;
}

View file

@ -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)}`;
}
},

View file

@ -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()');

View file

@ -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, any>): string[] {
function findAssets(potentialAssets: Record<string, any>): 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<string, any>,
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({

View file

@ -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)},

View file

@ -213,10 +213,34 @@ describe('astro:image', () => {
$ = cheerio.load(html);
});
it('Adds the <img> tag', () => {
it('Adds the <img> 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);

View file

@ -0,0 +1,10 @@
---
title: One
image: penguin2.jpg
cover:
image: penguin1.jpg
---
# A post
text here

View file

@ -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
};

View file

@ -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);
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
<div id="direct-image">
<img src={entry.data.image.src} width={entry.data.image.width} height={entry.data.image.height} />
</div>
<div id="nested-image">
<img src={entry.data.cover.image.src} width={entry.data.cover.image.width} height={entry.data.cover.image.height} />
</div>
<Content />
</body>
</html>

View file

@ -1,6 +1,8 @@
---
title: One
image: penguin2.jpg
cover:
image: penguin1.jpg
---
# A post

View file

@ -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()
})
}),
});

View file

@ -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);
<body>
<h1>Testing</h1>
<img src={myImage.src} {...myImage.attributes} />
<div id="direct-image">
<img src={entry.data.image.src} width={entry.data.image.width} height={entry.data.image.height} />
</div>
<div id="nested-image">
<img src={entry.data.cover.image.src} width={entry.data.cover.image.width} height={entry.data.cover.image.height} />
</div>
<div id="optimized-image-get-image">
<img src={myImage.src} {...myImage.attributes} />
</div>
<div id="optimized-image-component">
<Image src={entry.data.image} alt="A penguin!" />
</div>
<Content />
</body>