From 80a90e0d6528e1b5377cf6f2a36fead8a995e50d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 26 Sep 2025 16:56:04 +0200 Subject: [PATCH 01/37] hoist regex --- packages/@tailwindcss-upgrade/src/utils/dimension.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/@tailwindcss-upgrade/src/utils/dimension.ts index a1dd4bded229..17f05f9bd951 100644 --- a/packages/@tailwindcss-upgrade/src/utils/dimension.ts +++ b/packages/@tailwindcss-upgrade/src/utils/dimension.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 From 33dd0f8c053cf215a1a5871ef9cad645fb156c42 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 11:46:35 +0200 Subject: [PATCH 02/37] remove async While going through the migrations, I noticed that this was still marked as async even though nothing is async about this. Simplified it to a simple sync function instead. --- .../src/codemods/template/migrate-deprecated-utilities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts index 41fc3697b169..61ab5a49f3aa 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts @@ -6,11 +6,11 @@ import { computeUtilitySignature } from './signatures' const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) -export async function migrateDeprecatedUtilities( +export function migrateDeprecatedUtilities( designSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, -): Promise { +): string { let signatures = computeUtilitySignature.get(designSystem) for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { From a62d857fc581051b43f056bed2280f0895796a30 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 11:46:46 +0200 Subject: [PATCH 03/37] tmp: annotate migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is likely temporary, but needed to know which migrations were sync or async, and which migrations are an _actual_ migration from v3 → v4 or which ones are mere optimizations in v4 land. The v4 optimizations will be moved to the `canonicalizeCandidates` logic straight on the design system. --- .../src/codemods/template/migrate.ts | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 4763577c71c6..8bd08fced99f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -35,26 +35,26 @@ export type Migration = ( ) => string | Promise 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, + migrateEmptyArbitraryValues, // sync, v3 → v4 + migratePrefix, // sync, v3 → v4 + migrateCanonicalizeCandidate, // sync, v4 (optimization, can probably be removed) + migrateBgGradient, // sync, v4 (optimization, can probably be removed) + migrateSimpleLegacyClasses, // sync, v3 → v4 + migrateCamelcaseInNamedValue, // sync, v3 → v4 + migrateLegacyClasses, // async, v3 → v4 + migrateMaxWidthScreen, // sync, v3 → v4 + migrateThemeToVar, // sync, v4 (optimization) + migrateVariantOrder, // sync, v3 → v4, Has to happen before migrations that modify variants + migrateAutomaticVarInjection, // sync, v3 → v4 + migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) + migrateArbitraryUtilities, // sync, v4 + migrateBareValueUtilities, // sync, v4 + migrateDeprecatedUtilities, // sync, v4 (deprecation map, order-none → order-0) + migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? + migrateArbitraryVariants, // sync, v4 + migrateDropUnnecessaryDataTypes, // sync, v4 (I think this can be dropped?) + migrateArbitraryValueToBareValue, // sync, v4 (optimization) + migrateOptimizeModifier, // sync, v4 (optimization) ] let migrateCached = new DefaultMap< From c82bc30621402389fa39fa0ed9bd84f82d47e8d4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 12:50:10 +0200 Subject: [PATCH 04/37] move `printCandidate` normalization tests to core --- .../src/codemods/template/candidates.test.ts | 115 +--------------- packages/tailwindcss/src/candidate.test.ts | 126 +++++++++++++++++- 2 files changed, 126 insertions(+), 115 deletions(-) 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/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) + }) +}) From 8c6fe358cffc3f42e6eb0a3de6a351a89b1bb87c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 26 Sep 2025 18:03:06 +0200 Subject: [PATCH 05/37] expose `canonicalizeCandidates` on the design system + initial basic implementation --- .../src/canonicalize-candidates.test.ts | 99 +++++++++++++++++++ .../src/canonicalize-candidates.ts | 30 ++++++ packages/tailwindcss/src/design-system.ts | 6 ++ 3 files changed, 135 insertions(+) create mode 100644 packages/tailwindcss/src/canonicalize-candidates.test.ts create mode 100644 packages/tailwindcss/src/canonicalize-candidates.ts diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts new file mode 100644 index 000000000000..cb848a661ad2 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -0,0 +1,99 @@ +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([ + // + ['[display:_flex_]', '[display:flex]'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization( + css` + @import 'tailwindcss'; + `, + candidate, + expected, + ) + }) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts new file mode 100644 index 000000000000..2146c92e52e7 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -0,0 +1,30 @@ +import type { DesignSystem } from './design-system' +import { DefaultMap } from './utils/default-map' + +export function canonicalizeCandidates(ds: DesignSystem, candidates: string[]): string[] { + return candidates.map((candidate) => { + return canonicalizeCandidateCache.get(ds).get(candidate) + }) +} + +const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { + return new DefaultMap((candidate: string) => { + let result = candidate + for (let fn of CANONICALIZATIONS) { + let newResult = fn(ds, result) + if (newResult !== result) { + result = newResult + } + } + return result + }) +}) + +const CANONICALIZATIONS = [print] + +function print(designSystem: DesignSystem, rawCandidate: string): string { + for (let candidate of designSystem.parseCandidate(rawCandidate)) { + return designSystem.printCandidate(candidate) + } + return rawCandidate +} diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 7efebf1a5d26..296d478de465 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -8,6 +8,7 @@ import { type Candidate, type Variant, } from './candidate' +import { canonicalizeCandidates } from './canonicalize-candidates' import { compileAstNodes, compileCandidates } from './compile' import { substituteFunctions } from './css-functions' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' @@ -48,6 +49,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 +204,10 @@ export function buildDesignSystem(theme: Theme): DesignSystem { trackUsedVariables(raw: string) { trackUsedVariables.get(raw) }, + + canonicalizeCandidates(candidates: string[]) { + return canonicalizeCandidates(this, candidates) + }, } return designSystem From 5d6ea74bd472d3318599b29957f3c2c2b1029cd0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 14:29:53 +0200 Subject: [PATCH 06/37] canonicalize candidates at the end of upgrading your project After all migrations happened, we run the canonicalization step to perform the last bit of migrations --- packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 8bd08fced99f..f70d2d2ea459 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -69,6 +69,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. From 44352e8e640ddd7b6bbb5da475bd9c25a8d765f2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 14:33:23 +0200 Subject: [PATCH 07/37] move `migrate-bg-gradient` to core --- .../template/migrate-bg-gradient.test.ts | 22 ---------------- .../codemods/template/migrate-bg-gradient.ts | 26 ------------------- .../src/codemods/template/migrate.ts | 2 -- .../src/canonicalize-candidates.test.ts | 11 ++++++-- .../src/canonicalize-candidates.ts | 21 ++++++++++++++- 5 files changed, 29 insertions(+), 53 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts 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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index f70d2d2ea459..2e8c3943e626 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -11,7 +11,6 @@ import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-b 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' @@ -38,7 +37,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateEmptyArbitraryValues, // sync, v3 → v4 migratePrefix, // sync, v3 → v4 migrateCanonicalizeCandidate, // sync, v4 (optimization, can probably be removed) - migrateBgGradient, // sync, v4 (optimization, can probably be removed) migrateSimpleLegacyClasses, // sync, v3 → v4 migrateCamelcaseInNamedValue, // sync, v3 → v4 migrateLegacyClasses, // async, v3 → v4 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index cb848a661ad2..54db42107cc8 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -85,8 +85,15 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', /// ---------------------------------- test.each([ - // - ['[display:_flex_]', '[display:flex]'], + // 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'], ])(testName, async (candidate, expected) => { await expectCanonicalization( css` diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 2146c92e52e7..664a2d0b87c6 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -20,7 +20,7 @@ const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { }) }) -const CANONICALIZATIONS = [print] +const CANONICALIZATIONS = [bgGradientToLinear, print] function print(designSystem: DesignSystem, rawCandidate: string): string { for (let candidate of designSystem.parseCandidate(rawCandidate)) { @@ -28,3 +28,22 @@ function print(designSystem: DesignSystem, rawCandidate: string): string { } return rawCandidate } + +const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] +function bgGradientToLinear(designSystem: DesignSystem, 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 +} From afa3216cbc75f4576dd543e7ba6ee56e37612fe6 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 15:03:39 +0200 Subject: [PATCH 08/37] move `migrate-theme-to-var` to core --- .../template/migrate-theme-to-var.test.ts | 195 ---------- .../codemods/template/migrate-theme-to-var.ts | 66 +--- .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 182 ++++++++- .../src/canonicalize-candidates.ts | 351 +++++++++++++++++- 5 files changed, 532 insertions(+), 264 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.test.ts 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 2e8c3943e626..64696b86d556 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -23,7 +23,6 @@ import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-v 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' @@ -41,7 +40,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateCamelcaseInNamedValue, // sync, v3 → v4 migrateLegacyClasses, // async, v3 → v4 migrateMaxWidthScreen, // sync, v3 → v4 - migrateThemeToVar, // sync, v4 (optimization) migrateVariantOrder, // sync, v3 → v4, Has to happen before migrations that modify variants migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 54db42107cc8..9bea4f968bdf 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -85,7 +85,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', /// ---------------------------------- test.each([ - // Legacy bg-gradient-* → bg-linear-* + /// 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'], @@ -94,6 +94,132 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['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]', '[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', + ], ])(testName, async (candidate, expected) => { await expectCanonicalization( css` @@ -104,3 +230,57 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ) }) }) + +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 index 664a2d0b87c6..1112c31937f6 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,5 +1,11 @@ +import { parseCandidate, type Candidate, type CandidateModifier, type Variant } from './candidate' +import { keyPathToCssProperty } from './compat/apply-config-to-theme' import type { DesignSystem } from './design-system' import { DefaultMap } from './utils/default-map' +import { isValidSpacingMultiplier } from './utils/infer-data-type' +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[] { return candidates.map((candidate) => { @@ -20,7 +26,7 @@ const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { }) }) -const CANONICALIZATIONS = [bgGradientToLinear, print] +const CANONICALIZATIONS = [bgGradientToLinear, themeToVar, print] function print(designSystem: DesignSystem, rawCandidate: string): string { for (let candidate of designSystem.parseCandidate(rawCandidate)) { @@ -29,6 +35,8 @@ function print(designSystem: DesignSystem, rawCandidate: string): string { return rawCandidate } +// ---- + const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] function bgGradientToLinear(designSystem: DesignSystem, rawCandidate: string): string { for (let candidate of designSystem.parseCandidate(rawCandidate)) { @@ -47,3 +55,344 @@ function bgGradientToLinear(designSystem: DesignSystem, rawCandidate: string): s } return rawCandidate } + +// ---- + +const enum Convert { + All = 0, + MigrateModifier = 1 << 0, + MigrateThemeOnly = 1 << 1, +} + +function themeToVar(designSystem: DesignSystem, 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 +} + +function createConverter(designSystem: DesignSystem, { prettyPrint = false } = {}) { + 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) { + let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const + if (!designSystem.theme.get([variable])) return null + + if (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) + if (!variable) return null + + let modifier = + parts.length > 0 ? (prettyPrint ? ` / ${parts.join(' / ')}` : `/${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(candidate: Candidate) { + function* inner( + variant: Variant, + parent: Extract | null = null, + ): Iterable<[Variant, Extract | null]> { + yield [variant, parent] + + if (variant.kind === 'compound') { + yield* inner(variant.variant, variant) + } + } + + for (let variant of candidate.variants) { + yield* inner(variant, null) + } +} From 852e28868f7ffa60aabcc170540d1e0cd3586324 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 15:06:36 +0200 Subject: [PATCH 09/37] cache the converter We will likely clean this up later. But just trying to move things aroudn first. --- .../src/canonicalize-candidates.ts | 247 +++++++++--------- 1 file changed, 125 insertions(+), 122 deletions(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 1112c31937f6..43a3d897c3ee 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -65,7 +65,7 @@ const enum Convert { } function themeToVar(designSystem: DesignSystem, rawCandidate: string): string { - let convert = createConverter(designSystem) + let convert = converterCache.get(designSystem) for (let candidate of parseCandidate(rawCandidate, designSystem)) { let clone = structuredClone(candidate) @@ -122,161 +122,164 @@ function themeToVar(designSystem: DesignSystem, rawCandidate: string): string { return rawCandidate } -function createConverter(designSystem: DesignSystem, { prettyPrint = false } = {}) { - function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { - let ast = ValueParser.parse(input) +const converterCache = new DefaultMap((ds: DesignSystem) => { + return createConverter(ds) - // 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] - } + function createConverter(designSystem: DesignSystem) { + function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { + let ast = ValueParser.parse(input) - let themeUsageCount = 0 - let themeModifierCount = 0 + // 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] + } - // Analyze AST - ValueParser.walk(ast, (node) => { - if (node.kind !== 'function') return - if (node.value !== 'theme') return + let themeUsageCount = 0 + let themeModifierCount = 0 - // We are only interested in the `theme` function - themeUsageCount += 1 + // Analyze AST + ValueParser.walk(ast, (node) => { + if (node.kind !== 'function') return + if (node.value !== 'theme') return - // 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 - } + // We are only interested in the `theme` function + themeUsageCount += 1 - return ValueParser.ValueWalkAction.Skip - }) - }) - - // No `theme(…)` calls, nothing to do - if (themeUsageCount === 0) { - return [input, null] - } + // 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 + } - // No `theme(…)` with modifiers, we can migrate to `var(…)` - if (themeModifierCount === 0) { - return [substituteFunctionsInValue(ast, toVar), null] - } + // If we see a `/`, we have a modifier + else if (child.kind === 'separator' && child.value.trim() === '/') { + themeModifierCount += 1 + return ValueParser.ValueWalkAction.Stop + } - // 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] - } + return ValueParser.ValueWalkAction.Skip + }) + }) - // 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()) + // No `theme(…)` calls, nothing to do + if (themeUsageCount === 0) { + return [input, null] + } - // Multiple `/` separators, which makes this an invalid path - if (parts.length > 2) return null + // No `theme(…)` with modifiers, we can migrate to `var(…)` + if (themeModifierCount === 0) { + return [substituteFunctionsInValue(ast, toVar), null] + } - // The path contains a `/`, which means that there is a modifier such as - // `theme(colors.red.500/50%)`. + // Multiple modifiers which means that there are multiple `theme(…/…)` + // values. In this case, we can't convert the modifier to a candidate + // modifier. // - // 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. + // We also can't migrate to `var(…)` because that would lose the modifier. // - // 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 + // Try to convert each `theme(…)` call to the modern syntax. + if (themeModifierCount > 1) { + return [substituteFunctionsInValue(ast, toTheme), null] + } - // 50% -> /50 - if (/^\d+%$/.test(modifierPart)) { - modifier = { kind: 'named', value: modifierPart.slice(0, -1) } - } + // 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(), + // .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 } } - } - // Anything else becomes arbitrary - else { - modifier = { kind: 'arbitrary', value: modifierPart } + // Update path to be the first part + path = pathPart } - // Update path to be the first part - path = pathPart - } + return toVar(path, fallback) || toTheme(path, fallback) + }) - return toVar(path, fallback) || toTheme(path, fallback) - }) + return [result, modifier] + } - return [result, modifier] - } + function pathToVariableName(path: string) { + let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const + if (!designSystem.theme.get([variable])) return null - function pathToVariableName(path: string) { - let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const - if (!designSystem.theme.get([variable])) return null + if (designSystem.theme.prefix) { + return `--${designSystem.theme.prefix}-${variable.slice(2)}` + } - if (designSystem.theme.prefix) { - return `--${designSystem.theme.prefix}-${variable.slice(2)}` + return variable } - return variable - } + function toVar(path: string, fallback?: string) { + let variable = pathToVariableName(path) + if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${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 - 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 `--spacing(${multiplier})` + return null } - return null - } + function toTheme(path: string, fallback?: string) { + let parts = segment(path, '/').map((part) => part.trim()) + path = parts.shift()! - function toTheme(path: string, fallback?: string) { - let parts = segment(path, '/').map((part) => part.trim()) - path = parts.shift()! + let variable = pathToVariableName(path) + if (!variable) return null - let variable = pathToVariableName(path) - if (!variable) return null + let modifier = parts.length > 0 ? `/${parts.join('/')}` : '' + return fallback + ? `--theme(${variable}${modifier}, ${fallback})` + : `--theme(${variable}${modifier})` + } - let modifier = - parts.length > 0 ? (prettyPrint ? ` / ${parts.join(' / ')}` : `/${parts.join('/')}`) : '' - return fallback - ? `--theme(${variable}${modifier}, ${fallback})` - : `--theme(${variable}${modifier})` + return convert } - - return convert -} +}) function substituteFunctionsInValue( ast: ValueParser.ValueAstNode[], From 4c722c028e6ed0b191dd4f61fe8317d8ec095c76 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 15:54:35 +0200 Subject: [PATCH 10/37] =?UTF-8?q?only=20prefix=20variable=20that=20are=20n?= =?UTF-8?q?ot=20in=20`--theme(=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/canonicalize-candidates.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 43a3d897c3ee..2f8b7e2104f1 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -238,11 +238,11 @@ const converterCache = new DefaultMap((ds: DesignSystem) => { return [result, modifier] } - function pathToVariableName(path: string) { + function pathToVariableName(path: string, shouldPrefix = true) { let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const if (!designSystem.theme.get([variable])) return null - if (designSystem.theme.prefix) { + if (shouldPrefix && designSystem.theme.prefix) { return `--${designSystem.theme.prefix}-${variable.slice(2)}` } @@ -268,7 +268,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => { let parts = segment(path, '/').map((part) => part.trim()) path = parts.shift()! - let variable = pathToVariableName(path) + let variable = pathToVariableName(path, false) if (!variable) return null let modifier = parts.length > 0 ? `/${parts.join('/')}` : '' From 4bf93bff9873a8da2e751f53b9018ac812e56ea1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 16:06:44 +0200 Subject: [PATCH 11/37] move types to core --- .../src/codemods/template/migrate-arbitrary-utilities.ts | 2 +- .../src/codemods/template/migrate-arbitrary-variants.ts | 2 +- .../src/codemods/template/migrate-bare-utilities.ts | 2 +- .../src/codemods/template/migrate-deprecated-utilities.ts | 2 +- .../src/codemods/template/migrate-optimize-modifier.ts | 2 +- .../src/utils => tailwindcss/src}/types.ts | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename packages/{@tailwindcss-upgrade/src/utils => tailwindcss/src}/types.ts (100%) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts index 3007e18deee3..360a8bf9fd52 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -1,10 +1,10 @@ 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 type { Writable } from '../../../../tailwindcss/src/types' 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' 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..ae3afbad559e 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -1,7 +1,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import type { Writable } from '../../../../tailwindcss/src/types' import { replaceObject } from '../../utils/replace-object' -import type { Writable } from '../../utils/types' import { walkVariants } from '../../utils/walk-variants' import { computeVariantSignature, preComputedVariants } from './signatures' diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts index 475720191ae6..f8a8991686cc 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts @@ -1,8 +1,8 @@ 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 type { Writable } from '../../../../tailwindcss/src/types' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import type { Writable } from '../../utils/types' import { baseCandidate, parseCandidate } from './candidates' import { computeUtilitySignature, preComputedUtilities } from './signatures' diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts index 61ab5a49f3aa..9689bb7112d6 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts @@ -1,6 +1,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import type { Writable } from '../../utils/types' +import type { Writable } from '../../../../tailwindcss/src/types' import { baseCandidate, parseCandidate, printUnprefixedCandidate } from './candidates' import { computeUtilitySignature } from './signatures' diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts index f07b6680c4a9..aed80012ac03 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts @@ -1,7 +1,7 @@ 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 type { Writable } from '../../../../tailwindcss/src/types' import { computeUtilitySignature } from './signatures' // Optimize the modifier 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 From 5de55bd4bb305e24d597d8023878ba1a25fe5f0c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 16:08:03 +0200 Subject: [PATCH 12/37] move dimensions to core --- .../src/codemods/template/migrate-arbitrary-utilities.ts | 2 +- .../@tailwindcss-upgrade/src/codemods/template/signatures.ts | 2 +- .../utils/dimension.ts => tailwindcss/src/utils/dimensions.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/{@tailwindcss-upgrade/src/utils/dimension.ts => tailwindcss/src/utils/dimensions.ts} (100%) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts index 360a8bf9fd52..b0c1a35ff1e9 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -3,8 +3,8 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import type { Writable } from '../../../../tailwindcss/src/types' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { dimensions } from '../../../../tailwindcss/src/utils/dimensions' import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { dimensions } from '../../utils/dimension' import { baseCandidate, parseCandidate } from './candidates' import { computeUtilitySignature, preComputedUtilities } from './signatures' diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index 4c857fdf511a..e248b3db373b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -5,9 +5,9 @@ import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-par import { CompileAstFlags, type DesignSystem } from '../../../../tailwindcss/src/design-system' import { ThemeOptions } from '../../../../tailwindcss/src/theme' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { dimensions } from '../../../../tailwindcss/src/utils/dimensions' import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { dimensions } from '../../utils/dimension' // Given a utility, compute a signature that represents the utility. The // signature will be a normalised form of the generated CSS for the utility, or diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/tailwindcss/src/utils/dimensions.ts similarity index 100% rename from packages/@tailwindcss-upgrade/src/utils/dimension.ts rename to packages/tailwindcss/src/utils/dimensions.ts From 2c9a2c704f1eb4b065c9ef34f12da3c0d04c5833 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 16:20:56 +0200 Subject: [PATCH 13/37] move signatures to core --- .../template/migrate-arbitrary-utilities.ts | 5 ++++- .../migrate-arbitrary-value-to-bare-value.ts | 2 +- .../template/migrate-arbitrary-variants.ts | 5 ++++- .../template/migrate-bare-utilities.ts | 5 ++++- .../template/migrate-deprecated-utilities.ts | 2 +- .../migrate-drop-unnecessary-data-types.ts | 2 +- .../migrate-modernize-arbitrary-values.ts | 2 +- .../template/migrate-optimize-modifier.ts | 2 +- .../src/codemods/template/migrate.ts | 2 +- .../src}/signatures.ts | 20 +++++++++---------- 10 files changed, 28 insertions(+), 19 deletions(-) rename packages/{@tailwindcss-upgrade/src/codemods/template => tailwindcss/src}/signatures.ts (95%) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts index b0c1a35ff1e9..af5f81bdc398 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -1,12 +1,15 @@ 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 { + computeUtilitySignature, + preComputedUtilities, +} from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { dimensions } from '../../../../tailwindcss/src/utils/dimensions' import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { baseCandidate, parseCandidate } from './candidates' -import { computeUtilitySignature, preComputedUtilities } from './signatures' const baseReplacementsCache = new DefaultMap>( () => new Map(), diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts index 116888ca39c3..d942ce028256 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts @@ -5,13 +5,13 @@ import { } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' 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, 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 ae3afbad559e..3255d5d2c842 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 { + computeVariantSignature, + preComputedVariants, +} from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' import { replaceObject } from '../../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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts index f8a8991686cc..f5bc17327e8f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts @@ -1,10 +1,13 @@ 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 { + computeUtilitySignature, + preComputedUtilities, +} from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { baseCandidate, parseCandidate } from './candidates' -import { computeUtilitySignature, preComputedUtilities } from './signatures' const baseReplacementsCache = new DefaultMap>( () => new Map(), diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts index 9689bb7112d6..5a8688e8f3eb 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts @@ -1,8 +1,8 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' import { baseCandidate, parseCandidate, printUnprefixedCandidate } from './candidates' -import { computeUtilitySignature } from './signatures' const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) 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 index ae32458ef275..1d42c6850241 100644 --- 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 @@ -1,6 +1,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { computeUtilitySignature } from './signatures' +import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' export function migrateDropUnnecessaryDataTypes( designSystem: DesignSystem, 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..8cda07ff0d84 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 @@ -2,11 +2,11 @@ import SelectorParser from 'postcss-selector-parser' import { parseCandidate, type Variant } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { computeVariantSignature } from '../../../../tailwindcss/src/signatures' import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { replaceObject } from '../../utils/replace-object' import { walkVariants } from '../../utils/walk-variants' -import { computeVariantSignature } from './signatures' export function migrateModernizeArbitraryValues( designSystem: DesignSystem, diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts index aed80012ac03..d5296cd49e1c 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts @@ -1,8 +1,8 @@ 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 { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' -import { computeUtilitySignature } from './signatures' // Optimize the modifier // diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 64696b86d556..9214107c3d3e 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -2,6 +2,7 @@ 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' @@ -24,7 +25,6 @@ import { migrateOptimizeModifier } from './migrate-optimize-modifier' import { migratePrefix } from './migrate-prefix' import { migrateSimpleLegacyClasses } from './migrate-simple-legacy-classes' import { migrateVariantOrder } from './migrate-variant-order' -import { computeUtilitySignature } from './signatures' export type Migration = ( designSystem: DesignSystem, diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/tailwindcss/src/signatures.ts similarity index 95% rename from packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts rename to packages/tailwindcss/src/signatures.ts index e248b3db373b..ead120c88958 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 { dimensions } from '../../../../tailwindcss/src/utils/dimensions' -import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import { substituteAtApply } from './apply' +import { atRule, styleRule, toCss, walk, type AstNode } from './ast' +import { printArbitraryValue } from './candidate' +import * as SelectorParser from './compat/selector-parser' +import { CompileAstFlags, type DesignSystem } from './design-system' +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 From 34cb8b8e9d6911a68f7c42a85de152ca60983ca2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 16:42:13 +0200 Subject: [PATCH 14/37] ensure `canonicalizeCandidates` returns a unique list If you use `canonicalizeCandidates(['bg-red-500', 'bg-red-500/100'])` they would both canonicalize to `bg-red-500`. No need to return duplicates here. --- packages/tailwindcss/src/canonicalize-candidates.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 2f8b7e2104f1..0f67e224a791 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -8,9 +8,11 @@ import { toKeyPath } from './utils/to-key-path' import * as ValueParser from './value-parser' export function canonicalizeCandidates(ds: DesignSystem, candidates: string[]): string[] { - return candidates.map((candidate) => { - return canonicalizeCandidateCache.get(ds).get(candidate) - }) + let result = new Set() + for (let candidate of candidates) { + result.add(canonicalizeCandidateCache.get(ds).get(candidate)) + } + return Array.from(result) } const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { From 0c3d48e5112b5f48950f3d4066db5b8f82437316 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 19:36:34 +0200 Subject: [PATCH 15/37] move `migrate-arbitrary-utilities` to core Note: this commit on its own _will_ have some failing tests because the `migrate-arbitrary-utilities.test.ts` file combined some other migrations as well. Eventually once those are ported over, will make the tests pass. Another thing to notice is that we updated some tests. The more we canonicalize candidates, the more tests will need upgrading to an even more canonical form. --- .../migrate-arbitrary-utilities.test.ts | 349 ------------------ .../template/migrate-arbitrary-utilities.ts | 289 --------------- .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 285 ++++++++++++-- .../src/canonicalize-candidates.ts | 303 ++++++++++++++- 5 files changed, 561 insertions(+), 667 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts 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 af5f81bdc398..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ /dev/null @@ -1,289 +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 { - computeUtilitySignature, - preComputedUtilities, -} from '../../../../tailwindcss/src/signatures' -import type { Writable } from '../../../../tailwindcss/src/types' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { dimensions } from '../../../../tailwindcss/src/utils/dimensions' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { baseCandidate, parseCandidate } from './candidates' - -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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 9214107c3d3e..fde829d2695c 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -7,7 +7,6 @@ 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' @@ -43,7 +42,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateVariantOrder, // sync, v3 → v4, Has to happen before migrations that modify variants migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) - migrateArbitraryUtilities, // sync, v4 migrateBareValueUtilities, // sync, v4 migrateDeprecatedUtilities, // sync, v4 (deprecation map, order-none → order-0) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 9bea4f968bdf..dfef9c21d668 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -97,7 +97,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', /// theme(…) to `var(…)` // Keep candidates that don't contain `theme(…)` or `theme(…, …)` - ['[color:red]', '[color:red]'], + ['[color:red]', 'text-[red]'], // Handle special cases around `.1` in the `theme(…)` ['[--value:theme(spacing.1)]', '[--value:--spacing(1)]'], @@ -108,10 +108,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['[--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 + ['[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. @@ -128,10 +128,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // 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))]', - ], + ['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)]'], @@ -145,41 +142,41 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // 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))]', + 'text-[--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', + '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%)]', '[color:var(--color-red-500)]/75'], + ['[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)]', '[color:var(--color-red-500)]/12'], - ['[color:theme(colors.red.500/0.12)]', '[color:var(--color-red-500)]/12'], + ['[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%)]', '[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%]'], + ['[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/[12.345]'], + ['[color:theme(colors.red.500/50.25%)]', 'text-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%]'], + ['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', '[color:--theme(--color-red-500/50%)]/50'], + ['[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-[shadow:inset_0px_1px_--theme(--color-white/15%)]', + 'shadow-[inset_0px_1px_--theme(--color-white/15%)]', ], // Arbitrary value, where the candidate already contains a modifier @@ -205,7 +202,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // Renamed theme keys ['max-w-[theme(screens.md)]', 'max-w-(--breakpoint-md)'], - ['w-[theme(maxWidth.md)]', 'w-(--container-md)'], + ['w-[theme(maxWidth.md)]', 'w-md'], // Invalid cases ['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'], @@ -229,6 +226,248 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 0f67e224a791..c4d5d567f1f2 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,7 +1,10 @@ -import { parseCandidate, type Candidate, type CandidateModifier, type Variant } from './candidate' +import { printModifier, type Candidate, type CandidateModifier, type Variant } from './candidate' import { keyPathToCssProperty } from './compat/apply-config-to-theme' import type { DesignSystem } from './design-system' +import { computeUtilitySignature, preComputedUtilities } from './signatures' +import type { Writable } from './types' import { DefaultMap } from './utils/default-map' +import { dimensions } from './utils/dimensions' import { isValidSpacingMultiplier } from './utils/infer-data-type' import { segment } from './utils/segment' import { toKeyPath } from './utils/to-key-path' @@ -28,7 +31,7 @@ const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { }) }) -const CANONICALIZATIONS = [bgGradientToLinear, themeToVar, print] +const CANONICALIZATIONS = [bgGradientToLinear, themeToVar, arbitraryUtilities, print] function print(designSystem: DesignSystem, rawCandidate: string): string { for (let candidate of designSystem.parseCandidate(rawCandidate)) { @@ -69,8 +72,8 @@ const enum Convert { function themeToVar(designSystem: DesignSystem, rawCandidate: string): string { let convert = converterCache.get(designSystem) - for (let candidate of parseCandidate(rawCandidate, designSystem)) { - let clone = structuredClone(candidate) + for (let candidate of parseCandidate(designSystem, rawCandidate)) { + let clone = structuredClone(candidate) as Writable let changed = false if (clone.kind === 'arbitrary') { @@ -401,3 +404,295 @@ function* walkVariants(candidate: Candidate) { yield* inner(variant, null) } } + +function baseCandidate(candidate: T) { + let base = structuredClone(candidate) + + base.important = false + base.variants = [] + + return base +} + +function parseCandidate(designSystem: DesignSystem, input: string) { + return designSystem.parseCandidate( + designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`) + ? `${designSystem.theme.prefix}:${input}` + : input, + ) +} + +// ---- + +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 + }) +}) + +function arbitraryUtilities(designSystem: DesignSystem, 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 +} From f0884592296e401254b62a212762bf3183537b78 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 20:21:29 +0200 Subject: [PATCH 16/37] move `migrate-bare-utilities` to core --- .../template/migrate-bare-utilities.test.ts | 72 ---------- .../template/migrate-bare-utilities.ts | 125 ------------------ .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 26 ++++ .../src/canonicalize-candidates.ts | 117 +++++++++++++++- 5 files changed, 142 insertions(+), 200 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts 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 f5bc17327e8f..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts +++ /dev/null @@ -1,125 +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 { - computeUtilitySignature, - preComputedUtilities, -} from '../../../../tailwindcss/src/signatures' -import type { Writable } from '../../../../tailwindcss/src/types' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { baseCandidate, parseCandidate } from './candidates' - -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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index fde829d2695c..573eeab447a4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -10,7 +10,6 @@ import { isSafeMigration } from './is-safe-migration' 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 { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value' import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' import { migrateDeprecatedUtilities } from './migrate-deprecated-utilities' @@ -42,7 +41,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateVariantOrder, // sync, v3 → v4, Has to happen before migrations that modify variants migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) - migrateBareValueUtilities, // sync, v4 migrateDeprecatedUtilities, // sync, v4 (deprecation map, order-none → order-0) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? migrateArbitraryVariants, // sync, v4 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index dfef9c21d668..42acb724927f 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -468,6 +468,32 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index c4d5d567f1f2..84ba650ebee2 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -31,7 +31,13 @@ const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { }) }) -const CANONICALIZATIONS = [bgGradientToLinear, themeToVar, arbitraryUtilities, print] +const CANONICALIZATIONS = [ + bgGradientToLinear, + themeToVar, + arbitraryUtilities, + bareValueUtilities, + print, +] function print(designSystem: DesignSystem, rawCandidate: string): string { for (let candidate of designSystem.parseCandidate(rawCandidate)) { @@ -696,3 +702,112 @@ function allVariablesAreUsed( return isSafeMigration } + +// ---- + +function bareValueUtilities(designSystem: DesignSystem, 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 + } + } + } +} From ae5909c2a457ae0feef5eaa2d2111a401b0624a9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 20:39:32 +0200 Subject: [PATCH 17/37] move `migrate-deprecated-utilities` to core --- .../template/migrate-deprecated-utilities.ts | 52 ------------------ .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 28 ++++++++++ .../src/canonicalize-candidates.ts | 54 +++++++++++++++++++ 4 files changed, 82 insertions(+), 54 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-deprecated-utilities.ts 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 5a8688e8f3eb..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 { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' -import type { Writable } from '../../../../tailwindcss/src/types' -import { baseCandidate, parseCandidate, printUnprefixedCandidate } from './candidates' - -const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) - -export function migrateDeprecatedUtilities( - designSystem: DesignSystem, - _userConfig: Config | null, - rawCandidate: string, -): string { - 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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 573eeab447a4..602816614f79 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -12,7 +12,6 @@ import { migrateArbitraryVariants } from './migrate-arbitrary-variants' import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' 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' @@ -41,7 +40,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateVariantOrder, // sync, v3 → v4, Has to happen before migrations that modify variants migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) - migrateDeprecatedUtilities, // sync, v4 (deprecation map, order-none → order-0) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? migrateArbitraryVariants, // sync, v4 migrateDropUnnecessaryDataTypes, // sync, v4 (I think this can be dropped?) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 42acb724927f..927d6bd92a58 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -494,6 +494,34 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 84ba650ebee2..a056f2890bd1 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -36,6 +36,7 @@ const CANONICALIZATIONS = [ themeToVar, arbitraryUtilities, bareValueUtilities, + deprecatedUtilities, print, ] @@ -428,6 +429,14 @@ function parseCandidate(designSystem: DesignSystem, input: string) { ) } +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 baseReplacementsCache = new DefaultMap>( @@ -811,3 +820,48 @@ function bareValueUtilities(designSystem: DesignSystem, rawCandidate: string): s } } } + +// ---- + +const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) + +function deprecatedUtilities(designSystem: DesignSystem, rawCandidate: string): string { + 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 +} From e5352d34e315cfc20ce875592332a773e0096b8c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 21:00:42 +0200 Subject: [PATCH 18/37] move `replaceObject` to core --- .../src/codemods/template/migrate-arbitrary-variants.ts | 2 +- .../src/codemods/template/migrate-modernize-arbitrary-values.ts | 2 +- .../src/utils/replace-object.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/{@tailwindcss-upgrade => tailwindcss}/src/utils/replace-object.ts (100%) 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 3255d5d2c842..fb1e9948e963 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -5,7 +5,7 @@ import { preComputedVariants, } from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' -import { replaceObject } from '../../utils/replace-object' +import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' import { walkVariants } from '../../utils/walk-variants' export function migrateArbitraryVariants( 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 8cda07ff0d84..23d352ccecfd 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 @@ -4,8 +4,8 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { computeVariantSignature } from '../../../../tailwindcss/src/signatures' import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' +import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { replaceObject } from '../../utils/replace-object' import { walkVariants } from '../../utils/walk-variants' export function migrateModernizeArbitraryValues( 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 From 2863c603b68c205614b49e913dc874adbbfaef77 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 21:00:55 +0200 Subject: [PATCH 19/37] move `migrate-arbitrary-variants` to core --- .../migrate-arbitrary-variants.test.ts | 158 ------------------ .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 115 +++++++++++++ .../src/canonicalize-candidates.ts | 46 ++++- 4 files changed, 160 insertions(+), 161 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts 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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 602816614f79..0ad63e1b91c1 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -8,7 +8,6 @@ import { spliceChangesIntoString, type StringChange } from '../../utils/splice-c import { extractRawCandidates } from './candidates' import { isSafeMigration } from './is-safe-migration' import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value' -import { migrateArbitraryVariants } from './migrate-arbitrary-variants' import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value' import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' @@ -41,7 +40,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? - migrateArbitraryVariants, // sync, v4 migrateDropUnnecessaryDataTypes, // sync, v4 (I think this can be dropped?) migrateArbitraryValueToBareValue, // sync, v4 (optimization) migrateOptimizeModifier, // sync, v4 (optimization) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 927d6bd92a58..d52c95a5112e 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -522,6 +522,121 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index a056f2890bd1..44943b7fc2de 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,11 +1,17 @@ import { printModifier, type Candidate, type CandidateModifier, type Variant } from './candidate' import { keyPathToCssProperty } from './compat/apply-config-to-theme' import type { DesignSystem } from './design-system' -import { computeUtilitySignature, preComputedUtilities } from './signatures' +import { + computeUtilitySignature, + computeVariantSignature, + preComputedUtilities, + preComputedVariants, +} from './signatures' import type { Writable } from './types' import { DefaultMap } from './utils/default-map' import { dimensions } from './utils/dimensions' import { 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' @@ -37,6 +43,7 @@ const CANONICALIZATIONS = [ arbitraryUtilities, bareValueUtilities, deprecatedUtilities, + arbitraryVariants, print, ] @@ -865,3 +872,40 @@ function deprecatedUtilities(designSystem: DesignSystem, rawCandidate: string): return rawCandidate } + +// ---- + +function arbitraryVariants(designSystem: DesignSystem, rawCandidate: string): string { + let signatures = computeVariantSignature.get(designSystem) + let variants = preComputedVariants.get(designSystem) + + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { + // We are only interested in the variants + if (readonlyCandidate.variants.length <= 0) return rawCandidate + + // The below logic makes use of mutation. Since candidates in the + // DesignSystem are cached, we can't mutate them directly. + let candidate = structuredClone(readonlyCandidate) as Writable + + for (let [variant] of walkVariants(candidate)) { + if (variant.kind === 'compound') continue + + let targetString = designSystem.printVariant(variant) + let targetSignature = signatures.get(targetString) + if (typeof targetSignature !== 'string') continue + + let foundVariants = variants.get(targetSignature) + if (foundVariants.length !== 1) continue + + let foundVariant = foundVariants[0] + let parsedVariant = designSystem.parseVariant(foundVariant) + if (parsedVariant === null) continue + + replaceObject(variant, parsedVariant) + } + + return designSystem.printCandidate(candidate) + } + + return rawCandidate +} From 0eaf373fa109368c62d9e2cd50da7e515556a59b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 21:12:27 +0200 Subject: [PATCH 20/37] move `migrate-drop-unnecessary-data-types` to core --- ...igrate-drop-unnecessary-data-types.test.ts | 63 ------------------- .../migrate-drop-unnecessary-data-types.ts | 30 --------- .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 23 +++++++ .../src/canonicalize-candidates.ts | 26 ++++++++ 5 files changed, 49 insertions(+), 95 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts 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 1d42c6850241..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 '../../../../tailwindcss/src/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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 0ad63e1b91c1..4f725bc75c8a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -11,7 +11,6 @@ import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-b import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value' import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' -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' @@ -40,7 +39,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? - migrateDropUnnecessaryDataTypes, // sync, v4 (I think this can be dropped?) migrateArbitraryValueToBareValue, // sync, v4 (optimization) migrateOptimizeModifier, // sync, v4 (optimization) ] diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index d52c95a5112e..b5666d88ff9b 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -637,6 +637,29 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 44943b7fc2de..04b0ca5581cc 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -44,6 +44,7 @@ const CANONICALIZATIONS = [ bareValueUtilities, deprecatedUtilities, arbitraryVariants, + dropUnnecessaryDataTypes, print, ] @@ -909,3 +910,28 @@ function arbitraryVariants(designSystem: DesignSystem, rawCandidate: string): st return rawCandidate } + +// ---- + +function dropUnnecessaryDataTypes(designSystem: DesignSystem, 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 +} From 683ffc5a0cc4c3a120b319497067463cb3d8353e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 22:28:00 +0200 Subject: [PATCH 21/37] move `migrate-arbitrary-value-to-bare-value` to core --- ...rate-arbitrary-value-to-bare-value.test.ts | 68 ------- .../migrate-arbitrary-value-to-bare-value.ts | 180 ------------------ .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 66 +++++++ .../src/canonicalize-candidates.ts | 175 ++++++++++++++++- 5 files changed, 239 insertions(+), 252 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts 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 d942ce028256..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 { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' -import { - isPositiveInteger, - isValidSpacingMultiplier, -} from '../../../../tailwindcss/src/utils/infer-data-type' -import { segment } from '../../../../tailwindcss/src/utils/segment' -import { walkVariants } from '../../utils/walk-variants' - -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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 4f725bc75c8a..bd417ee59051 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -7,7 +7,6 @@ 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 { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value' import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value' import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' @@ -39,7 +38,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? - migrateArbitraryValueToBareValue, // sync, v4 (optimization) migrateOptimizeModifier, // sync, v4 (optimization) ] diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index b5666d88ff9b..74533da1122c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -660,6 +660,72 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 04b0ca5581cc..09b2729bf612 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,4 +1,10 @@ -import { printModifier, type Candidate, type CandidateModifier, type Variant } from './candidate' +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 { @@ -10,7 +16,7 @@ import { import type { Writable } from './types' import { DefaultMap } from './utils/default-map' import { dimensions } from './utils/dimensions' -import { isValidSpacingMultiplier } from './utils/infer-data-type' +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' @@ -45,6 +51,7 @@ const CANONICALIZATIONS = [ deprecatedUtilities, arbitraryVariants, dropUnnecessaryDataTypes, + arbitraryValueToBareValue, print, ] @@ -935,3 +942,167 @@ function dropUnnecessaryDataTypes(designSystem: DesignSystem, rawCandidate: stri return rawCandidate } + +// ---- + +function arbitraryValueToBareValue(designSystem: DesignSystem, rawCandidate: string): string { + let signatures = computeUtilitySignature.get(designSystem) + + for (let candidate of parseCandidate(designSystem, rawCandidate)) { + let clone = structuredClone(candidate) as Writable + 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) + } +} From 1c02690668a015d754e19d2e7a9098dd9d8e4005 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Sep 2025 22:34:44 +0200 Subject: [PATCH 22/37] move `migrate-optimize-modifier` to core --- .../migrate-optimize-modifier.test.ts | 68 ---------------- .../template/migrate-optimize-modifier.ts | 80 ------------------- .../src/codemods/template/migrate.ts | 2 - .../src/canonicalize-candidates.test.ts | 34 +++++++- .../src/canonicalize-candidates.ts | 74 +++++++++++++++++ 5 files changed, 106 insertions(+), 152 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts 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 d5296cd49e1c..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 { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' -import type { Writable } from '../../../../tailwindcss/src/types' - -// 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.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index bd417ee59051..b3597acb1de4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -15,7 +15,6 @@ 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 { migrateVariantOrder } from './migrate-variant-order' @@ -38,7 +37,6 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateAutomaticVarInjection, // sync, v3 → v4 migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? - migrateOptimizeModifier, // sync, v4 (optimization) ] let migrateCached = new DefaultMap< diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 74533da1122c..45b363565ab0 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -162,8 +162,8 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // 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/[12.345]'], - ['[color:theme(colors.red.500/50.25%)]', 'text-red-500/[50.25%]'], + ['[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'], @@ -726,6 +726,36 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', 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', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 09b2729bf612..9c3229f7eb60 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -52,6 +52,7 @@ const CANONICALIZATIONS = [ arbitraryVariants, dropUnnecessaryDataTypes, arbitraryValueToBareValue, + optimizeModifier, print, ] @@ -1106,3 +1107,76 @@ function* tryValueReplacements( yield* tryValueReplacements(candidate, option, seen) } } + +// ---- + +// Optimize the modifier +// +// E.g.: +// +// - `/[25%]` → `/25` +// - `/[100%]` → `/100` → +// - `/100` → +// +function optimizeModifier(designSystem: DesignSystem, 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 +} From 760aef88cb93c84f2eb86a61b439455d0093c504 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 2 Oct 2025 12:46:58 +0200 Subject: [PATCH 23/37] remove `!` from test case This test setup already tests various cases including prefixes and important strategies. --- packages/tailwindcss/src/canonicalize-candidates.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 45b363565ab0..9c2cdf6ca89d 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -714,7 +714,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['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!'], + ['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', From d5ba9330a2dfff3f57075e14a94cb4080782bc4e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 3 Oct 2025 12:56:12 +0200 Subject: [PATCH 24/37] handle `&` and `*` in selector parser as standalone selector We were also converting the current charcode back to a string, but we already had that information in `input[i]` --- .../src/compat/selector-parser.test.ts | 28 ++++++++++++++++ .../tailwindcss/src/compat/selector-parser.ts | 33 ++++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/compat/selector-parser.test.ts b/packages/tailwindcss/src/compat/selector-parser.test.ts index f995c9de72c5..2a22835226f5 100644 --- a/packages/tailwindcss/src/compat/selector-parser.test.ts +++ b/packages/tailwindcss/src/compat/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/compat/selector-parser.ts index 77a1ce4d4b49..b25fd6cc9c2d 100644 --- a/packages/tailwindcss/src/compat/selector-parser.ts +++ b/packages/tailwindcss/src/compat/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,7 +371,7 @@ export function parse(input: string) { ast.push(node) } } - buffer = String.fromCharCode(currentChar) + buffer = input[i] break } @@ -443,17 +445,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] } } } From 80029cd1a4d0e4df59efc4ffc4b3f5cee41cc659 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 3 Oct 2025 13:21:07 +0200 Subject: [PATCH 25/37] move parts of `migrate-modernize-arbitrary-values` to core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part where we upgrade old v3 syntax like `group-[]:` still exists in the `@tailwindcss/upgrade` package. We were relying on `postcss-selector-parser`, but the copied part is now using our own `SelectorParser`. I also added a temporary attribute selector parser to make everything pass. Don't look at the code for that _too_ much because we will swap it out in a later commit 😅 --- ...migrate-modernize-arbitrary-values.test.ts | 88 --- .../migrate-modernize-arbitrary-values.ts | 328 +---------- .../src/canonicalize-candidates.test.ts | 98 ++++ .../src/canonicalize-candidates.ts | 514 +++++++++++++++++- 4 files changed, 613 insertions(+), 415 deletions(-) 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..91906caa630b 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 @@ -22,29 +22,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,71 +36,6 @@ 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, 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 23d352ccecfd..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,11 +1,7 @@ -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 { computeVariantSignature } from '../../../../tailwindcss/src/signatures' -import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { walkVariants } from '../../utils/walk-variants' export function migrateModernizeArbitraryValues( @@ -13,13 +9,11 @@ export function migrateModernizeArbitraryValues( _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/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 9c2cdf6ca89d..811de77f5dc0 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -727,6 +727,104 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', }) }) + 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'; diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 9c3229f7eb60..3e84d49597aa 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -6,6 +6,7 @@ import { type Variant, } from './candidate' import { keyPathToCssProperty } from './compat/apply-config-to-theme' +import * as SelectorParser from './compat/selector-parser' import type { DesignSystem } from './design-system' import { computeUtilitySignature, @@ -49,9 +50,10 @@ const CANONICALIZATIONS = [ arbitraryUtilities, bareValueUtilities, deprecatedUtilities, - arbitraryVariants, dropUnnecessaryDataTypes, arbitraryValueToBareValue, + modernizeArbitraryValues, + arbitraryVariants, optimizeModifier, print, ] @@ -1110,6 +1112,516 @@ function* tryValueReplacements( // ---- +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 isAsciiWhitespace(char: string) { + return char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '\f' +} + +enum AttributePart { + Start, + Attribute, + Value, + Modifier, + End, +} + +function parseAttributeSelector(value: string) { + let attribute = { + key: '', + operator: null as '=' | '~=' | '|=' | '^=' | '$=' | '*=' | null, + quote: '', + value: null as string | null, + modifier: null as 'i' | 's' | null, + } + + let state = AttributePart.Start + outer: for (let i = 0; i < value.length; i++) { + // Skip whitespace + if (isAsciiWhitespace(value[i])) { + if (attribute.quote === '' && state !== AttributePart.Value) { + continue + } + } + + switch (state) { + case AttributePart.Start: { + if (value[i] === '[') { + state = AttributePart.Attribute + } else { + return null + } + break + } + + case AttributePart.Attribute: { + switch (value[i]) { + case ']': { + return attribute + } + + case '=': { + attribute.operator = '=' + state = AttributePart.Value + continue outer + } + + case '~': + case '|': + case '^': + case '$': + case '*': { + if (value[i + 1] === '=') { + attribute.operator = (value[i] + '=') as '=' | '~=' | '|=' | '^=' | '$=' | '*=' + i++ + state = AttributePart.Value + continue outer + } + + return null + } + } + + attribute.key += value[i] + break + } + + case AttributePart.Value: { + // End of attribute selector + if (value[i] === ']') { + return attribute + } + + // Quoted value + else if (value[i] === "'" || value[i] === '"') { + attribute.value ??= '' + + attribute.quote = value[i] + + for (let j = i + 1; j < value.length; j++) { + if (value[j] === '\\' && j + 1 < value.length) { + // Skip the escaped character + j++ + attribute.value += value[j] + } else if (value[j] === attribute.quote) { + i = j + state = AttributePart.Modifier + continue outer + } else { + attribute.value += value[j] + } + } + } + + // Unquoted value + else { + if (isAsciiWhitespace(value[i])) { + state = AttributePart.Modifier + } else { + attribute.value ??= '' + attribute.value += value[i] + } + } + break + } + + case AttributePart.Modifier: { + if (value[i] === 'i' || value[i] === 's') { + attribute.modifier = value[i] as 'i' | 's' + state = AttributePart.End + } else if (value[i] == ']') { + return attribute + } + break + } + + case AttributePart.End: { + if (value[i] === ']') { + return attribute + } + break + } + } + } + + return attribute +} + +function modernizeArbitraryValues(designSystem: DesignSystem, rawCandidate: string): string { + let signatures = computeVariantSignature.get(designSystem) + + for (let candidate of parseCandidate(designSystem, rawCandidate)) { + let clone = structuredClone(candidate) + let changed = false + + for (let [variant, parent] of walkVariants(clone)) { + // 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 === '*' + ) { + changed = true + 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 === '*' + ) { + 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.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 + + 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-[${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) { + 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.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 + changed = true + replaceObject(variant, structuredClone(parsed)) + } + + // Expecting an attribute selector + else if (isAttributeSelector(target)) { + let attribute = parseAttributeSelector(target.value) + if (attribute === null) continue // Invalid attribute selector + + // Migrate `data-*` + if (attribute.key.startsWith('data-')) { + changed = true + let name = attribute.key.slice(5) // Remove `data-` + + replaceObject(variant, { + kind: 'functional', + root: 'data', + modifier: null, + value: + attribute.value === null + ? { kind: 'named', value: name } + : { + kind: 'arbitrary', + value: `${name}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, + }, + } satisfies Variant) + } + + // Migrate `aria-*` + else if (attribute.key.startsWith('aria-')) { + changed = true + let name = attribute.key.slice(5) // Remove `aria-` + replaceObject(variant, { + kind: 'functional', + root: 'aria', + modifier: null, + value: + attribute.value === null + ? { kind: 'arbitrary', value: name } // aria-[foo] + : attribute.operator === '=' && + attribute.value === 'true' && + attribute.modifier === null + ? { kind: 'named', value: name } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] + : { + kind: 'arbitrary', + value: `${attribute.key}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, + }, // 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 + } + + return rawCandidate +} + +// ---- + // Optimize the modifier // // E.g.: From 02ba94f77a0534190ac0f930d9329965a98b7ff2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 3 Oct 2025 16:28:20 +0200 Subject: [PATCH 26/37] ensure we canonicalize at the end A bunch of migrations were moved to `canonicalizeCandidates`, so we still have to run that at the end. Also used: ```css @theme { --*: initial; } ``` To make the design system a bit faster. --- ...migrate-modernize-arbitrary-values.test.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 91906caa630b..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) } @@ -37,9 +42,15 @@ test.each([ ['not-group-[]:flex', 'not-in-[.group]:flex'], ['not-group-[]/name:flex', 'not-in-[.group\\/name]: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) }) @@ -66,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) }) From c956b0804f66a33c17c992c636fa62fba7cd871b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 3 Oct 2025 23:13:17 +0200 Subject: [PATCH 27/37] big refactor Note: we made sure that all the tests passed before this commit, and we also made sure to not touch any of the tests as part of this commit ensuring that everything still works as expected. Aka, an actual refactor without behavior change. This is a big commit and a big refactor but that's mainly because there are some re-indents that are happening. Will maybe split up this commit but then it won't be an atomic (working) commit anymore. One thing I noticed is that all our "migrations" essentially parsed a raw candidate, did some work and then stringified the candidate AST again. That's one of the reasons I inlined the migrations in previous commits in a single file. Looking at the file now, it's a little bit silly that we parse and print over and over again. Parsing a raw candidate also results in an an array of `Candidate` AST nodes, so we always had a loop going on. Another thing I noticed is that often we need to work with a "base" candidate, that's essentially the candidate without the variants and without the important flag. That's where this refactor comes in: 1. We parse each candidate _once_ 2. When there are variants, we canonicalize each variant separately and store the results in a separate cache. 3. When there are variants _or_ the important flag is present, we canonicalize the base utility separately and later re-attach the variants and important flag at the end. This means that not a single canonicalization step has to deal with variants or important flag. 4. Since we only want to parse the candidate once on the outside, we will pass a cloned `Candidate` to each canonicalize step to prevent parsing and printing over and over again. 5. Only at the end will we re-print the `Candidate` This process has a few benefits: 1. We are handling candidates and variants separately and caching them separately. This means that we can increase cache hits because variants from candidate A and candidate B can be re-used. E.g.: `[@media_print]:flex` and `[@media_print]:underline` will map to `print:flex` and `print:underline` where `[@media_print]` is only handled once. 2. This also means that we could simplify some canonicalization steps because they don't have to worry about variants and the important flag. 3. There is no parsing & looping over the parsed candidates array going on. --- .../src/canonicalize-candidates.ts | 1368 ++++++++--------- 1 file changed, 658 insertions(+), 710 deletions(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 3e84d49597aa..63b4ddf88efd 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -25,65 +25,146 @@ 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(canonicalizeCandidateCache.get(ds).get(candidate)) + result.add(cache.get(candidate)) } return Array.from(result) } const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { - return new DefaultMap((candidate: string) => { - let result = candidate - for (let fn of CANONICALIZATIONS) { - let newResult = fn(ds, result) - if (newResult !== result) { - result = newResult + 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 result + return replacement }) }) -const CANONICALIZATIONS = [ +const UTILITY_CANONICALIZATIONS = [ bgGradientToLinear, - themeToVar, + themeToVarUtility, arbitraryUtilities, bareValueUtilities, deprecatedUtilities, dropUnnecessaryDataTypes, - arbitraryValueToBareValue, - modernizeArbitraryValues, - arbitraryVariants, + arbitraryValueToBareValueUtility, optimizeModifier, - print, ] -function print(designSystem: DesignSystem, rawCandidate: string): string { - for (let candidate of designSystem.parseCandidate(rawCandidate)) { - return designSystem.printCandidate(candidate) - } - return rawCandidate -} +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: DesignSystem, 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) +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)) { - continue - } - - return designSystem.printCandidate({ - ...candidate, - root: `bg-linear-to-${direction}`, - }) + if (!DIRECTIONS.includes(direction)) { + return candidate } + + candidate.root = `bg-linear-to-${direction}` + return candidate } - return rawCandidate + + return candidate } // ---- @@ -94,62 +175,57 @@ const enum Convert { MigrateThemeOnly = 1 << 1, } -function themeToVar(designSystem: DesignSystem, rawCandidate: string): string { +function themeToVarUtility(designSystem: DesignSystem, candidate: Candidate): Candidate { let convert = converterCache.get(designSystem) - for (let candidate of parseCandidate(designSystem, rawCandidate)) { - let clone = structuredClone(candidate) as Writable - 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 (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) { - clone.modifier = modifier - } + if (modifier !== null) { + candidate.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 + } + } 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) { - clone.modifier = modifier - } + if (modifier !== null) { + candidate.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 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 changed ? designSystem.printCandidate(clone) : rawCandidate } - return rawCandidate + return variant } const converterCache = new DefaultMap((ds: DesignSystem) => { @@ -413,7 +489,7 @@ function eventuallyUnquote(value: string) { // ---- -function* walkVariants(candidate: Candidate) { +function* walkVariants(variant: Variant) { function* inner( variant: Variant, parent: Extract | null = null, @@ -425,18 +501,7 @@ function* walkVariants(candidate: Candidate) { } } - for (let variant of candidate.variants) { - yield* inner(variant, null) - } -} - -function baseCandidate(candidate: T) { - let base = structuredClone(candidate) - - base.important = false - base.variants = [] - - return base + yield* inner(variant, null) } function parseCandidate(designSystem: DesignSystem, input: string) { @@ -457,10 +522,6 @@ function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candida // ---- -const baseReplacementsCache = new DefaultMap>( - () => new Map(), -) - const spacing = new DefaultMap | null>((ds) => { let spacingMultiplier = ds.resolveThemeValue('--spacing') if (spacingMultiplier === undefined) return null @@ -481,78 +542,47 @@ const spacing = new DefaultMap | }) }) -function arbitraryUtilities(designSystem: DesignSystem, rawCandidate: string): string { +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) - 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 - } + let targetCandidateString = designSystem.printCandidate(candidate) - // 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 + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (typeof targetSignature !== 'string') return candidate - // 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) + // 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 } - // 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 + // Ensure that if CSS variables were used, that they are still used + if (!allVariablesAreUsed(designSystem, candidate, replacementCandidate)) { + continue + } - // Update the candidate with the new value - Object.assign(candidate, replacementCandidate) + // Update the candidate with the new value + replaceObject(candidate, replacementCandidate) - // We will re-print the candidate to get the migrated candidate out - return designSystem.printCandidate(candidate) - } + // We will re-print the candidate to get the migrated candidate out + return candidate } - return rawCandidate + return candidate function* tryReplacements( targetSignature: string, @@ -732,68 +762,37 @@ function allVariablesAreUsed( // ---- -function bareValueUtilities(designSystem: DesignSystem, rawCandidate: string): string { +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) - 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 + let targetCandidateString = designSystem.printCandidate(candidate) - // 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) + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (typeof targetSignature !== 'string') return 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) + // 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 } - // 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) + // Update the candidate with the new value + replaceObject(candidate, replacementCandidate) - // We will re-print the candidate to get the migrated candidate out - return designSystem.printCandidate(candidate) - } + // We will re-print the candidate to get the migrated candidate out + return candidate } - return rawCandidate + return candidate function* tryReplacements( targetSignature: string, @@ -843,199 +842,167 @@ function bareValueUtilities(designSystem: DesignSystem, rawCandidate: string): s const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) -function deprecatedUtilities(designSystem: DesignSystem, rawCandidate: string): string { +function deprecatedUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { 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 targetCandidateString = printUnprefixedCandidate(designSystem, candidate) - let replacementString = DEPRECATION_MAP.get(targetCandidateString) ?? null - if (replacementString === null) return rawCandidate + let replacementString = DEPRECATION_MAP.get(targetCandidateString) ?? null + if (replacementString === null) return candidate - let legacySignature = signatures.get(targetCandidateString) - if (typeof legacySignature !== 'string') return rawCandidate - - let replacementSignature = signatures.get(replacementString) - if (typeof replacementSignature !== 'string') return rawCandidate + let legacySignature = signatures.get(targetCandidateString) + if (typeof legacySignature !== 'string') return candidate - // Not the same signature, not safe to migrate - if (legacySignature !== replacementSignature) return rawCandidate + let replacementSignature = signatures.get(replacementString) + if (typeof replacementSignature !== 'string') return candidate - let [replacement] = parseCandidate(designSystem, replacementString) + // Not the same signature, not safe to migrate + if (legacySignature !== replacementSignature) return candidate - // Re-add the variants and important flag from the original candidate - return designSystem.printCandidate( - Object.assign(structuredClone(replacement), { - variants: candidate.variants, - important: candidate.important, - }), - ) - } + let [replacement] = parseCandidate(designSystem, replacementString) - return rawCandidate + return replaceObject(candidate, replacement) } // ---- -function arbitraryVariants(designSystem: DesignSystem, rawCandidate: string): string { +function arbitraryVariants(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { let signatures = computeVariantSignature.get(designSystem) let variants = preComputedVariants.get(designSystem) - for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { - // We are only interested in the variants - if (readonlyCandidate.variants.length <= 0) return rawCandidate - - // The below logic makes use of mutation. Since candidates in the - // DesignSystem are cached, we can't mutate them directly. - let candidate = structuredClone(readonlyCandidate) as Writable - - for (let [variant] of walkVariants(candidate)) { - if (variant.kind === 'compound') continue - - let targetString = designSystem.printVariant(variant) - let targetSignature = signatures.get(targetString) - if (typeof targetSignature !== 'string') continue + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + if (variant.kind === 'compound') continue - let foundVariants = variants.get(targetSignature) - if (foundVariants.length !== 1) continue + let targetString = designSystem.printVariant(variant) + let targetSignature = signatures.get(targetString) + if (typeof targetSignature !== 'string') continue - let foundVariant = foundVariants[0] - let parsedVariant = designSystem.parseVariant(foundVariant) - if (parsedVariant === null) continue + let foundVariants = variants.get(targetSignature) + if (foundVariants.length !== 1) continue - replaceObject(variant, parsedVariant) - } + let foundVariant = foundVariants[0] + let parsedVariant = designSystem.parseVariant(foundVariant) + if (parsedVariant === null) continue - return designSystem.printCandidate(candidate) + replaceObject(variant, parsedVariant) } - return rawCandidate + return variant } // ---- -function dropUnnecessaryDataTypes(designSystem: DesignSystem, rawCandidate: string): string { +function dropUnnecessaryDataTypes(designSystem: DesignSystem, candidate: Candidate): Candidate { 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 ( + 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 - } + if (signatures.get(designSystem.printCandidate(candidate)) === signatures.get(replacement)) { + candidate.value.dataType = null } } - return rawCandidate + return candidate } // ---- -function arbitraryValueToBareValue(designSystem: DesignSystem, rawCandidate: string): string { +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) - for (let candidate of parseCandidate(designSystem, rawCandidate)) { - let clone = structuredClone(candidate) as Writable - 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 - } - } + 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, } } - for (let [variant] of walkVariants(clone)) { - // Convert `data-[selected]` to `data-selected` + // 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 ( - variant.kind === 'functional' && - variant.root === 'data' && - variant.value?.kind === 'arbitrary' && - !variant.value.value.includes('=') + // 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] === '*' ) { - changed = true - variant.value = { - kind: 'named', - value: variant.value.value, - } + continue } - // 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('=')), - } + 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, - } + // 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 changed ? designSystem.printCandidate(clone) : rawCandidate } - return rawCandidate + return variant } // Convert functional utilities with arbitrary values to bare values if we can. @@ -1254,370 +1221,354 @@ function parseAttributeSelector(value: string) { return attribute } -function modernizeArbitraryValues(designSystem: DesignSystem, rawCandidate: string): string { +function modernizeArbitraryValuesVariant( + designSystem: DesignSystem, + variant: Variant, +): Variant | Variant[] { + let result = [variant] let signatures = computeVariantSignature.get(designSystem) - for (let candidate of parseCandidate(designSystem, rawCandidate)) { - let clone = structuredClone(candidate) - let changed = false - - for (let [variant, parent] of walkVariants(clone)) { - // 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 - } + 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 + // 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()) + let ast = SelectorParser.parse(variant.selector.trim()) - // Expecting a single selector node - if (!isSingleSelector(ast)) continue + // 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 === '*' - ) { - changed = true - 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() === '>' && + 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 === '*' - ) { - changed = true - 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 - - 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-[${SelectorParser.toCss(ast)}]`)) - 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 - // Hoist `not` modifier for `@media` or `@supports` variants + // 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]]`. // - // 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) { - changed = true - replaceObject(variant, hoistedNot) - continue - } + // 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([]) } - } + }) - let prefixedVariant: Variant | null = null + // 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 = ' ' + } + }) - // 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('*') + 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 + } } + } - // 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('**') - } + 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() === '&'), + ) - // 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 selector (normal selector or attribute selector) - if (selectorNodes.length !== 1) continue + // Expecting a single attribute selector + if (!isAttributeSelector(target.nodes[0])) 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 + // Unwrap the selector from inside `&:is(…)` + target = target.nodes[0] + } - // Expecting a single attribute selector - if (!isAttributeSelector(target.nodes[0])) continue + // 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 - // Unwrap the selector from inside `&:is(…)` - target = target.nodes[0] + targetNode = targetNode.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 + 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' } - if (targetNode.nodes[0].value[0] !== ':') continue - - targetNode = targetNode.nodes[0] + return 'odd' } - 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' - } + 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 === ':nth-child' && + value === selector && targetNode.kind === 'function' && - targetNode.nodes.length === 1 && - targetNode.nodes[0].kind === 'value' && - targetNode.nodes[0].value === 'even' + targetNode.nodes.length === 1 ) { - 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 + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + isPositiveInteger(targetNode.nodes[0].value) ) { - 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)}]` + 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}]` - } + // 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) + return null + })(targetNode.value) - if (newVariant === null) continue + if (newVariant === null) continue - // Add `not-` prefix - if (compoundNot) newVariant = `not-${newVariant}` + // Add `not-` prefix + if (compoundNot) newVariant = `not-${newVariant}` - let parsed = designSystem.parseVariant(newVariant) - if (parsed === null) continue + let parsed = designSystem.parseVariant(newVariant) + if (parsed === null) continue - // Update original variant - changed = true - replaceObject(variant, structuredClone(parsed)) + // Update original variant + replaceObject(variant, parsed) + } + + // Expecting an attribute selector + else if (isAttributeSelector(target)) { + let attribute = parseAttributeSelector(target.value) + if (attribute === null) continue // Invalid attribute selector + + // Migrate `data-*` + if (attribute.key.startsWith('data-')) { + let name = attribute.key.slice(5) // Remove `data-` + + replaceObject(variant, { + kind: 'functional', + root: 'data', + modifier: null, + value: + attribute.value === null + ? { kind: 'named', value: name } + : { + kind: 'arbitrary', + value: `${name}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, + }, + } satisfies Variant) } - // Expecting an attribute selector - else if (isAttributeSelector(target)) { - let attribute = parseAttributeSelector(target.value) - if (attribute === null) continue // Invalid attribute selector - - // Migrate `data-*` - if (attribute.key.startsWith('data-')) { - changed = true - let name = attribute.key.slice(5) // Remove `data-` - - replaceObject(variant, { - kind: 'functional', - root: 'data', - modifier: null, - value: - attribute.value === null - ? { kind: 'named', value: name } + // Migrate `aria-*` + else if (attribute.key.startsWith('aria-')) { + let name = attribute.key.slice(5) // Remove `aria-` + replaceObject(variant, { + kind: 'functional', + root: 'aria', + modifier: null, + value: + attribute.value === null + ? { kind: 'arbitrary', value: name } // aria-[foo] + : attribute.operator === '=' && + attribute.value === 'true' && + attribute.modifier === null + ? { kind: 'named', value: name } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] : { kind: 'arbitrary', - value: `${name}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, - }, - } satisfies Variant) - } - - // Migrate `aria-*` - else if (attribute.key.startsWith('aria-')) { - changed = true - let name = attribute.key.slice(5) // Remove `aria-` - replaceObject(variant, { - kind: 'functional', - root: 'aria', - modifier: null, - value: - attribute.value === null - ? { kind: 'arbitrary', value: name } // aria-[foo] - : attribute.operator === '=' && - attribute.value === 'true' && - attribute.modifier === null - ? { kind: 'named', value: name } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] - : { - kind: 'arbitrary', - value: `${attribute.key}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, - }, // aria-[foo~="true"], aria-[foo|="true"], … - } satisfies Variant) - } + value: `${attribute.key}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, + }, // 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) - } + if (prefixedVariant) { + return [prefixedVariant, variant] } } - - return changed ? designSystem.printCandidate(clone) : rawCandidate } - return rawCandidate + return result } // ---- @@ -1630,65 +1581,62 @@ function modernizeArbitraryValues(designSystem: DesignSystem, rawCandidate: stri // - `/[100%]` → `/100` → // - `/100` → // -function optimizeModifier(designSystem: DesignSystem, rawCandidate: string): string { - let signatures = computeUtilitySignature.get(designSystem) +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 + } - 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 + let signatures = computeUtilitySignature.get(designSystem) - // 1. Try to drop the modifier entirely - if ( - targetSignature === - signatures.get(designSystem.printCandidate({ ...candidate, modifier: null })) - ) { - changed = true - candidate.modifier = null - } + let targetSignature = signatures.get(designSystem.printCandidate(candidate)) + let modifier = candidate.modifier - // 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, - } + // 1. Try to drop the modifier entirely + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: null })) + ) { + candidate.modifier = null + return candidate + } - if ( - targetSignature === - signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier })) - ) { - changed = true - candidate.modifier = newModifier - } - } + // 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, + } - // 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 })) + ) { + candidate.modifier = newModifier + return candidate + } + } - 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` + { + let newModifier: NamedUtilityValue = { + kind: 'named', + value: `${parseFloat(modifier.value) * 100}`, + fraction: null, + } - return changed ? designSystem.printCandidate(candidate) : rawCandidate + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier })) + ) { + candidate.modifier = newModifier + return candidate } } - return rawCandidate + return candidate } From c281426da10caf060851c6af20ece4552f03c60e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 4 Oct 2025 11:43:28 +0200 Subject: [PATCH 28/37] drop unnecessary calls The the scenario of creating signatures for a component, we always want to respect the important flag if it is given. Therefore I think that we can drop the `designSystem.compileAstNodes(candidate, CompileAstFlags.None)` which saves us some time. Also removed the `structuredClone` call. Whenever we handle `@apply` which essentially happened the step before, we already start from a cloned node internally. This call just meant we were doing it again. --- packages/tailwindcss/src/signatures.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index ead120c88958..7a0b34ca1eaa 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -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. From 368eb80c0cfcd1d4ba997acb94ab218d6ffc97b3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 4 Oct 2025 11:44:00 +0200 Subject: [PATCH 29/37] only stringify the value when something changed --- packages/tailwindcss/src/signatures.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 7a0b34ca1eaa..e5df69a6cabc 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -115,6 +115,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() @@ -157,6 +158,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}`)) } } @@ -169,6 +171,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)) } } @@ -176,7 +179,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 From 55cbbaf7e10ac97ef156770ea1d5204ae225c415 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 4 Oct 2025 22:24:06 +0200 Subject: [PATCH 30/37] remove migration annotations --- .../src/codemods/template/migrate.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index b3597acb1de4..d402847fc0e9 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -26,17 +26,17 @@ export type Migration = ( ) => string | Promise export const DEFAULT_MIGRATIONS: Migration[] = [ - migrateEmptyArbitraryValues, // sync, v3 → v4 - migratePrefix, // sync, v3 → v4 - migrateCanonicalizeCandidate, // sync, v4 (optimization, can probably be removed) - migrateSimpleLegacyClasses, // sync, v3 → v4 - migrateCamelcaseInNamedValue, // sync, v3 → v4 - migrateLegacyClasses, // async, v3 → v4 - migrateMaxWidthScreen, // sync, v3 → v4 - migrateVariantOrder, // sync, v3 → v4, Has to happen before migrations that modify variants - migrateAutomaticVarInjection, // sync, v3 → v4 - migrateLegacyArbitraryValues, // sync, v3 → v4 (could also consider it a v4 optimization) - migrateModernizeArbitraryValues, // sync, v3 and v4 optimizations, split up? + migrateEmptyArbitraryValues, + migratePrefix, + migrateCanonicalizeCandidate, + migrateSimpleLegacyClasses, + migrateCamelcaseInNamedValue, + migrateLegacyClasses, + migrateMaxWidthScreen, + migrateVariantOrder, // Has to happen before migrations that modify variants + migrateAutomaticVarInjection, + migrateLegacyArbitraryValues, + migrateModernizeArbitraryValues, ] let migrateCached = new DefaultMap< From e7ce1ad98935335234e339c23db2fdac83e4fbcf Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 5 Oct 2025 17:58:26 +0200 Subject: [PATCH 31/37] move `selector-parser` from `./src/compat` to just `./src` --- packages/tailwindcss/src/canonicalize-candidates.ts | 2 +- packages/tailwindcss/src/compat/plugin-api.ts | 2 +- packages/tailwindcss/src/{compat => }/selector-parser.test.ts | 0 packages/tailwindcss/src/{compat => }/selector-parser.ts | 0 packages/tailwindcss/src/signatures.ts | 2 +- 5 files changed, 3 insertions(+), 3 deletions(-) rename packages/tailwindcss/src/{compat => }/selector-parser.test.ts (100%) rename packages/tailwindcss/src/{compat => }/selector-parser.ts (100%) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 63b4ddf88efd..8d4381993f0d 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -6,8 +6,8 @@ import { type Variant, } from './candidate' import { keyPathToCssProperty } from './compat/apply-config-to-theme' -import * as SelectorParser from './compat/selector-parser' import type { DesignSystem } from './design-system' +import * as SelectorParser from './selector-parser' import { computeUtilitySignature, computeVariantSignature, 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/compat/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts similarity index 100% rename from packages/tailwindcss/src/compat/selector-parser.test.ts rename to packages/tailwindcss/src/selector-parser.test.ts diff --git a/packages/tailwindcss/src/compat/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts similarity index 100% rename from packages/tailwindcss/src/compat/selector-parser.ts rename to packages/tailwindcss/src/selector-parser.ts diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index e5df69a6cabc..383d6687e6e2 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -1,8 +1,8 @@ import { substituteAtApply } from './apply' import { atRule, styleRule, toCss, walk, type AstNode } from './ast' import { printArbitraryValue } from './candidate' -import * as SelectorParser from './compat/selector-parser' 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' From 705476c01741496e9e5ea113071feb717a13d498 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 5 Oct 2025 23:34:55 +0200 Subject: [PATCH 32/37] add attribute selector parser --- .../src/attribute-selector-parser.bench.ts | 35 +++ .../src/attribute-selector-parser.test.ts | 64 +++++ .../src/attribute-selector-parser.ts | 229 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 packages/tailwindcss/src/attribute-selector-parser.bench.ts create mode 100644 packages/tailwindcss/src/attribute-selector-parser.test.ts create mode 100644 packages/tailwindcss/src/attribute-selector-parser.ts 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 + } +} From 226ae47941d8eb3c4127f99d85f43821cc486bbe Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 5 Oct 2025 23:35:04 +0200 Subject: [PATCH 33/37] use dedicated `AttributeSelectorParser` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implementation is also 2-3x faster than the original one. ``` AttributeSelectorParser.parse - src/attribute-selector-parser.bench.ts > parsing 2.24x faster than REGEX.test(…) 2.59x faster than ….match(REGEX) 2.84x faster than parseAttributeSelector ``` --- .../src/canonicalize-candidates.ts | 160 ++---------------- 1 file changed, 14 insertions(+), 146 deletions(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 8d4381993f0d..d27cd345a5cf 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,3 +1,4 @@ +import * as AttributeSelectorParser from './attribute-selector-parser' import { printModifier, type Candidate, @@ -1088,139 +1089,6 @@ function isAttributeSelector(node: SelectorParser.SelectorAstNode): boolean { return node.kind === 'selector' && value[0] === '[' && value[value.length - 1] === ']' } -function isAsciiWhitespace(char: string) { - return char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '\f' -} - -enum AttributePart { - Start, - Attribute, - Value, - Modifier, - End, -} - -function parseAttributeSelector(value: string) { - let attribute = { - key: '', - operator: null as '=' | '~=' | '|=' | '^=' | '$=' | '*=' | null, - quote: '', - value: null as string | null, - modifier: null as 'i' | 's' | null, - } - - let state = AttributePart.Start - outer: for (let i = 0; i < value.length; i++) { - // Skip whitespace - if (isAsciiWhitespace(value[i])) { - if (attribute.quote === '' && state !== AttributePart.Value) { - continue - } - } - - switch (state) { - case AttributePart.Start: { - if (value[i] === '[') { - state = AttributePart.Attribute - } else { - return null - } - break - } - - case AttributePart.Attribute: { - switch (value[i]) { - case ']': { - return attribute - } - - case '=': { - attribute.operator = '=' - state = AttributePart.Value - continue outer - } - - case '~': - case '|': - case '^': - case '$': - case '*': { - if (value[i + 1] === '=') { - attribute.operator = (value[i] + '=') as '=' | '~=' | '|=' | '^=' | '$=' | '*=' - i++ - state = AttributePart.Value - continue outer - } - - return null - } - } - - attribute.key += value[i] - break - } - - case AttributePart.Value: { - // End of attribute selector - if (value[i] === ']') { - return attribute - } - - // Quoted value - else if (value[i] === "'" || value[i] === '"') { - attribute.value ??= '' - - attribute.quote = value[i] - - for (let j = i + 1; j < value.length; j++) { - if (value[j] === '\\' && j + 1 < value.length) { - // Skip the escaped character - j++ - attribute.value += value[j] - } else if (value[j] === attribute.quote) { - i = j - state = AttributePart.Modifier - continue outer - } else { - attribute.value += value[j] - } - } - } - - // Unquoted value - else { - if (isAsciiWhitespace(value[i])) { - state = AttributePart.Modifier - } else { - attribute.value ??= '' - attribute.value += value[i] - } - } - break - } - - case AttributePart.Modifier: { - if (value[i] === 'i' || value[i] === 's') { - attribute.modifier = value[i] as 'i' | 's' - state = AttributePart.End - } else if (value[i] == ']') { - return attribute - } - break - } - - case AttributePart.End: { - if (value[i] === ']') { - return attribute - } - break - } - } - } - - return attribute -} - function modernizeArbitraryValuesVariant( designSystem: DesignSystem, variant: Variant, @@ -1519,44 +1387,44 @@ function modernizeArbitraryValuesVariant( // Expecting an attribute selector else if (isAttributeSelector(target)) { - let attribute = parseAttributeSelector(target.value) - if (attribute === null) continue // Invalid attribute selector + let attributeSelector = AttributeSelectorParser.parse(target.value) + if (attributeSelector === null) continue // Invalid attribute selector // Migrate `data-*` - if (attribute.key.startsWith('data-')) { - let name = attribute.key.slice(5) // Remove `data-` + if (attributeSelector.attribute.startsWith('data-')) { + let name = attributeSelector.attribute.slice(5) // Remove `data-` replaceObject(variant, { kind: 'functional', root: 'data', modifier: null, value: - attribute.value === null + attributeSelector.value === null ? { kind: 'named', value: name } : { kind: 'arbitrary', - value: `${name}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, + value: `${name}${attributeSelector.operator}${attributeSelector.quote ?? ''}${attributeSelector.value}${attributeSelector.quote ?? ''}${attributeSelector.sensitivity ? ` ${attributeSelector.sensitivity}` : ''}`, }, } satisfies Variant) } // Migrate `aria-*` - else if (attribute.key.startsWith('aria-')) { - let name = attribute.key.slice(5) // Remove `aria-` + else if (attributeSelector.attribute.startsWith('aria-')) { + let name = attributeSelector.attribute.slice(5) // Remove `aria-` replaceObject(variant, { kind: 'functional', root: 'aria', modifier: null, value: - attribute.value === null + attributeSelector.value === null ? { kind: 'arbitrary', value: name } // aria-[foo] - : attribute.operator === '=' && - attribute.value === 'true' && - attribute.modifier === null + : 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: `${attribute.key}${attribute.operator}${attribute.quote}${attribute.value}${attribute.quote}${attribute.modifier ? ` ${attribute.modifier}` : ''}`, + value: `${attributeSelector.attribute}${attributeSelector.operator}${attributeSelector.quote ?? ''}${attributeSelector.value}${attributeSelector.quote ?? ''}${attributeSelector.sensitivity ? ` ${attributeSelector.sensitivity}` : ''}`, }, // aria-[foo~="true"], aria-[foo|="true"], … } satisfies Variant) } From 4441c44b7a8f75914c33db7497171dd310f67fb9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 6 Oct 2025 18:46:01 +0200 Subject: [PATCH 34/37] remove intellisense features from `@tailwindcss/browser` --- packages/@tailwindcss-browser/tsup.config.ts | 16 ++++++++++++++++ packages/tailwindcss/src/design-system.ts | 9 +++++++-- packages/tailwindcss/src/intellisense.ts | 1 + 3 files changed, 24 insertions(+), 2 deletions(-) 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/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 296d478de465..5f4450d50fbf 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -8,10 +8,15 @@ import { type Candidate, type Variant, } from './candidate' -import { canonicalizeCandidates } from './canonicalize-candidates' 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' 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[] From 5b7fa87867f22d64b42872ad57d67d2c1a580e38 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 6 Oct 2025 22:23:00 +0200 Subject: [PATCH 35/37] immediately return replacement candidates --- .../tailwindcss/src/canonicalize-candidates.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index d27cd345a5cf..ba6910552ba4 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -576,11 +576,7 @@ function arbitraryUtilities(designSystem: DesignSystem, candidate: Candidate): C continue } - // Update the candidate with the new value - replaceObject(candidate, replacementCandidate) - - // We will re-print the candidate to get the migrated candidate out - return candidate + return replacementCandidate } return candidate @@ -786,11 +782,7 @@ function bareValueUtilities(designSystem: DesignSystem, candidate: Candidate): C continue } - // Update the candidate with the new value - replaceObject(candidate, replacementCandidate) - - // We will re-print the candidate to get the migrated candidate out - return candidate + return replacementCandidate } return candidate @@ -861,8 +853,7 @@ function deprecatedUtilities(designSystem: DesignSystem, candidate: Candidate): if (legacySignature !== replacementSignature) return candidate let [replacement] = parseCandidate(designSystem, replacementString) - - return replaceObject(candidate, replacement) + return replacement } // ---- From 93b97b1372ed8590ffadefb96449653358674ee3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Oct 2025 01:40:32 +0200 Subject: [PATCH 36/37] remove `@property` when computing a signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are globally inserted and don't super matter, at least I don't think so and it will free up a lot of memory. ~213 MB → ~154 MB --- packages/tailwindcss/src/signatures.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 383d6687e6e2..89a5edde424c 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -70,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. From ce08cba1cffdc8d7c5a270e4061d18961eb9aabe Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Oct 2025 11:55:37 +0200 Subject: [PATCH 37/37] add note when parsing the attribute selector as a whole --- packages/tailwindcss/src/selector-parser.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/tailwindcss/src/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts index b25fd6cc9c2d..0d28fdc4fe7e 100644 --- a/packages/tailwindcss/src/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -376,6 +376,13 @@ export function parse(input: string) { } // 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) {