Skip to content

Commit

Permalink
Add CSS codemod for missing @layer (#14504)
Browse files Browse the repository at this point in the history
This PR adds a codemod that ensures that some parts of your stylesheet
are wrapped in an `@layer`.

This is a follow-up PR of #14411, in that PR we migrate `@tailwind`
directives to imports.

As a quick summary, that will turn this:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

Into:
```css
@import 'tailwindcss';
```

But there are a few issues with that _if_ we have additional CSS on the
page. For example let's imagine we had this:
```css
@tailwind base;

body {
  background-color: red;
}

@tailwind components;

.btn {}

@tailwind utilities;
```

This will now be turned into:
```css
@import 'tailwindcss';

body {
  background-color: red;
}

.btn {}
```

But in v4 we use real layers, in v3 we used to replace the directive
with the result of that layer. This means that now the `body` and `.btn`
styles are in the incorrect spot.

To solve this, we have to wrap them in a layer. The `body` should go in
an `@layer base`, and the `.btn` should be in an `@layer components` to
make sure it's in the same spot as it was before.

That's what this PR does, the original input will now be turned into:

```css
@import 'tailwindcss';

@layer base {
  body {
    background-color: red;
  }
}

@layer components {
  .btn {
  }
}
```

There are a few internal refactors going on as well, but those are less
important.
  • Loading branch information
RobinMalfait authored Sep 24, 2024
1 parent d14249d commit d869442
Show file tree
Hide file tree
Showing 14 changed files with 423 additions and 80 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add new `shadow-initial` and `inset-shadow-initial` utilities for resetting shadow colors ([#14468](https://github.com/tailwindlabs/tailwindcss/pull/14468))
- Add `field-sizing-*` utilities ([#14469](https://github.com/tailwindlabs/tailwindcss/pull/14469))
- Include gradient color properties in color transitions ([#14489](https://github.com/tailwindlabs/tailwindcss/pull/14489))
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411))
- _Experimental_: Add CSS codemods for `@apply` ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14434))
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411), [#14504](https://github.com/tailwindlabs/tailwindcss/pull/14504))
- _Experimental_: Add CSS codemods for migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455))

### Fixed
Expand Down
32 changes: 31 additions & 1 deletion integrations/cli/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,45 @@ test(
`,
'src/index.css': css`
@tailwind base;
html {
color: #333;
}
@tailwind components;
.btn {
color: red;
}
@tailwind utilities;
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade')

await fs.expectFileToContain('src/index.css', css` @import 'tailwindcss'; `)
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.css',
css`
@layer base {
html {
color: #333;
}
}
`,
)
await fs.expectFileToContain(
'src/index.css',
css`
@layer components {
.btn {
color: red;
}
}
`,
)
},
)

Expand Down
35 changes: 35 additions & 0 deletions packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import postcss, { type Plugin } from 'postcss'
import { expect, it } from 'vitest'
import { formatNodes } from './format-nodes'

function markPretty(): Plugin {
return {
postcssPlugin: '@tailwindcss/upgrade/mark-pretty',
OnceExit(root) {
root.walkAtRules('utility', (atRule) => {
atRule.raws.tailwind_pretty = true
})
},
}
}

function migrate(input: string) {
return postcss()
.use(markPretty())
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
}

it('should format PostCSS nodes that are marked with tailwind_pretty', async () => {
expect(
await migrate(`
@utility .foo { .foo { color: red; } }`),
).toMatchInlineSnapshot(`
"@utility .foo {
.foo {
color: red;
}
}"
`)
})
30 changes: 30 additions & 0 deletions packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { parse, type ChildNode, type Plugin, type Root } from 'postcss'
import { format } from 'prettier'
import { walk, WalkAction } from '../utils/walk'

// Prettier is used to generate cleaner output, but it's only used on the nodes
// that were marked as `pretty` during the migration.
export function formatNodes(): Plugin {
async function migrate(root: Root) {
// Find the nodes to format
let nodesToFormat: ChildNode[] = []
walk(root, (child) => {
if (child.raws.tailwind_pretty) {
nodesToFormat.push(child)
return WalkAction.Skip
}
})

// Format the nodes
await Promise.all(
nodesToFormat.map(async (node) => {
node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true })))
}),
)
}

return {
postcssPlugin: '@tailwindcss/upgrade/format-nodes',
OnceExit: migrate,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export function migrateAtApply(): Plugin {
let params = utilities.map((part) => {
// Keep whitespace
if (part.trim() === '') return part

let variants = segment(part, ':')
let utility = variants.pop()!

Expand All @@ -36,8 +35,8 @@ export function migrateAtApply(): Plugin {

return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply',
AtRule: {
apply: migrate,
OnceExit(root) {
root.walkAtRules('apply', migrate)
},
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { describe, expect, it } from 'vitest'
import { formatNodes } from './format-nodes'
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'

const css = dedent

function migrate(input: string) {
return postcss()
.use(migrateAtLayerUtilities())
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,65 +1,16 @@
import { AtRule, parse, Rule, type ChildNode, type Comment, type Plugin } from 'postcss'
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
import SelectorParser from 'postcss-selector-parser'
import { format } from 'prettier'
import { segment } from '../../../tailwindcss/src/utils/segment'

enum WalkAction {
// Continue walking the tree. Default behavior.
Continue,

// Skip walking into the current node.
Skip,

// Stop walking the tree entirely.
Stop,
}

interface Walkable<T> {
each(cb: (node: T, index: number) => void): void
}

// Custom walk implementation where we can skip going into nodes when we don't
// need to process them.
function walk<T>(rule: Walkable<T>, cb: (rule: T) => void | WalkAction): undefined | false {
let result: undefined | false = undefined

rule.each?.((node) => {
let action = cb(node) ?? WalkAction.Continue
if (action === WalkAction.Stop) {
result = false
return result
}
if (action !== WalkAction.Skip) {
result = walk(node as Walkable<T>, cb)
return result
}
})

return result
}

// Depth first walk reversal implementation.
function walkDepth<T>(rule: Walkable<T>, cb: (rule: T) => void) {
rule?.each?.((node) => {
walkDepth(node as Walkable<T>, cb)
cb(node)
})
}
import { walk, WalkAction, walkDepth } from '../utils/walk'

export function migrateAtLayerUtilities(): Plugin {
function migrate(atRule: AtRule) {
// Only migrate `@layer utilities` and `@layer components`.
if (atRule.params !== 'utilities' && atRule.params !== 'components') return

// If the `@layer utilities` contains CSS that should not be turned into an
// `@utility` at-rule, then we have to keep it around (including the
// `@layer utilities` wrapper). To prevent this from being processed over
// and over again, we mark it as seen and bail early.
if (atRule.raws.seen) return

// Keep rules that should not be turned into utilities as is. This will
// include rules with element or ID selectors.
let defaultsAtRule = atRule.clone({ raws: { seen: true } })
let defaultsAtRule = atRule.clone()

// Clone each rule with multiple selectors into their own rule with a single
// selector.
Expand Down Expand Up @@ -312,32 +263,12 @@ export function migrateAtLayerUtilities(): Plugin {

return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
OnceExit: async (root) => {
OnceExit: (root) => {
// 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.
root.walkAtRules('layer', migrate)

// Prettier is used to generate cleaner output, but it's only used on the
// nodes that were marked as `pretty` during the migration.
{
// Find the nodes to format
let nodesToFormat: ChildNode[] = []
walk(root, (child) => {
if (child.raws.tailwind_pretty) {
nodesToFormat.push(child)
return WalkAction.Skip
}
})

// Format the nodes
await Promise.all(
nodesToFormat.map(async (node) => {
node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true })))
}),
)
}

// Merge `@utility <name>` with the same name into a single rule. This can
// happen when the same classes is used in multiple `@layer utilities`
// blocks.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { expect, it } from 'vitest'
import { formatNodes } from './format-nodes'
import { migrateMissingLayers } from './migrate-missing-layers'

const css = dedent

function migrate(input: string) {
return postcss()
.use(migrateMissingLayers())
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
}

it('should migrate rules between tailwind directives', async () => {
expect(
await migrate(css`
@tailwind base;
.base {
}
@tailwind components;
.component-a {
}
.component-b {
}
@tailwind utilities;
.utility-a {
}
.utility-b {
}
`),
).toMatchInlineSnapshot(`
"@tailwind base;
@layer base {
.base {
}
}
@tailwind components;
@layer components {
.component-a {
}
.component-b {
}
}
@tailwind utilities;
@layer utilities {
.utility-a {
}
.utility-b {
}
}"
`)
})
Loading

0 comments on commit d869442

Please sign in to comment.