diff --git a/CHANGELOG.md b/CHANGELOG.md
index 101339a9e426..3de214458772 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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))
diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts
index 264bd7ff4c3c..df86c3b6bd61 100644
--- a/integrations/upgrade/index.test.ts
+++ b/integrations/upgrade/index.test.ts
@@ -209,7 +209,7 @@ test(
"
--- ./src/index.html ---
{
"
--- src/index.html ---
--- src/input.css ---
@@ -1439,12 +1439,12 @@ describe('border compatibility', () => {
"
--- src/index.html ---
--- src/input.css ---
diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts
index 0c72cb7be4cb..d0b722c9cf15 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts
@@ -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;
}"
`)
})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts
index ba8a2d0d9852..1054b56eca01 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts
@@ -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])
})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts
index 281b985aebc6..e4f8c3720f0f 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts
@@ -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.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
-}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts
new file mode 100644
index 000000000000..6a4c4166c080
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts
@@ -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)
+ })
+})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts
new file mode 100644
index 000000000000..2f3ee7e434c7
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts
@@ -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>((ds) => {
+ let signatures = computeUtilitySignature.get(ds)
+ let lookup = new DefaultMap(() => [])
+
+ 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>(
+ () => new Map(),
+)
+
+const spacing = new DefaultMap | 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((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
+
+ // 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,
+ ): Generator {
+ // 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
+}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts
index 77e275360027..45ab8bd2abe0 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts
@@ -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,
})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts
index fd26a9a041cb..116888ca39c3 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts
@@ -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-[] as leading-[]
- 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 {
- 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,
+ value: string = candidate.value?.value ?? '',
+ seen: Set = new Set(),
+): Generator {
+ 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()
+
+ // 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)
}
}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts
new file mode 100644
index 000000000000..439082570a4c
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts
@@ -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)
+})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts
new file mode 100644
index 000000000000..d2d78931e8a9
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts
@@ -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) => {
+ let signatures = computeVariantSignature.get(designSystem)
+ let lookup = new DefaultMap(() => [])
+
+ // 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
+
+ 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
+}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts
index 9fd1aaaf7594..62da826d9336 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts
@@ -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
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts
index 2a16524a2b1c..ca81d4f4da1d 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts
@@ -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}`,
})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts
new file mode 100644
index 000000000000..c0a8bda8fcdf
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts
@@ -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)
+ })
+})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts
new file mode 100644
index 000000000000..ae32458ef275
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts
@@ -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
+}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts
index 34b6d4ef2f01..27a663f47acf 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts
@@ -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)
}
}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts
index 784480521082..89afbb21ea79 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts
@@ -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
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts
index 2a231bb3daa7..234907e8a3e0 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts
@@ -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
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts
index f7e04dbf194d..cc9b7e9e260f 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts
@@ -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,
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts
index 842d627f94b1..a4fda26ea29b 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts
@@ -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)
})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts
index c3325f2ced8e..8c75587cdcbc 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts
@@ -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(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()
- }
-
- // 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) {
- changed = true
- memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`))
+ // 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 = ' '
}
- }
+ })
- // 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) {
+ 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
}
}
- 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 | null = null,
- ): Iterable<[Variant, Extract | null]> {
- yield [variant, parent]
-
- if (variant.kind === 'compound') {
- yield* inner(variant.variant, variant)
- }
- }
-
- for (let variant of candidate.variants) {
- yield* inner(variant, null)
- }
-}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts
new file mode 100644
index 000000000000..d4a6e1bd4afc
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts
@@ -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)
+ })
+})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts
new file mode 100644
index 000000000000..b4d5d58e5681
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts
@@ -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` →
+// - `/100` →
+//
+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
+ 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
+}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts
index 3e55dddf06d5..3e014817c6d2 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts
@@ -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()
@@ -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
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts
index c87624a200b5..415ed524c2d1 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts
@@ -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],
})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts
index 6e99d6f9482d..6f7a3ece1fa0 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts
@@ -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 {
- yield variant
- if (variant.kind === 'compound') {
- yield* inner(variant.variant)
- }
- }
-
- for (let variant of candidate.variants) {
- yield* inner(variant)
- }
-}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts
index f9da9ecc28ec..fb5306caa1b4 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts
@@ -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
}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts
new file mode 100644
index 000000000000..45a5a15a9b08
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts
@@ -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)
+ })
+})
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts
index a80cb2499320..029a2a97bd98 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts
@@ -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)
}
}
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts
new file mode 100644
index 000000000000..f15cf35d4711
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts
@@ -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
+>((designSystem) => {
+ return new DefaultMap((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()
+ 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
+>((designSystem) => {
+ return new DefaultMap((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(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
+ }
+ }
+}
diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/@tailwindcss-upgrade/src/utils/dimension.ts
new file mode 100644
index 000000000000..a1dd4bded229
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/utils/dimension.ts
@@ -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 = /^(?-?(?:\d*\.)?\d+)(?[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
+})
diff --git a/packages/@tailwindcss-upgrade/src/utils/replace-object.ts b/packages/@tailwindcss-upgrade/src/utils/replace-object.ts
new file mode 100644
index 000000000000..b4fc993fa809
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/utils/replace-object.ts
@@ -0,0 +1,7 @@
+export function replaceObject(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)
+}
diff --git a/packages/@tailwindcss-upgrade/src/utils/types.ts b/packages/@tailwindcss-upgrade/src/utils/types.ts
new file mode 100644
index 000000000000..f0fca5cf70d0
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/utils/types.ts
@@ -0,0 +1 @@
+export type Writable = T extends Readonly ? U : T
diff --git a/packages/@tailwindcss-upgrade/src/utils/walk-variants.ts b/packages/@tailwindcss-upgrade/src/utils/walk-variants.ts
new file mode 100644
index 000000000000..e0dd16899fa2
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/utils/walk-variants.ts
@@ -0,0 +1,18 @@
+import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
+
+export function* walkVariants(candidate: Candidate) {
+ function* inner(
+ variant: Variant,
+ parent: Extract | null = null,
+ ): Iterable<[Variant, Extract | null]> {
+ yield [variant, parent]
+
+ if (variant.kind === 'compound') {
+ yield* inner(variant.variant, variant)
+ }
+ }
+
+ for (let variant of candidate.variants) {
+ yield* inner(variant, null)
+ }
+}
diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts
index 27a6b6294a3f..aff6d74dc116 100644
--- a/packages/tailwindcss/src/candidate.ts
+++ b/packages/tailwindcss/src/candidate.ts
@@ -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((input) => {
+ let ast = ValueParser.parse(input)
+
+ let drop = new Set()
+
+ 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((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((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
+}
diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts
index e18e882ba092..60fe16ecc8c1 100644
--- a/packages/tailwindcss/src/design-system.ts
+++ b/packages/tailwindcss/src/design-system.ts
@@ -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 | null
compileAstNodes(candidate: Candidate): ReturnType
+ printCandidate(candidate: Candidate): string
+ printVariant(variant: Variant): string
+
getVariantOrder(): Map
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))
diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts
index 1b6abc60b98e..d28a368a43f5 100644
--- a/packages/tailwindcss/src/utilities.ts
+++ b/packages/tailwindcss/src/utilities.ts
@@ -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'])