tailwindcss/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts
Robin Malfait 4e4275638f
Design system driven upgrade migrations (#17831)
This PR introduces a vastly improved upgrade migrations system, to
migrate your codebase and modernize your utilities to make use of the
latest variants and utilities.

It all started when I saw this PR the other day:
https://github.com/tailwindlabs/tailwindcss/pull/17790

I was about to comment "Don't forget to add a migration". But I've been
thinking about a system where we can automate this process away. This PR
introduces this system.

This PR introduces upgrade migrations based on the internal Design
System, and it mainly updates arbitrary variants, arbitrary properties
and arbitrary values.

## The problem

Whenever we ship new utilities, or you make changes to your CSS file by
introducing new `@theme` values, or adding new `@utility` rules. It
could be that the rest of your codebase isn't aware of that, but you
could be using these values.

For example, it could be that you have a lot of arbitrary properties in
your codebase, they look something like this:

```html
<div class="[color-scheme:dark] [text-wrap:balance]"></div>
```

Whenever we introduce new features in Tailwind CSS, you probably don't
keep an eye on the release notes and update all of these arbitrary
properties to the newly introduced utilities.

But with this PR, we can run the upgrade tool:

```console
npx -y @tailwindcss/upgrade@latest
```

...and it will upgrade your project to use the new utilities:

```html
<div class="scheme-dark text-balance"></div>
```

It also works for arbitrary values, for example imagine you have classes
like this:

```html
<!-- Arbitrary property -->
<div class="[max-height:1lh]"></div>

<!-- Arbitrary value -->
<div class="max-h-[1lh]"></div>
```

Running the upgrade tool again:

```console
npx -y @tailwindcss/upgrade@latest
```

... gives you the following output:

```html
<!-- Arbitrary property -->
<div class="max-h-lh"></div>

<!-- Arbitrary value -->
<div class="max-h-lh"></div>
```

This is because of the original PR I mentioned, which introduced the
`max-h-lh` utilities.

A nice benefit is that this output only has 1 unique class instead of 2,
which also potentially reduces the size of your CSS file.

It could also be that you are using arbitrary values where you (or a
team member) didn't even know an alternative solution existed.

E.g.:

```html
<div class="w-[48rem]"></div>
```

After running the upgrade tool you will get this:

```html
<div class="w-3xl"></div>
```

We can go further though. Since the release of Tailwind CSS v4, we
introduced the concept of "bare values". Essentially allowing you to
type a number on utilities where it makes sense, and we produce a value
based on that number.

So an input like this:

```html
<div class="border-[123px]"></div>
```

Will be optimized to just:

```html
<div class="border-123"></div>
```

This can be very useful for complex utilities, for example, how many
times have you written something like this:

```html
<div class="grid-cols-[repeat(16,minmax(0,1fr))]"></div>
```

Because up until Tailwind CSS v4, we only generated 12 columns by
default. But since v4, we can generate any number of columns
automatically.

Running the migration tool will give you this:

```html
<div class="grid-cols-16"></div>
```

### User CSS

But, what if I told you that we can keep going...

In [Catalyst](https://tailwindcss.com/plus/ui-kit) we often use classes
that look like this for accessibility reasons:

```html
<div class="text-[CanvasText] bg-[Highlight]"></div>
```

What if you want to move the `CanvasText` and `Highlight` colors to your
CSS:

```css
@import "tailwincdss";

@theme {
  --color-canvas: CanvasText;
  --color-highlight: Highlight;
}
```

If you now run the upgrade tool again, this will be the result:

```html
<div class="text-canvas bg-highlight"></div>
```

We never shipped a `text-canvas` or `bg-highlight` utility, but the
upgrade tool uses your own CSS configuration to migrate your codebase.

This will keep your codebase clean, consistent and modern and you are in
control.

Let's look at one more example, what if you have this in a lot of
places:

```html
<div class="[scrollbar-gutter:stable]"></div>
```

And you don't want to wait for the Tailwind CSS team to ship a
`scrollbar-stable` (or similar) feature. You can add your own utility:

```css
@import "tailwincdss";

@utility scrollbar-stable {
  scrollbar-gutter: stable;
}
```

```html
<div class="scrollbar-stable"></div>
```

## The solution — how it works

There are 2 big things happening here:

1. Instead of us (the Tailwind CSS team) hardcoding certain migrations,
we will make use of the internal `DesignSystem` which is the source of
truth for all this information. This is also what Tailwind CSS itself
uses to generate the CSS file.

   The internal `DesignSystem` is essentially a list of all:

   1. The internal utilities
   2. The internal variants
   3. The default theme we ship
   4. The user CSS
      1. With custom `@theme` values
      2. With custom `@custom-variant` implementations
      3. With custom `@utility` implementations
2. The upgrade tool now has a concept of `signatures`

The signatures part is the most interesting one, and it allows us to be
100% sure that we can migrate your codebase without breaking anything.

A signature is some unique identifier that represents a utility. But 2
utilities that do the exact same thing will have the same signature.

To make this work, we have to make sure that we normalize values. One
such value is the selector. I think a little visualization will help
here:

| UTILITY          | GENERATED SIGNATURE     |
| ---------------- | ----------------------- |
| `[display:flex]` | `.x { display: flex; }` |
| `flex`           | `.x { display: flex; }` |

They have the exact same signature and therefore the upgrade tool can
safely migrate them to the same utility.

For this we will prefer the following order:

1. Static utilities — essentially no brackets. E.g.: `flex`,
`grid-cols-2`
2. Arbitrary values — e.g.: `max-h-[1lh]`, `border-[2px]`
3. Arbitrary properties — e.g.: `[color-scheme:dark]`, `[display:flex]`

We also have to canonicalize utilities to there minimal form.
Essentially making sure we increase the chance of finding a match.

```
[display:_flex_] → [display:flex] → flex
[display:_flex]  → [display:flex] → flex
[display:flex_]  → [display:flex] → flex
[display:flex]   → [display:flex] → flex
```

If we don't do this, then the signatures will be slightly different, due
to the whitespace:

| UTILITY            | GENERATED SIGNATURE       |
| ------------------ | ------------------------- |
| `[display:_flex_]` | `.x { display:  flex ; }` |
| `[display:_flex]`  | `.x { display:  flex; }`  |
| `[display:flex_]`  | `.x { display: flex ; }`  |
| `[display:flex]`   | `.x { display: flex; }`   |

### Other small improvements

A few other improvements are for optimizing existing utilities:

1. Remove unnecessary data types. E.g.:

   - `bg-[color:red]` -> `bg-[red]`
- `shadow-[shadow:inset_0_1px_--theme(--color-white/15%)]` ->
`shadow-[inset_0_1px_--theme(--color-white/15%)]`

This also makes use of these signatures and if dropping the data type
results in the same signature then we can safely drop it.

Additionally, if a more specific utility exists, we will prefer that
one. This reduced ambiguity and the need for data types.

   - `bg-[position:123px]` → `bg-position-[123px]`
   - `bg-[123px]` → `bg-position-[123px]`
   - `bg-[size:123px]` → `bg-size-[123px]`


2. Optimizing modifiers. E.g.:
   - `bg-red-500/[25%]` → `bg-red-500/25`
   - `bg-red-500/[100%]` → `bg-red-500`
   - `bg-red-500/100` → `bg-red-500`

3. Hoist `not` in arbitrary variants

- `[@media_not_(prefers-color-scheme:dark)]:flex` →
`not-[@media_(prefers-color-scheme:dark)]:flex` → `not-dark:flex` (in
case you are using the default `dark` mode implementation

4. Optimize raw values that could be converted to bare values. This uses
the `--spacing` variable to ensure it is safe.

   - `w-[64rem]` → `w-256`

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Philipp Spiess <hello@philippspiess.com>
2025-05-02 23:18:06 +02:00

396 lines
14 KiB
TypeScript

import SelectorParser from 'postcss-selector-parser'
import { parseCandidate, type Variant } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { replaceObject } from '../../utils/replace-object'
import { walkVariants } from '../../utils/walk-variants'
import { computeVariantSignature } from './signatures'
export function migrateModernizeArbitraryValues(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let signatures = computeVariantSignature.get(designSystem)
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
let changed = false
for (let [variant, parent] of walkVariants(clone)) {
// Forward modifier from the root to the compound variant
if (
variant.kind === 'compound' &&
(variant.root === 'has' || variant.root === 'not' || variant.root === 'in')
) {
if (variant.modifier !== null) {
if ('modifier' in variant.variant) {
variant.variant.modifier = variant.modifier
variant.modifier = null
}
}
}
// 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 === '&'
) {
// `group-[]`
if (variant.modifier === null) {
changed = true
replaceObject(
variant,
designSystem.parseVariant(
designSystem.theme.prefix
? `in-[.${designSystem.theme.prefix}\\:group]`
: 'in-[.group]',
),
)
}
// `group-[]/name`
else if (variant.modifier.kind === 'named') {
changed = true
replaceObject(
variant,
designSystem.parseVariant(
designSystem.theme.prefix
? `in-[.${designSystem.theme.prefix}\\:group\\/${variant.modifier.value}]`
: `in-[.group\\/${variant.modifier.value}]`,
),
)
}
continue
}
// Expecting an arbitrary variant
if (variant.kind === 'arbitrary') {
// 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
// `[&>*]` 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
replaceObject(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
replaceObject(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.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.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
// that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`.
//
// Later this gets converted to `in-data-visible`.
replaceObject(variant, designSystem.parseVariant(`in-[${ast.toString()}]`))
continue
}
// Hoist `not` modifier for `@media` or `@supports` variants
//
// E.g.: `[@media_not_(scripting:none)]:` -> `not-[@media_(scripting:none)]:`
if (
// Only top-level, so something like `in-[@media(scripting:none)]`
// (which is not valid anyway) is not supported
parent === null &&
// [@media_not(scripting:none)]:flex
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
ast.nodes[0].nodes[0].type === 'tag' &&
(ast.nodes[0].nodes[0].value.startsWith('@media') ||
ast.nodes[0].nodes[0].value.startsWith('@supports'))
) {
let targetSignature = signatures.get(designSystem.printVariant(variant))
let parsed = ValueParser.parse(ast.nodes[0].toString().trim())
let containsNot = false
ValueParser.walk(parsed, (node, { replaceWith }) => {
if (node.kind === 'word' && node.value === 'not') {
containsNot = true
replaceWith([])
}
})
// Remove unnecessary whitespace
parsed = ValueParser.parse(ValueParser.toCss(parsed))
ValueParser.walk(parsed, (node) => {
if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') {
// node.value contains at least 2 spaces. Normalize it to a single
// space.
node.value = ' '
}
})
if (containsNot) {
let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`)
if (hoistedNot === null) continue
let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot))
if (targetSignature === hoistedNotSignature) {
changed = true
replaceObject(variant, hoistedNot)
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
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 === ':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()}]`
}
}
// Hoist `not` modifier
if (compoundNot) {
let targetSignature = signatures.get(designSystem.printVariant(variant))
let replacementSignature = signatures.get(`not-[${value}]`)
if (targetSignature === replacementSignature) {
return `[&${value}]`
}
}
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
replaceObject(variant, structuredClone(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-`
replaceObject(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-`
replaceObject(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 ? designSystem.printCandidate(clone) : rawCandidate
}
return rawCandidate
}