feat(v2): persist docs preferred version (#3543)

* persist docs preferred version

* add proper implementation for useDocsPreferredVersion

* add proper implementation for useDocsPreferredVersion

* useDocsPreferredVersion => make localstorage read only after mount

* why @docusaurus/constants can't work?

* fix weird TS issue when not duplicating constants

* add basic @docusaurus/constants doc

* attempt to fix docs-only mode where we should not call useDocs hooks

* attempt to fix docs-only mode where we should not call useDocs hooks

* fix children

* encapsulate hacky isDocsPluginEnabled in docsUtils

* use same priority order for all navbar items: activeVersion ?? preferredVersion ?? latestVersion
This commit is contained in:
Sébastien Lorber 2020-10-12 18:56:24 +02:00 committed by GitHub
parent d1510770f4
commit 4130f1a195
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 344 additions and 12 deletions

View file

@ -21,10 +21,10 @@ import {
GetActivePluginOptions, GetActivePluginOptions,
} from '../../client/docsClientUtils'; } from '../../client/docsClientUtils';
const useAllDocsData = (): Record<string, GlobalPluginData> => export const useAllDocsData = (): Record<string, GlobalPluginData> =>
useAllPluginInstancesData('docusaurus-plugin-content-docs'); useAllPluginInstancesData('docusaurus-plugin-content-docs');
const useDocsData = (pluginId: string | undefined) => export const useDocsData = (pluginId: string | undefined) =>
usePluginData('docusaurus-plugin-content-docs', pluginId) as GlobalPluginData; usePluginData('docusaurus-plugin-content-docs', pluginId) as GlobalPluginData;
export const useActivePlugin = (options: GetActivePluginOptions = {}) => { export const useActivePlugin = (options: GetActivePluginOptions = {}) => {

View file

@ -13,6 +13,7 @@ import {
useActiveVersion, useActiveVersion,
useDocVersionSuggestions, useDocVersionSuggestions,
} from '@theme/hooks/useDocs'; } from '@theme/hooks/useDocs';
import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion';
const getVersionMainDoc = (version) => const getVersionMainDoc = (version) =>
version.docs.find((doc) => doc.id === version.mainDocId); version.docs.find((doc) => doc.id === version.mainDocId);
@ -22,6 +23,9 @@ function DocVersionSuggestions(): JSX.Element {
siteConfig: {title: siteTitle}, siteConfig: {title: siteTitle},
} = useDocusaurusContext(); } = useDocusaurusContext();
const {pluginId} = useActivePlugin({failfast: true}); const {pluginId} = useActivePlugin({failfast: true});
const {savePreferredVersionName} = useDocsPreferredVersion(pluginId);
const activeVersion = useActiveVersion(pluginId); const activeVersion = useActiveVersion(pluginId);
const { const {
latestDocSuggestion, latestDocSuggestion,
@ -35,7 +39,7 @@ function DocVersionSuggestions(): JSX.Element {
// try to link to same doc in latest version (not always possible) // try to link to same doc in latest version (not always possible)
// fallback to main doc of latest version // fallback to main doc of latest version
const suggestedDoc = const latestVersionSuggestedDoc =
latestDocSuggestion ?? getVersionMainDoc(latestVersionSuggestion); latestDocSuggestion ?? getVersionMainDoc(latestVersionSuggestion);
return ( return (
@ -58,7 +62,13 @@ function DocVersionSuggestions(): JSX.Element {
<div className="margin-top--md"> <div className="margin-top--md">
For up-to-date documentation, see the{' '} For up-to-date documentation, see the{' '}
<strong> <strong>
<Link to={suggestedDoc.path}>latest version</Link> <Link
to={latestVersionSuggestedDoc.path}
onClick={() =>
savePreferredVersionName(latestVersionSuggestion.name)
}>
latest version
</Link>
</strong>{' '} </strong>{' '}
({latestVersionSuggestion.label}). ({latestVersionSuggestion.label}).
</div> </div>

View file

@ -19,11 +19,16 @@ import Footer from '@theme/Footer';
import type {Props} from '@theme/Layout'; import type {Props} from '@theme/Layout';
import './styles.css'; import './styles.css';
import DocsPreferredVersionContextProvider from '../../utils/docsPreferredVersion/DocsPreferredVersionProvider';
function Providers({children}) { function Providers({children}) {
return ( return (
<ThemeProvider> <ThemeProvider>
<UserPreferencesProvider>{children}</UserPreferencesProvider> <UserPreferencesProvider>
<DocsPreferredVersionContextProvider>
{children}
</DocsPreferredVersionContextProvider>
</UserPreferencesProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View file

@ -10,6 +10,7 @@ import DefaultNavbarItem from './DefaultNavbarItem';
import {useLatestVersion, useActiveDocContext} from '@theme/hooks/useDocs'; import {useLatestVersion, useActiveDocContext} from '@theme/hooks/useDocs';
import clsx from 'clsx'; import clsx from 'clsx';
import type {Props} from '@theme/NavbarItem/DocNavbarItem'; import type {Props} from '@theme/NavbarItem/DocNavbarItem';
import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion';
export default function DocNavbarItem({ export default function DocNavbarItem({
docId, docId,
@ -18,10 +19,11 @@ export default function DocNavbarItem({
docsPluginId, docsPluginId,
...props ...props
}: Props): JSX.Element { }: Props): JSX.Element {
const latestVersion = useLatestVersion(docsPluginId);
const {activeVersion, activeDoc} = useActiveDocContext(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); const doc = version.docs.find((versionDoc) => versionDoc.id === docId);
if (!doc) { if (!doc) {

View file

@ -13,6 +13,7 @@ import {
useActiveDocContext, useActiveDocContext,
} from '@theme/hooks/useDocs'; } from '@theme/hooks/useDocs';
import type {Props} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem'; import type {Props} from '@theme/NavbarItem/DocsVersionDropdownNavbarItem';
import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion';
const getVersionMainDoc = (version) => const getVersionMainDoc = (version) =>
version.docs.find((doc) => doc.id === version.mainDocId); version.docs.find((doc) => doc.id === version.mainDocId);
@ -29,6 +30,10 @@ export default function DocsVersionDropdownNavbarItem({
const versions = useVersions(docsPluginId); const versions = useVersions(docsPluginId);
const latestVersion = useLatestVersion(docsPluginId); const latestVersion = useLatestVersion(docsPluginId);
const {preferredVersion, savePreferredVersionName} = useDocsPreferredVersion(
docsPluginId,
);
function getItems() { function getItems() {
const versionLinks = versions.map((version) => { const versionLinks = versions.map((version) => {
// We try to link to the same doc, in another version // We try to link to the same doc, in another version
@ -41,6 +46,9 @@ export default function DocsVersionDropdownNavbarItem({
label: version.label, label: version.label,
to: versionDoc.path, to: versionDoc.path,
isActive: () => version === activeDocContext?.activeVersion, isActive: () => version === activeDocContext?.activeVersion,
onClick: () => {
savePreferredVersionName(version.name);
},
}; };
}); });
@ -60,7 +68,8 @@ export default function DocsVersionDropdownNavbarItem({
return items; return items;
} }
const dropdownVersion = activeDocContext.activeVersion ?? latestVersion; const dropdownVersion =
activeDocContext.activeVersion ?? preferredVersion ?? latestVersion;
// Mobile is handled a bit differently // Mobile is handled a bit differently
const dropdownLabel = mobile ? 'Versions' : dropdownVersion.label; const dropdownLabel = mobile ? 'Versions' : dropdownVersion.label;

View file

@ -9,6 +9,7 @@ import React from 'react';
import DefaultNavbarItem from './DefaultNavbarItem'; import DefaultNavbarItem from './DefaultNavbarItem';
import {useActiveVersion, useLatestVersion} from '@theme/hooks/useDocs'; import {useActiveVersion, useLatestVersion} from '@theme/hooks/useDocs';
import type {Props} from '@theme/NavbarItem/DocsVersionNavbarItem'; import type {Props} from '@theme/NavbarItem/DocsVersionNavbarItem';
import useDocsPreferredVersion from '../../utils/docsPreferredVersion/useDocsPreferredVersion';
const getVersionMainDoc = (version) => const getVersionMainDoc = (version) =>
version.docs.find((doc) => doc.id === version.mainDocId); version.docs.find((doc) => doc.id === version.mainDocId);
@ -20,8 +21,9 @@ export default function DocsVersionNavbarItem({
...props ...props
}: Props): JSX.Element { }: Props): JSX.Element {
const activeVersion = useActiveVersion(docsPluginId); const activeVersion = useActiveVersion(docsPluginId);
const {preferredVersion} = useDocsPreferredVersion(docsPluginId);
const latestVersion = useLatestVersion(docsPluginId); const latestVersion = useLatestVersion(docsPluginId);
const version = activeVersion ?? latestVersion; const version = activeVersion ?? preferredVersion ?? latestVersion;
const label = staticLabel ?? version.label; const label = staticLabel ?? version.label;
const path = staticTo ?? getVersionMainDoc(version).path; const path = staticTo ?? getVersionMainDoc(version).path;
return <DefaultNavbarItem {...props} label={label} to={path} />; return <DefaultNavbarItem {...props} label={label} to={path} />;

View file

@ -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<typeof useContextValue>;
const Context = createContext<DocsPreferredVersionContextValue | null>(null);
export default function DocsPreferredVersionContextProvider({
children,
}: {
children: ReactNode;
}) {
if (isDocsPluginEnabled) {
return (
<DocsPreferredVersionContextProviderUnsafe>
{children}
</DocsPreferredVersionContextProviderUnsafe>
);
} else {
return <>{children}</>;
}
}
function DocsPreferredVersionContextProviderUnsafe({
children,
}: {
children: ReactNode;
}) {
const contextValue = useContextValue();
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
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;
}

View file

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

View file

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

View file

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

View file

@ -6,7 +6,13 @@
*/ */
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 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 // TODO we should complete this theme config type over time
// and share it across all themes // and share it across all themes
// and use it in the Joi validation schema? // and use it in the Joi validation schema?

View file

@ -8,6 +8,15 @@
const Joi = require('@hapi/joi'); const Joi = require('@hapi/joi');
const {URISchema} = require('@docusaurus/utils-validation'); 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 = { const DEFAULT_COLOR_MODE_CONFIG = {
defaultMode: 'light', defaultMode: 'light',
disableSwitch: false, disableSwitch: false,
@ -22,6 +31,7 @@ const DEFAULT_COLOR_MODE_CONFIG = {
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
colorMode: DEFAULT_COLOR_MODE_CONFIG, colorMode: DEFAULT_COLOR_MODE_CONFIG,
docs: DEFAULT_DOCS_CONFIG,
metadatas: [], metadatas: [],
prism: { prism: {
additionalLanguages: [], additionalLanguages: [],
@ -206,6 +216,7 @@ const ThemeConfigSchema = Joi.object({
customCss: CustomCssSchema, customCss: CustomCssSchema,
colorMode: ColorModeSchema, colorMode: ColorModeSchema,
image: Joi.string(), image: Joi.string(),
docs: DocsSchema,
metadatas: Joi.array() metadatas: Joi.array()
.items(HtmlMetadataSchema) .items(HtmlMetadataSchema)
.default(DEFAULT_CONFIG.metadatas), .default(DEFAULT_CONFIG.metadatas),

View file

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

View file

@ -320,3 +320,15 @@ function MyPage() {
| `ExecutionEnvironment.canUseEventListeners` | `true` if on client and has `window.addEventListener`. | | `ExecutionEnvironment.canUseEventListeners` | `true` if on client and has `window.addEventListener`. |
| `ExecutionEnvironment.canUseIntersectionObserver` | `true` if on client and has `IntersectionObserver`. | | `ExecutionEnvironment.canUseIntersectionObserver` | `true` if on client and has `IntersectionObserver`. |
| `ExecutionEnvironment.canUseViewport` | `true` if on client and has `window.screen`. | | `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` |

View file

@ -17689,7 +17689,7 @@ react-dev-utils@^9.1.0:
strip-ansi "5.2.0" strip-ansi "5.2.0"
text-table "0.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" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
@ -17869,7 +17869,7 @@ react-waypoint@^9.0.2:
prop-types "^15.0.0" prop-types "^15.0.0"
react-is "^16.6.3" react-is "^16.6.3"
react@^16.8.4: react@^16.10.2, react@^16.8.4:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==