docusaurus/packages/docusaurus-plugin-content-docs/src/client/docsPreferredVersion.tsx
2024-12-06 18:03:04 +01:00

248 lines
7 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {
useContext,
useEffect,
useMemo,
useState,
useCallback,
type ReactNode,
} from 'react';
import {
useAllDocsData,
useDocsData,
type GlobalPluginData,
type GlobalVersion,
} from '@docusaurus/plugin-content-docs/client';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
import {useThemeConfig, type ThemeConfig} from '@docusaurus/theme-common';
import {
ReactContextError,
createStorageSlot,
} from '@docusaurus/theme-common/internal';
type DocsVersionPersistence = ThemeConfig['docs']['versionPersistence'];
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
const DocsPreferredVersionStorage = {
save: (
pluginId: string,
persistence: DocsVersionPersistence,
versionName: string,
): void => {
createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
},
read: (
pluginId: string,
persistence: DocsVersionPersistence,
): string | null =>
createStorageSlot(storageKey(pluginId), {persistence}).get(),
clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
createStorageSlot(storageKey(pluginId), {persistence}).del();
},
};
type DocsPreferredVersionName = string | null;
/** State for a single docs plugin instance */
type DocsPreferredVersionPluginState = {
preferredVersionName: DocsPreferredVersionName;
};
/**
* We need to store the state in storage globally, with one preferred version
* per docs plugin instance.
*/
type DocsPreferredVersionState = {
[pluginId: string]: DocsPreferredVersionPluginState;
};
/**
* Initial state is always null as we can't read local storage from node SSR
*/
const getInitialState = (pluginIds: string[]): DocsPreferredVersionState =>
Object.fromEntries(pluginIds.map((id) => [id, {preferredVersionName: null}]));
/**
* Read storage for all docs plugins, assigning each doc plugin a preferred
* version (if found)
*/
function readStorageState({
pluginIds,
versionPersistence,
allDocsData,
}: {
pluginIds: string[];
versionPersistence: DocsVersionPersistence;
allDocsData: {[pluginId: string]: GlobalPluginData};
}): DocsPreferredVersionState {
/**
* The storage value we read might be stale, and belong to a version that does
* not exist in the site anymore. In such case, we remove the storage value to
* avoid downstream errors.
*/
function restorePluginState(
pluginId: string,
): DocsPreferredVersionPluginState {
const preferredVersionNameUnsafe = DocsPreferredVersionStorage.read(
pluginId,
versionPersistence,
);
const pluginData = allDocsData[pluginId]!;
const versionExists = pluginData.versions.some(
(version) => version.name === preferredVersionNameUnsafe,
);
if (versionExists) {
return {preferredVersionName: preferredVersionNameUnsafe};
}
DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
return {preferredVersionName: null};
}
return Object.fromEntries(
pluginIds.map((id) => [id, restorePluginState(id)]),
);
}
function useVersionPersistence(): DocsVersionPersistence {
return useThemeConfig().docs.versionPersistence;
}
type ContextValue = [
state: DocsPreferredVersionState,
api: {
savePreferredVersion: (pluginId: string, versionName: string) => void;
},
];
const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const allDocsData = useAllDocsData();
const versionPersistence = useVersionPersistence();
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
// Initial state is empty, as we can't read browser storage in node/SSR
const [state, setState] = useState(() => getInitialState(pluginIds));
// On mount, we set the state read from browser storage
useEffect(() => {
setState(readStorageState({allDocsData, versionPersistence, pluginIds}));
}, [allDocsData, versionPersistence, pluginIds]);
// The API that we expose to consumer hooks (memo for constant object)
const api = useMemo(() => {
function savePreferredVersion(pluginId: string, versionName: string) {
DocsPreferredVersionStorage.save(
pluginId,
versionPersistence,
versionName,
);
setState((s) => ({
...s,
[pluginId]: {preferredVersionName: versionName},
}));
}
return {
savePreferredVersion,
};
}, [versionPersistence]);
return [state, api];
}
function DocsPreferredVersionContextProviderUnsafe({
children,
}: {
children: ReactNode;
}): ReactNode {
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
/**
* This is a maybe-layer. If the docs plugin is not enabled, this provider is a
* simple pass-through.
*/
export function DocsPreferredVersionContextProvider({
children,
}: {
children: ReactNode;
}): ReactNode {
return (
<DocsPreferredVersionContextProviderUnsafe>
{children}
</DocsPreferredVersionContextProviderUnsafe>
);
}
function useDocsPreferredVersionContext(): ContextValue {
const value = useContext(Context);
if (!value) {
throw new ReactContextError('DocsPreferredVersionContextProvider');
}
return value;
}
/**
* Returns a read-write interface to a plugin's preferred version. The
* "preferred version" is defined as the last version that the user visited.
* For example, if a user is using v3, even when v4 is later published, the user
* would still be browsing v3 docs when she opens the website next time. Note,
* the `preferredVersion` attribute will always be `null` before mount.
*/
export function useDocsPreferredVersion(
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
): {
preferredVersion: GlobalVersion | null;
savePreferredVersionName: (versionName: string) => void;
} {
const docsData = useDocsData(pluginId);
const [state, api] = useDocsPreferredVersionContext();
const {preferredVersionName} = state[pluginId]!;
const preferredVersion =
docsData.versions.find(
(version) => version.name === preferredVersionName,
) ?? null;
const savePreferredVersionName = useCallback(
(versionName: string) => {
api.savePreferredVersion(pluginId, versionName);
},
[api, pluginId],
);
return {preferredVersion, savePreferredVersionName};
}
export function useDocsPreferredVersionByPluginId(): {
[pluginId: string]: GlobalVersion | null;
} {
const allDocsData = useAllDocsData();
const [state] = useDocsPreferredVersionContext();
function getPluginIdPreferredVersion(pluginId: string) {
const docsData = allDocsData[pluginId]!;
const {preferredVersionName} = state[pluginId]!;
return (
docsData.versions.find(
(version) => version.name === preferredVersionName,
) ?? null
);
}
const pluginIds = Object.keys(allDocsData);
return Object.fromEntries(
pluginIds.map((id) => [id, getPluginIdPreferredVersion(id)]),
);
}