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:
parent
7d1dd510fb
commit
a9a6ae2981
13 changed files with 199 additions and 62 deletions
5
.changeset/five-coats-tie.md
Normal file
5
.changeset/five-coats-tie.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Fix images defined in content collections schemas not working
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)}`;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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()');
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)},
|
||||
|
|
|
@ -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);
|
||||
|
|
10
packages/astro/test/fixtures/core-image-ssg/src/content/blog/one.md
vendored
Normal file
10
packages/astro/test/fixtures/core-image-ssg/src/content/blog/one.md
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: One
|
||||
image: penguin2.jpg
|
||||
cover:
|
||||
image: penguin1.jpg
|
||||
---
|
||||
|
||||
# A post
|
||||
|
||||
text here
|
15
packages/astro/test/fixtures/core-image-ssg/src/content/config.ts
vendored
Normal file
15
packages/astro/test/fixtures/core-image-ssg/src/content/config.ts
vendored
Normal 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
|
||||
};
|
33
packages/astro/test/fixtures/core-image-ssg/src/pages/blog/[...slug].astro
vendored
Normal file
33
packages/astro/test/fixtures/core-image-ssg/src/pages/blog/[...slug].astro
vendored
Normal 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>
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
title: One
|
||||
image: penguin2.jpg
|
||||
cover:
|
||||
image: penguin1.jpg
|
||||
---
|
||||
|
||||
# A post
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue