tailwindcss/packages/@tailwindcss-upgrade/src/stylesheet.ts
Robin Malfait d2865c3e58
Remove layer(utilities) if imports contain @utility (#14738)
We have a migration that adds the `layer(…)` next to the `@import`
depending on the order of original values. For example:
```css
@import "tailwindcss/utilities":
@import "./foo.css":
@import "tailwindcss/components":
```

Will be turned into:
```css
@import "tailwindcss":
@import "./foo.css" layer(utilities):
```

Because it used to exist between `utilities` and `components`. Without
this it would be _after_ `components`.

This results in an issue if an import has (deeply) nested `@utility`
at-rules after migrations. This is because if this is generated:
```css
/* ./src/index.css */
@import "tailwindcss";
@import "./foo.css" layer(utilities);

/* ./src/foo.css */
@utility foo {
  color: red;
}
```

Once we interpret this (and thus flatten it), the final CSS would look
like:
```css
@layer utilities {
  @utility foo {
    color: red;
  }
}
```

This means that `@utility` is not top-level and an error would occur.

This fixes that by removing the `layer(…)` from the import if the
imported file (or any of its children) contains an `@utility`. This is
to ensure that once everything is imported and flattened, that all
`@utility` at-rules are top-level.
2024-10-21 23:46:24 +02:00

249 lines
6.5 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
/**
* 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 }
}
[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,
}
}
}