tailwindcss/packages/@tailwindcss-upgrade/src/codemods/css/analyze.ts
Robin Malfait 71fb9cdf59
Improve @tailwindcss/upgrade and pnpm workspaces support (#18065)
This PR fixes an issue where an error such as:

<img width="1702" alt="image"
src="https://github.com/user-attachments/assets/4e6f75c7-3182-4497-939e-96cff08c55ae"
/>

Will be thrown during the upgrade process. This can happen when you are
using `pnpm` and your CSS file includes a `@import "tailwindcss";`. In
this scenario, `tailwindcss` will be loaded from a shared `.pnpm` folder
outside of the current working directory.

In this case, we are also not interested in migrating _that_ file, but
we also don't want the upgrade process to just crash.

I didn't see an option to ignore errors like this, so wrapped it in a
try/catch instead.

It also fixes another issue where if you are using a pnpm workspace and
run the upgrade tool from the root, then it throws you an error that you
cannot add dependencies to the workspace root unless `-w` or
`--workspace-root` flags are passed.

For this, we disable the check entirely using the
`--ignore-workspace-root-check` flag. If we always used the
`--workspace-root` flag, then the dependencies would always be added to
the root, regardless of where you are running the script from which is
not what we want.

## Test plan

Before:

<img width="1816" alt="image"
src="https://github.com/user-attachments/assets/78246876-3eb6-4539-a557-d3d366f1b3a3"
/>

After:

<img width="1816" alt="image"
src="https://github.com/user-attachments/assets/a65e4421-d7c5-4d83-b35d-934708543e25"
/>

Before:
 
<img width="1816" alt="image"
src="https://github.com/user-attachments/assets/53772661-2c4a-4212-84d9-a556a0ad320f"
/>

After:

<img width="1816" alt="image"
src="https://github.com/user-attachments/assets/5bfaf20e-34b8-44fd-9b59-e72d36738879"
/>
2025-05-19 12:47:08 +02:00

300 lines
9.8 KiB
TypeScript

import { isGitIgnored } from 'globby'
import path from 'node:path'
import postcss, { type Result } from 'postcss'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { Stylesheet, type StylesheetConnection } from '../../stylesheet'
import { error, highlight, relative } from '../../utils/renderer'
import { resolveCssId } from '../../utils/resolve'
export async function analyze(stylesheets: Stylesheet[]) {
let isIgnored = await isGitIgnored()
let processingQueue: (() => Promise<Result>)[] = []
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
// We don't want to process ignored files (like node_modules)
try {
if (isIgnored(file)) {
return null
}
} catch {
// If the file is not part of the current working directory (which can
// happen if you import `tailwindcss` and it's loading a shared file from
// pnpm) then this will throw.
return null
}
try {
let sheet = Stylesheet.loadSync(file)
// Mutate incoming stylesheets to include the newly discovered sheet
stylesheets.push(sheet)
// Queue up the processing of this stylesheet
processingQueue.push(() => processor.process(sheet.root, { from: sheet.file! }))
return sheet
} catch {
return null
}
})
// Step 1: Record which `@import` rules point to which stylesheets
// and which stylesheets are parents/children of each other
let processor = postcss([
{
postcssPlugin: 'mark-import-nodes',
AtRule: {
import(node) {
// Find what the import points to
let id = node.params.match(/['"](.*?)['"]/)?.[1]
if (!id) return
let basePath = node.source?.input.file
? path.dirname(node.source.input.file)
: process.cwd()
// Resolve the import to a file path
let resolvedPath: string | false = false
try {
// We first try to resolve the file as relative to the current file
// to mimic the behavior of `postcss-import` since that's what was
// used to resolve imports in Tailwind CSS v3.
if (id[0] !== '.') {
try {
resolvedPath = resolveCssId(`./${id}`, basePath)
} catch {}
}
if (!resolvedPath) {
resolvedPath = resolveCssId(id, basePath)
}
} catch (err) {
// Import is a URL, we don't want to process these, but also don't
// want to show an error message for them.
if (id.startsWith('http://') || id.startsWith('https://') || id.startsWith('//')) {
return
}
// Something went wrong, we can't resolve the import.
error(
`Failed to resolve import: ${highlight(id)} in ${highlight(relative(node.source?.input.file!, basePath))}. Skipping.`,
{ prefix: '↳ ' },
)
return
}
if (!resolvedPath) return
// Find the stylesheet pointing to the resolved path
let stylesheet = stylesheetsByFile.get(resolvedPath)
// If it _does not_ exist in stylesheets we don't care and skip it
// this is likely because its in node_modules or a workspace package
// that we don't want to modify
if (!stylesheet) return
// Mark the import node with the ID of the stylesheet it points to
// We will use these later to build lookup tables and modify the AST
node.raws.tailwind_destination_sheet_id = stylesheet.id
let parent = node.source?.input.file
? stylesheetsByFile.get(node.source.input.file)
: undefined
let layers: string[] = []
for (let part of segment(node.params, ' ')) {
if (!part.startsWith('layer(')) continue
if (!part.endsWith(')')) continue
layers.push(part.slice(6, -1).trim())
}
// Connect sheets together in a dependency graph
if (parent) {
let meta = { layers }
stylesheet.parents.add({ item: parent, meta })
parent.children.add({ item: stylesheet, meta })
}
},
},
},
])
// Seed the map with all the known stylesheets, and queue up the processing of
// each incoming stylesheet.
for (let sheet of stylesheets) {
if (sheet.file) {
stylesheetsByFile.set(sheet.file, sheet)
processingQueue.push(() => processor.process(sheet.root, { from: sheet.file ?? undefined }))
}
}
// Process all the stylesheets from step 1
while (processingQueue.length > 0) {
let task = processingQueue.shift()!
await task()
}
// ---
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 { convertiblePaths, nonConvertiblePaths } = sheet.analyzeImportPaths()
let isAmbiguous = convertiblePaths.length > 0 && nonConvertiblePaths.length > 0
if (!isAmbiguous) continue
sheet.canMigrate = false
let filePath = sheet.file.replace(commonPath, '')
for (let path of convertiblePaths) {
lines.push(`- ${filePath} <- ${pathToString(path)}`)
}
for (let path of nonConvertiblePaths) {
lines.push(`- ${filePath} <- ${pathToString(path)}`)
}
}
if (lines.length === 0) {
let tailwindRootLeafs = new Set<Stylesheet>()
for (let sheet of stylesheets) {
// If the current file already contains `@config`, then we can assume it's
// a Tailwind CSS root file.
sheet.root.walkAtRules('config', () => {
sheet.isTailwindRoot = true
return false
})
if (sheet.isTailwindRoot) continue
// If an `@tailwind` at-rule, or `@import "tailwindcss"` is present,
// then we can assume it's a file where Tailwind CSS might be configured.
//
// However, if 2 or more stylesheets exist with these rules that share a
// common parent, then we want to mark the common parent as the root
// stylesheet instead.
sheet.root.walkAtRules((node) => {
if (
node.name === 'tailwind' ||
(node.name === 'import' && node.params.match(/^["']tailwindcss["']/)) ||
(node.name === 'import' && node.params.match(/^["']tailwindcss\/.*?["']$/))
) {
sheet.isTailwindRoot = true
tailwindRootLeafs.add(sheet)
}
})
}
// Only a single Tailwind CSS root file exists, no need to do anything else.
if (tailwindRootLeafs.size <= 1) {
return
}
// Mark the common parent as the root file
{
// Group each sheet from tailwindRootLeafs by their common parent
let commonParents = new DefaultMap<Stylesheet, Set<Stylesheet>>(() => new Set<Stylesheet>())
// Seed common parents with leafs
for (let sheet of tailwindRootLeafs) {
commonParents.get(sheet).add(sheet)
}
// If any 2 common parents come from the same tree, then all children of
// parent A and parent B will be moved to the parent of parent A and
// parent B. Parent A and parent B will be removed.
let repeat = true
repeat: while (repeat) {
repeat = false
for (let [sheetA, childrenA] of commonParents) {
for (let [sheetB, childrenB] of commonParents) {
if (sheetA === sheetB) continue
// Ancestors from self to root. Reversed order so we find the
// nearest common parent first
//
// Including self because if you compare a sheet with its parent,
// then the parent is still the common sheet between the two. In
// this case, the parent is the root file.
let ancestorsA = [sheetA].concat(Array.from(sheetA.ancestors()).reverse())
let ancestorsB = [sheetB].concat(Array.from(sheetB.ancestors()).reverse())
for (let parentA of ancestorsA) {
for (let parentB of ancestorsB) {
if (parentA !== parentB) continue
// Found the parent
let parent = parentA
commonParents.delete(sheetA)
commonParents.delete(sheetB)
for (let child of childrenA) {
commonParents.get(parent).add(child)
}
for (let child of childrenB) {
commonParents.get(parent).add(child)
}
// Found a common parent between sheet A and sheet B. We can
// stop looking for more common parents between A and B, and
// continue with the next sheet.
repeat = true
continue repeat
}
}
}
}
}
// Mark the common parent as the Tailwind CSS root file, and remove the
// flag from each leaf.
for (let [parent, children] of commonParents) {
parent.isTailwindRoot = true
for (let child of children) {
if (parent === child) continue
child.isTailwindRoot = false
}
}
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'))
}
}