diff --git a/packages/@tailwindcss-browser/tsup.config.ts b/packages/@tailwindcss-browser/tsup.config.ts index bb0ddc39cde4..10a73f2b7e09 100644 --- a/packages/@tailwindcss-browser/tsup.config.ts +++ b/packages/@tailwindcss-browser/tsup.config.ts @@ -13,4 +13,20 @@ export default defineConfig({ 'process.env.NODE_ENV': '"production"', 'process.env.FEATURES_ENV': '"stable"', }, + esbuildPlugins: [ + { + name: 'patch-intellisense-apis', + setup(build) { + build.onLoad({ filter: /intellisense.ts$/ }, () => { + return { + contents: ` + export function getClassList() { return [] } + export function getVariants() { return [] } + export function canonicalizeCandidates() { return [] } + `, + } + }) + }, + }, + ], }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts index 1054b56eca01..2e4d150aa55a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts @@ -1,5 +1,5 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { describe, expect, test } from 'vitest' +import { expect, test } from 'vitest' import { spliceChangesIntoString } from '../../utils/splice-changes-into-string' import { extractRawCandidates } from './candidates' @@ -82,116 +82,3 @@ test('replaces the right positions for a candidate', async () => { " `) }) - -const candidates = [ - // Arbitrary candidates - ['[color:red]', '[color:red]'], - ['[color:red]/50', '[color:red]/50'], - ['[color:red]/[0.5]', '[color:red]/[0.5]'], - ['[color:red]/50!', '[color:red]/50!'], - ['![color:red]/50', '[color:red]/50!'], - ['[color:red]/[0.5]!', '[color:red]/[0.5]!'], - - // Static candidates - ['box-border', 'box-border'], - ['underline!', 'underline!'], - ['!underline', 'underline!'], - ['-inset-full', '-inset-full'], - - // Functional candidates - ['bg-red-500', 'bg-red-500'], - ['bg-red-500/50', 'bg-red-500/50'], - ['bg-red-500/[0.5]', 'bg-red-500/[0.5]'], - ['bg-red-500!', 'bg-red-500!'], - ['!bg-red-500', 'bg-red-500!'], - ['bg-[#0088cc]/50', 'bg-[#0088cc]/50'], - ['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'], - ['bg-[#0088cc]!', 'bg-[#0088cc]!'], - ['!bg-[#0088cc]', 'bg-[#0088cc]!'], - ['bg-[var(--spacing)-1px]', 'bg-[var(--spacing)-1px]'], - ['bg-[var(--spacing)_-_1px]', 'bg-[var(--spacing)-1px]'], - ['bg-[var(--_spacing)]', 'bg-(--_spacing)'], - ['bg-(--_spacing)', 'bg-(--_spacing)'], - ['bg-[var(--\_spacing)]', 'bg-(--_spacing)'], - ['bg-(--\_spacing)', 'bg-(--_spacing)'], - ['bg-[-1px_-1px]', 'bg-[-1px_-1px]'], - ['p-[round(to-zero,1px)]', 'p-[round(to-zero,1px)]'], - ['w-1/2', 'w-1/2'], - ['p-[calc((100vw-theme(maxWidth.2xl))_/_2)]', 'p-[calc((100vw-theme(maxWidth.2xl))/2)]'], - - // Keep spaces in strings - ['content-["hello_world"]', 'content-["hello_world"]'], - ['content-[____"hello_world"___]', 'content-["hello_world"]'], - - // Do not escape underscores for url() and CSS variable in var() - ['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'], - [ - 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]', - 'bg-(--spacing-0_5,var(--spacing-1_5,3rem))', - ], -] - -const variants = [ - ['', ''], // no variant - ['*:', '*:'], - ['focus:', 'focus:'], - ['group-focus:', 'group-focus:'], - - ['hover:focus:', 'hover:focus:'], - ['hover:group-focus:', 'hover:group-focus:'], - ['group-hover:focus:', 'group-hover:focus:'], - ['group-hover:group-focus:', 'group-hover:group-focus:'], - - ['min-[10px]:', 'min-[10px]:'], - - // Normalize spaces - ['min-[calc(1000px_+_12em)]:', 'min-[calc(1000px+12em)]:'], - ['min-[calc(1000px_+12em)]:', 'min-[calc(1000px+12em)]:'], - ['min-[calc(1000px+_12em)]:', 'min-[calc(1000px+12em)]:'], - ['min-[calc(1000px___+___12em)]:', 'min-[calc(1000px+12em)]:'], - - ['peer-[&_p]:', 'peer-[&_p]:'], - ['peer-[&_p]:hover:', 'peer-[&_p]:hover:'], - ['hover:peer-[&_p]:', 'hover:peer-[&_p]:'], - ['hover:peer-[&_p]:focus:', 'hover:peer-[&_p]:focus:'], - ['peer-[&:hover]:peer-[&_p]:', 'peer-[&:hover]:peer-[&_p]:'], - - ['[p]:', '[p]:'], - ['[_p_]:', '[p]:'], - ['has-[p]:', 'has-[p]:'], - ['has-[_p_]:', 'has-[p]:'], - - // Simplify `&:is(p)` to `p` - ['[&:is(p)]:', '[p]:'], - ['[&:is(_p_)]:', '[p]:'], - ['has-[&:is(p)]:', 'has-[p]:'], - ['has-[&:is(_p_)]:', 'has-[p]:'], - - // Handle special `@` variants. These shouldn't be printed as `@-` - ['@xl:', '@xl:'], - ['@[123px]:', '@[123px]:'], -] - -let combinations: [string, string][] = [] - -for (let [inputVariant, outputVariant] of variants) { - for (let [inputCandidate, outputCandidate] of candidates) { - combinations.push([`${inputVariant}${inputCandidate}`, `${outputVariant}${outputCandidate}`]) - } -} - -describe('printCandidate()', () => { - test.each(combinations)('%s -> %s', async (candidate: string, result: string) => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - let candidates = designSystem.parseCandidate(candidate) - - // 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) => designSystem.printCandidate(c))) - - expect([...cleaned]).toEqual([result]) - }) -}) 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 deleted file mode 100644 index c0dbc7a072a1..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -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 an 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 deleted file mode 100644 index 3007e18deee3..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ /dev/null @@ -1,286 +0,0 @@ -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 * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { dimensions } from '../../utils/dimension' -import type { Writable } from '../../utils/types' -import { baseCandidate, parseCandidate } from './candidates' -import { computeUtilitySignature, preComputedUtilities } from './signatures' - -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 - } - - // 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 = baseCandidate(candidate) - - 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) ?? null - let rootPrefix = '' - if (spacingMultiplier !== null && spacingMultiplier < 0) { - rootPrefix = '-' - spacingMultiplier = Math.abs(spacingMultiplier) - } - - for (let root of Array.from(designSystem.utilities.keys('functional')).sort( - // Sort negative roots after positive roots so that we can try - // `mt-*` before `-mt-*`. This is especially useful in situations where - // `-mt-[0px]` can be translated to `mt-[0px]`. - (a, z) => Number(a[0] === '-') - Number(z[0] === '-'), - )) { - if (rootPrefix) root = `${rootPrefix}${root}` - - // 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 - } - } - } - } - } -} - -// 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 deleted file mode 100644 index 45ab8bd2abe0..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { expect, test } from 'vitest' -import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value' - -test.each([ - ['aspect-[12/34]', 'aspect-12/34'], - ['aspect-[1.2/34]', 'aspect-[1.2/34]'], - ['col-start-[7]', 'col-start-7'], - ['flex-[2]', 'flex-2'], // `flex` is implemented as static and functional utilities - - ['grid-cols-[subgrid]', 'grid-cols-subgrid'], - ['grid-rows-[subgrid]', 'grid-rows-subgrid'], - - // Only 50-200% (inclusive) are valid: - // https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#percentage - ['font-stretch-[50%]', 'font-stretch-50%'], - ['font-stretch-[50.5%]', 'font-stretch-[50.5%]'], - ['font-stretch-[201%]', 'font-stretch-[201%]'], - ['font-stretch-[49%]', 'font-stretch-[49%]'], - // Should stay as-is - ['font-stretch-[1/2]', 'font-stretch-[1/2]'], - - // Bare value with % is valid for these utilities - ['from-[28%]', 'from-28%'], - ['via-[28%]', 'via-28%'], - ['to-[28%]', 'to-28%'], - ['from-[28.5%]', 'from-[28.5%]'], - ['via-[28.5%]', 'via-[28.5%]'], - ['to-[28.5%]', 'to-[28.5%]'], - - // This test in itself is a bit flawed because `text-[1/2]` currently - // generates something. Converting it to `text-1/2` doesn't produce anything. - ['text-[1/2]', 'text-[1/2]'], - - // Leading is special, because `leading-[123]` is the direct value of 123, but - // `leading-123` maps to `calc(--spacing(123))`. - ['leading-[123]', 'leading-[123]'], - - ['data-[selected]:flex', 'data-selected:flex'], - ['data-[foo=bar]:flex', 'data-[foo=bar]:flex'], - - ['supports-[gap]:flex', 'supports-gap:flex'], - ['supports-[display:grid]:flex', 'supports-[display:grid]:flex'], - - ['group-data-[selected]:flex', 'group-data-selected:flex'], - ['group-data-[foo=bar]:flex', 'group-data-[foo=bar]:flex'], - ['group-has-data-[selected]:flex', 'group-has-data-selected:flex'], - - ['aria-[selected]:flex', 'aria-[selected]:flex'], - ['aria-[selected="true"]:flex', 'aria-selected:flex'], - ['aria-[selected*="true"]:flex', 'aria-[selected*="true"]:flex'], - - ['group-aria-[selected]:flex', 'group-aria-[selected]:flex'], - ['group-aria-[selected="true"]:flex', 'group-aria-selected:flex'], - ['group-has-aria-[selected]:flex', 'group-has-aria-[selected]:flex'], - - ['max-lg:hover:data-[selected]:flex!', 'max-lg:hover:data-selected:flex!'], - [ - 'data-[selected]:aria-[selected="true"]:aspect-[12/34]', - 'data-selected:aria-selected:aspect-12/34', - ], -])('%s => %s (%#)', async (candidate, result) => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - expect(migrateArbitraryValueToBareValue(designSystem, {}, candidate)).toEqual(result) -}) 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 deleted file mode 100644 index 116888ca39c3..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts +++ /dev/null @@ -1,180 +0,0 @@ -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, - isValidSpacingMultiplier, -} from '../../../../tailwindcss/src/utils/infer-data-type' -import { segment } from '../../../../tailwindcss/src/utils/segment' -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 - - // 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 - } - } - } - } - - for (let [variant] of walkVariants(clone)) { - // Convert `data-[selected]` to `data-selected` - if ( - variant.kind === 'functional' && - variant.root === 'data' && - variant.value?.kind === 'arbitrary' && - !variant.value.value.includes('=') - ) { - changed = true - variant.value = { - kind: 'named', - value: variant.value.value, - } - } - - // Convert `aria-[selected="true"]` to `aria-selected` - else if ( - variant.kind === 'functional' && - variant.root === 'aria' && - variant.value?.kind === 'arbitrary' && - (variant.value.value.endsWith('=true') || - variant.value.value.endsWith('="true"') || - variant.value.value.endsWith("='true'")) - ) { - let [key, _value] = segment(variant.value.value, '=') - if ( - // aria-[foo~="true"] - key[key.length - 1] === '~' || - // aria-[foo|="true"] - key[key.length - 1] === '|' || - // aria-[foo^="true"] - key[key.length - 1] === '^' || - // aria-[foo$="true"] - key[key.length - 1] === '$' || - // aria-[foo*="true"] - key[key.length - 1] === '*' - ) { - continue - } - - changed = true - variant.value = { - kind: 'named', - value: variant.value.value.slice(0, variant.value.value.indexOf('=')), - } - } - - // Convert `supports-[gap]` to `supports-gap` - else if ( - variant.kind === 'functional' && - variant.root === 'supports' && - variant.value?.kind === 'arbitrary' && - /^[a-z-][a-z0-9-]*$/i.test(variant.value.value) - ) { - changed = true - variant.value = { - kind: 'named', - value: variant.value.value, - } - } - } - - return changed ? designSystem.printCandidate(clone) : rawCandidate - } - - return rawCandidate -} - -// 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, - } - } - - // 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 deleted file mode 100644 index 439082570a4c..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -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 index a210b03d48c0..fb1e9948e963 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -1,9 +1,12 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { replaceObject } from '../../utils/replace-object' -import type { Writable } from '../../utils/types' +import { + computeVariantSignature, + preComputedVariants, +} from '../../../../tailwindcss/src/signatures' +import type { Writable } from '../../../../tailwindcss/src/types' +import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' import { walkVariants } from '../../utils/walk-variants' -import { computeVariantSignature, preComputedVariants } from './signatures' export function migrateArbitraryVariants( designSystem: DesignSystem, diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts deleted file mode 100644 index 2a75b9208a43..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 { migrateBareValueUtilities } from './migrate-bare-utilities' - -const css = String.raw - -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 [migrateBareValueUtilities]) { - 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; - --aspect-video: 16 / 9; - --tab-size-github: 8; - } - - @utility tab-* { - tab-size: --value(--tab-size, integer); - } - ` - - test.each([ - // Built-in utility with bare value fraction - ['aspect-16/9', 'aspect-video'], - - // Custom utility with bare value integer - ['tab-8', 'tab-github'], - ])(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) - }) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts deleted file mode 100644 index 475720191ae6..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { 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 type { Writable } from '../../utils/types' -import { baseCandidate, parseCandidate } from './candidates' -import { computeUtilitySignature, preComputedUtilities } from './signatures' - -const baseReplacementsCache = new DefaultMap>( - () => new Map(), -) - -export function migrateBareValueUtilities( - 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 bare value utilities - if (readonlyCandidate.kind !== 'functional' || readonlyCandidate.value?.kind !== 'named') { - continue - } - - // 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 = baseCandidate(candidate) - - 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 - } - - 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 - } - } - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.test.ts deleted file mode 100644 index df0ca747ba8e..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { expect, test } from 'vitest' -import { migrateBgGradient } from './migrate-bg-gradient' - -test.each([ - ['bg-gradient-to-t', 'bg-linear-to-t'], - ['bg-gradient-to-tr', 'bg-linear-to-tr'], - ['bg-gradient-to-r', 'bg-linear-to-r'], - ['bg-gradient-to-br', 'bg-linear-to-br'], - ['bg-gradient-to-b', 'bg-linear-to-b'], - ['bg-gradient-to-bl', 'bg-linear-to-bl'], - ['bg-gradient-to-l', 'bg-linear-to-l'], - ['bg-gradient-to-tl', 'bg-linear-to-tl'], - - ['max-lg:hover:bg-gradient-to-t', 'max-lg:hover:bg-linear-to-t'], -])('%s => %s', async (candidate, result) => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - expect(migrateBgGradient(designSystem, {}, candidate)).toEqual(result) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts deleted file mode 100644 index ca81d4f4da1d..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' - -const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] - -export function migrateBgGradient( - designSystem: DesignSystem, - _userConfig: Config | null, - rawCandidate: string, -): string { - for (let candidate of designSystem.parseCandidate(rawCandidate)) { - if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) { - let direction = candidate.root.slice(15) - - if (!DIRECTIONS.includes(direction)) { - continue - } - - return designSystem.printCandidate({ - ...candidate, - root: `bg-linear-to-${direction}`, - }) - } - } - return rawCandidate -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts deleted file mode 100644 index 41fc3697b169..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import type { Writable } from '../../utils/types' -import { baseCandidate, parseCandidate, printUnprefixedCandidate } from './candidates' -import { computeUtilitySignature } from './signatures' - -const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) - -export async function migrateDeprecatedUtilities( - designSystem: DesignSystem, - _userConfig: Config | null, - rawCandidate: string, -): Promise { - let signatures = computeUtilitySignature.get(designSystem) - - for (let readonlyCandidate of designSystem.parseCandidate(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 - - // 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 = baseCandidate(candidate) - let targetCandidateString = printUnprefixedCandidate(designSystem, targetCandidate) - - let replacementString = DEPRECATION_MAP.get(targetCandidateString) ?? null - if (replacementString === null) return rawCandidate - - let legacySignature = signatures.get(targetCandidateString) - if (typeof legacySignature !== 'string') return rawCandidate - - let replacementSignature = signatures.get(replacementString) - if (typeof replacementSignature !== 'string') return rawCandidate - - // Not the same signature, not safe to migrate - if (legacySignature !== replacementSignature) return rawCandidate - - let [replacement] = parseCandidate(designSystem, replacementString) - - // Re-add the variants and important flag from the original candidate - return designSystem.printCandidate( - Object.assign(structuredClone(replacement), { - variants: candidate.variants, - important: candidate.important, - }), - ) - } - - return rawCandidate -} 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 deleted file mode 100644 index c0a8bda8fcdf..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index ae32458ef275..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts +++ /dev/null @@ -1,30 +0,0 @@ -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-modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts index a4fda26ea29b..9f26f0188606 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 @@ -9,12 +9,17 @@ import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-v import { migratePrefix } from './migrate-prefix' vi.spyOn(versions, 'isMajor').mockReturnValue(true) +const css = String.raw + function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) { for (let migration of [ migrateEmptyArbitraryValues, migratePrefix, migrateModernizeArbitraryValues, migrateArbitraryVariants, + (designSystem: DesignSystem, _, rawCandidate: string) => { + return designSystem.canonicalizeCandidates([rawCandidate]).pop() ?? rawCandidate + }, ]) { rawCandidate = migration(designSystem, userConfig, rawCandidate) } @@ -22,29 +27,6 @@ function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawC } test.each([ - // Arbitrary variants - ['[[data-visible]]:flex', 'data-visible:flex'], - ['[&[data-visible]]:flex', 'data-visible:flex'], - ['[[data-visible]&]:flex', 'data-visible:flex'], - ['[&>[data-visible]]:flex', '*:data-visible:flex'], - ['[&_>_[data-visible]]:flex', '*:data-visible:flex'], - ['[&>*]:flex', '*:flex'], - ['[&_>_*]:flex', '*:flex'], - - ['[&_[data-visible]]:flex', '**:data-visible:flex'], - ['[&_*]:flex', '**:flex'], - - ['[&:first-child]:flex', 'first:flex'], - ['[&:not(:first-child)]:flex', 'not-first:flex'], - - // in-* variants - ['[p_&]:flex', 'in-[p]:flex'], - ['[.foo_&]:flex', 'in-[.foo]:flex'], - ['[[data-visible]_&]:flex', 'in-data-visible:flex'], - // Multiple selectors, should stay as-is - ['[[data-foo][data-bar]_&]:flex', '[[data-foo][data-bar]_&]:flex'], - // Using `>` instead of ` ` should not be transformed: - ['[figure>&]:my-0', '[figure>&]:my-0'], // Some extreme examples of what happens in the wild: ['group-[]:flex', 'in-[.group]:flex'], ['group-[]/name:flex', 'in-[.group\\/name]:flex'], @@ -59,75 +41,16 @@ test.each([ ['has-group-[]/name:flex', 'has-in-[.group\\/name]:flex'], ['not-group-[]:flex', 'not-in-[.group]:flex'], ['not-group-[]/name:flex', 'not-in-[.group\\/name]:flex'], - - // nth-child - ['[&:nth-child(2)]:flex', 'nth-2:flex'], - ['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'], - - ['[&:nth-child(-n+3)]:flex', 'nth-[-n+3]:flex'], - ['[&:not(:nth-child(-n+3))]:flex', 'not-nth-[-n+3]:flex'], - ['[&:nth-child(-n_+_3)]:flex', 'nth-[-n+3]:flex'], - ['[&:not(:nth-child(-n_+_3))]:flex', 'not-nth-[-n+3]:flex'], - - // nth-last-child - ['[&:nth-last-child(2)]:flex', 'nth-last-2:flex'], - ['[&:not(:nth-last-child(2))]:flex', 'not-nth-last-2:flex'], - - ['[&:nth-last-child(-n+3)]:flex', 'nth-last-[-n+3]:flex'], - ['[&:not(:nth-last-child(-n+3))]:flex', 'not-nth-last-[-n+3]:flex'], - ['[&:nth-last-child(-n_+_3)]:flex', 'nth-last-[-n+3]:flex'], - ['[&:not(:nth-last-child(-n_+_3))]:flex', 'not-nth-last-[-n+3]:flex'], - - // nth-child odd/even - ['[&:nth-child(odd)]:flex', 'odd:flex'], - ['[&:not(:nth-child(odd))]:flex', 'even:flex'], - ['[&:nth-child(even)]:flex', 'even:flex'], - ['[&:not(:nth-child(even))]:flex', 'odd:flex'], - - // 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'], - ['[[data-url$=.com_i]]:flex', 'data-[url$=.com_i]:flex'], - - // Attribute selector wrapped in `&:is(…)` - ['[&:is([data-visible])]:flex', 'data-visible:flex'], - - // Media queries - ['[@media(pointer:fine)]:flex', 'pointer-fine:flex'], - ['[@media_(pointer_:_fine)]:flex', 'pointer-fine:flex'], - ['[@media_not_(pointer_:_fine)]:flex', 'not-pointer-fine:flex'], - ['[@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'], - ['has-[&>[data-visible]]:flex', 'has-[&>[data-visible]]:flex'], - - ['has-[[data-slot=description]]:flex', 'has-data-[slot=description]:flex'], - ['has-[&:is([data-slot=description])]:flex', 'has-data-[slot=description]:flex'], - - ['has-[[aria-visible="true"]]:flex', 'has-aria-visible:flex'], - ['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'], - - ['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'], ])('%s => %s (%#)', async (candidate, result) => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + `, + { base: __dirname }, + ) expect(migrate(designSystem, {}, candidate)).toEqual(result) }) @@ -154,9 +77,15 @@ test.each([ ['not-group-[]:tw-flex', 'tw:not-in-[.tw\\:group]:flex'], ['not-group-[]/name:tw-flex', 'tw:not-in-[.tw\\:group\\/name]:flex'], ])('%s => %s (%#)', async (candidate, result) => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss" prefix(tw);', { - base: __dirname, - }) + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss' prefix(tw); + @theme { + --*: initial; + } + `, + { base: __dirname }, + ) 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 8c75587cdcbc..cafae7aedb3a 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,25 +1,19 @@ -import SelectorParser from 'postcss-selector-parser' -import { parseCandidate, type Variant } from '../../../../tailwindcss/src/candidate' +import { parseCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { replaceObject } from '../../utils/replace-object' +import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' import { walkVariants } from '../../utils/walk-variants' -import { computeVariantSignature } from './signatures' export function migrateModernizeArbitraryValues( designSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, ): string { - let signatures = computeVariantSignature.get(designSystem) - for (let candidate of parseCandidate(rawCandidate, designSystem)) { let clone = structuredClone(candidate) let changed = false - for (let [variant, parent] of walkVariants(clone)) { + for (let [variant] of walkVariants(clone)) { // Forward modifier from the root to the compound variant if ( variant.kind === 'compound' && @@ -69,324 +63,6 @@ export function migrateModernizeArbitraryValues( } continue } - - // Expecting an arbitrary variant - if (variant.kind === 'arbitrary') { - // Expecting a non-relative arbitrary variant - if (variant.relative) continue - - let ast = SelectorParser().astSync(variant.selector) - - // Expecting a single selector node - if (ast.nodes.length !== 1) continue - - // `[&>*]` can be replaced with `*` - if ( - // Only top-level, so `has-[&>*]` is not supported - parent === null && - // [&_>_*]:flex - // ^ ^ ^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === '>' && - ast.nodes[0].nodes[2].type === 'universal' - ) { - changed = true - replaceObject(variant, designSystem.parseVariant('*')) - continue - } - - // `[&_*]` can be replaced with `**` - if ( - // Only top-level, so `has-[&_*]` is not supported - parent === null && - // [&_*]:flex - // ^ ^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === ' ' && - ast.nodes[0].nodes[2].type === 'universal' - ) { - changed = true - replaceObject(variant, designSystem.parseVariant('**')) - continue - } - - // `in-*` variant. If the selector ends with ` &`, we can convert it to an - // `in-*` variant. - // - // E.g.: `[[data-visible]_&]` => `in-data-visible` - if ( - // Only top-level, so `in-[&_[data-visible]]` is not supported - parent === null && - // [[data-visible]___&]:flex - // ^^^^^^^^^^^^^^ ^ ^ - ast.nodes[0].nodes.length === 3 && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === ' ' && - ast.nodes[0].nodes[2].type === 'nesting' - ) { - ast.nodes[0].nodes.pop() // Remove the nesting node - ast.nodes[0].nodes.pop() // Remove the combinator - - changed = true - // When handling a compound like `in-[[data-visible]]`, we will first - // handle `[[data-visible]]`, then the parent `in-*` part. This means - // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. - // - // Later this gets converted to `in-data-visible`. - replaceObject(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) - continue - } - - // Hoist `not` modifier for `@media` or `@supports` variants - // - // E.g.: `[@media_not_(scripting:none)]:` -> `not-[@media_(scripting:none)]:` - if ( - // Only top-level, so something like `in-[@media(scripting:none)]` - // (which is not valid anyway) is not supported - parent === null && - // [@media_not(scripting:none)]:flex - // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ast.nodes[0].nodes[0].type === 'tag' && - (ast.nodes[0].nodes[0].value.startsWith('@media') || - ast.nodes[0].nodes[0].value.startsWith('@supports')) - ) { - let targetSignature = signatures.get(designSystem.printVariant(variant)) - let parsed = ValueParser.parse(ast.nodes[0].toString().trim()) - let containsNot = false - ValueParser.walk(parsed, (node, { replaceWith }) => { - if (node.kind === 'word' && node.value === 'not') { - containsNot = true - replaceWith([]) - } - }) - - // Remove unnecessary whitespace - parsed = ValueParser.parse(ValueParser.toCss(parsed)) - ValueParser.walk(parsed, (node) => { - if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') { - // node.value contains at least 2 spaces. Normalize it to a single - // space. - node.value = ' ' - } - }) - - if (containsNot) { - let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`) - if (hoistedNot === null) continue - let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot)) - if (targetSignature === hoistedNotSignature) { - changed = true - replaceObject(variant, hoistedNot) - continue - } - } - } - - let prefixedVariant: Variant | null = null - - // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` - if ( - // Only top-level, so `has-[&>[data-visible]]` is not supported - parent === null && - // [&_>_[data-visible]]:flex - // ^ ^ ^^^^^^^^^^^^^^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === '>' && - ast.nodes[0].nodes[2].type === 'attribute' - ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[2]] - prefixedVariant = designSystem.parseVariant('*') - } - - // Handling a grand child combinator. E.g.: `[&_[data-visible]]` => `**:data-visible` - if ( - // Only top-level, so `has-[&_[data-visible]]` is not supported - parent === null && - // [&_[data-visible]]:flex - // ^ ^^^^^^^^^^^^^^ - ast.nodes[0].length === 3 && - ast.nodes[0].nodes[0].type === 'nesting' && - ast.nodes[0].nodes[0].value === '&' && - ast.nodes[0].nodes[1].type === 'combinator' && - ast.nodes[0].nodes[1].value === ' ' && - ast.nodes[0].nodes[2].type === 'attribute' - ) { - ast.nodes[0].nodes = [ast.nodes[0].nodes[2]] - prefixedVariant = designSystem.parseVariant('**') - } - - // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` - let selectorNodes = ast.nodes[0].filter((node) => node.type !== 'nesting') - - // Expecting a single selector (normal selector or attribute selector) - if (selectorNodes.length !== 1) continue - - let target = selectorNodes[0] - if (target.type === 'pseudo' && target.value === ':is') { - // Expecting a single selector node - if (target.nodes.length !== 1) continue - - // Expecting a single attribute selector - if (target.nodes[0].nodes.length !== 1) continue - - // Unwrap the selector from inside `&:is(…)` - target = target.nodes[0].nodes[0] - } - - // Expecting a pseudo selector - if (target.type === 'pseudo') { - let targetNode = target - let compoundNot = false - if (target.value === ':not') { - compoundNot = true - if (target.nodes.length !== 1) continue - if (target.nodes[0].type !== 'selector') continue - if (target.nodes[0].nodes.length !== 1) continue - if (target.nodes[0].nodes[0].type !== 'pseudo') continue - - targetNode = target.nodes[0].nodes[0] - } - - let newVariant = ((value) => { - if ( - value === ':nth-child' && - targetNode.nodes.length === 1 && - targetNode.nodes[0].nodes.length === 1 && - targetNode.nodes[0].nodes[0].type === 'tag' && - targetNode.nodes[0].nodes[0].value === 'odd' - ) { - if (compoundNot) { - compoundNot = false - return 'even' - } - return 'odd' - } - if ( - value === ':nth-child' && - targetNode.nodes.length === 1 && - targetNode.nodes[0].nodes.length === 1 && - targetNode.nodes[0].nodes[0].type === 'tag' && - targetNode.nodes[0].nodes[0].value === 'even' - ) { - if (compoundNot) { - compoundNot = false - return 'odd' - } - return 'even' - } - - for (let [selector, variantName] of [ - [':nth-child', 'nth'], - [':nth-last-child', 'nth-last'], - [':nth-of-type', 'nth-of-type'], - [':nth-last-of-type', 'nth-of-last-type'], - ]) { - if (value === selector && targetNode.nodes.length === 1) { - if ( - targetNode.nodes[0].nodes.length === 1 && - targetNode.nodes[0].nodes[0].type === 'tag' && - isPositiveInteger(targetNode.nodes[0].nodes[0].value) - ) { - return `${variantName}-${targetNode.nodes[0].nodes[0].value}` - } - - return `${variantName}-[${targetNode.nodes[0].toString()}]` - } - } - - // Hoist `not` modifier - if (compoundNot) { - let targetSignature = signatures.get(designSystem.printVariant(variant)) - let replacementSignature = signatures.get(`not-[${value}]`) - if (targetSignature === replacementSignature) { - return `[&${value}]` - } - } - - return null - })(targetNode.value) - - if (newVariant === null) continue - - // Add `not-` prefix - if (compoundNot) newVariant = `not-${newVariant}` - - let parsed = designSystem.parseVariant(newVariant) - if (parsed === null) continue - - // Update original variant - changed = true - replaceObject(variant, structuredClone(parsed)) - } - - // Expecting an attribute selector - else if (target.type === 'attribute') { - // Attribute selectors - let attributeKey = target.attribute - let attributeValue = target.value - ? target.quoted - ? `${target.quoteMark}${target.value}${target.quoteMark}` - : target.value - : null - - // Insensitive attribute selectors. E.g.: `[data-foo="value" i]` - // ^ - if (target.insensitive && attributeValue) { - attributeValue += ' i' - } - - let operator = target.operator ?? '=' - - // Migrate `data-*` - if (attributeKey.startsWith('data-')) { - changed = true - attributeKey = attributeKey.slice(5) // Remove `data-` - replaceObject(variant, { - kind: 'functional', - root: 'data', - modifier: null, - value: - attributeValue === null - ? { kind: 'named', value: attributeKey } - : { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, - } satisfies Variant) - } - - // Migrate `aria-*` - else if (attributeKey.startsWith('aria-')) { - changed = true - attributeKey = attributeKey.slice(5) // Remove `aria-` - replaceObject(variant, { - kind: 'functional', - root: 'aria', - modifier: null, - value: - attributeValue === null - ? { kind: 'arbitrary', value: attributeKey } // aria-[foo] - : operator === '=' && target.value === 'true' && !target.insensitive - ? { kind: 'named', value: attributeKey } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] - : { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, // aria-[foo~="true"], aria-[foo|="true"], … - } satisfies Variant) - } - } - - if (prefixedVariant) { - let idx = clone.variants.indexOf(variant) - if (idx === -1) continue - - // Ensure we inject the prefixed variant - clone.variants.splice(idx, 1, variant, prefixedVariant) - } - } } return changed ? designSystem.printCandidate(clone) : rawCandidate 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 deleted file mode 100644 index 3b1459af8702..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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'], - - // Convert 0-1 values to bare values - ['bg-[#f00]/[0.16]', 'bg-[#f00]/16'], - - // 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 deleted file mode 100644 index f07b6680c4a9..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts +++ /dev/null @@ -1,80 +0,0 @@ -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 - } - } - - // 3. Try to remove the square brackets, but multiply by 100. E.g.: `[0.16]` -> `16` - if (!changed) { - let newModifier: NamedUtilityValue = { - kind: 'named', - value: `${parseFloat(modifier.value) * 100}`, - 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-theme-to-var.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.test.ts deleted file mode 100644 index e30109429f29..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { expect, test } from 'vitest' -import { migrateThemeToVar } from './migrate-theme-to-var' - -let css = String.raw - -test.each([ - // Keep candidates that don't contain `theme(…)` or `theme(…, …)` - ['[color:red]', '[color:red]'], - - // Handle special cases around `.1` in the `theme(…)` - ['[--value:theme(spacing.1)]', '[--value:--spacing(1)]'], - ['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'], - ['[--value:theme(spacing[1.25])]', '[--value:--spacing(1.25)]'], - - // Should not convert invalid spacing values to calc - ['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'], - - // Convert to `var(…)` if we can resolve the path - ['[color:theme(colors.red.500)]', '[color:var(--color-red-500)]'], // Arbitrary property - ['[color:theme(colors.red.500)]/50', '[color:var(--color-red-500)]/50'], // Arbitrary property + modifier - ['bg-[theme(colors.red.500)]', 'bg-(--color-red-500)'], // Arbitrary value - ['bg-[size:theme(spacing.4)]', 'bg-[size:--spacing(4)]'], // Arbitrary value + data type hint - - // Pretty print CSS functions preceded by an operator to prevent consecutive - // operator characters. - ['w-[calc(100dvh-theme(spacing.2))]', 'w-[calc(100dvh-(--spacing(2)))]'], - ['w-[calc(100dvh+theme(spacing.2))]', 'w-[calc(100dvh+(--spacing(2)))]'], - ['w-[calc(100dvh/theme(spacing.2))]', 'w-[calc(100dvh/(--spacing(2)))]'], - ['w-[calc(100dvh*theme(spacing.2))]', 'w-[calc(100dvh*(--spacing(2)))]'], - - // Convert to `var(…)` if we can resolve the path, but keep fallback values - ['bg-[theme(colors.red.500,red)]', 'bg-(--color-red-500,red)'], - - // Keep `theme(…)` if we can't resolve the path - ['bg-[theme(colors.foo.1000)]', 'bg-[theme(colors.foo.1000)]'], - - // Keep `theme(…)` if we can't resolve the path, but still try to convert the - // fallback value. - [ - 'bg-[theme(colors.foo.1000,theme(colors.red.500))]', - 'bg-[theme(colors.foo.1000,var(--color-red-500))]', - ], - - // Use `theme(…)` (deeply nested) inside of a `calc(…)` function - ['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--text-xs)*2)]'], - - // Multiple `theme(… / …)` calls should result in modern syntax of `theme(…)` - // - Can't convert to `var(…)` because that would lose the modifier. - // - Can't convert to a candidate modifier because there are multiple - // `theme(…)` calls. - // - // If we really want to, we can make a fancy migration that tries to move it - // to a candidate modifier _if_ all `theme(…)` calls use the same modifier. - [ - '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]', - '[color:--theme(--color-red-500/50,--theme(--color-blue-500/50))]', - ], - [ - '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]/50', - '[color:--theme(--color-red-500/50,--theme(--color-blue-500/50))]/50', - ], - - // Convert the `theme(…)`, but try to move the inline modifier (e.g. `50%`), - // to a candidate modifier. - // Arbitrary property, with simple percentage modifier - ['[color:theme(colors.red.500/75%)]', '[color:var(--color-red-500)]/75'], - - // Arbitrary property, with numbers (0-1) without a unit - ['[color:theme(colors.red.500/.12)]', '[color:var(--color-red-500)]/12'], - ['[color:theme(colors.red.500/0.12)]', '[color:var(--color-red-500)]/12'], - - // Arbitrary property, with more complex modifier (we only allow whole numbers - // as bare modifiers). Convert the complex numbers to arbitrary values instead. - ['[color:theme(colors.red.500/12.34%)]', '[color:var(--color-red-500)]/[12.34%]'], - ['[color:theme(colors.red.500/var(--opacity))]', '[color:var(--color-red-500)]/(--opacity)'], - ['[color:theme(colors.red.500/.12345)]', '[color:var(--color-red-500)]/[12.345]'], - ['[color:theme(colors.red.500/50.25%)]', '[color:var(--color-red-500)]/[50.25%]'], - - // Arbitrary value - ['bg-[theme(colors.red.500/75%)]', 'bg-(--color-red-500)/75'], - ['bg-[theme(colors.red.500/12.34%)]', 'bg-(--color-red-500)/[12.34%]'], - - // Arbitrary property that already contains a modifier - ['[color:theme(colors.red.500/50%)]/50', '[color:--theme(--color-red-500/50%)]/50'], - - // Values that don't contain only `theme(…)` calls should not be converted to - // use a modifier since the color is not the whole value. - [ - 'shadow-[shadow:inset_0px_1px_theme(colors.white/15%)]', - 'shadow-[shadow:inset_0px_1px_--theme(--color-white/15%)]', - ], - - // Arbitrary value, where the candidate already contains a modifier - // This should still migrate the `theme(…)` syntax to the modern syntax. - ['bg-[theme(colors.red.500/50%)]/50', 'bg-[--theme(--color-red-500/50%)]/50'], - - // Variants, we can't use `var(…)` especially inside of `@media(…)`. We can - // still upgrade the `theme(…)` to the modern syntax. - ['max-[theme(screens.lg)]:flex', 'max-[--theme(--breakpoint-lg)]:flex'], - // There are no variables for `--spacing` multiples, so we can't convert this - ['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'], - - // This test in itself doesn't make much sense. But we need to make sure - // that this doesn't end up as the modifier in the candidate itself. - ['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'], - - // `theme(…)` calls in another CSS function is replaced correctly. - // Additionally we remove unnecessary whitespace. - ['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,--spacing(80))_auto]'], - - // `theme(…)` calls valid in v3, but not in v4 should still be converted. - ['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'], - - // Renamed theme keys - ['max-w-[theme(screens.md)]', 'max-w-(--breakpoint-md)'], - ['w-[theme(maxWidth.md)]', 'w-(--container-md)'], - - // Invalid cases - ['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'], - ['[--foo:theme(colors.red.500/50/50)]/50', '[--foo:theme(colors.red.500/50/50)]/50'], - - // Partially invalid cases - [ - '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]', - '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]', - ], - [ - '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]/50', - '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50', - ], -])('%s => %s', async (candidate, result) => { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - `, - { - base: __dirname, - }, - ) - - expect(migrateThemeToVar(designSystem, {}, candidate)).toEqual(result) -}) - -test('extended space scale converts to var or calc', async () => { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - @theme { - --spacing-2: 2px; - --spacing-miami: 0.875rem; - } - `, - { - base: __dirname, - }, - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual( - '[--value:--spacing(1)]', - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual( - '[--value:var(--spacing-2)]', - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.miami)]')).toEqual( - '[--value:var(--spacing-miami)]', - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.nyc)]')).toEqual( - '[--value:theme(spacing.nyc)]', - ) -}) - -test('custom space scale converts to var', async () => { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - @theme { - --spacing-*: initial; - --spacing-1: 0.25rem; - --spacing-2: 0.5rem; - } - `, - { - base: __dirname, - }, - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual( - '[--value:var(--spacing-1)]', - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual( - '[--value:var(--spacing-2)]', - ) - expect(migrateThemeToVar(designSystem, {}, '[--value:theme(spacing.3)]')).toEqual( - '[--value:theme(spacing.3)]', - ) -}) 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 d6d862d6242a..aa3a5bb6f67b 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,12 +1,10 @@ -import { parseCandidate, type CandidateModifier } from '../../../../tailwindcss/src/candidate' +import { 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' import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' 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 { walkVariants } from '../../utils/walk-variants' export const enum Convert { All = 0, @@ -14,68 +12,6 @@ export const enum Convert { MigrateThemeOnly = 1 << 1, } -export function migrateThemeToVar( - designSystem: DesignSystem, - _userConfig: Config | null, - rawCandidate: string, -): string { - let convert = createConverter(designSystem) - - for (let candidate of parseCandidate(rawCandidate, designSystem)) { - let clone = structuredClone(candidate) - let changed = false - - if (clone.kind === 'arbitrary') { - let [newValue, modifier] = convert( - clone.value, - clone.modifier === null ? Convert.MigrateModifier : Convert.All, - ) - if (newValue !== clone.value) { - changed = true - clone.value = newValue - - if (modifier !== null) { - clone.modifier = modifier - } - } - } else if (clone.kind === 'functional' && clone.value?.kind === 'arbitrary') { - let [newValue, modifier] = convert( - clone.value.value, - clone.modifier === null ? Convert.MigrateModifier : Convert.All, - ) - if (newValue !== clone.value.value) { - changed = true - clone.value.value = newValue - - if (modifier !== null) { - clone.modifier = modifier - } - } - } - - // Handle variants - for (let [variant] of walkVariants(clone)) { - if (variant.kind === 'arbitrary') { - let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly) - if (newValue !== variant.selector) { - changed = true - variant.selector = newValue - } - } else if (variant.kind === 'functional' && variant.value?.kind === 'arbitrary') { - let [newValue] = convert(variant.value.value, Convert.MigrateThemeOnly) - if (newValue !== variant.value.value) { - changed = true - variant.value.value = newValue - } - } - } - - return changed ? designSystem.printCandidate(clone) : rawCandidate - } - - return rawCandidate -} - export function createConverter(designSystem: DesignSystem, { prettyPrint = false } = {}) { function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { let ast = ValueParser.parse(input) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 4763577c71c6..d402847fc0e9 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -2,31 +2,22 @@ import fs from 'node:fs/promises' import path, { extname } from 'node:path' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' import { extractRawCandidates } from './candidates' import { isSafeMigration } from './is-safe-migration' -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 { migrateBareValueUtilities } from './migrate-bare-utilities' -import { migrateBgGradient } from './migrate-bg-gradient' import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value' import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' -import { migrateDeprecatedUtilities } from './migrate-deprecated-utilities' -import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types' import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values' 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' import { migrateVariantOrder } from './migrate-variant-order' -import { computeUtilitySignature } from './signatures' export type Migration = ( designSystem: DesignSystem, @@ -38,23 +29,14 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateEmptyArbitraryValues, migratePrefix, migrateCanonicalizeCandidate, - migrateBgGradient, migrateSimpleLegacyClasses, migrateCamelcaseInNamedValue, migrateLegacyClasses, migrateMaxWidthScreen, - migrateThemeToVar, migrateVariantOrder, // Has to happen before migrations that modify variants migrateAutomaticVarInjection, migrateLegacyArbitraryValues, - migrateArbitraryUtilities, - migrateBareValueUtilities, - migrateDeprecatedUtilities, migrateModernizeArbitraryValues, - migrateArbitraryVariants, - migrateDropUnnecessaryDataTypes, - migrateArbitraryValueToBareValue, - migrateOptimizeModifier, ] let migrateCached = new DefaultMap< @@ -69,6 +51,9 @@ let migrateCached = new DefaultMap< rawCandidate = await migration(designSystem, userConfig, rawCandidate) } + // Canonicalize the final migrated candidate to its final form + rawCandidate = designSystem.canonicalizeCandidates([rawCandidate]).pop()! + // Verify that the candidate actually makes sense at all. E.g.: `duration` // is not a valid candidate, but it will parse because `duration-` // exists. diff --git a/packages/tailwindcss/src/attribute-selector-parser.bench.ts b/packages/tailwindcss/src/attribute-selector-parser.bench.ts new file mode 100644 index 000000000000..2d6e8959059c --- /dev/null +++ b/packages/tailwindcss/src/attribute-selector-parser.bench.ts @@ -0,0 +1,35 @@ +import { bench, describe } from 'vitest' +import * as AttributeSelectorParser from './attribute-selector-parser' + +let examples = [ + '[open]', + '[data-foo]', + '[data-state=expanded]', + '[data-state = expanded ]', + '[data-state*="expanded"]', + '[data-state*="expanded"i]', + '[data-state*=expanded i]', +] + +const ATTRIBUTE_REGEX = + /\[\s*(?[a-zA-Z_-][a-zA-Z0-9_-]*)\s*((?[*|~^$]?=)\s*(?['"])?\s*(?.*?)\4\s*(?[is])?\s*)?\]/ + +describe('parsing', () => { + bench('AttributeSelectorParser.parse', () => { + for (let example of examples) { + AttributeSelectorParser.parse(example) + } + }) + + bench('REGEX.test(…)', () => { + for (let example of examples) { + ATTRIBUTE_REGEX.exec(example) + } + }) + + bench('….match(REGEX)', () => { + for (let example of examples) { + example.match(ATTRIBUTE_REGEX) + } + }) +}) diff --git a/packages/tailwindcss/src/attribute-selector-parser.test.ts b/packages/tailwindcss/src/attribute-selector-parser.test.ts new file mode 100644 index 000000000000..140336ab0b90 --- /dev/null +++ b/packages/tailwindcss/src/attribute-selector-parser.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { parse } from './attribute-selector-parser' + +describe('parse', () => { + it.each([ + [''], + [']'], + ['[]'], + ['['], + ['="value"'], + ['data-foo]'], + ['[data-foo'], + ['[data-foo="foo]'], + ['[data-foo * = foo]'], + ['[data-foo*=]'], + ['[data-foo=value x]'], + ['[data-foo=value ix]'], + ])('should parse an invalid attribute selector (%s) as `null`', (input) => { + expect(parse(input)).toBeNull() + }) + + it.each([ + [ + '[data-foo]', + { attribute: 'data-foo', operator: null, quote: null, value: null, sensitivity: null }, + ], + [ + '[ data-foo ]', + { attribute: 'data-foo', operator: null, quote: null, value: null, sensitivity: null }, + ], + [ + '[data-state=expanded]', + { attribute: 'data-state', operator: '=', quote: null, value: 'expanded', sensitivity: null }, + ], + [ + '[data-state = expanded ]', + { attribute: 'data-state', operator: '=', quote: null, value: 'expanded', sensitivity: null }, + ], + [ + '[data-state*="expanded"]', + { attribute: 'data-state', operator: '*=', quote: '"', value: 'expanded', sensitivity: null }, + ], + [ + '[data-state*="expanded"i]', + { attribute: 'data-state', operator: '*=', quote: '"', value: 'expanded', sensitivity: 'i' }, + ], + [ + '[data-state*=expanded i]', + { attribute: 'data-state', operator: '*=', quote: null, value: 'expanded', sensitivity: 'i' }, + ], + ])('should parse correctly: %s', (selector, expected) => { + expect(parse(selector)).toEqual(expected) + }) + + it('should work with a real-world example', () => { + expect(parse('[data-url$=".com"i]')).toEqual({ + attribute: 'data-url', + operator: '$=', + quote: '"', + value: '.com', + sensitivity: 'i', + }) + }) +}) diff --git a/packages/tailwindcss/src/attribute-selector-parser.ts b/packages/tailwindcss/src/attribute-selector-parser.ts new file mode 100644 index 000000000000..e732b29795b0 --- /dev/null +++ b/packages/tailwindcss/src/attribute-selector-parser.ts @@ -0,0 +1,229 @@ +const TAB = 9 +const LINE_BREAK = 10 +const CARRIAGE_RETURN = 13 +const SPACE = 32 +const DOUBLE_QUOTE = 34 +const DOLLAR = 36 +const SINGLE_QUOTE = 39 +const ASTERISK = 42 +const EQUALS = 61 +const UPPER_I = 73 +const UPPER_S = 83 +const BACKSLASH = 92 +const CARET = 94 +const LOWER_I = 105 +const LOWER_S = 115 +const PIPE = 124 +const TILDE = 126 +const LOWER_A = 97 +const LOWER_Z = 122 +const UPPER_A = 65 +const UPPER_Z = 90 +const ZERO = 48 +const NINE = 57 +const DASH = 45 +const UNDERSCORE = 95 + +interface AttributeSelector { + attribute: string + operator: '=' | '~=' | '|=' | '^=' | '$=' | '*=' | null + quote: '"' | "'" | null + value: string | null + sensitivity: 'i' | 's' | null +} + +export function parse(input: string): AttributeSelector | null { + // Must start with `[` and end with `]` + if (input[0] !== '[' || input[input.length - 1] !== ']') { + return null + } + + let i = 1 + let start = i + let end = input.length - 1 + + // Skip whitespace, e.g.: [ data-foo] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // Attribute name, e.g.: [data-foo] + // ^^^^^^^^ + { + start = i + for (; i < end; i++) { + let currentChar = input.charCodeAt(i) + // Skip escaped character + if (currentChar === BACKSLASH) { + i++ + continue + } + if (currentChar >= UPPER_A && currentChar <= UPPER_Z) continue + if (currentChar >= LOWER_A && currentChar <= LOWER_Z) continue + if (currentChar >= ZERO && currentChar <= NINE) continue + if (currentChar === DASH || currentChar === UNDERSCORE) continue + break + } + + // Must have at least one character in the attribute name + if (start === i) { + return null + } + } + let attribute = input.slice(start, i) + + // Skip whitespace, e.g.: [data-foo =value] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // At the end, e.g.: `[data-foo]` + if (i === end) { + return { + attribute, + operator: null, + quote: null, + value: null, + sensitivity: null, + } + } + + // Operator, e.g.: [data-foo*=value] + // ^^ + let operator = null + let currentChar = input.charCodeAt(i) + if (currentChar === EQUALS) { + operator = '=' + i++ + } else if ( + (currentChar === TILDE || + currentChar === PIPE || + currentChar === CARET || + currentChar === DOLLAR || + currentChar === ASTERISK) && + input.charCodeAt(i + 1) === EQUALS + ) { + operator = input[i] + '=' + i += 2 + } else { + return null // Invalid operator + } + + // Skip whitespace, e.g.: [data-foo*= value] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // At the end, that means that we have an operator but no valid, which is + // invalid, e.g.: `[data-foo*=]` + if (i === end) { + return null + } + + // Value, e.g.: [data-foo*=value] + // ^^^^^ + let value = '' + + // Quoted value, e.g.: [data-foo*="value"] + // ^^^^^^^ + let quote = null + currentChar = input.charCodeAt(i) + if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { + quote = input[i] as '"' | "'" + i++ + + start = i + for (let j = i; j < end; j++) { + let current = input.charCodeAt(j) + // Found ending quote + if (current === currentChar) { + i = j + 1 + } + + // Skip escaped character + else if (current === BACKSLASH) { + j++ + } + } + + value = input.slice(start, i - 1) + } + + // Unquoted value, e.g.: [data-foo*=value] + // ^^^^^ + else { + start = i + // Keep going until we find whitespace or the end + while (i < end && !isAsciiWhitespace(input.charCodeAt(i))) i++ + value = input.slice(start, i) + } + + // Skip whitespace, e.g.: [data-foo*=value ] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // At the end, e.g.: `[data-foo=value]` + if (i === end) { + return { + attribute, + operator: operator as '=' | '~=' | '|=' | '^=' | '$=' | '*=', + quote: quote as '"' | "'" | null, + value, + sensitivity: null, + } + } + + // Sensitivity, e.g.: [data-foo=value i] + // ^ + let sensitivity = null + { + switch (input.charCodeAt(i)) { + case LOWER_I: + case UPPER_I: { + sensitivity = 'i' + i++ + break + } + + case LOWER_S: + case UPPER_S: { + sensitivity = 's' + i++ + break + } + + default: + return null // Invalid sensitivity + } + } + + // Skip whitespace, e.g.: [data-foo=value i ] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // We must be at the end now, if not, then there is an additional character + // after the sensitivity which is invalid, e.g.: [data-foo=value iX] + // ^ + if (i !== end) { + return null + } + + // Fully done + return { + attribute, + operator: operator as '=' | '~=' | '|=' | '^=' | '$=' | '*=', + quote: quote as '"' | "'" | null, + value, + sensitivity: sensitivity as 'i' | 's' | null, + } +} + +function isAsciiWhitespace(code: number): boolean { + switch (code) { + case SPACE: + case TAB: + case LINE_BREAK: + case CARRIAGE_RETURN: + return true + + default: + return false + } +} diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 4355071f2aac..dfc65ac73c26 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -1,4 +1,5 @@ -import { expect, it } from 'vitest' +import { describe, expect, it, test } from 'vitest' +import { __unstable__loadDesignSystem } from '.' import { buildDesignSystem } from './design-system' import { Theme } from './theme' import { Utilities } from './utilities' @@ -1999,3 +2000,126 @@ it.each([ expect(run(rawCandidate, { utilities, variants })).toEqual([]) }) + +const candidates = [ + // Arbitrary candidates + ['[color:red]', '[color:red]'], + ['[color:red]/50', '[color:red]/50'], + ['[color:red]/[0.5]', '[color:red]/[0.5]'], + ['[color:red]/50!', '[color:red]/50!'], + ['![color:red]/50', '[color:red]/50!'], + ['[color:red]/[0.5]!', '[color:red]/[0.5]!'], + + // Static candidates + ['box-border', 'box-border'], + ['underline!', 'underline!'], + ['!underline', 'underline!'], + ['-inset-full', '-inset-full'], + + // Functional candidates + ['bg-red-500', 'bg-red-500'], + ['bg-red-500/50', 'bg-red-500/50'], + ['bg-red-500/[0.5]', 'bg-red-500/[0.5]'], + ['bg-red-500!', 'bg-red-500!'], + ['!bg-red-500', 'bg-red-500!'], + ['bg-[#0088cc]/50', 'bg-[#0088cc]/50'], + ['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'], + ['bg-[#0088cc]!', 'bg-[#0088cc]!'], + ['!bg-[#0088cc]', 'bg-[#0088cc]!'], + ['bg-[var(--spacing)-1px]', 'bg-[var(--spacing)-1px]'], + ['bg-[var(--spacing)_-_1px]', 'bg-[var(--spacing)-1px]'], + ['bg-[var(--_spacing)]', 'bg-(--_spacing)'], + ['bg-(--_spacing)', 'bg-(--_spacing)'], + ['bg-[var(--\_spacing)]', 'bg-(--_spacing)'], + ['bg-(--\_spacing)', 'bg-(--_spacing)'], + ['bg-[-1px_-1px]', 'bg-[-1px_-1px]'], + ['p-[round(to-zero,1px)]', 'p-[round(to-zero,1px)]'], + ['w-1/2', 'w-1/2'], + ['p-[calc((100vw-theme(maxWidth.2xl))_/_2)]', 'p-[calc((100vw-theme(maxWidth.2xl))/2)]'], + + // Keep spaces in strings + ['content-["hello_world"]', 'content-["hello_world"]'], + ['content-[____"hello_world"___]', 'content-["hello_world"]'], + + // Do not escape underscores for url() and CSS variable in var() + ['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'], + [ + 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]', + 'bg-(--spacing-0_5,var(--spacing-1_5,3rem))', + ], + + // Normalize whitespace in arbitrary properties + ['[display:flex]', '[display:flex]'], + ['[display:_flex]', '[display:flex]'], + ['[display:flex_]', '[display:flex]'], + ['[display:_flex_]', '[display:flex]'], + + // Normalize whitespace in `calc` expressions + ['w-[calc(100%-2rem)]', 'w-[calc(100%-2rem)]'], + ['w-[calc(100%_-_2rem)]', 'w-[calc(100%-2rem)]'], + + // Normalize the important modifier + ['!flex', 'flex!'], + ['flex!', 'flex!'], +] + +const variants = [ + ['', ''], // no variant + ['*:', '*:'], + ['focus:', 'focus:'], + ['group-focus:', 'group-focus:'], + + ['hover:focus:', 'hover:focus:'], + ['hover:group-focus:', 'hover:group-focus:'], + ['group-hover:focus:', 'group-hover:focus:'], + ['group-hover:group-focus:', 'group-hover:group-focus:'], + + ['min-[10px]:', 'min-[10px]:'], + + // Normalize spaces + ['min-[calc(1000px_+_12em)]:', 'min-[calc(1000px+12em)]:'], + ['min-[calc(1000px_+12em)]:', 'min-[calc(1000px+12em)]:'], + ['min-[calc(1000px+_12em)]:', 'min-[calc(1000px+12em)]:'], + ['min-[calc(1000px___+___12em)]:', 'min-[calc(1000px+12em)]:'], + + ['peer-[&_p]:', 'peer-[&_p]:'], + ['peer-[&_p]:hover:', 'peer-[&_p]:hover:'], + ['hover:peer-[&_p]:', 'hover:peer-[&_p]:'], + ['hover:peer-[&_p]:focus:', 'hover:peer-[&_p]:focus:'], + ['peer-[&:hover]:peer-[&_p]:', 'peer-[&:hover]:peer-[&_p]:'], + + ['[p]:', '[p]:'], + ['[_p_]:', '[p]:'], + ['has-[p]:', 'has-[p]:'], + ['has-[_p_]:', 'has-[p]:'], + + // Simplify `&:is(p)` to `p` + ['[&:is(p)]:', '[p]:'], + ['[&:is(_p_)]:', '[p]:'], + ['has-[&:is(p)]:', 'has-[p]:'], + ['has-[&:is(_p_)]:', 'has-[p]:'], + + // Handle special `@` variants. These shouldn't be printed as `@-` + ['@xl:', '@xl:'], + ['@[123px]:', '@[123px]:'], +] + +let combinations: [string, string][] = [] + +for (let [inputVariant, outputVariant] of variants) { + for (let [inputCandidate, outputCandidate] of candidates) { + combinations.push([`${inputVariant}${inputCandidate}`, `${outputVariant}${outputCandidate}`]) + } +} + +describe('normalize candidates', () => { + test.each(combinations)('`%s` -> `%s`', async (candidate: string, result: string) => { + let designSystem = await __unstable__loadDesignSystem('@tailwind utilities', { + base: __dirname, + }) + + let [parsed] = designSystem.parseCandidate(candidate) + + expect(designSystem.printCandidate(parsed)).toEqual(result) + }) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts new file mode 100644 index 000000000000..811de77f5dc0 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -0,0 +1,911 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, test } from 'vitest' +import { __unstable__loadDesignSystem } from '.' +import { DefaultMap } from './utils/default-map' + +const css = String.raw +const defaultTheme = fs.readFileSync(path.resolve(__dirname, '../theme.css'), 'utf8') + +const designSystems = new DefaultMap((base: string) => { + return new DefaultMap((input: string) => { + return __unstable__loadDesignSystem(input, { + base, + async loadStylesheet() { + return { + path: '', + base: '', + content: css` + @tailwind utilities; + + ${defaultTheme} + `, + } + }, + }) + }) +}) + +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') + } + + function prepare(candidate: string) { + if (strategy === 'with-variant') { + candidate = `focus:${candidate}` + } else if (strategy === 'important') { + candidate = `${candidate}!` + } else if (strategy === 'prefix') { + candidate = `tw:${candidate}` + + // Prefix all known CSS variables with `--tw-`, except when used inside of `--theme(…)`. + if (candidate.includes('--')) { + candidate = candidate + .replace( + // Replace the variable, as long as it is preceded by a `(`, e.g.: + // `bg-(--foo)` or an `:` in case of `bg-(color:--foo)`. + // + // It also has to end in a `,` or `)` to prevent replacing functions + // that look like variables, e.g.: `--spacing(…)` + /([(:])--([\w-]+)([,)])/g, + (_, start, variable, end) => `${start}--tw-${variable}${end}`, + ) + .replaceAll('--theme(--tw-', '--theme(--') + } + } + + return candidate + } + + async function expectCanonicalization(input: string, candidate: string, expected: string) { + candidate = prepare(candidate) + expected = prepare(expected) + + if (strategy === 'prefix') { + input = input.replace("@import 'tailwindcss';", "@import 'tailwindcss' prefix(tw);") + } + + let designSystem = await designSystems.get(__dirname).get(input) + let [actual] = designSystem.canonicalizeCandidates([candidate]) + + try { + expect(actual).toBe(expected) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, expectCanonicalization) + throw err + } + } + + /// ---------------------------------- + + test.each([ + /// Legacy bg-gradient-* → bg-linear-* + ['bg-gradient-to-t', 'bg-linear-to-t'], + ['bg-gradient-to-tr', 'bg-linear-to-tr'], + ['bg-gradient-to-r', 'bg-linear-to-r'], + ['bg-gradient-to-br', 'bg-linear-to-br'], + ['bg-gradient-to-b', 'bg-linear-to-b'], + ['bg-gradient-to-bl', 'bg-linear-to-bl'], + ['bg-gradient-to-l', 'bg-linear-to-l'], + ['bg-gradient-to-tl', 'bg-linear-to-tl'], + + /// theme(…) to `var(…)` + // Keep candidates that don't contain `theme(…)` or `theme(…, …)` + ['[color:red]', 'text-[red]'], + + // Handle special cases around `.1` in the `theme(…)` + ['[--value:theme(spacing.1)]', '[--value:--spacing(1)]'], + ['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'], + ['[--value:theme(spacing[1.25])]', '[--value:--spacing(1.25)]'], + + // Should not convert invalid spacing values to calc + ['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'], + + // Convert to `var(…)` if we can resolve the path + ['[color:theme(colors.red.500)]', 'text-red-500'], // Arbitrary property + ['[color:theme(colors.red.500)]/50', 'text-red-500/50'], // Arbitrary property + modifier + ['bg-[theme(colors.red.500)]', 'bg-red-500'], // Arbitrary value + ['bg-[size:theme(spacing.4)]', 'bg-size-[--spacing(4)]'], // Arbitrary value + data type hint + + // Pretty print CSS functions preceded by an operator to prevent consecutive + // operator characters. + ['w-[calc(100dvh-theme(spacing.2))]', 'w-[calc(100dvh-(--spacing(2)))]'], + ['w-[calc(100dvh+theme(spacing.2))]', 'w-[calc(100dvh+(--spacing(2)))]'], + ['w-[calc(100dvh/theme(spacing.2))]', 'w-[calc(100dvh/(--spacing(2)))]'], + ['w-[calc(100dvh*theme(spacing.2))]', 'w-[calc(100dvh*(--spacing(2)))]'], + + // Convert to `var(…)` if we can resolve the path, but keep fallback values + ['bg-[theme(colors.red.500,red)]', 'bg-(--color-red-500,red)'], + + // Keep `theme(…)` if we can't resolve the path + ['bg-[theme(colors.foo.1000)]', 'bg-[theme(colors.foo.1000)]'], + + // Keep `theme(…)` if we can't resolve the path, but still try to convert the + // fallback value. + ['bg-[theme(colors.foo.1000,theme(colors.red.500))]', 'bg-red-500'], + + // Use `theme(…)` (deeply nested) inside of a `calc(…)` function + ['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--text-xs)*2)]'], + + // Multiple `theme(… / …)` calls should result in modern syntax of `theme(…)` + // - Can't convert to `var(…)` because that would lose the modifier. + // - Can't convert to a candidate modifier because there are multiple + // `theme(…)` calls. + // + // If we really want to, we can make a fancy migration that tries to move it + // to a candidate modifier _if_ all `theme(…)` calls use the same modifier. + [ + '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]', + 'text-[--theme(--color-red-500/50,--theme(--color-blue-500/50))]', + ], + [ + '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]/50', + 'text-[--theme(--color-red-500/50,--theme(--color-blue-500/50))]/50', + ], + + // Convert the `theme(…)`, but try to move the inline modifier (e.g. `50%`), + // to a candidate modifier. + // Arbitrary property, with simple percentage modifier + ['[color:theme(colors.red.500/75%)]', 'text-red-500/75'], + + // Arbitrary property, with numbers (0-1) without a unit + ['[color:theme(colors.red.500/.12)]', 'text-red-500/12'], + ['[color:theme(colors.red.500/0.12)]', 'text-red-500/12'], + + // Arbitrary property, with more complex modifier (we only allow whole numbers + // as bare modifiers). Convert the complex numbers to arbitrary values instead. + ['[color:theme(colors.red.500/12.34%)]', 'text-red-500/[12.34%]'], + ['[color:theme(colors.red.500/var(--opacity))]', 'text-red-500/(--opacity)'], + ['[color:theme(colors.red.500/.12345)]', 'text-red-500/1234.5'], + ['[color:theme(colors.red.500/50.25%)]', 'text-red-500/50.25'], + + // Arbitrary value + ['bg-[theme(colors.red.500/75%)]', 'bg-red-500/75'], + ['bg-[theme(colors.red.500/12.34%)]', 'bg-red-500/[12.34%]'], + + // Arbitrary property that already contains a modifier + ['[color:theme(colors.red.500/50%)]/50', 'text-[--theme(--color-red-500/50%)]/50'], + + // Values that don't contain only `theme(…)` calls should not be converted to + // use a modifier since the color is not the whole value. + [ + 'shadow-[shadow:inset_0px_1px_theme(colors.white/15%)]', + 'shadow-[inset_0px_1px_--theme(--color-white/15%)]', + ], + + // Arbitrary value, where the candidate already contains a modifier + // This should still migrate the `theme(…)` syntax to the modern syntax. + ['bg-[theme(colors.red.500/50%)]/50', 'bg-[--theme(--color-red-500/50%)]/50'], + + // Variants, we can't use `var(…)` especially inside of `@media(…)`. We can + // still upgrade the `theme(…)` to the modern syntax. + ['max-[theme(screens.lg)]:flex', 'max-[--theme(--breakpoint-lg)]:flex'], + // There are no variables for `--spacing` multiples, so we can't convert this + ['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'], + + // This test in itself doesn't make much sense. But we need to make sure + // that this doesn't end up as the modifier in the candidate itself. + ['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'], + + // `theme(…)` calls in another CSS function is replaced correctly. + // Additionally we remove unnecessary whitespace. + ['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,--spacing(80))_auto]'], + + // `theme(…)` calls valid in v3, but not in v4 should still be converted. + ['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'], + + // Renamed theme keys + ['max-w-[theme(screens.md)]', 'max-w-(--breakpoint-md)'], + ['w-[theme(maxWidth.md)]', 'w-md'], + + // Invalid cases + ['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'], + ['[--foo:theme(colors.red.500/50/50)]/50', '[--foo:theme(colors.red.500/50/50)]/50'], + + // Partially invalid cases + [ + '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]', + '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]', + ], + [ + '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]/50', + '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50', + ], + ])(testName, async (candidate, expected) => { + await expectCanonicalization( + css` + @import 'tailwindcss'; + `, + candidate, + expected, + ) + }) + + describe('arbitrary utilities', () => { + 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, expected) => { + let input = css` + @import 'tailwindcss'; + + @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); + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('migrate with custom static utility `@utility custom {…}`', async () => { + let candidate = '[--key:value]' + let expected = 'custom' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility custom { + --key: value; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('migrate with custom functional utility `@utility custom-* {…}`', async () => { + let candidate = '[--key:value]' + let expected = 'custom-value' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility custom-* { + --key: --value('value'); + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('migrate with custom functional utility `@utility custom-* {…}` that supports bare values', async () => { + let candidate = '[tab-size:4]' + let expected = 'tab-4' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility tab-* { + tab-size: --value(integer); + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + 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]); + } + ` + + await expectCanonicalization(input, candidate, expected) + }, + ) + + describe.each([['@theme'], ['@theme inline']])('%s', (theme) => { + test.each([ + ['[color:CanvasText]', 'text-canvas'], + ['text-[CanvasText]', 'text-canvas'], + ])(`migrate arbitrary value to theme value ${testName}`, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ${theme} { + --*: initial; + --color-canvas: CanvasText; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + // 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 ${testName}`, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ${theme} { + --*: initial; + --breakpoint-md: 48rem; + --container-3xl: 48rem; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + test('migrate an 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; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + 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} (spacing = \`%s\`)`, async (candidate, expected, spacing) => { + let input = css` + @import 'tailwindcss'; + + @theme { + --*: initial; + --spacing: ${spacing}; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('bare values', () => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --spacing: 0.25rem; + --aspect-video: 16 / 9; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: --value(--tab-size, integer); + } + ` + + test.each([ + // Built-in utility with bare value fraction + ['aspect-16/9', 'aspect-video'], + + // Custom utility with bare value integer + ['tab-8', 'tab-github'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('deprecated utilities', () => { + test('`order-none` → `order-0`', async () => { + let candidate = 'order-none' + let expected = 'order-0' + + let input = css` + @import 'tailwindcss'; + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('`order-none` → `order-none` with custom implementation', async () => { + let candidate = 'order-none' + let expected = 'order-none' + + let input = css` + @import 'tailwindcss'; + + @utility order-none { + order: none; /* imagine this exists */ + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('arbitrary variants', () => { + let input = css` + @import 'tailwindcss'; + @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, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + + 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 expected = '[&:hover]:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + 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 expected = 'hover:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant hover (&:hover); + ` + + await expectCanonicalization(input, candidate, expected) + }) + + 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 expected = 'hover:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant hover { + &:hover { + @slot; + } + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('custom selector-based variants', async () => { + let candidate = '[&.macos]:flex' + let expected = 'is-macos:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant is-macos (&.macos); + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('custom @media-based variants', async () => { + let candidate = '[@media(prefers-reduced-transparency:reduce)]:flex' + let expected = 'transparency-safe:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant transparency-safe { + @media (prefers-reduced-transparency: reduce) { + @slot; + } + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('drop unnecessary data types', () => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --color-red-500: red; + } + ` + + test.each([ + // A color value can be inferred from the value + ['bg-[color:#008cc]', 'bg-[#008cc]'], + + // A color is the default for `bg-*` + ['bg-(color:--my-value)', 'bg-(--my-value)'], + + // A color with a known theme variable migrates to the full utility + ['bg-(color:--color-red-500)', 'bg-red-500'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('arbitrary value to bare value', () => { + test.each([ + ['aspect-[12/34]', 'aspect-12/34'], + ['aspect-[1.2/34]', 'aspect-[1.2/34]'], + ['col-start-[7]', 'col-start-7'], + ['flex-[2]', 'flex-2'], // `flex` is implemented as static and functional utilities + + ['grid-cols-[subgrid]', 'grid-cols-subgrid'], + ['grid-rows-[subgrid]', 'grid-rows-subgrid'], + + // Only 50-200% (inclusive) are valid: + // https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#percentage + ['font-stretch-[50%]', 'font-stretch-50%'], + ['font-stretch-[50.5%]', 'font-stretch-[50.5%]'], + ['font-stretch-[201%]', 'font-stretch-[201%]'], + ['font-stretch-[49%]', 'font-stretch-[49%]'], + // Should stay as-is + ['font-stretch-[1/2]', 'font-stretch-[1/2]'], + + // Bare value with % is valid for these utilities + ['from-[28%]', 'from-28%'], + ['via-[28%]', 'via-28%'], + ['to-[28%]', 'to-28%'], + ['from-[28.5%]', 'from-[28.5%]'], + ['via-[28.5%]', 'via-[28.5%]'], + ['to-[28.5%]', 'to-[28.5%]'], + + // This test in itself is a bit flawed because `text-[1/2]` currently + // generates something. Converting it to `text-1/2` doesn't produce anything. + ['text-[1/2]', 'text-[1/2]'], + + // Leading is special, because `leading-[123]` is the direct value of 123, but + // `leading-123` maps to `calc(--spacing(123))`. + ['leading-[123]', 'leading-[123]'], + + ['data-[selected]:flex', 'data-selected:flex'], + ['data-[foo=bar]:flex', 'data-[foo=bar]:flex'], + + ['supports-[gap]:flex', 'supports-gap:flex'], + ['supports-[display:grid]:flex', 'supports-[display:grid]:flex'], + + ['group-data-[selected]:flex', 'group-data-selected:flex'], + ['group-data-[foo=bar]:flex', 'group-data-[foo=bar]:flex'], + ['group-has-data-[selected]:flex', 'group-has-data-selected:flex'], + + ['aria-[selected]:flex', 'aria-[selected]:flex'], + ['aria-[selected="true"]:flex', 'aria-selected:flex'], + ['aria-[selected*="true"]:flex', 'aria-[selected*="true"]:flex'], + + ['group-aria-[selected]:flex', 'group-aria-[selected]:flex'], + ['group-aria-[selected="true"]:flex', 'group-aria-selected:flex'], + ['group-has-aria-[selected]:flex', 'group-has-aria-[selected]:flex'], + + ['max-lg:hover:data-[selected]:flex', 'max-lg:hover:data-selected:flex'], + [ + 'data-[selected]:aria-[selected="true"]:aspect-[12/34]', + 'data-selected:aria-selected:aspect-12/34', + ], + ])(testName, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ` + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('modernize arbitrary variants', () => { + test.each([ + // Arbitrary variants + ['[[data-visible]]:flex', 'data-visible:flex'], + ['[&[data-visible]]:flex', 'data-visible:flex'], + ['[[data-visible]&]:flex', 'data-visible:flex'], + ['[&>[data-visible]]:flex', '*:data-visible:flex'], + ['[&_>_[data-visible]]:flex', '*:data-visible:flex'], + ['[&>*]:flex', '*:flex'], + ['[&_>_*]:flex', '*:flex'], + + ['[&_[data-visible]]:flex', '**:data-visible:flex'], + ['[&_*]:flex', '**:flex'], + + ['[&:first-child]:flex', 'first:flex'], + ['[&:not(:first-child)]:flex', 'not-first:flex'], + + // in-* variants + ['[p_&]:flex', 'in-[p]:flex'], + ['[.foo_&]:flex', 'in-[.foo]:flex'], + ['[[data-visible]_&]:flex', 'in-data-visible:flex'], + // Multiple selectors, should stay as-is + ['[[data-foo][data-bar]_&]:flex', '[[data-foo][data-bar]_&]:flex'], + // Using `>` instead of ` ` should not be transformed: + ['[figure>&]:my-0', '[figure>&]:my-0'], + + // nth-child + ['[&:nth-child(2)]:flex', 'nth-2:flex'], + ['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'], + + ['[&:nth-child(-n+3)]:flex', 'nth-[-n+3]:flex'], + ['[&:not(:nth-child(-n+3))]:flex', 'not-nth-[-n+3]:flex'], + ['[&:nth-child(-n_+_3)]:flex', 'nth-[-n+3]:flex'], + ['[&:not(:nth-child(-n_+_3))]:flex', 'not-nth-[-n+3]:flex'], + + // nth-last-child + ['[&:nth-last-child(2)]:flex', 'nth-last-2:flex'], + ['[&:not(:nth-last-child(2))]:flex', 'not-nth-last-2:flex'], + + ['[&:nth-last-child(-n+3)]:flex', 'nth-last-[-n+3]:flex'], + ['[&:not(:nth-last-child(-n+3))]:flex', 'not-nth-last-[-n+3]:flex'], + ['[&:nth-last-child(-n_+_3)]:flex', 'nth-last-[-n+3]:flex'], + ['[&:not(:nth-last-child(-n_+_3))]:flex', 'not-nth-last-[-n+3]:flex'], + + // nth-child odd/even + ['[&:nth-child(odd)]:flex', 'odd:flex'], + ['[&:not(:nth-child(odd))]:flex', 'even:flex'], + ['[&:nth-child(even)]:flex', 'even:flex'], + ['[&:not(:nth-child(even))]:flex', 'odd:flex'], + + // 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'], + ['[[data-url$=.com_i]]:flex', 'data-[url$=.com_i]:flex'], + + // Attribute selector wrapped in `&:is(…)` + ['[&:is([data-visible])]:flex', 'data-visible:flex'], + + // Media queries + ['[@media(pointer:fine)]:flex', 'pointer-fine:flex'], + ['[@media_(pointer_:_fine)]:flex', 'pointer-fine:flex'], + ['[@media_not_(pointer_:_fine)]:flex', 'not-pointer-fine:flex'], + ['[@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'], + ['has-[&>[data-visible]]:flex', 'has-[&>[data-visible]]:flex'], + + ['has-[[data-slot=description]]:flex', 'has-data-[slot=description]:flex'], + ['has-[&:is([data-slot=description])]:flex', 'has-data-[slot=description]:flex'], + + ['has-[[aria-visible="true"]]:flex', 'has-aria-visible:flex'], + ['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'], + + ['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'], + ])(testName, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ` + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('optimize modifier', () => { + let input = css` + @import 'tailwindcss'; + @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'], + + // Convert 0-1 values to bare values + ['bg-[#f00]/[0.16]', 'bg-[#f00]/16'], + + // 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) => { + await expectCanonicalization(input, candidate, expected) + }) + }) +}) + +describe('theme to var', () => { + test('extended space scale converts to var or calc', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --spacing: 0.25rem; + --spacing-2: 2px; + --spacing-miami: 0.875rem; + } + `, + { base: __dirname }, + ) + expect( + designSystem.canonicalizeCandidates([ + '[--value:theme(spacing.1)]', + '[--value:theme(spacing.2)]', + '[--value:theme(spacing.miami)]', + '[--value:theme(spacing.nyc)]', + ]), + ).toEqual([ + '[--value:--spacing(1)]', + '[--value:var(--spacing-2)]', + '[--value:var(--spacing-miami)]', + '[--value:theme(spacing.nyc)]', + ]) + }) + + test('custom space scale converts to var', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --spacing-*: initial; + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + } + `, + { base: __dirname }, + ) + expect( + designSystem.canonicalizeCandidates([ + '[--value:theme(spacing.1)]', + '[--value:theme(spacing.2)]', + '[--value:theme(spacing.3)]', + ]), + ).toEqual([ + '[--value:var(--spacing-1)]', + '[--value:var(--spacing-2)]', + '[--value:theme(spacing.3)]', + ]) + }) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts new file mode 100644 index 000000000000..ba6910552ba4 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -0,0 +1,1501 @@ +import * as AttributeSelectorParser from './attribute-selector-parser' +import { + printModifier, + type Candidate, + type CandidateModifier, + type NamedUtilityValue, + type Variant, +} from './candidate' +import { keyPathToCssProperty } from './compat/apply-config-to-theme' +import type { DesignSystem } from './design-system' +import * as SelectorParser from './selector-parser' +import { + computeUtilitySignature, + computeVariantSignature, + preComputedUtilities, + preComputedVariants, +} from './signatures' +import type { Writable } from './types' +import { DefaultMap } from './utils/default-map' +import { dimensions } from './utils/dimensions' +import { isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type' +import { replaceObject } from './utils/replace-object' +import { segment } from './utils/segment' +import { toKeyPath } from './utils/to-key-path' +import * as ValueParser from './value-parser' + +export function canonicalizeCandidates(ds: DesignSystem, candidates: string[]): string[] { + let result = new Set() + let cache = canonicalizeCandidateCache.get(ds) + for (let candidate of candidates) { + result.add(cache.get(candidate)) + } + return Array.from(result) +} + +const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { + let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' + let variantCache = canonicalizeVariantCache.get(ds) + let utilityCache = canonicalizeUtilityCache.get(ds) + + return new DefaultMap((rawCandidate: string, self) => { + for (let candidate of ds.parseCandidate(rawCandidate)) { + let variants = candidate.variants + .slice() + .reverse() + .flatMap((variant) => variantCache.get(variant)) + let important = candidate.important + + // Canonicalize the base candidate (utility), and re-attach the variants + // and important flag afterwards. This way we can maximize cache hits for + // the base candidate and each individual variant. + if (important || variants.length > 0) { + let canonicalizedUtility = self.get( + ds.printCandidate({ ...candidate, variants: [], important: false }), + ) + + // Rebuild the final candidate + let result = canonicalizedUtility + + // Remove the prefix if there are variants, because the variants exist + // between the prefix and the base candidate. + if (ds.theme.prefix !== null && variants.length > 0) { + result = result.slice(prefix.length) + } + + // Re-attach the variants + if (variants.length > 0) { + result = `${variants.map((v) => ds.printVariant(v)).join(':')}:${result}` + } + + // Re-attach the important flag + if (important) { + result += '!' + } + + // Re-attach the prefix if there were variants + if (ds.theme.prefix !== null && variants.length > 0) { + result = `${prefix}${result}` + } + + return result + } + + // We are guaranteed to have no variants and no important flag, just the + // base candidate left to canonicalize. + let result = utilityCache.get(rawCandidate) + if (result !== rawCandidate) { + return result + } + } + + return rawCandidate + }) +}) + +const VARIANT_CANONICALIZATIONS = [ + themeToVarVariant, + arbitraryValueToBareValueVariant, + modernizeArbitraryValuesVariant, + arbitraryVariants, +] + +const canonicalizeVariantCache = new DefaultMap((ds: DesignSystem) => { + return new DefaultMap((variant: Variant): Variant[] => { + let replacement = [variant] + for (let fn of VARIANT_CANONICALIZATIONS) { + for (let current of replacement.splice(0)) { + // A single variant can result in multiple variants, e.g.: + // `[&>[data-selected]]:flex` → `*:data-selected:flex` + let result = fn(ds, structuredClone(current)) + if (Array.isArray(result)) { + replacement.push(...result) + continue + } else { + replacement.push(result) + } + } + } + return replacement + }) +}) + +const UTILITY_CANONICALIZATIONS = [ + bgGradientToLinear, + themeToVarUtility, + arbitraryUtilities, + bareValueUtilities, + deprecatedUtilities, + dropUnnecessaryDataTypes, + arbitraryValueToBareValueUtility, + optimizeModifier, +] + +const canonicalizeUtilityCache = new DefaultMap((ds: DesignSystem) => { + return new DefaultMap((rawCandidate: string): string => { + for (let readonlyCandidate of ds.parseCandidate(rawCandidate)) { + let replacement = structuredClone(readonlyCandidate) as Writable + + for (let fn of UTILITY_CANONICALIZATIONS) { + replacement = fn(ds, replacement) + } + + let canonicalizedCandidate = ds.printCandidate(replacement) + if (rawCandidate !== canonicalizedCandidate) { + return canonicalizedCandidate + } + } + + return rawCandidate + }) +}) + +// ---- + +const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] +function bgGradientToLinear(_: DesignSystem, candidate: Candidate) { + if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) { + let direction = candidate.root.slice(15) + + if (!DIRECTIONS.includes(direction)) { + return candidate + } + + candidate.root = `bg-linear-to-${direction}` + return candidate + } + + return candidate +} + +// ---- + +const enum Convert { + All = 0, + MigrateModifier = 1 << 0, + MigrateThemeOnly = 1 << 1, +} + +function themeToVarUtility(designSystem: DesignSystem, candidate: Candidate): Candidate { + let convert = converterCache.get(designSystem) + + if (candidate.kind === 'arbitrary') { + let [newValue, modifier] = convert( + candidate.value, + candidate.modifier === null ? Convert.MigrateModifier : Convert.All, + ) + if (newValue !== candidate.value) { + candidate.value = newValue + + if (modifier !== null) { + candidate.modifier = modifier + } + } + } else if (candidate.kind === 'functional' && candidate.value?.kind === 'arbitrary') { + let [newValue, modifier] = convert( + candidate.value.value, + candidate.modifier === null ? Convert.MigrateModifier : Convert.All, + ) + if (newValue !== candidate.value.value) { + candidate.value.value = newValue + + if (modifier !== null) { + candidate.modifier = modifier + } + } + } + + return candidate +} + +function themeToVarVariant(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { + let convert = converterCache.get(designSystem) + + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + if (variant.kind === 'arbitrary') { + let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly) + if (newValue !== variant.selector) { + variant.selector = newValue + } + } else if (variant.kind === 'functional' && variant.value?.kind === 'arbitrary') { + let [newValue] = convert(variant.value.value, Convert.MigrateThemeOnly) + if (newValue !== variant.value.value) { + variant.value.value = newValue + } + } + } + + return variant +} + +const converterCache = new DefaultMap((ds: DesignSystem) => { + return createConverter(ds) + + function createConverter(designSystem: DesignSystem) { + function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { + let ast = ValueParser.parse(input) + + // In some scenarios (e.g.: variants), we can't migrate to `var(…)` if it + // ends up in the `@media (…)` part. In this case we only have to migrate to + // the new `theme(…)` notation. + if (options & Convert.MigrateThemeOnly) { + return [substituteFunctionsInValue(ast, toTheme), null] + } + + let themeUsageCount = 0 + let themeModifierCount = 0 + + // Analyze AST + ValueParser.walk(ast, (node) => { + if (node.kind !== 'function') return + if (node.value !== 'theme') return + + // We are only interested in the `theme` function + themeUsageCount += 1 + + // Figure out if a modifier is used + ValueParser.walk(node.nodes, (child) => { + // If we see a `,`, it means that we have a fallback value + if (child.kind === 'separator' && child.value.includes(',')) { + return ValueParser.ValueWalkAction.Stop + } + + // If we see a `/`, we have a modifier + else if (child.kind === 'separator' && child.value.trim() === '/') { + themeModifierCount += 1 + return ValueParser.ValueWalkAction.Stop + } + + return ValueParser.ValueWalkAction.Skip + }) + }) + + // No `theme(…)` calls, nothing to do + if (themeUsageCount === 0) { + return [input, null] + } + + // No `theme(…)` with modifiers, we can migrate to `var(…)` + if (themeModifierCount === 0) { + return [substituteFunctionsInValue(ast, toVar), null] + } + + // Multiple modifiers which means that there are multiple `theme(…/…)` + // values. In this case, we can't convert the modifier to a candidate + // modifier. + // + // We also can't migrate to `var(…)` because that would lose the modifier. + // + // Try to convert each `theme(…)` call to the modern syntax. + if (themeModifierCount > 1) { + return [substituteFunctionsInValue(ast, toTheme), null] + } + + // Only a single `theme(…)` with a modifier left, that modifier will be + // migrated to a candidate modifier. + let modifier: CandidateModifier | null = null + let result = substituteFunctionsInValue(ast, (path, fallback) => { + let parts = segment(path, '/').map((part) => part.trim()) + + // Multiple `/` separators, which makes this an invalid path + if (parts.length > 2) return null + + // The path contains a `/`, which means that there is a modifier such as + // `theme(colors.red.500/50%)`. + // + // Currently, we are assuming that this is only being used for colors, + // which means that we can typically convert them to a modifier on the + // candidate itself. + // + // If there is more than one node in the AST though, `theme(…)` must not + // be the whole value so it's not safe to use a modifier instead. + // + // E.g.: `inset 0px 1px theme(colors.red.500/50%)` is a shadow, not a color. + if (ast.length === 1 && parts.length === 2 && options & Convert.MigrateModifier) { + let [pathPart, modifierPart] = parts + + // 50% -> /50 + if (/^\d+%$/.test(modifierPart)) { + modifier = { kind: 'named', value: modifierPart.slice(0, -1) } + } + + // .12 -> /12 + // .12345 -> /[12.345] + else if (/^0?\.\d+$/.test(modifierPart)) { + let value = Number(modifierPart) * 100 + modifier = { + kind: Number.isInteger(value) ? 'named' : 'arbitrary', + value: value.toString(), + } + } + + // Anything else becomes arbitrary + else { + modifier = { kind: 'arbitrary', value: modifierPart } + } + + // Update path to be the first part + path = pathPart + } + + return toVar(path, fallback) || toTheme(path, fallback) + }) + + return [result, modifier] + } + + function pathToVariableName(path: string, shouldPrefix = true) { + let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const + if (!designSystem.theme.get([variable])) return null + + if (shouldPrefix && designSystem.theme.prefix) { + return `--${designSystem.theme.prefix}-${variable.slice(2)}` + } + + return variable + } + + function toVar(path: string, fallback?: string) { + let variable = pathToVariableName(path) + if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})` + + let keyPath = toKeyPath(path) + if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) { + let multiplier = keyPath[1] + if (!isValidSpacingMultiplier(multiplier)) return null + + return `--spacing(${multiplier})` + } + + return null + } + + function toTheme(path: string, fallback?: string) { + let parts = segment(path, '/').map((part) => part.trim()) + path = parts.shift()! + + let variable = pathToVariableName(path, false) + if (!variable) return null + + let modifier = parts.length > 0 ? `/${parts.join('/')}` : '' + return fallback + ? `--theme(${variable}${modifier}, ${fallback})` + : `--theme(${variable}${modifier})` + } + + return convert + } +}) + +function substituteFunctionsInValue( + ast: ValueParser.ValueAstNode[], + handle: (value: string, fallback?: string) => string | null, +) { + ValueParser.walk(ast, (node, { parent, replaceWith }) => { + if (node.kind === 'function' && node.value === 'theme') { + if (node.nodes.length < 1) return + + // Ignore whitespace before the first argument + if (node.nodes[0].kind === 'separator' && node.nodes[0].value.trim() === '') { + node.nodes.shift() + } + + let pathNode = node.nodes[0] + if (pathNode.kind !== 'word') return + + let path = pathNode.value + + // For the theme function arguments, we require all separators to contain + // comma (`,`), spaces alone should be merged into the previous word to + // avoid splitting in this case: + // + // theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar) + // + // We only need to do this for the first node, as the fallback values are + // passed through as-is. + let skipUntilIndex = 1 + for (let i = skipUntilIndex; i < node.nodes.length; i++) { + if (node.nodes[i].value.includes(',')) { + break + } + path += ValueParser.toCss([node.nodes[i]]) + skipUntilIndex = i + 1 + } + + path = eventuallyUnquote(path) + let fallbackValues = node.nodes.slice(skipUntilIndex + 1) + + let replacement = + fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path) + if (replacement === null) return + + if (parent) { + let idx = parent.nodes.indexOf(node) - 1 + while (idx !== -1) { + let previous = parent.nodes[idx] + // Skip the space separator + if (previous.kind === 'separator' && previous.value.trim() === '') { + idx -= 1 + continue + } + + // If the previous node is a word and contains an operator, we need to + // wrap the replacement in parentheses to make the output less + // ambiguous. + // + // Input: + // - `calc(100dvh-theme(spacing.2))` + // + // Output: + // - `calc(100dvh-(--spacing(2)))` + // + // Not: + // -`calc(100dvh---spacing(2))` + // + if (/^[-+*/]$/.test(previous.value.trim())) { + replacement = `(${replacement})` + } + + break + } + } + + replaceWith(ValueParser.parse(replacement)) + } + }) + + return ValueParser.toCss(ast) +} + +function eventuallyUnquote(value: string) { + if (value[0] !== "'" && value[0] !== '"') return value + + let unquoted = '' + let quoteChar = value[0] + for (let i = 1; i < value.length - 1; i++) { + let currentChar = value[i] + let nextChar = value[i + 1] + + if (currentChar === '\\' && (nextChar === quoteChar || nextChar === '\\')) { + unquoted += nextChar + i++ + } else { + unquoted += currentChar + } + } + + return unquoted +} + +// ---- + +function* walkVariants(variant: Variant) { + function* inner( + variant: Variant, + parent: Extract | null = null, + ): Iterable<[Variant, Extract | null]> { + yield [variant, parent] + + if (variant.kind === 'compound') { + yield* inner(variant.variant, variant) + } + } + + yield* inner(variant, null) +} + +function parseCandidate(designSystem: DesignSystem, input: string) { + return designSystem.parseCandidate( + designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`) + ? `${designSystem.theme.prefix}:${input}` + : input, + ) +} + +function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candidate) { + let candidateString = designSystem.printCandidate(candidate) + + return designSystem.theme.prefix && candidateString.startsWith(`${designSystem.theme.prefix}:`) + ? candidateString.slice(designSystem.theme.prefix.length + 1) + : candidateString +} + +// ---- + +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 + }) +}) + +function arbitraryUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { + // We are only interested in arbitrary properties and arbitrary values + if ( + // Arbitrary property + candidate.kind !== 'arbitrary' && + // Arbitrary value + !(candidate.kind === 'functional' && candidate.value?.kind === 'arbitrary') + ) { + return candidate + } + + let utilities = preComputedUtilities.get(designSystem) + let signatures = computeUtilitySignature.get(designSystem) + + let targetCandidateString = designSystem.printCandidate(candidate) + + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (typeof targetSignature !== 'string') return candidate + + // Try a few options to find a suitable replacement utility + for (let replacementCandidate of tryReplacements(targetSignature, candidate)) { + 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 + } + + return replacementCandidate + } + + return candidate + + 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) ?? null + let rootPrefix = '' + if (spacingMultiplier !== null && spacingMultiplier < 0) { + rootPrefix = '-' + spacingMultiplier = Math.abs(spacingMultiplier) + } + + for (let root of Array.from(designSystem.utilities.keys('functional')).sort( + // Sort negative roots after positive roots so that we can try + // `mt-*` before `-mt-*`. This is especially useful in situations where + // `-mt-[0px]` can be translated to `mt-[0px]`. + (a, z) => Number(a[0] === '-') - Number(z[0] === '-'), + )) { + if (rootPrefix) root = `${rootPrefix}${root}` + + // 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 + } + } + } + } + } +} + +// 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 +} + +// ---- + +function bareValueUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { + // We are only interested in bare value utilities + if (candidate.kind !== 'functional' || candidate.value?.kind !== 'named') { + return candidate + } + + let utilities = preComputedUtilities.get(designSystem) + let signatures = computeUtilitySignature.get(designSystem) + + let targetCandidateString = designSystem.printCandidate(candidate) + + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (typeof targetSignature !== 'string') return candidate + + // Try a few options to find a suitable replacement utility + for (let replacementCandidate of tryReplacements(targetSignature, candidate)) { + let replacementString = designSystem.printCandidate(replacementCandidate) + let replacementSignature = signatures.get(replacementString) + if (replacementSignature !== targetSignature) { + continue + } + + return replacementCandidate + } + + return candidate + + 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 + } + } + } +} + +// ---- + +const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) + +function deprecatedUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { + let signatures = computeUtilitySignature.get(designSystem) + + let targetCandidateString = printUnprefixedCandidate(designSystem, candidate) + + let replacementString = DEPRECATION_MAP.get(targetCandidateString) ?? null + if (replacementString === null) return candidate + + let legacySignature = signatures.get(targetCandidateString) + if (typeof legacySignature !== 'string') return candidate + + let replacementSignature = signatures.get(replacementString) + if (typeof replacementSignature !== 'string') return candidate + + // Not the same signature, not safe to migrate + if (legacySignature !== replacementSignature) return candidate + + let [replacement] = parseCandidate(designSystem, replacementString) + return replacement +} + +// ---- + +function arbitraryVariants(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { + let signatures = computeVariantSignature.get(designSystem) + let variants = preComputedVariants.get(designSystem) + + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + 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 variant +} + +// ---- + +function dropUnnecessaryDataTypes(designSystem: DesignSystem, candidate: Candidate): Candidate { + let signatures = computeUtilitySignature.get(designSystem) + + 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(designSystem.printCandidate(candidate)) === signatures.get(replacement)) { + candidate.value.dataType = null + } + } + + return candidate +} + +// ---- + +function arbitraryValueToBareValueUtility( + designSystem: DesignSystem, + candidate: Candidate, +): Candidate { + // We are only interested in functional utilities with arbitrary values + if (candidate.kind !== 'functional' || candidate.value?.kind !== 'arbitrary') { + return candidate + } + + let signatures = computeUtilitySignature.get(designSystem) + + let expectedSignature = signatures.get(designSystem.printCandidate(candidate)) + if (expectedSignature === null) return candidate + + for (let value of tryValueReplacements(candidate)) { + let newSignature = signatures.get(designSystem.printCandidate({ ...candidate, value })) + if (newSignature === expectedSignature) { + candidate.value = value + return candidate + } + } + + return candidate +} + +function arbitraryValueToBareValueVariant(_: DesignSystem, variant: Variant): Variant | Variant[] { + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + // Convert `data-[selected]` to `data-selected` + if ( + variant.kind === 'functional' && + variant.root === 'data' && + variant.value?.kind === 'arbitrary' && + !variant.value.value.includes('=') + ) { + variant.value = { + kind: 'named', + value: variant.value.value, + } + } + + // Convert `aria-[selected="true"]` to `aria-selected` + else if ( + variant.kind === 'functional' && + variant.root === 'aria' && + variant.value?.kind === 'arbitrary' && + (variant.value.value.endsWith('=true') || + variant.value.value.endsWith('="true"') || + variant.value.value.endsWith("='true'")) + ) { + let [key, _value] = segment(variant.value.value, '=') + if ( + // aria-[foo~="true"] + key[key.length - 1] === '~' || + // aria-[foo|="true"] + key[key.length - 1] === '|' || + // aria-[foo^="true"] + key[key.length - 1] === '^' || + // aria-[foo$="true"] + key[key.length - 1] === '$' || + // aria-[foo*="true"] + key[key.length - 1] === '*' + ) { + continue + } + + variant.value = { + kind: 'named', + value: variant.value.value.slice(0, variant.value.value.indexOf('=')), + } + } + + // Convert `supports-[gap]` to `supports-gap` + else if ( + variant.kind === 'functional' && + variant.root === 'supports' && + variant.value?.kind === 'arbitrary' && + /^[a-z-][a-z0-9-]*$/i.test(variant.value.value) + ) { + variant.value = { + kind: 'named', + value: variant.value.value, + } + } + } + + return 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, + } + } + + // 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) + } +} + +// ---- + +function isSingleSelector(ast: SelectorParser.SelectorAstNode[]): boolean { + return !ast.some((node) => node.kind === 'separator' && node.value.trim() === ',') +} + +function isAttributeSelector(node: SelectorParser.SelectorAstNode): boolean { + let value = node.value.trim() + return node.kind === 'selector' && value[0] === '[' && value[value.length - 1] === ']' +} + +function modernizeArbitraryValuesVariant( + designSystem: DesignSystem, + variant: Variant, +): Variant | Variant[] { + let result = [variant] + let signatures = computeVariantSignature.get(designSystem) + + let iterator = walkVariants(variant) + for (let [variant, parent] of iterator) { + // Forward modifier from the root to the compound variant + if ( + variant.kind === 'compound' && + (variant.root === 'has' || variant.root === 'not' || variant.root === 'in') + ) { + if (variant.modifier !== null) { + if ('modifier' in variant.variant) { + variant.variant.modifier = variant.modifier + variant.modifier = null + } + } + } + + // Expecting an arbitrary variant + if (variant.kind === 'arbitrary') { + // Expecting a non-relative arbitrary variant + if (variant.relative) continue + + let ast = SelectorParser.parse(variant.selector.trim()) + + // Expecting a single selector node + if (!isSingleSelector(ast)) continue + + // `[&>*]` can be replaced with `*` + if ( + // Only top-level, so `has-[&>*]` is not supported + parent === null && + // [&_>_*]:flex + // ^ ^ ^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '>' && + ast[2].kind === 'selector' && + ast[2].value === '*' + ) { + replaceObject(variant, designSystem.parseVariant('*')) + continue + } + + // `[&_*]` can be replaced with `**` + if ( + // Only top-level, so `has-[&_*]` is not supported + parent === null && + // [&_*]:flex + // ^ ^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '' && // space, but trimmed because there could be multiple spaces + ast[2].kind === 'selector' && + ast[2].value === '*' + ) { + replaceObject(variant, designSystem.parseVariant('**')) + continue + } + + // `in-*` variant. If the selector ends with ` &`, we can convert it to an + // `in-*` variant. + // + // E.g.: `[[data-visible]_&]` => `in-data-visible` + if ( + // Only top-level, so `in-[&_[data-visible]]` is not supported + parent === null && + // [[data-visible]___&]:flex + // ^^^^^^^^^^^^^^ ^ ^ + ast.length === 3 && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '' && // Space, but trimmed because there could be multiple spaces + ast[2].kind === 'selector' && + ast[2].value === '&' + ) { + ast.pop() // Remove the nesting node + ast.pop() // Remove the combinator + + // When handling a compound like `in-[[data-visible]]`, we will first + // handle `[[data-visible]]`, then the parent `in-*` part. This means + // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. + // + // Later this gets converted to `in-data-visible`. + replaceObject(variant, designSystem.parseVariant(`in-[${SelectorParser.toCss(ast)}]`)) + continue + } + + // Hoist `not` modifier for `@media` or `@supports` variants + // + // E.g.: `[@media_not_(scripting:none)]:` -> `not-[@media_(scripting:none)]:` + if ( + // Only top-level, so something like `in-[@media(scripting:none)]` + // (which is not valid anyway) is not supported + parent === null && + // [@media_not(scripting:none)]:flex + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ast[0].kind === 'selector' && + (ast[0].value === '@media' || ast[0].value === '@supports') + ) { + let targetSignature = signatures.get(designSystem.printVariant(variant)) + + let parsed = ValueParser.parse(SelectorParser.toCss(ast)) + let containsNot = false + ValueParser.walk(parsed, (node, { replaceWith }) => { + if (node.kind === 'word' && node.value === 'not') { + containsNot = true + replaceWith([]) + } + }) + + // Remove unnecessary whitespace + parsed = ValueParser.parse(ValueParser.toCss(parsed)) + ValueParser.walk(parsed, (node) => { + if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') { + // node.value contains at least 2 spaces. Normalize it to a single + // space. + node.value = ' ' + } + }) + + if (containsNot) { + let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`) + if (hoistedNot === null) continue + let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot)) + if (targetSignature === hoistedNotSignature) { + replaceObject(variant, hoistedNot) + continue + } + } + } + + let prefixedVariant: Variant | null = null + + // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` + if ( + // Only top-level, so `has-[&>[data-visible]]` is not supported + parent === null && + // [&_>_[data-visible]]:flex + // ^ ^ ^^^^^^^^^^^^^^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value.trim() === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '>' && + ast[2].kind === 'selector' && + isAttributeSelector(ast[2]) + ) { + ast = [ast[2]] + prefixedVariant = designSystem.parseVariant('*') + } + + // Handling a grand child combinator. E.g.: `[&_[data-visible]]` => `**:data-visible` + if ( + // Only top-level, so `has-[&_[data-visible]]` is not supported + parent === null && + // [&_[data-visible]]:flex + // ^ ^^^^^^^^^^^^^^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value.trim() === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '' && // space, but trimmed because there could be multiple spaces + ast[2].kind === 'selector' && + isAttributeSelector(ast[2]) + ) { + ast = [ast[2]] + prefixedVariant = designSystem.parseVariant('**') + } + + // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` + let selectorNodes = ast.filter( + (node) => !(node.kind === 'selector' && node.value.trim() === '&'), + ) + + // Expecting a single selector (normal selector or attribute selector) + if (selectorNodes.length !== 1) continue + + let target = selectorNodes[0] + if (target.kind === 'function' && target.value === ':is') { + // Expecting a single selector node + if ( + !isSingleSelector(target.nodes) || + // [foo][bar] is considered a single selector but has multiple nodes + target.nodes.length !== 1 + ) + continue + + // Expecting a single attribute selector + if (!isAttributeSelector(target.nodes[0])) continue + + // Unwrap the selector from inside `&:is(…)` + target = target.nodes[0] + } + + // Expecting a pseudo selector (or function) + if ( + (target.kind === 'function' && target.value[0] === ':') || + (target.kind === 'selector' && target.value[0] === ':') + ) { + let targetNode = target + let compoundNot = false + if (targetNode.kind === 'function' && targetNode.value === ':not') { + compoundNot = true + if (targetNode.nodes.length !== 1) continue + if (targetNode.nodes[0].kind !== 'selector' && targetNode.nodes[0].kind !== 'function') { + continue + } + if (targetNode.nodes[0].value[0] !== ':') continue + + targetNode = targetNode.nodes[0] + } + + let newVariant = ((value) => { + if ( + value === ':nth-child' && + targetNode.kind === 'function' && + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + targetNode.nodes[0].value === 'odd' + ) { + if (compoundNot) { + compoundNot = false + return 'even' + } + return 'odd' + } + + if ( + value === ':nth-child' && + targetNode.kind === 'function' && + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + targetNode.nodes[0].value === 'even' + ) { + if (compoundNot) { + compoundNot = false + return 'odd' + } + return 'even' + } + + for (let [selector, variantName] of [ + [':nth-child', 'nth'], + [':nth-last-child', 'nth-last'], + [':nth-of-type', 'nth-of-type'], + [':nth-last-of-type', 'nth-of-last-type'], + ]) { + if ( + value === selector && + targetNode.kind === 'function' && + targetNode.nodes.length === 1 + ) { + if ( + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + isPositiveInteger(targetNode.nodes[0].value) + ) { + return `${variantName}-${targetNode.nodes[0].value}` + } + + return `${variantName}-[${SelectorParser.toCss(targetNode.nodes)}]` + } + } + + // Hoist `not` modifier + if (compoundNot) { + let targetSignature = signatures.get(designSystem.printVariant(variant)) + let replacementSignature = signatures.get(`not-[${value}]`) + if (targetSignature === replacementSignature) { + return `[&${value}]` + } + } + + return null + })(targetNode.value) + + if (newVariant === null) continue + + // Add `not-` prefix + if (compoundNot) newVariant = `not-${newVariant}` + + let parsed = designSystem.parseVariant(newVariant) + if (parsed === null) continue + + // Update original variant + replaceObject(variant, parsed) + } + + // Expecting an attribute selector + else if (isAttributeSelector(target)) { + let attributeSelector = AttributeSelectorParser.parse(target.value) + if (attributeSelector === null) continue // Invalid attribute selector + + // Migrate `data-*` + if (attributeSelector.attribute.startsWith('data-')) { + let name = attributeSelector.attribute.slice(5) // Remove `data-` + + replaceObject(variant, { + kind: 'functional', + root: 'data', + modifier: null, + value: + attributeSelector.value === null + ? { kind: 'named', value: name } + : { + kind: 'arbitrary', + value: `${name}${attributeSelector.operator}${attributeSelector.quote ?? ''}${attributeSelector.value}${attributeSelector.quote ?? ''}${attributeSelector.sensitivity ? ` ${attributeSelector.sensitivity}` : ''}`, + }, + } satisfies Variant) + } + + // Migrate `aria-*` + else if (attributeSelector.attribute.startsWith('aria-')) { + let name = attributeSelector.attribute.slice(5) // Remove `aria-` + replaceObject(variant, { + kind: 'functional', + root: 'aria', + modifier: null, + value: + attributeSelector.value === null + ? { kind: 'arbitrary', value: name } // aria-[foo] + : attributeSelector.operator === '=' && + attributeSelector.value === 'true' && + attributeSelector.sensitivity === null + ? { kind: 'named', value: name } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] + : { + kind: 'arbitrary', + value: `${attributeSelector.attribute}${attributeSelector.operator}${attributeSelector.quote ?? ''}${attributeSelector.value}${attributeSelector.quote ?? ''}${attributeSelector.sensitivity ? ` ${attributeSelector.sensitivity}` : ''}`, + }, // aria-[foo~="true"], aria-[foo|="true"], … + } satisfies Variant) + } + } + + if (prefixedVariant) { + return [prefixedVariant, variant] + } + } + } + + return result +} + +// ---- + +// Optimize the modifier +// +// E.g.: +// +// - `/[25%]` → `/25` +// - `/[100%]` → `/100` → +// - `/100` → +// +function optimizeModifier(designSystem: DesignSystem, candidate: Candidate): Candidate { + // We are only interested in functional or arbitrary utilities with a modifier + if ( + (candidate.kind !== 'functional' && candidate.kind !== 'arbitrary') || + candidate.modifier === null + ) { + return candidate + } + + let signatures = computeUtilitySignature.get(designSystem) + + let targetSignature = signatures.get(designSystem.printCandidate(candidate)) + let modifier = candidate.modifier + + // 1. Try to drop the modifier entirely + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: null })) + ) { + candidate.modifier = null + return candidate + } + + // 2. Try to remove the square brackets and the `%` sign + { + 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 })) + ) { + candidate.modifier = newModifier + return candidate + } + } + + // 3. Try to remove the square brackets, but multiply by 100. E.g.: `[0.16]` -> `16` + { + let newModifier: NamedUtilityValue = { + kind: 'named', + value: `${parseFloat(modifier.value) * 100}`, + fraction: null, + } + + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier })) + ) { + candidate.modifier = newModifier + return candidate + } + } + + return candidate +} diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 3b2f0712c2af..f5444cf5686c 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -5,6 +5,7 @@ import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candida import { substituteFunctions } from '../css-functions' import * as CSS from '../css-parser' import type { DesignSystem } from '../design-system' +import * as SelectorParser from '../selector-parser' import type { SourceLocation } from '../source-maps/source' import { withAlpha } from '../utilities' import { DefaultMap } from '../utils/default-map' @@ -15,7 +16,6 @@ import { toKeyPath } from '../utils/to-key-path' import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtSlot } from '../variants' import type { ResolvedConfig, UserConfig } from './config/types' import { createThemeFn } from './plugin-functions' -import * as SelectorParser from './selector-parser' export type Config = UserConfig export type PluginFn = (api: PluginAPI) => void diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 7efebf1a5d26..5f4450d50fbf 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -10,7 +10,13 @@ import { } from './candidate' import { compileAstNodes, compileCandidates } from './compile' import { substituteFunctions } from './css-functions' -import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' +import { + canonicalizeCandidates, + getClassList, + getVariants, + type ClassEntry, + type VariantEntry, +} from './intellisense' import { getClassOrder } from './sort' import { Theme, ThemeOptions, type ThemeKey } from './theme' import { Utilities, createUtilities, withAlpha } from './utilities' @@ -48,6 +54,7 @@ export type DesignSystem = { resolveThemeValue(path: string, forceInline?: boolean): string | undefined trackUsedVariables(raw: string): void + canonicalizeCandidates(candidates: string[]): string[] // Used by IntelliSense candidatesToCss(classes: string[]): (string | null)[] @@ -202,6 +209,10 @@ export function buildDesignSystem(theme: Theme): DesignSystem { trackUsedVariables(raw: string) { trackUsedVariables.get(raw) }, + + canonicalizeCandidates(candidates: string[]) { + return canonicalizeCandidates(this, candidates) + }, } return designSystem diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index e6db73b50f4a..b04d8d212948 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -3,6 +3,7 @@ import { applyVariant } from './compile' import type { DesignSystem } from './design-system' import { compare } from './utils/compare' import { DefaultMap } from './utils/default-map' +export { canonicalizeCandidates } from './canonicalize-candidates' interface ClassMetadata { modifiers: string[] diff --git a/packages/tailwindcss/src/compat/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts similarity index 83% rename from packages/tailwindcss/src/compat/selector-parser.test.ts rename to packages/tailwindcss/src/selector-parser.test.ts index f995c9de72c5..2a22835226f5 100644 --- a/packages/tailwindcss/src/compat/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -131,6 +131,34 @@ describe('parse', () => { }, ]) }) + + it('parses nesting selector before attribute selector', () => { + expect(parse('&[data-foo]')).toEqual([ + { kind: 'selector', value: '&' }, + { kind: 'selector', value: '[data-foo]' }, + ]) + }) + + it('parses nesting selector after an attribute selector', () => { + expect(parse('[data-foo]&')).toEqual([ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: '&' }, + ]) + }) + + it('parses universal selector before attribute selector', () => { + expect(parse('*[data-foo]')).toEqual([ + { kind: 'selector', value: '*' }, + { kind: 'selector', value: '[data-foo]' }, + ]) + }) + + it('parses universal selector after an attribute selector', () => { + expect(parse('[data-foo]*')).toEqual([ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: '*' }, + ]) + }) }) describe('toCss', () => { diff --git a/packages/tailwindcss/src/compat/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts similarity index 90% rename from packages/tailwindcss/src/compat/selector-parser.ts rename to packages/tailwindcss/src/selector-parser.ts index 77a1ce4d4b49..0d28fdc4fe7e 100644 --- a/packages/tailwindcss/src/compat/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -181,6 +181,8 @@ const SINGLE_QUOTE = 0x27 const SPACE = 0x20 const TAB = 0x09 const TILDE = 0x7e +const AMPERSAND = 0x26 +const ASTERISK = 0x2a export function parse(input: string) { input = input.replaceAll('\r\n', '\n') @@ -369,11 +371,18 @@ export function parse(input: string) { ast.push(node) } } - buffer = String.fromCharCode(currentChar) + buffer = input[i] break } // Start of an attribute selector. + // + // NOTE: Right now we don't care about the individual parts of the + // attribute selector, we just want to find the matching closing bracket. + // + // If we need more information from inside the attribute selector in the + // future, then we can use the `AttributeSelectorParser` here (and even + // inline it if needed) case OPEN_BRACKET: { // Handle everything before the combinator as a selector if (buffer.length > 0) { @@ -443,17 +452,40 @@ export function parse(input: string) { break } + // Nesting `&` is always a new selector. + // Universal `*` is always a new selector. + case AMPERSAND: + case ASTERISK: { + // 1. Handle everything before the combinator as a selector + if (buffer.length > 0) { + let node = selector(buffer) + if (parent) { + parent.nodes.push(node) + } else { + ast.push(node) + } + buffer = '' + } + + // 2. Handle the `&` or `*` as a selector on its own + if (parent) { + parent.nodes.push(selector(input[i])) + } else { + ast.push(selector(input[i])) + } + break + } + // Escaped characters. case BACKSLASH: { - let nextChar = input.charCodeAt(i + 1) - buffer += String.fromCharCode(currentChar) + String.fromCharCode(nextChar) + buffer += input[i] + input[i + 1] i += 1 break } // Everything else will be collected in the buffer default: { - buffer += String.fromCharCode(currentChar) + buffer += input[i] } } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/tailwindcss/src/signatures.ts similarity index 94% rename from packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts rename to packages/tailwindcss/src/signatures.ts index 4c857fdf511a..89a5edde424c 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -1,13 +1,13 @@ -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 { CompileAstFlags, type DesignSystem } from '../../../../tailwindcss/src/design-system' -import { ThemeOptions } from '../../../../tailwindcss/src/theme' -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 { substituteAtApply } from './apply' +import { atRule, styleRule, toCss, walk, type AstNode } from './ast' +import { printArbitraryValue } from './candidate' +import { CompileAstFlags, type DesignSystem } from './design-system' +import * as SelectorParser from './selector-parser' +import { ThemeOptions } from './theme' +import { DefaultMap } from './utils/default-map' +import { dimensions } from './utils/dimensions' +import { isValidSpacingMultiplier } from './utils/infer-data-type' +import * as ValueParser from './value-parser' // 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 @@ -44,17 +44,12 @@ export const computeUtilitySignature = new DefaultMap< // There's separate utility caches for respect important vs not // so we want to compile them both with `@theme inline` disabled for (let candidate of designSystem.parseCandidate(utility)) { - designSystem.compileAstNodes(candidate, CompileAstFlags.None) designSystem.compileAstNodes(candidate, CompileAstFlags.RespectImportant) } 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. @@ -75,6 +70,11 @@ export const computeUtilitySignature = new DefaultMap< else if (node.kind === 'comment') { replaceWith([]) } + + // Remove at-rules that are not needed for the signature + else if (node.kind === 'at-rule' && node.name === '@property') { + replaceWith([]) + } }) // Resolve theme values to their inlined value. @@ -120,6 +120,7 @@ export const computeUtilitySignature = new DefaultMap< // Handle declarations if (node.kind === 'declaration' && node.value !== undefined) { if (node.value.includes('var(')) { + let changed = false let valueAst = ValueParser.parse(node.value) let seen = new Set() @@ -162,6 +163,7 @@ export const computeUtilitySignature = new DefaultMap< // More than 1 argument means that a fallback is already present if (valueNode.nodes.length === 1) { // Inject the fallback value into the variable lookup + changed = true valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) } } @@ -174,6 +176,7 @@ export const computeUtilitySignature = new DefaultMap< let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable let constructedValue = `${valueNode.nodes[0].value},${variableValue}` if (nodeAsString === constructedValue) { + changed = true replaceWith(ValueParser.parse(variableValue)) } } @@ -181,7 +184,7 @@ export const computeUtilitySignature = new DefaultMap< }) // Replace the value with the new value - node.value = ValueParser.toCss(valueAst) + if (changed) node.value = ValueParser.toCss(valueAst) } // Very basic `calc(…)` constant folding to handle the spacing scale diff --git a/packages/@tailwindcss-upgrade/src/utils/types.ts b/packages/tailwindcss/src/types.ts similarity index 100% rename from packages/@tailwindcss-upgrade/src/utils/types.ts rename to packages/tailwindcss/src/types.ts diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/tailwindcss/src/utils/dimensions.ts similarity index 80% rename from packages/@tailwindcss-upgrade/src/utils/dimension.ts rename to packages/tailwindcss/src/utils/dimensions.ts index a1dd4bded229..17f05f9bd951 100644 --- a/packages/@tailwindcss-upgrade/src/utils/dimension.ts +++ b/packages/tailwindcss/src/utils/dimensions.ts @@ -1,8 +1,10 @@ import { DefaultMap } from '../../../tailwindcss/src/utils/default-map' +const DIMENSION_REGEX = /^(?-?(?:\d*\.)?\d+)(?[a-z]+|%)$/i + // Parse a dimension such as `64rem` into `[64, 'rem']`. export const dimensions = new DefaultMap((input) => { - let match = /^(?-?(?:\d*\.)?\d+)(?[a-z]+|%)$/i.exec(input) + let match = DIMENSION_REGEX.exec(input) if (!match) return null let value = match.groups?.value diff --git a/packages/@tailwindcss-upgrade/src/utils/replace-object.ts b/packages/tailwindcss/src/utils/replace-object.ts similarity index 100% rename from packages/@tailwindcss-upgrade/src/utils/replace-object.ts rename to packages/tailwindcss/src/utils/replace-object.ts