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>
This commit is contained in:
Robin Malfait 2025-05-02 23:18:06 +02:00 committed by GitHub
parent 45cd32eed7
commit 4e4275638f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2170 additions and 629 deletions

View File

@ -7,13 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Nothing yet!
### Added
- Upgrade: Automatically convert candidates with arbitrary values to their utilities ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
### Fixed
- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
## [4.1.5] - 2025-04-30
### Added
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.\* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
- Add `h-lh` / `min-h-lh` / `max-h-lh` utilities ([#17790](https://github.com/tailwindlabs/tailwindcss/pull/17790))
- Transition `display`, `visibility`, `content-visibility`, `overlay`, and `pointer-events` when using `transition` to simplify `@starting-style` usage ([#17812](https://github.com/tailwindlabs/tailwindcss/pull/17812))

View File

@ -209,7 +209,7 @@ test(
"
--- ./src/index.html ---
<div
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:[color:red] tw:in-[.tw\\:group]:flex"
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:text-[red] tw:in-[.tw\\:group]:flex"
></div>
<div
class="tw:group tw:group/foo tw:peer tw:peer/foo tw:group-hover:flex tw:group-hover/foo:flex tw:peer-hover:flex tw:peer-hover/foo:flex"

View File

@ -1329,12 +1329,12 @@ describe('border compatibility', () => {
"
--- src/index.html ---
<div
class="[width:--spacing(2)]
[width:--spacing(4.5)]
[width:var(--spacing-5_5)]
[width:--spacing(13)]
[width:var(--spacing-100)]
[width:var(--spacing-miami)]"
class="w-2
w-4.5
w-5.5
w-13
w-100
w-miami"
></div>
--- src/input.css ---
@ -1439,12 +1439,12 @@ describe('border compatibility', () => {
"
--- src/index.html ---
<div
class="[width:var(--spacing-2)]
[width:var(--spacing-4_5)]
[width:var(--spacing-5_5)]
[width:var(--spacing-13)]
[width:var(--spacing-100)]
[width:var(--spacing-miami)]"
class="w-2
w-4.5
w-5.5
w-13
w-100
w-miami"
></div>
--- src/input.css ---

View File

@ -113,7 +113,7 @@ it('should apply all candidate migration when migrating with a config', async ()
`),
).toMatchInlineSnapshot(`
".foo {
@apply tw:flex! tw:[color:var(--my-color)] tw:bg-linear-to-t;
@apply tw:flex! tw:text-(--my-color) tw:bg-linear-to-t;
}"
`)
})

View File

@ -1,7 +1,7 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { spliceChangesIntoString } from '../../utils/splice-changes-into-string'
import { extractRawCandidates, printCandidate } from './candidates'
import { extractRawCandidates } from './candidates'
let html = String.raw
@ -190,7 +190,7 @@ describe('printCandidate()', () => {
// Sometimes we will have a functional and a static candidate for the same
// raw input string (e.g. `-inset-full`). Dedupe in this case.
let cleaned = new Set([...candidates].map((c) => printCandidate(designSystem, c)))
let cleaned = new Set([...candidates].map((c) => designSystem.printCandidate(c)))
expect([...cleaned]).toEqual([result])
})

View File

@ -1,7 +1,4 @@
import { Scanner } from '@tailwindcss/oxide'
import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
export async function extractRawCandidates(
content: string,
@ -16,273 +13,3 @@ export async function extractRawCandidates(
}
return candidates
}
export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
let parts: string[] = []
for (let variant of candidate.variants) {
parts.unshift(printVariant(variant))
}
// Handle prefix
if (designSystem.theme.prefix) {
parts.unshift(designSystem.theme.prefix)
}
let base: string = ''
// Handle static
if (candidate.kind === 'static') {
base += candidate.root
}
// Handle functional
if (candidate.kind === 'functional') {
base += candidate.root
if (candidate.value) {
if (candidate.value.kind === 'arbitrary') {
if (candidate.value !== null) {
let isVarValue = isVar(candidate.value.value)
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
if (candidate.value.dataType) {
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
} else {
base += `-${open}${printArbitraryValue(value)}${close}`
}
}
} else if (candidate.value.kind === 'named') {
base += `-${candidate.value.value}`
}
}
}
// Handle arbitrary
if (candidate.kind === 'arbitrary') {
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
}
// Handle modifier
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
if (candidate.modifier) {
let isVarValue = isVar(candidate.modifier.value)
let value = isVarValue ? candidate.modifier.value.slice(4, -1) : candidate.modifier.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
if (candidate.modifier.kind === 'arbitrary') {
base += `/${open}${printArbitraryValue(value)}${close}`
} else if (candidate.modifier.kind === 'named') {
base += `/${candidate.modifier.value}`
}
}
}
// Handle important
if (candidate.important) {
base += '!'
}
parts.push(base)
return parts.join(':')
}
function printVariant(variant: Variant) {
// Handle static variants
if (variant.kind === 'static') {
return variant.root
}
// Handle arbitrary variants
if (variant.kind === 'arbitrary') {
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
}
let base: string = ''
// Handle functional variants
if (variant.kind === 'functional') {
base += variant.root
// `@` is a special case for functional variants. We want to print: `@lg`
// instead of `@-lg`
let hasDash = variant.root !== '@'
if (variant.value) {
if (variant.value.kind === 'arbitrary') {
let isVarValue = isVar(variant.value.value)
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}`
} else if (variant.value.kind === 'named') {
base += `${hasDash ? '-' : ''}${variant.value.value}`
}
}
}
// Handle compound variants
if (variant.kind === 'compound') {
base += variant.root
base += '-'
base += printVariant(variant.variant)
}
// Handle modifiers
if (variant.kind === 'functional' || variant.kind === 'compound') {
if (variant.modifier) {
if (variant.modifier.kind === 'arbitrary') {
base += `/[${printArbitraryValue(variant.modifier.value)}]`
} else if (variant.modifier.kind === 'named') {
base += `/${variant.modifier.value}`
}
}
}
return base
}
function printArbitraryValue(input: string) {
let ast = ValueParser.parse(input)
let drop = new Set<ValueParser.ValueAstNode>()
ValueParser.walk(ast, (node, { parent }) => {
let parentArray = parent === null ? ast : (parent.nodes ?? [])
// Handle operators (e.g.: inside of `calc(…)`)
if (
node.kind === 'word' &&
// Operators
(node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/')
) {
let idx = parentArray.indexOf(node) ?? -1
// This should not be possible
if (idx === -1) return
let previous = parentArray[idx - 1]
if (previous?.kind !== 'separator' || previous.value !== ' ') return
let next = parentArray[idx + 1]
if (next?.kind !== 'separator' || next.value !== ' ') return
drop.add(previous)
drop.add(next)
}
// The value parser handles `/` as a separator in some scenarios. E.g.:
// `theme(colors.red/50%)`. Because of this, we have to handle this case
// separately.
else if (node.kind === 'separator' && node.value.trim() === '/') {
node.value = '/'
}
// Leading and trailing whitespace
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {
drop.add(node)
}
}
// Whitespace around `,` separators can be removed.
// E.g.: `min(1px , 2px)` -> `min(1px,2px)`
else if (node.kind === 'separator' && node.value.trim() === ',') {
node.value = ','
}
})
if (drop.size > 0) {
ValueParser.walk(ast, (node, { replaceWith }) => {
if (drop.has(node)) {
drop.delete(node)
replaceWith([])
}
})
}
recursivelyEscapeUnderscores(ast)
return ValueParser.toCss(ast)
}
function simplifyArbitraryVariant(input: string) {
let ast = ValueParser.parse(input)
// &:is(…)
if (
ast.length === 3 &&
// &
ast[0].kind === 'word' &&
ast[0].value === '&' &&
// :
ast[1].kind === 'separator' &&
ast[1].value === ':' &&
// is(…)
ast[2].kind === 'function' &&
ast[2].value === 'is'
) {
return ValueParser.toCss(ast[2].nodes)
}
return input
}
function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
for (let node of ast) {
switch (node.kind) {
case 'function': {
if (node.value === 'url' || node.value.endsWith('_url')) {
// Don't decode underscores in url() but do decode the function name
node.value = escapeUnderscore(node.value)
break
}
if (
node.value === 'var' ||
node.value.endsWith('_var') ||
node.value === 'theme' ||
node.value.endsWith('_theme')
) {
node.value = escapeUnderscore(node.value)
for (let i = 0; i < node.nodes.length; i++) {
recursivelyEscapeUnderscores([node.nodes[i]])
}
break
}
node.value = escapeUnderscore(node.value)
recursivelyEscapeUnderscores(node.nodes)
break
}
case 'separator':
node.value = escapeUnderscore(node.value)
break
case 'word': {
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
// have underscores escaped
if (node.value[0] !== '-' && node.value[1] !== '-') {
node.value = escapeUnderscore(node.value)
}
break
}
default:
never(node)
}
}
}
function isVar(value: string) {
let ast = ValueParser.parse(value)
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
}
function never(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}
function escapeUnderscore(value: string): string {
return value
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
.replaceAll(' ', '_') // Replace spaces with underscores
}

View File

@ -0,0 +1,349 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
import { migrateOptimizeModifier } from './migrate-optimize-modifier'
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, { base })
})
})
function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) {
for (let migration of [
migrateArbitraryUtilities,
migrateDropUnnecessaryDataTypes,
migrateArbitraryValueToBareValue,
migrateOptimizeModifier,
]) {
rawCandidate = migration(designSystem, userConfig, rawCandidate)
}
return rawCandidate
}
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
// Basic input with minimal design system to keep the tests fast
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
--spacing: 0.25rem;
--color-red-500: red;
/* Equivalent of blue-500/50 */
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
}
`
test.each([
// Arbitrary property to static utility
['[text-wrap:balance]', 'text-balance'],
// Arbitrary property to static utility with slight differences in
// whitespace. This will require some canonicalization.
['[display:_flex_]', 'flex'],
['[display:_flex]', 'flex'],
['[display:flex_]', 'flex'],
// Arbitrary property to static utility
// Map number to keyword-like value
['leading-[1]', 'leading-none'],
// Arbitrary property to named functional utility
['[color:var(--color-red-500)]', 'text-red-500'],
['[background-color:var(--color-red-500)]', 'bg-red-500'],
// Arbitrary property with modifier to named functional utility with modifier
['[color:var(--color-red-500)]/25', 'text-red-500/25'],
// Arbitrary property with arbitrary modifier to named functional utility with
// arbitrary modifier
['[color:var(--color-red-500)]/[25%]', 'text-red-500/25'],
['[color:var(--color-red-500)]/[100%]', 'text-red-500'],
['[color:var(--color-red-500)]/100', 'text-red-500'],
// No need for `/50` because that's already encoded in the `--color-primary`
// value
['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'],
// Arbitrary property to arbitrary value
['[max-height:20px]', 'max-h-[20px]'],
// Arbitrary property to bare value
['[grid-column:2]', 'col-2'],
['[grid-column:1234]', 'col-1234'],
// Arbitrary value to bare value
['border-[2px]', 'border-2'],
['border-[1234px]', 'border-1234'],
// Arbitrary value with data type, to more specific arbitrary value
['bg-[position:123px]', 'bg-position-[123px]'],
['bg-[size:123px]', 'bg-size-[123px]'],
// Arbitrary value with inferred data type, to more specific arbitrary value
['bg-[123px]', 'bg-position-[123px]'],
// Arbitrary value with spacing mul
['w-[64rem]', 'w-256'],
// Complex arbitrary property to arbitrary value
[
'[grid-template-columns:repeat(2,minmax(100px,1fr))]',
'grid-cols-[repeat(2,minmax(100px,1fr))]',
],
// Complex arbitrary property to bare value
['[grid-template-columns:repeat(2,minmax(0,1fr))]', 'grid-cols-2'],
// Arbitrary value to bare value with percentage
['from-[25%]', 'from-25%'],
// Arbitrary percentage value must be a whole number. Should not migrate to
// a bare value.
['from-[2.5%]', 'from-[2.5%]'],
])(testName, async (candidate, result) => {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
result = `focus:${result}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
result = `${result}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
}
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
})
const css = String.raw
test('migrate with custom static utility `@utility custom {…}`', async () => {
let candidate = '[--key:value]'
let result = 'custom'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@utility custom {
--key: value;
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test('migrate with custom functional utility `@utility custom-* {…}`', async () => {
let candidate = '[--key:value]'
let result = 'custom-value'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@utility custom-* {
--key: --value('value');
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test('migrate with custom functional utility `@utility custom-* {…}` that supports bare values', async () => {
let candidate = '[tab-size:4]'
let result = 'tab-4'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@utility tab-* {
tab-size: --value(integer);
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test.each([
['[tab-size:0]', 'tab-0'],
['[tab-size:4]', 'tab-4'],
['[tab-size:8]', 'tab-github'],
['tab-[0]', 'tab-0'],
['tab-[4]', 'tab-4'],
['tab-[8]', 'tab-github'],
])(
'migrate custom @utility from arbitrary values to bare values and named values (based on theme)',
async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size, integer, [integer]);
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(expected)
},
)
describe.each([['@theme'], ['@theme inline']])('%s', (theme) => {
test.each([
['[color:CanvasText]', 'text-canvas'],
['text-[CanvasText]', 'text-canvas'],
])('migrate arbitrary value to theme value %s => %s', async (candidate, result) => {
let input = css`
@import 'tailwindcss';
${theme} {
--*: initial;
--color-canvas: CanvasText;
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
// Some utilities read from specific namespaces, in this case we do not want
// to migrate to a value in that namespace if we reference a variable that
// results in the same value, but comes from a different namespace.
//
// E.g.: `max-w` reads from: ['--max-width', '--spacing', '--container']
test.each([
// `max-w` does not read from `--breakpoint-md`, but `--breakpoint-md` and
// `--container-3xl` happen to result in the same value. The difference is
// the semantics of the value.
['max-w-(--breakpoint-md)', 'max-w-(--breakpoint-md)'],
['max-w-(--container-3xl)', 'max-w-3xl'],
])('migrate arbitrary value to theme value %s => %s', async (candidate, result) => {
let input = css`
@import 'tailwindcss';
${theme} {
--*: initial;
--breakpoint-md: 48rem;
--container-3xl: 48rem;
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
})
test('migrate a arbitrary property without spaces, to a theme value with spaces (canonicalization)', async () => {
let candidate = 'font-[foo,bar,baz]'
let expected = 'font-example'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--font-example: foo, bar, baz;
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(expected)
})
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
test.each([
// Default spacing scale
['w-[64rem]', 'w-256', '0.25rem'],
// Keep arbitrary value if units are different
['w-[124px]', 'w-[124px]', '0.25rem'],
// Keep arbitrary value if bare value doesn't fit in steps of .25
['w-[0.123rem]', 'w-[0.123rem]', '0.25rem'],
// Custom pixel based spacing scale
['w-[123px]', 'w-123', '1px'],
['w-[256px]', 'w-128', '2px'],
])(testName, async (candidate, expected, spacing) => {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
expected = `focus:${expected}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
expected = `${expected}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}`
}
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
--spacing: ${spacing};
}
`
let designSystem = await __unstable__loadDesignSystem(input, {
base: __dirname,
})
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(expected)
})
})

View File

@ -0,0 +1,339 @@
import { printModifier, type Candidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { dimensions } from '../../utils/dimension'
import type { Writable } from '../../utils/types'
import { computeUtilitySignature } from './signatures'
// For all static utilities in the system, compute a lookup table that maps the
// utility signature to the utility name. This is used to find the utility name
// for a given utility signature.
//
// For all functional utilities, we can compute static-like utilities by
// essentially pre-computing the values and modifiers. This is a bit slow, but
// also only has to happen once per design system.
const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>((ds) => {
let signatures = computeUtilitySignature.get(ds)
let lookup = new DefaultMap<string, string[]>(() => [])
for (let [className, meta] of ds.getClassList()) {
let signature = signatures.get(className)
if (typeof signature !== 'string') continue
lookup.get(signature).push(className)
for (let modifier of meta.modifiers) {
// Modifiers representing numbers can be computed and don't need to be
// pre-computed. Doing the math and at the time of writing this, this
// would save you 250k additionally pre-computed utilities...
if (isValidSpacingMultiplier(modifier)) {
continue
}
let classNameWithModifier = `${className}/${modifier}`
let signature = signatures.get(classNameWithModifier)
if (typeof signature !== 'string') continue
lookup.get(signature).push(classNameWithModifier)
}
}
return lookup
})
const baseReplacementsCache = new DefaultMap<DesignSystem, Map<string, Candidate>>(
() => new Map<string, Candidate>(),
)
const spacing = new DefaultMap<DesignSystem, DefaultMap<string, number | null> | null>((ds) => {
let spacingMultiplier = ds.resolveThemeValue('--spacing')
if (spacingMultiplier === undefined) return null
let parsed = dimensions.get(spacingMultiplier)
if (!parsed) return null
let [value, unit] = parsed
return new DefaultMap<string, number | null>((input) => {
let parsed = dimensions.get(input)
if (!parsed) return null
let [myValue, myUnit] = parsed
if (myUnit !== unit) return null
return myValue / value
})
})
export function migrateArbitraryUtilities(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let utilities = preComputedUtilities.get(designSystem)
let signatures = computeUtilitySignature.get(designSystem)
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
// We are only interested in arbitrary properties and arbitrary values
if (
// Arbitrary property
readonlyCandidate.kind !== 'arbitrary' &&
// Arbitrary value
!(readonlyCandidate.kind === 'functional' && readonlyCandidate.value?.kind === 'arbitrary')
) {
continue
}
// 1. Canonicalize the value. This might be a bit wasteful because it might
// have been done by other migrations before, but essentially we want to
// canonicalize the arbitrary value to its simplest canonical form. We
// won't be constant folding `calc(…)` expressions (yet?), but we can
// remove unnecessary whitespace (which the `printCandidate` already
// handles for us).
//
// E.g.:
//
// ```
// [display:_flex_] => [display:flex]
// [display:_flex] => [display:flex]
// [display:flex_] => [display:flex]
// [display:flex] => [display:flex]
// ```
//
let canonicalizedCandidate = designSystem.printCandidate(readonlyCandidate)
if (canonicalizedCandidate !== rawCandidate) {
return migrateArbitraryUtilities(designSystem, _userConfig, canonicalizedCandidate)
}
// The below logic makes use of mutation. Since candidates in the
// DesignSystem are cached, we can't mutate them directly.
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
// Create a basic stripped candidate without variants or important flag. We
// will re-add those later but they are irrelevant for what we are trying to
// do here (and will increase cache hits because we only have to deal with
// the base utility, nothing more).
let targetCandidate = structuredClone(candidate)
targetCandidate.important = false
targetCandidate.variants = []
let targetCandidateString = designSystem.printCandidate(targetCandidate)
if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) {
let target = structuredClone(
baseReplacementsCache.get(designSystem).get(targetCandidateString)!,
)
// Re-add the variants and important flag from the original candidate
target.variants = candidate.variants
target.important = candidate.important
return designSystem.printCandidate(target)
}
// Compute the signature for the target candidate
let targetSignature = signatures.get(targetCandidateString)
if (typeof targetSignature !== 'string') continue
// Try a few options to find a suitable replacement utility
for (let replacementCandidate of tryReplacements(targetSignature, targetCandidate)) {
let replacementString = designSystem.printCandidate(replacementCandidate)
let replacementSignature = signatures.get(replacementString)
if (replacementSignature !== targetSignature) {
continue
}
// Ensure that if CSS variables were used, that they are still used
if (!allVariablesAreUsed(designSystem, candidate, replacementCandidate)) {
continue
}
replacementCandidate = structuredClone(replacementCandidate)
// Cache the result so we can re-use this work later
baseReplacementsCache.get(designSystem).set(targetCandidateString, replacementCandidate)
// Re-add the variants and important flag from the original candidate
replacementCandidate.variants = candidate.variants
replacementCandidate.important = candidate.important
// Update the candidate with the new value
Object.assign(candidate, replacementCandidate)
// We will re-print the candidate to get the migrated candidate out
return designSystem.printCandidate(candidate)
}
}
return rawCandidate
function* tryReplacements(
targetSignature: string,
candidate: Extract<Candidate, { kind: 'functional' | 'arbitrary' }>,
): Generator<Candidate> {
// Find a corresponding utility for the same signature
let replacements = utilities.get(targetSignature)
// Multiple utilities can map to the same signature. Not sure how to migrate
// this one so let's just skip it for now.
//
// TODO: Do we just migrate to the first one?
if (replacements.length > 1) return
// If we didn't find any replacement utilities, let's try to strip the
// modifier and find a replacement then. If we do, we can try to re-add the
// modifier later and verify if we have a valid migration.
//
// This is necessary because `text-red-500/50` will not be pre-computed,
// only `text-red-500` will.
if (replacements.length === 0 && candidate.modifier) {
let candidateWithoutModifier = { ...candidate, modifier: null }
let targetSignatureWithoutModifier = signatures.get(
designSystem.printCandidate(candidateWithoutModifier),
)
if (typeof targetSignatureWithoutModifier === 'string') {
for (let replacementCandidate of tryReplacements(
targetSignatureWithoutModifier,
candidateWithoutModifier,
)) {
yield Object.assign({}, replacementCandidate, { modifier: candidate.modifier })
}
}
}
// If only a single utility maps to the signature, we can use that as the
// replacement.
if (replacements.length === 1) {
for (let replacementCandidate of parseCandidate(designSystem, replacements[0])) {
yield replacementCandidate
}
}
// Find a corresponding functional utility for the same signature
else if (replacements.length === 0) {
// An arbitrary property will only set a single property, we can use that
// to find functional utilities that also set this property.
let value =
candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null)
if (value === null) return
let spacingMultiplier = spacing.get(designSystem)?.get(value)
for (let root of designSystem.utilities.keys('functional')) {
// Try as bare value
for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) {
yield replacementCandidate
}
// Try as bare value with modifier
if (candidate.modifier) {
for (let replacementCandidate of parseCandidate(
designSystem,
`${root}-${value}${candidate.modifier}`,
)) {
yield replacementCandidate
}
}
// Try bare value based on the `--spacing` value. E.g.:
//
// - `w-[64rem]` → `w-256`
if (spacingMultiplier !== null) {
for (let replacementCandidate of parseCandidate(
designSystem,
`${root}-${spacingMultiplier}`,
)) {
yield replacementCandidate
}
// Try bare value based on the `--spacing` value, but with a modifier
if (candidate.modifier) {
for (let replacementCandidate of parseCandidate(
designSystem,
`${root}-${spacingMultiplier}${printModifier(candidate.modifier)}`,
)) {
yield replacementCandidate
}
}
}
// Try as arbitrary value
for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) {
yield replacementCandidate
}
// Try as arbitrary value with modifier
if (candidate.modifier) {
for (let replacementCandidate of parseCandidate(
designSystem,
`${root}-[${value}]${printModifier(candidate.modifier)}`,
)) {
yield replacementCandidate
}
}
}
}
}
}
function parseCandidate(designSystem: DesignSystem, input: string) {
return designSystem.parseCandidate(
designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`)
? `${designSystem.theme.prefix}:${input}`
: input,
)
}
// Let's make sure that all variables used in the value are also all used in the
// found replacement. If not, then we are dealing with a different namespace or
// we could lose functionality in case the variable was changed higher up in the
// DOM tree.
function allVariablesAreUsed(
designSystem: DesignSystem,
candidate: Candidate,
replacement: Candidate,
) {
let value: string | null = null
// Functional utility with arbitrary value and variables
if (
candidate.kind === 'functional' &&
candidate.value?.kind === 'arbitrary' &&
candidate.value.value.includes('var(--')
) {
value = candidate.value.value
}
// Arbitrary property with variables
else if (candidate.kind === 'arbitrary' && candidate.value.includes('var(--')) {
value = candidate.value
}
// No variables in the value, so this is a safe migration
if (value === null) {
return true
}
let replacementAsCss = designSystem
.candidatesToCss([designSystem.printCandidate(replacement)])
.join('\n')
let isSafeMigration = true
ValueParser.walk(ValueParser.parse(value), (node) => {
if (node.kind === 'function' && node.value === 'var') {
let variable = node.nodes[0].value
let r = new RegExp(`var\\(${variable}[,)]\\s*`, 'g')
if (
// We need to check if the variable is used in the replacement
!r.test(replacementAsCss) ||
// The value cannot be set to a different value in the
// replacement because that would make it an unsafe migration
replacementAsCss.includes(`${variable}:`)
) {
isSafeMigration = false
return ValueParser.ValueWalkAction.Stop
}
}
})
return isSafeMigration
}

View File

@ -34,7 +34,6 @@ test.each([
// Leading is special, because `leading-[123]` is the direct value of 123, but
// `leading-123` maps to `calc(--spacing(123))`.
['leading-[1]', 'leading-none'],
['leading-[123]', 'leading-[123]'],
['data-[selected]:flex', 'data-selected:flex'],
@ -60,7 +59,7 @@ test.each([
'data-[selected]:aria-[selected="true"]:aspect-[12/34]',
'data-selected:aria-selected:aspect-12/34',
],
])('%s => %s', async (candidate, result) => {
])('%s => %s (%#)', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})

View File

@ -1,127 +1,45 @@
import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
import {
parseCandidate,
type Candidate,
type NamedUtilityValue,
} 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 {
isPositiveInteger,
isValidSpacingMultiplier,
} from '../../../../tailwindcss/src/utils/infer-data-type'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { printCandidate } from './candidates'
import { walkVariants } from '../../utils/walk-variants'
import { computeUtilitySignature } from './signatures'
export function migrateArbitraryValueToBareValue(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let signatures = computeUtilitySignature.get(designSystem)
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
let changed = false
// Convert [subgrid] to subgrid
if (
clone.kind === 'functional' &&
clone.value?.kind === 'arbitrary' &&
clone.value.value === 'subgrid' &&
(clone.root === 'grid-cols' || clone.root == 'grid-rows')
) {
changed = true
clone.value = {
kind: 'named',
value: 'subgrid',
fraction: null,
}
}
// Convert utilities that accept bare values ending in %
if (
clone.kind === 'functional' &&
clone.value?.kind === 'arbitrary' &&
clone.value.dataType === null &&
(clone.root === 'from' ||
clone.root === 'via' ||
clone.root === 'to' ||
clone.root === 'font-stretch')
) {
if (clone.value.value.endsWith('%') && isPositiveInteger(clone.value.value.slice(0, -1))) {
let percentage = parseInt(clone.value.value)
if (
clone.root === 'from' ||
clone.root === 'via' ||
clone.root === 'to' ||
(clone.root === 'font-stretch' && percentage >= 50 && percentage <= 200)
) {
changed = true
clone.value = {
kind: 'named',
value: clone.value.value,
fraction: null,
// Migrate arbitrary values to bare values
if (clone.kind === 'functional' && clone.value?.kind === 'arbitrary') {
let expectedSignature = signatures.get(rawCandidate)
if (expectedSignature !== null) {
for (let value of tryValueReplacements(clone)) {
let newSignature = signatures.get(designSystem.printCandidate({ ...clone, value }))
if (newSignature === expectedSignature) {
changed = true
clone.value = value
break
}
}
}
}
// Convert arbitrary values with positive integers to bare values
// Convert arbitrary values with fractions to bare values
else if (
clone.kind === 'functional' &&
clone.value?.kind === 'arbitrary' &&
clone.value.dataType === null
) {
if (clone.root === 'leading') {
// leading-[1] -> leading-none
if (clone.value.value === '1') {
changed = true
clone.value = {
kind: 'named',
value: 'none',
fraction: null,
}
}
// Keep leading-[<number>] as leading-[<number>]
else {
continue
}
}
let parts = segment(clone.value.value, '/')
if (parts.every((part) => isPositiveInteger(part))) {
changed = true
let currentValue = clone.value
let currentModifier = clone.modifier
// E.g.: `col-start-[12]`
// ^^
if (parts.length === 1) {
clone.value = {
kind: 'named',
value: clone.value.value,
fraction: null,
}
}
// E.g.: `aspect-[12/34]`
// ^^ ^^
else {
clone.value = {
kind: 'named',
value: parts[0],
fraction: clone.value.value,
}
clone.modifier = {
kind: 'named',
value: parts[1],
}
}
// Double check that the new value compiles correctly
if (designSystem.compileAstNodes(clone).length === 0) {
clone.value = currentValue
clone.modifier = currentModifier
changed = false
}
}
}
for (let variant of variants(clone)) {
for (let [variant] of walkVariants(clone)) {
// Convert `data-[selected]` to `data-selected`
if (
variant.kind === 'functional' &&
@ -183,21 +101,80 @@ export function migrateArbitraryValueToBareValue(
}
}
return changed ? printCandidate(designSystem, clone) : rawCandidate
return changed ? designSystem.printCandidate(clone) : rawCandidate
}
return rawCandidate
}
function* variants(candidate: Candidate) {
function* inner(variant: Variant): Iterable<Variant> {
yield variant
if (variant.kind === 'compound') {
yield* inner(variant.variant)
// Convert functional utilities with arbitrary values to bare values if we can.
// We know that bare values can only be:
//
// 1. A number (with increments of .25)
// 2. A percentage (with increments of .25 followed by a `%`)
// 3. A ratio with whole numbers
//
// Not a bare value per se, but if we are dealing with a keyword, that could
// potentially also look like a bare value (aka no `[` or `]`). E.g.:
// ```diff
// grid-cols-[subgrid]
// grid-cols-subgrid
// ```
function* tryValueReplacements(
candidate: Extract<Candidate, { kind: 'functional' }>,
value: string = candidate.value?.value ?? '',
seen: Set<string> = new Set(),
): Generator<NamedUtilityValue> {
if (seen.has(value)) return
seen.add(value)
// 0. Just try to drop the square brackets and see if it works
// 1. A number (with increments of .25)
yield {
kind: 'named',
value,
fraction: null,
}
// 2. A percentage (with increments of .25 followed by a `%`)
// Try to drop the `%` and see if it works
if (value.endsWith('%') && isValidSpacingMultiplier(value.slice(0, -1))) {
yield {
kind: 'named',
value: value.slice(0, -1),
fraction: null,
}
}
for (let variant of candidate.variants) {
yield* inner(variant)
// 3. A ratio with whole numbers
if (value.includes('/')) {
let [numerator, denominator] = value.split('/')
if (isPositiveInteger(numerator) && isPositiveInteger(denominator)) {
yield {
kind: 'named',
value: numerator,
fraction: `${numerator}/${denominator}`,
}
}
}
// It could also be that we have `20px`, we can try just `20` and see if it
// results in the same signature.
let allNumbersAndFractions = new Set<string>()
// Figure out all numbers and fractions in the value
for (let match of value.matchAll(/(\d+\/\d+)|(\d+\.?\d+)/g)) {
allNumbersAndFractions.add(match[0].trim())
}
// Sort the numbers and fractions where the smallest length comes first. This
// will result in the smallest replacement.
let options = Array.from(allNumbersAndFractions).sort((a, z) => {
return a.length - z.length
})
// Try all the options
for (let option of options) {
yield* tryValueReplacements(candidate, option, seen)
}
}

View File

@ -0,0 +1,158 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
const css = String.raw
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, { base })
})
})
describe.each([['default'], ['important'], ['prefix']].slice(0, 1))('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
// Basic input with minimal design system to keep the tests fast
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
}
`
test.each([
// Arbitrary variant to static variant
['[&:focus]:flex', 'focus:flex'],
// Arbitrary variant to static variant with at-rules
['[@media(scripting:_none)]:flex', 'noscript:flex'],
// Arbitrary variant to static utility at-rules and with slight differences
// in whitespace. This will require some canonicalization.
['[@media(scripting:none)]:flex', 'noscript:flex'],
['[@media(scripting:_none)]:flex', 'noscript:flex'],
['[@media_(scripting:_none)]:flex', 'noscript:flex'],
// With compound variants
['has-[&:focus]:flex', 'has-focus:flex'],
['not-[&:focus]:flex', 'not-focus:flex'],
['group-[&:focus]:flex', 'group-focus:flex'],
['peer-[&:focus]:flex', 'peer-focus:flex'],
['in-[&:focus]:flex', 'in-focus:flex'],
])(testName, async (candidate, result) => {
if (strategy === 'important') {
candidate = `${candidate}!`
result = `${result}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
}
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
})
test('unsafe migrations keep the candidate as-is', async () => {
// `hover:` also includes an `@media` query in addition to the `&:hover`
// state. Migration is not safe because the functionality would be different.
let candidate = '[&:hover]:flex'
let result = '[&:hover]:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
`
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test('make unsafe migration safe (1)', async () => {
// Overriding the `hover:` variant to only use a selector will make the
// migration safe.
let candidate = '[&:hover]:flex'
let result = 'hover:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant hover (&:hover);
`
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test('make unsafe migration safe (2)', async () => {
// Overriding the `hover:` variant to only use a selector will make the
// migration safe. This time with the long-hand `@variant` syntax.
let candidate = '[&:hover]:flex'
let result = 'hover:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant hover {
&:hover {
@slot;
}
}
`
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test('custom selector-based variants', async () => {
let candidate = '[&.macos]:flex'
let result = 'is-macos:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant is-macos (&.macos);
`
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
test('custom @media-based variants', async () => {
let candidate = '[@media(prefers-reduced-transparency:reduce)]:flex'
let result = 'transparency-safe:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant transparency-safe {
@media (prefers-reduced-transparency: reduce) {
@slot;
}
}
`
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})

View File

@ -0,0 +1,64 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { replaceObject } from '../../utils/replace-object'
import type { Writable } from '../../utils/types'
import { walkVariants } from '../../utils/walk-variants'
import { computeVariantSignature } from './signatures'
const variantsLookup = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>(
(designSystem) => {
let signatures = computeVariantSignature.get(designSystem)
let lookup = new DefaultMap<string, string[]>(() => [])
// Actual static variants
for (let [root, variant] of designSystem.variants.entries()) {
if (variant.kind === 'static') {
let signature = signatures.get(root)
if (typeof signature !== 'string') continue
lookup.get(signature).push(root)
}
}
return lookup
},
)
export function migrateArbitraryVariants(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let signatures = computeVariantSignature.get(designSystem)
let variants = variantsLookup.get(designSystem)
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
// We are only interested in the variants
if (readonlyCandidate.variants.length <= 0) return rawCandidate
// The below logic makes use of mutation. Since candidates in the
// DesignSystem are cached, we can't mutate them directly.
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
for (let [variant] of walkVariants(candidate)) {
if (variant.kind === 'compound') continue
let targetString = designSystem.printVariant(variant)
let targetSignature = signatures.get(targetString)
if (typeof targetSignature !== 'string') continue
let foundVariants = variants.get(targetSignature)
if (foundVariants.length !== 1) continue
let foundVariant = foundVariants[0]
let parsedVariant = designSystem.parseVariant(foundVariant)
if (parsedVariant === null) continue
replaceObject(variant, parsedVariant)
}
return designSystem.printCandidate(candidate)
}
return rawCandidate
}

View File

@ -2,7 +2,6 @@ import { walk, WalkAction } from '../../../../tailwindcss/src/ast'
import { type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from './candidates'
export function migrateAutomaticVarInjection(
designSystem: DesignSystem,
@ -66,7 +65,7 @@ export function migrateAutomaticVarInjection(
}
if (didChange) {
return printCandidate(designSystem, candidate)
return designSystem.printCandidate(candidate)
}
}
return rawCandidate

View File

@ -1,6 +1,5 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from './candidates'
const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl']
@ -17,7 +16,7 @@ export function migrateBgGradient(
continue
}
return printCandidate(designSystem, {
return designSystem.printCandidate({
...candidate,
root: `bg-linear-to-${direction}`,
})

View File

@ -0,0 +1,63 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
const css = String.raw
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, { base })
})
})
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
// Basic input with minimal design system to keep the tests fast
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
}
`
test.each([
// A color value can be inferred from the value
['bg-[color:#008cc]', 'bg-[#008cc]'],
// A position can be inferred from the value
['bg-[position:123px]', 'bg-[123px]'],
// A color is the default for `bg-*`
['bg-(color:--my-value)', 'bg-(--my-value)'],
// A position is not the default, so the `position` data type is kept
['bg-(position:--my-value)', 'bg-(position:--my-value)'],
])(testName, async (candidate, expected) => {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
expected = `focus:${expected}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
expected = `${expected}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}`
}
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateDropUnnecessaryDataTypes(designSystem, {}, candidate)
expect(migrated).toEqual(expected)
})
})

View File

@ -0,0 +1,30 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { computeUtilitySignature } from './signatures'
export function migrateDropUnnecessaryDataTypes(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let signatures = computeUtilitySignature.get(designSystem)
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (
candidate.kind === 'functional' &&
candidate.value?.kind === 'arbitrary' &&
candidate.value.dataType !== null
) {
let replacement = designSystem.printCandidate({
...candidate,
value: { ...candidate.value, dataType: null },
})
if (signatures.get(rawCandidate) === signatures.get(replacement)) {
return replacement
}
}
}
return rawCandidate
}

View File

@ -1,7 +1,6 @@
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from './candidates'
import { isSafeMigration } from './is-safe-migration'
// In v3 the important modifier `!` sits in front of the utility itself, not
@ -41,7 +40,7 @@ export function migrateImportant(
// The printCandidate function will already put the exclamation mark in
// the right place, so we just need to mark this candidate as requiring a
// migration.
return printCandidate(designSystem, candidate)
return designSystem.printCandidate(candidate)
}
}

View File

@ -2,7 +2,6 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { printCandidate } from './candidates'
export function migrateLegacyArbitraryValues(
designSystem: DesignSystem,
@ -23,7 +22,7 @@ export function migrateLegacyArbitraryValues(
clone.value.value = segment(clone.value.value, ',').join(' ')
}
return changed ? printCandidate(designSystem, clone) : rawCandidate
return changed ? designSystem.printCandidate(clone) : rawCandidate
}
return rawCandidate

View File

@ -6,7 +6,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import * as version from '../../utils/version'
import { printCandidate } from './candidates'
import { isSafeMigration } from './is-safe-migration'
const __filename = url.fileURLToPath(import.meta.url)
@ -94,7 +93,7 @@ export async function migrateLegacyClasses(
let baseCandidate = structuredClone(candidate) as Candidate
baseCandidate.variants = []
baseCandidate.important = false
let baseCandidateString = printCandidate(designSystem, baseCandidate)
let baseCandidateString = designSystem.printCandidate(baseCandidate)
// Find the new base candidate string. `blur` -> `blur-sm`
let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString)
@ -171,7 +170,7 @@ export async function migrateLegacyClasses(
}
}
return printCandidate(designSystem, toCandidate)
return designSystem.printCandidate(toCandidate)
}
return rawCandidate

View File

@ -1,6 +1,5 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from './candidates'
export function migrateMaxWidthScreen(
designSystem: DesignSystem,
@ -13,7 +12,7 @@ export function migrateMaxWidthScreen(
candidate.root === 'max-w' &&
candidate.value?.value.startsWith('screen-')
) {
return printCandidate(designSystem, {
return designSystem.printCandidate({
...candidate,
value: {
...candidate.value,

View File

@ -1,11 +1,26 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test, vi } from 'vitest'
import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as versions from '../../utils/version'
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values'
import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-values'
import { migratePrefix } from './migrate-prefix'
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) {
for (let migration of [
migrateEmptyArbitraryValues,
migratePrefix,
migrateModernizeArbitraryValues,
migrateArbitraryVariants,
]) {
rawCandidate = migration(designSystem, userConfig, rawCandidate)
}
return rawCandidate
}
test.each([
// Arbitrary variants
['[[data-visible]]:flex', 'data-visible:flex'],
@ -72,6 +87,9 @@ test.each([
// Keep multiple attribute selectors as-is
['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'],
// Keep `:where(…)` as is
['[:where([data-visible])]:flex', '[:where([data-visible])]: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'],
@ -87,6 +105,13 @@ test.each([
['[@media_print]:flex', 'print:flex'],
['[@media_not_print]:flex', 'not-print:flex'],
// Hoist the `:not` part to a compound variant
['[@media_not_(prefers-color-scheme:dark)]:flex', 'not-dark:flex'],
[
'[@media_not_(prefers-color-scheme:unknown)]:flex',
'not-[@media_(prefers-color-scheme:unknown)]:flex',
],
// Compound arbitrary variants
['has-[[data-visible]]:flex', 'has-data-visible:flex'],
['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'],
@ -104,12 +129,7 @@ test.each([
base: __dirname,
})
expect(
[migrateEmptyArbitraryValues, migrateModernizeArbitraryValues].reduce(
(acc, step) => step(designSystem, {}, acc),
candidate,
),
).toEqual(result)
expect(migrate(designSystem, {}, candidate)).toEqual(result)
})
test.each([
@ -138,10 +158,5 @@ test.each([
base: __dirname,
})
expect(
[migrateEmptyArbitraryValues, migratePrefix, migrateModernizeArbitraryValues].reduce(
(acc, step) => step(designSystem, { prefix: 'tw-' }, acc),
candidate,
),
).toEqual(result)
expect(migrate(designSystem, { prefix: 'tw-' }, candidate)).toEqual(result)
})

View File

@ -1,29 +1,25 @@
import SelectorParser from 'postcss-selector-parser'
import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
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 { printCandidate } from './candidates'
function memcpy<T extends object, U extends object | null>(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)
}
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 variants(clone)) {
for (let [variant, parent] of walkVariants(clone)) {
// Forward modifier from the root to the compound variant
if (
variant.kind === 'compound' &&
@ -49,7 +45,7 @@ export function migrateModernizeArbitraryValues(
// `group-[]`
if (variant.modifier === null) {
changed = true
memcpy(
replaceObject(
variant,
designSystem.parseVariant(
designSystem.theme.prefix
@ -62,7 +58,7 @@ export function migrateModernizeArbitraryValues(
// `group-[]/name`
else if (variant.modifier.kind === 'named') {
changed = true
memcpy(
replaceObject(
variant,
designSystem.parseVariant(
designSystem.theme.prefix
@ -98,7 +94,7 @@ export function migrateModernizeArbitraryValues(
ast.nodes[0].nodes[2].type === 'universal'
) {
changed = true
memcpy(variant, designSystem.parseVariant('*'))
replaceObject(variant, designSystem.parseVariant('*'))
continue
}
@ -116,7 +112,7 @@ export function migrateModernizeArbitraryValues(
ast.nodes[0].nodes[2].type === 'universal'
) {
changed = true
memcpy(variant, designSystem.parseVariant('**'))
replaceObject(variant, designSystem.parseVariant('**'))
continue
}
@ -143,109 +139,53 @@ export function migrateModernizeArbitraryValues(
// 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()}]`))
replaceObject(variant, designSystem.parseVariant(`in-[${ast.toString()}]`))
continue
}
// Migrate `@media` variants
// Hoist `not` modifier for `@media` or `@supports` variants
//
// E.g.: `[@media(scripting:none)]:` -> `noscript:`
// 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(scripting:none)]:flex
// ^^^^^^^^^^^^^^^^^^^^^^
// [@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('@media') ||
ast.nodes[0].nodes[0].value.startsWith('@supports'))
) {
// Replace all whitespace such that `@media (scripting: none)` and
// `@media(scripting:none)` are equivalent.
//
// As arbitrary variants that means that these are equivalent:
// - `[@media_(scripting:_none)]:`
// - `[@media(scripting:none)]:`
let parsed = ValueParser.parse(ast.nodes[0].toString().trim().replace('@media', ''))
// Drop whitespace
let targetSignature = signatures.get(designSystem.printVariant(variant))
let parsed = ValueParser.parse(ast.nodes[0].toString().trim())
let containsNot = false
ValueParser.walk(parsed, (node, { replaceWith }) => {
// Drop whitespace nodes
if (node.kind === 'separator' && !node.value.trim()) {
if (node.kind === 'word' && node.value === 'not') {
containsNot = true
replaceWith([])
}
// Trim whitespace
else {
node.value = node.value.trim()
}
})
let not = false
if (parsed[0]?.kind === 'word' && parsed[0].value === 'not') {
not = true
parsed.shift()
}
// 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 = ' '
}
})
// Single keyword at-rules.
//
// E.g.: `[@media_print]:` -< `@media print` -> `print:`
if (parsed.length === 1 && parsed[0].kind === 'word') {
let key = parsed[0].value
let replacement: string | null = null
if (key === 'print') replacement = 'print'
if (replacement) {
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
memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`))
replaceObject(variant, hoistedNot)
continue
}
}
// Key/value at-rules.
//
// E.g.: `[@media(scripting:none)]:` -> `scripting:`
if (
parsed.length === 1 &&
parsed[0].kind === 'function' && // `(` and `)` are considered a function
parsed[0].nodes.length === 3 &&
parsed[0].nodes[0].kind === 'word' &&
parsed[0].nodes[1].kind === 'separator' &&
parsed[0].nodes[1].value === ':' &&
parsed[0].nodes[2].kind === 'word'
) {
let key = parsed[0].nodes[0].value
let value = parsed[0].nodes[2].value
let replacement: string | null = null
if (key === 'prefers-reduced-motion' && value === 'no-preference')
replacement = 'motion-safe'
if (key === 'prefers-reduced-motion' && value === 'reduce')
replacement = 'motion-reduce'
if (key === 'prefers-contrast' && value === 'more') replacement = 'contrast-more'
if (key === 'prefers-contrast' && value === 'less') replacement = 'contrast-less'
if (key === 'orientation' && value === 'portrait') replacement = 'portrait'
if (key === 'orientation' && value === 'landscape') replacement = 'landscape'
if (key === 'forced-colors' && value === 'active') replacement = 'forced-colors'
if (key === 'inverted-colors' && value === 'inverted') replacement = 'inverted-colors'
if (key === 'pointer' && value === 'none') replacement = 'pointer-none'
if (key === 'pointer' && value === 'coarse') replacement = 'pointer-coarse'
if (key === 'pointer' && value === 'fine') replacement = 'pointer-fine'
if (key === 'any-pointer' && value === 'none') replacement = 'any-pointer-none'
if (key === 'any-pointer' && value === 'coarse') replacement = 'any-pointer-coarse'
if (key === 'any-pointer' && value === 'fine') replacement = 'any-pointer-fine'
if (key === 'scripting' && value === 'none') replacement = 'noscript'
if (replacement) {
changed = true
memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`))
}
}
continue
}
let prefixedVariant: Variant | null = null
@ -317,48 +257,6 @@ export function migrateModernizeArbitraryValues(
}
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 === ':user-valid') return 'user-valid'
else if (value === ':user-invalid') return 'user-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 &&
@ -405,6 +303,15 @@ export function migrateModernizeArbitraryValues(
}
}
// 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)
@ -418,7 +325,7 @@ export function migrateModernizeArbitraryValues(
// Update original variant
changed = true
memcpy(variant, parsed)
replaceObject(variant, structuredClone(parsed))
}
// Expecting an attribute selector
@ -443,7 +350,7 @@ export function migrateModernizeArbitraryValues(
if (attributeKey.startsWith('data-')) {
changed = true
attributeKey = attributeKey.slice(5) // Remove `data-`
memcpy(variant, {
replaceObject(variant, {
kind: 'functional',
root: 'data',
modifier: null,
@ -458,7 +365,7 @@ export function migrateModernizeArbitraryValues(
else if (attributeKey.startsWith('aria-')) {
changed = true
attributeKey = attributeKey.slice(5) // Remove `aria-`
memcpy(variant, {
replaceObject(variant, {
kind: 'functional',
root: 'aria',
modifier: null,
@ -482,25 +389,8 @@ export function migrateModernizeArbitraryValues(
}
}
return changed ? printCandidate(designSystem, clone) : rawCandidate
return changed ? designSystem.printCandidate(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

@ -0,0 +1,65 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { migrateOptimizeModifier } from './migrate-optimize-modifier'
const css = String.raw
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, { base })
})
})
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
// Basic input with minimal design system to keep the tests fast
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
--color-red-500: red;
}
`
test.each([
// Keep the modifier as-is, nothing to optimize
['bg-red-500/25', 'bg-red-500/25'],
// Use a bare value modifier
['bg-red-500/[25%]', 'bg-red-500/25'],
// Drop unnecessary modifiers
['bg-red-500/[100%]', 'bg-red-500'],
['bg-red-500/100', 'bg-red-500'],
// Keep modifiers on classes that don't _really_ exist
['group/name', 'group/name'],
])(testName, async (candidate, expected) => {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
expected = `focus:${expected}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
expected = `${expected}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}`
}
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrateOptimizeModifier(designSystem, {}, candidate)
expect(migrated).toEqual(expected)
})
})

View File

@ -0,0 +1,63 @@
import type { NamedUtilityValue } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import type { Writable } from '../../utils/types'
import { computeUtilitySignature } from './signatures'
// Optimize the modifier
//
// E.g.:
//
// - `/[25%]` → `/25`
// - `/[100%]` → `/100` → <empty>
// - `/100` → <empty>
//
export function migrateOptimizeModifier(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let signatures = computeUtilitySignature.get(designSystem)
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
if (
(candidate.kind === 'functional' && candidate.modifier !== null) ||
(candidate.kind === 'arbitrary' && candidate.modifier !== null)
) {
let targetSignature = signatures.get(rawCandidate)
let modifier = candidate.modifier
let changed = false
// 1. Try to drop the modifier entirely
if (
targetSignature ===
signatures.get(designSystem.printCandidate({ ...candidate, modifier: null }))
) {
changed = true
candidate.modifier = null
}
// 2. Try to remove the square brackets and the `%` sign
if (!changed) {
let newModifier: NamedUtilityValue = {
kind: 'named',
value: modifier.value.endsWith('%') ? modifier.value.slice(0, -1) : modifier.value,
fraction: null,
}
if (
targetSignature ===
signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier }))
) {
changed = true
candidate.modifier = newModifier
}
}
return changed ? designSystem.printCandidate(candidate) : rawCandidate
}
}
return rawCandidate
}

