[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:
parent
4a1cabfe6b
commit
5d960fc40e
13 changed files with 162 additions and 61 deletions
|
@ -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 };
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
6
packages/astro/test/fixtures/content-collections/src/content/with-union-schema/newsletter.md
vendored
Normal file
6
packages/astro/test/fixtures/content-collections/src/content/with-union-schema/newsletter.md
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
type: newsletter
|
||||
subject: My Newsletter
|
||||
---
|
||||
|
||||
# It's a newsletter!
|
7
packages/astro/test/fixtures/content-collections/src/content/with-union-schema/post.md
vendored
Normal file
7
packages/astro/test/fixtures/content-collections/src/content/with-union-schema/post.md
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
type: post
|
||||
title: My Post
|
||||
description: This is my post
|
||||
---
|
||||
|
||||
# It's a post!
|
|
@ -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}),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in a new issue