From e044feeae9a336a87db526107e5772b54ddc567f Mon Sep 17 00:00:00 2001 From: HiDeoo <494699+HiDeoo@users.noreply.github.com> Date: Fri, 16 Aug 2024 19:03:48 +0200 Subject: [PATCH] Use Starlight `sidebar` user-config format for `` `sidebar` prop (#2168) Co-authored-by: Chris Swithinbank --- .changeset/chilled-kiwis-count.md | 56 +++++ docs/src/content/docs/guides/pages.mdx | 15 +- .../basics/starlight-page-route-data.test.ts | 235 +++++++++--------- packages/starlight/utils/navigation.ts | 16 +- packages/starlight/utils/starlight-page.ts | 102 ++------ 5 files changed, 213 insertions(+), 211 deletions(-) create mode 100644 .changeset/chilled-kiwis-count.md diff --git a/.changeset/chilled-kiwis-count.md b/.changeset/chilled-kiwis-count.md new file mode 100644 index 00000000000..dac9116a00d --- /dev/null +++ b/.changeset/chilled-kiwis-count.md @@ -0,0 +1,56 @@ +--- +'@astrojs/starlight': minor +--- + +⚠️ **BREAKING CHANGE:** Updates the `` component `sidebar` prop to accept an array of [`SidebarItem`](https://starlight.astro.build/reference/configuration/#sidebaritem)s like the main Starlight `sidebar` configuration in `astro.config.mjs`. + +This change simplifies the definition of sidebar items in the `` component, allows for shared sidebar configuration between the global `sidebar` option and `` component, and also enables the usage of autogenerated sidebar groups with the `` component. +If you are using the `` component with a custom `sidebar` configuration, you will need to update the `sidebar` prop to an array of [`SidebarItem`](https://starlight.astro.build/reference/configuration/#sidebaritem) objects. + +For example, the following custom page with a custom `sidebar` configuration defines a “Resources” group with a “New” badge, a link to the “Showcase” page which is part of the `docs` content collection, and a link to the Starlight website: + +```astro +--- +// src/pages/custom-page/example.astro +--- + + +

This is a custom page with a custom component.

+
+``` + +This configuration will now need to be updated to the following: + +```astro +--- +// src/pages/custom-page/example.astro +--- + + +

This is a custom page with a custom component.

+
+``` + +See the [“Sidebar Navigation”](https://starlight.astro.build/guides/sidebar/) guide to learn more about the available options for customizing the sidebar. diff --git a/docs/src/content/docs/guides/pages.mdx b/docs/src/content/docs/guides/pages.mdx index bb3825906e5..f112a5722fd 100644 --- a/docs/src/content/docs/guides/pages.mdx +++ b/docs/src/content/docs/guides/pages.mdx @@ -109,26 +109,25 @@ The following properties differ from Markdown frontmatter: ##### `sidebar` -**type:** `SidebarEntry[]` +**type:** [`SidebarItem[]`](/reference/configuration/#sidebaritem) **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. +For example, the following page overrides the default sidebar with a link to the homepage and a group of links to various other custom pages. ```astro {3-13} ``` +See the [“Sidebar Navigation”](/guides/sidebar/) guide to learn more about the available options for customizing the sidebar. + ##### `hasSidebar` **type:** `boolean` 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 e5164e04108..3592f09b060 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 { assert, expect, test, vi } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { generateRouteData } from '../../utils/route-data'; import { routes } from '../../utils/routing'; import { @@ -15,6 +15,9 @@ vi.mock('astro:content', async () => docs: [ ['index.mdx', { title: 'Home Page' }], ['getting-started.mdx', { title: 'Getting Started' }], + ['guides/authoring-content.md', { title: 'Authoring Markdown' }], + ['guides/components.mdx', { title: 'Components' }], + ['reference/frontmatter.md', { title: 'Frontmatter Reference' }], ], }) ); @@ -102,10 +105,64 @@ test('uses generated sidebar when no sidebar is provided', async () => { props: starlightPageProps, url: starlightPageUrl, }); - expect(data.sidebar.map((entry) => entry.label)).toMatchInlineSnapshot(` + expect(data.sidebar).toMatchInlineSnapshot(` [ - "Home Page", - "Getting Started", + { + "attrs": {}, + "badge": undefined, + "href": "/", + "isCurrent": false, + "label": "Home Page", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/getting-started/", + "isCurrent": false, + "label": "Getting Started", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/guides/authoring-content/", + "isCurrent": false, + "label": "Authoring Markdown", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/guides/components/", + "isCurrent": false, + "label": "Components", + "type": "link", + }, + ], + "label": "guides", + "type": "group", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/reference/frontmatter/", + "isCurrent": false, + "label": "Frontmatter Reference", + "type": "link", + }, + ], + "label": "reference", + "type": "group", + }, ] `); }); @@ -116,98 +173,76 @@ test('uses provided sidebar if any', async () => { ...starlightPageProps, sidebar: [ { - type: 'link', label: 'Custom link 1', - href: '/test/1', - isCurrent: false, - badge: undefined, - attrs: {}, + link: '/test/1', + badge: 'New', }, { - type: 'link', label: 'Custom link 2', - href: '/test/2', - isCurrent: false, - badge: undefined, - attrs: {}, + link: '/test/2', }, - ], - }, - url: starlightPageUrl, - }); - expect(data.sidebar.map((entry) => entry.label)).toMatchInlineSnapshot(` - [ - "Custom link 1", - "Custom link 2", - ] - `); -}); - -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' }, - ], + label: 'Guides', + autogenerate: { directory: 'guides' }, }, + 'reference/frontmatter', ], }, url: starlightPageUrl, }); - assert(data.sidebar[0]!.type === 'group'); - expect(data.sidebar[0]!.entries.map((entry) => entry.label)).toMatchInlineSnapshot(` + expect(data.sidebar).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", + { + "attrs": {}, + "badge": { + "text": "New", + "variant": "default", + }, + "href": "/test/1", + "isCurrent": false, + "label": "Custom link 1", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/test/2", + "isCurrent": false, + "label": "Custom link 2", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/guides/authoring-content/", + "isCurrent": false, + "label": "Authoring Markdown", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/guides/components/", + "isCurrent": false, + "label": "Components", + "type": "link", + }, + ], + "label": "Guides", + "type": "group", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/reference/frontmatter", + "isCurrent": false, + "label": "Frontmatter Reference", + "type": "link", + }, ] `); }); @@ -221,34 +256,7 @@ test('throws error if sidebar is malformated', async () => { { label: 'Custom link 1', //@ts-expect-error Intentionally bad type to cause error. - href: 5, - }, - ], - }, - url: starlightPageUrl, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(` - "[AstroUserError]: - Invalid sidebar prop passed to the \`\` component. - Hint: - **0**: Did not match union. - > Expected type \`{ href: string } | { entries: array }\` - > Received \`{ "label": "Custom link 1", "href": 5 }\`" - `); -}); - -test('throws error if sidebar uses wrong literal for entry type', async () => { - // This test also makes sure we show a helpful error for incorrect literals. - expect(() => - generateStarlightPageRouteData({ - props: { - ...starlightPageProps, - sidebar: [ - { - //@ts-expect-error Intentionally bad type to cause error. - type: 'typo', - label: 'Custom link 1', - href: '/', + href: '/test/1', }, ], }, @@ -259,7 +267,8 @@ test('throws error if sidebar uses wrong literal for entry type', async () => { Invalid sidebar prop passed to the \`\` component. Hint: **0**: Did not match union. - > **0.type**: Expected \`"link" | "group"\`, received \`"typo"\`" + > Expected type \`{ link: string; } | { items: array; } | { autogenerate: object; } | { slug: string } | string\` + > Received \`{ "label": "Custom link 1", "href": "/test/1" }\`" `); }); diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts index d6a67a65b6e..cd1475d1522 100644 --- a/packages/starlight/utils/navigation.ts +++ b/packages/starlight/utils/navigation.ts @@ -15,6 +15,7 @@ import { pickLang } from './i18n'; import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingAndTrailingSlashes } from './path'; import { getLocaleRoutes, routes, type Route } from './routing'; import { localeToLang, slugToPathname } from './slugs'; +import type { StarlightConfig } from './user-config'; const DirKey = Symbol('DirKey'); const SlugKey = Symbol('SlugKey'); @@ -333,11 +334,20 @@ function sidebarFromDir( ); } -/** Get the sidebar for the current page. */ +/** Get the sidebar for the current page using the global config. */ export function getSidebar(pathname: string, locale: string | undefined): SidebarEntry[] { + return getSidebarFromConfig(config.sidebar, pathname, locale); +} + +/** Get the sidebar for the current page using the specified sidebar config. */ +export function getSidebarFromConfig( + sidebarConfig: StarlightConfig['sidebar'], + pathname: string, + locale: string | undefined +): SidebarEntry[] { const routes = getLocaleRoutes(locale); - if (config.sidebar) { - return config.sidebar.map((group) => configItemToEntry(group, pathname, locale, routes)); + if (sidebarConfig) { + return sidebarConfig.map((group) => configItemToEntry(group, pathname, locale, routes)); } else { const tree = treeify(routes, locale || ''); return sidebarFromDir(tree, pathname, locale, false); diff --git a/packages/starlight/utils/starlight-page.ts b/packages/starlight/utils/starlight-page.ts index d4310800828..2777431c923 100644 --- a/packages/starlight/utils/starlight-page.ts +++ b/packages/starlight/utils/starlight-page.ts @@ -12,11 +12,11 @@ import { } from './route-data'; import type { StarlightDocsEntry } from './routing'; import { slugToLocaleData, urlToSlug } from './slugs'; -import { getPrevNextLinks, getSidebar } from './navigation'; +import { getPrevNextLinks, getSidebarFromConfig } from './navigation'; import { useTranslations } from './translations'; import { docsSchema } from '../schema'; -import { BadgeConfigSchema } from '../schemas/badge'; -import { SidebarLinkItemHTMLAttributesSchema } from '../schemas/sidebar'; +import { SidebarItemSchema } from '../schemas/sidebar'; +import type { StarlightConfig, StarlightUserConfig } from './user-config'; /** * The frontmatter schema for Starlight pages derived from the default schema for Starlight’s @@ -64,88 +64,12 @@ 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'] => { +/** Parse sidebar prop to ensure it's valid. */ +const validateSidebarProp = ( + sidebarProp: StarlightUserConfig['sidebar'] +): StarlightConfig['sidebar'] => { return parseWithFriendlyErrors( - StarlightPageSidebarSchema, + SidebarItemSchema.array().optional(), sidebarProp, 'Invalid sidebar prop passed to the `` component.' ); @@ -159,7 +83,7 @@ export type StarlightPageProps = Prettify< Partial, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> & // Add the sidebar definitions for a Starlight page. Partial> & { - sidebar?: StarlightPageSidebarUserConfig; + sidebar?: StarlightUserConfig['sidebar']; // And finally add the Starlight page frontmatter properties in a `frontmatter` property. frontmatter: StarlightPageFrontmatter; } @@ -190,9 +114,11 @@ export async function generateStarlightPageRouteData({ const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter); const id = `${stripLeadingAndTrailingSlashes(slug)}.md`; const localeData = slugToLocaleData(slug); - const sidebar = props.sidebar - ? normalizeSidebarProp(props.sidebar) - : getSidebar(url.pathname, localeData.locale); + const sidebar = getSidebarFromConfig( + props.sidebar ? validateSidebarProp(props.sidebar) : config.sidebar, + url.pathname, + localeData.locale + ); const headings = props.headings ?? []; const pageDocsEntry: StarlightPageDocsEntry = { id,