feat(docs,theme-classic): docs breadcrumbs (#6517)

Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
Jody Heavener 2022-02-16 14:02:58 -04:00 committed by GitHub
parent 67918e35e2
commit 3629b5ab39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 341 additions and 1 deletions

View file

@ -242,6 +242,7 @@ exports[`simple website content 5`] = `
Object {
"pluginName": Object {
"pluginId": Object {
"breadcrumbs": true,
"path": "/docs",
"versions": Array [
Object {
@ -955,6 +956,7 @@ exports[`simple website content: global data 1`] = `
Object {
"pluginName": Object {
"pluginId": Object {
"breadcrumbs": true,
"path": "/docs",
"versions": Array [
Object {
@ -2411,6 +2413,7 @@ exports[`versioned website (community) content: global data 1`] = `
Object {
"pluginName": Object {
"pluginId": Object {
"breadcrumbs": true,
"path": "/community",
"versions": Array [
Object {
@ -3450,6 +3453,7 @@ exports[`versioned website content: global data 1`] = `
Object {
"pluginName": Object {
"pluginId": Object {
"breadcrumbs": true,
"path": "/docs",
"versions": Array [
Object {

View file

@ -56,6 +56,7 @@ describe('normalizeDocsPluginOptions', () => {
rehypePlugins: [markdownPluginsFunctionStub],
beforeDefaultRehypePlugins: [],
beforeDefaultRemarkPlugins: [],
breadcrumbs: true,
showLastUpdateTime: true,
showLastUpdateAuthor: true,
admonitions: {},

View file

@ -217,6 +217,7 @@ export default async function pluginContentDocs(
docLayoutComponent,
docItemComponent,
docCategoryGeneratedIndexComponent,
breadcrumbs,
} = options;
const {addRoute, createData, setGlobalData} = actions;
@ -295,6 +296,7 @@ export default async function pluginContentDocs(
setGlobalData<GlobalPluginData>({
path: normalizeUrl([baseUrl, options.routeBasePath]),
versions: loadedVersions.map(toGlobalDataVersion),
breadcrumbs,
});
},

View file

@ -55,6 +55,7 @@ export const DEFAULT_OPTIONS: Omit<PluginOptions, 'id' | 'sidebarPath'> = {
editLocalizedFiles: false,
sidebarCollapsible: true,
sidebarCollapsed: true,
breadcrumbs: true,
};
const VersionOptionsSchema = Joi.object({
@ -139,6 +140,7 @@ export const OptionsSchema = Joi.object({
disableVersioning: Joi.bool().default(DEFAULT_OPTIONS.disableVersioning),
lastVersion: Joi.string().optional(),
versions: VersionsOptionsSchema,
breadcrumbs: Joi.bool().default(DEFAULT_OPTIONS.breadcrumbs),
});
export function validateOptions({

View file

@ -38,6 +38,7 @@ declare module '@docusaurus/plugin-content-docs' {
showLastUpdateTime?: boolean;
showLastUpdateAuthor?: boolean;
numberPrefixParser: NumberPrefixParser;
breadcrumbs: boolean;
};
export type PathOptions = {
@ -126,6 +127,8 @@ declare module '@docusaurus/plugin-content-docs' {
export type PropSidebarItemCategory =
import('./sidebars/types').PropSidebarItemCategory;
export type PropSidebarItem = import('./sidebars/types').PropSidebarItem;
export type PropSidebarBreadcrumbsItem =
import('./sidebars/types').PropSidebarBreadcrumbsItem;
export type PropSidebar = import('./sidebars/types').PropSidebar;
export type PropSidebars = import('./sidebars/types').PropSidebars;
@ -237,6 +240,10 @@ declare module '@theme/DocTagDocListPage' {
export default function DocTagDocListPage(props: Props): JSX.Element;
}
declare module '@theme/DocBreadcrumbs' {
export default function DocBreadcrumbs(): JSX.Element;
}
declare module '@theme/DocPage' {
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
import type {DocumentRoute} from '@theme/DocItem';
@ -294,6 +301,7 @@ declare module '@docusaurus/plugin-content-docs/client' {
export type GlobalPluginData = {
path: string;
versions: GlobalVersion[];
breadcrumbs: boolean;
};
export type DocVersionSuggestions = {
// suggest the latest version

View file

@ -195,6 +195,10 @@ export type PropSidebars = {
[sidebarId: string]: PropSidebar;
};
export type PropSidebarBreadcrumbsItem =
| PropSidebarItemLink
| PropSidebarItemCategory;
export type PropVersionDoc = {
id: string;
title: string;

View file

@ -0,0 +1,86 @@
/**
* 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, {type ReactNode} from 'react';
import {ThemeClassNames, useSidebarBreadcrumbs} from '@docusaurus/theme-common';
import styles from './styles.module.css';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
// TODO move to design system folder
function BreadcrumbsItemLink({
children,
href,
}: {
children: ReactNode;
href?: string;
}): JSX.Element {
const className = clsx('breadcrumbs__link', styles.breadcrumbsItemLink);
return href ? (
<Link className={className} href={href}>
{children}
</Link>
) : (
<span className={className}>{children}</span>
);
}
// TODO move to design system folder
function BreadcrumbsItem({
children,
active,
}: {
children: ReactNode;
active?: boolean;
}): JSX.Element {
return (
<li
className={clsx('breadcrumbs__item', {
'breadcrumbs__item--active': active,
})}>
{children}
</li>
);
}
function HomeBreadcrumbItem() {
const homeHref = useBaseUrl('/');
return (
<BreadcrumbsItem>
<BreadcrumbsItemLink href={homeHref}>🏠</BreadcrumbsItemLink>
</BreadcrumbsItem>
);
}
export default function DocBreadcrumbs(): JSX.Element | null {
const breadcrumbs = useSidebarBreadcrumbs();
if (!breadcrumbs) {
return null;
}
return (
<nav
className={clsx(
ThemeClassNames.docs.docBreadcrumbs,
styles.breadcrumbsContainer,
)}
aria-label="breadcrumbs">
<ul className="breadcrumbs">
<HomeBreadcrumbItem />
{breadcrumbs.map((item, idx) => (
<BreadcrumbsItem key={idx} active={idx === breadcrumbs.length - 1}>
<BreadcrumbsItemLink href={item.href}>
{item.label}
</BreadcrumbsItemLink>
</BreadcrumbsItem>
))}
</ul>
</nav>
);
}

View file

@ -0,0 +1,26 @@
/**
* 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.
*/
.breadcrumbsContainer {
margin-bottom: 0.4rem;
}
.breadcrumbsItemLink {
--ifm-breadcrumb-size-multiplier: 0.7 !important;
margin-bottom: 0.4rem;
background: var(--ifm-color-gray-100);
}
html[data-theme='dark'] .breadcrumbsItemLink {
background-color: var(--ifm-color-gray-900);
}
@media (min-width: 997px) {
.breadcrumbsItemLink {
--ifm-breadcrumb-size-multiplier: 0.8;
}
}

View file

@ -13,6 +13,7 @@ import DocPaginator from '@theme/DocPaginator';
import Seo from '@theme/Seo';
import DocVersionBanner from '@theme/DocVersionBanner';
import DocVersionBadge from '@theme/DocVersionBadge';
import DocBreadcrumbs from '@theme/DocBreadcrumbs';
import Heading from '@theme/Heading';
import useBaseUrl from '@docusaurus/useBaseUrl';
@ -33,6 +34,7 @@ export default function DocCategoryGeneratedIndexPage({
/>
<div className={styles.generatedIndexPage}>
<DocVersionBanner />
<DocBreadcrumbs />
<DocVersionBadge />
<header>
<Heading as="h1" className={styles.title}>

View file

@ -18,6 +18,7 @@ import TOCCollapsible from '@theme/TOCCollapsible';
import Heading from '@theme/Heading';
import styles from './styles.module.css';
import {ThemeClassNames, useWindowSize} from '@docusaurus/theme-common';
import DocBreadcrumbs from '@theme/DocBreadcrumbs';
export default function DocItem(props: Props): JSX.Element {
const {content: DocContent} = props;
@ -58,6 +59,7 @@ export default function DocItem(props: Props): JSX.Element {
<DocVersionBanner />
<div className={styles.docItemContainer}>
<article>
<DocBreadcrumbs />
<DocVersionBadge />
{canRenderTOC && (

View file

@ -49,6 +49,7 @@ export {
findFirstCategoryLink,
useCurrentSidebarCategory,
isActiveSidebarItem,
useSidebarBreadcrumbs,
} from './utils/docsUtils';
export {isSamePath} from './utils/pathUtils';

View file

@ -43,6 +43,7 @@ export const ThemeClassNames = {
docs: {
docVersionBanner: 'theme-doc-version-banner',
docVersionBadge: 'theme-doc-version-badge',
docBreadcrumbs: 'theme-doc-breadcrumbs',
docMarkdown: 'theme-doc-markdown',
docTocMobile: 'theme-doc-toc-mobile',
docTocDesktop: 'theme-doc-toc-desktop',

View file

@ -16,11 +16,13 @@ import {
useDocsSidebar,
DocsSidebarProvider,
findSidebarCategory,
getBreadcrumbs,
} from '../docsUtils';
import type {
PropSidebar,
PropSidebarItem,
PropSidebarItemCategory,
PropSidebarItemLink,
PropVersionMetadata,
} from '@docusaurus/plugin-content-docs';
@ -39,6 +41,15 @@ function testCategory(
};
}
function testLink(data?: Partial<PropSidebarItemLink>): PropSidebarItemLink {
return {
type: 'link',
href: '/testLinkHref',
label: 'Link label',
...data,
};
}
function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
return {
version: 'versionName',
@ -330,4 +341,123 @@ describe('docsUtils', () => {
expect(isActiveSidebarItem(item, '/sub-sub-link-path/')).toEqual(true);
});
});
describe('getBreadcrumbs', () => {
test('should return empty for empty sidebar', () => {
expect(
getBreadcrumbs({
sidebar: [],
pathname: '/doesNotExist',
}),
).toEqual([]);
});
test('should return empty for sidebar but unknown pathname', () => {
const sidebar = [testCategory(), testLink()];
expect(
getBreadcrumbs({
sidebar,
pathname: '/doesNotExist',
}),
).toEqual([]);
});
test('should return first level category', () => {
const pathname = '/somePathName';
const sidebar = [testCategory({href: pathname}), testLink()];
expect(
getBreadcrumbs({
sidebar,
pathname,
}),
).toEqual([sidebar[0]]);
});
test('should return first level link', () => {
const pathname = '/somePathName';
const sidebar = [testCategory(), testLink({href: pathname})];
expect(
getBreadcrumbs({
sidebar,
pathname,
}),
).toEqual([sidebar[1]]);
});
test('should return nested category', () => {
const pathname = '/somePathName';
const categoryLevel3 = testCategory({
href: pathname,
});
const categoryLevel2 = testCategory({
items: [
testCategory(),
categoryLevel3,
testLink({href: pathname}),
testLink(),
],
});
const categoryLevel1 = testCategory({
items: [testLink(), categoryLevel2],
});
const sidebar = [
testLink(),
testCategory(),
categoryLevel1,
testLink(),
testCategory(),
];
expect(
getBreadcrumbs({
sidebar,
pathname,
}),
).toEqual([categoryLevel1, categoryLevel2, categoryLevel3]);
});
});
test('should return nested link', () => {
const pathname = '/somePathName';
const link = testLink({href: pathname});
const categoryLevel3 = testCategory({
items: [testLink(), link, testLink()],
});
const categoryLevel2 = testCategory({
items: [
testCategory(),
categoryLevel3,
testLink({href: pathname}),
testLink(),
],
});
const categoryLevel1 = testCategory({
items: [testLink(), categoryLevel2],
});
const sidebar = [
testLink(),
testCategory(),
categoryLevel1,
testLink(),
testCategory(),
];
expect(
getBreadcrumbs({
sidebar,
pathname,
}),
).toEqual([categoryLevel1, categoryLevel2, categoryLevel3, link]);
});
});

View file

@ -6,13 +6,17 @@
*/
import React, {createContext, type ReactNode, useContext} from 'react';
import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
import {
useActivePlugin,
useAllDocsData,
} from '@docusaurus/plugin-content-docs/client';
import type {
PropSidebar,
PropSidebarItem,
PropSidebarItemCategory,
PropVersionDoc,
PropVersionMetadata,
PropSidebarBreadcrumbsItem,
} from '@docusaurus/plugin-content-docs';
import {isSamePath} from './pathUtils';
import {useLocation} from '@docusaurus/router';
@ -181,3 +185,46 @@ export function isActiveSidebarItem(
return false;
}
export function getBreadcrumbs({
sidebar,
pathname,
}: {
sidebar: PropSidebar;
pathname: string;
}): PropSidebarBreadcrumbsItem[] {
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
function extract(items: PropSidebar) {
for (const item of items) {
if (
item.type === 'category' &&
(isSamePath(item.href, pathname) || extract(item.items))
) {
breadcrumbs.push(item);
return true;
} else if (item.type === 'link' && isSamePath(item.href, pathname)) {
breadcrumbs.push(item);
return true;
}
}
return false;
}
extract(sidebar);
return breadcrumbs.reverse();
}
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
const sidebar = useDocsSidebar();
const {pathname} = useLocation();
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
if (breadcrumbsOption === false || !sidebar) {
return null;
}
return getBreadcrumbs({sidebar, pathname});
}

View file

@ -32,6 +32,7 @@ Accepted fields:
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | `string` | `'docs'` | Path to data on filesystem relative to site dir. |
| `breadcrumbs` | `boolean` | `true` | To enable or disable the breadcrumbs on docs pages. |
| `editUrl` | <code>string \| EditUrlFunction</code> | `undefined` | Base URL to edit your site. The final URL is computed by `editUrl + relativeDocPath`. Using a function allows more nuanced control for each file. Omitting this variable entirely will disable edit links. |
| `editLocalizedFiles` | `boolean` | `false` | The edit URL will target the localized file, instead of the original unlocalized file. Ignored when `editUrl` is a function. |
| `editCurrentVersion` | `boolean` | `false` | The edit URL will always target the current version doc instead of older versions. Ignored when `editUrl` is a function. |
@ -127,6 +128,7 @@ Most Docusaurus users configure this plugin through the preset options.
const config = {
path: 'docs',
breadcrumbs: true,
// Simple use-case: string editUrl
// editUrl: 'https://github.com/facebook/docusaurus/edit/main/website/',
// Advanced use-case: functional editUrl

View file

@ -160,6 +160,28 @@ To pass in custom props to a swizzled sidebar item, add the optional `customProp
};
```
## Sidebar Breadcrumbs {#sidebar-breadcrumbs}
By default, breadcrumbs are rendered at the top, using the "sidebar path" of the current page.
This behavior can be disabled with plugin options:
```js title="docusaurus.config.js"
module.exports = {
presets: [
[
'@docusaurus/preset-classic',
{
docs: {
// highlight-next-line
breadcrumbs: false,
},
},
],
],
};
```
## Complex sidebars example {#complex-sidebars-example}
A real-world example from the Docusaurus site: