diff --git a/packages/docusaurus-plugin-content-docs/src/theme/hooks/useDocs.ts b/packages/docusaurus-plugin-content-docs/src/theme/hooks/useDocs.ts index eea342cbeb6d..bbf3cff1e81b 100644 --- a/packages/docusaurus-plugin-content-docs/src/theme/hooks/useDocs.ts +++ b/packages/docusaurus-plugin-content-docs/src/theme/hooks/useDocs.ts @@ -21,10 +21,10 @@ import { GetActivePluginOptions, } from '../../client/docsClientUtils'; -const useAllDocsData = (): Record => +export const useAllDocsData = (): Record => useAllPluginInstancesData('docusaurus-plugin-content-docs'); -const useDocsData = (pluginId: string | undefined) => +export const useDocsData = (pluginId: string | undefined) => usePluginData('docusaurus-plugin-content-docs', pluginId) as GlobalPluginData; export const useActivePlugin = (options: GetActivePluginOptions = {}) => { diff --git a/packages/docusaurus-theme-classic/src/theme/DocVersionSuggestions/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocVersionSuggestions/index.tsx index 5d2168b1b2d0..81f171f1f468 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocVersionSuggestions/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocVersionSuggestions/index.tsx @@ -13,6 +13,7 @@ import { useActiveVersion, useDocVersionSuggestions, } from '@theme/hooks/useDocs'; +import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion'; const getVersionMainDoc = (version) => version.docs.find((doc) => doc.id === version.mainDocId); @@ -22,6 +23,9 @@ function DocVersionSuggestions(): JSX.Element { siteConfig: {title: siteTitle}, } = useDocusaurusContext(); const {pluginId} = useActivePlugin({failfast: true}); + + const {savePreferredVersionName} = useDocsPreferredVersion(pluginId); + const activeVersion = useActiveVersion(pluginId); const { latestDocSuggestion, @@ -35,7 +39,7 @@ function DocVersionSuggestions(): JSX.Element { // try to link to same doc in latest version (not always possible) // fallback to main doc of latest version - const suggestedDoc = + const latestVersionSuggestedDoc = latestDocSuggestion ?? getVersionMainDoc(latestVersionSuggestion); return ( @@ -58,7 +62,13 @@ function DocVersionSuggestions(): JSX.Element {
For up-to-date documentation, see the{' '} - latest version + + savePreferredVersionName(latestVersionSuggestion.name) + }> + latest version + {' '} ({latestVersionSuggestion.label}).
diff --git a/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx b/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx index 452fa26a3631..5ac0799497a0 100644 --- a/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx @@ -19,11 +19,16 @@ import Footer from '@theme/Footer'; import type {Props} from '@theme/Layout'; import './styles.css'; +import DocsPreferredVersionContextProvider from '../../utils/docsPreferredVersion/DocsPreferredVersionProvider'; function Providers({children}) { return ( - {children} + + + {children} + + ); } diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocNavbarItem.tsx index 602843f06c85..90b73d560a40 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocNavbarItem.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocNavbarItem.tsx @@ -10,6 +10,7 @@ import DefaultNavbarItem from './DefaultNavbarItem'; import {useLatestVersion, useActiveDocContext} from '@theme/hooks/useDocs'; import clsx from 'clsx'; import type {Props} from '@theme/NavbarItem/DocNavbarItem'; +import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion'; export default function DocNavbarItem({ docId, @@ -18,10 +19,11 @@ export default function DocNavbarItem({ docsPluginId, ...props }: Props): JSX.Element { - const latestVersion = useLatestVersion(docsPluginId); const {activeVersion, activeDoc} = useActiveDocContext(docsPluginId); + const {preferredVersion} = useDocsPreferredVersion(docsPluginId); + const latestVersion = useLatestVersion(docsPluginId); - const version = activeVersion ?? latestVersion; + const version = activeVersion ?? preferredVersion ?? latestVersion; const doc = version.docs.find((versionDoc) => versionDoc.id === docId); if (!doc) { diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx index 6eb68860c121..8befa845b302 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.tsx @@ -13,6 +13,7 @@ import { useActiveDocContext, } from '@theme/hooks/useDocs'; import type {Props} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem'; +import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion'; const getVersionMainDoc = (version) => version.docs.find((doc) => doc.id === version.mainDocId); @@ -29,6 +30,10 @@ export default function DocsVersionDropdownNavbarItem({ const versions = useVersions(docsPluginId); const latestVersion = useLatestVersion(docsPluginId); + const {preferredVersion, savePreferredVersionName} = useDocsPreferredVersion( + docsPluginId, + ); + function getItems() { const versionLinks = versions.map((version) => { // We try to link to the same doc, in another version @@ -41,6 +46,9 @@ export default function DocsVersionDropdownNavbarItem({ label: version.label, to: versionDoc.path, isActive: () => version === activeDocContext?.activeVersion, + onClick: () => { + savePreferredVersionName(version.name); + }, }; }); @@ -60,7 +68,8 @@ export default function DocsVersionDropdownNavbarItem({ return items; } - const dropdownVersion = activeDocContext.activeVersion ?? latestVersion; + const dropdownVersion = + activeDocContext.activeVersion ?? preferredVersion ?? latestVersion; // Mobile is handled a bit differently const dropdownLabel = mobile ? 'Versions' : dropdownVersion.label; diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionNavbarItem.tsx index 9a82a31ffba8..bab774760eca 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionNavbarItem.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DocsVersionNavbarItem.tsx @@ -9,6 +9,7 @@ import React from 'react'; import DefaultNavbarItem from './DefaultNavbarItem'; import {useActiveVersion, useLatestVersion} from '@theme/hooks/useDocs'; import type {Props} from '@theme/NavbarItem/DocsVersionNavbarItem'; +import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion'; const getVersionMainDoc = (version) => version.docs.find((doc) => doc.id === version.mainDocId); @@ -20,8 +21,9 @@ export default function DocsVersionNavbarItem({ ...props }: Props): JSX.Element { const activeVersion = useActiveVersion(docsPluginId); + const {preferredVersion} = useDocsPreferredVersion(docsPluginId); const latestVersion = useLatestVersion(docsPluginId); - const version = activeVersion ?? latestVersion; + const version = activeVersion ?? preferredVersion ?? latestVersion; const label = staticLabel ?? version.label; const path = staticTo ?? getVersionMainDoc(version).path; return ; diff --git a/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx b/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx new file mode 100644 index 000000000000..f98d7030efc3 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx @@ -0,0 +1,165 @@ +/** + * 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, { + createContext, + ReactNode, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import useThemeConfig, {DocsVersionPersistence} from '../useThemeConfig'; +import {isDocsPluginEnabled} from '../docsUtils'; + +import {useAllDocsData} from '@theme/hooks/useDocs'; + +import DocsPreferredVersionStorage from './DocsPreferredVersionStorage'; + +type DocsPreferredVersionName = string | null; + +// State for a single docs plugin instance +type DocsPreferredVersionPluginState = { + preferredVersionName: DocsPreferredVersionName; +}; + +// We need to store in state/storage globally +// one preferred version per docs plugin instance +// pluginId => pluginState +type DocsPreferredVersionState = Record< + string, + DocsPreferredVersionPluginState +>; + +// Initial state is always null as we can't read localstorage from node SSR +function getInitialState(pluginIds: string[]): DocsPreferredVersionState { + const initialState: DocsPreferredVersionState = {}; + pluginIds.forEach((pluginId) => { + initialState[pluginId] = { + preferredVersionName: null, + }; + }); + return initialState; +} + +// Read storage for all docs plugins +// Assign to each doc plugin a preferred version (if found) +function readStorageState({ + pluginIds, + versionPersistence, + allDocsData, +}: { + pluginIds: string[]; + versionPersistence: DocsVersionPersistence; + allDocsData: any; // TODO find a way to type it :( +}): 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}; + } else { + DocsPreferredVersionStorage.clear(pluginId, versionPersistence); + return {preferredVersionName: null}; + } + } + + const initialState: DocsPreferredVersionState = {}; + pluginIds.forEach((pluginId) => { + initialState[pluginId] = restorePluginState(pluginId); + }); + return initialState; +} + +function useVersionPersistence(): DocsVersionPersistence { + return useThemeConfig().docs.versionPersistence; +} + +// Value that will be accessible through context: [state,api] +function useContextValue() { + const allDocsData = useAllDocsData(); + const versionPersistence = useVersionPersistence(); + const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]); + + // Initial state is empty, as we can't read browser storage in node/SSR + const [state, setState] = useState(() => getInitialState(pluginIds)); + + // On mount, we set the state read from browser storage + useEffect(() => { + setState(readStorageState({allDocsData, versionPersistence, pluginIds})); + }, [allDocsData, versionPersistence, pluginIds]); + + // The API that we expose to consumer hooks (memo for constant object) + const api = useMemo(() => { + function savePreferredVersion(pluginId: string, versionName: string) { + DocsPreferredVersionStorage.save( + pluginId, + versionPersistence, + versionName, + ); + setState((s) => ({ + ...s, + [pluginId]: {preferredVersionName: versionName}, + })); + } + + return { + savePreferredVersion, + }; + }, [setState]); + + return [state, api] as const; +} + +type DocsPreferredVersionContextValue = ReturnType; + +const Context = createContext(null); + +export default function DocsPreferredVersionContextProvider({ + children, +}: { + children: ReactNode; +}) { + if (isDocsPluginEnabled) { + return ( + + {children} + + ); + } else { + return <>{children}; + } +} + +function DocsPreferredVersionContextProviderUnsafe({ + children, +}: { + children: ReactNode; +}) { + const contextValue = useContextValue(); + return {children}; +} + +export function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue { + const value = useContext(Context); + if (!value) { + throw new Error( + "Can't find docs preferred context, maybe you forgot to use the DocsPreferredVersionContextProvider ?", + ); + } + return value; +} diff --git a/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts b/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts new file mode 100644 index 000000000000..22ac6b7001bb --- /dev/null +++ b/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts @@ -0,0 +1,45 @@ +/** + * 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 {DocsVersionPersistence} from '../useThemeConfig'; + +const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`; + +const DocsPreferredVersionStorage = { + save: ( + pluginId: string, + persistence: DocsVersionPersistence, + versionName: string, + ): void => { + if (persistence === 'none') { + // noop + } else { + window.localStorage.setItem(storageKey(pluginId), versionName); + } + }, + + read: ( + pluginId: string, + persistence: DocsVersionPersistence, + ): string | null => { + if (persistence === 'none') { + return null; + } else { + return window.localStorage.getItem(storageKey(pluginId)); + } + }, + + clear: (pluginId: string, persistence: DocsVersionPersistence): void => { + if (persistence === 'none') { + // noop + } else { + window.localStorage.removeItem(storageKey(pluginId)); + } + }, +}; + +export default DocsPreferredVersionStorage; diff --git a/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts b/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts new file mode 100644 index 000000000000..a6a14778b2a5 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import {useCallback} from 'react'; +import {useDocsPreferredVersionContext} from './DocsPreferredVersionProvider'; +import {useDocsData} from '@theme/hooks/useDocs'; + +import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants'; + +// Note, the preferredVersion attribute will always be null before mount +export default function useDocsPreferredVersion( + pluginId: string | undefined = DEFAULT_PLUGIN_ID, +) { + const docsData = useDocsData(pluginId); + const [state, api] = useDocsPreferredVersionContext(); + + const {preferredVersionName} = state[pluginId]; + + const preferredVersion = preferredVersionName + ? docsData.versions.find((version) => version.name === preferredVersionName) + : null; + + const savePreferredVersionName = useCallback( + (versionName: string) => { + api.savePreferredVersion(pluginId, versionName); + }, + [api], + ); + + return {preferredVersion, savePreferredVersionName} as const; +} diff --git a/packages/docusaurus-theme-classic/src/utils/docsUtils.ts b/packages/docusaurus-theme-classic/src/utils/docsUtils.ts new file mode 100644 index 000000000000..eba2c7cd2f6e --- /dev/null +++ b/packages/docusaurus-theme-classic/src/utils/docsUtils.ts @@ -0,0 +1,11 @@ +/** + * 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} from '@theme/hooks/useDocs'; + +// TODO not ideal, see also "useDocs" +export const isDocsPluginEnabled: boolean = !!useAllDocsData; diff --git a/packages/docusaurus-theme-classic/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-classic/src/utils/useThemeConfig.ts index db8b66a6b6d3..d8497b2f9451 100644 --- a/packages/docusaurus-theme-classic/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-classic/src/utils/useThemeConfig.ts @@ -6,7 +6,13 @@ */ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -type ThemeConfig = { +export type DocsVersionPersistence = 'localStorage' | 'none'; + +export type ThemeConfig = { + docs: { + versionPersistence: DocsVersionPersistence; + }; + // TODO we should complete this theme config type over time // and share it across all themes // and use it in the Joi validation schema? diff --git a/packages/docusaurus-theme-classic/src/validateThemeConfig.js b/packages/docusaurus-theme-classic/src/validateThemeConfig.js index 400790d41beb..56e84246286b 100644 --- a/packages/docusaurus-theme-classic/src/validateThemeConfig.js +++ b/packages/docusaurus-theme-classic/src/validateThemeConfig.js @@ -8,6 +8,15 @@ const Joi = require('@hapi/joi'); const {URISchema} = require('@docusaurus/utils-validation'); +const DEFAULT_DOCS_CONFIG = { + versionPersistence: 'localStorage', +}; +const DocsSchema = Joi.object({ + versionPersistence: Joi.string() + .equal('localStorage', 'none') + .default(DEFAULT_DOCS_CONFIG.versionPersistence), +}).default(DEFAULT_DOCS_CONFIG); + const DEFAULT_COLOR_MODE_CONFIG = { defaultMode: 'light', disableSwitch: false, @@ -22,6 +31,7 @@ const DEFAULT_COLOR_MODE_CONFIG = { const DEFAULT_CONFIG = { colorMode: DEFAULT_COLOR_MODE_CONFIG, + docs: DEFAULT_DOCS_CONFIG, metadatas: [], prism: { additionalLanguages: [], @@ -206,6 +216,7 @@ const ThemeConfigSchema = Joi.object({ customCss: CustomCssSchema, colorMode: ColorModeSchema, image: Joi.string(), + docs: DocsSchema, metadatas: Joi.array() .items(HtmlMetadataSchema) .default(DEFAULT_CONFIG.metadatas), diff --git a/packages/docusaurus/src/client/exports/constants.ts b/packages/docusaurus/src/client/exports/constants.ts new file mode 100644 index 000000000000..48fa05bd6982 --- /dev/null +++ b/packages/docusaurus/src/client/exports/constants.ts @@ -0,0 +1,20 @@ +/** + * 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. + */ + +/* +// eslint-disable-next-line no-restricted-imports +export { + // constants were only available on node + // this makes some useful constants available to frontend/themes too + // import {DEFAULT_PLUGIN_ID} '@docusaurus/constants' + DEFAULT_PLUGIN_ID, +} from '../../constants'; + */ + +// Not duplicating the constants seems to produce +// weird TS compilation side-effects +export const DEFAULT_PLUGIN_ID = 'default'; diff --git a/website/docs/docusaurus-core.md b/website/docs/docusaurus-core.md index dea573e06bba..f16164f56352 100644 --- a/website/docs/docusaurus-core.md +++ b/website/docs/docusaurus-core.md @@ -320,3 +320,15 @@ function MyPage() { | `ExecutionEnvironment.canUseEventListeners` | `true` if on client and has `window.addEventListener`. | | `ExecutionEnvironment.canUseIntersectionObserver` | `true` if on client and has `IntersectionObserver`. | | `ExecutionEnvironment.canUseViewport` | `true` if on client and has `window.screen`. | + +### `constants` + +A module exposing useful constants to client-side theme code. + +```jsx +import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants'; +``` + +| Named export | Value | +| ------------------- | --------- | +| `DEFAULT_PLUGIN_ID` | `default` | diff --git a/yarn.lock b/yarn.lock index 2386c1bc5155..88c1e2661f29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17689,7 +17689,7 @@ react-dev-utils@^9.1.0: strip-ansi "5.2.0" text-table "0.2.0" -react-dom@^16.8.4: +react-dom@^16.10.2, react-dom@^16.8.4: version "16.13.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== @@ -17869,7 +17869,7 @@ react-waypoint@^9.0.2: prop-types "^15.0.0" react-is "^16.6.3" -react@^16.8.4: +react@^16.10.2, react@^16.8.4: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==