[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({
|
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 };
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
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 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}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
Loading…
Reference in a new issue