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..95c3ed453dd1 --- /dev/null +++ b/packages/@tailwindcss-upgrade/package.json @@ -0,0 +1,45 @@ +{ + "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" + }, + "bin": { + "tailwindcss-upgrade": "./dist/index.mjs" + }, + "exports": { + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "provenance": true, + "access": "public" + }, + "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", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "@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/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.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 new file mode 100644 index 000000000000..19513d1cbe54 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -0,0 +1,74 @@ +#!/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 { eprintln, error, header, highlight, info, success } from './utils/renderer' + +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) +} + +async function run() { + eprintln(header()) + eprintln() + + if (!flags['--force']) { + let stdout = execSync('git status --porcelain', { encoding: 'utf-8' }) + if (stdout.trim()) { + error('Git directory is not clean. Please stash or commit your changes before migrating.') + info(`You may use the ${highlight('--force')} flag to override this safety check.`) + process.exit(1) + } + } + + // 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) { + info( + 'No files provided. Searching for CSS files in the current directory and its subdirectories…', + ) + + 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()) { + success('Migration complete. Verify the changes and commit them to your repository.') + } else { + success('Migration complete. No changes were made to your repository.') + } +} + +run() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err) + 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..68e0d336034a --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import postcss from 'postcss' +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)) +} 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..b9b9245f7b04 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/renderer.ts @@ -0,0 +1,130 @@ +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}` +} + +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 = '') { + 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) +} 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..7d82eee2c882 --- /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'], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08d91bab8c94..ba22273e2f77 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': @@ -391,7 +425,7 @@ importers: version: link:../../packages/@tailwindcss-vite '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.3.1(vite@5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6)) + version: 4.3.1(vite@5.4.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6)) react: specifier: ^18.3.1 version: 18.3.1 @@ -413,10 +447,10 @@ importers: version: 1.1.22 vite: specifier: 'catalog:' - version: 5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) + version: 5.4.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) vite-plugin-handlebars: specifier: ^2.0.0 - version: 2.0.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) + version: 2.0.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) packages: @@ -1021,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': @@ -1032,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': @@ -1055,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': @@ -1066,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': @@ -1077,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': @@ -1088,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': @@ -1111,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': @@ -1239,6 +1280,9 @@ packages: '@types/node@20.14.13': resolution: {integrity: sha512-+bHoGiZb8UiQ0+WEtmph2IWQCjIqg8MDZMAV+ppRRhUZnquF5mQkP/9vpSwJClEiSM/C7fZZExPzfU0vJTyp8w==} + '@types/node@22.5.4': + resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==} + '@types/postcss-import@14.0.3': resolution: {integrity: sha512-raZhRVTf6Vw5+QbmQ7LOHSDML71A5rj4+EqDzAbrZPfxfoGzFxMHRCq16VlddGIZpHELw0BG4G0YE2ANkdZiIQ==} @@ -1442,11 +1486,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 @@ -2209,11 +2255,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: @@ -2231,21 +2279,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: @@ -2257,6 +2309,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: @@ -3000,6 +3053,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + update-browserslist-db@1.1.0: resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true @@ -3736,6 +3792,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@22.5.4': + dependencies: + undici-types: 6.19.8 + '@types/postcss-import@14.0.3': dependencies: postcss: 8.4.41 @@ -3753,7 +3813,7 @@ snapshots: '@types/ws@8.5.12': dependencies: - '@types/node': 20.14.13 + '@types/node': 22.5.4 '@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4)': dependencies: @@ -3797,14 +3857,14 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-react@4.3.1(vite@5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6))': + '@vitejs/plugin-react@4.3.1(vite@5.4.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6))': dependencies: '@babel/core': 7.25.2 '@babel/plugin-transform-react-jsx-self': 7.24.7(@babel/core@7.25.2) '@babel/plugin-transform-react-jsx-source': 7.24.7(@babel/core@7.25.2) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) + vite: 5.4.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) transitivePeerDependencies: - supports-color @@ -4370,7 +4430,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) @@ -4394,7 +4454,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 @@ -4416,7 +4476,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 @@ -5759,6 +5819,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.19.8: {} + update-browserslist-db@1.1.0(browserslist@4.23.2): dependencies: browserslist: 4.23.2 @@ -5787,10 +5849,10 @@ snapshots: - supports-color - terser - vite-plugin-handlebars@2.0.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6): + vite-plugin-handlebars@2.0.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6): dependencies: handlebars: 4.7.8 - vite: 5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) + vite: 5.4.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6) transitivePeerDependencies: - '@types/node' - less @@ -5812,6 +5874,17 @@ snapshots: lightningcss: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4) terser: 5.31.6 + vite@5.4.0(@types/node@22.5.4)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.41 + rollup: 4.20.0 + optionalDependencies: + '@types/node': 22.5.4 + fsevents: 2.3.3 + lightningcss: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4) + terser: 5.31.6 + vitest@2.0.5(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6): dependencies: '@ampproject/remapping': 2.3.0