diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 28e0906a127e..4602f2e999b5 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -199,6 +199,9 @@ export type AllContent = Record< > >; +// TODO improve type (not exposed by postcss-loader) +export type PostCssOptions = Record & {plugins: any[]}; + export interface Plugin { name: string; loadContent?(): Promise; @@ -220,6 +223,7 @@ export interface Plugin { isServer: boolean, utils: ConfigureWebpackUtils, ): Configuration & {mergeStrategy?: ConfigureWebpackFnMergeStrategy}; + configurePostCss?(options: PostCssOptions): PostCssOptions; getThemePath?(): string; getTypeScriptThemePath?(): string; getPathsToWatch?(): string[]; @@ -253,6 +257,7 @@ export interface Plugin { export type ConfigureWebpackFn = Plugin['configureWebpack']; export type ConfigureWebpackFnMergeStrategy = Record; +export type ConfigurePostCssFn = Plugin['configurePostCss']; export type PluginOptions = {id?: string} & Record; diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index 34b1557535c6..0ec5f13cfa2d 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -20,7 +20,11 @@ import {handleBrokenLinks} from '../server/brokenLinks'; import {BuildCLIOptions, Props} from '@docusaurus/types'; import createClientConfig from '../webpack/client'; import createServerConfig from '../webpack/server'; -import {compile, applyConfigureWebpack} from '../webpack/utils'; +import { + compile, + applyConfigureWebpack, + applyConfigurePostCss, +} from '../webpack/utils'; import CleanWebpackPlugin from '../webpack/plugins/CleanWebpackPlugin'; import {loadI18n} from '../server/i18n'; import {mapAsyncSequencial} from '@docusaurus/utils'; @@ -166,24 +170,27 @@ async function buildLocale({ }); } - // Plugin Lifecycle - configureWebpack. + // Plugin Lifecycle - configureWebpack and configurePostCss. plugins.forEach((plugin) => { - const {configureWebpack} = plugin; - if (!configureWebpack) { - return; + const {configureWebpack, configurePostCss} = plugin; + + if (configurePostCss) { + clientConfig = applyConfigurePostCss(configurePostCss, clientConfig); } - clientConfig = applyConfigureWebpack( - configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. - clientConfig, - false, - ); + if (configureWebpack) { + clientConfig = applyConfigureWebpack( + configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. + clientConfig, + false, + ); - serverConfig = applyConfigureWebpack( - configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. - serverConfig, - true, - ); + serverConfig = applyConfigureWebpack( + configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. + serverConfig, + true, + ); + } }); // Make sure generated client-manifest is cleaned first so we don't reuse diff --git a/packages/docusaurus/src/commands/start.ts b/packages/docusaurus/src/commands/start.ts index 47af0df45170..40a023e0cc57 100644 --- a/packages/docusaurus/src/commands/start.ts +++ b/packages/docusaurus/src/commands/start.ts @@ -24,7 +24,11 @@ import {load} from '../server'; import {StartCLIOptions} from '@docusaurus/types'; import {CONFIG_FILE_NAME, STATIC_DIR_NAME} from '../constants'; import createClientConfig from '../webpack/client'; -import {applyConfigureWebpack, getHttpsConfig} from '../webpack/utils'; +import { + applyConfigureWebpack, + applyConfigurePostCss, + getHttpsConfig, +} from '../webpack/utils'; import {getCLIOptionHost, getCLIOptionPort} from './commandUtils'; import {getTranslationsLocaleDirPath} from '../server/translations/translations'; @@ -134,18 +138,21 @@ export default async function start( ], }); - // Plugin Lifecycle - configureWebpack. + // Plugin Lifecycle - configureWebpack and configurePostCss. plugins.forEach((plugin) => { - const {configureWebpack} = plugin; - if (!configureWebpack) { - return; + const {configureWebpack, configurePostCss} = plugin; + + if (configurePostCss) { + config = applyConfigurePostCss(configurePostCss, config); } - config = applyConfigureWebpack( - configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. - config, - false, - ); + if (configureWebpack) { + config = applyConfigureWebpack( + configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. + config, + false, + ); + } }); // https://webpack.js.org/configuration/dev-server diff --git a/packages/docusaurus/src/webpack/__tests__/utils.test.ts b/packages/docusaurus/src/webpack/__tests__/utils.test.ts index 16385d022a60..4cef4e213866 100644 --- a/packages/docusaurus/src/webpack/__tests__/utils.test.ts +++ b/packages/docusaurus/src/webpack/__tests__/utils.test.ts @@ -12,7 +12,11 @@ import { } from 'webpack'; import path from 'path'; -import {applyConfigureWebpack, getFileLoaderUtils} from '../utils'; +import { + applyConfigureWebpack, + applyConfigurePostCss, + getFileLoaderUtils, +} from '../utils'; import { ConfigureWebpackFn, ConfigureWebpackFnMergeStrategy, @@ -148,3 +152,123 @@ describe('getFileLoaderUtils()', () => { ); }); }); + +describe('extending PostCSS', () => { + test('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) => { + return { + ...postCssOptions, + plugins: [ + ...postCssOptions.plugins, + createFakePlugin('postcss-plugin-1'), + ], + }; + }, webpackConfig); + + webpackConfig = applyConfigurePostCss((postCssOptions) => { + return { + ...postCssOptions, + plugins: [ + createFakePlugin('postcss-plugin-2'), + ...postCssOptions.plugins, + ], + }; + }, webpackConfig); + + webpackConfig = applyConfigurePostCss((postCssOptions) => { + return { + ...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).toEqual('postcss-loader-1'); + + const pluginNames1 = postCssLoader1.options.postcssOptions.plugins.map( + // @ts-expect-error: relax type + (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).toEqual('postcss-loader-2'); + + const pluginNames2 = postCssLoader2.options.postcssOptions.plugins.map( + // @ts-expect-error: relax type + (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', + ]); + }); +}); diff --git a/packages/docusaurus/src/webpack/utils.ts b/packages/docusaurus/src/webpack/utils.ts index 726fd21a187f..5ff76cd1cc5c 100644 --- a/packages/docusaurus/src/webpack/utils.ts +++ b/packages/docusaurus/src/webpack/utils.ts @@ -11,6 +11,7 @@ import merge from 'webpack-merge'; import webpack, { Configuration, Loader, + NewLoader, Plugin, RuleSetRule, Stats, @@ -23,7 +24,7 @@ import path from 'path'; import crypto from 'crypto'; import chalk from 'chalk'; import {TransformOptions} from '@babel/core'; -import {ConfigureWebpackFn} from '@docusaurus/types'; +import {ConfigureWebpackFn, ConfigurePostCssFn} from '@docusaurus/types'; import CssNanoPreset from '@docusaurus/cssnano-preset'; import {version as cacheLoaderVersion} from 'cache-loader/package.json'; import {BABEL_CONFIG_FILE_NAME, STATIC_ASSETS_DIR_NAME} from '../constants'; @@ -175,6 +176,31 @@ export function applyConfigureWebpack( return config; } +export function applyConfigurePostCss( + configurePostCss: NonNullable, + config: Configuration, +): Configuration { + type LocalPostCSSLoader = Loader & {options: {postcssOptions: any}}; + + function isPostCssLoader(loader: Loader): loader is LocalPostCSSLoader { + // TODO not ideal heuristic but good enough for our usecase? + return !!(loader as any)?.options?.postcssOptions; + } + + // Does not handle all edge cases, but good enough for now + config.module?.rules.map((rule) => { + for (const loader of rule.use as NewLoader[]) { + if (isPostCssLoader(loader)) { + loader.options.postcssOptions = configurePostCss( + loader.options.postcssOptions, + ); + } + } + }); + + return config; +} + // See https://webpack.js.org/configuration/stats/#statswarningsfilter // @slorber: note sure why we have to re-implement this logic // just know that legacy had this only partially implemented, so completed it diff --git a/website/docs/lifecycle-apis.md b/website/docs/lifecycle-apis.md index 9b6caec277f9..c41b3ac9143a 100644 --- a/website/docs/lifecycle-apis.md +++ b/website/docs/lifecycle-apis.md @@ -346,6 +346,45 @@ module.exports = function (context, options) { Read the [webpack-merge strategy doc](https://github.com/survivejs/webpack-merge#merging-with-strategies) for more details. +## `configurePostCss(options)` + +Modifies [`postcssOptions` of `postcss-loader`](https://webpack.js.org/loaders/postcss-loader/#postcssoptions) during the generation of the client bundle. + +Should return the mutated `postcssOptions`. + +By default, `postcssOptions` looks like this: + +```js +const postcssOptions = { + ident: 'postcss', + plugins: [ + require('postcss-preset-env')({ + autoprefixer: { + flexbox: 'no-2009', + }, + stage: 4, + }), + ], +}; +``` + +Example: + +```js title="docusaurus-plugin/src/index.js" +module.exports = function (context, options) { + return { + name: 'docusaurus-plugin', + // highlight-start + configurePostCss(postcssOptions) { + // Appends new PostCSS plugin. + postcssOptions.plugins.push(require('postcss-import')); + return postcssOptions; + }, + // highlight-end + }; +}; +``` + ## `postBuild(props)` Called when a (production) build finishes.