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'])