mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-22 13:37:05 +02:00
feat(theme-common): JSDoc for all APIs (#6974)
* feat(theme-common): JSDoc for all APIs * fix tests
This commit is contained in:
parent
4103fef11e
commit
b456a64f61
48 changed files with 871 additions and 679 deletions
|
@ -20,20 +20,19 @@ import React, {
|
||||||
|
|
||||||
const DefaultAnimationEasing = 'ease-in-out';
|
const DefaultAnimationEasing = 'ease-in-out';
|
||||||
|
|
||||||
export type UseCollapsibleConfig = {
|
/**
|
||||||
|
* This hook is a very thin wrapper around a `useState`.
|
||||||
|
*/
|
||||||
|
export function useCollapsible({
|
||||||
|
initialState,
|
||||||
|
}: {
|
||||||
|
/** The initial state. Will be non-collapsed by default. */
|
||||||
initialState: boolean | (() => boolean);
|
initialState: boolean | (() => boolean);
|
||||||
};
|
}): {
|
||||||
|
|
||||||
export type UseCollapsibleReturns = {
|
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
setCollapsed: Dispatch<SetStateAction<boolean>>;
|
setCollapsed: Dispatch<SetStateAction<boolean>>;
|
||||||
toggleCollapsed: () => void;
|
toggleCollapsed: () => void;
|
||||||
};
|
} {
|
||||||
|
|
||||||
// This hook just define the state
|
|
||||||
export function useCollapsible({
|
|
||||||
initialState,
|
|
||||||
}: UseCollapsibleConfig): UseCollapsibleReturns {
|
|
||||||
const [collapsed, setCollapsed] = useState(initialState ?? false);
|
const [collapsed, setCollapsed] = useState(initialState ?? false);
|
||||||
|
|
||||||
const toggleCollapsed = useCallback(() => {
|
const toggleCollapsed = useCallback(() => {
|
||||||
|
@ -152,8 +151,10 @@ type CollapsibleElementType = React.ElementType<
|
||||||
Pick<React.HTMLAttributes<unknown>, 'className' | 'onTransitionEnd' | 'style'>
|
Pick<React.HTMLAttributes<unknown>, 'className' | 'onTransitionEnd' | 'style'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Prevent hydration layout shift before animations are handled imperatively
|
/**
|
||||||
// with JS
|
* Prevent hydration layout shift before animations are handled imperatively
|
||||||
|
* with JS
|
||||||
|
*/
|
||||||
function getSSRStyle(collapsed: boolean) {
|
function getSSRStyle(collapsed: boolean) {
|
||||||
if (ExecutionEnvironment.canUseDOM) {
|
if (ExecutionEnvironment.canUseDOM) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -162,16 +163,27 @@ function getSSRStyle(collapsed: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollapsibleBaseProps = {
|
type CollapsibleBaseProps = {
|
||||||
|
/** The actual DOM element to be used in the markup. */
|
||||||
as?: CollapsibleElementType;
|
as?: CollapsibleElementType;
|
||||||
|
/** Initial collapsed state. */
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** Configuration of animation, like `duration` and `easing` */
|
||||||
animation?: CollapsibleAnimationConfig;
|
animation?: CollapsibleAnimationConfig;
|
||||||
|
/**
|
||||||
|
* A callback fired when the collapse transition animation ends. Receives
|
||||||
|
* the **new** collapsed state: e.g. when
|
||||||
|
* expanding, `collapsed` will be `false`. You can use this for some "cleanup"
|
||||||
|
* like applying new styles when the container is fully expanded.
|
||||||
|
*/
|
||||||
onCollapseTransitionEnd?: (collapsed: boolean) => void;
|
onCollapseTransitionEnd?: (collapsed: boolean) => void;
|
||||||
|
/** Class name for the underlying DOM element. */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
// This is mostly useful for details/summary component where ssrStyle is not
|
* This is mostly useful for details/summary component where ssrStyle is not
|
||||||
// needed (as details are hidden natively) and can mess up with the default
|
* needed (as details are hidden natively) and can mess up with the browser's
|
||||||
// native behavior of the browser when JS fails to load or is disabled
|
* native behavior when JS fails to load or is disabled
|
||||||
|
*/
|
||||||
disableSSRStyle?: boolean;
|
disableSSRStyle?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -233,14 +245,20 @@ function CollapsibleLazy({collapsed, ...props}: CollapsibleBaseProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollapsibleProps = CollapsibleBaseProps & {
|
type CollapsibleProps = CollapsibleBaseProps & {
|
||||||
// Lazy allows to delay the rendering when collapsed => it will render
|
/**
|
||||||
// children only after hydration, on first expansion
|
* Delay rendering of the content till first expansion. Marked as required to
|
||||||
// Required prop: it forces to think if content should be server-rendered
|
* force us to think if content should be server-rendered or not. This has
|
||||||
// or not! This has perf impact on the SSR output and html file sizes
|
* perf impact since it reduces html file sizes, but could undermine SEO.
|
||||||
// See https://github.com/facebook/docusaurus/issues/4753
|
* @see https://github.com/facebook/docusaurus/issues/4753
|
||||||
|
*/
|
||||||
lazy: boolean;
|
lazy: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A headless component providing smooth and uniform collapsing behavior. The
|
||||||
|
* component will be invisible (zero height) when collapsed. Doesn't provide
|
||||||
|
* interactivity by itself: collapse state is toggled through props.
|
||||||
|
*/
|
||||||
export function Collapsible({lazy, ...props}: CollapsibleProps): JSX.Element {
|
export function Collapsible({lazy, ...props}: CollapsibleProps): JSX.Element {
|
||||||
const Comp = lazy ? CollapsibleLazy : CollapsibleBase;
|
const Comp = lazy ? CollapsibleLazy : CollapsibleBase;
|
||||||
return <Comp {...props} />;
|
return <Comp {...props} />;
|
||||||
|
|
|
@ -31,9 +31,14 @@ function hasParent(node: HTMLElement | null, parent: HTMLElement): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DetailsProps = {
|
export type DetailsProps = {
|
||||||
|
/** Summary is provided as props, including the wrapping `<summary>` tag */
|
||||||
summary?: ReactElement;
|
summary?: ReactElement;
|
||||||
} & ComponentProps<'details'>;
|
} & ComponentProps<'details'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mostly un-styled `<details>` element with smooth collapsing. Provides some
|
||||||
|
* very lightweight styles, but you should bring your UI.
|
||||||
|
*/
|
||||||
export function Details({
|
export function Details({
|
||||||
summary,
|
summary,
|
||||||
children,
|
children,
|
||||||
|
@ -45,8 +50,8 @@ export function Details({
|
||||||
const {collapsed, setCollapsed} = useCollapsible({
|
const {collapsed, setCollapsed} = useCollapsible({
|
||||||
initialState: !props.open,
|
initialState: !props.open,
|
||||||
});
|
});
|
||||||
// Use a separate prop because it must be set only after animation completes
|
// Use a separate state for the actual details prop, because it must be set
|
||||||
// Otherwise close anim won't work
|
// only after animation completes, otherwise close animations won't work
|
||||||
const [open, setOpen] = useState(props.open);
|
const [open, setOpen] = useState(props.open);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -32,12 +32,18 @@ const isDismissedInStorage = () =>
|
||||||
const setDismissedInStorage = (bool: boolean) =>
|
const setDismissedInStorage = (bool: boolean) =>
|
||||||
AnnouncementBarDismissStorage.set(String(bool));
|
AnnouncementBarDismissStorage.set(String(bool));
|
||||||
|
|
||||||
type AnnouncementBarAPI = {
|
type ContextValue = {
|
||||||
|
/** Whether the announcement bar should be displayed. */
|
||||||
readonly isActive: boolean;
|
readonly isActive: boolean;
|
||||||
|
/**
|
||||||
|
* Callback fired when the user closes the announcement. Will be saved.
|
||||||
|
*/
|
||||||
readonly close: () => void;
|
readonly close: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAnnouncementBarContextValue = (): AnnouncementBarAPI => {
|
const Context = React.createContext<ContextValue | null>(null);
|
||||||
|
|
||||||
|
function useContextValue(): ContextValue {
|
||||||
const {announcementBar} = useThemeConfig();
|
const {announcementBar} = useThemeConfig();
|
||||||
const isBrowser = useIsBrowser();
|
const isBrowser = useIsBrowser();
|
||||||
|
|
||||||
|
@ -93,27 +99,19 @@ const useAnnouncementBarContextValue = (): AnnouncementBarAPI => {
|
||||||
}),
|
}),
|
||||||
[announcementBar, isClosed, handleClose],
|
[announcementBar, isClosed, handleClose],
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const AnnouncementBarContext = React.createContext<AnnouncementBarAPI | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function AnnouncementBarProvider({
|
export function AnnouncementBarProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const value = useAnnouncementBarContextValue();
|
const value = useContextValue();
|
||||||
return (
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
<AnnouncementBarContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</AnnouncementBarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAnnouncementBar(): AnnouncementBarAPI {
|
export function useAnnouncementBar(): ContextValue {
|
||||||
const api = useContext(AnnouncementBarContext);
|
const api = useContext(Context);
|
||||||
if (!api) {
|
if (!api) {
|
||||||
throw new ReactContextError('AnnouncementBarProvider');
|
throw new ReactContextError('AnnouncementBarProvider');
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,8 +20,10 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||||
import {createStorageSlot} from '../utils/storageUtils';
|
import {createStorageSlot} from '../utils/storageUtils';
|
||||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||||
|
|
||||||
type ColorModeContextValue = {
|
type ContextValue = {
|
||||||
|
/** Current color mode. */
|
||||||
readonly colorMode: ColorMode;
|
readonly colorMode: ColorMode;
|
||||||
|
/** Set new color mode. */
|
||||||
readonly setColorMode: (colorMode: ColorMode) => void;
|
readonly setColorMode: (colorMode: ColorMode) => void;
|
||||||
|
|
||||||
// TODO legacy APIs kept for retro-compatibility: deprecate them
|
// TODO legacy APIs kept for retro-compatibility: deprecate them
|
||||||
|
@ -30,6 +32,8 @@ type ColorModeContextValue = {
|
||||||
readonly setDarkTheme: () => void;
|
readonly setDarkTheme: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Context = React.createContext<ContextValue | undefined>(undefined);
|
||||||
|
|
||||||
const ColorModeStorageKey = 'theme';
|
const ColorModeStorageKey = 'theme';
|
||||||
const ColorModeStorage = createStorageSlot(ColorModeStorageKey);
|
const ColorModeStorage = createStorageSlot(ColorModeStorageKey);
|
||||||
|
|
||||||
|
@ -44,18 +48,16 @@ export type ColorMode = typeof ColorModes[keyof typeof ColorModes];
|
||||||
const coerceToColorMode = (colorMode?: string | null): ColorMode =>
|
const coerceToColorMode = (colorMode?: string | null): ColorMode =>
|
||||||
colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light;
|
colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light;
|
||||||
|
|
||||||
const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => {
|
const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode =>
|
||||||
if (!ExecutionEnvironment.canUseDOM) {
|
ExecutionEnvironment.canUseDOM
|
||||||
return coerceToColorMode(defaultMode);
|
? coerceToColorMode(document.documentElement.getAttribute('data-theme'))
|
||||||
}
|
: coerceToColorMode(defaultMode);
|
||||||
return coerceToColorMode(document.documentElement.getAttribute('data-theme'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const storeColorMode = (newColorMode: ColorMode) => {
|
const storeColorMode = (newColorMode: ColorMode) => {
|
||||||
ColorModeStorage.set(coerceToColorMode(newColorMode));
|
ColorModeStorage.set(coerceToColorMode(newColorMode));
|
||||||
};
|
};
|
||||||
|
|
||||||
function useColorModeContextValue(): ColorModeContextValue {
|
function useContextValue(): ContextValue {
|
||||||
const {
|
const {
|
||||||
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
|
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
|
||||||
} = useThemeConfig();
|
} = useThemeConfig();
|
||||||
|
@ -153,25 +155,17 @@ function useColorModeContextValue(): ColorModeContextValue {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorModeContext = React.createContext<ColorModeContextValue | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ColorModeProvider({
|
export function ColorModeProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const contextValue = useColorModeContextValue();
|
const value = useContextValue();
|
||||||
return (
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
<ColorModeContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</ColorModeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useColorMode(): ColorModeContextValue {
|
export function useColorMode(): ContextValue {
|
||||||
const context = useContext(ColorModeContext);
|
const context = useContext(Context);
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
throw new ReactContextError(
|
throw new ReactContextError(
|
||||||
'ColorModeProvider',
|
'ColorModeProvider',
|
||||||
|
|
|
@ -8,15 +8,30 @@
|
||||||
import React, {type ReactNode, useMemo, useState, useContext} from 'react';
|
import React, {type ReactNode, useMemo, useState, useContext} from 'react';
|
||||||
import {ReactContextError} from '../utils/reactUtils';
|
import {ReactContextError} from '../utils/reactUtils';
|
||||||
|
|
||||||
const EmptyContext: unique symbol = Symbol('EmptyContext');
|
type ContextValue = {
|
||||||
const Context = React.createContext<
|
/**
|
||||||
DocSidebarItemsExpandedState | typeof EmptyContext
|
* The item that the user last opened, `null` when there's none open. On
|
||||||
>(EmptyContext);
|
* initial render, it will always be `null`, which doesn't necessarily mean
|
||||||
type DocSidebarItemsExpandedState = {
|
* there's no category open (can have 0, 1, or many being initially open).
|
||||||
|
*/
|
||||||
expandedItem: number | null;
|
expandedItem: number | null;
|
||||||
|
/**
|
||||||
|
* Set the currently expanded item, when the user opens one. Set the value to
|
||||||
|
* `null` when the user closes an open category.
|
||||||
|
*/
|
||||||
setExpandedItem: (a: number | null) => void;
|
setExpandedItem: (a: number | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EmptyContext: unique symbol = Symbol('EmptyContext');
|
||||||
|
const Context = React.createContext<ContextValue | typeof EmptyContext>(
|
||||||
|
EmptyContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should be used to wrap one sidebar category level. This provider syncs the
|
||||||
|
* expanded states of all sibling categories, and categories can choose to
|
||||||
|
* collapse itself if another one is expanded.
|
||||||
|
*/
|
||||||
export function DocSidebarItemsExpandedStateProvider({
|
export function DocSidebarItemsExpandedStateProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
@ -31,10 +46,10 @@ export function DocSidebarItemsExpandedStateProvider({
|
||||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDocSidebarItemsExpandedState(): DocSidebarItemsExpandedState {
|
export function useDocSidebarItemsExpandedState(): ContextValue {
|
||||||
const contextValue = useContext(Context);
|
const value = useContext(Context);
|
||||||
if (contextValue === EmptyContext) {
|
if (value === EmptyContext) {
|
||||||
throw new ReactContextError('DocSidebarItemsExpandedStateProvider');
|
throw new ReactContextError('DocSidebarItemsExpandedStateProvider');
|
||||||
}
|
}
|
||||||
return contextValue;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,32 +54,29 @@ const DocsPreferredVersionStorage = {
|
||||||
|
|
||||||
type DocsPreferredVersionName = string | null;
|
type DocsPreferredVersionName = string | null;
|
||||||
|
|
||||||
// State for a single docs plugin instance
|
/** State for a single docs plugin instance */
|
||||||
type DocsPreferredVersionPluginState = {
|
type DocsPreferredVersionPluginState = {
|
||||||
preferredVersionName: DocsPreferredVersionName;
|
preferredVersionName: DocsPreferredVersionName;
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to store in state/storage globally
|
/**
|
||||||
// one preferred version per docs plugin instance
|
* We need to store the state in storage globally, with one preferred version
|
||||||
// pluginId => pluginState
|
* per docs plugin instance.
|
||||||
type DocsPreferredVersionState = Record<
|
*/
|
||||||
string,
|
type DocsPreferredVersionState = {
|
||||||
DocsPreferredVersionPluginState
|
[pluginId: string]: DocsPreferredVersionPluginState;
|
||||||
>;
|
};
|
||||||
|
|
||||||
// Initial state is always null as we can't read local storage from node SSR
|
/**
|
||||||
function getInitialState(pluginIds: string[]): DocsPreferredVersionState {
|
* Initial state is always null as we can't read local storage from node SSR
|
||||||
const initialState: DocsPreferredVersionState = {};
|
*/
|
||||||
pluginIds.forEach((pluginId) => {
|
const getInitialState = (pluginIds: string[]): DocsPreferredVersionState =>
|
||||||
initialState[pluginId] = {
|
Object.fromEntries(pluginIds.map((id) => [id, {preferredVersionName: null}]));
|
||||||
preferredVersionName: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return initialState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read storage for all docs plugins
|
/**
|
||||||
// Assign to each doc plugin a preferred version (if found)
|
* Read storage for all docs plugins, assigning each doc plugin a preferred
|
||||||
|
* version (if found)
|
||||||
|
*/
|
||||||
function readStorageState({
|
function readStorageState({
|
||||||
pluginIds,
|
pluginIds,
|
||||||
versionPersistence,
|
versionPersistence,
|
||||||
|
@ -89,9 +86,11 @@ function readStorageState({
|
||||||
versionPersistence: DocsVersionPersistence;
|
versionPersistence: DocsVersionPersistence;
|
||||||
allDocsData: Record<string, GlobalPluginData>;
|
allDocsData: Record<string, GlobalPluginData>;
|
||||||
}): DocsPreferredVersionState {
|
}): DocsPreferredVersionState {
|
||||||
// The storage value we read might be stale,
|
/**
|
||||||
// and belong to a version that does not exist in the site anymore
|
* The storage value we read might be stale, and belong to a version that does
|
||||||
// In such case, we remove the storage value to avoid downstream errors
|
* not exist in the site anymore. In such case, we remove the storage value to
|
||||||
|
* avoid downstream errors.
|
||||||
|
*/
|
||||||
function restorePluginState(
|
function restorePluginState(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
): DocsPreferredVersionPluginState {
|
): DocsPreferredVersionPluginState {
|
||||||
|
@ -109,20 +108,25 @@ function readStorageState({
|
||||||
DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
|
DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
|
||||||
return {preferredVersionName: null};
|
return {preferredVersionName: null};
|
||||||
}
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
const initialState: DocsPreferredVersionState = {};
|
pluginIds.map((id) => [id, restorePluginState(id)]),
|
||||||
pluginIds.forEach((pluginId) => {
|
);
|
||||||
initialState[pluginId] = restorePluginState(pluginId);
|
|
||||||
});
|
|
||||||
return initialState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useVersionPersistence(): DocsVersionPersistence {
|
function useVersionPersistence(): DocsVersionPersistence {
|
||||||
return useThemeConfig().docs.versionPersistence;
|
return useThemeConfig().docs.versionPersistence;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value that will be accessible through context: [state,api]
|
type ContextValue = [
|
||||||
function useContextValue() {
|
state: DocsPreferredVersionState,
|
||||||
|
api: {
|
||||||
|
savePreferredVersion: (pluginId: string, versionName: string) => void;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Context = React.createContext<ContextValue | null>(null);
|
||||||
|
|
||||||
|
function useContextValue(): ContextValue {
|
||||||
const allDocsData = useAllDocsData();
|
const allDocsData = useAllDocsData();
|
||||||
const versionPersistence = useVersionPersistence();
|
const versionPersistence = useVersionPersistence();
|
||||||
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
|
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
|
||||||
|
@ -154,15 +158,22 @@ function useContextValue() {
|
||||||
};
|
};
|
||||||
}, [versionPersistence]);
|
}, [versionPersistence]);
|
||||||
|
|
||||||
return [state, api] as const;
|
return [state, api];
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocsPreferredVersionContextValue = ReturnType<typeof useContextValue>;
|
function DocsPreferredVersionContextProviderUnsafe({
|
||||||
|
children,
|
||||||
const Context = React.createContext<DocsPreferredVersionContextValue | null>(
|
}: {
|
||||||
null,
|
children: ReactNode;
|
||||||
);
|
}): JSX.Element {
|
||||||
|
const value = useContextValue();
|
||||||
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a maybe-layer. If the docs plugin is not enabled, this provider is a
|
||||||
|
* simple pass-through.
|
||||||
|
*/
|
||||||
export function DocsPreferredVersionContextProvider({
|
export function DocsPreferredVersionContextProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
@ -178,16 +189,7 @@ export function DocsPreferredVersionContextProvider({
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocsPreferredVersionContextProviderUnsafe({
|
function useDocsPreferredVersionContext(): ContextValue {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}): JSX.Element {
|
|
||||||
const contextValue = useContextValue();
|
|
||||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
|
|
||||||
const value = useContext(Context);
|
const value = useContext(Context);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new ReactContextError('DocsPreferredVersionContextProvider');
|
throw new ReactContextError('DocsPreferredVersionContextProvider');
|
||||||
|
@ -195,11 +197,14 @@ function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note, the preferredVersion attribute will always be null before mount
|
/**
|
||||||
|
* Returns a read-write interface to a plugin's preferred version.
|
||||||
|
* Note, the `preferredVersion` attribute will always be `null` before mount.
|
||||||
|
*/
|
||||||
export function useDocsPreferredVersion(
|
export function useDocsPreferredVersion(
|
||||||
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
|
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
|
||||||
): {
|
): {
|
||||||
preferredVersion: GlobalVersion | null | undefined;
|
preferredVersion: GlobalVersion | null;
|
||||||
savePreferredVersionName: (versionName: string) => void;
|
savePreferredVersionName: (versionName: string) => void;
|
||||||
} {
|
} {
|
||||||
const docsData = useDocsData(pluginId);
|
const docsData = useDocsData(pluginId);
|
||||||
|
@ -207,9 +212,10 @@ export function useDocsPreferredVersion(
|
||||||
|
|
||||||
const {preferredVersionName} = state[pluginId]!;
|
const {preferredVersionName} = state[pluginId]!;
|
||||||
|
|
||||||
const preferredVersion = preferredVersionName
|
const preferredVersion =
|
||||||
? docsData.versions.find((version) => version.name === preferredVersionName)
|
docsData.versions.find(
|
||||||
: null;
|
(version) => version.name === preferredVersionName,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
const savePreferredVersionName = useCallback(
|
const savePreferredVersionName = useCallback(
|
||||||
(versionName: string) => {
|
(versionName: string) => {
|
||||||
|
@ -218,12 +224,12 @@ export function useDocsPreferredVersion(
|
||||||
[api, pluginId],
|
[api, pluginId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {preferredVersion, savePreferredVersionName} as const;
|
return {preferredVersion, savePreferredVersionName};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDocsPreferredVersionByPluginId(): Record<
|
export function useDocsPreferredVersionByPluginId(): Record<
|
||||||
string,
|
string,
|
||||||
GlobalVersion | null | undefined
|
GlobalVersion | null
|
||||||
> {
|
> {
|
||||||
const allDocsData = useAllDocsData();
|
const allDocsData = useAllDocsData();
|
||||||
const [state] = useDocsPreferredVersionContext();
|
const [state] = useDocsPreferredVersionContext();
|
||||||
|
@ -232,19 +238,14 @@ export function useDocsPreferredVersionByPluginId(): Record<
|
||||||
const docsData = allDocsData[pluginId]!;
|
const docsData = allDocsData[pluginId]!;
|
||||||
const {preferredVersionName} = state[pluginId]!;
|
const {preferredVersionName} = state[pluginId]!;
|
||||||
|
|
||||||
return preferredVersionName
|
return (
|
||||||
? docsData.versions.find(
|
docsData.versions.find(
|
||||||
(version) => version.name === preferredVersionName,
|
(version) => version.name === preferredVersionName,
|
||||||
)
|
) ?? null
|
||||||
: null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginIds = Object.keys(allDocsData);
|
const pluginIds = Object.keys(allDocsData);
|
||||||
|
return Object.fromEntries(
|
||||||
const result: Record<string, GlobalVersion | null | undefined> = {};
|
pluginIds.map((id) => [id, getPluginIdPreferredVersion(id)]),
|
||||||
pluginIds.forEach((pluginId) => {
|
);
|
||||||
result[pluginId] = getPluginIdPreferredVersion(pluginId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -6,11 +6,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {useWindowSize} from '../hooks/useWindowSize';
|
import {useWindowSize} from '../hooks/useWindowSize';
|
||||||
import {useHistoryPopHandler} from '../utils/historyUtils';
|
import {useHistoryPopHandler} from '../utils/historyUtils';
|
||||||
|
@ -18,31 +18,38 @@ import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
|
||||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||||
import {ReactContextError} from '../utils/reactUtils';
|
import {ReactContextError} from '../utils/reactUtils';
|
||||||
|
|
||||||
type NavbarMobileSidebarContextValue = {
|
type ContextValue = {
|
||||||
|
/**
|
||||||
|
* Mobile sidebar should be disabled in case it's empty, i.e. no secondary
|
||||||
|
* menu + no navbar items). If disabled, the toggle button should not be
|
||||||
|
* displayed at all.
|
||||||
|
*/
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/**
|
||||||
|
* Signals whether the actual sidebar should be displayed (contrary to
|
||||||
|
* `disabled` which is about the toggle button). Sidebar should not visible
|
||||||
|
* until user interaction to avoid SSR rendering.
|
||||||
|
*/
|
||||||
shouldRender: boolean;
|
shouldRender: boolean;
|
||||||
toggle: () => void;
|
/** The displayed state. Can be toggled with the `toggle` callback. */
|
||||||
shown: boolean;
|
shown: boolean;
|
||||||
|
/** Toggle the `shown` attribute. */
|
||||||
|
toggle: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Context = React.createContext<
|
const Context = React.createContext<ContextValue | undefined>(undefined);
|
||||||
NavbarMobileSidebarContextValue | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
// Mobile sidebar can be disabled in case it would lead to an empty sidebar
|
function useIsNavbarMobileSidebarDisabled() {
|
||||||
// In this case it's not useful to display a navbar sidebar toggle button
|
|
||||||
function useNavbarMobileSidebarDisabled() {
|
|
||||||
const activeDocPlugin = useActivePlugin();
|
const activeDocPlugin = useActivePlugin();
|
||||||
const {items} = useThemeConfig().navbar;
|
const {items} = useThemeConfig().navbar;
|
||||||
return items.length === 0 && !activeDocPlugin;
|
return items.length === 0 && !activeDocPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
|
function useContextValue(): ContextValue {
|
||||||
const disabled = useNavbarMobileSidebarDisabled();
|
const disabled = useIsNavbarMobileSidebarDisabled();
|
||||||
const windowSize = useWindowSize();
|
const windowSize = useWindowSize();
|
||||||
|
|
||||||
// Mobile sidebar not visible until user interaction: can avoid SSR rendering
|
const shouldRender = !disabled && windowSize === 'mobile';
|
||||||
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
|
|
||||||
|
|
||||||
const [shown, setShown] = useState(false);
|
const [shown, setShown] = useState(false);
|
||||||
|
|
||||||
|
@ -68,14 +75,8 @@ function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
|
||||||
}
|
}
|
||||||
}, [windowSize]);
|
}, [windowSize]);
|
||||||
|
|
||||||
// Return stable context value
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({disabled, shouldRender, toggle, shown}),
|
||||||
disabled,
|
|
||||||
shouldRender,
|
|
||||||
toggle,
|
|
||||||
shown,
|
|
||||||
}),
|
|
||||||
[disabled, shouldRender, toggle, shown],
|
[disabled, shouldRender, toggle, shown],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -85,13 +86,13 @@ export function NavbarMobileSidebarProvider({
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const value = useNavbarMobileSidebarContextValue();
|
const value = useContextValue();
|
||||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
|
export function useNavbarMobileSidebar(): ContextValue {
|
||||||
const context = React.useContext(Context);
|
const context = React.useContext(Context);
|
||||||
if (context == null) {
|
if (context === undefined) {
|
||||||
throw new ReactContextError('NavbarMobileSidebarProvider');
|
throw new ReactContextError('NavbarMobileSidebarProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
|
|
|
@ -14,19 +14,8 @@ import React, {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {ReactContextError, usePrevious} from './reactUtils';
|
import {ReactContextError, usePrevious} from '../utils/reactUtils';
|
||||||
import {useNavbarMobileSidebar} from '../contexts/navbarMobileSidebar';
|
import {useNavbarMobileSidebar} from './navbarMobileSidebar';
|
||||||
|
|
||||||
/*
|
|
||||||
The idea behind all this is that a specific component must be able to fill a
|
|
||||||
placeholder in the generic layout. The doc page should be able to fill the
|
|
||||||
secondary menu of the main mobile navbar. This permits to reduce coupling
|
|
||||||
between the main layout and the specific page.
|
|
||||||
|
|
||||||
This kind of feature is often called portal/teleport/gateway... various
|
|
||||||
unmaintained React libs exist. Most up-to-date one: https://github.com/gregberge/react-teleporter
|
|
||||||
Not sure any of those is safe regarding concurrent mode.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
|
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
|
||||||
|
|
||||||
|
@ -34,7 +23,7 @@ type State = {
|
||||||
shown: boolean;
|
shown: boolean;
|
||||||
content:
|
content:
|
||||||
| {
|
| {
|
||||||
component: ComponentType<object>;
|
component: NavbarSecondaryMenuComponent<object>;
|
||||||
props: object;
|
props: object;
|
||||||
}
|
}
|
||||||
| {component: null; props: null};
|
| {component: null; props: null};
|
||||||
|
@ -45,7 +34,14 @@ const InitialState: State = {
|
||||||
content: {component: null, props: null},
|
content: {component: null, props: null},
|
||||||
};
|
};
|
||||||
|
|
||||||
function useContextValue() {
|
type ContextValue = [
|
||||||
|
state: State,
|
||||||
|
setState: React.Dispatch<React.SetStateAction<State>>,
|
||||||
|
];
|
||||||
|
|
||||||
|
const Context = React.createContext<ContextValue | null>(null);
|
||||||
|
|
||||||
|
function useContextValue(): ContextValue {
|
||||||
const mobileSidebar = useNavbarMobileSidebar();
|
const mobileSidebar = useNavbarMobileSidebar();
|
||||||
|
|
||||||
const [state, setState] = useState<State>(InitialState);
|
const [state, setState] = useState<State>(InitialState);
|
||||||
|
@ -76,21 +72,16 @@ function useContextValue() {
|
||||||
}
|
}
|
||||||
}, [mobileSidebar.shown, hasContent]);
|
}, [mobileSidebar.shown, hasContent]);
|
||||||
|
|
||||||
return [state, setState] as const;
|
return [state, setState];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextValue = ReturnType<typeof useContextValue>;
|
|
||||||
|
|
||||||
const Context = React.createContext<ContextValue | null>(null);
|
|
||||||
|
|
||||||
export function NavbarSecondaryMenuProvider({
|
export function NavbarSecondaryMenuProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
const value = useContextValue();
|
||||||
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNavbarSecondaryMenuContext(): ContextValue {
|
function useNavbarSecondaryMenuContext(): ContextValue {
|
||||||
|
@ -101,7 +92,7 @@ function useNavbarSecondaryMenuContext(): ContextValue {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
|
function useShallowMemoizedObject<O>(obj: O) {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => obj,
|
() => obj,
|
||||||
// Is this safe?
|
// Is this safe?
|
||||||
|
@ -110,15 +101,22 @@ function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill the secondary menu placeholder with some real content
|
/**
|
||||||
export function NavbarSecondaryMenuFiller<
|
* This component renders nothing by itself, but it fills the placeholder in the
|
||||||
Props extends Record<string, unknown>,
|
* generic secondary menu layout. This reduces coupling between the main layout
|
||||||
>({
|
* and the specific page.
|
||||||
|
*
|
||||||
|
* This kind of feature is often called portal/teleport/gateway/outlet...
|
||||||
|
* Various unmaintained React libs exist. Most up-to-date one:
|
||||||
|
* https://github.com/gregberge/react-teleporter
|
||||||
|
* Not sure any of those is safe regarding concurrent mode.
|
||||||
|
*/
|
||||||
|
export function NavbarSecondaryMenuFiller<P extends object>({
|
||||||
component,
|
component,
|
||||||
props,
|
props,
|
||||||
}: {
|
}: {
|
||||||
component: NavbarSecondaryMenuComponent<Props>;
|
component: NavbarSecondaryMenuComponent<P>;
|
||||||
props: Props;
|
props: P;
|
||||||
}): JSX.Element | null {
|
}): JSX.Element | null {
|
||||||
const [, setState] = useNavbarSecondaryMenuContext();
|
const [, setState] = useNavbarSecondaryMenuContext();
|
||||||
|
|
||||||
|
@ -146,9 +144,16 @@ function renderElement(state: State): JSX.Element | undefined {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wires the logic for rendering the mobile navbar secondary menu. */
|
||||||
export function useNavbarSecondaryMenu(): {
|
export function useNavbarSecondaryMenu(): {
|
||||||
|
/** Whether secondary menu is displayed. */
|
||||||
shown: boolean;
|
shown: boolean;
|
||||||
|
/**
|
||||||
|
* Hide the secondary menu; fired either when hiding the entire sidebar, or
|
||||||
|
* when going back to the primary menu.
|
||||||
|
*/
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
|
/** The content returned from the current secondary menu filler. */
|
||||||
content: JSX.Element | undefined;
|
content: JSX.Element | undefined;
|
||||||
} {
|
} {
|
||||||
const [state, setState] = useNavbarSecondaryMenuContext();
|
const [state, setState] = useNavbarSecondaryMenuContext();
|
||||||
|
@ -159,11 +164,7 @@ export function useNavbarSecondaryMenu(): {
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({shown: state.shown, hide, content: renderElement(state)}),
|
||||||
shown: state.shown,
|
|
||||||
hide,
|
|
||||||
content: renderElement(state),
|
|
||||||
}),
|
|
||||||
[hide, state],
|
[hide, state],
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -18,16 +18,16 @@ import {ReactContextError} from '../utils/reactUtils';
|
||||||
|
|
||||||
const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
|
const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
|
||||||
|
|
||||||
type TabGroupChoiceContextValue = {
|
type ContextValue = {
|
||||||
|
/** A map from `groupId` to the `value` of the saved choice. */
|
||||||
readonly tabGroupChoices: {readonly [groupId: string]: string};
|
readonly tabGroupChoices: {readonly [groupId: string]: string};
|
||||||
|
/** Set the new choice value of a group. */
|
||||||
readonly setTabGroupChoices: (groupId: string, newChoice: string) => void;
|
readonly setTabGroupChoices: (groupId: string, newChoice: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TabGroupChoiceContext = React.createContext<
|
const Context = React.createContext<ContextValue | undefined>(undefined);
|
||||||
TabGroupChoiceContextValue | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue {
|
function useContextValue(): ContextValue {
|
||||||
const [tabGroupChoices, setChoices] = useState<{
|
const [tabGroupChoices, setChoices] = useState<{
|
||||||
readonly [groupId: string]: string;
|
readonly [groupId: string]: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
@ -50,13 +50,18 @@ function useTabGroupChoiceContextValue(): TabGroupChoiceContextValue {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
const setTabGroupChoices = useCallback(
|
||||||
tabGroupChoices,
|
(groupId: string, newChoice: string) => {
|
||||||
setTabGroupChoices: (groupId: string, newChoice: string) => {
|
|
||||||
setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice}));
|
setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice}));
|
||||||
setChoiceSyncWithLocalStorage(groupId, newChoice);
|
setChoiceSyncWithLocalStorage(groupId, newChoice);
|
||||||
},
|
},
|
||||||
};
|
[setChoiceSyncWithLocalStorage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({tabGroupChoices, setTabGroupChoices}),
|
||||||
|
[tabGroupChoices, setTabGroupChoices],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabGroupChoiceProvider({
|
export function TabGroupChoiceProvider({
|
||||||
|
@ -64,23 +69,12 @@ export function TabGroupChoiceProvider({
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoiceContextValue();
|
const value = useContextValue();
|
||||||
const contextValue = useMemo(
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
() => ({
|
|
||||||
tabGroupChoices,
|
|
||||||
setTabGroupChoices,
|
|
||||||
}),
|
|
||||||
[tabGroupChoices, setTabGroupChoices],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<TabGroupChoiceContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</TabGroupChoiceContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTabGroupChoice(): TabGroupChoiceContextValue {
|
export function useTabGroupChoice(): ContextValue {
|
||||||
const context = useContext(TabGroupChoiceContext);
|
const context = useContext(Context);
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
throw new ReactContextError('TabGroupChoiceProvider');
|
throw new ReactContextError('TabGroupChoiceProvider');
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,14 @@ import {useState, useCallback, useRef} from 'react';
|
||||||
import {useLocationChange} from '../utils/useLocationChange';
|
import {useLocationChange} from '../utils/useLocationChange';
|
||||||
import {useScrollPosition} from '../utils/scrollUtils';
|
import {useScrollPosition} from '../utils/scrollUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the imperative logic of a hideable navbar.
|
||||||
|
* @param hideOnScroll If `false`, this hook is basically a no-op.
|
||||||
|
*/
|
||||||
export function useHideableNavbar(hideOnScroll: boolean): {
|
export function useHideableNavbar(hideOnScroll: boolean): {
|
||||||
|
/** A ref to the navbar component. Plug this into the actual element. */
|
||||||
readonly navbarRef: (node: HTMLElement | null) => void;
|
readonly navbarRef: (node: HTMLElement | null) => void;
|
||||||
|
/** If `false`, the navbar component should not be rendered. */
|
||||||
readonly isNavbarVisible: boolean;
|
readonly isNavbarVisible: boolean;
|
||||||
} {
|
} {
|
||||||
const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll);
|
const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll);
|
||||||
|
@ -29,7 +35,8 @@ export function useHideableNavbar(hideOnScroll: boolean): {
|
||||||
|
|
||||||
const scrollTop = currentPosition.scrollY;
|
const scrollTop = currentPosition.scrollY;
|
||||||
|
|
||||||
// It needed for mostly to handle rubber band scrolling
|
// Needed mostly for handling rubber band scrolling.
|
||||||
|
// See https://github.com/facebook/docusaurus/pull/5721
|
||||||
if (scrollTop < navbarHeight.current) {
|
if (scrollTop < navbarHeight.current) {
|
||||||
setIsNavbarVisible(true);
|
setIsNavbarVisible(true);
|
||||||
return;
|
return;
|
||||||
|
@ -66,8 +73,5 @@ export function useHideableNavbar(hideOnScroll: boolean): {
|
||||||
setIsNavbarVisible(true);
|
setIsNavbarVisible(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {navbarRef, isNavbarVisible};
|
||||||
navbarRef,
|
|
||||||
isNavbarVisible,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,13 @@ import './styles.css';
|
||||||
export const keyboardFocusedClassName = 'navigation-with-keyboard';
|
export const keyboardFocusedClassName = 'navigation-with-keyboard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect keyboard focus indicator to not show outline for mouse users
|
* Side-effect that adds the `keyboardFocusedClassName` to the body element when
|
||||||
|
* the keyboard has been pressed, or removes it when the mouse is clicked.
|
||||||
|
*
|
||||||
|
* The presence of this class name signals that the user may be using keyboard
|
||||||
|
* for navigation, and the theme **must** add focus outline when this class name
|
||||||
|
* is present. (And optionally not if it's absent, for design purposes)
|
||||||
|
*
|
||||||
* Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
|
* Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
|
||||||
*/
|
*/
|
||||||
export function useKeyboardNavigation(): void {
|
export function useKeyboardNavigation(): void {
|
||||||
|
|
|
@ -7,10 +7,13 @@
|
||||||
|
|
||||||
import {useEffect} from 'react';
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-effect that locks the document body's scroll throughout the lifetime of
|
||||||
|
* the containing component. e.g. when the mobile sidebar is expanded.
|
||||||
|
*/
|
||||||
export function useLockBodyScroll(lock: boolean = true): void {
|
export function useLockBodyScroll(lock: boolean = true): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = lock ? 'hidden' : 'visible';
|
document.body.style.overflow = lock ? 'hidden' : 'visible';
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = 'visible';
|
document.body.style.overflow = 'visible';
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,6 +9,10 @@ import defaultTheme from 'prism-react-renderer/themes/palenight';
|
||||||
import {useColorMode} from '../contexts/colorMode';
|
import {useColorMode} from '../contexts/colorMode';
|
||||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a color-mode-dependent Prism theme: whatever the user specified in
|
||||||
|
* the config. Falls back to `palenight`.
|
||||||
|
*/
|
||||||
export function usePrismTheme(): typeof defaultTheme {
|
export function usePrismTheme(): typeof defaultTheme {
|
||||||
const {prism} = useThemeConfig();
|
const {prism} = useThemeConfig();
|
||||||
const {colorMode} = useColorMode();
|
const {colorMode} = useColorMode();
|
||||||
|
|
|
@ -11,9 +11,22 @@ import {useCallback, useEffect, useState} from 'react';
|
||||||
|
|
||||||
const SEARCH_PARAM_QUERY = 'q';
|
const SEARCH_PARAM_QUERY = 'q';
|
||||||
|
|
||||||
|
/** Some utility functions around search queries. */
|
||||||
export function useSearchPage(): {
|
export function useSearchPage(): {
|
||||||
|
/**
|
||||||
|
* Works hand-in-hand with `setSearchQuery`; whatever the user has inputted
|
||||||
|
* into the search box.
|
||||||
|
*/
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
/**
|
||||||
|
* Set a new search query. In addition to updating `searchQuery`, this handle
|
||||||
|
* also mutates the location and appends the query.
|
||||||
|
*/
|
||||||
setSearchQuery: (newSearchQuery: string) => void;
|
setSearchQuery: (newSearchQuery: string) => void;
|
||||||
|
/**
|
||||||
|
* Given a query, this handle generates the corresponding search page link,
|
||||||
|
* with base URL prepended.
|
||||||
|
*/
|
||||||
generateSearchPageLink: (targetSearchQuery: string) => string;
|
generateSearchPageLink: (targetSearchQuery: string) => string;
|
||||||
} {
|
} {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
@ -52,7 +65,9 @@ export function useSearchPage(): {
|
||||||
const generateSearchPageLink = useCallback(
|
const generateSearchPageLink = useCallback(
|
||||||
(targetSearchQuery: string) =>
|
(targetSearchQuery: string) =>
|
||||||
// Refer to https://github.com/facebook/docusaurus/pull/2838
|
// Refer to https://github.com/facebook/docusaurus/pull/2838
|
||||||
`${baseUrl}search?q=${encodeURIComponent(targetSearchQuery)}`,
|
`${baseUrl}search?${SEARCH_PARAM_QUERY}=${encodeURIComponent(
|
||||||
|
targetSearchQuery,
|
||||||
|
)}`,
|
||||||
[baseUrl],
|
[baseUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,10 @@ import {useThemeConfig} from '../utils/useThemeConfig';
|
||||||
// TODO make the hardcoded theme-classic classnames configurable (or add them
|
// TODO make the hardcoded theme-classic classnames configurable (or add them
|
||||||
// to ThemeClassNames?)
|
// to ThemeClassNames?)
|
||||||
|
|
||||||
// If the anchor has no height and is just a "marker" in the dom; we'll use the
|
/**
|
||||||
// parent (normally the link text) rect boundaries instead
|
* If the anchor has no height and is just a "marker" in the DOM; we'll use the
|
||||||
|
* parent (normally the link text) rect boundaries instead
|
||||||
|
*/
|
||||||
function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
|
function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const hasNoHeight = rect.top === rect.bottom;
|
const hasNoHeight = rect.top === rect.bottom;
|
||||||
|
@ -24,7 +26,7 @@ function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Considering we divide viewport into 2 zones of each 50vh, this returns true
|
* Considering we divide viewport into 2 zones of each 50vh, this returns true
|
||||||
* if an element is in the first zone (ie, appear in viewport, near the top)
|
* if an element is in the first zone (i.e., appear in viewport, near the top)
|
||||||
*/
|
*/
|
||||||
function isInViewportTopHalf(boundingRect: DOMRect) {
|
function isInViewportTopHalf(boundingRect: DOMRect) {
|
||||||
return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2;
|
return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2;
|
||||||
|
@ -114,12 +116,23 @@ function useAnchorTopOffsetRef() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TOCHighlightConfig = {
|
export type TOCHighlightConfig = {
|
||||||
|
/** A class name that all TOC links share. */
|
||||||
linkClassName: string;
|
linkClassName: string;
|
||||||
|
/** The class name applied to the active (highlighted) link. */
|
||||||
linkActiveClassName: string;
|
linkActiveClassName: string;
|
||||||
|
/**
|
||||||
|
* The minimum heading level that the TOC includes. Only headings that are in
|
||||||
|
* this range will be eligible as "active heading".
|
||||||
|
*/
|
||||||
minHeadingLevel: number;
|
minHeadingLevel: number;
|
||||||
|
/** @see {@link TOCHighlightConfig.minHeadingLevel} */
|
||||||
maxHeadingLevel: number;
|
maxHeadingLevel: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-effect that applies the active class name to the TOC heading that the
|
||||||
|
* user is currently viewing. Disabled when `config` is undefined.
|
||||||
|
*/
|
||||||
export function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
|
export function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
|
||||||
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
|
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
|
||||||
|
|
||||||
|
|
|
@ -12,12 +12,6 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||||
const windowSizes = {
|
const windowSizes = {
|
||||||
desktop: 'desktop',
|
desktop: 'desktop',
|
||||||
mobile: 'mobile',
|
mobile: 'mobile',
|
||||||
|
|
||||||
// This "ssr" value is very important to handle hydration FOUC / layout shifts
|
|
||||||
// You have to handle server-rendering explicitly on the call-site
|
|
||||||
// On the server, you may need to render BOTH the mobile/desktop elements (and
|
|
||||||
// hide one of them with mediaquery)
|
|
||||||
// We don't return "undefined" on purpose, to make it more explicit
|
|
||||||
ssr: 'ssr',
|
ssr: 'ssr',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@ -34,13 +28,21 @@ function getWindowSize() {
|
||||||
: windowSizes.mobile;
|
: windowSizes.mobile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate the SSR window size in dev, so that potential hydration FOUC/layout
|
|
||||||
// shift problems can be seen in dev too!
|
|
||||||
const DevSimulateSSR = process.env.NODE_ENV === 'development' && true;
|
const DevSimulateSSR = process.env.NODE_ENV === 'development' && true;
|
||||||
|
|
||||||
// This hook returns an enum value on purpose!
|
/**
|
||||||
// We don't want it to return the actual width value, for resize perf reasons
|
* Gets the current window size as an enum value. We don't want it to return the
|
||||||
// We only want to re-render once a breakpoint is crossed
|
* actual width value, so that it only re-renders once a breakpoint is crossed.
|
||||||
|
*
|
||||||
|
* It may return `"ssr"`, which is very important to handle hydration FOUC or
|
||||||
|
* layout shifts. You have to handle it explicitly upfront. On the server, you
|
||||||
|
* may need to render BOTH the mobile/desktop elements (and hide one of them
|
||||||
|
* with mediaquery). We don't return `undefined` on purpose, to make it more
|
||||||
|
* explicit.
|
||||||
|
*
|
||||||
|
* In development mode, this hook will still return `"ssr"` for one second, to
|
||||||
|
* catch potential layout shifts, similar to strict mode calling effects twice.
|
||||||
|
*/
|
||||||
export function useWindowSize(): WindowSize {
|
export function useWindowSize(): WindowSize {
|
||||||
const [windowSize, setWindowSize] = useState<WindowSize>(() => {
|
const [windowSize, setWindowSize] = useState<WindowSize>(() => {
|
||||||
if (DevSimulateSSR) {
|
if (DevSimulateSSR) {
|
||||||
|
|
|
@ -23,28 +23,28 @@ export {
|
||||||
DocSidebarItemsExpandedStateProvider,
|
DocSidebarItemsExpandedStateProvider,
|
||||||
useDocSidebarItemsExpandedState,
|
useDocSidebarItemsExpandedState,
|
||||||
} from './contexts/docSidebarItemsExpandedState';
|
} from './contexts/docSidebarItemsExpandedState';
|
||||||
|
export {DocsVersionProvider, useDocsVersion} from './contexts/docsVersion';
|
||||||
|
export {DocsSidebarProvider, useDocsSidebar} from './contexts/docsSidebar';
|
||||||
|
|
||||||
export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
|
export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
|
||||||
|
|
||||||
export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
|
export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
|
||||||
|
|
||||||
export {useContextualSearchFilters} from './utils/useContextualSearchFilters';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
parseCodeBlockTitle,
|
parseCodeBlockTitle,
|
||||||
parseLanguage,
|
parseLanguage,
|
||||||
parseLines,
|
parseLines,
|
||||||
} from './utils/codeBlockUtils';
|
} from './utils/codeBlockUtils';
|
||||||
|
|
||||||
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils';
|
export {
|
||||||
|
docVersionSearchTag,
|
||||||
|
DEFAULT_SEARCH_TAG,
|
||||||
|
useContextualSearchFilters,
|
||||||
|
} from './utils/searchUtils';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
isDocsPluginEnabled,
|
isDocsPluginEnabled,
|
||||||
DocsVersionProvider,
|
|
||||||
useDocsVersion,
|
|
||||||
useDocById,
|
useDocById,
|
||||||
DocsSidebarProvider,
|
|
||||||
useDocsSidebar,
|
|
||||||
findSidebarCategory,
|
findSidebarCategory,
|
||||||
findFirstCategoryLink,
|
findFirstCategoryLink,
|
||||||
useCurrentSidebarCategory,
|
useCurrentSidebarCategory,
|
||||||
|
@ -52,20 +52,13 @@ export {
|
||||||
useSidebarBreadcrumbs,
|
useSidebarBreadcrumbs,
|
||||||
} from './utils/docsUtils';
|
} from './utils/docsUtils';
|
||||||
|
|
||||||
export {isSamePath} from './utils/pathUtils';
|
|
||||||
|
|
||||||
export {useTitleFormatter} from './utils/generalUtils';
|
export {useTitleFormatter} from './utils/generalUtils';
|
||||||
|
|
||||||
export {usePluralForm} from './utils/usePluralForm';
|
export {usePluralForm} from './utils/usePluralForm';
|
||||||
|
|
||||||
export {useLocationChange} from './utils/useLocationChange';
|
export {useLocationChange} from './utils/useLocationChange';
|
||||||
|
|
||||||
export {
|
export {useCollapsible, Collapsible} from './components/Collapsible';
|
||||||
useCollapsible,
|
|
||||||
Collapsible,
|
|
||||||
type UseCollapsibleConfig,
|
|
||||||
type UseCollapsibleReturns,
|
|
||||||
} from './components/Collapsible';
|
|
||||||
|
|
||||||
export {Details, type DetailsProps} from './components/Details';
|
export {Details, type DetailsProps} from './components/Details';
|
||||||
|
|
||||||
|
@ -124,7 +117,7 @@ export {
|
||||||
|
|
||||||
export {isRegexpStringMatch} from './utils/regexpUtils';
|
export {isRegexpStringMatch} from './utils/regexpUtils';
|
||||||
|
|
||||||
export {useHomePageRoute} from './utils/routesUtils';
|
export {useHomePageRoute, isSamePath} from './utils/routesUtils';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PageMetadata,
|
PageMetadata,
|
||||||
|
@ -149,8 +142,8 @@ export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
|
||||||
export {
|
export {
|
||||||
useNavbarSecondaryMenu,
|
useNavbarSecondaryMenu,
|
||||||
NavbarSecondaryMenuFiller,
|
NavbarSecondaryMenuFiller,
|
||||||
} from './utils/navbarSecondaryMenu';
|
type NavbarSecondaryMenuComponent,
|
||||||
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenu';
|
} from './contexts/navbarSecondaryMenu';
|
||||||
|
|
||||||
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -5,10 +5,13 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// These class names are used to style page layouts in Docusaurus
|
|
||||||
// Those are meant to be targeted by user-provided custom CSS selectors
|
|
||||||
// Please do not modify the classnames! This is a breaking change, and annoying
|
// Please do not modify the classnames! This is a breaking change, and annoying
|
||||||
// for users!
|
// for users!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These class names are used to style page layouts in Docusaurus, meant to be
|
||||||
|
* targeted by user-provided custom CSS selectors.
|
||||||
|
*/
|
||||||
export const ThemeClassNames = {
|
export const ThemeClassNames = {
|
||||||
page: {
|
page: {
|
||||||
blogListPage: 'blog-list-page',
|
blogListPage: 'blog-list-page',
|
||||||
|
@ -17,8 +20,8 @@ export const ThemeClassNames = {
|
||||||
blogTagPostListPage: 'blog-tags-post-list-page',
|
blogTagPostListPage: 'blog-tags-post-list-page',
|
||||||
|
|
||||||
docsDocPage: 'docs-doc-page',
|
docsDocPage: 'docs-doc-page',
|
||||||
docsTagsListPage: 'docs-tags-list-page', // List of tags
|
docsTagsListPage: 'docs-tags-list-page',
|
||||||
docsTagDocListPage: 'docs-tags-doc-list-page', // Docs for a tag
|
docsTagDocListPage: 'docs-tags-doc-list-page',
|
||||||
|
|
||||||
mdxPage: 'mdx-page',
|
mdxPage: 'mdx-page',
|
||||||
},
|
},
|
||||||
|
@ -29,8 +32,9 @@ export const ThemeClassNames = {
|
||||||
mdxPages: 'mdx-wrapper',
|
mdxPages: 'mdx-wrapper',
|
||||||
},
|
},
|
||||||
|
|
||||||
// /!\ Please keep the naming convention consistent!
|
/**
|
||||||
// Something like: "theme-{blog,doc,version,page}?-<suffix>"
|
* Follows the naming convention "theme-{blog,doc,version,page}?-<suffix>"
|
||||||
|
*/
|
||||||
common: {
|
common: {
|
||||||
editThisPage: 'theme-edit-this-page',
|
editThisPage: 'theme-edit-this-page',
|
||||||
lastUpdated: 'theme-last-updated',
|
lastUpdated: 'theme-last-updated',
|
||||||
|
|
|
@ -10,15 +10,13 @@ import {renderHook} from '@testing-library/react-hooks';
|
||||||
import {
|
import {
|
||||||
findFirstCategoryLink,
|
findFirstCategoryLink,
|
||||||
isActiveSidebarItem,
|
isActiveSidebarItem,
|
||||||
DocsVersionProvider,
|
|
||||||
useDocsVersion,
|
|
||||||
useDocById,
|
useDocById,
|
||||||
useDocsSidebar,
|
|
||||||
DocsSidebarProvider,
|
|
||||||
findSidebarCategory,
|
findSidebarCategory,
|
||||||
useCurrentSidebarCategory,
|
useCurrentSidebarCategory,
|
||||||
useSidebarBreadcrumbs,
|
useSidebarBreadcrumbs,
|
||||||
} from '../docsUtils';
|
} from '../docsUtils';
|
||||||
|
import {DocsSidebarProvider} from '../../contexts/docsSidebar';
|
||||||
|
import {DocsVersionProvider} from '../../contexts/docsVersion';
|
||||||
import {StaticRouter} from 'react-router-dom';
|
import {StaticRouter} from 'react-router-dom';
|
||||||
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
|
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
|
||||||
import type {
|
import type {
|
||||||
|
@ -68,46 +66,6 @@ function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useDocsVersion', () => {
|
|
||||||
it('throws if context provider is missing', () => {
|
|
||||||
expect(
|
|
||||||
() => renderHook(() => useDocsVersion()).result.current,
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(
|
|
||||||
`"Hook useDocsVersion is called outside the <DocsVersionProvider>. "`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads value from context provider', () => {
|
|
||||||
const version = testVersion();
|
|
||||||
const {result} = renderHook(() => useDocsVersion(), {
|
|
||||||
wrapper: ({children}) => (
|
|
||||||
<DocsVersionProvider version={version}>{children}</DocsVersionProvider>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(version);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useDocsSidebar', () => {
|
|
||||||
it('throws if context provider is missing', () => {
|
|
||||||
expect(
|
|
||||||
() => renderHook(() => useDocsSidebar()).result.current,
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(
|
|
||||||
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads value from context provider', () => {
|
|
||||||
const sidebar: PropSidebar = [];
|
|
||||||
const {result} = renderHook(() => useDocsSidebar(), {
|
|
||||||
wrapper: ({children}) => (
|
|
||||||
<DocsSidebarProvider sidebar={sidebar}>{children}</DocsSidebarProvider>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(sidebar);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useDocById', () => {
|
describe('useDocById', () => {
|
||||||
const version = testVersion({
|
const version = testVersion({
|
||||||
docs: {
|
docs: {
|
||||||
|
@ -506,11 +464,11 @@ describe('useCurrentSidebarCategory', () => {
|
||||||
const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([
|
const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([
|
||||||
category,
|
category,
|
||||||
]);
|
]);
|
||||||
expect(() => mockUseCurrentSidebarCategory('/cat'))
|
expect(() =>
|
||||||
.toThrowErrorMatchingInlineSnapshot(`
|
mockUseCurrentSidebarCategory('/cat'),
|
||||||
"Unexpected: sidebar category could not be found for pathname='/cat'.
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
Hook useCurrentSidebarCategory() should only be used on Category pages"
|
`"/cat is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages."`,
|
||||||
`);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when sidebar is missing', () => {
|
it('throws when sidebar is missing', () => {
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -6,7 +6,39 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {Route} from '@docusaurus/types';
|
import type {Route} from '@docusaurus/types';
|
||||||
import {findHomePageRoute} from '../routesUtils';
|
import {findHomePageRoute, isSamePath} from '../routesUtils';
|
||||||
|
|
||||||
|
describe('isSamePath', () => {
|
||||||
|
it('returns true for compared path without trailing slash', () => {
|
||||||
|
expect(isSamePath('/docs', '/docs')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for compared path with trailing slash', () => {
|
||||||
|
expect(isSamePath('/docs', '/docs/')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for compared path with different case', () => {
|
||||||
|
expect(isSamePath('/doCS', '/DOcs')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for compared path with different case + trailing slash', () => {
|
||||||
|
expect(isSamePath('/doCS', '/DOcs/')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for compared path with double trailing slash', () => {
|
||||||
|
expect(isSamePath('/docs', '/docs//')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for twice undefined/null', () => {
|
||||||
|
expect(isSamePath(undefined, undefined)).toBeTruthy();
|
||||||
|
expect(isSamePath(undefined, undefined)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when one undefined', () => {
|
||||||
|
expect(isSamePath('/docs', undefined)).toBeFalsy();
|
||||||
|
expect(isSamePath(undefined, '/docs')).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findHomePageRoute', () => {
|
describe('findHomePageRoute', () => {
|
||||||
const homePage: Route = {
|
const homePage: Route = {
|
||||||
|
|
|
@ -10,47 +10,24 @@ import rangeParser from 'parse-numeric-range';
|
||||||
const codeBlockTitleRegex = /title=(?<quote>["'])(?<title>.*?)\1/;
|
const codeBlockTitleRegex = /title=(?<quote>["'])(?<title>.*?)\1/;
|
||||||
const highlightLinesRangeRegex = /\{(?<range>[\d,-]+)\}/;
|
const highlightLinesRangeRegex = /\{(?<range>[\d,-]+)\}/;
|
||||||
|
|
||||||
const commentTypes = ['js', 'jsBlock', 'jsx', 'python', 'html'] as const;
|
|
||||||
type CommentType = typeof commentTypes[number];
|
|
||||||
|
|
||||||
type CommentPattern = {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Supported types of highlight comments
|
// Supported types of highlight comments
|
||||||
const commentPatterns: Record<CommentType, CommentPattern> = {
|
const commentPatterns = {
|
||||||
js: {
|
js: {start: '\\/\\/', end: ''},
|
||||||
start: '\\/\\/',
|
jsBlock: {start: '\\/\\*', end: '\\*\\/'},
|
||||||
end: '',
|
jsx: {start: '\\{\\s*\\/\\*', end: '\\*\\/\\s*\\}'},
|
||||||
},
|
python: {start: '#', end: ''},
|
||||||
jsBlock: {
|
html: {start: '<!--', end: '-->'},
|
||||||
start: '\\/\\*',
|
|
||||||
end: '\\*\\/',
|
|
||||||
},
|
|
||||||
jsx: {
|
|
||||||
start: '\\{\\s*\\/\\*',
|
|
||||||
end: '\\*\\/\\s*\\}',
|
|
||||||
},
|
|
||||||
python: {
|
|
||||||
start: '#',
|
|
||||||
end: '',
|
|
||||||
},
|
|
||||||
html: {
|
|
||||||
start: '<!--',
|
|
||||||
end: '-->',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommentType = keyof typeof commentPatterns;
|
||||||
|
|
||||||
const magicCommentDirectives = [
|
const magicCommentDirectives = [
|
||||||
'highlight-next-line',
|
'highlight-next-line',
|
||||||
'highlight-start',
|
'highlight-start',
|
||||||
'highlight-end',
|
'highlight-end',
|
||||||
];
|
];
|
||||||
|
|
||||||
const getMagicCommentDirectiveRegex = (
|
function getCommentPattern(languages: CommentType[]) {
|
||||||
languages: readonly CommentType[] = commentTypes,
|
|
||||||
) => {
|
|
||||||
// to be more reliable, the opening and closing comment must match
|
// to be more reliable, the opening and closing comment must match
|
||||||
const commentPattern = languages
|
const commentPattern = languages
|
||||||
.map((lang) => {
|
.map((lang) => {
|
||||||
|
@ -60,38 +37,45 @@ const getMagicCommentDirectiveRegex = (
|
||||||
.join('|');
|
.join('|');
|
||||||
// white space is allowed, but otherwise it should be on it's own line
|
// white space is allowed, but otherwise it should be on it's own line
|
||||||
return new RegExp(`^\\s*(?:${commentPattern})\\s*$`);
|
return new RegExp(`^\\s*(?:${commentPattern})\\s*$`);
|
||||||
};
|
}
|
||||||
|
|
||||||
// select comment styles based on language
|
/**
|
||||||
const magicCommentDirectiveRegex = (lang: string) => {
|
* Select comment styles based on language
|
||||||
|
*/
|
||||||
|
function getAllMagicCommentDirectiveStyles(lang: string) {
|
||||||
switch (lang) {
|
switch (lang) {
|
||||||
case 'js':
|
case 'js':
|
||||||
case 'javascript':
|
case 'javascript':
|
||||||
case 'ts':
|
case 'ts':
|
||||||
case 'typescript':
|
case 'typescript':
|
||||||
return getMagicCommentDirectiveRegex(['js', 'jsBlock']);
|
return getCommentPattern(['js', 'jsBlock']);
|
||||||
|
|
||||||
case 'jsx':
|
case 'jsx':
|
||||||
case 'tsx':
|
case 'tsx':
|
||||||
return getMagicCommentDirectiveRegex(['js', 'jsBlock', 'jsx']);
|
return getCommentPattern(['js', 'jsBlock', 'jsx']);
|
||||||
|
|
||||||
case 'html':
|
case 'html':
|
||||||
return getMagicCommentDirectiveRegex(['js', 'jsBlock', 'html']);
|
return getCommentPattern(['js', 'jsBlock', 'html']);
|
||||||
|
|
||||||
case 'python':
|
case 'python':
|
||||||
case 'py':
|
case 'py':
|
||||||
return getMagicCommentDirectiveRegex(['python']);
|
return getCommentPattern(['python']);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// all comment types
|
// all comment types
|
||||||
return getMagicCommentDirectiveRegex();
|
return getCommentPattern(Object.keys(commentPatterns) as CommentType[]);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
export function parseCodeBlockTitle(metastring?: string): string {
|
export function parseCodeBlockTitle(metastring?: string): string {
|
||||||
return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? '';
|
return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the language name from the class name (set by MDX).
|
||||||
|
* e.g. `"language-javascript"` => `"javascript"`.
|
||||||
|
* Returns undefined if there is no language class name.
|
||||||
|
*/
|
||||||
export function parseLanguage(className: string): string | undefined {
|
export function parseLanguage(className: string): string | undefined {
|
||||||
const languageClassName = className
|
const languageClassName = className
|
||||||
.split(' ')
|
.split(' ')
|
||||||
|
@ -100,15 +84,33 @@ export function parseLanguage(className: string): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param metastring The highlight range declared here starts at 1
|
* Parses the code content, strips away any magic comments, and returns the
|
||||||
* @returns Note: all line numbers start at 0, not 1
|
* clean content and the highlighted lines marked by the comments or metastring.
|
||||||
|
*
|
||||||
|
* If the metastring contains highlight range, the `content` will be returned
|
||||||
|
* as-is without any parsing.
|
||||||
|
*
|
||||||
|
* @param content The raw code with magic comments. Trailing newline will be
|
||||||
|
* trimmed upfront.
|
||||||
|
* @param metastring The full metastring, as received from MDX. Highlight range
|
||||||
|
* declared here starts at 1.
|
||||||
|
* @param language Language of the code block, used to determine which kinds of
|
||||||
|
* magic comment styles to enable.
|
||||||
*/
|
*/
|
||||||
export function parseLines(
|
export function parseLines(
|
||||||
content: string,
|
content: string,
|
||||||
metastring?: string,
|
metastring?: string,
|
||||||
language?: string,
|
language?: string,
|
||||||
): {
|
): {
|
||||||
|
/**
|
||||||
|
* The highlighted lines, 0-indexed. e.g. `[0, 1, 4]` means the 1st, 2nd, and
|
||||||
|
* 5th lines are highlighted.
|
||||||
|
*/
|
||||||
highlightLines: number[];
|
highlightLines: number[];
|
||||||
|
/**
|
||||||
|
* The clean code without any magic comments (only if highlight range isn't
|
||||||
|
* present in the metastring).
|
||||||
|
*/
|
||||||
code: string;
|
code: string;
|
||||||
} {
|
} {
|
||||||
let code = content.replace(/\n$/, '');
|
let code = content.replace(/\n$/, '');
|
||||||
|
@ -124,7 +126,7 @@ export function parseLines(
|
||||||
if (language === undefined) {
|
if (language === undefined) {
|
||||||
return {highlightLines: [], code};
|
return {highlightLines: [], code};
|
||||||
}
|
}
|
||||||
const directiveRegex = magicCommentDirectiveRegex(language);
|
const directiveRegex = getAllMagicCommentDirectiveStyles(language);
|
||||||
// go through line by line
|
// go through line by line
|
||||||
const lines = code.split('\n');
|
const lines = code.split('\n');
|
||||||
let highlightBlockStart: number;
|
let highlightBlockStart: number;
|
||||||
|
|
|
@ -5,57 +5,32 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {type ReactNode, useContext} from 'react';
|
|
||||||
import {
|
import {
|
||||||
useActivePlugin,
|
|
||||||
useAllDocsData,
|
useAllDocsData,
|
||||||
|
useActivePlugin,
|
||||||
} from '@docusaurus/plugin-content-docs/client';
|
} from '@docusaurus/plugin-content-docs/client';
|
||||||
import type {
|
import type {
|
||||||
PropSidebar,
|
PropSidebar,
|
||||||
PropSidebarItem,
|
PropSidebarItem,
|
||||||
PropSidebarItemCategory,
|
PropSidebarItemCategory,
|
||||||
PropVersionDoc,
|
PropVersionDoc,
|
||||||
PropVersionMetadata,
|
|
||||||
PropSidebarBreadcrumbsItem,
|
PropSidebarBreadcrumbsItem,
|
||||||
} from '@docusaurus/plugin-content-docs';
|
} from '@docusaurus/plugin-content-docs';
|
||||||
import {isSamePath} from './pathUtils';
|
import {useDocsVersion} from '../contexts/docsVersion';
|
||||||
import {ReactContextError} from './reactUtils';
|
import {useDocsSidebar} from '../contexts/docsSidebar';
|
||||||
|
import {isSamePath} from './routesUtils';
|
||||||
import {useLocation} from '@docusaurus/router';
|
import {useLocation} from '@docusaurus/router';
|
||||||
|
|
||||||
// TODO not ideal, see also "useDocs"
|
// TODO not ideal, see also "useDocs"
|
||||||
export const isDocsPluginEnabled: boolean = !!useAllDocsData;
|
export const isDocsPluginEnabled: boolean = !!useAllDocsData;
|
||||||
|
|
||||||
// Using a Symbol because null is a valid context value (a doc with no sidebar)
|
/**
|
||||||
// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx
|
* A null-safe way to access a doc's data by ID in the active version.
|
||||||
const EmptyContextValue: unique symbol = Symbol('EmptyContext');
|
*/
|
||||||
|
|
||||||
const DocsVersionContext = React.createContext<
|
|
||||||
PropVersionMetadata | typeof EmptyContextValue
|
|
||||||
>(EmptyContextValue);
|
|
||||||
|
|
||||||
export function DocsVersionProvider({
|
|
||||||
children,
|
|
||||||
version,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
version: PropVersionMetadata | typeof EmptyContextValue;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
|
||||||
<DocsVersionContext.Provider value={version}>
|
|
||||||
{children}
|
|
||||||
</DocsVersionContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDocsVersion(): PropVersionMetadata {
|
|
||||||
const version = useContext(DocsVersionContext);
|
|
||||||
if (version === EmptyContextValue) {
|
|
||||||
throw new ReactContextError('DocsVersionProvider');
|
|
||||||
}
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDocById(id: string): PropVersionDoc;
|
export function useDocById(id: string): PropVersionDoc;
|
||||||
|
/**
|
||||||
|
* A null-safe way to access a doc's data by ID in the active version.
|
||||||
|
*/
|
||||||
export function useDocById(id: string | undefined): PropVersionDoc | undefined;
|
export function useDocById(id: string | undefined): PropVersionDoc | undefined;
|
||||||
export function useDocById(id: string | undefined): PropVersionDoc | undefined {
|
export function useDocById(id: string | undefined): PropVersionDoc | undefined {
|
||||||
const version = useDocsVersion();
|
const version = useDocsVersion();
|
||||||
|
@ -69,34 +44,9 @@ export function useDocById(id: string | undefined): PropVersionDoc | undefined {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocsSidebarContext = React.createContext<
|
/**
|
||||||
PropSidebar | null | typeof EmptyContextValue
|
* Pure function, similar to `Array#find`, but works on the sidebar tree.
|
||||||
>(EmptyContextValue);
|
*/
|
||||||
|
|
||||||
export function DocsSidebarProvider({
|
|
||||||
children,
|
|
||||||
sidebar,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
sidebar: PropSidebar | null;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
|
||||||
<DocsSidebarContext.Provider value={sidebar}>
|
|
||||||
{children}
|
|
||||||
</DocsSidebarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDocsSidebar(): PropSidebar | null {
|
|
||||||
const sidebar = useContext(DocsSidebarContext);
|
|
||||||
if (sidebar === EmptyContextValue) {
|
|
||||||
throw new ReactContextError('DocsSidebarProvider');
|
|
||||||
}
|
|
||||||
return sidebar;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the components props and the sidebar in context
|
|
||||||
// to get back the related sidebar category that we want to render
|
|
||||||
export function findSidebarCategory(
|
export function findSidebarCategory(
|
||||||
sidebar: PropSidebar,
|
sidebar: PropSidebar,
|
||||||
predicate: (category: PropSidebarItemCategory) => boolean,
|
predicate: (category: PropSidebarItemCategory) => boolean,
|
||||||
|
@ -115,7 +65,10 @@ export function findSidebarCategory(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a category card has no link => link to the first subItem having a link
|
/**
|
||||||
|
* Best effort to assign a link to a sidebar category. If the category doesn't
|
||||||
|
* have a link itself, we link to the first sub item with a link.
|
||||||
|
*/
|
||||||
export function findFirstCategoryLink(
|
export function findFirstCategoryLink(
|
||||||
item: PropSidebarItemCategory,
|
item: PropSidebarItemCategory,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
@ -142,6 +95,10 @@ export function findFirstCategoryLink(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the category associated with the current location. Should only be used
|
||||||
|
* on category index pages.
|
||||||
|
*/
|
||||||
export function useCurrentSidebarCategory(): PropSidebarItemCategory {
|
export function useCurrentSidebarCategory(): PropSidebarItemCategory {
|
||||||
const {pathname} = useLocation();
|
const {pathname} = useLocation();
|
||||||
const sidebar = useDocsSidebar();
|
const sidebar = useDocsSidebar();
|
||||||
|
@ -153,47 +110,53 @@ export function useCurrentSidebarCategory(): PropSidebarItemCategory {
|
||||||
);
|
);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unexpected: sidebar category could not be found for pathname='${pathname}'.
|
`${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`,
|
||||||
Hook useCurrentSidebarCategory() should only be used on Category pages`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return category;
|
return category;
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsActiveSidebarItem(
|
const isActive = (testedPath: string | undefined, activePath: string) =>
|
||||||
|
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
|
||||||
|
const containsActiveSidebarItem = (
|
||||||
items: PropSidebarItem[],
|
items: PropSidebarItem[],
|
||||||
activePath: string,
|
activePath: string,
|
||||||
): boolean {
|
) => items.some((subItem) => isActiveSidebarItem(subItem, activePath));
|
||||||
return items.some((subItem) => isActiveSidebarItem(subItem, activePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a sidebar item should be active, based on the active path.
|
||||||
|
*/
|
||||||
export function isActiveSidebarItem(
|
export function isActiveSidebarItem(
|
||||||
item: PropSidebarItem,
|
item: PropSidebarItem,
|
||||||
activePath: string,
|
activePath: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const isActive = (testedPath: string | undefined) =>
|
|
||||||
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
|
|
||||||
|
|
||||||
if (item.type === 'link') {
|
if (item.type === 'link') {
|
||||||
return isActive(item.href);
|
return isActive(item.href, activePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'category') {
|
if (item.type === 'category') {
|
||||||
return (
|
return (
|
||||||
isActive(item.href) || containsActiveSidebarItem(item.items, activePath)
|
isActive(item.href, activePath) ||
|
||||||
|
containsActiveSidebarItem(item.items, activePath)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBreadcrumbs({
|
/**
|
||||||
sidebar,
|
* Gets the breadcrumbs of the current doc page, based on its sidebar location.
|
||||||
pathname,
|
* Returns `null` if there's no sidebar or breadcrumbs are disabled.
|
||||||
}: {
|
*/
|
||||||
sidebar: PropSidebar;
|
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
|
||||||
pathname: string;
|
const sidebar = useDocsSidebar();
|
||||||
}): PropSidebarBreadcrumbsItem[] {
|
const {pathname} = useLocation();
|
||||||
|
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
|
||||||
|
|
||||||
|
if (breadcrumbsOption === false || !sidebar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
|
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
|
||||||
|
|
||||||
function extract(items: PropSidebar) {
|
function extract(items: PropSidebar) {
|
||||||
|
@ -215,15 +178,3 @@ function getBreadcrumbs({
|
||||||
|
|
||||||
return breadcrumbs.reverse();
|
return breadcrumbs.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
|
|
||||||
const sidebar = useDocsSidebar();
|
|
||||||
const {pathname} = useLocation();
|
|
||||||
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
|
|
||||||
|
|
||||||
if (breadcrumbsOption === false || !sidebar) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBreadcrumbs({sidebar, pathname});
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,6 +7,10 @@
|
||||||
|
|
||||||
import type {MultiColumnFooter, SimpleFooter} from './useThemeConfig';
|
import type {MultiColumnFooter, SimpleFooter} from './useThemeConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rough duck-typing about whether the `footer.links` is intended to be multi-
|
||||||
|
* column.
|
||||||
|
*/
|
||||||
export function isMultiColumnFooterLinks(
|
export function isMultiColumnFooterLinks(
|
||||||
links: MultiColumnFooter['links'] | SimpleFooter['links'],
|
links: MultiColumnFooter['links'] | SimpleFooter['links'],
|
||||||
): links is MultiColumnFooter['links'] {
|
): links is MultiColumnFooter['links'] {
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the page's title based on relevant site config and other contexts.
|
||||||
|
*/
|
||||||
export function useTitleFormatter(title?: string | undefined): string {
|
export function useTitleFormatter(title?: string | undefined): string {
|
||||||
const {siteConfig} = useDocusaurusContext();
|
const {siteConfig} = useDocusaurusContext();
|
||||||
const {title: siteTitle, titleDelimiter} = siteConfig;
|
const {title: siteTitle, titleDelimiter} = siteConfig;
|
||||||
|
|
|
@ -5,44 +5,38 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useEffect, useRef} from 'react';
|
import {useEffect} from 'react';
|
||||||
import {useHistory} from '@docusaurus/router';
|
import {useHistory} from '@docusaurus/router';
|
||||||
|
import {useDynamicCallback} from './reactUtils';
|
||||||
import type {Location, Action} from 'history';
|
import type {Location, Action} from 'history';
|
||||||
|
|
||||||
type HistoryBlockHandler = (location: Location, action: Action) => void | false;
|
type HistoryBlockHandler = (location: Location, action: Action) => void | false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permits to register a handler that will be called on history actions (pop,
|
* Permits to register a handler that will be called on history actions (pop,
|
||||||
* push, replace) If the handler returns false, the navigation transition will
|
* push, replace). If the handler returns `false`, the navigation transition
|
||||||
* be blocked/cancelled
|
* will be blocked/cancelled.
|
||||||
*/
|
*/
|
||||||
export function useHistoryActionHandler(handler: HistoryBlockHandler): void {
|
function useHistoryActionHandler(handler: HistoryBlockHandler): void {
|
||||||
const {block} = useHistory();
|
const {block} = useHistory();
|
||||||
|
const stableHandler = useDynamicCallback(handler);
|
||||||
// Avoid stale closure issues without triggering useless re-renders
|
|
||||||
const lastHandlerRef = useRef(handler);
|
|
||||||
useEffect(() => {
|
|
||||||
lastHandlerRef.current = handler;
|
|
||||||
}, [handler]);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
// See https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md
|
||||||
// See https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md
|
() => block((location, action) => stableHandler(location, action)),
|
||||||
block((location, action) => lastHandlerRef.current(location, action)),
|
[block, stableHandler],
|
||||||
[block, lastHandlerRef],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permits to register a handler that will be called on history pop navigation
|
* Permits to register a handler that will be called on history pop navigation
|
||||||
* (backward/forward) If the handler returns false, the backward/forward
|
* (backward/forward). If the handler returns `false`, the backward/forward
|
||||||
* transition will be blocked. Unfortunately there's no good way to detect the
|
* transition will be blocked. Unfortunately there's no good way to detect the
|
||||||
* "direction" (backward/forward) of the POP event.
|
* "direction" (backward/forward) of the POP event.
|
||||||
*/
|
*/
|
||||||
export function useHistoryPopHandler(handler: HistoryBlockHandler): void {
|
export function useHistoryPopHandler(handler: HistoryBlockHandler): void {
|
||||||
useHistoryActionHandler((location, action) => {
|
useHistoryActionHandler((location, action) => {
|
||||||
if (action === 'POP') {
|
if (action === 'POP') {
|
||||||
// Eventually block navigation if handler returns false
|
// Maybe block navigation if handler returns false
|
||||||
return handler(location, action);
|
return handler(location, action);
|
||||||
}
|
}
|
||||||
// Don't block other navigation actions
|
// Don't block other navigation actions
|
||||||
|
|
|
@ -26,7 +26,7 @@ export function duplicates<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove duplicate array items (similar to _.uniq)
|
* Remove duplicate array items (similar to `_.uniq`)
|
||||||
* @param arr The array.
|
* @param arr The array.
|
||||||
* @returns An array with duplicate elements removed by reference comparison.
|
* @returns An array with duplicate elements removed by reference comparison.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -20,7 +20,10 @@ interface PageMetadataProps {
|
||||||
readonly children?: ReactNode;
|
readonly children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper component to manipulate page metadata and override site defaults
|
/**
|
||||||
|
* Helper component to manipulate page metadata and override site defaults.
|
||||||
|
* Works in the same way as Helmet.
|
||||||
|
*/
|
||||||
export function PageMetadata({
|
export function PageMetadata({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
@ -44,6 +47,7 @@ export function PageMetadata({
|
||||||
<meta
|
<meta
|
||||||
name="keywords"
|
name="keywords"
|
||||||
content={
|
content={
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/17002
|
||||||
(Array.isArray(keywords) ? keywords.join(',') : keywords) as string
|
(Array.isArray(keywords) ? keywords.join(',') : keywords) as string
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -59,8 +63,12 @@ export function PageMetadata({
|
||||||
|
|
||||||
const HtmlClassNameContext = React.createContext<string | undefined>(undefined);
|
const HtmlClassNameContext = React.createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
// This wrapper is necessary because Helmet does not "merge" classes
|
/**
|
||||||
// See https://github.com/staylor/react-helmet-async/issues/161
|
* Every layer of this provider will append a class name to the HTML element.
|
||||||
|
* There's no consumer for this hook: it's side-effect-only. This wrapper is
|
||||||
|
* necessary because Helmet does not "merge" classes.
|
||||||
|
* @see https://github.com/staylor/react-helmet-async/issues/161
|
||||||
|
*/
|
||||||
export function HtmlClassNameProvider({
|
export function HtmlClassNameProvider({
|
||||||
className: classNameProp,
|
className: classNameProp,
|
||||||
children,
|
children,
|
||||||
|
@ -87,6 +95,10 @@ function pluginNameToClassName(pluginName: string) {
|
||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A very thin wrapper around `HtmlClassNameProvider` that adds the plugin ID +
|
||||||
|
* name to the HTML class name.
|
||||||
|
*/
|
||||||
export function PluginHtmlClassNameProvider({
|
export function PluginHtmlClassNameProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import React, {type ReactNode} from 'react';
|
import React, {type ReactNode} from 'react';
|
||||||
import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar';
|
import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar';
|
||||||
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenu';
|
import {NavbarSecondaryMenuProvider} from '../contexts/navbarSecondaryMenu';
|
||||||
|
|
||||||
const DefaultNavItemPosition = 'right';
|
const DefaultNavItemPosition = 'right';
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -9,11 +9,12 @@ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
|
||||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This hook is like useLayoutEffect, but without the SSR warning
|
* This hook is like `useLayoutEffect`, but without the SSR warning.
|
||||||
* It seems hacky but it's used in many React libs (Redux, Formik...)
|
* It seems hacky but it's used in many React libs (Redux, Formik...).
|
||||||
* Also mentioned here: https://github.com/facebook/react/issues/16956
|
* Also mentioned here: https://github.com/facebook/react/issues/16956
|
||||||
|
*
|
||||||
* It is useful when you need to update a ref as soon as possible after a React
|
* It is useful when you need to update a ref as soon as possible after a React
|
||||||
* render (before `useEffect`)
|
* render (before `useEffect`).
|
||||||
*/
|
*/
|
||||||
export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
|
export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
|
||||||
? useLayoutEffect
|
? useLayoutEffect
|
||||||
|
@ -23,10 +24,11 @@ export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
|
||||||
* Permits to transform an unstable callback (like an arrow function provided as
|
* Permits to transform an unstable callback (like an arrow function provided as
|
||||||
* props) to a "stable" callback that is safe to use in a `useEffect` dependency
|
* props) to a "stable" callback that is safe to use in a `useEffect` dependency
|
||||||
* array. Useful to avoid React stale closure problems + avoid useless effect
|
* array. Useful to avoid React stale closure problems + avoid useless effect
|
||||||
* re-executions
|
* re-executions.
|
||||||
*
|
*
|
||||||
* Workaround until the React team recommends a good solution, see
|
* Workaround until the React team recommends a good solution, see
|
||||||
* https://github.com/facebook/react/issues/16956
|
* https://github.com/facebook/react/issues/16956
|
||||||
|
*
|
||||||
* This generally works but has some potential drawbacks, such as
|
* This generally works but has some potential drawbacks, such as
|
||||||
* https://github.com/facebook/react/issues/16956#issuecomment-536636418
|
* https://github.com/facebook/react/issues/16956#issuecomment-536636418
|
||||||
*/
|
*/
|
||||||
|
@ -44,6 +46,9 @@ export function useDynamicCallback<T extends (...args: never[]) => unknown>(
|
||||||
return useCallback<T>((...args) => ref.current(...args), []);
|
return useCallback<T>((...args) => ref.current(...args), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets `value` from the last render.
|
||||||
|
*/
|
||||||
export function usePrevious<T>(value: T): T | undefined {
|
export function usePrevious<T>(value: T): T | undefined {
|
||||||
const ref = useRef<T>();
|
const ref = useRef<T>();
|
||||||
|
|
||||||
|
@ -54,6 +59,11 @@ export function usePrevious<T>(value: T): T | undefined {
|
||||||
return ref.current;
|
return ref.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This error is thrown when a context is consumed outside its provider. Allows
|
||||||
|
* reusing a generic error message format and reduces bundle size. The hook's
|
||||||
|
* name will be extracted from its stack, so only the provider's name is needed.
|
||||||
|
*/
|
||||||
export class ReactContextError extends Error {
|
export class ReactContextError extends Error {
|
||||||
constructor(providerName: string, additionalInfo?: string) {
|
constructor(providerName: string, additionalInfo?: string) {
|
||||||
super();
|
super();
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an optional string into a Regex case insensitive and global
|
* Matches a string regex (as provided from the config) against a target in a
|
||||||
|
* null-safe fashion, case insensitive and global.
|
||||||
*/
|
*/
|
||||||
export function isRegexpStringMatch(
|
export function isRegexpStringMatch(
|
||||||
regexAsString?: string,
|
regexAsString?: string,
|
||||||
|
|
|
@ -5,11 +5,26 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import generatedRoutes from '@generated/routes';
|
|
||||||
import {useMemo} from 'react';
|
import {useMemo} from 'react';
|
||||||
|
import generatedRoutes from '@generated/routes';
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
import type {Route} from '@docusaurus/types';
|
import type {Route} from '@docusaurus/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare the 2 paths, case insensitive and ignoring trailing slash
|
||||||
|
*/
|
||||||
|
export function isSamePath(
|
||||||
|
path1: string | undefined,
|
||||||
|
path2: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
const normalize = (pathname: string | undefined) =>
|
||||||
|
(!pathname || pathname?.endsWith('/')
|
||||||
|
? pathname
|
||||||
|
: `${pathname}/`
|
||||||
|
)?.toLowerCase();
|
||||||
|
return normalize(path1) === normalize(path2);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note that sites don't always have a homepage in practice, so we can't assume
|
* Note that sites don't always have a homepage in practice, so we can't assume
|
||||||
* that linking to '/' is always safe.
|
* that linking to '/' is always safe.
|
||||||
|
@ -47,14 +62,14 @@ export function findHomePageRoute({
|
||||||
return doFindHomePageRoute(initialRoutes);
|
return doFindHomePageRoute(initialRoutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the route that points to "/". Use this instead of the naive "/",
|
||||||
|
* because the homepage may not exist.
|
||||||
|
*/
|
||||||
export function useHomePageRoute(): Route | undefined {
|
export function useHomePageRoute(): Route | undefined {
|
||||||
const {baseUrl} = useDocusaurusContext().siteConfig;
|
const {baseUrl} = useDocusaurusContext().siteConfig;
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() => findHomePageRoute({routes: generatedRoutes, baseUrl}),
|
||||||
findHomePageRoute({
|
|
||||||
routes: generatedRoutes,
|
|
||||||
baseUrl,
|
|
||||||
}),
|
|
||||||
[baseUrl],
|
[baseUrl],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,25 +17,12 @@ import React, {
|
||||||
import {useDynamicCallback, ReactContextError} from './reactUtils';
|
import {useDynamicCallback, ReactContextError} from './reactUtils';
|
||||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||||
|
|
||||||
/**
|
|
||||||
* We need a way to update the scroll position while ignoring scroll events
|
|
||||||
* without affecting Navbar/BackToTop visibility
|
|
||||||
*
|
|
||||||
* This API permits to temporarily disable/ignore scroll events
|
|
||||||
* Motivated by https://github.com/facebook/docusaurus/pull/5618
|
|
||||||
*/
|
|
||||||
type ScrollController = {
|
type ScrollController = {
|
||||||
/**
|
/** A boolean ref tracking whether scroll events are enabled. */
|
||||||
* A boolean ref tracking whether scroll events are enabled
|
|
||||||
*/
|
|
||||||
scrollEventsEnabledRef: React.MutableRefObject<boolean>;
|
scrollEventsEnabledRef: React.MutableRefObject<boolean>;
|
||||||
/**
|
/** Enable scroll events in `useScrollPosition`. */
|
||||||
* Enables scroll events in `useScrollPosition`
|
|
||||||
*/
|
|
||||||
enableScrollEvents: () => void;
|
enableScrollEvents: () => void;
|
||||||
/**
|
/** Disable scroll events in `useScrollPosition`. */
|
||||||
* Disables scroll events in `useScrollPosition`
|
|
||||||
*/
|
|
||||||
disableScrollEvents: () => void;
|
disableScrollEvents: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -65,13 +52,21 @@ export function ScrollControllerProvider({
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const value = useScrollControllerContextValue();
|
||||||
return (
|
return (
|
||||||
<ScrollMonitorContext.Provider value={useScrollControllerContextValue()}>
|
<ScrollMonitorContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ScrollMonitorContext.Provider>
|
</ScrollMonitorContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need a way to update the scroll position while ignoring scroll events
|
||||||
|
* so as not to toggle Navbar/BackToTop visibility.
|
||||||
|
*
|
||||||
|
* This API permits to temporarily disable/ignore scroll events. Motivated by
|
||||||
|
* https://github.com/facebook/docusaurus/pull/5618
|
||||||
|
*/
|
||||||
export function useScrollController(): ScrollController {
|
export function useScrollController(): ScrollController {
|
||||||
const context = useContext(ScrollMonitorContext);
|
const context = useContext(ScrollMonitorContext);
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
|
@ -80,6 +75,8 @@ export function useScrollController(): ScrollController {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScrollPosition = {scrollX: number; scrollY: number};
|
||||||
|
|
||||||
const getScrollPosition = (): ScrollPosition | null =>
|
const getScrollPosition = (): ScrollPosition | null =>
|
||||||
ExecutionEnvironment.canUseDOM
|
ExecutionEnvironment.canUseDOM
|
||||||
? {
|
? {
|
||||||
|
@ -88,8 +85,14 @@ const getScrollPosition = (): ScrollPosition | null =>
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
type ScrollPosition = {scrollX: number; scrollY: number};
|
/**
|
||||||
|
* This hook fires an effect when the scroll position changes. The effect will
|
||||||
|
* be provided with the before/after scroll positions. Note that the effect may
|
||||||
|
* not be always run: if scrolling is disabled through `useScrollController`, it
|
||||||
|
* will be a no-op.
|
||||||
|
*
|
||||||
|
* @see {@link useScrollController}
|
||||||
|
*/
|
||||||
export function useScrollPosition(
|
export function useScrollPosition(
|
||||||
effect: (
|
effect: (
|
||||||
position: ScrollPosition,
|
position: ScrollPosition,
|
||||||
|
@ -124,22 +127,16 @@ export function useScrollPosition(
|
||||||
window.addEventListener('scroll', handleScroll, opts);
|
window.addEventListener('scroll', handleScroll, opts);
|
||||||
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll, opts);
|
return () => window.removeEventListener('scroll', handleScroll, opts);
|
||||||
}, [
|
|
||||||
dynamicEffect,
|
|
||||||
scrollEventsEnabledRef,
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
...deps,
|
}, [dynamicEffect, scrollEventsEnabledRef, ...deps]);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseScrollPositionSaver = {
|
type UseScrollPositionSaver = {
|
||||||
/**
|
/** Measure the top of an element, and store the details. */
|
||||||
* Measure the top of an element, and store the details
|
|
||||||
*/
|
|
||||||
save: (elem: HTMLElement) => void;
|
save: (elem: HTMLElement) => void;
|
||||||
/**
|
/**
|
||||||
* Restore the page position to keep the stored element's position from
|
* Restore the page position to keep the stored element's position from
|
||||||
* the top of the viewport, and remove the stored details
|
* the top of the viewport, and remove the stored details.
|
||||||
*/
|
*/
|
||||||
restore: () => {restored: boolean};
|
restore: () => {restored: boolean};
|
||||||
};
|
};
|
||||||
|
@ -177,21 +174,24 @@ function useScrollPositionSaver(): UseScrollPositionSaver {
|
||||||
return useMemo(() => ({save, restore}), [restore, save]);
|
return useMemo(() => ({save, restore}), [restore, save]);
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseScrollPositionBlockerReturn = {
|
|
||||||
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This hook permits to "block" the scroll position of a dom element
|
* This hook permits to "block" the scroll position of a DOM element.
|
||||||
* The idea is that we should be able to update DOM content above this element
|
* The idea is that we should be able to update DOM content above this element
|
||||||
* but the screen position of this element should not change
|
* but the screen position of this element should not change.
|
||||||
*
|
*
|
||||||
* Feature motivated by the Tabs groups:
|
* Feature motivated by the Tabs groups: clicking on a tab may affect tabs of
|
||||||
* clicking on a tab may affect tabs of the same group upper in the tree
|
* the same group upper in the tree, yet to avoid a bad UX, the clicked tab must
|
||||||
* Yet to avoid a bad UX, the clicked tab must remain under the user mouse!
|
* remain under the user mouse.
|
||||||
* See GIF here: https://github.com/facebook/docusaurus/pull/5618
|
*
|
||||||
|
* @see https://github.com/facebook/docusaurus/pull/5618
|
||||||
*/
|
*/
|
||||||
export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
|
export function useScrollPositionBlocker(): {
|
||||||
|
/**
|
||||||
|
* Takes an element, and keeps its screen position no matter what's getting
|
||||||
|
* rendered above it, until the next render.
|
||||||
|
*/
|
||||||
|
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
|
||||||
|
} {
|
||||||
const scrollController = useScrollController();
|
const scrollController = useScrollController();
|
||||||
const scrollPositionSaver = useScrollPositionSaver();
|
const scrollPositionSaver = useScrollPositionSaver();
|
||||||
|
|
||||||
|
@ -207,9 +207,9 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
|
||||||
const {restored} = scrollPositionSaver.restore();
|
const {restored} = scrollPositionSaver.restore();
|
||||||
nextLayoutEffectCallbackRef.current = undefined;
|
nextLayoutEffectCallbackRef.current = undefined;
|
||||||
|
|
||||||
// Restoring the former scroll position will trigger a scroll event
|
// Restoring the former scroll position will trigger a scroll event. We
|
||||||
// We need to wait for next scroll event to happen
|
// need to wait for next scroll event to happen before enabling the
|
||||||
// before enabling again the scrollController events
|
// scrollController events again.
|
||||||
if (restored) {
|
if (restored) {
|
||||||
const handleScrollRestoreEvent = () => {
|
const handleScrollRestoreEvent = () => {
|
||||||
scrollController.enableScrollEvents();
|
scrollController.enableScrollEvents();
|
||||||
|
|
|
@ -5,11 +5,60 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useAllDocsData,
|
||||||
|
useActivePluginAndVersion,
|
||||||
|
} from '@docusaurus/plugin-content-docs/client';
|
||||||
|
import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersion';
|
||||||
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
|
|
||||||
export const DEFAULT_SEARCH_TAG = 'default';
|
export const DEFAULT_SEARCH_TAG = 'default';
|
||||||
|
|
||||||
|
/** The search tag to append as each doc's metadata. */
|
||||||
export function docVersionSearchTag(
|
export function docVersionSearchTag(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
versionName: string,
|
versionName: string,
|
||||||
): string {
|
): string {
|
||||||
return `docs-${pluginId}-${versionName}`;
|
return `docs-${pluginId}-${versionName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the relevant context information for contextual search.
|
||||||
|
*
|
||||||
|
* The value is generic and not coupled to Algolia/DocSearch, since we may want
|
||||||
|
* to support multiple search engines, or allowing users to use their own search
|
||||||
|
* engine solution.
|
||||||
|
*/
|
||||||
|
export function useContextualSearchFilters(): {locale: string; tags: string[]} {
|
||||||
|
const {i18n} = useDocusaurusContext();
|
||||||
|
const allDocsData = useAllDocsData();
|
||||||
|
const activePluginAndVersion = useActivePluginAndVersion();
|
||||||
|
const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId();
|
||||||
|
|
||||||
|
function getDocPluginTags(pluginId: string) {
|
||||||
|
const activeVersion =
|
||||||
|
activePluginAndVersion?.activePlugin?.pluginId === pluginId
|
||||||
|
? activePluginAndVersion.activeVersion
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const preferredVersion = docsPreferredVersionByPluginId[pluginId];
|
||||||
|
|
||||||
|
const latestVersion = allDocsData[pluginId]!.versions.find(
|
||||||
|
(v) => v.isLast,
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const version = activeVersion ?? preferredVersion ?? latestVersion;
|
||||||
|
|
||||||
|
return docVersionSearchTag(pluginId, version.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = [
|
||||||
|
DEFAULT_SEARCH_TAG,
|
||||||
|
...Object.keys(allDocsData).map(getDocPluginTags),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: i18n.currentLocale,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -11,8 +11,12 @@ export type StorageType = typeof StorageTypes[number];
|
||||||
|
|
||||||
const DefaultStorageType: StorageType = 'localStorage';
|
const DefaultStorageType: StorageType = 'localStorage';
|
||||||
|
|
||||||
// Will return null browser storage is unavailable (like running Docusaurus in
|
/**
|
||||||
// iframe) See https://github.com/facebook/docusaurus/pull/4501
|
* Will return `null` if browser storage is unavailable (like running Docusaurus
|
||||||
|
* in an iframe). This should NOT be called in SSR.
|
||||||
|
*
|
||||||
|
* @see https://github.com/facebook/docusaurus/pull/4501
|
||||||
|
*/
|
||||||
function getBrowserStorage(
|
function getBrowserStorage(
|
||||||
storageType: StorageType = DefaultStorageType,
|
storageType: StorageType = DefaultStorageType,
|
||||||
): Storage | null {
|
): Storage | null {
|
||||||
|
@ -32,11 +36,12 @@ function getBrowserStorage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Poor man's memoization to avoid logging multiple times the same warning
|
|
||||||
* Sometimes, localStorage/sessionStorage is unavailable due to browser policies
|
|
||||||
*/
|
|
||||||
let hasLoggedBrowserStorageNotAvailableWarning = false;
|
let hasLoggedBrowserStorageNotAvailableWarning = false;
|
||||||
|
/**
|
||||||
|
* Poor man's memoization to avoid logging multiple times the same warning.
|
||||||
|
* Sometimes, `localStorage`/`sessionStorage` is unavailable due to browser
|
||||||
|
* policies.
|
||||||
|
*/
|
||||||
function logOnceBrowserStorageNotAvailableWarning(error: Error) {
|
function logOnceBrowserStorageNotAvailableWarning(error: Error) {
|
||||||
if (!hasLoggedBrowserStorageNotAvailableWarning) {
|
if (!hasLoggedBrowserStorageNotAvailableWarning) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
@ -61,7 +66,7 @@ const NoopStorageSlot: StorageSlot = {
|
||||||
del: () => {},
|
del: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fail-fast, as storage APIs should not be used during the SSR process
|
// Fail-fast, as storage APIs should not be used during the SSR process
|
||||||
function createServerStorageSlot(key: string): StorageSlot {
|
function createServerStorageSlot(key: string): StorageSlot {
|
||||||
function throwError(): never {
|
function throwError(): never {
|
||||||
throw new Error(`Illegal storage API usage for storage key "${key}".
|
throw new Error(`Illegal storage API usage for storage key "${key}".
|
||||||
|
@ -77,16 +82,19 @@ Please only call storage APIs in effects and event handlers.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an object for accessing a particular key in localStorage.
|
* Creates an interface to work on a particular key in the storage model.
|
||||||
* The API is fail-safe, and usage of browser storage should be considered
|
* Note that this function only initializes the interface, but doesn't allocate
|
||||||
|
* anything by itself (i.e. no side-effects).
|
||||||
|
*
|
||||||
|
* The API is fail-safe, since usage of browser storage should be considered
|
||||||
* unreliable. Local storage might simply be unavailable (iframe + browser
|
* unreliable. Local storage might simply be unavailable (iframe + browser
|
||||||
* security) or operations might fail individually. Please assume that using
|
* security) or operations might fail individually. Please assume that using
|
||||||
* this API can be a NO-OP. See also https://github.com/facebook/docusaurus/issues/6036
|
* this API can be a no-op. See also https://github.com/facebook/docusaurus/issues/6036
|
||||||
*/
|
*/
|
||||||
export const createStorageSlot = (
|
export function createStorageSlot(
|
||||||
key: string,
|
key: string,
|
||||||
options?: {persistence?: StorageType},
|
options?: {persistence?: StorageType},
|
||||||
): StorageSlot => {
|
): StorageSlot {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return createServerStorageSlot(key);
|
return createServerStorageSlot(key);
|
||||||
}
|
}
|
||||||
|
@ -121,10 +129,10 @@ export const createStorageSlot = (
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of all the keys currently stored in browser storage
|
* Returns a list of all the keys currently stored in browser storage,
|
||||||
* or an empty list if browser storage can't be accessed.
|
* or an empty list if browser storage can't be accessed.
|
||||||
*/
|
*/
|
||||||
export function listStorageKeys(
|
export function listStorageKeys(
|
||||||
|
|
|
@ -26,10 +26,13 @@ function getTagLetter(tag: string): string {
|
||||||
return tag[0]!.toUpperCase();
|
return tag[0]!.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a list of tags (as provided by the content plugins), and groups them by
|
||||||
|
* their initials.
|
||||||
|
*/
|
||||||
export function listTagsByLetters(
|
export function listTagsByLetters(
|
||||||
tags: readonly TagsListItem[],
|
tags: readonly TagsListItem[],
|
||||||
): TagLetterEntry[] {
|
): TagLetterEntry[] {
|
||||||
// Group by letters
|
|
||||||
const groups: Record<string, TagsListItem[]> = {};
|
const groups: Record<string, TagsListItem[]> = {};
|
||||||
Object.values(tags).forEach((tag) => {
|
Object.values(tags).forEach((tag) => {
|
||||||
const letter = getTagLetter(tag.name);
|
const letter = getTagLetter(tag.name);
|
||||||
|
|
|
@ -52,6 +52,10 @@ function treeifyTOC(flatTOC: readonly TOCItem[]): TOCTreeNode[] {
|
||||||
return rootNodes;
|
return rootNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a flat TOC list (from the MDX loader) and treeifies it into what the
|
||||||
|
* TOC components expect. Memoized for performance.
|
||||||
|
*/
|
||||||
export function useTreeifiedTOC(toc: TOCItem[]): readonly TOCTreeNode[] {
|
export function useTreeifiedTOC(toc: TOCItem[]): readonly TOCTreeNode[] {
|
||||||
return useMemo(() => treeifyTOC(toc), [toc]);
|
return useMemo(() => treeifyTOC(toc), [toc]);
|
||||||
}
|
}
|
||||||
|
@ -87,6 +91,18 @@ function filterTOC({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a flat TOC list (from the MDX loader) and treeifies it into what the
|
||||||
|
* TOC components expect, applying the `minHeadingLevel` and `maxHeadingLevel`.
|
||||||
|
* Memoized for performance.
|
||||||
|
*
|
||||||
|
* **Important**: this is not the same as `useTreeifiedTOC(toc.filter(...))`,
|
||||||
|
* because we have to filter the TOC after it has been treeified. This is mostly
|
||||||
|
* to ensure that weird TOC structures preserve their semantics. For example, an
|
||||||
|
* h3-h2-h4 sequence should not be treeified as an "h3 > h4" hierarchy with
|
||||||
|
* min=3, max=4, but should rather be "[h3, h4]" (since the h2 heading has split
|
||||||
|
* the two headings and they are not parents)
|
||||||
|
*/
|
||||||
export function useFilteredAndTreeifiedTOC({
|
export function useFilteredAndTreeifiedTOC({
|
||||||
toc,
|
toc,
|
||||||
minHeadingLevel,
|
minHeadingLevel,
|
||||||
|
@ -97,12 +113,7 @@ export function useFilteredAndTreeifiedTOC({
|
||||||
maxHeadingLevel: number;
|
maxHeadingLevel: number;
|
||||||
}): readonly TOCTreeNode[] {
|
}): readonly TOCTreeNode[] {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() => filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}),
|
||||||
// Note: we have to filter the TOC after it has been treeified. This is
|
|
||||||
// mostly to ensure that weird TOC structures preserve their semantics.
|
|
||||||
// For example, an h3-h2-h4 sequence should not be treeified as an h3 > h4
|
|
||||||
// hierarchy with min=3, max=4, but should rather be [h3, h4]
|
|
||||||
filterTOC({toc: treeifyTOC(toc), minHeadingLevel, maxHeadingLevel}),
|
|
||||||
[toc, minHeadingLevel, maxHeadingLevel],
|
[toc, minHeadingLevel, maxHeadingLevel],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,26 @@
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
import {useLocation} from '@docusaurus/router';
|
import {useLocation} from '@docusaurus/router';
|
||||||
|
|
||||||
// Permits to obtain the url of the current page in another locale
|
/**
|
||||||
// Useful to generate hreflang meta headers etc...
|
* Permits to obtain the url of the current page in another locale, useful to
|
||||||
// See https://developers.google.com/search/docs/advanced/crawling/localized-versions
|
* generate hreflang meta headers etc...
|
||||||
|
*
|
||||||
|
* @see https://developers.google.com/search/docs/advanced/crawling/localized-versions
|
||||||
|
*/
|
||||||
export function useAlternatePageUtils(): {
|
export function useAlternatePageUtils(): {
|
||||||
|
/**
|
||||||
|
* Everything (pathname, base URL, etc.) is read from the context. Just tell
|
||||||
|
* it which locale to link to and it will give you the alternate link for the
|
||||||
|
* current page.
|
||||||
|
*/
|
||||||
createUrl: ({
|
createUrl: ({
|
||||||
|
/** The locale name to link to. */
|
||||||
locale,
|
locale,
|
||||||
|
/**
|
||||||
|
* For hreflang SEO headers, we need it to be fully qualified (full
|
||||||
|
* protocol/domain/path...); but for locale dropdowns, using a pathname is
|
||||||
|
* good enough.
|
||||||
|
*/
|
||||||
fullyQualified,
|
fullyQualified,
|
||||||
}: {
|
}: {
|
||||||
locale: string;
|
locale: string;
|
||||||
|
@ -46,8 +60,6 @@ export function useAlternatePageUtils(): {
|
||||||
fullyQualified,
|
fullyQualified,
|
||||||
}: {
|
}: {
|
||||||
locale: string;
|
locale: string;
|
||||||
// For hreflang SEO headers, we need it to be fully qualified (full
|
|
||||||
// protocol/domain/path...) or locale dropdown, using a path is good enough
|
|
||||||
fullyQualified: boolean;
|
fullyQualified: boolean;
|
||||||
}) {
|
}) {
|
||||||
return `${fullyQualified ? url : ''}${getLocalizedBaseUrl(
|
return `${fullyQualified ? url : ''}${getLocalizedBaseUrl(
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -5,13 +5,13 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
||||||
import {useLocation} from '@docusaurus/router';
|
import {useLocation} from '@docusaurus/router';
|
||||||
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the pathname of current route, without the optional site baseUrl
|
* Get the pathname of current route, without the optional site baseUrl.
|
||||||
* - /docs/myDoc => /docs/myDoc
|
* - `/docs/myDoc` => `/docs/myDoc`
|
||||||
* - /baseUrl/docs/myDoc => /docs/myDoc
|
* - `/baseUrl/docs/myDoc` => `/docs/myDoc`
|
||||||
*/
|
*/
|
||||||
export function useLocalPathname(): string {
|
export function useLocalPathname(): string {
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -10,14 +10,17 @@ import {useLocation} from '@docusaurus/router';
|
||||||
import type {Location} from 'history';
|
import type {Location} from 'history';
|
||||||
import {useDynamicCallback, usePrevious} from './reactUtils';
|
import {useDynamicCallback, usePrevious} from './reactUtils';
|
||||||
|
|
||||||
type LocationChangeEvent = {
|
/**
|
||||||
location: Location;
|
* Fires an effect when the location changes (which includes hash, query, etc.).
|
||||||
previousLocation: Location | undefined;
|
* Importantly, doesn't fire when there's no previous location: see
|
||||||
};
|
* https://github.com/facebook/docusaurus/pull/6696
|
||||||
|
*/
|
||||||
type OnLocationChange = (locationChangeEvent: LocationChangeEvent) => void;
|
export function useLocationChange(
|
||||||
|
onLocationChange: (locationChangeEvent: {
|
||||||
export function useLocationChange(onLocationChange: OnLocationChange): void {
|
location: Location;
|
||||||
|
previousLocation: Location | undefined;
|
||||||
|
}) => void,
|
||||||
|
): void {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const previousLocation = usePrevious(location);
|
const previousLocation = usePrevious(location);
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,18 @@ function selectPluralMessage(
|
||||||
return parts[Math.min(pluralFormIndex, parts.length - 1)]!;
|
return parts[Math.min(pluralFormIndex, parts.length - 1)]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the current locale and returns an interface very similar to
|
||||||
|
* `Intl.PluralRules`.
|
||||||
|
*/
|
||||||
export function usePluralForm(): {
|
export function usePluralForm(): {
|
||||||
|
/**
|
||||||
|
* Give it a `count` and it will select the relevant message from
|
||||||
|
* `pluralMessages`. `pluralMessages` should be separated by `|`, and in the
|
||||||
|
* order of "zero", "one", "two", "few", "many", "other". The actual selection
|
||||||
|
* is done by `Intl.PluralRules`, which tells us all plurals the locale has
|
||||||
|
* and which plural we should use for `count`.
|
||||||
|
*/
|
||||||
selectMessage: (count: number, pluralMessages: string) => string;
|
selectMessage: (count: number, pluralMessages: string) => string;
|
||||||
} {
|
} {
|
||||||
const localePluralForm = useLocalePluralForms();
|
const localePluralForm = useLocalePluralForms();
|
||||||
|
|
|
@ -127,6 +127,9 @@ export type ThemeConfig = {
|
||||||
// User-provided theme config, unnormalized
|
// User-provided theme config, unnormalized
|
||||||
export type UserThemeConfig = DeepPartial<ThemeConfig>;
|
export type UserThemeConfig = DeepPartial<ThemeConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A convenient/more semantic way to get theme config from context.
|
||||||
|
*/
|
||||||
export function useThemeConfig(): ThemeConfig {
|
export function useThemeConfig(): ThemeConfig {
|
||||||
return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig;
|
return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue