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

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

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

View file

@ -20,20 +20,19 @@ import React, {
const DefaultAnimationEasing = 'ease-in-out';
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,9 +31,14 @@ 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'>;
/**
* 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,
@ -45,8 +50,8 @@ export function Details({
const {collapsed, setCollapsed} = useCollapsible({
initialState: !props.open,
});
// Use a separate prop because it must be set only after animation completes
// Otherwise close anim won't work
// Use a separate state for the actual details prop, because it must be set
// only after animation completes, otherwise close animations won't work
const [open, setOpen] = useState(props.open);
return (

View file

@ -0,0 +1,31 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import {renderHook} from '@testing-library/react-hooks';
import {useDocsSidebar, DocsSidebarProvider} from '../docsSidebar';
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
describe('useDocsSidebar', () => {
it('throws if context provider is missing', () => {
expect(
() => renderHook(() => useDocsSidebar()).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
);
});
it('reads value from context provider', () => {
const sidebar: PropSidebar = [];
const {result} = renderHook(() => useDocsSidebar(), {
wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}>{children}</DocsSidebarProvider>
),
});
expect(result.current).toBe(sidebar);
});
});

View file

@ -0,0 +1,46 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import {renderHook} from '@testing-library/react-hooks';
import {useDocsVersion, DocsVersionProvider} from '../docsVersion';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
return {
version: 'versionName',
label: 'Version Label',
className: 'version className',
badge: true,
banner: 'unreleased',
docs: {},
docsSidebars: {},
isLast: false,
pluginId: 'default',
...data,
};
}
describe('useDocsVersion', () => {
it('throws if context provider is missing', () => {
expect(
() => renderHook(() => useDocsVersion()).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsVersion is called outside the <DocsVersionProvider>. "`,
);
});
it('reads value from context provider', () => {
const version = testVersion();
const {result} = renderHook(() => useDocsVersion(), {
wrapper: ({children}) => (
<DocsVersionProvider version={version}>{children}</DocsVersionProvider>
),
});
expect(result.current).toBe(version);
});
});

View file

@ -32,12 +32,18 @@ const isDismissedInStorage = () =>
const setDismissedInStorage = (bool: boolean) =>
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

@ -20,8 +20,10 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import {createStorageSlot} from '../utils/storageUtils';
import {useThemeConfig} from '../utils/useThemeConfig';
type ColorModeContextValue = {
type ContextValue = {
/** Current color mode. */
readonly colorMode: ColorMode;
/** Set new color mode. */
readonly setColorMode: (colorMode: ColorMode) => void;
// TODO legacy APIs kept for retro-compatibility: deprecate them
@ -30,6 +32,8 @@ type ColorModeContextValue = {
readonly setDarkTheme: () => void;
};
const Context = React.createContext<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,25 +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(ColorModeContext);
export function useColorMode(): ContextValue {
const context = useContext(Context);
if (context == null) {
throw new ReactContextError(
'ColorModeProvider',

View file

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

View file

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

View file

@ -0,0 +1,42 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode, useContext} from 'react';
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
import {ReactContextError} from '../utils/reactUtils';
// Using a Symbol because null is a valid context value (a doc with no sidebar)
// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx
const EmptyContext: unique symbol = Symbol('EmptyContext');
const Context = React.createContext<PropSidebar | null | typeof EmptyContext>(
EmptyContext,
);
/**
* Provide the current sidebar to your children.
*/
export function DocsSidebarProvider({
children,
sidebar,
}: {
children: ReactNode;
sidebar: PropSidebar | null;
}): JSX.Element {
return <Context.Provider value={sidebar}>{children}</Context.Provider>;
}
/**
* Gets the sidebar that's currently displayed, or `null` if there isn't one
*/
export function useDocsSidebar(): PropSidebar | null {
const sidebar = useContext(Context);
if (sidebar === EmptyContext) {
throw new ReactContextError('DocsSidebarProvider');
}
return sidebar;
}

View file

@ -0,0 +1,36 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode, useContext} from 'react';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
import {ReactContextError} from '../utils/reactUtils';
const Context = React.createContext<PropVersionMetadata | null>(null);
/**
* Provide the current version's metadata to your children.
*/
export function DocsVersionProvider({
children,
version,
}: {
children: ReactNode;
version: PropVersionMetadata | null;
}): JSX.Element {
return <Context.Provider value={version}>{children}</Context.Provider>;
}
/**
* Gets the version metadata of the current doc page.
*/
export function useDocsVersion(): PropVersionMetadata {
const version = useContext(Context);
if (version === null) {
throw new ReactContextError('DocsVersionProvider');
}
return version;
}

View file

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

View file

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

View file

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

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

View file

@ -12,7 +12,13 @@ import './styles.css';
export const keyboardFocusedClassName = 'navigation-with-keyboard';
/**
* Detect keyboard focus indicator to not show outline for mouse users
* Side-effect that adds the `keyboardFocusedClassName` to the body element when
* the keyboard has been pressed, or removes it when the mouse is clicked.
*
* The presence of this class name signals that the user may be using keyboard
* for navigation, and the theme **must** add focus outline when this class name
* is present. (And optionally not if it's absent, for design purposes)
*
* Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
*/
export function useKeyboardNavigation(): void {

View file

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

View file

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

View file

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

View file

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

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

View file

@ -23,28 +23,28 @@ export {
DocSidebarItemsExpandedStateProvider,
useDocSidebarItemsExpandedState,
} 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,20 +52,13 @@ export {
useSidebarBreadcrumbs,
} from './utils/docsUtils';
export {isSamePath} from './utils/pathUtils';
export {useTitleFormatter} from './utils/generalUtils';
export {usePluralForm} from './utils/usePluralForm';
export {useLocationChange} from './utils/useLocationChange';
export {
useCollapsible,
Collapsible,
type UseCollapsibleConfig,
type UseCollapsibleReturns,
} from './components/Collapsible';
export {useCollapsible, Collapsible} from './components/Collapsible';
export {Details, type DetailsProps} from './components/Details';
@ -124,7 +117,7 @@ export {
export {isRegexpStringMatch} from './utils/regexpUtils';
export {useHomePageRoute} from './utils/routesUtils';
export {useHomePageRoute, isSamePath} from './utils/routesUtils';
export {
PageMetadata,
@ -149,8 +142,8 @@ export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
export {
useNavbarSecondaryMenu,
NavbarSecondaryMenuFiller,
} from './utils/navbarSecondaryMenu';
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenu';
type NavbarSecondaryMenuComponent,
} from './contexts/navbarSecondaryMenu';
export {useHideableNavbar} from './hooks/useHideableNavbar';
export {

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

View file

@ -1,40 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {isSamePath} from '../pathUtils';
describe('isSamePath', () => {
it('returns true for compared path without trailing slash', () => {
expect(isSamePath('/docs', '/docs')).toBeTruthy();
});
it('returns true for compared path with trailing slash', () => {
expect(isSamePath('/docs', '/docs/')).toBeTruthy();
});
it('returns true for compared path with different case', () => {
expect(isSamePath('/doCS', '/DOcs')).toBeTruthy();
});
it('returns true for compared path with different case + trailing slash', () => {
expect(isSamePath('/doCS', '/DOcs/')).toBeTruthy();
});
it('returns false for compared path with double trailing slash', () => {
expect(isSamePath('/docs', '/docs//')).toBeFalsy();
});
it('returns true for twice undefined/null', () => {
expect(isSamePath(undefined, undefined)).toBeTruthy();
expect(isSamePath(undefined, undefined)).toBeTruthy();
});
it('returns false when one undefined', () => {
expect(isSamePath('/docs', undefined)).toBeFalsy();
expect(isSamePath(undefined, '/docs')).toBeFalsy();
});
});

View file

@ -6,7 +6,39 @@
*/
import type {Route} from '@docusaurus/types';
import {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

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

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,6 +95,10 @@ export function findFirstCategoryLink(
return undefined;
}
/**
* Gets the category associated with the current location. Should only be used
* on category index pages.
*/
export function useCurrentSidebarCategory(): PropSidebarItemCategory {
const {pathname} = useLocation();
const sidebar = useDocsSidebar();
@ -153,47 +110,53 @@ export function useCurrentSidebarCategory(): PropSidebarItemCategory {
);
if (!category) {
throw new Error(
`Unexpected: sidebar category could not be found for pathname='${pathname}'.
Hook useCurrentSidebarCategory() should only be used on Category pages`,
`${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`,
);
}
return category;
}
function containsActiveSidebarItem(
const isActive = (testedPath: string | undefined, activePath: string) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
const containsActiveSidebarItem = (
items: PropSidebarItem[],
activePath: string,
): boolean {
return items.some((subItem) => isActiveSidebarItem(subItem, activePath));
}
) => items.some((subItem) => isActiveSidebarItem(subItem, activePath));
/**
* Checks if a sidebar item should be active, based on the active path.
*/
export function isActiveSidebarItem(
item: PropSidebarItem,
activePath: string,
): boolean {
const isActive = (testedPath: string | undefined) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
if (item.type === 'link') {
return isActive(item.href);
return isActive(item.href, activePath);
}
if (item.type === 'category') {
return (
isActive(item.href) || containsActiveSidebarItem(item.items, activePath)
isActive(item.href, activePath) ||
containsActiveSidebarItem(item.items, activePath)
);
}
return false;
}
function getBreadcrumbs({
sidebar,
pathname,
}: {
sidebar: PropSidebar;
pathname: string;
}): PropSidebarBreadcrumbsItem[] {
/**
* Gets the breadcrumbs of the current doc page, based on its sidebar location.
* Returns `null` if there's no sidebar or breadcrumbs are disabled.
*/
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
const sidebar = useDocsSidebar();
const {pathname} = useLocation();
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
if (breadcrumbsOption === false || !sidebar) {
return null;
}
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
function extract(items: PropSidebar) {
@ -215,15 +178,3 @@ function getBreadcrumbs({
return breadcrumbs.reverse();
}
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
const sidebar = useDocsSidebar();
const {pathname} = useLocation();
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
if (breadcrumbsOption === false || !sidebar) {
return null;
}
return getBreadcrumbs({sidebar, pathname});
}

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

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

View file

@ -1,21 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* Compare the 2 paths, case insensitive and ignoring trailing slash
*/
export const isSamePath = (
path1: string | undefined,
path2: string | undefined,
): boolean => {
const normalize = (pathname: string | undefined) =>
(!pathname || pathname?.endsWith('/')
? pathname
: `${pathname}/`
)?.toLowerCase();
return normalize(path1) === normalize(path2);
};

View file

@ -9,11 +9,12 @@ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/**
* This hook is like useLayoutEffect, but without the SSR warning
* It seems hacky but it's used in many React libs (Redux, Formik...)
* This hook is like `useLayoutEffect`, but without the SSR warning.
* It seems hacky but it's used in many React libs (Redux, Formik...).
* Also mentioned here: https://github.com/facebook/react/issues/16956
*
* It is useful when you need to update a ref as soon as possible after a React
* render (before `useEffect`)
* render (before `useEffect`).
*/
export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
? useLayoutEffect
@ -23,10 +24,11 @@ export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
* Permits to transform an unstable callback (like an arrow function provided as
* props) to a "stable" callback that is safe to use in a `useEffect` dependency
* array. Useful to avoid React stale closure problems + avoid useless effect
* re-executions
* re-executions.
*
* Workaround until the React team recommends a good solution, see
* https://github.com/facebook/react/issues/16956
*
* This generally works but has some potential drawbacks, such as
* https://github.com/facebook/react/issues/16956#issuecomment-536636418
*/
@ -44,6 +46,9 @@ export function useDynamicCallback<T extends (...args: never[]) => unknown>(
return useCallback<T>((...args) => ref.current(...args), []);
}
/**
* Gets `value` from the last render.
*/
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
@ -54,6 +59,11 @@ export function usePrevious<T>(value: T): T | undefined {
return ref.current;
}
/**
* This error is thrown when a context is consumed outside its provider. Allows
* reusing a generic error message format and reduces bundle size. The hook's
* name will be extracted from its stack, so only the provider's name is needed.
*/
export class ReactContextError extends Error {
constructor(providerName: string, additionalInfo?: string) {
super();

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,11 +5,26 @@
* LICENSE file in the root directory of this source tree.
*/
import generatedRoutes from '@generated/routes';
import {useMemo} from 'react';
import generatedRoutes from '@generated/routes';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {Route} from '@docusaurus/types';
/**
* Compare the 2 paths, case insensitive and ignoring trailing slash
*/
export function isSamePath(
path1: string | undefined,
path2: string | undefined,
): boolean {
const normalize = (pathname: string | undefined) =>
(!pathname || pathname?.endsWith('/')
? pathname
: `${pathname}/`
)?.toLowerCase();
return normalize(path1) === normalize(path2);
}
/**
* Note that sites don't always have a homepage in practice, so we can't assume
* that linking to '/' is always safe.
@ -47,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();

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

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 '../contexts/docsPreferredVersion';
import {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './searchUtils';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
export type useContextualSearchFiltersReturns = {
locale: string;
tags: string[];
};
// We may want to support multiple search engines, don't couple that to
// Algolia/DocSearch. Maybe users want to use their own search engine solution
export function useContextualSearchFilters(): useContextualSearchFiltersReturns {
const {i18n} = useDocusaurusContext();
const allDocsData = useAllDocsData();
const activePluginAndVersion = useActivePluginAndVersion();
const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId();
function getDocPluginTags(pluginId: string) {
const activeVersion =
activePluginAndVersion?.activePlugin?.pluginId === pluginId
? activePluginAndVersion.activeVersion
: undefined;
const preferredVersion = docsPreferredVersionByPluginId[pluginId];
const latestVersion = allDocsData[pluginId]!.versions.find(
(v) => v.isLast,
)!;
const version = activeVersion ?? preferredVersion ?? latestVersion;
return docVersionSearchTag(pluginId, version.name);
}
const tags = [
DEFAULT_SEARCH_TAG,
...Object.keys(allDocsData).map(getDocPluginTags),
];
return {
locale: i18n.currentLocale,
tags,
};
}

View file

@ -5,13 +5,13 @@
* LICENSE file in the root directory of this source tree.
*/
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 {

View file

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

View file

@ -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

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