diff --git a/CHANGELOG.md b/CHANGELOG.md index d95be2ff6..952d5ea12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for `aria`, `supports`, and `data` variants defined in JS config files ([#14407](https://github.com/tailwindlabs/tailwindcss/pull/14407)) - Add `@tailwindcss/upgrade` tooling ([#14434](https://github.com/tailwindlabs/tailwindcss/pull/14434)) +- Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411)) ### Added diff --git a/integrations/cli/upgrade.test.ts b/integrations/cli/upgrade.test.ts index 0c2cb46e0..60eeb06c5 100644 --- a/integrations/cli/upgrade.test.ts +++ b/integrations/cli/upgrade.test.ts @@ -50,3 +50,29 @@ test( ) }, ) + +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/src/codemods/migrate-tailwind-directives.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts new file mode 100644 index 000000000..f59fef01e --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts @@ -0,0 +1,214 @@ +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([ + [ + // The default order + css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + css` + @import 'tailwindcss'; + `, + ], + + // @tailwind components moved, but has no effect in v4. Therefore `base` and + // `utilities` are still in the correct order. + [ + css` + @tailwind base; + @tailwind utilities; + @tailwind components; + `, + css` + @import 'tailwindcss'; + `, + ], + + // Same as previous comment + [ + css` + @tailwind components; + @tailwind base; + @tailwind utilities; + `, + css` + @import 'tailwindcss'; + `, + ], + + // `base` and `utilities` swapped order, thus the `@layer` directives are + // needed. The `components` directive is still ignored. + [ + css` + @tailwind components; + @tailwind utilities; + @tailwind base; + `, + css` + @layer theme, components, utilities, base; + @import 'tailwindcss'; + `, + ], + [ + css` + @tailwind utilities; + @tailwind base; + @tailwind components; + `, + css` + @layer theme, components, utilities, base; + @import 'tailwindcss'; + `, + ], + [ + css` + @tailwind utilities; + @tailwind components; + @tailwind base; + `, + css` + @layer theme, components, utilities, base; + @import 'tailwindcss'; + `, + ], +])( + 'should migrate the default directives (but in different order) to a single import, order %#', + async (input, expected) => { + expect(await migrate(input)).toEqual(expected) + }, +) + +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 000000000..cb47714a0 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts @@ -0,0 +1,91 @@ +import { AtRule, type Plugin, type Root } from 'postcss' + +const DEFAULT_LAYER_ORDER = ['theme', 'base', 'components', 'utilities'] + +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 + + let layerOrder: string[] = [] + + root.walkAtRules((node) => { + // Track old imports and directives + if ( + (node.name === 'tailwind' && node.params === 'base') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']$/)) + ) { + layerOrder.push('base') + baseNode = node + node.remove() + } else if ( + (node.name === 'tailwind' && node.params === 'utilities') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']$/)) + ) { + layerOrder.push('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)" })) + } + } + + // Insert `@layer …;` at the top when the order in the CSS was different + // from the default. + { + // Determine if the order is different from the default. + let sortedLayerOrder = layerOrder.toSorted((a, z) => { + return DEFAULT_LAYER_ORDER.indexOf(a) - DEFAULT_LAYER_ORDER.indexOf(z) + }) + + if (layerOrder.some((layer, index) => layer !== sortedLayerOrder[index])) { + // Create a new `@layer` rule with the sorted order. + let newLayerOrder = DEFAULT_LAYER_ORDER.toSorted((a, z) => { + return layerOrder.indexOf(a) - layerOrder.indexOf(z) + }) + root.prepend({ name: 'layer', params: newLayerOrder.join(', ') }) + } + } + } + + return { + postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives', + Once: migrate, + } +} diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 68e0d3360..db45f099c 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -2,10 +2,12 @@ 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(migrateAtApply()) + .use(migrateTailwindDirectives()) .process(contents, { from: file }) .then((result) => result.css) }