feat(core): add new plugin allContentLoaded lifecycle (#9931)

This commit is contained in:
Sébastien Lorber 2024-03-08 19:13:59 +01:00 committed by GitHub
parent d02b96f7f5
commit 8d115a9e0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 791 additions and 228 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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",

View file

@ -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/",
},
],
}
`;

View file

@ -6,15 +6,27 @@
*/
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 () => {
function testLoad({
plugins,
themes,
}: {
plugins: PluginConfig<any>[];
themes: PluginConfig<any>[];
}) {
const siteDir = path.join(__dirname, '__fixtures__/site-with-plugin');
await expect(
loadPlugins({
const context = fromPartial<LoadContext>({
siteDir,
siteConfigPath: path.join(siteDir, 'docusaurus.config.js'),
generatedFilesDir: path.join(siteDir, '.docusaurus'),
outDir: path.join(siteDir, 'build'),
siteConfig: {
@ -22,37 +34,495 @@ describe('loadPlugins', () => {
trailingSlash: true,
themeConfig: {},
presets: [],
plugins: [
() =>
({
name: 'test1',
prop: 'a',
async loadContent() {
// Testing that plugin lifecycle is bound to the instance
return this.prop;
plugins,
themes,
},
async contentLoaded({content, actions}) {
actions.addRoute({
path: 'foo',
component: 'Comp',
modules: {content: 'path'},
context: {content: 'path'},
});
actions.setGlobalData({content, prop: this.prop});
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'},
},
} as Plugin & ThisType<{prop: 'a'}>),
],
themes: [
() => ({
name: 'test2',
configureWebpack() {
return {};
};
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',
},
},
});
});
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/",
},
],
},
siteConfigPath: path.join(siteDir, 'docusaurus.config.js'),
} as unknown as Props),
).resolves.toMatchSnapshot();
{
"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",
},
},
}
`);
});
});

View 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,
};
}

View file

@ -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,
},
const pluginActionsUtils = await createPluginActionsUtils({
plugin,
generatedFilesDir: context.generatedFilesDir,
baseUrl: context.siteConfig.baseUrl,
trailingSlash: context.siteConfig.trailingSlash,
});
},
async createData(name, data) {
const modulePath = path.join(dataDir, name);
await generate(dataDir, name, data);
return modulePath;
},
setGlobalData(data) {
globalData = data;
},
};
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};
});
}