View File

@ -3,7 +3,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import * as version from '../../utils/version'
import { printCandidate } from './candidates'
let seenDesignSystems = new WeakSet<DesignSystem>()
@ -48,7 +47,7 @@ export function migratePrefix(
if (!candidate) return rawCandidate
return printCandidate(designSystem, candidate)
return designSystem.printCandidate(candidate)
}
// Parses a raw candidate with v3 compatible prefix syntax. This won't match if

View File

@ -1,7 +1,6 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as version from '../../utils/version'
import { printCandidate } from './candidates'
// Classes that used to exist in Tailwind CSS v3, but do not exist in Tailwind
// CSS v4 anymore.
@ -53,7 +52,7 @@ export function migrateSimpleLegacyClasses(
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.kind === 'static' && Object.hasOwn(LEGACY_CLASS_MAP, candidate.root)) {
return printCandidate(designSystem, {
return designSystem.printCandidate({
...candidate,
root: LEGACY_CLASS_MAP[candidate.root as keyof typeof LEGACY_CLASS_MAP],
})

View File

@ -1,9 +1,4 @@
import {
parseCandidate,
type Candidate,
type CandidateModifier,
type Variant,
} from '../../../../tailwindcss/src/candidate'
import { parseCandidate, type CandidateModifier } from '../../../../tailwindcss/src/candidate'
import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
@ -11,7 +6,7 @@ import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infe
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { printCandidate } from './candidates'
import { walkVariants } from '../../utils/walk-variants'
export const enum Convert {
All = 0,
@ -59,7 +54,7 @@ export function migrateThemeToVar(
}
// Handle variants
for (let variant of variants(clone)) {
for (let [variant] of walkVariants(clone)) {
if (variant.kind === 'arbitrary') {
let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly)
if (newValue !== variant.selector) {
@ -75,7 +70,7 @@ export function migrateThemeToVar(
}
}
return changed ? printCandidate(designSystem, clone) : rawCandidate
return changed ? designSystem.printCandidate(clone) : rawCandidate
}
return rawCandidate
@ -332,16 +327,3 @@ function eventuallyUnquote(value: string) {
return unquoted
}
function* variants(candidate: Candidate) {
function* inner(variant: Variant): Iterable<Variant> {
yield variant
if (variant.kind === 'compound') {
yield* inner(variant.variant)
}
}
for (let variant of candidate.variants) {
yield* inner(variant)
}
}

View File

@ -3,7 +3,6 @@ import { type Variant } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as version from '../../utils/version'
import { printCandidate } from './candidates'
export function migrateVariantOrder(
designSystem: DesignSystem,
@ -56,7 +55,7 @@ export function migrateVariantOrder(
continue
}
return printCandidate(designSystem, { ...candidate, variants: newOrder })
return designSystem.printCandidate({ ...candidate, variants: newOrder })
}
return rawCandidate
}

View File

@ -0,0 +1,66 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test, vi } from 'vitest'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import * as versions from '../../utils/version'
import { migrateCandidate as migrate } from './migrate'
vi.spyOn(versions, 'isMajor').mockReturnValue(false)
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, { base })
})
})
const css = String.raw
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
// Basic input with minimal design system to keep the tests fast
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
--spacing: 0.25rem;
--color-red-500: red;
/* Equivalent of blue-500/50 */
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
}
`
test.each([
// Arbitrary property to named functional utlity
['[color:red]', 'text-red-500'],
// Promote data types to more specific utility if it exists
['bg-(position:--my-value)', 'bg-position-(--my-value)'],
// Promote inferred data type to more specific utility if it exists
['bg-[123px]', 'bg-position-[123px]'],
])(testName, async (candidate, result) => {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
result = `focus:${result}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
result = `${result}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
}
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = await migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
})

View File

@ -4,16 +4,20 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string'
import { extractRawCandidates, printCandidate } from './candidates'
import { extractRawCandidates } from './candidates'
import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection'
import { migrateBgGradient } from './migrate-bg-gradient'
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values'
import { migrateImportant } from './migrate-important'
import { migrateLegacyArbitraryValues } from './migrate-legacy-arbitrary-values'
import { migrateLegacyClasses } from './migrate-legacy-classes'
import { migrateMaxWidthScreen } from './migrate-max-width-screen'
import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-values'
import { migrateOptimizeModifier } from './migrate-optimize-modifier'
import { migratePrefix } from './migrate-prefix'
import { migrateSimpleLegacyClasses } from './migrate-simple-legacy-classes'
import { migrateThemeToVar } from './migrate-theme-to-var'
@ -42,8 +46,12 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
migrateVariantOrder, // Has to happen before migrations that modify variants
migrateAutomaticVarInjection,
migrateLegacyArbitraryValues,
migrateArbitraryValueToBareValue,
migrateArbitraryUtilities,
migrateModernizeArbitraryValues,
migrateArbitraryVariants,
migrateDropUnnecessaryDataTypes,
migrateArbitraryValueToBareValue,
migrateOptimizeModifier,
]
export async function migrateCandidate(
@ -69,7 +77,7 @@ export async function migrateCandidate(
// E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)`
if (rawCandidate === original) {
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
return printCandidate(designSystem, candidate)
return designSystem.printCandidate(candidate)
}
}

