From 7be5346e2e7512d6d4b2b17b0e8edb5b6047a456 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Mon, 7 Oct 2024 18:02:28 +0200 Subject: [PATCH] Ensure upgrade tool has access to a JS config (#14597) In order to properly migrate your Tailwind CSS v3 project to v4, we need access to the JavaScript configuration object. This was previously only required for template migrations, but in this PR we're making it so that this is also a prerequisite of the CSS migrations. This is because some migrations, like `@apply`, also need to convert candidates that to the v4 syntax and we need the full config in order to properly validate them. In addition to requiring a JS config, we also now attempt to automatically find the right configuration file inside the current working directory. This is now matching the behavior of the Tailwind CSS v3 CLI where it will find the config automatically if it's in the current directory and called `tailwind.conf.js`. --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 3 +- integrations/upgrade/index.test.ts | 7 +- .../src/codemods/migrate-at-apply.test.ts | 29 +++-- .../src/codemods/migrate-at-apply.ts | 14 +-- .../migrate-tailwind-directives.test.ts | 2 +- .../codemods/migrate-tailwind-directives.ts | 2 +- .../@tailwindcss-upgrade/src/index.test.ts | 19 +++- packages/@tailwindcss-upgrade/src/index.ts | 37 ++----- packages/@tailwindcss-upgrade/src/migrate.ts | 6 +- .../src/template/parseConfig.ts | 69 ------------ .../src/template/prepare-config.ts | 102 ++++++++++++++++++ packages/tailwindcss/src/at-import.test.ts | 2 +- 12 files changed, 161 insertions(+), 131 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/template/parseConfig.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/prepare-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b17e38d735c..6074ecce90b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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)) +- 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)) ### Fixed diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 881f40010a78..c348dc4c4a04 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -29,7 +29,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', @@ -77,7 +77,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', @@ -111,6 +111,7 @@ test( } } `, + 'tailwind.config.js': js`module.exports = {}`, 'src/index.css': css` @import 'tailwindcss'; @@ -162,6 +163,7 @@ test( } } `, + 'tailwind.config.js': js`module.exports = {}`, 'src/index.css': css` @tailwind base; @@ -218,6 +220,7 @@ test( } } `, + 'tailwind.config.js': js`module.exports = {}`, 'src/index.css': css` @import 'tailwindcss'; diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts index df6b35d58ae9..7e8428b38a82 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts @@ -1,21 +1,34 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import dedent from 'dedent' import postcss from 'postcss' +import type { Config } from 'tailwindcss' import { expect, it } from 'vitest' import { migrateAtApply } from './migrate-at-apply' const css = dedent -function migrateWithoutConfig(input: string) { +async function migrate(input: string, config: Config = {}) { + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + `, + { base: __dirname }, + ) + return postcss() - .use(migrateAtApply()) + .use( + migrateAtApply({ + designSystem, + userConfig: config, + }), + ) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) } it('should not migrate `@apply`, when there are no issues', async () => { expect( - await migrateWithoutConfig(css` + await migrate(css` .foo { @apply flex flex-col items-center; } @@ -29,7 +42,7 @@ it('should not migrate `@apply`, when there are no issues', async () => { it('should append `!` to each utility, when using `!important`', async () => { expect( - await migrateWithoutConfig(css` + await migrate(css` .foo { @apply flex flex-col !important; } @@ -44,7 +57,7 @@ it('should append `!` to each utility, when using `!important`', async () => { // TODO: Handle SCSS syntax it.skip('should append `!` to each utility, when using `#{!important}`', async () => { expect( - await migrateWithoutConfig(css` + await migrate(css` .foo { @apply flex flex-col #{!important}; } @@ -58,7 +71,7 @@ it.skip('should append `!` to each utility, when using `#{!important}`', async ( it('should move the legacy `!` prefix, to the new `!` postfix notation', async () => { expect( - await migrateWithoutConfig(css` + await migrate(css` .foo { @apply !flex flex-col! hover:!items-start items-center; } @@ -71,7 +84,7 @@ it('should move the legacy `!` prefix, to the new `!` postfix notation', async ( }) it('should apply all candidate migration when migrating with a config', async () => { - async function migrateWithConfig(input: string) { + async function migrateWithPrefix(input: string) { return postcss() .use( migrateAtApply({ @@ -91,7 +104,7 @@ it('should apply all candidate migration when migrating with a config', async () } expect( - await migrateWithConfig(css` + await migrateWithPrefix(css` .foo { @apply !tw_flex [color:--my-color] tw_bg-gradient-to-t; } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts index 487039f48b1b..988203b2f5c5 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts @@ -7,7 +7,10 @@ import { migrateCandidate } from '../template/migrate' export function migrateAtApply({ designSystem, userConfig, -}: { designSystem?: DesignSystem; userConfig?: Config } = {}): Plugin { +}: { + designSystem: DesignSystem + userConfig: Config +}): Plugin { function migrate(atRule: AtRule) { let utilities = atRule.params.split(/(\s+)/) let important = @@ -27,20 +30,13 @@ export function migrateAtApply({ utility += '!' } - // Migrate the important modifier to the end of the utility - if (utility[0] === '!') { - utility = `${utility.slice(1)}!` - } - // Reconstruct the utility with the variants return [...variants, utility].join(':') }) // If we have a valid designSystem and config setup, we can run all // candidate migrations on each utility - if (designSystem && userConfig) { - params = params.map((param) => migrateCandidate(designSystem, userConfig, param)) - } + params = params.map((param) => migrateCandidate(designSystem, userConfig, param)) atRule.params = params.join('').trim() } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts index 63b1f9334c14..ba2d9ea9dc3d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts @@ -6,7 +6,7 @@ import { migrateTailwindDirectives } from './migrate-tailwind-directives' const css = dedent -function migrate(input: string, options: { newPrefix?: string } = {}) { +function migrate(input: string, options: { newPrefix: string | null } = { newPrefix: null }) { return postcss() .use(migrateTailwindDirectives(options)) .use(formatNodes()) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts index 76449c309e86..0f714301713b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts @@ -2,7 +2,7 @@ import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss' const DEFAULT_LAYER_ORDER = ['theme', 'base', 'components', 'utilities'] -export function migrateTailwindDirectives(options: { newPrefix?: string }): Plugin { +export function migrateTailwindDirectives(options: { newPrefix: string | null }): Plugin { let prefixParams = options.newPrefix ? ` prefix(${options.newPrefix})` : '' function migrate(root: Root) { diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts index 4dbfaf46f412..581b9e3d6d9c 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -1,9 +1,18 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' import dedent from 'dedent' import { expect, it } from 'vitest' import { migrateContents } from './migrate' const css = dedent +let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + `, + { base: __dirname }, +) +let config = { designSystem, userConfig: {}, newPrefix: null } + it('should print the input as-is', async () => { expect( await migrateContents( @@ -15,7 +24,7 @@ it('should print the input as-is', async () => { /* below */ } `, - {}, + config, expect.getState().testPath, ), ).toMatchInlineSnapshot(` @@ -66,7 +75,7 @@ it('should migrate a stylesheet', async () => { } } `, - {}, + config, ), ).toMatchInlineSnapshot(` "@import 'tailwindcss'; @@ -116,7 +125,7 @@ it('should migrate a stylesheet (with imports)', async () => { @import 'tailwindcss/utilities'; @import './my-utilities.css'; `, - {}, + config, ), ).toMatchInlineSnapshot(` "@import 'tailwindcss'; @@ -140,7 +149,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in @tailwind components; @tailwind utilities; `, - {}, + config, ), ).toMatchInlineSnapshot(` "@charset "UTF-8"; @@ -169,7 +178,7 @@ it('should keep CSS as-is before existing `@layer` at-rules', async () => { } } `, - {}, + config, ), ).toMatchInlineSnapshot(` ".foo { diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 573c13f48cd6..2d323a24de0b 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -2,12 +2,10 @@ import { globby } from 'globby' import path from 'node:path' -import type { Config } from 'tailwindcss' -import type { DesignSystem } from '../../tailwindcss/src/design-system' import { help } from './commands/help' import { migrate as migrateStylesheet } from './migrate' import { migrate as migrateTemplate } from './template/migrate' -import { parseConfig } from './template/parseConfig' +import { prepareConfig } from './template/prepare-config' import { args, type Arg } from './utils/args' import { isRepoDirty } from './utils/git' import { eprintln, error, header, highlight, info, success } from './utils/renderer' @@ -42,28 +40,15 @@ async function run() { } } - let parsedConfig: { - designSystem: DesignSystem - globs: { pattern: string; base: string }[] - userConfig: Config - newPrefix: string | null - } | null = null - if (flags['--config']) { - try { - parsedConfig = await parseConfig(flags['--config'], { base: process.cwd() }) - } catch (e: any) { - error(`Failed to parse the configuration file: ${e.message}`) - process.exit(1) - } - } + let config = await prepareConfig(flags['--config'], { base: process.cwd() }) - if (parsedConfig) { + { // Template migrations info('Migrating templates using the provided configuration file.') let set = new Set() - for (let { pattern, base } of parsedConfig.globs) { + for (let { pattern, base } of config.globs) { let files = await globby([pattern], { absolute: true, gitignore: true, @@ -80,9 +65,7 @@ async function run() { // Migrate each file await Promise.allSettled( - files.map((file) => - migrateTemplate(parsedConfig.designSystem, parsedConfig.userConfig, file), - ), + files.map((file) => migrateTemplate(config.designSystem, config.userConfig, file)), ) success('Template migration complete.') @@ -110,15 +93,7 @@ async function run() { files = files.filter((file) => file.endsWith('.css')) // Migrate each file - await Promise.allSettled( - files.map((file) => - migrateStylesheet(file, { - newPrefix: parsedConfig?.newPrefix ?? undefined, - designSystem: parsedConfig?.designSystem, - userConfig: parsedConfig?.userConfig, - }), - ), - ) + await Promise.allSettled(files.map((file) => migrateStylesheet(file, config))) success('Stylesheet migration complete.') } diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 680e7c67b04c..76990936b549 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -10,9 +10,9 @@ import { migrateMissingLayers } from './codemods/migrate-missing-layers' import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' export interface MigrateOptions { - newPrefix?: string - designSystem?: DesignSystem - userConfig?: Config + newPrefix: string | null + designSystem: DesignSystem + userConfig: Config } export async function migrateContents(contents: string, options: MigrateOptions, file?: string) { diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts deleted file mode 100644 index b365ca3883a1..000000000000 --- a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node' -import path from 'node:path' -import { dirname } from 'path' -import type { Config } from 'tailwindcss' -import { fileURLToPath } from 'url' -import { loadModule } from '../../../@tailwindcss-node/src/compile' -import { resolveConfig } from '../../../tailwindcss/src/compat/config/resolve-config' -import type { DesignSystem } from '../../../tailwindcss/src/design-system' -import { migratePrefix } from './codemods/prefix' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -let css = String.raw - -export async function parseConfig( - configPath: string, - options: { base: string }, -): Promise<{ - designSystem: DesignSystem - globs: { base: string; pattern: string }[] - userConfig: Config - - newPrefix: string | null -}> { - // We create a relative path from the current file to the config file. This is - // required so that the base for Tailwind CSS can bet inside the - // @tailwindcss-upgrade package and we can require `tailwindcss` properly. - let fullConfigPath = path.resolve(options.base, configPath) - let fullFilePath = path.resolve(__dirname) - let relative = path.relative(fullFilePath, fullConfigPath) - // If the path points to a file in the same directory, `path.relative` will - // remove the leading `./` and we need to add it back in order to still - // consider the path relative - if (!relative.startsWith('.')) { - relative = './' + relative - } - - let userConfig = await createResolvedUserConfig(fullConfigPath) - - let newPrefix = userConfig.prefix ? migratePrefix(userConfig.prefix) : null - let input = css` - @import 'tailwindcss' ${newPrefix ? `prefix(${newPrefix})` : ''}; - @config './${relative}'; - ` - - let [compiler, designSystem] = await Promise.all([ - compile(input, { base: __dirname, onDependency: () => {} }), - __unstable__loadDesignSystem(input, { base: __dirname }), - ]) - - return { designSystem, globs: compiler.globs, userConfig, newPrefix } -} - -async function createResolvedUserConfig(fullConfigPath: string): Promise { - let [noopDesignSystem, unresolvedUserConfig] = await Promise.all([ - __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - `, - { base: __dirname }, - ), - loadModule(fullConfigPath, __dirname, () => {}).then((result) => result.module) as Config, - ]) - - return resolveConfig(noopDesignSystem, [ - { base: dirname(fullConfigPath), config: unresolvedUserConfig }, - ]) as any -} diff --git a/packages/@tailwindcss-upgrade/src/template/prepare-config.ts b/packages/@tailwindcss-upgrade/src/template/prepare-config.ts new file mode 100644 index 000000000000..c9667f455352 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/prepare-config.ts @@ -0,0 +1,102 @@ +import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node' +import fs from 'node:fs/promises' +import path from 'node:path' +import { dirname } from 'path' +import type { Config } from 'tailwindcss' +import { fileURLToPath } from 'url' +import { loadModule } from '../../../@tailwindcss-node/src/compile' +import { resolveConfig } from '../../../tailwindcss/src/compat/config/resolve-config' +import type { DesignSystem } from '../../../tailwindcss/src/design-system' +import { error } from '../utils/renderer' +import { migratePrefix } from './codemods/prefix' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const css = String.raw + +export async function prepareConfig( + configPath: string | null, + options: { base: string }, +): Promise<{ + designSystem: DesignSystem + globs: { base: string; pattern: string }[] + userConfig: Config + + newPrefix: string | null +}> { + try { + if (configPath === null) { + configPath = await detectConfigPath(options.base) + } + + // We create a relative path from the current file to the config file. This is + // required so that the base for Tailwind CSS can bet inside the + // @tailwindcss-upgrade package and we can require `tailwindcss` properly. + let fullConfigPath = path.resolve(options.base, configPath) + let fullFilePath = path.resolve(__dirname) + let relative = path.relative(fullFilePath, fullConfigPath) + + // If the path points to a file in the same directory, `path.relative` will + // remove the leading `./` and we need to add it back in order to still + // consider the path relative + if (!relative.startsWith('.')) { + relative = './' + relative + } + + let userConfig = await createResolvedUserConfig(fullConfigPath) + + let newPrefix = userConfig.prefix ? migratePrefix(userConfig.prefix) : null + let input = css` + @import 'tailwindcss' ${newPrefix ? `prefix(${newPrefix})` : ''}; + @config './${relative}'; + ` + + let [compiler, designSystem] = await Promise.all([ + compile(input, { base: __dirname, onDependency: () => {} }), + __unstable__loadDesignSystem(input, { base: __dirname }), + ]) + + return { designSystem, globs: compiler.globs, userConfig, newPrefix } + } catch (e: any) { + error('Could not load the configuration file: ' + e.message) + process.exit(1) + } +} + +async function createResolvedUserConfig(fullConfigPath: string): Promise { + let [noopDesignSystem, unresolvedUserConfig] = await Promise.all([ + __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + `, + { base: __dirname }, + ), + loadModule(fullConfigPath, __dirname, () => {}).then((result) => result.module) as Config, + ]) + + return resolveConfig(noopDesignSystem, [ + { base: dirname(fullConfigPath), config: unresolvedUserConfig }, + ]) as any +} + +const DEFAULT_CONFIG_FILES = [ + './tailwind.config.js', + './tailwind.config.cjs', + './tailwind.config.mjs', + './tailwind.config.ts', + './tailwind.config.cts', + './tailwind.config.mts', +] +async function detectConfigPath(base: string) { + for (let file of DEFAULT_CONFIG_FILES) { + let fullPath = path.resolve(base, file) + try { + await fs.access(fullPath) + return file + } catch {} + } + throw new Error( + 'No configuration file found. Please provide a path to the Tailwind CSS v3 config file via the `--config` option.', + ) +} diff --git a/packages/tailwindcss/src/at-import.test.ts b/packages/tailwindcss/src/at-import.test.ts index bbd5e6863f26..deedd554fedb 100644 --- a/packages/tailwindcss/src/at-import.test.ts +++ b/packages/tailwindcss/src/at-import.test.ts @@ -4,7 +4,7 @@ import { compile, type Config } from './index' import plugin from './plugin' import { optimizeCss } from './test-utils/run' -let css = String.raw +const css = String.raw async function run( css: string,