From 589d0e8858358df814f33c85090aa996539d9ec6 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 9 Oct 2024 13:20:10 -0400 Subject: [PATCH] Detect conflicting imports for potential utility files Co-authored-by: Robin Malfait --- integrations/upgrade/index.test.ts | 65 +++++++++++++ integrations/utils.ts | 2 +- packages/@tailwindcss-upgrade/src/migrate.ts | 55 ++++++++++- .../@tailwindcss-upgrade/src/stylesheet.ts | 96 +++++++++++++++++++ 4 files changed, 216 insertions(+), 2 deletions(-) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 9f81c1d2a5d4..8835c5483f36 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -808,3 +808,68 @@ test( `) }, ) + +test( + 'migrate utility files imported by multiple roots', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.js': js`module.exports = {}`, + 'src/index.html': html` +
+ `, + 'src/root.1.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css' layer(utilities); + `, + 'src/root.2.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css' layer(components); + `, + 'src/root.3.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css'; + `, + 'src/a.1.css': css` + .foo-from-a { + color: red; + } + `, + }, + }, + async ({ fs, exec }) => { + let output = await exec('npx @tailwindcss/upgrade --force') + + expect(output).toMatch( + /You have one or more stylesheets that are imported into a utility layer and non-utility layer./, + ) + + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/a.1.css --- + .foo-from-a { + color: red; + } + + --- ./src/root.1.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities); + + --- ./src/root.2.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(components); + + --- ./src/root.3.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities);" + `) + }, +) diff --git a/integrations/utils.ts b/integrations/utils.ts index c40476931536..ce700bce7f7a 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -114,7 +114,7 @@ export function test( if (execOptions.ignoreStdErr !== true) console.error(stderr) reject(error) } else { - resolve(stdout.toString()) + resolve(stdout.toString() + '\n\n' + stderr.toString()) } }, ) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index c0ae87f5829c..0b32cace52c0 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -9,7 +9,7 @@ import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities' import { migrateMediaScreen } from './codemods/migrate-media-screen' import { migrateMissingLayers } from './codemods/migrate-missing-layers' import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' -import { Stylesheet, type StylesheetId } from './stylesheet' +import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet' import { resolveCssId } from './utils/resolve' import { walk, WalkAction } from './utils/walk' @@ -43,6 +43,8 @@ export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { throw new Error('Cannot migrate a stylesheet without a file path') } + if (!stylesheet.canMigrate) return + await migrateContents(stylesheet, options) } @@ -123,6 +125,57 @@ export async function analyze(stylesheets: Stylesheet[]) { await processor.process(sheet.root, { from: sheet.file }) } + + let commonPath = process.cwd() + + function pathToString(path: StylesheetConnection[]) { + let parts: string[] = [] + + for (let connection of path) { + if (!connection.item.file) continue + + let filePath = connection.item.file.replace(commonPath, '') + let layers = connection.meta.layers.join(', ') + + if (layers.length > 0) { + parts.push(`${filePath} (layers: ${layers})`) + } else { + parts.push(filePath) + } + } + + return parts.join(' <- ') + } + + let lines: string[] = [] + + for (let sheet of stylesheets) { + if (!sheet.file) continue + + let { convertablePaths, nonConvertablePaths } = sheet.analyzeImportPaths() + let isAmbiguous = convertablePaths.length > 0 && nonConvertablePaths.length > 0 + + if (!isAmbiguous) continue + + sheet.canMigrate = false + + let filePath = sheet.file.replace(commonPath, '') + + for (let path of convertablePaths) { + lines.push(`- ${filePath} <- ${pathToString(path)}`) + } + + for (let path of nonConvertablePaths) { + lines.push(`- ${filePath} <- ${pathToString(path)}`) + } + } + + if (lines.length === 0) return + + let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n` + error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n` + + throw new Error(error + lines.join('\n')) } export async function split(stylesheets: Stylesheet[]) { diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts index 76666b75d8e5..c3a91fe4ba88 100644 --- a/packages/@tailwindcss-upgrade/src/stylesheet.ts +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -42,6 +42,11 @@ export class Stylesheet { */ children = new Set() + /** + * Whether or not this stylesheet can be migrated + */ + canMigrate = true + /** * Whether or not this stylesheet can be migrated */ @@ -123,6 +128,97 @@ export class Stylesheet { return layers } + /** + * Iterate all paths from a stylesheet through its ancestors to all roots + * + * For example, given the following structure: + * + * ``` + * c.css + * -> a.1.css @import "…" + * -> a.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * -> b.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * + * -> a.2.css @import "…" layer(foo) + * -> a.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * -> b.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * + * -> b.1.css @import "…" layer(components / utilities) + * -> a.css + * -> root.1.css (utility: yes) + * -> root.2.css (utility: yes) + * -> b.css + * -> root.1.css (utility: yes) + * -> root.2.css (utility: yes) + * ``` + * + * We can see there are a total of 12 import paths with various layers. + * We need to be able to iterate every one of these paths and inspect + * the layers used in each path.. + */ + *pathsToRoot(): Iterable { + for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) { + // Skip over intermediate stylesheets since all paths from a leaf to a + // root will encompass all possible intermediate stylesheet paths. + if (item.parents.size > 0) { + continue + } + + yield path + } + } + + /** + * Analyze a stylesheets import paths to see if some can be considered + * for conversion to utility rules and others can't. + * + * If a stylesheet is imported directly or indirectly and some imports are in + * a utility layer and some are not that means that we can't safely convert + * the rules in the stylesheet to `@utility`. Doing so would mean that we + * would need to replicate the stylesheet and change one to have `@utility` + * rules and leave the other as is. + * + * We can see, given the same structure from the `pathsToRoot` example, that + * `css.css` is imported into different layers: + * - `a.1.css` has no layers and should not be converted + * - `a.2.css` has a layer `foo` and should not be converted + * - `b.1.css` has a layer `utilities` (or `components`) which should be + * + * Since this means that `c.css` must both not be converted and converted + * we can't do this without replicating the stylesheet, any ancestors, and + * adjusting imports which is a non-trivial task. + */ + analyzeImportPaths() { + let convertablePaths: StylesheetConnection[][] = [] + let nonConvertablePaths: StylesheetConnection[][] = [] + + for (let path of this.pathsToRoot()) { + let isConvertable = false + + for (let { meta } of path) { + for (let layer of meta.layers) { + isConvertable ||= layer === 'utilities' || layer === 'components' + } + } + + if (isConvertable) { + convertablePaths.push(path) + } else { + nonConvertablePaths.push(path) + } + } + + return { convertablePaths, nonConvertablePaths } + } + [util.inspect.custom]() { return { ...this,