Robin Malfait 894bf9f5ef
Support migrating projects with multiple config files (#14863)
When migrating a project from Tailwind CSS v3 to Tailwind CSS v4, then
we started the migration process in the following order:

1. Migrate the JS/TS config file
2. Migrate the source files (found via the `content` option)
3. Migrate the CSS files

However, if you have a setup where you have multiple CSS root files
(e.g.: `frontend` and `admin` are separated), then that typically means
that you have an `@config` directive in your CSS files. These point to
the Tailwind CSS config file.

This PR changes the migration order to do the following:

1. Build a tree of all the CSS files
2. For each `@config` directive, migrate the JS/TS config file
3. For each JS/TS config file, migrate the source files

If a CSS file does not contain any `@config` directives, then we start
by filling in the `@config` directive with the default Tailwind CSS
config file (if found, or the one passed in). If no default config file
or passed in config file can be found, then we will error out (just like
we do now)

---------

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
2024-11-04 16:52:11 +00:00

284 lines
7.1 KiB
TypeScript

import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as util from 'node:util'
import * as postcss from 'postcss'
export type StylesheetId = string
export interface StylesheetConnection {
item: Stylesheet
meta: {
layers: string[]
}
}
export class Stylesheet {
/**
* A unique identifier for this stylesheet
*
* Used to track the stylesheet in PostCSS nodes.
*/
id: StylesheetId
/**
* The PostCSS AST that represents this stylesheet.
*/
root: postcss.Root
/**
* Whether or not this stylesheet is a Tailwind CSS root stylesheet.
*/
isTailwindRoot = false
/**
* The Tailwind config path that is linked to this stylesheet. Essentially the
* contents of `@config`.
*/
linkedConfigPath: string | null = null
/**
* The path to the file that this stylesheet was loaded from.
*
* If this stylesheet was not loaded from a file this will be `null`.
*/
file: string | null = null
/**
* Stylesheets that import this stylesheet.
*/
parents = new Set<StylesheetConnection>()
/**
* Stylesheets that are imported by stylesheet.
*/
children = new Set<StylesheetConnection>()
/**
* Whether or not this stylesheet can be migrated
*/
canMigrate = true
/**
* Whether or not this stylesheet can be migrated
*/
extension: string | null = null
static async load(filepath: string) {
filepath = path.resolve(process.cwd(), filepath)
let css = await fs.readFile(filepath, 'utf-8')
let root = postcss.parse(css, { from: filepath })
return new Stylesheet(root, filepath)
}
static async fromString(css: string) {
let root = postcss.parse(css)
return new Stylesheet(root)
}
static async fromRoot(root: postcss.Root, file?: string) {
return new Stylesheet(root, file)
}
constructor(root: postcss.Root, file?: string) {
this.id = Math.random().toString(36).slice(2)
this.root = root
this.file = file ?? null
if (file) {
this.extension = path.extname(file)
}
}
get importRules() {
let imports = new Set<postcss.AtRule>()
this.root.walkAtRules('import', (rule) => {
imports.add(rule)
})
return imports
}
get isEmpty() {
return this.root.toString().trim() === ''
}
*ancestors() {
for (let { item } of walkDepth(this, (sheet) => sheet.parents)) {
yield item
}
}
*descendants() {
for (let { item } of walkDepth(this, (sheet) => sheet.children)) {
yield item
}
}
/**
* Return the layers the stylesheet is imported into directly or indirectly
*/
layers() {
let layers = new Set<string>()
for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) {
if (item.parents.size > 0) {
continue
}
for (let { meta } of path) {
for (let layer of meta.layers) {
layers.add(layer)
}
}
}
return layers
}
/**
* Iterate all paths from a stylesheet through its ancestors to all roots
*
* For example, given the following structure:
*
* ```
* c.css
* -> a.1.css @import "…"
* -> a.css
* -> root.1.css (utility: no)
* -> root.2.css (utility: no)
* -> b.css
* -> root.1.css (utility: no)
* -> root.2.css (utility: no)
*
* -> a.2.css @import "…" layer(foo)
* -> a.css
* -> root.1.css (utility: no)
* -> root.2.css (utility: no)
* -> b.css
* -> root.1.css (utility: no)
* -> root.2.css (utility: no)
*
* -> b.1.css @import "…" layer(components / utilities)
* -> a.css
* -> root.1.css (utility: yes)
* -> root.2.css (utility: yes)
* -> b.css
* -> root.1.css (utility: yes)
* -> root.2.css (utility: yes)
* ```
*
* We can see there are a total of 12 import paths with various layers.
* We need to be able to iterate every one of these paths and inspect
* the layers used in each path..
*/
*pathsToRoot(): Iterable<StylesheetConnection[]> {
for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) {
// Skip over intermediate stylesheets since all paths from a leaf to a
// root will encompass all possible intermediate stylesheet paths.
if (item.parents.size > 0) {
continue
}
yield path
}
}
/**
* Analyze a stylesheets import paths to see if some can be considered
* for conversion to utility rules and others can't.
*
* If a stylesheet is imported directly or indirectly and some imports are in
* a utility layer and some are not that means that we can't safely convert
* the rules in the stylesheet to `@utility`. Doing so would mean that we
* would need to replicate the stylesheet and change one to have `@utility`
* rules and leave the other as is.
*
* We can see, given the same structure from the `pathsToRoot` example, that
* `css.css` is imported into different layers:
* - `a.1.css` has no layers and should not be converted
* - `a.2.css` has a layer `foo` and should not be converted
* - `b.1.css` has a layer `utilities` (or `components`) which should be
*
* Since this means that `c.css` must both not be converted and converted
* we can't do this without replicating the stylesheet, any ancestors, and
* adjusting imports which is a non-trivial task.
*/
analyzeImportPaths() {
let convertiblePaths: StylesheetConnection[][] = []
let nonConvertiblePaths: StylesheetConnection[][] = []
for (let path of this.pathsToRoot()) {
let isConvertible = false
for (let { meta } of path) {
for (let layer of meta.layers) {
isConvertible ||= layer === 'utilities' || layer === 'components'
}
}
if (isConvertible) {
convertiblePaths.push(path)
} else {
nonConvertiblePaths.push(path)
}
}
return { convertiblePaths, nonConvertiblePaths }
}
containsRule(cb: (rule: postcss.AnyNode) => boolean) {
let contains = false
this.root.walk((rule) => {
if (cb(rule)) {
contains = true
return false
}
})
if (contains) {
return true
}
for (let child of this.children) {
if (child.item.containsRule(cb)) {
return true
}
}
return false
}
[util.inspect.custom]() {
return {
...this,
root: this.root.toString(),
layers: Array.from(this.layers()),
parents: Array.from(this.parents, (s) => s.item.id),
children: Array.from(this.children, (s) => s.item.id),
parentsMeta: Array.from(this.parents, (s) => s.meta),
childrenMeta: Array.from(this.children, (s) => s.meta),
}
}
}
function* walkDepth(
value: Stylesheet,
connections: (value: Stylesheet) => Iterable<StylesheetConnection>,
path: StylesheetConnection[] = [],
): Iterable<{ item: Stylesheet; path: StylesheetConnection[] }> {
for (let connection of connections(value)) {
let newPath = [...path, connection]
yield* walkDepth(connection.item, connections, newPath)
yield {
item: connection.item,
path: newPath,
}
}
}