diff --git a/integrations/cli/codemods.test.ts b/integrations/cli/codemods.test.ts new file mode 100644 index 000000000000..60eeb06c563f --- /dev/null +++ b/integrations/cli/codemods.test.ts @@ -0,0 +1,78 @@ +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!; + } + `, + ) + }, +) + +test( + 'migrate @tailwind directives', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/index.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade') + + await fs.expectFileToContain('src/index.css', css` @import 'tailwindcss'; `) + }, +) 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..f828da3f1c46 --- /dev/null +++ b/packages/@tailwindcss-upgrade/package.json @@ -0,0 +1,43 @@ +{ + "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": "./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/codemods/migrate-tailwind-directives.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts new file mode 100644 index 000000000000..3731d7716809 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts @@ -0,0 +1,172 @@ +import dedent from 'dedent' +import postcss from 'postcss' +import { expect, it } from 'vitest' +import { migrateTailwindDirectives } from './migrate-tailwind-directives' + +const css = dedent + +function migrate(input: string) { + return postcss() + .use(migrateTailwindDirectives()) + .process(input, { from: expect.getState().testPath }) + .then((result) => result.css) +} + +it("should not migrate `@import 'tailwindcss'`", async () => { + expect( + await migrate(css` + @import 'tailwindcss'; + `), + ).toEqual(css` + @import 'tailwindcss'; + `) +}) + +it('should migrate the default @tailwind directives to a single import', async () => { + expect( + await migrate(css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `), + ).toEqual(css` + @import 'tailwindcss'; + `) +}) + +it('should migrate the default @tailwind directives as imports to a single import', async () => { + expect( + await migrate(css` + @import 'tailwindcss/base'; + @import 'tailwindcss/components'; + @import 'tailwindcss/utilities'; + `), + ).toEqual(css` + @import 'tailwindcss'; + `) +}) + +it.each([ + [ + css` + @tailwind components; + @tailwind base; + @tailwind utilities; + `, + ], + [ + css` + @tailwind components; + @tailwind utilities; + @tailwind base; + `, + ], + [ + css` + @tailwind utilities; + @tailwind base; + @tailwind components; + `, + ], + [ + css` + @tailwind utilities; + @tailwind components; + @tailwind base; + `, + ], +])( + 'should migrate the default directives (but in different order) to a single import, order %#', + async (input) => { + expect(await migrate(input)).toEqual(css` + @import 'tailwindcss'; + `) + }, +) + +it('should migrate `@tailwind base` to theme and preflight imports', async () => { + expect( + await migrate(css` + @tailwind base; + `), + ).toEqual(css` + @import 'tailwindcss/theme' layer(theme); + @import 'tailwindcss/preflight' layer(base); + `) +}) + +it('should migrate `@import "tailwindcss/base"` to theme and preflight imports', async () => { + expect( + await migrate(css` + @import 'tailwindcss/base'; + `), + ).toEqual(css` + @import 'tailwindcss/theme' layer(theme); + @import 'tailwindcss/preflight' layer(base); + `) +}) + +it('should migrate `@tailwind utilities` to an import', async () => { + expect( + await migrate(css` + @tailwind utilities; + `), + ).toEqual(css` + @import 'tailwindcss/utilities' layer(utilities); + `) +}) + +it('should migrate `@import "tailwindcss/utilities"` to an import', async () => { + expect( + await migrate(css` + @import 'tailwindcss/utilities'; + `), + ).toEqual(css` + @import 'tailwindcss/utilities' layer(utilities); + `) +}) + +it('should not migrate existing imports using a custom layer', async () => { + expect( + await migrate(css` + @import 'tailwindcss/utilities' layer(my-utilities); + `), + ).toEqual(css` + @import 'tailwindcss/utilities' layer(my-utilities); + `) +}) + +// We don't have a `@layer components` anymore, so omitting it should result +// in the full import as well. Alternatively, we could expand to: +// +// ```css +// @import 'tailwindcss/theme' layer(theme); +// @import 'tailwindcss/preflight' layer(base); +// @import 'tailwindcss/utilities' layer(utilities); +// ``` +it('should migrate `@tailwind base` and `@tailwind utilities` to a single import', async () => { + expect( + await migrate(css` + @tailwind base; + @tailwind utilities; + `), + ).toEqual(css` + @import 'tailwindcss'; + `) +}) + +it('should drop `@tailwind screens;`', async () => { + expect( + await migrate(css` + @tailwind screens; + `), + ).toEqual('') +}) + +it('should drop `@tailwind variants;`', async () => { + expect( + await migrate(css` + @tailwind variants; + `), + ).toEqual('') +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts new file mode 100644 index 000000000000..b7341c4849a4 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts @@ -0,0 +1,68 @@ +import { AtRule, type Plugin, type Root } from 'postcss' + +export function migrateTailwindDirectives(): Plugin { + function migrate(root: Root) { + let baseNode: AtRule | null = null + let utilitiesNode: AtRule | null = null + + let defaultImportNode: AtRule | null = null + let utilitiesImportNode: AtRule | null = null + let preflightImportNode: AtRule | null = null + let themeImportNode: AtRule | null = null + + root.walkAtRules((node) => { + // Track old imports and directives + if ( + (node.name === 'tailwind' && node.params === 'base') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']$/)) + ) { + baseNode = node + node.remove() + } else if ( + (node.name === 'tailwind' && node.params === 'utilities') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']$/)) + ) { + utilitiesNode = node + node.remove() + } + + // Remove directives that are not needed anymore + else if ( + (node.name === 'tailwind' && node.params === 'components') || + (node.name === 'tailwind' && node.params === 'screens') || + (node.name === 'tailwind' && node.params === 'variants') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/components["']$/)) + ) { + node.remove() + } + }) + + // Insert default import if all directives are present + if (baseNode !== null && utilitiesNode !== null) { + if (!defaultImportNode) { + root.prepend(new AtRule({ name: 'import', params: "'tailwindcss'" })) + } + } + + // Insert individual imports if not all directives are present + else if (utilitiesNode !== null) { + if (!utilitiesImportNode) { + root.prepend( + new AtRule({ name: 'import', params: "'tailwindcss/utilities' layer(utilities)" }), + ) + } + } else if (baseNode !== null) { + if (!preflightImportNode) { + root.prepend(new AtRule({ name: 'import', params: "'tailwindcss/preflight' layer(base)" })) + } + if (!themeImportNode) { + root.prepend(new AtRule({ name: 'import', params: "'tailwindcss/theme' layer(theme)" })) + } + } + } + + return { + postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives', + Once: 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..af114f191a33 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import fastGlob from 'fast-glob' +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 = { + '--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']) { + if (isRepoDirty()) { + error('Git directory is not clean. Please stash or commit your changes before migrating.') + info( + `You may use the ${highlight('--force')} flag to silence this warning and perform the migration.`, + ) + process.exit(1) + } + } + + // 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 + 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.') + } +} + +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..481469eb2797 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import postcss from 'postcss' +import { migrateAtApply } from './codemods/migrate-at-apply' +import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' + +export async function migrateContents(contents: string, file?: string) { + return postcss() + .use(migrateTailwindDirectives()) + .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/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 + } +} 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/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts new file mode 100644 index 000000000000..f62d08359737 --- /dev/null +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -0,0 +1,316 @@ +import { toCss, walk, type AstNode } from '../ast' +import type { DesignSystem } from '../design-system' +import type { Theme, ThemeKey } from '../theme' +import { withAlpha } from '../utilities' +import { segment } from '../utils/segment' +import { toKeyPath } from '../utils/to-key-path' +import { applyConfigToTheme } from './apply-config-to-theme' +import { createCompatConfig } from './config/create-compat-config' +import { resolveConfig } from './config/resolve-config' +import type { UserConfig } from './config/types' +import { darkModePlugin } from './dark-mode' +import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api' + +export async function applyCompatibilityHooks({ + designSystem, + ast, + loadPlugin, + loadConfig, + globs, +}: { + designSystem: DesignSystem + ast: AstNode[] + loadPlugin: (path: string) => Promise + loadConfig: (path: string) => Promise + globs: { origin?: string; pattern: string }[] +}) { + let pluginPaths: [string, CssPluginOptions | null][] = [] + let configPaths: string[] = [] + + walk(ast, (node, { parent, replaceWith }) => { + if (node.kind !== 'rule' || node.selector[0] !== '@') return + + // Collect paths from `@plugin` at-rules + if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) { + if (parent !== null) { + throw new Error('`@plugin` cannot be nested.') + } + + let pluginPath = node.selector.slice(9, -1) + if (pluginPath.length === 0) { + throw new Error('`@plugin` must have a path.') + } + + let options: CssPluginOptions = {} + + for (let decl of node.nodes ?? []) { + if (decl.kind !== 'declaration') { + throw new Error( + `Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`, + ) + } + + if (decl.value === undefined) continue + + // Parse the declaration value as a primitive type + // These are the same primitive values supported by JSON + let value: CssPluginOptions[keyof CssPluginOptions] = decl.value + + let parts = segment(value, ',').map((part) => { + part = part.trim() + + if (part === 'null') { + return null + } else if (part === 'true') { + return true + } else if (part === 'false') { + return false + } else if (!Number.isNaN(Number(part))) { + return Number(part) + } else if ( + (part[0] === '"' && part[part.length - 1] === '"') || + (part[0] === "'" && part[part.length - 1] === "'") + ) { + return part.slice(1, -1) + } else if (part[0] === '{' && part[part.length - 1] === '}') { + throw new Error( + `Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`, + ) + } + + return part + }) + + options[decl.property] = parts.length === 1 ? parts[0] : parts + } + + pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null]) + + replaceWith([]) + return + } + + // Collect paths from `@config` at-rules + if (node.selector === '@config' || node.selector.startsWith('@config ')) { + if (node.nodes.length > 0) { + throw new Error('`@config` cannot have a body.') + } + + if (parent !== null) { + throw new Error('`@config` cannot be nested.') + } + + configPaths.push(node.selector.slice(9, -1)) + replaceWith([]) + return + } + }) + + // Override `resolveThemeValue` with a version that is backwards compatible + // with dot notation paths like `colors.red.500`. We could do this by default + // in `resolveThemeValue` but handling it here keeps all backwards + // compatibility concerns localized to our compatibility layer. + let resolveThemeVariableValue = designSystem.resolveThemeValue + + designSystem.resolveThemeValue = function resolveThemeValue(path: string) { + if (path.startsWith('--')) { + return resolveThemeVariableValue(path) + } + + // Extract an eventual modifier from the path. e.g.: + // - "colors.red.500 / 50%" -> "50%" + let lastSlash = path.lastIndexOf('/') + let modifier: string | null = null + if (lastSlash !== -1) { + modifier = path.slice(lastSlash + 1).trim() + path = path.slice(0, lastSlash).trim() as ThemeKey + } + + let themeValue = lookupThemeValue(designSystem.theme, path) + + // Apply the opacity modifier if present + if (modifier && themeValue) { + return withAlpha(themeValue, modifier) + } + + return themeValue + } + + // If there are no plugins or configs registered, we don't need to register + // any additional backwards compatibility hooks. + if (!pluginPaths.length && !configPaths.length) return + + let configs = await Promise.all( + configPaths.map(async (configPath) => ({ + path: configPath, + config: await loadConfig(configPath), + })), + ) + let pluginDetails = await Promise.all( + pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ + path: pluginPath, + plugin: await loadPlugin(pluginPath), + options: pluginOptions, + })), + ) + + let plugins = pluginDetails.map((detail) => { + if (!detail.options) { + return detail.plugin + } + + if ('__isOptionsFunction' in detail.plugin) { + return detail.plugin(detail.options) + } + + throw new Error(`The plugin "${detail.path}" does not accept options`) + }) + + let userConfig = [{ config: { plugins } }, ...configs] + + let resolvedConfig = resolveConfig(designSystem, [ + { config: createCompatConfig(designSystem.theme) }, + ...userConfig, + { config: { plugins: [darkModePlugin] } }, + ]) + + let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig) + + for (let { handler } of resolvedConfig.plugins) { + handler(pluginApi) + } + + // Merge the user-configured theme keys into the design system. The compat + // config would otherwise expand into namespaces like `background-color` which + // core utilities already read from. + applyConfigToTheme(designSystem, userConfig) + + // Replace `resolveThemeValue` with a version that is backwards compatible + // with dot-notation but also aware of any JS theme configurations registered + // by plugins or JS config files. This is significantly slower than just + // upgrading dot-notation keys so we only use this version if plugins or + // config files are actually being used. In the future we may want to optimize + // this further by only doing this if plugins or config files _actually_ + // registered JS config objects. + designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) { + let resolvedValue = pluginApi.theme(path, defaultValue) + + if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { + // When a tuple is returned, return the first element + return resolvedValue[0] + } else if (Array.isArray(resolvedValue)) { + // Arrays get serialized into a comma-separated lists + return resolvedValue.join(', ') + } else if (typeof resolvedValue === 'string') { + // Otherwise only allow string values here, objects (and namespace maps) + // are treated as non-resolved values for the CSS `theme()` function. + return resolvedValue + } + } + + for (let file of resolvedConfig.content.files) { + if ('raw' in file) { + throw new Error( + `Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`, + ) + } + + globs.push({ origin: file.base, pattern: file.pattern }) + } +} + +function toThemeKey(keypath: string[]) { + return ( + keypath + // [1] should move into the nested object tuple. To create the CSS variable + // name for this, we replace it with an empty string that will result in two + // subsequent dashes when joined. + .map((path) => (path === '1' ? '' : path)) + + // Resolve the key path to a CSS variable segment + .map((part) => + part + .replaceAll('.', '_') + .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), + ) + + // Remove the `DEFAULT` key at the end of a path + // We're reading from CSS anyway so it'll be a string + .filter((part, index) => part !== 'DEFAULT' || index !== keypath.length - 1) + .join('-') + ) +} + +function lookupThemeValue(theme: Theme, path: string) { + let baseThemeKey = '--' + toThemeKey(toKeyPath(path)) + + let resolvedValue = theme.get([baseThemeKey as ThemeKey]) + + if (resolvedValue !== null) { + return resolvedValue + } + + for (let [givenKey, upgradeKey] of Object.entries(themeUpgradeKeys)) { + if (!baseThemeKey.startsWith(givenKey)) continue + + let upgradedKey = upgradeKey + baseThemeKey.slice(givenKey.length) + let resolvedValue = theme.get([upgradedKey as ThemeKey]) + + if (resolvedValue !== null) { + return resolvedValue + } + } +} + +let themeUpgradeKeys = { + '--colors': '--color', + '--accent-color': '--color', + '--backdrop-blur': '--blur', + '--backdrop-brightness': '--brightness', + '--backdrop-contrast': '--contrast', + '--backdrop-grayscale': '--grayscale', + '--backdrop-hue-rotate': '--hueRotate', + '--backdrop-invert': '--invert', + '--backdrop-opacity': '--opacity', + '--backdrop-saturate': '--saturate', + '--backdrop-sepia': '--sepia', + '--background-color': '--color', + '--background-opacity': '--opacity', + '--border-color': '--color', + '--border-opacity': '--opacity', + '--border-spacing': '--spacing', + '--box-shadow-color': '--color', + '--caret-color': '--color', + '--divide-color': '--borderColor', + '--divide-opacity': '--borderOpacity', + '--divide-width': '--borderWidth', + '--fill': '--color', + '--flex-basis': '--spacing', + '--gap': '--spacing', + '--gradient-color-stops': '--color', + '--height': '--spacing', + '--inset': '--spacing', + '--margin': '--spacing', + '--max-height': '--spacing', + '--max-width': '--spacing', + '--min-height': '--spacing', + '--min-width': '--spacing', + '--outline-color': '--color', + '--padding': '--spacing', + '--placeholder-color': '--color', + '--placeholder-opacity': '--opacity', + '--ring-color': '--color', + '--ring-offset-color': '--color', + '--ring-opacity': '--opacity', + '--scroll-margin': '--spacing', + '--scroll-padding': '--spacing', + '--space': '--spacing', + '--stroke': '--color', + '--text-color': '--color', + '--text-decoration-color': '--color', + '--text-indent': '--spacing', + '--text-opacity': '--opacity', + '--translate': '--spacing', + '--size': '--spacing', + '--width': '--spacing', +} diff --git a/packages/tailwindcss/src/compat/config/resolve-config.ts b/packages/tailwindcss/src/compat/config/resolve-config.ts index 0add9ccd7ecb..4ea45b83f77a 100644 --- a/packages/tailwindcss/src/compat/config/resolve-config.ts +++ b/packages/tailwindcss/src/compat/config/resolve-config.ts @@ -1,5 +1,5 @@ import type { DesignSystem } from '../../design-system' -import type { PluginWithConfig } from '../../plugin-api' +import type { PluginWithConfig } from '../plugin-api' import { createThemeFn } from '../plugin-functions' import { deepMerge, isPlainObject } from './deep-merge' import { diff --git a/packages/tailwindcss/src/compat/config/types.ts b/packages/tailwindcss/src/compat/config/types.ts index 593930240659..5a194e96e339 100644 --- a/packages/tailwindcss/src/compat/config/types.ts +++ b/packages/tailwindcss/src/compat/config/types.ts @@ -1,4 +1,4 @@ -import type { Plugin, PluginWithConfig } from '../../plugin-api' +import type { Plugin, PluginWithConfig } from '../plugin-api' import type { PluginUtils } from './resolve-config' export type ResolvableTo = T | ((utils: PluginUtils) => T) diff --git a/packages/tailwindcss/src/compat/dark-mode.ts b/packages/tailwindcss/src/compat/dark-mode.ts index 42061a0d674e..0f9bc2cdffe1 100644 --- a/packages/tailwindcss/src/compat/dark-mode.ts +++ b/packages/tailwindcss/src/compat/dark-mode.ts @@ -1,5 +1,5 @@ -import type { PluginAPI } from '../plugin-api' import type { ResolvedConfig } from './config/types' +import type { PluginAPI } from './plugin-api' export function darkModePlugin({ addVariant, config }: PluginAPI) { let darkMode = config('darkMode', null) as ResolvedConfig['darkMode'] diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts similarity index 99% rename from packages/tailwindcss/src/plugin-api.test.ts rename to packages/tailwindcss/src/compat/plugin-api.test.ts index ca16ab97780d..1a11882f35a9 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test, vi } from 'vitest' -import { compile } from '.' -import defaultTheme from './compat/default-theme' -import plugin from './plugin' +import { compile } from '..' +import plugin from '../plugin' +import { optimizeCss } from '../test-utils/run' +import defaultTheme from './default-theme' import type { CssInJs, PluginAPI } from './plugin-api' -import { optimizeCss } from './test-utils/run' const css = String.raw diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts similarity index 62% rename from packages/tailwindcss/src/plugin-api.ts rename to packages/tailwindcss/src/compat/plugin-api.ts index 16f3a31c24bf..69fafbf1bc66 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -1,21 +1,16 @@ -import { substituteAtApply } from './apply' -import { decl, rule, type AstNode } from './ast' -import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' -import { applyConfigToTheme } from './compat/apply-config-to-theme' -import { createCompatConfig } from './compat/config/create-compat-config' -import { resolveConfig } from './compat/config/resolve-config' -import type { ResolvedConfig, UserConfig } from './compat/config/types' -import { darkModePlugin } from './compat/dark-mode' -import { createThemeFn } from './compat/plugin-functions' -import { substituteFunctions } from './css-functions' -import * as CSS from './css-parser' -import type { DesignSystem } from './design-system' -import type { Theme, ThemeKey } from './theme' -import { withAlpha, withNegative } from './utilities' -import { inferDataType } from './utils/infer-data-type' -import { segment } from './utils/segment' -import { toKeyPath } from './utils/to-key-path' -import { substituteAtSlot } from './variants' +import { substituteAtApply } from '../apply' +import { decl, rule, type AstNode } from '../ast' +import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate' +import { substituteFunctions } from '../css-functions' +import * as CSS from '../css-parser' +import type { DesignSystem } from '../design-system' +import { withAlpha, withNegative } from '../utilities' +import { inferDataType } from '../utils/infer-data-type' +import { segment } from '../utils/segment' +import { toKeyPath } from '../utils/to-key-path' +import { substituteAtSlot } from '../variants' +import type { ResolvedConfig, UserConfig } from './config/types' +import { createThemeFn } from './plugin-functions' export type Config = UserConfig export type PluginFn = (api: PluginAPI) => void @@ -82,7 +77,7 @@ export type PluginAPI = { const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ -function buildPluginApi( +export function buildPluginApi( designSystem: DesignSystem, ast: AstNode[], resolvedConfig: ResolvedConfig, @@ -438,229 +433,3 @@ function parseVariantValue(resolved: string | string[], nodes: AstNode[]): AstNo type Primitive = string | number | boolean | null export type CssPluginOptions = Record - -export async function applyCompatibilityHooks({ - designSystem, - ast, - pluginPaths, - loadPlugin, - configPaths, - loadConfig, - globs, -}: { - designSystem: DesignSystem - ast: AstNode[] - pluginPaths: [string, CssPluginOptions | null][] - loadPlugin: (path: string) => Promise - configPaths: string[] - loadConfig: (path: string) => Promise - globs: { origin?: string; pattern: string }[] -}) { - // Override `resolveThemeValue` with a version that is backwards compatible - // with dot notation paths like `colors.red.500`. We could do this by default - // in `resolveThemeValue` but handling it here keeps all backwards - // compatibility concerns localized to our compatibility layer. - let resolveThemeVariableValue = designSystem.resolveThemeValue - - designSystem.resolveThemeValue = function resolveThemeValue(path: string) { - if (path.startsWith('--')) { - return resolveThemeVariableValue(path) - } - - // Extract an eventual modifier from the path. e.g.: - // - "colors.red.500 / 50%" -> "50%" - let lastSlash = path.lastIndexOf('/') - let modifier: string | null = null - if (lastSlash !== -1) { - modifier = path.slice(lastSlash + 1).trim() - path = path.slice(0, lastSlash).trim() as ThemeKey - } - - let themeValue = lookupThemeValue(designSystem.theme, path) - - // Apply the opacity modifier if present - if (modifier && themeValue) { - return withAlpha(themeValue, modifier) - } - - return themeValue - } - - // If there are no plugins or configs registered, we don't need to register - // any additional backwards compatibility hooks. - if (!pluginPaths.length && !configPaths.length) return - - let configs = await Promise.all( - configPaths.map(async (configPath) => ({ - path: configPath, - config: await loadConfig(configPath), - })), - ) - let pluginDetails = await Promise.all( - pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ - path: pluginPath, - plugin: await loadPlugin(pluginPath), - options: pluginOptions, - })), - ) - - let plugins = pluginDetails.map((detail) => { - if (!detail.options) { - return detail.plugin - } - - if ('__isOptionsFunction' in detail.plugin) { - return detail.plugin(detail.options) - } - - throw new Error(`The plugin "${detail.path}" does not accept options`) - }) - - let userConfig = [{ config: { plugins } }, ...configs] - - let resolvedConfig = resolveConfig(designSystem, [ - { config: createCompatConfig(designSystem.theme) }, - ...userConfig, - { config: { plugins: [darkModePlugin] } }, - ]) - - let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig) - - for (let { handler } of resolvedConfig.plugins) { - handler(pluginApi) - } - - // Merge the user-configured theme keys into the design system. The compat - // config would otherwise expand into namespaces like `background-color` which - // core utilities already read from. - applyConfigToTheme(designSystem, userConfig) - - // Replace `resolveThemeValue` with a version that is backwards compatible - // with dot-notation but also aware of any JS theme configurations registered - // by plugins or JS config files. This is significantly slower than just - // upgrading dot-notation keys so we only use this version if plugins or - // config files are actually being used. In the future we may want to optimize - // this further by only doing this if plugins or config files _actually_ - // registered JS config objects. - designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) { - let resolvedValue = pluginApi.theme(path, defaultValue) - - if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { - // When a tuple is returned, return the first element - return resolvedValue[0] - } else if (Array.isArray(resolvedValue)) { - // Arrays get serialized into a comma-separated lists - return resolvedValue.join(', ') - } else if (typeof resolvedValue === 'string') { - // Otherwise only allow string values here, objects (and namespace maps) - // are treated as non-resolved values for the CSS `theme()` function. - return resolvedValue - } - } - - for (let file of resolvedConfig.content.files) { - if ('raw' in file) { - throw new Error( - `Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`, - ) - } - - globs.push({ origin: file.base, pattern: file.pattern }) - } -} - -function toThemeKey(keypath: string[]) { - return ( - keypath - // [1] should move into the nested object tuple. To create the CSS variable - // name for this, we replace it with an empty string that will result in two - // subsequent dashes when joined. - .map((path) => (path === '1' ? '' : path)) - - // Resolve the key path to a CSS variable segment - .map((part) => - part - .replaceAll('.', '_') - .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), - ) - - // Remove the `DEFAULT` key at the end of a path - // We're reading from CSS anyway so it'll be a string - .filter((part, index) => part !== 'DEFAULT' || index !== keypath.length - 1) - .join('-') - ) -} - -function lookupThemeValue(theme: Theme, path: string) { - let baseThemeKey = '--' + toThemeKey(toKeyPath(path)) - - let resolvedValue = theme.get([baseThemeKey as ThemeKey]) - - if (resolvedValue !== null) { - return resolvedValue - } - - for (let [givenKey, upgradeKey] of Object.entries(themeUpgradeKeys)) { - if (!baseThemeKey.startsWith(givenKey)) continue - - let upgradedKey = upgradeKey + baseThemeKey.slice(givenKey.length) - let resolvedValue = theme.get([upgradedKey as ThemeKey]) - - if (resolvedValue !== null) { - return resolvedValue - } - } -} - -let themeUpgradeKeys = { - '--colors': '--color', - '--accent-color': '--color', - '--backdrop-blur': '--blur', - '--backdrop-brightness': '--brightness', - '--backdrop-contrast': '--contrast', - '--backdrop-grayscale': '--grayscale', - '--backdrop-hue-rotate': '--hueRotate', - '--backdrop-invert': '--invert', - '--backdrop-opacity': '--opacity', - '--backdrop-saturate': '--saturate', - '--backdrop-sepia': '--sepia', - '--background-color': '--color', - '--background-opacity': '--opacity', - '--border-color': '--color', - '--border-opacity': '--opacity', - '--border-spacing': '--spacing', - '--box-shadow-color': '--color', - '--caret-color': '--color', - '--divide-color': '--borderColor', - '--divide-opacity': '--borderOpacity', - '--divide-width': '--borderWidth', - '--fill': '--color', - '--flex-basis': '--spacing', - '--gap': '--spacing', - '--gradient-color-stops': '--color', - '--height': '--spacing', - '--inset': '--spacing', - '--margin': '--spacing', - '--max-height': '--spacing', - '--max-width': '--spacing', - '--min-height': '--spacing', - '--min-width': '--spacing', - '--outline-color': '--color', - '--padding': '--spacing', - '--placeholder-color': '--color', - '--placeholder-opacity': '--opacity', - '--ring-color': '--color', - '--ring-offset-color': '--color', - '--ring-opacity': '--opacity', - '--scroll-margin': '--spacing', - '--scroll-padding': '--spacing', - '--space': '--spacing', - '--stroke': '--color', - '--text-color': '--color', - '--text-decoration-color': '--color', - '--text-indent': '--spacing', - '--text-opacity': '--opacity', - '--translate': '--spacing', - '--size': '--spacing', - '--width': '--spacing', -} diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index d4fc573793df..fb226708e689 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -2,8 +2,8 @@ import fs from 'node:fs' import path from 'node:path' import { describe, expect, it, test } from 'vitest' import { compile } from '.' +import type { PluginAPI } from './compat/plugin-api' import plugin from './plugin' -import type { PluginAPI } from './plugin-api' import { compileCss, optimizeCss, run } from './test-utils/run' const css = String.raw diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 277ef12ff819..8e6854ce0dfc 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -1,12 +1,13 @@ import { version } from '../package.json' import { substituteAtApply } from './apply' import { comment, decl, rule, toCss, walk, WalkAction, type Rule } from './ast' +import { applyCompatibilityHooks } from './compat/apply-compat-hooks' import type { UserConfig } from './compat/config/types' +import { type Plugin } from './compat/plugin-api' import { compileCandidates } from './compile' import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './css-functions' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' -import { applyCompatibilityHooks, type CssPluginOptions, type Plugin } from './plugin-api' import { Theme, ThemeOptions } from './theme' import { segment } from './utils/segment' export type Config = UserConfig @@ -50,8 +51,6 @@ async function parseCss( // Find all `@theme` declarations let theme = new Theme() - let pluginPaths: [string, CssPluginOptions | null][] = [] - let configPaths: string[] = [] let customVariants: ((designSystem: DesignSystem) => void)[] = [] let customUtilities: ((designSystem: DesignSystem) => void)[] = [] let firstThemeRule: Rule | null = null @@ -61,81 +60,6 @@ async function parseCss( walk(ast, (node, { parent, replaceWith }) => { if (node.kind !== 'rule') return - // Collect paths from `@plugin` at-rules - if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) { - if (parent !== null) { - throw new Error('`@plugin` cannot be nested.') - } - - let pluginPath = node.selector.slice(9, -1) - if (pluginPath.length === 0) { - throw new Error('`@plugin` must have a path.') - } - - let options: CssPluginOptions = {} - - for (let decl of node.nodes ?? []) { - if (decl.kind !== 'declaration') { - throw new Error( - `Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`, - ) - } - - if (decl.value === undefined) continue - - // Parse the declaration value as a primitive type - // These are the same primitive values supported by JSON - let value: CssPluginOptions[keyof CssPluginOptions] = decl.value - - let parts = segment(value, ',').map((part) => { - part = part.trim() - - if (part === 'null') { - return null - } else if (part === 'true') { - return true - } else if (part === 'false') { - return false - } else if (!Number.isNaN(Number(part))) { - return Number(part) - } else if ( - (part[0] === '"' && part[part.length - 1] === '"') || - (part[0] === "'" && part[part.length - 1] === "'") - ) { - return part.slice(1, -1) - } else if (part[0] === '{' && part[part.length - 1] === '}') { - throw new Error( - `Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`, - ) - } - - return part - }) - - options[decl.property] = parts.length === 1 ? parts[0] : parts - } - - pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null]) - - replaceWith([]) - return - } - - // Collect paths from `@config` at-rules - if (node.selector === '@config' || node.selector.startsWith('@config ')) { - if (node.nodes.length > 0) { - throw new Error('`@config` cannot have a body.') - } - - if (parent !== null) { - throw new Error('`@config` cannot be nested.') - } - - configPaths.push(node.selector.slice(9, -1)) - replaceWith([]) - return - } - // Collect custom `@utility` at-rules if (node.selector.startsWith('@utility ')) { let name = node.selector.slice(9).trim() @@ -310,15 +234,7 @@ async function parseCss( // of random arguments because it really just needs access to "the world" to // do whatever ungodly things it needs to do to make things backwards // compatible without polluting core. - await applyCompatibilityHooks({ - designSystem, - ast, - pluginPaths, - loadPlugin, - configPaths, - loadConfig, - globs, - }) + await applyCompatibilityHooks({ designSystem, ast, loadPlugin, loadConfig, globs }) for (let customVariant of customVariants) { customVariant(designSystem) diff --git a/packages/tailwindcss/src/plugin.ts b/packages/tailwindcss/src/plugin.ts index 63d2ef8c6e2b..c6731eb0f55e 100644 --- a/packages/tailwindcss/src/plugin.ts +++ b/packages/tailwindcss/src/plugin.ts @@ -1,4 +1,4 @@ -import type { Config, PluginFn, PluginWithConfig, PluginWithOptions } from './plugin-api' +import type { Config, PluginFn, PluginWithConfig, PluginWithOptions } from './compat/plugin-api' function createPlugin(handler: PluginFn, config?: Partial): PluginWithConfig { return { 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':