Skip to content

Commit 15bea44

Browse files
Detect conflicting imports for potential utility files
Co-authored-by: Robin Malfait <[email protected]>
1 parent 9976509 commit 15bea44

File tree

4 files changed

+216
-2
lines changed

4 files changed

+216
-2
lines changed

integrations/upgrade/index.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,3 +575,68 @@ test(
575575
`)
576576
},
577577
)
578+
579+
test(
580+
'migrate utility files imported by multiple roots',
581+
{
582+
fs: {
583+
'package.json': json`
584+
{
585+
"dependencies": {
586+
"tailwindcss": "workspace:^",
587+
"@tailwindcss/cli": "workspace:^",
588+
"@tailwindcss/upgrade": "workspace:^"
589+
}
590+
}
591+
`,
592+
'tailwind.config.js': js`module.exports = {}`,
593+
'src/index.html': html`
594+
<div class="hover:thing"></div>
595+
`,
596+
'src/root.1.css': css`
597+
@import 'tailwindcss/utilities';
598+
@import './a.1.css' layer(utilities);
599+
`,
600+
'src/root.2.css': css`
601+
@import 'tailwindcss/utilities';
602+
@import './a.1.css' layer(components);
603+
`,
604+
'src/root.3.css': css`
605+
@import 'tailwindcss/utilities';
606+
@import './a.1.css';
607+
`,
608+
'src/a.1.css': css`
609+
.foo-from-a {
610+
color: red;
611+
}
612+
`,
613+
},
614+
},
615+
async ({ fs, exec }) => {
616+
let output = await exec('npx @tailwindcss/upgrade --force')
617+
618+
expect(output).toMatch(
619+
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
620+
)
621+
622+
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
623+
"
624+
--- ./src/a.1.css ---
625+
.foo-from-a {
626+
color: red;
627+
}
628+
629+
--- ./src/root.1.css ---
630+
@import 'tailwindcss/utilities' layer(utilities);
631+
@import './a.1.css' layer(utilities);
632+
633+
--- ./src/root.2.css ---
634+
@import 'tailwindcss/utilities' layer(utilities);
635+
@import './a.1.css' layer(components);
636+
637+
--- ./src/root.3.css ---
638+
@import 'tailwindcss/utilities' layer(utilities);
639+
@import './a.1.css' layer(utilities);"
640+
`)
641+
},
642+
)

integrations/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function test(
114114
if (execOptions.ignoreStdErr !== true) console.error(stderr)
115115
reject(error)
116116
} else {
117-
resolve(stdout.toString())
117+
resolve(stdout.toString() + '\n\n' + stderr.toString())
118118
}
119119
},
120120
)

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { migrateAtApply } from './codemods/migrate-at-apply'
88
import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
99
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
1010
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
11-
import { Stylesheet, type StylesheetId } from './stylesheet'
11+
import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet'
1212
import { resolveCssId } from './utils/resolve'
1313
import { walk, WalkAction } from './utils/walk'
1414

@@ -41,6 +41,8 @@ export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) {
4141
throw new Error('Cannot migrate a stylesheet without a file path')
4242
}
4343

44+
if (!stylesheet.canMigrate) return
45+
4446
await migrateContents(stylesheet, options)
4547
}
4648

@@ -121,6 +123,57 @@ export async function analyze(stylesheets: Stylesheet[]) {
121123

122124
await processor.process(sheet.root, { from: sheet.file })
123125
}
126+
127+
let commonPath = process.cwd()
128+
129+
function pathToString(path: StylesheetConnection[]) {
130+
let parts: string[] = []
131+
132+
for (let connection of path) {
133+
if (!connection.item.file) continue
134+
135+
let filePath = connection.item.file.replace(commonPath, '')
136+
let layers = connection.meta.layers.join(', ')
137+
138+
if (layers.length > 0) {
139+
parts.push(`${filePath} (layers: ${layers})`)
140+
} else {
141+
parts.push(filePath)
142+
}
143+
}
144+
145+
return parts.join(' <- ')
146+
}
147+
148+
let lines: string[] = []
149+
150+
for (let sheet of stylesheets) {
151+
if (!sheet.file) continue
152+
153+
let { convertablePaths, nonConvertablePaths } = sheet.analyzeImportPaths()
154+
let isAmbiguous = convertablePaths.length > 0 && nonConvertablePaths.length > 0
155+
156+
if (!isAmbiguous) continue
157+
158+
sheet.canMigrate = false
159+
160+
let filePath = sheet.file.replace(commonPath, '')
161+
162+
for (let path of convertablePaths) {
163+
lines.push(`- ${filePath} <- ${pathToString(path)}`)
164+
}
165+
166+
for (let path of nonConvertablePaths) {
167+
lines.push(`- ${filePath} <- ${pathToString(path)}`)
168+
}
169+
}
170+
171+
if (lines.length === 0) return
172+
173+
let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n`
174+
error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n`
175+
176+
throw new Error(error + lines.join('\n'))
124177
}
125178

