diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cd51e38546..06c698893cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- _Upgrade (experimental)_: Migrate `theme(…)` calls in classes to `var(…)` or to the modern `theme(…)` syntax ([#14664](https://github.com/tailwindlabs/tailwindcss/pull/14664)) + ### Fixed - Ensure `theme` values defined outside of `extend` in JS configuration files overwrite all existing values for that namespace ([#14672](https://github.com/tailwindlabs/tailwindcss/pull/14672)) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts new file mode 100644 index 000000000000..d8b0a238fbd0 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts @@ -0,0 +1,104 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { expect, test } from 'vitest' +import { themeToVar } from './theme-to-var' + +test.each([ + // Keep candidates that don't contain `theme(…)` or `theme(…, …)` + ['[color:red]', '[color:red]'], + + // Convert to `var(…)` if we can resolve the path + ['[color:theme(colors.red.500)]', '[color:var(--color-red-500)]'], // Arbitrary property + ['[color:theme(colors.red.500)]/50', '[color:var(--color-red-500)]/50'], // Arbitrary property + modifier + ['bg-[theme(colors.red.500)]', 'bg-[var(--color-red-500)]'], // Arbitrary value + ['bg-[size:theme(spacing.4)]', 'bg-[size:var(--spacing-4)]'], // Arbitrary value + data type hint + + // Convert to `var(…)` if we can resolve the path, but keep fallback values + ['bg-[theme(colors.red.500,red)]', 'bg-[var(--color-red-500,_red)]'], + + // Keep `theme(…)` if we can't resolve the path + ['bg-[theme(colors.foo.1000)]', 'bg-[theme(colors.foo.1000)]'], + + // Keep `theme(…)` if we can't resolve the path, but still try to convert the + // fallback value. + [ + 'bg-[theme(colors.foo.1000,theme(colors.red.500))]', + 'bg-[theme(colors.foo.1000,var(--color-red-500))]', + ], + + // Use `theme(…)` (deeply nested) inside of a `calc(…)` function + ['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--font-size-xs)_*_2)]'], + + // Multiple `theme(… / …)` calls should result in modern syntax of `theme(…)` + // - Can't convert to `var(…)` because that would lose the modifier. + // - Can't convert to a candidate modifier because there are multiple + // `theme(…)` calls. + // + // If we really want to, we can make a fancy migration that tries to move it + // to a candidate modifier _if_ all `theme(…)` calls use the same modifier. + [ + '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]', + '[color:theme(--color-red-500/50,_theme(--color-blue-500/50))]', + ], + [ + '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]/50', + '[color:theme(--color-red-500/50,_theme(--color-blue-500/50))]/50', + ], + + // Convert the `theme(…)`, but try to move the inline modifier (e.g. `50%`), + // to a candidate modifier. + // Arbitrary property, with simple percentage modifier + ['[color:theme(colors.red.500/75%)]', '[color:var(--color-red-500)]/75'], + + // Arbitrary property, with numbers (0-1) without a unit + ['[color:theme(colors.red.500/.12)]', '[color:var(--color-red-500)]/12'], + ['[color:theme(colors.red.500/0.12)]', '[color:var(--color-red-500)]/12'], + + // Arbitrary property, with more complex modifier (we only allow whole numbers + // as bare modifiers). Convert the complex numbers to arbitrary values instead. + ['[color:theme(colors.red.500/12.34%)]', '[color:var(--color-red-500)]/[12.34%]'], + ['[color:theme(colors.red.500/var(--opacity))]', '[color:var(--color-red-500)]/[var(--opacity)]'], + ['[color:theme(colors.red.500/.12345)]', '[color:var(--color-red-500)]/[12.345]'], + ['[color:theme(colors.red.500/50.25%)]', '[color:var(--color-red-500)]/[50.25%]'], + + // Arbitrary value + ['bg-[theme(colors.red.500/75%)]', 'bg-[var(--color-red-500)]/75'], + ['bg-[theme(colors.red.500/12.34%)]', 'bg-[var(--color-red-500)]/[12.34%]'], + + // Arbitrary property that already contains a modifier + ['[color:theme(colors.red.500/50%)]/50', '[color:theme(--color-red-500/50%)]/50'], + + // Arbitrary value, where the candidate already contains a modifier + // This should still migrate the `theme(…)` syntax to the modern syntax. + ['bg-[theme(colors.red.500/50%)]/50', 'bg-[theme(--color-red-500/50%)]/50'], + + // Variants, we can't use `var(…)` especially inside of `@media(…)`. We can + // still upgrade the `theme(…)` to the modern syntax. + ['max-[theme(spacing.4)]:flex', 'max-[theme(--spacing-4)]:flex'], + + // This test in itself doesn't make much sense. But we need to make sure + // that this doesn't end up as the modifier in the candidate itself. + ['max-[theme(spacing.4/50)]:flex', 'max-[theme(--spacing-4/50)]:flex'], + + // `theme(…)` calls valid in v3, but not in v4 should still be converted. + ['[--foo:theme(fontWeight.semibold)]', '[--foo:theme(fontWeight.semibold)]'], + + // Invalid cases + ['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'], + ['[--foo:theme(colors.red.500/50/50)]/50', '[--foo:theme(colors.red.500/50/50)]/50'], + + // Partially invalid cases + [ + '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]', + '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]', + ], + [ + '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]/50', + '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50', + ], +])('%s => %s', async (candidate, result) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(themeToVar(designSystem, {}, candidate)).toEqual(result) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts new file mode 100644 index 000000000000..7294059cec5e --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts @@ -0,0 +1,290 @@ +import type { Config } from 'tailwindcss' +import { + parseCandidate, + type Candidate, + type CandidateModifier, + type Variant, +} from '../../../../tailwindcss/src/candidate' +import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +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' + +enum Convert { + All = 0, + MigrateModifier = 1 << 0, + MigrateThemeOnly = 1 << 1, +} + +export function themeToVar( + designSystem: DesignSystem, + _userConfig: Config, + rawCandidate: string, +): string { + for (let candidate of parseCandidate(rawCandidate, designSystem)) { + let clone = structuredClone(candidate) + let changed = false + + if (clone.kind === 'arbitrary') { + let [newValue, modifier] = convert( + clone.value, + clone.modifier === null ? Convert.MigrateModifier : Convert.All, + ) + if (newValue !== clone.value) { + changed = true + clone.value = newValue + + if (modifier !== null) { + clone.modifier = modifier + } + } + } else if (clone.kind === 'functional' && clone.value?.kind === 'arbitrary') { + let [newValue, modifier] = convert( + clone.value.value, + clone.modifier === null ? Convert.MigrateModifier : Convert.All, + ) + if (newValue !== clone.value.value) { + changed = true + clone.value.value = newValue + + if (modifier !== null) { + clone.modifier = modifier + } + } + } + + // Handle variants + for (let variant of variants(clone)) { + if (variant.kind === 'arbitrary') { + let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly) + if (newValue !== variant.selector) { + changed = true + variant.selector = newValue + } + } else if (variant.kind === 'functional' && variant.value?.kind === 'arbitrary') { + let [newValue] = convert(variant.value.value, Convert.MigrateThemeOnly) + if (newValue !== variant.value.value) { + changed = true + variant.value.value = newValue + } + } + } + + return changed ? printCandidate(designSystem, clone) : rawCandidate + } + + function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { + let ast = ValueParser.parse(input) + + // In some scenarios (e.g.: variants), we can't migrate to `var(…)` if it + // ends up in the `@media (…)` part. In this case we only have to migrate to + // the new `theme(…)` notation. + if (options & Convert.MigrateThemeOnly) { + return [substituteFunctionsInValue(ast, toTheme), null] + } + + let themeUsageCount = 0 + let themeModifierCount = 0 + + // Analyze AST + ValueParser.walk(ast, (node) => { + if (node.kind !== 'function') return + if (node.value !== 'theme') return + + // We are only interested in the `theme` function + themeUsageCount += 1 + + // Figure out if a modifier is used + ValueParser.walk(node.nodes, (child) => { + // If we see a `,`, it means that we have a fallback value + if (child.kind === 'separator' && child.value.includes(',')) { + return ValueParser.ValueWalkAction.Stop + } + + // If we see a `/`, we have a modifier + else if (child.kind === 'separator' && child.value === '/') { + themeModifierCount += 1 + return ValueParser.ValueWalkAction.Stop + } + + return ValueParser.ValueWalkAction.Skip + }) + }) + + // No `theme(…)` calls, nothing to do + if (themeUsageCount === 0) { + return [input, null] + } + + // No `theme(…)` with modifiers, we can migrate to `var(…)` + if (themeModifierCount === 0) { + return [substituteFunctionsInValue(ast, toVar), null] + } + + // Multiple modifiers which means that there are multiple `theme(…/…)` + // values. In this case, we can't convert the modifier to a candidate + // modifier. + // + // We also can't migrate to `var(…)` because that would lose the modifier. + // + // Try to convert each `theme(…)` call to the modern syntax. + if (themeModifierCount > 1) { + return [substituteFunctionsInValue(ast, toTheme), null] + } + + // Only a single `theme(…)` with a modifier left, that modifier will be + // migrated to a candidate modifier. + let modifier: CandidateModifier | null = null + let result = substituteFunctionsInValue(ast, (path, fallback) => { + let parts = segment(path, '/').map((part) => part.trim()) + + // Multiple `/` separators, which makes this an invalid path + if (parts.length > 2) { + return null + } + + // The path contains a `/`, which means that there is a modifier such as + // `theme(colors.red.500/50%)`. + // + // Currently, we are assuming that this is only being used for colors, + // which means that we can typically convert them to a modifier on the + // candidate itself. + if (parts.length === 2 && options & Convert.MigrateModifier) { + let [pathPart, modifierPart] = parts + + // 50% -> /50 + if (/^\d+%$/.test(modifierPart)) { + modifier = { kind: 'named', value: modifierPart.slice(0, -1) } + } + + // .12 -> /12 + // .12345 -> /[12.345] + else if (/^0?\.\d+$/.test(modifierPart)) { + let value = Number(modifierPart) * 100 + modifier = { + kind: Number.isInteger(value) ? 'named' : 'arbitrary', + value: value.toString(), + } + } + + // Anything else becomes arbitrary + else { + modifier = { kind: 'arbitrary', value: modifierPart } + } + + // Update path to be the first part + path = pathPart + } + + return toVar(path, fallback) || toTheme(path, fallback) + }) + + return [result, modifier] + } + + function pathToVariableName(path: string) { + let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const + if (!designSystem.theme.get([variable])) return null + + return variable + } + + function toVar(path: string, fallback?: string) { + let variable = pathToVariableName(path) + if (!variable) return null + + return fallback ? `var(${variable}, ${fallback})` : `var(${variable})` + } + + function toTheme(path: string, fallback?: string) { + let parts = segment(path, '/').map((part) => part.trim()) + path = parts.shift()! + + let variable = pathToVariableName(path) + if (!variable) return null + + let modifier = parts.length > 0 ? `/${parts.join('/')}` : '' + return fallback ? `theme(${variable}${modifier}, ${fallback})` : `theme(${variable}${modifier})` + } + + return rawCandidate +} + +function substituteFunctionsInValue( + ast: ValueParser.ValueAstNode[], + handle: (value: string, fallback?: string) => string | null, +) { + ValueParser.walk(ast, (node, { replaceWith }) => { + if (node.kind === 'function' && node.value === 'theme') { + if (node.nodes.length < 1) return + + let pathNode = node.nodes[0] + if (pathNode.kind !== 'word') return + + let path = pathNode.value + + // For the theme function arguments, we require all separators to contain + // comma (`,`), spaces alone should be merged into the previous word to + // avoid splitting in this case: + // + // theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar) + // + // We only need to do this for the first node, as the fallback values are + // passed through as-is. + let skipUntilIndex = 1 + for (let i = skipUntilIndex; i < node.nodes.length; i++) { + if (node.nodes[i].value.includes(',')) { + break + } + path += ValueParser.toCss([node.nodes[i]]) + skipUntilIndex = i + 1 + } + + path = eventuallyUnquote(path) + let fallbackValues = node.nodes.slice(skipUntilIndex + 1) + + let replacement = + fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path) + if (replacement === null) return + + replaceWith(ValueParser.parse(replacement)) + } + }) + + return ValueParser.toCss(ast) +} + +function eventuallyUnquote(value: string) { + if (value[0] !== "'" && value[0] !== '"') return value + + let unquoted = '' + let quoteChar = value[0] + for (let i = 1; i < value.length - 1; i++) { + let currentChar = value[i] + let nextChar = value[i + 1] + + if (currentChar === '\\' && (nextChar === quoteChar || nextChar === '\\')) { + unquoted += nextChar + i++ + } else { + unquoted += currentChar + } + } + + 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/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index 3e4c498d5340..c6aa0a11cdef 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -9,6 +9,7 @@ import { bgGradient } from './codemods/bg-gradient' import { important } from './codemods/important' import { prefix } from './codemods/prefix' import { simpleLegacyClasses } from './codemods/simple-legacy-classes' +import { themeToVar } from './codemods/theme-to-var' import { variantOrder } from './codemods/variant-order' import { spliceChangesIntoString, type StringChange } from './splice-changes-into-string' @@ -25,6 +26,7 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ bgGradient, simpleLegacyClasses, arbitraryValueToBareValue, + themeToVar, variantOrder, ] diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index ab72ab6531f5..2bc1473838e6 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -1444,7 +1444,7 @@ test('important: true', async () => { `) }) -test('blocklisted canddiates are not generated', async () => { +test('blocklisted candidates are not generated', async () => { let compiler = await compile( css` @theme reference { @@ -1483,7 +1483,7 @@ test('blocklisted canddiates are not generated', async () => { `) }) -test('blocklisted canddiates cannot be used with `@apply`', async () => { +test('blocklisted candidates cannot be used with `@apply`', async () => { await expect(() => compile( css` diff --git a/packages/tailwindcss/src/value-parser.ts b/packages/tailwindcss/src/value-parser.ts index 852ba6ed02e1..8f41b5d3f8d4 100644 --- a/packages/tailwindcss/src/value-parser.ts +++ b/packages/tailwindcss/src/value-parser.ts @@ -38,7 +38,7 @@ function separator(value: string): ValueSeparatorNode { } } -enum ValueWalkAction { +export enum ValueWalkAction { /** Continue walking, which is the default */ Continue,