From 17b79e92e99720963f3f5d934967068c57ec6a3c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 13 Sep 2024 16:02:23 +0200 Subject: [PATCH 01/12] init `@tailwindcss/upgrade` --- packages/@tailwindcss-upgrade/README.md | 40 ++++++++++++++++++++ packages/@tailwindcss-upgrade/package.json | 35 +++++++++++++++++ packages/@tailwindcss-upgrade/src/index.ts | 7 ++++ packages/@tailwindcss-upgrade/tsconfig.json | 3 ++ packages/@tailwindcss-upgrade/tsup.config.ts | 8 ++++ 5 files changed, 93 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/README.md create mode 100644 packages/@tailwindcss-upgrade/package.json create mode 100644 packages/@tailwindcss-upgrade/src/index.ts create mode 100644 packages/@tailwindcss-upgrade/tsconfig.json create mode 100644 packages/@tailwindcss-upgrade/tsup.config.ts diff --git a/packages/@tailwindcss-upgrade/README.md b/packages/@tailwindcss-upgrade/README.md new file mode 100644 index 000000000000..95ec9d87ddcc --- /dev/null +++ b/packages/@tailwindcss-upgrade/README.md @@ -0,0 +1,40 @@ +

+ + + + + Tailwind CSS + + +

+ +

+ A utility-first CSS framework for rapidly building custom user interfaces. +

+ +

+ Build Status + Total Downloads + Latest Release + License +

+ +--- + +## Documentation + +For full documentation, visit [tailwindcss.com](https://tailwindcss.com). + +## Community + +For help, discussion about best practices, or any other conversation that would benefit from being searchable: + +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) + +For chatting with others using the framework: + +[Join the Tailwind CSS Discord Server](https://discord.gg/7NF8GNe) + +## Contributing + +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json new file mode 100644 index 000000000000..c2ff7e4c9d17 --- /dev/null +++ b/packages/@tailwindcss-upgrade/package.json @@ -0,0 +1,35 @@ +{ + "name": "@tailwindcss/upgrade", + "version": "4.0.0-alpha.24", + "description": "A utility-first CSS framework for rapidly building custom user interfaces.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tailwindlabs/tailwindcss.git", + "directory": "packages/@tailwindcss-cli" + }, + "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", + "homepage": "https://tailwindcss.com", + "scripts": { + "lint": "tsc --noEmit", + "build": "tsup-node", + "dev": "pnpm run build -- --watch" + }, + "exports": { + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "provenance": true, + "access": "public" + }, + "dependencies": { + "postcss-import": "^16.1.0", + "postcss": "^8.4.41" + }, + "devDependencies": { + "@types/postcss-import": "^14.0.3" + } +} diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts new file mode 100644 index 000000000000..5fc4f5cd8dd1 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +console.log({ + argv: process.argv, +}) + +process.exit(0) diff --git a/packages/@tailwindcss-upgrade/tsconfig.json b/packages/@tailwindcss-upgrade/tsconfig.json new file mode 100644 index 000000000000..6ae022f65bf0 --- /dev/null +++ b/packages/@tailwindcss-upgrade/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.base.json", +} diff --git a/packages/@tailwindcss-upgrade/tsup.config.ts b/packages/@tailwindcss-upgrade/tsup.config.ts new file mode 100644 index 000000000000..915692c12afe --- /dev/null +++ b/packages/@tailwindcss-upgrade/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'tsup' + +export default defineConfig({ + format: ['esm'], + clean: true, + minify: true, + entry: ['src/index.ts'], +}) From a6597f284b6be241d8d9ca66fd3ea9ac2bf37a8d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 11:38:12 +0200 Subject: [PATCH 02/12] add codemod tooling + add codemod for migrating `@apply` --- packages/@tailwindcss-upgrade/package.json | 12 +++- .../src/codemods/migrate-at-apply.test.ts | 70 +++++++++++++++++++ .../src/codemods/migrate-at-apply.ts | 43 ++++++++++++ .../@tailwindcss-upgrade/src/index.test.ts | 28 ++++++++ packages/@tailwindcss-upgrade/src/index.ts | 54 ++++++++++++-- packages/@tailwindcss-upgrade/src/migrate.ts | 36 ++++++++++ 6 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts create mode 100644 packages/@tailwindcss-upgrade/src/index.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/migrate.ts diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index c2ff7e4c9d17..de7120e053c5 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -15,6 +15,9 @@ "build": "tsup-node", "dev": "pnpm run build -- --watch" }, + "bin": { + "tailwindcss-upgrade": "./dist/index.mjs" + }, "exports": { "./package.json": "./package.json" }, @@ -26,10 +29,13 @@ "access": "public" }, "dependencies": { - "postcss-import": "^16.1.0", - "postcss": "^8.4.41" + "picocolors": "^1.0.1", + "postcss": "^8.4.41", + "postcss-import": "^16.1.0" }, "devDependencies": { - "@types/postcss-import": "^14.0.3" + "@types/node": "catalog:", + "@types/postcss-import": "^14.0.3", + "dedent": "1.5.3" } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts new file mode 100644 index 000000000000..f270472d1e65 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.test.ts @@ -0,0 +1,70 @@ +import dedent from 'dedent' +import postcss from 'postcss' +import { expect, it } from 'vitest' +import { migrateAtApply } from './migrate-at-apply' + +const css = dedent + +function migrate(input: string) { + return postcss() + .use(migrateAtApply()) + .process(input, { from: expect.getState().testPath }) + .then((result) => result.css) +} + +it('should not migrate `@apply`, when there are no issues', async () => { + expect( + await migrate(css` + .foo { + @apply flex flex-col items-center; + } + `), + ).toMatchInlineSnapshot(` + ".foo { + @apply flex flex-col items-center; + }" + `) +}) + +it('should append `!` to each utility, when using `!important`', async () => { + expect( + await migrate(css` + .foo { + @apply flex flex-col !important; + } + `), + ).toMatchInlineSnapshot(` + ".foo { + @apply flex! flex-col!; + }" + `) +}) + +// TODO: Handle SCSS syntax +it.skip('should append `!` to each utility, when using `#{!important}`', async () => { + expect( + await migrate(css` + .foo { + @apply flex flex-col #{!important}; + } + `), + ).toMatchInlineSnapshot(` + ".foo { + @apply flex! flex-col!; + }" + `) +}) + +it('should move the legacy `!` prefix, to the new `!` postfix notation', async () => { + expect( + await migrate(css` + .foo { + @apply !flex flex-col! hover:!items-start items-center; + } + `), + ).toMatchInlineSnapshot(` + ".foo { + @apply flex! flex-col! hover:items-start! items-center; + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts new file mode 100644 index 000000000000..41ff03ec3323 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts @@ -0,0 +1,43 @@ +import type { AtRule, Plugin } from 'postcss' +import { segment } from '../../../tailwindcss/src/utils/segment' + +export function migrateAtApply(): Plugin { + function migrate(atRule: AtRule) { + let utilities = atRule.params.split(/(\s+)/) + let important = + utilities[utilities.length - 1] === '!important' || + utilities[utilities.length - 1] === '#{!important}' // Sass/SCSS + + if (important) utilities.pop() // Remove `!important` + + let params = utilities.map((part) => { + // Keep whitespace + if (part.trim() === '') return part + + let variants = segment(part, ':') + let utility = variants.pop()! + + // Apply the important modifier to all the rules if necessary + if (important && utility[0] !== '!' && utility[utility.length - 1] !== '!') { + 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(':') + }) + + atRule.params = params.join('').trim() + } + + return { + postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply', + AtRule: { + apply: migrate, + }, + } +} diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts new file mode 100644 index 000000000000..a043092d9ae6 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -0,0 +1,28 @@ +import dedent from 'dedent' +import { expect, it } from 'vitest' +import { migrateContents } from './migrate' + +const css = dedent + +it('should print the input as-is', async () => { + expect( + await migrateContents( + css` + /* above */ + .foo/* after */ { + /* above */ + color: /* before */ red /* after */; + /* below */ + } + `, + expect.getState().testPath, + ), + ).toMatchInlineSnapshot(` + "/* above */ + .foo/* after */ { + /* above */ + color: /* before */ red /* after */; + /* below */ + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 5fc4f5cd8dd1..c6459876174a 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,7 +1,53 @@ #!/usr/bin/env node -console.log({ - argv: process.argv, -}) +import { execSync } from 'node:child_process' +import path from 'node:path' +import pc from 'picocolors' +import { help } from '../../@tailwindcss-cli/src/commands/help' +import { args, type Arg } from '../../@tailwindcss-cli/src/utils/args' +import { eprintln, header, highlight, wordWrap } from '../../@tailwindcss-cli/src/utils/renderer' +import { migrate } from './migrate' -process.exit(0) +const options = { + '--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) +} + +const file = flags._[0] + +async function run() { + eprintln(header()) + eprintln() + + if (!flags['--force']) { + let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) + if (stdout.trim()) { + wordWrap( + 'Git directory is not clean. Please stash or commit your changes before migrating.', + process.stderr.columns - 5 - 4, + ).map((line) => eprintln(`${pc.red('\u2502')} ${line}`)) + wordWrap( + `You may use the ${highlight('--force')} flag to silence this warning and perform the migration.`, + process.stderr.columns - 2 - 4, + ).map((line) => eprintln(`${pc.red('\u2502')} ${line}`)) + eprintln() + process.exit(1) + } + } + + await migrate(path.resolve(process.cwd(), file)) +} + +run() + .then(() => process.exit(0)) + .catch(() => process.exit(1)) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts new file mode 100644 index 000000000000..a8ac2e7a5dc5 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -0,0 +1,36 @@ +import { execSync } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' +import pc from 'picocolors' +import postcss from 'postcss' +import { eprintln, wordWrap } from '../../@tailwindcss-cli/src/utils/renderer' +import { migrateAtApply } from './codemods/migrate-at-apply' + +export async function migrateContents(contents: string, file?: string) { + return postcss() + .use(migrateAtApply()) + .process(contents, { from: file }) + .then((result) => result.css) +} + +export async function migrate(file: string) { + let fullPath = path.resolve(process.cwd(), file) + let contents = await fs.readFile(fullPath, 'utf-8') + + await fs.writeFile(fullPath, await migrateContents(contents, fullPath)) + + let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) + if (stdout.trim()) { + wordWrap( + 'Migration complete. Verify the changes and commit them to your repository.', + process.stderr.columns - 5 - 4, + ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) + eprintln() + } else { + wordWrap( + 'Migration complete. No changes were made to your repository.', + process.stderr.columns - 5 - 4, + ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) + eprintln() + } +} From dfac2f91565de2642b3668538c9e42b8827972bf Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 12:15:21 +0200 Subject: [PATCH 03/12] copy utils from `@tailwindcss/cli` Copied some utils from `@tailwindcss/cli`. Initially referenced them, but we also rely on some dependencies that we have to install. One solution is to move it to an internal package and import it so that tsup can inline everything. This also has the same 'we need the correct dependencies' problem. Another solution is to create a proper `@tailwindcss/cli-utils` (or similar) package that we can import as a dependency. However, this one will require us to actually publish the package, and give it a proper name. --- packages/@tailwindcss-upgrade/package.json | 2 + .../src/commands/help/index.ts | 170 ++++++++++++++++++ packages/@tailwindcss-upgrade/src/index.ts | 6 +- packages/@tailwindcss-upgrade/src/migrate.ts | 2 +- .../src/utils/args.test.ts | 123 +++++++++++++ .../@tailwindcss-upgrade/src/utils/args.ts | 160 +++++++++++++++++ .../src/utils/format-ns.test.ts | 28 +++ .../src/utils/format-ns.ts | 23 +++ .../src/utils/renderer.ts | 102 +++++++++++ .../@tailwindcss-upgrade/src/utils/resolve.ts | 32 ++++ 10 files changed, 644 insertions(+), 4 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/commands/help/index.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/args.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/args.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/format-ns.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/format-ns.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/renderer.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/resolve.ts diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index de7120e053c5..2f0791088c51 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -29,6 +29,8 @@ "access": "public" }, "dependencies": { + "enhanced-resolve": "^5.17.1", + "mri": "^1.2.0", "picocolors": "^1.0.1", "postcss": "^8.4.41", "postcss-import": "^16.1.0" diff --git a/packages/@tailwindcss-upgrade/src/commands/help/index.ts b/packages/@tailwindcss-upgrade/src/commands/help/index.ts new file mode 100644 index 000000000000..20ee78208440 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/commands/help/index.ts @@ -0,0 +1,170 @@ +import pc from 'picocolors' +import type { Arg } from '../../utils/args' +import { UI, header, highlight, indent, println, wordWrap } from '../../utils/renderer' + +export function help({ + invalid, + usage, + options, +}: { + invalid?: string + usage?: string[] + options?: Arg +}) { + // Available terminal width + let width = process.stdout.columns + + // Render header + println(header()) + + // Render the invalid command + if (invalid) { + println() + println(`${pc.dim('Invalid command:')} ${invalid}`) + } + + // Render usage + if (usage && usage.length > 0) { + println() + println(pc.dim('Usage:')) + for (let [idx, example] of usage.entries()) { + // Split the usage example into the command and its options. This allows + // us to wrap the options based on the available width of the terminal. + let command = example.slice(0, example.indexOf('[')) + let options = example.slice(example.indexOf('[')) + + // Make the options dimmed, to make them stand out less than the command + // itself. + options = options.replace(/\[.*?\]/g, (option) => pc.dim(option)) + + // The space between the command and the options. + let space = 1 + + // Wrap the options based on the available width of the terminal. + let lines = wordWrap(options, width - UI.indent - command.length - space) + + // Print an empty line between the usage examples if we need to split due + // to width constraints. This ensures that the usage examples are visually + // separated. + // + // E.g.: when enough space is available + // + // ``` + // Usage: + // tailwindcss build [--input input.css] [--output output.css] [--watch] [options...] + // tailwindcss other [--watch] [options...] + // ``` + // + // E.g.: when not enough space is available + // + // ``` + // Usage: + // tailwindcss build [--input input.css] [--output output.css] + // [--watch] [options...] + // + // tailwindcss other [--watch] [options...] + // ``` + if (lines.length > 1 && idx !== 0) { + println() + } + + // Print the usage examples based on available width of the terminal. + // + // E.g.: when enough space is available + // + // ``` + // Usage: + // tailwindcss [--input input.css] [--output output.css] [--watch] [options...] + // ``` + // + // E.g.: when not enough space is available + // + // ``` + // Usage: + // tailwindcss [--input input.css] [--output output.css] + // [--watch] [options...] + // ``` + // + // > Note how the second line is indented to align with the first line. + println(indent(`${command}${lines.shift()}`)) + for (let line of lines) { + println(indent(line, command.length)) + } + } + } + + // Render options + if (options) { + // Track the max alias length, this is used to indent the options that don't + // have an alias such that everything is aligned properly. + let maxAliasLength = 0 + for (let { alias } of Object.values(options)) { + if (alias) { + maxAliasLength = Math.max(maxAliasLength, alias.length) + } + } + + // The option strings, which are the combination of the `alias` and the + // `flag`, with the correct spacing. + let optionStrings: string[] = [] + + // Track the max option length, which is the longest combination of an + // `alias` followed by `, ` and followed by the `flag`. + let maxOptionLength = 0 + + for (let [flag, { alias }] of Object.entries(options)) { + // The option string, which is the combination of the alias and the flag + // but already properly indented based on the other aliases to ensure + // everything is aligned properly. + let option = [ + alias ? `${alias.padStart(maxAliasLength)}` : alias, + alias ? flag : ' '.repeat(maxAliasLength + 2 /* `, `.length */) + flag, + ] + .filter(Boolean) + .join(', ') + + optionStrings.push(option) + maxOptionLength = Math.max(maxOptionLength, option.length) + } + + println() + println(pc.dim('Options:')) + + // The minimum amount of dots between the option and the description. + let minimumGap = 8 + + for (let { description, default: defaultValue = null } of Object.values(options)) { + // The option to render + let option = optionStrings.shift() as string + + // The amount of dots to show between the option and the description. + let dotCount = minimumGap + (maxOptionLength - option.length) + + // To account for the space before and after the dots. + let spaces = 2 + + // The available width remaining for the description. + let availableWidth = width - option.length - dotCount - spaces - UI.indent + + // Wrap the description and the default value (if present), based on the + // available width. + let lines = wordWrap( + defaultValue !== null + ? `${description} ${pc.dim(`[default:\u202F${highlight(`${defaultValue}`)}]`)}` + : description, + availableWidth, + ) + + // Print the option, the spacer dots and the start of the description. + println( + indent(`${pc.blue(option)} ${pc.dim(pc.gray('\u00B7')).repeat(dotCount)} ${lines.shift()}`), + ) + + // Print the remaining lines of the description, indenting them to align + // with the start of the description. + for (let line of lines) { + println(indent(`${' '.repeat(option.length + dotCount + spaces)}${line}`)) + } + } + } +} diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index c6459876174a..7dd39e8d6917 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -3,10 +3,10 @@ import { execSync } from 'node:child_process' import path from 'node:path' import pc from 'picocolors' -import { help } from '../../@tailwindcss-cli/src/commands/help' -import { args, type Arg } from '../../@tailwindcss-cli/src/utils/args' -import { eprintln, header, highlight, wordWrap } from '../../@tailwindcss-cli/src/utils/renderer' +import { help } from './commands/help' import { migrate } from './migrate' +import { args, type Arg } from './utils/args' +import { eprintln, header, highlight, wordWrap } from './utils/renderer' const options = { '--help': { type: 'boolean', description: 'Display usage information', alias: '-h' }, diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index a8ac2e7a5dc5..d0cf71d12247 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -3,8 +3,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import pc from 'picocolors' import postcss from 'postcss' -import { eprintln, wordWrap } from '../../@tailwindcss-cli/src/utils/renderer' import { migrateAtApply } from './codemods/migrate-at-apply' +import { eprintln, wordWrap } from './utils/renderer' export async function migrateContents(contents: string, file?: string) { return postcss() diff --git a/packages/@tailwindcss-upgrade/src/utils/args.test.ts b/packages/@tailwindcss-upgrade/src/utils/args.test.ts new file mode 100644 index 000000000000..51d2c4747241 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/args.test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest' +import { args, type Arg } from './args' + +it('should be possible to parse a single argument', () => { + expect( + args( + { + '--input': { type: 'string', description: 'Input file' }, + }, + ['--input', 'input.css'], + ), + ).toMatchInlineSnapshot(` + { + "--input": "input.css", + "_": [], + } + `) +}) + +it('should fallback to the default value if no flag is passed', () => { + expect( + args( + { + '--input': { type: 'string', description: 'Input file', default: 'input.css' }, + }, + ['--other'], + ), + ).toMatchInlineSnapshot(` + { + "--input": "input.css", + "_": [], + } + `) +}) + +it('should fallback to null if no flag is passed and no default value is provided', () => { + expect( + args( + { + '--input': { type: 'string', description: 'Input file' }, + }, + ['--other'], + ), + ).toMatchInlineSnapshot(` + { + "--input": null, + "_": [], + } + `) +}) + +it('should be possible to parse a single argument using the shorthand alias', () => { + expect( + args( + { + '--input': { type: 'string', description: 'Input file', alias: '-i' }, + }, + ['-i', 'input.css'], + ), + ).toMatchInlineSnapshot(` + { + "--input": "input.css", + "_": [], + } + `) +}) + +it('should convert the incoming value to the correct type', () => { + expect( + args( + { + '--input': { type: 'string', description: 'Input file' }, + '--watch': { type: 'boolean', description: 'Watch mode' }, + '--retries': { type: 'number', description: 'Amount of retries' }, + }, + ['--input', 'input.css', '--watch', '--retries', '3'], + ), + ).toMatchInlineSnapshot(` + { + "--input": "input.css", + "--retries": 3, + "--watch": true, + "_": [], + } + `) +}) + +it('should be possible to provide multiple types, and convert the value to that type', () => { + let options = { + '--retries': { type: 'boolean | number | string', description: 'Retries' }, + } satisfies Arg + + expect(args(options, ['--retries'])).toMatchInlineSnapshot(` + { + "--retries": true, + "_": [], + } + `) + expect(args(options, ['--retries', 'true'])).toMatchInlineSnapshot(` + { + "--retries": true, + "_": [], + } + `) + expect(args(options, ['--retries', 'false'])).toMatchInlineSnapshot(` + { + "--retries": false, + "_": [], + } + `) + expect(args(options, ['--retries', '5'])).toMatchInlineSnapshot(` + { + "--retries": 5, + "_": [], + } + `) + expect(args(options, ['--retries', 'indefinitely'])).toMatchInlineSnapshot(` + { + "--retries": "indefinitely", + "_": [], + } + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/utils/args.ts b/packages/@tailwindcss-upgrade/src/utils/args.ts new file mode 100644 index 000000000000..81bd847d50d1 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/args.ts @@ -0,0 +1,160 @@ +import parse from 'mri' + +// Definition of the arguments for a command in the CLI. +export type Arg = { + [key: `--${string}`]: { + type: keyof Types + description: string + alias?: `-${string}` + default?: Types[keyof Types] + } +} + +// Each argument will have a type and we want to convert the incoming raw string +// based value to the correct type. We can't use pure TypeScript types because +// these don't exist at runtime. Instead, we define a string-based type that +// maps to a TypeScript type. +type Types = { + boolean: boolean + number: number | null + string: string | null + 'boolean | string': boolean | string | null + 'number | string': number | string | null + 'boolean | number': boolean | number | null + 'boolean | number | string': boolean | number | string | null +} + +// Convert the `Arg` type to a type that can be used at runtime. +// +// E.g.: +// +// Arg: +// ``` +// { '--input': { type: 'string', description: 'Input file', alias: '-i' } } +// ``` +// +// Command: +// ``` +// ./tailwindcss -i input.css +// ./tailwindcss --input input.css +// ``` +// +// Result type: +// ``` +// { +// _: string[], // All non-flag arguments +// '--input': string | null // The `--input` flag will be filled with `null`, if the flag is not used. +// // The `null` type will not be there if `default` is provided. +// } +// ``` +// +// Result runtime object: +// ``` +// { +// _: [], +// '--input': 'input.css' +// } +// ``` +export type Result = { + [K in keyof T]: T[K] extends { type: keyof Types; default?: any } + ? undefined extends T[K]['default'] + ? Types[T[K]['type']] + : NonNullable + : never +} & { + // All non-flag arguments + _: string[] +} + +export function args(options: T, argv = process.argv.slice(2)): Result { + let parsed = parse(argv) + + let result: { _: string[]; [key: string]: unknown } = { + _: parsed._, + } + + for (let [ + flag, + { type, alias, default: defaultValue = type === 'boolean' ? false : null }, + ] of Object.entries(options)) { + // Start with the default value + result[flag] = defaultValue + + // Try to find the `alias`, and map it to long form `flag` + if (alias) { + let key = alias.slice(1) + if (parsed[key] !== undefined) { + result[flag] = convert(parsed[key], type) + } + } + + // Try to find the long form `flag` + { + let key = flag.slice(2) + if (parsed[key] !== undefined) { + result[flag] = convert(parsed[key], type) + } + } + } + + return result as Result +} + +// --- + +type ArgumentType = string | boolean + +// Try to convert the raw incoming `value` (which will be a string or a boolean, +// this is coming from `mri`'s parse function'), to the correct type based on +// the `type` of the argument. +function convert(value: string | boolean, type: T) { + switch (type) { + case 'string': + return convertString(value) + case 'boolean': + return convertBoolean(value) + case 'number': + return convertNumber(value) + case 'boolean | string': + return convertBoolean(value) ?? convertString(value) + case 'number | string': + return convertNumber(value) ?? convertString(value) + case 'boolean | number': + return convertBoolean(value) ?? convertNumber(value) + case 'boolean | number | string': + return convertBoolean(value) ?? convertNumber(value) ?? convertString(value) + default: + throw new Error(`Unhandled type: ${type}`) + } +} + +function convertBoolean(value: ArgumentType) { + if (value === true || value === false) { + return value + } + + if (value === 'true') { + return true + } + + if (value === 'false') { + return false + } +} + +function convertNumber(value: ArgumentType) { + if (typeof value === 'number') { + return value + } + + { + let valueAsNumber = Number(value) + if (!Number.isNaN(valueAsNumber)) { + return valueAsNumber + } + } +} + +function convertString(value: ArgumentType) { + return `${value}` +} diff --git a/packages/@tailwindcss-upgrade/src/utils/format-ns.test.ts b/packages/@tailwindcss-upgrade/src/utils/format-ns.test.ts new file mode 100644 index 000000000000..a5382d21772e --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/format-ns.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest' +import { formatNanoseconds } from './format-ns' + +it.each([ + [0, '0ns'], + [1, '1ns'], + [999, '999ns'], + [1000, '1µs'], + [1001, '1µs'], + [999999, '999µs'], + [1000000, '1ms'], + [1000001, '1ms'], + [999999999, '999ms'], + [1000000000, '1s'], + [1000000001, '1s'], + [59999999999, '59s'], + [60000000000, '1m'], + [60000000001, '1m'], + [3599999999999n, '59m'], + [3600000000000n, '1h'], + [3600000000001n, '1h'], + [86399999999999n, '23h'], + [86400000000000n, '1d'], + [86400000000001n, '1d'], + [8640000000000000n, '100d'], +])('should format %s nanoseconds as %s', (ns, expected) => { + expect(formatNanoseconds(ns)).toBe(expected) +}) diff --git a/packages/@tailwindcss-upgrade/src/utils/format-ns.ts b/packages/@tailwindcss-upgrade/src/utils/format-ns.ts new file mode 100644 index 000000000000..39889d401149 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/format-ns.ts @@ -0,0 +1,23 @@ +export function formatNanoseconds(input: bigint | number) { + let ns = typeof input === 'number' ? BigInt(input) : input + + if (ns < 1_000n) return `${ns}ns` + ns /= 1_000n + + if (ns < 1_000n) return `${ns}µs` + ns /= 1_000n + + if (ns < 1_000n) return `${ns}ms` + ns /= 1_000n + + if (ns < 60n) return `${ns}s` + ns /= 60n + + if (ns < 60n) return `${ns}m` + ns /= 60n + + if (ns < 24n) return `${ns}h` + ns /= 24n + + return `${ns}d` +} diff --git a/packages/@tailwindcss-upgrade/src/utils/renderer.ts b/packages/@tailwindcss-upgrade/src/utils/renderer.ts new file mode 100644 index 000000000000..6b00985866cb --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/renderer.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs' +import path from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import pc from 'picocolors' +import { resolve } from '../utils/resolve' +import { formatNanoseconds } from './format-ns' + +export const UI = { + indent: 2, +} +export function header() { + return `${pc.italic(pc.bold(pc.blue('\u2248')))} tailwindcss ${pc.blue(`v${getVersion()}`)}` +} + +export function highlight(file: string) { + return `${pc.dim(pc.blue('`'))}${pc.blue(file)}${pc.dim(pc.blue('`'))}` +} + +/** + * Convert an `absolute` path to a `relative` path from the current working + * directory. + */ +export function relative( + to: string, + from = process.cwd(), + { preferAbsoluteIfShorter = true } = {}, +) { + let result = path.relative(from, to) + if (!result.startsWith('..')) { + result = `.${path.sep}${result}` + } + + if (preferAbsoluteIfShorter && result.length > to.length) { + return to + } + + return result +} + +/** + * Wrap `text` into multiple lines based on the `width`. + */ +export function wordWrap(text: string, width: number) { + let words = text.split(' ') + let lines = [] + + let line = '' + let lineLength = 0 + for (let word of words) { + let wordLength = stripVTControlCharacters(word).length + + if (lineLength + wordLength + 1 > width) { + lines.push(line) + line = '' + lineLength = 0 + } + + line += (lineLength ? ' ' : '') + word + lineLength += wordLength + (lineLength ? 1 : 0) + } + + if (lineLength) { + lines.push(line) + } + + return lines +} + +/** + * Format a duration in nanoseconds to a more human readable format. + */ +export function formatDuration(ns: bigint) { + let formatted = formatNanoseconds(ns) + + if (ns <= 50 * 1e6) return pc.green(formatted) + if (ns <= 300 * 1e6) return pc.blue(formatted) + if (ns <= 1000 * 1e6) return pc.yellow(formatted) + + return pc.red(formatted) +} + +export function indent(value: string, offset = 0) { + return `${' '.repeat(offset + UI.indent)}${value}` +} + +// Rust inspired functions to print to the console: + +export function eprintln(value = '') { + process.stderr.write(`${value}\n`) +} + +export function println(value = '') { + process.stdout.write(`${value}\n`) +} + +function getVersion(): string { + if (typeof globalThis.__tw_version === 'string') { + return globalThis.__tw_version + } + let { version } = JSON.parse(fs.readFileSync(resolve('tailwindcss/package.json'), 'utf-8')) + return version +} diff --git a/packages/@tailwindcss-upgrade/src/utils/resolve.ts b/packages/@tailwindcss-upgrade/src/utils/resolve.ts new file mode 100644 index 000000000000..e9197e8741ab --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/resolve.ts @@ -0,0 +1,32 @@ +import EnhancedResolve from 'enhanced-resolve' +import fs from 'node:fs' +import { createRequire } from 'node:module' + +const localResolve = createRequire(import.meta.url).resolve +export function resolve(id: string) { + if (typeof globalThis.__tw_resolve === 'function') { + let resolved = globalThis.__tw_resolve(id) + if (resolved) { + return resolved + } + } + return localResolve(id) +} + +const resolver = EnhancedResolve.ResolverFactory.createResolver({ + fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), + useSyncFileSystemCalls: true, + extensions: ['.css'], + mainFields: ['style'], + conditionNames: ['style'], +}) +export function resolveCssId(id: string, base: string) { + if (typeof globalThis.__tw_resolve === 'function') { + let resolved = globalThis.__tw_resolve(id, base) + if (resolved) { + return resolved + } + } + + return resolver.resolveSync({}, base, id) +} From 741e5ac35d8b2c0000e817cebd9d9ad05f11cb6d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 12:42:02 +0200 Subject: [PATCH 04/12] migrate passed in files (or discover files) --- packages/@tailwindcss-upgrade/package.json | 4 +- packages/@tailwindcss-upgrade/src/index.ts | 47 ++++++++++++++++++-- packages/@tailwindcss-upgrade/src/migrate.ts | 18 -------- packages/@tailwindcss-upgrade/tsup.config.ts | 2 +- 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index 2f0791088c51..95c3ed453dd1 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -30,10 +30,12 @@ }, "dependencies": { "enhanced-resolve": "^5.17.1", + "fast-glob": "^3.3.2", "mri": "^1.2.0", "picocolors": "^1.0.1", "postcss": "^8.4.41", - "postcss-import": "^16.1.0" + "postcss-import": "^16.1.0", + "tailwindcss": "workspace:^" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 7dd39e8d6917..e3aca280a6ea 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import fastGlob from 'fast-glob' import { execSync } from 'node:child_process' import path from 'node:path' import pc from 'picocolors' @@ -23,8 +24,6 @@ if (flags['--help']) { process.exit(0) } -const file = flags._[0] - async function run() { eprintln(header()) eprintln() @@ -45,9 +44,49 @@ async function run() { } } - await migrate(path.resolve(process.cwd(), file)) + // Use provided files + let files = flags._.map((file) => path.resolve(process.cwd(), file)) + + // Discover CSS files in case no files were provided + if (files.length === 0) { + wordWrap( + 'No files provided. Searching for CSS files in the current directory and its subdirectories…', + process.stderr.columns - 5 - 4, + ).map((line) => eprintln(`${pc.blue('\u2502')} ${line}`)) + eprintln() + + files = await fastGlob(['**/*.css'], { + absolute: true, + ignore: ['**/node_modules', '**/vendor'], + }) + } + + // Ensure we are only dealing with CSS files + files = files.filter((file) => file.endsWith('.css')) + + // Migrate each file + await Promise.allSettled(files.map((file) => migrate(file))) + + // Figure out if we made any changes + let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) + if (stdout.trim()) { + wordWrap( + 'Migration complete. Verify the changes and commit them to your repository.', + process.stderr.columns - 5 - 4, + ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) + eprintln() + } else { + wordWrap( + 'Migration complete. No changes were made to your repository.', + process.stderr.columns - 5 - 4, + ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) + eprintln() + } } run() .then(() => process.exit(0)) - .catch(() => process.exit(1)) + .catch((err) => { + console.error(err) + process.exit(1) + }) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index d0cf71d12247..68e0d336034a 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,10 +1,7 @@ -import { execSync } from 'node:child_process' import fs from 'node:fs/promises' import path from 'node:path' -import pc from 'picocolors' import postcss from 'postcss' import { migrateAtApply } from './codemods/migrate-at-apply' -import { eprintln, wordWrap } from './utils/renderer' export async function migrateContents(contents: string, file?: string) { return postcss() @@ -18,19 +15,4 @@ export async function migrate(file: string) { let contents = await fs.readFile(fullPath, 'utf-8') await fs.writeFile(fullPath, await migrateContents(contents, fullPath)) - - let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) - if (stdout.trim()) { - wordWrap( - 'Migration complete. Verify the changes and commit them to your repository.', - process.stderr.columns - 5 - 4, - ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) - eprintln() - } else { - wordWrap( - 'Migration complete. No changes were made to your repository.', - process.stderr.columns - 5 - 4, - ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) - eprintln() - } } diff --git a/packages/@tailwindcss-upgrade/tsup.config.ts b/packages/@tailwindcss-upgrade/tsup.config.ts index 915692c12afe..7d82eee2c882 100644 --- a/packages/@tailwindcss-upgrade/tsup.config.ts +++ b/packages/@tailwindcss-upgrade/tsup.config.ts @@ -1,4 +1,4 @@ -import {defineConfig} from 'tsup' +import { defineConfig } from 'tsup' export default defineConfig({ format: ['esm'], From 48157366072197475f187a51aeb5657edd4252e5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 12:50:52 +0200 Subject: [PATCH 05/12] add `error`, `info`, `success`, and `warn` helpers --- packages/@tailwindcss-upgrade/src/index.ts | 32 +++++-------------- .../src/utils/renderer.ts | 28 ++++++++++++++++ 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index e3aca280a6ea..08ffe7873c42 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -3,11 +3,10 @@ import fastGlob from 'fast-glob' import { execSync } from 'node:child_process' import path from 'node:path' -import pc from 'picocolors' import { help } from './commands/help' import { migrate } from './migrate' import { args, type Arg } from './utils/args' -import { eprintln, header, highlight, wordWrap } from './utils/renderer' +import { eprintln, error, header, highlight, info, success } from './utils/renderer' const options = { '--help': { type: 'boolean', description: 'Display usage information', alias: '-h' }, @@ -31,15 +30,10 @@ async function run() { if (!flags['--force']) { let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) if (stdout.trim()) { - wordWrap( - 'Git directory is not clean. Please stash or commit your changes before migrating.', - process.stderr.columns - 5 - 4, - ).map((line) => eprintln(`${pc.red('\u2502')} ${line}`)) - wordWrap( + 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.stderr.columns - 2 - 4, - ).map((line) => eprintln(`${pc.red('\u2502')} ${line}`)) - eprintln() + ) process.exit(1) } } @@ -49,11 +43,9 @@ async function run() { // Discover CSS files in case no files were provided if (files.length === 0) { - wordWrap( + info( 'No files provided. Searching for CSS files in the current directory and its subdirectories…', - process.stderr.columns - 5 - 4, - ).map((line) => eprintln(`${pc.blue('\u2502')} ${line}`)) - eprintln() + ) files = await fastGlob(['**/*.css'], { absolute: true, @@ -70,17 +62,9 @@ async function run() { // Figure out if we made any changes let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) if (stdout.trim()) { - wordWrap( - 'Migration complete. Verify the changes and commit them to your repository.', - process.stderr.columns - 5 - 4, - ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) - eprintln() + success('Migration complete. Verify the changes and commit them to your repository.') } else { - wordWrap( - 'Migration complete. No changes were made to your repository.', - process.stderr.columns - 5 - 4, - ).map((line) => eprintln(`${pc.green('\u2502')} ${line}`)) - eprintln() + success('Migration complete. No changes were made to your repository.') } } diff --git a/packages/@tailwindcss-upgrade/src/utils/renderer.ts b/packages/@tailwindcss-upgrade/src/utils/renderer.ts index 6b00985866cb..b9b9245f7b04 100644 --- a/packages/@tailwindcss-upgrade/src/utils/renderer.ts +++ b/packages/@tailwindcss-upgrade/src/utils/renderer.ts @@ -83,6 +83,34 @@ export function indent(value: string, offset = 0) { return `${' '.repeat(offset + UI.indent)}${value}` } +export function success(message: string, print = eprintln) { + wordWrap(message, process.stderr.columns - 3).map((line) => { + return print(`${pc.green('\u2502')} ${line}`) + }) + print() +} + +export function info(message: string, print = eprintln) { + wordWrap(message, process.stderr.columns - 3).map((line) => { + return print(`${pc.blue('\u2502')} ${line}`) + }) + print() +} + +export function error(message: string, print = eprintln) { + wordWrap(message, process.stderr.columns - 3).map((line) => { + return print(`${pc.red('\u2502')} ${line}`) + }) + print() +} + +export function warn(message: string, print = eprintln) { + wordWrap(message, process.stderr.columns - 3).map((line) => { + return print(`${pc.yellow('\u2502')} ${line}`) + }) + print() +} + // Rust inspired functions to print to the console: export function eprintln(value = '') { From 44b500929b9b4c7e4f2eb439f93f6842cec5f6f5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 13:22:35 +0200 Subject: [PATCH 06/12] sync pnpm-lock.yaml --- pnpm-lock.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08d91bab8c94..06f8fe05d3ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,40 @@ importers: specifier: ^1.25.1 version: 1.26.0 + packages/@tailwindcss-upgrade: + dependencies: + enhanced-resolve: + specifier: ^5.17.1 + version: 5.17.1 + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 + mri: + specifier: ^1.2.0 + version: 1.2.0 + picocolors: + specifier: ^1.0.1 + version: 1.0.1 + postcss: + specifier: ^8.4.41 + version: 8.4.41 + postcss-import: + specifier: ^16.1.0 + version: 16.1.0(postcss@8.4.41) + tailwindcss: + specifier: workspace:^ + version: link:../tailwindcss + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.14.13 + '@types/postcss-import': + specifier: ^14.0.3 + version: 14.0.3 + dedent: + specifier: 1.5.3 + version: 1.5.3 + packages/@tailwindcss-vite: dependencies: '@tailwindcss/node': From da424c0b1c7e9fe325d09836d412eafca8607e7b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 15:09:21 +0200 Subject: [PATCH 07/12] add integration test for the `@apply` codemod --- integrations/cli/codemods.test.ts | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 integrations/cli/codemods.test.ts diff --git a/integrations/cli/codemods.test.ts b/integrations/cli/codemods.test.ts new file mode 100644 index 000000000000..0c2cb46e0b79 --- /dev/null +++ b/integrations/cli/codemods.test.ts @@ -0,0 +1,52 @@ +import { css, json, test } from '../utils' + +test( + 'migrate @apply', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/index.css': css` + @import 'tailwindcss'; + + .a { + @apply flex; + } + + .b { + @apply !flex; + } + + .c { + @apply !flex flex-col! items-center !important; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade') + + await fs.expectFileToContain( + 'src/index.css', + css` + .a { + @apply flex; + } + + .b { + @apply flex!; + } + + .c { + @apply flex! flex-col! items-center!; + } + `, + ) + }, +) From eaecae0e633a860e16f703f34d25ef05ef4964d9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 15:09:58 +0200 Subject: [PATCH 08/12] safely check whether we are in a git directory --- packages/@tailwindcss-upgrade/src/index.ts | 8 +++----- packages/@tailwindcss-upgrade/src/utils/git.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/utils/git.ts diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 08ffe7873c42..af114f191a33 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node import fastGlob from 'fast-glob' -import { execSync } from 'node:child_process' import path from 'node:path' import { help } from './commands/help' import { migrate } from './migrate' import { args, type Arg } from './utils/args' +import { isRepoDirty } from './utils/git' import { eprintln, error, header, highlight, info, success } from './utils/renderer' const options = { @@ -28,8 +28,7 @@ async function run() { eprintln() if (!flags['--force']) { - let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) - if (stdout.trim()) { + 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.`, @@ -60,8 +59,7 @@ async function run() { await Promise.allSettled(files.map((file) => migrate(file))) // Figure out if we made any changes - let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) - if (stdout.trim()) { + if (isRepoDirty()) { success('Migration complete. Verify the changes and commit them to your repository.') } else { success('Migration complete. No changes were made to your repository.') diff --git a/packages/@tailwindcss-upgrade/src/utils/git.ts b/packages/@tailwindcss-upgrade/src/utils/git.ts new file mode 100644 index 000000000000..d716fbb8a4c5 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/git.ts @@ -0,0 +1,17 @@ +import { execSync } from 'node:child_process' + +export function isRepoDirty() { + try { + let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) + return stdout.trim() !== '' + } catch (error) { + // If it's not a git repository we don't know if it's dirty or not. But we + // also don't want to block the migration. Maybe we can still fail and + // require a `--force` flag? + if (error?.toString?.().includes('not a git repository')) { + return false + } + + return true + } +} From c6b573a70225262ef8923ffcd7f77f234faca70a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Sep 2024 15:10:34 +0200 Subject: [PATCH 09/12] use binary directly --- packages/@tailwindcss-upgrade/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index 95c3ed453dd1..f828da3f1c46 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -15,9 +15,7 @@ "build": "tsup-node", "dev": "pnpm run build -- --watch" }, - "bin": { - "tailwindcss-upgrade": "./dist/index.mjs" - }, + "bin": "./dist/index.mjs", "exports": { "./package.json": "./package.json" }, From 330dbff6d70d29ea7a6579b2336170dae15b5b32 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 18 Sep 2024 15:27:05 +0200 Subject: [PATCH 10/12] rename `codemods.test.ts` -> `upgrade.test.ts` --- integrations/cli/{codemods.test.ts => upgrade.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename integrations/cli/{codemods.test.ts => upgrade.test.ts} (100%) diff --git a/integrations/cli/codemods.test.ts b/integrations/cli/upgrade.test.ts similarity index 100% rename from integrations/cli/codemods.test.ts rename to integrations/cli/upgrade.test.ts From 484b21c9d2e1af06c52113e2aea6172ad6f75979 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 18 Sep 2024 16:05:39 +0200 Subject: [PATCH 11/12] use globby instead of fast-glob Globby, created by Sindresorhus, is based on fast-glob but with additional features. One of those features is the `gitignore` feature which respects your `.gitignore` file, which is exactly what we need. See: https://github.com/sindresorhus/globby#gitignore --- packages/@tailwindcss-upgrade/package.json | 2 +- packages/@tailwindcss-upgrade/src/index.ts | 6 +- pnpm-lock.yaml | 65 ++++++++++++++++++++-- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index f828da3f1c46..23a3dc3c465f 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "enhanced-resolve": "^5.17.1", - "fast-glob": "^3.3.2", + "globby": "^14.0.2", "mri": "^1.2.0", "picocolors": "^1.0.1", "postcss": "^8.4.41", diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index af114f191a33..d3243a199968 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import fastGlob from 'fast-glob' +import { globby } from 'globby' import path from 'node:path' import { help } from './commands/help' import { migrate } from './migrate' @@ -46,9 +46,9 @@ async function run() { 'No files provided. Searching for CSS files in the current directory and its subdirectories…', ) - files = await fastGlob(['**/*.css'], { + files = await globby(['**/*.css'], { absolute: true, - ignore: ['**/node_modules', '**/vendor'], + gitignore: true, }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06f8fe05d3ee..60e19c541d07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,9 +286,9 @@ importers: enhanced-resolve: specifier: ^5.17.1 version: 5.17.1 - fast-glob: - specifier: ^3.3.2 - version: 3.3.2 + globby: + specifier: ^14.0.2 + version: 14.0.2 mri: specifier: ^1.2.0 version: 1.2.0 @@ -1055,6 +1055,7 @@ packages: '@parcel/watcher-darwin-arm64@2.4.2-alpha.0': resolution: {integrity: sha512-2xH4Ve7OKjIh+4YRfTN3HGJa2W8KTPLOALHZj5fxcbTPwaVxdpIRItDrcikUx2u3AzGAFme7F+AZZXHnf0F15Q==} engines: {node: '>= 10.0.0'} + cpu: [arm64] os: [darwin] '@parcel/watcher-darwin-x64@2.4.1': @@ -1066,6 +1067,7 @@ packages: '@parcel/watcher-darwin-x64@2.4.2-alpha.0': resolution: {integrity: sha512-xtjmXUH4YZVah5+7Q0nb+fpRP5qZn9cFfuPuZ4k77UfUGVwhacgZyIRQgIOwMP3GkgW4TsrKQaw1KIe7L1ZqcQ==} engines: {node: '>= 10.0.0'} + cpu: [x64] os: [darwin] '@parcel/watcher-freebsd-x64@2.4.1': @@ -1089,6 +1091,7 @@ packages: '@parcel/watcher-linux-arm64-glibc@2.4.2-alpha.0': resolution: {integrity: sha512-vIIOcZf+fgsRReIK3Fw0WINvGo9UwiXfisnqYRzfpNByRZvkEPkGTIVe8iiDp72NhPTVmwIvBqM6yKDzIaw8GQ==} engines: {node: '>= 10.0.0'} + cpu: [arm64] os: [linux] '@parcel/watcher-linux-arm64-musl@2.4.1': @@ -1100,6 +1103,7 @@ packages: '@parcel/watcher-linux-arm64-musl@2.4.2-alpha.0': resolution: {integrity: sha512-gXqEAoLG9bBCbQNUgqjSOxHcjpmCZmYT9M8UvrdTMgMYgXgiWcR8igKlPRd40mCIRZSkMpN2ScSy2WjQ0bQZnQ==} engines: {node: '>= 10.0.0'} + cpu: [arm64] os: [linux] '@parcel/watcher-linux-x64-glibc@2.4.1': @@ -1111,6 +1115,7 @@ packages: '@parcel/watcher-linux-x64-glibc@2.4.2-alpha.0': resolution: {integrity: sha512-/WJJ3Y46ubwQW+Z+mzpzK3pvqn/AT7MA63NB0+k9GTLNxJQZNREensMtpJ/FJ+LVIiraEHTY22KQrsx9+DeNbw==} engines: {node: '>= 10.0.0'} + cpu: [x64] os: [linux] '@parcel/watcher-linux-x64-musl@2.4.1': @@ -1122,6 +1127,7 @@ packages: '@parcel/watcher-linux-x64-musl@2.4.2-alpha.0': resolution: {integrity: sha512-1dz4fTM5HaANk3RSRmdhALT+bNqTHawVDL1D77HwV/FuF/kSjlM3rGrJuFaCKwQ5E8CInHCcobqMN8Jh8LYaRg==} engines: {node: '>= 10.0.0'} + cpu: [x64] os: [linux] '@parcel/watcher-win32-arm64@2.4.1': @@ -1145,6 +1151,7 @@ packages: '@parcel/watcher-win32-x64@2.4.2-alpha.0': resolution: {integrity: sha512-U2abMKF7JUiIxQkos19AvTLFcnl2Xn8yIW1kzu+7B0Lux4Gkuu/BUDBroaM1s6+hwgK63NOLq9itX2Y3GwUThg==} engines: {node: '>= 10.0.0'} + cpu: [x64] os: [win32] '@parcel/watcher@2.4.1': @@ -1243,6 +1250,10 @@ packages: '@rushstack/eslint-patch@1.10.4': resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==} + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + '@swc/helpers@0.5.2': resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} @@ -1476,11 +1487,13 @@ packages: bun@1.1.22: resolution: {integrity: sha512-G2HCPhzhjDc2jEDkZsO9vwPlpHrTm7a8UVwx9oNS5bZqo5OcSK5GPuWYDWjj7+37bRk5OVLfeIvUMtSrbKeIjQ==} + cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true bun@1.1.26: resolution: {integrity: sha512-dWSewAqE7sVbYmflJxgG47dW4vmsbar7VAnQ4ao45y3ulr3n7CwdsMLFnzd28jhPRtF+rsaVK2y4OLIkP3OD4A==} + cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true @@ -1964,6 +1977,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globby@14.0.2: + resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==} + engines: {node: '>=18'} + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -2243,11 +2260,13 @@ packages: lightningcss-darwin-arm64@1.26.0: resolution: {integrity: sha512-n4TIvHO1NY1ondKFYpL2ZX0bcC2y6yjXMD6JfyizgR8BCFNEeArINDzEaeqlfX9bXz73Bpz/Ow0nu+1qiDrBKg==} engines: {node: '>= 12.0.0'} + cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.26.0: resolution: {integrity: sha512-Rf9HuHIDi1R6/zgBkJh25SiJHF+dm9axUZW/0UoYCW1/8HV0gMI0blARhH4z+REmWiU1yYT/KyNF3h7tHyRXUg==} engines: {node: '>= 12.0.0'} + cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.26.0: @@ -2265,21 +2284,25 @@ packages: lightningcss-linux-arm64-gnu@1.26.0: resolution: {integrity: sha512-iJmZM7fUyVjH+POtdiCtExG+67TtPUTer7K/5A8DIfmPfrmeGvzfRyBltGhQz13Wi15K1lf2cPYoRaRh6vcwNA==} engines: {node: '>= 12.0.0'} + cpu: [arm64] os: [linux] lightningcss-linux-arm64-musl@1.26.0: resolution: {integrity: sha512-XxoEL++tTkyuvu+wq/QS8bwyTXZv2y5XYCMcWL45b8XwkiS8eEEEej9BkMGSRwxa5J4K+LDeIhLrS23CpQyfig==} engines: {node: '>= 12.0.0'} + cpu: [arm64] os: [linux] lightningcss-linux-x64-gnu@1.26.0: resolution: {integrity: sha512-1dkTfZQAYLj8MUSkd6L/+TWTG8V6Kfrzfa0T1fSlXCXQHrt1HC1/UepXHtKHDt/9yFwyoeayivxXAsApVxn6zA==} engines: {node: '>= 12.0.0'} + cpu: [x64] os: [linux] lightningcss-linux-x64-musl@1.26.0: resolution: {integrity: sha512-yX3Rk9m00JGCUzuUhFEojY+jf/6zHs3XU8S8Vk+FRbnr4St7cjyMXdNjuA2LjiT8e7j8xHRCH8hyZ4H/btRE4A==} engines: {node: '>= 12.0.0'} + cpu: [x64] os: [linux] lightningcss-win32-arm64-msvc@1.26.0: @@ -2291,6 +2314,7 @@ packages: lightningcss-win32-x64-msvc@1.26.0: resolution: {integrity: sha512-pYS3EyGP3JRhfqEFYmfFDiZ9/pVNfy8jVIYtrx9TVNusVyDK3gpW1w/rbvroQ4bDJi7grdUtyrYU6V2xkY/bBw==} engines: {node: '>= 12.0.0'} + cpu: [x64] os: [win32] lightningcss@1.26.0: @@ -2528,6 +2552,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + path-type@5.0.0: + resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==} + engines: {node: '>=12'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -2758,6 +2786,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -3034,6 +3066,10 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + update-browserslist-db@1.1.0: resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true @@ -3729,6 +3765,8 @@ snapshots: '@rushstack/eslint-patch@1.10.4': {} + '@sindresorhus/merge-streams@2.3.0': {} + '@swc/helpers@0.5.2': dependencies: tslib: 2.6.3 @@ -4404,7 +4442,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -4428,7 +4466,7 @@ snapshots: enhanced-resolve: 5.17.1 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.6 is-core-module: 2.15.0 @@ -4450,7 +4488,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -4770,6 +4808,15 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globby@14.0.2: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.2 + ignore: 5.3.1 + path-type: 5.0.0 + slash: 5.1.0 + unicorn-magic: 0.1.0 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -5277,6 +5324,8 @@ snapshots: path-type@4.0.0: {} + path-type@5.0.0: {} + pathe@1.1.2: {} pathval@2.0.0: {} @@ -5507,6 +5556,8 @@ snapshots: slash@3.0.0: {} + slash@5.1.0: {} + source-map-js@1.2.0: {} source-map-support@0.5.21: @@ -5793,6 +5844,8 @@ snapshots: undici-types@5.26.5: {} + unicorn-magic@0.1.0: {} + update-browserslist-db@1.1.0(browserslist@4.23.2): dependencies: browserslist: 4.23.2 From 1453b56d75201bd91e02564132bcbe730dcebece Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 18 Sep 2024 16:27:30 +0200 Subject: [PATCH 12/12] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6fe4a6aa45..053842c8120a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add support for `aria`, `supports`, and `data` variants defined in JS config files ([#14407](https://github.com/tailwindlabs/tailwindcss/pull/14407)) +- Add `@tailwindcss/upgrade` tooling ([#14434](https://github.com/tailwindlabs/tailwindcss/pull/14434)) ### Fixed