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 @@
+
+
+
+
+
+
+
+
+
+
+
+ A utility-first CSS framework for rapidly building custom user interfaces.
+
+
+
+
+
+
+
+
+
+---
+
+## 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':