Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/with-content/src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,7 +12,7 @@ const blog = defineCollection({
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
}),
});

export const collections = { blog };
98 changes: 56 additions & 42 deletions examples/with-content/src/content/types.generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand All @@ -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 = {
Expand All @@ -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");
}
21 changes: 17 additions & 4 deletions packages/astro/src/content/template/types.generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand All @@ -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 = {
Expand Down
14 changes: 13 additions & 1 deletion packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
) {
Comment on lines +72 to +75
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we could allow POJOs (plain ole' JS objects) here and automatically wrap them with z.object if necessary? Small convenience if you don't need any of the advanced z.object features.

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 {
Expand Down
29 changes: 29 additions & 0 deletions packages/astro/test/content-collections.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: newsletter
subject: My Newsletter
---

# It's a newsletter!
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
type: post
title: My Post
description: This is my post
---

# It's a post!
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
}
}
Original file line number Diff line number Diff line change
@@ -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)),
Expand All @@ -10,7 +10,7 @@ const blog = defineCollection({
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
}),
});

export const collections = { blog };
Original file line number Diff line number Diff line change
@@ -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)),
Expand All @@ -10,7 +10,7 @@ const blog = defineCollection({
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
}),
});

export const collections = { blog };
4 changes: 2 additions & 2 deletions packages/astro/test/fixtures/content/src/content/config.ts
Original file line number Diff line number Diff line change
@@ -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 };