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 Daniel Roe
parent 20dc6e5030
commit 4391381c61
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
15 changed files with 138 additions and 83 deletions

View File

@ -59,9 +59,11 @@
"@vitejs/plugin-vue": "5.0.5", "@vitejs/plugin-vue": "5.0.5",
"@vitest/coverage-v8": "1.6.0", "@vitest/coverage-v8": "1.6.0",
"@vue/test-utils": "2.4.6", "@vue/test-utils": "2.4.6",
"autoprefixer": "^10.4.19",
"case-police": "0.6.1", "case-police": "0.6.1",
"changelogen": "0.5.5", "changelogen": "0.5.5",
"consola": "3.2.3", "consola": "3.2.3",
"cssnano": "^7.0.3",
"devalue": "5.0.0", "devalue": "5.0.0",
"eslint": "9.6.0", "eslint": "9.6.0",
"eslint-plugin-no-only-tests": "3.1.0", "eslint-plugin-no-only-tests": "3.1.0",

View File

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

View File

@ -1,12 +1,45 @@
import { defineUntypedSchema } from 'untyped' 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({ export default defineUntypedSchema({
postcss: { 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. * Options for configuring PostCSS plugins.
* *
* https://postcss.org/ * 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: { plugins: {
/** /**

View File

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

View File

@ -1,11 +1,16 @@
import { requireModule } from '@nuxt/kit' import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Nuxt } from '@nuxt/schema' import { requireModule, tryResolveModule } from '@nuxt/kit'
import type { Nuxt, NuxtOptions } from '@nuxt/schema'
import type { InlineConfig as ViteConfig } from 'vite' 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>> } = { const css: ViteConfig['css'] & { postcss: NonNullable<Exclude<NonNullable<ViteConfig['css']>['postcss'], string>> } = {
postcss: { postcss: {
plugins: [], plugins: [],
@ -14,19 +19,26 @@ export function resolveCSSOptions (nuxt: Nuxt): ViteConfig['css'] {
css.postcss.plugins = [] css.postcss.plugins = []
const plugins = Object.entries(nuxt.options.postcss.plugins) const postcssOptions = nuxt.options.postcss
.sort((a, b) => lastPlugins.indexOf(a[0]) - lastPlugins.indexOf(b[0]))
for (const [name, opts] of plugins) { const cwd = fileURLToPath(new URL('.', import.meta.url))
if (opts) { for (const pluginName of sortPlugins(postcssOptions)) {
// TODO: remove use of requireModule in favour of ESM import const pluginOptions = postcssOptions.plugins[pluginName]
const plugin = requireModule(name, { if (!pluginOptions) { continue }
paths: [
...nuxt.options.modulesDir, const path = await tryResolveModule(pluginName, nuxt.options.modulesDir)
distDir,
], let pluginFn: (opts: Record<string, any>) => Plugin
}) // TODO: use jiti v2
css.postcss.plugins.push(plugin(opts)) 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 { logLevelMap } from './utils/logger'
import { ssrStylesPlugin } from './plugins/ssr-styles' import { ssrStylesPlugin } from './plugins/ssr-styles'
import { VitePublicDirsPlugin } from './plugins/public-dirs' import { VitePublicDirsPlugin } from './plugins/public-dirs'
import { distDir } from './dirs'
export interface ViteBuildContext { export interface ViteBuildContext {
nuxt: Nuxt nuxt: Nuxt
@ -33,6 +34,8 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
(nuxt.options.vite.devBundler === 'vite-node' && nuxt.options.dev) (nuxt.options.vite.devBundler === 'vite-node' && nuxt.options.dev)
const entry = await resolvePath(resolve(nuxt.options.appDir, useAsyncEntry ? 'entry.async' : 'entry')) const entry = await resolvePath(resolve(nuxt.options.appDir, useAsyncEntry ? 'entry.async' : 'entry'))
nuxt.options.modulesDir.push(distDir)
let allowDirs = [ let allowDirs = [
nuxt.options.appDir, nuxt.options.appDir,
nuxt.options.workspaceDir, nuxt.options.workspaceDir,
@ -72,7 +75,7 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
'abort-controller': 'unenv/runtime/mock/empty', 'abort-controller': 'unenv/runtime/mock/empty',
}, },
}, },
css: resolveCSSOptions(nuxt), css: await resolveCSSOptions(nuxt),
define: { define: {
__NUXT_VERSION__: JSON.stringify(nuxt._version), __NUXT_VERSION__: JSON.stringify(nuxt._version),
__NUXT_ASYNC_CONTEXT__: nuxt.options.experimental.asyncContext, __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 { applyPresets } from '../utils/config'
import { nuxt } from '../presets/nuxt' import { nuxt } from '../presets/nuxt'
export function client (ctx: WebpackConfigContext) { export async function client (ctx: WebpackConfigContext) {
ctx.name = 'client' ctx.name = 'client'
ctx.isClient = true ctx.isClient = true
applyPresets(ctx, [ await applyPresets(ctx, [
nuxt, nuxt,
clientPlugins, clientPlugins,
clientOptimization, 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 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.name = 'server'
ctx.isServer = true ctx.isServer = true
applyPresets(ctx, [ await applyPresets(ctx, [
nuxt, nuxt,
node, node,
serverStandalone, serverStandalone,

View File

@ -16,8 +16,8 @@ import WarningIgnorePlugin from '../plugins/warning-ignore'
import type { WebpackConfigContext } from '../utils/config' import type { WebpackConfigContext } from '../utils/config'
import { applyPresets, fileName } from '../utils/config' import { applyPresets, fileName } from '../utils/config'
export function base (ctx: WebpackConfigContext) { export async function base (ctx: WebpackConfigContext) {
applyPresets(ctx, [ await applyPresets(ctx, [
baseAlias, baseAlias,
baseConfig, baseConfig,
basePlugins, basePlugins,

View File

@ -8,8 +8,8 @@ import { pug } from './pug'
import { style } from './style' import { style } from './style'
import { vue } from './vue' import { vue } from './vue'
export function nuxt (ctx: WebpackConfigContext) { export async function nuxt (ctx: WebpackConfigContext) {
applyPresets(ctx, [ await applyPresets(ctx, [
base, base,
assets, assets,
esbuild, esbuild,

View File

@ -4,8 +4,8 @@ import type { WebpackConfigContext } from '../utils/config'
import { applyPresets, fileName } from '../utils/config' import { applyPresets, fileName } from '../utils/config'
import { getPostcssConfig } from '../utils/postcss' import { getPostcssConfig } from '../utils/postcss'
export function style (ctx: WebpackConfigContext) { export async function style (ctx: WebpackConfigContext) {
applyPresets(ctx, [ await applyPresets(ctx, [
loaders, loaders,
extractCSS, extractCSS,
minimizer, minimizer,
@ -32,32 +32,32 @@ function extractCSS (ctx: WebpackConfigContext) {
})) }))
} }
function loaders (ctx: WebpackConfigContext) { async function loaders (ctx: WebpackConfigContext) {
// CSS // 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 // 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 // Less
const lessLoader = { loader: 'less-loader', options: ctx.userConfig.loaders.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) // Sass (TODO: optional dependency)
const sassLoader = { loader: 'sass-loader', options: ctx.userConfig.loaders.sass } 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 } 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 // Stylus
const stylusLoader = { loader: 'stylus-loader', options: ctx.userConfig.loaders.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 = [ const styleLoaders = [
createPostcssLoadersRule(ctx), await createPostcssLoadersRule(ctx),
processorLoader, processorLoader,
].filter(Boolean) ].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 } if (!ctx.options.postcss) { return }
const config = getPostcssConfig(ctx.nuxt) const config = await getPostcssConfig(ctx.nuxt)
if (!config) { if (!config) {
return return

View File

@ -17,7 +17,7 @@ export interface WebpackConfigContext {
transpile: RegExp[] transpile: RegExp[]
} }
type WebpackConfigPreset = (ctx: WebpackConfigContext, options?: object) => void type WebpackConfigPreset = (ctx: WebpackConfigContext, options?: object) => void | Promise<void>
type WebpackConfigPresetItem = WebpackConfigPreset | [WebpackConfigPreset, any] type WebpackConfigPresetItem = WebpackConfigPreset | [WebpackConfigPreset, any]
export function createWebpackConfigContext (nuxt: Nuxt): WebpackConfigContext { 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)) { for (const preset of toArray(presets)) {
if (Array.isArray(preset)) { if (Array.isArray(preset)) {
preset[0](ctx, preset[1]) await preset[0](ctx, preset[1])
} else { } 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 createResolver from 'postcss-import-resolver'
import { requireModule } from '@nuxt/kit' import { interopDefault } from 'mlly'
import type { Nuxt } from '@nuxt/schema' import { requireModule, tryResolveModule } from '@nuxt/kit'
import type { Nuxt, NuxtOptions } from '@nuxt/schema'
import { defu } from 'defu' 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 isPureObject = (obj: unknown): obj is Object => obj !== null && !Array.isArray(obj) && typeof obj === 'object'
const ensureItemIsLast = (item: string) => (arr: string[]) => { function sortPlugins ({ plugins, order }: NuxtOptions['postcss']): string[] {
const index = arr.indexOf(item) const names = Object.keys(plugins)
if (index !== -1) { return typeof order === 'function' ? order(names) : (order || names)
arr.splice(index, 1)
arr.push(item)
}
return arr
} }
const orderPresets = { export async function getPostcssConfig (nuxt: Nuxt) {
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)
}
if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) { if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) {
return false return false
} }
@ -54,21 +36,35 @@ export const getPostcssConfig = (nuxt: Nuxt) => {
'postcss-url': {}, 'postcss-url': {},
}, },
sourceMap: nuxt.options.webpack.cssSourceMap, sourceMap: nuxt.options.webpack.cssSourceMap,
// Array, String or Function
order: 'autoprefixerAndCssnanoLast',
}) })
// Keep the order of default plugins // Keep the order of default plugins
if (!Array.isArray(postcssOptions.plugins) && isPureObject(postcssOptions.plugins)) { if (!Array.isArray(postcssOptions.plugins) && isPureObject(postcssOptions.plugins)) {
// Map postcss plugins into instances on object mode once // Map postcss plugins into instances on object mode once
const cwd = fileURLToPath(new URL('.', import.meta.url)) const cwd = fileURLToPath(new URL('.', import.meta.url))
postcssOptions.plugins = sortPlugins(postcssOptions).map((pluginName: string) => { const plugins: Plugin[] = []
// TODO: remove use of requireModule in favour of ESM import for (const pluginName of sortPlugins(postcssOptions)) {
const pluginFn = requireModule(pluginName, { paths: [cwd] })
const pluginOptions = postcssOptions.plugins[pluginName] const pluginOptions = postcssOptions.plugins[pluginName]
if (!pluginOptions || typeof pluginFn !== 'function') { return null } if (!pluginOptions) { continue }
return pluginFn(pluginOptions)
}).filter(Boolean) 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 { return {

View File

@ -25,12 +25,12 @@ import { applyPresets, createWebpackConfigContext, getWebpackConfig } from './ut
export const bundle: NuxtBuilder['bundle'] = async (nuxt) => { export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
registerVirtualModules() 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) const ctx = createWebpackConfigContext(nuxt)
ctx.userConfig = defu(nuxt.options.webpack[`$${preset.name as 'client' | 'server'}`], ctx.userConfig) ctx.userConfig = defu(nuxt.options.webpack[`$${preset.name as 'client' | 'server'}`], ctx.userConfig)
applyPresets(ctx, preset) await applyPresets(ctx, preset)
return getWebpackConfig(ctx) return getWebpackConfig(ctx)
}) }))
await nuxt.callHook('webpack:config', webpackConfigs) await nuxt.callHook('webpack:config', webpackConfigs)

View File

@ -59,6 +59,9 @@ importers:
'@vue/test-utils': '@vue/test-utils':
specifier: 2.4.6 specifier: 2.4.6
version: 2.4.6 version: 2.4.6
autoprefixer:
specifier: ^10.4.19
version: 10.4.19(postcss@8.4.39)
case-police: case-police:
specifier: 0.6.1 specifier: 0.6.1
version: 0.6.1 version: 0.6.1
@ -68,6 +71,9 @@ importers:
consola: consola:
specifier: 3.2.3 specifier: 3.2.3
version: 3.2.3 version: 3.2.3
cssnano:
specifier: ^7.0.3
version: 7.0.3(postcss@8.4.39)
devalue: devalue:
specifier: 5.0.0 specifier: 5.0.0
version: 5.0.0 version: 5.0.0