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 <malfait.robin@gmail.com>
This commit is contained in:
Philipp Spiess 2024-10-07 18:02:28 +02:00 • committed by GitHub
parent c7a9e9bc39
commit 7be5346e2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 161 additions and 131 deletions

View file

@ -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

View file

@ -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';

View file

@ -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;
}

View file

@ -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()
}

View file

@ -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())

View file

@ -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) {

View file

@ -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 {

View file

@ -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<string>()
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.')
}

View file

@ -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) {

View file

@ -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<Config> {
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
}

View file

@ -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<Config> {
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.',
)
}

View file

@ -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,