Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/all-badgers-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/starlight': patch
---

Fixes support for modifying Zod enums when passing an [`extend` option](https://starlight.astro.build/reference/frontmatter/#extend) to Starlight’s `docsSchema()`
74 changes: 74 additions & 0 deletions packages/starlight/__tests__/basics/schema.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { docsSchema } from '@astrojs/starlight/schema';
import { z } from 'astro/zod';
import { describe, expectTypeOf, test } from 'vitest';

interface PartialDocsSchemaOutput {
title: string;
description?: string | undefined;
template: 'doc' | 'splash';
sidebar: {
hidden: boolean;
};
}

describe('docs schema', () => {
test('docs schema should parse to expected shape', () => {
const schema = docsSchema()({ image: () => ({}) as any });
const parsed = schema.parse({});
expectTypeOf(parsed).toExtend<PartialDocsSchemaOutput>();
});

test('docs schema should parse to expected shape when making existing key required', () => {
const schema = docsSchema({ extend: z.object({ description: z.string() }) })({
image: () => ({}) as any,
});
const parsed = schema.parse({});
expectTypeOf(parsed.description).toBeString();
});

test('docs schema should parse to expected shape when extending existing enum', () => {
const schema = docsSchema({
extend: z.object({
template: z.enum(['doc', 'splash', 'custom']),
hero: z
.object({
actions: z
.array(
z.object({ variant: z.enum(['primary', 'secondary', 'custom']).default('primary') })
)
.default([]),
})
.optional(),
}),
})({
image: () => ({}) as any,
});
const parsed = schema.parse({});
expectTypeOf(parsed.template).toEqualTypeOf<'doc' | 'splash' | 'custom'>();
expectTypeOf(parsed.hero!.actions[0]!.text).toBeString();
expectTypeOf(parsed.hero!.actions[0]!.link).toBeString();
expectTypeOf(parsed.hero!.actions[0]!.variant).toEqualTypeOf<
'primary' | 'secondary' | 'custom'
>();
});

test('docs schema should parse to expected shape when adding a custom key', () => {
const schema = docsSchema({ extend: z.object({ custom: z.string() }) })({
image: () => ({}) as any,
});
const parsed = schema.parse({});
expectTypeOf(parsed).toExtend<PartialDocsSchemaOutput>();
expectTypeOf(parsed.custom).toBeString();
});

test('docs schema should parse to expected shape when extending a nested object', () => {
const schema = docsSchema({
extend: z.object({ sidebar: z.object({ custom: z.number() }) }),
})({
image: () => ({}) as any,
});
const parsed = schema.parse({});
expectTypeOf(parsed).toExtend<PartialDocsSchemaOutput>();
expectTypeOf(parsed.sidebar.custom).toBeNumber();
});
});
Comment thread
miichom marked this conversation as resolved.
202 changes: 202 additions & 0 deletions packages/starlight/__tests__/basics/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'vitest';
import { docsSchema } from '../../schema';
import { FaviconSchema } from '../../schemas/favicon';
import { TitleTransformConfigSchema } from '../../schemas/site-title';
import { HeadConfigSchema, type HeadUserConfig } from '../../schemas/head';
import { parseWithFriendlyErrors } from '../../utils/error-map';
import { z } from 'astro/zod';

describe('FaviconSchema', () => {
test('returns the proper href and type attributes', () => {
Expand Down Expand Up @@ -171,3 +173,203 @@ describe('HeadConfigSchema', () => {
});
});
});

describe('docsSchema', () => {
test('basic frontmatter should parse to expected shape', () => {
const schema = docsSchema()({ image: () => ({}) as never });
const parsed = schema.parse({ title: 'Test Title' });
expect(parsed).toMatchInlineSnapshot(`
{
"draft": false,
"editUrl": true,
"head": [],
"pagefind": true,
"sidebar": {
"attrs": {},
"hidden": false,
},
"template": "doc",
"title": "Test Title",
}
`);
});

test('docs schema can be extended to make property required', () => {
const schema = docsSchema({ extend: z.object({ description: z.string() }) })({
image: () => ({}) as never,
});
expect(() => schema.parse({ title: 'Test Title' })).toThrowErrorMatchingInlineSnapshot(`
[ZodError: [
{
"expected": "string",
"code": "invalid_type",
"path": [
"description"
],
"message": "Invalid input: expected string, received undefined"
}
]]
`);
});

test('an existing enum can be extended', () => {
const schema = docsSchema({
extend: z.object({
template: z.enum(['doc', 'splash', 'custom']),
hero: z
.object({
actions: z
.array(
z.object({ variant: z.enum(['primary', 'secondary', 'custom']).default('primary') })
)
.default([]),
})
.optional(),
}),
})({
image: () => ({}) as never,
});

const parsed = schema.parse({
title: 'Test Title',
template: 'custom',
hero: { actions: [{ variant: 'custom', text: '', link: '' }] },
});
expect(parsed).toMatchInlineSnapshot(`
{
"draft": false,
"editUrl": true,
"head": [],
"hero": {
"actions": [
{
"link": "",
"text": "",
"variant": "custom",
},
],
Comment thread
delucis marked this conversation as resolved.
},
"pagefind": true,
"sidebar": {
"attrs": {},
"hidden": false,
},
"template": "custom",
"title": "Test Title",
}
`);
});

test('docs schema can be extended to add a new property', () => {
const schema = docsSchema({ extend: z.object({ custom: z.string() }) })({
image: () => ({}) as never,
});
const parsed = schema.parse({ title: 'Test Title', custom: 'Custom Value' });
expect(parsed).toMatchInlineSnapshot(`
{
"custom": "Custom Value",
"draft": false,
"editUrl": true,
"head": [],
"pagefind": true,
"sidebar": {
"attrs": {},
"hidden": false,
},
"template": "doc",
"title": "Test Title",
}
`);
});

test(`docs schema can be extended to override existing property`, () => {
const schema = docsSchema({
extend: z.object({
hero: z
.object({
actions: z.array(z.object({ icon: z.string() })).default([]),
})
.optional(),
}),
})({
image: () => ({}) as never,
});
const parsed = schema.parse({
title: 'Test Title',
hero: { actions: [{ text: '', link: '', icon: 'icon-x' }] },
});
expect(parsed).toMatchInlineSnapshot(`
{
"draft": false,
"editUrl": true,
"head": [],
"hero": {
"actions": [
{
"icon": "icon-x",
"link": "",
"text": "",
"variant": "primary",
},
],
},
"pagefind": true,
"sidebar": {
"attrs": {},
"hidden": false,
},
"template": "doc",
"title": "Test Title",
}
`);
});

test('docs schema can be extended to add a property to a nested object', () => {
const schema = docsSchema({ extend: z.object({ sidebar: z.object({ custom: z.number() }) }) })({
image: () => ({}) as never,
});
const parsed = schema.parse({ title: 'Test Title', sidebar: { custom: 42 } });
expect(parsed).toMatchInlineSnapshot(`
{
"draft": false,
"editUrl": true,
"head": [],
"pagefind": true,
"sidebar": {
"attrs": {},
"custom": 42,
"hidden": false,
},
"template": "doc",
"title": "Test Title",
}
`);
});

test('docs schema preserves user-defined refinements when extending a nested object', () => {
const schema = docsSchema({
extend: z.object({
sidebar: z
.object({ custom: z.number() })
.refine(({ custom }) => custom > 0, 'custom must be positive'),
}),
})({
image: () => ({}) as never,
});

expect(() => schema.parse({ title: 'Test Title', sidebar: { custom: 42 } })).not.toThrow();

expect(() => schema.parse({ title: 'Test Title', sidebar: { custom: -1 } }))
.toThrowErrorMatchingInlineSnapshot(`
[ZodError: [
{
"code": "custom",
"path": [
"sidebar"
],
"message": "custom must be positive"
}
]]
`);
});
});
9 changes: 4 additions & 5 deletions packages/starlight/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FrontmatterTableOfContentsSchema } from './schemas/tableOfContents';
import { BadgeConfigSchema } from './schemas/badge';
import { HeroSchema } from './schemas/hero';
import { SidebarLinkItemHTMLAttributesSchema } from './schemas/sidebar';
import { mergeWithDefaultSchema, type MergedSchema } from './utils/zodDeepMerge';
export { i18nSchema } from './schemas/i18n';

