Skip to content

Commit

Permalink
Move image() to be passed as part of schema (#6703)
Browse files Browse the repository at this point in the history
* 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 <hey@bholmes.dev>

---------

Co-authored-by: Ben Holmes <hey@bholmes.dev>
  • Loading branch information
Princesseuh and bholmesdev authored Apr 5, 2023
1 parent 9302c0d commit a1108e0
Show file tree
Hide file tree
Showing 16 changed files with 150 additions and 153 deletions.
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

**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
* `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

0 comments on commit a1108e0

Please sign in to comment.