import { normalizePath } from '@tailwindcss/node' import { isGitIgnored } from 'globby' import path from 'node:path' import postcss, { type Result } from 'postcss' import type { Config } from '../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../tailwindcss/src/design-system' import { DefaultMap } from '../../tailwindcss/src/utils/default-map' import { segment } from '../../tailwindcss/src/utils/segment' import { migrateAtApply } from './codemods/migrate-at-apply' import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities' import { migrateConfig } from './codemods/migrate-config' import { migrateImport } from './codemods/migrate-import' import { migrateMediaScreen } from './codemods/migrate-media-screen' import { migrateMissingLayers } from './codemods/migrate-missing-layers' import { migratePreflight } from './codemods/migrate-preflight' import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' import { migrateThemeToVar } from './codemods/migrate-theme-to-var' import { migrateVariantsDirective } from './codemods/migrate-variants-directive' import type { JSConfigMigration } from './migrate-js-config' import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet' import { detectConfigPath } from './template/prepare-config' import { error, highlight, relative, success } from './utils/renderer' import { resolveCssId } from './utils/resolve' import { walk, WalkAction } from './utils/walk' export interface MigrateOptions { newPrefix: string | null designSystem: DesignSystem userConfig: Config configFilePath: string jsConfigMigration: JSConfigMigration } export async function migrateContents( stylesheet: Stylesheet | string, options: MigrateOptions, file?: string, ) { if (typeof stylesheet === 'string') { stylesheet = await Stylesheet.fromString(stylesheet) stylesheet.file = file ?? null } return postcss() .use(migrateImport()) .use(migrateAtApply(options)) .use(migrateMediaScreen(options)) .use(migrateVariantsDirective()) .use(migrateAtLayerUtilities(stylesheet)) .use(migrateMissingLayers()) .use(migrateTailwindDirectives(options)) .use(migrateConfig(stylesheet, options)) .use(migratePreflight(options)) .use(migrateThemeToVar(options)) .process(stylesheet.root, { from: stylesheet.file ?? undefined }) } export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { if (!stylesheet.file) { throw new Error('Cannot migrate a stylesheet without a file path') } if (!stylesheet.canMigrate) return await migrateContents(stylesheet, options) } export async function analyze(stylesheets: Stylesheet[]) { let isIgnored = await isGitIgnored() let processingQueue: (() => Promise)[] = [] let stylesheetsByFile = new DefaultMap((file) => { // We don't want to process ignored files (like node_modules) if (isIgnored(file)) { 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() 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>(() => new Set()) // 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')) } } export async function linkConfigs( stylesheets: Stylesheet[], { configPath, base }: { configPath: string | null; base: string }, ) { let rootStylesheets = stylesheets.filter((sheet) => sheet.isTailwindRoot) if (rootStylesheets.length === 0) { throw new Error( `Cannot find any CSS files that reference Tailwind CSS.\nBefore your project can be upgraded you need to create a CSS file that imports Tailwind CSS or uses ${highlight('@tailwind')}.`, ) } let withoutAtConfig = rootStylesheets.filter((sheet) => { let hasConfig = false sheet.root.walkAtRules('config', (node) => { let configPath = path.resolve(path.dirname(sheet.file!), node.params.slice(1, -1)) sheet.linkedConfigPath = configPath hasConfig = true return false }) return !hasConfig }) // All stylesheets have a `@config` directives if (withoutAtConfig.length === 0) return // Find the config file path for each stylesheet let configPathBySheet = new Map() let sheetByConfigPath = new DefaultMap>(() => new Set()) for (let sheet of withoutAtConfig) { if (!sheet.file) continue let localConfigPath = configPath as string if (configPath === null) { localConfigPath = await detectConfigPath(path.dirname(sheet.file), base) } else if (!path.isAbsolute(localConfigPath)) { localConfigPath = path.resolve(base, localConfigPath) } configPathBySheet.set(sheet, localConfigPath) sheetByConfigPath.get(localConfigPath).add(sheet) } let problematicStylesheets = new Set() for (let sheets of sheetByConfigPath.values()) { if (sheets.size > 1) { for (let sheet of sheets) { problematicStylesheets.add(sheet) } } } // There are multiple "root" files without `@config` directives. Manual // intervention is needed to link to the correct Tailwind config files. if (problematicStylesheets.size > 1) { for (let sheet of problematicStylesheets) { error( `Could not determine configuration file for: ${highlight(relative(sheet.file!, base))}\nUpdate your stylesheet to use ${highlight('@config')} to specify the correct configuration file explicitly and then run the upgrade tool again.`, { prefix: '↳ ' }, ) } process.exit(1) } let relativePath = relative for (let [sheet, configPath] of configPathBySheet) { try { if (!sheet || !sheet.file) return success( `Linked ${highlight(relativePath(configPath, base))} to ${highlight(relativePath(sheet.file, base))}`, { prefix: '↳ ' }, ) // Link the `@config` directive to the root stylesheets // Track the config file path on the stylesheet itself for easy access // without traversing the CSS ast and finding the corresponding // `@config` later. sheet.linkedConfigPath = configPath // Create a relative path from the current file to the config file. let relative = path.relative(path.dirname(sheet.file), configPath) // If the path points to a file in the same directory, `path.relative` will // remove the leading `./` and we need to add it back in order to still // consider the path relative if (!relative.startsWith('.') && !path.isAbsolute(relative)) { relative = './' + relative } relative = normalizePath(relative) // Add the `@config` directive to the root stylesheet. { let target = sheet.root as postcss.Root | postcss.AtRule let atConfig = postcss.atRule({ name: 'config', params: `'${relative}'` }) sheet.root.walkAtRules((node) => { if (node.name === 'tailwind' || node.name === 'import') { target = node } }) if (target.type === 'root') { sheet.root.prepend(atConfig) } else if (target.type === 'atrule') { target.after(atConfig) } } } catch (e: any) { error('Could not load the configuration file: ' + e.message, { prefix: '↳ ' }) process.exit(1) } } } export async function split(stylesheets: Stylesheet[]) { let stylesheetsById = new Map() let stylesheetsByFile = new Map() for (let sheet of stylesheets) { stylesheetsById.set(sheet.id, sheet) if (sheet.file) { stylesheetsByFile.set(sheet.file, sheet) } } // Keep track of sheets that contain `@utility` rules let requiresSplit = new Set() for (let sheet of stylesheets) { // Root files don't need to be split if (sheet.isTailwindRoot) continue let containsUtility = false let containsUnsafe = sheet.layers().size > 0 walk(sheet.root, (node) => { if (node.type === 'atrule' && node.name === 'utility') { containsUtility = true } // Safe to keep without splitting else if ( // An `@import "…" layer(…)` is safe (node.type === 'atrule' && node.name === 'import' && node.params.includes('layer(')) || // @layer blocks are safe (node.type === 'atrule' && node.name === 'layer') || // Comments are safe node.type === 'comment' ) { return WalkAction.Skip } // Everything else is not safe, and requires a split else { containsUnsafe = true } // We already know we need to split this sheet if (containsUtility && containsUnsafe) { return WalkAction.Stop } return WalkAction.Skip }) if (containsUtility && containsUnsafe) { requiresSplit.add(sheet) } } // Split every imported stylesheet into two parts let utilitySheets = new Map() for (let sheet of stylesheets) { // Ignore stylesheets that were not imported if (!sheet.file) continue if (sheet.parents.size === 0) continue // Skip stylesheets that don't have utilities // and don't have any children that have utilities if (!requiresSplit.has(sheet)) { if (!Array.from(sheet.descendants()).some((child) => requiresSplit.has(child))) { continue } } let utilities = postcss.root() walk(sheet.root, (node) => { if (node.type !== 'atrule') return if (node.name !== 'utility') return // `append` will move this node from the original sheet // to the new utilities sheet utilities.append(node) return WalkAction.Skip }) let newFileName = sheet.file.replace(/\.css$/, '.utilities.css') let counter = 0 // If we already have a utility sheet with this name, we need to rename it while (stylesheetsByFile.has(newFileName)) { counter += 1 newFileName = sheet.file.replace(/\.css$/, `.utilities.${counter}.css`) } let utilitySheet = await Stylesheet.fromRoot(utilities, newFileName) utilitySheet.extension = counter > 0 ? `.utilities.${counter}.css` : `.utilities.css` utilitySheets.set(sheet, utilitySheet) stylesheetsById.set(utilitySheet.id, utilitySheet) } // Make sure the utility sheets are linked to one another for (let [normalSheet, utilitySheet] of utilitySheets) { for (let parent of normalSheet.parents) { let utilityParent = utilitySheets.get(parent.item) if (!utilityParent) continue utilitySheet.parents.add({ item: utilityParent, meta: parent.meta, }) } for (let child of normalSheet.children) { let utilityChild = utilitySheets.get(child.item) if (!utilityChild) continue utilitySheet.children.add({ item: utilityChild, meta: child.meta, }) } } for (let sheet of stylesheets) { let utilitySheet = utilitySheets.get(sheet) let utilityImports: Set = new Set() for (let node of sheet.importRules) { let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined // This import rule does not point to a stylesheet // which likely means it points to `node_modules` if (!sheetId) continue let originalDestination = stylesheetsById.get(sheetId) // This import points to a stylesheet that no longer exists which likely // means it was removed by the optimizer this will be cleaned up later if (!originalDestination) continue let utilityDestination = utilitySheets.get(originalDestination) // A utility sheet doesn't exist for this import so it doesn't need // to be processed if (!utilityDestination) continue let match = node.params.match(/(['"])(.*)\1/) if (!match) return let quote = match[1] let id = match[2] let newFile = id.replace(/\.css$/, utilityDestination.extension!) // The import will just point to the new file without any media queries, // layers, or other conditions because `@utility` MUST be top-level. let newImport = node.clone({ params: `${quote}${newFile}${quote}`, raws: { tailwind_injected_layer: node.raws.tailwind_injected_layer, tailwind_original_params: `${quote}${id}${quote}`, tailwind_destination_sheet_id: utilityDestination.id, }, }) if (utilitySheet) { // If this import is intended to go into the utility sheet // we'll collect it into a list to add later. If we don't' // we'll end up adding them in reverse order. utilityImports.add(newImport) } else { // This import will go immediately after the original import node.after(newImport) } } // Add imports to the top of the utility sheet if necessary if (utilitySheet && utilityImports.size > 0) { utilitySheet.root.prepend(Array.from(utilityImports)) } } // Tracks the at rules that import a given stylesheet let importNodes = new DefaultMap>(() => new Set()) for (let sheet of stylesheetsById.values()) { for (let node of sheet.importRules) { let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined // This import rule does not point to a stylesheet if (!sheetId) continue let destination = stylesheetsById.get(sheetId) // This import rule does not point to a stylesheet that exists // We'll remove it later if (!destination) continue importNodes.get(destination).add(node) } } // At this point we've created many `{name}.utilities.css` files. // If the original file _becomes_ empty after splitting that means that // dedicated utility file is not required and we can move the utilities // back to the original file. // // This could be done in one step but separating them makes it easier to // reason about since the stylesheets are in a consistent state before we // perform any cleanup tasks. let list: Stylesheet[] = [] for (let sheet of stylesheets.slice()) { for (let child of sheet.descendants()) { list.push(child) } list.push(sheet) } for (let sheet of list) { let utilitySheet = utilitySheets.get(sheet) // This sheet was not split so there's nothing to do if (!utilitySheet) continue // This sheet did not become empty if (!sheet.isEmpty) continue // We have a sheet that became empty after splitting // 1. Replace the sheet with it's utility sheet content sheet.root = utilitySheet.root // 2. Rewrite imports in parent sheets to point to the original sheet // Ideally this wouldn't need to be _undone_ but instead only done once at the end for (let node of importNodes.get(utilitySheet)) { node.params = node.raws.tailwind_original_params as any } // 3. Remove the original import from the non-utility sheet for (let node of importNodes.get(sheet)) { node.remove() } // 3. Mark the utility sheet for removal utilitySheets.delete(sheet) } stylesheets.push(...utilitySheets.values()) }