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
```
This commit is contained in:
Robin Malfait 2024-09-18 22:40:23 +02:00 • committed by GitHub
parent 2ddb715abd
commit 67d1849f34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 334 additions and 0 deletions

View file

@ -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

View file

@ -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'; `)
},
)

View file

@ -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('')
})

View file

@ -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,
}
}

View file

@ -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)
}