From a1108e037115cdb67d03505286c7d3a4fc2a1ff5 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 5 Apr 2023 15:40:05 +0200 Subject: [PATCH] Move `image()` to be passed as part of `schema` (#6703) * feat(images): Move image() to schema so we can do relative images easily instead of clumsily * chore: changeset * fix: apply feedback * test: add more tests * fix: properly infer type if a function is used * feat(iamge): Add errors when using the old methods * chore: update to minor * feat(image): Move function shape to be experimental.asets only * Update packages/astro/src/content/template/virtual-mod.mjs Co-authored-by: Ben Holmes --------- Co-authored-by: Ben Holmes --- .changeset/many-dancers-fold.md | 22 ++++ packages/astro/src/assets/utils/emitAsset.ts | 18 ++-- packages/astro/src/content/runtime-assets.ts | 45 ++++---- .../astro/src/content/template/types.d.ts | 26 ++++- .../content/template/virtual-mod-assets.mjs | 7 -- .../src/content/template/virtual-mod.mjs | 7 ++ packages/astro/src/content/utils.ts | 102 ++++-------------- .../content/vite-plugin-content-imports.ts | 7 +- .../vite-plugin-content-virtual-mod.ts | 11 +- packages/astro/test/core-image.test.js | 19 +++- .../core-image-base/src/content/config.ts | 4 +- .../core-image-errors/src/content/config.ts | 4 +- .../core-image-ssg/src/content/config.ts | 4 +- .../core-image/src/content/blog/one.md | 4 + .../fixtures/core-image/src/content/config.ts | 9 +- .../core-image/src/pages/blog/[...slug].astro | 10 ++ 16 files changed, 148 insertions(+), 151 deletions(-) create mode 100644 .changeset/many-dancers-fold.md delete mode 100644 packages/astro/src/content/template/virtual-mod-assets.mjs diff --git a/.changeset/many-dancers-fold.md b/.changeset/many-dancers-fold.md new file mode 100644 index 000000000..d2278950a --- /dev/null +++ b/.changeset/many-dancers-fold.md @@ -0,0 +1,22 @@ +--- +'astro': minor +--- + +Move `image()` to come from `schema` instead to fix it not working with refine and inside complex types + +**Migration**: + +Remove the `image` import from `astro:content`, and instead use a function to generate your schema, like such: + +```ts +import { defineCollection, z } from "astro:content"; + +defineCollection({ + schema: ({ image }) => + z.object({ + image: image().refine((img) => img.width >= 200, { + message: "image too small", + }), + }), +}); +``` diff --git a/packages/astro/src/assets/utils/emitAsset.ts b/packages/astro/src/assets/utils/emitAsset.ts index 420264289..79775b96d 100644 --- a/packages/astro/src/assets/utils/emitAsset.ts +++ b/packages/astro/src/assets/utils/emitAsset.ts @@ -3,19 +3,23 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import slash from 'slash'; import type { AstroConfig, AstroSettings } from '../../@types/astro'; -import { imageMetadata } from './metadata.js'; +import { imageMetadata, type Metadata } from './metadata.js'; export async function emitESMImage( - id: string, + id: string | undefined, watchMode: boolean, fileEmitter: any, settings: Pick -) { +): Promise { + if (!id) { + return undefined; + } + const url = pathToFileURL(id); const meta = await imageMetadata(url); if (!meta) { - return; + return undefined; } // Build @@ -48,13 +52,13 @@ export async function emitESMImage( * due to Vite dependencies in core. */ -function rootRelativePath(config: Pick, url: URL) { +function rootRelativePath(config: Pick, url: URL): string { const basePath = fileURLToNormalizedPath(url); const rootPath = fileURLToNormalizedPath(config.root); return prependForwardSlash(basePath.slice(rootPath.length)); } -function prependForwardSlash(filePath: string) { +function prependForwardSlash(filePath: string): string { return filePath[0] === '/' ? filePath : '/' + filePath; } @@ -64,6 +68,6 @@ function fileURLToNormalizedPath(filePath: URL): string { return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/'); } -export function emoji(char: string, fallback: string) { +export function emoji(char: string, fallback: string): string { return process.platform !== 'win32' ? char : fallback; } diff --git a/packages/astro/src/content/runtime-assets.ts b/packages/astro/src/content/runtime-assets.ts index 99a83d143..4d07c305b 100644 --- a/packages/astro/src/content/runtime-assets.ts +++ b/packages/astro/src/content/runtime-assets.ts @@ -1,20 +1,24 @@ -import { pathToFileURL } from 'url'; +import type { PluginContext } from 'rollup'; import { z } from 'zod'; -import { - imageMetadata as internalGetImageMetadata, - type Metadata, -} from '../assets/utils/metadata.js'; +import type { AstroSettings } from '../@types/astro.js'; +import { emitESMImage } from '../assets/index.js'; -export function createImage(options: { assetsDir: string; relAssetsDir: string }) { +export function createImage( + settings: AstroSettings, + pluginContext: PluginContext, + entryFilePath: string +) { return () => { - if (options.assetsDir === 'undefined') { - throw new Error('Enable `experimental.assets` in your Astro config to use image()'); - } + return z.string().transform(async (imagePath, ctx) => { + const resolvedFilePath = (await pluginContext.resolve(imagePath, entryFilePath))?.id; + const metadata = await emitESMImage( + resolvedFilePath, + pluginContext.meta.watchMode, + pluginContext.emitFile, + settings + ); - return z.string({ description: '__image' }).transform(async (imagePath, ctx) => { - const imageMetadata = await getImageMetadata(pathToFileURL(imagePath)); - - if (!imageMetadata) { + if (!metadata) { ctx.addIssue({ code: 'custom', message: `Image ${imagePath} does not exist. Is the path correct?`, @@ -24,20 +28,7 @@ export function createImage(options: { assetsDir: string; relAssetsDir: string } return z.NEVER; } - return imageMetadata; + return metadata; }); }; } - -async function getImageMetadata( - imagePath: URL -): Promise<(Metadata & { __astro_asset: true }) | undefined> { - const meta = await internalGetImageMetadata(imagePath); - - if (!meta) { - return undefined; - } - - delete meta.orientation; - return { ...meta, __astro_asset: true }; -} diff --git a/packages/astro/src/content/template/types.d.ts b/packages/astro/src/content/template/types.d.ts index 2485e2699..346d3ff95 100644 --- a/packages/astro/src/content/template/types.d.ts +++ b/packages/astro/src/content/template/types.d.ts @@ -13,8 +13,27 @@ declare module 'astro:content' { export type CollectionEntry = (typeof entryMap)[C][keyof (typeof entryMap)[C]]; + // TODO: Remove this when having this fallback is no longer relevant. 2.3? 3.0? - erika, 2023-04-04 + /** + * @deprecated + * `astro:content` no longer provide `image()`. + * + * Please use it through `schema`, like such: + * ```ts + * import { defineCollection, z } from "astro:content"; + * + * defineCollection({ + * schema: ({ image }) => + * z.object({ + * image: image(), + * }), + * }); + * ``` + */ + export const image: never; + // This needs to be in sync with ImageMetadata - export const image: () => import('astro/zod').ZodObject<{ + type ImageFunction = () => import('astro/zod').ZodObject<{ src: import('astro/zod').ZodString; width: import('astro/zod').ZodNumber; height: import('astro/zod').ZodNumber; @@ -45,7 +64,7 @@ declare module 'astro:content' { | import('astro/zod').ZodEffects; type BaseCollectionConfig = { - schema?: S; + schema?: S | (({ image }: { image: ImageFunction }) => S); slug?: (entry: { id: CollectionEntry['id']; defaultSlug: string; @@ -81,8 +100,9 @@ declare module 'astro:content' { filter?: (entry: CollectionEntry) => unknown ): Promise[]>; + type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; type InferEntrySchema = import('astro/zod').infer< - Required['schema'] + ReturnTypeOrOriginal['schema']> >; const entryMap: { diff --git a/packages/astro/src/content/template/virtual-mod-assets.mjs b/packages/astro/src/content/template/virtual-mod-assets.mjs deleted file mode 100644 index 5f2a1d54c..000000000 --- a/packages/astro/src/content/template/virtual-mod-assets.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { createImage } from 'astro/content/runtime-assets'; - -const assetsDir = '@@ASSETS_DIR@@'; - -export const image = createImage({ - assetsDir, -}); diff --git a/packages/astro/src/content/template/virtual-mod.mjs b/packages/astro/src/content/template/virtual-mod.mjs index c5dc1b4f3..5e04ac5e7 100644 --- a/packages/astro/src/content/template/virtual-mod.mjs +++ b/packages/astro/src/content/template/virtual-mod.mjs @@ -11,6 +11,13 @@ export function defineCollection(config) { return config; } +// TODO: Remove this when having this fallback is no longer relevant. 2.3? 3.0? - erika, 2023-04-04 +export const image = () => { + throw new Error( + 'Importing `image()` from `astro:content` is no longer supported. See https://docs.astro.build/en/guides/assets/#update-content-collections-schemas for our new import instructions.' + ); +}; + const contentDir = '@@CONTENT_DIR@@'; const entryGlob = import.meta.glob('@@ENTRY_GLOB_PATH@@', { diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index e9c45c5bb..d6e92cd68 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -3,14 +3,14 @@ import matter from 'gray-matter'; import fsMod from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { EmitFile, PluginContext } from 'rollup'; -import { normalizePath, type ErrorPayload as ViteErrorPayload, type ViteDevServer } from 'vite'; +import type { PluginContext } from 'rollup'; +import { normalizePath, type ViteDevServer, type ErrorPayload as ViteErrorPayload } from 'vite'; import { z } from 'zod'; import type { AstroConfig, AstroSettings } from '../@types/astro.js'; -import { emitESMImage } from '../assets/utils/emitAsset.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { CONTENT_TYPES_FILE } from './consts.js'; import { errorMap } from './error-map.js'; +import { createImage } from './runtime-assets.js'; export const collectionConfigParser = z.object({ schema: z.any().optional(), @@ -33,7 +33,6 @@ export type CollectionConfig = z.infer; export type ContentConfig = z.infer; type EntryInternal = { rawData: string | undefined; filePath: string }; - export type EntryInfo = { id: string; slug: string; @@ -45,31 +44,6 @@ export const msg = { `${collection} does not have a config. We suggest adding one for type safety!`, }; -/** - * Mutate (arf) the entryData to reroute assets to their final paths - */ -export async function patchAssets( - frontmatterEntry: Record, - 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); - } - } - } -} - export function getEntrySlug({ id, collection, @@ -89,71 +63,37 @@ export function getEntrySlug({ export async function getEntryData( entry: EntryInfo & { unvalidatedData: Record; _internal: EntryInternal }, collectionConfig: CollectionConfig, - resolver: (idToResolve: string) => ReturnType + pluginContext: PluginContext, + settings: AstroSettings ) { // Remove reserved `slug` field before parsing data let { slug, ...data } = entry.unvalidatedData; - if (collectionConfig.schema) { - // TODO: remove for 2.0 stable release - if ( - typeof collectionConfig.schema === 'object' && - !('safeParseAsync' in collectionConfig.schema) - ) { - throw new AstroError({ - title: 'Invalid content collection config', - message: `New: Content collection schemas must be Zod objects. Update your collection config to use \`schema: z.object({...})\` instead of \`schema: {...}\`.`, - hint: 'See https://docs.astro.build/en/reference/api-reference/#definecollection for an example.', - code: 99999, - }); + + let schema = collectionConfig.schema; + if (typeof schema === 'function') { + if (!settings.config.experimental.assets) { + throw new Error( + 'The function shape for schema can only be used when `experimental.assets` is enabled.' + ); } + + schema = schema({ + image: createImage(settings, pluginContext, entry._internal.filePath), + }); + } + + if (schema) { // Catch reserved `slug` field inside schema // Note: will not warn for `z.union` or `z.intersection` schemas - if ( - typeof collectionConfig.schema === 'object' && - 'shape' in collectionConfig.schema && - collectionConfig.schema.shape.slug - ) { + if (typeof schema === 'object' && 'shape' in schema && schema.shape.slug) { throw new AstroError({ ...AstroErrorData.ContentSchemaContainsSlugError, message: AstroErrorData.ContentSchemaContainsSlugError.message(entry.collection), }); } - /** - * Resolve all the images referred to in the frontmatter from the file requesting them - */ - async function preprocessAssetPaths(object: Record) { - if (typeof object !== 'object' || object === null) return; - - for (let [schemaName, schema] of Object.entries(object)) { - if (schema._def.description === '__image') { - object[schemaName] = z.preprocess( - async (value: unknown) => { - if (!value || typeof value !== 'string') return value; - return ( - (await resolver(value))?.id ?? - path.join(path.dirname(entry._internal.filePath), value) - ); - }, - schema, - { description: '__image' } - ); - } else if ('shape' in schema) { - await preprocessAssetPaths(schema.shape); - } else if ('unwrap' in schema) { - const unwrapped = schema.unwrap().shape; - - if (unwrapped) { - await preprocessAssetPaths(unwrapped); - } - } - } - } - - await preprocessAssetPaths(collectionConfig.schema.shape); - // Use `safeParseAsync` to allow async transforms - const parsed = await collectionConfig.schema.safeParseAsync(entry.unvalidatedData, { + const parsed = await schema.safeParseAsync(entry.unvalidatedData, { errorMap, }); if (parsed.success) { diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index 4437f4fa0..cd944731f 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -10,6 +10,7 @@ import { AstroError } from '../core/errors/errors.js'; import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; import { CONTENT_FLAG } from './consts.js'; import { + NoCollectionError, getContentEntryExts, getContentPaths, getEntryData, @@ -17,8 +18,6 @@ import { getEntrySlug, getEntryType, globalContentConfigObserver, - NoCollectionError, - patchAssets, type ContentConfig, } from './utils.js'; @@ -235,11 +234,11 @@ export const _internal = { ? await getEntryData( { id, collection, slug, _internal, unvalidatedData }, collectionConfig, - (idToResolve: string) => pluginContext.resolve(idToResolve, fileId) + pluginContext, + settings ) : unvalidatedData; - await patchAssets(data, pluginContext.meta.watchMode, pluginContext.emitFile, settings); const contentEntryModule: ContentEntryModule = { id, slug, diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts index faa6cb9be..3a72bf1de 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -24,9 +24,6 @@ export function astroContentVirtualModPlugin({ ); const contentEntryExts = getContentEntryExts(settings); - const assetsDir = settings.config.experimental.assets - ? contentPaths.assetsDir.toString() - : 'undefined'; const extGlob = contentEntryExts.length === 1 ? // Wrapping {...} breaks when there is only one extension @@ -38,14 +35,8 @@ export function astroContentVirtualModPlugin({ .replace('@@CONTENT_DIR@@', relContentDir) .replace('@@ENTRY_GLOB_PATH@@', entryGlob) .replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob); - const virtualAssetsModContents = fsMod - .readFileSync(contentPaths.virtualAssetsModTemplate, 'utf-8') - .replace('@@ASSETS_DIR@@', assetsDir); const astroContentVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; - const allContents = settings.config.experimental.assets - ? virtualModContents + virtualAssetsModContents - : virtualModContents; return { name: 'astro-content-virtual-mod-plugin', @@ -58,7 +49,7 @@ export function astroContentVirtualModPlugin({ load(id) { if (id === astroContentVirtualModuleId) { return { - code: allContents, + code: virtualModContents, }; } }, diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index 4b444708e..49f6ce6ae 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -263,7 +263,7 @@ describe('astro:image', () => { it('Adds the tags', () => { let $img = $('img'); - expect($img).to.have.a.lengthOf(4); + expect($img).to.have.a.lengthOf(7); }); it('has proper source for directly used image', () => { @@ -271,6 +271,19 @@ describe('astro:image', () => { expect($img.attr('src').startsWith('/src/')).to.equal(true); }); + it('has proper source for refined image', () => { + let $img = $('#refined-image img'); + expect($img.attr('src').startsWith('/src/')).to.equal(true); + }); + + it('has proper sources for array of images', () => { + let $img = $('#array-of-images img'); + const imgsSrcs = []; + $img.each((i, img) => imgsSrcs.push(img.attribs['src'])); + expect($img).to.have.a.lengthOf(2); + expect(imgsSrcs.every((img) => img.startsWith('/src/'))).to.be.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); @@ -365,7 +378,7 @@ describe('astro:image', () => { it('properly error image in Markdown frontmatter is not found', async () => { logs.length = 0; let res = await fixture.fetch('/blog/one'); - const text = await res.text(); + await res.text(); expect(logs).to.have.a.lengthOf(1); expect(logs[0].message).to.contain('does not exist. Is the path correct?'); @@ -374,7 +387,7 @@ describe('astro:image', () => { it('properly error image in Markdown content is not found', async () => { logs.length = 0; let res = await fixture.fetch('/post'); - const text = await res.text(); + await res.text(); expect(logs).to.have.a.lengthOf(1); expect(logs[0].message).to.contain('Could not find requested image'); diff --git a/packages/astro/test/fixtures/core-image-base/src/content/config.ts b/packages/astro/test/fixtures/core-image-base/src/content/config.ts index b38ad070e..aa35fb4b6 100644 --- a/packages/astro/test/fixtures/core-image-base/src/content/config.ts +++ b/packages/astro/test/fixtures/core-image-base/src/content/config.ts @@ -1,7 +1,7 @@ -import { defineCollection, image, z } from "astro:content"; +import { defineCollection, z } from "astro:content"; const blogCollection = defineCollection({ - schema: z.object({ + schema: ({image}) => z.object({ title: z.string(), image: image(), cover: z.object({ diff --git a/packages/astro/test/fixtures/core-image-errors/src/content/config.ts b/packages/astro/test/fixtures/core-image-errors/src/content/config.ts index b38ad070e..aa35fb4b6 100644 --- a/packages/astro/test/fixtures/core-image-errors/src/content/config.ts +++ b/packages/astro/test/fixtures/core-image-errors/src/content/config.ts @@ -1,7 +1,7 @@ -import { defineCollection, image, z } from "astro:content"; +import { defineCollection, z } from "astro:content"; const blogCollection = defineCollection({ - schema: z.object({ + schema: ({image}) => z.object({ title: z.string(), image: image(), cover: z.object({ diff --git a/packages/astro/test/fixtures/core-image-ssg/src/content/config.ts b/packages/astro/test/fixtures/core-image-ssg/src/content/config.ts index b38ad070e..aa35fb4b6 100644 --- a/packages/astro/test/fixtures/core-image-ssg/src/content/config.ts +++ b/packages/astro/test/fixtures/core-image-ssg/src/content/config.ts @@ -1,7 +1,7 @@ -import { defineCollection, image, z } from "astro:content"; +import { defineCollection, z } from "astro:content"; const blogCollection = defineCollection({ - schema: z.object({ + schema: ({image}) => z.object({ title: z.string(), image: image(), cover: z.object({ diff --git a/packages/astro/test/fixtures/core-image/src/content/blog/one.md b/packages/astro/test/fixtures/core-image/src/content/blog/one.md index 59a5b77ba..ef0993f63 100644 --- a/packages/astro/test/fixtures/core-image/src/content/blog/one.md +++ b/packages/astro/test/fixtures/core-image/src/content/blog/one.md @@ -3,6 +3,10 @@ title: One image: ~/assets/penguin2.jpg cover: image: ../../assets/penguin1.jpg +arrayOfImages: + - ~/assets/penguin2.jpg + - ~/assets/penguin1.jpg +refinedImage: ../../assets/penguin1.jpg --- # A post diff --git a/packages/astro/test/fixtures/core-image/src/content/config.ts b/packages/astro/test/fixtures/core-image/src/content/config.ts index b38ad070e..2d88c49ee 100644 --- a/packages/astro/test/fixtures/core-image/src/content/config.ts +++ b/packages/astro/test/fixtures/core-image/src/content/config.ts @@ -1,15 +1,18 @@ -import { defineCollection, image, z } from "astro:content"; +import { defineCollection, z } from "astro:content"; const blogCollection = defineCollection({ - schema: z.object({ + schema: ({image}) => z.object({ title: z.string(), image: image(), cover: z.object({ image: image() - }) + }), + arrayOfImages: z.array(image()), + refinedImage: image().refine((img) => img.width > 200) }), }); + export const collections = { blog: blogCollection }; diff --git a/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro index b2ccdaeee..33f96a70d 100644 --- a/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro +++ b/packages/astro/test/fixtures/core-image/src/pages/blog/[...slug].astro @@ -28,6 +28,16 @@ const myImage = await getImage({src: entry.data.image}); +
+ { + entry.data.arrayOfImages.map((image) => ) + } +
+ +
+ +
+