Skip to content

Commit 4d1becd

Browse files
Migrate utilities in CSS files imported into layers (#14617)
When a stylesheet is imported with `@import “…” layer(utilities)` that means that all classes in that stylesheet and any of its imported stylesheets become candidates for `@utility` conversion. Doing this correctly requires us to place `@utility` rules into separate stylesheets (usually) and replicate the import tree without layers as `@utility` MUST be root-level. If a file consists of only utilities we won't create a separate file for it and instead place the `@utility` rules in the same stylesheet. Been doing a LOT of pairing with @RobinMalfait on this one but I think this is finally ready to be looked at --------- Co-authored-by: Robin Malfait <[email protected]>
1 parent 75b9066 commit 4d1becd

File tree

9 files changed

+1374
-114
lines changed

9 files changed

+1374
-114
lines changed

integrations/upgrade/index.test.ts

Lines changed: 374 additions & 87 deletions
Large diffs are not rendered by default.

integrations/utils.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface TestContext {
4141
write(filePath: string, content: string): Promise<void>
4242
read(filePath: string): Promise<string>
4343
glob(pattern: string): Promise<[string, string][]>
44+
dumpFiles(pattern: string): Promise<string>
4445
expectFileToContain(
4546
filePath: string,
4647
contents: string | string[] | RegExp | RegExp[],
@@ -113,7 +114,7 @@ export function test(
113114
if (execOptions.ignoreStdErr !== true) console.error(stderr)
114115
reject(error)
115116
} else {
116-
resolve(stdout.toString())
117+
resolve(stdout.toString() + '\n\n' + stderr.toString())
117118
}
118119
},
119120
)
@@ -306,6 +307,31 @@ export function test(
306307
}),
307308
)
308309
},
310+
async dumpFiles(pattern: string) {
311+
let files = await context.fs.glob(pattern)
312+
return `\n${files
313+
.slice()
314+
.sort((a: [string], z: [string]) => {
315+
let aParts = a[0].split('/')
316+
let zParts = z[0].split('/')
317+
318+
let aFile = aParts.at(-1)
319+
let zFile = aParts.at(-1)
320+
321+
// Sort by depth, shallow first
322+
if (aParts.length < zParts.length) return -1
323+
if (aParts.length > zParts.length) return 1
324+
325+
// Sort by filename, sort files named `index` before others
326+
if (aFile?.startsWith('index')) return -1
327+
if (zFile?.startsWith('index')) return 1
328+
329+
// Sort by filename, alphabetically
330+
return a[0].localeCompare(z[0])
331+
})
332+
.map(([file, content]) => `--- ${file} ---\n${content || '<EMPTY>'}`)
333+
.join('\n\n')}`
334+
},
309335
async expectFileToContain(filePath, contents) {
310336
return retryAssertion(async () => {
311337
let fileContent = await this.read(filePath)

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

Lines changed: 237 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
11
import dedent from 'dedent'
22
import postcss from 'postcss'
33
import { describe, expect, it } from 'vitest'
4+
import { Stylesheet } from '../stylesheet'
45
import { formatNodes } from './format-nodes'
56
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
67

78
const css = dedent
89

9-
function migrate(input: string) {
10+
async function migrate(
11+
data:
12+
| string
13+
| {
14+
root: postcss.Root
15+
layers?: string[]
16+
},
17+
) {
18+
let stylesheet: Stylesheet
19+
20+
if (typeof data === 'string') {
21+
stylesheet = await Stylesheet.fromString(data)
22+
} else {
23+
stylesheet = await Stylesheet.fromRoot(data.root)
24+
25+
if (data.layers) {
26+
let meta = { layers: data.layers }
27+
let parent = await Stylesheet.fromString('.placeholder {}')
28+
29+
stylesheet.parents.add({ item: parent, meta })
30+
parent.children.add({ item: stylesheet, meta })
31+
}
32+
}
33+
1034
return postcss()
11-
.use(migrateAtLayerUtilities())
35+
.use(migrateAtLayerUtilities(stylesheet))
1236
.use(formatNodes())
13-
.process(input, { from: expect.getState().testPath })
37+
.process(stylesheet.root!, { from: expect.getState().testPath })
1438
.then((result) => result.css)
1539
}
1640

@@ -820,3 +844,213 @@ it('should not lose attribute selectors', async () => {
820844
}"
821845
`)
822846
})
847+
848+
describe('layered stylesheets', () => {
849+
it('should transform classes to utilities inside a layered stylesheet (utilities)', async () => {
850+
expect(
851+
await migrate({
852+
root: postcss.parse(css`
853+
/* Utility #1 */
854+
.foo {
855+
/* Declarations: */
856+
color: red;
857+
}
858+
`),
859+
layers: ['utilities'],
860+
}),
861+
).toMatchInlineSnapshot(`
862+
"@utility foo {
863+
/* Utility #1 */
864+
/* Declarations: */
865+
color: red;
866+
}"
867+
`)
868+
})
869+
870+
it('should transform classes to utilities inside a layered stylesheet (components)', async () => {
871+
expect(
872+
await migrate({
873+
root: postcss.parse(css`
874+
/* Utility #1 */
875+
.foo {
876+
/* Declarations: */
877+
color: red;
878+
}
879+
`),
880+
layers: ['components'],
881+
}),
882+
).toMatchInlineSnapshot(`
883+
"@utility foo {
884+
/* Utility #1 */
885+
/* Declarations: */
886+
color: red;
887+
}"
888+
`)
889+
})
890+
891+
it('should NOT transform classes to utilities inside a non-utility, layered stylesheet', async () => {
892+
expect(
893+
await migrate({
894+
root: postcss.parse(css`
895+
/* Utility #1 */
896+
.foo {
897+
/* Declarations: */
898+
color: red;
899+
}
900+
`),
901+
layers: ['foo'],
902+
}),
903+
).toMatchInlineSnapshot(`
904+
"/* Utility #1 */
905+
.foo {
906+
/* Declarations: */
907+
color: red;
908+
}"
909+
`)
910+
})
911+
912+
it('should handle non-classes in utility-layered stylesheets', async () => {
913+
expect(
914+
await migrate({
915+
root: postcss.parse(css`
916+
/* Utility #1 */
917+
.foo {
918+
/* Declarations: */
919+
color: red;
920+
}
921+
#main {
922+
color: red;
923+
}
924+
`),
925+
layers: ['utilities'],
926+
}),
927+
).toMatchInlineSnapshot(`
928+
"
929+
#main {
930+
color: red;
931+
}
932+
933+
@utility foo {
934+
/* Utility #1 */
935+
/* Declarations: */
936+
color: red;
937+
}"
938+
`)
939+
})
940+
941+
it('should handle non-classes in utility-layered stylesheets', async () => {
942+
expect(
943+
await migrate({
944+
root: postcss.parse(css`
945+
@layer utilities {
946+
@layer utilities {
947+
/* Utility #1 */
948+
.foo {
949+
/* Declarations: */
950+
color: red;
951+
}
952+
}
953+
954+
/* Utility #2 */
955+
.bar {
956+
/* Declarations: */
957+
color: red;
958+
}
959+
960+
#main {
961+
color: red;
962+
}
963+
}
964+
965+
/* Utility #3 */
966+
.baz {
967+
/* Declarations: */
968+
color: red;
969+
}
970+
971+
#secondary {
972+
color: red;
973+
}
974+
`),
975+
layers: ['utilities'],
976+
}),
977+
).toMatchInlineSnapshot(`
978+
"@layer utilities {
979+
980+
#main {
981+
color: red;
982+
}
983+
}
984+
985+
#secondary {
986+
color: red;
987+
}
988+
989+
@utility foo {
990+
@layer utilities {
991+
@layer utilities {
992+
/* Utility #1 */
993+
/* Declarations: */
994+
color: red;
995+
}
996+
}
997+
}
998+
999+
@utility bar {
1000+
@layer utilities {
1001+
/* Utility #2 */
1002+
/* Declarations: */
1003+
color: red;
1004+
}
1005+
}
1006+
1007+
@utility baz {
1008+
/* Utility #3 */
1009+
/* Declarations: */
1010+
color: red;
1011+
}"
1012+
`)
1013+
})
1014+
1015+
it('imports are preserved in layered stylesheets', async () => {
1016+
expect(
1017+
await migrate({
1018+
root: postcss.parse(css`
1019+
@import 'thing';
1020+
1021+
.foo {
1022+
color: red;
1023+
}
1024+
`),
1025+
layers: ['utilities'],
1026+
}),
1027+
).toMatchInlineSnapshot(`
1028+
"@import 'thing';
1029+
1030+
@utility foo {
1031+
color: red;
1032+
}"
1033+
`)
1034+
})
1035+
1036+
it('charset is preserved in layered stylesheets', async () => {
1037+
expect(
1038+
await migrate({
1039+
root: postcss.parse(css`
1040+
@charset "utf-8";
1041+
1042+
.foo {
1043+
color: red;
1044+
}
1045+
`),
1046+
layers: ['utilities'],
1047+
}),
1048+
).toMatchInlineSnapshot(`
1049+
"@charset "utf-8";
1050+
1051+
@utility foo {
1052+
color: red;
1053+
}"
1054+
`)
1055+
})
1056+
})

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
22
import SelectorParser from 'postcss-selector-parser'
33
import { segment } from '../../../tailwindcss/src/utils/segment'
4+
import { Stylesheet } from '../stylesheet'
45
import { walk, WalkAction, walkDepth } from '../utils/walk'
56

6-
export function migrateAtLayerUtilities(): Plugin {
7+
export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
78
function migrate(atRule: AtRule) {
89
// Only migrate `@layer utilities` and `@layer components`.
910
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
@@ -86,6 +87,12 @@ export function migrateAtLayerUtilities(): Plugin {
8687
clones.push(clone)
8788

8889
walk(clone, (node) => {
90+
if (node.type === 'atrule') {
91+
if (!node.nodes || node.nodes?.length === 0) {
92+
node.remove()
93+
}
94+
}
95+
8996
if (node.type !== 'rule') return
9097

9198
// Fan out each utility into its own rule.
@@ -186,7 +193,7 @@ export function migrateAtLayerUtilities(): Plugin {
186193

187194
// Mark the node as pretty so that it gets formatted by Prettier later.
188195
clone.raws.tailwind_pretty = true
189-
clone.raws.before += '\n\n'
196+
clone.raws.before = `${clone.raws.before ?? ''}\n\n`
190197
}
191198

192199
// Cleanup
@@ -259,7 +266,16 @@ export function migrateAtLayerUtilities(): Plugin {
259266

260267
return {
261268
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
262-
OnceExit: (root) => {
269+
OnceExit: (root, { atRule }) => {
270+
let layers = stylesheet.layers()
271+
let isUtilityStylesheet = layers.has('utilities') || layers.has('components')
272+
273+
if (isUtilityStylesheet) {
274+
let rule = atRule({ name: 'layer', params: 'utilities' })
275+
rule.append(root.nodes)
276+
root.append(rule)
277+
}
278+
263279
// Migrate `@layer utilities` and `@layer components` into `@utility`.
264280
// Using this instead of the visitor API in case we want to use
265281
// postcss-nesting in the future.
@@ -282,6 +298,17 @@ export function migrateAtLayerUtilities(): Plugin {
282298
}
283299
})
284300
}
301+
302+
// If the stylesheet is inside a layered import then we can remove the top-level layer directive we added
303+
if (isUtilityStylesheet) {
304+
root.each((node) => {
305+
if (node.type !== 'atrule') return
306+
if (node.name !== 'layer') return
307+
if (node.params !== 'utilities') return
308+
309+
node.replaceWith(node.nodes ?? [])
310+
})
311+
}
285312
},
286313
}
287314
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function migrateMissingLayers(): Plugin {
7171
if (node.name === 'import') {
7272
if (lastLayer !== '' && !node.params.includes('layer(')) {
7373
node.params += ` layer(${lastLayer})`
74+
node.raws.tailwind_injected_layer = true
7475
}
7576

7677
if (bucket.length > 0) {
@@ -110,7 +111,7 @@ export function migrateMissingLayers(): Plugin {
110111
let target = nodes[0]
111112
let layerNode = new AtRule({
112113
name: 'layer',
113-
params: layerName || firstLayerName || '',
114+
params: targetLayerName,
114115
nodes: nodes.map((node) => {
115116
// Keep the target node as-is, because we will be replacing that one
116117
// with the new layer node.

0 commit comments

Comments
 (0)