Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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))
- _Experimental_: The upgrade tool now automatically discovers your JavaScript config ([#14597](https://github.com/tailwindlabs/tailwindcss/pull/14597))
- _Experimental_: Migrate v3 PostCSS setups to v4 in some cases ([#14612](https://github.com/tailwindlabs/tailwindcss/pull/14612))

### Fixed

Expand Down
80 changes: 80 additions & 0 deletions integrations/upgrade/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expect } from 'vitest'
import { css, html, js, json, test } from '../utils'

test(
Expand Down Expand Up @@ -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'),
})
},
)

Expand Down Expand Up @@ -264,3 +271,76 @@ test(
)
},
)

test(
'fully migrate a simple postcss setup',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"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: {
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 -c tailwind.config.js')

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.devDependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringContaining('4.0.0'),
})
},
)
12 changes: 12 additions & 0 deletions packages/@tailwindcss-upgrade/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.')
Expand Down
95 changes: 95 additions & 0 deletions packages/@tailwindcss-upgrade/src/migrate-postcss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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: {
// tailwindcss: {},
// autoprefixer: {},
// }
// }
export async function migratePostCSSConfig(base: string) {
let configPath = await detectConfigPath(base)
if (configPath === null) {
// TODO: We can look for an eventual config inside package.json
return
}

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
}

let didAddPostcssClient = false
let didRemoveAutoprefixer = 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 line of lines) {
if (line.includes('tailwindcss:')) {
didAddPostcssClient = true
newLines.push(line.replace('tailwindcss:', `'@tailwindcss/postcss':`))
} else if (line.includes('autoprefixer:')) {
didRemoveAutoprefixer = true
} else {
newLines.push(line)
}
}
await fs.writeFile(fullPath, newLines.join('\n'))

if (didAddPostcssClient) {
try {
await pkg('add -D @tailwindcss/postcss@next', base)
} catch {}
}
if (didRemoveAutoprefixer) {
try {
await pkg('remove autoprefixer', base)
} catch {}
}

success(`PostCSS config in file ${configPath} has been upgraded.`)
}

const 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 detectConfigPath(base: string): Promise<null | string> {
for (let file of 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') || content.includes('import'))
)
}
72 changes: 72 additions & 0 deletions packages/@tailwindcss-upgrade/src/utils/packages.ts
Original file line number Diff line number Diff line change
@@ -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)
}