Skip to content

Commit

Permalink
feat(core,theme): useRouteContext + HtmlClassNameProvider (#6933)
Browse files Browse the repository at this point in the history
Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
  • Loading branch information
slorber and Josh-Cena committed Mar 18, 2022
1 parent 9b4ba78 commit 8a1421a
Show file tree
Hide file tree
Showing 14 changed files with 235 additions and 25 deletions.
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
56 changes: 56 additions & 0 deletions packages/docusaurus-theme-common/src/utils/metadataUtilsTemp.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 18 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,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>;
Expand Down
25 changes: 23 additions & 2 deletions 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 All @@ -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>
);
},
});
}

Expand Down Expand Up @@ -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 changes: 20 additions & 0 deletions packages/docusaurus/src/client/exports/useRouteContext.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 58 additions & 0 deletions packages/docusaurus/src/client/routeContext.tsx
Original file line number Diff line number Diff line change
@@ -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>;
}
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

0 comments on commit 8a1421a

Please sign in to comment.