mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-02 19:57:25 +02:00
feat(core): rework client modules lifecycles, officially make API public (#6732)
This commit is contained in:
parent
2429bfbd59
commit
ae788c536f
16 changed files with 259 additions and 126 deletions
|
@ -5,21 +5,19 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import type {ClientModule} from '@docusaurus/types';
|
||||
|
||||
export default (function analyticsModule() {
|
||||
if (!ExecutionEnvironment.canUseDOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
onRouteUpdate({location}: {location: Location}) {
|
||||
const clientModule: ClientModule = {
|
||||
onRouteDidUpdate({location, previousLocation}) {
|
||||
if (previousLocation && location.pathname !== previousLocation.pathname) {
|
||||
// Set page so that subsequent hits on this page are attributed
|
||||
// to this page. This is recommended for Single-page Applications.
|
||||
window.ga('set', 'page', location.pathname);
|
||||
// Always refer to the variable on window in-case it gets
|
||||
// overridden elsewhere.
|
||||
window.ga('send', 'pageview');
|
||||
},
|
||||
};
|
||||
})();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default clientModule;
|
||||
|
|
|
@ -5,20 +5,16 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import globalData from '@generated/globalData';
|
||||
import type {PluginOptions} from '@docusaurus/plugin-google-gtag';
|
||||
import type {ClientModule} from '@docusaurus/types';
|
||||
|
||||
export default (function gtagModule() {
|
||||
if (!ExecutionEnvironment.canUseDOM) {
|
||||
return null;
|
||||
}
|
||||
const {trackingID} = globalData['docusaurus-plugin-google-gtag']!
|
||||
.default as PluginOptions;
|
||||
|
||||
const {trackingID} = globalData['docusaurus-plugin-google-gtag']!
|
||||
.default as PluginOptions;
|
||||
|
||||
return {
|
||||
onRouteUpdate({location}: {location: Location}) {
|
||||
const clientModule: ClientModule = {
|
||||
onRouteDidUpdate({location, previousLocation}) {
|
||||
if (previousLocation && location.pathname !== previousLocation.pathname) {
|
||||
// Always refer to the variable on window in case it gets overridden
|
||||
// elsewhere.
|
||||
window.gtag('config', trackingID, {
|
||||
|
@ -27,9 +23,11 @@ export default (function gtagModule() {
|
|||
});
|
||||
window.gtag('event', 'page_view', {
|
||||
page_title: document.title,
|
||||
page_location: location.href,
|
||||
page_location: window.location.href,
|
||||
page_path: location.pathname,
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default clientModule;
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"copy-text-to-clipboard": "^3.0.1",
|
||||
"infima": "0.2.0-alpha.38",
|
||||
"lodash": "^4.17.21",
|
||||
"nprogress": "^0.2.0",
|
||||
"postcss": "^8.4.12",
|
||||
"prism-react-renderer": "^1.3.1",
|
||||
"prismjs": "^1.28.0",
|
||||
|
@ -48,6 +49,7 @@
|
|||
"@docusaurus/module-type-aliases": "2.0.0-beta.18",
|
||||
"@docusaurus/types": "2.0.0-beta.18",
|
||||
"@types/mdx-js__react": "^1.5.5",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/rtlcss": "^3.1.4",
|
||||
"cross-env": "^7.0.3",
|
||||
|
|
|
@ -138,6 +138,7 @@ export default function docusaurusThemeClassic(
|
|||
require.resolve(getInfimaCSSFile(direction)),
|
||||
'./prism-include-languages',
|
||||
'./admonitions.css',
|
||||
'./nprogress',
|
||||
];
|
||||
|
||||
if (customCss) {
|
||||
|
|
|
@ -11,12 +11,16 @@
|
|||
* https://github.com/rstacruz/nprogress/blob/master/nprogress.css
|
||||
*/
|
||||
|
||||
:root {
|
||||
--docusaurus-progress-bar-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: #29d;
|
||||
background: var(--docusaurus-progress-bar-color);
|
||||
position: fixed;
|
||||
z-index: 1031;
|
||||
top: 0;
|
||||
|
@ -30,7 +34,8 @@
|
|||
right: 0;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 10px #29d, 0 0 5px #29d;
|
||||
box-shadow: 0 0 10px var(--docusaurus-progress-bar-color),
|
||||
0 0 5px var(--docusaurus-progress-bar-color);
|
||||
opacity: 1;
|
||||
transform: rotate(3deg) translate(0, -4px);
|
||||
}
|
31
packages/docusaurus-theme-classic/src/nprogress.ts
Normal file
31
packages/docusaurus-theme-classic/src/nprogress.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 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 nprogress from 'nprogress';
|
||||
import './nprogress.css';
|
||||
import type {ClientModule} from '@docusaurus/types';
|
||||
|
||||
nprogress.configure({showSpinner: false});
|
||||
|
||||
const delay = 200;
|
||||
|
||||
const clientModule: ClientModule = {
|
||||
onRouteUpdate({location, previousLocation}) {
|
||||
if (previousLocation && location.pathname !== previousLocation.pathname) {
|
||||
const progressBarTimeout = window.setTimeout(() => {
|
||||
nprogress.start();
|
||||
}, delay);
|
||||
return () => window.clearTimeout(progressBarTimeout);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
onRouteDidUpdate() {
|
||||
nprogress.done();
|
||||
},
|
||||
};
|
||||
|
||||
export default clientModule;
|
7
packages/docusaurus-types/src/index.d.ts
vendored
7
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -601,11 +601,14 @@ export type TOCItem = {
|
|||
};
|
||||
|
||||
export type ClientModule = {
|
||||
onRouteDidUpdate?: (args: {
|
||||
previousLocation: Location | null;
|
||||
location: Location;
|
||||
}) => (() => void) | void;
|
||||
onRouteUpdate?: (args: {
|
||||
previousLocation: Location | null;
|
||||
location: Location;
|
||||
}) => void;
|
||||
onRouteUpdateDelayed?: (args: {location: Location}) => void;
|
||||
}) => (() => void) | void;
|
||||
};
|
||||
|
||||
/** What the user configures. */
|
||||
|
|
|
@ -77,7 +77,6 @@
|
|||
"leven": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"postcss": "^8.4.12",
|
||||
"postcss-loader": "^6.2.1",
|
||||
"prompts": "^2.4.2",
|
||||
|
@ -108,7 +107,6 @@
|
|||
"@docusaurus/module-type-aliases": "2.0.0-beta.18",
|
||||
"@docusaurus/types": "2.0.0-beta.18",
|
||||
"@types/detect-port": "^1.3.2",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react-dom": "^18.0.2",
|
||||
"@types/react-router-config": "^5.0.6",
|
||||
"@types/rtl-detect": "^1.0.0",
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import '@generated/client-modules';
|
||||
|
||||
import routes from '@generated/routes';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
|
@ -19,8 +20,6 @@ import SiteMetadataDefaults from './SiteMetadataDefaults';
|
|||
import Root from '@theme/Root';
|
||||
import SiteMetadata from '@theme/SiteMetadata';
|
||||
|
||||
import './clientLifecyclesDispatcher';
|
||||
|
||||
// TODO, quick fix for CSS insertion order
|
||||
import ErrorBoundary from '@docusaurus/ErrorBoundary';
|
||||
import Error from '@theme/Error';
|
||||
|
@ -36,9 +35,7 @@ export default function App(): JSX.Element {
|
|||
<SiteMetadataDefaults />
|
||||
<SiteMetadata />
|
||||
<BaseUrlIssueBanner />
|
||||
<PendingNavigation
|
||||
location={normalizeLocation(location)}
|
||||
delay={200}>
|
||||
<PendingNavigation location={normalizeLocation(location)}>
|
||||
{routeElement}
|
||||
</PendingNavigation>
|
||||
</Root>
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 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 {useLayoutEffect, type ReactElement} from 'react';
|
||||
import clientModules from '@generated/client-modules';
|
||||
import type {ClientModule} from '@docusaurus/types';
|
||||
import type {Location} from 'history';
|
||||
|
||||
export function dispatchLifecycleAction<K extends keyof ClientModule>(
|
||||
lifecycleAction: K,
|
||||
...args: Parameters<NonNullable<ClientModule[K]>>
|
||||
): () => void {
|
||||
const callbacks = clientModules.map((clientModule) => {
|
||||
const lifecycleFunction = (clientModule?.default?.[lifecycleAction] ??
|
||||
clientModule[lifecycleAction]) as
|
||||
| ((
|
||||
...a: Parameters<NonNullable<ClientModule[K]>>
|
||||
) => (() => void) | void)
|
||||
| undefined;
|
||||
|
||||
return lifecycleFunction?.(...args);
|
||||
});
|
||||
return () => callbacks.forEach((cb) => cb?.());
|
||||
}
|
||||
|
||||
function ClientLifecyclesDispatcher({
|
||||
children,
|
||||
location,
|
||||
previousLocation,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
location: Location;
|
||||
previousLocation: Location | null;
|
||||
}): JSX.Element {
|
||||
useLayoutEffect(() => {
|
||||
if (previousLocation !== location) {
|
||||
const {hash} = location;
|
||||
if (!hash) {
|
||||
window.scrollTo(0, 0);
|
||||
} else {
|
||||
const id = decodeURIComponent(hash.substring(1));
|
||||
const element = document.getElementById(id);
|
||||
element?.scrollIntoView();
|
||||
}
|
||||
dispatchLifecycleAction('onRouteDidUpdate', {previousLocation, location});
|
||||
}
|
||||
}, [previousLocation, location]);
|
||||
return children;
|
||||
}
|
||||
|
||||
export default ClientLifecyclesDispatcher;
|
|
@ -7,18 +7,14 @@
|
|||
|
||||
import React from 'react';
|
||||
import {Route} from 'react-router-dom';
|
||||
import nprogress from 'nprogress';
|
||||
|
||||
import clientLifecyclesDispatcher from './clientLifecyclesDispatcher';
|
||||
import ClientLifecyclesDispatcher, {
|
||||
dispatchLifecycleAction,
|
||||
} from './ClientLifecyclesDispatcher';
|
||||
import ExecutionEnvironment from './exports/ExecutionEnvironment';
|
||||
import preload from './preload';
|
||||
import type {Location} from 'history';
|
||||
|
||||
import './nprogress.css';
|
||||
|
||||
nprogress.configure({showSpinner: false});
|
||||
|
||||
type Props = {
|
||||
readonly delay: number;
|
||||
readonly location: Location;
|
||||
readonly children: JSX.Element;
|
||||
};
|
||||
|
@ -28,14 +24,19 @@ type State = {
|
|||
|
||||
class PendingNavigation extends React.Component<Props, State> {
|
||||
private previousLocation: Location | null;
|
||||
private progressBarTimeout: number | null;
|
||||
private routeUpdateCleanupCb: () => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
// previousLocation doesn't affect rendering, hence not stored in state.
|
||||
this.previousLocation = null;
|
||||
this.progressBarTimeout = null;
|
||||
this.routeUpdateCleanupCb = ExecutionEnvironment.canUseDOM
|
||||
? dispatchLifecycleAction('onRouteUpdate', {
|
||||
previousLocation: null,
|
||||
location: this.props.location,
|
||||
})!
|
||||
: () => {};
|
||||
this.state = {
|
||||
nextRouteHasLoaded: true,
|
||||
};
|
||||
|
@ -56,56 +57,32 @@ class PendingNavigation extends React.Component<Props, State> {
|
|||
// Save the location first.
|
||||
this.previousLocation = this.props.location;
|
||||
this.setState({nextRouteHasLoaded: false});
|
||||
this.startProgressBar();
|
||||
this.routeUpdateCleanupCb = dispatchLifecycleAction('onRouteUpdate', {
|
||||
previousLocation: this.previousLocation,
|
||||
location: nextLocation,
|
||||
})!;
|
||||
|
||||
// Load data while the old screen remains.
|
||||
preload(nextLocation.pathname)
|
||||
.then(() => {
|
||||
clientLifecyclesDispatcher.onRouteUpdate({
|
||||
previousLocation: this.previousLocation,
|
||||
location: nextLocation,
|
||||
});
|
||||
this.setState({nextRouteHasLoaded: true}, this.stopProgressBar);
|
||||
const {hash} = nextLocation;
|
||||
if (!hash) {
|
||||
window.scrollTo(0, 0);
|
||||
} else {
|
||||
const id = decodeURIComponent(hash.substring(1));
|
||||
const element = document.getElementById(id);
|
||||
element?.scrollIntoView();
|
||||
}
|
||||
this.routeUpdateCleanupCb?.();
|
||||
this.setState({nextRouteHasLoaded: true});
|
||||
})
|
||||
.catch((e) => console.warn(e));
|
||||
return false;
|
||||
}
|
||||
|
||||
private clearProgressBarTimeout() {
|
||||
if (this.progressBarTimeout) {
|
||||
window.clearTimeout(this.progressBarTimeout);
|
||||
this.progressBarTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startProgressBar() {
|
||||
this.clearProgressBarTimeout();
|
||||
this.progressBarTimeout = window.setTimeout(() => {
|
||||
clientLifecyclesDispatcher.onRouteUpdateDelayed({
|
||||
location: this.props.location,
|
||||
});
|
||||
nprogress.start();
|
||||
}, this.props.delay);
|
||||
}
|
||||
|
||||
private stopProgressBar() {
|
||||
this.clearProgressBarTimeout();
|
||||
nprogress.done();
|
||||
}
|
||||
|
||||
override render(): JSX.Element {
|
||||
const {children, location} = this.props;
|
||||
// Use a controlled <Route> to trick all descendants into rendering the old
|
||||
// location.
|
||||
return <Route location={location} render={() => children} />;
|
||||
return (
|
||||
<ClientLifecyclesDispatcher
|
||||
previousLocation={this.previousLocation}
|
||||
location={location}>
|
||||
<Route location={location} render={() => children} />
|
||||
</ClientLifecyclesDispatcher>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/**
|
||||
* 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 clientModules from '@generated/client-modules';
|
||||
import type {ClientModule} from '@docusaurus/types';
|
||||
|
||||
function dispatchLifecycleAction<K extends keyof ClientModule>(
|
||||
lifecycleAction: K,
|
||||
args: Parameters<NonNullable<ClientModule[K]>>,
|
||||
) {
|
||||
clientModules.forEach((clientModule) => {
|
||||
const lifecycleFunction = (clientModule?.default?.[lifecycleAction] ??
|
||||
clientModule[lifecycleAction]) as
|
||||
| ((...a: Parameters<NonNullable<ClientModule[K]>>) => void)
|
||||
| undefined;
|
||||
|
||||
lifecycleFunction?.(...args);
|
||||
});
|
||||
}
|
||||
|
||||
const clientLifecyclesDispatchers: Required<ClientModule> = {
|
||||
onRouteUpdate(...args) {
|
||||
dispatchLifecycleAction('onRouteUpdate', args);
|
||||
},
|
||||
onRouteUpdateDelayed(...args) {
|
||||
dispatchLifecycleAction('onRouteUpdateDelayed', args);
|
||||
},
|
||||
};
|
||||
|
||||
export default clientLifecyclesDispatchers;
|
|
@ -215,6 +215,7 @@ preconfigured
|
|||
preconnect
|
||||
prefetch
|
||||
prefetching
|
||||
preloads
|
||||
prepended
|
||||
preprocessors
|
||||
prerendered
|
||||
|
|
|
@ -5,13 +5,48 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import siteConfig from '@generated/docusaurus.config';
|
||||
import type {Location} from 'history';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function onRouteUpdate({location}: {location: Location}): void {
|
||||
// console.log('onRouteUpdate', {location});
|
||||
function logPage(
|
||||
event: string,
|
||||
location: Location,
|
||||
previousLocation: Location | null,
|
||||
): void {
|
||||
console.log(`${event}
|
||||
Previous location: ${previousLocation?.pathname}
|
||||
Current location: ${location.pathname}
|
||||
Current heading: ${document.getElementsByTagName('h1')[0]?.innerText}`);
|
||||
}
|
||||
|
||||
if (ExecutionEnvironment.canUseDOM) {
|
||||
// console.log('client module example log');
|
||||
export function onRouteUpdate({
|
||||
location,
|
||||
previousLocation,
|
||||
}: {
|
||||
location: Location;
|
||||
previousLocation: Location | null;
|
||||
}): (() => void) | void {
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
siteConfig.customFields!.isDeployPreview
|
||||
) {
|
||||
logPage('onRouteUpdate', location, previousLocation);
|
||||
return () => logPage('onRouteUpdate cleanup', location, previousLocation);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function onRouteDidUpdate({
|
||||
location,
|
||||
previousLocation,
|
||||
}: {
|
||||
location: Location;
|
||||
previousLocation: Location | null;
|
||||
}): void {
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
siteConfig.customFields!.isDeployPreview
|
||||
) {
|
||||
logPage('onRouteDidUpdate', location, previousLocation);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,7 +86,7 @@ Client modules are part of your site's bundle, just like theme components. Howev
|
|||
|
||||
These modules are imported globally before React even renders the initial UI.
|
||||
|
||||
```js title="App.tsx"
|
||||
```js title="@docusaurus/core/App.tsx"
|
||||
// How it works under the hood
|
||||
import '@generated/client-modules';
|
||||
```
|
||||
|
@ -117,5 +117,70 @@ CSS stylesheets imported as client modules are [global](../styling-layout.md#glo
|
|||
}
|
||||
```
|
||||
|
||||
<!-- TODO client module lifecycles -->
|
||||
<!-- https://github.com/facebook/docusaurus/issues/3399 -->
|
||||
### Client module lifecycles {#client-module-lifecycles}
|
||||
|
||||
Besides introducing side-effects, client modules can optionally export two lifecycle functions: `onRouteUpdate` and `onRouteDidUpdate`.
|
||||
|
||||
Because Docusaurus builds a single-page application, `script` tags will only be executed the first time the page loads, but will not re-execute on page transitions. These lifecycles are useful if you have some imperative JS logic that should execute every time a new page has loaded, e.g., to manipulate DOM elements, to send analytics data, etc.
|
||||
|
||||
For every route transition, there will be several important timings:
|
||||
|
||||
1. The user clicks a link, which causes the router to change its current location.
|
||||
2. Docusaurus preloads the next route's assets, while keeping displaying the current page's content.
|
||||
3. The next route's assets have loaded.
|
||||
4. The new location's route component gets rendered to DOM.
|
||||
|
||||
`onRouteUpdate` will be called at event (2), and `onRouteDidUpdate` will be called at (4). They both receive the current location and the previous location (which can be `null`, if this is the first screen).
|
||||
|
||||
`onRouteUpdate` can optionally return a "cleanup" callback, which will be called at (3). For example, if you want to display a progress bar, you can start a timeout in `onRouteUpdate`, and clear the timeout in the callback. (The classic theme already provides an `nprogress` integration this way.)
|
||||
|
||||
Note that the new page's DOM is only available during event (4). If you need to manipulate the new page's DOM, you'll likely want to use `onRouteDidUpdate`, which will be fired as soon as the DOM on the new page has mounted.
|
||||
|
||||
```js title="myClientModule.js"
|
||||
import type {Location} from 'history';
|
||||
|
||||
export function onRouteDidUpdate({location, previousLocation}) {
|
||||
// Don't execute if we are still on the same page; the lifecycle may be fired
|
||||
// because the hash changes (e.g. when navigating between headings)
|
||||
if (location.pathname !== previousLocation?.pathname) {
|
||||
const title = document.getElementsByTagName('h1')[0];
|
||||
if (title) {
|
||||
title.innerText += '❤️';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onRouteUpdate({location, previousLocation}) {
|
||||
if (location.pathname !== previousLocation?.pathname) {
|
||||
const progressBarTimeout = window.setTimeout(() => {
|
||||
nprogress.start();
|
||||
}, delay);
|
||||
return () => window.clearTimeout(progressBarTimeout);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Or, if you are using TypeScript and you want to leverage contextual typing:
|
||||
|
||||
```ts title="myClientModule.js"
|
||||
import type {ClientModule} from '@docusaurus/types';
|
||||
|
||||
const module: ClientModule = {
|
||||
onRouteUpdate({location, previousLocation}) {
|
||||
// ...
|
||||
},
|
||||
onRouteDidUpdate({location, previousLocation}) {
|
||||
// ...
|
||||
},
|
||||
};
|
||||
export default module;
|
||||
```
|
||||
|
||||
Both lifecycles will fire on first render, but they will not fire on server-side, so you can safely access browser globals in them.
|
||||
|
||||
:::tip Prefer using React
|
||||
|
||||
Client module lifecycles are purely imperative, and you can't use React hooks or access React contexts within them. If your operations are state-driven or involve complicated DOM manipulations, you should consider [swizzling components](../swizzling.md) instead.
|
||||
|
||||
:::
|
||||
|
|
|
@ -113,6 +113,7 @@ const config = {
|
|||
onBrokenMarkdownLinks: 'warn',
|
||||
favicon: 'img/docusaurus.ico',
|
||||
customFields: {
|
||||
isDeployPreview,
|
||||
description:
|
||||
'An optimized site generator in React. Docusaurus helps you to move fast and write content. Build documentation websites, blogs, marketing pages, and more.',
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue