This PR fixes an issue where the upgrade tests were taking too long. This PR fixes that. Essentially when updating dependencies we did this: ```sh npm install tailwindcss@latest npm install @tailwindcss/postcss@latest npm install prettier-plugin-tailwindcss@latest ``` But this is not ideal, because we are calling out to `npm` and run each dependency in isolation. With this PR, we essentially do it all in one go: ```sh npm install tailwindcss@latest @tailwindcss/postcss@latest prettier-plugin-tailwindcss@latest ``` ## Test plan Testing this locally, the results look like this: | Before | After | |--------|-------| | <img width="590" alt="image" src="https://github.com/user-attachments/assets/c899d432-78c3-4945-af73-3ef4fffa08da" /> | <img width="656" alt="image" src="https://github.com/user-attachments/assets/a448d711-dd74-44cf-9790-c8a14fc6964f" /> | In CI: | Before (with a failure) | After | | --- | --- | | <img width="224" alt="image" src="https://github.com/user-attachments/assets/f58a6bf6-fdbe-4474-aa1f-444ab51a53c9" /> | <img width="224" alt="image" src="https://github.com/user-attachments/assets/54606df5-4f69-444b-8d4c-5ce27c5d6b41" /> | [ci-all]
339 lines
11 KiB
JavaScript
339 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { Scanner } from '@tailwindcss/oxide'
|
|
import { globby } from 'globby'
|
|
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import postcss from 'postcss'
|
|
import { migrateJsConfig } from './codemods/config/migrate-js-config'
|
|
import { migratePostCSSConfig } from './codemods/config/migrate-postcss'
|
|
import { analyze as analyzeStylesheets } from './codemods/css/analyze'
|
|
import { formatNodes } from './codemods/css/format-nodes'
|
|
import { linkConfigs as linkConfigsToStylesheets } from './codemods/css/link'
|
|
import { migrate as migrateStylesheet } from './codemods/css/migrate'
|
|
import { sortBuckets } from './codemods/css/sort-buckets'
|
|
import { split as splitStylesheets } from './codemods/css/split'
|
|
import { migrate as migrateTemplate } from './codemods/template/migrate'
|
|
import { prepareConfig } from './codemods/template/prepare-config'
|
|
import { help } from './commands/help'
|
|
import { Stylesheet } from './stylesheet'
|
|
import { args, type Arg } from './utils/args'
|
|
import { isRepoDirty } from './utils/git'
|
|
import { pkg } from './utils/packages'
|
|
import { eprintln, error, header, highlight, info, relative, success } from './utils/renderer'
|
|
import * as version from './utils/version'
|
|
|
|
const options = {
|
|
'--config': { type: 'string', description: 'Path to the configuration file', alias: '-c' },
|
|
'--help': { type: 'boolean', description: 'Display usage information', alias: '-h' },
|
|
'--force': { type: 'boolean', description: 'Force the migration', alias: '-f' },
|
|
'--version': { type: 'boolean', description: 'Display the version number', alias: '-v' },
|
|
} satisfies Arg
|
|
const flags = args(options)
|
|
|
|
if (flags['--help']) {
|
|
help({
|
|
usage: ['npx @tailwindcss/upgrade'],
|
|
options,
|
|
})
|
|
process.exit(0)
|
|
}
|
|
|
|
async function run() {
|
|
let base = process.cwd()
|
|
|
|
eprintln(header())
|
|
eprintln()
|
|
|
|
let cleanup: (() => void)[] = []
|
|
|
|
if (!flags['--force']) {
|
|
// Require a clean git directory
|
|
if (isRepoDirty()) {
|
|
error('Git directory is not clean. Please stash or commit your changes before migrating.')
|
|
info(
|
|
`You may use the ${highlight('--force')} flag to silence this warning and perform the migration.`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
{
|
|
// Stylesheet migrations
|
|
|
|
// Use provided files
|
|
let files = flags._.map((file) => path.resolve(base, file))
|
|
|
|
// Discover CSS files in case no files were provided
|
|
if (files.length === 0) {
|
|
info('Searching for CSS files in the current directory and its subdirectories…')
|
|
|
|
files = await globby(['**/*.css'], {
|
|
absolute: true,
|
|
gitignore: true,
|
|
})
|
|
}
|
|
|
|
// Ensure we are only dealing with CSS files
|
|
files = files.filter((file) => file.endsWith('.css'))
|
|
|
|
// Analyze the stylesheets
|
|
let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath)))
|
|
|
|
// Load and parse all stylesheets
|
|
for (let result of loadResults) {
|
|
if (result.status === 'rejected') {
|
|
error(`${result.reason?.message ?? result.reason}`, { prefix: '↳ ' })
|
|
}
|
|
}
|
|
|
|
let stylesheets = loadResults
|
|
.filter((result) => result.status === 'fulfilled')
|
|
.map((result) => result.value)
|
|
let originals = new Map(stylesheets.map((sheet) => [sheet, sheet.root.toString()]))
|
|
|
|
// Analyze the stylesheets
|
|
try {
|
|
await analyzeStylesheets(stylesheets)
|
|
} catch (e: any) {
|
|
error(`${e?.message ?? e}`, { prefix: '↳ ' })
|
|
}
|
|
|
|
// Ensure stylesheets are linked to configs. But this is only necessary when
|
|
// migrating from v3 to v4.
|
|
if (version.isMajor(3)) {
|
|
try {
|
|
await linkConfigsToStylesheets(stylesheets, {
|
|
configPath: flags['--config'],
|
|
base,
|
|
})
|
|
} catch (e: any) {
|
|
error(`${e?.message ?? e}`, { prefix: '↳ ' })
|
|
}
|
|
}
|
|
|
|
// Migrate js config files, linked to stylesheets
|
|
if (stylesheets.some((sheet) => sheet.isTailwindRoot && sheet.linkedConfigPath)) {
|
|
info('Migrating JavaScript configuration files…')
|
|
}
|
|
let configBySheet = new Map<Stylesheet, Awaited<ReturnType<typeof prepareConfig>>>()
|
|
let jsConfigMigrationBySheet = new Map<
|
|
Stylesheet,
|
|
Awaited<ReturnType<typeof migrateJsConfig>>
|
|
>()
|
|
for (let sheet of stylesheets) {
|
|
if (!sheet.isTailwindRoot) continue
|
|
if (!version.isMajor(3) && !sheet.linkedConfigPath) continue
|
|
|
|
let config = await prepareConfig(sheet.linkedConfigPath, { base })
|
|
configBySheet.set(sheet, config)
|
|
|
|
let jsConfigMigration = await migrateJsConfig(
|
|
config.designSystem,
|
|
config.configFilePath,
|
|
base,
|
|
)
|
|
jsConfigMigrationBySheet.set(sheet, jsConfigMigration)
|
|
|
|
if (jsConfigMigration !== null) {
|
|
// Remove the JS config if it was fully migrated
|
|
cleanup.push(() => fs.rm(config.configFilePath))
|
|
}
|
|
|
|
if (jsConfigMigration !== null) {
|
|
success(
|
|
`Migrated configuration file: ${highlight(relative(config.configFilePath, base))}`,
|
|
{ prefix: '↳ ' },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Migrate each CSS file
|
|
if (stylesheets.length > 0) {
|
|
info('Migrating stylesheets…')
|
|
}
|
|
await Promise.all(
|
|
stylesheets.map(async (sheet) => {
|
|
try {
|
|
let config = configBySheet.get(sheet)
|
|
let jsConfigMigration = jsConfigMigrationBySheet.get(sheet) ?? null
|
|
|
|
if (!config) {
|
|
for (let parent of sheet.ancestors()) {
|
|
if (parent.isTailwindRoot) {
|
|
config ??= configBySheet.get(parent)!
|
|
jsConfigMigration ??= jsConfigMigrationBySheet.get(parent) ?? null
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
await migrateStylesheet(sheet, {
|
|
newPrefix: config?.newPrefix ?? null,
|
|
designSystem: config?.designSystem ?? (await sheet.designSystem()),
|
|
userConfig: config?.userConfig ?? null,
|
|
configFilePath: config?.configFilePath ?? null,
|
|
jsConfigMigration,
|
|
})
|
|
} catch (e: any) {
|
|
error(`${e?.message ?? e} in ${highlight(relative(sheet.file!, base))}`, { prefix: '↳ ' })
|
|
}
|
|
}),
|
|
)
|
|
|
|
// Split up stylesheets (as needed)
|
|
if (version.isMajor(3)) {
|
|
try {
|
|
await splitStylesheets(stylesheets)
|
|
} catch (e: any) {
|
|
error(`${e?.message ?? e}`, { prefix: '↳ ' })
|
|
}
|
|
|
|
// Cleanup `@import "…" layer(utilities)`
|
|
for (let sheet of stylesheets) {
|
|
for (let importRule of sheet.importRules) {
|
|
if (!importRule.raws.tailwind_injected_layer) continue
|
|
let importedSheet = stylesheets.find(
|
|
(sheet) => sheet.id === importRule.raws.tailwind_destination_sheet_id,
|
|
)
|
|
if (!importedSheet) continue
|
|
|
|
// Only remove the `layer(…)` next to the import if any of the children
|
|
// contain `@utility`. Otherwise `@utility` will not be top-level.
|
|
if (
|
|
!importedSheet.containsRule((node) => node.type === 'atrule' && node.name === 'utility')
|
|
) {
|
|
continue
|
|
}
|
|
|
|
// Make sure to remove the `layer(…)` from the `@import` at-rule
|
|
importRule.params = importRule.params.replace(/ layer\([^)]+\)/, '').trim()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format nodes
|
|
for (let sheet of stylesheets) {
|
|
if (originals.get(sheet) === sheet.root.toString()) continue
|
|
await postcss([sortBuckets(), formatNodes()]).process(sheet.root!, { from: sheet.file! })
|
|
}
|
|
|
|
// Write all files to disk
|
|
for (let sheet of stylesheets) {
|
|
if (!sheet.file) continue
|
|
|
|
await fs.writeFile(sheet.file, sheet.root.toString())
|
|
|
|
if (sheet.isTailwindRoot) {
|
|
success(`Migrated stylesheet: ${highlight(relative(sheet.file, base))}`, { prefix: '↳ ' })
|
|
}
|
|
}
|
|
|
|
info('Updating dependencies…')
|
|
{
|
|
let pkgManager = pkg(base)
|
|
let dependencies = [
|
|
'tailwindcss',
|
|
'@tailwindcss/cli',
|
|
'@tailwindcss/postcss',
|
|
'@tailwindcss/vite',
|
|
'@tailwindcss/node',
|
|
'@tailwindcss/oxide',
|
|
'prettier-plugin-tailwindcss',
|
|
].filter((dependency) => dependency === 'tailwindcss' || pkgManager.has(dependency))
|
|
try {
|
|
await pkgManager.add(dependencies.map((dependency) => `${dependency}@latest`))
|
|
for (let dependency of dependencies) {
|
|
success(`Updated package: ${highlight(dependency)}`, { prefix: '↳ ' })
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
let tailwindRootStylesheets = stylesheets.filter((sheet) => sheet.isTailwindRoot && sheet.file)
|
|
|
|
// Migrate source files
|
|
if (tailwindRootStylesheets.length > 0) {
|
|
info('Migrating templates…')
|
|
}
|
|
{
|
|
let seenFiles = new Set()
|
|
|
|
// Template migrations
|
|
for (let sheet of tailwindRootStylesheets) {
|
|
let compiler = await sheet.compiler()
|
|
if (!compiler) continue
|
|
let designSystem = await sheet.designSystem()
|
|
if (!designSystem) continue
|
|
|
|
// Figure out the source files to migrate
|
|
let sources = (() => {
|
|
// Disable auto source detection
|
|
if (compiler.root === 'none') {
|
|
return []
|
|
}
|
|
|
|
// No root specified, use the base directory
|
|
if (compiler.root === null) {
|
|
return [{ base, pattern: '**/*', negated: false }]
|
|
}
|
|
|
|
// Use the specified root
|
|
return [{ ...compiler.root, negated: false }]
|
|
})().concat(compiler.sources)
|
|
|
|
let config = configBySheet.get(sheet)
|
|
let scanner = new Scanner({ sources })
|
|
let filesToMigrate = []
|
|
for (let file of scanner.files) {
|
|
if (file.endsWith('.css')) continue
|
|
if (seenFiles.has(file)) continue
|
|
seenFiles.add(file)
|
|
filesToMigrate.push(file)
|
|
}
|
|
|
|
// Migrate each file
|
|
await Promise.allSettled(
|
|
filesToMigrate.map((file) =>
|
|
migrateTemplate(designSystem, config?.userConfig ?? null, file),
|
|
),
|
|
)
|
|
|
|
if (config?.configFilePath) {
|
|
success(
|
|
`Migrated templates for configuration file: ${highlight(relative(config.configFilePath, base))}`,
|
|
{ prefix: '↳ ' },
|
|
)
|
|
} else {
|
|
success(
|
|
`Migrated templates for: ${highlight(relative(sheet.file ?? '<unknown>', base))}`,
|
|
{
|
|
prefix: '↳ ',
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (version.isMajor(3)) {
|
|
// PostCSS config migration
|
|
await migratePostCSSConfig(base)
|
|
}
|
|
|
|
// Run all cleanup functions because we completed the migration
|
|
await Promise.allSettled(cleanup.map((fn) => fn()))
|
|
|
|
// Figure out if we made any changes
|
|
if (isRepoDirty()) {
|
|
success('Verify the changes and commit them to your repository.')
|
|
} else {
|
|
success('No changes were made to your repository.')
|
|
}
|
|
}
|
|
|
|
run()
|
|
.then(() => process.exit(0))
|
|
.catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|