Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate utilities in CSS files imported into layers #14617

Merged
merged 8 commits into from
Oct 10, 2024
461 changes: 374 additions & 87 deletions integrations/upgrade/index.test.ts

Large diffs are not rendered by default.

28 changes: 27 additions & 1 deletion integrations/utils.ts
Original file line number Diff line number Diff line change
@@ -41,6 +41,7 @@ interface TestContext {
write(filePath: string, content: string): Promise<void>
read(filePath: string): Promise<string>
glob(pattern: string): Promise<[string, string][]>
dumpFiles(pattern: string): Promise<string>
expectFileToContain(
filePath: string,
contents: string | string[] | RegExp | RegExp[],
@@ -113,7 +114,7 @@ export function test(
if (execOptions.ignoreStdErr !== true) console.error(stderr)
reject(error)
} else {
resolve(stdout.toString())
resolve(stdout.toString() + '\n\n' + stderr.toString())
}
},
)
@@ -306,6 +307,31 @@ export function test(
}),
)
},
async dumpFiles(pattern: string) {
let files = await context.fs.glob(pattern)
return `\n${files
.slice()
.sort((a: [string], z: [string]) => {
let aParts = a[0].split('/')
let zParts = z[0].split('/')

let aFile = aParts.at(-1)
let zFile = aParts.at(-1)

// Sort by depth, shallow first
if (aParts.length < zParts.length) return -1
if (aParts.length > zParts.length) return 1

// Sort by filename, sort files named `index` before others
if (aFile?.startsWith('index')) return -1
if (zFile?.startsWith('index')) return 1

// Sort by filename, alphabetically
return a[0].localeCompare(z[0])
})
.map(([file, content]) => `--- ${file} ---\n${content || '<EMPTY>'}`)
.join('\n\n')}`
},
async expectFileToContain(filePath, contents) {
return retryAssertion(async () => {
let fileContent = await this.read(filePath)
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { describe, expect, it } from 'vitest'
import { Stylesheet } from '../stylesheet'
import { formatNodes } from './format-nodes'
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'

const css = dedent

function migrate(input: string) {
async function migrate(
data:
| string
| {
root: postcss.Root
layers?: string[]
},
) {
let stylesheet: Stylesheet

if (typeof data === 'string') {
stylesheet = await Stylesheet.fromString(data)
} else {
stylesheet = await Stylesheet.fromRoot(data.root)

if (data.layers) {
let meta = { layers: data.layers }
let parent = await Stylesheet.fromString('.placeholder {}')

stylesheet.parents.add({ item: parent, meta })
parent.children.add({ item: stylesheet, meta })
}
}

return postcss()
.use(migrateAtLayerUtilities())
.use(migrateAtLayerUtilities(stylesheet))
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.process(stylesheet.root!, { from: expect.getState().testPath })
.then((result) => result.css)
}

@@ -820,3 +844,213 @@ it('should not lose attribute selectors', async () => {
}"
`)
})

describe('layered stylesheets', () => {
it('should transform classes to utilities inside a layered stylesheet (utilities)', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})

it('should transform classes to utilities inside a layered stylesheet (components)', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
`),
layers: ['components'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})

it('should NOT transform classes to utilities inside a non-utility, layered stylesheet', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
`),
layers: ['foo'],
}),
).toMatchInlineSnapshot(`
"/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}"
`)
})

it('should handle non-classes in utility-layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
#main {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"
#main {
color: red;
}

@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})

it('should handle non-classes in utility-layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
@layer utilities {
@layer utilities {
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
}

/* Utility #2 */
.bar {
/* Declarations: */
color: red;
}

#main {
color: red;
}
}

/* Utility #3 */
.baz {
/* Declarations: */
color: red;
}

#secondary {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@layer utilities {

#main {
color: red;
}
}

#secondary {
color: red;
}

@utility foo {
@layer utilities {
@layer utilities {
/* Utility #1 */
/* Declarations: */
color: red;
}
}
}

@utility bar {
@layer utilities {
/* Utility #2 */
/* Declarations: */
color: red;
}
}

@utility baz {
/* Utility #3 */
/* Declarations: */
color: red;
}"
`)
})

it('imports are preserved in layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
@import 'thing';

.foo {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@import 'thing';

@utility foo {
color: red;
}"
`)
})

it('charset is preserved in layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
@charset "utf-8";

.foo {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@charset "utf-8";

@utility foo {
color: red;
}"
`)
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
import SelectorParser from 'postcss-selector-parser'
import { segment } from '../../../tailwindcss/src/utils/segment'
import { Stylesheet } from '../stylesheet'
import { walk, WalkAction, walkDepth } from '../utils/walk'

export function migrateAtLayerUtilities(): Plugin {
export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
function migrate(atRule: AtRule) {
// Only migrate `@layer utilities` and `@layer components`.
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
@@ -86,6 +87,12 @@ export function migrateAtLayerUtilities(): Plugin {
clones.push(clone)

walk(clone, (node) => {
if (node.type === 'atrule') {
if (!node.nodes || node.nodes?.length === 0) {
node.remove()
}
}

if (node.type !== 'rule') return

// Fan out each utility into its own rule.
@@ -186,7 +193,7 @@ export function migrateAtLayerUtilities(): Plugin {

// Mark the node as pretty so that it gets formatted by Prettier later.
clone.raws.tailwind_pretty = true
clone.raws.before += '\n\n'
clone.raws.before = `${clone.raws.before ?? ''}\n\n`
}

// Cleanup
@@ -259,7 +266,16 @@ export function migrateAtLayerUtilities(): Plugin {

return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
OnceExit: (root) => {
OnceExit: (root, { atRule }) => {
let layers = stylesheet.layers()
let isUtilityStylesheet = layers.has('utilities') || layers.has('components')

if (isUtilityStylesheet) {
let rule = atRule({ name: 'layer', params: 'utilities' })
rule.append(root.nodes)
root.append(rule)
}

// Migrate `@layer utilities` and `@layer components` into `@utility`.
// Using this instead of the visitor API in case we want to use
// postcss-nesting in the future.
@@ -282,6 +298,17 @@ export function migrateAtLayerUtilities(): Plugin {
}
})
}

// If the stylesheet is inside a layered import then we can remove the top-level layer directive we added
if (isUtilityStylesheet) {
root.each((node) => {
if (node.type !== 'atrule') return
if (node.name !== 'layer') return
if (node.params !== 'utilities') return

node.replaceWith(node.nodes ?? [])
})
}
},
}
}
Original file line number Diff line number Diff line change
@@ -71,6 +71,7 @@ export function migrateMissingLayers(): Plugin {
if (node.name === 'import') {
if (lastLayer !== '' && !node.params.includes('layer(')) {
node.params += ` layer(${lastLayer})`
node.raws.tailwind_injected_layer = true
}

if (bucket.length > 0) {
@@ -110,7 +111,7 @@ export function migrateMissingLayers(): Plugin {
let target = nodes[0]
let layerNode = new AtRule({
name: 'layer',
params: layerName || firstLayerName || '',
params: targetLayerName,
nodes: nodes.map((node) => {
// Keep the target node as-is, because we will be replacing that one
// with the new layer node.
19 changes: 13 additions & 6 deletions packages/@tailwindcss-upgrade/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import postcss from 'postcss'
import { expect, it } from 'vitest'
import { formatNodes } from './codemods/format-nodes'
import { migrateContents } from './migrate'

const css = dedent
@@ -13,9 +15,15 @@ let designSystem = await __unstable__loadDesignSystem(
)
let config = { designSystem, userConfig: {}, newPrefix: null }

function migrate(input: string, config: any) {
return migrateContents(input, config, expect.getState().testPath)
.then((result) => postcss([formatNodes()]).process(result.root, result.opts))
.then((result) => result.css)
}

it('should print the input as-is', async () => {
expect(
await migrateContents(
await migrate(
css`
/* above */
.foo/* after */ {
@@ -25,7 +33,6 @@ it('should print the input as-is', async () => {
}
`,
config,
expect.getState().testPath,
),
).toMatchInlineSnapshot(`
"/* above */
@@ -39,7 +46,7 @@ it('should print the input as-is', async () => {

it('should migrate a stylesheet', async () => {
expect(
await migrateContents(
await migrate(
css`
@tailwind base;
@@ -116,7 +123,7 @@ it('should migrate a stylesheet', async () => {

it('should migrate a stylesheet (with imports)', async () => {
expect(
await migrateContents(
await migrate(
css`
@import 'tailwindcss/base';
@import './my-base.css';
@@ -137,7 +144,7 @@ it('should migrate a stylesheet (with imports)', async () => {

it('should migrate a stylesheet (with preceding rules that should be wrapped in an `@layer`)', async () => {
expect(
await migrateContents(
await migrate(
css`
@charset "UTF-8";
@layer foo, bar, baz;
@@ -166,7 +173,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in

it('should keep CSS as-is before existing `@layer` at-rules', async () => {
expect(
await migrateContents(
await migrate(
css`
.foo {
color: blue;
60 changes: 58 additions & 2 deletions packages/@tailwindcss-upgrade/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
#!/usr/bin/env node

import { globby } from 'globby'
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import { formatNodes } from './codemods/format-nodes'
import { help } from './commands/help'
import { migrate as migrateStylesheet } from './migrate'
import {
analyze as analyzeStylesheets,
migrate as migrateStylesheet,
split as splitStylesheets,
} from './migrate'
import { migratePostCSSConfig } from './migrate-postcss'
import { Stylesheet } from './stylesheet'
import { migrate as migrateTemplate } from './template/migrate'
import { prepareConfig } from './template/prepare-config'
import { args, type Arg } from './utils/args'
@@ -94,8 +102,56 @@ async function run() {
// Ensure we are only dealing with CSS files
files = files.filter((file) => file.endsWith('.css'))

// Analyze the stylesheets
let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath)))

// Load and parse all stylesheets
for (let result of loadResults) {
if (result.status === 'rejected') {
error(`${result.reason}`)
}
}

let stylesheets = loadResults
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value)

// Analyze the stylesheets
try {
await analyzeStylesheets(stylesheets)
} catch (e: unknown) {
error(`${e}`)
}

// Migrate each file
await Promise.allSettled(files.map((file) => migrateStylesheet(file, config)))
let migrateResults = await Promise.allSettled(
stylesheets.map((sheet) => migrateStylesheet(sheet, config)),
)

for (let result of migrateResults) {
if (result.status === 'rejected') {
error(`${result.reason}`)
}
}

// Split up stylesheets (as needed)
try {
await splitStylesheets(stylesheets)
} catch (e: unknown) {
error(`${e}`)
}

// Format nodes
for (let sheet of stylesheets) {
await postcss([formatNodes()]).process(sheet.root!, { from: sheet.file! })
}

// Write all files to disk
for (let sheet of stylesheets) {
if (!sheet.file) continue

await fs.writeFile(sheet.file, sheet.root.toString())
}

success('Stylesheet migration complete.')
}
395 changes: 384 additions & 11 deletions packages/@tailwindcss-upgrade/src/migrate.ts

Large diffs are not rendered by default.

249 changes: 249 additions & 0 deletions packages/@tailwindcss-upgrade/src/stylesheet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
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 convertablePaths: StylesheetConnection[][] = []
let nonConvertablePaths: StylesheetConnection[][] = []

for (let path of this.pathsToRoot()) {
let isConvertable = false

for (let { meta } of path) {
for (let layer of meta.layers) {
isConvertable ||= layer === 'utilities' || layer === 'components'
}
}

if (isConvertable) {
convertablePaths.push(path)
} else {
nonConvertablePaths.push(path)
}
}

return { convertablePaths, nonConvertablePaths }
}

[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,
}
}
}