Skip to content

Commit d869442

Browse files
authored
Add CSS codemod for missing @layer (#14504)
This PR adds a codemod that ensures that some parts of your stylesheet are wrapped in an `@layer`. This is a follow-up PR of #14411, in that PR we migrate `@tailwind` directives to imports. As a quick summary, that will turn this: ```css @tailwind base; @tailwind components; @tailwind utilities; ``` Into: ```css @import 'tailwindcss'; ``` But there are a few issues with that _if_ we have additional CSS on the page. For example let's imagine we had this: ```css @tailwind base; body { background-color: red; } @tailwind components; .btn {} @tailwind utilities; ``` This will now be turned into: ```css @import 'tailwindcss'; body { background-color: red; } .btn {} ``` But in v4 we use real layers, in v3 we used to replace the directive with the result of that layer. This means that now the `body` and `.btn` styles are in the incorrect spot. To solve this, we have to wrap them in a layer. The `body` should go in an `@layer base`, and the `.btn` should be in an `@layer components` to make sure it's in the same spot as it was before. That's what this PR does, the original input will now be turned into: ```css @import 'tailwindcss'; @layer base { body { background-color: red; } } @layer components { .btn { } } ``` There are a few internal refactors going on as well, but those are less important.
1 parent d14249d commit d869442

14 files changed

+423
-80
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Add new `shadow-initial` and `inset-shadow-initial` utilities for resetting shadow colors ([#14468](https://github.com/tailwindlabs/tailwindcss/pull/14468))
1717
- Add `field-sizing-*` utilities ([#14469](https://github.com/tailwindlabs/tailwindcss/pull/14469))
1818
- Include gradient color properties in color transitions ([#14489](https://github.com/tailwindlabs/tailwindcss/pull/14489))
19-
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411))
19+
- _Experimental_: Add CSS codemods for `@apply` ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14434))
20+
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411), [#14504](https://github.com/tailwindlabs/tailwindcss/pull/14504))
2021
- _Experimental_: Add CSS codemods for migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455))
2122

2223
### Fixed

integrations/cli/upgrade.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,45 @@ test(
6565
`,
6666
'src/index.css': css`
6767
@tailwind base;
68+
69+
html {
70+
color: #333;
71+
}
72+
6873
@tailwind components;
74+
75+
.btn {
76+
color: red;
77+
}
78+
6979
@tailwind utilities;
7080
`,
7181
},
7282
},
7383
async ({ fs, exec }) => {
7484
await exec('npx @tailwindcss/upgrade')
7585

76-
await fs.expectFileToContain('src/index.css', css` @import 'tailwindcss'; `)
86+
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
87+
await fs.expectFileToContain(
88+
'src/index.css',
89+
css`
90+
@layer base {
91+
html {
92+
color: #333;
93+
}
94+
}
95+
`,
96+
)
97+
await fs.expectFileToContain(
98+
'src/index.css',
99+
css`
100+
@layer components {
101+
.btn {
102+
color: red;
103+
}
104+
}
105+
`,
106+
)
77107
},
78108
)
79109

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import postcss, { type Plugin } from 'postcss'
2+
import { expect, it } from 'vitest'
3+
import { formatNodes } from './format-nodes'
4+
5+
function markPretty(): Plugin {
6+
return {
7+
postcssPlugin: '@tailwindcss/upgrade/mark-pretty',
8+
OnceExit(root) {
9+
root.walkAtRules('utility', (atRule) => {
10+
atRule.raws.tailwind_pretty = true
11+
})
12+
},
13+
}
14+
}
15+
16+
function migrate(input: string) {
17+
return postcss()
18+
.use(markPretty())
19+
.use(formatNodes())
20+
.process(input, { from: expect.getState().testPath })
21+
.then((result) => result.css)
22+
}
23+
24+
it('should format PostCSS nodes that are marked with tailwind_pretty', async () => {
25+
expect(
26+
await migrate(`
27+
@utility .foo { .foo { color: red; } }`),
28+
).toMatchInlineSnapshot(`
29+
"@utility .foo {
30+
.foo {
31+
color: red;
32+
}
33+
}"
34+
`)
35+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { parse, type ChildNode, type Plugin, type Root } from 'postcss'
2+
import { format } from 'prettier'
3+
import { walk, WalkAction } from '../utils/walk'
4+
5+
// Prettier is used to generate cleaner output, but it's only used on the nodes
6+
// that were marked as `pretty` during the migration.
7+
export function formatNodes(): Plugin {
8+
async function migrate(root: Root) {
9+
// Find the nodes to format
10+
let nodesToFormat: ChildNode[] = []
11+
walk(root, (child) => {
12+
if (child.raws.tailwind_pretty) {
13+
nodesToFormat.push(child)
14+
return WalkAction.Skip
15+
}
16+
})
17+
18+
// Format the nodes
19+
await Promise.all(
20+
nodesToFormat.map(async (node) => {
21+
node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true })))
22+
}),
23+
)
24+
}
25+
26+
return {
27+
postcssPlugin: '@tailwindcss/upgrade/format-nodes',
28+
OnceExit: migrate,
29+
}
30+
}

packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export function migrateAtApply(): Plugin {
1313
let params = utilities.map((part) => {
1414
// Keep whitespace
1515
if (part.trim() === '') return part
16-
1716
let variants = segment(part, ':')
1817
let utility = variants.pop()!
1918

@@ -36,8 +35,8 @@ export function migrateAtApply(): Plugin {
3635

3736
return {
3837
postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply',
39-
AtRule: {
40-
apply: migrate,
38+
OnceExit(root) {
39+
root.walkAtRules('apply', migrate)
4140
},
4241
}
4342
}

packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import dedent from 'dedent'
22
import postcss from 'postcss'
33
import { describe, expect, it } from 'vitest'
4+
import { formatNodes } from './format-nodes'
45
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
56

67
const css = dedent
78

89
function migrate(input: string) {
910
return postcss()
1011
.use(migrateAtLayerUtilities())
12+
.use(formatNodes())
1113
.process(input, { from: expect.getState().testPath })
1214
.then((result) => result.css)
1315
}

packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts

Lines changed: 4 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,16 @@
1-
import { AtRule, parse, Rule, type ChildNode, type Comment, type Plugin } from 'postcss'
1+
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
22
import SelectorParser from 'postcss-selector-parser'
3-
import { format } from 'prettier'
43
import { segment } from '../../../tailwindcss/src/utils/segment'
5-
6-
enum WalkAction {
7-
// Continue walking the tree. Default behavior.
8-
Continue,
9-
10-
// Skip walking into the current node.
11-
Skip,
12-
13-
// Stop walking the tree entirely.
14-
Stop,
15-
}
16-
17-
interface Walkable<T> {
18-
each(cb: (node: T, index: number) => void): void
19-
}
20-
21-
// Custom walk implementation where we can skip going into nodes when we don't
22-
// need to process them.
23-
function walk<T>(rule: Walkable<T>, cb: (rule: T) => void | WalkAction): undefined | false {
24-
let result: undefined | false = undefined
25-
26-
rule.each?.((node) => {
27-
let action = cb(node) ?? WalkAction.Continue
28-
if (action === WalkAction.Stop) {
29-
result = false
30-
return result
31-
}
32-
if (action !== WalkAction.Skip) {
33-
result = walk(node as Walkable<T>, cb)
34-
return result
35-
}
36-
})
37-
38-
return result
39-
}
40-
41-
// Depth first walk reversal implementation.
42-
function walkDepth<T>(rule: Walkable<T>, cb: (rule: T) => void) {
43-
rule?.each?.((node) => {
44-
walkDepth(node as Walkable<T>, cb)
45-
cb(node)
46-
})
47-
}
4+
import { walk, WalkAction, walkDepth } from '../utils/walk'
485

496
export function migrateAtLayerUtilities(): Plugin {
507
function migrate(atRule: AtRule) {
518
// Only migrate `@layer utilities` and `@layer components`.
529
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
5310

54-
// If the `@layer utilities` contains CSS that should not be turned into an
55-
// `@utility` at-rule, then we have to keep it around (including the
56-
// `@layer utilities` wrapper). To prevent this from being processed over
57-
// and over again, we mark it as seen and bail early.
58-
if (atRule.raws.seen) return
59-
6011
// Keep rules that should not be turned into utilities as is. This will
6112
// include rules with element or ID selectors.
62-
let defaultsAtRule = atRule.clone({ raws: { seen: true } })
13+
let defaultsAtRule = atRule.clone()
6314

6415
// Clone each rule with multiple selectors into their own rule with a single
6516
// selector.
@@ -312,32 +263,12 @@ export function migrateAtLayerUtilities(): Plugin {
312263

313264
return {
314265
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
315-
OnceExit: async (root) => {
266+
OnceExit: (root) => {
316267
// Migrate `@layer utilities` and `@layer components` into `@utility`.
317268
// Using this instead of the visitor API in case we want to use
318269
// postcss-nesting in the future.
319270
root.walkAtRules('layer', migrate)
320271

321-
// Prettier is used to generate cleaner output, but it's only used on the
322-
// nodes that were marked as `pretty` during the migration.
323-
{
324-
// Find the nodes to format
325-
let nodesToFormat: ChildNode[] = []
326-
walk(root, (child) => {
327-
if (child.raws.tailwind_pretty) {
328-
nodesToFormat.push(child)
329-
return WalkAction.Skip
330-
}
331-
})
332-
333-
// Format the nodes
334-
await Promise.all(
335-
nodesToFormat.map(async (node) => {
336-
node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true })))
337-
}),
338-
)
339-
}
340-
341272
// Merge `@utility <name>` with the same name into a single rule. This can
342273
// happen when the same classes is used in multiple `@layer utilities`
343274
// blocks.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import dedent from 'dedent'
2+
import postcss from 'postcss'
3+
import { expect, it } from 'vitest'
4+
import { formatNodes } from './format-nodes'
5+
import { migrateMissingLayers } from './migrate-missing-layers'
6+
7+
const css = dedent
8+
9+
function migrate(input: string) {
10+
return postcss()
11+
.use(migrateMissingLayers())
12+
.use(formatNodes())
13+
.process(input, { from: expect.getState().testPath })
14+
.then((result) => result.css)
15+
}
16+
17+
it('should migrate rules between tailwind directives', async () => {
18+
expect(
19+
await migrate(css`
20+
@tailwind base;
21+
22+
.base {
23+
}
24+
25+
@tailwind components;
26+
27+
.component-a {
28+
}
29+
.component-b {
30+
}
31+
32+
@tailwind utilities;
33+
34+
.utility-a {
35+
}
36+
.utility-b {
37+
}
38+
`),
39+
).toMatchInlineSnapshot(`
40+
"@tailwind base;
41+
42+
@layer base {
43+
.base {
44+
}
45+
}
46+
47+
@tailwind components;
48+
49+
@layer components {
50+
.component-a {
51+
}
52+
.component-b {
53+
}
54+
}
55+
56+
@tailwind utilities;
57+
58+
@layer utilities {
59+
.utility-a {
60+
}
61+
.utility-b {
62+
}
63+
}"
64+
`)
65+
})

0 commit comments

Comments
 (0)