[Content Collections] Allow Zod unions, objects, and transforms as schemas (#5770)

* feat: require `schema: z.object({...})`

* fix: update zod object type check

* fix: update types template

* chore: update with-content config

* chore: update test fixture configs

* test: zod union type

* refactor: enumerate valid schema types
This commit is contained in:
Ben Holmes 2023-01-10 08:46:43 -05:00 committed by GitHub
parent 4a1cabfe6b
commit 5d960fc40e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 162 additions and 61 deletions

View file

@ -2,7 +2,7 @@ import { defineCollection, z } from 'astro:content';
const blog = defineCollection({ const blog = defineCollection({
// Type-check frontmatter using a schema // Type-check frontmatter using a schema
schema: { schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
// Transform string to Date object // Transform string to Date object
@ -12,7 +12,7 @@ const blog = defineCollection({
.optional() .optional()
.transform((str) => (str ? new Date(str) : undefined)), .transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(), heroImage: z.string().optional(),
}, }),
}); });
export const collections = { blog }; export const collections = { blog };

View file

@ -3,17 +3,30 @@ declare module 'astro:content' {
export type CollectionEntry<C extends keyof typeof entryMap> = export type CollectionEntry<C extends keyof typeof entryMap> =
(typeof entryMap)[C][keyof (typeof entryMap)[C]] & Render; (typeof entryMap)[C][keyof (typeof entryMap)[C]] & Render;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = { type BaseSchemaWithoutEffects =
| import('astro/zod').AnyZodObject
| import('astro/zod').ZodUnion<import('astro/zod').AnyZodObject[]>
| import('astro/zod').ZodDiscriminatedUnion<string, import('astro/zod').AnyZodObject[]>
| import('astro/zod').ZodIntersection<
import('astro/zod').AnyZodObject,
import('astro/zod').AnyZodObject
>;
type BaseSchema =
| BaseSchemaWithoutEffects
| import('astro/zod').ZodEffects<BaseSchemaWithoutEffects>;
type BaseCollectionConfig<S extends BaseSchema> = {
schema?: S; schema?: S;
slug?: (entry: { slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id']; id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string; defaultSlug: string;
collection: string; collection: string;
body: string; body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>; data: import('astro/zod').infer<S>;
}) => string | Promise<string>; }) => string | Promise<string>;
}; };
export function defineCollection<S extends import('astro/zod').ZodRawShape>( export function defineCollection<S extends BaseSchema>(
input: BaseCollectionConfig<S> input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>; ): BaseCollectionConfig<S>;
@ -30,7 +43,7 @@ declare module 'astro:content' {
): Promise<((typeof entryMap)[C][E] & Render)[]>; ): Promise<((typeof entryMap)[C][E] & Render)[]>;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer< type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']> Required<ContentConfig['collections'][C]>['schema']
>; >;
type Render = { type Render = {
@ -42,44 +55,45 @@ declare module 'astro:content' {
}; };
const entryMap: { const entryMap: {
blog: { "blog": {
'first-post.md': { "first-post.md": {
id: 'first-post.md'; id: "first-post.md",
slug: 'first-post'; slug: "first-post",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'markdown-style-guide.md': { "markdown-style-guide.md": {
id: 'markdown-style-guide.md'; id: "markdown-style-guide.md",
slug: 'markdown-style-guide'; slug: "markdown-style-guide",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'second-post.md': { "second-post.md": {
id: 'second-post.md'; id: "second-post.md",
slug: 'second-post'; slug: "second-post",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'third-post.md': { "third-post.md": {
id: 'third-post.md'; id: "third-post.md",
slug: 'third-post'; slug: "third-post",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'using-mdx.mdx': { "using-mdx.mdx": {
id: 'using-mdx.mdx'; id: "using-mdx.mdx",
slug: 'using-mdx'; slug: "using-mdx",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
}; },
}; };
type ContentConfig = typeof import('./config'); type ContentConfig = typeof import("./config");
} }

View file

@ -3,17 +3,30 @@ declare module 'astro:content' {
export type CollectionEntry<C extends keyof typeof entryMap> = export type CollectionEntry<C extends keyof typeof entryMap> =
(typeof entryMap)[C][keyof (typeof entryMap)[C]] & Render; (typeof entryMap)[C][keyof (typeof entryMap)[C]] & Render;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = { type BaseSchemaWithoutEffects =
| import('astro/zod').AnyZodObject
| import('astro/zod').ZodUnion<import('astro/zod').AnyZodObject[]>
| import('astro/zod').ZodDiscriminatedUnion<string, import('astro/zod').AnyZodObject[]>
| import('astro/zod').ZodIntersection<
import('astro/zod').AnyZodObject,
import('astro/zod').AnyZodObject
>;
type BaseSchema =
| BaseSchemaWithoutEffects
| import('astro/zod').ZodEffects<BaseSchemaWithoutEffects>;
type BaseCollectionConfig<S extends BaseSchema> = {
schema?: S; schema?: S;
slug?: (entry: { slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id']; id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string; defaultSlug: string;
collection: string; collection: string;
body: string; body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>; data: import('astro/zod').infer<S>;
}) => string | Promise<string>; }) => string | Promise<string>;
}; };
export function defineCollection<S extends import('astro/zod').ZodRawShape>( export function defineCollection<S extends BaseSchema>(
input: BaseCollectionConfig<S> input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>; ): BaseCollectionConfig<S>;
@ -30,7 +43,7 @@ declare module 'astro:content' {
): Promise<((typeof entryMap)[C][E] & Render)[]>; ): Promise<((typeof entryMap)[C][E] & Render)[]>;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer< type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']> Required<ContentConfig['collections'][C]>['schema']
>; >;
type Render = { type Render = {

View file

@ -68,8 +68,20 @@ export async function getEntrySlug(entry: Entry, collectionConfig: CollectionCon
export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) { export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) {
let data = entry.data; let data = entry.data;
if (collectionConfig.schema) { 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,
});
}
// Use `safeParseAsync` to allow async transforms // Use `safeParseAsync` to allow async transforms
const parsed = await z.object(collectionConfig.schema).safeParseAsync(entry.data, { errorMap }); const parsed = await collectionConfig.schema.safeParseAsync(entry.data, { errorMap });
if (parsed.success) { if (parsed.success) {
data = parsed.data; data = parsed.data;
} else { } else {

View file

@ -103,6 +103,25 @@ describe('Content Collections', () => {
const slugs = json.withSlugConfig.map((item) => item.slug); const slugs = json.withSlugConfig.map((item) => item.slug);
expect(slugs).to.deep.equal(['fancy-one.md', 'excellent-three.md', 'interesting-two.md']); expect(slugs).to.deep.equal(['fancy-one.md', 'excellent-three.md', 'interesting-two.md']);
}); });
it('Returns `with union schema` collection', async () => {
expect(json).to.haveOwnProperty('withUnionSchema');
expect(Array.isArray(json.withUnionSchema)).to.equal(true);
const post = json.withUnionSchema.find((item) => item.id === 'post.md');
expect(post).to.not.be.undefined;
expect(post.data).to.deep.equal({
type: 'post',
title: 'My Post',
description: 'This is my post',
});
const newsletter = json.withUnionSchema.find((item) => item.id === 'newsletter.md');
expect(newsletter).to.not.be.undefined;
expect(newsletter.data).to.deep.equal({
type: 'newsletter',
subject: 'My Newsletter',
});
});
}); });
describe('Entry', () => { describe('Entry', () => {
@ -130,6 +149,16 @@ describe('Content Collections', () => {
expect(json).to.haveOwnProperty('twoWithSlugConfig'); expect(json).to.haveOwnProperty('twoWithSlugConfig');
expect(json.twoWithSlugConfig.slug).to.equal('interesting-two.md'); expect(json.twoWithSlugConfig.slug).to.equal('interesting-two.md');
}); });
it('Returns `with union schema` collection entry', async () => {
expect(json).to.haveOwnProperty('postWithUnionSchema');
expect(json.postWithUnionSchema.id).to.equal('post.md');
expect(json.postWithUnionSchema.data).to.deep.equal({
type: 'post',
title: 'My Post',
description: 'This is my post',
});
});
}); });
}); });

