feat(theme-common): JSDoc for all APIs (#6974)

* feat(theme-common): JSDoc for all APIs

* fix tests
This commit is contained in:
Joshua Chen 2022-03-23 21:39:19 +08:00 committed by GitHub
parent 4103fef11e
commit b456a64f61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 871 additions and 679 deletions

View file

@ -20,20 +20,19 @@ import React, {
const DefaultAnimationEasing = 'ease-in-out'; 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); initialState: boolean | (() => boolean);
}; }): {
export type UseCollapsibleReturns = {
collapsed: boolean; collapsed: boolean;
setCollapsed: Dispatch<SetStateAction<boolean>>; setCollapsed: Dispatch<SetStateAction<boolean>>;
toggleCollapsed: () => void; toggleCollapsed: () => void;
}; } {
// This hook just define the state
export function useCollapsible({
initialState,
}: UseCollapsibleConfig): UseCollapsibleReturns {
const [collapsed, setCollapsed] = useState(initialState ?? false); const [collapsed, setCollapsed] = useState(initialState ?? false);
const toggleCollapsed = useCallback(() => { const toggleCollapsed = useCallback(() => {
@ -152,8 +151,10 @@ type CollapsibleElementType = React.ElementType<
Pick<React.HTMLAttributes<unknown>, 'className' | 'onTransitionEnd' | 'style'> Pick<React.HTMLAttributes<unknown>, '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) { function getSSRStyle(collapsed: boolean) {
if (ExecutionEnvironment.canUseDOM) { if (ExecutionEnvironment.canUseDOM) {
return undefined; return undefined;
@ -162,16 +163,27 @@ function getSSRStyle(collapsed: boolean) {
} }
type CollapsibleBaseProps = { type CollapsibleBaseProps = {
/** The actual DOM element to be used in the markup. */
as?: CollapsibleElementType; as?: CollapsibleElementType;
/** Initial collapsed state. */
collapsed: boolean; collapsed: boolean;
children: ReactNode; children: ReactNode;
/** Configuration of animation, like `duration` and `easing` */
animation?: CollapsibleAnimationConfig; 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; onCollapseTransitionEnd?: (collapsed: boolean) => void;
/** Class name for the underlying DOM element. */
className?: string; className?: string;
/**
// This is mostly useful for details/summary component where ssrStyle is not * 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 * needed (as details are hidden natively) and can mess up with the browser's
// native behavior of the browser when JS fails to load or is disabled * native behavior when JS fails to load or is disabled
*/
disableSSRStyle?: boolean; disableSSRStyle?: boolean;
}; };
@ -233,14 +245,20 @@ function CollapsibleLazy({collapsed, ...props}: CollapsibleBaseProps) {
} }
type CollapsibleProps = CollapsibleBaseProps & { type CollapsibleProps = CollapsibleBaseProps & {
// Lazy allows to delay the rendering when collapsed => it will render /**
// children only after hydration, on first expansion * Delay rendering of the content till first expansion. Marked as required to
// Required prop: it forces to think if content should be server-rendered * force us to think if content should be server-rendered or not. This has
// or not! This has perf impact on the SSR output and html file sizes * perf impact since it reduces html file sizes, but could undermine SEO.
// See https://github.com/facebook/docusaurus/issues/4753 * @see https://github.com/facebook/docusaurus/issues/4753
*/
lazy: boolean; 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 { export function Collapsible({lazy, ...props}: CollapsibleProps): JSX.Element {
const Comp = lazy ? CollapsibleLazy : CollapsibleBase; const Comp = lazy ? CollapsibleLazy : CollapsibleBase;
return <Comp {...props} />; return <Comp {...props} />;

View file

@ -31,9 +31,14 @@ function hasParent(node: HTMLElement | null, parent: HTMLElement): boolean {
} }
export type DetailsProps = { export type DetailsProps = {
/** Summary is provided as props, including the wrapping `<summary>` tag */
summary?: ReactElement; summary?: ReactElement;
} & ComponentProps<'details'>; } & ComponentProps<'details'>;
/**
* A mostly un-styled `<details>` element with smooth collapsing. Provides some
* very lightweight styles, but you should bring your UI.
*/
export function Details({ export function Details({
summary, summary,
children, children,
@ -45,8 +50,8 @@ export function Details({
const {collapsed, setCollapsed} = useCollapsible({ const {collapsed, setCollapsed} = useCollapsible({
initialState: !props.open, initialState: !props.open,
}); });
// Use a separate prop because it must be set only after animation completes // Use a separate state for the actual details prop, because it must be set
// Otherwise close anim won't work // only after animation completes, otherwise close animations won't work
const [open, setOpen] = useState(props.open); const [open, setOpen] = useState(props.open);
return ( return (

View file

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

View file

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

View file

@ -32,12 +32,18 @@ const isDismissedInStorage = () =>
const setDismissedInStorage = (bool: boolean) => const setDismissedInStorage = (bool: boolean) =>
AnnouncementBarDismissStorage.set(String(bool)); AnnouncementBarDismissStorage.set(String(bool));
type AnnouncementBarAPI = { type ContextValue = {
/** Whether the announcement bar should be displayed. */
readonly isActive: boolean; readonly isActive: boolean;
/**
* Callback fired when the user closes the announcement. Will be saved.
*/
readonly close: () => void; readonly close: () => void;
}; };
const useAnnouncementBarContextValue = (): AnnouncementBarAPI => { const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const {announcementBar} = useThemeConfig(); const {announcementBar} = useThemeConfig();
const isBrowser = useIsBrowser(); const isBrowser = useIsBrowser();
@ -93,27 +99,19 @@ const useAnnouncementBarContextValue = (): AnnouncementBarAPI => {
}), }),
[announcementBar, isClosed, handleClose], [announcementBar, isClosed, handleClose],
); );
}; }
const AnnouncementBarContext = React.createContext<AnnouncementBarAPI | null>(
null,
);
export function AnnouncementBarProvider({ export function AnnouncementBarProvider({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const value = useAnnouncementBarContextValue(); const value = useContextValue();
return ( return <Context.Provider value={value}>{children}</Context.Provider>;
<AnnouncementBarContext.Provider value={value}>
{children}
</AnnouncementBarContext.Provider>
);
} }
export function useAnnouncementBar(): AnnouncementBarAPI { export function useAnnouncementBar(): ContextValue {
const api = useContext(AnnouncementBarContext); const api = useContext(Context);
if (!api) { if (!api) {
throw new ReactContextError('AnnouncementBarProvider'); throw new ReactContextError('AnnouncementBarProvider');
} }

View file

@ -20,8 +20,10 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import {createStorageSlot} from '../utils/storageUtils'; import {createStorageSlot} from '../utils/storageUtils';
import {useThemeConfig} from '../utils/useThemeConfig'; import {useThemeConfig} from '../utils/useThemeConfig';
type ColorModeContextValue = { type ContextValue = {
/** Current color mode. */
readonly colorMode: ColorMode; readonly colorMode: ColorMode;
/** Set new color mode. */
readonly setColorMode: (colorMode: ColorMode) => void; readonly setColorMode: (colorMode: ColorMode) => void;
// TODO legacy APIs kept for retro-compatibility: deprecate them // TODO legacy APIs kept for retro-compatibility: deprecate them
@ -30,6 +32,8 @@ type ColorModeContextValue = {
readonly setDarkTheme: () => void; readonly setDarkTheme: () => void;
}; };
const Context = React.createContext<ContextValue | undefined>(undefined);
const ColorModeStorageKey = 'theme'; const ColorModeStorageKey = 'theme';
const ColorModeStorage = createStorageSlot(ColorModeStorageKey); const ColorModeStorage = createStorageSlot(ColorModeStorageKey);
@ -44,18 +48,16 @@ export type ColorMode = typeof ColorModes[keyof typeof ColorModes];
const coerceToColorMode = (colorMode?: string | null): ColorMode => const coerceToColorMode = (colorMode?: string | null): ColorMode =>
colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light; colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light;
const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => { const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode =>
if (!ExecutionEnvironment.canUseDOM) { ExecutionEnvironment.canUseDOM
return coerceToColorMode(defaultMode); ? coerceToColorMode(document.documentElement.getAttribute('data-theme'))
} : coerceToColorMode(defaultMode);
return coerceToColorMode(document.documentElement.getAttribute('data-theme'));
};
const storeColorMode = (newColorMode: ColorMode) => { const storeColorMode = (newColorMode: ColorMode) => {
ColorModeStorage.set(coerceToColorMode(newColorMode)); ColorModeStorage.set(coerceToColorMode(newColorMode));
}; };
function useColorModeContextValue(): ColorModeContextValue { function useContextValue(): ContextValue {
const { const {
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme}, colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
} = useThemeConfig(); } = useThemeConfig();
@ -153,25 +155,17 @@ function useColorModeContextValue(): ColorModeContextValue {
); );
} }
const ColorModeContext = React.createContext<ColorModeContextValue | undefined>(
undefined,
);
export function ColorModeProvider({ export function ColorModeProvider({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const contextValue = useColorModeContextValue(); const value = useContextValue();
return ( return <Context.Provider value={value}>{children}</Context.Provider>;
<ColorModeContext.Provider value={contextValue}>
{children}
</ColorModeContext.Provider>
);
} }
export function useColorMode(): ColorModeContextValue { export function useColorMode(): ContextValue {
const context = useContext(ColorModeContext); const context = useContext(Context);
if (context == null) { if (context == null) {
throw new ReactContextError( throw new ReactContextError(
'ColorModeProvider', 'ColorModeProvider',

View file

@ -8,15 +8,30 @@
import React, {type ReactNode, useMemo, useState, useContext} from 'react'; import React, {type ReactNode, useMemo, useState, useContext} from 'react';
import {ReactContextError} from '../utils/reactUtils'; import {ReactContextError} from '../utils/reactUtils';
const EmptyContext: unique symbol = Symbol('EmptyContext'); type ContextValue = {
const Context = React.createContext< /**
DocSidebarItemsExpandedState | typeof EmptyContext * The item that the user last opened, `null` when there's none open. On
>(EmptyContext); * initial render, it will always be `null`, which doesn't necessarily mean
type DocSidebarItemsExpandedState = { * there's no category open (can have 0, 1, or many being initially open).
*/
expandedItem: number | null; 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; setExpandedItem: (a: number | null) => void;
}; };
const EmptyContext: unique symbol = Symbol('EmptyContext');
const Context = React.createContext<ContextValue | typeof EmptyContext>(
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({ export function DocSidebarItemsExpandedStateProvider({
children, children,
}: { }: {
@ -31,10 +46,10 @@ export function DocSidebarItemsExpandedStateProvider({
return <Context.Provider value={contextValue}>{children}</Context.Provider>; return <Context.Provider value={contextValue}>{children}</Context.Provider>;
} }
export function useDocSidebarItemsExpandedState(): DocSidebarItemsExpandedState { export function useDocSidebarItemsExpandedState(): ContextValue {
const contextValue = useContext(Context); const value = useContext(Context);
if (contextValue === EmptyContext) { if (value === EmptyContext) {
throw new ReactContextError('DocSidebarItemsExpandedStateProvider'); throw new ReactContextError('DocSidebarItemsExpandedStateProvider');
} }
return contextValue; return value;
} }

View file

@ -54,32 +54,29 @@ const DocsPreferredVersionStorage = {
type DocsPreferredVersionName = string | null; type DocsPreferredVersionName = string | null;
// State for a single docs plugin instance /** State for a single docs plugin instance */
type DocsPreferredVersionPluginState = { type DocsPreferredVersionPluginState = {
preferredVersionName: DocsPreferredVersionName; preferredVersionName: DocsPreferredVersionName;
}; };
// We need to store in state/storage globally /**
// one preferred version per docs plugin instance * We need to store the state in storage globally, with one preferred version
// pluginId => pluginState * per docs plugin instance.
type DocsPreferredVersionState = Record< */
string, type DocsPreferredVersionState = {
DocsPreferredVersionPluginState [pluginId: string]: DocsPreferredVersionPluginState;
>; };
// Initial state is always null as we can't read local storage from node SSR /**
function getInitialState(pluginIds: string[]): DocsPreferredVersionState { * Initial state is always null as we can't read local storage from node SSR
const initialState: DocsPreferredVersionState = {}; */
pluginIds.forEach((pluginId) => { const getInitialState = (pluginIds: string[]): DocsPreferredVersionState =>
initialState[pluginId] = { Object.fromEntries(pluginIds.map((id) => [id, {preferredVersionName: null}]));
preferredVersionName: null,
};
});
return initialState;
}
// Read storage for all docs plugins /**
// Assign to each doc plugin a preferred version (if found) * Read storage for all docs plugins, assigning each doc plugin a preferred
* version (if found)
*/
function readStorageState({ function readStorageState({
pluginIds, pluginIds,
versionPersistence, versionPersistence,
@ -89,9 +86,11 @@ function readStorageState({
versionPersistence: DocsVersionPersistence; versionPersistence: DocsVersionPersistence;
allDocsData: Record<string, GlobalPluginData>; allDocsData: Record<string, GlobalPluginData>;
}): DocsPreferredVersionState { }): DocsPreferredVersionState {
// The storage value we read might be stale, /**
// and belong to a version that does not exist in the site anymore * The storage value we read might be stale, and belong to a version that does
// In such case, we remove the storage value to avoid downstream errors * not exist in the site anymore. In such case, we remove the storage value to
* avoid downstream errors.
*/
function restorePluginState( function restorePluginState(
pluginId: string, pluginId: string,
): DocsPreferredVersionPluginState { ): DocsPreferredVersionPluginState {
@ -109,20 +108,25 @@ function readStorageState({
DocsPreferredVersionStorage.clear(pluginId, versionPersistence); DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
return {preferredVersionName: null}; return {preferredVersionName: null};
} }
return Object.fromEntries(
const initialState: DocsPreferredVersionState = {}; pluginIds.map((id) => [id, restorePluginState(id)]),
pluginIds.forEach((pluginId) => { );
initialState[pluginId] = restorePluginState(pluginId);
});
return initialState;
} }
function useVersionPersistence(): DocsVersionPersistence { function useVersionPersistence(): DocsVersionPersistence {
return useThemeConfig().docs.versionPersistence; return useThemeConfig().docs.versionPersistence;
} }
// Value that will be accessible through context: [state,api] type ContextValue = [
function useContextValue() { state: DocsPreferredVersionState,
api: {
savePreferredVersion: (pluginId: string, versionName: string) => void;
},
];
const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const allDocsData = useAllDocsData(); const allDocsData = useAllDocsData();
const versionPersistence = useVersionPersistence(); const versionPersistence = useVersionPersistence();
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]); const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
@ -154,15 +158,22 @@ function useContextValue() {
}; };
}, [versionPersistence]); }, [versionPersistence]);
return [state, api] as const; return [state, api];
} }
type DocsPreferredVersionContextValue = ReturnType<typeof useContextValue>; function DocsPreferredVersionContextProviderUnsafe({
children,
const Context = React.createContext<DocsPreferredVersionContextValue | null>( }: {
null, children: ReactNode;
); }): JSX.Element {
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
/**
* This is a maybe-layer. If the docs plugin is not enabled, this provider is a
* simple pass-through.
*/
export function DocsPreferredVersionContextProvider({ export function DocsPreferredVersionContextProvider({
children, children,
}: { }: {
@ -178,16 +189,7 @@ export function DocsPreferredVersionContextProvider({
return children; return children;
} }
function DocsPreferredVersionContextProviderUnsafe({ function useDocsPreferredVersionContext(): ContextValue {
children,
}: {
children: ReactNode;
}): JSX.Element {
const contextValue = useContextValue();
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
const value = useContext(Context); const value = useContext(Context);
if (!value) { if (!value) {
throw new ReactContextError('DocsPreferredVersionContextProvider'); throw new ReactContextError('DocsPreferredVersionContextProvider');
@ -195,11 +197,14 @@ function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
return value; 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( export function useDocsPreferredVersion(
pluginId: string | undefined = DEFAULT_PLUGIN_ID, pluginId: string | undefined = DEFAULT_PLUGIN_ID,
): { ): {
preferredVersion: GlobalVersion | null | undefined; preferredVersion: GlobalVersion | null;
savePreferredVersionName: (versionName: string) => void; savePreferredVersionName: (versionName: string) => void;
} { } {
const docsData = useDocsData(pluginId); const docsData = useDocsData(pluginId);
@ -207,9 +212,10 @@ export function useDocsPreferredVersion(
const {preferredVersionName} = state[pluginId]!; const {preferredVersionName} = state[pluginId]!;
const preferredVersion = preferredVersionName const preferredVersion =
? docsData.versions.find((version) => version.name === preferredVersionName) docsData.versions.find(
: null; (version) => version.name === preferredVersionName,
) ?? null;
const savePreferredVersionName = useCallback( const savePreferredVersionName = useCallback(
(versionName: string) => { (versionName: string) => {
@ -218,12 +224,12 @@ export function useDocsPreferredVersion(
[api, pluginId], [api, pluginId],
); );
return {preferredVersion, savePreferredVersionName} as const; return {preferredVersion, savePreferredVersionName};
} }
export function useDocsPreferredVersionByPluginId(): Record< export function useDocsPreferredVersionByPluginId(): Record<
string, string,
GlobalVersion | null | undefined GlobalVersion | null
> { > {
const allDocsData = useAllDocsData(); const allDocsData = useAllDocsData();
const [state] = useDocsPreferredVersionContext(); const [state] = useDocsPreferredVersionContext();
@ -232,19 +238,14 @@ export function useDocsPreferredVersionByPluginId(): Record<
const docsData = allDocsData[pluginId]!; const docsData = allDocsData[pluginId]!;
const {preferredVersionName} = state[pluginId]!; const {preferredVersionName} = state[pluginId]!;
return preferredVersionName return (
? docsData.versions.find( docsData.versions.find(
(version) => version.name === preferredVersionName, (version) => version.name === preferredVersionName,
) ) ?? null
: null; );
} }
const pluginIds = Object.keys(allDocsData); const pluginIds = Object.keys(allDocsData);
return Object.fromEntries(
const result: Record<string, GlobalVersion | null | undefined> = {}; pluginIds.map((id) => [id, getPluginIdPreferredVersion(id)]),
pluginIds.forEach((pluginId) => { );
result[pluginId] = getPluginIdPreferredVersion(pluginId);
});
return result;
} }

View file

@ -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<PropSidebar | null | typeof EmptyContext>(
EmptyContext,
);
/**
* Provide the current sidebar to your children.
*/
export function DocsSidebarProvider({
children,
sidebar,
}: {
children: ReactNode;
sidebar: PropSidebar | null;
}): JSX.Element {
return <Context.Provider value={sidebar}>{children}</Context.Provider>;
}
/**
* 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;
}

View file

@ -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<PropVersionMetadata | null>(null);
/**
* Provide the current version's metadata to your children.
*/
export function DocsVersionProvider({
children,
version,
}: {
children: ReactNode;
version: PropVersionMetadata | null;
}): JSX.Element {
return <Context.Provider value={version}>{children}</Context.Provider>;
}
/**
* 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;
}

View file

@ -6,11 +6,11 @@
*/ */
import React, { import React, {
type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
useMemo, useMemo,
type ReactNode,
} from 'react'; } from 'react';
import {useWindowSize} from '../hooks/useWindowSize'; import {useWindowSize} from '../hooks/useWindowSize';
import {useHistoryPopHandler} from '../utils/historyUtils'; import {useHistoryPopHandler} from '../utils/historyUtils';
@ -18,31 +18,38 @@ import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
import {useThemeConfig} from '../utils/useThemeConfig'; import {useThemeConfig} from '../utils/useThemeConfig';
import {ReactContextError} from '../utils/reactUtils'; 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; 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; shouldRender: boolean;
toggle: () => void; /** The displayed state. Can be toggled with the `toggle` callback. */
shown: boolean; shown: boolean;
/** Toggle the `shown` attribute. */
toggle: () => void;
}; };
const Context = React.createContext< const Context = React.createContext<ContextValue | undefined>(undefined);
NavbarMobileSidebarContextValue | undefined
>(undefined);
// Mobile sidebar can be disabled in case it would lead to an empty sidebar function useIsNavbarMobileSidebarDisabled() {
// In this case it's not useful to display a navbar sidebar toggle button
function useNavbarMobileSidebarDisabled() {
const activeDocPlugin = useActivePlugin(); const activeDocPlugin = useActivePlugin();
const {items} = useThemeConfig().navbar; const {items} = useThemeConfig().navbar;
return items.length === 0 && !activeDocPlugin; return items.length === 0 && !activeDocPlugin;
} }
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue { function useContextValue(): ContextValue {
const disabled = useNavbarMobileSidebarDisabled(); const disabled = useIsNavbarMobileSidebarDisabled();
const windowSize = useWindowSize(); const windowSize = useWindowSize();
// Mobile sidebar not visible until user interaction: can avoid SSR rendering const shouldRender = !disabled && windowSize === 'mobile';
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
const [shown, setShown] = useState(false); const [shown, setShown] = useState(false);
@ -68,14 +75,8 @@ function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
} }
}, [windowSize]); }, [windowSize]);
// Return stable context value
return useMemo( return useMemo(
() => ({ () => ({disabled, shouldRender, toggle, shown}),
disabled,
shouldRender,
toggle,
shown,
}),
[disabled, shouldRender, toggle, shown], [disabled, shouldRender, toggle, shown],
); );
} }
@ -85,13 +86,13 @@ export function NavbarMobileSidebarProvider({
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const value = useNavbarMobileSidebarContextValue(); const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>; return <Context.Provider value={value}>{children}</Context.Provider>;
} }
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue { export function useNavbarMobileSidebar(): ContextValue {
const context = React.useContext(Context); const context = React.useContext(Context);
if (context == null) { if (context === undefined) {
throw new ReactContextError('NavbarMobileSidebarProvider'); throw new ReactContextError('NavbarMobileSidebarProvider');
} }
return context; return context;

View file

@ -14,19 +14,8 @@ import React, {
type ReactNode, type ReactNode,
type ComponentType, type ComponentType,
} from 'react'; } from 'react';
import {ReactContextError, usePrevious} from './reactUtils'; import {ReactContextError, usePrevious} from '../utils/reactUtils';
import {useNavbarMobileSidebar} from '../contexts/navbarMobileSidebar'; import {useNavbarMobileSidebar} from './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.
*/
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>; export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
@ -34,7 +23,7 @@ type State = {
shown: boolean; shown: boolean;
content: content:
| { | {
component: ComponentType<object>; component: NavbarSecondaryMenuComponent<object>;
props: object; props: object;
} }
| {component: null; props: null}; | {component: null; props: null};
@ -45,7 +34,14 @@ const InitialState: State = {
content: {component: null, props: null}, content: {component: null, props: null},
}; };
function useContextValue() { type ContextValue = [
state: State,
setState: React.Dispatch<React.SetStateAction<State>>,
];
const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const mobileSidebar = useNavbarMobileSidebar(); const mobileSidebar = useNavbarMobileSidebar();
const [state, setState] = useState<State>(InitialState); const [state, setState] = useState<State>(InitialState);
@ -76,21 +72,16 @@ function useContextValue() {
} }
}, [mobileSidebar.shown, hasContent]); }, [mobileSidebar.shown, hasContent]);
return [state, setState] as const; return [state, setState];
} }
type ContextValue = ReturnType<typeof useContextValue>;
const Context = React.createContext<ContextValue | null>(null);
export function NavbarSecondaryMenuProvider({ export function NavbarSecondaryMenuProvider({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
return ( const value = useContextValue();
<Context.Provider value={useContextValue()}>{children}</Context.Provider> return <Context.Provider value={value}>{children}</Context.Provider>;
);
} }
function useNavbarSecondaryMenuContext(): ContextValue { function useNavbarSecondaryMenuContext(): ContextValue {
@ -101,7 +92,7 @@ function useNavbarSecondaryMenuContext(): ContextValue {
return value; return value;
} }
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) { function useShallowMemoizedObject<O>(obj: O) {
return useMemo( return useMemo(
() => obj, () => obj,
// Is this safe? // Is this safe?
@ -110,15 +101,22 @@ function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
); );
} }
// Fill the secondary menu placeholder with some real content /**
export function NavbarSecondaryMenuFiller< * This component renders nothing by itself, but it fills the placeholder in the
Props extends Record<string, unknown>, * 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<P extends object>({
component, component,
props, props,
}: { }: {
component: NavbarSecondaryMenuComponent<Props>; component: NavbarSecondaryMenuComponent<P>;
props: Props; props: P;
}): JSX.Element | null { }): JSX.Element | null {
const [, setState] = useNavbarSecondaryMenuContext(); const [, setState] = useNavbarSecondaryMenuContext();
@ -146,9 +144,16 @@ function renderElement(state: State): JSX.Element | undefined {
return undefined; return undefined;
} }
/** Wires the logic for rendering the mobile navbar secondary menu. */
export function useNavbarSecondaryMenu(): { export function useNavbarSecondaryMenu(): {
/** Whether secondary menu is displayed. */
shown: boolean; shown: boolean;
/**
* Hide the secondary menu; fired either when hiding the entire sidebar, or
* when going back to the primary menu.
*/
hide: () => void; hide: () => void;
/** The content returned from the current secondary menu filler. */
content: JSX.Element | undefined; content: JSX.Element | undefined;
} { } {
const [state, setState] = useNavbarSecondaryMenuContext(); const [state, setState] = useNavbarSecondaryMenuContext();
@ -159,11 +164,7 @@ export function useNavbarSecondaryMenu(): {
); );
return useMemo( return useMemo(
() => ({ () => ({shown: state.shown, hide, content: renderElement(state)}),
shown: state.shown,
hide,
content: renderElement(state),
}),
[hide, state], [hide, state],
); );
} }

View file

@ -18,16 +18,16 @@ import {ReactContextError} from '../utils/reactUtils';
const TAB_CHOICE_PREFIX = 'docusaurus.tab.'; 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}; readonly tabGroupChoices: {readonly [groupId: string]: string};
/** Set the new choice value of a group. */
readonly setTabGroupChoices: (groupId: string, newChoice: string) => void; readonly setTabGroupChoices: (groupId: string, newChoice: string) => void;
}; };
const TabGroupChoiceContext = React.createContext< const Context = React.createContext<ContextValue | undefined>(undefined);
TabGroupChoiceContextValue | undefined
>(undefined);
function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue { function useContextValue(): ContextValue {
const [tabGroupChoices, setChoices] = useState<{ const [tabGroupChoices, setChoices] = useState<{
readonly [groupId: string]: string; readonly [groupId: string]: string;
}>({}); }>({});
@ -50,13 +50,18 @@ function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue {
} }
}, []); }, []);
return { const setTabGroupChoices = useCallback(
tabGroupChoices, (groupId: string, newChoice: string) => {
setTabGroupChoices: (groupId: string, newChoice: string) => {
setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice})); setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice}));
setChoiceSyncWithLocalStorage(groupId, newChoice); setChoiceSyncWithLocalStorage(groupId, newChoice);
}, },
}; [setChoiceSyncWithLocalStorage],
);
return useMemo(
() => ({tabGroupChoices, setTabGroupChoices}),
[tabGroupChoices, setTabGroupChoices],
);
} }
export function TabGroupChoiceProvider({ export function TabGroupChoiceProvider({
@ -64,23 +69,12 @@ export function TabGroupChoiceProvider({
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoiceContextValue(); const value = useContextValue();
const contextValue = useMemo( return <Context.Provider value={value}>{children}</Context.Provider>;
() => ({
tabGroupChoices,
setTabGroupChoices,
}),
[tabGroupChoices, setTabGroupChoices],
);
return (
<TabGroupChoiceContext.Provider value={contextValue}>
{children}
</TabGroupChoiceContext.Provider>
);
} }
export function useTabGroupChoice(): TabGroupChoiceContextValue { export function useTabGroupChoice(): ContextValue {
const context = useContext(TabGroupChoiceContext); const context = useContext(Context);
if (context == null) { if (context == null) {
throw new ReactContextError('TabGroupChoiceProvider'); throw new ReactContextError('TabGroupChoiceProvider');
} }

View file

@ -9,8 +9,14 @@ import {useState, useCallback, useRef} from 'react';
import {useLocationChange} from '../utils/useLocationChange'; import {useLocationChange} from '../utils/useLocationChange';
import {useScrollPosition} from '../utils/scrollUtils'; 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): { export function useHideableNavbar(hideOnScroll: boolean): {
/** A ref to the navbar component. Plug this into the actual element. */
readonly navbarRef: (node: HTMLElement | null) => void; readonly navbarRef: (node: HTMLElement | null) => void;
/** If `false`, the navbar component should not be rendered. */
readonly isNavbarVisible: boolean; readonly isNavbarVisible: boolean;
} { } {
const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll); const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll);
@ -29,7 +35,8 @@ export function useHideableNavbar(hideOnScroll: boolean): {
const scrollTop = currentPosition.scrollY; 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) { if (scrollTop < navbarHeight.current) {
setIsNavbarVisible(true); setIsNavbarVisible(true);
return; return;
@ -66,8 +73,5 @@ export function useHideableNavbar(hideOnScroll: boolean): {
setIsNavbarVisible(true); setIsNavbarVisible(true);
}); });
return { return {navbarRef, isNavbarVisible};
navbarRef,
isNavbarVisible,
};
} }

View file

@ -12,7 +12,13 @@ import './styles.css';
export const keyboardFocusedClassName = 'navigation-with-keyboard'; 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 * Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
*/ */
export function useKeyboardNavigation(): void { export function useKeyboardNavigation(): void {

View file

@ -7,10 +7,13 @@
import {useEffect} from 'react'; 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 { export function useLockBodyScroll(lock: boolean = true): void {
useEffect(() => { useEffect(() => {
document.body.style.overflow = lock ? 'hidden' : 'visible'; document.body.style.overflow = lock ? 'hidden' : 'visible';
return () => { return () => {
document.body.style.overflow = 'visible'; document.body.style.overflow = 'visible';
}; };

View file

@ -9,6 +9,10 @@ import defaultTheme from 'prism-react-renderer/themes/palenight';
import {useColorMode} from '../contexts/colorMode'; import {useColorMode} from '../contexts/colorMode';
import {useThemeConfig} from '../utils/useThemeConfig'; 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 { export function usePrismTheme(): typeof defaultTheme {
const {prism} = useThemeConfig(); const {prism} = useThemeConfig();
const {colorMode} = useColorMode(); const {colorMode} = useColorMode();

View file

@ -11,9 +11,22 @@ import {useCallback, useEffect, useState} from 'react';
const SEARCH_PARAM_QUERY = 'q'; const SEARCH_PARAM_QUERY = 'q';
/** Some utility functions around search queries. */
export function useSearchPage(): { export function useSearchPage(): {
/**
* Works hand-in-hand with `setSearchQuery`; whatever the user has inputted
* into the search box.
*/
searchQuery: string; 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; setSearchQuery: (newSearchQuery: string) => void;
/**
* Given a query, this handle generates the corresponding search page link,
* with base URL prepended.
*/
generateSearchPageLink: (targetSearchQuery: string) => string; generateSearchPageLink: (targetSearchQuery: string) => string;
} { } {
const history = useHistory(); const history = useHistory();
@ -52,7 +65,9 @@ export function useSearchPage(): {
const generateSearchPageLink = useCallback( const generateSearchPageLink = useCallback(
(targetSearchQuery: string) => (targetSearchQuery: string) =>
// Refer to https://github.com/facebook/docusaurus/pull/2838 // Refer to https://github.com/facebook/docusaurus/pull/2838
`${baseUrl}search?q=${encodeURIComponent(targetSearchQuery)}`, `${baseUrl}search?${SEARCH_PARAM_QUERY}=${encodeURIComponent(
targetSearchQuery,
)}`,
[baseUrl], [baseUrl],
); );

View file

@ -11,8 +11,10 @@ import {useThemeConfig} from '../utils/useThemeConfig';
// TODO make the hardcoded theme-classic classnames configurable (or add them // TODO make the hardcoded theme-classic classnames configurable (or add them
// to ThemeClassNames?) // 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 { function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const hasNoHeight = rect.top === rect.bottom; 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 * 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) { function isInViewportTopHalf(boundingRect: DOMRect) {
return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2; return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2;
@ -114,12 +116,23 @@ function useAnchorTopOffsetRef() {
} }
export type TOCHighlightConfig = { export type TOCHighlightConfig = {
/** A class name that all TOC links share. */
linkClassName: string; linkClassName: string;
/** The class name applied to the active (highlighted) link. */
linkActiveClassName: string; 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; minHeadingLevel: number;
/** @see {@link TOCHighlightConfig.minHeadingLevel} */
maxHeadingLevel: number; 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 { export function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined); const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);

View file

@ -12,12 +12,6 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
const windowSizes = { const windowSizes = {
desktop: 'desktop', desktop: 'desktop',
mobile: 'mobile', 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', ssr: 'ssr',
} as const; } as const;
@ -34,13 +28,21 @@ function getWindowSize() {
: windowSizes.mobile; : 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; 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 * Gets the current window size as an enum value. We don't want it to return the
// We only want to re-render once a breakpoint is crossed * 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 { export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>(() => { const [windowSize, setWindowSize] = useState<WindowSize>(() => {
if (DevSimulateSSR) { if (DevSimulateSSR) {

View file

@ -23,28 +23,28 @@ export {
DocSidebarItemsExpandedStateProvider, DocSidebarItemsExpandedStateProvider,
useDocSidebarItemsExpandedState, useDocSidebarItemsExpandedState,
} from './contexts/docSidebarItemsExpandedState'; } from './contexts/docSidebarItemsExpandedState';
export {DocsVersionProvider, useDocsVersion} from './contexts/docsVersion';
export {DocsSidebarProvider, useDocsSidebar} from './contexts/docsSidebar';
export {createStorageSlot, listStorageKeys} from './utils/storageUtils'; export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
export {useAlternatePageUtils} from './utils/useAlternatePageUtils'; export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
export {useContextualSearchFilters} from './utils/useContextualSearchFilters';
export { export {
parseCodeBlockTitle, parseCodeBlockTitle,
parseLanguage, parseLanguage,
parseLines, parseLines,
} from './utils/codeBlockUtils'; } from './utils/codeBlockUtils';
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils'; export {
docVersionSearchTag,
DEFAULT_SEARCH_TAG,
useContextualSearchFilters,
} from './utils/searchUtils';
export { export {
isDocsPluginEnabled, isDocsPluginEnabled,
DocsVersionProvider,
useDocsVersion,
useDocById, useDocById,
DocsSidebarProvider,
useDocsSidebar,
findSidebarCategory, findSidebarCategory,
findFirstCategoryLink, findFirstCategoryLink,
useCurrentSidebarCategory, useCurrentSidebarCategory,
@ -52,20 +52,13 @@ export {
useSidebarBreadcrumbs, useSidebarBreadcrumbs,
} from './utils/docsUtils'; } from './utils/docsUtils';
export {isSamePath} from './utils/pathUtils';
export {useTitleFormatter} from './utils/generalUtils'; export {useTitleFormatter} from './utils/generalUtils';
export {usePluralForm} from './utils/usePluralForm'; export {usePluralForm} from './utils/usePluralForm';
export {useLocationChange} from './utils/useLocationChange'; export {useLocationChange} from './utils/useLocationChange';
export { export {useCollapsible, Collapsible} from './components/Collapsible';
useCollapsible,
Collapsible,
type UseCollapsibleConfig,
type UseCollapsibleReturns,
} from './components/Collapsible';
export {Details, type DetailsProps} from './components/Details'; export {Details, type DetailsProps} from './components/Details';
@ -124,7 +117,7 @@ export {
export {isRegexpStringMatch} from './utils/regexpUtils'; export {isRegexpStringMatch} from './utils/regexpUtils';
export {useHomePageRoute} from './utils/routesUtils'; export {useHomePageRoute, isSamePath} from './utils/routesUtils';
export { export {
PageMetadata, PageMetadata,
@ -149,8 +142,8 @@ export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
export { export {
useNavbarSecondaryMenu, useNavbarSecondaryMenu,
NavbarSecondaryMenuFiller, NavbarSecondaryMenuFiller,
} from './utils/navbarSecondaryMenu'; type NavbarSecondaryMenuComponent,
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenu'; } from './contexts/navbarSecondaryMenu';
export {useHideableNavbar} from './hooks/useHideableNavbar'; export {useHideableNavbar} from './hooks/useHideableNavbar';
export { export {

View file

@ -5,10 +5,13 @@
* LICENSE file in the root directory of this source tree. * 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 // Please do not modify the classnames! This is a breaking change, and annoying
// for users! // 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 = { export const ThemeClassNames = {
page: { page: {
blogListPage: 'blog-list-page', blogListPage: 'blog-list-page',
@ -17,8 +20,8 @@ export const ThemeClassNames = {
blogTagPostListPage: 'blog-tags-post-list-page', blogTagPostListPage: 'blog-tags-post-list-page',
docsDocPage: 'docs-doc-page', docsDocPage: 'docs-doc-page',
docsTagsListPage: 'docs-tags-list-page', // List of tags docsTagsListPage: 'docs-tags-list-page',
docsTagDocListPage: 'docs-tags-doc-list-page', // Docs for a tag docsTagDocListPage: 'docs-tags-doc-list-page',
mdxPage: 'mdx-page', mdxPage: 'mdx-page',
}, },
@ -29,8 +32,9 @@ export const ThemeClassNames = {
mdxPages: 'mdx-wrapper', mdxPages: 'mdx-wrapper',
}, },
// /!\ Please keep the naming convention consistent! /**
// Something like: "theme-{blog,doc,version,page}?-<suffix>" * Follows the naming convention "theme-{blog,doc,version,page}?-<suffix>"
*/
common: { common: {
editThisPage: 'theme-edit-this-page', editThisPage: 'theme-edit-this-page',
lastUpdated: 'theme-last-updated', lastUpdated: 'theme-last-updated',

View file

@ -10,15 +10,13 @@ import {renderHook} from '@testing-library/react-hooks';
import { import {
findFirstCategoryLink, findFirstCategoryLink,
isActiveSidebarItem, isActiveSidebarItem,
DocsVersionProvider,
useDocsVersion,
useDocById, useDocById,
useDocsSidebar,
DocsSidebarProvider,
findSidebarCategory, findSidebarCategory,
useCurrentSidebarCategory, useCurrentSidebarCategory,
useSidebarBreadcrumbs, useSidebarBreadcrumbs,
} from '../docsUtils'; } from '../docsUtils';
import {DocsSidebarProvider} from '../../contexts/docsSidebar';
import {DocsVersionProvider} from '../../contexts/docsVersion';
import {StaticRouter} from 'react-router-dom'; import {StaticRouter} from 'react-router-dom';
import {Context} from '@docusaurus/core/src/client/docusaurusContext'; import {Context} from '@docusaurus/core/src/client/docusaurusContext';
import type { 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', () => { describe('useDocById', () => {
const version = testVersion({ const version = testVersion({
docs: { docs: {
@ -506,11 +464,11 @@ describe('useCurrentSidebarCategory', () => {
const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([ const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([
category, category,
]); ]);
expect(() => mockUseCurrentSidebarCategory('/cat')) expect(() =>
.toThrowErrorMatchingInlineSnapshot(` mockUseCurrentSidebarCategory('/cat'),
"Unexpected: sidebar category could not be found for pathname='/cat'. ).toThrowErrorMatchingInlineSnapshot(
Hook useCurrentSidebarCategory() should only be used on Category pages" `"/cat is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages."`,
`); );
}); });
it('throws when sidebar is missing', () => { it('throws when sidebar is missing', () => {

View file

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

View file

@ -6,7 +6,39 @@
*/ */
import type {Route} from '@docusaurus/types'; 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', () => { describe('findHomePageRoute', () => {
const homePage: Route = { const homePage: Route = {

View file

@ -10,47 +10,24 @@ import rangeParser from 'parse-numeric-range';
const codeBlockTitleRegex = /title=(?<quote>["'])(?<title>.*?)\1/; const codeBlockTitleRegex = /title=(?<quote>["'])(?<title>.*?)\1/;
const highlightLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; 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 // Supported types of highlight comments
const commentPatterns: Record<CommentType, CommentPattern> = { const commentPatterns = {
js: { js: {start: '\\/\\/', end: ''},
start: '\\/\\/', jsBlock: {start: '\\/\\*', end: '\\*\\/'},
end: '', jsx: {start: '\\{\\s*\\/\\*', end: '\\*\\/\\s*\\}'},
}, python: {start: '#', end: ''},
jsBlock: { html: {start: '<!--', end: '-->'},
start: '\\/\\*',
end: '\\*\\/',
},
jsx: {
start: '\\{\\s*\\/\\*',
end: '\\*\\/\\s*\\}',
},
python: {
start: '#',
end: '',
},
html: {
start: '<!--',
end: '-->',
},
}; };
type CommentType = keyof typeof commentPatterns;
const magicCommentDirectives = [ const magicCommentDirectives = [
'highlight-next-line', 'highlight-next-line',
'highlight-start', 'highlight-start',
'highlight-end', 'highlight-end',
]; ];
const getMagicCommentDirectiveRegex = ( function getCommentPattern(languages: CommentType[]) {
languages: readonly CommentType[] = commentTypes,
) => {
// to be more reliable, the opening and closing comment must match // to be more reliable, the opening and closing comment must match
const commentPattern = languages const commentPattern = languages
.map((lang) => { .map((lang) => {
@ -60,38 +37,45 @@ const getMagicCommentDirectiveRegex = (
.join('|'); .join('|');
// white space is allowed, but otherwise it should be on it's own line // white space is allowed, but otherwise it should be on it's own line
return new RegExp(`^\\s*(?:${commentPattern})\\s*$`); 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) { switch (lang) {
case 'js': case 'js':
case 'javascript': case 'javascript':
case 'ts': case 'ts':
case 'typescript': case 'typescript':
return getMagicCommentDirectiveRegex(['js', 'jsBlock']); return getCommentPattern(['js', 'jsBlock']);
case 'jsx': case 'jsx':
case 'tsx': case 'tsx':
return getMagicCommentDirectiveRegex(['js', 'jsBlock', 'jsx']); return getCommentPattern(['js', 'jsBlock', 'jsx']);
case 'html': case 'html':
return getMagicCommentDirectiveRegex(['js', 'jsBlock', 'html']); return getCommentPattern(['js', 'jsBlock', 'html']);
case 'python': case 'python':
case 'py': case 'py':
return getMagicCommentDirectiveRegex(['python']); return getCommentPattern(['python']);
default: default:
// all comment types // all comment types
return getMagicCommentDirectiveRegex(); return getCommentPattern(Object.keys(commentPatterns) as CommentType[]);
} }
}; }
export function parseCodeBlockTitle(metastring?: string): string { export function parseCodeBlockTitle(metastring?: string): string {
return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? ''; 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 { export function parseLanguage(className: string): string | undefined {
const languageClassName = className const languageClassName = className
.split(' ') .split(' ')
@ -100,15 +84,33 @@ export function parseLanguage(className: string): string | undefined {
} }
/** /**
* @param metastring The highlight range declared here starts at 1 * Parses the code content, strips away any magic comments, and returns the
* @returns Note: all line numbers start at 0, not 1 * 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( export function parseLines(
content: string, content: string,
metastring?: string, metastring?: string,
language?: string, language?: string,
): { ): {
/**
* The highlighted lines, 0-indexed. e.g. `[0, 1, 4]` means the 1st, 2nd, and
* 5th lines are highlighted.
*/
highlightLines: number[]; highlightLines: number[];
/**
* The clean code without any magic comments (only if highlight range isn't
* present in the metastring).
*/
code: string; code: string;
} { } {
let code = content.replace(/\n$/, ''); let code = content.replace(/\n$/, '');
@ -124,7 +126,7 @@ export function parseLines(
if (language === undefined) { if (language === undefined) {
return {highlightLines: [], code}; return {highlightLines: [], code};
} }
const directiveRegex = magicCommentDirectiveRegex(language); const directiveRegex = getAllMagicCommentDirectiveStyles(language);
// go through line by line // go through line by line
const lines = code.split('\n'); const lines = code.split('\n');
let highlightBlockStart: number; let highlightBlockStart: number;

View file

@ -5,57 +5,32 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {type ReactNode, useContext} from 'react';
import { import {
useActivePlugin,
useAllDocsData, useAllDocsData,
useActivePlugin,
} from '@docusaurus/plugin-content-docs/client'; } from '@docusaurus/plugin-content-docs/client';
import type { import type {
PropSidebar, PropSidebar,
PropSidebarItem, PropSidebarItem,
PropSidebarItemCategory, PropSidebarItemCategory,
PropVersionDoc, PropVersionDoc,
PropVersionMetadata,
PropSidebarBreadcrumbsItem, PropSidebarBreadcrumbsItem,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import {isSamePath} from './pathUtils'; import {useDocsVersion} from '../contexts/docsVersion';
import {ReactContextError} from './reactUtils'; import {useDocsSidebar} from '../contexts/docsSidebar';
import {isSamePath} from './routesUtils';
import {useLocation} from '@docusaurus/router'; import {useLocation} from '@docusaurus/router';
// TODO not ideal, see also "useDocs" // TODO not ideal, see also "useDocs"
export const isDocsPluginEnabled: boolean = !!useAllDocsData; 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 * A null-safe way to access a doc's data by ID in the active version.
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;
}
export function useDocById(id: string): PropVersionDoc; 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;
export function useDocById(id: string | undefined): PropVersionDoc | undefined { export function useDocById(id: string | undefined): PropVersionDoc | undefined {
const version = useDocsVersion(); const version = useDocsVersion();
@ -69,34 +44,9 @@ export function useDocById(id: string | undefined): PropVersionDoc | undefined {
return doc; return doc;
} }
const DocsSidebarContext = React.createContext< /**
PropSidebar | null | typeof EmptyContextValue * Pure function, similar to `Array#find`, but works on the sidebar tree.
>(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
export function findSidebarCategory( export function findSidebarCategory(
sidebar: PropSidebar, sidebar: PropSidebar,
predicate: (category: PropSidebarItemCategory) => boolean, predicate: (category: PropSidebarItemCategory) => boolean,
@ -115,7 +65,10 @@ export function findSidebarCategory(
return undefined; 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( export function findFirstCategoryLink(
item: PropSidebarItemCategory, item: PropSidebarItemCategory,
): string | undefined { ): string | undefined {
@ -142,6 +95,10 @@ export function findFirstCategoryLink(
return undefined; return undefined;
} }
/**
* Gets the category associated with the current location. Should only be used
* on category index pages.
*/
export function useCurrentSidebarCategory(): PropSidebarItemCategory { export function useCurrentSidebarCategory(): PropSidebarItemCategory {
const {pathname} = useLocation(); const {pathname} = useLocation();
const sidebar = useDocsSidebar(); const sidebar = useDocsSidebar();
@ -153,47 +110,53 @@ export function useCurrentSidebarCategory(): PropSidebarItemCategory {
); );
if (!category) { if (!category) {
throw new Error( throw new Error(
`Unexpected: sidebar category could not be found for pathname='${pathname}'. `${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`,
Hook useCurrentSidebarCategory() should only be used on Category pages`,
); );
} }
return category; return category;
} }
function containsActiveSidebarItem( const isActive = (testedPath: string | undefined, activePath: string) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
const containsActiveSidebarItem = (
items: PropSidebarItem[], items: PropSidebarItem[],
activePath: string, activePath: string,
): boolean { ) => items.some((subItem) => isActiveSidebarItem(subItem, activePath));
return items.some((subItem) => isActiveSidebarItem(subItem, activePath));
}
/**
* Checks if a sidebar item should be active, based on the active path.
*/
export function isActiveSidebarItem( export function isActiveSidebarItem(
item: PropSidebarItem, item: PropSidebarItem,
activePath: string, activePath: string,
): boolean { ): boolean {
const isActive = (testedPath: string | undefined) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
if (item.type === 'link') { if (item.type === 'link') {
return isActive(item.href); return isActive(item.href, activePath);
} }
if (item.type === 'category') { if (item.type === 'category') {
return ( return (
isActive(item.href) || containsActiveSidebarItem(item.items, activePath) isActive(item.href, activePath) ||
containsActiveSidebarItem(item.items, activePath)
); );
} }
return false; return false;
} }
function getBreadcrumbs({ /**
sidebar, * Gets the breadcrumbs of the current doc page, based on its sidebar location.
pathname, * Returns `null` if there's no sidebar or breadcrumbs are disabled.
}: { */
sidebar: PropSidebar; export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
pathname: string; const sidebar = useDocsSidebar();
}): PropSidebarBreadcrumbsItem[] { const {pathname} = useLocation();
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
if (breadcrumbsOption === false || !sidebar) {
return null;
}
const breadcrumbs: PropSidebarBreadcrumbsItem[] = []; const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
function extract(items: PropSidebar) { function extract(items: PropSidebar) {
@ -215,15 +178,3 @@ function getBreadcrumbs({
return breadcrumbs.reverse(); return breadcrumbs.reverse();
} }
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
const sidebar = useDocsSidebar();
const {pathname} = useLocation();
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
if (breadcrumbsOption === false || !sidebar) {
return null;
}
return getBreadcrumbs({sidebar, pathname});
}

View file

@ -7,6 +7,10 @@
import type {MultiColumnFooter, SimpleFooter} from './useThemeConfig'; import type {MultiColumnFooter, SimpleFooter} from './useThemeConfig';
/**
* A rough duck-typing about whether the `footer.links` is intended to be multi-
* column.
*/
export function isMultiColumnFooterLinks( export function isMultiColumnFooterLinks(
links: MultiColumnFooter['links'] | SimpleFooter['links'], links: MultiColumnFooter['links'] | SimpleFooter['links'],
): links is MultiColumnFooter['links'] { ): links is MultiColumnFooter['links'] {

View file

@ -7,6 +7,9 @@
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 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 { export function useTitleFormatter(title?: string | undefined): string {
const {siteConfig} = useDocusaurusContext(); const {siteConfig} = useDocusaurusContext();
const {title: siteTitle, titleDelimiter} = siteConfig; const {title: siteTitle, titleDelimiter} = siteConfig;

View file

@ -5,44 +5,38 @@
* LICENSE file in the root directory of this source tree. * 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 {useHistory} from '@docusaurus/router';
import {useDynamicCallback} from './reactUtils';
import type {Location, Action} from 'history'; import type {Location, Action} from 'history';
type HistoryBlockHandler = (location: Location, action: Action) => void | false; type HistoryBlockHandler = (location: Location, action: Action) => void | false;
/** /**
* Permits to register a handler that will be called on history actions (pop, * Permits to register a handler that will be called on history actions (pop,
* push, replace) If the handler returns false, the navigation transition will * push, replace). If the handler returns `false`, the navigation transition
* be blocked/cancelled * will be blocked/cancelled.
*/ */
export function useHistoryActionHandler(handler: HistoryBlockHandler): void { function useHistoryActionHandler(handler: HistoryBlockHandler): void {
const {block} = useHistory(); const {block} = useHistory();
const stableHandler = useDynamicCallback(handler);
// Avoid stale closure issues without triggering useless re-renders
const lastHandlerRef = useRef(handler);
useEffect(() => {
lastHandlerRef.current = handler;
}, [handler]);
useEffect( useEffect(
() => // See https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md
// See https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md () => block((location, action) => stableHandler(location, action)),
block((location, action) => lastHandlerRef.current(location, action)), [block, stableHandler],
[block, lastHandlerRef],
); );
} }
/** /**
* Permits to register a handler that will be called on history pop navigation * 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 * transition will be blocked. Unfortunately there's no good way to detect the
* "direction" (backward/forward) of the POP event. * "direction" (backward/forward) of the POP event.
*/ */
export function useHistoryPopHandler(handler: HistoryBlockHandler): void { export function useHistoryPopHandler(handler: HistoryBlockHandler): void {
useHistoryActionHandler((location, action) => { useHistoryActionHandler((location, action) => {
if (action === 'POP') { if (action === 'POP') {
// Eventually block navigation if handler returns false // Maybe block navigation if handler returns false
return handler(location, action); return handler(location, action);
} }
// Don't block other navigation actions // Don't block other navigation actions

View file

@ -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. * @param arr The array.
* @returns An array with duplicate elements removed by reference comparison. * @returns An array with duplicate elements removed by reference comparison.
*/ */

View file

@ -20,7 +20,10 @@ interface PageMetadataProps {
readonly children?: ReactNode; 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({ export function PageMetadata({
title, title,
description, description,
@ -44,6 +47,7 @@ export function PageMetadata({
<meta <meta
name="keywords" name="keywords"
content={ content={
// https://github.com/microsoft/TypeScript/issues/17002
(Array.isArray(keywords) ? keywords.join(',') : keywords) as string (Array.isArray(keywords) ? keywords.join(',') : keywords) as string
} }
/> />
@ -59,8 +63,12 @@ export function PageMetadata({
const HtmlClassNameContext = React.createContext<string | undefined>(undefined); 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({ export function HtmlClassNameProvider({
className: classNameProp, className: classNameProp,
children, children,
@ -87,6 +95,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({ export function PluginHtmlClassNameProvider({
children, children,
}: { }: {

View file

@ -7,7 +7,7 @@
import React, {type ReactNode} from 'react'; import React, {type ReactNode} from 'react';
import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar'; import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar';
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenu'; import {NavbarSecondaryMenuProvider} from '../contexts/navbarSecondaryMenu';
const DefaultNavItemPosition = 'right'; const DefaultNavItemPosition = 'right';

View file

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

View file

@ -9,11 +9,12 @@ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/** /**
* This hook is like useLayoutEffect, but without the SSR warning * This hook is like `useLayoutEffect`, but without the SSR warning.
* It seems hacky but it's used in many React libs (Redux, Formik...) * It seems hacky but it's used in many React libs (Redux, Formik...).
* Also mentioned here: https://github.com/facebook/react/issues/16956 * 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 * 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 export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
? useLayoutEffect ? useLayoutEffect
@ -23,10 +24,11 @@ export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
* Permits to transform an unstable callback (like an arrow function provided as * 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 * 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 * 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 * Workaround until the React team recommends a good solution, see
* https://github.com/facebook/react/issues/16956 * https://github.com/facebook/react/issues/16956
*
* This generally works but has some potential drawbacks, such as * This generally works but has some potential drawbacks, such as
* https://github.com/facebook/react/issues/16956#issuecomment-536636418 * 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), []); return useCallback<T>((...args) => ref.current(...args), []);
} }
/**
* Gets `value` from the last render.
*/
export function usePrevious<T>(value: T): T | undefined { export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>(); const ref = useRef<T>();
@ -54,6 +59,11 @@ export function usePrevious<T>(value: T): T | undefined {
return ref.current; 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 { export class ReactContextError extends Error {
constructor(providerName: string, additionalInfo?: string) { constructor(providerName: string, additionalInfo?: string) {
super(); super();

View file

@ -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( export function isRegexpStringMatch(
regexAsString?: string, regexAsString?: string,

View file

@ -5,11 +5,26 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import generatedRoutes from '@generated/routes';
import {useMemo} from 'react'; import {useMemo} from 'react';
import generatedRoutes from '@generated/routes';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {Route} from '@docusaurus/types'; 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 * Note that sites don't always have a homepage in practice, so we can't assume
* that linking to '/' is always safe. * that linking to '/' is always safe.
@ -47,14 +62,14 @@ export function findHomePageRoute({
return doFindHomePageRoute(initialRoutes); 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 { export function useHomePageRoute(): Route | undefined {
const {baseUrl} = useDocusaurusContext().siteConfig; const {baseUrl} = useDocusaurusContext().siteConfig;
return useMemo( return useMemo(
() => () => findHomePageRoute({routes: generatedRoutes, baseUrl}),
findHomePageRoute({
routes: generatedRoutes,
baseUrl,
}),
[baseUrl], [baseUrl],
); );
} }

View file

@ -17,25 +17,12 @@ import React, {
import {useDynamicCallback, ReactContextError} from './reactUtils'; import {useDynamicCallback, ReactContextError} from './reactUtils';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; 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 = { type ScrollController = {
/** /** A boolean ref tracking whether scroll events are enabled. */
* A boolean ref tracking whether scroll events are enabled
*/
scrollEventsEnabledRef: React.MutableRefObject<boolean>; scrollEventsEnabledRef: React.MutableRefObject<boolean>;
/** /** Enable scroll events in `useScrollPosition`. */
* Enables scroll events in `useScrollPosition`
*/
enableScrollEvents: () => void; enableScrollEvents: () => void;
/** /** Disable scroll events in `useScrollPosition`. */
* Disables scroll events in `useScrollPosition`
*/
disableScrollEvents: () => void; disableScrollEvents: () => void;
}; };
@ -65,13 +52,21 @@ export function ScrollControllerProvider({
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const value = useScrollControllerContextValue();
return ( return (
<ScrollMonitorContext.Provider value={useScrollControllerContextValue()}> <ScrollMonitorContext.Provider value={value}>
{children} {children}
</ScrollMonitorContext.Provider> </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 { export function useScrollController(): ScrollController {
const context = useContext(ScrollMonitorContext); const context = useContext(ScrollMonitorContext);
if (context == null) { if (context == null) {
@ -80,6 +75,8 @@ export function useScrollController(): ScrollController {
return context; return context;
} }
type ScrollPosition = {scrollX: number; scrollY: number};
const getScrollPosition = (): ScrollPosition | null => const getScrollPosition = (): ScrollPosition | null =>
ExecutionEnvironment.canUseDOM ExecutionEnvironment.canUseDOM
? { ? {
@ -88,8 +85,14 @@ const getScrollPosition = (): ScrollPosition | null =>
} }
: 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( export function useScrollPosition(
effect: ( effect: (
position: ScrollPosition, position: ScrollPosition,
@ -124,22 +127,16 @@ export function useScrollPosition(
window.addEventListener('scroll', handleScroll, opts); window.addEventListener('scroll', handleScroll, opts);
return () => window.removeEventListener('scroll', handleScroll, opts); return () => window.removeEventListener('scroll', handleScroll, opts);
}, [
dynamicEffect,
scrollEventsEnabledRef,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
...deps, }, [dynamicEffect, scrollEventsEnabledRef, ...deps]);
]);
} }
type UseScrollPositionSaver = { 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; save: (elem: HTMLElement) => void;
/** /**
* Restore the page position to keep the stored element's position from * 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}; restore: () => {restored: boolean};
}; };
@ -177,21 +174,24 @@ function useScrollPositionSaver(): UseScrollPositionSaver {
return useMemo(() => ({save, restore}), [restore, save]); 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 * 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: * Feature motivated by the Tabs groups: clicking on a tab may affect tabs of
* clicking on a tab may affect tabs of the same group upper in the tree * the same group upper in the tree, yet to avoid a bad UX, the clicked tab must
* Yet to avoid a bad UX, the clicked tab must remain under the user mouse! * 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 scrollController = useScrollController();
const scrollPositionSaver = useScrollPositionSaver(); const scrollPositionSaver = useScrollPositionSaver();
@ -207,9 +207,9 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
const {restored} = scrollPositionSaver.restore(); const {restored} = scrollPositionSaver.restore();
nextLayoutEffectCallbackRef.current = undefined; nextLayoutEffectCallbackRef.current = undefined;
// Restoring the former scroll position will trigger a scroll event // Restoring the former scroll position will trigger a scroll event. We
// We need to wait for next scroll event to happen // need to wait for next scroll event to happen before enabling the
// before enabling again the scrollController events // scrollController events again.
if (restored) { if (restored) {
const handleScrollRestoreEvent = () => { const handleScrollRestoreEvent = () => {
scrollController.enableScrollEvents(); scrollController.enableScrollEvents();

View file

@ -5,11 +5,60 @@
* LICENSE file in the root directory of this source tree. * 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'; export const DEFAULT_SEARCH_TAG = 'default';
/** The search tag to append as each doc's metadata. */
export function docVersionSearchTag( export function docVersionSearchTag(
pluginId: string, pluginId: string,
versionName: string, versionName: string,
): string { ): string {
return `docs-${pluginId}-${versionName}`; 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,
};
}

View file

@ -11,8 +11,12 @@ export type StorageType = typeof StorageTypes[number];
const DefaultStorageType: StorageType = 'localStorage'; 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( function getBrowserStorage(
storageType: StorageType = DefaultStorageType, storageType: StorageType = DefaultStorageType,
): Storage | null { ): Storage | null {
@ -32,11 +36,12 @@ function getBrowserStorage(
} }
} }
/**
* Poor man's memoization to avoid logging multiple times the same warning
* Sometimes, localStorage/sessionStorage is unavailable due to browser policies
*/
let hasLoggedBrowserStorageNotAvailableWarning = false; let hasLoggedBrowserStorageNotAvailableWarning = false;
/**
* Poor man's memoization to avoid logging multiple times the same warning.
* Sometimes, `localStorage`/`sessionStorage` is unavailable due to browser
* policies.
*/
function logOnceBrowserStorageNotAvailableWarning(error: Error) { function logOnceBrowserStorageNotAvailableWarning(error: Error) {
if (!hasLoggedBrowserStorageNotAvailableWarning) { if (!hasLoggedBrowserStorageNotAvailableWarning) {
console.warn( console.warn(
@ -61,7 +66,7 @@ const NoopStorageSlot: StorageSlot = {
del: () => {}, 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 createServerStorageSlot(key: string): StorageSlot {
function throwError(): never { function throwError(): never {
throw new Error(`Illegal storage API usage for storage key "${key}". 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. * Creates an interface to work on a particular key in the storage model.
* The API is fail-safe, and usage of browser storage should be considered * 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 * unreliable. Local storage might simply be unavailable (iframe + browser
* security) or operations might fail individually. Please assume that using * 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, key: string,
options?: {persistence?: StorageType}, options?: {persistence?: StorageType},
): StorageSlot => { ): StorageSlot {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return createServerStorageSlot(key); 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. * or an empty list if browser storage can't be accessed.
*/ */
export function listStorageKeys( export function listStorageKeys(

View file

@ -26,10 +26,13 @@ function getTagLetter(tag: string): string {
return tag[0]!.toUpperCase(); return tag[0]!.toUpperCase();
} }
/**
* Takes a list of tags (as provided by the content plugins), and groups them by
* their initials.
*/
export function listTagsByLetters( export function listTagsByLetters(
tags: readonly TagsListItem[], tags: readonly TagsListItem[],
): TagLetterEntry[] { ): TagLetterEntry[] {
// Group by letters
const groups: Record<string, TagsListItem[]> = {}; const groups: Record<string, TagsListItem[]> = {};
Object.values(tags).forEach((tag) => { Object.values(tags).forEach((tag) => {
const letter = getTagLetter(tag.name); const letter = getTagLetter(tag.name);

View file

@ -52,6 +52,10 @@ function treeifyTOC(flatTOC: readonly TOCItem[]): TOCTreeNode[] {
return rootNodes; 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[] { export function useTreeifiedTOC(toc: TOCItem[]): readonly TOCTreeNode[] {
return useMemo(() => treeifyTOC(toc), [toc]); 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({ export function useFilteredAndTreeifiedTOC({
toc, toc,
minHeadingLevel, minHeadingLevel,
@ -97,12 +113,7 @@ export function useFilteredAndTreeifiedTOC({
maxHeadingLevel: number; maxHeadingLevel: number;
}): readonly TOCTreeNode[] { }): readonly TOCTreeNode[] {
return useMemo( return useMemo(
() => () => filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}),
// 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}),
[toc, minHeadingLevel, maxHeadingLevel], [toc, minHeadingLevel, maxHeadingLevel],
); );
} }

View file

@ -8,12 +8,26 @@
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useLocation} from '@docusaurus/router'; import {useLocation} from '@docusaurus/router';
// Permits to obtain the url of the current page in another locale /**
// Useful to generate hreflang meta headers etc... * Permits to obtain the url of the current page in another locale, useful to
// See https://developers.google.com/search/docs/advanced/crawling/localized-versions * generate hreflang meta headers etc...
*
* @see https://developers.google.com/search/docs/advanced/crawling/localized-versions
*/
export function useAlternatePageUtils(): { 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: ({ createUrl: ({
/** The locale name to link to. */
locale, 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, fullyQualified,
}: { }: {
locale: string; locale: string;
@ -46,8 +60,6 @@ export function useAlternatePageUtils(): {
fullyQualified, fullyQualified,
}: { }: {
locale: string; 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; fullyQualified: boolean;
}) { }) {
return `${fullyQualified ? url : ''}${getLocalizedBaseUrl( return `${fullyQualified ? url : ''}${getLocalizedBaseUrl(

View file

@ -1,55 +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';
export type useContextualSearchFiltersReturns = {
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();
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,
};
}

View file

@ -5,13 +5,13 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useLocation} from '@docusaurus/router'; import {useLocation} from '@docusaurus/router';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
/** /**
* Get the pathname of current route, without the optional site baseUrl * Get the pathname of current route, without the optional site baseUrl.
* - /docs/myDoc => /docs/myDoc * - `/docs/myDoc` => `/docs/myDoc`
* - /baseUrl/docs/myDoc => /docs/myDoc * - `/baseUrl/docs/myDoc` => `/docs/myDoc`
*/ */
export function useLocalPathname(): string { export function useLocalPathname(): string {
const { const {

View file

@ -10,14 +10,17 @@ import {useLocation} from '@docusaurus/router';
import type {Location} from 'history'; import type {Location} from 'history';
import {useDynamicCallback, usePrevious} from './reactUtils'; import {useDynamicCallback, usePrevious} from './reactUtils';
type LocationChangeEvent = { /**
location: Location; * Fires an effect when the location changes (which includes hash, query, etc.).
previousLocation: Location | undefined; * Importantly, doesn't fire when there's no previous location: see
}; * https://github.com/facebook/docusaurus/pull/6696
*/
type OnLocationChange = (locationChangeEvent: LocationChangeEvent) => void; export function useLocationChange(
onLocationChange: (locationChangeEvent: {
export function useLocationChange(onLocationChange: OnLocationChange): void { location: Location;
previousLocation: Location | undefined;
}) => void,
): void {
const location = useLocation(); const location = useLocation();
const previousLocation = usePrevious(location); const previousLocation = usePrevious(location);

View file

@ -105,7 +105,18 @@ function selectPluralMessage(
return parts[Math.min(pluralFormIndex, parts.length - 1)]!; return parts[Math.min(pluralFormIndex, parts.length - 1)]!;
} }
/**
* Reads the current locale and returns an interface very similar to
* `Intl.PluralRules`.
*/
export function usePluralForm(): { 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; selectMessage: (count: number, pluralMessages: string) => string;
} { } {
const localePluralForm = useLocalePluralForms(); const localePluralForm = useLocalePluralForms();

View file

@ -127,6 +127,9 @@ export type ThemeConfig = {
// User-provided theme config, unnormalized // User-provided theme config, unnormalized
export type UserThemeConfig = DeepPartial<ThemeConfig>; export type UserThemeConfig = DeepPartial<ThemeConfig>;
/**
* A convenient/more semantic way to get theme config from context.
*/
export function useThemeConfig(): ThemeConfig { export function useThemeConfig(): ThemeConfig {
return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig; return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig;
} }