Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move image() to be passed as part of schema #6703

Merged
merged 11 commits into from
Apr 5, 2023
22 changes: 22 additions & 0 deletions .changeset/many-dancers-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'astro': minor
---

Move `image()` to come from `schema` instead to fix it not working with refine and inside complex types
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved

**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",
}),
}),
});
```
18 changes: 11 additions & 7 deletions packages/astro/src/assets/utils/emitAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AstroSettings, 'config'>
) {
): Promise<Metadata | undefined> {
if (!id) {
return undefined;
}

const url = pathToFileURL(id);
const meta = await imageMetadata(url);

if (!meta) {
return;
return undefined;
}

// Build
Expand Down Expand Up @@ -48,13 +52,13 @@ export async function emitESMImage(
* due to Vite dependencies in core.
*/

function rootRelativePath(config: Pick<AstroConfig, 'root'>, url: URL) {
function rootRelativePath(config: Pick<AstroConfig, 'root'>, 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;
}

Expand All @@ -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;
}
49 changes: 20 additions & 29 deletions packages/astro/src/content/runtime-assets.ts
Original file line number Diff line number Diff line change
@@ -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';

export function createImage(options: { assetsDir: string; relAssetsDir: string }) {
import type { AstroSettings } from '../@types/astro.js';
import { emitESMImage } from '../assets/index.js';

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({ description: '__image' }).transform(async (imagePath, ctx) => {
const imageMetadata = await getImageMetadata(pathToFileURL(imagePath));

if (!imageMetadata) {
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
);

if (!metadata) {
ctx.addIssue({
code: 'custom',
message: `Image ${imagePath} does not exist. Is the path correct?`,
Expand All @@ -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 };
}
26 changes: 23 additions & 3 deletions packages/astro/src/content/template/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,27 @@ declare module 'astro:content' {
export type CollectionEntry<C extends keyof typeof entryMap> =
(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
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved
* `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;
Expand Down Expand Up @@ -45,7 +64,7 @@ declare module 'astro:content' {
| import('astro/zod').ZodEffects<BaseSchemaWithoutEffects>;

type BaseCollectionConfig<S extends BaseSchema> = {
schema?: S;
schema?: S | (({ image }: { image: ImageFunction }) => S);
slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string;
Expand Down Expand Up @@ -81,8 +100,9 @@ declare module 'astro:content' {
filter?: (entry: CollectionEntry<C>) => unknown
): Promise<CollectionEntry<C>[]>;

type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
Required<ContentConfig['collections'][C]>['schema']
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;

const entryMap: {
Expand Down
7 changes: 0 additions & 7 deletions packages/astro/src/content/template/virtual-mod-assets.mjs

This file was deleted.

7 changes: 7 additions & 0 deletions packages/astro/src/content/template/virtual-mod.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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@@', {
Expand Down
102 changes: 21 additions & 81 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -33,7 +33,6 @@ export type CollectionConfig = z.infer<typeof collectionConfigParser>;
export type ContentConfig = z.infer<typeof contentConfigParser>;

type EntryInternal = { rawData: string | undefined; filePath: string };

export type EntryInfo = {
id: string;
slug: string;
Expand All @@ -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<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);
}
}
}
}

export function getEntrySlug({
id,
collection,
Expand All @@ -89,71 +63,37 @@ export function getEntrySlug({
export async function getEntryData(
entry: EntryInfo & { unvalidatedData: Record<string, unknown>; _internal: EntryInternal },
collectionConfig: CollectionConfig,
resolver: (idToResolve: string) => ReturnType<PluginContext['resolve']>
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<string, any>) {
if (typeof object !== 'object' || object === null) return;

for (let [schemaName, schema] of Object.entries<any>(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) {
Expand Down
7 changes: 3 additions & 4 deletions packages/astro/src/content/vite-plugin-content-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,14 @@ 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,
getEntryInfo,
getEntrySlug,
getEntryType,
globalContentConfigObserver,
NoCollectionError,
patchAssets,
type ContentConfig,
} from './utils.js';

Expand Down Expand Up @@ -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,
Expand Down
Loading