Skip to content

Commit

Permalink
Improve DX for sidebar prop in <StarlightPage> and document it (#…
Browse files Browse the repository at this point in the history
…1534)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
delucis and sarah11918 committed Feb 20, 2024
1 parent fc83a05 commit aada680
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/wise-kiwis-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/starlight': patch
---

Improves DX of the `sidebar` prop used by the new `<StarlightPage>` component.
40 changes: 31 additions & 9 deletions docs/src/content/docs/guides/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test, vi } from 'vitest';
import { assert, expect, test, vi } from 'vitest';
import {
generateStarlightPageRouteData,
type StarlightPageProps,
Expand Down Expand Up @@ -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: {
Expand Down
99 changes: 97 additions & 2 deletions packages/starlight/utils/starlight-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,14 +58,105 @@ type StarlightPageFrontmatter = Omit<
'editUrl' | 'sidebar'
> & { 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;
}
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit aada680

Please sign in to comment.