From f93f7d3085b6495827f1d6fee1ee9aecc35991de Mon Sep 17 00:00:00 2001 From: miichom Date: Sun, 31 May 2026 18:25:23 +0100 Subject: [PATCH 01/15] fix: invalid option error when modifying enums in docsSchema's extend --- packages/starlight/schema.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index 746734f3f78..c92f1e1ade7 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -114,14 +114,12 @@ const StarlightFrontmatterSchema = (context: SchemaContext) => type DefaultSchema = ReturnType; /** Base subset of Zod types that we support passing to the `extend` option. */ -type BaseSchema = z.core.$ZodType; +type BaseSchema = z.ZodObject; /** Type that extends Starlight’s default schema with an optional, user-defined schema. */ type ExtendedSchema = [T] extends [never] ? DefaultSchema - : T extends BaseSchema - ? z.ZodIntersection - : DefaultSchema; + : z.ZodObject & T['shape']>; interface DocsSchemaOpts { /** @@ -160,8 +158,8 @@ export function docsSchema( return ( UserSchema - ? StarlightFrontmatterSchema(context).and(UserSchema) + ? StarlightFrontmatterSchema(context).extend(UserSchema.shape) : StarlightFrontmatterSchema(context) ) as ExtendedSchema; }; -} +} \ No newline at end of file From 92ea6bb22663e64e0d51d9a41445fd5a4fee839d Mon Sep 17 00:00:00 2001 From: miichom Date: Sun, 31 May 2026 18:29:05 +0100 Subject: [PATCH 02/15] [ci] format --- README.md | 2 +- packages/starlight/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ec76536653..1e325879515 120000 --- a/README.md +++ b/README.md @@ -1 +1 @@ -packages/starlight/README.md \ No newline at end of file +packages/starlight/README.md diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index c92f1e1ade7..a559ae82469 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -162,4 +162,4 @@ export function docsSchema( : StarlightFrontmatterSchema(context) ) as ExtendedSchema; }; -} \ No newline at end of file +} From f7da3e6e2312a095228784bb9363a6057183593b Mon Sep 17 00:00:00 2001 From: miichom Date: Sun, 31 May 2026 18:34:12 +0100 Subject: [PATCH 03/15] [ci] changeset --- .changeset/all-badgers-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/all-badgers-grin.md diff --git a/.changeset/all-badgers-grin.md b/.changeset/all-badgers-grin.md new file mode 100644 index 00000000000..ed6f93311fb --- /dev/null +++ b/.changeset/all-badgers-grin.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Replace "and" in "docsSchema" with "extend" to support the modifying of ZodEnum. From 038cabf5e7d38a373307bd148ba5ca4ec30d9b35 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Fri, 3 Jul 2026 19:41:22 +0200 Subject: [PATCH 04/15] Add test suites for schema extension --- .../__tests__/basics/schema.test-d.ts | 72 ++++++++++ .../starlight/__tests__/basics/schema.test.ts | 129 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 packages/starlight/__tests__/basics/schema.test-d.ts diff --git a/packages/starlight/__tests__/basics/schema.test-d.ts b/packages/starlight/__tests__/basics/schema.test-d.ts new file mode 100644 index 00000000000..88457c552f0 --- /dev/null +++ b/packages/starlight/__tests__/basics/schema.test-d.ts @@ -0,0 +1,72 @@ +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(); + }); + + 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]!.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(); + 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(); + expectTypeOf(parsed.sidebar.custom).toBeNumber(); + }); +}); diff --git a/packages/starlight/__tests__/basics/schema.test.ts b/packages/starlight/__tests__/basics/schema.test.ts index 2e274a1524e..15e372d6f85 100644 --- a/packages/starlight/__tests__/basics/schema.test.ts +++ b/packages/starlight/__tests__/basics/schema.test.ts @@ -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', () => { @@ -171,3 +173,130 @@ 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": [ + { + "variant": "custom", + }, + ], + }, + "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 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": { + "custom": 42, + }, + "template": "doc", + "title": "Test Title", + } + `); + }); +}); From 8c7ce93d3a269e3584632416880f134b680f2b26 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Fri, 3 Jul 2026 19:44:09 +0200 Subject: [PATCH 05/15] Discard changes to README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e325879515..2ec76536653 120000 --- a/README.md +++ b/README.md @@ -1 +1 @@ -packages/starlight/README.md +packages/starlight/README.md \ No newline at end of file From 4b2d4e5b7122a7607f673c5ee2741ee94c2ea6a1 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Fri, 3 Jul 2026 20:41:52 +0200 Subject: [PATCH 06/15] Represent runtime failure in snapshot --- packages/starlight/__tests__/basics/schema.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/starlight/__tests__/basics/schema.test.ts b/packages/starlight/__tests__/basics/schema.test.ts index 15e372d6f85..8743813dd81 100644 --- a/packages/starlight/__tests__/basics/schema.test.ts +++ b/packages/starlight/__tests__/basics/schema.test.ts @@ -292,6 +292,8 @@ describe('docsSchema', () => { "head": [], "pagefind": true, "sidebar": { + "attrs": {}, + "hidden": false, "custom": 42, }, "template": "doc", From 657ebcc7f2a3ff57aa0c2f46d47363d8e6610ba8 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Fri, 3 Jul 2026 21:01:38 +0200 Subject: [PATCH 07/15] Also add snapshot for hero action --- packages/starlight/__tests__/basics/schema.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/starlight/__tests__/basics/schema.test.ts b/packages/starlight/__tests__/basics/schema.test.ts index 8743813dd81..3b6cf7b1f7a 100644 --- a/packages/starlight/__tests__/basics/schema.test.ts +++ b/packages/starlight/__tests__/basics/schema.test.ts @@ -243,6 +243,8 @@ describe('docsSchema', () => { "hero": { "actions": [ { + "link": "", + "text": "", "variant": "custom", }, ], From 706e02f48009d7173ec9f2312d0be2411434d028 Mon Sep 17 00:00:00 2001 From: miichom Date: Fri, 3 Jul 2026 20:46:05 +0100 Subject: [PATCH 08/15] Fix typing failure for nested object --- packages/starlight/__tests__/basics/schema.test-d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/starlight/__tests__/basics/schema.test-d.ts b/packages/starlight/__tests__/basics/schema.test-d.ts index 88457c552f0..c5d4325623a 100644 --- a/packages/starlight/__tests__/basics/schema.test-d.ts +++ b/packages/starlight/__tests__/basics/schema.test-d.ts @@ -66,7 +66,6 @@ describe('docs schema', () => { image: () => ({}) as any, }); const parsed = schema.parse({}); - expectTypeOf(parsed).toExtend(); expectTypeOf(parsed.sidebar.custom).toBeNumber(); }); }); From 776508b82d661df4ff9361648c53bb86dca76318 Mon Sep 17 00:00:00 2001 From: miichom Date: Fri, 3 Jul 2026 22:32:36 +0100 Subject: [PATCH 09/15] Revert typing fixes --- packages/starlight/__tests__/basics/schema.test-d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/starlight/__tests__/basics/schema.test-d.ts b/packages/starlight/__tests__/basics/schema.test-d.ts index c5d4325623a..88457c552f0 100644 --- a/packages/starlight/__tests__/basics/schema.test-d.ts +++ b/packages/starlight/__tests__/basics/schema.test-d.ts @@ -66,6 +66,7 @@ describe('docs schema', () => { image: () => ({}) as any, }); const parsed = schema.parse({}); + expectTypeOf(parsed).toExtend(); expectTypeOf(parsed.sidebar.custom).toBeNumber(); }); }); From 1201a3742624414ed53dbb69a98bc594ca910e7c Mon Sep 17 00:00:00 2001 From: miichom Date: Fri, 17 Jul 2026 19:09:34 +0100 Subject: [PATCH 10/15] chore: cherry-pick code from #4040 --- .../__tests__/basics/schema.test-d.ts | 2 + .../starlight/__tests__/basics/schema.test.ts | 29 +++- packages/starlight/schema.ts | 124 +++++++++++++++++- 3 files changed, 152 insertions(+), 3 deletions(-) diff --git a/packages/starlight/__tests__/basics/schema.test-d.ts b/packages/starlight/__tests__/basics/schema.test-d.ts index 88457c552f0..185ec9a9f1d 100644 --- a/packages/starlight/__tests__/basics/schema.test-d.ts +++ b/packages/starlight/__tests__/basics/schema.test-d.ts @@ -45,6 +45,8 @@ describe('docs schema', () => { }); 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' >(); diff --git a/packages/starlight/__tests__/basics/schema.test.ts b/packages/starlight/__tests__/basics/schema.test.ts index 3b6cf7b1f7a..92a3e609a92 100644 --- a/packages/starlight/__tests__/basics/schema.test.ts +++ b/packages/starlight/__tests__/basics/schema.test.ts @@ -295,12 +295,39 @@ describe('docsSchema', () => { "pagefind": true, "sidebar": { "attrs": {}, - "hidden": false, "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" + } + ]] + `); + }); }); diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index a559ae82469..0f4184e3557 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -119,7 +119,55 @@ type BaseSchema = z.ZodObject; /** Type that extends Starlight’s default schema with an optional, user-defined schema. */ type ExtendedSchema = [T] extends [never] ? DefaultSchema - : z.ZodObject & T['shape']>; + : MergedSchema; + +/** Type-level equivalent of {@link mergeWithDefaultSchema}. */ +type MergedSchema = + User extends z.ZodOptional + ? z.ZodOptional, WrappedSchema>> + : User extends z.ZodNullable + ? z.ZodNullable, WrappedSchema>> + : User extends z.ZodDefault + ? z.ZodDefault, WrappedSchema>> + : User extends z.ZodPrefault + ? z.ZodPrefault, WrappedSchema>> + : UnwrappedSchema extends z.ZodObject + ? User extends z.ZodObject + ? z.ZodObject, UserConfig> + : User + : UnwrappedSchema extends z.ZodArray< + infer DefaultItemSchema extends z.core.$ZodType + > + ? User extends z.ZodArray + ? z.ZodArray> + : User + : User; + +/** Type-level equivalent of {@link unwrapSchema}. */ +type UnwrappedSchema = + T extends z.ZodOptional + ? UnwrappedSchema + : T extends z.ZodNullable + ? UnwrappedSchema + : T extends z.ZodDefault + ? UnwrappedSchema + : T extends z.ZodPrefault + ? UnwrappedSchema + : T; + +/** + * Merged Zod object shapes, preserving default properties, adding user-only properties, and + * recursively merging shared properties. + */ +type MergedShape = { + [Key in keyof Default | keyof User]: Key extends keyof User + ? Key extends keyof Default + ? MergedSchema + : User[Key] + : Key extends keyof Default + ? Default[Key] + : never; +}; interface DocsSchemaOpts { /** @@ -158,8 +206,80 @@ export function docsSchema( return ( UserSchema - ? StarlightFrontmatterSchema(context).extend(UserSchema.shape) + ? mergeWithDefaultSchema(StarlightFrontmatterSchema(context), UserSchema) : StarlightFrontmatterSchema(context) ) as ExtendedSchema; }; } + +function mergeWithDefaultSchema(defaultSchema: z.ZodType, userSchema: z.ZodType): z.ZodType { + const unwrappedDefaultSchema = unwrapSchema(defaultSchema); + + if (isWrappedSchema(userSchema)) { + return userSchema.clone({ + ...userSchema._zod.def, + innerType: mergeWithDefaultSchema(unwrappedDefaultSchema, userSchema._zod.def.innerType), + } as Parameters[0]); + } + + if (isZodObject(unwrappedDefaultSchema) && isZodObject(userSchema)) { + const shape: z.core.$ZodLooseShape = { ...unwrappedDefaultSchema.shape }; + + for (const key of Object.keys(userSchema.shape)) { + const userFieldSchema = userSchema.shape[key] as z.ZodType; + const defaultFieldSchema = shape[key] as z.ZodType | undefined; + + shape[key] = defaultFieldSchema + ? mergeWithDefaultSchema(defaultFieldSchema, userFieldSchema) + : userFieldSchema; + } + + return userSchema.safeExtend(shape); + } + + if (isZodArray(unwrappedDefaultSchema) && isZodArray(userSchema)) { + return userSchema.clone({ + ...userSchema._zod.def, + element: mergeWithDefaultSchema(unwrappedDefaultSchema.element, userSchema.element), + }); + } + + return userSchema; +} + +/** + * Unwrap any Zod schema that wraps other schemas, such as `z.optional()`, `z.nullable()`, + * `z.default()`, etc. until we reach the innermost schema. + */ +function unwrapSchema(schema: z.ZodType): z.ZodType { + let current = schema; + + while (isWrappedSchema(current)) { + current = current._zod.def.innerType; + } + + return current; +} + +/** + * Check if a schema wraps another schema which can happen when using modifiers like + * `z.optional()`, `z.nullable()`, `z.default()`, etc. + */ +function isWrappedSchema(schema: z.ZodType): schema is z.ZodType & { + _zod: { def: z.ZodType['_zod']['def'] & { innerType: z.ZodType } }; +} { + return ( + schema._zod.def.type === 'optional' || + schema._zod.def.type === 'nullable' || + schema._zod.def.type === 'default' || + schema._zod.def.type === 'prefault' + ); +} + +function isZodObject(schema: z.ZodType): schema is z.ZodObject { + return schema._zod.def.type === 'object'; +} + +function isZodArray(schema: z.ZodType): schema is z.ZodArray { + return schema._zod.def.type === 'array'; +} From 4d5becf23e26b726a5e31df1204dca9ba261688f Mon Sep 17 00:00:00 2001 From: miichom Date: Fri, 17 Jul 2026 19:09:45 +0100 Subject: [PATCH 11/15] feat: extend docs schema to override existing property with hero actions --- .../starlight/__tests__/basics/schema.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/starlight/__tests__/basics/schema.test.ts b/packages/starlight/__tests__/basics/schema.test.ts index 92a3e609a92..5ee810312a9 100644 --- a/packages/starlight/__tests__/basics/schema.test.ts +++ b/packages/starlight/__tests__/basics/schema.test.ts @@ -282,6 +282,48 @@ describe('docsSchema', () => { `); }); + 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, From 090d7690caaa10652de239322703c5591db10bee Mon Sep 17 00:00:00 2001 From: miichom Date: Tue, 21 Jul 2026 15:35:15 +0100 Subject: [PATCH 12/15] refactor: move typings and functions to packages/starlight/utils --- packages/starlight/schema.ts | 121 +---------------------------- packages/starlight/utils/schema.ts | 121 +++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 120 deletions(-) create mode 100644 packages/starlight/utils/schema.ts diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index 0f4184e3557..64f73b2a75d 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -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/schema'; export { i18nSchema } from './schemas/i18n'; /** Default content collection schema for Starlight’s `docs` collection. */ @@ -121,54 +122,6 @@ type ExtendedSchema = [T] extends [never] ? DefaultSchema : MergedSchema; -/** Type-level equivalent of {@link mergeWithDefaultSchema}. */ -type MergedSchema = - User extends z.ZodOptional - ? z.ZodOptional, WrappedSchema>> - : User extends z.ZodNullable - ? z.ZodNullable, WrappedSchema>> - : User extends z.ZodDefault - ? z.ZodDefault, WrappedSchema>> - : User extends z.ZodPrefault - ? z.ZodPrefault, WrappedSchema>> - : UnwrappedSchema extends z.ZodObject - ? User extends z.ZodObject - ? z.ZodObject, UserConfig> - : User - : UnwrappedSchema extends z.ZodArray< - infer DefaultItemSchema extends z.core.$ZodType - > - ? User extends z.ZodArray - ? z.ZodArray> - : User - : User; - -/** Type-level equivalent of {@link unwrapSchema}. */ -type UnwrappedSchema = - T extends z.ZodOptional - ? UnwrappedSchema - : T extends z.ZodNullable - ? UnwrappedSchema - : T extends z.ZodDefault - ? UnwrappedSchema - : T extends z.ZodPrefault - ? UnwrappedSchema - : T; - -/** - * Merged Zod object shapes, preserving default properties, adding user-only properties, and - * recursively merging shared properties. - */ -type MergedShape = { - [Key in keyof Default | keyof User]: Key extends keyof User - ? Key extends keyof Default - ? MergedSchema - : User[Key] - : Key extends keyof Default - ? Default[Key] - : never; -}; - interface DocsSchemaOpts { /** * Extend Starlight’s schema with additional fields. @@ -211,75 +164,3 @@ export function docsSchema( ) as ExtendedSchema; }; } - -function mergeWithDefaultSchema(defaultSchema: z.ZodType, userSchema: z.ZodType): z.ZodType { - const unwrappedDefaultSchema = unwrapSchema(defaultSchema); - - if (isWrappedSchema(userSchema)) { - return userSchema.clone({ - ...userSchema._zod.def, - innerType: mergeWithDefaultSchema(unwrappedDefaultSchema, userSchema._zod.def.innerType), - } as Parameters[0]); - } - - if (isZodObject(unwrappedDefaultSchema) && isZodObject(userSchema)) { - const shape: z.core.$ZodLooseShape = { ...unwrappedDefaultSchema.shape }; - - for (const key of Object.keys(userSchema.shape)) { - const userFieldSchema = userSchema.shape[key] as z.ZodType; - const defaultFieldSchema = shape[key] as z.ZodType | undefined; - - shape[key] = defaultFieldSchema - ? mergeWithDefaultSchema(defaultFieldSchema, userFieldSchema) - : userFieldSchema; - } - - return userSchema.safeExtend(shape); - } - - if (isZodArray(unwrappedDefaultSchema) && isZodArray(userSchema)) { - return userSchema.clone({ - ...userSchema._zod.def, - element: mergeWithDefaultSchema(unwrappedDefaultSchema.element, userSchema.element), - }); - } - - return userSchema; -} - -/** - * Unwrap any Zod schema that wraps other schemas, such as `z.optional()`, `z.nullable()`, - * `z.default()`, etc. until we reach the innermost schema. - */ -function unwrapSchema(schema: z.ZodType): z.ZodType { - let current = schema; - - while (isWrappedSchema(current)) { - current = current._zod.def.innerType; - } - - return current; -} - -/** - * Check if a schema wraps another schema which can happen when using modifiers like - * `z.optional()`, `z.nullable()`, `z.default()`, etc. - */ -function isWrappedSchema(schema: z.ZodType): schema is z.ZodType & { - _zod: { def: z.ZodType['_zod']['def'] & { innerType: z.ZodType } }; -} { - return ( - schema._zod.def.type === 'optional' || - schema._zod.def.type === 'nullable' || - schema._zod.def.type === 'default' || - schema._zod.def.type === 'prefault' - ); -} - -function isZodObject(schema: z.ZodType): schema is z.ZodObject { - return schema._zod.def.type === 'object'; -} - -function isZodArray(schema: z.ZodType): schema is z.ZodArray { - return schema._zod.def.type === 'array'; -} diff --git a/packages/starlight/utils/schema.ts b/packages/starlight/utils/schema.ts new file mode 100644 index 00000000000..fe43768058b --- /dev/null +++ b/packages/starlight/utils/schema.ts @@ -0,0 +1,121 @@ +import type { z } from 'astro/zod'; + +/** Type-level equivalent of {@link mergeWithDefaultSchema}. */ +export type MergedSchema = + User extends z.ZodOptional + ? z.ZodOptional, WrappedSchema>> + : User extends z.ZodNullable + ? z.ZodNullable, WrappedSchema>> + : User extends z.ZodDefault + ? z.ZodDefault, WrappedSchema>> + : User extends z.ZodPrefault + ? z.ZodPrefault, WrappedSchema>> + : UnwrappedSchema extends z.ZodObject + ? User extends z.ZodObject + ? z.ZodObject, UserConfig> + : User + : UnwrappedSchema extends z.ZodArray< + infer DefaultItemSchema extends z.core.$ZodType + > + ? User extends z.ZodArray + ? z.ZodArray> + : User + : User; + +/** Type-level equivalent of {@link unwrapSchema}. */ +type UnwrappedSchema = + T extends z.ZodOptional + ? UnwrappedSchema + : T extends z.ZodNullable + ? UnwrappedSchema + : T extends z.ZodDefault + ? UnwrappedSchema + : T extends z.ZodPrefault + ? UnwrappedSchema + : T; + +/** + * Merged Zod object shapes, preserving default properties, adding user-only properties, and + * recursively merging shared properties. + */ +type MergedShape = { + [Key in keyof Default | keyof User]: Key extends keyof User + ? Key extends keyof Default + ? MergedSchema + : User[Key] + : Key extends keyof Default + ? Default[Key] + : never; +}; + +export function mergeWithDefaultSchema(defaultSchema: z.ZodType, userSchema: z.ZodType): z.ZodType { + const unwrappedDefaultSchema = unwrapSchema(defaultSchema); + + if (isWrappedSchema(userSchema)) { + return userSchema.clone({ + ...userSchema._zod.def, + innerType: mergeWithDefaultSchema(unwrappedDefaultSchema, userSchema._zod.def.innerType), + } as Parameters[0]); + } + + if (isZodObject(unwrappedDefaultSchema) && isZodObject(userSchema)) { + const shape: z.core.$ZodLooseShape = { ...unwrappedDefaultSchema.shape }; + + for (const key of Object.keys(userSchema.shape)) { + const userFieldSchema = userSchema.shape[key] as z.ZodType; + const defaultFieldSchema = shape[key] as z.ZodType | undefined; + + shape[key] = defaultFieldSchema + ? mergeWithDefaultSchema(defaultFieldSchema, userFieldSchema) + : userFieldSchema; + } + + return userSchema.safeExtend(shape); + } + + if (isZodArray(unwrappedDefaultSchema) && isZodArray(userSchema)) { + return userSchema.clone({ + ...userSchema._zod.def, + element: mergeWithDefaultSchema(unwrappedDefaultSchema.element, userSchema.element), + }); + } + + return userSchema; +} + +/** + * Unwrap any Zod schema that wraps other schemas, such as `z.optional()`, `z.nullable()`, + * `z.default()`, etc. until we reach the innermost schema. + */ +function unwrapSchema(schema: z.ZodType): z.ZodType { + let current = schema; + + while (isWrappedSchema(current)) { + current = current._zod.def.innerType; + } + + return current; +} + +/** + * Check if a schema wraps another schema which can happen when using modifiers like + * `z.optional()`, `z.nullable()`, `z.default()`, etc. + */ +function isWrappedSchema(schema: z.ZodType): schema is z.ZodType & { + _zod: { def: z.ZodType['_zod']['def'] & { innerType: z.ZodType } }; +} { + return ( + schema._zod.def.type === 'optional' || + schema._zod.def.type === 'nullable' || + schema._zod.def.type === 'default' || + schema._zod.def.type === 'prefault' + ); +} + +function isZodObject(schema: z.ZodType): schema is z.ZodObject { + return schema._zod.def.type === 'object'; +} + +function isZodArray(schema: z.ZodType): schema is z.ZodArray { + return schema._zod.def.type === 'array'; +} From 8a4e56a29362d0aa199f635a15bf40c27a4003a3 Mon Sep 17 00:00:00 2001 From: miichom Date: Tue, 21 Jul 2026 18:42:03 +0100 Subject: [PATCH 13/15] docs(changelog): update release note for Zod enum extension fix --- .changeset/all-badgers-grin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/all-badgers-grin.md b/.changeset/all-badgers-grin.md index ed6f93311fb..c8464022dda 100644 --- a/.changeset/all-badgers-grin.md +++ b/.changeset/all-badgers-grin.md @@ -2,4 +2,4 @@ '@astrojs/starlight': patch --- -Replace "and" in "docsSchema" with "extend" to support the modifying of ZodEnum. +Fixes support for modifying Zod enums when passing an [`extend` option](https://starlight.astro.build/reference/frontmatter/#extend) to Starlight’s `docsSchema()` \ No newline at end of file From d58283d28398169b7c58de140db2f3a908684654 Mon Sep 17 00:00:00 2001 From: miichom Date: Tue, 21 Jul 2026 18:42:45 +0100 Subject: [PATCH 14/15] refactor(schema): rename schema module to zodDeepMerge for clarity --- packages/starlight/schema.ts | 2 +- packages/starlight/utils/{schema.ts => zodDeepMerge.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/starlight/utils/{schema.ts => zodDeepMerge.ts} (100%) diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index 64f73b2a75d..6afcb3ebcb6 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -6,7 +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/schema'; +import { mergeWithDefaultSchema, type MergedSchema } from './utils/zodDeepMerge'; export { i18nSchema } from './schemas/i18n'; /** Default content collection schema for Starlight’s `docs` collection. */ diff --git a/packages/starlight/utils/schema.ts b/packages/starlight/utils/zodDeepMerge.ts similarity index 100% rename from packages/starlight/utils/schema.ts rename to packages/starlight/utils/zodDeepMerge.ts From 2370b13d886e64a914348da419e53cc6c6ca2575 Mon Sep 17 00:00:00 2001 From: miichom Date: Wed, 22 Jul 2026 14:54:45 +0100 Subject: [PATCH 15/15] refactor(schema): rename merge functions for clarity and consistency --- packages/starlight/schema.ts | 6 +++--- packages/starlight/utils/zodDeepMerge.ts | 26 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index 6afcb3ebcb6..48cb1f615ca 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -6,7 +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'; +import { deepMergeSchemas, type DeepMergedSchema } from './utils/zodDeepMerge'; export { i18nSchema } from './schemas/i18n'; /** Default content collection schema for Starlight’s `docs` collection. */ @@ -120,7 +120,7 @@ type BaseSchema = z.ZodObject; /** Type that extends Starlight’s default schema with an optional, user-defined schema. */ type ExtendedSchema = [T] extends [never] ? DefaultSchema - : MergedSchema; + : DeepMergedSchema; interface DocsSchemaOpts { /** @@ -159,7 +159,7 @@ export function docsSchema( return ( UserSchema - ? mergeWithDefaultSchema(StarlightFrontmatterSchema(context), UserSchema) + ? deepMergeSchemas(StarlightFrontmatterSchema(context), UserSchema) : StarlightFrontmatterSchema(context) ) as ExtendedSchema; }; diff --git a/packages/starlight/utils/zodDeepMerge.ts b/packages/starlight/utils/zodDeepMerge.ts index fe43768058b..f771610bc64 100644 --- a/packages/starlight/utils/zodDeepMerge.ts +++ b/packages/starlight/utils/zodDeepMerge.ts @@ -1,24 +1,24 @@ import type { z } from 'astro/zod'; /** Type-level equivalent of {@link mergeWithDefaultSchema}. */ -export type MergedSchema = +export type DeepMergedSchema = User extends z.ZodOptional - ? z.ZodOptional, WrappedSchema>> + ? z.ZodOptional, WrappedSchema>> : User extends z.ZodNullable - ? z.ZodNullable, WrappedSchema>> + ? z.ZodNullable, WrappedSchema>> : User extends z.ZodDefault - ? z.ZodDefault, WrappedSchema>> + ? z.ZodDefault, WrappedSchema>> : User extends z.ZodPrefault - ? z.ZodPrefault, WrappedSchema>> + ? z.ZodPrefault, WrappedSchema>> : UnwrappedSchema extends z.ZodObject ? User extends z.ZodObject - ? z.ZodObject, UserConfig> + ? z.ZodObject, UserConfig> : User : UnwrappedSchema extends z.ZodArray< infer DefaultItemSchema extends z.core.$ZodType > ? User extends z.ZodArray - ? z.ZodArray> + ? z.ZodArray> : User : User; @@ -38,23 +38,23 @@ type UnwrappedSchema = * Merged Zod object shapes, preserving default properties, adding user-only properties, and * recursively merging shared properties. */ -type MergedShape = { +type DeepMergedShape = { [Key in keyof Default | keyof User]: Key extends keyof User ? Key extends keyof Default - ? MergedSchema + ? DeepMergedSchema : User[Key] : Key extends keyof Default ? Default[Key] : never; }; -export function mergeWithDefaultSchema(defaultSchema: z.ZodType, userSchema: z.ZodType): z.ZodType { +export function deepMergeSchemas(defaultSchema: z.ZodType, userSchema: z.ZodType): z.ZodType { const unwrappedDefaultSchema = unwrapSchema(defaultSchema); if (isWrappedSchema(userSchema)) { return userSchema.clone({ ...userSchema._zod.def, - innerType: mergeWithDefaultSchema(unwrappedDefaultSchema, userSchema._zod.def.innerType), + innerType: deepMergeSchemas(unwrappedDefaultSchema, userSchema._zod.def.innerType), } as Parameters[0]); } @@ -66,7 +66,7 @@ export function mergeWithDefaultSchema(defaultSchema: z.ZodType, userSchema: z.Z const defaultFieldSchema = shape[key] as z.ZodType | undefined; shape[key] = defaultFieldSchema - ? mergeWithDefaultSchema(defaultFieldSchema, userFieldSchema) + ? deepMergeSchemas(defaultFieldSchema, userFieldSchema) : userFieldSchema; } @@ -76,7 +76,7 @@ export function mergeWithDefaultSchema(defaultSchema: z.ZodType, userSchema: z.Z if (isZodArray(unwrappedDefaultSchema) && isZodArray(userSchema)) { return userSchema.clone({ ...userSchema._zod.def, - element: mergeWithDefaultSchema(unwrappedDefaultSchema.element, userSchema.element), + element: deepMergeSchemas(unwrappedDefaultSchema.element, userSchema.element), }); }