diff options
author | Chris Swithinbank | 2024-02-20 19:41:08 +0100 |
---|---|---|
committer | GitHub | 2024-02-20 19:41:08 +0100 |
commit | aada6805abc0068f07393585b86978ef5200439c (patch) | |
tree | 1a897bef32eef16b51293b68b30184a1f1a76887 | |
parent | fc83a05235b74be2bfe6ba8e7f95a8a5a618ead3 (diff) | |
download | IT.starlight-aada6805abc0068f07393585b86978ef5200439c.tar.gz IT.starlight-aada6805abc0068f07393585b86978ef5200439c.tar.bz2 IT.starlight-aada6805abc0068f07393585b86978ef5200439c.zip |
Improve DX for `sidebar` prop in `<StarlightPage>` and document it (#1534)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
-rw-r--r-- | .changeset/wise-kiwis-sneeze.md | 5 | ||||
-rw-r--r-- | docs/src/content/docs/guides/pages.mdx | 40 | ||||
-rw-r--r-- | packages/starlight/__tests__/basics/starlight-page-route-data.test.ts | 92 | ||||
-rw-r--r-- | packages/starlight/utils/starlight-page.ts | 99 |
4 files changed, 224 insertions, 12 deletions
diff --git a/.changeset/wise-kiwis-sneeze.md b/.changeset/wise-kiwis-sneeze.md new file mode 100644 index 00000000..368aba87 --- /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 `<StarlightPage>` component. diff --git a/docs/src/content/docs/guides/pages.mdx b/docs/src/content/docs/guides/pages.mdx index 0daeef16..07db994d 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 `<StarlightPage />` 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 `<StarlightPage />` 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} +<StarlightPage + frontmatter={{ title: 'Orion' }} + sidebar={[ + { label: 'Home', href: '/' }, + { + label: 'Constellations', + items: [ + { label: 'Andromeda', href: '/andromeda/' }, + { label: 'Orion', href: '/orion/', isCurrent: true }, + { label: 'Ursa Minor', href: '/ursa-minor/', badge: 'Stub' }, + ], + }, + ]} +> + Example content. +</StarlightPage> +``` ##### `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 0484f610..064e261a 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 \`<StarlightPage/>\` 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 c08ce4fa..5bdb4e9d 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 @@ -57,13 +59,104 @@ type StarlightPageFrontmatter = Omit< > & { editUrl?: string | false }; /** + * Link configuration schema for `<StarlightPage>`. + * 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<typeof LinkGroupBase> & + // The original implementation of `<StarlightPage>` 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<z.input<typeof LinkSchema> | ManualLinkGroupInput>; + } + | { + /** + * @deprecated Use `items` instead of `entries`. + * Support for `entries` will be removed in a future version of Starlight. + */ + entries: Array<z.input<typeof LinkSchema> | ManualLinkGroupInput>; + } + ) +>; +type ManualLinkGroupOutput = z.output<typeof LinkGroupBase> & { + entries: Array<z.output<typeof LinkSchema> | ManualLinkGroupOutput>; + badge: z.output<typeof LinkGroupBase>['badge']; +}; +type LinkGroupSchemaType = z.ZodType<ManualLinkGroupOutput, z.ZodTypeDef, ManualLinkGroupInput>; +/** + * Link group configuration schema for `<StarlightPage>`. + * 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 `<StarlightPage>` */ +const StarlightPageSidebarSchema = z.union([LinkSchema, LinkGroupSchema]).array(); +type StarlightPageSidebarUserConfig = z.input<typeof StarlightPageSidebarSchema>; + +/** 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 `<StarlightPage/>` component.' + ); + } + return sidebar.data; +}; + +/** * The props accepted by the `<StarlightPage/>` component. */ export type StarlightPageProps = Prettify< // Remove the index signature from `Route`, omit undesired properties and make the rest optional. Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> & // Add the sidebar definitions for a Starlight page. - Partial<Pick<StarlightRouteData, 'hasSidebar' | 'sidebar'>> & { + Partial<Pick<StarlightRouteData, 'hasSidebar'>> & { + 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, |