Skip to content

Commit 67d1849

Browse files
authored
Add CSS codemods for migrating @tailwind directives (#14411)
This PR adds CSS codemods for migrating existing `@tailwind` directives to the new alternatives. This PR has the ability to migrate the following cases: --- Typical default usage of `@tailwind` directives in v3. Input: ```css @tailwind base; @tailwind components; @tailwind utilities; ``` Output: ```css @import 'tailwindcss'; ``` --- Similar as above, but always using `@import` instead of `@import` directly. Input: ```css @import 'tailwindcss/base'; @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; ``` Output: ```css @import 'tailwindcss'; ``` --- When you are _only_ using `@tailwind base`: Input: ```css @tailwind base; ``` Output: ```css @import 'tailwindcss/theme' layer(theme); @import 'tailwindcss/preflight' layer(base); ``` --- When you are _only_ using `@tailwind utilities`: Input: ```css @tailwind utilities; ``` Output: ```css @import 'tailwindcss/utilities' layer(utilities); ``` --- If the default order changes (aka, `@tailwind utilities` was defined _before_ `@tailwind base`), then an additional `@layer` will be added to the top to re-define the default order. Input: ```css @tailwind utilities; @tailwind base; ``` Output: ```css @layer theme, components, utilities, base; @import 'tailwindcss'; ``` --- When you are _only_ using `@tailwind base; @tailwind utilities;`: Input: ```css @tailwind base; @tailwind utilities; ``` Output: ```css @import 'tailwindcss'; ``` We currently don't have a concept of `@tailwind components` in v4, so if you are not using `@tailwind components`, we can expand to the default `@import 'tailwindcss';` instead of the individual imports. --- `@tailwind screens` and `@tailwind variants` are not supported/necessary in v4, so we can safely remove them. Input: ```css @tailwind screens; @tailwind variants; ``` Output: ```css ```
1 parent 2ddb715 commit 67d1849

File tree

5 files changed

+334
-0
lines changed

5 files changed

+334
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add support for `aria`, `supports`, and `data` variants defined in JS config files ([#14407](https://github.com/tailwindlabs/tailwindcss/pull/14407))
1313
- Add `@tailwindcss/upgrade` tooling ([#14434](https://github.com/tailwindlabs/tailwindcss/pull/14434))
14+
- Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411))
1415

1516
### Added
1617

integrations/cli/upgrade.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,29 @@ test(
5050
)
5151
},
5252
)
53+
54+
test(
55+
'migrate @tailwind directives',
56+
{
57+
fs: {
58+
'package.json': json`
59+
{
60+
"dependencies": {
61+
"tailwindcss": "workspace:^",
62+
"@tailwindcss/upgrade": "workspace:^"
63+
}
64+
}
65+
`,
66+
'src/index.css': css`
67+
@tailwind base;
68+
@tailwind components;
69+
@tailwind utilities;
70+
`,
71+
},
72+
},
73+
async ({ fs, exec }) => {
74+
await exec('npx @tailwindcss/upgrade')
75+
76+
await fs.expectFileToContain('src/index.css', css` @import 'tailwindcss'; `)
77+
},
78+
)
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import dedent from 'dedent'
2+
import postcss from 'postcss'
3+
import { expect, it } from 'vitest'
4+
import { migrateTailwindDirectives } from './migrate-tailwind-directives'
5+
6+
const css = dedent
7+
8+
function migrate(input: string) {
9+
return postcss()
10+
.use(migrateTailwindDirectives())
11+
.process(input, { from: expect.getState().testPath })
12+
.then((result) => result.css)
13+
}
14+
15+
it("should not migrate `@import 'tailwindcss'`", async () => {
16+
expect(
17+
await migrate(css`
18+
@import 'tailwindcss';
19+
`),
20+
).toEqual(css`
21+
@import 'tailwindcss';
22+
`)
23+
})
24+
25+
it('should migrate the default @tailwind directives to a single import', async () => {
26+
expect(
27+
await migrate(css`
28+
@tailwind base;
29+
@tailwind components;
30+
@tailwind utilities;
31+
`),
32+
).toEqual(css`
33+
@import 'tailwindcss';
34+
`)
35+
})
36+
37+
it('should migrate the default @tailwind directives as imports to a single import', async () => {
38+
expect(
39+
await migrate(css`
40+
@import 'tailwindcss/base';
41+
@import 'tailwindcss/components';
42+
@import 'tailwindcss/utilities';
43+
`),
44+
).toEqual(css`
45+
@import 'tailwindcss';
46+
`)
47+
})
48+
49+
it.each([
50+
[
51+
// The default order
52+
css`
53+
@tailwind base;
54+
@tailwind components;
55+
@tailwind utilities;
56+
`,
57+
css`
58+
@import 'tailwindcss';
59+
`,
60+
],
61+
62+
// @tailwind components moved, but has no effect in v4. Therefore `base` and
63+
// `utilities` are still in the correct order.
64+
[
65+
css`
66+
@tailwind base;
67+
@tailwind utilities;
68+
@tailwind components;
69+
`,
70+
css`
71+
@import 'tailwindcss';
72+
`,
73+
],
74+
75+
// Same as previous comment
76+
[
77+
css`
78+
@tailwind components;
79+
@tailwind base;
80+
@tailwind utilities;
81+
`,
82+
css`
83+
@import 'tailwindcss';
84+
`,
85+
],
86+
87+
// `base` and `utilities` swapped order, thus the `@layer` directives are
88+
// needed. The `components` directive is still ignored.
89+
[
90+
css`
91+
@tailwind components;
92+
@tailwind utilities;
93+
@tailwind base;
94+
`,
95+
css`
96+
@layer theme, components, utilities, base;
97+
@import 'tailwindcss';
98+
`,
99+
],
100+
[
101+
css`
102+
@tailwind utilities;
103+
@tailwind base;
104+
@tailwind components;
105+
`,
106+
css`
107+
@layer theme, components, utilities, base;
108+
@import 'tailwindcss';
109+
`,
110+
],
111+
[
112+
css`
113+
@tailwind utilities;
114+
@tailwind components;
115+
@tailwind base;
116+
`,
117+
css`
118+
@layer theme, components, utilities, base;
119+
@import 'tailwindcss';
120+
`,
121+
],
122+
])(
123+
'should migrate the default directives (but in different order) to a single import, order %#',
124+
async (input, expected) => {
125+
expect(await migrate(input)).toEqual(expected)
126+
},
127+
)
128+
129+
it('should migrate `@tailwind base` to theme and preflight imports', async () => {
130+
expect(
131+
await migrate(css`
132+
@tailwind base;
133+
`),
134+
).toEqual(css`
135+
@import 'tailwindcss/theme' layer(theme);
136+
@import 'tailwindcss/preflight' layer(base);
137+
`)
138+
})
139+
140+
it('should migrate `@import "tailwindcss/base"` to theme and preflight imports', async () => {
141+
expect(
142+
await migrate(css`
143+
@import 'tailwindcss/base';
144+
`),
145+
).toEqual(css`
146+
@import 'tailwindcss/theme' layer(theme);
147+
@import 'tailwindcss/preflight' layer(base);
148+
`)
149+
})
150+
151+
it('should migrate `@tailwind utilities` to an import', async () => {
152+
expect(
153+
await migrate(css`
154+
@tailwind utilities;
155+
`),
156+
).toEqual(css`
157+
@import 'tailwindcss/utilities' layer(utilities);
158+
`)
159+
})
160+
161+
it('should migrate `@import "tailwindcss/utilities"` to an import', async () => {
162+
expect(
163+
await migrate(css`
164+
@import 'tailwindcss/utilities';
165+
`),
166+
).toEqual(css`
167+
@import 'tailwindcss/utilities' layer(utilities);
168+
`)
169+
})
170+
171+
it('should not migrate existing imports using a custom layer', async () => {
172+
expect(
173+
await migrate(css`
174+
@import 'tailwindcss/utilities' layer(my-utilities);
175+
`),
176+
).toEqual(css`
177+
@import 'tailwindcss/utilities' layer(my-utilities);
178+
`)
179+
})
180+
181+
// We don't have a `@layer components` anymore, so omitting it should result
182+
// in the full import as well. Alternatively, we could expand to:
183+
//
184+
// ```css
185+
// @import 'tailwindcss/theme' layer(theme);
186+
// @import 'tailwindcss/preflight' layer(base);
187+
// @import 'tailwindcss/utilities' layer(utilities);
188+
// ```
189+
it('should migrate `@tailwind base` and `@tailwind utilities` to a single import', async () => {
190+
expect(
191+
await migrate(css`
192+
@tailwind base;
193+
@tailwind utilities;
194+
`),
195+
).toEqual(css`
196+
@import 'tailwindcss';
197+
`)
198+
})
199+
200+
it('should drop `@tailwind screens;`', async () => {
201+
expect(
202+
await migrate(css`
203+
@tailwind screens;
204+
`),
205+
).toEqual('')
206+
})
207+
208+
it('should drop `@tailwind variants;`', async () => {
209+
expect(
210+
await migrate(css`
211+
@tailwind variants;
212+
`),
213+
).toEqual('')
214+
})
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { AtRule, type Plugin, type Root } from 'postcss'
2+
3+
const DEFAULT_LAYER_ORDER = ['theme', 'base', 'components', 'utilities']
4+
5+
export function migrateTailwindDirectives(): Plugin {
6+
function migrate(root: Root) {
7+
let baseNode: AtRule | null = null
8+
let utilitiesNode: AtRule | null = null
9+
10+
let defaultImportNode: AtRule | null = null
11+
let utilitiesImportNode: AtRule | null = null
12+
let preflightImportNode: AtRule | null = null
13+
let themeImportNode: AtRule | null = null
14+
15+
let layerOrder: string[] = []
16+
17+
root.walkAtRules((node) => {
18+
// Track old imports and directives
19+
if (
20+
(node.name === 'tailwind' && node.params === 'base') ||
21+
(node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']$/))
22+
) {
23+
layerOrder.push('base')
24+
baseNode = node
25+
node.remove()
26+
} else if (
27+
(node.name === 'tailwind' && node.params === 'utilities') ||
28+
(node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']$/))
29+
) {
30+
layerOrder.push('utilities')
31+
utilitiesNode = node
32+
node.remove()
33+
}
34+
35+
// Remove directives that are not needed anymore
36+
else if (
37+
(node.name === 'tailwind' && node.params === 'components') ||
38+
(node.name === 'tailwind' && node.params === 'screens') ||
39+
(node.name === 'tailwind' && node.params === 'variants') ||
40+
(node.name === 'import' && node.params.match(/^["']tailwindcss\/components["']$/))
41+
) {
42+
node.remove()
43+
}
44+
})
45+
46+
// Insert default import if all directives are present
47+
if (baseNode !== null && utilitiesNode !== null) {
48+
if (!defaultImportNode) {
49+
root.prepend(new AtRule({ name: 'import', params: "'tailwindcss'" }))
50+
}
51+
}
52+
53+
// Insert individual imports if not all directives are present
54+
else if (utilitiesNode !== null) {
55+
if (!utilitiesImportNode) {
56+
root.prepend(
57+
new AtRule({ name: 'import', params: "'tailwindcss/utilities' layer(utilities)" }),
58+
)
59+
}
60+
} else if (baseNode !== null) {
61+
if (!preflightImportNode) {
62+
root.prepend(new AtRule({ name: 'import', params: "'tailwindcss/preflight' layer(base)" }))
63+
}
64+
if (!themeImportNode) {
65+
root.prepend(new AtRule({ name: 'import', params: "'tailwindcss/theme' layer(theme)" }))
66+
}
67+
}
68+
69+
// Insert `@layer …;` at the top when the order in the CSS was different
70+
// from the default.
71+
{
72+
// Determine if the order is different from the default.
73+
let sortedLayerOrder = layerOrder.toSorted((a, z) => {
74+
return DEFAULT_LAYER_ORDER.indexOf(a) - DEFAULT_LAYER_ORDER.indexOf(z)
75+
})
76+
77+
if (layerOrder.some((layer, index) => layer !== sortedLayerOrder[index])) {
78+
// Create a new `@layer` rule with the sorted order.
79+
let newLayerOrder = DEFAULT_LAYER_ORDER.toSorted((a, z) => {
80+
return layerOrder.indexOf(a) - layerOrder.indexOf(z)
81+
})
82+
root.prepend({ name: 'layer', params: newLayerOrder.join(', ') })
83+
}
84+
}
85+
}
86+
87+
return {
88+
postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives',
89+
Once: migrate,
90+
}
91+
}

packages/@tailwindcss-upgrade/src/migrate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import fs from 'node:fs/promises'
22
import path from 'node:path'
33
import postcss from 'postcss'
44
import { migrateAtApply } from './codemods/migrate-at-apply'
5+
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
56

67
export async function migrateContents(contents: string, file?: string) {
78
return postcss()
89
.use(migrateAtApply())
10+
.use(migrateTailwindDirectives())
911
.process(contents, { from: file })
1012
.then((result) => result.css)
1113
}

0 commit comments

Comments
 (0)