Skip to content

Commit

Permalink
Fix using images in content collections (#6483)
Browse files Browse the repository at this point in the history
* fix(images): Fix not being able to refer to images in content collections

* fix(images): Normalize path

* fix(images): Do it properly

* chore: changeset
  • Loading branch information
Princesseuh authored Mar 9, 2023
1 parent 7d1dd51 commit a9a6ae2
Show file tree
Hide file tree
Showing 13 changed files with 199 additions and 62 deletions.
5 changes: 5 additions & 0 deletions .changeset/five-coats-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fix images defined in content collections schemas not working
42 changes: 42 additions & 0 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
35 changes: 3 additions & 32 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)}`;
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/content/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()');
Expand Down
37 changes: 23 additions & 14 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand Down
13 changes: 3 additions & 10 deletions packages/astro/src/content/vite-plugin-content-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,22 @@ 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';
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
import { CONTENT_FLAG } from './consts.js';
import {
ContentConfig,
extractFrontmatterAssets,
getContentEntryExts,
getContentPaths,
getEntryData,
getEntryInfo,
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));
Expand Down Expand Up @@ -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)},
Expand Down
44 changes: 42 additions & 2 deletions packages/astro/test/core-image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: One
image: penguin2.jpg
cover:
image: penguin1.jpg
---

# A post

text here
15 changes: 15 additions & 0 deletions packages/astro/test/fixtures/core-image-ssg/src/content/config.ts
Original file line number Diff line number Diff line change
@@ -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
};
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
---
title: One
image: penguin2.jpg
cover:
image: penguin1.jpg
---

# A post
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
}),
});

Expand Down
Loading

0 comments on commit a9a6ae2

Please sign in to comment.