feat(v2): @docusaurus/theme-common (#3775)

* create base @docusaurus/theme-common package + fix Webpack client export aliases issue shadowing other theme-common package

* Move theme-classic/src/utils code to new @docusaurus/theme-common package

* add prettierignore

* fix bad test location for getDocusaurusAliases()
This commit is contained in:
Sébastien Lorber 2020-11-18 16:00:51 +01:00 committed by GitHub
parent 5872bbc735
commit abcd8cefd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 176 additions and 32 deletions

View file

@ -0,0 +1,19 @@
/**
* 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.
*/
export {useThemeConfig, ThemeConfig} from './utils/useThemeConfig';
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils';
export {isDocsPluginEnabled} from './utils/docsUtils';
export {isSamePath} from './utils/pathUtils';
export {
useDocsPreferredVersion,
useDocsPreferredVersionByPluginId,
} from './utils/docsPreferredVersion/useDocsPreferredVersion';
export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider';

View file

@ -0,0 +1,13 @@
/**
* 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 import/no-duplicates */
/* eslint-disable spaced-comment */
/// <reference types="@docusaurus/module-type-aliases" />
/// <reference types="@docusaurus/plugin-content-blog" />
/// <reference types="@docusaurus/plugin-content-docs" />
/// <reference types="@docusaurus/plugin-content-pages" />

View file

@ -0,0 +1,32 @@
/**
* 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', () => {
test('should be true for compared path without trailing slash', () => {
expect(isSamePath('/docs', '/docs')).toBeTruthy();
});
test('should be true for compared path with trailing slash', () => {
expect(isSamePath('/docs', '/docs/')).toBeTruthy();
});
test('should be false for compared path with double trailing slash', () => {
expect(isSamePath('/docs', '/docs//')).toBeFalsy();
});
test('should be true for twice undefined/null', () => {
expect(isSamePath(undefined, undefined)).toBeTruthy();
expect(isSamePath(undefined, undefined)).toBeTruthy();
});
test('should be false when one undefined', () => {
expect(isSamePath('/docs', undefined)).toBeFalsy();
expect(isSamePath(undefined, '/docs')).toBeFalsy();
});
});

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: any) => 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 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,66 @@
/**
* 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 {useAllDocsData, useDocsData} from '@theme/hooks/useDocs';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
// TODO improve typing
// Note, the preferredVersion attribute will always be null before mount
export 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: any) => version.name === preferredVersionName,
)
: null;
const savePreferredVersionName = useCallback(
(versionName: string) => {
api.savePreferredVersion(pluginId, versionName);
},
[api],
);
return {preferredVersion, savePreferredVersionName} as const;
}
export function useDocsPreferredVersionByPluginId() {
const allDocsData = useAllDocsData();
const [state] = useDocsPreferredVersionContext();
function getPluginIdPreferredVersion(pluginId: string) {
const docsData = allDocsData[pluginId];
const {preferredVersionName} = state[pluginId];
return preferredVersionName
? docsData.versions.find(
(version: any) => version.name === preferredVersionName,
)
: null;
}
const pluginIds = Object.keys(allDocsData);
const result: Record<
string,
any // TODO find a way to type this properly!
> = {};
pluginIds.forEach((pluginId) => {
result[pluginId] = getPluginIdPreferredVersion(pluginId);
});
return result;
}

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

@ -0,0 +1,17 @@
/**
* 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, ignoring trailing /
export const isSamePath = (
path1: string | undefined,
path2: string | undefined,
) => {
const normalize = (pathname: string | undefined) => {
return !pathname || pathname?.endsWith('/') ? pathname : `${pathname}/`;
};
return normalize(path1) === normalize(path2);
};

View file

@ -0,0 +1,12 @@
/**
* 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.
*/
export const DEFAULT_SEARCH_TAG = 'default';
export function docVersionSearchTag(pluginId: string, versionName: string) {
return `docs-${pluginId}-${versionName}`;
}

View file

@ -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 useDocusaurusContext from '@docusaurus/useDocusaurusContext';
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?
// TODO temporary types
navbar: any;
colorMode: any;
announcementBar: any;
prism: any;
footer: any;
hideableSidebar: any;
};
export function useThemeConfig(): ThemeConfig {
return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig;
}