From d14249ddc20efc892a394ebaf30dc0eefd08c8cb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Sep 2024 18:17:09 +0200 Subject: [PATCH] Add CSS codemods for migrating `@layer utilities` (#14455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- CHANGELOG.md | 3 +- integrations/cli/upgrade.test.ts | 58 +- packages/@tailwindcss-upgrade/package.json | 2 + .../migrate-at-layer-utilities.test.ts | 783 ++++++++++++++++++ .../codemods/migrate-at-layer-utilities.ts | 360 ++++++++ packages/@tailwindcss-upgrade/src/migrate.ts | 2 + packages/tailwindcss/src/candidate.test.ts | 2 +- pnpm-lock.yaml | 27 + 8 files changed, 1234 insertions(+), 3 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 527b87fb7..6922f5bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/cli/upgrade.test.ts b/integrations/cli/upgrade.test.ts index 60eeb06c5..72b1bf4a8 100644 --- a/integrations/cli/upgrade.test.ts +++ b/integrations/cli/upgrade.test.ts @@ -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; + } + `, + ) + }, +) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index 23a3dc3c4..02fdf3685 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -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": { diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts new file mode 100644 index 000000000..a493639f2 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts @@ -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 */" + `) + }) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts new file mode 100644 index 000000000..2bc0c9601 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts @@ -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 { + 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(rule: Walkable, 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, cb) + return result + } + }) + + return result +} + +// Depth first walk reversal implementation. +function walkDepth(rule: Walkable, cb: (rule: T) => void) { + rule?.each?.((node) => { + walkDepth(node as Walkable, 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() + + 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 ` + 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 ` 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() + 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) + } + } + }) + } + }, + } +} diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index db45f099c..88c54350d 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -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) } diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index caa9278d8..11e23c1a3 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -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', () => []) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1f10b82..5bb0394c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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