Add CSS codemods for migrating @layer utilities (#14455)

This PR adds CSS codemods for migrating existing `@layer utilities` to
`@utility` directives.

This PR has the ability to migrate the following cases:

---

The most basic case is when you want to migrate a simple class to a
utility directive.

Input:
```css
@layer utilities {
  .foo {
    color: red;
  }

  .bar {
    color: blue;
  }
}
```

Output:
```css
@utility foo {
  color: red;
}

@utility bar {
  color: blue;
}
```

You'll notice that the class `foo` will be used as the utility name, the
declarations (and the rest of the body of the rule) will become the body
of the `@utility` definition.

---

In v3, every class in a selector will become a utility. To correctly
migrate this to `@utility` directives, we have to register each class in
the selector and generate `n` utilities.

We can use nesting syntax, and replace the current class with `&` to
ensure that the final result behaves the same.

Input:
```css
@layer utilities {
  .foo .bar .baz {
    color: red;
  }
}
```

Output:
```css
@utility foo {
  & .bar .baz {
    color: red;
  }
}

@utility bar {
  .foo & .baz {
    color: red;
  }
}

@utility .baz {
  .foo .bar & {
    color: red;
  }
}
```

In this case, it could be that you know that some of them will never be
used as a utility (e.g.: `hover:bar`), but then you can safely remove
them.

---

Even classes inside of `:has(…)` will become a utility. The only
exception to the rule is that we don't do it for `:not(…)`.

Input:
```css
@layer utilities {
  .foo .bar:not(.qux):has(.baz) {
    display: none;
  }
}
```

Output:
```css
@utility foo {
  & .bar:not(.qux):has(.baz) {
    display: none;
  }
}

@utility bar {
  .foo &:not(.qux):has(.baz) {
    display: none;
  }
}

@utility baz {
  .foo .bar:not(.qux):has(&) {
    display: none;
  }
}
```

Notice that there is no `@utility qux` because it was used inside of
`:not(…)`.

---

When classes are nested inside at-rules, then these classes will also
become utilities. However, the `@utility <name>` will be at the top and
the at-rules will live inside of it. If there are multiple classes
inside a shared at-rule, then the at-rule will be duplicated for each
class.

Let's look at an example to make it more clear:

Input:
```css
@layer utilities {
  @media (min-width: 640px) {
    .foo {
      color: red;
    }

    .bar {
      color: blue;
    }

    @media (min-width: 1024px) {
      .baz {
        color: green;
      }

      @media (min-width: 1280px) {
        .qux {
          color: yellow;
        }
      }
    }
  }
}
```

Output:
```css
@utility foo {
  @media (min-width: 640px) {
    color: red;
  }
}

@utility bar {
  @media (min-width: 640px) {
    color: blue;
  }
}

@utility baz {
  @media (min-width: 640px) {
    @media (min-width: 1024px) {
      color: green;
    }
  }
}

@utility qux {
  @media (min-width: 640px) {
    @media (min-width: 1024px) {
      @media (min-width: 1280px) {
        color: yellow;
      }
    }
  }
}
```

---

When classes result in multiple `@utility` directives with the same
name, then the definitions will be merged together.

Input:
```css
@layer utilities {
  .no-scrollbar::-webkit-scrollbar {
    display: none;
  }

  .no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
}
```

Intermediate representation:
```css
@utility no-scrollbar {
  &::-webkit-scrollbar {
    display: none;
  }
}

@utility no-scrollbar {
  -ms-overflow-style: none;
  scrollbar-width: none;
}
```

Output:
```css
@utility no-scrollbar {
  &::-webkit-scrollbar {
    display: none;
  }
  -ms-overflow-style: none;
  scrollbar-width: none
}
```

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait 2024-09-24 18:17:09 +02:00 committed by GitHub
parent abde4c9694
commit d14249ddc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1234 additions and 3 deletions

View File

@ -11,12 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `aria`, `supports`, and `data` variants defined in JS config files ([#14407](https://github.com/tailwindlabs/tailwindcss/pull/14407))
- Add `@tailwindcss/upgrade` tooling ([#14434](https://github.com/tailwindlabs/tailwindcss/pull/14434))
- Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411))
- Support `screens` in JS config files ([#14415](https://github.com/tailwindlabs/tailwindcss/pull/14415))
- Add `bg-radial-*` and `bg-conic-*` utilities for radial and conic gradients ([#14467](https://github.com/tailwindlabs/tailwindcss/pull/14467))
- 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 migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455))
### Fixed

View File

@ -52,7 +52,7 @@ test(
)
test(
'migrate @tailwind directives',
'migrate `@tailwind` directives',
{
fs: {
'package.json': json`
@ -76,3 +76,59 @@ test(
await fs.expectFileToContain('src/index.css', css` @import 'tailwindcss'; `)
},
)
test(
'migrate `@layer utilities` and `@layer components`',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.css': css`
@import 'tailwindcss';
@layer components {
.btn {
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
}
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain(
'src/index.css',
css`
@utility btn {
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
}
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
`,
)
},
)

View File

@ -33,6 +33,8 @@
"picocolors": "^1.0.1",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"postcss-selector-parser": "^6.1.2",
"prettier": "^3.3.3",
"tailwindcss": "workspace:^"
},
"devDependencies": {

View File

@ -0,0 +1,783 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { describe, expect, it } from 'vitest'
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
const css = dedent
function migrate(input: string) {
return postcss()
.use(migrateAtLayerUtilities())
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
}
it('should migrate simple `@layer utilities` to `@utility`', async () => {
expect(
await migrate(css`
@layer utilities {
.foo {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
color: red;
}"
`)
})
it('should split multiple selectors in separate utilities', async () => {
expect(
await migrate(css`
@layer utilities {
.foo,
.bar {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
color: red;
}
@utility bar {
color: red;
}"
`)
})
it('should merge `@utility` with the same name', async () => {
expect(
await migrate(css`
@layer utilities {
.foo {
color: red;
}
}
.bar {
color: blue;
}
@layer utilities {
.foo {
font-weight: bold;
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
color: red;
font-weight: bold;
}
.bar {
color: blue;
}"
`)
})
it('should leave non-class utilities alone', async () => {
expect(
await migrate(css`
@layer utilities {
/* 1. */
#before {
/* 1.1. */
color: red;
/* 1.2. */
.bar {
/* 1.2.1. */
font-weight: bold;
}
}
/* 2. */
.foo {
/* 2.1. */
color: red;
/* 2.2. */
.bar {
/* 2.2.1. */
font-weight: bold;
}
}
/* 3. */
#after {
/* 3.1. */
color: blue;
/* 3.2. */
.bar {
/* 3.2.1. */
font-weight: bold;
}
}
}
`),
).toMatchInlineSnapshot(`
"@layer utilities {
/* 1. */
#before {
/* 1.1. */
color: red;
/* 1.2. */
.bar {
/* 1.2.1. */
font-weight: bold;
}
}
/* 3. */
#after {
/* 3.1. */
color: blue;
/* 3.2. */
.bar {
/* 3.2.1. */
font-weight: bold;
}
}
}
@utility foo {
/* 2. */
/* 2.1. */
color: red;
/* 2.2. */
.bar {
/* 2.2.1. */
font-weight: bold;
}
}"
`)
})
it('should migrate simple `@layer utilities` with nesting to `@utility`', async () => {
expect(
await migrate(css`
@layer utilities {
.foo {
color: red;
&:hover {
color: blue;
}
&:focus {
color: green;
}
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
color: red;
&:hover {
color: blue;
}
&:focus {
color: green;
}
}"
`)
})
it('should migrate multiple simple `@layer utilities` to `@utility`', async () => {
expect(
await migrate(css`
@layer utilities {
.foo {
color: red;
}
.bar {
color: blue;
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
color: red;
}
@utility bar {
color: blue;
}"
`)
})
it('should not migrate Rules inside of Rules to a `@utility`', async () => {
expect(
await migrate(css`
@layer utilities {
.foo {
color: red;
}
.bar {
color: blue;
.baz {
color: green;
}
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
color: red;
}
@utility bar {
color: blue;
.baz {
color: green;
}
}"
`)
})
it('should invert at-rules to make them migrate-able', async () => {
expect(
await migrate(css`
@layer utilities {
@media (min-width: 640px) {
.foo {
color: red;
}
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
@media (min-width: 640px) {
color: red;
}
}"
`)
})
it('should migrate at-rules with multiple utilities and invert them', async () => {
expect(
await migrate(css`
@layer utilities {
@media (min-width: 640px) {
.foo {
color: red;
}
}
}
@layer utilities {
@media (min-width: 640px) {
.bar {
color: blue;
}
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
@media (min-width: 640px) {
color: red;
}
}
@utility bar {
@media (min-width: 640px) {
color: blue;
}
}"
`)
})
it('should migrate deeply nested at-rules with multiple utilities and invert them', async () => {
expect(
await migrate(css`
@layer utilities {
@media (min-width: 640px) {
.foo {
color: red;
}
.bar {
color: blue;
}
@media (min-width: 1024px) {
.baz {
color: green;
}
@media (min-width: 1280px) {
.qux {
color: yellow;
}
}
}
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
@media (min-width: 640px) {
color: red;
}
}
@utility bar {
@media (min-width: 640px) {
color: blue;
}
}
@utility baz {
@media (min-width: 640px) {
@media (min-width: 1024px) {
color: green;
}
}
}
@utility qux {
@media (min-width: 640px) {
@media (min-width: 1024px) {
@media (min-width: 1280px) {
color: yellow;
}
}
}
}"
`)
})
it('should migrate classes with pseudo elements', async () => {
expect(
await migrate(css`
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
}"
`)
})
it('should migrate classes with attribute selectors', async () => {
expect(
await migrate(css`
@layer utilities {
.no-scrollbar[data-checked=''] {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
&[data-checked=""] {
display: none;
}
}"
`)
})
it('should migrate classes with element selectors', async () => {
expect(
await migrate(css`
@layer utilities {
.no-scrollbar main {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
& main {
display: none;
}
}"
`)
})
it('should migrate classes attached to an element selector', async () => {
expect(
await migrate(css`
@layer utilities {
main.no-scrollbar {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
&main {
display: none;
}
}"
`)
})
it('should migrate classes with id selectors', async () => {
expect(
await migrate(css`
@layer utilities {
.no-scrollbar#main {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
&#main {
display: none;
}
}"
`)
})
it('should migrate classes with another attached class', async () => {
expect(
await migrate(css`
@layer utilities {
.no-scrollbar.main {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
&.main {
display: none;
}
}
@utility main {
&.no-scrollbar {
display: none;
}
}"
`)
})
it('should migrate a selector with multiple classes to multiple @utility definitions', async () => {
expect(
await migrate(css`
@layer utilities {
.foo .bar:hover .baz:focus {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
& .bar:hover .baz:focus {
display: none;
}
}
@utility bar {
.foo &:hover .baz:focus {
display: none;
}
}
@utility baz {
.foo .bar:hover &:focus {
display: none;
}
}"
`)
})
it('should merge `@utility` definitions with the same name', async () => {
expect(
await migrate(css`
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute w-7 h-7 bg-default-100 rounded-full font-medium text-center text-base inline-flex items-center justify-center -indent-px;
@apply ml-[-41px];
content: counter(step);
}
}
`),
).toMatchInlineSnapshot(`
"@utility step {
counter-increment: step;
&:before {
@apply absolute w-7 h-7 bg-default-100 rounded-full font-medium text-center text-base inline-flex items-center justify-center -indent-px;
@apply ml-[-41px];
content: counter(step);
}
}"
`)
})
it('should not migrate nested classes inside a `:not(…)`', async () => {
expect(
await migrate(css`
@layer utilities {
.foo .bar:not(.qux):has(.baz) {
display: none;
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
& .bar:not(.qux):has(.baz) {
display: none;
}
}
@utility bar {
.foo &:not(.qux):has(.baz) {
display: none;
}
}
@utility baz {
.foo .bar:not(.qux):has(&) {
display: none;
}
}"
`)
})
it('should migrate advanced combinations', async () => {
expect(
await migrate(css`
@layer utilities {
@media (width >= 100px) {
@supports (display: none) {
.foo .bar:not(.qux):has(.baz) {
display: none;
}
}
.bar {
color: red;
}
}
@media (width >= 200px) {
.foo {
&:hover {
@apply bg-red-500;
.bar {
color: red;
}
}
}
}
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
@media (width >= 100px) {
@supports (display: none) {
& .bar:not(.qux):has(.baz) {
display: none;
}
}
}
@media (width >= 200px) {
&:hover {
@apply bg-red-500;
.bar {
color: red;
}
}
}
}
@utility bar {
@media (width >= 100px) {
@supports (display: none) {
.foo &:not(.qux):has(.baz) {
display: none;
}
}
color: red;
}
}
@utility baz {
@media (width >= 100px) {
@supports (display: none) {
.foo .bar:not(.qux):has(&) {
display: none;
}
}
}
}"
`)
})
describe('comments', () => {
it('should preserve comment location for a simple utility', async () => {
expect(
await migrate(css`
/* Start of utilities: */
@layer utilities {
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
}
`),
).toMatchInlineSnapshot(`
"/* Start of utilities: */
@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})
it('should copy comments when creating multiple utilities from a single selector', async () => {
expect(
await migrate(css`
/* Start of utilities: */
@layer utilities {
/* Foo & Bar */
.foo .bar {
/* Declarations: */
color: red;
}
}
`),
).toMatchInlineSnapshot(`
"/* Start of utilities: */
@utility foo {
/* Foo & Bar */
& .bar {
/* Declarations: */
color: red;
}
}
@utility bar {
/* Foo & Bar */
.foo & {
/* Declarations: */
color: red;
}
}"
`)
})
it('should preserve comments for utilities wrapped in at-rules', async () => {
expect(
await migrate(css`
/* Start of utilities: */
@layer utilities {
/* Mobile only */
@media (width <= 640px) {
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
}
}
`),
).toMatchInlineSnapshot(`
"/* Start of utilities: */
@utility foo {
/* Mobile only */
@media (width <= 640px) {
/* Utility #1 */
/* Declarations: */
color: red;
}
}"
`)
})
it('should preserve comment locations as best as possible', async () => {
expect(
await migrate(css`
/* Above */
.before {
/* Inside */
}
/* After */
/* Tailwind Utilities: */
@layer utilities {
/* Chrome, Safari and Opera */
/* Second comment */
@media (min-width: 640px) {
/* Foobar */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}
/* Firefox, IE and Edge */
/* Second comment */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
/* Above */
.after {
/* Inside */
}
/* After */
`),
).toMatchInlineSnapshot(`
"/* Above */
.before {
/* Inside */
}
/* After */
/* Tailwind Utilities: */
@utility no-scrollbar {
/* Chrome, Safari and Opera */
/* Second comment */
@media (min-width: 640px) {
/* Foobar */
&::-webkit-scrollbar {
display: none;
}
}
/* Firefox, IE and Edge */
/* Second comment */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Above */
.after {
/* Inside */
}
/* After */"
`)
})
})

View File

@ -0,0 +1,360 @@
import { AtRule, parse, Rule, type ChildNode, type Comment, type Plugin } 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)
})
}
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 } })
// Clone each rule with multiple selectors into their own rule with a single
// selector.
walk(atRule, (node) => {
if (node.type !== 'rule') return
// Clone the node for each selector
let selectors = segment(node.selector, ',')
if (selectors.length > 1) {
let clonedNodes: Rule[] = []
for (let selector of selectors) {
let clone = node.clone({ selector })
clonedNodes.push(clone)
}
node.replaceWith(clonedNodes)
}
return WalkAction.Skip
})
// Track all the classes that we want to create an `@utility` for.
let classes = new Set<string>()
walk(atRule, (node) => {
if (node.type !== 'rule') return
// Find all the classes in the selector
SelectorParser((selectors) => {
selectors.each((selector) => {
walk(selector, (selectorNode) => {
// Ignore everything in `:not(…)`
if (selectorNode.type === 'pseudo' && selectorNode.value === ':not') {
return WalkAction.Skip
}
if (selectorNode.type === 'class') {
classes.add(selectorNode.value)
}
})
})
}).processSync(node.selector, { updateSelector: false })
return WalkAction.Skip
})
// Remove all the nodes from the default `@layer utilities` that we know
// should be turned into `@utility` at-rules.
walk(defaultsAtRule, (node) => {
if (node.type !== 'rule') return
SelectorParser((selectors) => {
selectors.each((selector) => {
walk(selector, (selectorNode) => {
// Ignore everything in `:not(…)`
if (selectorNode.type === 'pseudo' && selectorNode.value === ':not') {
return WalkAction.Skip
}
// Remove the node if the class is in the list
if (selectorNode.type === 'class' && classes.has(selectorNode.value)) {
node.remove()
return WalkAction.Stop
}
})
node.selector = selector.toString()
})
}).processSync(node.selector, { updateSelector: false })
})
// Upgrade every Rule in `@layer utilities` to an `@utility` at-rule.
let clones: AtRule[] = [defaultsAtRule]
for (let cls of classes) {
let clone = atRule.clone()
clones.push(clone)
walk(clone, (node) => {
if (node.type !== 'rule') return
// Fan out each utility into its own rule.
//
// E.g.:
// ```css
// .foo .bar:hover .baz {
// color: red;
// }
// ```
//
// Becomes:
// ```css
// @utility foo {
// & .bar:hover .baz {
// color: red;
// }
// }
//
// @utility bar {
// .foo &:hover .baz {
// color: red;
// }
// }
//
// @utility baz {
// .foo .bar:hover & {
// color: red;
// }
// }
// ```
let containsClass = false
SelectorParser((selectors) => {
selectors.each((selector) => {
walk(selector, (selectorNode) => {
// Ignore everything in `:not(…)`
if (selectorNode.type === 'pseudo' && selectorNode.value === ':not') {
return WalkAction.Skip
}
// Replace the class with `&` and track the new selector
if (selectorNode.type === 'class' && selectorNode.value === cls) {
containsClass = true
// Find the node in the clone based on the position of the
// original node.
let target = selector.atPosition(
selectorNode.source!.start!.line,
selectorNode.source!.start!.column,
)
// Keep moving the target to the front until we hit the start or
// find a combinator. This is to prevent `.foo.bar` from
// becoming `.bar&`. Instead we want `&.bar`.
let parent = target.parent!
let idx = (target.parent?.index(target) ?? 0) - 1
while (idx >= 0 && parent.at(idx)?.type !== 'combinator') {
let current = parent.at(idx + 1)
let previous = parent.at(idx)
parent.at(idx + 1).replaceWith(previous)
parent.at(idx).replaceWith(current)
idx--
}
// Replace the class with `&`
target.replaceWith(SelectorParser.nesting())
}
})
})
// Update the selector
node.selector = selectors.toString()
}).processSync(node.selector)
// Cleanup all the nodes that should not be part of the `@utility` rule.
if (!containsClass) {
let toRemove: (Comment | Rule)[] = [node]
let idx = node.parent?.index(node) ?? null
if (idx !== null) {
for (let i = idx - 1; i >= 0; i--) {
if (node.parent?.nodes.at(i)?.type === 'rule') {
break
}
if (node.parent?.nodes.at(i)?.type === 'comment') {
toRemove.push(node.parent?.nodes.at(i) as Comment)
}
}
}
for (let node of toRemove) {
node.remove()
}
}
return WalkAction.Skip
})
// Migrate the `@layer utilities` to `@utility <name>`
clone.name = 'utility'
clone.params = cls
// Mark the node as pretty so that it gets formatted by Prettier later.
clone.raws.tailwind_pretty = true
clone.raws.before += '\n\n'
}
// Cleanup
for (let idx = clones.length - 1; idx >= 0; idx--) {
let clone = clones[idx]
walkDepth(clone, (node) => {
// Remove comments from the main `@layer utilities` we want to keep,
// that are part of any of the other clones.
if (clone === defaultsAtRule) {
if (node.type === 'comment') {
let found = false
for (let other of clones) {
if (other === defaultsAtRule) continue
walk(other, (child) => {
if (
child.type === 'comment' &&
child.source?.start?.offset === node.source?.start?.offset
) {
node.remove()
found = true
return WalkAction.Stop
}
})
if (found) {
return WalkAction.Skip
}
}
}
}
// Remove empty rules
if ((node.type === 'rule' || node.type === 'atrule') && node.nodes?.length === 0) {
node.remove()
}
// Replace `&` selectors with its children
else if (node.type === 'rule' && node.selector === '&') {
interface PostCSSNode {
type: string
parent?: PostCSSNode
}
let parent: PostCSSNode | undefined = node.parent
let skip = false
while (parent) {
if (parent.type === 'rule') {
skip = true
break
}
parent = parent.parent
}
if (!skip) node.replaceWith(node.nodes)
}
})
// Remove empty clones entirely
if (clone.nodes?.length === 0) {
clones.splice(idx, 1)
}
}
// Finally, replace the original `@layer utilities` with the new rules.
atRule.replaceWith(clones)
}
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
OnceExit: async (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.
{
let utilities = new Map<string, AtRule>()
walk(root, (child) => {
if (child.type === 'atrule' && child.name === 'utility') {
let existing = utilities.get(child.params)
if (existing) {
existing.append(child.nodes!)
child.remove()
} else {
utilities.set(child.params, child)
}
}
})
}
},
}
}

View File

@ -2,12 +2,14 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import { migrateAtApply } from './codemods/migrate-at-apply'
import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
export async function migrateContents(contents: string, file?: string) {
return postcss()
.use(migrateAtApply())
.use(migrateTailwindDirectives())
.use(migrateAtLayerUtilities())
.process(contents, { from: file })
.then((result) => result.css)
}

View File

@ -483,7 +483,7 @@ it('should parse a utility with a modifier and a variant', () => {
`)
})
it.skip('should not parse a partial utility', () => {
it('should not parse a partial utility', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])
utilities.functional('bg', () => [])

27
pnpm-lock.yaml generated
View File

@ -288,6 +288,12 @@ importers:
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.41)
postcss-selector-parser:
specifier: ^6.1.2
version: 6.1.2
prettier:
specifier: ^3.3.3
version: 3.3.3
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@ -1533,6 +1539,11 @@ packages:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -2563,6 +2574,10 @@ packages:
yaml:
optional: true
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -3026,6 +3041,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite-node@2.0.5:
resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -4122,6 +4140,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
cssesc@3.0.0: {}
csstype@3.1.3: {}
damerau-levenshtein@1.0.8: {}
@ -5310,6 +5330,11 @@ snapshots:
postcss: 8.4.41
yaml: 2.5.0
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-value-parser@4.2.0: {}
postcss@8.4.31:
@ -5803,6 +5828,8 @@ snapshots:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
vite-node@2.0.5(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6):
dependencies:
cac: 6.7.14