From ff5039f4137c80de010308d775b5f0bc74be9f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Mon, 13 May 2024 15:03:48 +0200 Subject: [PATCH] fix(core): `configurePostCss()` should run after `configureWebpack()` (#10132) --- .../src/__tests__/index.test.ts | 2 +- packages/docusaurus/src/commands/build.ts | 11 +- .../docusaurus/src/commands/start/webpack.ts | 4 +- .../src/webpack/__tests__/configure.test.ts | 458 ++++++++++++++++++ .../src/webpack/__tests__/utils.test.ts | 260 +--------- packages/docusaurus/src/webpack/configure.ts | 156 ++++++ packages/docusaurus/src/webpack/utils.ts | 134 ----- 7 files changed, 620 insertions(+), 405 deletions(-) create mode 100644 packages/docusaurus/src/webpack/__tests__/configure.test.ts create mode 100644 packages/docusaurus/src/webpack/configure.ts diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts index 65c1b3217919..b6975a29b0a5 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts @@ -13,7 +13,7 @@ import {isMatch} from 'picomatch'; import commander from 'commander'; import webpack from 'webpack'; import {loadContext} from '@docusaurus/core/src/server/site'; -import {applyConfigureWebpack} from '@docusaurus/core/src/webpack/utils'; +import {applyConfigureWebpack} from '@docusaurus/core/src/webpack/configure'; import {sortRoutes} from '@docusaurus/core/src/server/plugins/routeConfig'; import {posixPath} from '@docusaurus/utils'; import {normalizePluginOptions} from '@docusaurus/utils-validation'; diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index 21a7b14b8bbc..b823dbd1b627 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -15,11 +15,8 @@ import {handleBrokenLinks} from '../server/brokenLinks'; import {createBuildClientConfig} from '../webpack/client'; import createServerConfig from '../webpack/server'; -import { - executePluginsConfigurePostCss, - executePluginsConfigureWebpack, - compile, -} from '../webpack/utils'; +import {executePluginsConfigureWebpack} from '../webpack/configure'; +import {compile} from '../webpack/utils'; import {PerfLogger} from '../utils'; import {loadI18n} from '../server/i18n'; @@ -325,10 +322,6 @@ async function getBuildClientConfig({ bundleAnalyzer: cliOptions.bundleAnalyzer ?? false, }); let {config} = result; - config = executePluginsConfigurePostCss({ - plugins, - config, - }); config = executePluginsConfigureWebpack({ plugins, config, diff --git a/packages/docusaurus/src/commands/start/webpack.ts b/packages/docusaurus/src/commands/start/webpack.ts index 4a9d8fa8413e..c3b0ceec8296 100644 --- a/packages/docusaurus/src/commands/start/webpack.ts +++ b/packages/docusaurus/src/commands/start/webpack.ts @@ -13,12 +13,11 @@ import WebpackDevServer from 'webpack-dev-server'; import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware'; import {createPollingOptions} from './watcher'; import { - executePluginsConfigurePostCss, - executePluginsConfigureWebpack, formatStatsErrorMessage, getHttpsConfig, printStatsWarnings, } from '../../webpack/utils'; +import {executePluginsConfigureWebpack} from '../../webpack/configure'; import {createStartClientConfig} from '../../webpack/client'; import type {StartCLIOptions} from './start'; import type {Props} from '@docusaurus/types'; @@ -135,7 +134,6 @@ async function getStartClientConfig({ minify, poll, }); - config = executePluginsConfigurePostCss({plugins, config}); config = executePluginsConfigureWebpack({ plugins, config, diff --git a/packages/docusaurus/src/webpack/__tests__/configure.test.ts b/packages/docusaurus/src/webpack/__tests__/configure.test.ts new file mode 100644 index 000000000000..8d1ce1e9114b --- /dev/null +++ b/packages/docusaurus/src/webpack/__tests__/configure.test.ts @@ -0,0 +1,458 @@ +/** + * 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 * as path from 'path'; +import * as webpack from 'webpack'; +import {fromPartial} from '@total-typescript/shoehorn'; +import { + applyConfigureWebpack, + applyConfigurePostCss, + executePluginsConfigureWebpack, +} from '../configure'; +import type {Configuration} from 'webpack'; +import type {LoadedPlugin, Plugin} from '@docusaurus/types'; + +describe('extending generated webpack config', () => { + it('direct mutation on generated webpack config object', async () => { + // Fake generated webpack config + let config: Configuration = { + output: { + path: __dirname, + filename: 'bundle.js', + }, + }; + + // @ts-expect-error: Testing an edge-case that we did not write types for + const configureWebpack: NonNullable = ( + generatedConfig, + isServer, + ) => { + if (!isServer) { + generatedConfig.entry = 'entry.js'; + generatedConfig.output = { + path: path.join(__dirname, 'dist'), + filename: 'new.bundle.js', + }; + } + // Implicitly returning undefined to test null-safety + }; + + config = applyConfigureWebpack(configureWebpack, config, false, undefined, { + content: 42, + }); + expect(config).toEqual({ + entry: 'entry.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'new.bundle.js', + }, + }); + const errors = webpack.validate(config); + expect(errors).toBeUndefined(); + }); + + it('webpack-merge with user webpack config object', async () => { + let config: Configuration = { + output: { + path: __dirname, + filename: 'bundle.js', + }, + }; + + const configureWebpack: Plugin['configureWebpack'] = () => ({ + entry: 'entry.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'new.bundle.js', + }, + }); + + config = applyConfigureWebpack(configureWebpack, config, false, undefined, { + content: 42, + }); + expect(config).toEqual({ + entry: 'entry.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'new.bundle.js', + }, + }); + const errors = webpack.validate(config); + expect(errors).toBeUndefined(); + }); + + it('webpack-merge with custom strategy', async () => { + const config: Configuration = { + module: { + rules: [{use: 'xxx'}, {use: 'yyy'}], + }, + }; + + const createConfigureWebpack = + (mergeStrategy?: { + [key: string]: 'prepend' | 'append'; + }): NonNullable => + () => ({ + module: { + rules: [{use: 'zzz'}], + }, + mergeStrategy, + }); + + const defaultStrategyMergeConfig = applyConfigureWebpack( + createConfigureWebpack(), + config, + false, + undefined, + {content: 42}, + ); + expect(defaultStrategyMergeConfig).toEqual({ + module: { + rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}], + }, + }); + + const prependRulesStrategyConfig = applyConfigureWebpack( + createConfigureWebpack({'module.rules': 'prepend'}), + config, + false, + undefined, + {content: 42}, + ); + expect(prependRulesStrategyConfig).toEqual({ + module: { + rules: [{use: 'zzz'}, {use: 'xxx'}, {use: 'yyy'}], + }, + }); + + const uselessMergeStrategyConfig = applyConfigureWebpack( + createConfigureWebpack({uselessAttributeName: 'append'}), + config, + false, + undefined, + {content: 42}, + ); + expect(uselessMergeStrategyConfig).toEqual({ + module: { + rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}], + }, + }); + }); +}); + +describe('extending PostCSS', () => { + it('user plugin should be appended in PostCSS loader', () => { + let webpackConfig: Configuration = { + output: { + path: __dirname, + filename: 'bundle.js', + }, + module: { + rules: [ + { + test: 'any', + use: [ + { + loader: 'some-loader-1', + options: {}, + }, + { + loader: 'some-loader-2', + options: {}, + }, + { + loader: 'postcss-loader-1', + options: { + postcssOptions: { + plugins: [['default-postcss-loader-1-plugin']], + }, + }, + }, + { + loader: 'some-loader-3', + options: {}, + }, + ], + }, + { + test: '2nd-test', + use: [ + { + loader: 'postcss-loader-2', + options: { + postcssOptions: { + plugins: [['default-postcss-loader-2-plugin']], + }, + }, + }, + ], + }, + ], + }, + }; + + function createFakePlugin(name: string) { + return [name, {}]; + } + + // Run multiple times: ensure last run does not override previous runs + webpackConfig = applyConfigurePostCss( + (postCssOptions) => ({ + ...postCssOptions, + plugins: [ + ...postCssOptions.plugins, + createFakePlugin('postcss-plugin-1'), + ], + }), + webpackConfig, + ); + + webpackConfig = applyConfigurePostCss( + (postCssOptions) => ({ + ...postCssOptions, + plugins: [ + createFakePlugin('postcss-plugin-2'), + ...postCssOptions.plugins, + ], + }), + webpackConfig, + ); + + webpackConfig = applyConfigurePostCss( + (postCssOptions) => ({ + ...postCssOptions, + plugins: [ + ...postCssOptions.plugins, + createFakePlugin('postcss-plugin-3'), + ], + }), + webpackConfig, + ); + + // @ts-expect-error: relax type + const postCssLoader1 = webpackConfig.module?.rules[0].use[2]; + expect(postCssLoader1.loader).toBe('postcss-loader-1'); + + const pluginNames1 = postCssLoader1.options.postcssOptions.plugins.map( + (p: unknown[]) => p[0], + ); + expect(pluginNames1).toHaveLength(4); + expect(pluginNames1).toEqual([ + 'postcss-plugin-2', + 'default-postcss-loader-1-plugin', + 'postcss-plugin-1', + 'postcss-plugin-3', + ]); + + // @ts-expect-error: relax type + const postCssLoader2 = webpackConfig.module?.rules[1].use[0]; + expect(postCssLoader2.loader).toBe('postcss-loader-2'); + + const pluginNames2 = postCssLoader2.options.postcssOptions.plugins.map( + (p: unknown[]) => p[0], + ); + expect(pluginNames2).toHaveLength(4); + expect(pluginNames2).toEqual([ + 'postcss-plugin-2', + 'default-postcss-loader-2-plugin', + 'postcss-plugin-1', + 'postcss-plugin-3', + ]); + }); +}); + +describe('executePluginsConfigureWebpack', () => { + function fakePlugin(partialPlugin: Partial): LoadedPlugin { + return fromPartial({ + ...partialPlugin, + }); + } + + it('can merge Webpack aliases of 2 plugins into base config', () => { + const config = executePluginsConfigureWebpack({ + config: {resolve: {alias: {'initial-alias': 'initial-alias-value'}}}, + isServer: false, + jsLoader: 'babel', + plugins: [ + fakePlugin({ + configureWebpack: () => { + return {resolve: {alias: {'p1-alias': 'p1-alias-value'}}}; + }, + }), + fakePlugin({ + configureWebpack: () => { + return {resolve: {alias: {'p2-alias': 'p2-alias-value'}}}; + }, + }), + ], + }); + + expect(config).toMatchInlineSnapshot( + {}, + ` + { + "resolve": { + "alias": { + "initial-alias": "initial-alias-value", + "p1-alias": "p1-alias-value", + "p2-alias": "p2-alias-value", + }, + }, + } + `, + ); + }); + + it('can configurePostCSS() for all loaders added through configureWebpack()', () => { + const config = executePluginsConfigureWebpack({ + config: {}, + isServer: false, + jsLoader: 'babel', + plugins: [ + fakePlugin({ + configurePostCss: (postCssOptions) => { + // Imperative mutation should work + postCssOptions.plugins.push('p1-added-postcss-plugin'); + return postCssOptions; + }, + configureWebpack: () => { + return { + module: { + rules: [ + { + test: /\.module.scss$/, + use: 'some-loader', + options: { + postcssOptions: { + plugins: ['p1-initial-postcss-plugin'], + }, + }, + }, + ], + }, + }; + }, + }), + fakePlugin({ + configurePostCss: (postCssOptions) => { + postCssOptions.plugins.push('p2-added-postcss-plugin'); + return postCssOptions; + }, + configureWebpack: () => { + return { + module: { + rules: [ + { + test: /\.module.scss$/, + use: [ + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: ['p2-initial-postcss-plugin'], + }, + }, + }, + ], + }, + ], + }, + }; + }, + }), + fakePlugin({ + configurePostCss: (postCssOptions) => { + // Functional/immutable copy mutation should work + return { + ...postCssOptions, + plugins: [...postCssOptions.plugins, 'p3-added-postcss-plugin'], + }; + }, + configureWebpack: () => { + return { + module: { + rules: [ + { + test: /\.module.scss$/, + oneOf: [ + { + use: 'some-loader', + options: { + postcssOptions: { + plugins: ['p3-initial-postcss-plugin'], + }, + }, + }, + ], + }, + ], + }, + }; + }, + }), + ], + }); + + expect(config.module.rules).toHaveLength(3); + expect(config.module.rules[0]).toMatchInlineSnapshot(` + { + "options": { + "postcssOptions": { + "plugins": [ + "p1-initial-postcss-plugin", + "p1-added-postcss-plugin", + "p2-added-postcss-plugin", + "p3-added-postcss-plugin", + ], + }, + }, + "test": /\\\\\\.module\\.scss\\$/, + "use": "some-loader", + } + `); + expect(config.module.rules[1]).toMatchInlineSnapshot(` + { + "test": /\\\\\\.module\\.scss\\$/, + "use": [ + { + "loader": "postcss-loader", + "options": { + "postcssOptions": { + "plugins": [ + "p2-initial-postcss-plugin", + "p1-added-postcss-plugin", + "p2-added-postcss-plugin", + "p3-added-postcss-plugin", + ], + }, + }, + }, + ], + } + `); + expect(config.module.rules[2]).toMatchInlineSnapshot(` + { + "oneOf": [ + { + "options": { + "postcssOptions": { + "plugins": [ + "p3-initial-postcss-plugin", + "p1-added-postcss-plugin", + "p2-added-postcss-plugin", + "p3-added-postcss-plugin", + ], + }, + }, + "use": "some-loader", + }, + ], + "test": /\\\\\\.module\\.scss\\$/, + } + `); + }); +}); diff --git a/packages/docusaurus/src/webpack/__tests__/utils.test.ts b/packages/docusaurus/src/webpack/__tests__/utils.test.ts index dcc23d635154..5f0a769e6585 100644 --- a/packages/docusaurus/src/webpack/__tests__/utils.test.ts +++ b/packages/docusaurus/src/webpack/__tests__/utils.test.ts @@ -6,15 +6,8 @@ */ import path from 'path'; -import webpack, {type Configuration, type RuleSetRule} from 'webpack'; - -import { - getCustomizableJSLoader, - applyConfigureWebpack, - applyConfigurePostCss, - getHttpsConfig, -} from '../utils'; -import type {Plugin} from '@docusaurus/types'; +import {getCustomizableJSLoader, getHttpsConfig} from '../utils'; +import type {RuleSetRule} from 'webpack'; describe('customize JS loader', () => { it('getCustomizableJSLoader defaults to babel loader', () => { @@ -50,255 +43,6 @@ describe('customize JS loader', () => { }); }); -describe('extending generated webpack config', () => { - it('direct mutation on generated webpack config object', async () => { - // Fake generated webpack config - let config: Configuration = { - output: { - path: __dirname, - filename: 'bundle.js', - }, - }; - - // @ts-expect-error: Testing an edge-case that we did not write types for - const configureWebpack: NonNullable = ( - generatedConfig, - isServer, - ) => { - if (!isServer) { - generatedConfig.entry = 'entry.js'; - generatedConfig.output = { - path: path.join(__dirname, 'dist'), - filename: 'new.bundle.js', - }; - } - // Implicitly returning undefined to test null-safety - }; - - config = applyConfigureWebpack(configureWebpack, config, false, undefined, { - content: 42, - }); - expect(config).toEqual({ - entry: 'entry.js', - output: { - path: path.join(__dirname, 'dist'), - filename: 'new.bundle.js', - }, - }); - const errors = webpack.validate(config); - expect(errors).toBeUndefined(); - }); - - it('webpack-merge with user webpack config object', async () => { - let config: Configuration = { - output: { - path: __dirname, - filename: 'bundle.js', - }, - }; - - const configureWebpack: Plugin['configureWebpack'] = () => ({ - entry: 'entry.js', - output: { - path: path.join(__dirname, 'dist'), - filename: 'new.bundle.js', - }, - }); - - config = applyConfigureWebpack(configureWebpack, config, false, undefined, { - content: 42, - }); - expect(config).toEqual({ - entry: 'entry.js', - output: { - path: path.join(__dirname, 'dist'), - filename: 'new.bundle.js', - }, - }); - const errors = webpack.validate(config); - expect(errors).toBeUndefined(); - }); - - it('webpack-merge with custom strategy', async () => { - const config: Configuration = { - module: { - rules: [{use: 'xxx'}, {use: 'yyy'}], - }, - }; - - const createConfigureWebpack = - (mergeStrategy?: { - [key: string]: 'prepend' | 'append'; - }): NonNullable => - () => ({ - module: { - rules: [{use: 'zzz'}], - }, - mergeStrategy, - }); - - const defaultStrategyMergeConfig = applyConfigureWebpack( - createConfigureWebpack(), - config, - false, - undefined, - {content: 42}, - ); - expect(defaultStrategyMergeConfig).toEqual({ - module: { - rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}], - }, - }); - - const prependRulesStrategyConfig = applyConfigureWebpack( - createConfigureWebpack({'module.rules': 'prepend'}), - config, - false, - undefined, - {content: 42}, - ); - expect(prependRulesStrategyConfig).toEqual({ - module: { - rules: [{use: 'zzz'}, {use: 'xxx'}, {use: 'yyy'}], - }, - }); - - const uselessMergeStrategyConfig = applyConfigureWebpack( - createConfigureWebpack({uselessAttributeName: 'append'}), - config, - false, - undefined, - {content: 42}, - ); - expect(uselessMergeStrategyConfig).toEqual({ - module: { - rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}], - }, - }); - }); -}); - -describe('extending PostCSS', () => { - it('user plugin should be appended in PostCSS loader', () => { - let webpackConfig: Configuration = { - output: { - path: __dirname, - filename: 'bundle.js', - }, - module: { - rules: [ - { - test: 'any', - use: [ - { - loader: 'some-loader-1', - options: {}, - }, - { - loader: 'some-loader-2', - options: {}, - }, - { - loader: 'postcss-loader-1', - options: { - postcssOptions: { - plugins: [['default-postcss-loader-1-plugin']], - }, - }, - }, - { - loader: 'some-loader-3', - options: {}, - }, - ], - }, - { - test: '2nd-test', - use: [ - { - loader: 'postcss-loader-2', - options: { - postcssOptions: { - plugins: [['default-postcss-loader-2-plugin']], - }, - }, - }, - ], - }, - ], - }, - }; - - function createFakePlugin(name: string) { - return [name, {}]; - } - - // Run multiple times: ensure last run does not override previous runs - webpackConfig = applyConfigurePostCss( - (postCssOptions) => ({ - ...postCssOptions, - plugins: [ - ...postCssOptions.plugins, - createFakePlugin('postcss-plugin-1'), - ], - }), - webpackConfig, - ); - - webpackConfig = applyConfigurePostCss( - (postCssOptions) => ({ - ...postCssOptions, - plugins: [ - createFakePlugin('postcss-plugin-2'), - ...postCssOptions.plugins, - ], - }), - webpackConfig, - ); - - webpackConfig = applyConfigurePostCss( - (postCssOptions) => ({ - ...postCssOptions, - plugins: [ - ...postCssOptions.plugins, - createFakePlugin('postcss-plugin-3'), - ], - }), - webpackConfig, - ); - - // @ts-expect-error: relax type - const postCssLoader1 = webpackConfig.module?.rules[0].use[2]; - expect(postCssLoader1.loader).toBe('postcss-loader-1'); - - const pluginNames1 = postCssLoader1.options.postcssOptions.plugins.map( - (p: unknown[]) => p[0], - ); - expect(pluginNames1).toHaveLength(4); - expect(pluginNames1).toEqual([ - 'postcss-plugin-2', - 'default-postcss-loader-1-plugin', - 'postcss-plugin-1', - 'postcss-plugin-3', - ]); - - // @ts-expect-error: relax type - const postCssLoader2 = webpackConfig.module?.rules[1].use[0]; - expect(postCssLoader2.loader).toBe('postcss-loader-2'); - - const pluginNames2 = postCssLoader2.options.postcssOptions.plugins.map( - (p: unknown[]) => p[0], - ); - expect(pluginNames2).toHaveLength(4); - expect(pluginNames2).toEqual([ - 'postcss-plugin-2', - 'default-postcss-loader-2-plugin', - 'postcss-plugin-1', - 'postcss-plugin-3', - ]); - }); -}); - describe('getHttpsConfig', () => { const originalEnv = process.env; diff --git a/packages/docusaurus/src/webpack/configure.ts b/packages/docusaurus/src/webpack/configure.ts new file mode 100644 index 000000000000..cf499645feca --- /dev/null +++ b/packages/docusaurus/src/webpack/configure.ts @@ -0,0 +1,156 @@ +/** + * 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 { + mergeWithCustomize, + customizeArray, + customizeObject, +} from 'webpack-merge'; +import {getCustomizableJSLoader, getStyleLoaders} from './utils'; + +import type {Configuration, RuleSetRule} from 'webpack'; +import type { + Plugin, + PostCssOptions, + ConfigureWebpackUtils, + LoadedPlugin, +} from '@docusaurus/types'; + +/** + * Helper function to modify webpack config + * @param configureWebpack a webpack config or a function to modify config + * @param config initial webpack config + * @param isServer indicates if this is a server webpack configuration + * @param jsLoader custom js loader config + * @param content content loaded by the plugin + * @returns final/ modified webpack config + */ +export function applyConfigureWebpack( + configureWebpack: NonNullable, + config: Configuration, + isServer: boolean, + jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined, + content: unknown, +): Configuration { + // Export some utility functions + const utils: ConfigureWebpackUtils = { + getStyleLoaders, + getJSLoader: getCustomizableJSLoader(jsLoader), + }; + if (typeof configureWebpack === 'function') { + const {mergeStrategy, ...res} = + configureWebpack(config, isServer, utils, content) ?? {}; + const customizeRules = mergeStrategy ?? {}; + return mergeWithCustomize({ + customizeArray: customizeArray(customizeRules), + customizeObject: customizeObject(customizeRules), + })(config, res); + } + return config; +} + +export function applyConfigurePostCss( + configurePostCss: NonNullable, + config: Configuration, +): Configuration { + type LocalPostCSSLoader = object & { + options: {postcssOptions: PostCssOptions}; + }; + + // Not ideal heuristic but good enough for our use-case? + function isPostCssLoader(loader: unknown): loader is LocalPostCSSLoader { + return !!(loader as LocalPostCSSLoader)?.options?.postcssOptions; + } + + // Does not handle all edge cases, but good enough for now + function overridePostCssOptions(entry: RuleSetRule) { + if (isPostCssLoader(entry)) { + entry.options.postcssOptions = configurePostCss( + entry.options.postcssOptions, + ); + } else if (Array.isArray(entry.oneOf)) { + entry.oneOf.forEach((r) => { + if (r) { + overridePostCssOptions(r); + } + }); + } else if (Array.isArray(entry.use)) { + entry.use + .filter((u) => typeof u === 'object') + .forEach((rule) => overridePostCssOptions(rule as RuleSetRule)); + } + } + + config.module?.rules?.forEach((rule) => + overridePostCssOptions(rule as RuleSetRule), + ); + + return config; +} + +// Plugin Lifecycle - configurePostCss() +function executePluginsConfigurePostCss({ + plugins, + config, +}: { + plugins: LoadedPlugin[]; + config: Configuration; +}): Configuration { + let resultConfig = config; + plugins.forEach((plugin) => { + const {configurePostCss} = plugin; + if (configurePostCss) { + resultConfig = applyConfigurePostCss( + configurePostCss.bind(plugin), + resultConfig, + ); + } + }); + return resultConfig; +} + +// Plugin Lifecycle - configureWebpack() +export function executePluginsConfigureWebpack({ + plugins, + config, + isServer, + jsLoader, +}: { + plugins: LoadedPlugin[]; + config: Configuration; + isServer: boolean; + jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined; +}): Configuration { + // Step1 - Configure Webpack + let resultConfig = config; + plugins.forEach((plugin) => { + const {configureWebpack} = plugin; + if (configureWebpack) { + resultConfig = applyConfigureWebpack( + configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. + resultConfig, + isServer, + jsLoader, + plugin.content, + ); + } + }); + + // Step2 - For client code, configure PostCSS + // The order matters! We want to configure PostCSS on loaders + // that were potentially added by configureWebpack + // See https://github.com/facebook/docusaurus/issues/10106 + // Note: it's useless to configure postCSS for the server + if (!isServer) { + resultConfig = executePluginsConfigurePostCss({ + plugins, + config: resultConfig, + }); + } + + return resultConfig; +} diff --git a/packages/docusaurus/src/webpack/utils.ts b/packages/docusaurus/src/webpack/utils.ts index ee6c61f1ec66..86a695ea79a1 100644 --- a/packages/docusaurus/src/webpack/utils.ts +++ b/packages/docusaurus/src/webpack/utils.ts @@ -11,20 +11,9 @@ import crypto from 'crypto'; import logger from '@docusaurus/logger'; import {BABEL_CONFIG_FILE_NAME} from '@docusaurus/utils'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import { - mergeWithCustomize, - customizeArray, - customizeObject, -} from 'webpack-merge'; import webpack, {type Configuration, type RuleSetRule} from 'webpack'; import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages'; import type {TransformOptions} from '@babel/core'; -import type { - Plugin, - PostCssOptions, - ConfigureWebpackUtils, - LoadedPlugin, -} from '@docusaurus/types'; export function formatStatsErrorMessage( statsJson: ReturnType | undefined, @@ -181,129 +170,6 @@ export const getCustomizableJSLoader = ? getDefaultBabelLoader({isServer, babelOptions}) : jsLoader(isServer); -/** - * Helper function to modify webpack config - * @param configureWebpack a webpack config or a function to modify config - * @param config initial webpack config - * @param isServer indicates if this is a server webpack configuration - * @param jsLoader custom js loader config - * @param content content loaded by the plugin - * @returns final/ modified webpack config - */ -export function applyConfigureWebpack( - configureWebpack: NonNullable, - config: Configuration, - isServer: boolean, - jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined, - content: unknown, -): Configuration { - // Export some utility functions - const utils: ConfigureWebpackUtils = { - getStyleLoaders, - getJSLoader: getCustomizableJSLoader(jsLoader), - }; - if (typeof configureWebpack === 'function') { - const {mergeStrategy, ...res} = - configureWebpack(config, isServer, utils, content) ?? {}; - const customizeRules = mergeStrategy ?? {}; - return mergeWithCustomize({ - customizeArray: customizeArray(customizeRules), - customizeObject: customizeObject(customizeRules), - })(config, res); - } - return config; -} - -export function applyConfigurePostCss( - configurePostCss: NonNullable, - config: Configuration, -): Configuration { - type LocalPostCSSLoader = object & { - options: {postcssOptions: PostCssOptions}; - }; - - // Not ideal heuristic but good enough for our use-case? - function isPostCssLoader(loader: unknown): loader is LocalPostCSSLoader { - return !!(loader as LocalPostCSSLoader)?.options?.postcssOptions; - } - - // Does not handle all edge cases, but good enough for now - function overridePostCssOptions(entry: RuleSetRule) { - if (isPostCssLoader(entry)) { - entry.options.postcssOptions = configurePostCss( - entry.options.postcssOptions, - ); - } else if (Array.isArray(entry.oneOf)) { - entry.oneOf.forEach((r) => { - if (r) { - overridePostCssOptions(r); - } - }); - } else if (Array.isArray(entry.use)) { - entry.use - .filter((u) => typeof u === 'object') - .forEach((rule) => overridePostCssOptions(rule as RuleSetRule)); - } - } - - config.module?.rules?.forEach((rule) => - overridePostCssOptions(rule as RuleSetRule), - ); - - return config; -} - -// Plugin Lifecycle - configurePostCss() -export function executePluginsConfigurePostCss({ - plugins, - config, -}: { - plugins: LoadedPlugin[]; - config: Configuration; -}): Configuration { - let resultConfig = config; - plugins.forEach((plugin) => { - const {configurePostCss} = plugin; - if (configurePostCss) { - resultConfig = applyConfigurePostCss( - configurePostCss.bind(plugin), - resultConfig, - ); - } - }); - return resultConfig; -} - -// Plugin Lifecycle - configureWebpack() -export function executePluginsConfigureWebpack({ - plugins, - config, - isServer, - jsLoader, -}: { - plugins: LoadedPlugin[]; - config: Configuration; - isServer: boolean; - jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined; -}): Configuration { - let resultConfig = config; - - plugins.forEach((plugin) => { - const {configureWebpack} = plugin; - if (configureWebpack) { - resultConfig = applyConfigureWebpack( - configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. - resultConfig, - isServer, - jsLoader, - plugin.content, - ); - } - }); - - return resultConfig; -} - declare global { interface Error { /** @see https://webpack.js.org/api/node/#error-handling */