diff --git a/.changeset/wise-kiwis-sneeze.md b/.changeset/wise-kiwis-sneeze.md new file mode 100644 index 00000000000..368aba87b64 --- /dev/null +++ b/.changeset/wise-kiwis-sneeze.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Improves DX of the `sidebar` prop used by the new `` component. diff --git a/docs/src/content/docs/guides/pages.mdx b/docs/src/content/docs/guides/pages.mdx index 0daeef16c5b..07db994d794 100644 --- a/docs/src/content/docs/guides/pages.mdx +++ b/docs/src/content/docs/guides/pages.mdx @@ -104,15 +104,37 @@ The following properties differ from Markdown frontmatter: - The [`slug`](/reference/frontmatter/#slug) property is not supported and is automatically set based on the custom page’s URL. - The [`editUrl`](/reference/frontmatter/#editurl) option requires a URL to display an edit link. -- The [`sidebar`](/reference/frontmatter/#sidebar) property is not supported. In Markdown frontmatter, this option allows customization of [autogenerated link groups](/reference/configuration/#sidebar), which is not applicable to pages using the `` component. - -{/* ##### `sidebar` */} - -{/* **type:** `SidebarEntry[] | undefined` */} -{/* **default:** the sidebar generated based on the [global `sidebar` config](/reference/configuration/#sidebar) */} - -{/* Provide a custom site navigation sidebar for this page. */} -{/* If not set, the page will use the default global sidebar. */} +- The [`sidebar`](/reference/frontmatter/#sidebar) frontmatter property for customizing how the page appears in [autogenerated link groups](/reference/configuration/#sidebar) is not available. Pages using the `` component are not part of a collection and cannot be added to an autogenerated sidebar group. + +##### `sidebar` + +**type:** `SidebarEntry[]` +**default:** the sidebar generated based on the [global `sidebar` config](/reference/configuration/#sidebar) + +Provide a custom site navigation sidebar for this page. +If not set, the page will use the default global sidebar. + +For example, the following page overrides the default sidebar with a link to the homepage and a group of links to different constellations. +The current page in the sidebar is set using the `isCurrent` property and an optional `badge` has been added to a link item. + +```astro {3-13} + + Example content. + +``` ##### `hasSidebar` diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts index 0484f6105a1..064e261ac0a 100644 --- a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts +++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from 'vitest'; +import { assert, expect, test, vi } from 'vitest'; import { generateStarlightPageRouteData, type StarlightPageProps, @@ -140,6 +140,96 @@ test('uses provided sidebar if any', async () => { `); }); +test('uses provided sidebar with minimal config', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + sidebar: [ + { label: 'Custom link 1', href: '/test/1' }, + { label: 'Custom link 2', href: '/test/2' }, + ], + }, + url: starlightPageUrl, + }); + expect(data.sidebar.map((entry) => entry.label)).toMatchInlineSnapshot(` + [ + "Custom link 1", + "Custom link 2", + ] + `); +}); + +test('supports deprecated `entries` field for sidebar groups', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + sidebar: [ + { + label: 'Group', + entries: [ + { label: 'Custom link 1', href: '/test/1' }, + { label: 'Custom link 2', href: '/test/2' }, + ], + }, + ], + }, + url: starlightPageUrl, + }); + assert(data.sidebar[0]!.type === 'group'); + expect(data.sidebar[0]!.entries.map((entry) => entry.label)).toMatchInlineSnapshot(` + [ + "Custom link 1", + "Custom link 2", + ] + `); +}); + +test('supports `items` field for sidebar groups', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + sidebar: [ + { + label: 'Group', + items: [ + { label: 'Custom link 1', href: '/test/1' }, + { label: 'Custom link 2', href: '/test/2' }, + ], + }, + ], + }, + url: starlightPageUrl, + }); + assert(data.sidebar[0]!.type === 'group'); + expect(data.sidebar[0]!.entries.map((entry) => entry.label)).toMatchInlineSnapshot(` + [ + "Custom link 1", + "Custom link 2", + ] + `); +}); + +test('throws error if sidebar is malformated', async () => { + expect(() => + generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + sidebar: [ + { + label: 'Custom link 1', + //@ts-expect-error Intentionally bad type to cause error. + href: 5, + }, + ], + }, + url: starlightPageUrl, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [Error: Invalid sidebar prop passed to the \`\` component. + **0**: Did not match union:] + `); +}); + test('uses provided pagination if any', async () => { const data = await generateStarlightPageRouteData({ props: { diff --git a/packages/starlight/utils/starlight-page.ts b/packages/starlight/utils/starlight-page.ts index c08ce4fa679..5bdb4e9d87b 100644 --- a/packages/starlight/utils/starlight-page.ts +++ b/packages/starlight/utils/starlight-page.ts @@ -9,6 +9,8 @@ import { slugToLocaleData, urlToSlug } from './slugs'; import { getPrevNextLinks, getSidebar } from './navigation'; import { useTranslations } from './translations'; import { docsSchema } from '../schema'; +import { BadgeConfigSchema } from '../schemas/badge'; +import { SidebarLinkItemHTMLAttributesSchema } from '../schemas/sidebar'; /** * The frontmatter schema for Starlight pages derived from the default schema for Starlight’s @@ -56,6 +58,96 @@ type StarlightPageFrontmatter = Omit< 'editUrl' | 'sidebar' > & { editUrl?: string | false }; +/** + * Link configuration schema for ``. + * Sets default values where possible to be more user friendly than raw `SidebarEntry` type. + */ +const LinkSchema = z + .object({ + /** @deprecated Specifying `type` is no longer required. */ + type: z.literal('link').default('link'), + label: z.string(), + href: z.string(), + isCurrent: z.boolean().default(false), + badge: BadgeConfigSchema(), + attrs: SidebarLinkItemHTMLAttributesSchema(), + }) + // Make sure badge is in the object even if undefined — Zod doesn’t seem to have a way to set `undefined` as a default. + .transform((item) => ({ badge: undefined, ...item })); + +/** Base schema for link groups without the recursive `items` array. */ +const LinkGroupBase = z.object({ + /** @deprecated Specifying `type` is no longer required. */ + type: z.literal('group').default('group'), + label: z.string(), + collapsed: z.boolean().default(false), + badge: BadgeConfigSchema(), +}); + +// These manual types are needed to correctly type the recursive link group type. +type ManualLinkGroupInput = Prettify< + z.input & + // The original implementation of `` in v0.19.0 used `entries`. + // We want to use `items` so it matches the sidebar config in `astro.config.mjs`. + // Keeping `entries` support for now to not break anyone. + // TODO: warn about `entries` usage in a future version + // TODO: remove support for `entries` in a future version + (| { + /** Array of links and subcategories to display in this category. */ + items: Array | ManualLinkGroupInput>; + } + | { + /** + * @deprecated Use `items` instead of `entries`. + * Support for `entries` will be removed in a future version of Starlight. + */ + entries: Array | ManualLinkGroupInput>; + } + ) +>; +type ManualLinkGroupOutput = z.output & { + entries: Array | ManualLinkGroupOutput>; + badge: z.output['badge']; +}; +type LinkGroupSchemaType = z.ZodType; +/** + * Link group configuration schema for ``. + * Sets default values where possible to be more user friendly than raw `SidebarEntry` type. + */ +const LinkGroupSchema: LinkGroupSchemaType = z.preprocess( + // Map `items` to `entries` as expected by the `SidebarEntry` type. + (arg) => { + if (arg && typeof arg === 'object' && 'items' in arg) { + const { items, ...rest } = arg; + return { ...rest, entries: items }; + } + return arg; + }, + LinkGroupBase.extend({ + entries: z.lazy(() => z.union([LinkSchema, LinkGroupSchema]).array()), + }) + // Make sure badge is in the object even if undefined. + .transform((item) => ({ badge: undefined, ...item })) +) as LinkGroupSchemaType; + +/** Sidebar configuration schema for `` */ +const StarlightPageSidebarSchema = z.union([LinkSchema, LinkGroupSchema]).array(); +type StarlightPageSidebarUserConfig = z.input; + +/** Parse sidebar prop to ensure all required defaults are in place. */ +const normalizeSidebarProp = ( + sidebarProp: StarlightPageSidebarUserConfig +): StarlightRouteData['sidebar'] => { + const sidebar = StarlightPageSidebarSchema.safeParse(sidebarProp, { errorMap }); + if (!sidebar.success) { + throwValidationError( + sidebar.error, + 'Invalid sidebar prop passed to the `` component.' + ); + } + return sidebar.data; +}; + /** * The props accepted by the `` component. */ @@ -63,7 +155,8 @@ export type StarlightPageProps = Prettify< // Remove the index signature from `Route`, omit undesired properties and make the rest optional. Partial, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> & // Add the sidebar definitions for a Starlight page. - Partial> & { + Partial> & { + sidebar?: StarlightPageSidebarUserConfig; // And finally add the Starlight page frontmatter properties in a `frontmatter` property. frontmatter: StarlightPageFrontmatter; } @@ -94,7 +187,9 @@ export async function generateStarlightPageRouteData({ const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter); const id = `${stripLeadingAndTrailingSlashes(slug)}.md`; const localeData = slugToLocaleData(slug); - const sidebar = props.sidebar ?? getSidebar(url.pathname, localeData.locale); + const sidebar = props.sidebar + ? normalizeSidebarProp(props.sidebar) + : getSidebar(url.pathname, localeData.locale); const headings = props.headings ?? []; const pageDocsEntry: StarlightPageDocsEntry = { id,