View File

@ -0,0 +1,404 @@
import { substituteAtApply } from '../../../../tailwindcss/src/apply'
import { atRule, styleRule, toCss, walk, type AstNode } from '../../../../tailwindcss/src/ast'
import { printArbitraryValue } from '../../../../tailwindcss/src/candidate'
import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-parser'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { ThemeOptions } from '../../../../tailwindcss/src/theme'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { dimensions } from '../../utils/dimension'
// Given a utility, compute a signature that represents the utility. The
// signature will be a normalised form of the generated CSS for the utility, or
// a unique symbol if the utility is not valid. The class in the selector will
// be replaced with the `.x` selector.
//
// This function should only be passed the base utility so `flex`, `hover:flex`
// and `focus:flex` will all use just `flex`. Variants are handled separately.
//
// E.g.:
//
// | UTILITY | GENERATED SIGNATURE |
// | ---------------- | ----------------------- |
// | `[display:flex]` | `.x { display: flex; }` |
// | `flex` | `.x { display: flex; }` |
//
// These produce the same signature, therefore they represent the same utility.
export const computeUtilitySignature = new DefaultMap<
DesignSystem,
DefaultMap<string, string | Symbol>
>((designSystem) => {
return new DefaultMap<string, string | Symbol>((utility) => {
try {
// Ensure the prefix is added to the utility if it is not already present.
utility =
designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix)
? `${designSystem.theme.prefix}:${utility}`
: utility
// Use `@apply` to normalize the selector to `.x`
let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])]
temporarilyDisableThemeInline(designSystem, () => substituteAtApply(ast, designSystem))
// We will be mutating the AST, so we need to clone it first to not affect
// the original AST
ast = structuredClone(ast)
// Optimize the AST. This is needed such that any internal intermediate
// nodes are gone. This will also cleanup declaration nodes with undefined
// values or `--tw-sort` declarations.
walk(ast, (node, { replaceWith }) => {
// Optimize declarations
if (node.kind === 'declaration') {
if (node.value === undefined || node.property === '--tw-sort') {
replaceWith([])
}
}
// Replace special nodes with its children
else if (node.kind === 'context' || node.kind === 'at-root') {
replaceWith(node.nodes)
}
// Remove comments
else if (node.kind === 'comment') {
replaceWith([])
}
})
// Resolve theme values to their inlined value.
//
// E.g.:
//
// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]`
// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]`
//
// Due to the `@apply` from above, this will become:
//
// ```css
// .example {
// color: oklch(63.7% 0.237 25.331);
// }
// ```
//
// Which conveniently will be equivalent to: `text-red-500` when we inline
// the value.
//
// Without inlining:
// ```css
// .example {
// color: var(--color-red-500, oklch(63.7% 0.237 25.331));
// }
// ```
//
// Inlined:
// ```css
// .example {
// color: oklch(63.7% 0.237 25.331);
// }
// ```
//
// Recently we made sure that utilities like `text-red-500` also generate
// the fallback value for usage in `@reference` mode.
//
// The second assumption is that if you use `var(--key, fallback)` that
// happens to match a known variable _and_ its inlined value. Then we can
// replace it with the inlined variable. This allows us to handle custom
// `@theme` and `@theme inline` definitions.
walk(ast, (node) => {
// Handle declarations
if (node.kind === 'declaration' && node.value !== undefined) {
if (node.value.includes('var(')) {
let valueAst = ValueParser.parse(node.value)
let seen = new Set<string>()
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
if (valueNode.kind !== 'function') return
if (valueNode.value !== 'var') return
// Resolve the underlying value of the variable
if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) {
return
}
let variable = valueNode.nodes[0].value
// Drop the prefix from the variable name if it is present. The
// internal variable doesn't have the prefix.
if (
designSystem.theme.prefix &&
variable.startsWith(`--${designSystem.theme.prefix}-`)
) {
variable = variable.slice(`--${designSystem.theme.prefix}-`.length)
}
let variableValue = designSystem.resolveThemeValue(variable)
// Prevent infinite recursion when the variable value contains the
// variable itself.
if (seen.has(variable)) return
seen.add(variable)
if (variableValue === undefined) return // Couldn't resolve the variable
// Inject variable fallbacks when no fallback is present yet.
//
// A fallback could consist of multiple values.
//
// E.g.:
//
// ```
// var(--font-sans, ui-sans-serif, system-ui, sans-serif, …)
// ```
{
// More than 1 argument means that a fallback is already present
if (valueNode.nodes.length === 1) {
// Inject the fallback value into the variable lookup
valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`))
}
}
// Replace known variable + inlined fallback value with the value
// itself again
{
// We need at least 3 arguments. The variable, the separator and a fallback value.
if (valueNode.nodes.length >= 3) {
let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable
let constructedValue = `${valueNode.nodes[0].value},${variableValue}`
if (nodeAsString === constructedValue) {
replaceWith(ValueParser.parse(variableValue))
}
}
}
})
// Replace the value with the new value
node.value = ValueParser.toCss(valueAst)
}
// Very basic `calc(…)` constant folding to handle the spacing scale
// multiplier:
//
// Input: `--spacing(4)`
// → `calc(var(--spacing, 0.25rem) * 4)`
// → `calc(0.25rem * 4)` ← this is the case we will see
// after inlining the variable
// → `1rem`
if (node.value.includes('calc')) {
let folded = false
let valueAst = ValueParser.parse(node.value)
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
if (valueNode.kind !== 'function') return
if (valueNode.value !== 'calc') return
// [
// { kind: 'word', value: '0.25rem' }, 0
// { kind: 'separator', value: ' ' }, 1
// { kind: 'word', value: '*' }, 2
// { kind: 'separator', value: ' ' }, 3
// { kind: 'word', value: '256' } 4
// ]
if (valueNode.nodes.length !== 5) return
if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return
let parsed = dimensions.get(valueNode.nodes[0].value)
if (parsed === null) return
let [value, unit] = parsed
let multiplier = Number(valueNode.nodes[4].value)
if (Number.isNaN(multiplier)) return
folded = true
replaceWith(ValueParser.parse(`${value * multiplier}${unit}`))
})
if (folded) {
node.value = ValueParser.toCss(valueAst)
}
}
// We will normalize the `node.value`, this is the same kind of logic
// we use when printing arbitrary values. It will remove unnecessary
// whitespace.
//
// Essentially normalizing the `node.value` to a canonical form.
node.value = printArbitraryValue(node.value)
}
})
// Compute the final signature, by generating the CSS for the utility
let signature = toCss(ast)
return signature
} catch {
// A unique symbol is returned to ensure that 2 signatures resulting in
// `null` are not considered equal.
return Symbol()
}
})
})
// Given a variant, compute a signature that represents the variant. The
// signature will be a normalised form of the generated CSS for the variant, or
// a unique symbol if the variant is not valid. The class in the selector will
// be replaced with `.x`.
//
// E.g.:
//
// | VARIANT | GENERATED SIGNATURE |
// | ---------------- | ----------------------------- |
// | `[&:focus]:flex` | `.x:focus { display: flex; }` |
// | `focus:flex` | `.x:focus { display: flex; }` |
//
// These produce the same signature, therefore they represent the same variant.
export const computeVariantSignature = new DefaultMap<
DesignSystem,
DefaultMap<string, string | Symbol>
>((designSystem) => {
return new DefaultMap<string, string | Symbol>((variant) => {
try {
// Ensure the prefix is added to the utility if it is not already present.
variant =
designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix)
? `${designSystem.theme.prefix}:${variant}`
: variant
// Use `@apply` to normalize the selector to `.x`
let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])]
substituteAtApply(ast, designSystem)
// Canonicalize selectors to their minimal form
walk(ast, (node) => {
// At-rules
if (node.kind === 'at-rule' && node.params.includes(' ')) {
node.params = node.params.replaceAll(' ', '')
}
// Style rules
else if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
let changed = false
SelectorParser.walk(selectorAst, (node, { replaceWith }) => {
if (node.kind === 'separator' && node.value !== ' ') {
node.value = node.value.trim()
changed = true
}
// Remove unnecessary `:is(…)` selectors
else if (node.kind === 'function' && node.value === ':is') {
// A single selector inside of `:is(…)` can be replaced with the
// selector itself.
//
// E.g.: `:is(.foo)` → `.foo`
if (node.nodes.length === 1) {
changed = true
replaceWith(node.nodes)
}
// A selector with the universal selector `*` followed by a pseudo
// class, can be replaced with the pseudo class itself.
else if (
node.nodes.length === 2 &&
node.nodes[0].kind === 'selector' &&
node.nodes[0].value === '*' &&
node.nodes[1].kind === 'selector' &&
node.nodes[1].value[0] === ':'
) {
changed = true
replaceWith(node.nodes[1])
}
}
// Ensure `*` exists before pseudo selectors inside of `:not(…)`,
// `:where(…)`, …
//
// E.g.:
//
// `:not(:first-child)` → `:not(*:first-child)`
//
else if (
node.kind === 'function' &&
node.value[0] === ':' &&
node.nodes[0]?.kind === 'selector' &&
node.nodes[0]?.value[0] === ':'
) {
changed = true
node.nodes.unshift({ kind: 'selector', value: '*' })
}
})
if (changed) {
node.selector = SelectorParser.toCss(selectorAst)
}
}
})
// Compute the final signature, by generating the CSS for the variant
let signature = toCss(ast)
return signature
} catch {
// A unique symbol is returned to ensure that 2 signatures resulting in
// `null` are not considered equal.
return Symbol()
}
})
})
function temporarilyDisableThemeInline<T>(designSystem: DesignSystem, cb: () => T): T {
// Turn off `@theme inline` feature such that `@theme` and `@theme inline` are
// considered the same. The biggest motivation for this is referencing
// variables in another namespace that happen to contain the same value as the
// utility's own namespaces it is reading from.
//
// E.g.:
//
// The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace.
// But it does read from the `--container-*` namespace. It also happens to
// be the case that `--breakpoint-md` and `--container-3xl` are the exact
// same value.
//
// If you then use the `max-w-(--breakpoint-md)` utility, inlining the
// variable would mean:
// - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl`
// - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl`
//
// Not inlining the variable would mean:
// - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)`
// - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl`
// @ts-expect-error We are monkey-patching a method that's considered private
// in TypeScript
let originalGet = designSystem.theme.values.get
// Track all values with the inline option set, so we can restore them later.
let restorableInlineOptions = new Set<{ options: ThemeOptions }>()
// @ts-expect-error We are monkey-patching a method that's considered private
// in TypeScript
designSystem.theme.values.get = (key: string) => {
// @ts-expect-error We are monkey-patching a method that's considered private
// in TypeScript
let value = originalGet.call(designSystem.theme.values, key)
if (value === undefined) return value
// Remove `inline` if it was set
if (value.options & ThemeOptions.INLINE) {
restorableInlineOptions.add(value)
value.options &= ~ThemeOptions.INLINE
}
return value
}
try {
// Run the callback with the `@theme inline` feature disabled
return cb()
} finally {
// Restore the `@theme inline` to the original value
// @ts-expect-error We are monkey-patching a method that's private
designSystem.theme.values.get = originalGet
// Re-add the `inline` option, in case future lookups are done
for (let value of restorableInlineOptions) {
value.options |= ThemeOptions.INLINE
}
}
}

View File

@ -0,0 +1,18 @@
import { DefaultMap } from '../../../tailwindcss/src/utils/default-map'
// Parse a dimension such as `64rem` into `[64, 'rem']`.
export const dimensions = new DefaultMap((input) => {
let match = /^(?<value>-?(?:\d*\.)?\d+)(?<unit>[a-z]+|%)$/i.exec(input)
if (!match) return null
let value = match.groups?.value
if (value === undefined) return null
let unit = match.groups?.unit
if (unit === undefined) return null
let valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber)) return null
return [valueAsNumber, unit] as const
})

View File

@ -0,0 +1,7 @@
export function replaceObject<T extends object, U extends object | null>(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)
}

View File

@ -0,0 +1 @@
export type Writable<T> = T extends Readonly<infer U> ? U : T

View File

@ -0,0 +1,18 @@
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
export function* walkVariants(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

@ -1,14 +1,16 @@
import type { DesignSystem } from './design-system'
import { decodeArbitraryValue } from './utils/decode-arbitrary-value'
import { DefaultMap } from './utils/default-map'
import { isValidArbitrary } from './utils/is-valid-arbitrary'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
const COLON = 0x3a
const DASH = 0x2d
const LOWER_A = 0x61
const LOWER_Z = 0x7a
type ArbitraryUtilityValue = {
export type ArbitraryUtilityValue = {
kind: 'arbitrary'
/**
@ -60,7 +62,7 @@ export type NamedUtilityValue = {
fraction: string | null
}
type ArbitraryModifier = {
export type ArbitraryModifier = {
kind: 'arbitrary'
/**
@ -72,7 +74,7 @@ type ArbitraryModifier = {
value: string
}
type NamedModifier = {
export type NamedModifier = {
kind: 'named'
/**
@ -776,3 +778,283 @@ function* findRoots(input: string, exists: (input: string) => boolean): Iterable
yield ['@', input.slice(1)]
}
}
export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
let parts: string[] = []
for (let variant of candidate.variants) {
parts.unshift(printVariant(variant))
}
// Handle prefix
if (designSystem.theme.prefix) {
parts.unshift(designSystem.theme.prefix)
}
let base: string = ''
// Handle static
if (candidate.kind === 'static') {
base += candidate.root
}
// Handle functional
if (candidate.kind === 'functional') {
base += candidate.root
if (candidate.value) {
if (candidate.value.kind === 'arbitrary') {
if (candidate.value !== null) {
let isVarValue = isVar(candidate.value.value)
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
if (candidate.value.dataType) {
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
} else {
base += `-${open}${printArbitraryValue(value)}${close}`
}
}
} else if (candidate.value.kind === 'named') {
base += `-${candidate.value.value}`
}
}
}
// Handle arbitrary
if (candidate.kind === 'arbitrary') {
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
}
// Handle modifier
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
base += printModifier(candidate.modifier)
}
// Handle important
if (candidate.important) {
base += '!'
}
parts.push(base)
return parts.join(':')
}
export function printModifier(modifier: ArbitraryModifier | NamedModifier | null) {
if (modifier === null) return ''
let isVarValue = isVar(modifier.value)
let value = isVarValue ? modifier.value.slice(4, -1) : modifier.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
if (modifier.kind === 'arbitrary') {
return `/${open}${printArbitraryValue(value)}${close}`
} else if (modifier.kind === 'named') {
return `/${modifier.value}`
} else {
modifier satisfies never
return ''
}
}
export function printVariant(variant: Variant) {
// Handle static variants
if (variant.kind === 'static') {
return variant.root
}
// Handle arbitrary variants
if (variant.kind === 'arbitrary') {
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
}
let base: string = ''
// Handle functional variants
if (variant.kind === 'functional') {
base += variant.root
// `@` is a special case for functional variants. We want to print: `@lg`
// instead of `@-lg`
let hasDash = variant.root !== '@'
if (variant.value) {
if (variant.value.kind === 'arbitrary') {
let isVarValue = isVar(variant.value.value)
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}`
} else if (variant.value.kind === 'named') {
base += `${hasDash ? '-' : ''}${variant.value.value}`
}
}
}
// Handle compound variants
if (variant.kind === 'compound') {
base += variant.root
base += '-'
base += printVariant(variant.variant)
}
// Handle modifiers
if (variant.kind === 'functional' || variant.kind === 'compound') {
base += printModifier(variant.modifier)
}
return base
}
const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
let ast = ValueParser.parse(input)
let drop = new Set<ValueParser.ValueAstNode>()
ValueParser.walk(ast, (node, { parent }) => {
let parentArray = parent === null ? ast : (parent.nodes ?? [])
// Handle operators (e.g.: inside of `calc(…)`)
if (
node.kind === 'word' &&
// Operators
(node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/')
) {
let idx = parentArray.indexOf(node) ?? -1
// This should not be possible
if (idx === -1) return
let previous = parentArray[idx - 1]
if (previous?.kind !== 'separator' || previous.value !== ' ') return
let next = parentArray[idx + 1]
if (next?.kind !== 'separator' || next.value !== ' ') return
drop.add(previous)
drop.add(next)
}
// The value parser handles `/` as a separator in some scenarios. E.g.:
// `theme(colors.red/50%)`. Because of this, we have to handle this case
// separately.
else if (node.kind === 'separator' && node.value.trim() === '/') {
node.value = '/'
}
// Leading and trailing whitespace
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {
drop.add(node)
}
}
// Whitespace around `,` separators can be removed.
// E.g.: `min(1px , 2px)` -> `min(1px,2px)`
else if (node.kind === 'separator' && node.value.trim() === ',') {
node.value = ','
}
})
if (drop.size > 0) {
ValueParser.walk(ast, (node, { replaceWith }) => {
if (drop.has(node)) {
drop.delete(node)
replaceWith([])
}
})
}
recursivelyEscapeUnderscores(ast)
return ValueParser.toCss(ast)
})
export function printArbitraryValue(input: string) {
return printArbitraryValueCache.get(input)
}
const simplifyArbitraryVariantCache = new DefaultMap<string, string>((input) => {
let ast = ValueParser.parse(input)
// &:is(…)
if (
ast.length === 3 &&
// &
ast[0].kind === 'word' &&
ast[0].value === '&' &&
// :
ast[1].kind === 'separator' &&
ast[1].value === ':' &&
// is(…)
ast[2].kind === 'function' &&
ast[2].value === 'is'
) {
return ValueParser.toCss(ast[2].nodes)
}
return input
})
function simplifyArbitraryVariant(input: string) {
return simplifyArbitraryVariantCache.get(input)
}
function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
for (let node of ast) {
switch (node.kind) {
case 'function': {
if (node.value === 'url' || node.value.endsWith('_url')) {
// Don't decode underscores in url() but do decode the function name
node.value = escapeUnderscore(node.value)
break
}
if (
node.value === 'var' ||
node.value.endsWith('_var') ||
node.value === 'theme' ||
node.value.endsWith('_theme')
) {
node.value = escapeUnderscore(node.value)
for (let i = 0; i < node.nodes.length; i++) {
recursivelyEscapeUnderscores([node.nodes[i]])
}
break
}
node.value = escapeUnderscore(node.value)
recursivelyEscapeUnderscores(node.nodes)
break
}
case 'separator':
node.value = escapeUnderscore(node.value)
break
case 'word': {
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
// have underscores escaped
if (node.value[0] !== '-' && node.value[1] !== '-') {
node.value = escapeUnderscore(node.value)
}
break
}
default:
never(node)
}
}
}
const isVarCache = new DefaultMap<string, boolean>((value) => {
let ast = ValueParser.parse(value)
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
})
function isVar(value: string) {
return isVarCache.get(value)
}
function never(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}
function escapeUnderscore(value: string): string {
return value
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
.replaceAll(' ', '_') // Replace spaces with underscores
}

View File

@ -1,6 +1,13 @@
import { Polyfills } from '.'
import { optimizeAst, toCss } from './ast'
import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate'
import {
parseCandidate,
parseVariant,
printCandidate,
printVariant,
type Candidate,
type Variant,
} from './candidate'
import { compileAstNodes, compileCandidates } from './compile'
import { substituteFunctions } from './css-functions'
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
@ -29,6 +36,9 @@ export type DesignSystem = {
parseVariant(variant: string): Readonly<Variant> | null
compileAstNodes(candidate: Candidate): ReturnType<typeof compileAstNodes>
printCandidate(candidate: Candidate): string
printVariant(variant: Variant): string
getVariantOrder(): Map<Variant, number>
resolveThemeValue(path: string, forceInline?: boolean): string | undefined
@ -127,6 +137,14 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
compileAstNodes(candidate: Candidate) {
return compiledAstNodes.get(candidate)
},
printCandidate(candidate: Candidate) {
return printCandidate(designSystem, candidate)
},
printVariant(variant: Variant) {
return printVariant(variant)
},
getVariantOrder() {
let variants = Array.from(parsedVariants.values())
variants.sort((a, z) => this.variants.compare(a, z))

View File

@ -1300,6 +1300,7 @@ export function createUtilities(theme: Theme) {
let value
if (candidate.value.kind === 'arbitrary') {
value = candidate.value.value
value = negative ? `calc(${value} * -1)` : value
return [decl('scale', value)]
} else {
value = theme.resolve(candidate.value.value, ['--scale'])