[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({
// Type-check frontmatter using a schema
schema: {
schema: z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
@ -12,7 +12,7 @@ const blog = defineCollection({
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
}),
});
export const collections = { blog };

View file

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

View file

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

View file

@ -103,6 +103,25 @@ describe('Content Collections', () => {
const slugs = json.withSlugConfig.map((item) => item.slug);
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', () => {
@ -130,6 +149,16 @@ describe('Content Collections', () => {
expect(json).to.haveOwnProperty('twoWithSlugConfig');
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({
slug({ id, data }) {
console.log({id, data})
return `${data.prefix}-${id}`;
},
schema: {
schema: z.object({
prefix: z.string(),
}
}),
});
const withSchemaConfig = defineCollection({
schema: {
schema: z.object({
title: z.string(),
isDraft: z.boolean().default(false),
lang: z.enum(['en', 'fr', 'es']).default('en'),
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 = {
'with-slug-config': withSlugConfig,
'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 withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config'));
const withSlugConfig = stripAllRenderFn(await getCollection('with-slug-config'));
const withUnionSchema = stripAllRenderFn(await getCollection('with-union-schema'));
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 oneWithSchemaConfig = stripRenderFn(await getEntry('with-schema-config', 'one.md'));
const twoWithSlugConfig = stripRenderFn(await getEntry('with-slug-config', 'two.md'));
const postWithUnionSchema = stripRenderFn(await getEntry('with-union-schema', 'post.md'));
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';
const blog = defineCollection({
schema: {
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.string().transform((str) => new Date(str)),
@ -10,7 +10,7 @@ const blog = defineCollection({
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
}),
});
export const collections = { blog };

View file

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

View file

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