Merge branch 'main' of github.com:facebook/docusaurus into lex111/filter-sidebar

This commit is contained in:
Alexey Pyltsyn 2022-03-28 21:38:50 +03:00
commit 2bc3b28cec
543 changed files with 11307 additions and 9410 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@docusaurus/theme-common",
"version": "2.0.0-beta.17",
"version": "2.0.0-beta.18",
"description": "Common code for Docusaurus themes.",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
@ -18,10 +18,10 @@
},
"license": "MIT",
"dependencies": {
"@docusaurus/module-type-aliases": "2.0.0-beta.17",
"@docusaurus/plugin-content-blog": "2.0.0-beta.17",
"@docusaurus/plugin-content-docs": "2.0.0-beta.17",
"@docusaurus/plugin-content-pages": "2.0.0-beta.17",
"@docusaurus/module-type-aliases": "2.0.0-beta.18",
"@docusaurus/plugin-content-blog": "2.0.0-beta.18",
"@docusaurus/plugin-content-docs": "2.0.0-beta.18",
"@docusaurus/plugin-content-pages": "2.0.0-beta.18",
"clsx": "^1.1.1",
"parse-numeric-range": "^1.3.0",
"prism-react-renderer": "^1.3.1",
@ -29,8 +29,8 @@
"utility-types": "^3.10.0"
},
"devDependencies": {
"@docusaurus/core": "2.0.0-beta.17",
"@docusaurus/types": "2.0.0-beta.17",
"@docusaurus/core": "2.0.0-beta.18",
"@docusaurus/types": "2.0.0-beta.18",
"fs-extra": "^10.0.1",
"lodash": "^4.17.21"
},

View file

