feat(core): allow plugins to self-disable by returning null (#10286)

This commit is contained in:
Sébastien Lorber 2024-07-10 13:13:30 +02:00 committed by GitHub
parent 8c2943421b
commit 80203b385d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 181 additions and 86 deletions

View file

@ -21,7 +21,7 @@ const PluginName = 'docusaurus-plugin-client-redirects';
export default function pluginClientRedirectsPages( export default function pluginClientRedirectsPages(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin<void> { ): Plugin<void> | null {
const {trailingSlash} = context.siteConfig; const {trailingSlash} = context.siteConfig;
const router = context.siteConfig.future.experimental_router; const router = context.siteConfig.future.experimental_router;
@ -29,7 +29,7 @@ export default function pluginClientRedirectsPages(
logger.warn( logger.warn(
`${PluginName} does not support the Hash Router and will be disabled.`, `${PluginName} does not support the Hash Router and will be disabled.`,
); );
return {name: PluginName}; return null;
} }
return { return {

View file

@ -18,21 +18,21 @@ import type {PluginOptions, Options} from './options';
export default function pluginGoogleAnalytics( export default function pluginGoogleAnalytics(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin { ): Plugin | null {
if (process.env.NODE_ENV !== 'production') {
return null;
}
const {trackingID, anonymizeIP} = options; const {trackingID, anonymizeIP} = options;
const isProd = process.env.NODE_ENV === 'production';
return { return {
name: 'docusaurus-plugin-google-analytics', name: 'docusaurus-plugin-google-analytics',
getClientModules() { getClientModules() {
return isProd ? ['./analytics'] : []; return ['./analytics'];
}, },
injectHtmlTags() { injectHtmlTags() {
if (!isProd) {
return {};
}
return { return {
headTags: [ headTags: [
{ {

View file

@ -32,8 +32,10 @@ function createConfigSnippets({
export default function pluginGoogleGtag( export default function pluginGoogleGtag(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin { ): Plugin | null {
const isProd = process.env.NODE_ENV === 'production'; if (process.env.NODE_ENV !== 'production') {
return null;
}
const firstTrackingId = options.trackingID[0]; const firstTrackingId = options.trackingID[0];
@ -45,13 +47,10 @@ export default function pluginGoogleGtag(
}, },
getClientModules() { getClientModules() {
return isProd ? ['./gtag'] : []; return ['./gtag'];
}, },
injectHtmlTags() { injectHtmlTags() {
if (!isProd) {
return {};
}
return { return {
// Gtag includes GA by default, so we also preconnect to // Gtag includes GA by default, so we also preconnect to
// google-analytics. // google-analytics.

View file

@ -16,10 +16,12 @@ import type {PluginOptions, Options} from './options';
export default function pluginGoogleAnalytics( export default function pluginGoogleAnalytics(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin { ): Plugin | null {
const {containerId} = options; if (process.env.NODE_ENV !== 'production') {
const isProd = process.env.NODE_ENV === 'production'; return null;
}
const {containerId} = options;
return { return {
name: 'docusaurus-plugin-google-tag-manager', name: 'docusaurus-plugin-google-tag-manager',
@ -28,9 +30,6 @@ export default function pluginGoogleAnalytics(
}, },
injectHtmlTags() { injectHtmlTags() {
if (!isProd) {
return {};
}
return { return {
preBodyTags: [ preBodyTags: [
{ {

View file

@ -19,8 +19,6 @@ import type {PluginOptions} from '@docusaurus/plugin-pwa';
const PluginName = 'docusaurus-plugin-pwa'; const PluginName = 'docusaurus-plugin-pwa';
const isProd = process.env.NODE_ENV === 'production';
function getSWBabelLoader() { function getSWBabelLoader() {
return { return {
loader: 'babel-loader', loader: 'babel-loader',
@ -45,12 +43,21 @@ function getSWBabelLoader() {
export default function pluginPWA( export default function pluginPWA(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin<void> { ): Plugin<void> | 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 { const {
outDir, outDir,
baseUrl, baseUrl,
i18n: {currentLocale}, i18n: {currentLocale},
siteConfig,
} = context; } = context;
const { const {
debug, debug,
@ -61,13 +68,6 @@ export default function pluginPWA(
swRegister, swRegister,
} = options; } = options;
if (siteConfig.future.experimental_router === 'hash') {
logger.warn(
`${PluginName} does not support the Hash Router and will be disabled.`,
);
return {name: PluginName};
}
return { return {
name: PluginName, name: PluginName,
@ -79,7 +79,7 @@ export default function pluginPWA(
}, },
getClientModules() { getClientModules() {
return isProd && swRegister ? [swRegister] : []; return swRegister ? [swRegister] : [];
}, },
getDefaultCodeTranslationMessages() { getDefaultCodeTranslationMessages() {
@ -90,10 +90,6 @@ export default function pluginPWA(
}, },
configureWebpack(config) { configureWebpack(config) {
if (!isProd) {
return {};
}
return { return {
plugins: [ plugins: [
new webpack.EnvironmentPlugin({ new webpack.EnvironmentPlugin({
@ -111,37 +107,31 @@ export default function pluginPWA(
injectHtmlTags() { injectHtmlTags() {
const headTags: HtmlTags = []; const headTags: HtmlTags = [];
if (isProd) { pwaHead.forEach(({tagName, ...attributes}) => {
pwaHead.forEach(({tagName, ...attributes}) => { (['href', 'content'] as const).forEach((attribute) => {
(['href', 'content'] as const).forEach((attribute) => { const attributeValue = attributes[attribute];
const attributeValue = attributes[attribute];
if (!attributeValue) { if (!attributeValue) {
return; return;
} }
const attributePath = const attributePath =
!!path.extname(attributeValue) && attributeValue; !!path.extname(attributeValue) && attributeValue;
if (attributePath && !attributePath.startsWith(baseUrl)) { if (attributePath && !attributePath.startsWith(baseUrl)) {
attributes[attribute] = normalizeUrl([baseUrl, attributeValue]); attributes[attribute] = normalizeUrl([baseUrl, attributeValue]);
} }
});
return headTags.push({
tagName,
attributes,
});
}); });
}
return headTags.push({
tagName,
attributes,
});
});
return {headTags}; return {headTags};
}, },
async postBuild(props) { async postBuild(props) {
if (!isProd) {
return;
}
const swSourceFileTest = /\.m?js$/; const swSourceFileTest = /\.m?js$/;
const swWebpackConfig: Configuration = { const swWebpackConfig: Configuration = {

View file

@ -17,12 +17,12 @@ const PluginName = 'docusaurus-plugin-sitemap';
export default function pluginSitemap( export default function pluginSitemap(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin<void> { ): Plugin<void> | null {
if (context.siteConfig.future.experimental_router === 'hash') { if (context.siteConfig.future.experimental_router === 'hash') {
logger.warn( logger.warn(
`${PluginName} does not support the Hash Router and will be disabled.`, `${PluginName} does not support the Hash Router and will be disabled.`,
); );
return {name: PluginName}; return null;
} }
return { return {

View file

@ -11,14 +11,15 @@ import type {PluginOptions, Options} from './options';
export default function pluginVercelAnalytics( export default function pluginVercelAnalytics(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin { ): Plugin | null {
const isProd = process.env.NODE_ENV === 'production'; if (process.env.NODE_ENV !== 'production') {
return null;
}
return { return {
name: 'docusaurus-plugin-vercel-analytics', name: 'docusaurus-plugin-vercel-analytics',
getClientModules() { getClientModules() {
return isProd ? ['./analytics'] : []; return ['./analytics'];
}, },
contentLoaded({actions}) { contentLoaded({actions}) {

View file

@ -191,7 +191,10 @@ export type LoadedPlugin = InitializedPlugin & {
export type PluginModule<Content = unknown> = { export type PluginModule<Content = unknown> = {
(context: LoadContext, options: unknown): (context: LoadContext, options: unknown):
| Plugin<Content> | Plugin<Content>
| Promise<Plugin<Content>>; | Promise<Plugin<Content>>
| null
| Promise<null>;
validateOptions?: <T, U>(data: OptionValidationContext<T, U>) => U; validateOptions?: <T, U>(data: OptionValidationContext<T, U>) => U;
validateThemeConfig?: <T>(data: ThemeConfigValidationContext<T>) => T; validateThemeConfig?: <T>(data: ThemeConfigValidationContext<T>) => T;

View file

@ -6,22 +6,41 @@
*/ */
import {loadContext} from '../../server/site'; import {loadContext} from '../../server/site';
import {initPlugins} from '../../server/plugins/init'; import {initPluginsConfigs} from '../../server/plugins/init';
import {loadPluginConfigs} from '../../server/plugins/configs'; 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<SwizzlePlugin[]> {
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( export async function initSwizzleContext(
siteDir: string, siteDir: string,
options: SwizzleCLIOptions, options: SwizzleCLIOptions,
): Promise<SwizzleContext> { ): Promise<SwizzleContext> {
const context = await loadContext({siteDir, config: options.config}); const context = await loadContext({siteDir, config: options.config});
const plugins = await initPlugins(context);
const pluginConfigs = await loadPluginConfigs(context);
return { return {
plugins: plugins.map((plugin, pluginIndex) => ({ plugins: await getSwizzlePlugins(context),
plugin: pluginConfigs[pluginIndex]!,
instance: plugin,
})),
}; };
} }

View file

@ -21,6 +21,10 @@ module.exports = {
}, },
{it: 'should work'}, {it: 'should work'},
], ],
function (context, options) {
// it's ok for a plugin to self-disable
return null;
},
'./plugin3.js', './plugin3.js',
['./plugin4.js', {}], ['./plugin4.js', {}],
'./pluginEsm', './pluginEsm',

View file

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

View file

@ -24,7 +24,7 @@ async function loadSite(
describe('initPlugins', () => { describe('initPlugins', () => {
it('parses plugins correctly and loads them in correct order', async () => { it('parses plugins correctly and loads them in correct order', async () => {
const {context, plugins} = await loadSite('site-with-plugin'); 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).toHaveLength(10);
expect(plugins[0]!.name).toBe('preset-plugin1'); expect(plugins[0]!.name).toBe('preset-plugin1');
@ -85,4 +85,13 @@ describe('initPlugins', () => {
Note that even inline/anonymous plugin functions require a 'name' property." 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'"
`);
});
}); });

View file

@ -49,17 +49,32 @@ function getThemeValidationFunction(
return normalizedPluginConfig.plugin.validateThemeConfig; 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 * Runs the plugin constructors and returns their return values. It would load
* plugin configs from `plugins`, `themes`, and `presets`. * plugin configs from `plugins`, `themes`, and `presets`.
*/ */
export async function initPlugins( export async function initPluginsConfigs(
context: LoadContext, context: LoadContext,
): Promise<InitializedPlugin[]> { pluginConfigs: NormalizedPluginConfig[],
): Promise<PluginConfigInitResult[]> {
// We need to resolve plugins from the perspective of the site config, as if // We need to resolve plugins from the perspective of the site config, as if
// we are using `require.resolve` on those module names. // we are using `require.resolve` on those module names.
const pluginRequire = createRequire(context.siteConfigPath); const pluginRequire = createRequire(context.siteConfigPath);
const pluginConfigs = await loadPluginConfigs(context);
async function doLoadPluginVersion( async function doLoadPluginVersion(
normalizedPluginConfig: NormalizedPluginConfig, normalizedPluginConfig: NormalizedPluginConfig,
@ -108,13 +123,15 @@ export async function initPlugins(
async function initializePlugin( async function initializePlugin(
normalizedPluginConfig: NormalizedPluginConfig, normalizedPluginConfig: NormalizedPluginConfig,
): Promise<InitializedPlugin> { ): Promise<PluginConfigInitResult> {
const pluginVersion: PluginVersionInformation = await doLoadPluginVersion( const pluginVersion: PluginVersionInformation = await doLoadPluginVersion(
normalizedPluginConfig, normalizedPluginConfig,
); );
const pluginOptions = doValidatePluginOptions(normalizedPluginConfig); const pluginOptions = doValidatePluginOptions(normalizedPluginConfig);
// Side-effect: merge the normalized theme config in the original one // 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 = {
...context.siteConfig.themeConfig, ...context.siteConfig.themeConfig,
...doValidateThemeConfig(normalizedPluginConfig), ...doValidateThemeConfig(normalizedPluginConfig),
@ -125,26 +142,61 @@ export async function initPlugins(
pluginOptions, 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( throw new Error(
`A Docusaurus plugin is missing a 'name' property. `A Docusaurus plugin is missing a 'name' property.
Note that even inline/anonymous plugin functions require a 'name' property.`, Note that even inline/anonymous plugin functions require a 'name' property.`,
); );
} }
return { const plugin: InitializedPlugin = {
...pluginInstance, ...pluginInstance,
options: pluginOptions, options: pluginOptions,
version: pluginVersion, version: pluginVersion,
path: path.dirname(normalizedPluginConfig.entryPath), path: path.dirname(normalizedPluginConfig.entryPath),
}; };
return {
config: normalizedPluginConfig,
plugin,
};
} }
const plugins: InitializedPlugin[] = await Promise.all( const plugins: PluginConfigInitResult[] = (
pluginConfigs.map(initializePlugin), await Promise.all(pluginConfigs.map(initializePlugin))
); ).filter((p) => p !== null);
ensureUniquePluginInstanceIds(plugins); ensureUniquePluginInstanceIds(onlyInitializedPlugins(plugins));
return 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<InitializedPlugin[]> {
const pluginConfigs = await loadPluginConfigs(context);
const initPluginsConfigsResults = await initPluginsConfigs(
context,
pluginConfigs,
);
return onlyInitializedPlugins(initPluginsConfigsResults);
}