summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIan Durward2023-07-29 04:29:50 +0800
committerGitHub2023-07-28 22:29:50 +0200
commite73331133b0e2574a139409ba76d97cc1bd52a82 (patch)
treee38e068124be6d9a9a37baa76cd04881b50bcf82
parentd56f39fbef3ff662f2c3f1e5fb76d7fab1f37f7b (diff)
downloadIT.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.md13
-rw-r--r--docs/src/content/docs/reference/frontmatter.md21
-rw-r--r--packages/starlight/__tests__/basics/navigation-order.test.ts53
-rw-r--r--packages/starlight/__tests__/basics/routing.test.ts4
-rw-r--r--packages/starlight/__tests__/i18n/navigation-order.test.ts52
-rw-r--r--packages/starlight/__tests__/sidebar/navigation-order.test.ts67
-rw-r--r--packages/starlight/schema.ts12
-rw-r--r--packages/starlight/utils/navigation.ts75
-rw-r--r--packages/starlight/utils/routing.ts3
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();