Skip to content

Commit 4f8ca55

Browse files
CSS codemod: inject @import in a more expected location (#14536)
This PR inserts the `@import` in a more sensible location when running codemods. The idea is that we replace `@tailwind base; @tailwind components; @tailwind utilities;` with the much simple `@import "tailwindcss";`. We did this by adding the `@import` to the top of the file. While this is correct, this means that the diff might not be as clear. For example, if you have a situation where you have a license comment: ```css /**! My license comment */ @tailwind base; @tailwind components; @tailwind utilities; ``` This resulted in: ```css @import "tailwindcss"; /**! My license comment */ ``` While it is not wrong, it feels weird that this behaves like this. In this commit we make sure that it is injected in-place (the first `@tailwind` at-rule we find) and fixup the position if we can't inject it in-place. The above example results in this: ```css /**! My license comment */ @import "tailwindcss"; ``` However, there are scenario's where you can't replace the `@tailwind` directives directly. E.g.: ```css /**! My license comment */ html { color: red; } @tailwind base; @tailwind components; @tailwind utilities; ``` If we replace the `@tailwind` directives in-place, it would look like this: ```css /**! My license comment */ html { color: red; } @import "tailwindcss"; ``` But this is invalid CSS, because you can't have CSS above an `@import` at-rule. There are some exceptions like: - `@charset` - `@import` - `@layer foo, bar;` (just the order, without a body) - comments In this scenario, we inject the import in the nearest place where it is allowed to. In this case: ```css /**! My license comment */ @import "tailwindcss"; @layer base { html { color: red; } } ``` Additionally, we will wrap the existing CSS in an `@layer` of the first Tailwind directive we saw. In this case an `@layer base`. This ensures that utilities still win from the default styles. Also note that the (license) comment is allowed to exist before the `@import`, therefore we do not put the `@import` above it. This also means that the diff doesn't touch the license header at all, which makes the diffs cleaner and easier to reason about. --------- Co-authored-by: Philipp Spiess <[email protected]>
1 parent f92eb26 commit 4f8ca55

File tree

6 files changed

+260
-21
lines changed

6 files changed

+260
-21
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add support for prefixes ([#14501](https://github.com/tailwindlabs/tailwindcss/pull/14501))
1313
- _Experimental_: Add template codemods for migrating `bg-gradient-*` utilities to `bg-linear-*` ([#14537](https://github.com/tailwindlabs/tailwindcss/pull/14537]))
14+
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
1415

1516
### Fixed
1617

1718
- Use the right import base path when using the CLI to reading files from stdin ([#14522](https://github.com/tailwindlabs/tailwindcss/pull/14522))
19+
- Ensure that `@utility` is top-level and cannot be nested ([#14525](https://github.com/tailwindlabs/tailwindcss/pull/14525))
1820
- _Experimental_: Improve codemod output, keep CSS after last Tailwind directive unlayered ([#14512](https://github.com/tailwindlabs/tailwindcss/pull/14512))
1921
- _Experimental_: Fix incorrect empty `layer()` at the end of `@import` at-rules when running codemods ([#14513](https://github.com/tailwindlabs/tailwindcss/pull/14513))
20-
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
2122
- _Experimental_: Do not wrap comment nodes in `@layer` when running codemods ([#14517](https://github.com/tailwindlabs/tailwindcss/pull/14517))
2223
- _Experimental_: Ensure we don't lose selectors when running codemods ([#14518](https://github.com/tailwindlabs/tailwindcss/pull/14518))
23-
- Ensure that `@utility` is top-level and cannot be nested ([#14525](https://github.com/tailwindlabs/tailwindcss/pull/14525))
24+
- _Experimental_: inject `@import` in a more expected location when running codemods ([#14536](https://github.com/tailwindlabs/tailwindcss/pull/14536))
2425

2526
## [4.0.0-alpha.25] - 2024-09-24
2627

packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@ it('should not migrate already migrated `@import` at-rules', async () => {
2222
).toMatchInlineSnapshot(`"@import 'tailwindcss';"`)
2323
})
2424

25+
it('should not migrate anything if no `@tailwind` directives (or imports) are found', async () => {
26+
expect(
27+
await migrate(css`
28+
/* Base */
29+
html {
30+
color: red;
31+
}
32+
33+
/* Utilities */
34+
.foo {
35+
color: blue;
36+
}
37+
`),
38+
).toMatchInlineSnapshot(`
39+
"/* Base */
40+
html {
41+
color: red;
42+
}
43+
44+
/* Utilities */
45+
.foo {
46+
color: blue;
47+
}"
48+
`)
49+
})
50+
2551
it('should not wrap comments in a layer, if they are the only nodes', async () => {
2652
expect(
2753
await migrate(css`
@@ -54,6 +80,44 @@ it('should not wrap comments in a layer, if they are the only nodes', async () =
5480
`)
5581
})
5682

83+
it('should migrate rules above the `@tailwind base` directive in an `@layer base`', async () => {
84+
expect(
85+
await migrate(css`
86+
@charset "UTF-8";
87+
@layer foo, bar, baz;
88+
89+
/**!
90+
* License header
91+
*/
92+
93+
html {
94+
color: red;
95+
}
96+
97+
@tailwind base;
98+
@tailwind components;
99+
@tailwind utilities;
100+
`),
101+
).toMatchInlineSnapshot(`
102+
"@charset "UTF-8";
103+
@layer foo, bar, baz;
104+
105+
/**!
106+
* License header
107+
*/
108+
109+
@layer base {
110+
html {
111+
color: red;
112+
}
113+
}
114+
115+
@tailwind base;
116+
@tailwind components;
117+
@tailwind utilities;"
118+
`)
119+
})
120+
57121
it('should migrate rules between tailwind directives', async () => {
58122
expect(
59123
await migrate(css`

packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export function migrateMissingLayers(): Plugin {
55
let lastLayer = ''
66
let bucket: ChildNode[] = []
77
let buckets: [layer: string, bucket: typeof bucket][] = []
8+
let firstLayerName: string | null = null
89

910
root.each((node) => {
1011
if (node.type === 'atrule') {
@@ -25,6 +26,7 @@ export function migrateMissingLayers(): Plugin {
2526
buckets.push([lastLayer, bucket.splice(0)])
2627
}
2728

29+
firstLayerName ??= 'base'
2830
lastLayer = 'base'
2931
return
3032
}
@@ -38,6 +40,7 @@ export function migrateMissingLayers(): Plugin {
3840
buckets.push([lastLayer, bucket.splice(0)])
3941
}
4042

43+
firstLayerName ??= 'components'
4144
lastLayer = 'components'
4245
return
4346
}
@@ -51,6 +54,7 @@ export function migrateMissingLayers(): Plugin {
5154
buckets.push([lastLayer, bucket.splice(0)])
5255
}
5356

57+
firstLayerName ??= 'utilities'
5458
lastLayer = 'utilities'
5559
return
5660
}
@@ -76,10 +80,19 @@ export function migrateMissingLayers(): Plugin {
7680
}
7781
}
7882

79-
// Track the node
80-
if (lastLayer !== '') {
81-
bucket.push(node)
83+
// (License) comments, body-less `@layer` and `@charset` can stay at the
84+
// top, when we haven't found any `@tailwind` at-rules yet.
85+
if (
86+
lastLayer === '' &&
87+
(node.type === 'comment' /* Comment */ ||
88+
(node.type === 'atrule' && !node.nodes) || // @layer foo, bar, baz;
89+
(node.type === 'atrule' && node.name === 'charset')) // @charset "UTF-8";
90+
) {
91+
return
8292
}
93+
94+
// Track the node
95+
bucket.push(node)
8396
})
8497

8598
// Wrap each bucket in an `@layer` at-rule
@@ -92,7 +105,7 @@ export function migrateMissingLayers(): Plugin {
92105
let target = nodes[0]
93106
let layerNode = new AtRule({
94107
name: 'layer',
95-
params: layerName,
108+
params: layerName || firstLayerName || '',
96109
nodes: nodes.map((node) => {
97110
// Keep the target node as-is, because we will be replacing that one
98111
// with the new layer node.

packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,70 @@ it('should migrate the default @tailwind directives as imports to a single impor
5858
`)
5959
})
6060

61+
it('should migrate the default @tailwind directives to a single import in a valid location', async () => {
62+
expect(
63+
await migrate(css`
64+
@charset "UTF-8";
65+
@layer foo, bar, baz;
66+
67+
/**!
68+
* License header
69+
*/
70+
71+
html {
72+
color: red;
73+
}
74+
75+
@tailwind base;
76+
@tailwind components;
77+
@tailwind utilities;
78+
`),
79+
)
80+
// NOTE: The `html {}` is not wrapped in a `@layer` directive, because that
81+
// is handled by another migration step. See ../index.test.ts for a
82+
// dedicated test.
83+
.toEqual(css`
84+
@charset "UTF-8";
85+
@layer foo, bar, baz;
86+
87+
/**!
88+
* License header
89+
*/
90+
91+
@import 'tailwindcss';
92+
93+
html {
94+
color: red;
95+
}
96+
`)
97+
})
98+
99+
it('should migrate the default @tailwind directives as imports to a single import in a valid location', async () => {
100+
expect(
101+
await migrate(css`
102+
@charset "UTF-8";
103+
@layer foo, bar, baz;
104+
105+
/**!
106+
* License header
107+
*/
108+
109+
@import 'tailwindcss/base';
110+
@import 'tailwindcss/components';
111+
@import 'tailwindcss/utilities';
112+
`),
113+
).toEqual(css`
114+
@charset "UTF-8";
115+
@layer foo, bar, baz;
116+
117+
/**!
118+
* License header
119+
*/
120+
121+
@import 'tailwindcss';
122+
`)
123+
})
124+
61125
it.each([
62126
[
63127
// The default order

packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import { AtRule, type Plugin, type Root } from 'postcss'
1+
import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss'
22

33
const DEFAULT_LAYER_ORDER = ['theme', 'base', 'components', 'utilities']
44

55
export function migrateTailwindDirectives(): Plugin {
66
function migrate(root: Root) {
7-
let baseNode: AtRule | null = null
8-
let utilitiesNode: AtRule | null = null
7+
let baseNode = null as AtRule | null
8+
let utilitiesNode = null as AtRule | null
9+
let orderedNodes: AtRule[] = []
910

10-
let defaultImportNode: AtRule | null = null
11-
let utilitiesImportNode: AtRule | null = null
12-
let preflightImportNode: AtRule | null = null
13-
let themeImportNode: AtRule | null = null
11+
let defaultImportNode = null as AtRule | null
12+
let utilitiesImportNode = null as AtRule | null
13+
let preflightImportNode = null as AtRule | null
14+
let themeImportNode = null as AtRule | null
1415

1516
let layerOrder: string[] = []
1617

@@ -26,15 +27,15 @@ export function migrateTailwindDirectives(): Plugin {
2627
(node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']$/))
2728
) {
2829
layerOrder.push('base')
30+
orderedNodes.push(node)
2931
baseNode = node
30-
node.remove()
3132
} else if (
3233
(node.name === 'tailwind' && node.params === 'utilities') ||
3334
(node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']$/))
3435
) {
3536
layerOrder.push('utilities')
37+
orderedNodes.push(node)
3638
utilitiesNode = node
37-
node.remove()
3839
}
3940

4041
// Remove directives that are not needed anymore
@@ -51,24 +52,34 @@ export function migrateTailwindDirectives(): Plugin {
5152
// Insert default import if all directives are present
5253
if (baseNode !== null && utilitiesNode !== null) {
5354
if (!defaultImportNode) {
54-
root.prepend(new AtRule({ name: 'import', params: "'tailwindcss'" }))
55+
findTargetNode(orderedNodes).before(new AtRule({ name: 'import', params: "'tailwindcss'" }))
5556
}
57+
baseNode?.remove()
58+
utilitiesNode?.remove()
5659
}
5760

5861
// Insert individual imports if not all directives are present
5962
else if (utilitiesNode !== null) {
6063
if (!utilitiesImportNode) {
61-
root.prepend(
64+
findTargetNode(orderedNodes).before(
6265
new AtRule({ name: 'import', params: "'tailwindcss/utilities' layer(utilities)" }),
6366
)
6467
}
68+
utilitiesNode?.remove()
6569
} else if (baseNode !== null) {
66-
if (!preflightImportNode) {
67-
root.prepend(new AtRule({ name: 'import', params: "'tailwindcss/preflight' layer(base)" }))
68-
}
6970
if (!themeImportNode) {
70-
root.prepend(new AtRule({ name: 'import', params: "'tailwindcss/theme' layer(theme)" }))
71+
findTargetNode(orderedNodes).before(
72+
new AtRule({ name: 'import', params: "'tailwindcss/theme' layer(theme)" }),
73+
)
74+
}
75+
76+
if (!preflightImportNode) {
77+
findTargetNode(orderedNodes).before(
78+
new AtRule({ name: 'import', params: "'tailwindcss/preflight' layer(base)" }),
79+
)
7180
}
81+
82+
baseNode?.remove()
7283
}
7384

7485
// Insert `@layer …;` at the top when the order in the CSS was different
@@ -94,3 +105,63 @@ export function migrateTailwindDirectives(): Plugin {
94105
OnceExit: migrate,
95106
}
96107
}
108+
109+
// Finds the location where we can inject the new `@import` at-rule. This
110+
// guarantees that the `@import` is inserted at the most expected location.
111+
//
112+
// Ideally it's replacing the existing Tailwind directives, but we have to
113+
// ensure that the `@import` is valid in this location or not. If not, we move
114+
// the `@import` up until we find a valid location.
115+
function findTargetNode(nodes: AtRule[]) {
116+
// Start at the `base` or `utilities` node (whichever comes first), and find
117+
// the spot where we can insert the new import.
118+
let target: ChildNode = nodes.at(0)!
119+
120+
// Only allowed nodes before the `@import` are:
121+
//
122+
// - `@charset` at-rule.
123+
// - `@layer foo, bar, baz;` at-rule to define the order of the layers.
124+
// - `@import` at-rule to import other CSS files.
125+
// - Comments.
126+
//
127+
// Nodes that cannot exist before the `@import` are:
128+
//
129+
// - Any other at-rule.
130+
// - Any rule.
131+
let previous = target.prev()
132+
while (previous) {
133+
// Rules are not allowed before the `@import`, so we have to at least inject
134+
// the `@import` before this rule.
135+
if (previous.type === 'rule') {
136+
target = previous
137+
}
138+
139+
// Some at-rules are allowed before the `@import`.
140+
if (previous.type === 'atrule') {
141+
// `@charset` and `@import` are allowed before the `@import`.
142+
if (previous.name === 'charset' || previous.name === 'import') {
143+
// Allowed
144+
previous = previous.prev()
145+
continue
146+
}
147+
148+
// `@layer` without any nodes is allowed before the `@import`.
149+
else if (previous.name === 'layer' && !previous.nodes) {
150+
// Allowed
151+
previous = previous.prev()
152+
continue
153+
}
154+
155+
// Anything other at-rule (`@media`, `@supports`, etc.) is not allowed
156+
// before the `@import`.
157+
else {
158+
target = previous
159+
}
160+
}
161+
162+
// Keep checking the previous node.
163+
previous = previous.prev()
164+
}
165+
166+
return target
167+
}

0 commit comments

Comments
 (0)