View file

@ -2,23 +2,39 @@ import { z, defineCollection } from 'astro:content';
const withSlugConfig = defineCollection({ const withSlugConfig = defineCollection({
slug({ id, data }) { slug({ id, data }) {
console.log({id, data})
return `${data.prefix}-${id}`; return `${data.prefix}-${id}`;
}, },
schema: { schema: z.object({
prefix: z.string(), prefix: z.string(),
} }),
}); });
const withSchemaConfig = defineCollection({ const withSchemaConfig = defineCollection({
schema: { schema: z.object({
title: z.string(), title: z.string(),
isDraft: z.boolean().default(false), isDraft: z.boolean().default(false),
lang: z.enum(['en', 'fr', 'es']).default('en'), lang: z.enum(['en', 'fr', 'es']).default('en'),
publishedAt: z.date().transform((val) => new Date(val)), publishedAt: z.date().transform((val) => new Date(val)),
} })
});
const withUnionSchema = defineCollection({
schema: z.discriminatedUnion('type', [
z.object({
type: z.literal('post'),
title: z.string(),
description: z.string(),
}),
z.object({
type: z.literal('newsletter'),
subject: z.string(),
}),
]),
}); });
export const collections = { export const collections = {
'with-slug-config': withSlugConfig, 'with-slug-config': withSlugConfig,
'with-schema-config': withSchemaConfig, 'with-schema-config': withSchemaConfig,
'with-union-schema': withUnionSchema,
} }