@ -20,20 +20,19 @@ import React, {
const DefaultAnimationEasing = 'ease-in-out';
export type UseCollapsibleConfig = {
/**
* This hook is a very thin wrapper around a `useState`.
*/
export function useCollapsible({
initialState,
}: {
/** The initial state. Will be non-collapsed by default. */
initialState: boolean | (() => boolean);
};
export type UseCollapsibleReturns = {
}): {
collapsed: boolean;
setCollapsed: Dispatch<SetStateAction<boolean>>;
toggleCollapsed: () => void;
};
// This hook just define the state
export function useCollapsible({
initialState,
}: UseCollapsibleConfig): UseCollapsibleReturns {
} {
const [collapsed, setCollapsed] = useState(initialState ?? false);
const toggleCollapsed = useCallback(() => {
@ -152,8 +151,10 @@ type CollapsibleElementType = React.ElementType<
Pick<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) {
if (ExecutionEnvironment.canUseDOM) {
return undefined;
@ -162,16 +163,27 @@ function getSSRStyle(collapsed: boolean) {
}
type CollapsibleBaseProps = {
/** The actual DOM element to be used in the markup. */
as?: CollapsibleElementType;
/** Initial collapsed state. */
collapsed: boolean;
children: ReactNode;
/** Configuration of animation, like `duration` and `easing` */
animation?: CollapsibleAnimationConfig;
/**
* A callback fired when the collapse transition animation ends. Receives
* the **new** collapsed state: e.g. when
* expanding, `collapsed` will be `false`. You can use this for some "cleanup"
* like applying new styles when the container is fully expanded.
*/
onCollapseTransitionEnd?: (collapsed: boolean) => void;
/** Class name for the underlying DOM element. */
className?: string;
// This is mostly useful for details/summary component where ssrStyle is not
// needed (as details are hidden natively) and can mess up with the default
// native behavior of the browser when JS fails to load or is disabled
/**
* This is mostly useful for details/summary component where ssrStyle is not
* needed (as details are hidden natively) and can mess up with the browser's
* native behavior when JS fails to load or is disabled
*/
disableSSRStyle?: boolean;
};
@ -233,14 +245,20 @@ function CollapsibleLazy({collapsed, ...props}: CollapsibleBaseProps) {
}
type CollapsibleProps = CollapsibleBaseProps & {
// Lazy allows to delay the rendering when collapsed => it will render
// children only after hydration, on first expansion
// Required prop: it forces to think if content should be server-rendered
// or not! This has perf impact on the SSR output and html file sizes
// See https://github.com/facebook/docusaurus/issues/4753
/**
* Delay rendering of the content till first expansion. Marked as required to
* force us to think if content should be server-rendered or not. This has
* perf impact since it reduces html file sizes, but could undermine SEO.
* @see https://github.com/facebook/docusaurus/issues/4753
*/
lazy: boolean;
};
/**
* A headless component providing smooth and uniform collapsing behavior. The
* component will be invisible (zero height) when collapsed. Doesn't provide
* interactivity by itself: collapse state is toggled through props.
*/
export function Collapsible({lazy, ...props}: CollapsibleProps): JSX.Element {
const Comp = lazy ? CollapsibleLazy : CollapsibleBase;
return <Comp {...props} />;

View file

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

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 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?.items,
).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
);
});
it('reads value from context provider', () => {
const name = 'mySidebarName';
const items: PropSidebar = [];
const {result} = renderHook(() => useDocsSidebar(), {
wrapper: ({children}) => (
<DocsSidebarProvider name={name} items={items}>
{children}
</DocsSidebarProvider>
),
});
expect(result.current).toBeDefined();
expect(result.current!.name).toBe(name);
expect(result.current!.items).toBe(items);
});
});

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

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

View file

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

View file

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

View file

@ -0,0 +1,250 @@
/**
* 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, {
useContext,
useEffect,
useMemo,
useState,
useCallback,
type ReactNode,
} from 'react';
import {
useThemeConfig,
type DocsVersionPersistence,
} from '../utils/useThemeConfig';
import {isDocsPluginEnabled} from '../utils/docsUtils';
import {ReactContextError} from '../utils/reactUtils';
import {createStorageSlot} from '../utils/storageUtils';
import {
useAllDocsData,
useDocsData,
type GlobalPluginData,
type GlobalVersion,
} from '@docusaurus/plugin-content-docs/client';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
const DocsPreferredVersionStorage = {
save: (
pluginId: string,
persistence: DocsVersionPersistence,
versionName: string,
): void => {
createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
},
read: (
pluginId: string,
persistence: DocsVersionPersistence,
): string | null =>
createStorageSlot(storageKey(pluginId), {persistence}).get(),
clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
createStorageSlot(storageKey(pluginId), {persistence}).del();
},
};
type DocsPreferredVersionName = string | null;
/** State for a single docs plugin instance */
type DocsPreferredVersionPluginState = {
preferredVersionName: DocsPreferredVersionName;
};
/**
* We need to store the state in storage globally, with one preferred version
* per docs plugin instance.
*/
type DocsPreferredVersionState = {
[pluginId: string]: DocsPreferredVersionPluginState;
};
/**
* Initial state is always null as we can't read local storage from node SSR
*/
const getInitialState = (pluginIds: string[]): DocsPreferredVersionState =>
Object.fromEntries(pluginIds.map((id) => [id, {preferredVersionName: null}]));
/**
* Read storage for all docs plugins, assigning each doc plugin a preferred
* version (if found)
*/
function readStorageState({
pluginIds,
versionPersistence,
allDocsData,
}: {
pluginIds: string[];
versionPersistence: DocsVersionPersistence;
allDocsData: {[pluginId: string]: GlobalPluginData};
}): DocsPreferredVersionState {
/**
* The storage value we read might be stale, and belong to a version that does
* not exist in the site anymore. In such case, we remove the storage value to
* avoid downstream errors.
*/
function restorePluginState(
pluginId: string,
): DocsPreferredVersionPluginState {
const preferredVersionNameUnsafe = DocsPreferredVersionStorage.read(
pluginId,
versionPersistence,
);
const pluginData = allDocsData[pluginId]!;
const versionExists = pluginData.versions.some(
(version) => version.name === preferredVersionNameUnsafe,
);
if (versionExists) {
return {preferredVersionName: preferredVersionNameUnsafe};
}
DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
return {preferredVersionName: null};
}
return Object.fromEntries(
pluginIds.map((id) => [id, restorePluginState(id)]),
);
}
function useVersionPersistence(): DocsVersionPersistence {
return useThemeConfig().docs.versionPersistence;
}
type ContextValue = [
state: DocsPreferredVersionState,
api: {
savePreferredVersion: (pluginId: string, versionName: string) => void;
},
];
const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const allDocsData = useAllDocsData();
const versionPersistence = useVersionPersistence();
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
// Initial state is empty, as we can't read browser storage in node/SSR
const [state, setState] = useState(() => getInitialState(pluginIds));
// On mount, we set the state read from browser storage
useEffect(() => {
setState(readStorageState({allDocsData, versionPersistence, pluginIds}));
}, [allDocsData, versionPersistence, pluginIds]);
// The API that we expose to consumer hooks (memo for constant object)
const api = useMemo(() => {
function savePreferredVersion(pluginId: string, versionName: string) {
DocsPreferredVersionStorage.save(
pluginId,
versionPersistence,
versionName,
);
setState((s) => ({
...s,
[pluginId]: {preferredVersionName: versionName},
}));
}
return {
savePreferredVersion,
};
}, [versionPersistence]);
return [state, api];
}
function DocsPreferredVersionContextProviderUnsafe({
children,
}: {
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({
children,
}: {
children: JSX.Element;
}): JSX.Element {
if (isDocsPluginEnabled) {
return (
<DocsPreferredVersionContextProviderUnsafe>
{children}
</DocsPreferredVersionContextProviderUnsafe>
);
}
return children;
}
function useDocsPreferredVersionContext(): ContextValue {
const value = useContext(Context);
if (!value) {
throw new ReactContextError('DocsPreferredVersionContextProvider');
}
return value;
}
/**
* Returns a read-write interface to a plugin's preferred version.
* Note, the `preferredVersion` attribute will always be `null` before mount.
*/
export function useDocsPreferredVersion(
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
): {
preferredVersion: GlobalVersion | null;
savePreferredVersionName: (versionName: string) => void;
} {
const docsData = useDocsData(pluginId);
const [state, api] = useDocsPreferredVersionContext();
const {preferredVersionName} = state[pluginId]!;
const preferredVersion =
docsData.versions.find(
(version) => version.name === preferredVersionName,
) ?? null;
const savePreferredVersionName = useCallback(
(versionName: string) => {
api.savePreferredVersion(pluginId, versionName);
},
[api, pluginId],
);
return {preferredVersion, savePreferredVersionName};
}
export function useDocsPreferredVersionByPluginId(): {
[pluginId: string]: GlobalVersion | null;
} {
const allDocsData = useAllDocsData();
const [state] = useDocsPreferredVersionContext();
function getPluginIdPreferredVersion(pluginId: string) {
const docsData = allDocsData[pluginId]!;
const {preferredVersionName} = state[pluginId]!;
return (
docsData.versions.find(
(version) => version.name === preferredVersionName,
) ?? null
);
}
const pluginIds = Object.keys(allDocsData);
return Object.fromEntries(
pluginIds.map((id) => [id, getPluginIdPreferredVersion(id)]),
);
}

View file

@ -0,0 +1,56 @@
/**
* 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, {useMemo, useContext, type ReactNode} 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');
type SidebarContextValue = {name: string; items: PropSidebar};
const Context = React.createContext<
SidebarContextValue | null | typeof EmptyContext
>(EmptyContext);
/**
* Provide the current sidebar to your children.
*/
export function DocsSidebarProvider({
children,
name,
items,
}: {
children: ReactNode;
name: string | undefined;
items: PropSidebar | undefined;
}): JSX.Element {
const stableValue: SidebarContextValue | null = useMemo(
() =>
name && items
? {
name,
items,
}
: null,
[name, items],
);
return <Context.Provider value={stableValue}>{children}</Context.Provider>;
}
/**
* Gets the sidebar data that's currently displayed, or `null` if there isn't one
*/
export function useDocsSidebar(): SidebarContextValue | null {
const value = useContext(Context);
if (value === EmptyContext) {
throw new ReactContextError('DocsSidebarProvider');
}
return value;
}

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

@ -0,0 +1,99 @@
/**
* 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, {
useCallback,
useEffect,
useState,
useMemo,
type ReactNode,
} from 'react';
import {useWindowSize} from '../hooks/useWindowSize';
import {useHistoryPopHandler} from '../utils/historyUtils';
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
import {useThemeConfig} from '../utils/useThemeConfig';
import {ReactContextError} from '../utils/reactUtils';
type ContextValue = {
/**
* Mobile sidebar should be disabled in case it's empty, i.e. no secondary
* menu + no navbar items). If disabled, the toggle button should not be
* displayed at all.
*/
disabled: boolean;
/**
* Signals whether the actual sidebar should be displayed (contrary to
* `disabled` which is about the toggle button). Sidebar should not visible
* until user interaction to avoid SSR rendering.
*/
shouldRender: boolean;
/** The displayed state. Can be toggled with the `toggle` callback. */
shown: boolean;
/** Toggle the `shown` attribute. */
toggle: () => void;
};
const Context = React.createContext<ContextValue | undefined>(undefined);
function useIsNavbarMobileSidebarDisabled() {
const activeDocPlugin = useActivePlugin();
const {items} = useThemeConfig().navbar;
return items.length === 0 && !activeDocPlugin;
}
function useContextValue(): ContextValue {
const disabled = useIsNavbarMobileSidebarDisabled();
const windowSize = useWindowSize();
const shouldRender = !disabled && windowSize === 'mobile';
const [shown, setShown] = useState(false);
// Close mobile sidebar on navigation pop
// Most likely firing when using the Android back button (but not only)
useHistoryPopHandler(() => {
if (shown) {
setShown(false);
// Should we prevent the navigation here?
// See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846
return false; // prevent pop navigation
}
return undefined;
});
const toggle = useCallback(() => {
setShown((s) => !s);
}, []);
useEffect(() => {
if (windowSize === 'desktop') {
setShown(false);
}
}, [windowSize]);
return useMemo(
() => ({disabled, shouldRender, toggle, shown}),
[disabled, shouldRender, toggle, shown],
);
}
export function NavbarMobileSidebarProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useNavbarMobileSidebar(): ContextValue {
const context = React.useContext(Context);
if (context === undefined) {
throw new ReactContextError('NavbarMobileSidebarProvider');
}
return context;
}

View file

@ -14,20 +14,8 @@ import React, {
type ReactNode,
type ComponentType,
} from 'react';
import {ReactContextError} from './reactUtils';
import {usePrevious} from './usePrevious';
import {useNavbarMobileSidebar} from './navbarUtils';
/*
The idea behind all this is that a specific component must be able to fill a
placeholder in the generic layout. The doc page should be able to fill the
secondary menu of the main mobile navbar. This permits to reduce coupling
between the main layout and the specific page.
This kind of feature is often called portal/teleport/gateway... various
unmaintained React libs exist. Most up-to-date one: https://github.com/gregberge/react-teleporter
Not sure any of those is safe regarding concurrent mode.
*/
import {ReactContextError, usePrevious} from '../utils/reactUtils';
import {useNavbarMobileSidebar} from './navbarMobileSidebar';
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
@ -35,7 +23,7 @@ type State = {
shown: boolean;
content:
| {
component: ComponentType<object>;
component: NavbarSecondaryMenuComponent<object>;
props: object;
}
| {component: null; props: null};
@ -46,7 +34,14 @@ const InitialState: State = {
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 [state, setState] = useState<State>(InitialState);
@ -77,21 +72,16 @@ function useContextValue() {
}
}, [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({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
);
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
function useNavbarSecondaryMenuContext(): ContextValue {
@ -102,7 +92,7 @@ function useNavbarSecondaryMenuContext(): ContextValue {
return value;
}
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
function useShallowMemoizedObject<O>(obj: O) {
return useMemo(
() => obj,
// Is this safe?
@ -111,15 +101,22 @@ function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
);
}
// Fill the secondary menu placeholder with some real content
export function NavbarSecondaryMenuFiller<
Props extends Record<string, unknown>,
>({
/**
* This component renders nothing by itself, but it fills the placeholder in the
* generic secondary menu layout. This reduces coupling between the main layout
* and the specific page.
*
* This kind of feature is often called portal/teleport/gateway/outlet...
* Various unmaintained React libs exist. Most up-to-date one:
* https://github.com/gregberge/react-teleporter
* Not sure any of those is safe regarding concurrent mode.
*/
export function NavbarSecondaryMenuFiller<P extends object>({
component,
props,
}: {
component: NavbarSecondaryMenuComponent<Props>;
props: Props;
component: NavbarSecondaryMenuComponent<P>;
props: P;
}): JSX.Element | null {
const [, setState] = useNavbarSecondaryMenuContext();
@ -147,9 +144,16 @@ function renderElement(state: State): JSX.Element | undefined {
return undefined;
}
/** Wires the logic for rendering the mobile navbar secondary menu. */
export function useNavbarSecondaryMenu(): {
/** Whether secondary menu is displayed. */
shown: boolean;
/**
* Hide the secondary menu; fired either when hiding the entire sidebar, or
* when going back to the primary menu.
*/
hide: () => void;
/** The content returned from the current secondary menu filler. */
content: JSX.Element | undefined;
} {
const [state, setState] = useNavbarSecondaryMenuContext();
@ -160,11 +164,7 @@ export function useNavbarSecondaryMenu(): {
);
return useMemo(
() => ({
shown: state.shown,
hide,
content: renderElement(state),
}),
() => ({shown: state.shown, hide, content: renderElement(state)}),
[hide, state],
);
}

View file

@ -13,21 +13,21 @@ import React, {
useContext,
type ReactNode,
} from 'react';
import {createStorageSlot, listStorageKeys} from './storageUtils';
import {ReactContextError} from './reactUtils';
import {createStorageSlot, listStorageKeys} from '../utils/storageUtils';
import {ReactContextError} from '../utils/reactUtils';
const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
type TabGroupChoiceContextValue = {
type ContextValue = {
/** A map from `groupId` to the `value` of the saved choice. */
readonly tabGroupChoices: {readonly [groupId: string]: string};
/** Set the new choice value of a group. */
readonly setTabGroupChoices: (groupId: string, newChoice: string) => void;
};
const TabGroupChoiceContext = React.createContext<
TabGroupChoiceContextValue | undefined
>(undefined);
const Context = React.createContext<ContextValue | undefined>(undefined);
function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue {
function useContextValue(): ContextValue {
const [tabGroupChoices, setChoices] = useState<{
readonly [groupId: string]: string;
}>({});
@ -37,7 +37,7 @@ function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue {
useEffect(() => {
try {
const localStorageChoices: Record<string, string> = {};
const localStorageChoices: {[groupId: string]: string} = {};
listStorageKeys().forEach((storageKey) => {
if (storageKey.startsWith(TAB_CHOICE_PREFIX)) {
const groupId = storageKey.substring(TAB_CHOICE_PREFIX.length);
@ -50,13 +50,18 @@ function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue {
}
}, []);
return {
tabGroupChoices,
setTabGroupChoices: (groupId: string, newChoice: string) => {
const setTabGroupChoices = useCallback(
(groupId: string, newChoice: string) => {
setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice}));
setChoiceSyncWithLocalStorage(groupId, newChoice);
},
};
[setChoiceSyncWithLocalStorage],
);
return useMemo(
() => ({tabGroupChoices, setTabGroupChoices}),
[tabGroupChoices, setTabGroupChoices],
);
}
export function TabGroupChoiceProvider({
@ -64,23 +69,12 @@ export function TabGroupChoiceProvider({
}: {
children: ReactNode;
}): JSX.Element {
const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoiceContextValue();
const contextValue = useMemo(
() => ({
tabGroupChoices,
setTabGroupChoices,
}),
[tabGroupChoices, setTabGroupChoices],
);
return (
<TabGroupChoiceContext.Provider value={contextValue}>
{children}
</TabGroupChoiceContext.Provider>
);
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useTabGroupChoice(): TabGroupChoiceContextValue {
const context = useContext(TabGroupChoiceContext);
export function useTabGroupChoice(): ContextValue {
const context = useContext(Context);
if (context == null) {
throw new ReactContextError('TabGroupChoiceProvider');
}

View file

@ -0,0 +1,73 @@
/**
* 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 {useRef, useState} from 'react';
import {useScrollPosition, useSmoothScrollTo} from '../utils/scrollUtils';
import {useLocationChange} from '../utils/useLocationChange';
/** Wires the logic for the back to top button. */
export function useBackToTopButton({
threshold,
}: {
/**
* The minimum vertical scroll position, above which a scroll-up would not
* cause `shown` to become `true`. This is because BTT is only useful if the
* user is far down the page.
*/
threshold: number;
}): {
/**
* Whether the button should be displayed. We only show if the user has
* scrolled up and is on a vertical position greater than `threshold`.
*/
shown: boolean;
/**
* A (memoized) handle for starting the scroll, which you can directly plug
* into the props.
*/
scrollToTop: () => void;
} {
const [shown, setShown] = useState(false);
const isFocusedAnchor = useRef(false);
const {startScroll, cancelScroll} = useSmoothScrollTo();
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
const lastScrollTop = lastPosition?.scrollY;
// Component is just being mounted. Not really a scroll event from the user.
// Ignore it.
if (!lastScrollTop) {
return;
}
if (isFocusedAnchor.current) {
// This scroll position change is triggered by navigating to an anchor.
// Ignore it.
isFocusedAnchor.current = false;
} else if (scrollTop >= lastScrollTop) {
// The user has scrolled down to "fight against" the animation. Cancel any
// animation under progress.
cancelScroll();
setShown(false);
} else if (scrollTop < threshold) {
// Scrolled to the minimum position; hide the button.
setShown(false);
} else if (
scrollTop + window.innerHeight <
document.documentElement.scrollHeight
) {
setShown(true);
}
});
useLocationChange((locationChangeEvent) => {
if (locationChangeEvent.location.hash) {
isFocusedAnchor.current = true;
setShown(false);
}
});
return {shown, scrollToTop: () => startScroll(0)};
}

View file

@ -9,14 +9,16 @@ import {useState, useCallback, useRef} from 'react';
import {useLocationChange} from '../utils/useLocationChange';
import {useScrollPosition} from '../utils/scrollUtils';
type UseHideableNavbarReturns = {
/**
* Wires the imperative logic of a hideable navbar.
* @param hideOnScroll If `false`, this hook is basically a no-op.
*/
export function useHideableNavbar(hideOnScroll: boolean): {
/** A ref to the navbar component. Plug this into the actual element. */
readonly navbarRef: (node: HTMLElement | null) => void;
/** If `false`, the navbar component should not be rendered. */
readonly isNavbarVisible: boolean;
};
export default function useHideableNavbar(
hideOnScroll: boolean,
): UseHideableNavbarReturns {
} {
const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll);
const isFocusedAnchor = useRef(false);
const navbarHeight = useRef(0);
@ -26,14 +28,13 @@ export default function useHideableNavbar(
}
}, []);
useScrollPosition((currentPosition, lastPosition) => {
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
if (!hideOnScroll) {
return;
}
const scrollTop = currentPosition.scrollY;
// It needed for mostly to handle rubber band scrolling
// Needed mostly for handling rubber band scrolling.
// See https://github.com/facebook/docusaurus/pull/5721
if (scrollTop < navbarHeight.current) {
setIsNavbarVisible(true);
return;
@ -70,8 +71,5 @@ export default function useHideableNavbar(
setIsNavbarVisible(true);
});
return {
navbarRef,
isNavbarVisible,
};
return {navbarRef, isNavbarVisible};
}

View file

@ -11,9 +11,17 @@ import './styles.css';
export const keyboardFocusedClassName = 'navigation-with-keyboard';
// This hook detect keyboard focus indicator to not show outline for mouse users
// Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
export default function useKeyboardNavigation(): void {
/**
* Side-effect that adds the `keyboardFocusedClassName` to the body element when
* the keyboard has been pressed, or removes it when the mouse is clicked.
*
* The presence of this class name signals that the user may be using keyboard
* for navigation, and the theme **must** add focus outline when this class name
* is present. (And optionally not if it's absent, for design purposes)
*
* Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
*/
export function useKeyboardNavigation(): void {
useEffect(() => {
function handleOutlineStyles(e: MouseEvent | KeyboardEvent) {
if (e.type === 'keydown' && (e as KeyboardEvent).key === 'Tab') {

View file

@ -7,10 +7,13 @@
import {useEffect} from 'react';
export default function useLockBodyScroll(lock: boolean = true): void {
/**
* Side-effect that locks the document body's scroll throughout the lifetime of
* the containing component. e.g. when the mobile sidebar is expanded.
*/
export function useLockBodyScroll(lock: boolean = true): void {
useEffect(() => {
document.body.style.overflow = lock ? 'hidden' : 'visible';
return () => {
document.body.style.overflow = 'visible';
};

View file

@ -6,10 +6,14 @@
*/
import defaultTheme from 'prism-react-renderer/themes/palenight';
import {useColorMode} from '../utils/colorModeUtils';
import {useColorMode} from '../contexts/colorMode';
import {useThemeConfig} from '../utils/useThemeConfig';
export default function usePrismTheme(): typeof defaultTheme {
/**
* Returns a color-mode-dependent Prism theme: whatever the user specified in
* the config. Falls back to `palenight`.
*/
export function usePrismTheme(): typeof defaultTheme {
const {prism} = useThemeConfig();
const {colorMode} = useColorMode();
const lightModeTheme = prism.theme || defaultTheme;

View file

@ -11,13 +11,24 @@ import {useCallback, useEffect, useState} from 'react';
const SEARCH_PARAM_QUERY = 'q';
interface UseSearchPageReturn {
/** Some utility functions around search queries. */
export function useSearchPage(): {
/**
* Works hand-in-hand with `setSearchQuery`; whatever the user has inputted
* into the search box.
*/
searchQuery: string;
/**
* Set a new search query. In addition to updating `searchQuery`, this handle
* also mutates the location and appends the query.
*/
setSearchQuery: (newSearchQuery: string) => void;
/**
* Given a query, this handle generates the corresponding search page link,
* with base URL prepended.
*/
generateSearchPageLink: (targetSearchQuery: string) => string;
}
export default function useSearchPage(): UseSearchPageReturn {
} {
const history = useHistory();
const {
siteConfig: {baseUrl},
@ -54,7 +65,9 @@ export default function useSearchPage(): UseSearchPageReturn {
const generateSearchPageLink = useCallback(
(targetSearchQuery: string) =>
// Refer to https://github.com/facebook/docusaurus/pull/2838
`${baseUrl}search?q=${encodeURIComponent(targetSearchQuery)}`,
`${baseUrl}search?${SEARCH_PARAM_QUERY}=${encodeURIComponent(
targetSearchQuery,
)}`,
[baseUrl],
);

View file

@ -6,13 +6,15 @@
*/
import {useEffect, useRef} from 'react';
import {useThemeConfig} from './useThemeConfig';
import {useThemeConfig} from '../utils/useThemeConfig';
// TODO make the hardcoded theme-classic classnames configurable (or add them
// to ThemeClassNames?)
// If the anchor has no height and is just a "marker" in the dom; we'll use the
// parent (normally the link text) rect boundaries instead
/**
* If the anchor has no height and is just a "marker" in the DOM; we'll use the
* parent (normally the link text) rect boundaries instead
*/
function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
const rect = element.getBoundingClientRect();
const hasNoHeight = rect.top === rect.bottom;
@ -24,7 +26,7 @@ function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
/**
* Considering we divide viewport into 2 zones of each 50vh, this returns true
* if an element is in the first zone (ie, appear in viewport, near the top)
* if an element is in the first zone (i.e., appear in viewport, near the top)
*/
function isInViewportTopHalf(boundingRect: DOMRect) {
return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2;
@ -114,15 +116,24 @@ function useAnchorTopOffsetRef() {
}
export type TOCHighlightConfig = {
/** A class name that all TOC links share. */
linkClassName: string;
/** The class name applied to the active (highlighted) link. */
linkActiveClassName: string;
/**
* The minimum heading level that the TOC includes. Only headings that are in
* this range will be eligible as "active heading".
*/
minHeadingLevel: number;
/** @see {@link TOCHighlightConfig.minHeadingLevel} */
maxHeadingLevel: number;
};
export default function useTOCHighlight(
config: TOCHighlightConfig | undefined,
): void {
/**
* Side-effect that applies the active class name to the TOC heading that the
* user is currently viewing. Disabled when `config` is undefined.
*/
export function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
const anchorTopOffsetRef = useAnchorTopOffsetRef();

View file

@ -12,12 +12,6 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
const windowSizes = {
desktop: 'desktop',
mobile: 'mobile',
// This "ssr" value is very important to handle hydration FOUC / layout shifts
// You have to handle server-rendering explicitly on the call-site
// On the server, you may need to render BOTH the mobile/desktop elements (and
// hide one of them with mediaquery)
// We don't return "undefined" on purpose, to make it more explicit
ssr: 'ssr',
} as const;
@ -34,14 +28,22 @@ function getWindowSize() {
: windowSizes.mobile;
}
// Simulate the SSR window size in dev, so that potential hydration FOUC/layout
// shift problems can be seen in dev too!
const DevSimulateSSR = process.env.NODE_ENV === 'development' && true;
// This hook returns an enum value on purpose!
// We don't want it to return the actual width value, for resize perf reasons
// We only want to re-render once a breakpoint is crossed
export default function useWindowSize(): WindowSize {
/**
* Gets the current window size as an enum value. We don't want it to return the
* actual width value, so that it only re-renders once a breakpoint is crossed.
*
* It may return `"ssr"`, which is very important to handle hydration FOUC or
* layout shifts. You have to handle it explicitly upfront. On the server, you
* may need to render BOTH the mobile/desktop elements (and hide one of them
* with mediaquery). We don't return `undefined` on purpose, to make it more
* explicit.
*
* In development mode, this hook will still return `"ssr"` for one second, to
* catch potential layout shifts, similar to strict mode calling effects twice.
*/
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>(() => {
if (DevSimulateSSR) {
return 'ssr';

View file

@ -22,29 +22,29 @@ export {
export {
DocSidebarItemsExpandedStateProvider,
useDocSidebarItemsExpandedState,
} from './utils/docSidebarItemsExpandedState';
} from './contexts/docSidebarItemsExpandedState';
export {DocsVersionProvider, useDocsVersion} from './contexts/docsVersion';
export {DocsSidebarProvider, useDocsSidebar} from './contexts/docsSidebar';
export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
export {useContextualSearchFilters} from './utils/useContextualSearchFilters';
export {
parseCodeBlockTitle,
parseLanguage,
parseLines,
} from './utils/codeBlockUtils';
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils';
export {
docVersionSearchTag,
DEFAULT_SEARCH_TAG,
useContextualSearchFilters,
} from './utils/searchUtils';
export {
isDocsPluginEnabled,
DocsVersionProvider,
useDocsVersion,
useDocById,
DocsSidebarProvider,
useDocsSidebar,
findSidebarCategory,
findFirstCategoryLink,
useCurrentSidebarCategory,
@ -52,40 +52,30 @@ export {
useSidebarBreadcrumbs,
} from './utils/docsUtils';
export {isSamePath} from './utils/pathUtils';
export {useTitleFormatter} from './utils/generalUtils';
export {usePluralForm} from './utils/usePluralForm';
export {useLocationChange} from './utils/useLocationChange';
export {usePrevious} from './utils/usePrevious';
export {useCollapsible, Collapsible} from './components/Collapsible';
export {
useCollapsible,
Collapsible,
type UseCollapsibleConfig,
type UseCollapsibleReturns,
} from './components/Collapsible';
export {default as Details, type DetailsProps} from './components/Details';
export {Details, type DetailsProps} from './components/Details';
export {
useDocsPreferredVersion,
useDocsPreferredVersionByPluginId,
} from './utils/docsPreferredVersion/useDocsPreferredVersion';
DocsPreferredVersionContextProvider,
} from './contexts/docsPreferredVersion';
export {duplicates, uniq} from './utils/jsUtils';
export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider';
export {ThemeClassNames} from './utils/ThemeClassNames';
export {
AnnouncementBarProvider,
useAnnouncementBar,
} from './utils/announcementBarUtils';
} from './contexts/announcementBar';
export {useLocalPathname} from './utils/useLocalPathname';
@ -99,9 +89,9 @@ export {
export {useHistoryPopHandler} from './utils/historyUtils';
export {
default as useTOCHighlight,
useTOCHighlight,
type TOCHighlightConfig,
} from './utils/useTOCHighlight';
} from './hooks/useTOCHighlight';
export {
useFilteredAndTreeifiedTOC,
@ -116,17 +106,19 @@ export {
useScrollController,
useScrollPosition,
useScrollPositionBlocker,
useSmoothScrollTo,
} from './utils/scrollUtils';
export {
useIsomorphicLayoutEffect,
useDynamicCallback,
usePrevious,
ReactContextError,
} from './utils/reactUtils';
export {isRegexpStringMatch} from './utils/regexpUtils';
export {useHomePageRoute} from './utils/routesUtils';
export {useHomePageRoute, isSamePath} from './utils/routesUtils';
export {
PageMetadata,
@ -138,23 +130,9 @@ export {
useColorMode,
ColorModeProvider,
type ColorMode,
} from './utils/colorModeUtils';
} from './contexts/colorMode';
export {
useTabGroupChoice,
TabGroupChoiceProvider,
} from './utils/tabGroupChoiceUtils';
export {
splitNavbarItems,
NavbarProvider,
useNavbarMobileSidebar,
} from './utils/navbarUtils';
export {
useNavbarSecondaryMenu,
NavbarSecondaryMenuFiller,
} from './utils/navbarSecondaryMenuUtils';
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenuUtils';
export {splitNavbarItems, NavbarProvider} from './utils/navbarUtils';
export {
DocsFilterProvider,
@ -162,12 +140,25 @@ export {
filterDocsSidebar,
} from './utils/docsFilterUtils';
export {default as useHideableNavbar} from './hooks/useHideableNavbar';
export {
default as useKeyboardNavigation,
useTabGroupChoice,
TabGroupChoiceProvider,
} from './contexts/tabGroupChoice';
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
export {
useNavbarSecondaryMenu,
NavbarSecondaryMenuFiller,
type NavbarSecondaryMenuComponent,
} from './contexts/navbarSecondaryMenu';
export {useBackToTopButton} from './hooks/useBackToTopButton';
export {useHideableNavbar} from './hooks/useHideableNavbar';
export {
useKeyboardNavigation,
keyboardFocusedClassName,
} from './hooks/useKeyboardNavigation';
export {default as usePrismTheme} from './hooks/usePrismTheme';
export {default as useLockBodyScroll} from './hooks/useLockBodyScroll';
export {default as useWindowSize} from './hooks/useWindowSize';
export {default as useSearchPage} from './hooks/useSearchPage';
export {usePrismTheme} from './hooks/usePrismTheme';
export {useLockBodyScroll} from './hooks/useLockBodyScroll';
export {useWindowSize} from './hooks/useWindowSize';
export {useSearchPage} from './hooks/useSearchPage';

View file

@ -5,10 +5,13 @@
* LICENSE file in the root directory of this source tree.
*/
// These class names are used to style page layouts in Docusaurus
// Those are meant to be targeted by user-provided custom CSS selectors
// Please do not modify the classnames! This is a breaking change, and annoying
// for users!
/**
* These class names are used to style page layouts in Docusaurus, meant to be
* targeted by user-provided custom CSS selectors.
*/
export const ThemeClassNames = {
page: {
blogListPage: 'blog-list-page',
@ -17,8 +20,8 @@ export const ThemeClassNames = {
blogTagPostListPage: 'blog-tags-post-list-page',
docsDocPage: 'docs-doc-page',
docsTagsListPage: 'docs-tags-list-page', // List of tags
docsTagDocListPage: 'docs-tags-doc-list-page', // Docs for a tag
docsTagsListPage: 'docs-tags-list-page',
docsTagDocListPage: 'docs-tags-doc-list-page',
mdxPage: 'mdx-page',
},
@ -29,8 +32,9 @@ export const ThemeClassNames = {
mdxPages: 'mdx-wrapper',
},
// /!\ Please keep the naming convention consistent!
// Something like: "theme-{blog,doc,version,page}?-<suffix>"
/**
* Follows the naming convention "theme-{blog,doc,version,page}?-<suffix>"
*/
common: {
editThisPage: 'theme-edit-this-page',
lastUpdated: 'theme-last-updated',

View file

@ -10,17 +10,15 @@ import {renderHook} from '@testing-library/react-hooks';
import {
findFirstCategoryLink,
isActiveSidebarItem,
DocsVersionProvider,
useDocsVersion,
useDocById,
useDocsSidebar,
DocsSidebarProvider,
findSidebarCategory,
useCurrentSidebarCategory,
useSidebarBreadcrumbs,
} from '../docsUtils';
import {DocsSidebarProvider} from '../../contexts/docsSidebar';
import {DocsVersionProvider} from '../../contexts/docsVersion';
import {StaticRouter} from 'react-router-dom';
import {Context} from '@docusaurus/docusaurusContext';
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
import type {
PropSidebar,
PropSidebarItem,
@ -68,46 +66,6 @@ function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
};
}
describe('useDocsVersion', () => {
it('throws if context provider is missing', () => {
expect(
() => renderHook(() => useDocsVersion()).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsVersion is called outside the <DocsVersionProvider>. "`,
);
});
it('reads value from context provider', () => {
const version = testVersion();
const {result} = renderHook(() => useDocsVersion(), {
wrapper: ({children}) => (
<DocsVersionProvider version={version}>{children}</DocsVersionProvider>
),
});
expect(result.current).toBe(version);
});
});
describe('useDocsSidebar', () => {
it('throws if context provider is missing', () => {
expect(
() => renderHook(() => useDocsSidebar()).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
);
});
it('reads value from context provider', () => {
const sidebar: PropSidebar = [];
const {result} = renderHook(() => useDocsSidebar(), {
wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}>{children}</DocsSidebarProvider>
),
});
expect(result.current).toBe(sidebar);
});
});
describe('useDocById', () => {
const version = testVersion({
docs: {
@ -349,7 +307,7 @@ describe('useSidebarBreadcrumbs', () => {
},
},
}}>
<DocsSidebarProvider sidebar={sidebar}>
<DocsSidebarProvider name="sidebarName" items={sidebar}>
{children}
</DocsSidebarProvider>
</Context.Provider>
@ -472,7 +430,7 @@ describe('useCurrentSidebarCategory', () => {
(sidebar?: PropSidebar) => (location: string) =>
renderHook(() => useCurrentSidebarCategory(), {
wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}>
<DocsSidebarProvider name="sidebarName" items={sidebar}>
<StaticRouter location={location}>{children}</StaticRouter>
</DocsSidebarProvider>
),
@ -506,11 +464,11 @@ describe('useCurrentSidebarCategory', () => {
const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([
category,
]);
expect(() => mockUseCurrentSidebarCategory('/cat'))
.toThrowErrorMatchingInlineSnapshot(`
"Unexpected: sidebar category could not be found for pathname='/cat'.
Hook useCurrentSidebarCategory() should only be used on Category pages"
`);
expect(() =>
mockUseCurrentSidebarCategory('/cat'),
).toThrowErrorMatchingInlineSnapshot(
`"/cat is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages."`,
);
});
it('throws when sidebar is missing', () => {

View file

@ -8,7 +8,7 @@
import React from 'react';
import {useTitleFormatter} from '../generalUtils';
import {renderHook} from '@testing-library/react-hooks';
import {Context} from '@docusaurus/docusaurusContext';
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
import type {DocusaurusContext} from '@docusaurus/types';
describe('useTitleFormatter', () => {

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

@ -0,0 +1,22 @@
/**
* 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 {usePrevious} from '../reactUtils';
import {renderHook} from '@testing-library/react-hooks';
describe('usePrevious', () => {
it('returns the previous value of a variable', () => {
const {result, rerender} = renderHook((val) => usePrevious(val), {
initialProps: 1,
});
expect(result.current).toBeUndefined();
rerender(2);
expect(result.current).toBe(1);
rerender(3);
expect(result.current).toBe(2);
});
});

View file

@ -6,7 +6,39 @@
*/
import type {Route} from '@docusaurus/types';
import {findHomePageRoute} from '../routesUtils';
import {findHomePageRoute, isSamePath} from '../routesUtils';
describe('isSamePath', () => {
it('returns true for compared path without trailing slash', () => {
expect(isSamePath('/docs', '/docs')).toBeTruthy();
});
it('returns true for compared path with trailing slash', () => {
expect(isSamePath('/docs', '/docs/')).toBeTruthy();
});
it('returns true for compared path with different case', () => {
expect(isSamePath('/doCS', '/DOcs')).toBeTruthy();
});
it('returns true for compared path with different case + trailing slash', () => {
expect(isSamePath('/doCS', '/DOcs/')).toBeTruthy();
});
it('returns false for compared path with double trailing slash', () => {
expect(isSamePath('/docs', '/docs//')).toBeFalsy();
});
it('returns true for twice undefined/null', () => {
expect(isSamePath(undefined, undefined)).toBeTruthy();
expect(isSamePath(undefined, undefined)).toBeTruthy();
});
it('returns false when one undefined', () => {
expect(isSamePath('/docs', undefined)).toBeFalsy();
expect(isSamePath(undefined, '/docs')).toBeFalsy();
});
});
describe('findHomePageRoute', () => {
const homePage: Route = {

View file

@ -9,7 +9,7 @@ import React from 'react';
import {useAlternatePageUtils} from '../useAlternatePageUtils';
import {renderHook} from '@testing-library/react-hooks';
import {StaticRouter} from 'react-router-dom';
import {Context} from '@docusaurus/docusaurusContext';
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
import type {DocusaurusContext} from '@docusaurus/types';
describe('useAlternatePageUtils', () => {

View file

@ -9,7 +9,7 @@ import React from 'react';
import {useLocalPathname} from '../useLocalPathname';
import {renderHook} from '@testing-library/react-hooks';
import {StaticRouter} from 'react-router-dom';
import {Context} from '@docusaurus/docusaurusContext';
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
import type {DocusaurusContext} from '@docusaurus/types';
describe('useLocalPathname', () => {

View file

@ -9,7 +9,7 @@ import {jest} from '@jest/globals';
import React from 'react';
import {usePluralForm} from '../usePluralForm';
import {renderHook} from '@testing-library/react-hooks';
import {Context} from '@docusaurus/docusaurusContext';
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
import type {DocusaurusContext} from '@docusaurus/types';
describe('usePluralForm', () => {

View file

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

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 React, {type ReactNode, useMemo, useState, useContext} from 'react';
import {ReactContextError} from './reactUtils';
const EmptyContext: unique symbol = Symbol('EmptyContext');
const Context = React.createContext<
DocSidebarItemsExpandedState | typeof EmptyContext
>(EmptyContext);
type DocSidebarItemsExpandedState = {
expandedItem: number | null;
setExpandedItem: (a: number | null) => void;
};
export function DocSidebarItemsExpandedStateProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const contextValue = useMemo(
() => ({expandedItem, setExpandedItem}),
[expandedItem],
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
export function useDocSidebarItemsExpandedState(): DocSidebarItemsExpandedState {
const contextValue = useContext(Context);
if (contextValue === EmptyContext) {
throw new ReactContextError('DocSidebarItemsExpandedStateProvider');
}
return contextValue;
}

View file

@ -1,167 +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 React, {
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {useThemeConfig, type DocsVersionPersistence} from '../useThemeConfig';
import {isDocsPluginEnabled} from '../docsUtils';
import {ReactContextError} from '../reactUtils';
import {
useAllDocsData,
type GlobalPluginData,
} from '@docusaurus/plugin-content-docs/client';
import DocsPreferredVersionStorage from './DocsPreferredVersionStorage';
type DocsPreferredVersionName = string | null;
// State for a single docs plugin instance
type DocsPreferredVersionPluginState = {
preferredVersionName: DocsPreferredVersionName;
};
// We need to store in state/storage globally
// one preferred version per docs plugin instance
// pluginId => pluginState
type DocsPreferredVersionState = Record<
string,
DocsPreferredVersionPluginState
>;
// Initial state is always null as we can't read local storage from node SSR
function getInitialState(pluginIds: string[]): DocsPreferredVersionState {
const initialState: DocsPreferredVersionState = {};
pluginIds.forEach((pluginId) => {
initialState[pluginId] = {
preferredVersionName: null,
};
});
return initialState;
}
// Read storage for all docs plugins
// Assign to each doc plugin a preferred version (if found)
function readStorageState({
pluginIds,
versionPersistence,
allDocsData,
}: {
pluginIds: string[];
versionPersistence: DocsVersionPersistence;
allDocsData: Record<string, GlobalPluginData>;
}): DocsPreferredVersionState {
// The storage value we read might be stale,
// and belong to a version that does not exist in the site anymore
// In such case, we remove the storage value to avoid downstream errors
function restorePluginState(
pluginId: string,
): DocsPreferredVersionPluginState {
const preferredVersionNameUnsafe = DocsPreferredVersionStorage.read(
pluginId,
versionPersistence,
);
const pluginData = allDocsData[pluginId]!;
const versionExists = pluginData.versions.some(
(version) => version.name === preferredVersionNameUnsafe,
);
if (versionExists) {
return {preferredVersionName: preferredVersionNameUnsafe};
}
DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
return {preferredVersionName: null};
}
const initialState: DocsPreferredVersionState = {};
pluginIds.forEach((pluginId) => {
initialState[pluginId] = restorePluginState(pluginId);
});
return initialState;
}
function useVersionPersistence(): DocsVersionPersistence {
return useThemeConfig().docs.versionPersistence;
}
// Value that will be accessible through context: [state,api]
function useContextValue() {
const allDocsData = useAllDocsData();
const versionPersistence = useVersionPersistence();
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
// Initial state is empty, as we can't read browser storage in node/SSR
const [state, setState] = useState(() => getInitialState(pluginIds));
// On mount, we set the state read from browser storage
useEffect(() => {
setState(readStorageState({allDocsData, versionPersistence, pluginIds}));
}, [allDocsData, versionPersistence, pluginIds]);
// The API that we expose to consumer hooks (memo for constant object)
const api = useMemo(() => {
function savePreferredVersion(pluginId: string, versionName: string) {
DocsPreferredVersionStorage.save(
pluginId,
versionPersistence,
versionName,
);
setState((s) => ({
...s,
[pluginId]: {preferredVersionName: versionName},
}));
}
return {
savePreferredVersion,
};
}, [versionPersistence]);
return [state, api] as const;
}
type DocsPreferredVersionContextValue = ReturnType<typeof useContextValue>;
const Context = React.createContext<DocsPreferredVersionContextValue | null>(
null,
);
export function DocsPreferredVersionContextProvider({
children,
}: {
children: JSX.Element;
}): JSX.Element {
if (isDocsPluginEnabled) {
return (
<DocsPreferredVersionContextProviderUnsafe>
{children}
</DocsPreferredVersionContextProviderUnsafe>
);
}
return children;
}
function DocsPreferredVersionContextProviderUnsafe({
children,
}: {
children: ReactNode;
}): JSX.Element {
const contextValue = useContextValue();
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
export function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
const value = useContext(Context);
if (!value) {
throw new ReactContextError('DocsPreferredVersionContextProvider');
}
return value;
}

View file

@ -1,33 +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 {createStorageSlot} from '../storageUtils';
import type {DocsVersionPersistence} from '../useThemeConfig';
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
const DocsPreferredVersionStorage = {
save: (
pluginId: string,
persistence: DocsVersionPersistence,
versionName: string,
): void => {
createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
},
read: (
pluginId: string,
persistence: DocsVersionPersistence,
): string | null =>
createStorageSlot(storageKey(pluginId), {persistence}).get(),
clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
createStorageSlot(storageKey(pluginId), {persistence}).del();
},
};
export default DocsPreferredVersionStorage;

View file

@ -1,70 +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 {useCallback} from 'react';
import {useDocsPreferredVersionContext} from './DocsPreferredVersionProvider';
import {
useAllDocsData,
useDocsData,
type GlobalVersion,
} from '@docusaurus/plugin-content-docs/client';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
// Note, the preferredVersion attribute will always be null before mount
export function useDocsPreferredVersion(
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
): {
preferredVersion: GlobalVersion | null | undefined;
savePreferredVersionName: (versionName: string) => void;
} {
const docsData = useDocsData(pluginId);
const [state, api] = useDocsPreferredVersionContext();
const {preferredVersionName} = state[pluginId]!;
const preferredVersion = preferredVersionName
? docsData.versions.find((version) => version.name === preferredVersionName)
: null;
const savePreferredVersionName = useCallback(
(versionName: string) => {
api.savePreferredVersion(pluginId, versionName);
},
[api, pluginId],
);
return {preferredVersion, savePreferredVersionName} as const;
}
export function useDocsPreferredVersionByPluginId(): Record<
string,
GlobalVersion | null | undefined
> {
const allDocsData = useAllDocsData();
const [state] = useDocsPreferredVersionContext();
function getPluginIdPreferredVersion(pluginId: string) {
const docsData = allDocsData[pluginId]!;
const {preferredVersionName} = state[pluginId]!;
return preferredVersionName
? docsData.versions.find(
(version) => version.name === preferredVersionName,
)
: null;
}
const pluginIds = Object.keys(allDocsData);
const result: Record<string, GlobalVersion | null | undefined> = {};
pluginIds.forEach((pluginId) => {
result[pluginId] = getPluginIdPreferredVersion(pluginId);
});
return result;
}

View file

@ -5,57 +5,32 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode, useContext} from 'react';
import {
useActivePlugin,
useAllDocsData,
useActivePlugin,
} from '@docusaurus/plugin-content-docs/client';
import type {
PropSidebar,
PropSidebarItem,
PropSidebarItemCategory,
PropVersionDoc,
PropVersionMetadata,
PropSidebarBreadcrumbsItem,
} from '@docusaurus/plugin-content-docs';
import {isSamePath} from './pathUtils';
import {ReactContextError} from './reactUtils';
import {useDocsVersion} from '../contexts/docsVersion';
import {useDocsSidebar} from '../contexts/docsSidebar';
import {isSamePath} from './routesUtils';
import {useLocation} from '@docusaurus/router';
// TODO not ideal, see also "useDocs"
export const isDocsPluginEnabled: boolean = !!useAllDocsData;
// Using a Symbol because null is a valid context value (a doc with no sidebar)
// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx
const EmptyContextValue: unique symbol = Symbol('EmptyContext');
const DocsVersionContext = React.createContext<
PropVersionMetadata | typeof EmptyContextValue
>(EmptyContextValue);
export function DocsVersionProvider({
children,
version,
}: {
children: ReactNode;
version: PropVersionMetadata | typeof EmptyContextValue;
}): JSX.Element {
return (
<DocsVersionContext.Provider value={version}>
{children}
</DocsVersionContext.Provider>
);
}
export function useDocsVersion(): PropVersionMetadata {
const version = useContext(DocsVersionContext);
if (version === EmptyContextValue) {
throw new ReactContextError('DocsVersionProvider');
}
return version;
}
/**
* A null-safe way to access a doc's data by ID in the active version.
*/
export function useDocById(id: string): PropVersionDoc;
/**
* A null-safe way to access a doc's data by ID in the active version.
*/
export function useDocById(id: string | undefined): PropVersionDoc | undefined;
export function useDocById(id: string | undefined): PropVersionDoc | undefined {
const version = useDocsVersion();
@ -69,34 +44,9 @@ export function useDocById(id: string | undefined): PropVersionDoc | undefined {
return doc;
}
const DocsSidebarContext = React.createContext<
PropSidebar | null | typeof EmptyContextValue
>(EmptyContextValue);
export function DocsSidebarProvider({
children,
sidebar,
}: {
children: ReactNode;
sidebar: PropSidebar | null;
}): JSX.Element {
return (
<DocsSidebarContext.Provider value={sidebar}>
{children}
</DocsSidebarContext.Provider>
);
}
export function useDocsSidebar(): PropSidebar | null {
const sidebar = useContext(DocsSidebarContext);
if (sidebar === EmptyContextValue) {
throw new ReactContextError('DocsSidebarProvider');
}
return sidebar;
}
// Use the components props and the sidebar in context
// to get back the related sidebar category that we want to render
/**
* Pure function, similar to `Array#find`, but works on the sidebar tree.
*/
export function findSidebarCategory(
sidebar: PropSidebar,
predicate: (category: PropSidebarItemCategory) => boolean,
@ -115,7 +65,10 @@ export function findSidebarCategory(
return undefined;
}
// If a category card has no link => link to the first subItem having a link
/**
* Best effort to assign a link to a sidebar category. If the category doesn't
* have a link itself, we link to the first sub item with a link.
*/
export function findFirstCategoryLink(
item: PropSidebarItemCategory,
): string | undefined {
@ -142,58 +95,68 @@ export function findFirstCategoryLink(
return undefined;
}
/**
* Gets the category associated with the current location. Should only be used
* on category index pages.
*/
export function useCurrentSidebarCategory(): PropSidebarItemCategory {
const {pathname} = useLocation();
const sidebar = useDocsSidebar();
if (!sidebar) {
throw new Error('Unexpected: cant find current sidebar in context');
}
const category = findSidebarCategory(sidebar, (item) =>
const category = findSidebarCategory(sidebar.items, (item) =>
isSamePath(item.href, pathname),
);
if (!category) {
throw new Error(
`Unexpected: sidebar category could not be found for pathname='${pathname}'.
Hook useCurrentSidebarCategory() should only be used on Category pages`,
`${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`,
);
}
return category;
}
function containsActiveSidebarItem(
const isActive = (testedPath: string | undefined, activePath: string) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
const containsActiveSidebarItem = (
items: PropSidebarItem[],
activePath: string,
): boolean {
return items.some((subItem) => isActiveSidebarItem(subItem, activePath));
}
) => items.some((subItem) => isActiveSidebarItem(subItem, activePath));
/**
* Checks if a sidebar item should be active, based on the active path.
*/
export function isActiveSidebarItem(
item: PropSidebarItem,
activePath: string,
): boolean {
const isActive = (testedPath: string | undefined) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
if (item.type === 'link') {
return isActive(item.href);
return isActive(item.href, activePath);
}
if (item.type === 'category') {
return (
isActive(item.href) || containsActiveSidebarItem(item.items, activePath)
isActive(item.href, activePath) ||
containsActiveSidebarItem(item.items, activePath)
);
}
return false;
}
function getBreadcrumbs({
sidebar,
pathname,
}: {
sidebar: PropSidebar;
pathname: string;
}): PropSidebarBreadcrumbsItem[] {
/**
* Gets the breadcrumbs of the current doc page, based on its sidebar location.
* Returns `null` if there's no sidebar or breadcrumbs are disabled.
*/
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
const sidebar = useDocsSidebar();
const {pathname} = useLocation();
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
if (breadcrumbsOption === false || !sidebar) {
return null;
}
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
function extract(items: PropSidebar) {
@ -211,19 +174,7 @@ function getBreadcrumbs({
return false;
}
extract(sidebar);
extract(sidebar.items);
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';
/**
* A rough duck-typing about whether the `footer.links` is intended to be multi-
* column.
*/
export function isMultiColumnFooterLinks(
links: MultiColumnFooter['links'] | SimpleFooter['links'],
): links is MultiColumnFooter['links'] {

View file

@ -7,6 +7,9 @@
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
/**
* Formats the page's title based on relevant site config and other contexts.
*/
export function useTitleFormatter(title?: string | undefined): string {
const {siteConfig} = useDocusaurusContext();
const {title: siteTitle, titleDelimiter} = siteConfig;

View file

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

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

View file

@ -20,7 +20,10 @@ interface PageMetadataProps {
readonly children?: ReactNode;
}
// Helper component to manipulate page metadata and override site defaults
/**
* Helper component to manipulate page metadata and override site defaults.
* Works in the same way as Helmet.
*/
export function PageMetadata({
title,
description,
@ -44,6 +47,7 @@ export function PageMetadata({
<meta
name="keywords"
content={
// https://github.com/microsoft/TypeScript/issues/17002
(Array.isArray(keywords) ? keywords.join(',') : keywords) as string
}
/>
@ -59,8 +63,12 @@ export function PageMetadata({
const HtmlClassNameContext = React.createContext<string | undefined>(undefined);
// This wrapper is necessary because Helmet does not "merge" classes
// See https://github.com/staylor/react-helmet-async/issues/161
/**
* Every layer of this provider will append a class name to the HTML element.
* There's no consumer for this hook: it's side-effect-only. This wrapper is
* necessary because Helmet does not "merge" classes.
* @see https://github.com/staylor/react-helmet-async/issues/161
*/
export function HtmlClassNameProvider({
className: classNameProp,
children,
@ -87,6 +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({
children,
}: {

View file

@ -5,24 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {
type ReactNode,
useCallback,
useEffect,
useState,
useMemo,
} from 'react';
import useWindowSize from '../hooks/useWindowSize';
import {useHistoryPopHandler} from './historyUtils';
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenuUtils';
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
import {useThemeConfig} from './useThemeConfig';
import {ReactContextError} from './reactUtils';
import React, {type ReactNode} from 'react';
import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar';
import {NavbarSecondaryMenuProvider} from '../contexts/navbarSecondaryMenu';
const DefaultNavItemPosition = 'right';
// If split links by left/right
// if position is unspecified, fallback to right
/**
* Split links by left/right. If position is unspecified, fallback to right.
*/
export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
items: T[],
): [leftItems: T[], rightItems: T[]] {
@ -36,90 +27,10 @@ export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
return [leftItems, rightItems];
}
type NavbarMobileSidebarContextValue = {
disabled: boolean;
shouldRender: boolean;
toggle: () => void;
shown: boolean;
};
const NavbarMobileSidebarContext = React.createContext<
NavbarMobileSidebarContextValue | undefined
>(undefined);
// Mobile sidebar can be disabled in case it would lead to an empty sidebar
// In this case it's not useful to display a navbar sidebar toggle button
function useNavbarMobileSidebarDisabled() {
const activeDocPlugin = useActivePlugin();
const {items} = useThemeConfig().navbar;
return items.length === 0 && !activeDocPlugin;
}
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
const disabled = useNavbarMobileSidebarDisabled();
const windowSize = useWindowSize();
// Mobile sidebar not visible until user interaction: can avoid SSR rendering
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
const [shown, setShown] = useState(false);
// Close mobile sidebar on navigation pop
// Most likely firing when using the Android back button (but not only)
useHistoryPopHandler(() => {
if (shown) {
setShown(false);
// Should we prevent the navigation here?
// See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846
return false; // prevent pop navigation
}
return undefined;
});
const toggle = useCallback(() => {
setShown((s) => !s);
}, []);
useEffect(() => {
if (windowSize === 'desktop') {
setShown(false);
}
}, [windowSize]);
// Return stable context value
return useMemo(
() => ({
disabled,
shouldRender,
toggle,
shown,
}),
[disabled, shouldRender, toggle, shown],
);
}
function NavbarMobileSidebarProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useNavbarMobileSidebarContextValue();
return (
<NavbarMobileSidebarContext.Provider value={value}>
{children}
</NavbarMobileSidebarContext.Provider>
);
}
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
const context = React.useContext(NavbarMobileSidebarContext);
if (context == null) {
throw new ReactContextError('NavbarMobileSidebarProvider');
}
return context;
}
// Add all Navbar providers at once
/**
* Composes the `NavbarMobileSidebarProvider` and `NavbarSecondaryMenuProvider`.
* Because the latter depends on the former, they can't be re-ordered.
*/
export function NavbarProvider({children}: {children: ReactNode}): JSX.Element {
return (
<NavbarMobileSidebarProvider>

View file

@ -1,19 +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

@ -6,25 +6,29 @@
*/
import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/**
* This hook is like useLayoutEffect, but without the SSR warning
* It seems hacky but it's used in many React libs (Redux, Formik...)
* This hook is like `useLayoutEffect`, but without the SSR warning.
* It seems hacky but it's used in many React libs (Redux, Formik...).
* Also mentioned here: https://github.com/facebook/react/issues/16956
*
* It is useful when you need to update a ref as soon as possible after a React
* render (before `useEffect`)
* render (before `useEffect`).
*/
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
? useLayoutEffect
: useEffect;
/**
* 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
* re-executions
* re-executions.
*
* Workaround until the React team recommends a good solution, see
* https://github.com/facebook/react/issues/16956
*
* This generally works but has some potential drawbacks, such as
* https://github.com/facebook/react/issues/16956#issuecomment-536636418
*/
@ -42,6 +46,24 @@ export function useDynamicCallback<T extends (...args: never[]) => unknown>(
return useCallback<T>((...args) => ref.current(...args), []);
}
/**
* Gets `value` from the last render.
*/
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useIsomorphicLayoutEffect(() => {
ref.current = value;
});
return ref.current;
}
/**
* This error is thrown when a context is consumed outside its provider. Allows
* reusing a generic error message format and reduces bundle size. The hook's
* name will be extracted from its stack, so only the provider's name is needed.
*/
export class ReactContextError extends Error {
constructor(providerName: string, additionalInfo?: string) {
super();

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(
regexAsString?: string,

View file

@ -5,13 +5,31 @@
* LICENSE file in the root directory of this source tree.
*/
import generatedRoutes from '@generated/routes';
import {useMemo} from 'react';
import generatedRoutes from '@generated/routes';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {Route} from '@docusaurus/types';
// Note that all sites don't always have a homepage in practice
// See https://github.com/facebook/docusaurus/pull/6517#issuecomment-1048709116
/**
* Compare the 2 paths, case insensitive and ignoring trailing slash
*/
export function isSamePath(
path1: string | undefined,
path2: string | undefined,
): boolean {
const normalize = (pathname: string | undefined) =>
(!pathname || pathname?.endsWith('/')
? pathname
: `${pathname}/`
)?.toLowerCase();
return normalize(path1) === normalize(path2);
}
/**
* Note that sites don't always have a homepage in practice, so we can't assume
* that linking to '/' is always safe.
* @see https://github.com/facebook/docusaurus/pull/6517#issuecomment-1048709116
*/
export function findHomePageRoute({
baseUrl,
routes: initialRoutes,
@ -44,14 +62,14 @@ export function findHomePageRoute({
return doFindHomePageRoute(initialRoutes);
}
/**
* Fetches the route that points to "/". Use this instead of the naive "/",
* because the homepage may not exist.
*/
export function useHomePageRoute(): Route | undefined {
const {baseUrl} = useDocusaurusContext().siteConfig;
return useMemo(
() =>
findHomePageRoute({
routes: generatedRoutes,
baseUrl,
}),
() => findHomePageRoute({routes: generatedRoutes, baseUrl}),
[baseUrl],
);
}

View file

@ -17,25 +17,12 @@ import React, {
import {useDynamicCallback, ReactContextError} from './reactUtils';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/**
* We need a way to update the scroll position while ignoring scroll events
* without affecting Navbar/BackToTop visibility
*
* This API permits to temporarily disable/ignore scroll events
* Motivated by https://github.com/facebook/docusaurus/pull/5618
*/
type ScrollController = {
/**
* A boolean ref tracking whether scroll events are enabled
*/
/** A boolean ref tracking whether scroll events are enabled. */
scrollEventsEnabledRef: React.MutableRefObject<boolean>;
/**
* Enables scroll events in `useScrollPosition`
*/
/** Enable scroll events in `useScrollPosition`. */
enableScrollEvents: () => void;
/**
* Disables scroll events in `useScrollPosition`
*/
/** Disable scroll events in `useScrollPosition`. */
disableScrollEvents: () => void;
};
@ -65,13 +52,21 @@ export function ScrollControllerProvider({
}: {
children: ReactNode;
}): JSX.Element {
const value = useScrollControllerContextValue();
return (
<ScrollMonitorContext.Provider value={useScrollControllerContextValue()}>
<ScrollMonitorContext.Provider value={value}>
{children}
</ScrollMonitorContext.Provider>
);
}
/**
* We need a way to update the scroll position while ignoring scroll events
* so as not to toggle Navbar/BackToTop visibility.
*
* This API permits to temporarily disable/ignore scroll events. Motivated by
* https://github.com/facebook/docusaurus/pull/5618
*/
export function useScrollController(): ScrollController {
const context = useContext(ScrollMonitorContext);
if (context == null) {
@ -80,6 +75,8 @@ export function useScrollController(): ScrollController {
return context;
}
type ScrollPosition = {scrollX: number; scrollY: number};
const getScrollPosition = (): ScrollPosition | null =>
ExecutionEnvironment.canUseDOM
? {
@ -88,8 +85,14 @@ const getScrollPosition = (): ScrollPosition | null =>
}
: null;
type ScrollPosition = {scrollX: number; scrollY: number};
/**
* This hook fires an effect when the scroll position changes. The effect will
* be provided with the before/after scroll positions. Note that the effect may
* not be always run: if scrolling is disabled through `useScrollController`, it
* will be a no-op.
*
* @see {@link useScrollController}
*/
export function useScrollPosition(
effect: (
position: ScrollPosition,
@ -124,22 +127,16 @@ export function useScrollPosition(
window.addEventListener('scroll', handleScroll, opts);
return () => window.removeEventListener('scroll', handleScroll, opts);
}, [
dynamicEffect,
scrollEventsEnabledRef,
// eslint-disable-next-line react-hooks/exhaustive-deps
...deps,
]);
}, [dynamicEffect, scrollEventsEnabledRef, ...deps]);
}
type UseScrollPositionSaver = {
/**
* Measure the top of an element, and store the details
*/
/** Measure the top of an element, and store the details. */
save: (elem: HTMLElement) => void;
/**
* Restore the page position to keep the stored element's position from
* the top of the viewport, and remove the stored details
* the top of the viewport, and remove the stored details.
*/
restore: () => {restored: boolean};
};
@ -177,21 +174,24 @@ function useScrollPositionSaver(): UseScrollPositionSaver {
return useMemo(() => ({save, restore}), [restore, save]);
}
type UseScrollPositionBlockerReturn = {
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
};
/**
* This hook permits to "block" the scroll position of a dom element
* This hook permits to "block" the scroll position of a DOM element.
* The idea is that we should be able to update DOM content above this element
* but the screen position of this element should not change
* but the screen position of this element should not change.
*
* Feature motivated by the Tabs groups:
* clicking on a tab may affect tabs of the same group upper in the tree
* Yet to avoid a bad UX, the clicked tab must remain under the user mouse!
* See GIF here: https://github.com/facebook/docusaurus/pull/5618
* Feature motivated by the Tabs groups: clicking on a tab may affect tabs of
* the same group upper in the tree, yet to avoid a bad UX, the clicked tab must
* remain under the user mouse.
*
* @see https://github.com/facebook/docusaurus/pull/5618
*/
export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
export function useScrollPositionBlocker(): {
/**
* Takes an element, and keeps its screen position no matter what's getting
* rendered above it, until the next render.
*/
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
} {
const scrollController = useScrollController();
const scrollPositionSaver = useScrollPositionSaver();
@ -207,9 +207,9 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
const {restored} = scrollPositionSaver.restore();
nextLayoutEffectCallbackRef.current = undefined;
// Restoring the former scroll position will trigger a scroll event
// We need to wait for next scroll event to happen
// before enabling again the scrollController events
// Restoring the former scroll position will trigger a scroll event. We
// need to wait for next scroll event to happen before enabling the
// scrollController events again.
if (restored) {
const handleScrollRestoreEvent = () => {
scrollController.enableScrollEvents();
@ -232,3 +232,76 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
blockElementScrollPositionUntilNextRender,
};
}
// Not all have support for smooth scrolling (particularly Safari mobile iOS)
// TODO proper detection is currently unreliable!
// see https://github.com/wessberg/scroll-behavior-polyfill/issues/16
const SupportsNativeSmoothScrolling = false;
// const SupportsNativeSmoothScrolling =
// ExecutionEnvironment.canUseDOM &&
// 'scrollBehavior' in document.documentElement.style;
type CancelScrollTop = () => void;
function smoothScrollNative(top: number): CancelScrollTop {
window.scrollTo({top, behavior: 'smooth'});
return () => {
// Nothing to cancel, it's natively cancelled if user tries to scroll down
};
}
function smoothScrollPolyfill(top: number): CancelScrollTop {
let raf: number | null = null;
const isUpScroll = document.documentElement.scrollTop > top;
function rafRecursion() {
const currentScroll = document.documentElement.scrollTop;
if (
(isUpScroll && currentScroll > top) ||
(!isUpScroll && currentScroll < top)
) {
raf = requestAnimationFrame(rafRecursion);
window.scrollTo(
0,
Math.floor(Math.abs(currentScroll - top) * 0.85) + top,
);
}
}
rafRecursion();
// Break the recursion. Prevents the user from "fighting" against that
// recursion producing a weird UX
return () => raf && cancelAnimationFrame(raf);
}
/**
* A "smart polyfill" of `window.scrollTo({ top, behavior: "smooth" })`.
* This currently always uses a polyfilled implementation, because native
* support detection seems unreliable.
*
* This hook does not do anything by itself: it returns a start and a stop
* handle. You can execute either handle at any time.
*/
export function useSmoothScrollTo(): {
/**
* Start the scroll.
*
* @param top The final scroll top position.
*/
startScroll: (top: number) => void;
/**
* A cancel function, because the non-native smooth scroll-top
* implementation must be interrupted if user scrolls down. If there's no
* existing animation or the scroll is using native behavior, this is a no-op.
*/
cancelScroll: CancelScrollTop;
} {
const cancelRef = useRef<CancelScrollTop | null>(null);
return {
startScroll: (top: number) => {
cancelRef.current = SupportsNativeSmoothScrolling
? smoothScrollNative(top)
: smoothScrollPolyfill(top);
},
cancelScroll: () => cancelRef?.current,
};
}

View file

@ -5,11 +5,60 @@
* LICENSE file in the root directory of this source tree.
*/
import {
useAllDocsData,
useActivePluginAndVersion,
} from '@docusaurus/plugin-content-docs/client';
import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersion';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
export const DEFAULT_SEARCH_TAG = 'default';
/** The search tag to append as each doc's metadata. */
export function docVersionSearchTag(
pluginId: string,
versionName: string,
): string {
return `docs-${pluginId}-${versionName}`;
}
/**
* Gets the relevant context information for contextual search.
*
* The value is generic and not coupled to Algolia/DocSearch, since we may want
* to support multiple search engines, or allowing users to use their own search
* engine solution.
*/
export function useContextualSearchFilters(): {locale: string; tags: string[]} {
const {i18n} = useDocusaurusContext();
const allDocsData = useAllDocsData();
const activePluginAndVersion = useActivePluginAndVersion();
const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId();
function getDocPluginTags(pluginId: string) {
const activeVersion =
activePluginAndVersion?.activePlugin?.pluginId === pluginId
? activePluginAndVersion.activeVersion
: undefined;
const preferredVersion = docsPreferredVersionByPluginId[pluginId];
const latestVersion = allDocsData[pluginId]!.versions.find(
(v) => v.isLast,
)!;
const version = activeVersion ?? preferredVersion ?? latestVersion;
return docVersionSearchTag(pluginId, version.name);
}
const tags = [
DEFAULT_SEARCH_TAG,
...Object.keys(allDocsData).map(getDocPluginTags),
];
return {
locale: i18n.currentLocale,
tags,
};
}

View file

@ -11,8 +11,12 @@ export type StorageType = typeof StorageTypes[number];
const DefaultStorageType: StorageType = 'localStorage';
// Will return null browser storage is unavailable (like running Docusaurus in
// iframe) See https://github.com/facebook/docusaurus/pull/4501
/**
* Will return `null` if browser storage is unavailable (like running Docusaurus
* in an iframe). This should NOT be called in SSR.
*
* @see https://github.com/facebook/docusaurus/pull/4501
*/
function getBrowserStorage(
storageType: StorageType = DefaultStorageType,
): Storage | null {
@ -32,11 +36,12 @@ function getBrowserStorage(
}
}
/**
* Poor man's memoization to avoid logging multiple times the same warning
* Sometimes, localStorage/sessionStorage is unavailable due to browser policies
*/
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) {
if (!hasLoggedBrowserStorageNotAvailableWarning) {
console.warn(
@ -61,7 +66,7 @@ const NoopStorageSlot: StorageSlot = {
del: () => {},
};
// Fail-fast, as storage APIs should not be used during the SSR process
// Fail-fast, as storage APIs should not be used during the SSR process
function createServerStorageSlot(key: string): StorageSlot {
function throwError(): never {
throw new Error(`Illegal storage API usage for storage key "${key}".
@ -77,16 +82,19 @@ Please only call storage APIs in effects and event handlers.`);
}
/**
* Creates an object for accessing a particular key in localStorage.
* The API is fail-safe, and usage of browser storage should be considered
* Creates an interface to work on a particular key in the storage model.
* Note that this function only initializes the interface, but doesn't allocate
* anything by itself (i.e. no side-effects).
*
* The API is fail-safe, since usage of browser storage should be considered
* unreliable. Local storage might simply be unavailable (iframe + browser
* security) or operations might fail individually. Please assume that using
* this API can be a NO-OP. See also https://github.com/facebook/docusaurus/issues/6036
* this API can be a no-op. See also https://github.com/facebook/docusaurus/issues/6036
*/
export const createStorageSlot = (
export function createStorageSlot(
key: string,
options?: {persistence?: StorageType},
): StorageSlot => {
): StorageSlot {
if (typeof window === 'undefined') {
return createServerStorageSlot(key);
}
@ -121,10 +129,10 @@ export const createStorageSlot = (
}
},
};
};
}
/**
* Returns a list of all the keys currently stored in browser storage
* Returns a list of all the keys currently stored in browser storage,
* or an empty list if browser storage can't be accessed.
*/
export function listStorageKeys(

View file

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

View file

@ -52,6 +52,10 @@ function treeifyTOC(flatTOC: readonly TOCItem[]): TOCTreeNode[] {
return rootNodes;
}
/**
* Takes a flat TOC list (from the MDX loader) and treeifies it into what the
* TOC components expect. Memoized for performance.
*/
export function useTreeifiedTOC(toc: TOCItem[]): readonly TOCTreeNode[] {
return useMemo(() => treeifyTOC(toc), [toc]);
}
@ -87,6 +91,18 @@ function filterTOC({
});
}
/**
* Takes a flat TOC list (from the MDX loader) and treeifies it into what the
* TOC components expect, applying the `minHeadingLevel` and `maxHeadingLevel`.
* Memoized for performance.
*
* **Important**: this is not the same as `useTreeifiedTOC(toc.filter(...))`,
* because we have to filter the TOC after it has been treeified. This is mostly
* to ensure that weird TOC structures preserve their semantics. For example, an
* h3-h2-h4 sequence should not be treeified as an "h3 > h4" hierarchy with
* min=3, max=4, but should rather be "[h3, h4]" (since the h2 heading has split
* the two headings and they are not parents)
*/
export function useFilteredAndTreeifiedTOC({
toc,
minHeadingLevel,
@ -97,12 +113,7 @@ export function useFilteredAndTreeifiedTOC({
maxHeadingLevel: number;
}): readonly TOCTreeNode[] {
return useMemo(
() =>
// Note: we have to filter the TOC after it has been treeified. This is
// mostly to ensure that weird TOC structures preserve their semantics.
// For example, an h3-h2-h4 sequence should not be treeified as an h3 > h4
// hierarchy with min=3, max=4, but should rather be [h3, h4]
filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}),
() => filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}),
[toc, minHeadingLevel, maxHeadingLevel],
);
}

View file

@ -8,12 +8,26 @@
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useLocation} from '@docusaurus/router';
// Permits to obtain the url of the current page in another locale
// Useful to generate hreflang meta headers etc...
// See https://developers.google.com/search/docs/advanced/crawling/localized-versions
/**
* Permits to obtain the url of the current page in another locale, useful to
* generate hreflang meta headers etc...
*
* @see https://developers.google.com/search/docs/advanced/crawling/localized-versions
*/
export function useAlternatePageUtils(): {
/**
* Everything (pathname, base URL, etc.) is read from the context. Just tell
* it which locale to link to and it will give you the alternate link for the
* current page.
*/
createUrl: ({
/** The locale name to link to. */
locale,
/**
* For hreflang SEO headers, we need it to be fully qualified (full
* protocol/domain/path...); but for locale dropdowns, using a pathname is
* good enough.
*/
fullyQualified,
}: {
locale: string;
@ -46,8 +60,6 @@ export function useAlternatePageUtils(): {
fullyQualified,
}: {
locale: string;
// For hreflang SEO headers, we need it to be fully qualified (full
// protocol/domain/path...) or locale dropdown, using a path is good enough
fullyQualified: boolean;
}) {
return `${fullyQualified ? url : ''}${getLocalizedBaseUrl(

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

View file

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

View file

@ -50,17 +50,17 @@ function createLocalePluralForms(locale: string): LocalePluralForms {
}
/**
* Poor man's PluralSelector implementation, using an english fallback. We want
* a lightweight, future-proof and good-enough solution. We don't want a perfect
* and heavy solution.
* Poor man's `PluralSelector` implementation, using an English fallback. We
* want a lightweight, future-proof and good-enough solution. We don't want a
* perfect and heavy solution.
*
* Docusaurus classic theme has only 2 deeply nested labels requiring complex
* plural rules. We don't want to use Intl + PluralRules polyfills + full ICU
* syntax (react-intl) just for that.
* plural rules. We don't want to use `Intl` + `PluralRules` polyfills + full
* ICU syntax (react-intl) just for that.
*
* Notes:
* - 2021: 92+% Browsers support Intl.PluralRules, and support will increase in
* the future
* - 2021: 92+% Browsers support `Intl.PluralRules`, and support will increase
* in the future
* - NodeJS >= 13 has full ICU support by default
* - In case of "mismatch" between SSR and Browser ICU support, React keeps
* working!
@ -105,7 +105,18 @@ function selectPluralMessage(
return parts[Math.min(pluralFormIndex, parts.length - 1)]!;
}
/**
* Reads the current locale and returns an interface very similar to
* `Intl.PluralRules`.
*/
export function usePluralForm(): {
/**
* Give it a `count` and it will select the relevant message from
* `pluralMessages`. `pluralMessages` should be separated by `|`, and in the
* order of "zero", "one", "two", "few", "many", "other". The actual selection
* is done by `Intl.PluralRules`, which tells us all plurals the locale has
* and which plural we should use for `count`.
*/
selectMessage: (count: number, pluralMessages: string) => string;
} {
const localePluralForm = useLocalePluralForms();

View file

@ -1,19 +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 {useRef} from 'react';
import {useIsomorphicLayoutEffect} from './reactUtils';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useIsomorphicLayoutEffect(() => {
ref.current = value;
});
return ref.current;
}

View file

@ -17,7 +17,7 @@ export type NavbarItem = {
items?: NavbarItem[];
label?: string;
position?: 'left' | 'right';
} & Record<string, unknown>;
} & {[key: string]: unknown};
export type NavbarLogo = {
src: string;
@ -65,7 +65,7 @@ export type FooterLinkItem = {
href?: string;
html?: string;
prependBaseUrlToHref?: string;
} & Record<string, unknown>;
} & {[key: string]: unknown};
export type FooterLogo = {
alt?: string;
@ -120,7 +120,7 @@ export type ThemeConfig = {
filterableSidebar: boolean;
autoCollapseSidebarCategories: boolean;
image?: string;
metadata: Array<Record<string, string>>;
metadata: Array<{[key: string]: string}>;
sidebarCollapsible: boolean;
tableOfContents: TableOfContents;
};
@ -128,6 +128,9 @@ export type ThemeConfig = {
// User-provided theme config, unnormalized
export type UserThemeConfig = DeepPartial<ThemeConfig>;
/**
* A convenient/more semantic way to get theme config from context.
*/
export function useThemeConfig(): ThemeConfig {
return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig;
}