/** Default content collection schema for Starlight’s `docs` collection. */
Expand Down Expand Up @@ -114,14 +115,12 @@ const StarlightFrontmatterSchema = (context: SchemaContext) =>
type DefaultSchema = ReturnType<typeof StarlightFrontmatterSchema>;

/** Base subset of Zod types that we support passing to the `extend` option. */
type BaseSchema = z.core.$ZodType;
type BaseSchema<T extends z.ZodRawShape = z.ZodRawShape> = z.ZodObject<T>;

/** Type that extends Starlight’s default schema with an optional, user-defined schema. */
type ExtendedSchema<T extends BaseSchema = never> = [T] extends [never]
? DefaultSchema
: T extends BaseSchema
? z.ZodIntersection<DefaultSchema, T>
: DefaultSchema;
: MergedSchema<DefaultSchema, T>;

interface DocsSchemaOpts<T extends BaseSchema> {
/**
Expand Down Expand Up @@ -160,7 +159,7 @@ export function docsSchema<T extends BaseSchema = never>(

return (
UserSchema
? StarlightFrontmatterSchema(context).and(UserSchema)
? mergeWithDefaultSchema(StarlightFrontmatterSchema(context), UserSchema)
: StarlightFrontmatterSchema(context)
) as ExtendedSchema<T>;
};
Expand Down
Loading