mirror of
https://github.com/facebook/docusaurus.git
synced 2025-07-23 19:48:54 +02:00
feat(core): add new plugin allContentLoaded lifecycle (#9931)
This commit is contained in:
parent
d02b96f7f5
commit
8d115a9e0d
7 changed files with 791 additions and 228 deletions
|
@ -30,7 +30,7 @@ export default function pluginDebug({
|
|||
return '../src/theme';
|
||||
},
|
||||
|
||||
async contentLoaded({actions: {createData, addRoute}, allContent}) {
|
||||
async allContentLoaded({actions: {createData, addRoute}, allContent}) {
|
||||
const allContentPath = await createData(
|
||||
// Note that this created data path must be in sync with
|
||||
// metadataPath provided to mdx-loader.
|
||||
|
|
16
packages/docusaurus-types/src/plugin.d.ts
vendored
16
packages/docusaurus-types/src/plugin.d.ts
vendored
|
@ -18,11 +18,11 @@ import type {RouteConfig} from './routing';
|
|||
|
||||
export type PluginOptions = {id?: string} & {[key: string]: unknown};
|
||||
|
||||
export type PluginConfig =
|
||||
export type PluginConfig<Content = unknown> =
|
||||
| string
|
||||
| [string, PluginOptions]
|
||||
| [PluginModule, PluginOptions]
|
||||
| PluginModule
|
||||
| [PluginModule<Content>, PluginOptions]
|
||||
| PluginModule<Content>
|
||||
| false
|
||||
| null;
|
||||
|
||||
|
@ -110,7 +110,9 @@ export type Plugin<Content = unknown> = {
|
|||
contentLoaded?: (args: {
|
||||
/** The content loaded by this plugin instance */
|
||||
content: Content; //
|
||||
/** Content loaded by ALL the plugins */
|
||||
actions: PluginContentLoadedActions;
|
||||
}) => Promise<void> | void;
|
||||
allContentLoaded?: (args: {
|
||||
allContent: AllContent;
|
||||
actions: PluginContentLoadedActions;
|
||||
}) => Promise<void> | void;
|
||||
|
@ -183,8 +185,10 @@ export type LoadedPlugin = InitializedPlugin & {
|
|||
readonly content: unknown;
|
||||
};
|
||||
|
||||
export type PluginModule = {
|
||||
(context: LoadContext, options: unknown): Plugin | Promise<Plugin>;
|
||||
export type PluginModule<Content = unknown> = {
|
||||
(context: LoadContext, options: unknown):
|
||||
| Plugin<Content>
|
||||
| Promise<Plugin<Content>>;
|
||||
validateOptions?: <T, U>(data: OptionValidationContext<T, U>) => U;
|
||||
validateThemeConfig?: <T>(data: ThemeConfigValidationContext<T>) => T;
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@
|
|||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.0.0",
|
||||
"@docusaurus/types": "3.0.0",
|
||||
"@total-typescript/shoehorn": "^0.1.2",
|
||||
"@types/detect-port": "^1.3.3",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-router-config": "^5.0.7",
|
||||
|
|
|
@ -1,82 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`loadPlugins loads plugins 1`] = `
|
||||
{
|
||||
"globalData": {
|
||||
"test1": {
|
||||
"default": {
|
||||
"content": "a",
|
||||
"prop": "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"content": "a",
|
||||
"contentLoaded": [Function],
|
||||
"loadContent": [Function],
|
||||
"name": "test1",
|
||||
"options": {
|
||||
"id": "default",
|
||||
},
|
||||
"path": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin",
|
||||
"prop": "a",
|
||||
"version": {
|
||||
"type": "local",
|
||||
},
|
||||
},
|
||||
{
|
||||
"configureWebpack": [Function],
|
||||
"content": undefined,
|
||||
"name": "test2",
|
||||
"options": {
|
||||
"id": "default",
|
||||
},
|
||||
"path": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin",
|
||||
"version": {
|
||||
"type": "local",
|
||||
},
|
||||
},
|
||||
{
|
||||
"content": undefined,
|
||||
"getClientModules": [Function],
|
||||
"injectHtmlTags": [Function],
|
||||
"name": "docusaurus-bootstrap-plugin",
|
||||
"options": {
|
||||
"id": "default",
|
||||
},
|
||||
"path": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin",
|
||||
"version": {
|
||||
"type": "synthetic",
|
||||
},
|
||||
},
|
||||
{
|
||||
"configureWebpack": [Function],
|
||||
"content": undefined,
|
||||
"name": "docusaurus-mdx-fallback-plugin",
|
||||
"options": {
|
||||
"id": "default",
|
||||
},
|
||||
"path": ".",
|
||||
"version": {
|
||||
"type": "synthetic",
|
||||
},
|
||||
},
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"data": {
|
||||
"content": "path",
|
||||
},
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/test1/default/plugin-route-context-module-100.json",
|
||||
},
|
||||
"modules": {
|
||||
"content": "path",
|
||||
},
|
||||
"path": "foo/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
|
@ -6,53 +6,523 @@
|
|||
*/
|
||||
|
||||
import path from 'path';
|
||||
import {loadPlugins} from '../plugins';
|
||||
import type {Plugin, Props} from '@docusaurus/types';
|
||||
import {fromPartial} from '@total-typescript/shoehorn';
|
||||
import {loadPlugins, mergeGlobalData} from '../plugins';
|
||||
import type {
|
||||
GlobalData,
|
||||
LoadContext,
|
||||
Plugin,
|
||||
PluginConfig,
|
||||
} from '@docusaurus/types';
|
||||
|
||||
describe('loadPlugins', () => {
|
||||
it('loads plugins', async () => {
|
||||
const siteDir = path.join(__dirname, '__fixtures__/site-with-plugin');
|
||||
await expect(
|
||||
loadPlugins({
|
||||
siteDir,
|
||||
generatedFilesDir: path.join(siteDir, '.docusaurus'),
|
||||
outDir: path.join(siteDir, 'build'),
|
||||
siteConfig: {
|
||||
baseUrl: '/',
|
||||
trailingSlash: true,
|
||||
themeConfig: {},
|
||||
presets: [],
|
||||
plugins: [
|
||||
() =>
|
||||
({
|
||||
name: 'test1',
|
||||
prop: 'a',
|
||||
async loadContent() {
|
||||
// Testing that plugin lifecycle is bound to the instance
|
||||
return this.prop;
|
||||
},
|
||||
async contentLoaded({content, actions}) {
|
||||
actions.addRoute({
|
||||
path: 'foo',
|
||||
component: 'Comp',
|
||||
modules: {content: 'path'},
|
||||
context: {content: 'path'},
|
||||
});
|
||||
actions.setGlobalData({content, prop: this.prop});
|
||||
},
|
||||
} as Plugin & ThisType<{prop: 'a'}>),
|
||||
],
|
||||
themes: [
|
||||
() => ({
|
||||
name: 'test2',
|
||||
configureWebpack() {
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
function testLoad({
|
||||
plugins,
|
||||
themes,
|
||||
}: {
|
||||
plugins: PluginConfig<any>[];
|
||||
themes: PluginConfig<any>[];
|
||||
}) {
|
||||
const siteDir = path.join(__dirname, '__fixtures__/site-with-plugin');
|
||||
|
||||
const context = fromPartial<LoadContext>({
|
||||
siteDir,
|
||||
siteConfigPath: path.join(siteDir, 'docusaurus.config.js'),
|
||||
generatedFilesDir: path.join(siteDir, '.docusaurus'),
|
||||
outDir: path.join(siteDir, 'build'),
|
||||
siteConfig: {
|
||||
baseUrl: '/',
|
||||
trailingSlash: true,
|
||||
themeConfig: {},
|
||||
presets: [],
|
||||
plugins,
|
||||
themes,
|
||||
},
|
||||
});
|
||||
|
||||
return loadPlugins(context);
|
||||
}
|
||||
|
||||
const SyntheticPluginNames = [
|
||||
'docusaurus-bootstrap-plugin',
|
||||
'docusaurus-mdx-fallback-plugin',
|
||||
];
|
||||
|
||||
async function testPlugin<Content = unknown>(
|
||||
pluginConfig: PluginConfig<Content>,
|
||||
) {
|
||||
const {plugins, routes, globalData} = await testLoad({
|
||||
plugins: [pluginConfig],
|
||||
themes: [],
|
||||
});
|
||||
|
||||
const nonSyntheticPlugins = plugins.filter(
|
||||
(p) => !SyntheticPluginNames.includes(p.name),
|
||||
);
|
||||
expect(nonSyntheticPlugins).toHaveLength(1);
|
||||
const plugin = nonSyntheticPlugins[0]!;
|
||||
expect(plugin).toBeDefined();
|
||||
|
||||
return {plugin, routes, globalData};
|
||||
}
|
||||
|
||||
describe('mergeGlobalData', () => {
|
||||
it('no global data', () => {
|
||||
expect(mergeGlobalData()).toEqual({});
|
||||
});
|
||||
|
||||
it('1 global data', () => {
|
||||
const globalData: GlobalData = {
|
||||
plugin: {
|
||||
default: {someData: 'val'},
|
||||
},
|
||||
};
|
||||
expect(mergeGlobalData(globalData)).toEqual(globalData);
|
||||
});
|
||||
|
||||
it('1 global data - primitive value', () => {
|
||||
// For retro-compatibility we allow primitive values to be kept as is
|
||||
// Not sure anyone is using primitive global data though...
|
||||
const globalData: GlobalData = {
|
||||
plugin: {
|
||||
default: 42,
|
||||
},
|
||||
};
|
||||
expect(mergeGlobalData(globalData)).toEqual(globalData);
|
||||
});
|
||||
|
||||
it('3 distinct plugins global data', () => {
|
||||
const globalData1: GlobalData = {
|
||||
plugin1: {
|
||||
default: {someData1: 'val1'},
|
||||
},
|
||||
};
|
||||
const globalData2: GlobalData = {
|
||||
plugin2: {
|
||||
default: {someData2: 'val2'},
|
||||
},
|
||||
};
|
||||
const globalData3: GlobalData = {
|
||||
plugin3: {
|
||||
default: {someData3: 'val3'},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mergeGlobalData(globalData1, globalData2, globalData3)).toEqual({
|
||||
plugin1: {
|
||||
default: {someData1: 'val1'},
|
||||
},
|
||||
plugin2: {
|
||||
default: {someData2: 'val2'},
|
||||
},
|
||||
plugin3: {
|
||||
default: {someData3: 'val3'},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('3 plugin instances of same plugin', () => {
|
||||
const globalData1: GlobalData = {
|
||||
plugin: {
|
||||
id1: {someData1: 'val1'},
|
||||
},
|
||||
};
|
||||
const globalData2: GlobalData = {
|
||||
plugin: {
|
||||
id2: {someData2: 'val2'},
|
||||
},
|
||||
};
|
||||
const globalData3: GlobalData = {
|
||||
plugin: {
|
||||
id3: {someData3: 'val3'},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mergeGlobalData(globalData1, globalData2, globalData3)).toEqual({
|
||||
plugin: {
|
||||
id1: {someData1: 'val1'},
|
||||
id2: {someData2: 'val2'},
|
||||
id3: {someData3: 'val3'},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('3 times the same plugin', () => {
|
||||
const globalData1: GlobalData = {
|
||||
plugin: {
|
||||
id: {someData1: 'val1', shared: 'shared1'},
|
||||
},
|
||||
};
|
||||
const globalData2: GlobalData = {
|
||||
plugin: {
|
||||
id: {someData2: 'val2', shared: 'shared2'},
|
||||
},
|
||||
};
|
||||
const globalData3: GlobalData = {
|
||||
plugin: {
|
||||
id: {someData3: 'val3', shared: 'shared3'},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mergeGlobalData(globalData1, globalData2, globalData3)).toEqual({
|
||||
plugin: {
|
||||
id: {
|
||||
someData1: 'val1',
|
||||
someData2: 'val2',
|
||||
someData3: 'val3',
|
||||
shared: 'shared3',
|
||||
},
|
||||
siteConfigPath: path.join(siteDir, 'docusaurus.config.js'),
|
||||
} as unknown as Props),
|
||||
).resolves.toMatchSnapshot();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('3 times same plugin - including primitive values', () => {
|
||||
// Very unlikely to happen, but we can't merge primitive values together
|
||||
// Since we use Object.assign(), the primitive values are simply ignored
|
||||
const globalData1: GlobalData = {
|
||||
plugin: {
|
||||
default: 42,
|
||||
},
|
||||
};
|
||||
const globalData2: GlobalData = {
|
||||
plugin: {
|
||||
default: {hey: 'val'},
|
||||
},
|
||||
};
|
||||
const globalData3: GlobalData = {
|
||||
plugin: {
|
||||
default: 84,
|
||||
},
|
||||
};
|
||||
expect(mergeGlobalData(globalData1, globalData2, globalData3)).toEqual({
|
||||
plugin: {
|
||||
default: {hey: 'val'},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('real world case', () => {
|
||||
const globalData1: GlobalData = {
|
||||
plugin1: {
|
||||
id1: {someData1: 'val1', shared: 'globalData1'},
|
||||
},
|
||||
};
|
||||
const globalData2: GlobalData = {
|
||||
plugin1: {
|
||||
id1: {someData2: 'val2', shared: 'globalData2'},
|
||||
},
|
||||
};
|
||||
|
||||
const globalData3: GlobalData = {
|
||||
plugin1: {
|
||||
id2: {someData3: 'val3', shared: 'globalData3'},
|
||||
},
|
||||
};
|
||||
|
||||
const globalData4: GlobalData = {
|
||||
plugin2: {
|
||||
id1: {someData1: 'val1', shared: 'globalData4'},
|
||||
},
|
||||
};
|
||||
const globalData5: GlobalData = {
|
||||
plugin2: {
|
||||
id2: {someData1: 'val1', shared: 'globalData5'},
|
||||
},
|
||||
};
|
||||
|
||||
const globalData6: GlobalData = {
|
||||
plugin3: {
|
||||
id1: {someData1: 'val1', shared: 'globalData6'},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
mergeGlobalData(
|
||||
globalData1,
|
||||
globalData2,
|
||||
globalData3,
|
||||
globalData4,
|
||||
globalData5,
|
||||
globalData6,
|
||||
),
|
||||
).toEqual({
|
||||
plugin1: {
|
||||
id1: {someData1: 'val1', someData2: 'val2', shared: 'globalData2'},
|
||||
id2: {someData3: 'val3', shared: 'globalData3'},
|
||||
},
|
||||
plugin2: {
|
||||
id1: {someData1: 'val1', shared: 'globalData4'},
|
||||
id2: {someData1: 'val1', shared: 'globalData5'},
|
||||
},
|
||||
plugin3: {
|
||||
id1: {someData1: 'val1', shared: 'globalData6'},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPlugins', () => {
|
||||
it('registers default synthetic plugins', async () => {
|
||||
const {plugins, routes, globalData} = await testLoad({
|
||||
plugins: [],
|
||||
themes: [],
|
||||
});
|
||||
// This adds some default synthetic plugins by default
|
||||
expect(plugins.map((p) => p.name)).toEqual(SyntheticPluginNames);
|
||||
expect(routes).toEqual([]);
|
||||
expect(globalData).toEqual({});
|
||||
});
|
||||
|
||||
it('simplest plugin', async () => {
|
||||
const {plugin, routes, globalData} = await testPlugin(() => ({
|
||||
name: 'plugin-name',
|
||||
}));
|
||||
expect(plugin.name).toBe('plugin-name');
|
||||
expect(routes).toEqual([]);
|
||||
expect(globalData).toEqual({});
|
||||
});
|
||||
|
||||
it('typical plugin', async () => {
|
||||
const {plugin, routes, globalData} = await testPlugin(() => ({
|
||||
name: 'plugin-name',
|
||||
loadContent: () => ({name: 'Toto', age: 42}),
|
||||
translateContent: ({content}) => ({
|
||||
...content,
|
||||
name: `${content.name} (translated)`,
|
||||
}),
|
||||
contentLoaded({content, actions}) {
|
||||
actions.addRoute({
|
||||
path: '/foo',
|
||||
component: 'Comp',
|
||||
modules: {someModule: 'someModulePath'},
|
||||
context: {someContext: 'someContextPath'},
|
||||
});
|
||||
actions.setGlobalData({
|
||||
globalName: content.name,
|
||||
globalAge: content.age,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
expect(plugin.content).toMatchInlineSnapshot(`
|
||||
{
|
||||
"age": 42,
|
||||
"name": "Toto (translated)",
|
||||
}
|
||||
`);
|
||||
expect(routes).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"data": {
|
||||
"someContext": "someContextPath",
|
||||
},
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json",
|
||||
},
|
||||
"modules": {
|
||||
"someModule": "someModulePath",
|
||||
},
|
||||
"path": "/foo/",
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(globalData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"plugin-name": {
|
||||
"default": {
|
||||
"globalAge": 42,
|
||||
"globalName": "Toto (translated)",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('plugin with options', async () => {
|
||||
const pluginOptions = {id: 'plugin-id', someOption: 42};
|
||||
|
||||
const {plugin, routes, globalData} = await testPlugin([
|
||||
(_context, options) => ({
|
||||
name: 'plugin-name',
|
||||
loadContent: () => ({options, name: 'Toto'}),
|
||||
contentLoaded({content, actions}) {
|
||||
actions.addRoute({
|
||||
path: '/foo',
|
||||
component: 'Comp',
|
||||
});
|
||||
actions.setGlobalData({
|
||||
// @ts-expect-error: TODO fix plugin/option type inference issue
|
||||
globalName: content.name,
|
||||
// @ts-expect-error: TODO fix plugin/option type inference issue
|
||||
globalSomeOption: content.options.someOption,
|
||||
});
|
||||
},
|
||||
}),
|
||||
pluginOptions,
|
||||
]);
|
||||
|
||||
expect(plugin.name).toBe('plugin-name');
|
||||
expect(plugin.options).toEqual(pluginOptions);
|
||||
expect(plugin.content).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "Toto",
|
||||
"options": {
|
||||
"id": "plugin-id",
|
||||
"someOption": 42,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
expect(routes).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/plugin-id/plugin-route-context-module-100.json",
|
||||
},
|
||||
"path": "/foo/",
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(globalData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"plugin-name": {
|
||||
"plugin-id": {
|
||||
"globalName": "Toto",
|
||||
"globalSomeOption": 42,
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('plugin with This binding', async () => {
|
||||
const {plugin, routes, globalData} = await testPlugin(
|
||||
() =>
|
||||
({
|
||||
name: 'plugin-name',
|
||||
someAttribute: 'val',
|
||||
async loadContent() {
|
||||
return this.someAttribute;
|
||||
},
|
||||
async contentLoaded({content, actions}) {
|
||||
actions.setGlobalData({
|
||||
content,
|
||||
someAttributeGlobal: this.someAttribute,
|
||||
});
|
||||
},
|
||||
} as Plugin & ThisType<{someAttribute: string}>),
|
||||
);
|
||||
|
||||
expect(plugin.content).toMatchInlineSnapshot(`"val"`);
|
||||
expect(routes).toMatchInlineSnapshot(`[]`);
|
||||
expect(globalData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"plugin-name": {
|
||||
"default": {
|
||||
"content": "val",
|
||||
"someAttributeGlobal": "val",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('plugin with contentLoaded + allContentLoaded lifecycle', async () => {
|
||||
const {routes, globalData} = await testPlugin(() => ({
|
||||
name: 'plugin-name',
|
||||
contentLoaded({actions}) {
|
||||
actions.addRoute({
|
||||
path: '/contentLoadedRouteParent',
|
||||
component: 'Comp',
|
||||
routes: [
|
||||
{path: '/contentLoadedRouteParent/child', component: 'Comp'},
|
||||
],
|
||||
});
|
||||
actions.addRoute({
|
||||
path: '/contentLoadedRouteSingle',
|
||||
component: 'Comp',
|
||||
});
|
||||
actions.setGlobalData({
|
||||
globalContentLoaded: 'val1',
|
||||
globalOverridden: 'initial-value',
|
||||
});
|
||||
},
|
||||
allContentLoaded({actions}) {
|
||||
actions.addRoute({
|
||||
path: '/allContentLoadedRouteParent',
|
||||
component: 'Comp',
|
||||
routes: [
|
||||
{path: '/allContentLoadedRouteParent/child', component: 'Comp'},
|
||||
],
|
||||
});
|
||||
actions.addRoute({
|
||||
path: '/allContentLoadedRouteSingle',
|
||||
component: 'Comp',
|
||||
});
|
||||
actions.setGlobalData({
|
||||
globalAllContentLoaded: 'val2',
|
||||
globalOverridden: 'override-value',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Routes of both lifecycles are appropriately sorted
|
||||
expect(routes).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json",
|
||||
},
|
||||
"path": "/allContentLoadedRouteSingle/",
|
||||
},
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json",
|
||||
},
|
||||
"path": "/contentLoadedRouteSingle/",
|
||||
},
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json",
|
||||
},
|
||||
"path": "/allContentLoadedRouteParent/",
|
||||
"routes": [
|
||||
{
|
||||
"component": "Comp",
|
||||
"path": "/allContentLoadedRouteParent/child/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "Comp",
|
||||
"context": {
|
||||
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json",
|
||||
},
|
||||
"path": "/contentLoadedRouteParent/",
|
||||
"routes": [
|
||||
{
|
||||
"component": "Comp",
|
||||
"path": "/contentLoadedRouteParent/child/",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
expect(globalData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"plugin-name": {
|
||||
"default": {
|
||||
"globalAllContentLoaded": "val2",
|
||||
"globalContentLoaded": "val1",
|
||||
"globalOverridden": "override-value",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
91
packages/docusaurus/src/server/plugins/actions.ts
Normal file
91
packages/docusaurus/src/server/plugins/actions.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 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 path from 'path';
|
||||
import {docuHash, generate} from '@docusaurus/utils';
|
||||
import {applyRouteTrailingSlash} from './routeConfig';
|
||||
import type {
|
||||
LoadedPlugin,
|
||||
PluginContentLoadedActions,
|
||||
PluginRouteContext,
|
||||
RouteConfig,
|
||||
} from '@docusaurus/types';
|
||||
|
||||
type PluginActionUtils = {
|
||||
getRoutes: () => RouteConfig[];
|
||||
getGlobalData: () => unknown;
|
||||
getActions: () => PluginContentLoadedActions;
|
||||
};
|
||||
|
||||
// TODO refactor historical action system and make this side-effect-free
|
||||
// If the function were pure, we could more easily compare previous/next values
|
||||
// on site reloads, and bail-out of the reload process earlier
|
||||
// Particularly, createData() modules should rather be declarative
|
||||
export async function createPluginActionsUtils({
|
||||
plugin,
|
||||
generatedFilesDir,
|
||||
baseUrl,
|
||||
trailingSlash,
|
||||
}: {
|
||||
plugin: LoadedPlugin;
|
||||
generatedFilesDir: string;
|
||||
baseUrl: string;
|
||||
trailingSlash: boolean | undefined;
|
||||
}): Promise<PluginActionUtils> {
|
||||
const pluginId = plugin.options.id;
|
||||
// Plugins data files are namespaced by pluginName/pluginId
|
||||
const dataDir = path.join(generatedFilesDir, plugin.name, pluginId);
|
||||
|
||||
const pluginRouteContext: PluginRouteContext['plugin'] = {
|
||||
name: plugin.name,
|
||||
id: pluginId,
|
||||
};
|
||||
const pluginRouteContextModulePath = path.join(
|
||||
dataDir,
|
||||
`${docuHash('pluginRouteContextModule')}.json`,
|
||||
);
|
||||
await generate(
|
||||
'/',
|
||||
pluginRouteContextModulePath,
|
||||
JSON.stringify(pluginRouteContext, null, 2),
|
||||
);
|
||||
|
||||
const routes: RouteConfig[] = [];
|
||||
let globalData: unknown;
|
||||
|
||||
const actions: PluginContentLoadedActions = {
|
||||
addRoute(initialRouteConfig) {
|
||||
// Trailing slash behavior is handled generically for all plugins
|
||||
const finalRouteConfig = applyRouteTrailingSlash(initialRouteConfig, {
|
||||
baseUrl,
|
||||
trailingSlash,
|
||||
});
|
||||
routes.push({
|
||||
...finalRouteConfig,
|
||||
context: {
|
||||
...(finalRouteConfig.context && {data: finalRouteConfig.context}),
|
||||
plugin: pluginRouteContextModulePath,
|
||||
},
|
||||
});
|
||||
},
|
||||
async createData(name, data) {
|
||||
const modulePath = path.join(dataDir, name);
|
||||
await generate(dataDir, name, data);
|
||||
return modulePath;
|
||||
},
|
||||
setGlobalData(data) {
|
||||
globalData = data;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
// Some variables are mutable, so we expose a getter instead of the value
|
||||
getRoutes: () => routes,
|
||||
getGlobalData: () => globalData,
|
||||
getActions: () => actions,
|
||||
};
|
||||
}
|
|
@ -5,24 +5,21 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import _ from 'lodash';
|
||||
import {docuHash, generate} from '@docusaurus/utils';
|
||||
import logger from '@docusaurus/logger';
|
||||
import {initPlugins} from './init';
|
||||
import {createBootstrapPlugin, createMDXFallbackPlugin} from './synthetic';
|
||||
import {localizePluginTranslationFile} from '../translations/translations';
|
||||
import {applyRouteTrailingSlash, sortRoutes} from './routeConfig';
|
||||
import {sortRoutes} from './routeConfig';
|
||||
import {PerfLogger} from '../../utils';
|
||||
import {createPluginActionsUtils} from './actions';
|
||||
import type {
|
||||
LoadContext,
|
||||
PluginContentLoadedActions,
|
||||
RouteConfig,
|
||||
AllContent,
|
||||
GlobalData,
|
||||
LoadedPlugin,
|
||||
InitializedPlugin,
|
||||
PluginRouteContext,
|
||||
} from '@docusaurus/types';
|
||||
import type {PluginIdentifier} from '@docusaurus/types/src/plugin';
|
||||
|
||||
|
@ -107,24 +104,12 @@ function aggregateAllContent(loadedPlugins: LoadedPlugin[]): AllContent {
|
|||
.value();
|
||||
}
|
||||
|
||||
// TODO refactor and make this side-effect-free
|
||||
// If the function was pure, we could more easily compare previous/next values
|
||||
// on site reloads, and bail-out of the reload process earlier
|
||||
// createData() modules should rather be declarative
|
||||
async function executePluginContentLoaded({
|
||||
plugin,
|
||||
context,
|
||||
allContent,
|
||||
}: {
|
||||
plugin: LoadedPlugin;
|
||||
context: LoadContext;
|
||||
// TODO AllContent was injected to this lifecycle for the debug plugin
|
||||
// This is what permits to create the debug routes for all other plugins
|
||||
// This was likely a bad idea and prevents to start executing contentLoaded()
|
||||
// until all plugins have finished loading all the data
|
||||
// we'd rather remove this and find another way to implement the debug plugin
|
||||
// A possible solution: make it a core feature instead of a plugin?
|
||||
allContent: AllContent;
|
||||
}): Promise<{routes: RouteConfig[]; globalData: unknown}> {
|
||||
return PerfLogger.async(
|
||||
`Plugins - contentLoaded - ${plugin.name}@${plugin.options.id}`,
|
||||
|
@ -132,63 +117,53 @@ async function executePluginContentLoaded({
|
|||
if (!plugin.contentLoaded) {
|
||||
return {routes: [], globalData: undefined};
|
||||
}
|
||||
|
||||
const pluginId = plugin.options.id;
|
||||
// Plugins data files are namespaced by pluginName/pluginId
|
||||
const dataDir = path.join(
|
||||
context.generatedFilesDir,
|
||||
plugin.name,
|
||||
pluginId,
|
||||
);
|
||||
const pluginRouteContextModulePath = path.join(
|
||||
dataDir,
|
||||
`${docuHash('pluginRouteContextModule')}.json`,
|
||||
);
|
||||
const pluginRouteContext: PluginRouteContext['plugin'] = {
|
||||
name: plugin.name,
|
||||
id: pluginId,
|
||||
};
|
||||
await generate(
|
||||
'/',
|
||||
pluginRouteContextModulePath,
|
||||
JSON.stringify(pluginRouteContext, null, 2),
|
||||
);
|
||||
|
||||
const routes: RouteConfig[] = [];
|
||||
let globalData: unknown;
|
||||
|
||||
const actions: PluginContentLoadedActions = {
|
||||
addRoute(initialRouteConfig) {
|
||||
// Trailing slash behavior is handled generically for all plugins
|
||||
const finalRouteConfig = applyRouteTrailingSlash(
|
||||
initialRouteConfig,
|
||||
context.siteConfig,
|
||||
);
|
||||
routes.push({
|
||||
...finalRouteConfig,
|
||||
context: {
|
||||
...(finalRouteConfig.context && {data: finalRouteConfig.context}),
|
||||
plugin: pluginRouteContextModulePath,
|
||||
},
|
||||
});
|
||||
},
|
||||
async createData(name, data) {
|
||||
const modulePath = path.join(dataDir, name);
|
||||
await generate(dataDir, name, data);
|
||||
return modulePath;
|
||||
},
|
||||
setGlobalData(data) {
|
||||
globalData = data;
|
||||
},
|
||||
};
|
||||
|
||||
const pluginActionsUtils = await createPluginActionsUtils({
|
||||
plugin,
|
||||
generatedFilesDir: context.generatedFilesDir,
|
||||
baseUrl: context.siteConfig.baseUrl,
|
||||
trailingSlash: context.siteConfig.trailingSlash,
|
||||
});
|
||||
await plugin.contentLoaded({
|
||||
content: plugin.content,
|
||||
actions,
|
||||
allContent,
|
||||
actions: pluginActionsUtils.getActions(),
|
||||
});
|
||||
return {
|
||||
routes: pluginActionsUtils.getRoutes(),
|
||||
globalData: pluginActionsUtils.getGlobalData(),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {routes, globalData};
|
||||
async function executePluginAllContentLoaded({
|
||||
plugin,
|
||||
context,
|
||||
allContent,
|
||||
}: {
|
||||
plugin: LoadedPlugin;
|
||||
context: LoadContext;
|
||||
allContent: AllContent;
|
||||
}): Promise<{routes: RouteConfig[]; globalData: unknown}> {
|
||||
return PerfLogger.async(
|
||||
`Plugins - allContentLoaded - ${plugin.name}@${plugin.options.id}`,
|
||||
async () => {
|
||||
if (!plugin.allContentLoaded) {
|
||||
return {routes: [], globalData: undefined};
|
||||
}
|
||||
const pluginActionsUtils = await createPluginActionsUtils({
|
||||
plugin,
|
||||
generatedFilesDir: context.generatedFilesDir,
|
||||
baseUrl: context.siteConfig.baseUrl,
|
||||
trailingSlash: context.siteConfig.trailingSlash,
|
||||
});
|
||||
await plugin.allContentLoaded({
|
||||
allContent,
|
||||
actions: pluginActionsUtils.getActions(),
|
||||
});
|
||||
return {
|
||||
routes: pluginActionsUtils.getRoutes(),
|
||||
globalData: pluginActionsUtils.getGlobalData(),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -201,6 +176,42 @@ async function executePluginsContentLoaded({
|
|||
context: LoadContext;
|
||||
}): Promise<{routes: RouteConfig[]; globalData: GlobalData}> {
|
||||
return PerfLogger.async(`Plugins - contentLoaded`, async () => {
|
||||
const routes: RouteConfig[] = [];
|
||||
const globalData: GlobalData = {};
|
||||
|
||||
await Promise.all(
|
||||
plugins.map(async (plugin) => {
|
||||
const {routes: pluginRoutes, globalData: pluginGlobalData} =
|
||||
await executePluginContentLoaded({
|
||||
plugin,
|
||||
context,
|
||||
});
|
||||
|
||||
routes.push(...pluginRoutes);
|
||||
|
||||
if (pluginGlobalData !== undefined) {
|
||||
globalData[plugin.name] ??= {};
|
||||
globalData[plugin.name]![plugin.options.id] = pluginGlobalData;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Sort the route config.
|
||||
// This ensures that route with sub routes are always placed last.
|
||||
sortRoutes(routes, context.siteConfig.baseUrl);
|
||||
|
||||
return {routes, globalData};
|
||||
});
|
||||
}
|
||||
|
||||
async function executePluginsAllContentLoaded({
|
||||
plugins,
|
||||
context,
|
||||
}: {
|
||||
plugins: LoadedPlugin[];
|
||||
context: LoadContext;
|
||||
}): Promise<{routes: RouteConfig[]; globalData: GlobalData}> {
|
||||
return PerfLogger.async(`Plugins - allContentLoaded`, async () => {
|
||||
const allContent = aggregateAllContent(plugins);
|
||||
|
||||
const routes: RouteConfig[] = [];
|
||||
|
@ -209,7 +220,7 @@ async function executePluginsContentLoaded({
|
|||
await Promise.all(
|
||||
plugins.map(async (plugin) => {
|
||||
const {routes: pluginRoutes, globalData: pluginGlobalData} =
|
||||
await executePluginContentLoaded({
|
||||
await executePluginAllContentLoaded({
|
||||
plugin,
|
||||
context,
|
||||
allContent,
|
||||
|
@ -238,37 +249,90 @@ export type LoadPluginsResult = {
|
|||
globalData: GlobalData;
|
||||
};
|
||||
|
||||
type ContentLoadedResult = {routes: RouteConfig[]; globalData: GlobalData};
|
||||
|
||||
export function mergeGlobalData(...globalDataList: GlobalData[]): GlobalData {
|
||||
const result: GlobalData = {};
|
||||
|
||||
const allPluginIdentifiers: PluginIdentifier[] = globalDataList.flatMap(
|
||||
(gd) =>
|
||||
Object.keys(gd).flatMap((name) =>
|
||||
Object.keys(gd[name]!).map((id) => ({name, id})),
|
||||
),
|
||||
);
|
||||
|
||||
allPluginIdentifiers.forEach(({name, id}) => {
|
||||
const allData = globalDataList
|
||||
.map((gd) => gd?.[name]?.[id])
|
||||
.filter((d) => typeof d !== 'undefined');
|
||||
const mergedData =
|
||||
allData.length === 1 ? allData[0] : Object.assign({}, ...allData);
|
||||
result[name] ??= {};
|
||||
result[name]![id] = mergedData;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeResults({
|
||||
contentLoadedResult,
|
||||
allContentLoadedResult,
|
||||
}: {
|
||||
contentLoadedResult: ContentLoadedResult;
|
||||
allContentLoadedResult: ContentLoadedResult;
|
||||
}): ContentLoadedResult {
|
||||
const routes = [
|
||||
...contentLoadedResult.routes,
|
||||
...allContentLoadedResult.routes,
|
||||
];
|
||||
sortRoutes(routes);
|
||||
|
||||
const globalData = mergeGlobalData(
|
||||
contentLoadedResult.globalData,
|
||||
allContentLoadedResult.globalData,
|
||||
);
|
||||
|
||||
return {routes, globalData};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the plugins, runs `loadContent`, `translateContent`,
|
||||
* `contentLoaded`, and `translateThemeConfig`. Because `contentLoaded` is
|
||||
* side-effect-ful (it generates temp files), so is this function. This function
|
||||
* would also mutate `context.siteConfig.themeConfig` to translate it.
|
||||
* Initializes the plugins and run their lifecycle functions.
|
||||
*/
|
||||
export async function loadPlugins(
|
||||
context: LoadContext,
|
||||
): Promise<LoadPluginsResult> {
|
||||
return PerfLogger.async('Plugins - loadPlugins', async () => {
|
||||
// 1. Plugin Lifecycle - Initialization/Constructor.
|
||||
const plugins: InitializedPlugin[] = await PerfLogger.async(
|
||||
const initializedPlugins: InitializedPlugin[] = await PerfLogger.async(
|
||||
'Plugins - initPlugins',
|
||||
() => initPlugins(context),
|
||||
);
|
||||
|
||||
plugins.push(
|
||||
initializedPlugins.push(
|
||||
createBootstrapPlugin(context),
|
||||
createMDXFallbackPlugin(context),
|
||||
);
|
||||
|
||||
// 2. Plugin Lifecycle - loadContent.
|
||||
const loadedPlugins = await executePluginsLoadContent({plugins, context});
|
||||
|
||||
// 3. Plugin Lifecycle - contentLoaded.
|
||||
const {routes, globalData} = await executePluginsContentLoaded({
|
||||
plugins: loadedPlugins,
|
||||
const plugins = await executePluginsLoadContent({
|
||||
plugins: initializedPlugins,
|
||||
context,
|
||||
});
|
||||
|
||||
return {plugins: loadedPlugins, routes, globalData};
|
||||
const contentLoadedResult = await executePluginsContentLoaded({
|
||||
plugins,
|
||||
context,
|
||||
});
|
||||
|
||||
const allContentLoadedResult = await executePluginsAllContentLoaded({
|
||||
plugins,
|
||||
context,
|
||||
});
|
||||
|
||||
const {routes, globalData} = mergeResults({
|
||||
contentLoadedResult,
|
||||
allContentLoadedResult,
|
||||
});
|
||||
|
||||
return {plugins, routes, globalData};
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -293,7 +357,7 @@ export function getPluginByIdentifier({
|
|||
|
||||
export async function reloadPlugin({
|
||||
pluginIdentifier,
|
||||
plugins,
|
||||
plugins: previousPlugins,
|
||||
context,
|
||||
}: {
|
||||
pluginIdentifier: PluginIdentifier;
|
||||
|
@ -301,18 +365,33 @@ export async function reloadPlugin({
|
|||
context: LoadContext;
|
||||
}): Promise<LoadPluginsResult> {
|
||||
return PerfLogger.async('Plugins - reloadPlugin', async () => {
|
||||
const plugin = getPluginByIdentifier({plugins, pluginIdentifier});
|
||||
const plugin = getPluginByIdentifier({
|
||||
plugins: previousPlugins,
|
||||
pluginIdentifier,
|
||||
});
|
||||
|
||||
const reloadedPlugin = await executePluginLoadContent({plugin, context});
|
||||
const newPlugins = plugins.with(plugins.indexOf(plugin), reloadedPlugin);
|
||||
const plugins = previousPlugins.with(
|
||||
previousPlugins.indexOf(plugin),
|
||||
reloadedPlugin,
|
||||
);
|
||||
|
||||
// Unfortunately, due to the "AllContent" data we have to re-execute this
|
||||
// for all plugins, not just the one to reload...
|
||||
const {routes, globalData} = await executePluginsContentLoaded({
|
||||
plugins: newPlugins,
|
||||
// TODO optimize this, we shouldn't need to re-run this lifecycle
|
||||
const contentLoadedResult = await executePluginsContentLoaded({
|
||||
plugins,
|
||||
context,
|
||||
});
|
||||
|
||||
return {plugins: newPlugins, routes, globalData};
|
||||
const allContentLoadedResult = await executePluginsAllContentLoaded({
|
||||
plugins,
|
||||
context,
|
||||
});
|
||||
|
||||
const {routes, globalData} = mergeResults({
|
||||
contentLoadedResult,
|
||||
allContentLoadedResult,
|
||||
});
|
||||
|
||||
return {plugins, routes, globalData};
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue