From cf91238cc125982b48d50bd49037c88023a7577b Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Wed, 23 Mar 2022 21:02:25 +0800 Subject: [PATCH 1/2] feat(theme-common): JSDoc for all APIs --- .../src/components/Collapsible/index.tsx | 60 +++++--- .../src/components/Details/index.tsx | 9 +- .../src/contexts/announcementBar.tsx | 28 ++-- .../src/contexts/colorMode.tsx | 34 ++--- .../contexts/docSidebarItemsExpandedState.tsx | 33 +++-- .../src/contexts/docsPreferredVersion.tsx | 137 ++++++++--------- .../src/contexts/docsSidebar.tsx | 42 ++++++ .../src/contexts/docsVersion.tsx | 36 +++++ .../src/contexts/navbarMobileSidebar.tsx | 47 +++--- .../navbarSecondaryMenu.tsx | 71 ++++----- .../src/contexts/tabGroupChoice.tsx | 42 +++--- .../src/hooks/useHideableNavbar.ts | 14 +- .../src/hooks/useKeyboardNavigation.ts | 8 +- .../src/hooks/useLockBodyScroll.ts | 5 +- .../src/hooks/usePrismTheme.ts | 4 + .../src/hooks/useSearchPage.ts | 17 ++- .../src/hooks/useTOCHighlight.ts | 19 ++- .../src/hooks/useWindowSize.ts | 24 +-- packages/docusaurus-theme-common/src/index.ts | 21 +-- .../src/utils/ThemeClassNames.ts | 16 +- .../src/utils/__tests__/pathUtils.test.ts | 40 ----- .../src/utils/__tests__/routesUtils.test.ts | 34 ++++- .../src/utils/codeBlockUtils.ts | 90 ++++++------ .../src/utils/docsUtils.tsx | 139 ++++++------------ .../src/utils/footerUtils.ts | 4 + .../src/utils/generalUtils.ts | 3 + .../src/utils/historyUtils.ts | 28 ++-- .../src/utils/jsUtils.ts | 2 +- .../src/utils/metadataUtils.tsx | 17 ++- .../src/utils/navbarUtils.tsx | 2 +- .../src/utils/pathUtils.ts | 21 --- .../src/utils/reactUtils.tsx | 18 ++- .../src/utils/regexpUtils.ts | 3 +- .../src/utils/routesUtils.ts | 21 ++- .../src/utils/scrollUtils.tsx | 78 +++++----- .../src/utils/searchUtils.ts | 1 + .../src/utils/storageUtils.ts | 34 +++-- .../src/utils/tagsUtils.ts | 5 +- .../src/utils/tocUtils.ts | 23 ++- .../src/utils/useAlternatePageUtils.ts | 22 ++- .../src/utils/useContextualSearchFilters.ts | 15 +- .../src/utils/useLocalPathname.ts | 8 +- .../src/utils/useLocationChange.ts | 19 ++- .../src/utils/usePluralForm.ts | 11 ++ .../src/utils/useThemeConfig.ts | 3 + 45 files changed, 740 insertions(+), 568 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/contexts/docsSidebar.tsx create mode 100644 packages/docusaurus-theme-common/src/contexts/docsVersion.tsx rename packages/docusaurus-theme-common/src/{utils => contexts}/navbarSecondaryMenu.tsx (68%) delete mode 100644 packages/docusaurus-theme-common/src/utils/__tests__/pathUtils.test.ts delete mode 100644 packages/docusaurus-theme-common/src/utils/pathUtils.ts diff --git a/packages/docusaurus-theme-common/src/components/Collapsible/index.tsx b/packages/docusaurus-theme-common/src/components/Collapsible/index.tsx index a608d03772fc..9185ac91c16f 100644 --- a/packages/docusaurus-theme-common/src/components/Collapsible/index.tsx +++ b/packages/docusaurus-theme-common/src/components/Collapsible/index.tsx @@ -20,20 +20,19 @@ import React, { const DefaultAnimationEasing = 'ease-in-out'; -export type UseCollapsibleConfig = { +/** + * This hook is a very thin wrapper around a `useState`. + */ +export function useCollapsible({ + initialState, +}: { + /** The initial state. Will be non-collapsed by default. */ initialState: boolean | (() => boolean); -}; - -export type UseCollapsibleReturns = { +}): { collapsed: boolean; setCollapsed: Dispatch>; toggleCollapsed: () => void; -}; - -// This hook just define the state -export function useCollapsible({ - initialState, -}: UseCollapsibleConfig): UseCollapsibleReturns { +} { const [collapsed, setCollapsed] = useState(initialState ?? false); const toggleCollapsed = useCallback(() => { @@ -152,8 +151,10 @@ type CollapsibleElementType = React.ElementType< Pick, 'className' | 'onTransitionEnd' | 'style'> >; -// Prevent hydration layout shift before animations are handled imperatively -// with JS +/** + * Prevent hydration layout shift before animations are handled imperatively + * with JS + */ function getSSRStyle(collapsed: boolean) { if (ExecutionEnvironment.canUseDOM) { return undefined; @@ -162,16 +163,27 @@ function getSSRStyle(collapsed: boolean) { } type CollapsibleBaseProps = { + /** The actual DOM element to be used in the markup. */ as?: CollapsibleElementType; + /** Initial collapsed state. */ collapsed: boolean; children: ReactNode; + /** Configuration of animation, like `duration` and `easing` */ animation?: CollapsibleAnimationConfig; + /** + * A callback fired when the collapse transition animation ends. Receives + * the **new** collapsed state: e.g. when + * expanding, `collapsed` will be `false`. You can use this for some "cleanup" + * like applying new styles when the container is fully expanded. + */ onCollapseTransitionEnd?: (collapsed: boolean) => void; + /** Class name for the underlying DOM element. */ className?: string; - - // This is mostly useful for details/summary component where ssrStyle is not - // needed (as details are hidden natively) and can mess up with the default - // native behavior of the browser when JS fails to load or is disabled + /** + * This is mostly useful for details/summary component where ssrStyle is not + * needed (as details are hidden natively) and can mess up with the browser's + * native behavior when JS fails to load or is disabled + */ disableSSRStyle?: boolean; }; @@ -233,14 +245,20 @@ function CollapsibleLazy({collapsed, ...props}: CollapsibleBaseProps) { } type CollapsibleProps = CollapsibleBaseProps & { - // Lazy allows to delay the rendering when collapsed => it will render - // children only after hydration, on first expansion - // Required prop: it forces to think if content should be server-rendered - // or not! This has perf impact on the SSR output and html file sizes - // See https://github.com/facebook/docusaurus/issues/4753 + /** + * Delay rendering of the content till first expansion. Marked as required to + * force us to think if content should be server-rendered or not. This has + * perf impact since it reduces html file sizes, but could undermine SEO. + * @see https://github.com/facebook/docusaurus/issues/4753 + */ lazy: boolean; }; +/** + * A headless component providing smooth and uniform collapsing behavior. The + * component will be invisible (zero height) when collapsed. Doesn't provide + * interactivity by itself: collapse state is toggled through props. + */ export function Collapsible({lazy, ...props}: CollapsibleProps): JSX.Element { const Comp = lazy ? CollapsibleLazy : CollapsibleBase; return ; diff --git a/packages/docusaurus-theme-common/src/components/Details/index.tsx b/packages/docusaurus-theme-common/src/components/Details/index.tsx index b8e172d62a4b..ccf7e10b7fb4 100644 --- a/packages/docusaurus-theme-common/src/components/Details/index.tsx +++ b/packages/docusaurus-theme-common/src/components/Details/index.tsx @@ -31,9 +31,14 @@ function hasParent(node: HTMLElement | null, parent: HTMLElement): boolean { } export type DetailsProps = { + /** Summary is provided as props, including the wrapping `` tag */ summary?: ReactElement; } & ComponentProps<'details'>; +/** + * A mostly un-styled `
` element with smooth collapsing. Provides some + * very lightweight styles, but you should bring your UI. + */ export function Details({ summary, children, @@ -45,8 +50,8 @@ export function Details({ const {collapsed, setCollapsed} = useCollapsible({ initialState: !props.open, }); - // Use a separate prop because it must be set only after animation completes - // Otherwise close anim won't work + // Use a separate state for the actual details prop, because it must be set + // only after animation completes, otherwise close animations won't work const [open, setOpen] = useState(props.open); return ( diff --git a/packages/docusaurus-theme-common/src/contexts/announcementBar.tsx b/packages/docusaurus-theme-common/src/contexts/announcementBar.tsx index 01fe0ac84852..f244be2bee2e 100644 --- a/packages/docusaurus-theme-common/src/contexts/announcementBar.tsx +++ b/packages/docusaurus-theme-common/src/contexts/announcementBar.tsx @@ -32,12 +32,18 @@ const isDismissedInStorage = () => const setDismissedInStorage = (bool: boolean) => AnnouncementBarDismissStorage.set(String(bool)); -type AnnouncementBarAPI = { +type ContextValue = { + /** Whether the announcement bar should be displayed. */ readonly isActive: boolean; + /** + * Callback fired when the user closes the announcement. Will be saved. + */ readonly close: () => void; }; -const useAnnouncementBarContextValue = (): AnnouncementBarAPI => { +const Context = React.createContext(null); + +function useContextValue(): ContextValue { const {announcementBar} = useThemeConfig(); const isBrowser = useIsBrowser(); @@ -93,27 +99,19 @@ const useAnnouncementBarContextValue = (): AnnouncementBarAPI => { }), [announcementBar, isClosed, handleClose], ); -}; - -const AnnouncementBarContext = React.createContext( - null, -); +} export function AnnouncementBarProvider({ children, }: { children: ReactNode; }): JSX.Element { - const value = useAnnouncementBarContextValue(); - return ( - - {children} - - ); + const value = useContextValue(); + return {children}; } -export function useAnnouncementBar(): AnnouncementBarAPI { - const api = useContext(AnnouncementBarContext); +export function useAnnouncementBar(): ContextValue { + const api = useContext(Context); if (!api) { throw new ReactContextError('AnnouncementBarProvider'); } diff --git a/packages/docusaurus-theme-common/src/contexts/colorMode.tsx b/packages/docusaurus-theme-common/src/contexts/colorMode.tsx index 7fe480d32b5b..ee7d4eb54f54 100644 --- a/packages/docusaurus-theme-common/src/contexts/colorMode.tsx +++ b/packages/docusaurus-theme-common/src/contexts/colorMode.tsx @@ -20,8 +20,10 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import {createStorageSlot} from '../utils/storageUtils'; import {useThemeConfig} from '../utils/useThemeConfig'; -type ColorModeContextValue = { +type ContextValue = { + /** Current color mode. */ readonly colorMode: ColorMode; + /** Set new color mode. */ readonly setColorMode: (colorMode: ColorMode) => void; // TODO legacy APIs kept for retro-compatibility: deprecate them @@ -30,6 +32,8 @@ type ColorModeContextValue = { readonly setDarkTheme: () => void; }; +const Context = React.createContext(undefined); + const ColorModeStorageKey = 'theme'; const ColorModeStorage = createStorageSlot(ColorModeStorageKey); @@ -44,18 +48,16 @@ export type ColorMode = typeof ColorModes[keyof typeof ColorModes]; const coerceToColorMode = (colorMode?: string | null): ColorMode => colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light; -const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => { - if (!ExecutionEnvironment.canUseDOM) { - return coerceToColorMode(defaultMode); - } - return coerceToColorMode(document.documentElement.getAttribute('data-theme')); -}; +const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => + ExecutionEnvironment.canUseDOM + ? coerceToColorMode(document.documentElement.getAttribute('data-theme')) + : coerceToColorMode(defaultMode); const storeColorMode = (newColorMode: ColorMode) => { ColorModeStorage.set(coerceToColorMode(newColorMode)); }; -function useColorModeContextValue(): ColorModeContextValue { +function useContextValue(): ContextValue { const { colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme}, } = useThemeConfig(); @@ -153,25 +155,17 @@ function useColorModeContextValue(): ColorModeContextValue { ); } -const ColorModeContext = React.createContext( - undefined, -); - export function ColorModeProvider({ children, }: { children: ReactNode; }): JSX.Element { - const contextValue = useColorModeContextValue(); - return ( - - {children} - - ); + const value = useContextValue(); + return {children}; } -export function useColorMode(): ColorModeContextValue { - const context = useContext(ColorModeContext); +export function useColorMode(): ContextValue { + const context = useContext(Context); if (context == null) { throw new ReactContextError( 'ColorModeProvider', diff --git a/packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx b/packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx index 72687024808e..b3d84a78389d 100644 --- a/packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx +++ b/packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx @@ -8,15 +8,30 @@ import React, {type ReactNode, useMemo, useState, useContext} from 'react'; import {ReactContextError} from '../utils/reactUtils'; -const EmptyContext: unique symbol = Symbol('EmptyContext'); -const Context = React.createContext< - DocSidebarItemsExpandedState | typeof EmptyContext ->(EmptyContext); -type DocSidebarItemsExpandedState = { +type ContextValue = { + /** + * The item that the user last opened, `null` when there's none open. On + * initial render, it will always be `null`, which doesn't necessarily mean + * there's no category open (can have 0, 1, or many being initially open). + */ expandedItem: number | null; + /** + * Set the currently expanded item, when the user opens one. Set the value to + * `null` when the user closes an open category. + */ setExpandedItem: (a: number | null) => void; }; +const EmptyContext: unique symbol = Symbol('EmptyContext'); +const Context = React.createContext( + EmptyContext, +); + +/** + * Should be used to wrap one sidebar category level. This provider syncs the + * expanded states of all sibling categories, and categories can choose to + * collapse itself if another one is expanded. + */ export function DocSidebarItemsExpandedStateProvider({ children, }: { @@ -31,10 +46,10 @@ export function DocSidebarItemsExpandedStateProvider({ return {children}; } -export function useDocSidebarItemsExpandedState(): DocSidebarItemsExpandedState { - const contextValue = useContext(Context); - if (contextValue === EmptyContext) { +export function useDocSidebarItemsExpandedState(): ContextValue { + const value = useContext(Context); + if (value === EmptyContext) { throw new ReactContextError('DocSidebarItemsExpandedStateProvider'); } - return contextValue; + return value; } diff --git a/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx b/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx index a1c95d592190..cd4bb935b683 100644 --- a/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx +++ b/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx @@ -54,32 +54,29 @@ const DocsPreferredVersionStorage = { type DocsPreferredVersionName = string | null; -// State for a single docs plugin instance +/** State for a single docs plugin instance */ type DocsPreferredVersionPluginState = { preferredVersionName: DocsPreferredVersionName; }; -// We need to store in state/storage globally -// one preferred version per docs plugin instance -// pluginId => pluginState -type DocsPreferredVersionState = Record< - string, - DocsPreferredVersionPluginState ->; - -// Initial state is always null as we can't read local storage from node SSR -function getInitialState(pluginIds: string[]): DocsPreferredVersionState { - const initialState: DocsPreferredVersionState = {}; - pluginIds.forEach((pluginId) => { - initialState[pluginId] = { - preferredVersionName: null, - }; - }); - return initialState; -} +/** + * We need to store the state in storage globally, with one preferred version + * per docs plugin instance. + */ +type DocsPreferredVersionState = { + [pluginId: string]: DocsPreferredVersionPluginState; +}; -// Read storage for all docs plugins -// Assign to each doc plugin a preferred version (if found) +/** + * Initial state is always null as we can't read local storage from node SSR + */ +const getInitialState = (pluginIds: string[]): DocsPreferredVersionState => + Object.fromEntries(pluginIds.map((id) => [id, {preferredVersionName: null}])); + +/** + * Read storage for all docs plugins, assigning each doc plugin a preferred + * version (if found) + */ function readStorageState({ pluginIds, versionPersistence, @@ -89,9 +86,11 @@ function readStorageState({ versionPersistence: DocsVersionPersistence; allDocsData: Record; }): DocsPreferredVersionState { - // The storage value we read might be stale, - // and belong to a version that does not exist in the site anymore - // In such case, we remove the storage value to avoid downstream errors + /** + * The storage value we read might be stale, and belong to a version that does + * not exist in the site anymore. In such case, we remove the storage value to + * avoid downstream errors. + */ function restorePluginState( pluginId: string, ): DocsPreferredVersionPluginState { @@ -109,20 +108,25 @@ function readStorageState({ DocsPreferredVersionStorage.clear(pluginId, versionPersistence); return {preferredVersionName: null}; } - - const initialState: DocsPreferredVersionState = {}; - pluginIds.forEach((pluginId) => { - initialState[pluginId] = restorePluginState(pluginId); - }); - return initialState; + return Object.fromEntries( + pluginIds.map((id) => [id, restorePluginState(id)]), + ); } function useVersionPersistence(): DocsVersionPersistence { return useThemeConfig().docs.versionPersistence; } -// Value that will be accessible through context: [state,api] -function useContextValue() { +type ContextValue = [ + state: DocsPreferredVersionState, + api: { + savePreferredVersion: (pluginId: string, versionName: string) => void; + }, +]; + +const Context = React.createContext(null); + +function useContextValue(): ContextValue { const allDocsData = useAllDocsData(); const versionPersistence = useVersionPersistence(); const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]); @@ -154,15 +158,22 @@ function useContextValue() { }; }, [versionPersistence]); - return [state, api] as const; + return [state, api]; } -type DocsPreferredVersionContextValue = ReturnType; - -const Context = React.createContext( - null, -); +function DocsPreferredVersionContextProviderUnsafe({ + children, +}: { + children: ReactNode; +}): JSX.Element { + const value = useContextValue(); + return {children}; +} +/** + * This is a maybe-layer. If the docs plugin is not enabled, this provider is a + * simple pass-through. + */ export function DocsPreferredVersionContextProvider({ children, }: { @@ -178,16 +189,7 @@ export function DocsPreferredVersionContextProvider({ return children; } -function DocsPreferredVersionContextProviderUnsafe({ - children, -}: { - children: ReactNode; -}): JSX.Element { - const contextValue = useContextValue(); - return {children}; -} - -function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue { +function useDocsPreferredVersionContext(): ContextValue { const value = useContext(Context); if (!value) { throw new ReactContextError('DocsPreferredVersionContextProvider'); @@ -195,11 +197,14 @@ function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue { return value; } -// Note, the preferredVersion attribute will always be null before mount +/** + * Returns a read-write interface to a plugin's preferred version. + * Note, the `preferredVersion` attribute will always be `null` before mount. + */ export function useDocsPreferredVersion( pluginId: string | undefined = DEFAULT_PLUGIN_ID, ): { - preferredVersion: GlobalVersion | null | undefined; + preferredVersion: GlobalVersion | null; savePreferredVersionName: (versionName: string) => void; } { const docsData = useDocsData(pluginId); @@ -207,9 +212,10 @@ export function useDocsPreferredVersion( const {preferredVersionName} = state[pluginId]!; - const preferredVersion = preferredVersionName - ? docsData.versions.find((version) => version.name === preferredVersionName) - : null; + const preferredVersion = + docsData.versions.find( + (version) => version.name === preferredVersionName, + ) ?? null; const savePreferredVersionName = useCallback( (versionName: string) => { @@ -218,12 +224,12 @@ export function useDocsPreferredVersion( [api, pluginId], ); - return {preferredVersion, savePreferredVersionName} as const; + return {preferredVersion, savePreferredVersionName}; } export function useDocsPreferredVersionByPluginId(): Record< string, - GlobalVersion | null | undefined + GlobalVersion | null > { const allDocsData = useAllDocsData(); const [state] = useDocsPreferredVersionContext(); @@ -232,19 +238,14 @@ export function useDocsPreferredVersionByPluginId(): Record< const docsData = allDocsData[pluginId]!; const {preferredVersionName} = state[pluginId]!; - return preferredVersionName - ? docsData.versions.find( - (version) => version.name === preferredVersionName, - ) - : null; + return ( + docsData.versions.find( + (version) => version.name === preferredVersionName, + ) ?? null + ); } - const pluginIds = Object.keys(allDocsData); - - const result: Record = {}; - pluginIds.forEach((pluginId) => { - result[pluginId] = getPluginIdPreferredVersion(pluginId); - }); - - return result; + return Object.fromEntries( + pluginIds.map((id) => [id, getPluginIdPreferredVersion(id)]), + ); } diff --git a/packages/docusaurus-theme-common/src/contexts/docsSidebar.tsx b/packages/docusaurus-theme-common/src/contexts/docsSidebar.tsx new file mode 100644 index 000000000000..4d2f7caa61d7 --- /dev/null +++ b/packages/docusaurus-theme-common/src/contexts/docsSidebar.tsx @@ -0,0 +1,42 @@ +/** + * 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, useContext} from 'react'; +import type {PropSidebar} from '@docusaurus/plugin-content-docs'; +import {ReactContextError} from '../utils/reactUtils'; + +// Using a Symbol because null is a valid context value (a doc with no sidebar) +// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx +const EmptyContext: unique symbol = Symbol('EmptyContext'); + +const Context = React.createContext( + EmptyContext, +); + +/** + * Provide the current sidebar to your children. + */ +export function DocsSidebarProvider({ + children, + sidebar, +}: { + children: ReactNode; + sidebar: PropSidebar | null; +}): JSX.Element { + return {children}; +} + +/** + * Gets the sidebar that's currently displayed, or `null` if there isn't one + */ +export function useDocsSidebar(): PropSidebar | null { + const sidebar = useContext(Context); + if (sidebar === EmptyContext) { + throw new ReactContextError('DocsSidebarProvider'); + } + return sidebar; +} diff --git a/packages/docusaurus-theme-common/src/contexts/docsVersion.tsx b/packages/docusaurus-theme-common/src/contexts/docsVersion.tsx new file mode 100644 index 000000000000..efa8ffc147d6 --- /dev/null +++ b/packages/docusaurus-theme-common/src/contexts/docsVersion.tsx @@ -0,0 +1,36 @@ +/** + * 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, useContext} from 'react'; +import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs'; +import {ReactContextError} from '../utils/reactUtils'; + +const Context = React.createContext(null); + +/** + * Provide the current version's metadata to your children. + */ +export function DocsVersionProvider({ + children, + version, +}: { + children: ReactNode; + version: PropVersionMetadata | null; +}): JSX.Element { + return {children}; +} + +/** + * Gets the version metadata of the current doc page. + */ +export function useDocsVersion(): PropVersionMetadata { + const version = useContext(Context); + if (version === null) { + throw new ReactContextError('DocsVersionProvider'); + } + return version; +} diff --git a/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx b/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx index 76f77974e6d6..81e04fc391b5 100644 --- a/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx +++ b/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx @@ -6,11 +6,11 @@ */ import React, { - type ReactNode, useCallback, useEffect, useState, useMemo, + type ReactNode, } from 'react'; import {useWindowSize} from '../hooks/useWindowSize'; import {useHistoryPopHandler} from '../utils/historyUtils'; @@ -18,31 +18,38 @@ import {useActivePlugin} from '@docusaurus/plugin-content-docs/client'; import {useThemeConfig} from '../utils/useThemeConfig'; import {ReactContextError} from '../utils/reactUtils'; -type NavbarMobileSidebarContextValue = { +type ContextValue = { + /** + * Mobile sidebar should be disabled in case it's empty, i.e. no secondary + * menu + no navbar items). If disabled, the toggle button should not be + * displayed at all. + */ disabled: boolean; + /** + * Signals whether the actual sidebar should be displayed (contrary to + * `disabled` which is about the toggle button). Sidebar should not visible + * until user interaction to avoid SSR rendering. + */ shouldRender: boolean; - toggle: () => void; + /** The displayed state. Can be toggled with the `toggle` callback. */ shown: boolean; + /** Toggle the `shown` attribute. */ + toggle: () => void; }; -const Context = React.createContext< - NavbarMobileSidebarContextValue | undefined ->(undefined); +const Context = React.createContext(undefined); -// Mobile sidebar can be disabled in case it would lead to an empty sidebar -// In this case it's not useful to display a navbar sidebar toggle button -function useNavbarMobileSidebarDisabled() { +function useIsNavbarMobileSidebarDisabled() { const activeDocPlugin = useActivePlugin(); const {items} = useThemeConfig().navbar; return items.length === 0 && !activeDocPlugin; } -function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue { - const disabled = useNavbarMobileSidebarDisabled(); +function useContextValue(): ContextValue { + const disabled = useIsNavbarMobileSidebarDisabled(); const windowSize = useWindowSize(); - // Mobile sidebar not visible until user interaction: can avoid SSR rendering - const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr'; + const shouldRender = !disabled && windowSize === 'mobile'; const [shown, setShown] = useState(false); @@ -68,14 +75,8 @@ function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue { } }, [windowSize]); - // Return stable context value return useMemo( - () => ({ - disabled, - shouldRender, - toggle, - shown, - }), + () => ({disabled, shouldRender, toggle, shown}), [disabled, shouldRender, toggle, shown], ); } @@ -85,13 +86,13 @@ export function NavbarMobileSidebarProvider({ }: { children: ReactNode; }): JSX.Element { - const value = useNavbarMobileSidebarContextValue(); + const value = useContextValue(); return {children}; } -export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue { +export function useNavbarMobileSidebar(): ContextValue { const context = React.useContext(Context); - if (context == null) { + if (context === undefined) { throw new ReactContextError('NavbarMobileSidebarProvider'); } return context; diff --git a/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenu.tsx b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu.tsx similarity index 68% rename from packages/docusaurus-theme-common/src/utils/navbarSecondaryMenu.tsx rename to packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu.tsx index 3e983234f961..1b6f188915f1 100644 --- a/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenu.tsx +++ b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu.tsx @@ -14,19 +14,8 @@ import React, { type ReactNode, type ComponentType, } from 'react'; -import {ReactContextError, usePrevious} from './reactUtils'; -import {useNavbarMobileSidebar} from '../contexts/navbarMobileSidebar'; - -/* -The idea behind all this is that a specific component must be able to fill a -placeholder in the generic layout. The doc page should be able to fill the -secondary menu of the main mobile navbar. This permits to reduce coupling -between the main layout and the specific page. - -This kind of feature is often called portal/teleport/gateway... various -unmaintained React libs exist. Most up-to-date one: https://github.com/gregberge/react-teleporter -Not sure any of those is safe regarding concurrent mode. - */ +import {ReactContextError, usePrevious} from '../utils/reactUtils'; +import {useNavbarMobileSidebar} from './navbarMobileSidebar'; export type NavbarSecondaryMenuComponent = ComponentType; @@ -34,7 +23,7 @@ type State = { shown: boolean; content: | { - component: ComponentType; + component: NavbarSecondaryMenuComponent; props: object; } | {component: null; props: null}; @@ -45,7 +34,14 @@ const InitialState: State = { content: {component: null, props: null}, }; -function useContextValue() { +type ContextValue = [ + state: State, + setState: React.Dispatch>, +]; + +const Context = React.createContext(null); + +function useContextValue(): ContextValue { const mobileSidebar = useNavbarMobileSidebar(); const [state, setState] = useState(InitialState); @@ -76,21 +72,16 @@ function useContextValue() { } }, [mobileSidebar.shown, hasContent]); - return [state, setState] as const; + return [state, setState]; } -type ContextValue = ReturnType; - -const Context = React.createContext(null); - export function NavbarSecondaryMenuProvider({ children, }: { children: ReactNode; }): JSX.Element { - return ( - {children} - ); + const value = useContextValue(); + return {children}; } function useNavbarSecondaryMenuContext(): ContextValue { @@ -101,7 +92,7 @@ function useNavbarSecondaryMenuContext(): ContextValue { return value; } -function useShallowMemoizedObject>(obj: O) { +function useShallowMemoizedObject(obj: O) { return useMemo( () => obj, // Is this safe? @@ -110,15 +101,22 @@ function useShallowMemoizedObject>(obj: O) { ); } -// Fill the secondary menu placeholder with some real content -export function NavbarSecondaryMenuFiller< - Props extends Record, ->({ +/** + * This component renders nothing by itself, but it fills the placeholder in the + * generic secondary menu layout. This reduces coupling between the main layout + * and the specific page. + * + * This kind of feature is often called portal/teleport/gateway/outlet... + * Various unmaintained React libs exist. Most up-to-date one: + * https://github.com/gregberge/react-teleporter + * Not sure any of those is safe regarding concurrent mode. + */ +export function NavbarSecondaryMenuFiller

({ component, props, }: { - component: NavbarSecondaryMenuComponent; - props: Props; + component: NavbarSecondaryMenuComponent

; + props: P; }): JSX.Element | null { const [, setState] = useNavbarSecondaryMenuContext(); @@ -146,9 +144,16 @@ function renderElement(state: State): JSX.Element | undefined { return undefined; } +/** Wires the logic for rendering the mobile navbar secondary menu. */ export function useNavbarSecondaryMenu(): { + /** Whether secondary menu is displayed. */ shown: boolean; + /** + * Hide the secondary menu; fired either when hiding the entire sidebar, or + * when going back to the primary menu. + */ hide: () => void; + /** The content returned from the current secondary menu filler. */ content: JSX.Element | undefined; } { const [state, setState] = useNavbarSecondaryMenuContext(); @@ -159,11 +164,7 @@ export function useNavbarSecondaryMenu(): { ); return useMemo( - () => ({ - shown: state.shown, - hide, - content: renderElement(state), - }), + () => ({shown: state.shown, hide, content: renderElement(state)}), [hide, state], ); } diff --git a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx index 3b0f14d90cf9..72b561d053b3 100644 --- a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx +++ b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx @@ -18,16 +18,16 @@ import {ReactContextError} from '../utils/reactUtils'; const TAB_CHOICE_PREFIX = 'docusaurus.tab.'; -type TabGroupChoiceContextValue = { +type ContextValue = { + /** A map from `groupId` to the `value` of the saved choice. */ readonly tabGroupChoices: {readonly [groupId: string]: string}; + /** Set the new choice value of a group. */ readonly setTabGroupChoices: (groupId: string, newChoice: string) => void; }; -const TabGroupChoiceContext = React.createContext< - TabGroupChoiceContextValue | undefined ->(undefined); +const Context = React.createContext(undefined); -function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue { +function useContextValue(): ContextValue { const [tabGroupChoices, setChoices] = useState<{ readonly [groupId: string]: string; }>({}); @@ -50,13 +50,18 @@ function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue { } }, []); - return { - tabGroupChoices, - setTabGroupChoices: (groupId: string, newChoice: string) => { + const setTabGroupChoices = useCallback( + (groupId: string, newChoice: string) => { setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice})); setChoiceSyncWithLocalStorage(groupId, newChoice); }, - }; + [setChoiceSyncWithLocalStorage], + ); + + return useMemo( + () => ({tabGroupChoices, setTabGroupChoices}), + [tabGroupChoices, setTabGroupChoices], + ); } export function TabGroupChoiceProvider({ @@ -64,23 +69,12 @@ export function TabGroupChoiceProvider({ }: { children: ReactNode; }): JSX.Element { - const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoiceContextValue(); - const contextValue = useMemo( - () => ({ - tabGroupChoices, - setTabGroupChoices, - }), - [tabGroupChoices, setTabGroupChoices], - ); - return ( - - {children} - - ); + const value = useContextValue(); + return {children}; } -export function useTabGroupChoice(): TabGroupChoiceContextValue { - const context = useContext(TabGroupChoiceContext); +export function useTabGroupChoice(): ContextValue { + const context = useContext(Context); if (context == null) { throw new ReactContextError('TabGroupChoiceProvider'); } diff --git a/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts b/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts index 62e9b3740585..2eb85744e8de 100644 --- a/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts +++ b/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts @@ -9,8 +9,14 @@ import {useState, useCallback, useRef} from 'react'; import {useLocationChange} from '../utils/useLocationChange'; import {useScrollPosition} from '../utils/scrollUtils'; +/** + * Wires the imperative logic of a hideable navbar. + * @param hideOnScroll If `false`, this hook is basically a no-op. + */ export function useHideableNavbar(hideOnScroll: boolean): { + /** A ref to the navbar component. Plug this into the actual element. */ readonly navbarRef: (node: HTMLElement | null) => void; + /** If `false`, the navbar component should not be rendered. */ readonly isNavbarVisible: boolean; } { const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll); @@ -29,7 +35,8 @@ export function useHideableNavbar(hideOnScroll: boolean): { const scrollTop = currentPosition.scrollY; - // It needed for mostly to handle rubber band scrolling + // Needed mostly for handling rubber band scrolling. + // See https://github.com/facebook/docusaurus/pull/5721 if (scrollTop < navbarHeight.current) { setIsNavbarVisible(true); return; @@ -66,8 +73,5 @@ export function useHideableNavbar(hideOnScroll: boolean): { setIsNavbarVisible(true); }); - return { - navbarRef, - isNavbarVisible, - }; + return {navbarRef, isNavbarVisible}; } diff --git a/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts b/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts index be6bfe8ecbf6..7a2ebfcc7a3f 100644 --- a/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts +++ b/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts @@ -12,7 +12,13 @@ import './styles.css'; export const keyboardFocusedClassName = 'navigation-with-keyboard'; /** - * Detect keyboard focus indicator to not show outline for mouse users + * Side-effect that adds the `keyboardFocusedClassName` to the body element when + * the keyboard has been pressed, or removes it when the mouse is clicked. + * + * The presence of this class name signals that the user may be using keyboard + * for navigation, and the theme **must** add focus outline when this class name + * is present. (And optionally not if it's absent, for design purposes) + * * Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 */ export function useKeyboardNavigation(): void { diff --git a/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts b/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts index c35e127fcf67..649eddff447f 100644 --- a/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts +++ b/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts @@ -7,10 +7,13 @@ import {useEffect} from 'react'; +/** + * Side-effect that locks the document body's scroll throughout the lifetime of + * the containing component. e.g. when the mobile sidebar is expanded. + */ export function useLockBodyScroll(lock: boolean = true): void { useEffect(() => { document.body.style.overflow = lock ? 'hidden' : 'visible'; - return () => { document.body.style.overflow = 'visible'; }; diff --git a/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts b/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts index 3c8f84760172..bb8034657baa 100644 --- a/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts +++ b/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts @@ -9,6 +9,10 @@ import defaultTheme from 'prism-react-renderer/themes/palenight'; import {useColorMode} from '../contexts/colorMode'; import {useThemeConfig} from '../utils/useThemeConfig'; +/** + * Returns a color-mode-dependent Prism theme: whatever the user specified in + * the config. Falls back to `palenight`. + */ export function usePrismTheme(): typeof defaultTheme { const {prism} = useThemeConfig(); const {colorMode} = useColorMode(); diff --git a/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts b/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts index f97c43c97cdb..36c1cdaacf0b 100644 --- a/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts +++ b/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts @@ -11,9 +11,22 @@ import {useCallback, useEffect, useState} from 'react'; const SEARCH_PARAM_QUERY = 'q'; +/** Some utility functions around search queries. */ export function useSearchPage(): { + /** + * Works hand-in-hand with `setSearchQuery`; whatever the user has inputted + * into the search box. + */ searchQuery: string; + /** + * Set a new search query. In addition to updating `searchQuery`, this handle + * also mutates the location and appends the query. + */ setSearchQuery: (newSearchQuery: string) => void; + /** + * Given a query, this handle generates the corresponding search page link, + * with base URL prepended. + */ generateSearchPageLink: (targetSearchQuery: string) => string; } { const history = useHistory(); @@ -52,7 +65,9 @@ export function useSearchPage(): { const generateSearchPageLink = useCallback( (targetSearchQuery: string) => // Refer to https://github.com/facebook/docusaurus/pull/2838 - `${baseUrl}search?q=${encodeURIComponent(targetSearchQuery)}`, + `${baseUrl}search?${SEARCH_PARAM_QUERY}=${encodeURIComponent( + targetSearchQuery, + )}`, [baseUrl], ); diff --git a/packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts b/packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts index aca25bf8311e..afd6a12b173f 100644 --- a/packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts +++ b/packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts @@ -11,8 +11,10 @@ import {useThemeConfig} from '../utils/useThemeConfig'; // TODO make the hardcoded theme-classic classnames configurable (or add them // to ThemeClassNames?) -// If the anchor has no height and is just a "marker" in the dom; we'll use the -// parent (normally the link text) rect boundaries instead +/** + * If the anchor has no height and is just a "marker" in the DOM; we'll use the + * parent (normally the link text) rect boundaries instead + */ function getVisibleBoundingClientRect(element: HTMLElement): DOMRect { const rect = element.getBoundingClientRect(); const hasNoHeight = rect.top === rect.bottom; @@ -24,7 +26,7 @@ function getVisibleBoundingClientRect(element: HTMLElement): DOMRect { /** * Considering we divide viewport into 2 zones of each 50vh, this returns true - * if an element is in the first zone (ie, appear in viewport, near the top) + * if an element is in the first zone (i.e., appear in viewport, near the top) */ function isInViewportTopHalf(boundingRect: DOMRect) { return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2; @@ -114,12 +116,23 @@ function useAnchorTopOffsetRef() { } export type TOCHighlightConfig = { + /** A class name that all TOC links share. */ linkClassName: string; + /** The class name applied to the active (highlighted) link. */ linkActiveClassName: string; + /** + * The minimum heading level that the TOC includes. Only headings that are in + * this range will be eligible as "active heading". + */ minHeadingLevel: number; + /** @see {@link TOCHighlightConfig.minHeadingLevel} */ maxHeadingLevel: number; }; +/** + * Side-effect that applies the active class name to the TOC heading that the + * user is currently viewing. Disabled when `config` is undefined. + */ export function useTOCHighlight(config: TOCHighlightConfig | undefined): void { const lastActiveLinkRef = useRef(undefined); diff --git a/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts b/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts index b867cf4fd95a..a9583b199eb0 100644 --- a/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts +++ b/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts @@ -12,12 +12,6 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; const windowSizes = { desktop: 'desktop', mobile: 'mobile', - - // This "ssr" value is very important to handle hydration FOUC / layout shifts - // You have to handle server-rendering explicitly on the call-site - // On the server, you may need to render BOTH the mobile/desktop elements (and - // hide one of them with mediaquery) - // We don't return "undefined" on purpose, to make it more explicit ssr: 'ssr', } as const; @@ -34,13 +28,21 @@ function getWindowSize() { : windowSizes.mobile; } -// Simulate the SSR window size in dev, so that potential hydration FOUC/layout -// shift problems can be seen in dev too! const DevSimulateSSR = process.env.NODE_ENV === 'development' && true; -// This hook returns an enum value on purpose! -// We don't want it to return the actual width value, for resize perf reasons -// We only want to re-render once a breakpoint is crossed +/** + * Gets the current window size as an enum value. We don't want it to return the + * actual width value, so that it only re-renders once a breakpoint is crossed. + * + * It may return `"ssr"`, which is very important to handle hydration FOUC or + * layout shifts. You have to handle it explicitly upfront. On the server, you + * may need to render BOTH the mobile/desktop elements (and hide one of them + * with mediaquery). We don't return `undefined` on purpose, to make it more + * explicit. + * + * In development mode, this hook will still return `"ssr"` for one second, to + * catch potential layout shifts, similar to strict mode calling effects twice. + */ export function useWindowSize(): WindowSize { const [windowSize, setWindowSize] = useState(() => { if (DevSimulateSSR) { diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 4d0a02ef1058..c75f9efdb8e3 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -23,6 +23,8 @@ export { DocSidebarItemsExpandedStateProvider, useDocSidebarItemsExpandedState, } from './contexts/docSidebarItemsExpandedState'; +export {DocsVersionProvider, useDocsVersion} from './contexts/docsVersion'; +export {DocsSidebarProvider, useDocsSidebar} from './contexts/docsSidebar'; export {createStorageSlot, listStorageKeys} from './utils/storageUtils'; @@ -40,11 +42,7 @@ export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils'; export { isDocsPluginEnabled, - DocsVersionProvider, - useDocsVersion, useDocById, - DocsSidebarProvider, - useDocsSidebar, findSidebarCategory, findFirstCategoryLink, useCurrentSidebarCategory, @@ -52,20 +50,13 @@ export { useSidebarBreadcrumbs, } from './utils/docsUtils'; -export {isSamePath} from './utils/pathUtils'; - export {useTitleFormatter} from './utils/generalUtils'; export {usePluralForm} from './utils/usePluralForm'; export {useLocationChange} from './utils/useLocationChange'; -export { - useCollapsible, - Collapsible, - type UseCollapsibleConfig, - type UseCollapsibleReturns, -} from './components/Collapsible'; +export {useCollapsible, Collapsible} from './components/Collapsible'; export {Details, type DetailsProps} from './components/Details'; @@ -124,7 +115,7 @@ export { export {isRegexpStringMatch} from './utils/regexpUtils'; -export {useHomePageRoute} from './utils/routesUtils'; +export {useHomePageRoute, isSamePath} from './utils/routesUtils'; export { PageMetadata, @@ -149,8 +140,8 @@ export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar'; export { useNavbarSecondaryMenu, NavbarSecondaryMenuFiller, -} from './utils/navbarSecondaryMenu'; -export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenu'; + type NavbarSecondaryMenuComponent, +} from './contexts/navbarSecondaryMenu'; export {useHideableNavbar} from './hooks/useHideableNavbar'; export { diff --git a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts index e5b49926357f..ec3ffc11a969 100644 --- a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts +++ b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts @@ -5,10 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -// These class names are used to style page layouts in Docusaurus -// Those are meant to be targeted by user-provided custom CSS selectors // Please do not modify the classnames! This is a breaking change, and annoying // for users! + +/** + * These class names are used to style page layouts in Docusaurus, meant to be + * targeted by user-provided custom CSS selectors. + */ export const ThemeClassNames = { page: { blogListPage: 'blog-list-page', @@ -17,8 +20,8 @@ export const ThemeClassNames = { blogTagPostListPage: 'blog-tags-post-list-page', docsDocPage: 'docs-doc-page', - docsTagsListPage: 'docs-tags-list-page', // List of tags - docsTagDocListPage: 'docs-tags-doc-list-page', // Docs for a tag + docsTagsListPage: 'docs-tags-list-page', + docsTagDocListPage: 'docs-tags-doc-list-page', mdxPage: 'mdx-page', }, @@ -29,8 +32,9 @@ export const ThemeClassNames = { mdxPages: 'mdx-wrapper', }, - // /!\ Please keep the naming convention consistent! - // Something like: "theme-{blog,doc,version,page}?-" + /** + * Follows the naming convention "theme-{blog,doc,version,page}?-" + */ common: { editThisPage: 'theme-edit-this-page', lastUpdated: 'theme-last-updated', diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/pathUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/pathUtils.test.ts deleted file mode 100644 index a35cd30199b9..000000000000 --- a/packages/docusaurus-theme-common/src/utils/__tests__/pathUtils.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 {isSamePath} from '../pathUtils'; - -describe('isSamePath', () => { - it('returns true for compared path without trailing slash', () => { - expect(isSamePath('/docs', '/docs')).toBeTruthy(); - }); - - it('returns true for compared path with trailing slash', () => { - expect(isSamePath('/docs', '/docs/')).toBeTruthy(); - }); - - it('returns true for compared path with different case', () => { - expect(isSamePath('/doCS', '/DOcs')).toBeTruthy(); - }); - - it('returns true for compared path with different case + trailing slash', () => { - expect(isSamePath('/doCS', '/DOcs/')).toBeTruthy(); - }); - - it('returns false for compared path with double trailing slash', () => { - expect(isSamePath('/docs', '/docs//')).toBeFalsy(); - }); - - it('returns true for twice undefined/null', () => { - expect(isSamePath(undefined, undefined)).toBeTruthy(); - expect(isSamePath(undefined, undefined)).toBeTruthy(); - }); - - it('returns false when one undefined', () => { - expect(isSamePath('/docs', undefined)).toBeFalsy(); - expect(isSamePath(undefined, '/docs')).toBeFalsy(); - }); -}); diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/routesUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/routesUtils.test.ts index 305164f36e64..a36e447d72a5 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/routesUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/routesUtils.test.ts @@ -6,7 +6,39 @@ */ import type {Route} from '@docusaurus/types'; -import {findHomePageRoute} from '../routesUtils'; +import {findHomePageRoute, isSamePath} from '../routesUtils'; + +describe('isSamePath', () => { + it('returns true for compared path without trailing slash', () => { + expect(isSamePath('/docs', '/docs')).toBeTruthy(); + }); + + it('returns true for compared path with trailing slash', () => { + expect(isSamePath('/docs', '/docs/')).toBeTruthy(); + }); + + it('returns true for compared path with different case', () => { + expect(isSamePath('/doCS', '/DOcs')).toBeTruthy(); + }); + + it('returns true for compared path with different case + trailing slash', () => { + expect(isSamePath('/doCS', '/DOcs/')).toBeTruthy(); + }); + + it('returns false for compared path with double trailing slash', () => { + expect(isSamePath('/docs', '/docs//')).toBeFalsy(); + }); + + it('returns true for twice undefined/null', () => { + expect(isSamePath(undefined, undefined)).toBeTruthy(); + expect(isSamePath(undefined, undefined)).toBeTruthy(); + }); + + it('returns false when one undefined', () => { + expect(isSamePath('/docs', undefined)).toBeFalsy(); + expect(isSamePath(undefined, '/docs')).toBeFalsy(); + }); +}); describe('findHomePageRoute', () => { const homePage: Route = { diff --git a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts index 3f4b2d7a2e93..8b89983bcbac 100644 --- a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts @@ -10,47 +10,24 @@ import rangeParser from 'parse-numeric-range'; const codeBlockTitleRegex = /title=(?["'])(?.*?)\1/; const highlightLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; -const commentTypes = ['js', 'jsBlock', 'jsx', 'python', 'html'] as const; -type CommentType = typeof commentTypes[number]; - -type CommentPattern = { - start: string; - end: string; -}; - // Supported types of highlight comments -const commentPatterns: Record<CommentType, CommentPattern> = { - js: { - start: '\\/\\/', - end: '', - }, - jsBlock: { - start: '\\/\\*', - end: '\\*\\/', - }, - jsx: { - start: '\\{\\s*\\/\\*', - end: '\\*\\/\\s*\\}', - }, - python: { - start: '#', - end: '', - }, - html: { - start: '<!--', - end: '-->', - }, +const commentPatterns = { + js: {start: '\\/\\/', end: ''}, + jsBlock: {start: '\\/\\*', end: '\\*\\/'}, + jsx: {start: '\\{\\s*\\/\\*', end: '\\*\\/\\s*\\}'}, + python: {start: '#', end: ''}, + html: {start: '<!--', end: '-->'}, }; +type CommentType = keyof typeof commentPatterns; + const magicCommentDirectives = [ 'highlight-next-line', 'highlight-start', 'highlight-end', ]; -const getMagicCommentDirectiveRegex = ( - languages: readonly CommentType[] = commentTypes, -) => { +function getCommentPattern(languages: CommentType[]) { // to be more reliable, the opening and closing comment must match const commentPattern = languages .map((lang) => { @@ -60,38 +37,45 @@ const getMagicCommentDirectiveRegex = ( .join('|'); // white space is allowed, but otherwise it should be on it's own line return new RegExp(`^\\s*(?:${commentPattern})\\s*$`); -}; +} -// select comment styles based on language -const magicCommentDirectiveRegex = (lang: string) => { +/** + * Select comment styles based on language + */ +function getAllMagicCommentDirectiveStyles(lang: string) { switch (lang) { case 'js': case 'javascript': case 'ts': case 'typescript': - return getMagicCommentDirectiveRegex(['js', 'jsBlock']); + return getCommentPattern(['js', 'jsBlock']); case 'jsx': case 'tsx': - return getMagicCommentDirectiveRegex(['js', 'jsBlock', 'jsx']); + return getCommentPattern(['js', 'jsBlock', 'jsx']); case 'html': - return getMagicCommentDirectiveRegex(['js', 'jsBlock', 'html']); + return getCommentPattern(['js', 'jsBlock', 'html']); case 'python': case 'py': - return getMagicCommentDirectiveRegex(['python']); + return getCommentPattern(['python']); default: // all comment types - return getMagicCommentDirectiveRegex(); + return getCommentPattern(Object.keys(commentPatterns) as CommentType[]); } -}; +} export function parseCodeBlockTitle(metastring?: string): string { return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? ''; } +/** + * Gets the language name from the class name (set by MDX). + * e.g. `"language-javascript"` => `"javascript"`. + * Returns undefined if there is no language class name. + */ export function parseLanguage(className: string): string | undefined { const languageClassName = className .split(' ') @@ -100,15 +84,33 @@ export function parseLanguage(className: string): string | undefined { } /** - * @param metastring The highlight range declared here starts at 1 - * @returns Note: all line numbers start at 0, not 1 + * Parses the code content, strips away any magic comments, and returns the + * clean content and the highlighted lines marked by the comments or metastring. + * + * If the metastring contains highlight range, the `content` will be returned + * as-is without any parsing. + * + * @param content The raw code with magic comments. Trailing newline will be + * trimmed upfront. + * @param metastring The full metastring, as received from MDX. Highlight range + * declared here starts at 1. + * @param language Language of the code block, used to determine which kinds of + * magic comment styles to enable. */ export function parseLines( content: string, metastring?: string, language?: string, ): { + /** + * The highlighted lines, 0-indexed. e.g. `[0, 1, 4]` means the 1st, 2nd, and + * 5th lines are highlighted. + */ highlightLines: number[]; + /** + * The clean code without any magic comments (only if highlight range isn't + * present in the metastring). + */ code: string; } { let code = content.replace(/\n$/, ''); @@ -124,7 +126,7 @@ export function parseLines( if (language === undefined) { return {highlightLines: [], code}; } - const directiveRegex = magicCommentDirectiveRegex(language); + const directiveRegex = getAllMagicCommentDirectiveStyles(language); // go through line by line const lines = code.split('\n'); let highlightBlockStart: number; diff --git a/packages/docusaurus-theme-common/src/utils/docsUtils.tsx b/packages/docusaurus-theme-common/src/utils/docsUtils.tsx index 7d51244caab3..2a16dc6f8eb0 100644 --- a/packages/docusaurus-theme-common/src/utils/docsUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/docsUtils.tsx @@ -5,57 +5,32 @@ * LICENSE file in the root directory of this source tree. */ -import React, {type ReactNode, useContext} from 'react'; import { - useActivePlugin, useAllDocsData, + useActivePlugin, } from '@docusaurus/plugin-content-docs/client'; import type { PropSidebar, PropSidebarItem, PropSidebarItemCategory, PropVersionDoc, - PropVersionMetadata, PropSidebarBreadcrumbsItem, } from '@docusaurus/plugin-content-docs'; -import {isSamePath} from './pathUtils'; -import {ReactContextError} from './reactUtils'; +import {useDocsVersion} from '../contexts/docsVersion'; +import {useDocsSidebar} from '../contexts/docsSidebar'; +import {isSamePath} from './routesUtils'; import {useLocation} from '@docusaurus/router'; // TODO not ideal, see also "useDocs" export const isDocsPluginEnabled: boolean = !!useAllDocsData; -// Using a Symbol because null is a valid context value (a doc with no sidebar) -// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx -const EmptyContextValue: unique symbol = Symbol('EmptyContext'); - -const DocsVersionContext = React.createContext< - PropVersionMetadata | typeof EmptyContextValue ->(EmptyContextValue); - -export function DocsVersionProvider({ - children, - version, -}: { - children: ReactNode; - version: PropVersionMetadata | typeof EmptyContextValue; -}): JSX.Element { - return ( - <DocsVersionContext.Provider value={version}> - {children} - </DocsVersionContext.Provider> - ); -} - -export function useDocsVersion(): PropVersionMetadata { - const version = useContext(DocsVersionContext); - if (version === EmptyContextValue) { - throw new ReactContextError('DocsVersionProvider'); - } - return version; -} - +/** + * 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(); @@ -69,34 +44,9 @@ export function useDocById(id: string | undefined): PropVersionDoc | undefined { return doc; } -const DocsSidebarContext = React.createContext< - PropSidebar | null | typeof EmptyContextValue ->(EmptyContextValue); - -export function DocsSidebarProvider({ - children, - sidebar, -}: { - children: ReactNode; - sidebar: PropSidebar | null; -}): JSX.Element { - return ( - <DocsSidebarContext.Provider value={sidebar}> - {children} - </DocsSidebarContext.Provider> - ); -} - -export function useDocsSidebar(): PropSidebar | null { - const sidebar = useContext(DocsSidebarContext); - if (sidebar === EmptyContextValue) { - throw new ReactContextError('DocsSidebarProvider'); - } - return sidebar; -} - -// Use the components props and the sidebar in context -// to get back the related sidebar category that we want to render +/** + * Pure function, similar to `Array#find`, but works on the sidebar tree. + */ export function findSidebarCategory( sidebar: PropSidebar, predicate: (category: PropSidebarItemCategory) => boolean, @@ -115,7 +65,10 @@ export function findSidebarCategory( return undefined; } -// If a category card has no link => link to the first subItem having a link +/** + * 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 { @@ -142,6 +95,10 @@ export function findFirstCategoryLink( 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(); @@ -153,47 +110,53 @@ export function useCurrentSidebarCategory(): PropSidebarItemCategory { ); if (!category) { throw new Error( - `Unexpected: sidebar category could not be found for pathname='${pathname}'. -Hook useCurrentSidebarCategory() should only be used on Category pages`, + `${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`, ); } return category; } -function containsActiveSidebarItem( +const isActive = (testedPath: string | undefined, activePath: string) => + typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath); +const containsActiveSidebarItem = ( items: PropSidebarItem[], activePath: string, -): boolean { - return items.some((subItem) => isActiveSidebarItem(subItem, activePath)); -} +) => 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 { - const isActive = (testedPath: string | undefined) => - typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath); - if (item.type === 'link') { - return isActive(item.href); + return isActive(item.href, activePath); } if (item.type === 'category') { return ( - isActive(item.href) || containsActiveSidebarItem(item.items, activePath) + isActive(item.href, activePath) || + containsActiveSidebarItem(item.items, activePath) ); } return false; } -function getBreadcrumbs({ - sidebar, - pathname, -}: { - sidebar: PropSidebar; - pathname: string; -}): PropSidebarBreadcrumbsItem[] { +/** + * 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) { @@ -215,15 +178,3 @@ function getBreadcrumbs({ 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/packages/docusaurus-theme-common/src/utils/footerUtils.ts b/packages/docusaurus-theme-common/src/utils/footerUtils.ts index 2ced0cd6df17..28add85dceee 100644 --- a/packages/docusaurus-theme-common/src/utils/footerUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/footerUtils.ts @@ -7,6 +7,10 @@ import type {MultiColumnFooter, SimpleFooter} from './useThemeConfig'; +/** + * A rough duck-typing about whether the `footer.links` is intended to be multi- + * column. + */ export function isMultiColumnFooterLinks( links: MultiColumnFooter['links'] | SimpleFooter['links'], ): links is MultiColumnFooter['links'] { diff --git a/packages/docusaurus-theme-common/src/utils/generalUtils.ts b/packages/docusaurus-theme-common/src/utils/generalUtils.ts index ba98a144bfa4..c6732b82ccf6 100644 --- a/packages/docusaurus-theme-common/src/utils/generalUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/generalUtils.ts @@ -7,6 +7,9 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +/** + * Formats the page's title based on relevant site config and other contexts. + */ export function useTitleFormatter(title?: string | undefined): string { const {siteConfig} = useDocusaurusContext(); const {title: siteTitle, titleDelimiter} = siteConfig; diff --git a/packages/docusaurus-theme-common/src/utils/historyUtils.ts b/packages/docusaurus-theme-common/src/utils/historyUtils.ts index c0a289dcc1f4..129396b44e7d 100644 --- a/packages/docusaurus-theme-common/src/utils/historyUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/historyUtils.ts @@ -5,44 +5,38 @@ * LICENSE file in the root directory of this source tree. */ -import {useEffect, useRef} from 'react'; +import {useEffect} from 'react'; import {useHistory} from '@docusaurus/router'; +import {useDynamicCallback} from './reactUtils'; import type {Location, Action} from 'history'; type HistoryBlockHandler = (location: Location, action: Action) => void | false; /** * Permits to register a handler that will be called on history actions (pop, - * push, replace) If the handler returns false, the navigation transition will - * be blocked/cancelled + * push, replace). If the handler returns `false`, the navigation transition + * will be blocked/cancelled. */ -export function useHistoryActionHandler(handler: HistoryBlockHandler): void { +function useHistoryActionHandler(handler: HistoryBlockHandler): void { const {block} = useHistory(); - - // Avoid stale closure issues without triggering useless re-renders - const lastHandlerRef = useRef(handler); - useEffect(() => { - lastHandlerRef.current = handler; - }, [handler]); - + const stableHandler = useDynamicCallback(handler); useEffect( - () => - // See https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md - block((location, action) => lastHandlerRef.current(location, action)), - [block, lastHandlerRef], + // See https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md + () => block((location, action) => stableHandler(location, action)), + [block, stableHandler], ); } /** * Permits to register a handler that will be called on history pop navigation - * (backward/forward) If the handler returns false, the backward/forward + * (backward/forward). If the handler returns `false`, the backward/forward * transition will be blocked. Unfortunately there's no good way to detect the * "direction" (backward/forward) of the POP event. */ export function useHistoryPopHandler(handler: HistoryBlockHandler): void { useHistoryActionHandler((location, action) => { if (action === 'POP') { - // Eventually block navigation if handler returns false + // Maybe block navigation if handler returns false return handler(location, action); } // Don't block other navigation actions diff --git a/packages/docusaurus-theme-common/src/utils/jsUtils.ts b/packages/docusaurus-theme-common/src/utils/jsUtils.ts index 5a4c43aca9d3..740a5a8b61df 100644 --- a/packages/docusaurus-theme-common/src/utils/jsUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/jsUtils.ts @@ -26,7 +26,7 @@ export function duplicates<T>( } /** - * Remove duplicate array items (similar to _.uniq) + * Remove duplicate array items (similar to `_.uniq`) * @param arr The array. * @returns An array with duplicate elements removed by reference comparison. */ diff --git a/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx b/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx index 9de31637150e..1cd7c226b78a 100644 --- a/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx @@ -20,7 +20,10 @@ interface PageMetadataProps { readonly children?: ReactNode; } -// Helper component to manipulate page metadata and override site defaults +/** + * Helper component to manipulate page metadata and override site defaults. + * Works in the same way as Helmet. + */ export function PageMetadata({ title, description, @@ -59,8 +62,12 @@ export function PageMetadata({ const HtmlClassNameContext = React.createContext<string | undefined>(undefined); -// This wrapper is necessary because Helmet does not "merge" classes -// See https://github.com/staylor/react-helmet-async/issues/161 +/** + * Every layer of this provider will append a class name to the HTML element. + * There's no consumer for this hook: it's side-effect-only. This wrapper is + * necessary because Helmet does not "merge" classes. + * @see https://github.com/staylor/react-helmet-async/issues/161 + */ export function HtmlClassNameProvider({ className: classNameProp, children, @@ -87,6 +94,10 @@ function pluginNameToClassName(pluginName: string) { )}`; } +/** + * A very thin wrapper around `HtmlClassNameProvider` that adds the plugin ID + + * name to the HTML class name. + */ export function PluginHtmlClassNameProvider({ children, }: { diff --git a/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx b/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx index b9433574caae..cb93708d3fb5 100644 --- a/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx @@ -7,7 +7,7 @@ import React, {type ReactNode} from 'react'; import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar'; -import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenu'; +import {NavbarSecondaryMenuProvider} from '../contexts/navbarSecondaryMenu'; const DefaultNavItemPosition = 'right'; diff --git a/packages/docusaurus-theme-common/src/utils/pathUtils.ts b/packages/docusaurus-theme-common/src/utils/pathUtils.ts deleted file mode 100644 index a3eacf47238b..000000000000 --- a/packages/docusaurus-theme-common/src/utils/pathUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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. - */ - -/** - * Compare the 2 paths, case insensitive and ignoring trailing slash - */ -export const isSamePath = ( - path1: string | undefined, - path2: string | undefined, -): boolean => { - const normalize = (pathname: string | undefined) => - (!pathname || pathname?.endsWith('/') - ? pathname - : `${pathname}/` - )?.toLowerCase(); - return normalize(path1) === normalize(path2); -}; diff --git a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx index b2e5d5c0436b..09cb1b7b52e7 100644 --- a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx @@ -9,11 +9,12 @@ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; /** - * This hook is like useLayoutEffect, but without the SSR warning - * It seems hacky but it's used in many React libs (Redux, Formik...) + * This hook is like `useLayoutEffect`, but without the SSR warning. + * It seems hacky but it's used in many React libs (Redux, Formik...). * Also mentioned here: https://github.com/facebook/react/issues/16956 + * * It is useful when you need to update a ref as soon as possible after a React - * render (before `useEffect`) + * render (before `useEffect`). */ export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM ? useLayoutEffect @@ -23,10 +24,11 @@ export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM * Permits to transform an unstable callback (like an arrow function provided as * props) to a "stable" callback that is safe to use in a `useEffect` dependency * array. Useful to avoid React stale closure problems + avoid useless effect - * re-executions + * re-executions. * * Workaround until the React team recommends a good solution, see * https://github.com/facebook/react/issues/16956 + * * This generally works but has some potential drawbacks, such as * https://github.com/facebook/react/issues/16956#issuecomment-536636418 */ @@ -44,6 +46,9 @@ export function useDynamicCallback<T extends (...args: never[]) => unknown>( return useCallback<T>((...args) => ref.current(...args), []); } +/** + * Gets `value` from the last render. + */ export function usePrevious<T>(value: T): T | undefined { const ref = useRef<T>(); @@ -54,6 +59,11 @@ export function usePrevious<T>(value: T): T | undefined { return ref.current; } +/** + * This error is thrown when a context is consumed outside its provider. Allows + * reusing a generic error message format and reduces bundle size. The hook's + * name will be extracted from its stack, so only the provider's name is needed. + */ export class ReactContextError extends Error { constructor(providerName: string, additionalInfo?: string) { super(); diff --git a/packages/docusaurus-theme-common/src/utils/regexpUtils.ts b/packages/docusaurus-theme-common/src/utils/regexpUtils.ts index 36d8b8e8de97..4e87fe68c2ef 100644 --- a/packages/docusaurus-theme-common/src/utils/regexpUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/regexpUtils.ts @@ -6,7 +6,8 @@ */ /** - * Converts an optional string into a Regex case insensitive and global + * Matches a string regex (as provided from the config) against a target in a + * null-safe fashion, case insensitive and global. */ export function isRegexpStringMatch( regexAsString?: string, diff --git a/packages/docusaurus-theme-common/src/utils/routesUtils.ts b/packages/docusaurus-theme-common/src/utils/routesUtils.ts index e826771119c6..6a2532354987 100644 --- a/packages/docusaurus-theme-common/src/utils/routesUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/routesUtils.ts @@ -5,11 +5,26 @@ * LICENSE file in the root directory of this source tree. */ -import generatedRoutes from '@generated/routes'; import {useMemo} from 'react'; +import generatedRoutes from '@generated/routes'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import type {Route} from '@docusaurus/types'; +/** + * Compare the 2 paths, case insensitive and ignoring trailing slash + */ +export function isSamePath( + path1: string | undefined, + path2: string | undefined, +): boolean { + const normalize = (pathname: string | undefined) => + (!pathname || pathname?.endsWith('/') + ? pathname + : `${pathname}/` + )?.toLowerCase(); + return normalize(path1) === normalize(path2); +} + /** * Note that sites don't always have a homepage in practice, so we can't assume * that linking to '/' is always safe. @@ -47,6 +62,10 @@ export function findHomePageRoute({ return doFindHomePageRoute(initialRoutes); } +/** + * Fetches the route that points to "/". Use this instead of the naive "/", + * because the homepage may not exist. + */ export function useHomePageRoute(): Route | undefined { const {baseUrl} = useDocusaurusContext().siteConfig; return useMemo( diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx index 74e372a1d999..f4f9bae94f07 100644 --- a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -17,25 +17,12 @@ import React, { import {useDynamicCallback, ReactContextError} from './reactUtils'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; -/** - * We need a way to update the scroll position while ignoring scroll events - * without affecting Navbar/BackToTop visibility - * - * This API permits to temporarily disable/ignore scroll events - * Motivated by https://github.com/facebook/docusaurus/pull/5618 - */ type ScrollController = { - /** - * A boolean ref tracking whether scroll events are enabled - */ + /** A boolean ref tracking whether scroll events are enabled. */ scrollEventsEnabledRef: React.MutableRefObject<boolean>; - /** - * Enables scroll events in `useScrollPosition` - */ + /** Enable scroll events in `useScrollPosition`. */ enableScrollEvents: () => void; - /** - * Disables scroll events in `useScrollPosition` - */ + /** Disable scroll events in `useScrollPosition`. */ disableScrollEvents: () => void; }; @@ -65,13 +52,21 @@ export function ScrollControllerProvider({ }: { children: ReactNode; }): JSX.Element { + const value = useScrollControllerContextValue(); return ( - <ScrollMonitorContext.Provider value={useScrollControllerContextValue()}> + <ScrollMonitorContext.Provider value={value}> {children} </ScrollMonitorContext.Provider> ); } +/** + * We need a way to update the scroll position while ignoring scroll events + * so as not to toggle Navbar/BackToTop visibility. + * + * This API permits to temporarily disable/ignore scroll events. Motivated by + * https://github.com/facebook/docusaurus/pull/5618 + */ export function useScrollController(): ScrollController { const context = useContext(ScrollMonitorContext); if (context == null) { @@ -80,6 +75,8 @@ export function useScrollController(): ScrollController { return context; } +type ScrollPosition = {scrollX: number; scrollY: number}; + const getScrollPosition = (): ScrollPosition | null => ExecutionEnvironment.canUseDOM ? { @@ -88,8 +85,14 @@ const getScrollPosition = (): ScrollPosition | null => } : null; -type ScrollPosition = {scrollX: number; scrollY: number}; - +/** + * This hook fires an effect when the scroll position changes. The effect will + * be provided with the before/after scroll positions. Note that the effect may + * not be always run: if scrolling is disabled through `useScrollController`, it + * will be a no-op. + * + * @see {@link useScrollController} + */ export function useScrollPosition( effect: ( position: ScrollPosition, @@ -133,13 +136,11 @@ export function useScrollPosition( } type UseScrollPositionSaver = { - /** - * Measure the top of an element, and store the details - */ + /** Measure the top of an element, and store the details. */ save: (elem: HTMLElement) => void; /** * Restore the page position to keep the stored element's position from - * the top of the viewport, and remove the stored details + * the top of the viewport, and remove the stored details. */ restore: () => {restored: boolean}; }; @@ -177,21 +178,24 @@ function useScrollPositionSaver(): UseScrollPositionSaver { return useMemo(() => ({save, restore}), [restore, save]); } -type UseScrollPositionBlockerReturn = { - blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void; -}; - /** - * This hook permits to "block" the scroll position of a dom element + * This hook permits to "block" the scroll position of a DOM element. * The idea is that we should be able to update DOM content above this element - * but the screen position of this element should not change + * but the screen position of this element should not change. + * + * Feature motivated by the Tabs groups: clicking on a tab may affect tabs of + * the same group upper in the tree, yet to avoid a bad UX, the clicked tab must + * remain under the user mouse. * - * Feature motivated by the Tabs groups: - * clicking on a tab may affect tabs of the same group upper in the tree - * Yet to avoid a bad UX, the clicked tab must remain under the user mouse! - * See GIF here: https://github.com/facebook/docusaurus/pull/5618 + * @see https://github.com/facebook/docusaurus/pull/5618 */ -export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn { +export function useScrollPositionBlocker(): { + /** + * Takes an element, and keeps its screen position no matter what's getting + * rendered above it, until the next render. + */ + blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void; +} { const scrollController = useScrollController(); const scrollPositionSaver = useScrollPositionSaver(); @@ -207,9 +211,9 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn { const {restored} = scrollPositionSaver.restore(); nextLayoutEffectCallbackRef.current = undefined; - // Restoring the former scroll position will trigger a scroll event - // We need to wait for next scroll event to happen - // before enabling again the scrollController events + // Restoring the former scroll position will trigger a scroll event. We + // need to wait for next scroll event to happen before enabling the + // scrollController events again. if (restored) { const handleScrollRestoreEvent = () => { scrollController.enableScrollEvents(); diff --git a/packages/docusaurus-theme-common/src/utils/searchUtils.ts b/packages/docusaurus-theme-common/src/utils/searchUtils.ts index daa93c9c4db1..0b2f801f0e37 100644 --- a/packages/docusaurus-theme-common/src/utils/searchUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/searchUtils.ts @@ -7,6 +7,7 @@ export const DEFAULT_SEARCH_TAG = 'default'; +/** The search tag to append as each doc's metadata. */ export function docVersionSearchTag( pluginId: string, versionName: string, diff --git a/packages/docusaurus-theme-common/src/utils/storageUtils.ts b/packages/docusaurus-theme-common/src/utils/storageUtils.ts index cd03b6417087..d5dcbdc8c3f5 100644 --- a/packages/docusaurus-theme-common/src/utils/storageUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/storageUtils.ts @@ -11,8 +11,12 @@ export type StorageType = typeof StorageTypes[number]; const DefaultStorageType: StorageType = 'localStorage'; -// Will return null browser storage is unavailable (like running Docusaurus in -// iframe) See https://github.com/facebook/docusaurus/pull/4501 +/** + * Will return `null` if browser storage is unavailable (like running Docusaurus + * in an iframe). This should NOT be called in SSR. + * + * @see https://github.com/facebook/docusaurus/pull/4501 + */ function getBrowserStorage( storageType: StorageType = DefaultStorageType, ): Storage | null { @@ -32,11 +36,12 @@ function getBrowserStorage( } } +let hasLoggedBrowserStorageNotAvailableWarning = false; /** - * Poor man's memoization to avoid logging multiple times the same warning - * Sometimes, localStorage/sessionStorage is unavailable due to browser policies + * Poor man's memoization to avoid logging multiple times the same warning. + * Sometimes, `localStorage`/`sessionStorage` is unavailable due to browser + * policies. */ -let hasLoggedBrowserStorageNotAvailableWarning = false; function logOnceBrowserStorageNotAvailableWarning(error: Error) { if (!hasLoggedBrowserStorageNotAvailableWarning) { console.warn( @@ -61,7 +66,7 @@ const NoopStorageSlot: StorageSlot = { del: () => {}, }; -// Fail-fast, as storage APIs should not be used during the SSR process +// Fail-fast, as storage APIs should not be used during the SSR process function createServerStorageSlot(key: string): StorageSlot { function throwError(): never { throw new Error(`Illegal storage API usage for storage key "${key}". @@ -77,16 +82,19 @@ Please only call storage APIs in effects and event handlers.`); } /** - * Creates an object for accessing a particular key in localStorage. - * The API is fail-safe, and usage of browser storage should be considered + * Creates an interface to work on a particular key in the storage model. + * Note that this function only initializes the interface, but doesn't allocate + * anything by itself (i.e. no side-effects). + * + * The API is fail-safe, since usage of browser storage should be considered * unreliable. Local storage might simply be unavailable (iframe + browser * security) or operations might fail individually. Please assume that using - * this API can be a NO-OP. See also https://github.com/facebook/docusaurus/issues/6036 + * this API can be a no-op. See also https://github.com/facebook/docusaurus/issues/6036 */ -export const createStorageSlot = ( +export function createStorageSlot( key: string, options?: {persistence?: StorageType}, -): StorageSlot => { +): StorageSlot { if (typeof window === 'undefined') { return createServerStorageSlot(key); } @@ -121,10 +129,10 @@ export const createStorageSlot = ( } }, }; -}; +} /** - * Returns a list of all the keys currently stored in browser storage + * Returns a list of all the keys currently stored in browser storage, * or an empty list if browser storage can't be accessed. */ export function listStorageKeys( diff --git a/packages/docusaurus-theme-common/src/utils/tagsUtils.ts b/packages/docusaurus-theme-common/src/utils/tagsUtils.ts index 3c1aee95c015..4405a8631d27 100644 --- a/packages/docusaurus-theme-common/src/utils/tagsUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/tagsUtils.ts @@ -26,10 +26,13 @@ function getTagLetter(tag: string): string { return tag[0]!.toUpperCase(); } +/** + * Takes a list of tags (as provided by the content plugins), and groups them by + * their initials. + */ export function listTagsByLetters( tags: readonly TagsListItem[], ): TagLetterEntry[] { - // Group by letters const groups: Record<string, TagsListItem[]> = {}; Object.values(tags).forEach((tag) => { const letter = getTagLetter(tag.name); diff --git a/packages/docusaurus-theme-common/src/utils/tocUtils.ts b/packages/docusaurus-theme-common/src/utils/tocUtils.ts index d02ed1b472ca..c5a7fc9c6659 100644 --- a/packages/docusaurus-theme-common/src/utils/tocUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/tocUtils.ts @@ -52,6 +52,10 @@ function treeifyTOC(flatTOC: readonly TOCItem[]): TOCTreeNode[] { return rootNodes; } +/** + * Takes a flat TOC list (from the MDX loader) and treeifies it into what the + * TOC components expect. Memoized for performance. + */ export function useTreeifiedTOC(toc: TOCItem[]): readonly TOCTreeNode[] { return useMemo(() => treeifyTOC(toc), [toc]); } @@ -87,6 +91,18 @@ function filterTOC({ }); } +/** + * Takes a flat TOC list (from the MDX loader) and treeifies it into what the + * TOC components expect, applying the `minHeadingLevel` and `maxHeadingLevel`. + * Memoized for performance. + * + * **Important**: this is not the same as `useTreeifiedTOC(toc.filter(...))`, + * because we have to filter the TOC after it has been treeified. This is mostly + * to ensure that weird TOC structures preserve their semantics. For example, an + * h3-h2-h4 sequence should not be treeified as an "h3 > h4" hierarchy with + * min=3, max=4, but should rather be "[h3, h4]" (since the h2 heading has split + * the two headings and they are not parents) + */ export function useFilteredAndTreeifiedTOC({ toc, minHeadingLevel, @@ -97,12 +113,7 @@ export function useFilteredAndTreeifiedTOC({ maxHeadingLevel: number; }): readonly TOCTreeNode[] { return useMemo( - () => - // Note: we have to filter the TOC after it has been treeified. This is - // mostly to ensure that weird TOC structures preserve their semantics. - // For example, an h3-h2-h4 sequence should not be treeified as an h3 > h4 - // hierarchy with min=3, max=4, but should rather be [h3, h4] - filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}), + () => filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}), [toc, minHeadingLevel, maxHeadingLevel], ); } diff --git a/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts b/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts index 29ce6f14fecf..ae0e55dcddff 100644 --- a/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts @@ -8,12 +8,26 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useLocation} from '@docusaurus/router'; -// Permits to obtain the url of the current page in another locale -// Useful to generate hreflang meta headers etc... -// See https://developers.google.com/search/docs/advanced/crawling/localized-versions +/** + * Permits to obtain the url of the current page in another locale, useful to + * generate hreflang meta headers etc... + * + * @see https://developers.google.com/search/docs/advanced/crawling/localized-versions + */ export function useAlternatePageUtils(): { + /** + * Everything (pathname, base URL, etc.) is read from the context. Just tell + * it which locale to link to and it will give you the alternate link for the + * current page. + */ createUrl: ({ + /** The locale name to link to. */ locale, + /** + * For hreflang SEO headers, we need it to be fully qualified (full + * protocol/domain/path...); but for locale dropdowns, using a pathname is + * good enough. + */ fullyQualified, }: { locale: string; @@ -46,8 +60,6 @@ export function useAlternatePageUtils(): { fullyQualified, }: { locale: string; - // For hreflang SEO headers, we need it to be fully qualified (full - // protocol/domain/path...) or locale dropdown, using a path is good enough fullyQualified: boolean; }) { return `${fullyQualified ? url : ''}${getLocalizedBaseUrl( diff --git a/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts b/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts index 28f9231a371a..ef84a859a33d 100644 --- a/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts +++ b/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts @@ -13,14 +13,17 @@ import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersio import {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './searchUtils'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -export type useContextualSearchFiltersReturns = { +/** + * Gets the relevant context information for contextual search. + * + * The value is generic and not coupled to Algolia/DocSearch, since we may want + * to support multiple search engines, or allowing users to use their own search + * engine solution. + */ +export function useContextualSearchFilters(): { locale: string; tags: string[]; -}; - -// We may want to support multiple search engines, don't couple that to -// Algolia/DocSearch. Maybe users want to use their own search engine solution -export function useContextualSearchFilters(): useContextualSearchFiltersReturns { +} { const {i18n} = useDocusaurusContext(); const allDocsData = useAllDocsData(); const activePluginAndVersion = useActivePluginAndVersion(); diff --git a/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts b/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts index a7097b13b56f..07a1e987136b 100644 --- a/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts +++ b/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts @@ -5,13 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useLocation} from '@docusaurus/router'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; /** - * Get the pathname of current route, without the optional site baseUrl - * - /docs/myDoc => /docs/myDoc - * - /baseUrl/docs/myDoc => /docs/myDoc + * Get the pathname of current route, without the optional site baseUrl. + * - `/docs/myDoc` => `/docs/myDoc` + * - `/baseUrl/docs/myDoc` => `/docs/myDoc` */ export function useLocalPathname(): string { const { diff --git a/packages/docusaurus-theme-common/src/utils/useLocationChange.ts b/packages/docusaurus-theme-common/src/utils/useLocationChange.ts index 8d947257c38f..dd8ec1d0249c 100644 --- a/packages/docusaurus-theme-common/src/utils/useLocationChange.ts +++ b/packages/docusaurus-theme-common/src/utils/useLocationChange.ts @@ -10,14 +10,17 @@ import {useLocation} from '@docusaurus/router'; import type {Location} from 'history'; import {useDynamicCallback, usePrevious} from './reactUtils'; -type LocationChangeEvent = { - location: Location; - previousLocation: Location | undefined; -}; - -type OnLocationChange = (locationChangeEvent: LocationChangeEvent) => void; - -export function useLocationChange(onLocationChange: OnLocationChange): void { +/** + * Fires an effect when the location changes (which includes hash, query, etc.). + * Importantly, doesn't fire when there's no previous location: see + * https://github.com/facebook/docusaurus/pull/6696 + */ +export function useLocationChange( + onLocationChange: (locationChangeEvent: { + location: Location; + previousLocation: Location | undefined; + }) => void, +): void { const location = useLocation(); const previousLocation = usePrevious(location); diff --git a/packages/docusaurus-theme-common/src/utils/usePluralForm.ts b/packages/docusaurus-theme-common/src/utils/usePluralForm.ts index 349047d216d7..cc5ce45317bf 100644 --- a/packages/docusaurus-theme-common/src/utils/usePluralForm.ts +++ b/packages/docusaurus-theme-common/src/utils/usePluralForm.ts @@ -105,7 +105,18 @@ function selectPluralMessage( return parts[Math.min(pluralFormIndex, parts.length - 1)]!; } +/** + * Reads the current locale and returns an interface very similar to + * `Intl.PluralRules`. + */ export function usePluralForm(): { + /** + * Give it a `count` and it will select the relevant message from + * `pluralMessages`. `pluralMessages` should be separated by `|`, and in the + * order of "zero", "one", "two", "few", "many", "other". The actual selection + * is done by `Intl.PluralRules`, which tells us all plurals the locale has + * and which plural we should use for `count`. + */ selectMessage: (count: number, pluralMessages: string) => string; } { const localePluralForm = useLocalePluralForms(); diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index a27451eacce7..8d301370883f 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -127,6 +127,9 @@ export type ThemeConfig = { // User-provided theme config, unnormalized export type UserThemeConfig = DeepPartial<ThemeConfig>; +/** + * A convenient/more semantic way to get theme config from context. + */ export function useThemeConfig(): ThemeConfig { return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig; } From 25d7fd7ff40e92cc206be08e67cbe2d4e271f836 Mon Sep 17 00:00:00 2001 From: Joshua Chen <sidachen2003@gmail.com> Date: Wed, 23 Mar 2022 21:28:29 +0800 Subject: [PATCH 2/2] fix tests --- .../contexts/__tests__/docsSidebar.test.tsx | 31 ++++++++++ .../contexts/__tests__/docsVersion.test.tsx | 46 +++++++++++++++ packages/docusaurus-theme-common/src/index.ts | 8 ++- .../src/utils/__tests__/docsUtils.test.tsx | 56 +++--------------- .../src/utils/metadataUtils.tsx | 1 + .../src/utils/routesUtils.ts | 6 +- .../src/utils/scrollUtils.tsx | 6 +- .../src/utils/searchUtils.ts | 48 +++++++++++++++ .../src/utils/useContextualSearchFilters.ts | 58 ------------------- 9 files changed, 140 insertions(+), 120 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/contexts/__tests__/docsSidebar.test.tsx create mode 100644 packages/docusaurus-theme-common/src/contexts/__tests__/docsVersion.test.tsx delete mode 100644 packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts diff --git a/packages/docusaurus-theme-common/src/contexts/__tests__/docsSidebar.test.tsx b/packages/docusaurus-theme-common/src/contexts/__tests__/docsSidebar.test.tsx new file mode 100644 index 000000000000..5d66881b7d16 --- /dev/null +++ b/packages/docusaurus-theme-common/src/contexts/__tests__/docsSidebar.test.tsx @@ -0,0 +1,31 @@ +/** + * 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 {renderHook} from '@testing-library/react-hooks'; +import {useDocsSidebar, DocsSidebarProvider} from '../docsSidebar'; +import type {PropSidebar} from '@docusaurus/plugin-content-docs'; + +describe('useDocsSidebar', () => { + it('throws if context provider is missing', () => { + expect( + () => renderHook(() => useDocsSidebar()).result.current, + ).toThrowErrorMatchingInlineSnapshot( + `"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`, + ); + }); + + it('reads value from context provider', () => { + const sidebar: PropSidebar = []; + const {result} = renderHook(() => useDocsSidebar(), { + wrapper: ({children}) => ( + <DocsSidebarProvider sidebar={sidebar}>{children}</DocsSidebarProvider> + ), + }); + expect(result.current).toBe(sidebar); + }); +}); diff --git a/packages/docusaurus-theme-common/src/contexts/__tests__/docsVersion.test.tsx b/packages/docusaurus-theme-common/src/contexts/__tests__/docsVersion.test.tsx new file mode 100644 index 000000000000..972a824cd31f --- /dev/null +++ b/packages/docusaurus-theme-common/src/contexts/__tests__/docsVersion.test.tsx @@ -0,0 +1,46 @@ +/** + * 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 {renderHook} from '@testing-library/react-hooks'; +import {useDocsVersion, DocsVersionProvider} from '../docsVersion'; +import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs'; + +function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata { + return { + version: 'versionName', + label: 'Version Label', + className: 'version className', + badge: true, + banner: 'unreleased', + docs: {}, + docsSidebars: {}, + isLast: false, + pluginId: 'default', + ...data, + }; +} + +describe('useDocsVersion', () => { + it('throws if context provider is missing', () => { + expect( + () => renderHook(() => useDocsVersion()).result.current, + ).toThrowErrorMatchingInlineSnapshot( + `"Hook useDocsVersion is called outside the <DocsVersionProvider>. "`, + ); + }); + + it('reads value from context provider', () => { + const version = testVersion(); + const {result} = renderHook(() => useDocsVersion(), { + wrapper: ({children}) => ( + <DocsVersionProvider version={version}>{children}</DocsVersionProvider> + ), + }); + expect(result.current).toBe(version); + }); +}); diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index c75f9efdb8e3..a823af8d4220 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -30,15 +30,17 @@ export {createStorageSlot, listStorageKeys} from './utils/storageUtils'; export {useAlternatePageUtils} from './utils/useAlternatePageUtils'; -export {useContextualSearchFilters} from './utils/useContextualSearchFilters'; - export { parseCodeBlockTitle, parseLanguage, parseLines, } from './utils/codeBlockUtils'; -export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils'; +export { + docVersionSearchTag, + DEFAULT_SEARCH_TAG, + useContextualSearchFilters, +} from './utils/searchUtils'; export { isDocsPluginEnabled, 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 f0cc92830c1e..4fd8df849b2a 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/docsUtils.test.tsx +++ b/packages/docusaurus-theme-common/src/utils/__tests__/docsUtils.test.tsx @@ -10,15 +10,13 @@ import {renderHook} from '@testing-library/react-hooks'; import { findFirstCategoryLink, isActiveSidebarItem, - DocsVersionProvider, - useDocsVersion, useDocById, - useDocsSidebar, - DocsSidebarProvider, findSidebarCategory, useCurrentSidebarCategory, useSidebarBreadcrumbs, } from '../docsUtils'; +import {DocsSidebarProvider} from '../../contexts/docsSidebar'; +import {DocsVersionProvider} from '../../contexts/docsVersion'; import {StaticRouter} from 'react-router-dom'; import {Context} from '@docusaurus/core/src/client/docusaurusContext'; import type { @@ -68,46 +66,6 @@ function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata { }; } -describe('useDocsVersion', () => { - it('throws if context provider is missing', () => { - expect( - () => renderHook(() => useDocsVersion()).result.current, - ).toThrowErrorMatchingInlineSnapshot( - `"Hook useDocsVersion is called outside the <DocsVersionProvider>. "`, - ); - }); - - it('reads value from context provider', () => { - const version = testVersion(); - const {result} = renderHook(() => useDocsVersion(), { - wrapper: ({children}) => ( - <DocsVersionProvider version={version}>{children}</DocsVersionProvider> - ), - }); - expect(result.current).toBe(version); - }); -}); - -describe('useDocsSidebar', () => { - it('throws if context provider is missing', () => { - expect( - () => renderHook(() => useDocsSidebar()).result.current, - ).toThrowErrorMatchingInlineSnapshot( - `"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`, - ); - }); - - it('reads value from context provider', () => { - const sidebar: PropSidebar = []; - const {result} = renderHook(() => useDocsSidebar(), { - wrapper: ({children}) => ( - <DocsSidebarProvider sidebar={sidebar}>{children}</DocsSidebarProvider> - ), - }); - expect(result.current).toBe(sidebar); - }); -}); - describe('useDocById', () => { const version = testVersion({ docs: { @@ -506,11 +464,11 @@ describe('useCurrentSidebarCategory', () => { const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([ category, ]); - expect(() => mockUseCurrentSidebarCategory('/cat')) - .toThrowErrorMatchingInlineSnapshot(` - "Unexpected: sidebar category could not be found for pathname='/cat'. - Hook useCurrentSidebarCategory() should only be used on Category pages" - `); + expect(() => + mockUseCurrentSidebarCategory('/cat'), + ).toThrowErrorMatchingInlineSnapshot( + `"/cat is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages."`, + ); }); it('throws when sidebar is missing', () => { diff --git a/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx b/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx index 1cd7c226b78a..d7b85f0d04b3 100644 --- a/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx @@ -47,6 +47,7 @@ export function PageMetadata({ <meta name="keywords" content={ + // https://github.com/microsoft/TypeScript/issues/17002 (Array.isArray(keywords) ? keywords.join(',') : keywords) as string } /> diff --git a/packages/docusaurus-theme-common/src/utils/routesUtils.ts b/packages/docusaurus-theme-common/src/utils/routesUtils.ts index 6a2532354987..2b48b8bccde2 100644 --- a/packages/docusaurus-theme-common/src/utils/routesUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/routesUtils.ts @@ -69,11 +69,7 @@ export function findHomePageRoute({ export function useHomePageRoute(): Route | undefined { const {baseUrl} = useDocusaurusContext().siteConfig; return useMemo( - () => - findHomePageRoute({ - routes: generatedRoutes, - baseUrl, - }), + () => findHomePageRoute({routes: generatedRoutes, baseUrl}), [baseUrl], ); } diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx index f4f9bae94f07..9c49e0c146bb 100644 --- a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -127,12 +127,8 @@ export function useScrollPosition( window.addEventListener('scroll', handleScroll, opts); return () => window.removeEventListener('scroll', handleScroll, opts); - }, [ - dynamicEffect, - scrollEventsEnabledRef, // eslint-disable-next-line react-hooks/exhaustive-deps - ...deps, - ]); + }, [dynamicEffect, scrollEventsEnabledRef, ...deps]); } type UseScrollPositionSaver = { diff --git a/packages/docusaurus-theme-common/src/utils/searchUtils.ts b/packages/docusaurus-theme-common/src/utils/searchUtils.ts index 0b2f801f0e37..57fdb3635f9b 100644 --- a/packages/docusaurus-theme-common/src/utils/searchUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/searchUtils.ts @@ -5,6 +5,13 @@ * LICENSE file in the root directory of this source tree. */ +import { + useAllDocsData, + useActivePluginAndVersion, +} from '@docusaurus/plugin-content-docs/client'; +import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersion'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; + export const DEFAULT_SEARCH_TAG = 'default'; /** The search tag to append as each doc's metadata. */ @@ -14,3 +21,44 @@ export function docVersionSearchTag( ): string { return `docs-${pluginId}-${versionName}`; } + +/** + * Gets the relevant context information for contextual search. + * + * The value is generic and not coupled to Algolia/DocSearch, since we may want + * to support multiple search engines, or allowing users to use their own search + * engine solution. + */ +export function useContextualSearchFilters(): {locale: string; tags: string[]} { + const {i18n} = useDocusaurusContext(); + const allDocsData = useAllDocsData(); + const activePluginAndVersion = useActivePluginAndVersion(); + const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId(); + + function getDocPluginTags(pluginId: string) { + const activeVersion = + activePluginAndVersion?.activePlugin?.pluginId === pluginId + ? activePluginAndVersion.activeVersion + : undefined; + + const preferredVersion = docsPreferredVersionByPluginId[pluginId]; + + const latestVersion = allDocsData[pluginId]!.versions.find( + (v) => v.isLast, + )!; + + const version = activeVersion ?? preferredVersion ?? latestVersion; + + return docVersionSearchTag(pluginId, version.name); + } + + const tags = [ + DEFAULT_SEARCH_TAG, + ...Object.keys(allDocsData).map(getDocPluginTags), + ]; + + return { + locale: i18n.currentLocale, + tags, + }; +} diff --git a/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts b/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts deleted file mode 100644 index ef84a859a33d..000000000000 --- a/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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 { - useAllDocsData, - useActivePluginAndVersion, -} from '@docusaurus/plugin-content-docs/client'; -import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersion'; -import {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './searchUtils'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; - -/** - * Gets the relevant context information for contextual search. - * - * The value is generic and not coupled to Algolia/DocSearch, since we may want - * to support multiple search engines, or allowing users to use their own search - * engine solution. - */ -export function useContextualSearchFilters(): { - locale: string; - tags: string[]; -} { - const {i18n} = useDocusaurusContext(); - const allDocsData = useAllDocsData(); - const activePluginAndVersion = useActivePluginAndVersion(); - const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId(); - - function getDocPluginTags(pluginId: string) { - const activeVersion = - activePluginAndVersion?.activePlugin?.pluginId === pluginId - ? activePluginAndVersion.activeVersion - : undefined; - - const preferredVersion = docsPreferredVersionByPluginId[pluginId]; - - const latestVersion = allDocsData[pluginId]!.versions.find( - (v) => v.isLast, - )!; - - const version = activeVersion ?? preferredVersion ?? latestVersion; - - return docVersionSearchTag(pluginId, version.name); - } - - const tags = [ - DEFAULT_SEARCH_TAG, - ...Object.keys(allDocsData).map(getDocPluginTags), - ]; - - return { - locale: i18n.currentLocale, - tags, - }; -}