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