Migrate simple PostCSS setup (#14612)

This PR attempts to detect simple postcss setups: These are setups that
do not load dynamic modules and are based off the examples we are
[recommending in our
docs](https://tailwindcss.com/docs/installation/using-postcss). We
detect wether a config is appropriate by having it use the object plugin
config and by not requiring any other modules:

```js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}
```

When we find such a config file, we will go over it line-by-line and
attempt to:

- Upgrade `tailwindcss:` to `'@tailwindcss/postcss':`
- Remove `autoprefixer` if used

We then attempt to install and remove the respective npm packages based
on the package manger we detect.

And since we now have logic to upgrade packages, this also makes sure to
install `tailwindcss@next` at the end of a sucessful migration.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Philipp Spiess 2024-10-10 15:09:14 +02:00 • committed by GitHub
parent 3ae22f1f9d
commit 75b906643c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 648 additions and 3 deletions

View file

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `tailwindcss/colors.js`, `tailwindcss/defaultTheme.js`, and `tailwindcss/plugin.js` exports ([#14595](https://github.com/tailwindlabs/tailwindcss/pull/14595))
- Support `keyframes` in JS config file themes ([#14594](https://github.com/tailwindlabs/tailwindcss/pull/14594))
- _Upgrade (experimental)_: Migrate v3 PostCSS setups to v4 in some cases ([#14612](https://github.com/tailwindlabs/tailwindcss/pull/14612))
- _Upgrade (experimental)_: The upgrade tool now automatically discovers your JavaScript config ([#14597](https://github.com/tailwindlabs/tailwindcss/pull/14597))
### Fixed

View file

@ -1,3 +1,4 @@
import { expect } from 'vitest'
import { css, html, js, json, test } from '../utils'
test(
@ -40,6 +41,12 @@ test(
)
await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss';`)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringContaining('4.0.0'),
})
},
)
@ -265,6 +272,233 @@ test(
},
)
test(
'migrates a simple postcss setup',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"autoprefixer": "^10",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'postcss.config.js': js`
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': 'postcss-nesting',
tailwindcss: {},
autoprefixer: {},
},
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain(
'postcss.config.js',
js`
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
)
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.html',
// prettier-ignore
js`
<div class="bg-[var(--my-red)]"></div>
`,
)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringContaining('4.0.0'),
})
expect(packageJson.dependencies).not.toHaveProperty('autoprefixer')
expect(packageJson.dependencies).not.toHaveProperty('postcss-import')
expect(packageJson.devDependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringContaining('4.0.0'),
})
},
)
test(
'migrates a postcss setup using package.json config',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"autoprefixer": "^10",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"postcss": {
"plugins": {
"postcss-import": {},
"tailwindcss/nesting": "postcss-nesting",
"tailwindcss": {},
"autoprefixer": {}
}
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.html',
// prettier-ignore
js`
<div class="bg-[var(--my-red)]"></div>
`,
)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.postcss).toMatchInlineSnapshot(`
{
"plugins": {
"@tailwindcss/postcss": {},
},
}
`)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringContaining('4.0.0'),
})
expect(packageJson.dependencies).not.toHaveProperty('autoprefixer')
expect(packageJson.dependencies).not.toHaveProperty('postcss-import')
expect(packageJson.devDependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringContaining('4.0.0'),
})
},
)
test(
'migrates a postcss setup using a json based config file',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"autoprefixer": "^10",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'.postcssrc.json': json`
{
"plugins": {
"postcss-import": {},
"tailwindcss/nesting": "postcss-nesting",
"tailwindcss": {},
"autoprefixer": {}
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.html',
// prettier-ignore
js`
<div class="bg-[var(--my-red)]"></div>
`,
)
let jsonConfigContent = await fs.read('.postcssrc.json')
let jsonConfig = JSON.parse(jsonConfigContent)
expect(jsonConfig).toMatchInlineSnapshot(`
{
"plugins": {
"@tailwindcss/postcss": {},
},
}
`)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringContaining('4.0.0'),
})
expect(packageJson.dependencies).not.toHaveProperty('autoprefixer')
expect(packageJson.dependencies).not.toHaveProperty('postcss-import')
expect(packageJson.devDependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringContaining('4.0.0'),
})
},
)
test(
`migrates prefixes even if other files have unprefixed versions of the candidate`,
{
@ -297,7 +531,7 @@ test(
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade -c tailwind.config.js')
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.html', html`
<div class="flex"></div>
@ -335,7 +569,7 @@ test(
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade -c tailwind.config.js')
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain(
'src/index.html',

View file

@ -74,7 +74,7 @@ export function test(
) {
return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)(
name,
{ timeout: TEST_TIMEOUT, retry: debug ? 0 : 3 },
{ timeout: TEST_TIMEOUT, retry: debug || only ? 0 : 3 },
async (options) => {
let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
await fs.mkdir(rootDir, { recursive: true })

View file

@ -4,10 +4,12 @@ import { globby } from 'globby'
import path from 'node:path'
import { help } from './commands/help'
import { migrate as migrateStylesheet } from './migrate'
import { migratePostCSSConfig } from './migrate-postcss'
import { migrate as migrateTemplate } from './template/migrate'
import { prepareConfig } from './template/prepare-config'
import { args, type Arg } from './utils/args'
import { isRepoDirty } from './utils/git'
import { pkg } from './utils/packages'
import { eprintln, error, header, highlight, info, success } from './utils/renderer'
const options = {
@ -98,6 +100,16 @@ async function run() {
success('Stylesheet migration complete.')
}
{
// PostCSS config migration
await migratePostCSSConfig(process.cwd())
}
try {
// Upgrade Tailwind CSS
await pkg('add tailwindcss@next', process.cwd())
} catch {}
// Figure out if we made any changes
if (isRepoDirty()) {
success('Verify the changes and commit them to your repository.')

View file

@ -0,0 +1,326 @@
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) {
try {
await pkg('add -D @tailwindcss/postcss@next', base)
} catch {}
}
if (didRemoveAutoprefixer || didRemovePostCSSImport) {
try {
let packagesToRemove = [
didRemoveAutoprefixer ? 'autoprefixer' : null,
didRemovePostCSSImport ? 'postcss-import' : null,
]
.filter(Boolean)
.join(' ')
await pkg(`remove ${packagesToRemove}`, base)
} 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
}

View file

@ -0,0 +1,72 @@
import { execSync } from 'node:child_process'
import fs from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { warn } from './renderer'
let didWarnAboutPackageManager = false
export async function pkg(command: string, base: string): Promise<Buffer | void> {
let packageManager = await detectPackageManager(base)
if (!packageManager) {
if (!didWarnAboutPackageManager) {
didWarnAboutPackageManager = true
warn('Could not detect a package manager. Please manually update `tailwindcss` to v4.')
}
return
}
return execSync(`${packageManager} ${command}`, {
cwd: base,
})
}
async function detectPackageManager(base: string): Promise<null | string> {
do {
// 1. Check package.json for a `packageManager` field
let packageJsonPath = resolve(base, 'package.json')
try {
let packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8')
let packageJson = JSON.parse(packageJsonContent)
if (packageJson.packageManager) {
if (packageJson.packageManager.includes('bun')) {
return 'bun'
}
if (packageJson.packageManager.includes('yarn')) {
return 'yarn'
}
if (packageJson.packageManager.includes('pnpm')) {
return 'pnpm'
}
if (packageJson.packageManager.includes('npm')) {
return 'npm'
}
}
} catch {}
// 2. Check for common lockfiles
try {
await fs.access(resolve(base, 'bun.lockb'))
return 'bun'
} catch {}
try {
await fs.access(resolve(base, 'bun.lock'))
return 'bun'
} catch {}
try {
await fs.access(resolve(base, 'pnpm-lock.yaml'))
return 'pnpm'
} catch {}
try {
await fs.access(resolve(base, 'yarn.lock'))
return 'yarn'
} catch {}
try {
await fs.access(resolve(base, 'package-lock.json'))
return 'npm'
} catch {}
// 3. If no lockfile is found, we might be in a monorepo
base = dirname(base)
} while (true)
}