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 bba2e20e8e..3fb1a48e0a 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 @@ -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 { diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts index a4d94d4c36..95fb65e38c 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts @@ -56,6 +56,7 @@ describe('normalizeDocsPluginOptions', () => { rehypePlugins: [markdownPluginsFunctionStub], beforeDefaultRehypePlugins: [], beforeDefaultRemarkPlugins: [], + breadcrumbs: true, showLastUpdateTime: true, showLastUpdateAuthor: true, admonitions: {}, diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index df71049e60..9aeac134bd 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -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({ path: normalizeUrl([baseUrl, options.routeBasePath]), versions: loadedVersions.map(toGlobalDataVersion), + breadcrumbs, }); }, diff --git a/packages/docusaurus-plugin-content-docs/src/options.ts b/packages/docusaurus-plugin-content-docs/src/options.ts index 7c79e5ac02..3e5e9f5a7a 100644 --- a/packages/docusaurus-plugin-content-docs/src/options.ts +++ b/packages/docusaurus-plugin-content-docs/src/options.ts @@ -55,6 +55,7 @@ export const DEFAULT_OPTIONS: Omit = { 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({ 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 c63dc52244..00c4e8fda2 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 @@ -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 diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts index 620344c4ed..914a854b63 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts @@ -195,6 +195,10 @@ export type PropSidebars = { [sidebarId: string]: PropSidebar; }; +export type PropSidebarBreadcrumbsItem = + | PropSidebarItemLink + | PropSidebarItemCategory; + export type PropVersionDoc = { id: string; title: string; diff --git a/packages/docusaurus-theme-classic/src/theme/DocBreadcrumbs/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocBreadcrumbs/index.tsx new file mode 100644 index 0000000000..8eecbe5417 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocBreadcrumbs/index.tsx @@ -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 ? ( + + {children} + + ) : ( + {children} + ); +} + +// TODO move to design system folder +function BreadcrumbsItem({ + children, + active, +}: { + children: ReactNode; + active?: boolean; +}): JSX.Element { + return ( +
  • + {children} +
  • + ); +} + +function HomeBreadcrumbItem() { + const homeHref = useBaseUrl('/'); + return ( + + 🏠 + + ); +} + +export default function DocBreadcrumbs(): JSX.Element | null { + const breadcrumbs = useSidebarBreadcrumbs(); + + if (!breadcrumbs) { + return null; + } + + return ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocBreadcrumbs/styles.module.css b/packages/docusaurus-theme-classic/src/theme/DocBreadcrumbs/styles.module.css new file mode 100644 index 0000000000..db445fbe11 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocBreadcrumbs/styles.module.css @@ -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; + } +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCategoryGeneratedIndexPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCategoryGeneratedIndexPage/index.tsx index 1214a86aab..0d6f3e5ffe 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocCategoryGeneratedIndexPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocCategoryGeneratedIndexPage/index.tsx @@ -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({ />
    +
    diff --git a/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx index d595bb186f..69befc8623 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx @@ -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 {
    + {canRenderTOC && ( diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 980738e137..ebcb240c36 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -49,6 +49,7 @@ export { findFirstCategoryLink, useCurrentSidebarCategory, isActiveSidebarItem, + useSidebarBreadcrumbs, } from './utils/docsUtils'; export {isSamePath} from './utils/pathUtils'; diff --git a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts index fea5ac7d4e..e5b4992635 100644 --- a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts +++ b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts @@ -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', diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/docsUtils.test.tsx b/packages/docusaurus-theme-common/src/utils/__tests__/docsUtils.test.tsx index 06fd3686d2..e0700f8d86 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/docsUtils.test.tsx +++ b/packages/docusaurus-theme-common/src/utils/__tests__/docsUtils.test.tsx @@ -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 { + return { + type: 'link', + href: '/testLinkHref', + label: 'Link label', + ...data, + }; +} + function testVersion(data?: Partial): 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]); + }); }); diff --git a/packages/docusaurus-theme-common/src/utils/docsUtils.tsx b/packages/docusaurus-theme-common/src/utils/docsUtils.tsx index 8be209b921..f02b213c32 100644 --- a/packages/docusaurus-theme-common/src/utils/docsUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/docsUtils.tsx @@ -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}); +} diff --git a/website/docs/api/plugins/plugin-content-docs.md b/website/docs/api/plugins/plugin-content-docs.md index 0c52f97ad2..2c97cd4ab7 100644 --- a/website/docs/api/plugins/plugin-content-docs.md +++ b/website/docs/api/plugins/plugin-content-docs.md @@ -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` | string \| EditUrlFunction | `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 diff --git a/website/docs/guides/docs/sidebar/index.md b/website/docs/guides/docs/sidebar/index.md index d78b0367d2..9634f4853c 100644 --- a/website/docs/guides/docs/sidebar/index.md +++ b/website/docs/guides/docs/sidebar/index.md @@ -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: