mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
abde4c9694
commit
d14249ddc2
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 */"
|
||||
`)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
27
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user