diff --git a/packages/docusaurus-plugin-debug/src/index.ts b/packages/docusaurus-plugin-debug/src/index.ts index 7ca3680678cb..cd27ebc549ee 100644 --- a/packages/docusaurus-plugin-debug/src/index.ts +++ b/packages/docusaurus-plugin-debug/src/index.ts @@ -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. diff --git a/packages/docusaurus-types/src/plugin.d.ts b/packages/docusaurus-types/src/plugin.d.ts index 858fba9b7c69..88f655dc086e 100644 --- a/packages/docusaurus-types/src/plugin.d.ts +++ b/packages/docusaurus-types/src/plugin.d.ts @@ -18,11 +18,11 @@ import type {RouteConfig} from './routing'; export type PluginOptions = {id?: string} & {[key: string]: unknown}; -export type PluginConfig = +export type PluginConfig = | string | [string, PluginOptions] - | [PluginModule, PluginOptions] - | PluginModule + | [PluginModule, PluginOptions] + | PluginModule | false | null; @@ -110,7 +110,9 @@ export type Plugin = { contentLoaded?: (args: { /** The content loaded by this plugin instance */ content: Content; // - /** Content loaded by ALL the plugins */ + actions: PluginContentLoadedActions; + }) => Promise | void; + allContentLoaded?: (args: { allContent: AllContent; actions: PluginContentLoadedActions; }) => Promise | void; @@ -183,8 +185,10 @@ export type LoadedPlugin = InitializedPlugin & { readonly content: unknown; }; -export type PluginModule = { - (context: LoadContext, options: unknown): Plugin | Promise; +export type PluginModule = { + (context: LoadContext, options: unknown): + | Plugin + | Promise>; validateOptions?: (data: OptionValidationContext) => U; validateThemeConfig?: (data: ThemeConfigValidationContext) => T; diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 99ae61d4d3ee..104ebb888fbc 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -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", diff --git a/packages/docusaurus/src/server/plugins/__tests__/__snapshots__/plugins.test.ts.snap b/packages/docusaurus/src/server/plugins/__tests__/__snapshots__/plugins.test.ts.snap deleted file mode 100644 index 47295e904880..000000000000 --- a/packages/docusaurus/src/server/plugins/__tests__/__snapshots__/plugins.test.ts.snap +++ /dev/null @@ -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": "/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": "/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": "/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": "/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/test1/default/plugin-route-context-module-100.json", - }, - "modules": { - "content": "path", - }, - "path": "foo/", - }, - ], -} -`; diff --git a/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts b/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts index 7729ef2bd05a..f2f94a06bb4d 100644 --- a/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts +++ b/packages/docusaurus/src/server/plugins/__tests__/plugins.test.ts @@ -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'; + +function testLoad({ + plugins, + themes, +}: { + plugins: PluginConfig[]; + themes: PluginConfig[]; +}) { + const siteDir = path.join(__dirname, '__fixtures__/site-with-plugin'); + + const context = fromPartial({ + 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( + pluginConfig: PluginConfig, +) { + 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', + }, + }, + }); + }); + + 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('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'}>), + 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": "/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": "/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'}, ], - themes: [ - () => ({ - name: 'test2', - configureWebpack() { - return {}; - }, - }), + }); + 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": "/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(globalData).toMatchInlineSnapshot(` + { + "plugin-name": { + "default": { + "globalAllContentLoaded": "val2", + "globalContentLoaded": "val1", + "globalOverridden": "override-value", + }, }, - siteConfigPath: path.join(siteDir, 'docusaurus.config.js'), - } as unknown as Props), - ).resolves.toMatchSnapshot(); + } + `); }); }); diff --git a/packages/docusaurus/src/server/plugins/actions.ts b/packages/docusaurus/src/server/plugins/actions.ts new file mode 100644 index 000000000000..45eb30b3d6bd --- /dev/null +++ b/packages/docusaurus/src/server/plugins/actions.ts @@ -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 { + 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, + }; +} diff --git a/packages/docusaurus/src/server/plugins/plugins.ts b/packages/docusaurus/src/server/plugins/plugins.ts index b7cd7729c6c2..3fa102ef90c8 100644 --- a/packages/docusaurus/src/server/plugins/plugins.ts +++ b/packages/docusaurus/src/server/plugins/plugins.ts @@ -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 { 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}); + const plugins = await executePluginsLoadContent({ + plugins: initializedPlugins, + context, + }); - // 3. Plugin Lifecycle - contentLoaded. - const {routes, globalData} = await executePluginsContentLoaded({ - plugins: loadedPlugins, + const contentLoadedResult = await executePluginsContentLoaded({ + plugins, context, }); - return {plugins: loadedPlugins, routes, globalData}; + 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 { 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}; }); }