/** * 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 {useMemo} from 'react'; import { useAllDocsData, useActivePlugin, useActiveDocContext, useLatestVersion, type GlobalVersion, type GlobalSidebar, type GlobalDoc, } from '@docusaurus/plugin-content-docs/client'; import type { PropSidebar, PropSidebarItem, PropSidebarItemCategory, PropVersionDoc, PropVersionMetadata, PropSidebarBreadcrumbsItem, } from '@docusaurus/plugin-content-docs'; import {useDocsPreferredVersion} from '../contexts/docsPreferredVersion'; import {useDocsVersion} from '../contexts/docsVersion'; import {useDocsSidebar} from '../contexts/docsSidebar'; import {uniq} from './jsUtils'; import {isSamePath} from './routesUtils'; import {matchPath, useLocation} from '@docusaurus/router'; import renderRoutes from '@docusaurus/renderRoutes'; import type {RouteConfig} from 'react-router-config'; // TODO not ideal, see also "useDocs" export const isDocsPluginEnabled: boolean = !!useAllDocsData; /** * A null-safe way to access a doc's data by ID in the active version. */ export function useDocById(id: string): PropVersionDoc; /** * A null-safe way to access a doc's data by ID in the active version. */ export function useDocById(id: string | undefined): PropVersionDoc | undefined; export function useDocById(id: string | undefined): PropVersionDoc | undefined { const version = useDocsVersion(); if (!id) { return undefined; } const doc = version.docs[id]; if (!doc) { throw new Error(`no version doc found by id=${id}`); } return doc; } /** * Pure function, similar to `Array#find`, but works on the sidebar tree. */ export function findSidebarCategory( sidebar: PropSidebar, predicate: (category: PropSidebarItemCategory) => boolean, ): PropSidebarItemCategory | undefined { for (const item of sidebar) { if (item.type === 'category') { if (predicate(item)) { return item; } const subItem = findSidebarCategory(item.items, predicate); if (subItem) { return subItem; } } } return undefined; } /** * Best effort to assign a link to a sidebar category. If the category doesn't * have a link itself, we link to the first sub item with a link. */ export function findFirstCategoryLink( item: PropSidebarItemCategory, ): string | undefined { if (item.href) { return item.href; } for (const subItem of item.items) { if (subItem.type === 'link') { return subItem.href; } else if (subItem.type === 'category') { const categoryLink = findFirstCategoryLink(subItem); if (categoryLink) { return categoryLink; } } // Could be "html" items } return undefined; } /** * Gets the category associated with the current location. Should only be used * on category index pages. */ export function useCurrentSidebarCategory(): PropSidebarItemCategory { const {pathname} = useLocation(); const sidebar = useDocsSidebar(); if (!sidebar) { throw new Error('Unexpected: cant find current sidebar in context'); } const category = findSidebarCategory(sidebar.items, (item) => isSamePath(item.href, pathname), ); if (!category) { throw new Error( `${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`, ); } return category; } const isActive = (testedPath: string | undefined, activePath: string) => typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath); const containsActiveSidebarItem = ( items: PropSidebarItem[], activePath: string, ) => items.some((subItem) => isActiveSidebarItem(subItem, activePath)); /** * Checks if a sidebar item should be active, based on the active path. */ export function isActiveSidebarItem( item: PropSidebarItem, activePath: string, ): boolean { if (item.type === 'link') { return isActive(item.href, activePath); } if (item.type === 'category') { return ( isActive(item.href, activePath) || containsActiveSidebarItem(item.items, activePath) ); } return false; } /** * Gets the breadcrumbs of the current doc page, based on its sidebar location. * Returns `null` if there's no sidebar or breadcrumbs are disabled. */ export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null { const sidebar = useDocsSidebar(); const {pathname} = useLocation(); const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs; if (breadcrumbsOption === false || !sidebar) { return null; } const breadcrumbs: PropSidebarBreadcrumbsItem[] = []; function extract(items: PropSidebar) { for (const item of items) { if ( (item.type === 'category' && (isSamePath(item.href, pathname) || extract(item.items))) || (item.type === 'link' && isSamePath(item.href, pathname)) ) { breadcrumbs.push(item); return true; } } return false; } extract(sidebar.items); return breadcrumbs.reverse(); } /** * "Version candidates" are mostly useful for the layout components, which must * be able to work on all pages. For example, if a user has `{ type: "doc", * docId: "intro" }` as a navbar item, which version does that refer to? We * believe that it could refer to at most three version candidates: * * 1. The **active version**, the one that the user is currently browsing. See * {@link useActiveDocContext}. * 2. The **preferred version**, the one that the user last visited. See * {@link useDocsPreferredVersion}. * 3. The **latest version**, the "default". See {@link useLatestVersion}. * * @param docsPluginId The plugin ID to get versions from. * @returns An array of 1~3 versions with priorities defined above, guaranteed * to be unique and non-sparse. Will be memoized, hence stable for deps array. */ export function useDocsVersionCandidates( docsPluginId?: string, ): [GlobalVersion, ...GlobalVersion[]] { const {activeVersion} = useActiveDocContext(docsPluginId); const {preferredVersion} = useDocsPreferredVersion(docsPluginId); const latestVersion = useLatestVersion(docsPluginId); return useMemo( () => uniq( [activeVersion, preferredVersion, latestVersion].filter(Boolean), ) as [GlobalVersion, ...GlobalVersion[]], [activeVersion, preferredVersion, latestVersion], ); } /** * The layout components, like navbar items, must be able to work on all pages, * even on non-doc ones where there's no version context, so a sidebar ID could * be ambiguous. This hook would always return a sidebar to be linked to. See * also {@link useDocsVersionCandidates} for how this selection is done. * * @throws This hook throws if a sidebar with said ID is not found. */ export function useLayoutDocsSidebar( sidebarId: string, docsPluginId?: string, ): GlobalSidebar { const versions = useDocsVersionCandidates(docsPluginId); return useMemo(() => { const allSidebars = versions.flatMap((version) => version.sidebars ? Object.entries(version.sidebars) : [], ); const sidebarEntry = allSidebars.find( (sidebar) => sidebar[0] === sidebarId, ); if (!sidebarEntry) { throw new Error( `Can't find any sidebar with id "${sidebarId}" in version${ versions.length > 1 ? 's' : '' } ${versions.map((version) => version.name).join(', ')}". Available sidebar ids are: - ${Object.keys(allSidebars).join('\n- ')}`, ); } return sidebarEntry[1]; }, [sidebarId, versions]); } /** * The layout components, like navbar items, must be able to work on all pages, * even on non-doc ones where there's no version context, so a doc ID could be * ambiguous. This hook would always return a doc to be linked to. See also * {@link useDocsVersionCandidates} for how this selection is done. * * @throws This hook throws if a doc with said ID is not found. */ export function useLayoutDoc( docId: string, docsPluginId?: string, ): GlobalDoc | null { const versions = useDocsVersionCandidates(docsPluginId); return useMemo(() => { const allDocs = versions.flatMap((version) => version.docs); const doc = allDocs.find((versionDoc) => versionDoc.id === docId); if (!doc) { const isDraft = versions .flatMap((version) => version.draftIds) .includes(docId); // Drafts should be silently filtered instead of throwing if (isDraft) { return null; } throw new Error( `DocNavbarItem: couldn't find any doc with id "${docId}" in version${ versions.length > 1 ? 's' : '' } ${versions.map((version) => version.name).join(', ')}". Available doc ids are: - ${uniq(allDocs.map((versionDoc) => versionDoc.id)).join('\n- ')}`, ); } return doc; }, [docId, versions]); } // TODO later read version/route directly from context export function useDocRouteMetadata({ route, versionMetadata, }: { route: RouteConfig; versionMetadata: PropVersionMetadata; }): null | { docElement: JSX.Element; sidebarName: string | undefined; sidebarItems: PropSidebar | undefined; } { const location = useLocation(); const docRoutes = route.routes; const currentDocRoute = docRoutes!.find((docRoute) => matchPath(location.pathname, docRoute), ); if (!currentDocRoute) { return null; } // For now, the sidebarName is added as route config: not ideal! const sidebarName = currentDocRoute.sidebar; const sidebarItems = sidebarName ? versionMetadata.docsSidebars[sidebarName] : undefined; const docElement = renderRoutes(route.routes!, { versionMetadata, }); return { docElement, sidebarName, sidebarItems, }; }