126179
export async function split(stylesheets: Stylesheet[]) {

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export class Stylesheet {
4242
*/
4343
children = new Set<StylesheetConnection>()
4444

45+
/**
46+
* Whether or not this stylesheet can be migrated
47+
*/
48+
canMigrate = true
49+
4550
/**
4651
* Whether or not this stylesheet can be migrated
4752
*/
@@ -123,6 +128,97 @@ export class Stylesheet {
123128
return layers
124129
}
125130

131+
/**
132+
* Iterate all paths from a stylesheet through its ancestors to all roots
133+
*
134+
* For example, given the following structure:
135+
*
136+
* ```
137+
* c.css
138+
* -> a.1.css @import "…"
139+
* -> a.css
140+
* -> root.1.css (utility: no)
141+
* -> root.2.css (utility: no)
142+
* -> b.css
143+
* -> root.1.css (utility: no)
144+
* -> root.2.css (utility: no)
145+
*
146+
* -> a.2.css @import "…" layer(foo)
147+
* -> a.css
148+
* -> root.1.css (utility: no)
149+
* -> root.2.css (utility: no)
150+
* -> b.css
151+
* -> root.1.css (utility: no)
152+
* -> root.2.css (utility: no)
153+
*
154+
* -> b.1.css @import "…" layer(components / utilities)
155+
* -> a.css
156+
* -> root.1.css (utility: yes)
157+
* -> root.2.css (utility: yes)
158+
* -> b.css
159+
* -> root.1.css (utility: yes)
160+
* -> root.2.css (utility: yes)
161+
* ```
162+
*
163+
* We can see there are a total of 12 import paths with various layers.
164+
* We need to be able to iterate every one of these paths and inspect
165+
* the layers used in each path..
166+
*/
167+
*pathsToRoot(): Iterable<StylesheetConnection[]> {
168+
for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) {
169+
// Skip over intermediate stylesheets since all paths from a leaf to a
170+
// root will encompass all possible intermediate stylesheet paths.
171+
if (item.parents.size > 0) {
172+
continue
173+
}
174+
175+
yield path
176+
}
177+
}
178+
179+
/**
180+
* Analyze a stylesheets import paths to see if some can be considered
181+
* for conversion to utility rules and others can't.
182+
*
183+
* If a stylesheet is imported directly or indirectly and some imports are in
184+
* a utility layer and some are not that means that we can't safely convert
185+
* the rules in the stylesheet to `@utility`. Doing so would mean that we
186+
* would need to replicate the stylesheet and change one to have `@utility`
187+
* rules and leave the other as is.
188+
*
189+
* We can see, given the same structure from the `pathsToRoot` example, that
190+
* `css.css` is imported into different layers:
191+
* - `a.1.css` has no layers and should not be converted
192+
* - `a.2.css` has a layer `foo` and should not be converted
193+
* - `b.1.css` has a layer `utilities` (or `components`) which should be
194+
*
195+
* Since this means that `c.css` must both not be converted and converted
196+
* we can't do this without replicating the stylesheet, any ancestors, and
197+
* adjusting imports which is a non-trivial task.
198+
*/
199+
analyzeImportPaths() {
200+
let convertablePaths: StylesheetConnection[][] = []
201+
let nonConvertablePaths: StylesheetConnection[][] = []
202+
203+
for (let path of this.pathsToRoot()) {
204+
let isConvertable = false
205+
206+
for (let { meta } of path) {
207+
for (let layer of meta.layers) {
208+
isConvertable ||= layer === 'utilities' || layer === 'components'
209+
}
210+
}
211+
212+
if (isConvertable) {
213+
convertablePaths.push(path)
214+
} else {
215+
nonConvertablePaths.push(path)
216+
}
217+
}
218+
219+
return { convertablePaths, nonConvertablePaths }
220+
}
221+
126222
[util.inspect.custom]() {
127223
return {
128224
...this,

0 commit comments

Comments
 (0)