feat(theme-classic): new navbar item linking to a sidebar (#6139)

Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
Minh Pham 2022-01-06 05:52:25 -05:00 committed by GitHub
parent 3cb99124de
commit eade41a702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 396 additions and 13 deletions

View file

@ -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",
},
},
},
},
],
},

View file

@ -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<string, GlobalSidebar> {
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),
};
}

View file

@ -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;

View file

@ -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', () => {

View file

@ -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]),
};
}

View file

@ -216,6 +216,17 @@ export type GlobalVersion = {
path: string;
mainDocId: string; // home doc (if docs homepage configured), or first doc
docs: GlobalDoc[];
sidebars?: Record<string, GlobalSidebar>;
};
export type GlobalSidebarLink = {
label: string;
path: string;
};
export type GlobalSidebar = {
link?: GlobalSidebarLink;
// ... we may add other things here later
};
export type GlobalPluginData = {

View file

@ -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';

View file

@ -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 (
<DefaultNavbarItem
exact
{...props}
className={clsx(props.className, {
[activeDocInfimaClassName]: activeDoc?.sidebar === sidebarId,
})}
activeClassName={activeDocInfimaClassName}
label={label ?? sidebarLink.label}
to={sidebarLink.path}
/>
);
}

View file

@ -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;

View file

@ -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,

View file

@ -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:
<APITable name="navbar-doc-sidebar">
| 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` | <code>'left' \| 'right'</code> | `'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. |
</APITable>
:::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.

View file

@ -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'},