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 all 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,56 @@
/**
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 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?
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤪 good catch

There's also a native "Loading" screen but afaik it's never displayed so 🤷‍♂️

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, the "native" name isn't the best, but I don't think this will be targeted anyways...

<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
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

not sure we really want to deep merge

In case there are multiple layers I'd rather ensure that laters do not override each others, there's no good use-case to do so that I can think of
We can keep the comment for now and figure this out later anyway

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think it would be immediately useful for content plugins, but maybe in userland? Deep merging sounds more natural about how these stores should behave

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