From 9da3448a0f8375ec2126e6989e43e98efaa84367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Wed, 10 Jul 2024 13:13:30 +0200 Subject: [PATCH] feat(core): allow plugins to self-disable by returning null (#10286) --- .../src/index.ts | 4 +- .../src/index.ts | 12 +-- .../src/index.ts | 11 ++- .../src/index.ts | 11 ++- packages/docusaurus-plugin-pwa/src/index.ts | 74 ++++++++----------- .../docusaurus-plugin-sitemap/src/index.ts | 4 +- .../src/index.ts | 9 ++- packages/docusaurus-types/src/plugin.d.ts | 5 +- .../src/commands/swizzle/context.ts | 37 +++++++--- .../site-with-plugin/docusaurus.config.js | 4 + .../docusaurus.config.js | 19 +++++ .../src/server/plugins/__tests__/init.test.ts | 11 ++- .../docusaurus/src/server/plugins/init.ts | 72 +++++++++++++++--- 13 files changed, 184 insertions(+), 89 deletions(-) create mode 100644 packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-undefined-plugin/docusaurus.config.js diff --git a/packages/docusaurus-plugin-client-redirects/src/index.ts b/packages/docusaurus-plugin-client-redirects/src/index.ts index 91cfa3a8a1531..8df92f1120e93 100644 --- a/packages/docusaurus-plugin-client-redirects/src/index.ts +++ b/packages/docusaurus-plugin-client-redirects/src/index.ts @@ -21,7 +21,7 @@ const PluginName = 'docusaurus-plugin-client-redirects'; export default function pluginClientRedirectsPages( context: LoadContext, options: PluginOptions, -): Plugin { +): Plugin | null { const {trailingSlash} = context.siteConfig; const router = context.siteConfig.future.experimental_router; @@ -29,7 +29,7 @@ export default function pluginClientRedirectsPages( logger.warn( `${PluginName} does not support the Hash Router and will be disabled.`, ); - return {name: PluginName}; + return null; } return { diff --git a/packages/docusaurus-plugin-google-analytics/src/index.ts b/packages/docusaurus-plugin-google-analytics/src/index.ts index 6250becb1244b..971b435060d21 100644 --- a/packages/docusaurus-plugin-google-analytics/src/index.ts +++ b/packages/docusaurus-plugin-google-analytics/src/index.ts @@ -18,21 +18,21 @@ import type {PluginOptions, Options} from './options'; export default function pluginGoogleAnalytics( context: LoadContext, options: PluginOptions, -): Plugin { +): Plugin | null { + if (process.env.NODE_ENV !== 'production') { + return null; + } + const {trackingID, anonymizeIP} = options; - const isProd = process.env.NODE_ENV === 'production'; return { name: 'docusaurus-plugin-google-analytics', getClientModules() { - return isProd ? ['./analytics'] : []; + return ['./analytics']; }, injectHtmlTags() { - if (!isProd) { - return {}; - } return { headTags: [ { diff --git a/packages/docusaurus-plugin-google-gtag/src/index.ts b/packages/docusaurus-plugin-google-gtag/src/index.ts index a3ea84ddc419c..f0e5334b1c801 100644 --- a/packages/docusaurus-plugin-google-gtag/src/index.ts +++ b/packages/docusaurus-plugin-google-gtag/src/index.ts @@ -32,8 +32,10 @@ function createConfigSnippets({ export default function pluginGoogleGtag( context: LoadContext, options: PluginOptions, -): Plugin { - const isProd = process.env.NODE_ENV === 'production'; +): Plugin | null { + if (process.env.NODE_ENV !== 'production') { + return null; + } const firstTrackingId = options.trackingID[0]; @@ -45,13 +47,10 @@ export default function pluginGoogleGtag( }, getClientModules() { - return isProd ? ['./gtag'] : []; + return ['./gtag']; }, injectHtmlTags() { - if (!isProd) { - return {}; - } return { // Gtag includes GA by default, so we also preconnect to // google-analytics. diff --git a/packages/docusaurus-plugin-google-tag-manager/src/index.ts b/packages/docusaurus-plugin-google-tag-manager/src/index.ts index 10e0c3868ea7a..2754967564bbd 100644 --- a/packages/docusaurus-plugin-google-tag-manager/src/index.ts +++ b/packages/docusaurus-plugin-google-tag-manager/src/index.ts @@ -16,10 +16,12 @@ import type {PluginOptions, Options} from './options'; export default function pluginGoogleAnalytics( context: LoadContext, options: PluginOptions, -): Plugin { - const {containerId} = options; - const isProd = process.env.NODE_ENV === 'production'; +): Plugin | null { + if (process.env.NODE_ENV !== 'production') { + return null; + } + const {containerId} = options; return { name: 'docusaurus-plugin-google-tag-manager', @@ -28,9 +30,6 @@ export default function pluginGoogleAnalytics( }, injectHtmlTags() { - if (!isProd) { - return {}; - } return { preBodyTags: [ { diff --git a/packages/docusaurus-plugin-pwa/src/index.ts b/packages/docusaurus-plugin-pwa/src/index.ts index e42783ee50569..4c23cb38457dd 100644 --- a/packages/docusaurus-plugin-pwa/src/index.ts +++ b/packages/docusaurus-plugin-pwa/src/index.ts @@ -19,8 +19,6 @@ import type {PluginOptions} from '@docusaurus/plugin-pwa'; const PluginName = 'docusaurus-plugin-pwa'; -const isProd = process.env.NODE_ENV === 'production'; - function getSWBabelLoader() { return { loader: 'babel-loader', @@ -45,12 +43,21 @@ function getSWBabelLoader() { export default function pluginPWA( context: LoadContext, options: PluginOptions, -): Plugin { +): Plugin | null { + if (process.env.NODE_ENV !== 'production') { + return null; + } + if (context.siteConfig.future.experimental_router === 'hash') { + logger.warn( + `${PluginName} does not support the Hash Router and will be disabled.`, + ); + return null; + } + const { outDir, baseUrl, i18n: {currentLocale}, - siteConfig, } = context; const { debug, @@ -61,13 +68,6 @@ export default function pluginPWA( swRegister, } = options; - if (siteConfig.future.experimental_router === 'hash') { - logger.warn( - `${PluginName} does not support the Hash Router and will be disabled.`, - ); - return {name: PluginName}; - } - return { name: PluginName, @@ -79,7 +79,7 @@ export default function pluginPWA( }, getClientModules() { - return isProd && swRegister ? [swRegister] : []; + return swRegister ? [swRegister] : []; }, getDefaultCodeTranslationMessages() { @@ -90,10 +90,6 @@ export default function pluginPWA( }, configureWebpack(config) { - if (!isProd) { - return {}; - } - return { plugins: [ new webpack.EnvironmentPlugin({ @@ -111,37 +107,31 @@ export default function pluginPWA( injectHtmlTags() { const headTags: HtmlTags = []; - if (isProd) { - pwaHead.forEach(({tagName, ...attributes}) => { - (['href', 'content'] as const).forEach((attribute) => { - const attributeValue = attributes[attribute]; - - if (!attributeValue) { - return; - } - - const attributePath = - !!path.extname(attributeValue) && attributeValue; - - if (attributePath && !attributePath.startsWith(baseUrl)) { - attributes[attribute] = normalizeUrl([baseUrl, attributeValue]); - } - }); - - return headTags.push({ - tagName, - attributes, - }); + pwaHead.forEach(({tagName, ...attributes}) => { + (['href', 'content'] as const).forEach((attribute) => { + const attributeValue = attributes[attribute]; + + if (!attributeValue) { + return; + } + + const attributePath = + !!path.extname(attributeValue) && attributeValue; + + if (attributePath && !attributePath.startsWith(baseUrl)) { + attributes[attribute] = normalizeUrl([baseUrl, attributeValue]); + } + }); + + return headTags.push({ + tagName, + attributes, }); - } + }); return {headTags}; }, async postBuild(props) { - if (!isProd) { - return; - } - const swSourceFileTest = /\.m?js$/; const swWebpackConfig: Configuration = { diff --git a/packages/docusaurus-plugin-sitemap/src/index.ts b/packages/docusaurus-plugin-sitemap/src/index.ts index 986d05aaca506..3bd27f9c26d72 100644 --- a/packages/docusaurus-plugin-sitemap/src/index.ts +++ b/packages/docusaurus-plugin-sitemap/src/index.ts @@ -17,12 +17,12 @@ const PluginName = 'docusaurus-plugin-sitemap'; export default function pluginSitemap( context: LoadContext, options: PluginOptions, -): Plugin { +): Plugin | null { if (context.siteConfig.future.experimental_router === 'hash') { logger.warn( `${PluginName} does not support the Hash Router and will be disabled.`, ); - return {name: PluginName}; + return null; } return { diff --git a/packages/docusaurus-plugin-vercel-analytics/src/index.ts b/packages/docusaurus-plugin-vercel-analytics/src/index.ts index c05d1cefe83a5..9ddf73ca9f964 100644 --- a/packages/docusaurus-plugin-vercel-analytics/src/index.ts +++ b/packages/docusaurus-plugin-vercel-analytics/src/index.ts @@ -11,14 +11,15 @@ import type {PluginOptions, Options} from './options'; export default function pluginVercelAnalytics( context: LoadContext, options: PluginOptions, -): Plugin { - const isProd = process.env.NODE_ENV === 'production'; - +): Plugin | null { + if (process.env.NODE_ENV !== 'production') { + return null; + } return { name: 'docusaurus-plugin-vercel-analytics', getClientModules() { - return isProd ? ['./analytics'] : []; + return ['./analytics']; }, contentLoaded({actions}) { diff --git a/packages/docusaurus-types/src/plugin.d.ts b/packages/docusaurus-types/src/plugin.d.ts index 076a39354a889..33ce5bb32b18f 100644 --- a/packages/docusaurus-types/src/plugin.d.ts +++ b/packages/docusaurus-types/src/plugin.d.ts @@ -191,7 +191,10 @@ export type LoadedPlugin = InitializedPlugin & { export type PluginModule = { (context: LoadContext, options: unknown): | Plugin - | Promise>; + | Promise> + | null + | Promise; + validateOptions?: (data: OptionValidationContext) => U; validateThemeConfig?: (data: ThemeConfigValidationContext) => T; diff --git a/packages/docusaurus/src/commands/swizzle/context.ts b/packages/docusaurus/src/commands/swizzle/context.ts index 45c0e1a874886..1b314228143df 100644 --- a/packages/docusaurus/src/commands/swizzle/context.ts +++ b/packages/docusaurus/src/commands/swizzle/context.ts @@ -6,22 +6,41 @@ */ import {loadContext} from '../../server/site'; -import {initPlugins} from '../../server/plugins/init'; +import {initPluginsConfigs} from '../../server/plugins/init'; import {loadPluginConfigs} from '../../server/plugins/configs'; -import type {SwizzleCLIOptions, SwizzleContext} from './common'; +import type {SwizzleCLIOptions, SwizzleContext, SwizzlePlugin} from './common'; +import type {LoadContext} from '@docusaurus/types'; + +async function getSwizzlePlugins( + context: LoadContext, +): Promise { + const pluginConfigs = await loadPluginConfigs(context); + const pluginConfigInitResults = await initPluginsConfigs( + context, + pluginConfigs, + ); + + return pluginConfigInitResults.flatMap((initResult) => { + // Ignore self-disabling plugins returning null + if (initResult.plugin === null) { + return []; + } + return [ + // TODO this is a bit confusing, need refactor + { + plugin: initResult.config, + instance: initResult.plugin, + }, + ]; + }); +} export async function initSwizzleContext( siteDir: string, options: SwizzleCLIOptions, ): Promise { const context = await loadContext({siteDir, config: options.config}); - const plugins = await initPlugins(context); - const pluginConfigs = await loadPluginConfigs(context); - return { - plugins: plugins.map((plugin, pluginIndex) => ({ - plugin: pluginConfigs[pluginIndex]!, - instance: plugin, - })), + plugins: await getSwizzlePlugins(context), }; } diff --git a/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/docusaurus.config.js b/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/docusaurus.config.js index d869bc6b51bab..8c05975aff054 100644 --- a/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/docusaurus.config.js +++ b/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/docusaurus.config.js @@ -21,6 +21,10 @@ module.exports = { }, {it: 'should work'}, ], + function (context, options) { + // it's ok for a plugin to self-disable + return null; + }, './plugin3.js', ['./plugin4.js', {}], './pluginEsm', diff --git a/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-undefined-plugin/docusaurus.config.js b/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-undefined-plugin/docusaurus.config.js new file mode 100644 index 0000000000000..7136240d8bded --- /dev/null +++ b/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-undefined-plugin/docusaurus.config.js @@ -0,0 +1,19 @@ +/** + * 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. + */ + +module.exports = { + title: 'My Site', + tagline: 'The tagline of my site', + url: 'https://your-docusaurus-site.example.com', + baseUrl: '/', + favicon: 'img/favicon.ico', + plugins: [ + function (context, options) { + return undefined; + }, + ], +}; diff --git a/packages/docusaurus/src/server/plugins/__tests__/init.test.ts b/packages/docusaurus/src/server/plugins/__tests__/init.test.ts index d61d913e1f18d..718f56ab0bd95 100644 --- a/packages/docusaurus/src/server/plugins/__tests__/init.test.ts +++ b/packages/docusaurus/src/server/plugins/__tests__/init.test.ts @@ -24,7 +24,7 @@ async function loadSite( describe('initPlugins', () => { it('parses plugins correctly and loads them in correct order', async () => { const {context, plugins} = await loadSite('site-with-plugin'); - expect(context.siteConfig.plugins).toHaveLength(6); + expect(context.siteConfig.plugins).toHaveLength(7); expect(plugins).toHaveLength(10); expect(plugins[0]!.name).toBe('preset-plugin1'); @@ -85,4 +85,13 @@ describe('initPlugins', () => { Note that even inline/anonymous plugin functions require a 'name' property." `); }); + + it('throws user-friendly error message for plugins returning undefined', async () => { + await expect(() => loadSite('site-with-undefined-plugin')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "A Docusaurus plugin returned 'undefined', which is forbidden. + A plugin is expected to return an object having at least a 'name' property. + If you want a plugin to self-disable depending on context/options, you can explicitly return 'null' instead of 'undefined'" + `); + }); }); diff --git a/packages/docusaurus/src/server/plugins/init.ts b/packages/docusaurus/src/server/plugins/init.ts index f3dae7c2e9df8..2243a5f87a926 100644 --- a/packages/docusaurus/src/server/plugins/init.ts +++ b/packages/docusaurus/src/server/plugins/init.ts @@ -49,17 +49,32 @@ function getThemeValidationFunction( return normalizedPluginConfig.plugin.validateThemeConfig; } +type PluginConfigInitResult = { + config: NormalizedPluginConfig; + // Plugins might self-disable during initialization by returning null + plugin: InitializedPlugin | null; +}; + +// This filters self-disabling plugins and returns only the initialized ones +function onlyInitializedPlugins( + initPluginsConfigsResults: PluginConfigInitResult[], +): InitializedPlugin[] { + return initPluginsConfigsResults + .map((results) => results.plugin) + .filter((p) => p !== null); +} + /** * Runs the plugin constructors and returns their return values. It would load * plugin configs from `plugins`, `themes`, and `presets`. */ -export async function initPlugins( +export async function initPluginsConfigs( context: LoadContext, -): Promise { + pluginConfigs: NormalizedPluginConfig[], +): Promise { // We need to resolve plugins from the perspective of the site config, as if // we are using `require.resolve` on those module names. const pluginRequire = createRequire(context.siteConfigPath); - const pluginConfigs = await loadPluginConfigs(context); async function doLoadPluginVersion( normalizedPluginConfig: NormalizedPluginConfig, @@ -108,13 +123,15 @@ export async function initPlugins( async function initializePlugin( normalizedPluginConfig: NormalizedPluginConfig, - ): Promise { + ): Promise { const pluginVersion: PluginVersionInformation = await doLoadPluginVersion( normalizedPluginConfig, ); const pluginOptions = doValidatePluginOptions(normalizedPluginConfig); // Side-effect: merge the normalized theme config in the original one + // Note: it's important to do this before calling the plugin constructor + // Example: the theme classic plugin will read siteConfig.themeConfig context.siteConfig.themeConfig = { ...context.siteConfig.themeConfig, ...doValidateThemeConfig(normalizedPluginConfig), @@ -125,26 +142,61 @@ export async function initPlugins( pluginOptions, ); - if (!pluginInstance.name) { + // Returning null has been explicitly allowed + // It's a way for plugins to self-disable depending on context + // See https://github.com/facebook/docusaurus/pull/10286 + if (pluginInstance === null) { + return {config: normalizedPluginConfig, plugin: null}; + } + if (pluginInstance === undefined) { + throw new Error( + `A Docusaurus plugin returned 'undefined', which is forbidden. +A plugin is expected to return an object having at least a 'name' property. +If you want a plugin to self-disable depending on context/options, you can explicitly return 'null' instead of 'undefined'`, + ); + } + + if (!pluginInstance?.name) { throw new Error( `A Docusaurus plugin is missing a 'name' property. Note that even inline/anonymous plugin functions require a 'name' property.`, ); } - return { + const plugin: InitializedPlugin = { ...pluginInstance, options: pluginOptions, version: pluginVersion, path: path.dirname(normalizedPluginConfig.entryPath), }; + + return { + config: normalizedPluginConfig, + plugin, + }; } - const plugins: InitializedPlugin[] = await Promise.all( - pluginConfigs.map(initializePlugin), - ); + const plugins: PluginConfigInitResult[] = ( + await Promise.all(pluginConfigs.map(initializePlugin)) + ).filter((p) => p !== null); - ensureUniquePluginInstanceIds(plugins); + ensureUniquePluginInstanceIds(onlyInitializedPlugins(plugins)); return plugins; } + +/** + * Runs the plugin constructors and returns their return values + * for all the site context plugins that do not return null to self-disable. + */ +export async function initPlugins( + context: LoadContext, +): Promise { + const pluginConfigs = await loadPluginConfigs(context); + const initPluginsConfigsResults = await initPluginsConfigs( + context, + pluginConfigs, + ); + + return onlyInitializedPlugins(initPluginsConfigsResults); +}