Migrate arbitrary values to built-in values (#14841)

This PR adds a migration where we detect arbitrary variants and try to
upgrade them to built-in variants.

For example, if you are using `[[data-visible]]:flex`, we can convert it
to `data-visible:flex`. We can also upgrade more advanced examples such
as `has-[[data-visible]]:flex` to a compound variant which becomes
`has-data-visible:flex`.

A table of example migrations:

| Before | After |
| ------------------------------------------ |
---------------------------------- |
| `[[data-visible]]:flex` | `data-visible:flex` |
| `[&[data-visible]]:flex` | `data-visible:flex` |
| `[[data-visible]&]:flex` | `data-visible:flex` |
| `[[data-url*="example"]]:flex` | `data-[url*="example"]:flex` |
| `[[data-url$=".com"_i]]:flex` | `data-[url$=".com"_i]:flex` |
| `[[data-url$=.com_i]]:flex` | `data-[url$=.com_i]:flex` |
| `[&:is([data-visible])]:flex` | `data-visible:flex` |
| `has-[[data-visible]]:flex` | `has-data-visible:flex` |
| `has-[&:is([data-visible])]:flex` | `has-data-visible:flex` |
| `has-[[data-slot=description]]:flex` |
`has-data-[slot=description]:flex` |
| `has-[&:is([data-slot=description])]:flex` |
`has-data-[slot=description]:flex` |
| `has-[[aria-visible="true"]]:flex` | `has-aria-visible:flex` |
| `has-[[aria-visible]]:flex` | `has-aria-[visible]:flex` |

We can also convert combinators from `[&>[data-visible]]:flex` to just
`*:data-visible:flex` and `[&_[data-visible]]:flex` to
`**:data-visible:flex`.

| Before | After |
| --- | --- |
| `[&>[data-visible]]:flex` | `*:data-visible:flex` |
| `[&_>_[data-visible]]:flex` | `*:data-visible:flex` |
| `[&_[data-visible]]:flex` | `**:data-visible:flex` |

Additionally, if you have complex selectors with `:not()`, we can
convert this to a compound `not-*` variant in some cases as well:

| Before | After |
| --- | --- |
| `[&:nth-child(2)]:flex` | `nth-2:flex` |
| `[&:not(:nth-child(2))]:flex` | `not-nth-2:flex` |

If some of the values in `nth-child(…)` are a bit too complex, then we
still try to convert them but to arbitrary values instead.

| Before | After |  
| --- | --- |
| `[&:nth-child(-n+3)]:flex` | `nth-[-n+3]:flex` |
| `[&:not(:nth-child(-n+3))]:flex` | `not-nth-[-n+3]:flex` |

This also implements some optimizations around `even` and `odd`:

| Before | After |
| --- | --- |
| `[&:nth-child(odd)]:flex` | `odd:flex` |
| `[&:not(:nth-child(odd))]:flex` | `even:flex` |
| `[&:nth-child(even)]:flex` | `even:flex` |
| `[&:not(:nth-child(even))]:flex` | `odd:flex` |


Some examples that stay as-is:

- `has-[&>[data-visible]]:flex` we can't upgrade this one because
`has-*` is not a valid variant.
- `has-[[data-visible][data-dark]]:flex` we can't upgrade this one
because `[data-visible][data-dark]` has to be on the same element. If we
convert this to `has-data-visible:has-data-dark:flex` then this
condition will be true if an element exists with `data-visible` and
another element exists with `data-dark` but we don't guarantee that they
are the same element.

---

Running this on the Catalyst codebase results in some updates that look
like this:

<img width="676" alt="image"
src="https://github.com/user-attachments/assets/6f0ff21d-5037-440b-9b80-0997ab0c11dd">
<img width="397" alt="image"
src="https://github.com/user-attachments/assets/8f0856fa-1709-404a-ac34-7d8c661fa799">

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
This commit is contained in:
Robin Malfait 2024-11-11 12:44:13 +01:00 • committed by GitHub
parent 4f76980651
commit 0d3e13ec60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 361 additions and 3 deletions

View file

@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Rename `rounded` to `rounded-sm` and `rounded-sm` to `rounded-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Rename `blur` to `blur-sm` and `blur-sm` to `blur-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Migrate `theme()` usage and JS config files to use the new `--spacing` multiplier where possible ([#14905](https://github.com/tailwindlabs/tailwindcss/pull/14905))
- _Upgrade (experimental)_: Migrate arbitrary values in variants to built-in values where possible ([#14841](https://github.com/tailwindlabs/tailwindcss/pull/14841))
### Fixed

View file

@ -0,0 +1,71 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test } from 'vitest'
import { modernizeArbitraryValues } from './modernize-arbitrary-values'
test.each([
// Arbitrary variants
['[[data-visible]]:flex', 'data-visible:flex'],
['[&[data-visible]]:flex', 'data-visible:flex'],
['[[data-visible]&]:flex', 'data-visible:flex'],
['[&>[data-visible]]:flex', '*:data-visible:flex'],
['[&_>_[data-visible]]:flex', '*:data-visible:flex'],
['[&_[data-visible]]:flex', '**:data-visible:flex'],
['[&:first-child]:flex', 'first:flex'],
['[&:not(:first-child)]:flex', 'not-first:flex'],
// nth-child
['[&:nth-child(2)]:flex', 'nth-2:flex'],
['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'],
['[&:nth-child(-n+3)]:flex', 'nth-[-n+3]:flex'],
['[&:not(:nth-child(-n+3))]:flex', 'not-nth-[-n+3]:flex'],
['[&:nth-child(-n_+_3)]:flex', 'nth-[-n+3]:flex'],
['[&:not(:nth-child(-n_+_3))]:flex', 'not-nth-[-n+3]:flex'],
// nth-last-child
['[&:nth-last-child(2)]:flex', 'nth-last-2:flex'],
['[&:not(:nth-last-child(2))]:flex', 'not-nth-last-2:flex'],
['[&:nth-last-child(-n+3)]:flex', 'nth-last-[-n+3]:flex'],
['[&:not(:nth-last-child(-n+3))]:flex', 'not-nth-last-[-n+3]:flex'],
['[&:nth-last-child(-n_+_3)]:flex', 'nth-last-[-n+3]:flex'],
['[&:not(:nth-last-child(-n_+_3))]:flex', 'not-nth-last-[-n+3]:flex'],
// nth-child odd/even
['[&:nth-child(odd)]:flex', 'odd:flex'],
['[&:not(:nth-child(odd))]:flex', 'even:flex'],
['[&:nth-child(even)]:flex', 'even:flex'],
['[&:not(:nth-child(even))]:flex', 'odd:flex'],
// Keep multiple attribute selectors as-is
['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'],
// Complex attribute selectors with operators, quotes and insensitivity flags
['[[data-url*="example"]]:flex', 'data-[url*="example"]:flex'],
['[[data-url$=".com"_i]]:flex', 'data-[url$=".com"_i]:flex'],
['[[data-url$=.com_i]]:flex', 'data-[url$=.com_i]:flex'],
// Attribute selector wrapped in `&:is(…)`
['[&:is([data-visible])]:flex', 'data-visible:flex'],
// Compound arbitrary variants
['has-[[data-visible]]:flex', 'has-data-visible:flex'],
['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'],
['has-[&>[data-visible]]:flex', 'has-[&>[data-visible]]:flex'],
['has-[[data-slot=description]]:flex', 'has-data-[slot=description]:flex'],
['has-[&:is([data-slot=description])]:flex', 'has-data-[slot=description]:flex'],
['has-[[aria-visible="true"]]:flex', 'has-aria-visible:flex'],
['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'],
['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(modernizeArbitraryValues(designSystem, {}, candidate)).toEqual(result)
})

View file

@ -0,0 +1,284 @@
import SelectorParser from 'postcss-selector-parser'
import type { Config } from 'tailwindcss'
import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type'
import { printCandidate } from '../candidates'
export function modernizeArbitraryValues(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
): string {
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
let changed = false
for (let [variant, parent] of variants(clone)) {
// Expecting an arbitrary variant
if (variant.kind !== 'arbitrary') continue
// Expecting a non-relative arbitrary variant
if (variant.relative) continue
let ast = SelectorParser().astSync(variant.selector)
// Expecting a single selector node
if (ast.nodes.length !== 1) continue
let prefixedVariant: Variant | null = null
// Track whether we need to add a `**:` variant
let addStarStarVariant = false
// 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')
// Expecting a single selector (normal selector or attribute selector)
if (selectorNodes.length !== 1) continue
let target = selectorNodes[0]
if (target.type === 'pseudo' && target.value === ':is') {
// Expecting a single selector node
if (target.nodes.length !== 1) continue
// Expecting a single attribute selector
if (target.nodes[0].nodes.length !== 1) continue
// Unwrap the selector from inside `&:is(…)`
target = target.nodes[0].nodes[0]
}
// 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]
}
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'
}
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 '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}`
}
return `${variantName}-[${targetNode.nodes[0].toString()}]`
}
}
return null
})(targetNode.value)
if (newVariant === null) continue
// Add `not-` prefix
if (compoundNot) newVariant = `not-${newVariant}`
let parsed = designSystem.parseVariant(newVariant)
if (parsed === null) continue
// Update original variant
changed = true
Object.assign(variant, parsed)
}
// 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'
}
let operator = target.operator ?? '='
// Migrate `data-*`
if (attributeKey.startsWith('data-')) {
changed = true
attributeKey = attributeKey.slice(5) // Remove `data-`
Object.assign(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-`
Object.assign(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
// Ensure we inject the prefixed variant
clone.variants.splice(idx, 1, variant, prefixedVariant)
}
}
return changed ? printCandidate(designSystem, clone) : rawCandidate
}
return rawCandidate
}
function* variants(candidate: Candidate) {
function* inner(
variant: Variant,
parent: Extract<Variant, { kind: 'compound' }> | null = null,
): Iterable<[Variant, Extract<Variant, { kind: 'compound' }> | null]> {
yield [variant, parent]
if (variant.kind === 'compound') {
yield* inner(variant.variant, variant)
}
}
for (let variant of candidate.variants) {
yield* inner(variant, null)
}
}

View file

@ -8,6 +8,7 @@ import { automaticVarInjection } from './codemods/automatic-var-injection'
import { bgGradient } from './codemods/bg-gradient'
import { important } from './codemods/important'
import { maxWidthScreen } from './codemods/max-width-screen'
import { modernizeArbitraryValues } from './codemods/modernize-arbitrary-values'
import { prefix } from './codemods/prefix'
import { simpleLegacyClasses } from './codemods/simple-legacy-classes'
import { themeToVar } from './codemods/theme-to-var'
@ -28,13 +29,14 @@ export type Migration = (
export const DEFAULT_MIGRATIONS: Migration[] = [
prefix,
important,
automaticVarInjection,
bgGradient,
simpleLegacyClasses,
arbitraryValueToBareValue,
maxWidthScreen,
themeToVar,
variantOrder,
variantOrder, // Has to happen before migrations that modify variants
automaticVarInjection,
arbitraryValueToBareValue,
modernizeArbitraryValues,
]
export function migrateCandidate(