diff options
author | Ian Durward | 2023-07-29 04:29:50 +0800 |
---|---|---|
committer | GitHub | 2023-07-28 22:29:50 +0200 |
commit | e73331133b0e2574a139409ba76d97cc1bd52a82 (patch) | |
tree | e38e068124be6d9a9a37baa76cd04881b50bcf82 | |
parent | d56f39fbef3ff662f2c3f1e5fb76d7fab1f37f7b (diff) | |
download | IT.starlight-e73331133b0e2574a139409ba76d97cc1bd52a82.tar.gz IT.starlight-e73331133b0e2574a139409ba76d97cc1bd52a82.tar.bz2 IT.starlight-e73331133b0e2574a139409ba76d97cc1bd52a82.zip |
Define the order of links in sidebar (#359)
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
-rw-r--r-- | .changeset/spicy-falcons-lick.md | 13 | ||||
-rw-r--r-- | docs/src/content/docs/reference/frontmatter.md | 21 | ||||
-rw-r--r-- | packages/starlight/__tests__/basics/navigation-order.test.ts | 53 | ||||
-rw-r--r-- | packages/starlight/__tests__/basics/routing.test.ts | 4 | ||||
-rw-r--r-- | packages/starlight/__tests__/i18n/navigation-order.test.ts | 52 | ||||
-rw-r--r-- | packages/starlight/__tests__/sidebar/navigation-order.test.ts | 67 | ||||
-rw-r--r-- | packages/starlight/schema.ts | 12 | ||||
-rw-r--r-- | packages/starlight/utils/navigation.ts | 75 | ||||
-rw-r--r-- | packages/starlight/utils/routing.ts | 3 |
9 files changed, 275 insertions, 25 deletions
diff --git a/.changeset/spicy-falcons-lick.md b/.changeset/spicy-falcons-lick.md new file mode 100644 index 00000000..da2569b3 --- /dev/null +++ b/.changeset/spicy-falcons-lick.md @@ -0,0 +1,13 @@ +--- +"@astrojs/starlight": minor +--- + +Add support for defining the order of auto-generated link groups in the sidebar using a frontmatter value: + +```md +--- +title: Page to display first +sidebar: + order: 1 +--- +``` diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index d850c1a5..f95910c6 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -188,3 +188,24 @@ Same as [`prev`](#prev) but for the next page link. next: false --- ``` + +### `sidebar` + +**type:** `{ order?: number }` + +Control how this page is displayed in the [sidebar](/reference/configuration/#sidebar), when using an autogenerated link group. + +#### `order` + +**type:** `number` + +Control the order of this page when sorting an autogenerated group of links. +Lower numbers are displayed higher up in the link group. + +```md +--- +title: Page to display first +sidebar: + order: 1 +--- +``` diff --git a/packages/starlight/__tests__/basics/navigation-order.test.ts b/packages/starlight/__tests__/basics/navigation-order.test.ts new file mode 100644 index 00000000..8b751230 --- /dev/null +++ b/packages/starlight/__tests__/basics/navigation-order.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from 'vitest'; +import { getSidebar } from '../../utils/navigation'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['index.mdx', { title: 'Home Page' }], + ['environmental-impact.md', { title: 'Eco-friendly docs', sidebar: { order: 1 } }], + ['guides/authoring-content.md', { title: 'Authoring Markdown' }], + ['guides/components.mdx', { title: 'Components', sidebar: { order: 0 } }], + ], + }) +); + +describe('getSidebar', () => { + test('returns sidebar entries sorted by frontmatter order', () => { + expect(getSidebar('/', undefined)).toMatchInlineSnapshot(` + [ + { + "collapsed": false, + "entries": [ + { + "href": "/guides/components/", + "isCurrent": false, + "label": "Components", + "type": "link", + }, + { + "href": "/guides/authoring-content/", + "isCurrent": false, + "label": "Authoring Markdown", + "type": "link", + }, + ], + "label": "guides", + "type": "group", + }, + { + "href": "/environmental-impact/", + "isCurrent": false, + "label": "Eco-friendly docs", + "type": "link", + }, + { + "href": "/", + "isCurrent": true, + "label": "Home Page", + "type": "link", + }, + ] + `); + }); +}); diff --git a/packages/starlight/__tests__/basics/routing.test.ts b/packages/starlight/__tests__/basics/routing.test.ts index d527d596..a33dd441 100644 --- a/packages/starlight/__tests__/basics/routing.test.ts +++ b/packages/starlight/__tests__/basics/routing.test.ts @@ -22,10 +22,6 @@ test('route slugs are normalized', () => { expect(indexRoute?.slug).toBe(''); }); -test('routes are sorted by slug', () => { - expect(routes[0]?.slug).toBe(''); -}); - test('routes contain copy of original doc as entry', async () => { const docs = await getCollection('docs'); for (const route of routes) { diff --git a/packages/starlight/__tests__/i18n/navigation-order.test.ts b/packages/starlight/__tests__/i18n/navigation-order.test.ts new file mode 100644 index 00000000..62fe4b34 --- /dev/null +++ b/packages/starlight/__tests__/i18n/navigation-order.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test, vi } from 'vitest'; +import { getSidebar } from '../../utils/navigation'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['fr/index.mdx', { title: 'Accueil' }], + // @ts-expect-error — Using a slug not present in Starlight docs site + ['en/index.mdx', { title: 'Home page', sidebar: { order: 1 } }], + // @ts-expect-error — Using a slug not present in Starlight docs site + ['ar/index.mdx', { title: 'الصفحة الرئيسية' }], + // @ts-expect-error — Using a slug not present in Starlight docs site + ['en/guides/authoring-content.md', { title: 'Authoring Markdown' }], + // @ts-expect-error — Using a slug not present in Starlight docs site + ['en/404.md', { title: 'Not found' }], + ], + }) +); + +describe('getSidebar', () => { + test('returns sidebar entries sorted by frontmatter order', () => { + expect(getSidebar('/en/', 'en')).toMatchInlineSnapshot(` + [ + { + "href": "/en/", + "isCurrent": true, + "label": "Home page", + "type": "link", + }, + { + "href": "/en/404/", + "isCurrent": false, + "label": "Not found", + "type": "link", + }, + { + "collapsed": false, + "entries": [ + { + "href": "/en/guides/authoring-content/", + "isCurrent": false, + "label": "Authoring Markdown", + "type": "link", + }, + ], + "label": "guides", + "type": "group", + }, + ] + `); + }); +}); diff --git a/packages/starlight/__tests__/sidebar/navigation-order.test.ts b/packages/starlight/__tests__/sidebar/navigation-order.test.ts new file mode 100644 index 00000000..9f003c67 --- /dev/null +++ b/packages/starlight/__tests__/sidebar/navigation-order.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test, vi } from 'vitest'; +import { getSidebar } from '../../utils/navigation'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['index.mdx', { title: 'Home Page' }], + ['environmental-impact.md', { title: 'Eco-friendly docs' }], + ['reference/configuration.md', { title: 'Config Reference' }], + ['reference/frontmatter.md', { title: 'Frontmatter Reference', sidebar: { order: 1 } }], + ['guides/components.mdx', { title: 'Components' }], + ], + }) +); + +describe('getSidebar', () => { + test('returns sidebar entries sorted by frontmatter order', () => { + expect(getSidebar('/', undefined)).toMatchInlineSnapshot(` + [ + { + "href": "/", + "isCurrent": true, + "label": "Home", + "type": "link", + }, + { + "collapsed": false, + "entries": [ + { + "href": "/intro/", + "isCurrent": false, + "label": "Introduction", + "type": "link", + }, + { + "href": "/next-steps/", + "isCurrent": false, + "label": "Next Steps", + "type": "link", + }, + ], + "label": "Start Here", + "type": "group", + }, + { + "collapsed": false, + "entries": [ + { + "href": "/reference/frontmatter/", + "isCurrent": false, + "label": "Frontmatter Reference", + "type": "link", + }, + { + "href": "/reference/configuration/", + "isCurrent": false, + "label": "Config Reference", + "type": "link", + }, + ], + "label": "Reference", + "type": "group", + }, + ] + `); + }); +}); diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index b3c5657a..112543cd 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -126,5 +126,17 @@ export function docsSchema() { * Overrides the `pagination` global config or the link text and/or URL. */ next: PrevNextLinkConfigSchema(), + + sidebar: z + .object({ + /** + * The order of this page in the navigation. + * Pages are sorted by this value in ascending order. Then by slug. + * If not provided, pages will be sorted alphabetically by slug. + * If two pages have the same order value, they will be sorted alphabetically by slug. + */ + order: z.number().optional(), + }) + .default({}), }); } diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts index aa6cea4a..cfc806b8 100644 --- a/packages/starlight/utils/navigation.ts +++ b/packages/starlight/utils/navigation.ts @@ -1,11 +1,13 @@ import { basename, dirname } from 'node:path'; import config from 'virtual:starlight/user-config'; +import type { PrevNextLinkConfig } from '../schemas/prevNextLink'; import { pathWithBase } from './base'; import { pickLang } from './i18n'; -import { type Route, getLocaleRoutes, routes } from './routing'; +import { getLocaleRoutes, type Route } from './routing'; import { localeToLang, slugToPathname } from './slugs'; import type { AutoSidebarGroup, SidebarItem, SidebarLinkItem } from './user-config'; -import type { PrevNextLinkConfig } from '../schemas/prevNextLink'; + +const DirKey = Symbol('DirKey'); export interface Link { type: 'link'; @@ -27,10 +29,24 @@ export type SidebarEntry = Link | Group; * A representation of the route structure. For each object entry: * if it’s a folder, the key is the directory name, and value is the directory * content; if it’s a route entry, the key is the last segment of the route, and value - * is the entry’s full slug. + * is the full entry. */ interface Dir { - [item: string]: Dir | string; + [DirKey]: undefined; + [item: string]: Dir | Route; +} + +/** Create a new directory object. */ +function makeDir(): Dir { + const dir = {} as Dir; + // Add DirKey as a non-enumerable property so that `Object.entries(dir)` ignores it. + Object.defineProperty(dir, DirKey, { enumerable: false }); + return dir; +} + +/** Test if the passed object is a directory record. */ +function isDir(data: Record<string, unknown>): data is Dir { + return DirKey in data; } /** Convert an item in a user’s sidebar config to a sidebar entry. */ @@ -132,7 +148,7 @@ function getBreadcrumbs(path: string, baseDir: string): string[] { /** Turn a flat array of routes into a tree structure. */ function treeify(routes: Route[], baseDir: string): Dir { - const treeRoot: Dir = {}; + const treeRoot: Dir = makeDir(); routes.forEach((doc) => { const breadcrumbs = getBreadcrumbs(doc.id, baseDir); @@ -140,20 +156,41 @@ function treeify(routes: Route[], baseDir: string): Dir { let currentDir = treeRoot; breadcrumbs.forEach((dir) => { // Create new folder if needed. - if (typeof currentDir[dir] === 'undefined') currentDir[dir] = {}; + if (typeof currentDir[dir] === 'undefined') currentDir[dir] = makeDir(); // Go into the subdirectory. currentDir = currentDir[dir] as Dir; }); // We’ve walked through the path. Register the route in this directory. - currentDir[basename(doc.slug)] = doc.slug; + currentDir[basename(doc.slug)] = doc; }); return treeRoot; } /** Create a link entry for a given content collection entry. */ -function linkFromSlug(slug: string, currentPathname: string): Link { - const doc = routes.find((doc) => doc.slug === slug)!; - return makeLink(slugToPathname(doc.slug), doc.entry.data.title, currentPathname); +function linkFromRoute(route: Route, currentPathname: string): Link { + return makeLink(slugToPathname(route.slug), route.entry.data.title, currentPathname); +} + +/** + * Get the sort weight for a given route or directory. Lower numbers rank higher. + * Directories have the weight of the lowest weighted route they contain. + */ +function getOrder(routeOrDir: Route | Dir): number { + return isDir(routeOrDir) + ? Math.min(...Object.values(routeOrDir).flatMap(getOrder)) + : // If no order value is found, set it to the largest number possible. + routeOrDir.entry.data.sidebar.order ?? Number.MAX_VALUE; +} + +/** Sort a directory’s entries by user-specified order or alphabetically if no order specified. */ +function sortDirEntries(dir: [string, Dir | Route][]): [string, Dir | Route][] { + return dir.sort(([, a], [, b]) => { + const [aOrder, bOrder] = [getOrder(a), getOrder(b)]; + // Pages are sorted by order in ascending order. + if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1; + // If two pages have the same order value they will be sorted by their slug. + return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0; + }); } /** Create a group entry for a given content collection directory. */ @@ -165,8 +202,8 @@ function groupFromDir( locale: string | undefined, collapsed: boolean ): Group { - const entries = Object.entries(dir).map(([key, dirOrSlug]) => - dirToItem(dirOrSlug, `${fullPath}/${key}`, key, currentPathname, locale, collapsed) + const entries = sortDirEntries(Object.entries(dir)).map(([key, dirOrRoute]) => + dirToItem(dirOrRoute, `${fullPath}/${key}`, key, currentPathname, locale, collapsed) ); return { type: 'group', @@ -176,18 +213,18 @@ function groupFromDir( }; } -/** Create a sidebar entry for a directory or content slug. */ +/** Create a sidebar entry for a directory or content entry. */ function dirToItem( - dirOrSlug: Dir[string], + dirOrRoute: Dir[string], fullPath: string, dirName: string, currentPathname: string, locale: string | undefined, collapsed: boolean ): SidebarEntry { - return typeof dirOrSlug === 'string' - ? linkFromSlug(dirOrSlug, currentPathname) - : groupFromDir(dirOrSlug, fullPath, dirName, currentPathname, locale, collapsed); + return isDir(dirOrRoute) + ? groupFromDir(dirOrRoute, fullPath, dirName, currentPathname, locale, collapsed) + : linkFromRoute(dirOrRoute, currentPathname); } /** Create a sidebar entry for a given content directory. */ @@ -197,8 +234,8 @@ function sidebarFromDir( locale: string | undefined, collapsed: boolean ) { - return Object.entries(tree).map(([key, dirOrSlug]) => - dirToItem(dirOrSlug, key, key, currentPathname, locale, collapsed) + return sortDirEntries(Object.entries(tree)).map(([key, dirOrRoute]) => + dirToItem(dirOrRoute, key, key, currentPathname, locale, collapsed) ); } diff --git a/packages/starlight/utils/routing.ts b/packages/starlight/utils/routing.ts index fb181408..56bd7c84 100644 --- a/packages/starlight/utils/routing.ts +++ b/packages/starlight/utils/routing.ts @@ -80,8 +80,7 @@ function getRoutes(): Route[] { } } - // Sort alphabetically by page slug to guarantee order regardless of platform. - return routes.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0)); + return routes; } export const routes = getRoutes(); |