diff --git a/CHANGELOG.md b/CHANGELOG.md index b88542fdae72..7ad5006a7d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 1b86c3d1a2a9..37c327faebe2 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -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` +
+ `, + '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` + + `, + ) + + 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` + + `, + '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` + + `, + ) + + 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` + + `, + '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` + + `, + ) + + 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` @@ -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', diff --git a/integrations/utils.ts b/integrations/utils.ts index e5f3311580fb..fcea2a0ff235 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -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 }) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 2d323a24de0b..eeae17edf8d0 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -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.') diff --git a/packages/@tailwindcss-upgrade/src/migrate-postcss.ts b/packages/@tailwindcss-upgrade/src/migrate-postcss.ts new file mode 100644 index 000000000000..2b383425d2fd --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/migrate-postcss.ts @@ -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