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:
parent
4f76980651
commit
0d3e13ec60
4 changed files with 361 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue