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<Result>)[] = []
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
if (isIgnored(file)) {
return null
}
try {
let sheet = Stylesheet.loadSync(file)
stylesheets.push(sheet)
processingQueue.push(() => processor.process(sheet.root, { from: sheet.file! }))
return sheet
} catch {
return null
}
})
let processor = postcss([
{
postcssPlugin: 'mark-import-nodes',
AtRule: {
import(node) {
let id = node.params.match(/['"](.*)['"]/)?.[1]
if (!id) return
let basePath = node.source?.input.file
? path.dirname(node.source.input.file)
: process.cwd()
let resolvedPath: string | false = false
try {
if (id[0] !== '.') {
try {
resolvedPath = resolveCssId(`./${id}`, basePath)
} catch {}
}
if (!resolvedPath) {
resolvedPath = resolveCssId(id, basePath)
}
} catch (err) {
if (id.startsWith('http://') || id.startsWith('https://') || id.startsWith('//')) {
return
}
error(
`Failed to resolve import: ${highlight(id)} in ${highlight(relative(node.source?.input.file!, basePath))}. Skipping.`,
{ prefix: '↳ ' },
)
return
}
if (!resolvedPath) return
let stylesheet = stylesheetsByFile.get(resolvedPath)
if (!stylesheet) return
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())
}
if (parent) {
let meta = { layers }
stylesheet.parents.add({ item: parent, meta })
parent.children.add({ item: stylesheet, meta })
}
},
},
},
])
for (let sheet of stylesheets) {
if (sheet.file) {
stylesheetsByFile.set(sheet.file, sheet)
processingQueue.push(() => processor.process(sheet.root, { from: sheet.file ?? undefined }))
}
}
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) {
sheet.root.walkAtRules('config', () => {
sheet.isTailwindRoot = true
return false
})
if (sheet.isTailwindRoot) continue
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)
}
})
}
if (tailwindRootLeafs.size <= 1) {
return
}
{
let commonParents = new DefaultMap<Stylesheet, Set<Stylesheet>>(() => new Set<Stylesheet>())
for (let sheet of tailwindRootLeafs) {
commonParents.get(sheet).add(sheet)
}
let repeat = true
repeat: while (repeat) {
repeat = false
for (let [sheetA, childrenA] of commonParents) {
for (let [sheetB, childrenB] of commonParents) {
if (sheetA === sheetB) continue
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
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)
}
repeat = true
continue repeat
}
}
}
}
}
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
})
if (withoutAtConfig.length === 0) return
let configPathBySheet = new Map<Stylesheet, string>()
let sheetByConfigPath = new DefaultMap<string, Set<Stylesheet>>(() => 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<Stylesheet>()
for (let sheets of sheetByConfigPath.values()) {
if (sheets.size > 1) {
for (let sheet of sheets) {
problematicStylesheets.add(sheet)
}
}
}
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: '↳ ' },
)
sheet.linkedConfigPath = configPath
let relative = path.relative(path.dirname(sheet.file), configPath)
if (!relative.startsWith('.')) {
relative = './' + relative
}
relative = normalizePath(relative)
{
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<StylesheetId, Stylesheet>()
let stylesheetsByFile = new Map<string, Stylesheet>()
for (let sheet of stylesheets) {
stylesheetsById.set(sheet.id, sheet)
if (sheet.file) {
stylesheetsByFile.set(sheet.file, sheet)
}
}
let requiresSplit = new Set<Stylesheet>()
for (let sheet of stylesheets) {
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
}
else if (
(node.type === 'atrule' && node.name === 'import' && node.params.includes('layer(')) ||
(node.type === 'atrule' && node.name === 'layer') ||
node.type === 'comment'
) {
return WalkAction.Skip
}
else {
containsUnsafe = true
}
if (containsUtility && containsUnsafe) {
return WalkAction.Stop
}
return WalkAction.Skip
})
if (containsUtility && containsUnsafe) {
requiresSplit.add(sheet)
}
}
let utilitySheets = new Map<Stylesheet, Stylesheet>()
for (let sheet of stylesheets) {
if (!sheet.file) continue
if (sheet.parents.size === 0) continue
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
utilities.append(node)
return WalkAction.Skip
})
let newFileName = sheet.file.replace(/\.css$/, '.utilities.css')
let counter = 0
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)
}
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<postcss.AtRule> = new Set()
for (let node of sheet.importRules) {
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
if (!sheetId) continue
let originalDestination = stylesheetsById.get(sheetId)
if (!originalDestination) continue
let utilityDestination = utilitySheets.get(originalDestination)
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!)
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) {
utilityImports.add(newImport)
} else {
node.after(newImport)
}
}
if (utilitySheet && utilityImports.size > 0) {
utilitySheet.root.prepend(Array.from(utilityImports))
}
}
let importNodes = new DefaultMap<Stylesheet, Set<postcss.AtRule>>(() => 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
if (!sheetId) continue
let destination = stylesheetsById.get(sheetId)
if (!destination) continue
importNodes.get(destination).add(node)
}
}
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)
if (!utilitySheet) continue
if (!sheet.isEmpty) continue
sheet.root = utilitySheet.root
for (let node of importNodes.get(utilitySheet)) {
node.params = node.raws.tailwind_original_params as any
}
for (let node of importNodes.get(sheet)) {
node.remove()
}
utilitySheets.delete(sheet)
}
stylesheets.push(...utilitySheets.values())
}