Robin Malfait fb1731a2de
Inject @config "..." when a tailwind.config.{js,ts,...} is detected (#14635)
This PR injects a `@config "…"` in the CSS file if a JS based config has
been found.

We will try to inject the `@config` in a sensible place:
1. Above the very first `@theme`
2. If that doesn't work, below the last `@import`
3. If that doesn't work, at the top of the file (as a last resort)
2024-10-10 14:02:42 +00:00

413 lines
13 KiB
TypeScript

import path from 'node:path'
import postcss from 'postcss'
import type { Config } from 'tailwindcss'
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 { migrateAtConfig } from './codemods/migrate-at-config'
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 StylesheetConnection, type StylesheetId } from './stylesheet'
import { resolveCssId } from './utils/resolve'
import { walk, WalkAction } from './utils/walk'
export interface MigrateOptions {
newPrefix: string | null
designSystem: DesignSystem
userConfig: Config
configFilePath: string
}
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(migrateAtApply(options))
.use(migrateMediaScreen(options))
.use(migrateAtLayerUtilities(stylesheet))
.use(migrateMissingLayers())
.use(migrateTailwindDirectives(options))
.use(migrateAtConfig(stylesheet, 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 stylesheetsByFile = new Map<string, Stylesheet>()
for (let sheet of stylesheets) {
if (sheet.file) {
stylesheetsByFile.set(sheet.file, sheet)
}
}
// 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
try {
resolvedPath = resolveCssId(id, basePath)
} catch (err) {
console.warn(`Failed to resolve import: ${id}. Skipping.`)
console.error(err)
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 })
}
},
},
},
])
for (let sheet of stylesheets) {
if (!sheet.file) continue
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[]) {
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)
}
}
// Keep track of sheets that contain `@utillity` rules
let containsUtilities = new Set<Stylesheet>()
for (let sheet of stylesheets) {
let layers = sheet.layers()
let isLayered = layers.has('utilities') || layers.has('components')
if (!isLayered) continue
walk(sheet.root, (node) => {
if (node.type !== 'atrule') return
if (node.name !== 'utility') return
containsUtilities.add(sheet)
return WalkAction.Stop
})
}
// Split every imported stylesheet into two parts
let utilitySheets = new Map<Stylesheet, Stylesheet>()
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 (!containsUtilities.has(sheet)) {
if (!Array.from(sheet.descendants()).some((child) => containsUtilities.has(child))) {
continue
}
}
let utilities = postcss.root({
raws: {
tailwind_pretty: true,
},
})
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<postcss.AtRule> = 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: {
after: '\n\n',
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<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
// 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())
}