Robin Malfait eb54dcdbfc
Install @tailwindcss/postcss next to tailwindcss (#14830)
This PR improves the PostCSS migrations to make sure that we install
`@tailwindcss/postcss` in the same bucket as `tailwindcss`.

If `tailwindcss` exists in the `dependencies` bucket, we install
`@tailwindcss/postcss` in the same bucket. If `tailwindcss` exists in
the `devDependencies` bucket, we install `@tailwindcss/postcss` in the
same bucket.

This also contains an internal refactor that normalizes the package
manager to make sure we can install a package to the correct bucket
depending on the package manager.

---------

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
2024-10-30 15:32:24 -04:00

333 lines
10 KiB
TypeScript

import fs from 'node:fs/promises'
import path from 'node:path'
import { pkg } from './utils/packages'
import { info, success, warn } from './utils/renderer'
// Migrates simple PostCSS setups. This is to cover non-dynamic config files
// similar to the ones we have all over our docs:
//
// ```js
// module.exports = {
// plugins: {
// 'postcss-import': {},
// 'tailwindcss/nesting': 'postcss-nesting',
// tailwindcss: {},
// autoprefixer: {},
// }
// }
export async function migratePostCSSConfig(base: string) {
let didMigrate = false
let didAddPostcssClient = false
let didRemoveAutoprefixer = false
let didRemovePostCSSImport = false
// Priority 1: Handle JS config files
let jsConfigPath = await detectJSConfigPath(base)
if (jsConfigPath) {
let result = await migratePostCSSJSConfig(base, jsConfigPath)
if (result) {
didMigrate = true
didAddPostcssClient = result.didAddPostcssClient
didRemoveAutoprefixer = result.didRemoveAutoprefixer
didRemovePostCSSImport = result.didRemovePostCSSImport
}
}
// Priority 2: Handle package.json config
let packageJsonPath = path.resolve(base, 'package.json')
let packageJson
try {
packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'))
} catch {}
if (!didMigrate && packageJson && 'postcss' in packageJson) {
let result = await migratePostCSSJsonConfig(base, packageJson.postcss)
if (result) {
await fs.writeFile(
packageJsonPath,
JSON.stringify({ ...packageJson, postcss: result?.json }, null, 2),
)
didMigrate = true
didAddPostcssClient = result.didAddPostcssClient
didRemoveAutoprefixer = result.didRemoveAutoprefixer
didRemovePostCSSImport = result.didRemovePostCSSImport
}
}
// Priority 3: JSON based postcss config files
let jsonConfigPath = await detectJSONConfigPath(base)
let jsonConfig: null | any = null
if (!didMigrate && jsonConfigPath) {
try {
jsonConfig = JSON.parse(await fs.readFile(jsonConfigPath, 'utf-8'))
} catch {}
if (jsonConfig) {
let result = await migratePostCSSJsonConfig(base, jsonConfig)
if (result) {
await fs.writeFile(jsonConfigPath, JSON.stringify(result.json, null, 2))
didMigrate = true
didAddPostcssClient = result.didAddPostcssClient
didRemoveAutoprefixer = result.didRemoveAutoprefixer
didRemovePostCSSImport = result.didRemovePostCSSImport
}
}
}
if (!didMigrate) {
info(`No PostCSS config found, skipping migration.`)
return
}
if (didAddPostcssClient) {
let location = Object.hasOwn(packageJson?.dependencies ?? {}, 'tailwindcss')
? ('dependencies' as const)
: Object.hasOwn(packageJson?.devDependencies ?? {}, 'tailwindcss')
? ('devDependencies' as const)
: null
if (location !== null) {
try {
await pkg(base).add(['@tailwindcss/postcss@next'], location)
} catch {}
}
}
if (didRemoveAutoprefixer || didRemovePostCSSImport) {
try {
let packagesToRemove = [
didRemoveAutoprefixer ? 'autoprefixer' : null,
didRemovePostCSSImport ? 'postcss-import' : null,
].filter(Boolean) as string[]
await pkg(base).remove(packagesToRemove)
} catch {}
}
success(`PostCSS config has been upgraded.`)
}
async function migratePostCSSJSConfig(
base: string,
configPath: string,
): Promise<{
didAddPostcssClient: boolean
didRemoveAutoprefixer: boolean
didRemovePostCSSImport: boolean
} | null> {
function isTailwindCSSPlugin(line: string) {
return /['"]?tailwindcss['"]?\: ?\{\}/.test(line)
}
function isPostCSSImportPlugin(line: string) {
return /['"]?postcss-import['"]?\: ?\{\}/.test(line)
}
function isAutoprefixerPlugin(line: string) {
return /['"]?autoprefixer['"]?\: ?\{\}/.test(line)
}
function isTailwindCSSNestingPlugin(line: string) {
return /['"]tailwindcss\/nesting['"]\: ?(\{\}|['"]postcss-nesting['"])/.test(line)
}
info(`Attempt to upgrade the PostCSS config in file: ${configPath}`)
let isSimpleConfig = await isSimplePostCSSConfig(base, configPath)
if (!isSimpleConfig) {
warn(`The PostCSS config contains dynamic JavaScript and can not be automatically migrated.`)
return null
}
let didAddPostcssClient = false
let didRemoveAutoprefixer = false
let didRemovePostCSSImport = false
let fullPath = path.resolve(base, configPath)
let content = await fs.readFile(fullPath, 'utf-8')
let lines = content.split('\n')
let newLines: string[] = []
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
if (isTailwindCSSPlugin(line)) {
didAddPostcssClient = true
newLines.push(line.replace('tailwindcss:', `'@tailwindcss/postcss':`))
} else if (isAutoprefixerPlugin(line)) {
didRemoveAutoprefixer = true
} else if (isPostCSSImportPlugin(line)) {
// Check that there are no unknown plugins before the tailwindcss plugin
let hasUnknownPluginsBeforeTailwindCSS = false
for (let j = i + 1; j < lines.length; j++) {
let nextLine = lines[j]
if (isTailwindCSSPlugin(nextLine)) {
break
}
if (isTailwindCSSNestingPlugin(nextLine)) {
continue
}
hasUnknownPluginsBeforeTailwindCSS = true
break
}
if (!hasUnknownPluginsBeforeTailwindCSS) {
didRemovePostCSSImport = true
} else {
newLines.push(line)
}
} else if (isTailwindCSSNestingPlugin(line)) {
// Check if the following rule is the tailwindcss plugin
let nextLine = lines[i + 1]
if (isTailwindCSSPlugin(nextLine)) {
// Since this plugin is bundled with `tailwindcss`, we don't need to
// clean up a package when deleting this line.
} else {
newLines.push(line)
}
} else {
newLines.push(line)
}
}
await fs.writeFile(fullPath, newLines.join('\n'))
return { didAddPostcssClient, didRemoveAutoprefixer, didRemovePostCSSImport }
}
async function migratePostCSSJsonConfig(
base: string,
json: any,
): Promise<{
json: any
didAddPostcssClient: boolean
didRemoveAutoprefixer: boolean
didRemovePostCSSImport: boolean
} | null> {
function isTailwindCSSPlugin(plugin: string, options: any) {
return plugin === 'tailwindcss' && isEmptyObject(options)
}
function isPostCSSImportPlugin(plugin: string, options: any) {
return plugin === 'postcss-import' && isEmptyObject(options)
}
function isAutoprefixerPlugin(plugin: string, options: any) {
return plugin === 'autoprefixer' && isEmptyObject(options)
}
function isTailwindCSSNestingPlugin(plugin: string, options: any) {
return (
plugin === 'tailwindcss/nesting' && (options === 'postcss-nesting' || isEmptyObject(options))
)
}
let didAddPostcssClient = false
let didRemoveAutoprefixer = false
let didRemovePostCSSImport = false
let plugins = Object.entries(json.plugins || {})
let newPlugins: [string, any][] = []
for (let i = 0; i < plugins.length; i++) {
let [plugin, options] = plugins[i]
if (isTailwindCSSPlugin(plugin, options)) {
didAddPostcssClient = true
newPlugins.push(['@tailwindcss/postcss', options])
} else if (isAutoprefixerPlugin(plugin, options)) {
didRemoveAutoprefixer = true
} else if (isPostCSSImportPlugin(plugin, options)) {
// Check that there are no unknown plugins before the tailwindcss plugin
let hasUnknownPluginsBeforeTailwindCSS = false
for (let j = i + 1; j < plugins.length; j++) {
let [nextPlugin, nextOptions] = plugins[j]
if (isTailwindCSSPlugin(nextPlugin, nextOptions)) {
break
}
if (isTailwindCSSNestingPlugin(nextPlugin, nextOptions)) {
continue
}
hasUnknownPluginsBeforeTailwindCSS = true
break
}
if (!hasUnknownPluginsBeforeTailwindCSS) {
didRemovePostCSSImport = true
} else {
newPlugins.push([plugin, options])
}
} else if (isTailwindCSSNestingPlugin(plugin, options)) {
// Check if the following rule is the tailwindcss plugin
let [nextPlugin, nextOptions] = plugins[i + 1]
if (isTailwindCSSPlugin(nextPlugin, nextOptions)) {
// Since this plugin is bundled with `tailwindcss`, we don't need to
// clean up a package when deleting this line.
} else {
newPlugins.push([plugin, options])
}
} else {
newPlugins.push([plugin, options])
}
}
return {
json: { ...json, plugins: Object.fromEntries(newPlugins) },
didAddPostcssClient,
didRemoveAutoprefixer,
didRemovePostCSSImport,
}
}
const JS_CONFIG_FILE_LOCATIONS = [
'.postcssrc.js',
'.postcssrc.mjs',
'.postcssrc.cjs',
'.postcssrc.ts',
'.postcssrc.mts',
'.postcssrc.cts',
'postcss.config.js',
'postcss.config.mjs',
'postcss.config.cjs',
'postcss.config.ts',
'postcss.config.mts',
'postcss.config.cts',
]
async function detectJSConfigPath(base: string): Promise<null | string> {
for (let file of JS_CONFIG_FILE_LOCATIONS) {
let fullPath = path.resolve(base, file)
try {
await fs.access(fullPath)
return file
} catch {}
}
return null
}
const JSON_CONFIG_FILE_LOCATIONS = [
'.postcssrc',
'.postcssrc.json',
// yaml syntax is not supported
// '.postcssrc.yml'
]
async function detectJSONConfigPath(base: string): Promise<null | string> {
for (let file of JSON_CONFIG_FILE_LOCATIONS) {
let fullPath = path.resolve(base, file)
try {
await fs.access(fullPath)
return file
} catch {}
}
return null
}
async function isSimplePostCSSConfig(base: string, configPath: string): Promise<boolean> {
let fullPath = path.resolve(base, configPath)
let content = await fs.readFile(fullPath, 'utf-8')
return (
content.includes('tailwindcss:') &&
!(
content.includes('require') ||
// Adding a space at the end to not match `'postcss-import'`
content.includes('import ')
)
)
}
function isEmptyObject(obj: any) {
return typeof obj === 'object' && obj !== null && Object.keys(obj).length === 0
}