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,