From abde4c9694a2d5bc5d57e7b513a680d3cc6fe94e Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Tue, 24 Sep 2024 11:43:57 -0400 Subject: [PATCH 01/12] Only apply hover on devices that support hover (#14500) This PR updates the `hover` variant to only apply when `@media (hover: hover)` matches. ```diff .hover\:bg-black { &:hover { + @media (hover: hover) { background: black; + } } } ``` This is technically a breaking change because you may have built your site in a way where some interactions depend on hover (like opening a dropdown menu), and were relying on the fact that tapping on mobile triggers hover. To bring back the old hover behavior, users can override the `hover` variant in their CSS file back to the simpler implementation: ```css @import "tailwindcss"; @variant hover (&:hover); ``` I've opted to go with just `@media (hover: hover)` for this because it seems like the best trade-off between the available options. Using `(any-hover: hover)` would mean users would get sticky hover states when tapping on an iPad if they have a mouse or trackpad connected, which feels wrong to me because in those cases touch is still likely the primary method of interaction. Sites built with this feature in mind will be treating hover styles as progressive enhancement, so it seems better to me that using an iPad with a mouse would not have hover styles, vs. having sticky hover styles in the same situation. Of course users can always override this with whatever they want, so making this the default isn't locking anyone in to a particular choice. --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + packages/tailwindcss/playwright.config.ts | 26 +++- .../tailwindcss/src/compat/plugin-api.test.ts | 6 +- packages/tailwindcss/src/index.test.ts | 136 ++++++++++++------ packages/tailwindcss/src/utilities.test.ts | 10 +- packages/tailwindcss/src/variants.test.ts | 122 ++++++++++------ packages/tailwindcss/src/variants.ts | 101 ++++++------- 7 files changed, 249 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8649e22891bd..527b87fb733c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve explicit transition duration and timing function when overriding transition property ([#14490](https://github.com/tailwindlabs/tailwindcss/pull/14490)) - Change the implementation for `@import` resolution to speed up initial builds ([#14446](https://github.com/tailwindlabs/tailwindcss/pull/14446)) - Remove automatic `var(…)` injection ([#13657](https://github.com/tailwindlabs/tailwindcss/pull/13657)) +- Only apply `:hover` states on devices that support `@media (hover: hover)` ([#14500](https://github.com/tailwindlabs/tailwindcss/pull/14500)) ## [4.0.0-alpha.24] - 2024-09-11 diff --git a/packages/tailwindcss/playwright.config.ts b/packages/tailwindcss/playwright.config.ts index 8b728ca82e19..c0a123abf5db 100644 --- a/packages/tailwindcss/playwright.config.ts +++ b/packages/tailwindcss/playwright.config.ts @@ -42,7 +42,31 @@ export default defineConfig({ }, { name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + use: { + ...devices['Desktop Firefox'], + // https://playwright.dev/docs/test-use-options#more-browser-and-context-options + launchOptions: { + // https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-firefox-user-prefs + firefoxUserPrefs: { + // By default, headless Firefox runs as though no pointers + // capabilities are available. + // https://github.com/microsoft/playwright/issues/7769#issuecomment-966098074 + // + // This impacts our `hover` variant implementation which uses an + // '(hover: hover)' media query to determine if hover is available. + // + // Available values for pointer capabilities: + // NO_POINTER = 0x00; + // COARSE_POINTER = 0x01; + // FINE_POINTER = 0x02; + // HOVER_CAPABLE_POINTER = 0x04; + // + // Setting to 0x02 | 0x04 says the system supports a mouse + 'ui.primaryPointerCapabilities': 0x02 | 0x04, + 'ui.allPointerCapabilities': 0x02 | 0x04, + }, + }, + }, }, /* Test against mobile viewports. */ diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 7d732ca1ccf1..cbf4b29ece79 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1919,8 +1919,10 @@ describe('matchVariant', () => { "@layer utilities { @media (width >= 100px) { @media (width <= 200px) { - .testmin-\\[100px\\]\\:testmax-\\[200px\\]\\:hover\\:underline:hover { - text-decoration-line: underline; + @media (hover: hover) { + .testmin-\\[100px\\]\\:testmax-\\[200px\\]\\:hover\\:underline:hover { + text-decoration-line: underline; + } } } } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 0f56d3430d3a..1305b9415fe6 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -35,8 +35,10 @@ describe('compiling CSS', () => { display: flex; } - .hover\\:underline:hover { - text-decoration-line: underline; + @media (hover: hover) { + .hover\\:underline:hover { + text-decoration-line: underline; + } } @media (width >= 768px) { @@ -193,8 +195,10 @@ describe('@apply', () => { text-decoration-line: underline; } - .foo:hover { - background-color: var(--color-blue-500, #3b82f6); + @media (hover: hover) { + .foo:hover { + background-color: var(--color-blue-500, #3b82f6); + } } @media (width >= 768px) { @@ -390,16 +394,20 @@ describe('arbitrary variants', () => { describe('variant stacking', () => { it('should stack simple variants', async () => { expect(await run(['focus:hover:flex'])).toMatchInlineSnapshot(` - ".focus\\:hover\\:flex:focus:hover { - display: flex; + "@media (hover: hover) { + .focus\\:hover\\:flex:focus:hover { + display: flex; + } }" `) }) it('should stack arbitrary variants and simple variants', async () => { expect(await run(['[&_p]:hover:flex'])).toMatchInlineSnapshot(` - ".\\[\\&_p\\]\\:hover\\:flex p:hover { - display: flex; + "@media (hover: hover) { + .\\[\\&_p\\]\\:hover\\:flex p:hover { + display: flex; + } }" `) }) @@ -420,13 +428,17 @@ describe('variant stacking', () => { content: var(--tw-content); } - .before\\:hover\\:flex:before:hover { - display: flex; + @media (hover: hover) { + .before\\:hover\\:flex:before:hover { + display: flex; + } } - .hover\\:before\\:flex:hover:before { - content: var(--tw-content); - display: flex; + @media (hover: hover) { + .hover\\:before\\:flex:hover:before { + content: var(--tw-content); + display: flex; + } } @supports (-moz-orient: inline) { @@ -627,22 +639,24 @@ describe('sorting', () => { ), ), ).toMatchInlineSnapshot(` - ".pointer-events-none { - pointer-events: none; - } + ".pointer-events-none { + pointer-events: none; + } - .flex { - display: flex; - } + .flex { + display: flex; + } + @media (hover: hover) { .hover\\:flex:hover { display: flex; } + } - .focus\\:pointer-events-none:focus { - pointer-events: none; - }" - `) + .focus\\:pointer-events-none:focus { + pointer-events: none; + }" + `) }) /** @@ -672,16 +686,20 @@ describe('sorting', () => { display: flex; } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } } .focus\\:flex:focus { display: flex; } - .hover\\:focus\\:flex:hover:focus { - display: flex; + @media (hover: hover) { + .hover\\:focus\\:flex:hover:focus { + display: flex; + } } .disabled\\:flex:disabled { @@ -715,44 +733,64 @@ describe('sorting', () => { ].sort(() => Math.random() - 0.5), ), ).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { display: flex; } - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } - .group-hover\\:peer-hover\\:flex:is(:where(.group):hover *):is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + @media (hover: hover) { + .group-hover\\:peer-hover\\:flex:is(:where(.group):hover *):is(:where(.peer):hover ~ *) { + display: flex; + } + } } - .peer-hover\\:group-hover\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + @media (hover: hover) { + .peer-hover\\:group-hover\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):hover *) { + display: flex; + } + } } - .group-focus\\:peer-hover\\:flex:is(:where(.group):focus *):is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .group-focus\\:peer-hover\\:flex:is(:where(.group):focus *):is(:where(.peer):hover ~ *) { + display: flex; + } } - .peer-hover\\:group-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):focus *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:group-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):focus *) { + display: flex; + } } .peer-focus\\:flex:is(:where(.peer):focus ~ *) { display: flex; } - .group-hover\\:peer-focus\\:flex:is(:where(.group):hover *):is(:where(.peer):focus ~ *) { - display: flex; + @media (hover: hover) { + .group-hover\\:peer-focus\\:flex:is(:where(.group):hover *):is(:where(.peer):focus ~ *) { + display: flex; + } } - .peer-focus\\:group-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + .peer-focus\\:group-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:peer-focus\\:flex:is(:where(.group):focus *):is(:where(.peer):focus ~ *) { @@ -763,8 +801,10 @@ describe('sorting', () => { display: flex; } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } }" `) }) @@ -2104,8 +2144,10 @@ describe('@variant', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { @media (any-hover: hover) { - .any-hover\\:hover\\:underline:hover { - text-decoration-line: underline; + @media (hover: hover) { + .any-hover\\:hover\\:underline:hover { + text-decoration-line: underline; + } } } }" diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 209bcd70178b..014c4973212a 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15660,10 +15660,12 @@ describe('custom utilities', () => { display: flex; } - .hover\\:foo:hover { - flex-direction: column; - text-decoration-line: underline; - display: flex; + @media (hover: hover) { + .hover\\:foo:hover { + flex-direction: column; + text-decoration-line: underline; + display: flex; + } }" `) }) diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 169f3b2750bf..f43945f890fd 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -581,16 +581,22 @@ test('focus-within', async () => { test('hover', async () => { expect(await run(['hover:flex', 'group-hover:flex', 'peer-hover:flex'])).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } }" `) expect(await run(['hover/foo:flex'])).toEqual('') @@ -615,8 +621,10 @@ test('focus', async () => { test('group-hover group-focus sorting', async () => { expect(await run(['group-hover:flex', 'group-focus:flex'])).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { @@ -624,8 +632,10 @@ test('group-hover group-focus sorting', async () => { }" `) expect(await run(['group-focus:flex', 'group-hover:flex'])).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { @@ -741,16 +751,24 @@ test('group-[...]', async () => { display: flex; } - .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover { - display: flex; + @media (hover: hover) { + .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover { + display: flex; + } } - .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { - display: flex; + @media (hover: hover) { + .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { + display: flex; + } } - .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { - display: flex; + @media (hover: hover) { + @media (hover: hover) { + .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { + display: flex; + } + } }" `) @@ -786,20 +804,26 @@ test('group-*', async () => { ], ), ).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { display: flex; } - .group-focus\\:group-hover\\:flex:is(:where(.group):focus *):is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + .group-focus\\:group-hover\\:flex:is(:where(.group):focus *):is(:where(.group):hover *) { + display: flex; + } } - .group-hover\\:group-focus\\:flex:is(:where(.group):hover *):is(:where(.group):focus *) { - display: flex; + @media (hover: hover) { + .group-hover\\:group-focus\\:flex:is(:where(.group):hover *):is(:where(.group):focus *) { + display: flex; + } } .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { @@ -843,16 +867,22 @@ test('peer-[...]', async () => { display: flex; } - .hover\\:peer-\\[\\&_p\\]\\:flex:hover:is(:where(.peer) p ~ *) { - display: flex; + @media (hover: hover) { + .hover\\:peer-\\[\\&_p\\]\\:flex:hover:is(:where(.peer) p ~ *) { + display: flex; + } } - .peer-\\[\\&_p\\]\\:hover\\:flex:is(:where(.peer) p ~ *):hover { - display: flex; + @media (hover: hover) { + .peer-\\[\\&_p\\]\\:hover\\:flex:is(:where(.peer) p ~ *):hover { + display: flex; + } } - .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover:is(:where(.peer) p ~ *):focus { - display: flex; + @media (hover: hover) { + .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover:is(:where(.peer) p ~ *):focus { + display: flex; + } }" `) @@ -887,20 +917,26 @@ test('peer-*', async () => { ], ), ).toMatchInlineSnapshot(` - ".peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + "@media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } .peer-focus\\:flex:is(:where(.peer):focus ~ *) { display: flex; } - .peer-focus\\:peer-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-focus\\:peer-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.peer):hover ~ *) { + display: flex; + } } - .peer-hover\\:peer-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer):focus ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:peer-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer):focus ~ *) { + display: flex; + } } .peer-hocus\\:flex:is(:is(:where(.peer):hover, :where(.peer):focus) ~ *) { @@ -2479,12 +2515,16 @@ test('variant order', async () => { --breakpoint-2xl: 1536px; } - .group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } .first-letter\\:flex:first-letter { @@ -2625,8 +2665,10 @@ test('variant order', async () => { display: flex; } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } } .focus\\:flex:focus { diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 7dfcc4046bb3..968f8d7a16d5 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -413,65 +413,48 @@ export function createVariants(theme: Theme): Variants { ) } - let pseudos: [name: string, selector: string][] = [ - // Positional - ['first', '&:first-child'], - ['last', '&:last-child'], - ['only', '&:only-child'], - ['odd', '&:nth-child(odd)'], - ['even', '&:nth-child(even)'], - ['first-of-type', '&:first-of-type'], - ['last-of-type', '&:last-of-type'], - ['only-of-type', '&:only-of-type'], - - // State - // TODO: Remove alpha vars or no? - ['visited', '&:visited'], - - ['target', '&:target'], - ['open', '&:is([open], :popover-open)'], - - // Forms - ['default', '&:default'], - ['checked', '&:checked'], - ['indeterminate', '&:indeterminate'], - ['placeholder-shown', '&:placeholder-shown'], - ['autofill', '&:autofill'], - ['optional', '&:optional'], - ['required', '&:required'], - ['valid', '&:valid'], - ['invalid', '&:invalid'], - ['in-range', '&:in-range'], - ['out-of-range', '&:out-of-range'], - ['read-only', '&:read-only'], - - // Content - ['empty', '&:empty'], - - // Interactive - ['focus-within', '&:focus-within'], - [ - 'hover', - '&:hover', - // TODO: Update tests for this: - // v => { - // v.nodes = [ - // rule('@media (hover: hover) and (pointer: fine)', [ - // rule('&:hover', v.nodes), - // ]), - // ] - // } - ], - ['focus', '&:focus'], - ['focus-visible', '&:focus-visible'], - ['active', '&:active'], - ['enabled', '&:enabled'], - ['disabled', '&:disabled'], - ] - - for (let [key, value] of pseudos) { - staticVariant(key, [value]) - } + // Positional + staticVariant('first', ['&:first-child']) + staticVariant('last', ['&:last-child']) + staticVariant('only', ['&:only-child']) + staticVariant('odd', ['&:nth-child(odd)']) + staticVariant('even', ['&:nth-child(even)']) + staticVariant('first-of-type', ['&:first-of-type']) + staticVariant('last-of-type', ['&:last-of-type']) + staticVariant('only-of-type', ['&:only-of-type']) + + // State + staticVariant('visited', ['&:visited']) + staticVariant('target', ['&:target']) + staticVariant('open', ['&:is([open], :popover-open)']) + + // Forms + staticVariant('default', ['&:default']) + staticVariant('checked', ['&:checked']) + staticVariant('indeterminate', ['&:indeterminate']) + staticVariant('placeholder-shown', ['&:placeholder-shown']) + staticVariant('autofill', ['&:autofill']) + staticVariant('optional', ['&:optional']) + staticVariant('required', ['&:required']) + staticVariant('valid', ['&:valid']) + staticVariant('invalid', ['&:invalid']) + staticVariant('in-range', ['&:in-range']) + staticVariant('out-of-range', ['&:out-of-range']) + staticVariant('read-only', ['&:read-only']) + + // Content + staticVariant('empty', ['&:empty']) + + // Interactive + staticVariant('focus-within', ['&:focus-within']) + variants.static('hover', (r) => { + r.nodes = [rule('&:hover', [rule('@media (hover: hover)', r.nodes)])] + }) + staticVariant('focus', ['&:focus']) + staticVariant('focus-visible', ['&:focus-visible']) + staticVariant('active', ['&:active']) + staticVariant('enabled', ['&:enabled']) + staticVariant('disabled', ['&:disabled']) staticVariant('inert', ['&:is([inert], [inert] *)']) From d14249ddc20efc892a394ebaf30dc0eefd08c8cb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Sep 2024 18:17:09 +0200 Subject: [PATCH 02/12] 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 527b87fb733c..6922f5bfd3eb 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 60eeb06c563f..72b1bf4a8a2f 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 23a3dc3c465f..02fdf3685c3e 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 000000000000..a493639f2ea3 --- /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 000000000000..2bc0c9601cbf --- /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 db45f099cf9c..88c54350db5f 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 caa9278d892a..11e23c1a3f5e 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 7a1f10b82655..5bb0394c2db4 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 From d869442a544072fe7d58f1d343acf69301ebb0c7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Sep 2024 18:32:50 +0200 Subject: [PATCH 03/12] Add CSS codemod for missing `@layer` (#14504) This PR adds a codemod that ensures that some parts of your stylesheet are wrapped in an `@layer`. This is a follow-up PR of #14411, in that PR we migrate `@tailwind` directives to imports. As a quick summary, that will turn this: ```css @tailwind base; @tailwind components; @tailwind utilities; ``` Into: ```css @import 'tailwindcss'; ``` But there are a few issues with that _if_ we have additional CSS on the page. For example let's imagine we had this: ```css @tailwind base; body { background-color: red; } @tailwind components; .btn {} @tailwind utilities; ``` This will now be turned into: ```css @import 'tailwindcss'; body { background-color: red; } .btn {} ``` But in v4 we use real layers, in v3 we used to replace the directive with the result of that layer. This means that now the `body` and `.btn` styles are in the incorrect spot. To solve this, we have to wrap them in a layer. The `body` should go in an `@layer base`, and the `.btn` should be in an `@layer components` to make sure it's in the same spot as it was before. That's what this PR does, the original input will now be turned into: ```css @import 'tailwindcss'; @layer base { body { background-color: red; } } @layer components { .btn { } } ``` There are a few internal refactors going on as well, but those are less important. --- CHANGELOG.md | 3 +- integrations/cli/upgrade.test.ts | 32 ++++- .../src/codemods/format-nodes.test.ts | 35 ++++++ .../src/codemods/format-nodes.ts | 30 +++++ .../src/codemods/migrate-at-apply.ts | 5 +- .../migrate-at-layer-utilities.test.ts | 2 + .../codemods/migrate-at-layer-utilities.ts | 77 +----------- .../codemods/migrate-missing-layers.test.ts | 65 +++++++++++ .../src/codemods/migrate-missing-layers.ts | 110 ++++++++++++++++++ .../migrate-tailwind-directives.test.ts | 2 + .../codemods/migrate-tailwind-directives.ts | 2 +- .../@tailwindcss-upgrade/src/index.test.ts | 92 +++++++++++++++ packages/@tailwindcss-upgrade/src/migrate.ts | 6 +- .../@tailwindcss-upgrade/src/utils/walk.ts | 42 +++++++ 14 files changed, 423 insertions(+), 80 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts create mode 100644 packages/@tailwindcss-upgrade/src/utils/walk.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6922f5bfd3eb..108fc3a26274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new `shadow-initial` and `inset-shadow-initial` utilities for resetting shadow colors ([#14468](https://github.com/tailwindlabs/tailwindcss/pull/14468)) - Add `field-sizing-*` utilities ([#14469](https://github.com/tailwindlabs/tailwindcss/pull/14469)) - Include gradient color properties in color transitions ([#14489](https://github.com/tailwindlabs/tailwindcss/pull/14489)) -- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411)) +- _Experimental_: Add CSS codemods for `@apply` ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14434)) +- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411), [#14504](https://github.com/tailwindlabs/tailwindcss/pull/14504)) - _Experimental_: Add CSS codemods for migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455)) ### Fixed diff --git a/integrations/cli/upgrade.test.ts b/integrations/cli/upgrade.test.ts index 72b1bf4a8a2f..9629a685eb1e 100644 --- a/integrations/cli/upgrade.test.ts +++ b/integrations/cli/upgrade.test.ts @@ -65,7 +65,17 @@ test( `, 'src/index.css': css` @tailwind base; + + html { + color: #333; + } + @tailwind components; + + .btn { + color: red; + } + @tailwind utilities; `, }, @@ -73,7 +83,27 @@ test( async ({ fs, exec }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain('src/index.css', css` @import 'tailwindcss'; `) + await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) + await fs.expectFileToContain( + 'src/index.css', + css` + @layer base { + html { + color: #333; + } + } + `, + ) + await fs.expectFileToContain( + 'src/index.css', + css` + @layer components { + .btn { + color: red; + } + } + `, + ) }, ) diff --git a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts new file mode 100644 index 000000000000..517c517c0cad --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts @@ -0,0 +1,35 @@ +import postcss, { type Plugin } from 'postcss' +import { expect, it } from 'vitest' +import { formatNodes } from './format-nodes' + +function markPretty(): Plugin { + return { + postcssPlugin: '@tailwindcss/upgrade/mark-pretty', + OnceExit(root) { + root.walkAtRules('utility', (atRule) => { + atRule.raws.tailwind_pretty = true + }) + }, + } +} + +function migrate(input: string) { + return postcss() + .use(markPretty()) + .use(formatNodes()) + .process(input, { from: expect.getState().testPath }) + .then((result) => result.css) +} + +it('should format PostCSS nodes that are marked with tailwind_pretty', async () => { + expect( + await migrate(` + @utility .foo { .foo { color: red; } }`), + ).toMatchInlineSnapshot(` + "@utility .foo { + .foo { + color: red; + } + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts new file mode 100644 index 000000000000..e90e0e2637ca --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts @@ -0,0 +1,30 @@ +import { parse, type ChildNode, type Plugin, type Root } from 'postcss' +import { format } from 'prettier' +import { walk, WalkAction } from '../utils/walk' + +// Prettier is used to generate cleaner output, but it's only used on the nodes +// that were marked as `pretty` during the migration. +export function formatNodes(): Plugin { + async function migrate(root: Root) { + // Find the nodes to format + let nodesToFormat: ChildNode[] = [] + walk(root, (child) => { + if (child.raws.tailwind_pretty) { + nodesToFormat.push(child) + return WalkAction.Skip + } + }) + + // Format the nodes + await Promise.all( + nodesToFormat.map(async (node) => { + node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true }))) + }), + ) + } + + return { + postcssPlugin: '@tailwindcss/upgrade/format-nodes', + OnceExit: migrate, + } +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts index 41ff03ec3323..ebea30e96d6a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts @@ -13,7 +13,6 @@ export function migrateAtApply(): Plugin { let params = utilities.map((part) => { // Keep whitespace if (part.trim() === '') return part - let variants = segment(part, ':') let utility = variants.pop()! @@ -36,8 +35,8 @@ export function migrateAtApply(): Plugin { return { postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply', - AtRule: { - apply: migrate, + OnceExit(root) { + root.walkAtRules('apply', migrate) }, } } 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 index a493639f2ea3..6f1a03301f93 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import postcss from 'postcss' import { describe, expect, it } from 'vitest' +import { formatNodes } from './format-nodes' import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' const css = dedent @@ -8,6 +9,7 @@ const css = dedent function migrate(input: string) { return postcss() .use(migrateAtLayerUtilities()) + .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts index 2bc0c9601cbf..c1e383ef5356 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts @@ -1,65 +1,16 @@ -import { AtRule, parse, Rule, type ChildNode, type Comment, type Plugin } from 'postcss' +import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss' import SelectorParser from 'postcss-selector-parser' -import { format } from 'prettier' import { segment } from '../../../tailwindcss/src/utils/segment' - -enum WalkAction { - // Continue walking the tree. Default behavior. - Continue, - - // Skip walking into the current node. - Skip, - - // Stop walking the tree entirely. - Stop, -} - -interface Walkable { - 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) - }) -} +import { walk, WalkAction, walkDepth } from '../utils/walk' export function migrateAtLayerUtilities(): Plugin { function migrate(atRule: AtRule) { // Only migrate `@layer utilities` and `@layer components`. if (atRule.params !== 'utilities' && atRule.params !== 'components') return - // If the `@layer utilities` contains CSS that should not be turned into an - // `@utility` at-rule, then we have to keep it around (including the - // `@layer utilities` wrapper). To prevent this from being processed over - // and over again, we mark it as seen and bail early. - if (atRule.raws.seen) return - // Keep rules that should not be turned into utilities as is. This will // include rules with element or ID selectors. - let defaultsAtRule = atRule.clone({ raws: { seen: true } }) + let defaultsAtRule = atRule.clone() // Clone each rule with multiple selectors into their own rule with a single // selector. @@ -312,32 +263,12 @@ export function migrateAtLayerUtilities(): Plugin { return { postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities', - OnceExit: async (root) => { + OnceExit: (root) => { // Migrate `@layer utilities` and `@layer components` into `@utility`. // Using this instead of the visitor API in case we want to use // postcss-nesting in the future. root.walkAtRules('layer', migrate) - // Prettier is used to generate cleaner output, but it's only used on the - // nodes that were marked as `pretty` during the migration. - { - // Find the nodes to format - let nodesToFormat: ChildNode[] = [] - walk(root, (child) => { - if (child.raws.tailwind_pretty) { - nodesToFormat.push(child) - return WalkAction.Skip - } - }) - - // Format the nodes - await Promise.all( - nodesToFormat.map(async (node) => { - node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true }))) - }), - ) - } - // Merge `@utility ` with the same name into a single rule. This can // happen when the same classes is used in multiple `@layer utilities` // blocks. diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts new file mode 100644 index 000000000000..c33bcc03e1d3 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts @@ -0,0 +1,65 @@ +import dedent from 'dedent' +import postcss from 'postcss' +import { expect, it } from 'vitest' +import { formatNodes } from './format-nodes' +import { migrateMissingLayers } from './migrate-missing-layers' + +const css = dedent + +function migrate(input: string) { + return postcss() + .use(migrateMissingLayers()) + .use(formatNodes()) + .process(input, { from: expect.getState().testPath }) + .then((result) => result.css) +} + +it('should migrate rules between tailwind directives', async () => { + expect( + await migrate(css` + @tailwind base; + + .base { + } + + @tailwind components; + + .component-a { + } + .component-b { + } + + @tailwind utilities; + + .utility-a { + } + .utility-b { + } + `), + ).toMatchInlineSnapshot(` + "@tailwind base; + + @layer base { + .base { + } + } + + @tailwind components; + + @layer components { + .component-a { + } + .component-b { + } + } + + @tailwind utilities; + + @layer utilities { + .utility-a { + } + .utility-b { + } + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts new file mode 100644 index 000000000000..3c1818ff24a4 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts @@ -0,0 +1,110 @@ +import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss' + +export function migrateMissingLayers(): Plugin { + function migrate(root: Root) { + let lastLayer = '' + let bucket: ChildNode[] = [] + let buckets: [layer: string, bucket: typeof bucket][] = [] + + root.each((node) => { + if (node.type === 'atrule') { + // Known Tailwind directives that should not be inside a layer. + if (node.name === 'theme' || node.name === 'utility') { + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + return + } + + // Base + if ( + (node.name === 'tailwind' && node.params === 'base') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']/)) + ) { + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + + lastLayer = 'base' + return + } + + // Components + if ( + (node.name === 'tailwind' && node.params === 'components') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/components["']/)) + ) { + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + + lastLayer = 'components' + return + } + + // Utilities + if ( + (node.name === 'tailwind' && node.params === 'utilities') || + (node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']/)) + ) { + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + + lastLayer = 'utilities' + return + } + + // Already in a layer + if (node.name === 'layer') { + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + return + } + + // Add layer to `@import` at-rules + if (node.name === 'import') { + if (!node.params.includes('layer(')) { + node.params += ` layer(${lastLayer})` + } + + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + return + } + } + + // Track the node + if (lastLayer !== '') { + if (bucket.push(node) !== 1) { + node.remove() + } + } + }) + + // Add the last bucket if it's not empty + if (bucket.length > 0) { + buckets.push([lastLayer, bucket.splice(0)]) + } + + // Wrap each bucket in an `@layer` at-rule + for (let [layerName, nodes] of buckets) { + let layerNode = new AtRule({ + name: 'layer', + params: layerName, + nodes, + raws: { + tailwind_pretty: true, + }, + }) + nodes[0].replaceWith(layerNode) + } + } + + return { + postcssPlugin: '@tailwindcss/upgrade/migrate-missing-layers', + OnceExit: migrate, + } +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts index f59fef01e848..9f4ae095501d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import postcss from 'postcss' import { expect, it } from 'vitest' +import { formatNodes } from './format-nodes' import { migrateTailwindDirectives } from './migrate-tailwind-directives' const css = dedent @@ -8,6 +9,7 @@ const css = dedent function migrate(input: string) { return postcss() .use(migrateTailwindDirectives()) + .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts index cb47714a0197..5bb4a34e2349 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts @@ -86,6 +86,6 @@ export function migrateTailwindDirectives(): Plugin { return { postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives', - Once: migrate, + OnceExit: migrate, } } diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts index a043092d9ae6..2f4f7475e3f6 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -26,3 +26,95 @@ it('should print the input as-is', async () => { }" `) }) + +it('should migrate a stylesheet', async () => { + expect( + await migrateContents(css` + @tailwind base; + + html { + overflow: hidden; + } + + @tailwind components; + + .a { + z-index: 1; + } + + @layer components { + .b { + z-index: 2; + } + } + + .c { + z-index: 3; + } + + @tailwind utilities; + + .d { + z-index: 4; + } + + @layer utilities { + .e { + z-index: 5; + } + } + `), + ).toMatchInlineSnapshot(` + "@import 'tailwindcss'; + + @layer base { + html { + overflow: hidden; + } + } + + @layer components { + .a { + z-index: 1; + } + } + + @utility b { + z-index: 2; + } + + @layer components { + .c { + z-index: 3; + } + } + + @layer utilities { + .d { + z-index: 4; + } + } + + @utility e { + z-index: 5; + }" + `) +}) + +it('should migrate a stylesheet (with imports)', async () => { + expect( + await migrateContents(css` + @import 'tailwindcss/base'; + @import './my-base.css'; + @import 'tailwindcss/components'; + @import './my-components.css'; + @import 'tailwindcss/utilities'; + @import './my-utilities.css'; + `), + ).toMatchInlineSnapshot(` + "@import 'tailwindcss'; + @import './my-base.css' layer(base); + @import './my-components.css' layer(components); + @import './my-utilities.css' layer(utilities);" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 88c54350db5f..cfbe50d3dcb3 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,15 +1,19 @@ import fs from 'node:fs/promises' import path from 'node:path' import postcss from 'postcss' +import { formatNodes } from './codemods/format-nodes' import { migrateAtApply } from './codemods/migrate-at-apply' import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities' +import { migrateMissingLayers } from './codemods/migrate-missing-layers' import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' export async function migrateContents(contents: string, file?: string) { return postcss() .use(migrateAtApply()) - .use(migrateTailwindDirectives()) .use(migrateAtLayerUtilities()) + .use(migrateMissingLayers()) + .use(migrateTailwindDirectives()) + .use(formatNodes()) .process(contents, { from: file }) .then((result) => result.css) } diff --git a/packages/@tailwindcss-upgrade/src/utils/walk.ts b/packages/@tailwindcss-upgrade/src/utils/walk.ts new file mode 100644 index 000000000000..7a86b7ae533d --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/walk.ts @@ -0,0 +1,42 @@ +export 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. +export 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. +export function walkDepth(rule: Walkable, cb: (rule: T) => void) { + rule?.each?.((node) => { + walkDepth(node as Walkable, cb) + cb(node) + }) +} From c094fadbbc12fa822e6b5e1da3c358baa70a8b0a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Sep 2024 19:03:00 +0200 Subject: [PATCH 04/12] Release v4.0.0-alpha.25 (#14507) --- CHANGELOG.md | 4 ++++ crates/node/npm/android-arm-eabi/package.json | 2 +- crates/node/npm/android-arm64/package.json | 2 +- crates/node/npm/darwin-arm64/package.json | 2 +- crates/node/npm/darwin-x64/package.json | 2 +- crates/node/npm/freebsd-x64/package.json | 2 +- crates/node/npm/linux-arm-gnueabihf/package.json | 2 +- crates/node/npm/linux-arm64-gnu/package.json | 2 +- crates/node/npm/linux-arm64-musl/package.json | 2 +- crates/node/npm/linux-x64-gnu/package.json | 2 +- crates/node/npm/linux-x64-musl/package.json | 2 +- crates/node/npm/win32-x64-msvc/package.json | 2 +- crates/node/package.json | 2 +- packages/@tailwindcss-cli/package.json | 2 +- packages/@tailwindcss-node/package.json | 2 +- packages/@tailwindcss-postcss/package.json | 2 +- packages/@tailwindcss-standalone/package.json | 2 +- packages/@tailwindcss-upgrade/package.json | 2 +- packages/@tailwindcss-vite/package.json | 2 +- packages/tailwindcss/package.json | 2 +- 20 files changed, 23 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 108fc3a26274..6bfe7e649f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Nothing yet! + +## [4.0.0-alpha.25] - 2024-09-24 + ### Added - Add support for `aria`, `supports`, and `data` variants defined in JS config files ([#14407](https://github.com/tailwindlabs/tailwindcss/pull/14407)) diff --git a/crates/node/npm/android-arm-eabi/package.json b/crates/node/npm/android-arm-eabi/package.json index df8e49616cc9..de34cf05b02c 100644 --- a/crates/node/npm/android-arm-eabi/package.json +++ b/crates/node/npm/android-arm-eabi/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-android-arm-eabi", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/android-arm64/package.json b/crates/node/npm/android-arm64/package.json index 381110811ee6..e96005d6ad64 100644 --- a/crates/node/npm/android-arm64/package.json +++ b/crates/node/npm/android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-android-arm64", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/darwin-arm64/package.json b/crates/node/npm/darwin-arm64/package.json index 0f5ea6d3f9b4..dc9d92f91584 100644 --- a/crates/node/npm/darwin-arm64/package.json +++ b/crates/node/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-darwin-arm64", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/darwin-x64/package.json b/crates/node/npm/darwin-x64/package.json index 252877e1aca7..64f4d92d6d49 100644 --- a/crates/node/npm/darwin-x64/package.json +++ b/crates/node/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-darwin-x64", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/freebsd-x64/package.json b/crates/node/npm/freebsd-x64/package.json index 9432437a77b8..0838c87424ae 100644 --- a/crates/node/npm/freebsd-x64/package.json +++ b/crates/node/npm/freebsd-x64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-freebsd-x64", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/linux-arm-gnueabihf/package.json b/crates/node/npm/linux-arm-gnueabihf/package.json index 65a79278e04d..bdbd9b71919c 100644 --- a/crates/node/npm/linux-arm-gnueabihf/package.json +++ b/crates/node/npm/linux-arm-gnueabihf/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-arm-gnueabihf", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/linux-arm64-gnu/package.json b/crates/node/npm/linux-arm64-gnu/package.json index 447b9736b4b3..e941c62f2f44 100644 --- a/crates/node/npm/linux-arm64-gnu/package.json +++ b/crates/node/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-arm64-gnu", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/linux-arm64-musl/package.json b/crates/node/npm/linux-arm64-musl/package.json index b800e92e3371..d30ecdd1dd0f 100644 --- a/crates/node/npm/linux-arm64-musl/package.json +++ b/crates/node/npm/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-arm64-musl", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/linux-x64-gnu/package.json b/crates/node/npm/linux-x64-gnu/package.json index 1b7b853e2141..51458173e766 100644 --- a/crates/node/npm/linux-x64-gnu/package.json +++ b/crates/node/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-x64-gnu", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/linux-x64-musl/package.json b/crates/node/npm/linux-x64-musl/package.json index 4cd6fa4d25d0..be716f86011b 100644 --- a/crates/node/npm/linux-x64-musl/package.json +++ b/crates/node/npm/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-x64-musl", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/npm/win32-x64-msvc/package.json b/crates/node/npm/win32-x64-msvc/package.json index 6656bc438ff3..a5f8c839c8bb 100644 --- a/crates/node/npm/win32-x64-msvc/package.json +++ b/crates/node/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-win32-x64-msvc", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/crates/node/package.json b/crates/node/package.json index 75a1eb9763f2..26cecea47fc0 100644 --- a/crates/node/package.json +++ b/crates/node/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json index c8dbf8cce678..cdc92b520d1b 100644 --- a/packages/@tailwindcss-cli/package.json +++ b/packages/@tailwindcss-cli/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/cli", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "repository": { diff --git a/packages/@tailwindcss-node/package.json b/packages/@tailwindcss-node/package.json index 97f63f77e40a..5e260bb562f1 100644 --- a/packages/@tailwindcss-node/package.json +++ b/packages/@tailwindcss-node/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/node", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "repository": { diff --git a/packages/@tailwindcss-postcss/package.json b/packages/@tailwindcss-postcss/package.json index 11da052a9e60..a2f6da74a502 100644 --- a/packages/@tailwindcss-postcss/package.json +++ b/packages/@tailwindcss-postcss/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/postcss", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "description": "PostCSS plugin for Tailwind CSS, a utility-first CSS framework for rapidly building custom user interfaces", "license": "MIT", "repository": { diff --git a/packages/@tailwindcss-standalone/package.json b/packages/@tailwindcss-standalone/package.json index 9fab9f2a3584..9d9abf8638a6 100644 --- a/packages/@tailwindcss-standalone/package.json +++ b/packages/@tailwindcss-standalone/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/standalone", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "private": true, "description": "Standalone CLI for Tailwind CSS", "license": "MIT", diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index 02fdf3685c3e..bfb04c9c16e1 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/upgrade", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "repository": { diff --git a/packages/@tailwindcss-vite/package.json b/packages/@tailwindcss-vite/package.json index 20a7de978cea..acce041a69b2 100644 --- a/packages/@tailwindcss-vite/package.json +++ b/packages/@tailwindcss-vite/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/vite", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "repository": { diff --git a/packages/tailwindcss/package.json b/packages/tailwindcss/package.json index 10e45bdb3aa1..32820dc19aa1 100644 --- a/packages/tailwindcss/package.json +++ b/packages/tailwindcss/package.json @@ -1,6 +1,6 @@ { "name": "tailwindcss", - "version": "4.0.0-alpha.24", + "version": "4.0.0-alpha.25", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "repository": { From 951f6448fe2bafa9c0de4f21f635ac199007f98e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 25 Sep 2024 10:42:19 +0200 Subject: [PATCH 05/12] Improve missing layer codemod (#14512) This PR improves the missing layers codemod where everything after the last Tailwind directive can stay as-is without wrapping it in a `@layer` directive. The `@layer` at-rules are only important for CSS that exists between Tailwind directives. E.g.: ```css @tailwind base; html {} @tailwind components; .btn {} @tailwind utilities; .foo {} .bar {} ``` Was transformed into: ```css @import "tailwindcss"; @layer base { html {} } @layer components { .btn {} } @layer utilities { .foo {} .bar {} } ``` But the last `@layer utilities` is already in the correct spot, so we can simplify this to just this instead: ```css @import "tailwindcss"; @layer base { html {} } @layer components { .btn {} } .foo {} .bar {} ``` --- CHANGELOG.md | 4 +++- .../codemods/migrate-missing-layers.test.ts | 8 +++---- .../src/codemods/migrate-missing-layers.ts | 24 +++++++++++-------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfe7e649f68..172e95fdf7fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- _Experimental_: Improve codemod output, keep CSS after last Tailwind directive unlayered ([#14512](https://github.com/tailwindlabs/tailwindcss/pull/14512)) ## [4.0.0-alpha.25] - 2024-09-24 diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts index c33bcc03e1d3..9cae49b04fd7 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts @@ -55,11 +55,9 @@ it('should migrate rules between tailwind directives', async () => { @tailwind utilities; - @layer utilities { - .utility-a { - } - .utility-b { - } + .utility-a { + } + .utility-b { }" `) }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts index 3c1818ff24a4..39f60e4cf1a9 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts @@ -78,28 +78,32 @@ export function migrateMissingLayers(): Plugin { // Track the node if (lastLayer !== '') { - if (bucket.push(node) !== 1) { - node.remove() - } + bucket.push(node) } }) - // Add the last bucket if it's not empty - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - // Wrap each bucket in an `@layer` at-rule for (let [layerName, nodes] of buckets) { + let target = nodes[0] let layerNode = new AtRule({ name: 'layer', params: layerName, - nodes, + nodes: nodes.map((node) => { + // Keep the target node as-is, because we will be replacing that one + // with the new layer node. + if (node === target) { + return node + } + + // Every other node should be removed from its original position. They + // will be added to the new layer node. + return node.remove() + }), raws: { tailwind_pretty: true, }, }) - nodes[0].replaceWith(layerNode) + target.replaceWith(layerNode) } } From 4d5d0238e6758fd82139774f4668801a1a815a9a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 13:48:32 +0200 Subject: [PATCH 06/12] WIP --- crates/node/src/lib.rs | 26 +++ crates/oxide/src/lib.rs | 22 +++ crates/oxide/src/parser.rs | 10 ++ package.json | 2 +- packages/@tailwindcss-node/src/compile.ts | 114 ++++++++----- packages/@tailwindcss-upgrade/package.json | 4 +- .../src/template/candidates.test.ts | 158 ++++++++++++++++++ .../src/template/candidates.ts | 77 +++++++++ packages/tailwindcss/src/candidate.ts | 2 +- pnpm-lock.yaml | 6 + 10 files changed, 372 insertions(+), 49 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/template/candidates.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/candidates.ts diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 7e8717ec400c..cb8f091c50d5 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -82,6 +82,16 @@ pub struct Scanner { scanner: tailwindcss_oxide::Scanner, } +#[derive(Debug, Clone)] +#[napi(object)] +pub struct CandidateWithPosition { + /// Base path of the glob + pub candidate: String, + + /// Glob pattern + pub position: f64, +} + #[napi] impl Scanner { #[napi(constructor)] @@ -108,6 +118,22 @@ impl Scanner { .scan_content(input.into_iter().map(Into::into).collect()) } + #[napi] + pub fn get_candidates_with_positions( + &mut self, + input: ChangedContent, + ) -> Vec { + self + .scanner + .get_candidates_with_positions(input.into()) + .into_iter() + .map(|(candidate, position)| CandidateWithPosition { + candidate, + position: position as f64, + }) + .collect() + } + #[napi(getter)] pub fn files(&mut self) -> Vec { self.scanner.get_files() diff --git a/crates/oxide/src/lib.rs b/crates/oxide/src/lib.rs index 58719dc70bb5..79c2bcb9e59a 100644 --- a/crates/oxide/src/lib.rs +++ b/crates/oxide/src/lib.rs @@ -124,6 +124,28 @@ impl Scanner { new_candidates } + #[tracing::instrument(skip_all)] + pub fn get_candidates_with_positions( + &mut self, + changed_content: ChangedContent, + ) -> Vec<(String, usize)> { + self.prepare(); + + let content = read_changed_content(changed_content).unwrap_or_default(); + let extractor = Extractor::with_positions(&content[..], Default::default()); + + let candidates: Vec<(String, usize)> = extractor + .into_iter() + .map(|(s, i)| { + // SAFETY: When we parsed the candidates, we already guaranteed that the byte slices + // are valid, therefore we don't have to re-check here when we want to convert it back + // to a string. + unsafe { (String::from_utf8_unchecked(s.to_vec()), i) } + }) + .collect(); + candidates + } + #[tracing::instrument(skip_all)] pub fn get_files(&mut self) -> Vec { self.prepare(); diff --git a/crates/oxide/src/parser.rs b/crates/oxide/src/parser.rs index ad612f9020b2..0e478e5a06a7 100644 --- a/crates/oxide/src/parser.rs +++ b/crates/oxide/src/parser.rs @@ -82,6 +82,16 @@ impl<'a> Extractor<'a> { candidates } + + pub fn with_positions(input: &'a [u8], opts: ExtractorOptions) -> Vec<(&'a [u8], usize)> { + let mut result = Vec::new(); + let extractor = Self::new(input, opts).flatten(); + for item in extractor { + let start_index = item.as_ptr() as usize - input.as_ptr() as usize; + result.push((item, start_index)); + } + result + } } impl<'a> Extractor<'a> { diff --git a/package.json b/package.json index e37447099703..ff5ff7892075 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "scripts": { "format": "prettier --write .", "lint": "prettier --check . && turbo lint", - "build": "turbo build --filter=!./playgrounds/*", + "build": "turbo build --filter=!./playgrounds/* --force", "postbuild": "node ./scripts/pack-packages.mjs", "dev": "turbo dev --filter=!./playgrounds/*", "test": "cargo test && vitest run", diff --git a/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts index 02332d578262..0f2cd712856d 100644 --- a/packages/@tailwindcss-node/src/compile.ts +++ b/packages/@tailwindcss-node/src/compile.ts @@ -4,7 +4,10 @@ import fs from 'node:fs' import fsPromises from 'node:fs/promises' import path, { dirname, extname } from 'node:path' import { pathToFileURL } from 'node:url' -import { compile as _compile } from 'tailwindcss' +import { + __unstable__loadDesignSystem as ___unstable__loadDesignSystem, + compile as _compile, +} from 'tailwindcss' import { getModuleDependencies } from './get-module-dependencies' export async function compile( @@ -14,59 +17,78 @@ export async function compile( return await _compile(css, { base, async loadModule(id, base) { - if (id[0] !== '.') { - let resolvedPath = await resolveJsId(id, base) - if (!resolvedPath) { - throw new Error(`Could not resolve '${id}' from '${base}'`) - } - - let module = await importModule(pathToFileURL(resolvedPath).href) - return { - base: dirname(resolvedPath), - module: module.default ?? module, - } - } + return loadModule(id, base, onDependency) + }, + async loadStylesheet(id, base) { + return loadStylesheet(id, base, onDependency) + }, + }) +} - let resolvedPath = await resolveJsId(id, base) - if (!resolvedPath) { - throw new Error(`Could not resolve '${id}' from '${base}'`) - } - let [module, moduleDependencies] = await Promise.all([ - importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()), - getModuleDependencies(resolvedPath), - ]) - - onDependency(resolvedPath) - for (let file of moduleDependencies) { - onDependency(file) - } - return { - base: dirname(resolvedPath), - module: module.default ?? module, - } +export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) { + return ___unstable__loadDesignSystem(css, { + base, + async loadModule(id, base) { + return loadModule(id, base, () => {}) + }, + async loadStylesheet(id, base) { + return loadStylesheet(id, base, () => {}) }, + }) +} - async loadStylesheet(id, basedir) { - let resolvedPath = await resolveCssId(id, basedir) - if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${basedir}'`) - - if (typeof globalThis.__tw_readFile === 'function') { - let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8') - if (file) { - return { - base: path.dirname(resolvedPath), - content: file, - } - } - } +async function loadModule(id: string, base: string, onDependency: (path: string) => void) { + if (id[0] !== '.') { + let resolvedPath = await resolveJsId(id, base) + if (!resolvedPath) { + throw new Error(`Could not resolve '${id}' from '${base}'`) + } - let file = await fsPromises.readFile(resolvedPath, 'utf-8') + let module = await importModule(pathToFileURL(resolvedPath).href) + return { + base: dirname(resolvedPath), + module: module.default ?? module, + } + } + + let resolvedPath = await resolveJsId(id, base) + if (!resolvedPath) { + throw new Error(`Could not resolve '${id}' from '${base}'`) + } + let [module, moduleDependencies] = await Promise.all([ + importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()), + getModuleDependencies(resolvedPath), + ]) + + onDependency(resolvedPath) + for (let file of moduleDependencies) { + onDependency(file) + } + return { + base: dirname(resolvedPath), + module: module.default ?? module, + } +} + +async function loadStylesheet(id: string, base: string, onDependency: (path: string) => void) { + let resolvedPath = await resolveCssId(id, base) + if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`) + + if (typeof globalThis.__tw_readFile === 'function') { + let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8') + if (file) { return { base: path.dirname(resolvedPath), content: file, } - }, - }) + } + } + + let file = await fsPromises.readFile(resolvedPath, 'utf-8') + return { + base: path.dirname(resolvedPath), + content: file, + } } // Attempts to import the module using the native `import()` function. If this diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index bfb04c9c16e1..b920e1c9dd66 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -35,7 +35,9 @@ "postcss-import": "^16.1.0", "postcss-selector-parser": "^6.1.2", "prettier": "^3.3.3", - "tailwindcss": "workspace:^" + "tailwindcss": "workspace:^", + "@tailwindcss/oxide": "workspace:^", + "@tailwindcss/node": "workspace:^" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts new file mode 100644 index 000000000000..f080e7af0139 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -0,0 +1,158 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { describe, expect, test } from 'vitest' +import { parseCandidate } from '../../../tailwindcss/src/candidate' +import { extractCandidates, toString } from './candidates' + +let html = String.raw + +test.skip('extracts candidates with positions from a template', () => { + let content = html` +
+ +
+ ` + + expect(extractCandidates(content)).resolves.toMatchInlineSnapshot(` + [ + { + "candidate": { + "important": false, + "kind": "functional", + "modifier": null, + "negative": false, + "raw": "bg-blue-500", + "root": "bg", + "value": { + "fraction": null, + "kind": "named", + "value": "blue-500", + }, + "variants": [], + }, + "end": 28, + "start": 17, + }, + { + "candidate": { + "important": false, + "kind": "functional", + "modifier": null, + "negative": false, + "raw": "hover:focus:text-white", + "root": "text", + "value": { + "fraction": null, + "kind": "named", + "value": "white", + }, + "variants": [ + { + "compounds": true, + "kind": "static", + "root": "focus", + }, + { + "compounds": true, + "kind": "static", + "root": "hover", + }, + ], + }, + "end": 51, + "start": 29, + }, + { + "candidate": { + "important": false, + "kind": "arbitrary", + "modifier": null, + "property": "color", + "raw": "[color:red]", + "value": "red", + "variants": [], + }, + "end": 63, + "start": 52, + }, + { + "candidate": { + "important": false, + "kind": "functional", + "modifier": null, + "negative": false, + "raw": "bg-blue-500", + "root": "bg", + "value": { + "fraction": null, + "kind": "named", + "value": "blue-500", + }, + "variants": [], + }, + "end": 98, + "start": 87, + }, + { + "candidate": { + "important": false, + "kind": "functional", + "modifier": null, + "negative": false, + "raw": "text-white", + "root": "text", + "value": { + "fraction": null, + "kind": "named", + "value": "white", + }, + "variants": [], + }, + "end": 109, + "start": 99, + }, + ] + `) +}) + +describe('toString()', () => { + test.each([ + // Arbitrary candidates + ['[color:red]', '[color:red]'], + ['[color:red]/50', '[color:red]/50'], + ['[color:red]/[0.5]', '[color:red]/[0.5]'], + ['[color:red]/50!', '[color:red]/50!'], + ['![color:red]/50', '[color:red]/50!'], + ['[color:red]/[0.5]!', '[color:red]/[0.5]!'], + + // Static candidates + ['box-border', 'box-border'], + ['underline!', 'underline!'], + ['!underline', 'underline!'], + ['-inset-full', '-inset-full'], + + // Functional candidates + ['bg-red-500', 'bg-red-500'], + ['bg-red-500/50', 'bg-red-500/50'], + ['bg-red-500/[0.5]', 'bg-red-500/[0.5]'], + ['bg-red-500!', 'bg-red-500!'], + ['!bg-red-500', 'bg-red-500!'], + ['bg-[#0088cc]/50', 'bg-[#0088cc]/50'], + ['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'], + ['bg-[#0088cc]!', 'bg-[#0088cc]!'], + ['!bg-[#0088cc]', 'bg-[#0088cc]!'], + ['w-1/2', 'w-1/2'], + ])('%s', async (candidate: string, result: string) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + let candidates = parseCandidate(candidate, designSystem) + + // TODO: This seems unexpected? + // Sometimes we will have a functional and a static candidate for the same + // raw input string (e.g. `-inset-full`). Dedupe in this case. + let cleaned = new Set([...candidates].map(toString)) + + expect([...cleaned]).toEqual([result]) + }) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts new file mode 100644 index 000000000000..a66245978b30 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -0,0 +1,77 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { Scanner } from '@tailwindcss/oxide' +// This file uses private APIs to work with candidates. +// TODO: Should we export this in the public package so we have the same +// version as the tailwindcss package? +import { + parseCandidate, + type Candidate, + type CandidateModifier, +} from '../../../tailwindcss/src/candidate' + +let css = String.raw + +export async function extractCandidates( + content: string, +): Promise<{ candidate: Candidate; start: number; end: number }[]> { + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + `, + { base: __dirname }, + ) + let scanner = new Scanner({}) + let result = scanner.getCandidatesWithPositions({ content, extension: 'html' }) + + let candidates: { candidate: Candidate; start: number; end: number }[] = [] + for (let { candidate: rawCandidate, position: start } of result) { + for (let candidate of parseCandidate(rawCandidate, designSystem)) { + candidates.push({ candidate, start, end: start + rawCandidate.length }) + } + } + return candidates +} + +export function toString(candidate: Candidate): string { + let variants = '' + let important = candidate.important ? '!' : '' + + switch (candidate.kind) { + case 'arbitrary': { + return `${variants}[${candidate.property}:${candidate.value}]${formatModifier( + candidate.modifier, + )}${important}` + } + case 'static': { + return `${formatNegative(candidate.negative)}${variants}${candidate.root}${important}` + } + case 'functional': { + let value = + candidate.value === null + ? '' + : candidate.value.kind === 'named' + ? `-${candidate.value.value}` + : `-[${candidate.value.value}]` + + return `${formatNegative(candidate.negative)}${variants}${candidate.root}${value}${formatModifier( + candidate.modifier, + )}${important}` + } + } +} + +function formatModifier(modifier: CandidateModifier | null): string { + if (modifier === null) { + return '' + } + switch (modifier.kind) { + case 'arbitrary': + return `/[${modifier.value}]` + case 'named': + return `/${modifier.value}` + } +} + +function formatNegative(negative: boolean): string { + return negative ? '-' : '' +} diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index ea11e2fb7ab8..178fabdfa555 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -189,7 +189,7 @@ export type Candidate = * E.g.: * * - `underline` - * - `flex` + * - `box-border` */ | { kind: 'static' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb0394c2db4..8917e64dab7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,12 @@ importers: packages/@tailwindcss-upgrade: dependencies: + '@tailwindcss/node': + specifier: workspace:^ + version: link:../@tailwindcss-node + '@tailwindcss/oxide': + specifier: workspace:^ + version: link:../../crates/node enhanced-resolve: specifier: ^5.17.1 version: 5.17.1 From c767e020cc03568eda1ccb15a204f8b58ec82bf5 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 15:15:37 +0200 Subject: [PATCH 07/12] Add important migration --- .../src/template/candidates.test.ts | 91 +++++++---- .../src/template/candidates.ts | 144 +++++++++++++----- .../codemods/migrate-important.test.ts | 20 +++ .../template/codemods/migrate-important.ts | 23 +++ .../src/template/migrate.ts | 30 ++++ 5 files changed, 242 insertions(+), 66 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/migrate.ts diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index f080e7af0139..b8f1b846cc83 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -1,11 +1,11 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' import { parseCandidate } from '../../../tailwindcss/src/candidate' -import { extractCandidates, toString } from './candidates' +import { extractCandidates, printCandidate } from './candidates' let html = String.raw -test.skip('extracts candidates with positions from a template', () => { +test('extracts candidates with positions from a template', () => { let content = html`
@@ -114,44 +114,75 @@ test.skip('extracts candidates with positions from a template', () => { `) }) -describe('toString()', () => { - test.each([ - // Arbitrary candidates - ['[color:red]', '[color:red]'], - ['[color:red]/50', '[color:red]/50'], - ['[color:red]/[0.5]', '[color:red]/[0.5]'], - ['[color:red]/50!', '[color:red]/50!'], - ['![color:red]/50', '[color:red]/50!'], - ['[color:red]/[0.5]!', '[color:red]/[0.5]!'], +const candidates = [ + // Arbitrary candidates + ['[color:red]', '[color:red]'], + ['[color:red]/50', '[color:red]/50'], + ['[color:red]/[0.5]', '[color:red]/[0.5]'], + ['[color:red]/50!', '[color:red]/50!'], + ['![color:red]/50', '[color:red]/50!'], + ['[color:red]/[0.5]!', '[color:red]/[0.5]!'], + + // Static candidates + ['box-border', 'box-border'], + ['underline!', 'underline!'], + ['!underline', 'underline!'], + ['-inset-full', '-inset-full'], + + // Functional candidates + ['bg-red-500', 'bg-red-500'], + ['bg-red-500/50', 'bg-red-500/50'], + ['bg-red-500/[0.5]', 'bg-red-500/[0.5]'], + ['bg-red-500!', 'bg-red-500!'], + ['!bg-red-500', 'bg-red-500!'], + ['bg-[#0088cc]/50', 'bg-[#0088cc]/50'], + ['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'], + ['bg-[#0088cc]!', 'bg-[#0088cc]!'], + ['!bg-[#0088cc]', 'bg-[#0088cc]!'], + ['w-1/2', 'w-1/2'], +] + +const variants = [ + '', // no variant + '*:', + 'focus:', + 'group-focus:', + + 'hover:focus:', + 'hover:group-focus:', + 'group-hover:focus:', + 'group-hover:group-focus:', - // Static candidates - ['box-border', 'box-border'], - ['underline!', 'underline!'], - ['!underline', 'underline!'], - ['-inset-full', '-inset-full'], + 'min-[10px]:', + // TODO: This currently expands `calc(1000px+12em)` to `calc(1000px_+_12em)` + 'min-[calc(1000px_+_12em)]:', - // Functional candidates - ['bg-red-500', 'bg-red-500'], - ['bg-red-500/50', 'bg-red-500/50'], - ['bg-red-500/[0.5]', 'bg-red-500/[0.5]'], - ['bg-red-500!', 'bg-red-500!'], - ['!bg-red-500', 'bg-red-500!'], - ['bg-[#0088cc]/50', 'bg-[#0088cc]/50'], - ['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'], - ['bg-[#0088cc]!', 'bg-[#0088cc]!'], - ['!bg-[#0088cc]', 'bg-[#0088cc]!'], - ['w-1/2', 'w-1/2'], - ])('%s', async (candidate: string, result: string) => { + 'peer-[&_p]:', + 'peer-[&_p]:hover:', + 'hover:peer-[&_p]:', + 'hover:peer-[&_p]:focus:', + 'peer-[&:hover]:peer-[&_p]:', +] + +let combinations: [string, string][] = [] +for (let variant of variants) { + for (let [input, output] of candidates) { + combinations.push([`${variant}${input}`, `${variant}${output}`]) + } +} + +describe('toString()', () => { + test.each(combinations)('%s', async (candidate: string, result: string) => { let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { base: __dirname, }) let candidates = parseCandidate(candidate, designSystem) - // TODO: This seems unexpected? // Sometimes we will have a functional and a static candidate for the same // raw input string (e.g. `-inset-full`). Dedupe in this case. - let cleaned = new Set([...candidates].map(toString)) + // TODO: This seems unexpected? + let cleaned = new Set([...candidates].map(printCandidate)) expect([...cleaned]).toEqual([result]) }) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index a66245978b30..a246b5ca4678 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -3,11 +3,7 @@ import { Scanner } from '@tailwindcss/oxide' // This file uses private APIs to work with candidates. // TODO: Should we export this in the public package so we have the same // version as the tailwindcss package? -import { - parseCandidate, - type Candidate, - type CandidateModifier, -} from '../../../tailwindcss/src/candidate' +import { parseCandidate, type Candidate, type Variant } from '../../../tailwindcss/src/candidate' let css = String.raw @@ -32,46 +28,122 @@ export async function extractCandidates( return candidates } -export function toString(candidate: Candidate): string { - let variants = '' - let important = candidate.important ? '!' : '' +export function printCandidate(candidate: Candidate | null) { + if (candidate === null) return 'null' + let parts: string[] = [] - switch (candidate.kind) { - case 'arbitrary': { - return `${variants}[${candidate.property}:${candidate.value}]${formatModifier( - candidate.modifier, - )}${important}` + for (let variant of candidate.variants) { + parts.unshift(printVariant(variant)) + } + + let base: string = '' + + // Handle negative + if (candidate.kind === 'static' || candidate.kind === 'functional') { + if (candidate.negative) { + base += '-' } - case 'static': { - return `${formatNegative(candidate.negative)}${variants}${candidate.root}${important}` + } + + // Handle static + if (candidate.kind === 'static') { + base += candidate.root + } + + // Handle functional + if (candidate.kind === 'functional') { + base += candidate.root + + if (candidate.value) { + if (candidate.value.kind === 'arbitrary') { + if (candidate.value === null) { + base += '' + } else if (candidate.value.dataType) { + base += `-[${candidate.value.dataType}:${escapeArbitrary(candidate.value.value)}]` + } else { + base += `-[${escapeArbitrary(candidate.value.value)}]` + } + } else if (candidate.value.kind === 'named') { + base += `-${candidate.value.value}` + } } - case 'functional': { - let value = - candidate.value === null - ? '' - : candidate.value.kind === 'named' - ? `-${candidate.value.value}` - : `-[${candidate.value.value}]` - - return `${formatNegative(candidate.negative)}${variants}${candidate.root}${value}${formatModifier( - candidate.modifier, - )}${important}` + } + + // Handle arbitrary + if (candidate.kind === 'arbitrary') { + base += `[${candidate.property}:${escapeArbitrary(candidate.value)}]` + } + + // Handle modifier + if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') { + if (candidate.modifier) { + if (candidate.modifier.kind === 'arbitrary') { + base += `/[${escapeArbitrary(candidate.modifier.value)}]` + } else if (candidate.modifier.kind === 'named') { + base += `/${candidate.modifier.value}` + } } } + + // Handle important + if (candidate.important) { + base += '!' + } + + parts.push(base) + + return parts.join(':') } -function formatModifier(modifier: CandidateModifier | null): string { - if (modifier === null) { - return '' +function printVariant(variant: Variant) { + // Handle static variants + if (variant.kind === 'static') { + return variant.root + } + + // Handle arbitrary variants + if (variant.kind === 'arbitrary') { + return `[${escapeArbitrary(variant.selector)}]` } - switch (modifier.kind) { - case 'arbitrary': - return `/[${modifier.value}]` - case 'named': - return `/${modifier.value}` + + let base: string = '' + + // Handle functional variants + if (variant.kind === 'functional') { + if (variant.value) { + if (variant.value.kind === 'arbitrary') { + base += `${variant.root}-[${escapeArbitrary(variant.value.value)}]` + } else if (variant.value.kind === 'named') { + base += `${variant.root}-${variant.value.value}` + } + } else { + base += variant.root + } } + + // Handle compound variants + if (variant.kind === 'compound') { + base += variant.root + base += '-' + base += printVariant(variant.variant) + } + + // Handle modifiers + if (variant.kind === 'functional' || variant.kind === 'compound') { + if (variant.modifier) { + if (variant.modifier.kind === 'arbitrary') { + base += `/[${escapeArbitrary(variant.modifier.value)}]` + } else if (variant.modifier.kind === 'named') { + base += `/${variant.modifier.value}` + } + } + } + + return base } -function formatNegative(negative: boolean): string { - return negative ? '-' : '' +function escapeArbitrary(input: string) { + return input + .replaceAll(String.raw`_`, String.raw`\_`) // Escape underscores to keep them as-is + .replaceAll(String.raw` `, String.raw`_`) // Replace spaces with underscores } diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts new file mode 100644 index 000000000000..1f88b8fa1af7 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts @@ -0,0 +1,20 @@ +import dedent from 'dedent' +import { expect, test } from 'vitest' +import migrate from '../migrate' +import { migrateImportant } from './migrate-important' + +let html = dedent + +test('applies the migration', () => { + let content = html` +
+ +
+ ` + + expect(migrate(content, [migrateImportant])).resolves.toMatchInlineSnapshot(` + "
+ +
" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts new file mode 100644 index 000000000000..703481aa4b2c --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts @@ -0,0 +1,23 @@ +import type { Candidate } from '../../../../tailwindcss/src/candidate' + +// In v3 the important modifier `!` sits in front of the utility itself, not +// before any of the variants. In v4, we want it to be at the end of the utility +// so that it's always in the same location regardless of whether you used +// variants or not. +// +// So this: +// +// !flex md:!block +// +// Should turn into: +// +// flex! md:block! +export function migrateImportant(candidate: Candidate): Candidate | null { + if (candidate.important) { + // The printCandidate function will already put the exclamation mark in the + // right place, so we just need to mark this candidate as requiring a + // migration. + return candidate + } + return null +} diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts new file mode 100644 index 000000000000..f61bde6a7fb6 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -0,0 +1,30 @@ +import type { Candidate } from '../../../tailwindcss/src/candidate' +import { extractCandidates, printCandidate } from './candidates' + +export type Migration = (candidate: Candidate) => Candidate | null + +export default async function migrate(input: string, migrations: Migration[]): Promise { + let candidates = await extractCandidates(input) + + // Sort candidates by starting position desc + candidates.sort((a, z) => z.start - a.start) + + let output = input + for (let { candidate, start, end } of candidates) { + let needsMigration = false + for (let migration of migrations) { + let migrated = migration(candidate) + if (migrated) { + candidate = migrated + needsMigration = true + break + } + } + + if (needsMigration) { + output = output.slice(0, start) + printCandidate(candidate) + output.slice(end) + } + } + + return output +} From a132a08fa40c6f27c066f9d8bf80edeb343bc9a1 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 15:46:39 +0200 Subject: [PATCH 08/12] Add migration scaffolding --- .../src/fixtures/src/index.html | 8 +++++++ .../src/fixtures/tailwind.config.js | 4 ++++ packages/@tailwindcss-upgrade/src/index.ts | 18 +++++++++++++++- .../src/template/candidates.test.ts | 8 +++++-- .../src/template/candidates.ts | 11 ++-------- .../codemods/migrate-important.test.ts | 9 ++++++-- .../src/template/migrate.ts | 21 ++++++++++++++++--- .../src/template/parseConfig.ts | 20 ++++++++++++++++++ 8 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/fixtures/src/index.html create mode 100644 packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js create mode 100644 packages/@tailwindcss-upgrade/src/template/parseConfig.ts diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html new file mode 100644 index 000000000000..db4a752da30e --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html @@ -0,0 +1,8 @@ + + + My Tailwind CSS Upgrade Example + + +
+ + diff --git a/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js b/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js new file mode 100644 index 000000000000..a3687ebedc54 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/fixtures/tailwind.config.js @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{html,js}'], +} diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index d3243a199968..1fac7c6f75f6 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -2,13 +2,16 @@ import { globby } from 'globby' import path from 'node:path' +import type { DesignSystem } from '../../tailwindcss/src/design-system' import { help } from './commands/help' import { migrate } from './migrate' +import { parseConfig } from './template/parseConfig' import { args, type Arg } from './utils/args' import { isRepoDirty } from './utils/git' import { eprintln, error, header, highlight, info, success } from './utils/renderer' const options = { + '--config': { type: 'string', description: 'Path to the configuration file', alias: '-c' }, '--help': { type: 'boolean', description: 'Display usage information', alias: '-h' }, '--force': { type: 'boolean', description: 'Force the migration', alias: '-f' }, '--version': { type: 'boolean', description: 'Display the version number', alias: '-v' }, @@ -37,13 +40,26 @@ async function run() { } } + let designSystem: DesignSystem | null = null + let paths: string[] = [] + if (flags['--config']) { + try { + designSystem = await parseConfig(flags['--config'], { base: process.cwd() }) + } catch (e: any) { + error(`Failed to parse the configuration file: ${e.message}`) + process.exit(1) + } + } + + // console.log(designSystem) + // Use provided files let files = flags._.map((file) => path.resolve(process.cwd(), file)) // Discover CSS files in case no files were provided if (files.length === 0) { info( - 'No files provided. Searching for CSS files in the current directory and its subdirectories…', + 'No input stylesheets provided. Searching for CSS files in the current directory and its subdirectories…', ) files = await globby(['**/*.css'], { diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index b8f1b846cc83..76e2fbb45921 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -5,14 +5,18 @@ import { extractCandidates, printCandidate } from './candidates' let html = String.raw -test('extracts candidates with positions from a template', () => { +test('extracts candidates with positions from a template', async () => { let content = html`
` - expect(extractCandidates(content)).resolves.toMatchInlineSnapshot(` + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(extractCandidates(designSystem, content)).resolves.toMatchInlineSnapshot(` [ { "candidate": { diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index a246b5ca4678..5c276e5b7bb9 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,21 +1,14 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { Scanner } from '@tailwindcss/oxide' // This file uses private APIs to work with candidates. // TODO: Should we export this in the public package so we have the same // version as the tailwindcss package? import { parseCandidate, type Candidate, type Variant } from '../../../tailwindcss/src/candidate' - -let css = String.raw +import type { DesignSystem } from '../../../tailwindcss/src/design-system' export async function extractCandidates( + designSystem: DesignSystem, content: string, ): Promise<{ candidate: Candidate; start: number; end: number }[]> { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - `, - { base: __dirname }, - ) let scanner = new Scanner({}) let result = scanner.getCandidatesWithPositions({ content, extension: 'html' }) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts index 1f88b8fa1af7..40da40d590f9 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts @@ -1,3 +1,4 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' import dedent from 'dedent' import { expect, test } from 'vitest' import migrate from '../migrate' @@ -5,14 +6,18 @@ import { migrateImportant } from './migrate-important' let html = dedent -test('applies the migration', () => { +test('applies the migration', async () => { let content = html`
` - expect(migrate(content, [migrateImportant])).resolves.toMatchInlineSnapshot(` + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(` "
" diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index f61bde6a7fb6..80e708c878c5 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -1,15 +1,23 @@ +import fs from 'node:fs/promises' +import path from 'node:path' import type { Candidate } from '../../../tailwindcss/src/candidate' +import type { DesignSystem } from '../../../tailwindcss/src/design-system' import { extractCandidates, printCandidate } from './candidates' +import { migrateImportant } from './codemods/migrate-important' export type Migration = (candidate: Candidate) => Candidate | null -export default async function migrate(input: string, migrations: Migration[]): Promise { - let candidates = await extractCandidates(input) +export default async function migrateContents( + designSystem: DesignSystem, + contents: string, + migrations: Migration[] = [migrateImportant], +): Promise { + let candidates = await extractCandidates(designSystem, contents) // Sort candidates by starting position desc candidates.sort((a, z) => z.start - a.start) - let output = input + let output = contents for (let { candidate, start, end } of candidates) { let needsMigration = false for (let migration of migrations) { @@ -28,3 +36,10 @@ export default async function migrate(input: string, migrations: Migration[]): P return output } + +export async function migrate(designSystem: DesignSystem, file: string) { + let fullPath = path.resolve(process.cwd(), file) + let contents = await fs.readFile(fullPath, 'utf-8') + + await fs.writeFile(fullPath, await migrateContents(designSystem, contents)) +} diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts new file mode 100644 index 000000000000..6035ac3fe5d6 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -0,0 +1,20 @@ +import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node' +import dedent from 'dedent' +import type { DesignSystem } from '../../../tailwindcss/src/design-system' + +let css = dedent +export async function parseConfig( + path: string, + options: { base: string }, +): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> { + let input = css` + @import 'tailwindcss'; + @config './${path}'; + ` + + let [compiler, designSystem] = await Promise.all([ + compile(input, { ...options, onDependency: () => {} }), + __unstable__loadDesignSystem(input, options), + ]) + return { designSystem, globs: compiler.globs } +} From 141cd39d7d9ff9c52e3326b2054c6f58e3645a7c Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 15:55:56 +0200 Subject: [PATCH 09/12] Migrate stylesheets and templates --- .../src/fixtures/src/index.html | 2 +- .../src/fixtures/src/input.css | 3 + packages/@tailwindcss-upgrade/src/index.ts | 78 +++++++++++++------ 3 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/fixtures/src/input.css diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html index db4a752da30e..0b7dfc0ddc02 100644 --- a/packages/@tailwindcss-upgrade/src/fixtures/src/index.html +++ b/packages/@tailwindcss-upgrade/src/fixtures/src/index.html @@ -3,6 +3,6 @@ My Tailwind CSS Upgrade Example -
+
diff --git a/packages/@tailwindcss-upgrade/src/fixtures/src/input.css b/packages/@tailwindcss-upgrade/src/fixtures/src/input.css new file mode 100644 index 000000000000..b5c61c956711 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/fixtures/src/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 1fac7c6f75f6..f84be145164e 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -4,7 +4,8 @@ import { globby } from 'globby' import path from 'node:path' import type { DesignSystem } from '../../tailwindcss/src/design-system' import { help } from './commands/help' -import { migrate } from './migrate' +import { migrate as migrateStylesheet } from './migrate' +import { migrate as migrateTemplate } from './template/migrate' import { parseConfig } from './template/parseConfig' import { args, type Arg } from './utils/args' import { isRepoDirty } from './utils/git' @@ -40,45 +41,78 @@ async function run() { } } - let designSystem: DesignSystem | null = null - let paths: string[] = [] + let parsedConfig: { + designSystem: DesignSystem + globs: { pattern: string; base: string }[] + } | null = null if (flags['--config']) { try { - designSystem = await parseConfig(flags['--config'], { base: process.cwd() }) + parsedConfig = await parseConfig(flags['--config'], { base: process.cwd() }) } catch (e: any) { error(`Failed to parse the configuration file: ${e.message}`) process.exit(1) } } - // console.log(designSystem) + if (parsedConfig) { + // Template migrations - // Use provided files - let files = flags._.map((file) => path.resolve(process.cwd(), file)) + info('Migrating templates using the provided configuration file.') - // Discover CSS files in case no files were provided - if (files.length === 0) { - info( - 'No input stylesheets provided. Searching for CSS files in the current directory and its subdirectories…', - ) + let set = new Set() + for (let { pattern, base } of parsedConfig.globs) { + let files = await globby([pattern], { + absolute: true, + gitignore: true, + cwd: base, + }) - files = await globby(['**/*.css'], { - absolute: true, - gitignore: true, - }) + for (let file of files) { + set.add(file) + } + } + + let files = Array.from(set) + files.sort() + + // Migrate each file + await Promise.allSettled(files.map((file) => migrateTemplate(parsedConfig.designSystem, file))) + + success('Template migration complete.') } - // Ensure we are only dealing with CSS files - files = files.filter((file) => file.endsWith('.css')) + { + // Stylesheet migrations - // Migrate each file - await Promise.allSettled(files.map((file) => migrate(file))) + // Use provided files + let files = flags._.map((file) => path.resolve(process.cwd(), file)) + + // Discover CSS files in case no files were provided + if (files.length === 0) { + info( + 'No input stylesheets provided. Searching for CSS files in the current directory and its subdirectories…', + ) + + files = await globby(['**/*.css'], { + absolute: true, + gitignore: true, + }) + } + + // Ensure we are only dealing with CSS files + files = files.filter((file) => file.endsWith('.css')) + + // Migrate each file + await Promise.allSettled(files.map((file) => migrateStylesheet(file))) + + success('Stylesheet migration complete.') + } // Figure out if we made any changes if (isRepoDirty()) { - success('Migration complete. Verify the changes and commit them to your repository.') + success('Verify the changes and commit them to your repository.') } else { - success('Migration complete. No changes were made to your repository.') + success('No changes were made to your repository.') } } From 8846fb46f2e2f4bcafe919d850a130c3f9ed6501 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 24 Sep 2024 16:28:55 +0200 Subject: [PATCH 10/12] Use parseCandidate from the DesignSystem --- .../@tailwindcss-upgrade/src/template/candidates.test.ts | 3 +-- packages/@tailwindcss-upgrade/src/template/candidates.ts | 7 ++----- packages/@tailwindcss-upgrade/src/template/parseConfig.ts | 1 + 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index 76e2fbb45921..e9acc519b1b6 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -1,6 +1,5 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' -import { parseCandidate } from '../../../tailwindcss/src/candidate' import { extractCandidates, printCandidate } from './candidates' let html = String.raw @@ -181,7 +180,7 @@ describe('toString()', () => { base: __dirname, }) - let candidates = parseCandidate(candidate, designSystem) + let candidates = designSystem.parseCandidate(candidate) // Sometimes we will have a functional and a static candidate for the same // raw input string (e.g. `-inset-full`). Dedupe in this case. diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index 5c276e5b7bb9..918f50e4a2a7 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,8 +1,5 @@ import { Scanner } from '@tailwindcss/oxide' -// This file uses private APIs to work with candidates. -// TODO: Should we export this in the public package so we have the same -// version as the tailwindcss package? -import { parseCandidate, type Candidate, type Variant } from '../../../tailwindcss/src/candidate' +import { type Candidate, type Variant } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' export async function extractCandidates( @@ -14,7 +11,7 @@ export async function extractCandidates( let candidates: { candidate: Candidate; start: number; end: number }[] = [] for (let { candidate: rawCandidate, position: start } of result) { - for (let candidate of parseCandidate(rawCandidate, designSystem)) { + for (let candidate of designSystem.parseCandidate(rawCandidate)) { candidates.push({ candidate, start, end: start + rawCandidate.length }) } } diff --git a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts index 6035ac3fe5d6..ac32e7f359dc 100644 --- a/packages/@tailwindcss-upgrade/src/template/parseConfig.ts +++ b/packages/@tailwindcss-upgrade/src/template/parseConfig.ts @@ -7,6 +7,7 @@ export async function parseConfig( path: string, options: { base: string }, ): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> { + // TODO: base path needs to be resolved to v4 let input = css` @import 'tailwindcss'; @config './${path}'; From c8fc77b4590d22acd7a88bad16bcf221829be36a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 11:30:12 +0200 Subject: [PATCH 11/12] Use i64 over f64 for pos --- crates/node/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index cb8f091c50d5..5c9baa67bc0d 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -85,11 +85,11 @@ pub struct Scanner { #[derive(Debug, Clone)] #[napi(object)] pub struct CandidateWithPosition { - /// Base path of the glob + // The candidate string pub candidate: String, - /// Glob pattern - pub position: f64, + // The position of the candidate inside the content file + pub position: i64, } #[napi] @@ -129,7 +129,7 @@ impl Scanner { .into_iter() .map(|(candidate, position)| CandidateWithPosition { candidate, - position: position as f64, + position: position as i64, }) .collect() } From da60fb87019fa118a6525f69a4b6b4c89c149bb2 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 25 Sep 2024 11:46:52 +0200 Subject: [PATCH 12/12] Fix slice issues with unicode surrogates --- packages/@tailwindcss-upgrade/package.json | 7 +++--- .../src/template/candidates.test.ts | 23 ++++++++++++++++++- .../src/template/candidates.ts | 10 ++++++++ .../src/template/migrate.ts | 4 ++-- pnpm-lock.yaml | 9 ++++++++ 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index b920e1c9dd66..87539e92c4cf 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -27,6 +27,8 @@ "access": "public" }, "dependencies": { + "@tailwindcss/node": "workspace:^", + "@tailwindcss/oxide": "workspace:^", "enhanced-resolve": "^5.17.1", "globby": "^14.0.2", "mri": "^1.2.0", @@ -35,9 +37,8 @@ "postcss-import": "^16.1.0", "postcss-selector-parser": "^6.1.2", "prettier": "^3.3.3", - "tailwindcss": "workspace:^", - "@tailwindcss/oxide": "workspace:^", - "@tailwindcss/node": "workspace:^" + "string-byte-slice": "^3.0.0", + "tailwindcss": "workspace:^" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index e9acc519b1b6..bc754ab6f6e2 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -1,6 +1,6 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' -import { extractCandidates, printCandidate } from './candidates' +import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates' let html = String.raw @@ -117,6 +117,27 @@ test('extracts candidates with positions from a template', async () => { `) }) +test('replaces the right positions for a candidate', async () => { + let content = html` +

🤠👋

+
+ ` + + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + let candidate = (await extractCandidates(designSystem, content))[0] + + expect(replaceCandidateInContent(content, 'flex', candidate.start, candidate.end)) + .toMatchInlineSnapshot(` + " +

🤠👋

+
+ " + `) +}) + const candidates = [ // Arbitrary candidates ['[color:red]', '[color:red]'], diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index 918f50e4a2a7..be850fc65ee5 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -1,4 +1,5 @@ import { Scanner } from '@tailwindcss/oxide' +import stringByteSlice from 'string-byte-slice' import { type Candidate, type Variant } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' @@ -137,3 +138,12 @@ function escapeArbitrary(input: string) { .replaceAll(String.raw`_`, String.raw`\_`) // Escape underscores to keep them as-is .replaceAll(String.raw` `, String.raw`_`) // Replace spaces with underscores } + +export function replaceCandidateInContent( + content: string, + replacement: string, + startByte: number, + endByte: number, +) { + return stringByteSlice(content, 0, startByte) + replacement + stringByteSlice(content, endByte) +} diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index 80e708c878c5..2decefc76781 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import type { Candidate } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' -import { extractCandidates, printCandidate } from './candidates' +import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates' import { migrateImportant } from './codemods/migrate-important' export type Migration = (candidate: Candidate) => Candidate | null @@ -30,7 +30,7 @@ export default async function migrateContents( } if (needsMigration) { - output = output.slice(0, start) + printCandidate(candidate) + output.slice(end) + output = replaceCandidateInContent(output, printCandidate(candidate), start, end) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8917e64dab7a..fd2ee2ce22e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ importers: prettier: specifier: ^3.3.3 version: 3.3.3 + string-byte-slice: + specifier: ^3.0.0 + version: 3.0.0 tailwindcss: specifier: workspace:^ version: link:../tailwindcss @@ -2787,6 +2790,10 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-byte-slice@3.0.0: + resolution: {integrity: sha512-KqTTvThKPDgBPr9jI2cOdO04tJ+upcADk4j4zmcBNmG6Bqstsq1x1Z3xvJAPqRQgPE8yocXNLVZuCfYlv4+PTg==} + engines: {node: '>=18.18.0'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5560,6 +5567,8 @@ snapshots: streamsearch@1.1.0: {} + string-byte-slice@3.0.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0