Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve DX for sidebar prop in <StarlightPage> and document it #1534

Merged
merged 8 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example is really explicit! Nicely done!

I am left wondering whether I can still do things like autogenerate here, though. If so, it might be nice to show another grouping where that happens? If not, a line mentioning that this config should manually list out the entire contents of the sidebar as autogeneration of groups is not available, or something like that, would be helpful!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autogeneration is not supported here no — it’s really just what is shown! I’m actually torn about documenting that we don’t support it. Discussed a bit on Discord with @HiDeoo about the idea that it could be supported in theory. So I’d kind of like to avoid explicitly saying we don‘t as a fishing hook to see if we get feedback that people need/are trying to use it.


```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
Loading