refactor(schema,vite,webpack): rework `postcss` module loading (#27946)

Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
izzy goldman 2024-07-02 21:28:48 +03:00 committed by GitHub
parent e717937c03
commit 373d015ae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 138 additions and 83 deletions

View File

@ -62,9 +62,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",

View File

@ -23,6 +23,8 @@ export default defineBuildConfig({
externals: [
// Type imports
'#app/components/nuxt-link',
'cssnano',
'autoprefixer',
'ofetch',
'vue-router',
'@nuxt/telemetry',

View File

@ -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<string, any> & { autoprefixer?: any; cssnano?: any }}
* @type {Record<string, Record<string, unknown> | false> & { autoprefixer?: typeof import('autoprefixer').Options; cssnano?: typeof import('cssnano').Options }}
*/
plugins: {
/**

View File

@ -75,9 +75,10 @@ export interface NuxtBuilder {
}
// Normalized Nuxt options available as `nuxt.options.*`
export interface NuxtOptions extends Omit<ConfigSchema, 'builder' | 'webpack'> {
export interface NuxtOptions extends Omit<ConfigSchema, 'builder' | 'webpack' | 'postcss'> {
sourcemap: Required<Exclude<ConfigSchema['sourcemap'], boolean>>
builder: '@nuxt/vite-builder' | '@nuxt/webpack-builder' | NuxtBuilder
postcss: Omit<ConfigSchema['postcss'], 'order'> & { order: Exclude<ConfigSchema['postcss']['order'], string> }
webpack: ConfigSchema['webpack'] & {
$client: ConfigSchema['webpack']
$server: ConfigSchema['webpack']

View File

@ -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<ViteConfig['css']> {
const css: ViteConfig['css'] & { postcss: NonNullable<Exclude<NonNullable<ViteConfig['css']>['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<string, any>) => 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))
}
}

View File

@ -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
@ -32,6 +33,8 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
const useAsyncEntry = nuxt.options.experimental.asyncEntry || 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,
@ -71,7 +74,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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -17,7 +17,7 @@ export interface WebpackConfigContext {
transpile: RegExp[]
}
type WebpackConfigPreset = (ctx: WebpackConfigContext, options?: object) => void
type WebpackConfigPreset = (ctx: WebpackConfigContext, options?: object) => void | Promise<void>
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)
}
}
}

View File

@ -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<string, any>) => 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 {

View File

@ -28,12 +28,12 @@ import { dynamicRequire } from './nitro/plugins/dynamic-require'
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)
})
}))
/** Inject rollup plugin for Nitro to handle dynamic imports from webpack chunks */
const nitro = useNitro()

View File

@ -67,6 +67,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
@ -76,6 +79,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