View file

@ -0,0 +1,6 @@
---
type: newsletter
subject: My Newsletter
---
# It's a newsletter!

View file

@ -0,0 +1,7 @@
---
type: post
title: My Post
description: This is my post
---
# It's a post!

View file

@ -6,7 +6,9 @@ export async function get() {
const withoutConfig = stripAllRenderFn(await getCollection('without-config')); const withoutConfig = stripAllRenderFn(await getCollection('without-config'));
const withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config')); const withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config'));
const withSlugConfig = stripAllRenderFn(await getCollection('with-slug-config')); const withSlugConfig = stripAllRenderFn(await getCollection('with-slug-config'));
const withUnionSchema = stripAllRenderFn(await getCollection('with-union-schema'));
return { return {
body: devalue.stringify({withoutConfig, withSchemaConfig, withSlugConfig}), body: devalue.stringify({withoutConfig, withSchemaConfig, withSlugConfig, withUnionSchema}),
} }
} }

View file

@ -6,7 +6,9 @@ export async function get() {
const columbiaWithoutConfig = stripRenderFn(await getEntry('without-config', 'columbia.md')); const columbiaWithoutConfig = stripRenderFn(await getEntry('without-config', 'columbia.md'));
const oneWithSchemaConfig = stripRenderFn(await getEntry('with-schema-config', 'one.md')); const oneWithSchemaConfig = stripRenderFn(await getEntry('with-schema-config', 'one.md'));
const twoWithSlugConfig = stripRenderFn(await getEntry('with-slug-config', 'two.md')); const twoWithSlugConfig = stripRenderFn(await getEntry('with-slug-config', 'two.md'));
const postWithUnionSchema = stripRenderFn(await getEntry('with-union-schema', 'post.md'));
return { return {
body: devalue.stringify({columbiaWithoutConfig, oneWithSchemaConfig, twoWithSlugConfig}), body: devalue.stringify({columbiaWithoutConfig, oneWithSchemaConfig, twoWithSlugConfig, postWithUnionSchema}),
} }
} }

View file

@ -1,7 +1,7 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content';
const blog = defineCollection({ const blog = defineCollection({
schema: { schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
pubDate: z.string().transform((str) => new Date(str)), pubDate: z.string().transform((str) => new Date(str)),
@ -10,7 +10,7 @@ const blog = defineCollection({
.optional() .optional()
.transform((str) => (str ? new Date(str) : undefined)), .transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(), heroImage: z.string().optional(),
}, }),
}); });
export const collections = { blog }; export const collections = { blog };

View file

@ -1,7 +1,7 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content';
const blog = defineCollection({ const blog = defineCollection({
schema: { schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
pubDate: z.string().transform((str) => new Date(str)), pubDate: z.string().transform((str) => new Date(str)),
@ -10,7 +10,7 @@ const blog = defineCollection({
.optional() .optional()
.transform((str) => (str ? new Date(str) : undefined)), .transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(), heroImage: z.string().optional(),
}, }),
}); });
export const collections = { blog }; export const collections = { blog };

View file

@ -1,10 +1,10 @@
import { z, defineCollection } from 'astro:content'; import { z, defineCollection } from 'astro:content';
const blog = defineCollection({ const blog = defineCollection({
schema: { schema: z.object({
title: z.string(), title: z.string(),
description: z.string().max(60, 'For SEO purposes, keep descriptions short!'), description: z.string().max(60, 'For SEO purposes, keep descriptions short!'),
}, }),
}); });
export const collections = { blog }; export const collections = { blog };