From 18a01d032bbb38e13c35d305782fec25ab9d1ca8 Mon Sep 17 00:00:00 2001 From: pyxelr Date: Wed, 11 Mar 2026 20:27:59 +0100 Subject: [PATCH 1/5] fix: respect global `tableOfContents` config with zod v4 With zod v4, `.default(value).optional()` returns the default value for `undefined` input instead of `undefined` itself. This caused frontmatter `tableOfContents` to always resolve to the schema default, preventing the global Starlight config from being used as a fallback. This fix introduces a separate `FrontmatterTableOfContentsSchema` that omits `.default()` so `undefined` is preserved, and updates `getToC()` to properly merge frontmatter values with the global config. Closes withastro/starlight#3748 --- .changeset/fix-toc-global-config-zod-v4.md | 5 ++++ .../__tests__/basics/route-data.test.ts | 22 ++++++++++++++++ packages/starlight/schema.ts | 4 +-- packages/starlight/schemas/tableOfContents.ts | 19 ++++++++++++++ packages/starlight/utils/routing/data.ts | 26 ++++++++++++++----- 5 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-toc-global-config-zod-v4.md diff --git a/.changeset/fix-toc-global-config-zod-v4.md b/.changeset/fix-toc-global-config-zod-v4.md new file mode 100644 index 00000000000..1d647c7d4f5 --- /dev/null +++ b/.changeset/fix-toc-global-config-zod-v4.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Fixes global `tableOfContents` config being ignored due to a zod v4 behavior change where `.default().optional()` returns the default value for `undefined` input instead of `undefined` itself. diff --git a/packages/starlight/__tests__/basics/route-data.test.ts b/packages/starlight/__tests__/basics/route-data.test.ts index feb5377f2bd..d3a0bb6526d 100644 --- a/packages/starlight/__tests__/basics/route-data.test.ts +++ b/packages/starlight/__tests__/basics/route-data.test.ts @@ -10,6 +10,8 @@ vi.mock('astro:content', async () => ['getting-started.mdx', { title: 'Splash', template: 'splash' }], ['showcase.mdx', { title: 'ToC Disabled', tableOfContents: false }], ['environmental-impact.md', { title: 'Explicit update date', lastUpdated: new Date() }], + ['toc-enabled.md', { title: 'ToC Enabled', tableOfContents: true }], + ['toc-custom.md', { title: 'ToC Custom', tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 4 } }], ], }) ); @@ -55,6 +57,8 @@ test('adds data to route shape', () => { "Explicit update date", "Splash", "ToC Disabled", + "ToC Custom", + "ToC Enabled", ] `); }); @@ -86,3 +90,21 @@ test('uses explicit last updated date from frontmatter', () => { expect(data.lastUpdated).toBeInstanceOf(Date); expect(data.lastUpdated).toEqual(route.entry.data.lastUpdated); }); + +test('uses global table of contents config when frontmatter sets `tableOfContents: true`', () => { + const route = routes[4]!; + const data = generateRouteData({ + props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, + context: getRouteDataTestContext({ pathname: '/toc-enabled/' }), + }); + expect(data.toc).toMatchObject({ minHeadingLevel: 2, maxHeadingLevel: 3 }); +}); + +test('uses custom table of contents levels from frontmatter', () => { + const route = routes[5]!; + const data = generateRouteData({ + props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, + context: getRouteDataTestContext({ pathname: '/toc-custom/' }), + }); + expect(data.toc).toMatchObject({ minHeadingLevel: 2, maxHeadingLevel: 4 }); +}); diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index a90220b87e6..746734f3f78 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -2,7 +2,7 @@ import { z } from 'astro/zod'; import type { SchemaContext } from 'astro:content'; import { HeadConfigSchema } from './schemas/head'; import { PrevNextLinkConfigSchema } from './schemas/prevNextLink'; -import { TableOfContentsSchema } from './schemas/tableOfContents'; +import { FrontmatterTableOfContentsSchema } from './schemas/tableOfContents'; import { BadgeConfigSchema } from './schemas/badge'; import { HeroSchema } from './schemas/hero'; import { SidebarLinkItemHTMLAttributesSchema } from './schemas/sidebar'; @@ -33,7 +33,7 @@ const StarlightFrontmatterSchema = (context: SchemaContext) => head: HeadConfigSchema({ source: 'content' }), /** Override global table of contents configuration for this page. */ - tableOfContents: TableOfContentsSchema().optional(), + tableOfContents: FrontmatterTableOfContentsSchema(), /** * Set the layout style for this page. diff --git a/packages/starlight/schemas/tableOfContents.ts b/packages/starlight/schemas/tableOfContents.ts index b83ea80b958..b60fba74490 100644 --- a/packages/starlight/schemas/tableOfContents.ts +++ b/packages/starlight/schemas/tableOfContents.ts @@ -17,3 +17,22 @@ export const TableOfContentsSchema = () => .refine((toc) => (toc ? toc.minHeadingLevel <= toc.maxHeadingLevel : true), { error: 'minHeadingLevel must be less than or equal to maxHeadingLevel', }); + +/** + * Schema for the `tableOfContents` frontmatter field. + * Unlike `TableOfContentsSchema`, this does not include a `.default()` so that + * `undefined` is preserved when the field is not set in frontmatter, allowing + * the global config to be used as a fallback. + */ +export const FrontmatterTableOfContentsSchema = () => + z + .union([ + z.object({ + /** The level to start including headings at in the table of contents. Default: 2. */ + minHeadingLevel: z.int().min(1).max(6).optional(), + /** The level to stop including headings at in the table of contents. Default: 3. */ + maxHeadingLevel: z.int().min(1).max(6).optional(), + }), + z.boolean(), + ]) + .optional(); diff --git a/packages/starlight/utils/routing/data.ts b/packages/starlight/utils/routing/data.ts index 25fdc6a877e..a3cac855327 100644 --- a/packages/starlight/utils/routing/data.ts +++ b/packages/starlight/utils/routing/data.ts @@ -61,12 +61,26 @@ export function generateRouteData({ } export function getToC({ entry, lang, headings }: PageProps) { - const tocConfig = - entry.data.template === 'splash' - ? false - : entry.data.tableOfContents !== undefined - ? entry.data.tableOfContents - : config.tableOfContents; + if (entry.data.template === 'splash') return; + + const frontmatterToC = entry.data.tableOfContents; + const globalToC = config.tableOfContents; + + // Resolve the effective ToC config from frontmatter and global settings. + let tocConfig: false | { minHeadingLevel: number; maxHeadingLevel: number }; + if (frontmatterToC === undefined || frontmatterToC === true) { + // No override or explicit enable — use global config. + tocConfig = globalToC; + } else if (typeof frontmatterToC === 'object' && globalToC !== false) { + // Partial override — merge with global config for missing values. + tocConfig = { + minHeadingLevel: frontmatterToC.minHeadingLevel ?? globalToC.minHeadingLevel, + maxHeadingLevel: frontmatterToC.maxHeadingLevel ?? globalToC.maxHeadingLevel, + }; + } else { + tocConfig = false; + } + if (!tocConfig) return; const t = useTranslations(lang); return { From bc92afa8fb5d2b0dfc60295b6e19796424640781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Cis=C5=82o?= Date: Thu, 12 Mar 2026 00:10:00 +0100 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Chris Swithinbank --- packages/starlight/schemas/tableOfContents.ts | 18 ++++++++++++- packages/starlight/utils/routing/data.ts | 26 +++++-------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/starlight/schemas/tableOfContents.ts b/packages/starlight/schemas/tableOfContents.ts index b60fba74490..4041e776bda 100644 --- a/packages/starlight/schemas/tableOfContents.ts +++ b/packages/starlight/schemas/tableOfContents.ts @@ -35,4 +35,20 @@ export const FrontmatterTableOfContentsSchema = () => }), z.boolean(), ]) - .optional(); +const TableOfContentsBaseSchema = z + .union([ + z.object({ + /** The level to start including headings at in the table of contents. Default: 2. */ + minHeadingLevel: z.int().min(1).max(6).optional().default(2), + /** The level to stop including headings at in the table of contents. Default: 3. */ + maxHeadingLevel: z.int().min(1).max(6).optional().default(3), + }), + z.boolean().transform((enabled) => (enabled ? defaults : false)), + ]) + .refine((toc) => (toc ? toc.minHeadingLevel <= toc.maxHeadingLevel : true), { + error: 'minHeadingLevel must be less than or equal to maxHeadingLevel', + }); + +export const UserConfigTableOfContentsSchema = () => TableOfContentsBaseSchema.default(defaults); + +export const FrontmatterTableOfContentsSchema = () => TableOfContentsBaseSchema.optional(); diff --git a/packages/starlight/utils/routing/data.ts b/packages/starlight/utils/routing/data.ts index a3cac855327..25fdc6a877e 100644 --- a/packages/starlight/utils/routing/data.ts +++ b/packages/starlight/utils/routing/data.ts @@ -61,26 +61,12 @@ export function generateRouteData({ } export function getToC({ entry, lang, headings }: PageProps) { - if (entry.data.template === 'splash') return; - - const frontmatterToC = entry.data.tableOfContents; - const globalToC = config.tableOfContents; - - // Resolve the effective ToC config from frontmatter and global settings. - let tocConfig: false | { minHeadingLevel: number; maxHeadingLevel: number }; - if (frontmatterToC === undefined || frontmatterToC === true) { - // No override or explicit enable — use global config. - tocConfig = globalToC; - } else if (typeof frontmatterToC === 'object' && globalToC !== false) { - // Partial override — merge with global config for missing values. - tocConfig = { - minHeadingLevel: frontmatterToC.minHeadingLevel ?? globalToC.minHeadingLevel, - maxHeadingLevel: frontmatterToC.maxHeadingLevel ?? globalToC.maxHeadingLevel, - }; - } else { - tocConfig = false; - } - + const tocConfig = + entry.data.template === 'splash' + ? false + : entry.data.tableOfContents !== undefined + ? entry.data.tableOfContents + : config.tableOfContents; if (!tocConfig) return; const t = useTranslations(lang); return { From 15fce7717331f3b9a26de0b8a3640bf9546abc7b Mon Sep 17 00:00:00 2001 From: pyxelr Date: Thu, 12 Mar 2026 00:16:58 +0100 Subject: [PATCH 3/5] fix: clean up duplicate exports and update references - Remove old TableOfContentsSchema and duplicate FrontmatterTableOfContentsSchema - Rename TableOfContentsSchema to UserConfigTableOfContentsSchema in user-config.ts - Revert test additions (no logic changes in data.ts) --- .../__tests__/basics/route-data.test.ts | 22 ------------- packages/starlight/schemas/tableOfContents.ts | 33 ------------------- packages/starlight/utils/user-config.ts | 4 +-- 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/packages/starlight/__tests__/basics/route-data.test.ts b/packages/starlight/__tests__/basics/route-data.test.ts index d3a0bb6526d..feb5377f2bd 100644 --- a/packages/starlight/__tests__/basics/route-data.test.ts +++ b/packages/starlight/__tests__/basics/route-data.test.ts @@ -10,8 +10,6 @@ vi.mock('astro:content', async () => ['getting-started.mdx', { title: 'Splash', template: 'splash' }], ['showcase.mdx', { title: 'ToC Disabled', tableOfContents: false }], ['environmental-impact.md', { title: 'Explicit update date', lastUpdated: new Date() }], - ['toc-enabled.md', { title: 'ToC Enabled', tableOfContents: true }], - ['toc-custom.md', { title: 'ToC Custom', tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 4 } }], ], }) ); @@ -57,8 +55,6 @@ test('adds data to route shape', () => { "Explicit update date", "Splash", "ToC Disabled", - "ToC Custom", - "ToC Enabled", ] `); }); @@ -90,21 +86,3 @@ test('uses explicit last updated date from frontmatter', () => { expect(data.lastUpdated).toBeInstanceOf(Date); expect(data.lastUpdated).toEqual(route.entry.data.lastUpdated); }); - -test('uses global table of contents config when frontmatter sets `tableOfContents: true`', () => { - const route = routes[4]!; - const data = generateRouteData({ - props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, - context: getRouteDataTestContext({ pathname: '/toc-enabled/' }), - }); - expect(data.toc).toMatchObject({ minHeadingLevel: 2, maxHeadingLevel: 3 }); -}); - -test('uses custom table of contents levels from frontmatter', () => { - const route = routes[5]!; - const data = generateRouteData({ - props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, - context: getRouteDataTestContext({ pathname: '/toc-custom/' }), - }); - expect(data.toc).toMatchObject({ minHeadingLevel: 2, maxHeadingLevel: 4 }); -}); diff --git a/packages/starlight/schemas/tableOfContents.ts b/packages/starlight/schemas/tableOfContents.ts index 4041e776bda..918a010f9b3 100644 --- a/packages/starlight/schemas/tableOfContents.ts +++ b/packages/starlight/schemas/tableOfContents.ts @@ -2,39 +2,6 @@ import { z } from 'astro/zod'; const defaults = { minHeadingLevel: 2, maxHeadingLevel: 3 }; -export const TableOfContentsSchema = () => - z - .union([ - z.object({ - /** The level to start including headings at in the table of contents. Default: 2. */ - minHeadingLevel: z.int().min(1).max(6).optional().default(2), - /** The level to stop including headings at in the table of contents. Default: 3. */ - maxHeadingLevel: z.int().min(1).max(6).optional().default(3), - }), - z.boolean().transform((enabled) => (enabled ? defaults : false)), - ]) - .default(defaults) - .refine((toc) => (toc ? toc.minHeadingLevel <= toc.maxHeadingLevel : true), { - error: 'minHeadingLevel must be less than or equal to maxHeadingLevel', - }); - -/** - * Schema for the `tableOfContents` frontmatter field. - * Unlike `TableOfContentsSchema`, this does not include a `.default()` so that - * `undefined` is preserved when the field is not set in frontmatter, allowing - * the global config to be used as a fallback. - */ -export const FrontmatterTableOfContentsSchema = () => - z - .union([ - z.object({ - /** The level to start including headings at in the table of contents. Default: 2. */ - minHeadingLevel: z.int().min(1).max(6).optional(), - /** The level to stop including headings at in the table of contents. Default: 3. */ - maxHeadingLevel: z.int().min(1).max(6).optional(), - }), - z.boolean(), - ]) const TableOfContentsBaseSchema = z .union([ z.object({ diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index 675adae7484..f896b663485 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -9,7 +9,7 @@ import { PagefindConfigDefaults, PagefindConfigSchema } from '../schemas/pagefin import { SidebarItemSchema } from '../schemas/sidebar'; import { TitleConfigSchema, TitleTransformConfigSchema } from '../schemas/site-title'; import { SocialLinksSchema } from '../schemas/social'; -import { TableOfContentsSchema } from '../schemas/tableOfContents'; +import { UserConfigTableOfContentsSchema } from '../schemas/tableOfContents'; import { BuiltInDefaultLocale } from './i18n'; const LocaleSchema = z.object({ @@ -49,7 +49,7 @@ const UserConfigSchema = z.object({ tagline: z.string().optional(), /** Configure the defaults for the table of contents on each page. */ - tableOfContents: TableOfContentsSchema(), + tableOfContents: UserConfigTableOfContentsSchema(), /** Enable and configure “Edit this page” links. */ editLink: z From a10310b428e61be9f23cf857cd55e0fb9ee8f89b Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Thu, 12 Mar 2026 00:20:51 +0100 Subject: [PATCH 4/5] Add some test cases for custom ToC config --- .../toc-custom-config.test.ts | 55 +++++++++++++++++++ .../toc-custom-config/vitest.config.ts | 9 +++ 2 files changed, 64 insertions(+) create mode 100644 packages/starlight/__tests__/toc-custom-config/toc-custom-config.test.ts create mode 100644 packages/starlight/__tests__/toc-custom-config/vitest.config.ts diff --git a/packages/starlight/__tests__/toc-custom-config/toc-custom-config.test.ts b/packages/starlight/__tests__/toc-custom-config/toc-custom-config.test.ts new file mode 100644 index 00000000000..a75b99c27d9 --- /dev/null +++ b/packages/starlight/__tests__/toc-custom-config/toc-custom-config.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, vi } from 'vitest'; +import { routes } from '../../utils/routing'; +import { getRouteDataTestContext } from '../test-utils'; +import { generateRouteData } from '../../utils/routing/data'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['index.mdx', { title: 'Home Page' }], + ['showcase.mdx', { title: 'ToC Disabled', tableOfContents: false }], + [ + 'environmental-impact.md', + { title: 'Explicit update date', tableOfContents: { minHeadingLevel: 2 } }, + ], + ], + }) +); + +const headings = [ + { depth: 1, slug: 'heading-1', text: 'Heading 1' }, + { depth: 2, slug: 'heading-2', text: 'Heading 2' }, + { depth: 3, slug: 'heading-3', text: 'Heading 3' }, + { depth: 4, slug: 'heading-4', text: 'Heading 4' }, +]; + +describe('custom table of contents config', () => { + test('table of contents heading levels match configuration', () => { + const route = routes[0]!; + const data = generateRouteData({ + props: { ...route, headings }, + context: getRouteDataTestContext(), + }); + expect(data.toc?.minHeadingLevel).toBe(1); + expect(data.toc?.maxHeadingLevel).toBe(4); + }); + + test('table of contents can be disabled by frontmatter', () => { + const route = routes[1]!; + const data = generateRouteData({ + props: { ...route, headings }, + context: getRouteDataTestContext(), + }); + expect(data.toc).toBeUndefined(); + }); + + test('table of contents heading levels can be customised by frontmatter', () => { + const route = routes[2]!; + const data = generateRouteData({ + props: { ...route, headings }, + context: getRouteDataTestContext(), + }); + expect(data.toc?.minHeadingLevel).toBe(2); + expect(data.toc?.maxHeadingLevel).toBe(3); + }); +}); diff --git a/packages/starlight/__tests__/toc-custom-config/vitest.config.ts b/packages/starlight/__tests__/toc-custom-config/vitest.config.ts new file mode 100644 index 00000000000..8e08279bfbb --- /dev/null +++ b/packages/starlight/__tests__/toc-custom-config/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineVitestConfig } from '../test-config'; + +export default defineVitestConfig({ + title: 'custom ToC config', + tableOfContents: { + minHeadingLevel: 1, + maxHeadingLevel: 4, + }, +}); From 2339c0b4803aa0ad107e25a4c84bb825c686a678 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Thu, 12 Mar 2026 00:29:18 +0100 Subject: [PATCH 5/5] Simplify changeset --- .changeset/fix-toc-global-config-zod-v4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-toc-global-config-zod-v4.md b/.changeset/fix-toc-global-config-zod-v4.md index 1d647c7d4f5..a9bca42e5c5 100644 --- a/.changeset/fix-toc-global-config-zod-v4.md +++ b/.changeset/fix-toc-global-config-zod-v4.md @@ -2,4 +2,4 @@ '@astrojs/starlight': patch --- -Fixes global `tableOfContents` config being ignored due to a zod v4 behavior change where `.default().optional()` returns the default value for `undefined` input instead of `undefined` itself. +Fixes a regression causing global `tableOfContents` config to be ignored