diff --git a/packages/docusaurus-types/src/plugin.d.ts b/packages/docusaurus-types/src/plugin.d.ts index 88f655dc086e..44935f380788 100644 --- a/packages/docusaurus-types/src/plugin.d.ts +++ b/packages/docusaurus-types/src/plugin.d.ts @@ -183,6 +183,8 @@ export type InitializedPlugin = Plugin & { export type LoadedPlugin = InitializedPlugin & { readonly content: unknown; + readonly globalData: unknown; + readonly routes: RouteConfig[]; }; export type PluginModule = { diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap index cba9622267ad..e551f0278426 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -37,12 +37,14 @@ exports[`load loads props for site with custom i18n path 1`] = ` { "content": undefined, "getClientModules": [Function], + "globalData": undefined, "injectHtmlTags": [Function], "name": "docusaurus-bootstrap-plugin", "options": { "id": "default", }, "path": "/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site", + "routes": [], "version": { "type": "synthetic", }, @@ -50,11 +52,13 @@ exports[`load loads props for site with custom i18n path 1`] = ` { "configureWebpack": [Function], "content": undefined, + "globalData": undefined, "name": "docusaurus-mdx-fallback-plugin", "options": { "id": "default", }, "path": ".", + "routes": [], "version": { "type": "synthetic", }, diff --git a/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts b/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts index f2f94a06bb4d..3a5c65234a00 100644 --- a/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts +++ b/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts @@ -7,15 +7,10 @@ import path from 'path'; import {fromPartial} from '@total-typescript/shoehorn'; -import {loadPlugins, mergeGlobalData} from '../plugins'; -import type { - GlobalData, - LoadContext, - Plugin, - PluginConfig, -} from '@docusaurus/types'; - -function testLoad({ +import {loadPlugins, reloadPlugin} from '../plugins'; +import type {LoadContext, Plugin, PluginConfig} from '@docusaurus/types'; + +async function testLoad({ plugins, themes, }: { @@ -39,7 +34,9 @@ function testLoad({ }, }); - return loadPlugins(context); + const result = await loadPlugins(context); + + return {context, ...result}; } const SyntheticPluginNames = [ @@ -50,7 +47,7 @@ const SyntheticPluginNames = [ async function testPlugin( pluginConfig: PluginConfig, ) { - const {plugins, routes, globalData} = await testLoad({ + const {context, plugins, routes, globalData} = await testLoad({ plugins: [pluginConfig], themes: [], }); @@ -62,204 +59,9 @@ async function testPlugin( const plugin = nonSyntheticPlugins[0]!; expect(plugin).toBeDefined(); - return {plugin, routes, globalData}; + return {context, 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', - }, - }, - }); - }); - - 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({ @@ -526,3 +328,272 @@ describe('loadPlugins', () => { `); }); }); + +describe('reloadPlugin', () => { + it('can reload a single complex plugin with same content', async () => { + const plugin: PluginConfig = () => ({ + 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', + }); + }, + }); + + const loadResult = await testLoad({ + plugins: [plugin], + themes: [], + }); + const reloadResult = await reloadPlugin({ + context: loadResult.context, + plugins: loadResult.plugins, + pluginIdentifier: {name: 'plugin-name', id: 'default'}, + }); + + expect(loadResult.routes).toEqual(reloadResult.routes); + expect(loadResult.globalData).toEqual(reloadResult.globalData); + expect(reloadResult.routes).toMatchInlineSnapshot(` + [ + { + "component": "Comp", + "context": { + "plugin": "/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": "/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": "/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": "/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(reloadResult.globalData).toMatchInlineSnapshot(` + { + "plugin-name": { + "default": { + "globalAllContentLoaded": "val2", + "globalContentLoaded": "val1", + "globalOverridden": "override-value", + }, + }, + } + `); + }); + + it('can reload plugins in real-world setup', async () => { + let isPlugin1Reload = false; + + const plugin1: PluginConfig = () => ({ + name: 'plugin-name-1', + contentLoaded({actions}) { + actions.addRoute({ + path: isPlugin1Reload + ? '/contentLoaded-route-reload' + : '/contentLoaded-route-initial', + component: 'Comp', + }); + actions.setGlobalData({ + contentLoadedVal: isPlugin1Reload + ? 'contentLoaded-val-reload' + : 'contentLoaded-val-initial', + }); + }, + allContentLoaded({actions}) { + actions.addRoute({ + path: isPlugin1Reload + ? '/allContentLoaded-route-reload' + : '/allContentLoaded-route-initial', + component: 'Comp', + }); + actions.setGlobalData({ + allContentLoadedVal: isPlugin1Reload + ? 'allContentLoaded-val-reload' + : 'allContentLoaded-val-initial', + }); + }, + }); + + const plugin2: PluginConfig = () => ({ + name: 'plugin-name-2', + contentLoaded({actions}) { + actions.addRoute({ + path: '/plugin-2-route', + component: 'Comp', + }); + actions.setGlobalData({plugin2Val: 'val'}); + }, + }); + + const loadResult = await testLoad({ + plugins: [plugin1, plugin2], + themes: [], + }); + + isPlugin1Reload = true; + + const reloadResult = await reloadPlugin({ + context: loadResult.context, + plugins: loadResult.plugins, + pluginIdentifier: {name: 'plugin-name-1', id: 'default'}, + }); + + expect(loadResult.routes).not.toEqual(reloadResult.routes); + expect(loadResult.globalData).not.toEqual(reloadResult.globalData); + expect(loadResult.routes).toMatchInlineSnapshot(` + [ + { + "component": "Comp", + "context": { + "plugin": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", + }, + "path": "/allContentLoaded-route-initial/", + }, + { + "component": "Comp", + "context": { + "plugin": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", + }, + "path": "/contentLoaded-route-initial/", + }, + { + "component": "Comp", + "context": { + "plugin": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-2/default/plugin-route-context-module-100.json", + }, + "path": "/plugin-2-route/", + }, + ] + `); + expect(loadResult.globalData).toMatchInlineSnapshot(` + { + "plugin-name-1": { + "default": { + "allContentLoadedVal": "allContentLoaded-val-initial", + "contentLoadedVal": "contentLoaded-val-initial", + }, + }, + "plugin-name-2": { + "default": { + "plugin2Val": "val", + }, + }, + } + `); + expect(reloadResult.routes).toMatchInlineSnapshot(` + [ + { + "component": "Comp", + "context": { + "plugin": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", + }, + "path": "/allContentLoaded-route-reload/", + }, + { + "component": "Comp", + "context": { + "plugin": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", + }, + "path": "/contentLoaded-route-reload/", + }, + { + "component": "Comp", + "context": { + "plugin": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-2/default/plugin-route-context-module-100.json", + }, + "path": "/plugin-2-route/", + }, + ] + `); + expect(reloadResult.globalData).toMatchInlineSnapshot(` + { + "plugin-name-1": { + "default": { + "allContentLoadedVal": "allContentLoaded-val-reload", + "contentLoadedVal": "contentLoaded-val-reload", + }, + }, + "plugin-name-2": { + "default": { + "plugin2Val": "val", + }, + }, + } + `); + + // Trying to reload again one plugin or the other should give + // the same result because the plugin content doesn't change + const reloadResult2 = await reloadPlugin({ + context: loadResult.context, + plugins: reloadResult.plugins, + pluginIdentifier: {name: 'plugin-name-1', id: 'default'}, + }); + expect(reloadResult2.routes).toEqual(reloadResult.routes); + expect(reloadResult2.globalData).toEqual(reloadResult.globalData); + + const reloadResult3 = await reloadPlugin({ + context: loadResult.context, + plugins: reloadResult2.plugins, + pluginIdentifier: {name: 'plugin-name-2', id: 'default'}, + }); + expect(reloadResult3.routes).toEqual(reloadResult.routes); + expect(reloadResult3.globalData).toEqual(reloadResult.globalData); + }); +}); diff --git a/packages/docusaurus/src/server/plugins/__tests__/pluginsUtils.test.ts b/packages/docusaurus/src/server/plugins/__tests__/pluginsUtils.test.ts new file mode 100644 index 000000000000..5b37d2da809c --- /dev/null +++ b/packages/docusaurus/src/server/plugins/__tests__/pluginsUtils.test.ts @@ -0,0 +1,204 @@ +/** + * 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 {mergeGlobalData} from '../pluginsUtils'; +import type {GlobalData} from '@docusaurus/types'; + +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', + }, + }, + }); + }); + + 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'}, + }, + }); + }); +}); diff --git a/packages/docusaurus/src/server/plugins/actions.ts b/packages/docusaurus/src/server/plugins/actions.ts index 45eb30b3d6bd..3e7d389a90ca 100644 --- a/packages/docusaurus/src/server/plugins/actions.ts +++ b/packages/docusaurus/src/server/plugins/actions.ts @@ -9,7 +9,7 @@ import path from 'path'; import {docuHash, generate} from '@docusaurus/utils'; import {applyRouteTrailingSlash} from './routeConfig'; import type { - LoadedPlugin, + InitializedPlugin, PluginContentLoadedActions, PluginRouteContext, RouteConfig, @@ -31,7 +31,7 @@ export async function createPluginActionsUtils({ baseUrl, trailingSlash, }: { - plugin: LoadedPlugin; + plugin: InitializedPlugin; generatedFilesDir: string; baseUrl: string; trailingSlash: boolean | undefined; diff --git a/packages/docusaurus/src/server/plugins/plugins.ts b/packages/docusaurus/src/server/plugins/plugins.ts index 57d6ac30a3c2..c08dbcdb2333 100644 --- a/packages/docusaurus/src/server/plugins/plugins.ts +++ b/packages/docusaurus/src/server/plugins/plugins.ts @@ -5,14 +5,19 @@ * LICENSE file in the root directory of this source tree. */ -import _ from 'lodash'; -import logger from '@docusaurus/logger'; import {initPlugins} from './init'; import {createBootstrapPlugin, createMDXFallbackPlugin} from './synthetic'; import {localizePluginTranslationFile} from '../translations/translations'; import {sortRoutes} from './routeConfig'; import {PerfLogger} from '../../utils'; import {createPluginActionsUtils} from './actions'; +import { + aggregateAllContent, + aggregateGlobalData, + aggregateRoutes, + getPluginByIdentifier, + mergeGlobalData, +} from './pluginsUtils'; import type { LoadContext, RouteConfig, @@ -23,17 +28,17 @@ import type { InitializedPlugin, } from '@docusaurus/types'; -async function translatePlugin({ +async function translatePluginContent({ plugin, + content, context, }: { - plugin: LoadedPlugin; + plugin: InitializedPlugin; + content: unknown; context: LoadContext; -}): Promise { - const {content} = plugin; - +}): Promise { const rawTranslationFiles = - (await plugin.getTranslationFiles?.({content: plugin.content})) ?? []; + (await plugin.getTranslationFiles?.({content})) ?? []; const translationFiles = await Promise.all( rawTranslationFiles.map((translationFile) => @@ -58,10 +63,10 @@ async function translatePlugin({ // translate its own slice of theme config and should make no assumptions // about other plugins' keys, so this is safe to run in parallel. Object.assign(context.siteConfig.themeConfig, translatedThemeConfigSlice); - return {...plugin, content: translatedContent}; + return translatedContent; } -async function executePluginLoadContent({ +async function executePluginContentLoading({ plugin, context, }: { @@ -69,65 +74,40 @@ async function executePluginLoadContent({ context: LoadContext; }): Promise { return PerfLogger.async( - `Plugin - loadContent - ${plugin.name}@${plugin.options.id}`, + `Plugins - single plugin content loading - ${plugin.name}@${plugin.options.id}`, async () => { - const content = await plugin.loadContent?.(); - const loadedPlugin: LoadedPlugin = {...plugin, content}; - return translatePlugin({plugin: loadedPlugin, context}); - }, - ); -} + let content = await plugin.loadContent?.(); -async function executePluginsLoadContent({ - plugins, - context, -}: { - plugins: InitializedPlugin[]; - context: LoadContext; -}) { - return PerfLogger.async(`Plugins - loadContent`, () => - Promise.all( - plugins.map((plugin) => executePluginLoadContent({plugin, context})), - ), - ); -} - -function aggregateAllContent(loadedPlugins: LoadedPlugin[]): AllContent { - return _.chain(loadedPlugins) - .groupBy((item) => item.name) - .mapValues((nameItems) => - _.chain(nameItems) - .groupBy((item) => item.options.id) - .mapValues((idItems) => idItems[0]!.content) - .value(), - ) - .value(); -} + content = await translatePluginContent({ + plugin, + content, + context, + }); -async function executePluginContentLoaded({ - plugin, - context, -}: { - plugin: LoadedPlugin; - context: LoadContext; -}): Promise<{routes: RouteConfig[]; globalData: unknown}> { - return PerfLogger.async( - `Plugins - contentLoaded - ${plugin.name}@${plugin.options.id}`, - async () => { if (!plugin.contentLoaded) { - return {routes: [], globalData: undefined}; + return { + ...plugin, + content, + routes: [], + globalData: undefined, + }; } + const pluginActionsUtils = await createPluginActionsUtils({ plugin, generatedFilesDir: context.generatedFilesDir, baseUrl: context.siteConfig.baseUrl, trailingSlash: context.siteConfig.trailingSlash, }); + await plugin.contentLoaded({ - content: plugin.content, + content, actions: pluginActionsUtils.getActions(), }); + return { + ...plugin, + content, routes: pluginActionsUtils.getRoutes(), globalData: pluginActionsUtils.getGlobalData(), }; @@ -135,6 +115,20 @@ async function executePluginContentLoaded({ ); } +async function executeAllPluginsContentLoading({ + plugins, + context, +}: { + plugins: InitializedPlugin[]; + context: LoadContext; +}): Promise { + return PerfLogger.async(`Plugins - all plugins content loading`, () => { + return Promise.all( + plugins.map((plugin) => executePluginContentLoading({plugin, context})), + ); + }); +} + async function executePluginAllContentLoaded({ plugin, context, @@ -168,49 +162,15 @@ async function executePluginAllContentLoaded({ ); } -async function executePluginsContentLoaded({ - plugins, - context, -}: { - plugins: LoadedPlugin[]; - 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); +type AllContentLoadedResult = {routes: RouteConfig[]; globalData: GlobalData}; - return {routes, globalData}; - }); -} - -async function executePluginsAllContentLoaded({ +async function executeAllPluginsAllContentLoaded({ plugins, context, }: { plugins: LoadedPlugin[]; context: LoadContext; -}): Promise<{routes: RouteConfig[]; globalData: GlobalData}> { +}): Promise { return PerfLogger.async(`Plugins - allContentLoaded`, async () => { const allContent = aggregateAllContent(plugins); @@ -235,66 +195,37 @@ async function executePluginsAllContentLoaded({ }), ); - // Sort the route config. - // This ensures that route with sub routes are always placed last. - sortRoutes(routes, context.siteConfig.baseUrl); - return {routes, globalData}; }); } -export type LoadPluginsResult = { - plugins: LoadedPlugin[]; - routes: RouteConfig[]; - 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, + plugins, allContentLoadedResult, }: { - contentLoadedResult: ContentLoadedResult; - allContentLoadedResult: ContentLoadedResult; -}): ContentLoadedResult { - const routes = [ - ...contentLoadedResult.routes, + plugins: LoadedPlugin[]; + allContentLoadedResult: AllContentLoadedResult; +}) { + const routes: RouteConfig[] = [ + ...aggregateRoutes(plugins), ...allContentLoadedResult.routes, ]; sortRoutes(routes); - const globalData = mergeGlobalData( - contentLoadedResult.globalData, + const globalData: GlobalData = mergeGlobalData( + aggregateGlobalData(plugins), allContentLoadedResult.globalData, ); return {routes, globalData}; } +export type LoadPluginsResult = { + plugins: LoadedPlugin[]; + routes: RouteConfig[]; + globalData: GlobalData; +}; + /** * Initializes the plugins and run their lifecycle functions. */ @@ -307,28 +238,24 @@ export async function loadPlugins( () => initPlugins(context), ); + // TODO probably not the ideal place to hardcode those plugins initializedPlugins.push( createBootstrapPlugin(context), createMDXFallbackPlugin(context), ); - const plugins = await executePluginsLoadContent({ + const plugins = await executeAllPluginsContentLoading({ plugins: initializedPlugins, context, }); - const contentLoadedResult = await executePluginsContentLoaded({ - plugins, - context, - }); - - const allContentLoadedResult = await executePluginsAllContentLoaded({ + const allContentLoadedResult = await executeAllPluginsAllContentLoaded({ plugins, context, }); const {routes, globalData} = mergeResults({ - contentLoadedResult, + plugins, allContentLoadedResult, }); @@ -336,25 +263,6 @@ export async function loadPlugins( }); } -export function getPluginByIdentifier({ - plugins, - pluginIdentifier, -}: { - pluginIdentifier: PluginIdentifier; - plugins: LoadedPlugin[]; -}): LoadedPlugin { - const plugin = plugins.find( - (p) => - p.name === pluginIdentifier.name && p.options.id === pluginIdentifier.id, - ); - if (!plugin) { - throw new Error( - logger.interpolate`Plugin not found for identifier ${pluginIdentifier.name}@${pluginIdentifier.id}`, - ); - } - return plugin; -} - export async function reloadPlugin({ pluginIdentifier, plugins: previousPlugins, @@ -365,30 +273,32 @@ export async function reloadPlugin({ context: LoadContext; }): Promise { return PerfLogger.async('Plugins - reloadPlugin', async () => { - const plugin = getPluginByIdentifier({ + const previousPlugin = getPluginByIdentifier({ plugins: previousPlugins, pluginIdentifier, }); + const plugin = await executePluginContentLoading({ + plugin: previousPlugin, + context, + }); - const reloadedPlugin = await executePluginLoadContent({plugin, context}); + /* + // TODO Docusaurus v4 - upgrade to Node 20, use array.with() const plugins = previousPlugins.with( - previousPlugins.indexOf(plugin), - reloadedPlugin, + previousPlugins.indexOf(previousPlugin), + plugin, ); + */ + const plugins = [...previousPlugins]; + plugins[previousPlugins.indexOf(previousPlugin)] = plugin; - // TODO optimize this, we shouldn't need to re-run this lifecycle - const contentLoadedResult = await executePluginsContentLoaded({ - plugins, - context, - }); - - const allContentLoadedResult = await executePluginsAllContentLoaded({ + const allContentLoadedResult = await executeAllPluginsAllContentLoaded({ plugins, context, }); const {routes, globalData} = mergeResults({ - contentLoadedResult, + plugins, allContentLoadedResult, }); diff --git a/packages/docusaurus/src/server/plugins/pluginsUtils.ts b/packages/docusaurus/src/server/plugins/pluginsUtils.ts new file mode 100644 index 000000000000..706e6ecafa06 --- /dev/null +++ b/packages/docusaurus/src/server/plugins/pluginsUtils.ts @@ -0,0 +1,87 @@ +/** + * 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 _ from 'lodash'; +import logger from '@docusaurus/logger'; +import type { + AllContent, + GlobalData, + InitializedPlugin, + LoadedPlugin, + PluginIdentifier, + RouteConfig, +} from '@docusaurus/types'; + +export function getPluginByIdentifier

({ + plugins, + pluginIdentifier, +}: { + pluginIdentifier: PluginIdentifier; + plugins: P[]; +}): P { + const plugin = plugins.find( + (p) => + p.name === pluginIdentifier.name && p.options.id === pluginIdentifier.id, + ); + if (!plugin) { + throw new Error( + logger.interpolate`Plugin not found for identifier ${pluginIdentifier.name}@${pluginIdentifier.id}`, + ); + } + return plugin; +} + +export function aggregateAllContent(loadedPlugins: LoadedPlugin[]): AllContent { + return _.chain(loadedPlugins) + .groupBy((item) => item.name) + .mapValues((nameItems) => + _.chain(nameItems) + .groupBy((item) => item.options.id) + .mapValues((idItems) => idItems[0]!.content) + .value(), + ) + .value(); +} + +export function aggregateRoutes(loadedPlugins: LoadedPlugin[]): RouteConfig[] { + return loadedPlugins.flatMap((p) => p.routes); +} + +export function aggregateGlobalData(loadedPlugins: LoadedPlugin[]): GlobalData { + const globalData: GlobalData = {}; + loadedPlugins.forEach((plugin) => { + if (plugin.globalData !== undefined) { + globalData[plugin.name] ??= {}; + globalData[plugin.name]![plugin.options.id] = plugin.globalData; + } + }); + + return 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; +} diff --git a/packages/docusaurus/src/server/plugins/synthetic.ts b/packages/docusaurus/src/server/plugins/synthetic.ts index 6a5a527917f7..ba6895996381 100644 --- a/packages/docusaurus/src/server/plugins/synthetic.ts +++ b/packages/docusaurus/src/server/plugins/synthetic.ts @@ -7,7 +7,11 @@ import path from 'path'; import type {RuleSetRule} from 'webpack'; -import type {HtmlTagObject, LoadedPlugin, LoadContext} from '@docusaurus/types'; +import type { + HtmlTagObject, + LoadContext, + InitializedPlugin, +} from '@docusaurus/types'; import type {Options as MDXLoaderOptions} from '@docusaurus/mdx-loader'; /** @@ -18,7 +22,7 @@ import type {Options as MDXLoaderOptions} from '@docusaurus/mdx-loader'; export function createBootstrapPlugin({ siteDir, siteConfig, -}: LoadContext): LoadedPlugin { +}: LoadContext): InitializedPlugin { const { stylesheets, scripts, @@ -27,7 +31,6 @@ export function createBootstrapPlugin({ } = siteConfig; return { name: 'docusaurus-bootstrap-plugin', - content: null, options: { id: 'default', }, @@ -75,10 +78,9 @@ export function createBootstrapPlugin({ export function createMDXFallbackPlugin({ siteDir, siteConfig, -}: LoadContext): LoadedPlugin { +}: LoadContext): InitializedPlugin { return { name: 'docusaurus-mdx-fallback-plugin', - content: null, options: { id: 'default', }, diff --git a/packages/docusaurus/src/server/site.ts b/packages/docusaurus/src/server/site.ts index 961184d73182..c693974fbeba 100644 --- a/packages/docusaurus/src/server/site.ts +++ b/packages/docusaurus/src/server/site.ts @@ -247,10 +247,6 @@ export async function reloadSitePlugin( site: Site, pluginIdentifier: PluginIdentifier, ): Promise { - console.log( - `reloadSitePlugin ${pluginIdentifier.name}@${pluginIdentifier.id}`, - ); - const {plugins, routes, globalData} = await reloadPlugin({ pluginIdentifier, plugins: site.props.plugins, diff --git a/packages/docusaurus/src/utils.ts b/packages/docusaurus/src/utils.ts index 9fe1c616a523..660072bae298 100644 --- a/packages/docusaurus/src/utils.ts +++ b/packages/docusaurus/src/utils.ts @@ -11,6 +11,12 @@ import logger from '@docusaurus/logger'; export const PerfDebuggingEnabled: boolean = !!process.env.DOCUSAURUS_PERF_LOGGER; +const Thresholds = { + min: 5, + yellow: 100, + red: 1000, +}; + type PerfLoggerAPI = { start: (label: string) => void; end: (label: string) => void; @@ -34,17 +40,40 @@ function createPerfLogger(): PerfLoggerAPI { const prefix = logger.yellow(`[PERF] `); - const start: PerfLoggerAPI['start'] = (label) => console.time(prefix + label); + const formatDuration = (duration: number): string => { + if (duration > Thresholds.red) { + return logger.red(`${(duration / 1000).toFixed(2)} seconds!`); + } else if (duration > Thresholds.yellow) { + return logger.yellow(`${duration.toFixed(2)} ms`); + } else { + return logger.green(`${duration.toFixed(2)} ms`); + } + }; - const end: PerfLoggerAPI['end'] = (label) => console.timeEnd(prefix + label); + const logDuration = (label: string, duration: number) => { + if (duration < Thresholds.min) { + return; + } + console.log(`${prefix + label} - ${formatDuration(duration)}`); + }; + + const start: PerfLoggerAPI['start'] = (label) => performance.mark(label); + + const end: PerfLoggerAPI['end'] = (label) => { + const {duration} = performance.measure(label); + performance.clearMarks(label); + logDuration(label, duration); + }; const log: PerfLoggerAPI['log'] = (label: string) => console.log(prefix + label); const async: PerfLoggerAPI['async'] = async (label, asyncFn) => { start(label); + const before = performance.now(); const result = await asyncFn(); - end(label); + const duration = performance.now() - before; + logDuration(label, duration); return result; };