From d9290d93d08a1a9ad1852e4d7f087414a0012713 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 11:33:31 +0100 Subject: [PATCH 01/11] add failing test This test fails and was converted to `in-[figure>]` which is incorrect. The reason is because we only checked for `&` and the very end, but it should be preceded by a combinator space. --- .../src/template/codemods/modernize-arbitrary-values.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts index 51d26a107eab..f7e00da8fc3a 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts @@ -22,6 +22,8 @@ test.each([ ['[p_&]:flex', 'in-[p]:flex'], ['[.foo_&]:flex', 'in-[.foo]:flex'], ['[[data-visible]_&]:flex', 'in-data-visible:flex'], + // Using `>` instead of ` ` should not be transformed + ['[figure>&]:my-0', '[figure>&]:my-0'], // nth-child ['[&:nth-child(2)]:flex', 'nth-2:flex'], From b31178321c2614b34bec9f070cdef693b3585716 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 11:34:32 +0100 Subject: [PATCH 02/11] add `memcpy` util It's a little bit silly, but during debugging I noticed that the `variant` looked all funky. This is because we convert one variant to another via `Object.assign`. It does work properly because we compare the `kind` field (which gets converted), and then we know that all the properties of the new `kind` are available. With this change we first clear out all the existing keys, resulting in an expected shape when inspecting the `variant`. --- .../codemods/modernize-arbitrary-values.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index 3efa3f5ddd38..d9f8314941c6 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -5,6 +5,14 @@ import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' import { printCandidate } from '../candidates' +function memcpy(target: T, source: U): U { + // Clear out the target object, otherwise inspecting the final object will + // look very confusing. + for (let key in target) delete target[key] + + return Object.assign(target, source) +} + export function modernizeArbitraryValues( designSystem: DesignSystem, _userConfig: Config, @@ -55,7 +63,7 @@ export function modernizeArbitraryValues( ast.nodes[0].nodes[2].type === 'universal' ) { changed = true - Object.assign(variant, designSystem.parseVariant('*')) + memcpy(variant, designSystem.parseVariant('*')) continue } @@ -73,7 +81,7 @@ export function modernizeArbitraryValues( ast.nodes[0].nodes[2].type === 'universal' ) { changed = true - Object.assign(variant, designSystem.parseVariant('**')) + memcpy(variant, designSystem.parseVariant('**')) continue } @@ -131,7 +139,7 @@ export function modernizeArbitraryValues( // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. // // Later this gets converted to `in-data-visible`. - Object.assign(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) + memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) continue } @@ -162,7 +170,7 @@ export function modernizeArbitraryValues( } changed = true - Object.assign(variant, designSystem.parseVariant(`in-[${selector.toString().trim()}]`)) + memcpy(variant, designSystem.parseVariant(`in-[${selector.toString().trim()}]`)) continue } @@ -298,7 +306,7 @@ export function modernizeArbitraryValues( // Update original variant changed = true - Object.assign(variant, parsed) + memcpy(variant, parsed) } // Expecting an attribute selector @@ -323,7 +331,7 @@ export function modernizeArbitraryValues( if (attributeKey.startsWith('data-')) { changed = true attributeKey = attributeKey.slice(5) // Remove `data-` - Object.assign(variant, { + memcpy(variant, { kind: 'functional', root: 'data', modifier: null, @@ -338,7 +346,7 @@ export function modernizeArbitraryValues( else if (attributeKey.startsWith('aria-')) { changed = true attributeKey = attributeKey.slice(5) // Remove `aria-` - Object.assign(variant, { + memcpy(variant, { kind: 'functional', root: 'aria', modifier: null, From a71a26890a865fb6043c7e6548365bdd214bc16d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 11:37:51 +0100 Subject: [PATCH 03/11] add dedicated `in-*` variant migration section --- .../codemods/modernize-arbitrary-values.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index d9f8314941c6..2e83fe422941 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -85,6 +85,30 @@ export function modernizeArbitraryValues( continue } + // `in-*` variant. If the selector ends with ` &`, we can convert it to an + // `in-*` variant. + // + // E.g.: `[[data-visible]_&]` => `in-data-visible` + if ( + // Only top-level, so `in-[&_[data-visible]]` is not supported + parent === null && + // [[data-visible]___&]:flex + // ^^^^^^^^^^^^^^ ^ ^ + ast.nodes[0].nodes.at(-2)?.type === 'combinator' && + ast.nodes[0].nodes.at(-2)?.value === ' ' && + ast.nodes[0].nodes.at(-1)?.type === 'nesting' + ) { + ast.nodes[0].nodes = [ast.nodes[0].nodes[0]] + changed = true + // When handling a compound like `in-[[data-visible]]`, we will first + // handle `[[data-visible]]`, then the parent `in-*` part. This means + // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. + // + // Later this gets converted to `in-data-visible`. + memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) + continue + } + // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` if ( // Only top-level, so `has-[&>[data-visible]]` is not supported From 57d676527cd57b6a3de60732c737cf4fdc3b263b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 11:38:11 +0100 Subject: [PATCH 04/11] remove old / complex `in-*` handling --- .../codemods/modernize-arbitrary-values.ts | 55 ------------------- 1 file changed, 55 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index 2e83fe422941..50033981c224 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -143,61 +143,6 @@ export function modernizeArbitraryValues( prefixedVariant = designSystem.parseVariant('**') } - // Handling a child/parent combinator. E.g.: `[[data-visible]_&]` => `in-data-visible` - if ( - // Only top-level, so `has-[&_[data-visible]]` is not supported - parent === null && - // [[data-visible]___&]:flex - // ^^^^^^^^^^^^^^ ^ ^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'attribute' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === ' ' && - ast.nodes[0].nodes[2].type === 'nesting' && - ast.nodes[0].nodes[2].value === '&' - ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[0]] - changed = true - // When handling a compound like `in-[[data-visible]]`, we will first - // handle `[[data-visible]]`, then the parent `in-*` part. This means - // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. - // - // Later this gets converted to `in-data-visible`. - memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) - continue - } - - // `in-*` variant - if ( - // Only top-level, so `has-[p_&]` is not supported - parent === null && - // `[data-*]` and `[aria-*]` are handled separately - !( - ast.nodes[0].nodes[0].type === 'attribute' && - (ast.nodes[0].nodes[0].attribute.startsWith('data-') || - ast.nodes[0].nodes[0].attribute.startsWith('aria-')) - ) && - // [.foo___&]:flex - // ^^^^ ^ ^ - ast.nodes[0].nodes.at(-1)?.type === 'nesting' - ) { - let selector = ast.nodes[0] - let nodes = selector.nodes - - nodes.pop() // Remove the last node `&` - - // Remove trailing whitespace - let last = nodes.at(-1) - while (last?.type === 'combinator' && last.value === ' ') { - nodes.pop() - last = nodes.at(-1) - } - - changed = true - memcpy(variant, designSystem.parseVariant(`in-[${selector.toString().trim()}]`)) - continue - } - // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` let selectorNodes = ast.nodes[0].filter((node) => node.type !== 'nesting') From 514cf13f12f4a58c063cd000e5915d53fe7bacd5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 19 Nov 2024 16:02:48 +0100 Subject: [PATCH 05/11] migrate `group-[]:flex` to `in-[.group]:flex` If you use a prefix, migrate `group-[]:tw-flex` to `tw:in-[.tw\:group]:flex` --- .../modernize-arbitrary-values.test.ts | 38 +++++++++++++++++++ .../codemods/modernize-arbitrary-values.ts | 36 ++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts index f7e00da8fc3a..6447723131ed 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts @@ -1,6 +1,7 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { expect, test } from 'vitest' import { modernizeArbitraryValues } from './modernize-arbitrary-values' +import { prefix } from './prefix' test.each([ // Arbitrary variants @@ -24,6 +25,16 @@ test.each([ ['[[data-visible]_&]:flex', 'in-data-visible:flex'], // Using `>` instead of ` ` should not be transformed ['[figure>&]:my-0', '[figure>&]:my-0'], + // Some extreme examples of what happens in the wild: + ['group-[]:flex', 'in-[.group]:flex'], + ['group-[]/name:flex', 'in-[.group\\/name]:flex'], + + // These shouldn't happen in the real world (because compound variants are + // new). But this could happen once we allow codemods to run in v4+ projects. + ['has-group-[]:flex', 'has-in-[.group]:flex'], + ['has-group-[]/name:flex', 'has-in-[.group\\/name]:flex'], + ['not-group-[]:flex', 'not-in-[.group]:flex'], + ['not-group-[]/name:flex', 'not-in-[.group\\/name]:flex'], // nth-child ['[&:nth-child(2)]:flex', 'nth-2:flex'], @@ -79,3 +90,30 @@ test.each([ expect(modernizeArbitraryValues(designSystem, {}, candidate)).toEqual(result) }) + +test.each([ + // Should not prefix classes in arbitrary values + ['[.foo_&]:tw-flex', 'tw:in-[.foo]:flex'], + + // Should migrate `.group` classes + ['group-[]:tw-flex', 'tw:in-[.tw\\:group]:flex'], + ['group-[]/name:tw-flex', 'tw:in-[.tw\\:group\\/name]:flex'], + + // These shouldn't happen in the real world (because compound variants are + // new). But this could happen once we allow codemods to run in v4+ projects. + ['has-group-[]:tw-flex', 'tw:has-in-[.tw\\:group]:flex'], + ['has-group-[]/name:tw-flex', 'tw:has-in-[.tw\\:group\\/name]:flex'], + ['not-group-[]:tw-flex', 'tw:not-in-[.tw\\:group]:flex'], + ['not-group-[]/name:tw-flex', 'tw:not-in-[.tw\\:group\\/name]:flex'], +])('%s => %s', async (candidate, result) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss" prefix(tw);', { + base: __dirname, + }) + + expect( + [prefix, modernizeArbitraryValues].reduce( + (acc, step) => step(designSystem, { prefix: 'tw-' }, acc), + candidate, + ), + ).toEqual(result) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index 50033981c224..d27c789e67a7 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -36,6 +36,42 @@ export function modernizeArbitraryValues( } } + // Promote `group-[]:flex` to `in-[.group]:flex` + // ^^ Yes, this is empty + // Promote `group-[]/name:flex` to `in-[.group\/name]:flex` + if ( + variant.kind === 'compound' && + variant.root === 'group' && + variant.variant.kind === 'arbitrary' && + variant.variant.selector === '&:is()' + ) { + // `group-[]` + if (variant.modifier === null) { + changed = true + memcpy( + variant, + designSystem.parseVariant( + designSystem.theme.prefix + ? `in-[.${designSystem.theme.prefix}\\:group]` + : 'in-[.group]', + ), + ) + } + + // `group-[]/name` + else if (variant.modifier.kind === 'named') { + changed = true + memcpy( + variant, + designSystem.parseVariant( + designSystem.theme.prefix + ? `in-[.${designSystem.theme.prefix}\\:group\\/${variant.modifier.value}]` + : `in-[.group\\/${variant.modifier.value}]`, + ), + ) + } + } + // Expecting an arbitrary variant if (variant.kind !== 'arbitrary') continue From 7e4ddcec52c16818ce87a9aa19634b2f8fbe27f0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 14:50:05 +0100 Subject: [PATCH 06/11] nest `arbitrary` migrations Instead of bailing early, let's nest it to keep it consistent with other migrations in the current file. Every "migration" should be guarded by some if statements. --- .../codemods/modernize-arbitrary-values.ts | 530 +++++++++--------- 1 file changed, 265 insertions(+), 265 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index d27c789e67a7..5886413a8041 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -73,304 +73,304 @@ export function modernizeArbitraryValues( } // Expecting an arbitrary variant - if (variant.kind !== 'arbitrary') continue + if (variant.kind === 'arbitrary') { + // Expecting a non-relative arbitrary variant + if (variant.relative) continue - // Expecting a non-relative arbitrary variant - if (variant.relative) continue + let ast = SelectorParser().astSync(variant.selector) - let ast = SelectorParser().astSync(variant.selector) - - // Expecting a single selector node - if (ast.nodes.length !== 1) continue - - let prefixedVariant: Variant | null = null - - // `[&>*]` can be replaced with `*` - if ( - // Only top-level, so `has-[&>*]` is not supported - parent === null && - // [&_>_*]:flex - // ^ ^ ^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === '>' && - ast.nodes[0].nodes[2].type === 'universal' - ) { - changed = true - memcpy(variant, designSystem.parseVariant('*')) - continue - } + // Expecting a single selector node + if (ast.nodes.length !== 1) continue + + let prefixedVariant: Variant | null = null + + // `[&>*]` can be replaced with `*` + if ( + // Only top-level, so `has-[&>*]` is not supported + parent === null && + // [&_>_*]:flex + // ^ ^ ^ + ast.nodes[0].length === 3 && + ast.nodes[0].nodes[0].type === 'nesting' && + ast.nodes[0].nodes[0].value === '&' && + ast.nodes[0].nodes[1].type === 'combinator' && + ast.nodes[0].nodes[1].value === '>' && + ast.nodes[0].nodes[2].type === 'universal' + ) { + changed = true + memcpy(variant, designSystem.parseVariant('*')) + continue + } - // `[&_*]` can be replaced with `**` - if ( - // Only top-level, so `has-[&_*]` is not supported - parent === null && - // [&_*]:flex - // ^ ^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === ' ' && - ast.nodes[0].nodes[2].type === 'universal' - ) { - changed = true - memcpy(variant, designSystem.parseVariant('**')) - continue - } + // `[&_*]` can be replaced with `**` + if ( + // Only top-level, so `has-[&_*]` is not supported + parent === null && + // [&_*]:flex + // ^ ^ + ast.nodes[0].length === 3 && + ast.nodes[0].nodes[0].type === 'nesting' && + ast.nodes[0].nodes[0].value === '&' && + ast.nodes[0].nodes[1].type === 'combinator' && + ast.nodes[0].nodes[1].value === ' ' && + ast.nodes[0].nodes[2].type === 'universal' + ) { + changed = true + memcpy(variant, designSystem.parseVariant('**')) + continue + } - // `in-*` variant. If the selector ends with ` &`, we can convert it to an - // `in-*` variant. - // - // E.g.: `[[data-visible]_&]` => `in-data-visible` - if ( - // Only top-level, so `in-[&_[data-visible]]` is not supported - parent === null && - // [[data-visible]___&]:flex - // ^^^^^^^^^^^^^^ ^ ^ - ast.nodes[0].nodes.at(-2)?.type === 'combinator' && - ast.nodes[0].nodes.at(-2)?.value === ' ' && - ast.nodes[0].nodes.at(-1)?.type === 'nesting' - ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[0]] - changed = true - // When handling a compound like `in-[[data-visible]]`, we will first - // handle `[[data-visible]]`, then the parent `in-*` part. This means - // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. + // `in-*` variant. If the selector ends with ` &`, we can convert it to an + // `in-*` variant. // - // Later this gets converted to `in-data-visible`. - memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) - continue - } - - // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` - if ( - // Only top-level, so `has-[&>[data-visible]]` is not supported - parent === null && - // [&_>_[data-visible]]:flex - // ^ ^ ^^^^^^^^^^^^^^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === '>' && - ast.nodes[0].nodes[2].type === 'attribute' - ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[2]] - prefixedVariant = designSystem.parseVariant('*') - } - - // Handling a grand child combinator. E.g.: `[&_[data-visible]]` => `**:data-visible` - if ( - // Only top-level, so `has-[&_[data-visible]]` is not supported - parent === null && - // [&_[data-visible]]:flex - // ^ ^^^^^^^^^^^^^^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === ' ' && - ast.nodes[0].nodes[2].type === 'attribute' - ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[2]] - prefixedVariant = designSystem.parseVariant('**') - } - - // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` - let selectorNodes = ast.nodes[0].filter((node) => node.type !== 'nesting') + // E.g.: `[[data-visible]_&]` => `in-data-visible` + if ( + // Only top-level, so `in-[&_[data-visible]]` is not supported + parent === null && + // [[data-visible]___&]:flex + // ^^^^^^^^^^^^^^ ^ ^ + ast.nodes[0].nodes.at(-2)?.type === 'combinator' && + ast.nodes[0].nodes.at(-2)?.value === ' ' && + ast.nodes[0].nodes.at(-1)?.type === 'nesting' + ) { + ast.nodes[0].nodes = [ast.nodes[0].nodes[0]] + changed = true + // When handling a compound like `in-[[data-visible]]`, we will first + // handle `[[data-visible]]`, then the parent `in-*` part. This means + // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. + // + // Later this gets converted to `in-data-visible`. + memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) + continue + } - // Expecting a single selector (normal selector or attribute selector) - if (selectorNodes.length !== 1) continue + // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` + if ( + // Only top-level, so `has-[&>[data-visible]]` is not supported + parent === null && + // [&_>_[data-visible]]:flex + // ^ ^ ^^^^^^^^^^^^^^ + ast.nodes[0].length === 3 && + ast.nodes[0].nodes[0].type === 'nesting' && + ast.nodes[0].nodes[0].value === '&' && + ast.nodes[0].nodes[1].type === 'combinator' && + ast.nodes[0].nodes[1].value === '>' && + ast.nodes[0].nodes[2].type === 'attribute' + ) { + ast.nodes[0].nodes = [ast.nodes[0].nodes[2]] + prefixedVariant = designSystem.parseVariant('*') + } - let target = selectorNodes[0] - if (target.type === 'pseudo' && target.value === ':is') { - // Expecting a single selector node - if (target.nodes.length !== 1) continue + // Handling a grand child combinator. E.g.: `[&_[data-visible]]` => `**:data-visible` + if ( + // Only top-level, so `has-[&_[data-visible]]` is not supported + parent === null && + // [&_[data-visible]]:flex + // ^ ^^^^^^^^^^^^^^ + ast.nodes[0].length === 3 && + ast.nodes[0].nodes[0].type === 'nesting' && + ast.nodes[0].nodes[0].value === '&' && + ast.nodes[0].nodes[1].type === 'combinator' && + ast.nodes[0].nodes[1].value === ' ' && + ast.nodes[0].nodes[2].type === 'attribute' + ) { + ast.nodes[0].nodes = [ast.nodes[0].nodes[2]] + prefixedVariant = designSystem.parseVariant('**') + } - // Expecting a single attribute selector - if (target.nodes[0].nodes.length !== 1) continue + // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` + let selectorNodes = ast.nodes[0].filter((node) => node.type !== 'nesting') - // Unwrap the selector from inside `&:is(…)` - target = target.nodes[0].nodes[0] - } + // Expecting a single selector (normal selector or attribute selector) + if (selectorNodes.length !== 1) continue - // Expecting a pseudo selector - if (target.type === 'pseudo') { - let targetNode = target - let compoundNot = false - if (target.value === ':not') { - compoundNot = true + let target = selectorNodes[0] + if (target.type === 'pseudo' && target.value === ':is') { + // Expecting a single selector node if (target.nodes.length !== 1) continue - if (target.nodes[0].type !== 'selector') continue + + // Expecting a single attribute selector if (target.nodes[0].nodes.length !== 1) continue - if (target.nodes[0].nodes[0].type !== 'pseudo') continue - targetNode = target.nodes[0].nodes[0] + // Unwrap the selector from inside `&:is(…)` + target = target.nodes[0].nodes[0] } - let newVariant = ((value) => { - // - if (value === ':first-letter') return 'first-letter' - else if (value === ':first-line') return 'first-line' - // - else if (value === ':file-selector-button') return 'file' - else if (value === ':placeholder') return 'placeholder' - else if (value === ':backdrop') return 'backdrop' - // Positional - else if (value === ':first-child') return 'first' - else if (value === ':last-child') return 'last' - else if (value === ':only-child') return 'only' - else if (value === ':first-of-type') return 'first-of-type' - else if (value === ':last-of-type') return 'last-of-type' - else if (value === ':only-of-type') return 'only-of-type' - // State - else if (value === ':visited') return 'visited' - else if (value === ':target') return 'target' - // Forms - else if (value === ':default') return 'default' - else if (value === ':checked') return 'checked' - else if (value === ':indeterminate') return 'indeterminate' - else if (value === ':placeholder-shown') return 'placeholder-shown' - else if (value === ':autofill') return 'autofill' - else if (value === ':optional') return 'optional' - else if (value === ':required') return 'required' - else if (value === ':valid') return 'valid' - else if (value === ':invalid') return 'invalid' - else if (value === ':in-range') return 'in-range' - else if (value === ':out-of-range') return 'out-of-range' - else if (value === ':read-only') return 'read-only' - // Content - else if (value === ':empty') return 'empty' - // Interactive - else if (value === ':focus-within') return 'focus-within' - else if (value === ':focus') return 'focus' - else if (value === ':focus-visible') return 'focus-visible' - else if (value === ':active') return 'active' - else if (value === ':enabled') return 'enabled' - else if (value === ':disabled') return 'disabled' - // - if ( - value === ':nth-child' && - targetNode.nodes.length === 1 && - targetNode.nodes[0].nodes.length === 1 && - targetNode.nodes[0].nodes[0].type === 'tag' && - targetNode.nodes[0].nodes[0].value === 'odd' - ) { - if (compoundNot) { - compoundNot = false - return 'even' - } - return 'odd' + // Expecting a pseudo selector + if (target.type === 'pseudo') { + let targetNode = target + let compoundNot = false + if (target.value === ':not') { + compoundNot = true + if (target.nodes.length !== 1) continue + if (target.nodes[0].type !== 'selector') continue + if (target.nodes[0].nodes.length !== 1) continue + if (target.nodes[0].nodes[0].type !== 'pseudo') continue + + targetNode = target.nodes[0].nodes[0] } - if ( - value === ':nth-child' && - targetNode.nodes.length === 1 && - targetNode.nodes[0].nodes.length === 1 && - targetNode.nodes[0].nodes[0].type === 'tag' && - targetNode.nodes[0].nodes[0].value === 'even' - ) { - if (compoundNot) { - compoundNot = false + + let newVariant = ((value) => { + // + if (value === ':first-letter') return 'first-letter' + else if (value === ':first-line') return 'first-line' + // + else if (value === ':file-selector-button') return 'file' + else if (value === ':placeholder') return 'placeholder' + else if (value === ':backdrop') return 'backdrop' + // Positional + else if (value === ':first-child') return 'first' + else if (value === ':last-child') return 'last' + else if (value === ':only-child') return 'only' + else if (value === ':first-of-type') return 'first-of-type' + else if (value === ':last-of-type') return 'last-of-type' + else if (value === ':only-of-type') return 'only-of-type' + // State + else if (value === ':visited') return 'visited' + else if (value === ':target') return 'target' + // Forms + else if (value === ':default') return 'default' + else if (value === ':checked') return 'checked' + else if (value === ':indeterminate') return 'indeterminate' + else if (value === ':placeholder-shown') return 'placeholder-shown' + else if (value === ':autofill') return 'autofill' + else if (value === ':optional') return 'optional' + else if (value === ':required') return 'required' + else if (value === ':valid') return 'valid' + else if (value === ':invalid') return 'invalid' + else if (value === ':in-range') return 'in-range' + else if (value === ':out-of-range') return 'out-of-range' + else if (value === ':read-only') return 'read-only' + // Content + else if (value === ':empty') return 'empty' + // Interactive + else if (value === ':focus-within') return 'focus-within' + else if (value === ':focus') return 'focus' + else if (value === ':focus-visible') return 'focus-visible' + else if (value === ':active') return 'active' + else if (value === ':enabled') return 'enabled' + else if (value === ':disabled') return 'disabled' + // + if ( + value === ':nth-child' && + targetNode.nodes.length === 1 && + targetNode.nodes[0].nodes.length === 1 && + targetNode.nodes[0].nodes[0].type === 'tag' && + targetNode.nodes[0].nodes[0].value === 'odd' + ) { + if (compoundNot) { + compoundNot = false + return 'even' + } return 'odd' } - return 'even' - } - - for (let [selector, variantName] of [ - [':nth-child', 'nth'], - [':nth-last-child', 'nth-last'], - [':nth-of-type', 'nth-of-type'], - [':nth-last-of-type', 'nth-of-last-type'], - ]) { - if (value === selector && targetNode.nodes.length === 1) { - if ( - targetNode.nodes[0].nodes.length === 1 && - targetNode.nodes[0].nodes[0].type === 'tag' && - isPositiveInteger(targetNode.nodes[0].nodes[0].value) - ) { - return `${variantName}-${targetNode.nodes[0].nodes[0].value}` + if ( + value === ':nth-child' && + targetNode.nodes.length === 1 && + targetNode.nodes[0].nodes.length === 1 && + targetNode.nodes[0].nodes[0].type === 'tag' && + targetNode.nodes[0].nodes[0].value === 'even' + ) { + if (compoundNot) { + compoundNot = false + return 'odd' } - - return `${variantName}-[${targetNode.nodes[0].toString()}]` + return 'even' } - } - return null - })(targetNode.value) + for (let [selector, variantName] of [ + [':nth-child', 'nth'], + [':nth-last-child', 'nth-last'], + [':nth-of-type', 'nth-of-type'], + [':nth-last-of-type', 'nth-of-last-type'], + ]) { + if (value === selector && targetNode.nodes.length === 1) { + if ( + targetNode.nodes[0].nodes.length === 1 && + targetNode.nodes[0].nodes[0].type === 'tag' && + isPositiveInteger(targetNode.nodes[0].nodes[0].value) + ) { + return `${variantName}-${targetNode.nodes[0].nodes[0].value}` + } + + return `${variantName}-[${targetNode.nodes[0].toString()}]` + } + } - if (newVariant === null) continue + return null + })(targetNode.value) - // Add `not-` prefix - if (compoundNot) newVariant = `not-${newVariant}` + if (newVariant === null) continue - let parsed = designSystem.parseVariant(newVariant) - if (parsed === null) continue + // Add `not-` prefix + if (compoundNot) newVariant = `not-${newVariant}` - // Update original variant - changed = true - memcpy(variant, parsed) - } + let parsed = designSystem.parseVariant(newVariant) + if (parsed === null) continue - // Expecting an attribute selector - else if (target.type === 'attribute') { - // Attribute selectors - let attributeKey = target.attribute - let attributeValue = target.value - ? target.quoted - ? `${target.quoteMark}${target.value}${target.quoteMark}` - : target.value - : null - - // Insensitive attribute selectors. E.g.: `[data-foo="value" i]` - // ^ - if (target.insensitive && attributeValue) { - attributeValue += ' i' + // Update original variant + changed = true + memcpy(variant, parsed) } - let operator = target.operator ?? '=' + // Expecting an attribute selector + else if (target.type === 'attribute') { + // Attribute selectors + let attributeKey = target.attribute + let attributeValue = target.value + ? target.quoted + ? `${target.quoteMark}${target.value}${target.quoteMark}` + : target.value + : null + + // Insensitive attribute selectors. E.g.: `[data-foo="value" i]` + // ^ + if (target.insensitive && attributeValue) { + attributeValue += ' i' + } - // Migrate `data-*` - if (attributeKey.startsWith('data-')) { - changed = true - attributeKey = attributeKey.slice(5) // Remove `data-` - memcpy(variant, { - kind: 'functional', - root: 'data', - modifier: null, - value: - attributeValue === null - ? { kind: 'named', value: attributeKey } - : { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, - } satisfies Variant) - } + let operator = target.operator ?? '=' + + // Migrate `data-*` + if (attributeKey.startsWith('data-')) { + changed = true + attributeKey = attributeKey.slice(5) // Remove `data-` + memcpy(variant, { + kind: 'functional', + root: 'data', + modifier: null, + value: + attributeValue === null + ? { kind: 'named', value: attributeKey } + : { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, + } satisfies Variant) + } - // Migrate `aria-*` - else if (attributeKey.startsWith('aria-')) { - changed = true - attributeKey = attributeKey.slice(5) // Remove `aria-` - memcpy(variant, { - kind: 'functional', - root: 'aria', - modifier: null, - value: - attributeValue === null - ? { kind: 'arbitrary', value: attributeKey } // aria-[foo] - : operator === '=' && target.value === 'true' && !target.insensitive - ? { kind: 'named', value: attributeKey } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] - : { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, // aria-[foo~="true"], aria-[foo|="true"], … - } satisfies Variant) + // Migrate `aria-*` + else if (attributeKey.startsWith('aria-')) { + changed = true + attributeKey = attributeKey.slice(5) // Remove `aria-` + memcpy(variant, { + kind: 'functional', + root: 'aria', + modifier: null, + value: + attributeValue === null + ? { kind: 'arbitrary', value: attributeKey } // aria-[foo] + : operator === '=' && target.value === 'true' && !target.insensitive + ? { kind: 'named', value: attributeKey } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] + : { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, // aria-[foo~="true"], aria-[foo|="true"], … + } satisfies Variant) + } } - } - if (prefixedVariant) { - let idx = clone.variants.indexOf(variant) - if (idx === -1) continue + if (prefixedVariant) { + let idx = clone.variants.indexOf(variant) + if (idx === -1) continue - // Ensure we inject the prefixed variant - clone.variants.splice(idx, 1, variant, prefixedVariant) + // Ensure we inject the prefixed variant + clone.variants.splice(idx, 1, variant, prefixedVariant) + } } } From 34737d23c8cf2c1d23758f1bf306d4d6b4481095 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 14:51:21 +0100 Subject: [PATCH 07/11] bail early --- .../src/template/codemods/modernize-arbitrary-values.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index 5886413a8041..a06376f63b82 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -70,6 +70,7 @@ export function modernizeArbitraryValues( ), ) } + continue } // Expecting an arbitrary variant From 2cfee19e6b372b9d81c1c5edb456f5e128399bad Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 14:52:26 +0100 Subject: [PATCH 08/11] only migrate to `in-*` if we have 3 nodes in the selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If multiple nodes are valid, it could result in different specificity because the `in-*` variant wraps everything in a `:where(…)`. --- .../codemods/modernize-arbitrary-values.test.ts | 2 ++ .../template/codemods/modernize-arbitrary-values.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts index 6447723131ed..2776c79623e5 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts @@ -23,6 +23,8 @@ test.each([ ['[p_&]:flex', 'in-[p]:flex'], ['[.foo_&]:flex', 'in-[.foo]:flex'], ['[[data-visible]_&]:flex', 'in-data-visible:flex'], + // Multiple selectors, should stay as-is + ['[[data-foo][data-bar]_&]:flex', '[[data-foo][data-bar]_&]:flex'], // Using `>` instead of ` ` should not be transformed ['[figure>&]:my-0', '[figure>&]:my-0'], // Some extreme examples of what happens in the wild: diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index a06376f63b82..673f73481d68 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -130,11 +130,14 @@ export function modernizeArbitraryValues( parent === null && // [[data-visible]___&]:flex // ^^^^^^^^^^^^^^ ^ ^ - ast.nodes[0].nodes.at(-2)?.type === 'combinator' && - ast.nodes[0].nodes.at(-2)?.value === ' ' && - ast.nodes[0].nodes.at(-1)?.type === 'nesting' + ast.nodes[0].nodes.length === 3 && + ast.nodes[0].nodes[1].type === 'combinator' && + ast.nodes[0].nodes[1].value === ' ' && + ast.nodes[0].nodes[2].type === 'nesting' ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[0]] + ast.nodes[0].nodes.pop() // Remove the nesting node + ast.nodes[0].nodes.pop() // Remove the combinator + changed = true // When handling a compound like `in-[[data-visible]]`, we will first // handle `[[data-visible]]`, then the parent `in-*` part. This means From 52ff284c9fda65bd6c627d2ce0bd3d991e5321c6 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 14:53:37 +0100 Subject: [PATCH 09/11] move `prefixedVariant` down to where it's used --- .../src/template/codemods/modernize-arbitrary-values.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index 673f73481d68..215b4533f79b 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -83,8 +83,6 @@ export function modernizeArbitraryValues( // Expecting a single selector node if (ast.nodes.length !== 1) continue - let prefixedVariant: Variant | null = null - // `[&>*]` can be replaced with `*` if ( // Only top-level, so `has-[&>*]` is not supported @@ -148,6 +146,8 @@ export function modernizeArbitraryValues( continue } + let prefixedVariant: Variant | null = null + // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` if ( // Only top-level, so `has-[&>[data-visible]]` is not supported From a9295d9b3a201c323f5c5e52f34093c01e9647e9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 15:35:38 +0100 Subject: [PATCH 10/11] add test to ensure `[.group_&]` does not receive a prefix --- .../src/template/codemods/modernize-arbitrary-values.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts index 2776c79623e5..442971ed0d30 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts @@ -25,7 +25,7 @@ test.each([ ['[[data-visible]_&]:flex', 'in-data-visible:flex'], // Multiple selectors, should stay as-is ['[[data-foo][data-bar]_&]:flex', '[[data-foo][data-bar]_&]:flex'], - // Using `>` instead of ` ` should not be transformed + // Using `>` instead of ` ` should not be transformed: ['[figure>&]:my-0', '[figure>&]:my-0'], // Some extreme examples of what happens in the wild: ['group-[]:flex', 'in-[.group]:flex'], @@ -101,6 +101,9 @@ test.each([ ['group-[]:tw-flex', 'tw:in-[.tw\\:group]:flex'], ['group-[]/name:tw-flex', 'tw:in-[.tw\\:group\\/name]:flex'], + // However, `.group` inside of an arbitrary variant should not be prefixed: + ['[.group_&]:tw-flex', 'tw:in-[.group]:flex'], + // These shouldn't happen in the real world (because compound variants are // new). But this could happen once we allow codemods to run in v4+ projects. ['has-group-[]:tw-flex', 'tw:has-in-[.tw\\:group]:flex'], From 2c3293b366708b753de49c8127931c155ce3a269 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 20 Nov 2024 15:45:30 +0100 Subject: [PATCH 11/11] update changelog --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 037f948f476a..247fe492c465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- _Upgrade (experimental)_: Convert `group-[]:flex` to `in-[.group]:flex` ([#15054](https://github.com/tailwindlabs/tailwindcss/pull/15054)) + +### Fixed + +- _Upgrade (experimental)_: Ensure migrating to the `in-*` requires a descendant selector ([#15054](https://github.com/tailwindlabs/tailwindcss/pull/15054)) ## [4.0.0-alpha.35] - 2024-11-20