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" />
300 lines
9.8 KiB
TypeScript
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'))
|
|
}
|
|
}
|