From eade41a702b03a5609c180eb3641d83cd06843ed Mon Sep 17 00:00:00 2001 From: Minh Pham <43410858+lmpham1@users.noreply.github.com> Date: Thu, 6 Jan 2022 05:52:25 -0500 Subject: [PATCH] feat(theme-classic): new navbar item linking to a sidebar (#6139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sébastien Lorber Co-authored-by: Joshua Chen Co-authored-by: sebastienlorber --- .../__snapshots__/index.test.ts.snap | 64 +++++++++++++++ .../src/globalData.ts | 31 +++++++ .../src/plugin-content-docs.d.ts | 3 +- .../src/sidebars/__tests__/utils.test.ts | 65 ++++++++++++--- .../src/sidebars/utils.ts | 57 +++++++++++++ .../src/types.ts | 11 +++ .../src/theme-classic.d.ts | 16 +++- .../theme/NavbarItem/DocSidebarNavbarItem.tsx | 81 +++++++++++++++++++ .../src/theme/NavbarItem/index.tsx | 1 + .../src/validateThemeConfig.ts | 10 +++ .../docs/api/themes/theme-configuration.md | 66 +++++++++++++++ website/docusaurus.config.js | 4 +- 12 files changed, 396 insertions(+), 13 deletions(-) create mode 100644 packages/docusaurus-theme-classic/src/theme/NavbarItem/DocSidebarNavbarItem.tsx diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap index 09fbc4a29e..953936e948 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap @@ -323,6 +323,14 @@ Object { "mainDocId": "hello", "name": "current", "path": "/docs", + "sidebars": Object { + "docs": Object { + "link": Object { + "label": "foo/bar", + "path": "/docs/foo/bar", + }, + }, + }, }, ], }, @@ -1007,6 +1015,14 @@ Object { "mainDocId": "hello", "name": "current", "path": "/docs", + "sidebars": Object { + "docs": Object { + "link": Object { + "label": "foo/bar", + "path": "/docs/foo/bar", + }, + }, + }, }, ], }, @@ -2359,6 +2375,14 @@ Object { "mainDocId": "team", "name": "current", "path": "/community/next", + "sidebars": Object { + "community": Object { + "link": Object { + "label": "team", + "path": "/community/next/team", + }, + }, + }, }, Object { "docs": Array [ @@ -2373,6 +2397,14 @@ Object { "mainDocId": "team", "name": "1.0.0", "path": "/community", + "sidebars": Object { + "version-1.0.0/community": Object { + "link": Object { + "label": "version-1.0.0/team", + "path": "/community/team", + }, + }, + }, }, ], }, @@ -3407,6 +3439,14 @@ Object { "mainDocId": "hello", "name": "current", "path": "/docs/next", + "sidebars": Object { + "docs": Object { + "link": Object { + "label": "foo/bar", + "path": "/docs/next/foo/barSlug", + }, + }, + }, }, Object { "docs": Array [ @@ -3426,6 +3466,14 @@ Object { "mainDocId": "hello", "name": "1.0.1", "path": "/docs", + "sidebars": Object { + "VersionedSideBarNameDoesNotMatter/docs": Object { + "link": Object { + "label": "foo/bar", + "path": "/docs/foo/bar", + }, + }, + }, }, Object { "docs": Array [ @@ -3450,6 +3498,14 @@ Object { "mainDocId": "hello", "name": "1.0.0", "path": "/docs/1.0.0", + "sidebars": Object { + "version-1.0.0/docs": Object { + "link": Object { + "label": "version-1.0.0/foo/bar", + "path": "/docs/1.0.0/foo/barSlug", + }, + }, + }, }, Object { "docs": Array [ @@ -3499,6 +3555,14 @@ Object { "mainDocId": "rootAbsoluteSlug", "name": "withSlugs", "path": "/docs/withSlugs", + "sidebars": Object { + "version-1.0.1/docs": Object { + "link": Object { + "label": "version-withSlugs/rootAbsoluteSlug", + "path": "/docs/withSlugs/rootAbsoluteSlug", + }, + }, + }, }, ], }, diff --git a/packages/docusaurus-plugin-content-docs/src/globalData.ts b/packages/docusaurus-plugin-content-docs/src/globalData.ts index 176a202fc8..6d95370134 100644 --- a/packages/docusaurus-plugin-content-docs/src/globalData.ts +++ b/packages/docusaurus-plugin-content-docs/src/globalData.ts @@ -5,11 +5,16 @@ * LICENSE file in the root directory of this source tree. */ +import {mapValues} from 'lodash'; +import {normalizeUrl} from '@docusaurus/utils'; +import type {Sidebars} from './sidebars/types'; +import {createSidebarsUtils} from './sidebars/utils'; import type { DocMetadata, GlobalDoc, LoadedVersion, GlobalVersion, + GlobalSidebar, } from './types'; export function toGlobalDataDoc(doc: DocMetadata): GlobalDoc { @@ -20,6 +25,31 @@ export function toGlobalDataDoc(doc: DocMetadata): GlobalDoc { }; } +export function toGlobalSidebars( + sidebars: Sidebars, + version: LoadedVersion, +): Record { + const {getFirstLink} = createSidebarsUtils(sidebars); + return mapValues(sidebars, (sidebar, sidebarId) => { + const firstLink = getFirstLink(sidebarId); + if (!firstLink) { + return {}; + } + return { + link: { + path: + firstLink.type === 'generated-index' + ? normalizeUrl([version.versionPath, firstLink.slug]) + : version.docs.find( + (doc) => + doc.id === firstLink.id || doc.unversionedId === firstLink.id, + )!.permalink, + label: firstLink.label, + }, + }; + }); +} + export function toGlobalDataVersion(version: LoadedVersion): GlobalVersion { return { name: version.versionName, @@ -28,5 +58,6 @@ export function toGlobalDataVersion(version: LoadedVersion): GlobalVersion { path: version.versionPath, mainDocId: version.mainDocId, docs: version.docs.map(toGlobalDataDoc), + sidebars: toGlobalSidebars(version.sidebars, version), }; } diff --git a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts index 9c4a20fc3f..ffef519821 100644 --- a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts +++ b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts @@ -11,9 +11,10 @@ declare module '@docusaurus/plugin-content-docs' { export type VersionBanner = import('./types').VersionBanner; type GlobalDataVersion = import('./types').GlobalVersion; type GlobalDataDoc = import('./types').GlobalDoc; + type GlobalDataSidebar = import('./types').GlobalSidebar; type VersionTag = import('./types').VersionTag; - export type {GlobalDataVersion, GlobalDataDoc}; + export type {GlobalDataVersion, GlobalDataDoc, GlobalDataSidebar}; export type PropNavigationLink = { readonly title: string; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts index 6d7ebe358c..24561157d0 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts @@ -46,7 +46,7 @@ describe('createSidebarsUtils', () => { collapsible: true, label: 'S2 Category', items: [ - {type: 'doc', id: 'doc3'}, + {type: 'doc', id: 'doc3', label: 'Doc 3'}, {type: 'doc', id: 'doc4'}, ], }, @@ -95,7 +95,25 @@ describe('createSidebarsUtils', () => { }, ]; - const sidebars: Sidebars = {sidebar1, sidebar2, sidebar3}; + const sidebar4: Sidebar = [ + { + type: 'category', + collapsed: false, + collapsible: true, + label: 'S4 Category', + link: { + type: 'generated-index', + slug: '/s4-category-slug', + permalink: '/s4-category-permalink', + }, + items: [ + {type: 'doc', id: 'doc8'}, + {type: 'doc', id: 'doc9'}, + ], + }, + ]; + + const sidebars: Sidebars = {sidebar1, sidebar2, sidebar3, sidebar4}; const { getFirstDocIdOfFirstSidebar, @@ -103,6 +121,7 @@ describe('createSidebarsUtils', () => { getDocNavigation, getCategoryGeneratedIndexNavigation, getCategoryGeneratedIndexList, + getFirstLink, } = createSidebarsUtils(sidebars); test('getSidebarNameByDocId', async () => { @@ -121,7 +140,7 @@ describe('createSidebarsUtils', () => { }); test('getDocNavigation', async () => { - expect(getDocNavigation('doc1')).toEqual({ + expect(getDocNavigation('doc1', 'doc1')).toEqual({ sidebarName: 'sidebar1', previous: undefined, next: { @@ -129,7 +148,7 @@ describe('createSidebarsUtils', () => { id: 'doc2', }, } as SidebarNavigation); - expect(getDocNavigation('doc2')).toEqual({ + expect(getDocNavigation('doc2', 'doc2')).toEqual({ sidebarName: 'sidebar1', previous: { type: 'doc', @@ -138,7 +157,7 @@ describe('createSidebarsUtils', () => { next: undefined, } as SidebarNavigation); - expect(getDocNavigation('doc3')).toEqual({ + expect(getDocNavigation('doc3', 'doc3')).toEqual({ sidebarName: 'sidebar2', previous: undefined, next: { @@ -146,16 +165,17 @@ describe('createSidebarsUtils', () => { id: 'doc4', }, } as SidebarNavigation); - expect(getDocNavigation('doc4')).toEqual({ + expect(getDocNavigation('doc4', 'doc4')).toEqual({ sidebarName: 'sidebar2', previous: { type: 'doc', id: 'doc3', + label: 'Doc 3', }, next: undefined, } as SidebarNavigation); - expect(getDocNavigation('doc5')).toMatchObject({ + expect(getDocNavigation('doc5', 'doc5')).toMatchObject({ sidebarName: 'sidebar3', previous: undefined, next: { @@ -163,7 +183,7 @@ describe('createSidebarsUtils', () => { label: 'S3 SubCategory', }, } as SidebarNavigation); - expect(getDocNavigation('doc6')).toMatchObject({ + expect(getDocNavigation('doc6', 'doc6')).toMatchObject({ sidebarName: 'sidebar3', previous: { type: 'category', @@ -174,7 +194,7 @@ describe('createSidebarsUtils', () => { id: 'doc7', }, } as SidebarNavigation); - expect(getDocNavigation('doc7')).toMatchObject({ + expect(getDocNavigation('doc7', 'doc7')).toMatchObject({ sidebarName: 'sidebar3', previous: { type: 'doc', @@ -224,8 +244,35 @@ describe('createSidebarsUtils', () => { type: 'category', label: 'S3 SubSubCategory', }, + { + type: 'category', + label: 'S4 Category', + }, ]); }); + + test('getFirstLink', () => { + expect(getFirstLink('sidebar1')).toEqual({ + id: 'doc1', + type: 'doc', + label: 'doc1', + }); + expect(getFirstLink('sidebar2')).toEqual({ + id: 'doc3', + type: 'doc', + label: 'Doc 3', + }); + expect(getFirstLink('sidebar3')).toEqual({ + id: 'doc5', + type: 'doc', + label: 'S3 Category', + }); + expect(getFirstLink('sidebar4')).toEqual({ + type: 'generated-index', + slug: '/s4-category-slug', + label: 'S4 Category', + }); + }); }); describe('collectSidebarDocItems', () => { diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts index b3f66e7d3a..f1dc826084 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts @@ -136,6 +136,18 @@ export type SidebarsUtils = { getCategoryGeneratedIndexNavigation: ( categoryGeneratedIndexPermalink: string, ) => SidebarNavigation; + getFirstLink: (sidebarId: string) => + | { + type: 'doc'; + id: string; + label: string; + } + | { + type: 'generated-index'; + slug: string; + label: string; + } + | undefined; checkSidebarsDocIds: (validDocIds: string[], sidebarFilePath: string) => void; }; @@ -264,6 +276,50 @@ Available document ids are: } } + function getFirstLink(sidebar: Sidebar): + | { + type: 'doc'; + id: string; + label: string; + } + | { + type: 'generated-index'; + slug: string; + label: string; + } + | undefined { + // eslint-disable-next-line no-restricted-syntax + for (const item of sidebar) { + if (item.type === 'doc') { + return { + type: 'doc', + id: item.id, + label: item.label ?? item.id, + }; + } else if (item.type === 'category') { + if (item.link?.type === 'doc') { + return { + type: 'doc', + id: item.link.id, + label: item.label, + }; + } else if (item.link?.type === 'generated-index') { + return { + type: 'generated-index', + slug: item.link.slug, + label: item.label, + }; + } else { + const firstSubItem = getFirstLink(item.items); + if (firstSubItem) { + return firstSubItem; + } + } + } + } + return undefined; + } + return { sidebars, getFirstDocIdOfFirstSidebar, @@ -272,6 +328,7 @@ Available document ids are: getCategoryGeneratedIndexList, getCategoryGeneratedIndexNavigation, checkSidebarsDocIds, + getFirstLink: (id) => getFirstLink(sidebars[id]), }; } diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index e34b97eb78..e908ef503d 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -216,6 +216,17 @@ export type GlobalVersion = { path: string; mainDocId: string; // home doc (if docs homepage configured), or first doc docs: GlobalDoc[]; + sidebars?: Record; +}; + +export type GlobalSidebarLink = { + label: string; + path: string; +}; + +export type GlobalSidebar = { + link?: GlobalSidebarLink; + // ... we may add other things here later }; export type GlobalPluginData = { diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 8fce545914..c7ea3e9de4 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -477,10 +477,23 @@ declare module '@theme/NavbarItem/DocNavbarItem' { export default DocsSidebarNavbarItem; } +declare module '@theme/NavbarItem/DocSidebarNavbarItem' { + import type {Props as DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem'; + + export interface Props extends DefaultNavbarItemProps { + readonly sidebarId: string; + readonly docsPluginId?: string; + } + + const DocSidebarNavbarItem: (props: Props) => JSX.Element; + export default DocSidebarNavbarItem; +} + declare module '@theme/NavbarItem' { import type {ComponentProps} from 'react'; import type {Props as DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem'; import type {Props as DocNavbarItemProps} from '@theme/NavbarItem/DocNavbarItem'; + import type {Props as DocSidebarNavbarItemProps} from '@theme/NavbarItem/DocSidebarNavbarItem'; import type {Props as DocsVersionNavbarItemProps} from '@theme/NavbarItem/DocsVersionNavbarItem'; import type {Props as DropdownNavbarItemProps} from '@theme/NavbarItem/DropdownNavbarItem'; import type {Props as DocsVersionDropdownNavbarItemProps} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem'; @@ -490,7 +503,8 @@ declare module '@theme/NavbarItem' { export type LinkLikeNavbarItemProps = | ({readonly type?: 'default'} & DefaultNavbarItemProps) | ({readonly type: 'doc'} & DocNavbarItemProps) - | ({readonly type: 'docsVersion'} & DocsVersionNavbarItemProps); + | ({readonly type: 'docsVersion'} & DocsVersionNavbarItemProps) + | ({readonly type: 'docSidebar'} & DocSidebarNavbarItemProps); export type Props = ComponentProps<'a'> & { readonly position?: 'left' | 'right'; diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocSidebarNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocSidebarNavbarItem.tsx new file mode 100644 index 0000000000..f1b76b216e --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocSidebarNavbarItem.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem'; +import {useLatestVersion, useActiveDocContext} from '@theme/hooks/useDocs'; +import clsx from 'clsx'; +import {getInfimaActiveClassName} from './index'; +import type {Props} from '@theme/NavbarItem/DocSidebarNavbarItem'; +import {useDocsPreferredVersion, uniq} from '@docusaurus/theme-common'; +import type { + GlobalDataVersion, + GlobalDataSidebar, +} from '@docusaurus/plugin-content-docs'; + +function getSidebarLink(versions: GlobalDataVersion[], sidebarId: string) { + const allSidebars = versions + .flatMap((version) => { + if (version.sidebars) { + return Object.entries(version.sidebars); + } + return undefined; + }) + .filter( + (sidebarItem): sidebarItem is [string, GlobalDataSidebar] => + !!sidebarItem, + ); + const sidebarEntry = allSidebars.find((sidebar) => sidebar[0] === sidebarId); + if (!sidebarEntry) { + throw new Error( + `DocSidebarNavbarItem: couldn't find any sidebar with id "${sidebarId}" in version${ + versions.length ? 's' : '' + } ${versions.map((version) => version.name).join(', ')}". +Available sidebar ids are: +- ${Object.keys(allSidebars).join('\n- ')}`, + ); + } + if (!sidebarEntry[1].link) { + throw new Error( + `DocSidebarNavbarItem: couldn't find any document for sidebar with id "${sidebarId}"`, + ); + } + return sidebarEntry[1].link; +} + +export default function DocSidebarNavbarItem({ + sidebarId, + label, + docsPluginId, + ...props +}: Props): JSX.Element { + const {activeVersion, activeDoc} = useActiveDocContext(docsPluginId); + const {preferredVersion} = useDocsPreferredVersion(docsPluginId); + const latestVersion = useLatestVersion(docsPluginId); + + // Versions used to look for the doc to link to, ordered + no duplicate + const versions = uniq( + [activeVersion, preferredVersion, latestVersion].filter( + Boolean, + ) as GlobalDataVersion[], + ); + const sidebarLink = getSidebarLink(versions, sidebarId); + const activeDocInfimaClassName = getInfimaActiveClassName(props.mobile); + + return ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx index af33eb2b6b..c74a0f39a6 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx @@ -32,6 +32,7 @@ const NavbarItemComponents: Record< docsVersionDropdown: () => require('@theme/NavbarItem/DocsVersionDropdownNavbarItem').default, doc: () => require('@theme/NavbarItem/DocNavbarItem').default, + docSidebar: () => require('@theme/NavbarItem/DocSidebarNavbarItem').default, /* eslint-enable @typescript-eslint/no-var-requires, global-require */ } as const; diff --git a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts index 1f7ca0b67f..2bc4fbbdb7 100644 --- a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts +++ b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts @@ -85,6 +85,12 @@ const DocItemSchema = NavbarItemBaseSchema.append({ docsPluginId: Joi.string(), }); +const DocSidebarItemSchema = NavbarItemBaseSchema.append({ + type: Joi.string().equal('docSidebar').required(), + sidebarId: Joi.string().required(), + docsPluginId: Joi.string(), +}); + const itemWithType = (type: string | undefined) => { // because equal(undefined) is not supported :/ const typeSchema = type @@ -172,6 +178,10 @@ const NavbarItemSchema = Joi.object({ is: itemWithType('doc'), then: DocItemSchema, }, + { + is: itemWithType('docSidebar'), + then: DocSidebarItemSchema, + }, { is: itemWithType('localeDropdown'), then: LocaleDropdownNavbarItemSchema, diff --git a/website/docs/api/themes/theme-configuration.md b/website/docs/api/themes/theme-configuration.md index a3d4fda985..2a667b872b 100644 --- a/website/docs/api/themes/theme-configuration.md +++ b/website/docs/api/themes/theme-configuration.md @@ -325,6 +325,7 @@ Navbar dropdown items only accept the following **"link-like" item types**: - [Navbar link](#navbar-link) - [Navbar doc link](#navbar-doc-link) - [Navbar docs version](#navbar-docs-version) +- [Navbar doc sidebar](#navbar-doc-sidebar) Note that the dropdown base item is a clickable link as well, so this item can receive any of the props of a [plain navbar link](#navbar-link). @@ -412,6 +413,71 @@ module.exports = { }; ``` +#### Navbar linked to a sidebar {#navbar-doc-sidebar} + +You can link a navbar item to the first document link (which can be a doc link or a generated category index) of a given sidebar without having to hardcode a doc ID. + +Accepted fields: + + + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `type` | `'docSidebar'` | **Required** | Sets the type of this navbar item to a sidebar's first document. | +| `sidebarId` | `string` | **Required** | The ID of the sidebar that this item is linked to. | +| `label` | `string` | First document link's sidebar label | The name to be shown for this item. | +| `position` | 'left' \| 'right' | `'left'` | The side of the navbar this item should appear on. | +| `docsPluginId` | `string` | `'default'` | The ID of the docs plugin that the sidebar belongs to. | + + + +:::tip + +Use this navbar item type if your sidebar is updated often and the order is not stable. + +::: + +Example configuration: + +```js title="docusaurus.config.js" +module.exports = { + themeConfig: { + navbar: { + items: [ + // highlight-start + { + type: 'docSidebar', + position: 'left', + sidebarId: 'api', + label: 'API', + }, + // highlight-end + ], + }, + }, +}; +``` + +```js title="sidebars.js" +module.exports = { + tutorial: [ + { + type: 'autogenerated', + dirName: 'guides', + }, + ], + api: [ + // highlight-next-line + 'cli', // The navbar item will be linking to this doc + 'docusaurus-core', + { + type: 'autogenerated', + dirName: 'api', + }, + ], +}; +``` + #### Navbar docs version dropdown {#navbar-docs-version-dropdown} If you use docs with versioning, this special navbar item type that will render a dropdown with all your site's available versions. diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 1b1a466163..8e3808f84d 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -366,9 +366,9 @@ const config = { label: 'Docs', }, { - type: 'doc', + type: 'docSidebar', position: 'left', - docId: 'cli', + sidebarId: 'api', label: 'API', }, {to: 'blog', label: 'Blog', position: 'left'},