From eeba06ea7df962e8f0520e145d28b8c17cd32c18 Mon Sep 17 00:00:00 2001 From: Lorenzo Lewis Date: Fri, 5 Jul 2024 12:57:23 +0200 Subject: Feat: Autogenerate sidebar labels (#1874) Co-authored-by: Chris Swithinbank --- .changeset/seven-owls-taste.md | 19 ++ docs/astro.config.mjs | 44 +--- docs/src/components/sidebar-preview.astro | 8 +- docs/src/content/docs/guides/sidebar.mdx | 137 +++++++++--- docs/src/content/docs/reference/configuration.mdx | 69 ++++-- examples/basics/astro.config.mjs | 2 +- examples/tailwind/astro.config.mjs | 2 +- .../__tests__/basics/config-errors.test.ts | 4 +- .../i18n-sidebar-fallback-slug.test.ts | 159 +++++++++++++ .../__tests__/i18n-sidebar/i18n-sidebar.test.ts | 246 +++++++++++++++++++++ .../sidebar-internal-link-error.test.ts | 33 +++ .../__tests__/i18n-sidebar/vitest.config.ts | 20 ++ .../sidebar-slug-error/sidebar-slug-error.test.ts | 19 ++ .../__tests__/sidebar-slug-error/vitest.config.ts | 6 + packages/starlight/schemas/sidebar.ts | 27 ++- packages/starlight/utils/error-map.ts | 7 +- packages/starlight/utils/navigation.ts | 42 +++- 17 files changed, 742 insertions(+), 102 deletions(-) create mode 100644 .changeset/seven-owls-taste.md create mode 100644 packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts create mode 100644 packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts create mode 100644 packages/starlight/__tests__/i18n-sidebar/sidebar-internal-link-error.test.ts create mode 100644 packages/starlight/__tests__/i18n-sidebar/vitest.config.ts create mode 100644 packages/starlight/__tests__/sidebar-slug-error/sidebar-slug-error.test.ts create mode 100644 packages/starlight/__tests__/sidebar-slug-error/vitest.config.ts diff --git a/.changeset/seven-owls-taste.md b/.changeset/seven-owls-taste.md new file mode 100644 index 00000000..ce108075 --- /dev/null +++ b/.changeset/seven-owls-taste.md @@ -0,0 +1,19 @@ +--- +'@astrojs/starlight': patch +--- + +Adds a new syntax for specifying sidebar link items for internal links + +You can now specify an internal page using only its slug, either as a string, or as an object with a `slug` property: + +```js +starlight({ + title: 'Docs with easier sidebars', + sidebar: [ + 'getting-started', + { slug: 'guides/installation' }, + ], +}) +``` + +Starlight will use the linked page’s frontmatter to configure the sidebar link. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index a94376ee..5e8f0ba0 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -87,49 +87,11 @@ export default defineConfig({ uk: 'Почніть звідси', }, items: [ - { - label: 'Getting Started', - link: 'getting-started', - translations: { - de: 'Erste Schritte', - es: 'Empezando', - ja: '入門', - fr: 'Mise en route', - it: 'Iniziamo', - id: 'Memulai', - 'zh-CN': '开始使用', - 'pt-BR': 'Introdução', - 'pt-PT': 'Introdução', - ko: '시작하기', - tr: 'Başlarken', - ru: 'Введение', - hi: 'पहले कदम', - uk: 'Вступ', - }, - }, - { - label: 'Manual Setup', - link: 'manual-setup', - translations: { - de: 'Manuelle Einrichtung', - es: 'Configuración Manual', - ja: '手動セットアップ', - fr: 'Installation manuelle', - // it: 'Manual Setup', - id: 'Instalasi Manual', - 'zh-CN': '手动配置', - 'pt-BR': 'Instalação Manual', - 'pt-PT': 'Instalação Manual', - ko: '수동으로 설정하기', - tr: 'Elle Kurulum', - ru: 'Установка вручную', - hi: 'मैनुअल सेटअप', - uk: 'Ручне встановлення', - }, - }, + 'getting-started', + 'manual-setup', { label: 'Environmental Impact', - link: 'environmental-impact', + slug: 'environmental-impact', translations: { de: 'Umweltbelastung', es: 'Documentación ecológica', diff --git a/docs/src/components/sidebar-preview.astro b/docs/src/components/sidebar-preview.astro index f392972a..810ac1cc 100644 --- a/docs/src/components/sidebar-preview.astro +++ b/docs/src/components/sidebar-preview.astro @@ -1,5 +1,9 @@ --- -import type { AutoSidebarGroup, SidebarItem } from '../../../packages/starlight/schemas/sidebar'; +import type { + AutoSidebarGroup, + SidebarItem, + InternalSidebarLinkItem, +} from '../../../packages/starlight/schemas/sidebar'; import SidebarSublist from '../../../packages/starlight/components/SidebarSublist.astro'; import type { SidebarEntry } from '../../../packages/starlight/utils/navigation'; @@ -7,7 +11,7 @@ interface Props { config: SidebarConfig; } -type SidebarConfig = Exclude[]; +type SidebarConfig = Exclude[]; const { config } = Astro.props; diff --git a/docs/src/content/docs/guides/sidebar.mdx b/docs/src/content/docs/guides/sidebar.mdx index 0632b537..8e368b15 100644 --- a/docs/src/content/docs/guides/sidebar.mdx +++ b/docs/src/content/docs/guides/sidebar.mdx @@ -49,19 +49,73 @@ Learn more about autogenerated sidebars in the [autogenerated groups](#autogener ## Add links and link groups -To configure your sidebar [links](#links) and [groups of links](#groups) (within a collapsible header), use the [`starlight.sidebar`](/reference/configuration/#sidebar) property in `astro.config.mjs`. +To configure your sidebar links and groups of links (within a collapsible header), use the [`starlight.sidebar`](/reference/configuration/#sidebar) property in `astro.config.mjs`. By combining links and groups, you can create a wide variety of sidebar layouts. -### Links +### Internal links -Add a link to an internal or external page using an object with `label` and `link` properties. +Add a link to a page in `src/content/docs/` using an object with the `slug` property. +The linked page’s title will be used as the label by default. + +For example, with the following configuration: + +```js "slug:" +starlight({ + sidebar: [ + { slug: 'constellations/andromeda' }, + { slug: 'constellations/orion' }, + ], +}); +``` + +And the following file structure: + + + +- src/ + - content/ + - docs/ + - constellations/ + - andromeda.md + - orion.md + + + +The following sidebar will be generated: + + + +To override the values inferred from a linked page’s frontmatter, you can add `label`, [`translations`](#internationalization), and [`attrs`](#custom-html-attributes) properties. + +See [“Customizing autogenerated links”](#customizing-autogenerated-links-in-frontmatter) for more details about controlling the sidebar appearance from page frontmatter. + +#### Shorthand for internal links + +Internal links can also be specified by providing only a string for the page slug as a shorthand. + +For example, the following configuration is equivalent to the configuration above, which used `slug`: + +```js "slug:" +starlight({ + sidebar: ['constellations/andromeda', 'constellations/orion'], +}); +``` + +### Other links + +Add a link to an external or non-docs page using an object with `label` and `link` properties. ```js "label:" "link:" starlight({ sidebar: [ - // A link to the Ganymede moon page. - { label: 'Ganymede', link: '/moons/ganymede/' }, + // A link to a non-docs page on this site. + { label: 'Meteor Store', link: '/shop/' }, // An external link to the NASA website. { label: 'NASA', link: 'https://www.nasa.gov/' }, ], @@ -72,7 +126,7 @@ The configuration above generates the following sidebar: @@ -93,15 +147,15 @@ starlight({ { label: 'Constellations', items: [ - { label: 'Carina', link: '/constellations/carina/' }, - { label: 'Centaurus', link: '/constellations/centaurus/' }, + 'constellations/carina', + 'constellations/centaurus', // A nested group of links for seasonal constellations. { label: 'Seasonal', items: [ - { label: 'Andromeda', link: '/constellations/andromeda/' }, - { label: 'Orion', link: '/constellations/orion/' }, - { label: 'Ursa Minor', link: '/constellations/ursa-minor/' }, + 'constellations/andromeda', + 'constellations/orion', + 'constellations/ursa-minor', ], }, ], @@ -186,7 +240,7 @@ The following sidebar will be generated: ]} /> -#### Customizing autogenerated links in frontmatter +## Customizing autogenerated links in frontmatter Use the [`sidebar` frontmatter field](/reference/frontmatter/#sidebar) in individual pages to customize autogenerated links. @@ -228,14 +282,14 @@ An autogenerated group including a page with the frontmatter above will generate /> :::note -The `sidebar` frontmatter configuration is only used for autogenerated links and will be ignored for manually defined links. +The `sidebar` frontmatter configuration is only used for links in autogenerated groups and docs links defined with the `slug` property. It does not apply to links defined with the `link` property. ::: ## Badges Links, groups, and autogenerated groups can also include a `badge` property to display a badge next to their label. -```js {10,17} +```js {9,16} starlight({ sidebar: [ { @@ -243,8 +297,7 @@ starlight({ items: [ // A link with a "Supergiant" badge. { - label: 'Persei', - link: '/stars/persei/', + slug: 'stars/persei', badge: 'Supergiant', }, ], @@ -303,7 +356,7 @@ By default, the badge will use the accent color of your site. To use a built-in Optionally, you can create a custom badge style by setting the `class` property to a CSS class name. -```js {10} +```js {9} starlight({ sidebar: [ { @@ -311,8 +364,7 @@ starlight({ items: [ // A link with a yellow "Stub" badge. { - label: 'Sirius', - link: '/stars/sirius/', + slug: 'stars/sirius', badge: { text: 'Stub', variant: 'caution' }, }, ], @@ -401,14 +453,14 @@ starlight({ translations: { 'pt-BR': 'Andrômeda', }, - link: '/constellations/andromeda/', + slug: 'constellations/andromeda', }, { label: 'Scorpius', translations: { 'pt-BR': 'Escorpião', }, - link: '/constellations/scorpius/', + slug: 'constellations/scorpius', }, ], }, @@ -430,6 +482,44 @@ Browsing the documentation in Brazilian Portuguese will generate the following s ]} /> +### Internationalization with internal links + +[Internal links](#internal-links) will automatically use translated page titles from content frontmatter by default: + +```js {9-10} +starlight({ + sidebar: [ + { + label: 'Constellations', + translations: { + 'pt-BR': 'Constelações', + }, + items: [ + { slug: 'constellations/andromeda' }, + { slug: 'constellations/scorpius' }, + ], + }, + ], +}); +``` + +Browsing the documentation in Brazilian Portuguese will generate the following sidebar: + + + +In multilingual sites, the value of `slug` does not include the language portion of the URL. +For example, if you have pages at `en/intro` and `pt-br/intro`, the slug is `intro` when configuring the sidebar. + ## Collapsing groups Groups of links can be collapsed by default by setting the `collapsed` property to `true`. @@ -441,10 +531,7 @@ starlight({ label: 'Constellations', // Collapse the group by default. collapsed: true, - items: [ - { label: 'Andromeda', link: '/constellations/andromeda/' }, - { label: 'Orion', link: '/constellations/orion/' }, - ], + items: ['constellations/andromeda', 'constellations/orion'], }, ], }); diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 40841629..8b97b0d3 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -100,25 +100,33 @@ With this config, a `/introduction` page would have an edit link pointing to `ht Configure your site’s sidebar navigation items. A sidebar is an array of links and groups of links. -Each item must have a `label` and one of the following properties: +With the exception of items using `slug`, each item must have a `label` and one of the following properties: - `link` — a single link to a specific URL, e.g. `'/home'` or `'https://example.com'`. +- `slug` — a reference to an internal page, e.g. `'guides/getting-started'`. + - `items` — an array containing more sidebar links and subgroups. - `autogenerate` — an object specifying a directory of your docs to automatically generate a group of links from. +Internal links can also be specified as a string instead of an object with a `slug` property. + ```js starlight({ sidebar: [ // A single link item labelled “Home”. { label: 'Home', link: '/' }, - // A group labelled “Start Here” containing two links. + // A group labelled “Start Here” containing four links. { label: 'Start Here', items: [ - { label: 'Introduction', link: '/intro' }, - { label: 'Next Steps', link: '/next-steps' }, + // Using `slug` for internal links. + { slug: 'intro' }, + { slug: 'installation' }, + // Or using the shorthand for internal links. + 'tutorial', + 'next-steps', ], }, // A group linking to all pages in the reference directory. @@ -141,16 +149,13 @@ Groups of links are expanded by default. You can change this behavior by setting Autogenerated subgroups respect the `collapsed` property of their parent group by default. Set the `autogenerate.collapsed` property to override this. -```js {5,16} +```js {5,13} sidebar: [ // A collapsed group of links. { label: 'Collapsed Links', collapsed: true, - items: [ - { label: 'Introduction', link: '/intro' }, - { label: 'Next Steps', link: '/next-steps' }, - ], + items: ['intro', 'next-steps'], }, // An expanded group containing collapsed autogenerated subgroups. { @@ -192,21 +197,37 @@ sidebar: [ #### `SidebarItem` ```ts -type SidebarItem = { - label: string; - translations?: Record; - badge?: string | BadgeConfig; -} & ( - | { - link: string; - attrs?: Record; - } - | { items: SidebarItem[]; collapsed?: boolean } - | { - autogenerate: { directory: string; collapsed?: boolean }; - collapsed?: boolean; - } -); +type SidebarItem = + | string + | ({ + translations?: Record; + badge?: string | BadgeConfig; + } & ( + | { + // Link + link: string; + label: string; + attrs?: Record; + } + | { + // Internal link + slug: string; + label?: string; + attrs?: Record; + } + | { + // Group of links + label: string; + items: SidebarItem[]; + collapsed?: boolean; + } + | { + // Autogenerated link group + label: string; + autogenerate: { directory: string; collapsed?: boolean }; + collapsed?: boolean; + } + )); ``` #### `BadgeConfig` diff --git a/examples/basics/astro.config.mjs b/examples/basics/astro.config.mjs index 9eacfd17..78078c32 100644 --- a/examples/basics/astro.config.mjs +++ b/examples/basics/astro.config.mjs @@ -14,7 +14,7 @@ export default defineConfig({ label: 'Guides', items: [ // Each item here is one entry in the navigation menu. - { label: 'Example Guide', link: '/guides/example/' }, + { label: 'Example Guide', slug: 'guides/example' }, ], }, { diff --git a/examples/tailwind/astro.config.mjs b/examples/tailwind/astro.config.mjs index 99448763..2a81ef78 100644 --- a/examples/tailwind/astro.config.mjs +++ b/examples/tailwind/astro.config.mjs @@ -15,7 +15,7 @@ export default defineConfig({ label: 'Guides', items: [ // Each item here is one entry in the navigation menu. - { label: 'Example Guide', link: '/guides/example/' }, + { label: 'Example Guide', slug: 'guides/example' }, ], }, { diff --git a/packages/starlight/__tests__/basics/config-errors.test.ts b/packages/starlight/__tests__/basics/config-errors.test.ts index 1639bd72..d4eeebc8 100644 --- a/packages/starlight/__tests__/basics/config-errors.test.ts +++ b/packages/starlight/__tests__/basics/config-errors.test.ts @@ -165,7 +165,7 @@ test('errors with bad sidebar config', () => { Invalid config passed to starlight integration Hint: **sidebar.0**: Did not match union. - > Expected type \`{ link: string; } | { items: array; } | { autogenerate: object; }\` + > Expected type \`{ link: string; } | { items: array; } | { autogenerate: object; } | { slug: string } | string\` > Received \`{ "label": "Example", "href": "/" }\`" ` ); @@ -190,7 +190,7 @@ test('errors with bad nested sidebar config', () => { Invalid config passed to starlight integration Hint: **sidebar.0.items.1**: Did not match union. - > Expected type \`{ link: string } | { items: array; } | { autogenerate: object; }\` + > Expected type \`{ link: string } | { items: array; } | { autogenerate: object; } | { slug: string } | string\` > Received \`{ "label": "Example", "items": [ { "label": "Nested Example 1", "link": "/" }, { "label": "Nested Example 2", "link": true } ] }\`" `); }); diff --git a/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts new file mode 100644 index 00000000..e67e558e --- /dev/null +++ b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts @@ -0,0 +1,159 @@ +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: 'Starlight 🌟 Build documentation sites with Astro' }], + ['getting-started.mdx', { title: 'Getting Started' }], + ['manual-setup.mdx', { title: 'Manual Setup' }], + ['environmental-impact.md', { title: 'Eco-friendly docs' }], + ['guides/pages.mdx', { title: 'Pages' }], + ['guides/authoring-content.md', { title: 'Authoring Content in Markdown' }], + ['resources/plugins.mdx', { title: 'Plugins and Integrations' }], + ], + }) +); + +describe('getSidebar', () => { + test('returns an array of sidebar entries', () => { + expect(getSidebar('/', undefined)).toMatchInlineSnapshot(` + [ + { + "attrs": {}, + "badge": undefined, + "href": "/", + "isCurrent": true, + "label": "Starlight 🌟 Build documentation sites with Astro", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/getting-started", + "isCurrent": false, + "label": "Getting Started", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/manual-setup", + "isCurrent": false, + "label": "Do it yourself", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/environmental-impact", + "isCurrent": false, + "label": "Eco-friendly docs", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/guides/pages", + "isCurrent": false, + "label": "Pages", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/guides/authoring-content", + "isCurrent": false, + "label": "Authoring Content in Markdown", + "type": "link", + }, + ], + "label": "Guides", + "type": "group", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/resources/plugins", + "isCurrent": false, + "label": "Plugins and Integrations", + "type": "link", + }, + ] + `); + }); + test('uses fallback labels from the default locale', () => { + expect(getSidebar('/fr', 'fr')).toMatchInlineSnapshot(` + [ + { + "attrs": {}, + "badge": undefined, + "href": "/fr", + "isCurrent": true, + "label": "Starlight 🌟 Build documentation sites with Astro", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/getting-started", + "isCurrent": false, + "label": "Getting Started", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/manual-setup", + "isCurrent": false, + "label": "Fait maison", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/environmental-impact", + "isCurrent": false, + "label": "Eco-friendly docs", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/fr/guides/pages", + "isCurrent": false, + "label": "Pages", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/guides/authoring-content", + "isCurrent": false, + "label": "Authoring Content in Markdown", + "type": "link", + }, + ], + "label": "Guides", + "type": "group", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/resources/plugins", + "isCurrent": false, + "label": "Plugins and Integrations", + "type": "link", + }, + ] + `); + }); +}); diff --git a/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts new file mode 100644 index 00000000..dad2b413 --- /dev/null +++ b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts @@ -0,0 +1,246 @@ +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: 'Starlight 🌟 Build documentation sites with Astro' }], + ['fr/index.mdx', { title: 'Starlight 🌟 Construire des sites de documentation avec Astro' }], + ['getting-started.mdx', { title: 'Getting Started' }], + ['fr/getting-started.mdx', { title: 'Mise en route' }], + ['manual-setup.mdx', { title: 'Manual Setup' }], + ['fr/manual-setup.mdx', { title: 'Installation manuelle' }], + ['environmental-impact.md', { title: 'Eco-friendly docs' }], + ['fr/environmental-impact.md', { title: 'Documents écologiques' }], + ['guides/pages.mdx', { title: 'Pages' }], + ['fr/guides/pages.mdx', { title: 'Pages' }], + ['guides/authoring-content.md', { title: 'Authoring Content in Markdown' }], + ['fr/guides/authoring-content.md', { title: 'Création de contenu en Markdown' }], + ['resources/plugins.mdx', { title: 'Plugins and Integrations' }], + ['fr/resources/plugins.mdx', { title: "Modules d'extension et outils" }], + ], + }) +); + +describe('getSidebar', () => { + test('returns an array of sidebar entries', () => { + expect(getSidebar('/', undefined)).toMatchInlineSnapshot(` + [ + { + "attrs": {}, + "badge": undefined, + "href": "/", + "isCurrent": true, + "label": "Starlight 🌟 Build documentation sites with Astro", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/getting-started", + "isCurrent": false, + "label": "Getting Started", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/manual-setup", + "isCurrent": false, + "label": "Do it yourself", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/environmental-impact", + "isCurrent": false, + "label": "Eco-friendly docs", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/guides/pages", + "isCurrent": false, + "label": "Pages", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/guides/authoring-content", + "isCurrent": false, + "label": "Authoring Content in Markdown", + "type": "link", + }, + ], + "label": "Guides", + "type": "group", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/resources/plugins", + "isCurrent": false, + "label": "Plugins and Integrations", + "type": "link", + }, + ] + `); + }); + test('returns an array of sidebar entries for a locale', () => { + expect(getSidebar('/fr', 'fr')).toMatchInlineSnapshot(` + [ + { + "attrs": {}, + "badge": undefined, + "href": "/fr", + "isCurrent": true, + "label": "Starlight 🌟 Construire des sites de documentation avec Astro", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/getting-started", + "isCurrent": false, + "label": "Mise en route", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/manual-setup", + "isCurrent": false, + "label": "Fait maison", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/environmental-impact", + "isCurrent": false, + "label": "Documents écologiques", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/fr/guides/pages", + "isCurrent": false, + "label": "Pages", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/guides/authoring-content", + "isCurrent": false, + "label": "Création de contenu en Markdown", + "type": "link", + }, + ], + "label": "Guides", + "type": "group", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/resources/plugins", + "isCurrent": false, + "label": "Modules d'extension et outils", + "type": "link", + }, + ] + `); + }); + test('returns an array of sidebar entries for a locale on current page', () => { + expect(getSidebar('/fr/getting-started', 'fr')).toMatchInlineSnapshot(` + [ + { + "attrs": {}, + "badge": undefined, + "href": "/fr", + "isCurrent": false, + "label": "Starlight 🌟 Construire des sites de documentation avec Astro", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/getting-started", + "isCurrent": true, + "label": "Mise en route", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/manual-setup", + "isCurrent": false, + "label": "Fait maison", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/environmental-impact", + "isCurrent": false, + "label": "Documents écologiques", + "type": "link", + }, + { + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": {}, + "badge": undefined, + "href": "/fr/guides/pages", + "isCurrent": false, + "label": "Pages", + "type": "link", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/guides/authoring-content", + "isCurrent": false, + "label": "Création de contenu en Markdown", + "type": "link", + }, + ], + "label": "Guides", + "type": "group", + }, + { + "attrs": {}, + "badge": undefined, + "href": "/fr/resources/plugins", + "isCurrent": false, + "label": "Modules d'extension et outils", + "type": "link", + }, + ] + `); + }); + test('uses label from config for internal links', () => { + const sidebar = getSidebar('/', undefined); + const entry = sidebar.find((item) => item.type === 'link' && item.href === '/manual-setup'); + expect(entry?.label).toBe('Do it yourself'); + }); + test('uses translation from config for internal links', () => { + const sidebar = getSidebar('/fr', 'fr'); + const entry = sidebar.find((item) => item.type === 'link' && item.href === '/fr/manual-setup'); + expect(entry?.label).toBe('Fait maison'); + }); +}); diff --git a/packages/starlight/__tests__/i18n-sidebar/sidebar-internal-link-error.test.ts b/packages/starlight/__tests__/i18n-sidebar/sidebar-internal-link-error.test.ts new file mode 100644 index 00000000..9489830b --- /dev/null +++ b/packages/starlight/__tests__/i18n-sidebar/sidebar-internal-link-error.test.ts @@ -0,0 +1,33 @@ +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: 'Starlight 🌟 Build documentation sites with Astro' }], + ['fr/index.mdx', { title: 'Starlight 🌟 Construire des sites de documentation avec Astro' }], + ['manual-setup.mdx', { title: 'Manual Setup' }], + ['fr/manual-setup.mdx', { title: 'Installation manuelle' }], + ['environmental-impact.md', { title: 'Eco-friendly docs' }], + ['fr/environmental-impact.md', { title: 'Documents écologiques' }], + ['guides/pages.mdx', { title: 'Pages' }], + ['fr/guides/pages.mdx', { title: 'Pages' }], + ['guides/authoring-content.md', { title: 'Authoring Content in Markdown' }], + ['fr/guides/authoring-content.md', { title: 'Création de contenu en Markdown' }], + ['resources/plugins.mdx', { title: 'Plugins and Integrations' }], + ['fr/resources/plugins.mdx', { title: "Modules d'extension et outils" }], + ], + }) +); + +describe('getSidebar', () => { + test('throws an error if slug doesn’t match a content collection entry', () => { + expect(() => getSidebar('/', undefined)).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The slug \`"getting-started"\` specified in the Starlight sidebar config does not exist. + Hint: + Update the Starlight config to reference a valid entry slug in the docs content collection. + Learn more about Astro content collection slugs at https://docs.astro.build/en/reference/api-reference/#getentry" + `); + }); +}); diff --git a/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts b/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts new file mode 100644 index 00000000..59f3bdf1 --- /dev/null +++ b/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineVitestConfig } from '../test-config'; + +export default defineVitestConfig({ + title: 'i18n sidebar', + locales: { + fr: { label: 'French' }, + root: { label: 'English', lang: 'en-US' }, + }, + sidebar: [ + { slug: 'index' }, + 'getting-started', + { slug: 'manual-setup', label: 'Do it yourself', translations: { fr: 'Fait maison' } }, + { slug: 'environmental-impact' }, + { + label: 'Guides', + items: [{ slug: 'guides/pages' }, { slug: 'guides/authoring-content' }], + }, + 'resources/plugins', + ], +}); diff --git a/packages/starlight/__tests__/sidebar-slug-error/sidebar-slug-error.test.ts b/packages/starlight/__tests__/sidebar-slug-error/sidebar-slug-error.test.ts new file mode 100644 index 00000000..72d79291 --- /dev/null +++ b/packages/starlight/__tests__/sidebar-slug-error/sidebar-slug-error.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test, vi } from 'vitest'; +import { getSidebar } from '../../utils/navigation'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [['getting-started.mdx', { title: 'Getting Started' }]], + }) +); + +describe('getSidebar', () => { + test('throws an error if slug doesn’t match a content collection entry', () => { + expect(() => getSidebar('/', undefined)).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The slug \`"/getting-started/"\` specified in the Starlight sidebar config must not start or end with a slash. + Hint: + Please try updating \`"/getting-started/"\` to \`"getting-started"\`." + `); + }); +}); diff --git a/packages/starlight/__tests__/sidebar-slug-error/vitest.config.ts b/packages/starlight/__tests__/sidebar-slug-error/vitest.config.ts new file mode 100644 index 00000000..96fc6c74 --- /dev/null +++ b/packages/starlight/__tests__/sidebar-slug-error/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineVitestConfig } from '../test-config'; + +export default defineVitestConfig({ + title: 'sidebar slug error', + sidebar: ['/getting-started/'], +}); diff --git a/packages/starlight/schemas/sidebar.ts b/packages/starlight/schemas/sidebar.ts index 41b1c3ae..b71d159e 100644 --- a/packages/starlight/schemas/sidebar.ts +++ b/packages/starlight/schemas/sidebar.ts @@ -58,6 +58,8 @@ type ManualSidebarGroupInput = z.input & { items: Array< | z.input | z.input + | z.input + | z.input | ManualSidebarGroupInput >; }; @@ -67,6 +69,8 @@ type ManualSidebarGroupOutput = z.output & { items: Array< | z.output | z.output + | z.output + | z.output | ManualSidebarGroupOutput >; }; @@ -78,13 +82,34 @@ const ManualSidebarGroupSchema: z.ZodType< > = SidebarGroupSchema.extend({ /** Array of links and subcategories to display in this category. */ items: z.lazy(() => - z.union([SidebarLinkItemSchema, ManualSidebarGroupSchema, AutoSidebarGroupSchema]).array() + z + .union([ + SidebarLinkItemSchema, + ManualSidebarGroupSchema, + AutoSidebarGroupSchema, + InternalSidebarLinkItemSchema, + InternalSidebarLinkItemShorthandSchema, + ]) + .array() ), }).strict(); +const InternalSidebarLinkItemSchema = SidebarBaseSchema.partial({ label: true }).extend({ + /** The link to this item’s content. Must be a slug of a Content Collection entry. */ + slug: z.string(), + /** HTML attributes to add to the link item. */ + attrs: SidebarLinkItemHTMLAttributesSchema(), +}); +const InternalSidebarLinkItemShorthandSchema = z + .string() + .transform((slug) => InternalSidebarLinkItemSchema.parse({ slug })); +export type InternalSidebarLinkItem = z.output; + export const SidebarItemSchema = z.union([ SidebarLinkItemSchema, ManualSidebarGroupSchema, AutoSidebarGroupSchema, + InternalSidebarLinkItemSchema, + InternalSidebarLinkItemShorthandSchema, ]); export type SidebarItem = z.infer; diff --git a/packages/starlight/utils/error-map.ts b/packages/starlight/utils/error-map.ts index c153e296..0b2d2c5c 100644 --- a/packages/starlight/utils/error-map.ts +++ b/packages/starlight/utils/error-map.ts @@ -88,7 +88,12 @@ const errorMap: z.ZodErrorMap = (baseError, ctx) => { expectedShape.push(relativePath); } } - expectedShapes.push(`{ ${expectedShape.join('; ')} }`); + if (expectedShape.length === 1 && !expectedShape[0]?.includes(':')) { + // In this case the expected shape is not an object, but probably a literal type, e.g. `['string']`. + expectedShapes.push(expectedShape.join('')); + } else { + expectedShapes.push(`{ ${expectedShape.join('; ')} }`); + } } if (expectedShapes.length) { details.push('> Expected type `' + expectedShapes.join(' | ') + '`'); diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts index aa4ab1f4..85829f53 100644 --- a/packages/starlight/utils/navigation.ts +++ b/packages/starlight/utils/navigation.ts @@ -1,8 +1,10 @@ +import { AstroError } from 'astro/errors'; import config from 'virtual:starlight/user-config'; import type { Badge } from '../schemas/badge'; import type { PrevNextLinkConfig } from '../schemas/prevNextLink'; import type { AutoSidebarGroup, + InternalSidebarLinkItem, LinkHTMLAttributes, SidebarItem, SidebarLinkItem, @@ -11,7 +13,7 @@ import { createPathFormatter } from './createPathFormatter'; import { formatPath } from './format-path'; import { pickLang } from './i18n'; import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingAndTrailingSlashes } from './path'; -import { getLocaleRoutes, type Route } from './routing'; +import { getLocaleRoutes, routes, type Route } from './routing'; import { localeToLang, slugToPathname } from './slugs'; const DirKey = Symbol('DirKey'); @@ -70,9 +72,11 @@ function configItemToEntry( routes: Route[] ): SidebarEntry { if ('link' in item) { - return linkFromConfig(item, locale, currentPathname); + return linkFromSidebarLinkItem(item, locale, currentPathname); } else if ('autogenerate' in item) { return groupFromAutogenerateConfig(item, locale, routes, currentPathname); + } else if ('slug' in item) { + return linkFromInternalSidebarLinkItem(item, locale, currentPathname); } else { return { type: 'group', @@ -113,8 +117,8 @@ function groupFromAutogenerateConfig( /** Check if a string starts with one of `http://` or `https://`. */ const isAbsolute = (link: string) => /^https?:\/\//.test(link); -/** Create a link entry from a user config object. */ -function linkFromConfig( +/** Create a link entry from a manual link item in user config. */ +function linkFromSidebarLinkItem( item: SidebarLinkItem, locale: string | undefined, currentPathname: string @@ -129,6 +133,36 @@ function linkFromConfig( return makeLink(href, label, currentPathname, item.badge, item.attrs); } +/** Create a link entry from an automatic internal link item in user config. */ +function linkFromInternalSidebarLinkItem( + item: InternalSidebarLinkItem, + locale: string | undefined, + currentPathname: string +) { + let slugWithLocale = locale ? locale + '/' + item.slug : item.slug; + // Astro passes root `index.[md|mdx]` entries with a slug of `index` + slugWithLocale = slugWithLocale.replace(/\/?index$/, ''); + const entry = routes.find((entry) => slugWithLocale === entry.slug); + if (!entry) { + const hasExternalSlashes = item.slug.at(0) === '/' || item.slug.at(-1) === '/'; + if (hasExternalSlashes) { + throw new AstroError( + `The slug \`"${item.slug}"\` specified in the Starlight sidebar config must not start or end with a slash.`, + `Please try updating \`"${item.slug}"\` to \`"${stripLeadingAndTrailingSlashes(item.slug)}"\`.` + ); + } else { + throw new AstroError( + `The slug \`"${item.slug}"\` specified in the Starlight sidebar config does not exist.`, + 'Update the Starlight config to reference a valid entry slug in the docs content collection.\n' + + 'Learn more about Astro content collection slugs at https://docs.astro.build/en/reference/api-reference/#getentry' + ); + } + } + const label = + pickLang(item.translations, localeToLang(locale)) || item.label || entry.entry.data.title; + return makeLink(entry.slug, label, currentPathname, item.badge, item.attrs); +} + /** Create a link entry. */ function makeLink( href: string, -- cgit