Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core,theme): useRouteContext + HtmlClassNameProvider #6933

Merged
merged 7 commits into from
Mar 18, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/docusaurus-module-type-aliases/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ declare module '@theme/DocItem' {
};

export type Metadata = {
readonly unversionedId?: string;
readonly description?: string;
readonly title?: string;
readonly permalink?: string;
Expand Down
10 changes: 7 additions & 3 deletions packages/docusaurus-theme-classic/src/theme/DocItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -107,6 +111,6 @@ export default function DocItem(props: Props): JSX.Element {
</div>
)}
</div>
</>
</HtmlClassNameProvider>
);
}
11 changes: 4 additions & 7 deletions packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -175,6 +172,6 @@ export default function DocPage(props: Props): JSX.Element {
</DocPageContent>
</DocsSidebarProvider>
</DocsVersionProvider>
</>
</HtmlClassNameProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DocsPreferredVersionContextProvider,
MobileSecondaryMenuProvider,
ScrollControllerProvider,
PluginHtmlClassNameProvider,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/LayoutProviders';

Expand All @@ -24,7 +25,9 @@ export default function LayoutProviders({children}: Props): JSX.Element {
<ScrollControllerProvider>
<DocsPreferredVersionContextProvider>
<MobileSecondaryMenuProvider>
{children}
<PluginHtmlClassNameProvider>
{children}
</PluginHtmlClassNameProvider>
</MobileSecondaryMenuProvider>
</DocsPreferredVersionContextProvider>
</ScrollControllerProvider>
Expand Down
5 changes: 5 additions & 0 deletions packages/docusaurus-theme-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temp because the other PR also have created a similarly named file

* 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 annoying 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(
new RegExp(
[
'docusaurus-plugin-content-',
'docusaurus-plugin-',
'docusaurus-theme-',
].join('|'),
'gi',
),
'',
)}`;
}

export function PluginHtmlClassNameProvider({children}: {children: ReactNode}) {
const routeContext = useRouteContext();
const nameClass = pluginNameToClassName(routeContext.plugin.name);
const idClass = `plugin-id-${routeContext.plugin.id}`;
return (
<HtmlClassNameProvider className={clsx(nameClass, idClass)}>
{children}
</HtmlClassNameProvider>
);
}
10 changes: 10 additions & 0 deletions packages/docusaurus-types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,16 @@ export interface RouteConfig {
[propName: string]: unknown;
}

export interface RouteContext<Data = unknown> {
data: Data; // plugin-specific contextual data
}

// Top-level plugin routes automatically add some context data to the route
// This permits to know which plugin is handling the current route
export interface PluginRouteContext<Data = unknown> extends RouteContext<Data> {
plugin: {id: string; name: string};
}

export type Route = {
readonly path: string;
readonly component: ReturnType<typeof Loadable>;
Expand Down
14 changes: 13 additions & 1 deletion packages/docusaurus/src/client/exports/ComponentCreator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]>;

Expand Down Expand Up @@ -84,7 +85,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>
);
},
});
}
22 changes: 22 additions & 0 deletions packages/docusaurus/src/client/exports/useRouteContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* 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<
Data = unknown,
>(): PluginRouteContext<Data> {
const context = React.useContext(Context);
if (!context) {
throw new Error(
'Unexpected: no Docusaurus parent/current route context found',
);
}
return context as PluginRouteContext<Data>;
}
60 changes: 60 additions & 0 deletions packages/docusaurus/src/client/routeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* 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: PluginRouteContext | 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',
);
} else {
return value;
}
}

// See TS issue https://stackoverflow.com/a/51193091/82609
// eslint-disable-next-line prefer-object-spread
const data = Object.assign({}, 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>;
}
42 changes: 30 additions & 12 deletions packages/docusaurus/src/server/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +18,7 @@ import type {
ThemeConfig,
LoadedPlugin,
InitializedPlugin,
PluginRouteContext,
} from '@docusaurus/types';
import initPlugins from './init';
import logger from '@docusaurus/logger';
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
}
`;