mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-29 18:27:56 +02:00
feat(core,theme): useRouteContext + HtmlClassNameProvider (#6933)
Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
This commit is contained in:
parent
9b4ba78f45
commit
8a1421a938
14 changed files with 235 additions and 25 deletions
|
@ -246,6 +246,12 @@ declare module '@docusaurus/useDocusaurusContext' {
|
|||
export default function useDocusaurusContext(): DocusaurusContext;
|
||||
}
|
||||
|
||||
declare module '@docusaurus/useRouteContext' {
|
||||
import type {PluginRouteContext} from '@docusaurus/types';
|
||||
|
||||
export default function useRouteContext(): PluginRouteContext;
|
||||
}
|
||||
|
||||
declare module '@docusaurus/useIsBrowser' {
|
||||
export default function useIsBrowser(): boolean;
|
||||
}
|
||||
|
|
|
@ -191,6 +191,7 @@ declare module '@theme/DocItem' {
|
|||
};
|
||||
|
||||
export type Metadata = {
|
||||
readonly unversionedId?: string;
|
||||
readonly description?: string;
|
||||
readonly title?: string;
|
||||
readonly permalink?: string;
|
||||
|
|
|
@ -17,7 +17,11 @@ import TOC from '@theme/TOC';
|
|||
import TOCCollapsible from '@theme/TOCCollapsible';
|
||||
import Heading from '@theme/Heading';
|
||||
import styles from './styles.module.css';
|
||||
import {ThemeClassNames, useWindowSize} from '@docusaurus/theme-common';
|
||||
import {
|
||||
HtmlClassNameProvider,
|
||||
ThemeClassNames,
|
||||
useWindowSize,
|
||||
} from '@docusaurus/theme-common';
|
||||
import DocBreadcrumbs from '@theme/DocBreadcrumbs';
|
||||
import MDXContent from '@theme/MDXContent';
|
||||
|
||||
|
@ -49,7 +53,7 @@ export default function DocItem(props: Props): JSX.Element {
|
|||
canRenderTOC && (windowSize === 'desktop' || windowSize === 'ssr');
|
||||
|
||||
return (
|
||||
<>
|
||||
<HtmlClassNameProvider className={`docs-doc-id-${metadata.unversionedId}`}>
|
||||
<Seo {...{title, description, keywords, image}} />
|
||||
|
||||
<div className="row">
|
||||
|
@ -107,6 +111,6 @@ export default function DocItem(props: Props): JSX.Element {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</HtmlClassNameProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,14 +20,15 @@ import {translate} from '@docusaurus/Translate';
|
|||
|
||||
import clsx from 'clsx';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
import {
|
||||
HtmlClassNameProvider,
|
||||
ThemeClassNames,
|
||||
docVersionSearchTag,
|
||||
DocsSidebarProvider,
|
||||
useDocsSidebar,
|
||||
DocsVersionProvider,
|
||||
} from '@docusaurus/theme-common';
|
||||
import Head from '@docusaurus/Head';
|
||||
|
||||
type DocPageContentProps = {
|
||||
readonly currentDocRoute: DocumentRoute;
|
||||
|
@ -160,11 +161,7 @@ export default function DocPage(props: Props): JSX.Element {
|
|||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{/* TODO we should add a core addRoute({htmlClassName}) action */}
|
||||
<html className={versionMetadata.className} />
|
||||
</Head>
|
||||
<HtmlClassNameProvider className={versionMetadata.className}>
|
||||
<DocsVersionProvider version={versionMetadata}>
|
||||
<DocsSidebarProvider sidebar={sidebar ?? null}>
|
||||
<DocPageContent
|
||||
|
@ -175,6 +172,6 @@ export default function DocPage(props: Props): JSX.Element {
|
|||
</DocPageContent>
|
||||
</DocsSidebarProvider>
|
||||
</DocsVersionProvider>
|
||||
</>
|
||||
</HtmlClassNameProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
DocsPreferredVersionContextProvider,
|
||||
MobileSecondaryMenuProvider,
|
||||
ScrollControllerProvider,
|
||||
PluginHtmlClassNameProvider,
|
||||
} from '@docusaurus/theme-common';
|
||||
import type {Props} from '@theme/LayoutProviders';
|
||||
|
||||
|
@ -24,7 +25,9 @@ export default function LayoutProviders({children}: Props): JSX.Element {
|
|||
<ScrollControllerProvider>
|
||||
<DocsPreferredVersionContextProvider>
|
||||
<MobileSecondaryMenuProvider>
|
||||
{children}
|
||||
<PluginHtmlClassNameProvider>
|
||||
{children}
|
||||
</PluginHtmlClassNameProvider>
|
||||
</MobileSecondaryMenuProvider>
|
||||
</DocsPreferredVersionContextProvider>
|
||||
</ScrollControllerProvider>
|
||||
|
|
|
@ -135,6 +135,11 @@ export {isRegexpStringMatch} from './utils/regexpUtils';
|
|||
|
||||
export {useHomePageRoute} from './utils/routesUtils';
|
||||
|
||||
export {
|
||||
HtmlClassNameProvider,
|
||||
PluginHtmlClassNameProvider,
|
||||
} from './utils/metadataUtilsTemp';
|
||||
|
||||
export {useColorMode, ColorModeProvider} from './utils/colorModeUtils';
|
||||
export {
|
||||
useTabGroupChoice,
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* 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} from 'react';
|
||||
import Head from '@docusaurus/Head';
|
||||
import clsx from 'clsx';
|
||||
import useRouteContext from '@docusaurus/useRouteContext';
|
||||
|
||||
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
|
||||
export function HtmlClassNameProvider({
|
||||
className: classNameProp,
|
||||
children,
|
||||
}: {
|
||||
className: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const classNameContext = React.useContext(HtmlClassNameContext);
|
||||
const className = clsx(classNameContext, classNameProp);
|
||||
return (
|
||||
<HtmlClassNameContext.Provider value={className}>
|
||||
<Head>
|
||||
<html className={className} />
|
||||
</Head>
|
||||
{children}
|
||||
</HtmlClassNameContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function pluginNameToClassName(pluginName: string) {
|
||||
return `plugin-${pluginName.replace(
|
||||
/docusaurus-(?:plugin|theme)-(?:content-)?/gi,
|
||||
'',
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function PluginHtmlClassNameProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const routeContext = useRouteContext();
|
||||
const nameClass = pluginNameToClassName(routeContext.plugin.name);
|
||||
const idClass = `plugin-id-${routeContext.plugin.id}`;
|
||||
return (
|
||||
<HtmlClassNameProvider className={clsx(nameClass, idClass)}>
|
||||
{children}
|
||||
</HtmlClassNameProvider>
|
||||
);
|
||||
}
|
18
packages/docusaurus-types/src/index.d.ts
vendored
18
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -377,6 +377,24 @@ export interface RouteConfig {
|
|||
[propName: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RouteContext {
|
||||
/**
|
||||
* Plugin-specific context data.
|
||||
*/
|
||||
data?: object | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level plugin routes automatically add some context data to the route.
|
||||
* This permits us to know which plugin is handling the current route.
|
||||
*/
|
||||
export interface PluginRouteContext extends RouteContext {
|
||||
plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Route = {
|
||||
readonly path: string;
|
||||
readonly component: ReturnType<typeof Loadable>;
|
||||
|
|
|
@ -11,6 +11,7 @@ import Loading from '@theme/Loading';
|
|||
import routesChunkNames from '@generated/routesChunkNames';
|
||||
import registry from '@generated/registry';
|
||||
import flat from '../flat';
|
||||
import {RouteContextProvider} from '../routeContext';
|
||||
|
||||
type OptsLoader = Record<string, typeof registry[keyof typeof registry][0]>;
|
||||
|
||||
|
@ -22,7 +23,16 @@ export default function ComponentCreator(
|
|||
if (path === '*') {
|
||||
return Loadable({
|
||||
loading: Loading,
|
||||
loader: () => import('@theme/NotFound'),
|
||||
loader: async () => {
|
||||
const NotFound = (await import('@theme/NotFound')).default;
|
||||
return (props) => (
|
||||
// Is there a better API for this?
|
||||
<RouteContextProvider
|
||||
value={{plugin: {name: 'native', id: 'default'}}}>
|
||||
<NotFound {...(props as never)} />
|
||||
</RouteContextProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -84,7 +94,18 @@ export default function ComponentCreator(
|
|||
|
||||
const Component = loadedModules.component;
|
||||
delete loadedModules.component;
|
||||
return <Component {...loadedModules} {...props} />;
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
const routeContextModule = loadedModules.__routeContextModule;
|
||||
delete loadedModules.__routeContextModule;
|
||||
/* eslint-enable no-underscore-dangle */
|
||||
|
||||
// Is there any way to put this RouteContextProvider upper in the tree?
|
||||
return (
|
||||
<RouteContextProvider value={routeContextModule}>
|
||||
<Component {...loadedModules} {...props} />;
|
||||
</RouteContextProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
20
packages/docusaurus/src/client/exports/useRouteContext.tsx
Normal file
20
packages/docusaurus/src/client/exports/useRouteContext.tsx
Normal 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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type {PluginRouteContext} from '@docusaurus/types';
|
||||
import {Context} from '../routeContext';
|
||||
|
||||
export default function useRouteContext(): PluginRouteContext {
|
||||
const context = React.useContext(Context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Unexpected: no Docusaurus parent/current route context found',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
58
packages/docusaurus/src/client/routeContext.tsx
Normal file
58
packages/docusaurus/src/client/routeContext.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 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, {useMemo, type ReactNode} from 'react';
|
||||
import type {PluginRouteContext, RouteContext} from '@docusaurus/types';
|
||||
|
||||
export const Context = React.createContext<PluginRouteContext | null>(null);
|
||||
|
||||
function mergeContexts({
|
||||
parent,
|
||||
value,
|
||||
}: {
|
||||
parent: PluginRouteContext | null;
|
||||
value: RouteContext | null;
|
||||
}): PluginRouteContext {
|
||||
if (!parent) {
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'Unexpected: no Docusaurus parent/current route context found',
|
||||
);
|
||||
} else if (!('plugin' in value)) {
|
||||
throw new Error(
|
||||
'Unexpected: Docusaurus parent route context has no plugin attribute',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// TODO deep merge this
|
||||
const data = {...parent.data, ...value?.data};
|
||||
|
||||
return {
|
||||
// nested routes are not supposed to override plugin attribute
|
||||
plugin: parent.plugin,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function RouteContextProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: PluginRouteContext | null;
|
||||
}): JSX.Element {
|
||||
const parent = React.useContext(Context);
|
||||
|
||||
const mergedValue = useMemo(
|
||||
() => mergeContexts({parent, value}),
|
||||
[parent, value],
|
||||
);
|
||||
|
||||
return <Context.Provider value={mergedValue}>{children}</Context.Provider>;
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {generate} from '@docusaurus/utils';
|
||||
import {docuHash, generate} from '@docusaurus/utils';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import type {
|
||||
|
@ -18,6 +18,7 @@ import type {
|
|||
ThemeConfig,
|
||||
LoadedPlugin,
|
||||
InitializedPlugin,
|
||||
PluginRouteContext,
|
||||
} from '@docusaurus/types';
|
||||
import initPlugins from './init';
|
||||
import logger from '@docusaurus/logger';
|
||||
|
@ -149,17 +150,6 @@ export async function loadPlugins({
|
|||
const dataDirRoot = path.join(context.generatedFilesDir, plugin.name);
|
||||
const dataDir = path.join(dataDirRoot, pluginId);
|
||||
|
||||
const addRoute: PluginContentLoadedActions['addRoute'] = (
|
||||
initialRouteConfig,
|
||||
) => {
|
||||
// Trailing slash behavior is handled in a generic way for all plugins
|
||||
const finalRouteConfig = applyRouteTrailingSlash(initialRouteConfig, {
|
||||
trailingSlash: context.siteConfig.trailingSlash,
|
||||
baseUrl: context.siteConfig.baseUrl,
|
||||
});
|
||||
pluginsRouteConfigs.push(finalRouteConfig);
|
||||
};
|
||||
|
||||
const createData: PluginContentLoadedActions['createData'] = async (
|
||||
name,
|
||||
data,
|
||||
|
@ -170,6 +160,34 @@ export async function loadPlugins({
|
|||
return modulePath;
|
||||
};
|
||||
|
||||
// TODO this would be better to do all that in the codegen phase
|
||||
// TODO handle context for nested routes
|
||||
const pluginRouteContext: PluginRouteContext = {
|
||||
plugin: {name: plugin.name, id: pluginId},
|
||||
data: undefined, // TODO allow plugins to provide context data
|
||||
};
|
||||
const pluginRouteContextModulePath = await createData(
|
||||
`${docuHash('pluginRouteContextModule')}.json`,
|
||||
JSON.stringify(pluginRouteContext, null, 2),
|
||||
);
|
||||
|
||||
const addRoute: PluginContentLoadedActions['addRoute'] = (
|
||||
initialRouteConfig,
|
||||
) => {
|
||||
// Trailing slash behavior is handled in a generic way for all plugins
|
||||
const finalRouteConfig = applyRouteTrailingSlash(initialRouteConfig, {
|
||||
trailingSlash: context.siteConfig.trailingSlash,
|
||||
baseUrl: context.siteConfig.baseUrl,
|
||||
});
|
||||
pluginsRouteConfigs.push({
|
||||
...finalRouteConfig,
|
||||
modules: {
|
||||
...finalRouteConfig.modules,
|
||||
__routeContextModule: pluginRouteContextModulePath,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// the plugins global data are namespaced to avoid data conflicts:
|
||||
// - by plugin name
|
||||
// - by plugin id (allow using multiple instances of the same plugin)
|
||||
|
|
|
@ -21,6 +21,7 @@ exports[`base webpack config creates webpack aliases 1`] = `
|
|||
"@docusaurus/useDocusaurusContext": "../../../../client/exports/useDocusaurusContext.ts",
|
||||
"@docusaurus/useGlobalData": "../../../../client/exports/useGlobalData.ts",
|
||||
"@docusaurus/useIsBrowser": "../../../../client/exports/useIsBrowser.ts",
|
||||
"@docusaurus/useRouteContext": "../../../../client/exports/useRouteContext.tsx",
|
||||
"@generated": "../../../../../../..",
|
||||
"@site": "",
|
||||
"@theme-init/PluginThemeComponentEnhanced": "pluginThemeFolder/PluginThemeComponentEnhanced.js",
|
||||
|
@ -68,5 +69,6 @@ exports[`getDocusaurusAliases() returns appropriate webpack aliases 1`] = `
|
|||
"@docusaurus/useDocusaurusContext": "../../client/exports/useDocusaurusContext.ts",
|
||||
"@docusaurus/useGlobalData": "../../client/exports/useGlobalData.ts",
|
||||
"@docusaurus/useIsBrowser": "../../client/exports/useIsBrowser.ts",
|
||||
"@docusaurus/useRouteContext": "../../client/exports/useRouteContext.tsx",
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -40,6 +40,7 @@ chedeau
|
|||
cheng
|
||||
clément
|
||||
clsx
|
||||
codegen
|
||||
codeql
|
||||
codesandbox
|
||||
codespaces
|
||||
|
|
Loading…
Add table
Reference in a new issue