diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index 31da033563..d8fbaa1b4b 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -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; } diff --git a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts index f14be0f462..5d7aa1e04d 100644 --- a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts +++ b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts @@ -191,6 +191,7 @@ declare module '@theme/DocItem' { }; export type Metadata = { + readonly unversionedId?: string; readonly description?: string; readonly title?: string; readonly permalink?: string; diff --git a/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx index bfea8f72f6..45b8dd52a7 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx @@ -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 ( - <> +
@@ -107,6 +111,6 @@ export default function DocItem(props: Props): JSX.Element {
)} - +
); } diff --git a/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx index b2d2ebbe32..be8ef387c7 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx @@ -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 ( - <> - - {/* TODO we should add a core addRoute({htmlClassName}) action */} - - + - + ); } diff --git a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx index 5d0d2bf859..601bea65ea 100644 --- a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx @@ -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 { - {children} + + {children} + diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 96244f0432..98e215b4d7 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -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, diff --git a/packages/docusaurus-theme-common/src/utils/metadataUtilsTemp.tsx b/packages/docusaurus-theme-common/src/utils/metadataUtilsTemp.tsx new file mode 100644 index 0000000000..f55899063b --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/metadataUtilsTemp.tsx @@ -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(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 ( + + + + + {children} + + ); +} + +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 ( + + {children} + + ); +} diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 7d86042aee..e5ef1a919b 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -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; diff --git a/packages/docusaurus/src/client/exports/ComponentCreator.tsx b/packages/docusaurus/src/client/exports/ComponentCreator.tsx index 1135df89b0..da50d1b3cf 100644 --- a/packages/docusaurus/src/client/exports/ComponentCreator.tsx +++ b/packages/docusaurus/src/client/exports/ComponentCreator.tsx @@ -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; @@ -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? + + + + ); + }, }); } @@ -84,7 +94,18 @@ export default function ComponentCreator( const Component = loadedModules.component; delete loadedModules.component; - return ; + + /* 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 ( + + ; + + ); }, }); } diff --git a/packages/docusaurus/src/client/exports/useRouteContext.tsx b/packages/docusaurus/src/client/exports/useRouteContext.tsx new file mode 100644 index 0000000000..79c57fa9ae --- /dev/null +++ b/packages/docusaurus/src/client/exports/useRouteContext.tsx @@ -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; +} diff --git a/packages/docusaurus/src/client/routeContext.tsx b/packages/docusaurus/src/client/routeContext.tsx new file mode 100644 index 0000000000..78e87a5a65 --- /dev/null +++ b/packages/docusaurus/src/client/routeContext.tsx @@ -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(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 {children}; +} diff --git a/packages/docusaurus/src/server/plugins/index.ts b/packages/docusaurus/src/server/plugins/index.ts index b279b4d7a6..56142e8c66 100644 --- a/packages/docusaurus/src/server/plugins/index.ts +++ b/packages/docusaurus/src/server/plugins/index.ts @@ -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) diff --git a/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap b/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap index d0271d7c5b..1e46aa3c5e 100644 --- a/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap +++ b/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap @@ -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", } `; diff --git a/project-words.txt b/project-words.txt index 9e125c6665..458398a940 100644 --- a/project-words.txt +++ b/project-words.txt @@ -40,6 +40,7 @@ chedeau cheng clément clsx +codegen codeql codesandbox codespaces