From 4391381c61caf90a9b59fe42cc6b1fe2dfe6c0ca Mon Sep 17 00:00:00 2001 From: izzy goldman Date: Tue, 2 Jul 2024 21:28:48 +0300 Subject: [PATCH] refactor(schema,vite,webpack): rework `postcss` module loading (#27946) Co-authored-by: Daniel Roe --- package.json | 2 + packages/schema/build.config.ts | 2 + packages/schema/src/config/postcss.ts | 35 +++++++++++++- packages/schema/src/types/config.ts | 3 +- packages/vite/src/css.ts | 46 +++++++++++------- packages/vite/src/vite.ts | 5 +- packages/webpack/src/configs/client.ts | 4 +- packages/webpack/src/configs/server.ts | 4 +- packages/webpack/src/presets/base.ts | 4 +- packages/webpack/src/presets/nuxt.ts | 4 +- packages/webpack/src/presets/style.ts | 26 +++++----- packages/webpack/src/utils/config.ts | 8 ++-- packages/webpack/src/utils/postcss.ts | 66 ++++++++++++-------------- packages/webpack/src/webpack.ts | 6 +-- pnpm-lock.yaml | 6 +++ 15 files changed, 138 insertions(+), 83 deletions(-) diff --git a/package.json b/package.json index 66273220f6..9a34d8e2a8 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,11 @@ "@vitejs/plugin-vue": "5.0.5", "@vitest/coverage-v8": "1.6.0", "@vue/test-utils": "2.4.6", + "autoprefixer": "^10.4.19", "case-police": "0.6.1", "changelogen": "0.5.5", "consola": "3.2.3", + "cssnano": "^7.0.3", "devalue": "5.0.0", "eslint": "9.6.0", "eslint-plugin-no-only-tests": "3.1.0", diff --git a/packages/schema/build.config.ts b/packages/schema/build.config.ts index e7179a174f..1583f9d538 100644 --- a/packages/schema/build.config.ts +++ b/packages/schema/build.config.ts @@ -23,6 +23,8 @@ export default defineBuildConfig({ externals: [ // Type imports '#app/components/nuxt-link', + 'cssnano', + 'autoprefixer', 'ofetch', 'vue-router', '@nuxt/telemetry', diff --git a/packages/schema/src/config/postcss.ts b/packages/schema/src/config/postcss.ts index 83694cfd18..56f02c1227 100644 --- a/packages/schema/src/config/postcss.ts +++ b/packages/schema/src/config/postcss.ts @@ -1,12 +1,45 @@ import { defineUntypedSchema } from 'untyped' +const ensureItemIsLast = (item: string) => (arr: string[]) => { + const index = arr.indexOf(item) + if (index !== -1) { + arr.splice(index, 1) + arr.push(item) + } + return arr +} + +const orderPresets = { + cssnanoLast: ensureItemIsLast('cssnano'), + autoprefixerLast: ensureItemIsLast('autoprefixer'), + autoprefixerAndCssnanoLast (names: string[]) { + return orderPresets.cssnanoLast(orderPresets.autoprefixerLast(names)) + }, +} + export default defineUntypedSchema({ postcss: { + /** + * A strategy for ordering PostCSS plugins. + * + * @type {'cssnanoLast' | 'autoprefixerLast' | 'autoprefixerAndCssnanoLast' | string[] | ((names: string[]) => string[])} + */ + order: { + $resolve: (val: string | string[] | ((plugins: string[]) => string[])): string[] | ((plugins: string[]) => string[]) => { + if (typeof val === 'string') { + if (!(val in orderPresets)) { + throw new Error(`[nuxt] Unknown PostCSS order preset: ${val}`) + } + return orderPresets[val as keyof typeof orderPresets] + } + return val ?? orderPresets.autoprefixerAndCssnanoLast + }, + }, /** * Options for configuring PostCSS plugins. * * https://postcss.org/ - * @type {Record & { autoprefixer?: any; cssnano?: any }} + * @type {Record | false> & { autoprefixer?: typeof import('autoprefixer').Options; cssnano?: typeof import('cssnano').Options }} */ plugins: { /** diff --git a/packages/schema/src/types/config.ts b/packages/schema/src/types/config.ts index 44a1390734..40ffdc508e 100644 --- a/packages/schema/src/types/config.ts +++ b/packages/schema/src/types/config.ts @@ -75,9 +75,10 @@ export interface NuxtBuilder { } // Normalized Nuxt options available as `nuxt.options.*` -export interface NuxtOptions extends Omit { +export interface NuxtOptions extends Omit { sourcemap: Required> builder: '@nuxt/vite-builder' | '@nuxt/webpack-builder' | NuxtBuilder + postcss: Omit & { order: Exclude } webpack: ConfigSchema['webpack'] & { $client: ConfigSchema['webpack'] $server: ConfigSchema['webpack'] diff --git a/packages/vite/src/css.ts b/packages/vite/src/css.ts index b5c4131140..954d630a8c 100644 --- a/packages/vite/src/css.ts +++ b/packages/vite/src/css.ts @@ -1,11 +1,16 @@ -import { requireModule } from '@nuxt/kit' -import type { Nuxt } from '@nuxt/schema' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { requireModule, tryResolveModule } from '@nuxt/kit' +import type { Nuxt, NuxtOptions } from '@nuxt/schema' import type { InlineConfig as ViteConfig } from 'vite' -import { distDir } from './dirs' +import { interopDefault } from 'mlly' +import type { Plugin } from 'postcss' -const lastPlugins = ['autoprefixer', 'cssnano'] +function sortPlugins ({ plugins, order }: NuxtOptions['postcss']): string[] { + const names = Object.keys(plugins) + return typeof order === 'function' ? order(names) : (order || names) +} -export function resolveCSSOptions (nuxt: Nuxt): ViteConfig['css'] { +export async function resolveCSSOptions (nuxt: Nuxt): Promise { const css: ViteConfig['css'] & { postcss: NonNullable['postcss'], string>> } = { postcss: { plugins: [], @@ -14,19 +19,26 @@ export function resolveCSSOptions (nuxt: Nuxt): ViteConfig['css'] { css.postcss.plugins = [] - const plugins = Object.entries(nuxt.options.postcss.plugins) - .sort((a, b) => lastPlugins.indexOf(a[0]) - lastPlugins.indexOf(b[0])) + const postcssOptions = nuxt.options.postcss - for (const [name, opts] of plugins) { - if (opts) { - // TODO: remove use of requireModule in favour of ESM import - const plugin = requireModule(name, { - paths: [ - ...nuxt.options.modulesDir, - distDir, - ], - }) - css.postcss.plugins.push(plugin(opts)) + const cwd = fileURLToPath(new URL('.', import.meta.url)) + for (const pluginName of sortPlugins(postcssOptions)) { + const pluginOptions = postcssOptions.plugins[pluginName] + if (!pluginOptions) { continue } + + const path = await tryResolveModule(pluginName, nuxt.options.modulesDir) + + let pluginFn: (opts: Record) => Plugin + // TODO: use jiti v2 + if (path) { + pluginFn = await import(pathToFileURL(path).href).then(interopDefault) + } else { + console.warn(`[nuxt] could not import postcss plugin \`${pluginName}\` with ESM. Please report this as a bug.`) + // fall back to cjs + pluginFn = requireModule(pluginName, { paths: [cwd] }) + } + if (typeof pluginFn === 'function') { + css.postcss.plugins.push(pluginFn(pluginOptions)) } } diff --git a/packages/vite/src/vite.ts b/packages/vite/src/vite.ts index a138240a59..bace63f36f 100644 --- a/packages/vite/src/vite.ts +++ b/packages/vite/src/vite.ts @@ -19,6 +19,7 @@ import { composableKeysPlugin } from './plugins/composable-keys' import { logLevelMap } from './utils/logger' import { ssrStylesPlugin } from './plugins/ssr-styles' import { VitePublicDirsPlugin } from './plugins/public-dirs' +import { distDir } from './dirs' export interface ViteBuildContext { nuxt: Nuxt @@ -33,6 +34,8 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => { (nuxt.options.vite.devBundler === 'vite-node' && nuxt.options.dev) const entry = await resolvePath(resolve(nuxt.options.appDir, useAsyncEntry ? 'entry.async' : 'entry')) + nuxt.options.modulesDir.push(distDir) + let allowDirs = [ nuxt.options.appDir, nuxt.options.workspaceDir, @@ -72,7 +75,7 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => { 'abort-controller': 'unenv/runtime/mock/empty', }, }, - css: resolveCSSOptions(nuxt), + css: await resolveCSSOptions(nuxt), define: { __NUXT_VERSION__: JSON.stringify(nuxt._version), __NUXT_ASYNC_CONTEXT__: nuxt.options.experimental.asyncContext, diff --git a/packages/webpack/src/configs/client.ts b/packages/webpack/src/configs/client.ts index aeb6cb2fe3..e1bf00a5fd 100644 --- a/packages/webpack/src/configs/client.ts +++ b/packages/webpack/src/configs/client.ts @@ -11,11 +11,11 @@ import type { WebpackConfigContext } from '../utils/config' import { applyPresets } from '../utils/config' import { nuxt } from '../presets/nuxt' -export function client (ctx: WebpackConfigContext) { +export async function client (ctx: WebpackConfigContext) { ctx.name = 'client' ctx.isClient = true - applyPresets(ctx, [ + await applyPresets(ctx, [ nuxt, clientPlugins, clientOptimization, diff --git a/packages/webpack/src/configs/server.ts b/packages/webpack/src/configs/server.ts index 16ab572de3..d9a65afcf4 100644 --- a/packages/webpack/src/configs/server.ts +++ b/packages/webpack/src/configs/server.ts @@ -9,11 +9,11 @@ import { node } from '../presets/node' const assetPattern = /\.(?:css|s[ca]ss|png|jpe?g|gif|svg|woff2?|eot|ttf|otf|webp|webm|mp4|ogv)(?:\?.*)?$/i -export function server (ctx: WebpackConfigContext) { +export async function server (ctx: WebpackConfigContext) { ctx.name = 'server' ctx.isServer = true - applyPresets(ctx, [ + await applyPresets(ctx, [ nuxt, node, serverStandalone, diff --git a/packages/webpack/src/presets/base.ts b/packages/webpack/src/presets/base.ts index 1149e980b4..6359e5283b 100644 --- a/packages/webpack/src/presets/base.ts +++ b/packages/webpack/src/presets/base.ts @@ -16,8 +16,8 @@ import WarningIgnorePlugin from '../plugins/warning-ignore' import type { WebpackConfigContext } from '../utils/config' import { applyPresets, fileName } from '../utils/config' -export function base (ctx: WebpackConfigContext) { - applyPresets(ctx, [ +export async function base (ctx: WebpackConfigContext) { + await applyPresets(ctx, [ baseAlias, baseConfig, basePlugins, diff --git a/packages/webpack/src/presets/nuxt.ts b/packages/webpack/src/presets/nuxt.ts index 5b95f07f59..318da10a20 100644 --- a/packages/webpack/src/presets/nuxt.ts +++ b/packages/webpack/src/presets/nuxt.ts @@ -8,8 +8,8 @@ import { pug } from './pug' import { style } from './style' import { vue } from './vue' -export function nuxt (ctx: WebpackConfigContext) { - applyPresets(ctx, [ +export async function nuxt (ctx: WebpackConfigContext) { + await applyPresets(ctx, [ base, assets, esbuild, diff --git a/packages/webpack/src/presets/style.ts b/packages/webpack/src/presets/style.ts index c9fa4be788..90d1031885 100644 --- a/packages/webpack/src/presets/style.ts +++ b/packages/webpack/src/presets/style.ts @@ -4,8 +4,8 @@ import type { WebpackConfigContext } from '../utils/config' import { applyPresets, fileName } from '../utils/config' import { getPostcssConfig } from '../utils/postcss' -export function style (ctx: WebpackConfigContext) { - applyPresets(ctx, [ +export async function style (ctx: WebpackConfigContext) { + await applyPresets(ctx, [ loaders, extractCSS, minimizer, @@ -32,32 +32,32 @@ function extractCSS (ctx: WebpackConfigContext) { })) } -function loaders (ctx: WebpackConfigContext) { +async function loaders (ctx: WebpackConfigContext) { // CSS - ctx.config.module!.rules!.push(createdStyleRule('css', /\.css$/i, null, ctx)) + ctx.config.module!.rules!.push(await createdStyleRule('css', /\.css$/i, null, ctx)) // PostCSS - ctx.config.module!.rules!.push(createdStyleRule('postcss', /\.p(ost)?css$/i, null, ctx)) + ctx.config.module!.rules!.push(await createdStyleRule('postcss', /\.p(ost)?css$/i, null, ctx)) // Less const lessLoader = { loader: 'less-loader', options: ctx.userConfig.loaders.less } - ctx.config.module!.rules!.push(createdStyleRule('less', /\.less$/i, lessLoader, ctx)) + ctx.config.module!.rules!.push(await createdStyleRule('less', /\.less$/i, lessLoader, ctx)) // Sass (TODO: optional dependency) const sassLoader = { loader: 'sass-loader', options: ctx.userConfig.loaders.sass } - ctx.config.module!.rules!.push(createdStyleRule('sass', /\.sass$/i, sassLoader, ctx)) + ctx.config.module!.rules!.push(await createdStyleRule('sass', /\.sass$/i, sassLoader, ctx)) const scssLoader = { loader: 'sass-loader', options: ctx.userConfig.loaders.scss } - ctx.config.module!.rules!.push(createdStyleRule('scss', /\.scss$/i, scssLoader, ctx)) + ctx.config.module!.rules!.push(await createdStyleRule('scss', /\.scss$/i, scssLoader, ctx)) // Stylus const stylusLoader = { loader: 'stylus-loader', options: ctx.userConfig.loaders.stylus } - ctx.config.module!.rules!.push(createdStyleRule('stylus', /\.styl(us)?$/i, stylusLoader, ctx)) + ctx.config.module!.rules!.push(await createdStyleRule('stylus', /\.styl(us)?$/i, stylusLoader, ctx)) } -function createdStyleRule (lang: string, test: RegExp, processorLoader: any, ctx: WebpackConfigContext) { +async function createdStyleRule (lang: string, test: RegExp, processorLoader: any, ctx: WebpackConfigContext) { const styleLoaders = [ - createPostcssLoadersRule(ctx), + await createPostcssLoadersRule(ctx), processorLoader, ].filter(Boolean) @@ -114,10 +114,10 @@ function createCssLoadersRule (ctx: WebpackConfigContext, cssLoaderOptions: any) ] } -function createPostcssLoadersRule (ctx: WebpackConfigContext) { +async function createPostcssLoadersRule (ctx: WebpackConfigContext) { if (!ctx.options.postcss) { return } - const config = getPostcssConfig(ctx.nuxt) + const config = await getPostcssConfig(ctx.nuxt) if (!config) { return diff --git a/packages/webpack/src/utils/config.ts b/packages/webpack/src/utils/config.ts index 602bc37aeb..526d5b111c 100644 --- a/packages/webpack/src/utils/config.ts +++ b/packages/webpack/src/utils/config.ts @@ -17,7 +17,7 @@ export interface WebpackConfigContext { transpile: RegExp[] } -type WebpackConfigPreset = (ctx: WebpackConfigContext, options?: object) => void +type WebpackConfigPreset = (ctx: WebpackConfigContext, options?: object) => void | Promise type WebpackConfigPresetItem = WebpackConfigPreset | [WebpackConfigPreset, any] export function createWebpackConfigContext (nuxt: Nuxt): WebpackConfigContext { @@ -37,12 +37,12 @@ export function createWebpackConfigContext (nuxt: Nuxt): WebpackConfigContext { } } -export function applyPresets (ctx: WebpackConfigContext, presets: WebpackConfigPresetItem | WebpackConfigPresetItem[]) { +export async function applyPresets (ctx: WebpackConfigContext, presets: WebpackConfigPresetItem | WebpackConfigPresetItem[]) { for (const preset of toArray(presets)) { if (Array.isArray(preset)) { - preset[0](ctx, preset[1]) + await preset[0](ctx, preset[1]) } else { - preset(ctx) + await preset(ctx) } } } diff --git a/packages/webpack/src/utils/postcss.ts b/packages/webpack/src/utils/postcss.ts index 58af9f7c63..3bafd486a5 100644 --- a/packages/webpack/src/utils/postcss.ts +++ b/packages/webpack/src/utils/postcss.ts @@ -1,37 +1,19 @@ -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import createResolver from 'postcss-import-resolver' -import { requireModule } from '@nuxt/kit' -import type { Nuxt } from '@nuxt/schema' +import { interopDefault } from 'mlly' +import { requireModule, tryResolveModule } from '@nuxt/kit' +import type { Nuxt, NuxtOptions } from '@nuxt/schema' import { defu } from 'defu' +import type { Plugin } from 'postcss' const isPureObject = (obj: unknown): obj is Object => obj !== null && !Array.isArray(obj) && typeof obj === 'object' -const ensureItemIsLast = (item: string) => (arr: string[]) => { - const index = arr.indexOf(item) - if (index !== -1) { - arr.splice(index, 1) - arr.push(item) - } - return arr +function sortPlugins ({ plugins, order }: NuxtOptions['postcss']): string[] { + const names = Object.keys(plugins) + return typeof order === 'function' ? order(names) : (order || names) } -const orderPresets = { - cssnanoLast: ensureItemIsLast('cssnano'), - autoprefixerLast: ensureItemIsLast('autoprefixer'), - autoprefixerAndCssnanoLast (names: string[]) { - return orderPresets.cssnanoLast(orderPresets.autoprefixerLast(names)) - }, -} - -export const getPostcssConfig = (nuxt: Nuxt) => { - function sortPlugins ({ plugins, order }: any) { - const names = Object.keys(plugins) - if (typeof order === 'string') { - order = orderPresets[order as keyof typeof orderPresets] - } - return typeof order === 'function' ? order(names, orderPresets) : (order || names) - } - +export async function getPostcssConfig (nuxt: Nuxt) { if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) { return false } @@ -54,21 +36,35 @@ export const getPostcssConfig = (nuxt: Nuxt) => { 'postcss-url': {}, }, sourceMap: nuxt.options.webpack.cssSourceMap, - // Array, String or Function - order: 'autoprefixerAndCssnanoLast', }) // Keep the order of default plugins if (!Array.isArray(postcssOptions.plugins) && isPureObject(postcssOptions.plugins)) { // Map postcss plugins into instances on object mode once const cwd = fileURLToPath(new URL('.', import.meta.url)) - postcssOptions.plugins = sortPlugins(postcssOptions).map((pluginName: string) => { - // TODO: remove use of requireModule in favour of ESM import - const pluginFn = requireModule(pluginName, { paths: [cwd] }) + const plugins: Plugin[] = [] + for (const pluginName of sortPlugins(postcssOptions)) { const pluginOptions = postcssOptions.plugins[pluginName] - if (!pluginOptions || typeof pluginFn !== 'function') { return null } - return pluginFn(pluginOptions) - }).filter(Boolean) + if (!pluginOptions) { continue } + + const path = await tryResolveModule(pluginName, nuxt.options.modulesDir) + + let pluginFn: (opts: Record) => Plugin + // TODO: use jiti v2 + if (path) { + pluginFn = await import(pathToFileURL(path).href).then(interopDefault) + } else { + console.warn(`[nuxt] could not import postcss plugin \`${pluginName}\` with ESM. Please report this as a bug.`) + // fall back to cjs + pluginFn = requireModule(pluginName, { paths: [cwd] }) + } + if (typeof pluginFn === 'function') { + plugins.push(pluginFn(pluginOptions)) + } + } + + // @ts-expect-error we are mutating type here from object to array + postcssOptions.plugins = plugins } return { diff --git a/packages/webpack/src/webpack.ts b/packages/webpack/src/webpack.ts index 2328a4104a..7b9555f768 100644 --- a/packages/webpack/src/webpack.ts +++ b/packages/webpack/src/webpack.ts @@ -25,12 +25,12 @@ import { applyPresets, createWebpackConfigContext, getWebpackConfig } from './ut export const bundle: NuxtBuilder['bundle'] = async (nuxt) => { registerVirtualModules() - const webpackConfigs = [client, ...nuxt.options.ssr ? [server] : []].map((preset) => { + const webpackConfigs = await Promise.all([client, ...nuxt.options.ssr ? [server] : []].map(async (preset) => { const ctx = createWebpackConfigContext(nuxt) ctx.userConfig = defu(nuxt.options.webpack[`$${preset.name as 'client' | 'server'}`], ctx.userConfig) - applyPresets(ctx, preset) + await applyPresets(ctx, preset) return getWebpackConfig(ctx) - }) + })) await nuxt.callHook('webpack:config', webpackConfigs) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68b1dc8f80..900fe3c028 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@vue/test-utils': specifier: 2.4.6 version: 2.4.6 + autoprefixer: + specifier: ^10.4.19 + version: 10.4.19(postcss@8.4.39) case-police: specifier: 0.6.1 version: 0.6.1 @@ -68,6 +71,9 @@ importers: consola: specifier: 3.2.3 version: 3.2.3 + cssnano: + specifier: ^7.0.3 + version: 7.0.3(postcss@8.4.39) devalue: specifier: 5.0.0 version: 5.0.0