From 4d7a28963af53f5bc95d386b00a190f4f94c26c2 Mon Sep 17 00:00:00 2001 From: Oleksiy Gapotchenko Date: Thu, 30 Jan 2025 18:21:54 +0100 Subject: [PATCH] feat(theme): add `versions` attribute to `docsVersionDropdown` navbar item (#10852) Co-authored-by: sebastien --- .../src/__tests__/options.test.ts | 104 ++++++++++++++++++ .../docusaurus-theme-classic/src/options.ts | 15 +++ .../src/theme-classic.d.ts | 11 ++ .../DocsVersionDropdownNavbarItem.tsx | 96 ++++++++++++++-- .../docs/api/themes/theme-configuration.mdx | 12 ++ website/docs/guides/docs/versioning.mdx | 48 +++++++- 6 files changed, 276 insertions(+), 10 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/__tests__/options.test.ts b/packages/docusaurus-theme-classic/src/__tests__/options.test.ts index a228aba6f8..0d21ee617c 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/options.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/options.test.ts @@ -827,6 +827,110 @@ describe('themeConfig', () => { ); }); }); + + describe('docsVersionDropdown', () => { + describe('versions', () => { + it('accepts array of strings', () => { + const config = { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + versions: ['current', '1.0'], + }, + ], + }, + }; + testValidateThemeConfig(config); + }); + + it('rejects empty array of strings', () => { + const config = { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + versions: [], + }, + ], + }, + }; + expect(() => + testValidateThemeConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `""navbar.items[0].versions" must contain at least 1 items"`, + ); + }); + + it('rejects array of non-strings', () => { + const config = { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + versions: [1, 2], + }, + ], + }, + }; + expect(() => + testValidateThemeConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `""navbar.items[0].versions[0]" must be a string"`, + ); + }); + + it('accepts dictionary of version objects', () => { + const config = { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + versions: {current: {}, '1.0': {label: '1.x'}}, + }, + ], + }, + }; + testValidateThemeConfig(config); + }); + + it('rejects empty dictionary of objects', () => { + const config = { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + versions: {}, + }, + ], + }, + }; + expect(() => + testValidateThemeConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `""navbar.items[0].versions" must have at least 1 key"`, + ); + }); + + it('rejects dictionary of invalid objects', () => { + const config = { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + versions: {current: {}, '1.0': {invalid: '1.x'}}, + }, + ], + }, + }; + expect(() => + testValidateThemeConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `""navbar.items[0].versions.1.0.invalid" is not allowed"`, + ); + }); + }); + }); }); describe('validateOptions', () => { diff --git a/packages/docusaurus-theme-classic/src/options.ts b/packages/docusaurus-theme-classic/src/options.ts index 6827c069bc..f4f84b5b1f 100644 --- a/packages/docusaurus-theme-classic/src/options.ts +++ b/packages/docusaurus-theme-classic/src/options.ts @@ -7,6 +7,10 @@ import {themes} from 'prism-react-renderer'; import {Joi, URISchema} from '@docusaurus/utils-validation'; +import type { + PropVersionItem, + PropVersionItems, +} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem'; import type {Options, PluginOptions} from '@docusaurus/theme-classic'; import type {ThemeConfig} from '@docusaurus/theme-common'; import type { @@ -210,6 +214,17 @@ const DocsVersionDropdownNavbarItemSchema = NavbarItemBaseSchema.append({ dropdownActiveClassDisabled: Joi.boolean(), dropdownItemsBefore: Joi.array().items(DropdownSubitemSchema).default([]), dropdownItemsAfter: Joi.array().items(DropdownSubitemSchema).default([]), + versions: Joi.alternatives().try( + Joi.array().items(Joi.string().min(1)).min(1), + Joi.object() + .pattern( + Joi.string().min(1), + Joi.object({ + label: Joi.string().min(1), + }), + ) + .min(1), + ), }); const LocaleDropdownNavbarItemSchema = NavbarItemBaseSchema.append({ diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index bef2dc5a14..233e7d49fd 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1257,11 +1257,22 @@ declare module '@theme/NavbarItem/DocsVersionDropdownNavbarItem' { import type {Props as DropdownNavbarItemProps} from '@theme/NavbarItem/DropdownNavbarItem'; import type {LinkLikeNavbarItemProps} from '@theme/NavbarItem'; + type PropVersionItem = { + readonly label?: string; + }; + + type PropVersionItems = { + readonly [version: string]: PropVersionItem; + }; + + type PropVersions = string[] | PropVersionItems; + export interface Props extends DropdownNavbarItemProps { readonly docsPluginId?: string; readonly dropdownActiveClassDisabled?: boolean; readonly dropdownItemsBefore: LinkLikeNavbarItemProps[]; readonly dropdownItemsAfter: LinkLikeNavbarItemProps[]; + readonly versions?: PropVersions; } export default function DocsVersionDropdownNavbarItem( diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx index 4d8c479cf4..1e2d0bbf7a 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx @@ -16,7 +16,11 @@ import {translate} from '@docusaurus/Translate'; import {useLocation} from '@docusaurus/router'; import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; -import type {Props} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem'; +import type { + Props, + PropVersions, + PropVersionItem, +} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem'; import type {LinkLikeNavbarItemProps} from '@theme/NavbarItem'; import type { GlobalVersion, @@ -24,6 +28,56 @@ import type { ActiveDocContext, } from '@docusaurus/plugin-content-docs/client'; +type VersionItem = { + version: GlobalVersion; + label: string; +}; + +function getVersionItems( + versions: GlobalVersion[], + configs?: PropVersions, +): VersionItem[] { + if (configs) { + // Collect all the versions we have + const versionMap = new Map( + versions.map((version) => [version.name, version]), + ); + + const toVersionItem = ( + name: string, + config?: PropVersionItem, + ): VersionItem => { + const version = versionMap.get(name); + if (!version) { + throw new Error(`No docs version exist for name '${name}', please verify your 'docsVersionDropdown' navbar item versions config. +Available version names:\n- ${versions.map((v) => `${v.name}`).join('\n- ')}`); + } + return {version, label: config?.label ?? version.label}; + }; + + if (Array.isArray(configs)) { + return configs.map((name) => toVersionItem(name, undefined)); + } else { + return Object.entries(configs).map(([name, config]) => + toVersionItem(name, config), + ); + } + } else { + return versions.map((version) => ({version, label: version.label})); + } +} + +function useVersionItems({ + docsPluginId, + configs, +}: { + docsPluginId: Props['docsPluginId']; + configs: Props['versions']; +}): VersionItem[] { + const versions = useVersions(docsPluginId); + return getVersionItems(versions, configs); +} + function getVersionMainDoc(version: GlobalVersion): GlobalDoc { return version.docs.find((doc) => doc.id === version.mainDocId)!; } @@ -40,23 +94,47 @@ function getVersionTargetDoc( ); } +// The version item to use for the "dropdown button" +function useDisplayedVersionItem({ + docsPluginId, + versionItems, +}: { + docsPluginId: Props['docsPluginId']; + versionItems: VersionItem[]; +}): VersionItem { + // The order of the candidates matters! + const candidates = useDocsVersionCandidates(docsPluginId); + const candidateItems = candidates + .map((candidate) => versionItems.find((vi) => vi.version === candidate)) + .filter((vi) => vi !== undefined); + return candidateItems[0] ?? versionItems[0]!; +} + export default function DocsVersionDropdownNavbarItem({ mobile, docsPluginId, dropdownActiveClassDisabled, dropdownItemsBefore, dropdownItemsAfter, + versions: configs, ...props }: Props): ReactNode { const {search, hash} = useLocation(); const activeDocContext = useActiveDocContext(docsPluginId); - const versions = useVersions(docsPluginId); const {savePreferredVersionName} = useDocsPreferredVersion(docsPluginId); + const versionItems = useVersionItems({docsPluginId, configs}); + const displayedVersionItem = useDisplayedVersionItem({ + docsPluginId, + versionItems, + }); - function versionToLink(version: GlobalVersion): LinkLikeNavbarItemProps { + function versionItemToLink({ + version, + label, + }: VersionItem): LinkLikeNavbarItemProps { const targetDoc = getVersionTargetDoc(version, activeDocContext); return { - label: version.label, + label, // preserve ?search#hash suffix on version switches to: `${targetDoc.path}${search}${hash}`, isActive: () => version === activeDocContext.activeVersion, @@ -66,12 +144,10 @@ export default function DocsVersionDropdownNavbarItem({ const items: LinkLikeNavbarItemProps[] = [ ...dropdownItemsBefore, - ...versions.map(versionToLink), + ...versionItems.map(versionItemToLink), ...dropdownItemsAfter, ]; - const dropdownVersion = useDocsVersionCandidates(docsPluginId)[0]; - // Mobile dropdown is handled a bit differently const dropdownLabel = mobile && items.length > 1 @@ -81,11 +157,13 @@ export default function DocsVersionDropdownNavbarItem({ description: 'The label for the navbar versions dropdown on mobile view', }) - : dropdownVersion.label; + : displayedVersionItem.label; + const dropdownTo = mobile && items.length > 1 ? undefined - : getVersionTargetDoc(dropdownVersion, activeDocContext).path; + : getVersionTargetDoc(displayedVersionItem.version, activeDocContext) + .path; // We don't want to render a version dropdown with 0 or 1 item. If we build // the site with a single docs version (onlyIncludeVersions: ['1.0.0']), diff --git a/website/docs/api/themes/theme-configuration.mdx b/website/docs/api/themes/theme-configuration.mdx index 7be1ba2b64..7349ed7983 100644 --- a/website/docs/api/themes/theme-configuration.mdx +++ b/website/docs/api/themes/theme-configuration.mdx @@ -597,11 +597,23 @@ Accepted fields: | `dropdownItemsAfter` | [LinkLikeItem](#navbar-dropdown)[] | `[]` | Add additional dropdown items at the end of the dropdown. | | `docsPluginId` | `string` | `'default'` | The ID of the docs plugin that the doc versioning belongs to. | | `dropdownActiveClassDisabled` | `boolean` | `false` | Do not add the link active class when browsing docs. | +| `versions` | `DropdownVersions` | `undefined` | Specify a custom list of versions to include in the dropdown. See [the versioning guide](../../guides/docs/versioning.mdx#docsVersionDropdown) for details. | ```mdx-code-block ``` +Types: + +```ts +type DropdownVersion = { + /** Allows you to provide a custom display label for each version. */ + label?: string; +}; + +type DropdownVersions = string[] | {[versionName: string]: DropdownVersion}; +``` + Example configuration: ```js title="docusaurus.config.js" diff --git a/website/docs/guides/docs/versioning.mdx b/website/docs/guides/docs/versioning.mdx index 08fab227b5..196f7a3790 100644 --- a/website/docs/guides/docs/versioning.mdx +++ b/website/docs/guides/docs/versioning.mdx @@ -258,7 +258,7 @@ See [docs plugin configuration](../../api/plugins/plugin-content-docs.mdx#config ## Navbar items {#navbar-items} -We offer several navbar items to help you quickly set up navigation without worrying about versioned routes. +We offer several docs navbar items to help you quickly set up navigation without worrying about versioned routes. - [`doc`](../../api/themes/theme-configuration.mdx#navbar-doc-link): a link to a doc. - [`docSidebar`](../../api/themes/theme-configuration.mdx#navbar-doc-sidebar): a link to the first item in a sidebar. @@ -271,6 +271,52 @@ These links would all look for an appropriate version to link to, in the followi 2. **Preferred version**: the version that the user last viewed. If there's no history, fall back to... 3. **Latest version**: the default version that we navigate to, configured by the `lastVersion` option. +## `docsVersionDropdown` {#docsVersionDropdown} + +By default, the [`docsVersionDropdown`](../../api/themes/theme-configuration.mdx#navbar-docs-version-dropdown) displays a dropdown with all the available docs versions. + +The `versions` attribute allows you to display a subset of the available docs versions in a given order: + +```js title="docusaurus.config.js" +export default { + themeConfig: { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + // highlight-start + versions: ['current', '3.0', '2.0'], + // highlight-end + }, + ], + }, + }, +}; +``` + +Passing a `versions` object, lets you override the display label of each version: + +```js title="docusaurus.config.js" +export default { + themeConfig: { + navbar: { + items: [ + { + type: 'docsVersionDropdown', + // highlight-start + versions: { + current: {label: 'Version 4.0'}, + '3.0': {label: 'Version 3.0'}, + '2.0': {label: 'Version 2.0'}, + }, + // highlight-end + }, + ], + }, + }, +}; +``` + ## Recommended practices {#recommended-practices} ### Version your documentation only when needed {#version-your-documentation-only-when-needed}