From 815a83cfbb000f0b440d69b5eff296a4b7c0862c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 15:59:49 +0200 Subject: [PATCH 01/39] add `Writable` types helper --- packages/@tailwindcss-upgrade/src/utils/types.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/@tailwindcss-upgrade/src/utils/types.ts diff --git a/packages/@tailwindcss-upgrade/src/utils/types.ts b/packages/@tailwindcss-upgrade/src/utils/types.ts new file mode 100644 index 000000000000..f0fca5cf70d0 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/types.ts @@ -0,0 +1 @@ +export type Writable = T extends Readonly ? U : T From 40fa9d7a4274bd3d64f9a844ba358dcb9e592c83 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:00:02 +0200 Subject: [PATCH 02/39] add `memcpy` util --- packages/@tailwindcss-upgrade/src/utils/memcpy.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/utils/memcpy.ts diff --git a/packages/@tailwindcss-upgrade/src/utils/memcpy.ts b/packages/@tailwindcss-upgrade/src/utils/memcpy.ts new file mode 100644 index 000000000000..3615e3a85906 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/memcpy.ts @@ -0,0 +1,7 @@ +export function memcpy(target: T, source: U): U { + // Clear out the target object, otherwise inspecting the final object will + // look very confusing. + for (let key in target) delete target[key] + + return Object.assign(target, source) +} From 551bbeda69637c67796d7bec5eb994be75860299 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:00:18 +0200 Subject: [PATCH 03/39] add signatures --- .../src/codemods/template/signatures.ts | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts new file mode 100644 index 000000000000..fb837a01a955 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -0,0 +1,305 @@ +import { substituteAtApply } from '../../../../tailwindcss/src/apply' +import { atRule, styleRule, toCss, walk, type AstNode } from '../../../../tailwindcss/src/ast' +import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-parser' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { ThemeOptions } from '../../../../tailwindcss/src/theme' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import { printArbitraryValue } from './candidates' + +// 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 +// null if the utility is not valid. The class in the selector will be replaced +// with the `.x` selector. +// +// This function should only be passed the base utility so `flex`, `hover:flex` +// and `focus:flex` will all use just `flex`. Variants are handled separately. +// +// E.g.: +// +// | UTILITY | GENERATED SIGNATURE | +// | ---------------- | ----------------------- | +// | `[display:flex]` | `.x { display: flex; }` | +// | `flex` | `.x { display: flex; }` | +// +// These produce the same signature, therefore they represent the same utility. +export const computeUtilitySignature = new DefaultMap< + DesignSystem, + DefaultMap +>((designSystem) => { + return new DefaultMap((utility) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + utility = + designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${utility}` + : utility + + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])] + + temporarilyDisableThemeInline(designSystem, () => substituteAtApply(ast, designSystem)) + + // We will be mutating the AST, so we need to clone it first to not affect + // the original AST + ast = structuredClone(ast) + + // Optimize the AST. This is needed such that any internal intermediate + // nodes are gone. This will also cleanup declaration nodes with undefined + // values or `--tw-sort` declarations. + walk(ast, (node, { replaceWith }) => { + // Optimize declarations + if (node.kind === 'declaration') { + if (node.value === undefined || node.value === '--tw-sort') { + replaceWith([]) + } + } + + // Replace special nodes with its children + else if (node.kind === 'context' || node.kind === 'at-root') { + replaceWith(node.nodes) + } + + // Remove comments + else if (node.kind === 'comment') { + replaceWith([]) + } + }) + + // Resolve theme values to their inlined value. + // + // E.g.: + // + // `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` + // `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` + // + // Due to the `@apply` from above, this will become: + // + // ```css + // .example { + // color: oklch(63.7% 0.237 25.331); + // } + // ``` + // + // Which conveniently will be equivalent to: `text-red-500` when we inline + // the value. + // + // Without inlining: + // ```css + // .example { + // color: var(--color-red-500, oklch(63.7% 0.237 25.331)); + // } + // ``` + // + // Inlined: + // ```css + // .example { + // color: oklch(63.7% 0.237 25.331); + // } + // ``` + // + // Recently we made sure that utilities like `text-red-500` also generate + // the fallback value for usage in `@reference` mode. + // + // The second assumption is that if you use `var(--key, fallback)` that + // happens to match a known variable _and_ its inlined value. Then we can + // replace it with the inlined variable. This allows us to handle custom + // `@theme` and `@theme inline` definitions. + walk(ast, (node) => { + // Handle declarations + if (node.kind === 'declaration' && node.value !== undefined) { + if (node.value.includes('var(')) { + let valueAst = ValueParser.parse(node.value) + + ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { + if (valueNode.kind !== 'function') return + if (valueNode.value !== 'var') return + + // Resolve the underlying value of the variable + if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { + return + } + + let variable = valueNode.nodes[0].value + + // Drop the prefix from the variable name if it is present. The + // internal variable doesn't have the prefix. + if ( + designSystem.theme.prefix && + variable.startsWith(`--${designSystem.theme.prefix}-`) + ) { + variable = variable.slice(`--${designSystem.theme.prefix}-`.length) + } + let variableValue = designSystem.resolveThemeValue(variable) + if (variableValue === undefined) return // Couldn't resolve the variable + + // Inject variable fallbacks when no fallback is present yet. + // + // A fallback could consist of multiple values. + // + // E.g.: + // + // ``` + // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) + // ``` + { + // More than 1 argument means that a fallback is already present + if (valueNode.nodes.length === 1) { + // Inject the fallback value into the variable lookup + valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) + } + } + + // Replace known variable + inlined fallback value with the value + // itself again + { + // We need at least 3 arguments. The variable, the separator and a fallback value. + if (valueNode.nodes.length >= 3) { + let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable + let constructedValue = `${valueNode.nodes[0].value},${variableValue}` + if (nodeAsString === constructedValue) { + replaceWith(ValueParser.parse(variableValue)) + } + } + } + }) + + // Replace the value with the new value + node.value = ValueParser.toCss(valueAst) + } + + // We will normalize the `node.value`, this is the same kind of logic + // we use when printing arbitrary values. It will remove unnecessary + // whitespace. + // + // Essentially normalizing the `node.value` to a canonical form. + node.value = printArbitraryValue(node.value) + } + }) + + // Compute the final signature, by generating the CSS for the utility + let signature = toCss(ast) + return signature + } catch { + return null + } + }) +}) + +// Given a variant, compute a signature that represents the variant. The +// signature will be a normalised form of the generated CSS for the variant, or +// null if the variant is not valid. The class in the selector will be replaced +// with `.x`. +// +// E.g.: +// +// | VARIANT | GENERATED SIGNATURE | +// | ---------------- | ----------------------------- | +// | `[&:focus]:flex` | `.x:focus { display: flex; }` | +// | `focus:flex` | `.x:focus { display: flex; }` | +// +// These produce the same signature, therefore they represent the same variant. +export const computeVariantSignature = new DefaultMap< + DesignSystem, + DefaultMap +>((designSystem) => { + return new DefaultMap((variant) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + variant = + designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${variant}` + : variant + + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] + substituteAtApply(ast, designSystem) + + // Canonicalize selectors to their minimal form + walk(ast, (node) => { + if (node.kind === 'at-rule' && node.params.includes(' ')) { + node.params = node.params.replaceAll(' ', '') + } else if (node.kind === 'rule') { + let selectorAst = SelectorParser.parse(node.selector) + let changed = false + SelectorParser.walk(selectorAst, (node) => { + if (node.kind === 'separator' && node.value !== ' ') { + node.value = node.value.trim() + changed = true + } + }) + if (changed) { + node.selector = SelectorParser.toCss(selectorAst) + } + } + }) + + // Compute the final signature, by generating the CSS for the variant + let signature = toCss(ast) + return signature + } catch { + return null + } + }) +}) + +function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => T): T { + // Turn off `@theme inline` feature such that `@theme` and `@theme inline` are + // considered the same. The biggest motivation for this is referencing + // variables in another namespace that happen to contain the same value as the + // utility's own namespaces it is reading from. + // + // E.g.: + // + // The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace. + // But it does read from the `--container-*` namespace. It also happens to + // be the case that `--breakpoint-md` and `--container-3xl` are the exact + // same value. + // + // If you then use the `max-w-(--breakpoint-md)` utility, inlining the + // variable would mean: + // - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl` + // - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl` + // + // Not inlining the variable would mean: + // - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)` + // - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl` + + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + let originalGet = designSystem.theme.values.get + + // Track all values with the inline option set, so we can restore them later. + let restorableInlineOptions = new Set<{ options: ThemeOptions }>() + + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + designSystem.theme.values.get = (key: string) => { + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + let value = originalGet.call(designSystem.theme.values, key) + if (value === undefined) return value + + // Remove `inline` if it was set + if (value.options & ThemeOptions.INLINE) { + restorableInlineOptions.add(value) + value.options &= ~ThemeOptions.INLINE + } + + return value + } + + // Run the callback with the `@theme inline` feature disabled + let result = cb() + + // Restore the `@theme inline` to the original value + // @ts-expect-error We are monkey-patching a method that's private + designSystem.theme.values.get = originalGet + + // Re-add the `inline` option, in case future lookups are done + for (let value of restorableInlineOptions) { + value.options |= ThemeOptions.INLINE + } + + return result +} From 1d328827dbe3f09357f18861148498d183201d88 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:00:33 +0200 Subject: [PATCH 04/39] add optimize modifier migration --- .../migrate-optimize-modifier.test.ts | 62 ++++++++++++++++++ .../template/migrate-optimize-modifier.ts | 64 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts create 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 new file mode 100644 index 000000000000..26fa6a383827 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts @@ -0,0 +1,62 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { describe, expect, test } from 'vitest' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { migrateOptimizeModifier } from './migrate-optimize-modifier' + +const css = String.raw + +const designSystems = new DefaultMap((base: string) => { + return new DefaultMap((input: string) => { + return __unstable__loadDesignSystem(input, { base }) + }) +}) + +describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => { + let testName = '%s => %s (%#)' + if (strategy === 'with-variant') { + testName = testName.replaceAll('%s', 'focus:%s') + } else if (strategy === 'important') { + testName = testName.replaceAll('%s', '%s!') + } else if (strategy === 'prefix') { + testName = testName.replaceAll('%s', 'tw:%s') + } + + // Basic input with minimal design system to keep the tests fast + let input = css` + @import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''}; + @theme { + --*: initial; + --color-red-500: red; + } + ` + + test.each([ + // Keep the modifier as-is, nothing to optimize + ['bg-red-500/25', 'bg-red-500/25'], + + // Use a bare value modifier + ['bg-red-500/[25%]', 'bg-red-500/25'], + + // Drop unnecessary modifiers + ['bg-red-500/[100%]', 'bg-red-500'], + ['bg-red-500/100', 'bg-red-500'], + ])(testName, async (candidate, expected) => { + if (strategy === 'with-variant') { + candidate = `focus:${candidate}` + expected = `focus:${expected}` + } else if (strategy === 'important') { + candidate = `${candidate}!` + expected = `${expected}!` + } else if (strategy === 'prefix') { + // Not only do we need to prefix the candidate, we also have to make + // sure that we prefix all CSS variables. + candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}` + expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}` + } + + let designSystem = await designSystems.get(__dirname).get(input) + + let migrated = migrateOptimizeModifier(designSystem, {}, candidate) + expect(migrated).toEqual(expected) + }) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts new file mode 100644 index 000000000000..90063ca1e1d1 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts @@ -0,0 +1,64 @@ +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 { printCandidate } from './candidates' +import { computeUtilitySignature } from './signatures' + +// Optimize the modifier +// +// E.g.: +// +// - `/[25%]` → `/25` +// - `/[100%]` → `/100` → +// - `/100` → +// +export function migrateOptimizeModifier( + designSystem: DesignSystem, + _userConfig: Config | null, + rawCandidate: string, +): string { + let signatures = computeUtilitySignature.get(designSystem) + + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { + let candidate = structuredClone(readonlyCandidate) as Writable + if ( + (candidate.kind === 'functional' && candidate.modifier !== null) || + (candidate.kind === 'arbitrary' && candidate.modifier !== null) + ) { + let targetSignature = signatures.get(rawCandidate) + let modifier = candidate.modifier + let changed = false + + // 1. Try to drop the modifier entirely + if ( + targetSignature === + signatures.get(printCandidate(designSystem, { ...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(printCandidate(designSystem, { ...candidate, modifier: newModifier })) + ) { + changed = true + candidate.modifier = newModifier + } + } + + return changed ? printCandidate(designSystem, candidate) : rawCandidate + } + } + + return rawCandidate +} From 7838f27b76b731f7931b4d9ba8dc71b9ea114985 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:00:47 +0200 Subject: [PATCH 05/39] add drop unnecessary data types migration --- ...igrate-drop-unnecessary-data-types.test.ts | 63 +++++++++++++++++++ .../migrate-drop-unnecessary-data-types.ts | 31 +++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts create 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 new file mode 100644 index 000000000000..c0a8bda8fcdf --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.test.ts @@ -0,0 +1,63 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { describe, expect, test } from 'vitest' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types' + +const css = String.raw + +const designSystems = new DefaultMap((base: string) => { + return new DefaultMap((input: string) => { + return __unstable__loadDesignSystem(input, { base }) + }) +}) + +describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => { + let testName = '%s => %s (%#)' + if (strategy === 'with-variant') { + testName = testName.replaceAll('%s', 'focus:%s') + } else if (strategy === 'important') { + testName = testName.replaceAll('%s', '%s!') + } else if (strategy === 'prefix') { + testName = testName.replaceAll('%s', 'tw:%s') + } + + // Basic input with minimal design system to keep the tests fast + let input = css` + @import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''}; + @theme { + --*: initial; + } + ` + + test.each([ + // A color value can be inferred from the value + ['bg-[color:#008cc]', 'bg-[#008cc]'], + + // A position can be inferred from the value + ['bg-[position:123px]', 'bg-[123px]'], + + // A color is the default for `bg-*` + ['bg-(color:--my-value)', 'bg-(--my-value)'], + + // A position is not the default, so the `position` data type is kept + ['bg-(position:--my-value)', 'bg-(position:--my-value)'], + ])(testName, async (candidate, expected) => { + if (strategy === 'with-variant') { + candidate = `focus:${candidate}` + expected = `focus:${expected}` + } else if (strategy === 'important') { + candidate = `${candidate}!` + expected = `${expected}!` + } else if (strategy === 'prefix') { + // Not only do we need to prefix the candidate, we also have to make + // sure that we prefix all CSS variables. + candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}` + expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}` + } + + let designSystem = await designSystems.get(__dirname).get(input) + + let migrated = migrateDropUnnecessaryDataTypes(designSystem, {}, candidate) + expect(migrated).toEqual(expected) + }) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts new file mode 100644 index 000000000000..7ace0282da03 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts @@ -0,0 +1,31 @@ +import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { printCandidate } from './candidates' +import { computeUtilitySignature } from './signatures' + +export function migrateDropUnnecessaryDataTypes( + designSystem: DesignSystem, + _userConfig: Config | null, + rawCandidate: string, +): string { + let signatures = computeUtilitySignature.get(designSystem) + + for (let candidate of designSystem.parseCandidate(rawCandidate)) { + if ( + candidate.kind === 'functional' && + candidate.value?.kind === 'arbitrary' && + candidate.value.dataType !== null + ) { + let replacement = printCandidate(designSystem, { + ...candidate, + value: { ...candidate.value, dataType: null }, + }) + + if (signatures.get(rawCandidate) === signatures.get(replacement)) { + return replacement + } + } + } + + return rawCandidate +} From 48d449dbe26f8d3006d669809a6da3452c18cd0b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:01:01 +0200 Subject: [PATCH 06/39] add arbitrary variants migration --- .../migrate-arbitrary-variants.test.ts | 158 ++++++++++++++++++ .../template/migrate-arbitrary-variants.ts | 75 +++++++++ 2 files changed, 233 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.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 new file mode 100644 index 000000000000..439082570a4c --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.test.ts @@ -0,0 +1,158 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { describe, expect, test } from 'vitest' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { migrateArbitraryVariants } from './migrate-arbitrary-variants' + +const css = String.raw +const designSystems = new DefaultMap((base: string) => { + return new DefaultMap((input: string) => { + return __unstable__loadDesignSystem(input, { base }) + }) +}) + +describe.each([['default'], ['important'], ['prefix']].slice(0, 1))('%s', (strategy) => { + let testName = '%s => %s (%#)' + if (strategy === 'with-variant') { + testName = testName.replaceAll('%s', 'focus:%s') + } else if (strategy === 'important') { + testName = testName.replaceAll('%s', '%s!') + } else if (strategy === 'prefix') { + testName = testName.replaceAll('%s', 'tw:%s') + } + + // Basic input with minimal design system to keep the tests fast + let input = css` + @import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''}; + @theme { + --*: initial; + } + ` + + test.each([ + // Arbitrary variant to static variant + ['[&:focus]:flex', 'focus:flex'], + + // Arbitrary variant to static variant with at-rules + ['[@media(scripting:_none)]:flex', 'noscript:flex'], + + // Arbitrary variant to static utility at-rules and with slight differences + // in whitespace. This will require some canonicalization. + ['[@media(scripting:none)]:flex', 'noscript:flex'], + ['[@media(scripting:_none)]:flex', 'noscript:flex'], + ['[@media_(scripting:_none)]:flex', 'noscript:flex'], + + // With compound variants + ['has-[&:focus]:flex', 'has-focus:flex'], + ['not-[&:focus]:flex', 'not-focus:flex'], + ['group-[&:focus]:flex', 'group-focus:flex'], + ['peer-[&:focus]:flex', 'peer-focus:flex'], + ['in-[&:focus]:flex', 'in-focus:flex'], + ])(testName, async (candidate, result) => { + if (strategy === 'important') { + candidate = `${candidate}!` + result = `${result}!` + } else if (strategy === 'prefix') { + // Not only do we need to prefix the candidate, we also have to make + // sure that we prefix all CSS variables. + candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}` + result = `tw:${result.replaceAll('var(--', 'var(--tw-')}` + } + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrateArbitraryVariants(designSystem, {}, candidate) + expect(migrated).toEqual(result) + }) +}) + +test('unsafe migrations keep the candidate as-is', async () => { + // `hover:` also includes an `@media` query in addition to the `&:hover` + // state. Migration is not safe because the functionality would be different. + let candidate = '[&:hover]:flex' + let result = '[&:hover]:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + ` + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrateArbitraryVariants(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test('make unsafe migration safe (1)', async () => { + // Overriding the `hover:` variant to only use a selector will make the + // migration safe. + let candidate = '[&:hover]:flex' + let result = 'hover:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant hover (&:hover); + ` + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrateArbitraryVariants(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test('make unsafe migration safe (2)', async () => { + // Overriding the `hover:` variant to only use a selector will make the + // migration safe. This time with the long-hand `@variant` syntax. + let candidate = '[&:hover]:flex' + let result = 'hover:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant hover { + &:hover { + @slot; + } + } + ` + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrateArbitraryVariants(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test('custom selector-based variants', async () => { + let candidate = '[&.macos]:flex' + let result = 'is-macos:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant is-macos (&.macos); + ` + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrateArbitraryVariants(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test('custom @media-based variants', async () => { + let candidate = '[@media(prefers-reduced-transparency:reduce)]:flex' + let result = 'transparency-safe:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant transparency-safe { + @media (prefers-reduced-transparency: reduce) { + @slot; + } + } + ` + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrateArbitraryVariants(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts new file mode 100644 index 000000000000..16cfec218422 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -0,0 +1,75 @@ +import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate' +import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { memcpy } from '../../utils/memcpy' +import type { Writable } from '../../utils/types' +import { printCandidate, printVariant } from './candidates' +import { computeVariantSignature } from './signatures' + +const variantsLookup = new DefaultMap>( + (designSystem) => { + let signatures = computeVariantSignature.get(designSystem) + let lookup = new DefaultMap(() => []) + + // Actual static variants + for (let [root, variant] of designSystem.variants.entries()) { + if (variant.kind === 'static') { + let signature = signatures.get(root) + if (signature === null) continue + lookup.get(signature).push(root) + } + } + + return lookup + }, +) + +export function migrateArbitraryVariants( + designSystem: DesignSystem, + _userConfig: Config | null, + rawCandidate: string, +): string { + 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 variants(candidate)) { + if (variant.kind === 'compound') continue + + let targetString = printVariant(variant) + let targetSignature = computeVariantSignature.get(designSystem).get(targetString) + if (!targetSignature) continue + + let foundVariants = variantsLookup.get(designSystem).get(targetSignature) + if (foundVariants.length !== 1) continue + + let foundVariant = foundVariants[0] + let parsedVariant = designSystem.parseVariant(foundVariant) + if (parsedVariant === null) continue + + memcpy(variant, parsedVariant) + } + + return printCandidate(designSystem, candidate) + } + + return rawCandidate +} + +function* variants(candidate: Candidate) { + function* inner(variant: Variant): Iterable { + yield variant + if (variant.kind === 'compound') { + yield* inner(variant.variant) + } + } + + for (let variant of candidate.variants) { + yield* inner(variant) + } +} From e064185e68d03fb5c8fa70ac6dc52d4145784cf9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:01:13 +0200 Subject: [PATCH 07/39] add arbitrary utilities migration --- .../migrate-arbitrary-utilities.test.ts | 280 +++++++++++++++ .../template/migrate-arbitrary-utilities.ts | 329 ++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts create 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 new file mode 100644 index 000000000000..a8f5da63c4db --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts @@ -0,0 +1,280 @@ +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 { 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, + 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 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'], + + // Complex arbitrary property to arbitrary value + [ + '[grid-template-columns:repeat(2,minmax(100px,1fr))]', + 'grid-cols-[repeat(2,minmax(100px,1fr))]', + ], + // Complex arbitrary property to bare value + ['[grid-template-columns:repeat(2,minmax(0,1fr))]', 'grid-cols-2'], + + // Arbitrary value to bare value with percentage + ['from-[25%]', 'from-25%'], + + // Arbitrary percentage value must be a whole number. Should not migrate to + // a bare value. + ['from-[2.5%]', 'from-[2.5%]'], + ])(testName, async (candidate, result) => { + if (strategy === 'with-variant') { + candidate = `focus:${candidate}` + result = `focus:${result}` + } else if (strategy === 'important') { + candidate = `${candidate}!` + result = `${result}!` + } else if (strategy === 'prefix') { + // Not only do we need to prefix the candidate, we also have to make + // sure that we prefix all CSS variables. + candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}` + result = `tw:${result.replaceAll('var(--', 'var(--tw-')}` + } + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) + }) +}) + +const css = String.raw +test('migrate with custom static utility `@utility custom {…}`', async () => { + let candidate = '[--key:value]' + let result = 'custom' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility custom { + --key: value; + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test('migrate with custom functional utility `@utility custom-* {…}`', async () => { + let candidate = '[--key:value]' + let result = 'custom-value' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility custom-* { + --key: --value('value'); + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test('migrate with custom functional utility `@utility custom-* {…}` that supports bare values', async () => { + let candidate = '[tab-size:4]' + let result = 'tab-4' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility tab-* { + tab-size: --value(integer); + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) +}) + +test.each([ + ['[tab-size:0]', 'tab-0'], + ['[tab-size:4]', 'tab-4'], + ['[tab-size:8]', 'tab-github'], + ['tab-[0]', 'tab-0'], + ['tab-[4]', 'tab-4'], + ['tab-[8]', 'tab-github'], +])( + 'migrate custom @utility from arbitrary values to bare values and named values (based on theme)', + async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: --value(--tab-size, integer, [integer]); + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(expected) + }, +) + +describe.each([['@theme'], ['@theme inline']])('%s', (theme) => { + test.each([ + ['[color:CanvasText]', 'text-canvas'], + ['text-[CanvasText]', 'text-canvas'], + ])('migrate arbitrary value to theme value %s => %s', async (candidate, result) => { + let input = css` + @import 'tailwindcss'; + ${theme} { + --*: initial; + --color-canvas: CanvasText; + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) + }) + + // Some utilities read from specific namespaces, in this case we do not want + // to migrate to a value in that namespace if we reference a variable that + // results in the same value, but comes from a different namespace. + // + // E.g.: `max-w` reads from: ['--max-width', '--spacing', '--container'] + test.each([ + // `max-w` does not read from `--breakpoint-md`, but `--breakpoint-md` and + // `--container-3xl` happen to result in the same value. The difference is + // the semantics of the value. + ['max-w-(--breakpoint-md)', 'max-w-(--breakpoint-md)'], + ['max-w-(--container-3xl)', 'max-w-3xl'], + ])('migrate arbitrary value to theme value %s => %s', async (candidate, result) => { + let input = css` + @import 'tailwindcss'; + ${theme} { + --*: initial; + --breakpoint-md: 48rem; + --container-3xl: 48rem; + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) + }) +}) + +test('migrate a arbitrary property without spaces, to a theme value with spaces (canonicalization)', async () => { + let candidate = 'font-[foo,bar,baz]' + let expected = 'font-example' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --font-example: foo, bar, baz; + } + ` + let designSystem = await __unstable__loadDesignSystem(input, { + base: __dirname, + }) + + let migrated = migrate(designSystem, {}, candidate) + expect(migrated).toEqual(expected) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts new file mode 100644 index 000000000000..868920af03e3 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -0,0 +1,329 @@ +import type { Candidate } from '../../../../tailwindcss/src/candidate' +import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' +import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import type { Writable } from '../../utils/types' +import { printCandidate, printModifier } from './candidates' +import { computeUtilitySignature } from './signatures' + +// For all static utilities in the system, compute a lookup table that maps the +// utility signature to the utility name. This is used to find the utility name +// for a given utility signature. +// +// For all functional utilities, we can compute static-like utilities by +// essentially pre-computing the values and modifiers. This is a bit slow, but +// also only has to happen once per design system. +const preComputedUtilities = new DefaultMap>((ds) => { + let signatures = computeUtilitySignature.get(ds) + let lookup = new DefaultMap(() => []) + + // Actual static utilities + for (let root of ds.utilities.keys('static')) { + let signature = signatures.get(root) + if (signature === null) continue + lookup.get(signature).push(root) + } + + // Consider functional utilities _with_ known named values as static + // utilities. Aka pre-computed values. + for (let root of ds.utilities.keys('functional')) { + let suggestions = ds.utilities.getCompletions(root) + if (suggestions.length === 0) continue + + for (let { supportsNegative, values, modifiers } of suggestions) { + for (let value of values) { + let candidateString = value === null ? root : `${root}-${value}` + let signature = signatures.get(candidateString) + if (signature !== null) lookup.get(signature).push(candidateString) + + if (supportsNegative) { + let negativeCandidateString = `-${candidateString}` + let signature = signatures.get(negativeCandidateString) + if (signature !== null) lookup.get(signature).push(negativeCandidateString) + } + } + + for (let modifier of modifiers) { + // Modifiers representing numbers can be computed and don't need to be + // pre-computed. Doing the math and at the time of writing this, this + // would save you 250k additionally pre-computed utilities... + if (isValidSpacingMultiplier(modifier)) { + continue + } + + for (let value of values) { + let candidateString = + value === null ? `${root}/${modifier}` : `${root}-${value}/${modifier}` + let signature = signatures.get(candidateString) + if (signature !== null) lookup.get(signature).push(candidateString) + + if (supportsNegative) { + let negativeCandidateString = `-${candidateString}` + let signature = signatures.get(negativeCandidateString) + if (signature !== null) lookup.get(signature).push(negativeCandidateString) + } + } + } + } + } + + return lookup +}) + +const baseReplacementsCache = new DefaultMap>( + () => new Map(), +) + +export function migrateArbitraryUtilities( + designSystem: DesignSystem, + _userConfig: Config | null, + rawCandidate: string, +): string { + let utilities = preComputedUtilities.get(designSystem) + let signatures = computeUtilitySignature.get(designSystem) + + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { + // We are only interested in arbitrary properties and arbitrary values + if ( + // Arbitrary property + readonlyCandidate.kind !== 'arbitrary' && + // Arbitrary value + !(readonlyCandidate.kind === 'functional' && readonlyCandidate.value?.kind === 'arbitrary') + ) { + continue + } + + // 1. Canonicalize the value. This might be a bit wasteful because it might + // have been done by other migrations before, but essentially we want to + // canonicalize the arbitrary value to its simplest canonical form. We + // won't be constant folding `calc(…)` expressions (yet?), but we can + // remove unnecessary whitespace (which the `printCandidate` already + // handles for us). + // + // E.g.: + // + // ``` + // [display:_flex_] => [display:flex] + // [display:_flex] => [display:flex] + // [display:flex_] => [display:flex] + // [display:flex] => [display:flex] + // ``` + // + let canonicalizedCandidate = printCandidate(designSystem, readonlyCandidate) + if (canonicalizedCandidate !== rawCandidate) { + return migrateArbitraryUtilities(designSystem, _userConfig, canonicalizedCandidate) + } + + // The below logic makes use of mutation. Since candidates in the + // DesignSystem are cached, we can't mutate them directly. + let candidate = structuredClone(readonlyCandidate) as Writable + + // Create a basic stripped candidate without variants or important flag. We + // will re-add those later but they are irrelevant for what we are trying to + // do here (and will increase cache hits because we only have to deal with + // the base utility, nothing more). + let targetCandidate = structuredClone(candidate) + targetCandidate.important = false + targetCandidate.variants = [] + + let targetCandidateString = printCandidate(designSystem, 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 printCandidate(designSystem, target) + } + + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (targetSignature === null) continue + + // Try a few options to find a suitable replacement utility + for (let replacementCandidate of tryReplacements(targetSignature, targetCandidate)) { + let replacementString = printCandidate(designSystem, 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 printCandidate(designSystem, 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( + printCandidate(designSystem, candidateWithoutModifier), + ) + if (targetSignatureWithoutModifier !== null) { + 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. + if (candidate.kind === 'arbitrary') { + for (let root of designSystem.utilities.keys('functional')) { + // Try as arbitrary value + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-[${candidate.value}]`, + )) { + yield replacementCandidate + } + + // Try as arbitrary value with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-[${candidate.value}]${printModifier(candidate.modifier)}`, + )) { + yield replacementCandidate + } + } + + // Try as bare value + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${candidate.value}`, + )) { + yield replacementCandidate + } + + // Try as bare value with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${candidate.value}${candidate.modifier}`, + )) { + yield replacementCandidate + } + } + } + } + } + } +} + +function parseCandidate(designSystem: DesignSystem, input: string) { + return designSystem.parseCandidate( + designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`) + ? `${designSystem.theme.prefix}:${input}` + : input, + ) +} + +// Let's make sure that all variables used in the value are also all used in the +// found replacement. If not, then we are dealing with a different namespace or +// we could lose functionality in case the variable was changed higher up in the +// DOM tree. +function allVariablesAreUsed( + designSystem: DesignSystem, + candidate: Candidate, + replacement: Candidate, +) { + let value: string | null = null + + // Functional utility with arbitrary value and variables + if ( + candidate.kind === 'functional' && + candidate.value?.kind === 'arbitrary' && + candidate.value.value.includes('var(--') + ) { + value = candidate.value.value + } + + // Arbitrary property with variables + else if (candidate.kind === 'arbitrary' && candidate.value.includes('var(--')) { + value = candidate.value + } + + // No variables in the value, so this is a safe migration + if (value === null) { + return true + } + + let replacementAsCss = designSystem + .candidatesToCss([printCandidate(designSystem, 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 b6a2a55224c0aa9fa85eb6f43f3ee842aa2254e3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:01:41 +0200 Subject: [PATCH 08/39] export candidate types --- packages/tailwindcss/src/candidate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 27a6b6294a3f..8a78a8a2fde2 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -8,7 +8,7 @@ const DASH = 0x2d const LOWER_A = 0x61 const LOWER_Z = 0x7a -type ArbitraryUtilityValue = { +export type ArbitraryUtilityValue = { kind: 'arbitrary' /** @@ -60,7 +60,7 @@ export type NamedUtilityValue = { fraction: string | null } -type ArbitraryModifier = { +export type ArbitraryModifier = { kind: 'arbitrary' /** @@ -72,7 +72,7 @@ type ArbitraryModifier = { value: string } -type NamedModifier = { +export type NamedModifier = { kind: 'named' /** From 56303f79e17468e174632d42e59691eae5cbb7de Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:02:23 +0200 Subject: [PATCH 09/39] use new migrations --- .../@tailwindcss-upgrade/src/codemods/template/migrate.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index a80cb2499320..a8ee2a3bf800 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -5,15 +5,19 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' import { extractRawCandidates, printCandidate } from './candidates' +import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities' import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value' +import { migrateArbitraryVariants } from './migrate-arbitrary-variants' import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' import { migrateBgGradient } from './migrate-bg-gradient' +import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types' import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values' import { migrateImportant } from './migrate-important' import { migrateLegacyArbitraryValues } from './migrate-legacy-arbitrary-values' import { migrateLegacyClasses } from './migrate-legacy-classes' import { migrateMaxWidthScreen } from './migrate-max-width-screen' import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-values' +import { migrateOptimizeModifier } from './migrate-optimize-modifier' import { migratePrefix } from './migrate-prefix' import { migrateSimpleLegacyClasses } from './migrate-simple-legacy-classes' import { migrateThemeToVar } from './migrate-theme-to-var' @@ -42,8 +46,12 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateVariantOrder, // Has to happen before migrations that modify variants migrateAutomaticVarInjection, migrateLegacyArbitraryValues, + migrateArbitraryUtilities, + migrateArbitraryVariants, + migrateDropUnnecessaryDataTypes, migrateArbitraryValueToBareValue, migrateModernizeArbitraryValues, + migrateOptimizeModifier, ] export async function migrateCandidate( From 24de47d3a176bdf72f996623f8439ea7244d2d69 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:02:42 +0200 Subject: [PATCH 10/39] use memcpy --- .../template/migrate-modernize-arbitrary-values.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts index c3325f2ced8e..46ece0c43a06 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,16 +4,9 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import { memcpy } from '../../utils/memcpy' import { printCandidate } from './candidates' -function memcpy(target: T, source: U): U { - // Clear out the target object, otherwise inspecting the final object will - // look very confusing. - for (let key in target) delete target[key] - - return Object.assign(target, source) -} - export function migrateModernizeArbitraryValues( designSystem: DesignSystem, _userConfig: Config | null, From 7f2384166490f56d0511fb9fe076e2ca7e5ede83 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:03:12 +0200 Subject: [PATCH 11/39] use migrate arbitrary value to bare value using signatures This will now make use of the signatures system so that we can verify that a migration is 100% successful. --- ...rate-arbitrary-value-to-bare-value.test.ts | 2 +- .../migrate-arbitrary-value-to-bare-value.ts | 199 +++++++++--------- 2 files changed, 96 insertions(+), 105 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts index 77e275360027..2e1547e28f12 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts @@ -60,7 +60,7 @@ test.each([ 'data-[selected]:aria-[selected="true"]:aspect-[12/34]', 'data-selected:aria-selected:aspect-12/34', ], -])('%s => %s', async (candidate, result) => { +])('%s => %s (%#)', async (candidate, result) => { let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { base: __dirname, }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts index fd26a9a041cb..7cca343666e9 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts @@ -1,126 +1,45 @@ -import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate' +import { + parseCandidate, + type Candidate, + type NamedUtilityValue, + type Variant, +} from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' +import { + isPositiveInteger, + isValidSpacingMultiplier, +} from '../../../../tailwindcss/src/utils/infer-data-type' import { segment } from '../../../../tailwindcss/src/utils/segment' import { printCandidate } from './candidates' +import { computeUtilitySignature } from './signatures' export function migrateArbitraryValueToBareValue( designSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, ): string { + let signatures = computeUtilitySignature.get(designSystem) + for (let candidate of parseCandidate(rawCandidate, designSystem)) { let clone = structuredClone(candidate) let changed = false - // Convert [subgrid] to subgrid - if ( - clone.kind === 'functional' && - clone.value?.kind === 'arbitrary' && - clone.value.value === 'subgrid' && - (clone.root === 'grid-cols' || clone.root == 'grid-rows') - ) { - changed = true - clone.value = { - kind: 'named', - value: 'subgrid', - fraction: null, - } - } - - // Convert utilities that accept bare values ending in % - if ( - clone.kind === 'functional' && - clone.value?.kind === 'arbitrary' && - clone.value.dataType === null && - (clone.root === 'from' || - clone.root === 'via' || - clone.root === 'to' || - clone.root === 'font-stretch') - ) { - if (clone.value.value.endsWith('%') && isPositiveInteger(clone.value.value.slice(0, -1))) { - let percentage = parseInt(clone.value.value) - if ( - clone.root === 'from' || - clone.root === 'via' || - clone.root === 'to' || - (clone.root === 'font-stretch' && percentage >= 50 && percentage <= 200) - ) { - changed = true - clone.value = { - kind: 'named', - value: clone.value.value, - fraction: null, + // Migrate arbitrary values to bare values + if (clone.kind === 'functional' && clone.value?.kind === 'arbitrary') { + let expectedSignature = signatures.get(rawCandidate) + if (expectedSignature !== null) { + for (let value of tryValueReplacements(clone)) { + let newSignature = signatures.get(printCandidate(designSystem, { ...clone, value })) + if (newSignature === expectedSignature) { + changed = true + clone.value = value + break } } } } - // Convert arbitrary values with positive integers to bare values - // Convert arbitrary values with fractions to bare values - else if ( - clone.kind === 'functional' && - clone.value?.kind === 'arbitrary' && - clone.value.dataType === null - ) { - if (clone.root === 'leading') { - // leading-[1] -> leading-none - if (clone.value.value === '1') { - changed = true - clone.value = { - kind: 'named', - value: 'none', - fraction: null, - } - } - - // Keep leading-[] as leading-[] - else { - continue - } - } - - let parts = segment(clone.value.value, '/') - if (parts.every((part) => isPositiveInteger(part))) { - changed = true - - let currentValue = clone.value - let currentModifier = clone.modifier - - // E.g.: `col-start-[12]` - // ^^ - if (parts.length === 1) { - clone.value = { - kind: 'named', - value: clone.value.value, - fraction: null, - } - } - - // E.g.: `aspect-[12/34]` - // ^^ ^^ - else { - clone.value = { - kind: 'named', - value: parts[0], - fraction: clone.value.value, - } - clone.modifier = { - kind: 'named', - value: parts[1], - } - } - - // Double check that the new value compiles correctly - if (designSystem.compileAstNodes(clone).length === 0) { - clone.value = currentValue - clone.modifier = currentModifier - changed = false - } - } - } - for (let variant of variants(clone)) { // Convert `data-[selected]` to `data-selected` if ( @@ -201,3 +120,75 @@ function* variants(candidate: Candidate) { yield* inner(variant) } } + +// Convert functional utilities with arbitrary values to bare values if we can. +// We know that bare values can only be: +// +// 1. A number (with increments of .25) +// 2. A percentage (with increments of .25 followed by a `%`) +// 3. A ratio with whole numbers +// +// Not a bare value per se, but if we are dealing with a keyword, that could +// potentially also look like a bare value (aka no `[` or `]`). E.g.: +// ```diff +// grid-cols-[subgrid] +// grid-cols-subgrid +// ``` +function* tryValueReplacements( + candidate: Extract, + value: string = candidate.value?.value ?? '', + seen: Set = new Set(), +): Generator { + if (seen.has(value)) return + seen.add(value) + + // 0. Just try to drop the square brackets and see if it works + // 1. A number (with increments of .25) + yield { + kind: 'named', + value, + fraction: null, + } + + // 2. A percentage (with increments of .25 followed by a `%`) + // Try to drop the `%` and see if it works + if (value.endsWith('%') && isValidSpacingMultiplier(value.slice(0, -1))) { + yield { + kind: 'named', + value: value.slice(0, -1), + fraction: null, + } + } + + // 3. A ratio with whole numbers + if (value.includes('/')) { + let [numerator, denominator] = value.split('/') + if (isPositiveInteger(numerator) && isPositiveInteger(denominator)) { + yield { + kind: 'named', + value: numerator, + fraction: `${numerator}/${denominator}`, + } + } + } + + // It could also be that we have `20px`, we can try just `20` and see if it + // results in the same signature. + let allNumbersAndFractions = new Set() + + // Figure out all numbers and fractions in the value + for (let match of value.matchAll(/(\d+\/\d+)|(\d+\.?\d+)/g)) { + allNumbersAndFractions.add(match[0].trim()) + } + + // Sort the numbers and fractions where the smallest length comes first. This + // will result in the smallest replacement. + let options = Array.from(allNumbersAndFractions).sort((a, z) => { + return a.length - z.length + }) + + // Try all the options + for (let option of options) { + yield* tryValueReplacements(candidate, option, seen) + } +} From a2585e9f1554f3c19913f1c65b92b511a873a3ec Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:04:37 +0200 Subject: [PATCH 12/39] improve printing of candidates 1. Introduced a `printModifier`, so we can re-use it 2. For formatters that work on strings, we can cache the information instead of re-computing everything over and over again. This is useful due to the new migrations that happen to parse & print very often. --- .../src/codemods/template/candidates.ts | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts index 281b985aebc6..e2e0cc549988 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts @@ -1,6 +1,12 @@ import { Scanner } from '@tailwindcss/oxide' -import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate' +import type { + ArbitraryModifier, + Candidate, + NamedModifier, + Variant, +} from '../../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import * as ValueParser from '../../../../tailwindcss/src/value-parser' export async function extractRawCandidates( @@ -66,17 +72,7 @@ export function printCandidate(designSystem: DesignSystem, candidate: Candidate) // Handle modifier if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') { - if (candidate.modifier) { - let isVarValue = isVar(candidate.modifier.value) - let value = isVarValue ? candidate.modifier.value.slice(4, -1) : candidate.modifier.value - let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] - - if (candidate.modifier.kind === 'arbitrary') { - base += `/${open}${printArbitraryValue(value)}${close}` - } else if (candidate.modifier.kind === 'named') { - base += `/${candidate.modifier.value}` - } - } + base += printModifier(candidate.modifier) } // Handle important @@ -89,7 +85,23 @@ export function printCandidate(designSystem: DesignSystem, candidate: Candidate) return parts.join(':') } -function printVariant(variant: Variant) { +export function printModifier(modifier: ArbitraryModifier | NamedModifier | null) { + if (modifier === null) return '' + + let isVarValue = isVar(modifier.value) + let value = isVarValue ? modifier.value.slice(4, -1) : modifier.value + let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] + + if (modifier.kind === 'arbitrary') { + return `/${open}${printArbitraryValue(value)}${close}` + } else if (modifier.kind === 'named') { + return `/${modifier.value}` + } else { + modifier satisfies never + } +} + +export function printVariant(variant: Variant) { // Handle static variants if (variant.kind === 'static') { return variant.root @@ -130,19 +142,13 @@ function printVariant(variant: Variant) { // Handle modifiers if (variant.kind === 'functional' || variant.kind === 'compound') { - if (variant.modifier) { - if (variant.modifier.kind === 'arbitrary') { - base += `/[${printArbitraryValue(variant.modifier.value)}]` - } else if (variant.modifier.kind === 'named') { - base += `/${variant.modifier.value}` - } - } + base += printModifier(variant.modifier) } return base } -function printArbitraryValue(input: string) { +const printArbitraryValueCache = new DefaultMap((input) => { let ast = ValueParser.parse(input) let drop = new Set() @@ -204,9 +210,12 @@ function printArbitraryValue(input: string) { recursivelyEscapeUnderscores(ast) return ValueParser.toCss(ast) +}) +export function printArbitraryValue(input: string) { + return printArbitraryValueCache.get(input) } -function simplifyArbitraryVariant(input: string) { +const simplifyArbitraryVariantCache = new DefaultMap((input) => { let ast = ValueParser.parse(input) // &:is(…) @@ -226,6 +235,9 @@ function simplifyArbitraryVariant(input: string) { } return input +}) +function simplifyArbitraryVariant(input: string) { + return simplifyArbitraryVariantCache.get(input) } function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) { @@ -272,9 +284,12 @@ function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) { } } -function isVar(value: string) { +const isVarCache = new DefaultMap((value) => { let ast = ValueParser.parse(value) return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var' +}) +function isVar(value: string) { + return isVarCache.get(value) } function never(value: never): never { From 52de5ed72b4e6ec457bee070c3f3e21144148743 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:05:38 +0200 Subject: [PATCH 13/39] update apply migration test Since this is using all the migrations, it also means that some of the values were optimized, so let's reflect that in the changes. --- .../src/codemods/css/migrate-at-apply.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts index 0c72cb7be4cb..d0b722c9cf15 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts @@ -113,7 +113,7 @@ it('should apply all candidate migration when migrating with a config', async () `), ).toMatchInlineSnapshot(` ".foo { - @apply tw:flex! tw:[color:var(--my-color)] tw:bg-linear-to-t; + @apply tw:flex! tw:text-(--my-color) tw:bg-linear-to-t; }" `) }) From 271f803d198995a7ac1fada0809126fef5c70ad8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:07:55 +0200 Subject: [PATCH 14/39] move test Note: I removed the `leading-[1]` -> `leading-[none]` test. This is not special cased anymore and is now done by another migration. So this test is just moved to a different spot. --- .../src/codemods/template/migrate-arbitrary-utilities.test.ts | 4 ++++ .../template/migrate-arbitrary-value-to-bare-value.test.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) 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 index a8f5da63c4db..b477209e834f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts @@ -57,6 +57,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['[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'], diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts index 2e1547e28f12..45ab8bd2abe0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.test.ts @@ -34,7 +34,6 @@ test.each([ // Leading is special, because `leading-[123]` is the direct value of 123, but // `leading-123` maps to `calc(--spacing(123))`. - ['leading-[1]', 'leading-none'], ['leading-[123]', 'leading-[123]'], ['data-[selected]:flex', 'data-selected:flex'], From 3705e1a5f4a046675369fd7a73834a549ca1a889 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 16:29:31 +0200 Subject: [PATCH 15/39] add walk variants util --- .../migrate-arbitrary-value-to-bare-value.ts | 17 ++------------ .../template/migrate-arbitrary-variants.ts | 17 ++------------ .../migrate-modernize-arbitrary-values.ts | 22 +++--------------- .../codemods/template/migrate-theme-to-var.ts | 23 +++---------------- .../src/utils/walk-variants.ts | 18 +++++++++++++++ 5 files changed, 28 insertions(+), 69 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/utils/walk-variants.ts 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 7cca343666e9..d688228c8e73 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 @@ -2,7 +2,6 @@ import { parseCandidate, type Candidate, type NamedUtilityValue, - type Variant, } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' @@ -11,6 +10,7 @@ import { isValidSpacingMultiplier, } from '../../../../tailwindcss/src/utils/infer-data-type' import { segment } from '../../../../tailwindcss/src/utils/segment' +import { walkVariants } from '../../utils/walk-variants' import { printCandidate } from './candidates' import { computeUtilitySignature } from './signatures' @@ -40,7 +40,7 @@ export function migrateArbitraryValueToBareValue( } } - for (let variant of variants(clone)) { + for (let [variant] of walkVariants(clone)) { // Convert `data-[selected]` to `data-selected` if ( variant.kind === 'functional' && @@ -108,19 +108,6 @@ export function migrateArbitraryValueToBareValue( return rawCandidate } -function* variants(candidate: Candidate) { - function* inner(variant: Variant): Iterable { - yield variant - if (variant.kind === 'compound') { - yield* inner(variant.variant) - } - } - - for (let variant of candidate.variants) { - yield* inner(variant) - } -} - // Convert functional utilities with arbitrary values to bare values if we can. // We know that bare values can only be: // 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 16cfec218422..2c469d2a8742 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,9 @@ -import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { memcpy } from '../../utils/memcpy' import type { Writable } from '../../utils/types' +import { walkVariants } from '../../utils/walk-variants' import { printCandidate, printVariant } from './candidates' import { computeVariantSignature } from './signatures' @@ -38,7 +38,7 @@ export function migrateArbitraryVariants( // DesignSystem are cached, we can't mutate them directly. let candidate = structuredClone(readonlyCandidate) as Writable - for (let variant of variants(candidate)) { + for (let [variant] of walkVariants(candidate)) { if (variant.kind === 'compound') continue let targetString = printVariant(variant) @@ -60,16 +60,3 @@ export function migrateArbitraryVariants( return rawCandidate } - -function* variants(candidate: Candidate) { - function* inner(variant: Variant): Iterable { - yield variant - if (variant.kind === 'compound') { - yield* inner(variant.variant) - } - } - - for (let variant of candidate.variants) { - yield* inner(variant) - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts index 46ece0c43a06..6bff4aab1624 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,10 +1,11 @@ import SelectorParser from 'postcss-selector-parser' -import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate' +import { parseCandidate, type Variant } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { memcpy } from '../../utils/memcpy' +import { walkVariants } from '../../utils/walk-variants' import { printCandidate } from './candidates' export function migrateModernizeArbitraryValues( @@ -16,7 +17,7 @@ export function migrateModernizeArbitraryValues( let clone = structuredClone(candidate) let changed = false - for (let [variant, parent] of variants(clone)) { + for (let [variant, parent] of walkVariants(clone)) { // Forward modifier from the root to the compound variant if ( variant.kind === 'compound' && @@ -480,20 +481,3 @@ export function migrateModernizeArbitraryValues( return rawCandidate } - -function* variants(candidate: Candidate) { - function* inner( - variant: Variant, - parent: Extract | null = null, - ): Iterable<[Variant, Extract | null]> { - yield [variant, parent] - - if (variant.kind === 'compound') { - yield* inner(variant.variant, variant) - } - } - - for (let variant of candidate.variants) { - yield* inner(variant, null) - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts index 6e99d6f9482d..20c6072cc39e 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts @@ -1,9 +1,4 @@ -import { - parseCandidate, - type Candidate, - type CandidateModifier, - type Variant, -} from '../../../../tailwindcss/src/candidate' +import { parseCandidate, type CandidateModifier } from '../../../../tailwindcss/src/candidate' import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' @@ -11,6 +6,7 @@ import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infe import { segment } from '../../../../tailwindcss/src/utils/segment' import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path' import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import { walkVariants } from '../../utils/walk-variants' import { printCandidate } from './candidates' export const enum Convert { @@ -59,7 +55,7 @@ export function migrateThemeToVar( } // Handle variants - for (let variant of variants(clone)) { + for (let [variant] of walkVariants(clone)) { if (variant.kind === 'arbitrary') { let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly) if (newValue !== variant.selector) { @@ -332,16 +328,3 @@ function eventuallyUnquote(value: string) { return unquoted } - -function* variants(candidate: Candidate) { - function* inner(variant: Variant): Iterable { - yield variant - if (variant.kind === 'compound') { - yield* inner(variant.variant) - } - } - - for (let variant of candidate.variants) { - yield* inner(variant) - } -} diff --git a/packages/@tailwindcss-upgrade/src/utils/walk-variants.ts b/packages/@tailwindcss-upgrade/src/utils/walk-variants.ts new file mode 100644 index 000000000000..e0dd16899fa2 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/walk-variants.ts @@ -0,0 +1,18 @@ +import type { Candidate, Variant } from '../../../tailwindcss/src/candidate' + +export function* walkVariants(candidate: Candidate) { + function* inner( + variant: Variant, + parent: Extract | null = null, + ): Iterable<[Variant, Extract | null]> { + yield [variant, parent] + + if (variant.kind === 'compound') { + yield* inner(variant.variant, variant) + } + } + + for (let variant of candidate.variants) { + yield* inner(variant, null) + } +} From 49da4f0355362e9c41c04cd73b44b8d76e0d53a0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 17:15:56 +0200 Subject: [PATCH 16/39] remove hardcoded list of variants --- ...migrate-modernize-arbitrary-values.test.ts | 39 ++-- .../migrate-modernize-arbitrary-values.ts | 166 +++++------------- .../src/codemods/template/migrate.ts | 2 +- 3 files changed, 68 insertions(+), 139 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 842d627f94b1..a4fda26ea29b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts @@ -1,11 +1,26 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { expect, test, vi } from 'vitest' +import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import * as versions from '../../utils/version' +import { migrateArbitraryVariants } from './migrate-arbitrary-variants' import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values' import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-values' import { migratePrefix } from './migrate-prefix' vi.spyOn(versions, 'isMajor').mockReturnValue(true) +function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) { + for (let migration of [ + migrateEmptyArbitraryValues, + migratePrefix, + migrateModernizeArbitraryValues, + migrateArbitraryVariants, + ]) { + rawCandidate = migration(designSystem, userConfig, rawCandidate) + } + return rawCandidate +} + test.each([ // Arbitrary variants ['[[data-visible]]:flex', 'data-visible:flex'], @@ -72,6 +87,9 @@ test.each([ // Keep multiple attribute selectors as-is ['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'], + // Keep `:where(…)` as is + ['[:where([data-visible])]:flex', '[:where([data-visible])]:flex'], + // Complex attribute selectors with operators, quotes and insensitivity flags ['[[data-url*="example"]]:flex', 'data-[url*="example"]:flex'], ['[[data-url$=".com"_i]]:flex', 'data-[url$=".com"_i]:flex'], @@ -87,6 +105,13 @@ test.each([ ['[@media_print]:flex', 'print:flex'], ['[@media_not_print]:flex', 'not-print:flex'], + // Hoist the `:not` part to a compound variant + ['[@media_not_(prefers-color-scheme:dark)]:flex', 'not-dark:flex'], + [ + '[@media_not_(prefers-color-scheme:unknown)]:flex', + 'not-[@media_(prefers-color-scheme:unknown)]:flex', + ], + // Compound arbitrary variants ['has-[[data-visible]]:flex', 'has-data-visible:flex'], ['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'], @@ -104,12 +129,7 @@ test.each([ base: __dirname, }) - expect( - [migrateEmptyArbitraryValues, migrateModernizeArbitraryValues].reduce( - (acc, step) => step(designSystem, {}, acc), - candidate, - ), - ).toEqual(result) + expect(migrate(designSystem, {}, candidate)).toEqual(result) }) test.each([ @@ -138,10 +158,5 @@ test.each([ base: __dirname, }) - expect( - [migrateEmptyArbitraryValues, migratePrefix, migrateModernizeArbitraryValues].reduce( - (acc, step) => step(designSystem, { prefix: 'tw-' }, acc), - candidate, - ), - ).toEqual(result) + expect(migrate(designSystem, { prefix: 'tw-' }, candidate)).toEqual(result) }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts index 6bff4aab1624..a1e7e6cb729a 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 @@ -6,13 +6,16 @@ import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data- import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { memcpy } from '../../utils/memcpy' import { walkVariants } from '../../utils/walk-variants' -import { printCandidate } from './candidates' +import { printCandidate, printVariant } from './candidates' +import { computeVariantSignature } from './signatures' export function migrateModernizeArbitraryValues( designSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, ): string { + let signatures = computeVariantSignature.get(designSystem) + for (let candidate of parseCandidate(rawCandidate, designSystem)) { let clone = structuredClone(candidate) let changed = false @@ -141,105 +144,49 @@ export function migrateModernizeArbitraryValues( continue } - // Migrate `@media` variants + // Hoist `not` modifier for `@media` or `@supports` variants // - // E.g.: `[@media(scripting:none)]:` -> `noscript:` + // E.g.: `[@media_not_(scripting:none)]:` -> `not-[@media_(scripting:none)]:` if ( // Only top-level, so something like `in-[@media(scripting:none)]` // (which is not valid anyway) is not supported parent === null && - // [@media(scripting:none)]:flex - // ^^^^^^^^^^^^^^^^^^^^^^ + // [@media_not(scripting:none)]:flex + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ast.nodes[0].nodes[0].type === 'tag' && - ast.nodes[0].nodes[0].value.startsWith('@media') + (ast.nodes[0].nodes[0].value.startsWith('@media') || + ast.nodes[0].nodes[0].value.startsWith('@supports')) ) { - // Replace all whitespace such that `@media (scripting: none)` and - // `@media(scripting:none)` are equivalent. - // - // As arbitrary variants that means that these are equivalent: - // - `[@media_(scripting:_none)]:` - // - `[@media(scripting:none)]:` - let parsed = ValueParser.parse(ast.nodes[0].toString().trim().replace('@media', '')) - - // Drop whitespace + let targetSignature = signatures.get(printVariant(variant)) + let parsed = ValueParser.parse(ast.nodes[0].toString().trim()) + let containsNot = false ValueParser.walk(parsed, (node, { replaceWith }) => { - // Drop whitespace nodes - if (node.kind === 'separator' && !node.value.trim()) { + if (node.kind === 'word' && node.value === 'not') { + containsNot = true replaceWith([]) } - - // Trim whitespace - else { - node.value = node.value.trim() - } }) - let not = false - if (parsed[0]?.kind === 'word' && parsed[0].value === 'not') { - not = true - parsed.shift() - } - - // Single keyword at-rules. - // - // E.g.: `[@media_print]:` -< `@media print` -> `print:` - if (parsed.length === 1 && parsed[0].kind === 'word') { - let key = parsed[0].value - let replacement: string | null = null - if (key === 'print') replacement = 'print' - - if (replacement) { - changed = true - memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`)) + // Remove unnecessary whitespace + parsed = ValueParser.parse(ValueParser.toCss(parsed)) + ValueParser.walk(parsed, (node) => { + if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') { + // node.value contains at least 2 spaces. Normalize it to a single + // space. + node.value = ' ' } - } + }) - // Key/value at-rules. - // - // E.g.: `[@media(scripting:none)]:` -> `scripting:` - if ( - parsed.length === 1 && - parsed[0].kind === 'function' && // `(` and `)` are considered a function - parsed[0].nodes.length === 3 && - parsed[0].nodes[0].kind === 'word' && - parsed[0].nodes[1].kind === 'separator' && - parsed[0].nodes[1].value === ':' && - parsed[0].nodes[2].kind === 'word' - ) { - let key = parsed[0].nodes[0].value - let value = parsed[0].nodes[2].value - let replacement: string | null = null - - if (key === 'prefers-reduced-motion' && value === 'no-preference') - replacement = 'motion-safe' - if (key === 'prefers-reduced-motion' && value === 'reduce') - replacement = 'motion-reduce' - - if (key === 'prefers-contrast' && value === 'more') replacement = 'contrast-more' - if (key === 'prefers-contrast' && value === 'less') replacement = 'contrast-less' - - if (key === 'orientation' && value === 'portrait') replacement = 'portrait' - if (key === 'orientation' && value === 'landscape') replacement = 'landscape' - - if (key === 'forced-colors' && value === 'active') replacement = 'forced-colors' - - if (key === 'inverted-colors' && value === 'inverted') replacement = 'inverted-colors' - - if (key === 'pointer' && value === 'none') replacement = 'pointer-none' - if (key === 'pointer' && value === 'coarse') replacement = 'pointer-coarse' - if (key === 'pointer' && value === 'fine') replacement = 'pointer-fine' - if (key === 'any-pointer' && value === 'none') replacement = 'any-pointer-none' - if (key === 'any-pointer' && value === 'coarse') replacement = 'any-pointer-coarse' - if (key === 'any-pointer' && value === 'fine') replacement = 'any-pointer-fine' - - if (key === 'scripting' && value === 'none') replacement = 'noscript' - - if (replacement) { + if (containsNot) { + let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`) + if (hoistedNot === null) continue + let hoistedNotSignature = signatures.get(printVariant(hoistedNot)) + if (targetSignature === hoistedNotSignature) { changed = true - memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`)) + memcpy(variant, hoistedNot) + continue } } - continue } let prefixedVariant: Variant | null = null @@ -311,48 +258,6 @@ export function migrateModernizeArbitraryValues( } let newVariant = ((value) => { - // - if (value === ':first-letter') return 'first-letter' - else if (value === ':first-line') return 'first-line' - // - else if (value === ':file-selector-button') return 'file' - else if (value === ':placeholder') return 'placeholder' - else if (value === ':backdrop') return 'backdrop' - // Positional - else if (value === ':first-child') return 'first' - else if (value === ':last-child') return 'last' - else if (value === ':only-child') return 'only' - else if (value === ':first-of-type') return 'first-of-type' - else if (value === ':last-of-type') return 'last-of-type' - else if (value === ':only-of-type') return 'only-of-type' - // State - else if (value === ':visited') return 'visited' - else if (value === ':target') return 'target' - // Forms - else if (value === ':default') return 'default' - else if (value === ':checked') return 'checked' - else if (value === ':indeterminate') return 'indeterminate' - else if (value === ':placeholder-shown') return 'placeholder-shown' - else if (value === ':autofill') return 'autofill' - else if (value === ':optional') return 'optional' - else if (value === ':required') return 'required' - else if (value === ':valid') return 'valid' - else if (value === ':invalid') return 'invalid' - else if (value === ':user-valid') return 'user-valid' - else if (value === ':user-invalid') return 'user-invalid' - else if (value === ':in-range') return 'in-range' - else if (value === ':out-of-range') return 'out-of-range' - else if (value === ':read-only') return 'read-only' - // Content - else if (value === ':empty') return 'empty' - // Interactive - else if (value === ':focus-within') return 'focus-within' - else if (value === ':focus') return 'focus' - else if (value === ':focus-visible') return 'focus-visible' - else if (value === ':active') return 'active' - else if (value === ':enabled') return 'enabled' - else if (value === ':disabled') return 'disabled' - // if ( value === ':nth-child' && targetNode.nodes.length === 1 && @@ -399,6 +304,15 @@ export function migrateModernizeArbitraryValues( } } + // Hoist `not` modifier + if (compoundNot) { + let targetSignature = signatures.get(printVariant(variant)) + let replacementSignature = signatures.get(`not-[${value}]`) + if (targetSignature === replacementSignature) { + return `[&${value}]` + } + } + return null })(targetNode.value) @@ -412,7 +326,7 @@ export function migrateModernizeArbitraryValues( // Update original variant changed = true - memcpy(variant, parsed) + memcpy(variant, structuredClone(parsed)) } // Expecting an attribute selector diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index a8ee2a3bf800..61cc58a9b62b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -47,10 +47,10 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateAutomaticVarInjection, migrateLegacyArbitraryValues, migrateArbitraryUtilities, + migrateModernizeArbitraryValues, migrateArbitraryVariants, migrateDropUnnecessaryDataTypes, migrateArbitraryValueToBareValue, - migrateModernizeArbitraryValues, migrateOptimizeModifier, ] From 1f69c0fc69151cd5ee79c53a15bb817a4e2eee30 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 17:23:04 +0200 Subject: [PATCH 17/39] ensure incomputable signatures result in unique value --- .../template/migrate-arbitrary-utilities.ts | 14 +++++------ .../template/migrate-arbitrary-variants.ts | 11 +++++---- .../migrate-optimize-modifier.test.ts | 3 +++ .../src/codemods/template/signatures.ts | 24 +++++++++++-------- 4 files changed, 31 insertions(+), 21 deletions(-) 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 868920af03e3..1fe7cfc9058a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -22,7 +22,7 @@ const preComputedUtilities = new DefaultMap for (let [root, variant] of designSystem.variants.entries()) { if (variant.kind === 'static') { let signature = signatures.get(root) - if (signature === null) continue + if (typeof signature !== 'string') continue lookup.get(signature).push(root) } } @@ -30,6 +30,9 @@ export function migrateArbitraryVariants( _userConfig: Config | null, rawCandidate: string, ): string { + let signatures = computeVariantSignature.get(designSystem) + let variants = variantsLookup.get(designSystem) + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { // We are only interested in the variants if (readonlyCandidate.variants.length <= 0) return rawCandidate @@ -42,10 +45,10 @@ export function migrateArbitraryVariants( if (variant.kind === 'compound') continue let targetString = printVariant(variant) - let targetSignature = computeVariantSignature.get(designSystem).get(targetString) - if (!targetSignature) continue + let targetSignature = signatures.get(targetString) + if (typeof targetSignature !== 'string') continue - let foundVariants = variantsLookup.get(designSystem).get(targetSignature) + let foundVariants = variants.get(targetSignature) if (foundVariants.length !== 1) continue let foundVariant = foundVariants[0] 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 index 26fa6a383827..d4a6e1bd4afc 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.test.ts @@ -40,6 +40,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // 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}` diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index fb837a01a955..5bdf70d2f7bc 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -9,8 +9,8 @@ import { printArbitraryValue } from './candidates' // 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 -// null if the utility is not valid. The class in the selector will be replaced -// with the `.x` selector. +// a unique symbol if the utility is not valid. The class in the selector will +// be replaced with the `.x` selector. // // This function should only be passed the base utility so `flex`, `hover:flex` // and `focus:flex` will all use just `flex`. Variants are handled separately. @@ -25,9 +25,9 @@ import { printArbitraryValue } from './candidates' // These produce the same signature, therefore they represent the same utility. export const computeUtilitySignature = new DefaultMap< DesignSystem, - DefaultMap + DefaultMap >((designSystem) => { - return new DefaultMap((utility) => { + return new DefaultMap((utility) => { try { // Ensure the prefix is added to the utility if it is not already present. utility = @@ -181,15 +181,17 @@ export const computeUtilitySignature = new DefaultMap< let signature = toCss(ast) return signature } catch { - return null + // A unique symbol is returned to ensure that 2 signatures resulting in + // `null` are not considered equal. + return Symbol() } }) }) // Given a variant, compute a signature that represents the variant. The // signature will be a normalised form of the generated CSS for the variant, or -// null if the variant is not valid. The class in the selector will be replaced -// with `.x`. +// a unique symbol if the variant is not valid. The class in the selector will +// be replaced with `.x`. // // E.g.: // @@ -201,9 +203,9 @@ export const computeUtilitySignature = new DefaultMap< // These produce the same signature, therefore they represent the same variant. export const computeVariantSignature = new DefaultMap< DesignSystem, - DefaultMap + DefaultMap >((designSystem) => { - return new DefaultMap((variant) => { + return new DefaultMap((variant) => { try { // Ensure the prefix is added to the utility if it is not already present. variant = @@ -238,7 +240,9 @@ export const computeVariantSignature = new DefaultMap< let signature = toCss(ast) return signature } catch { - return null + // A unique symbol is returned to ensure that 2 signatures resulting in + // `null` are not considered equal. + return Symbol() } }) }) From 5af9fa5b2b8e9d75e31daab576dce8afc4a58050 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 20:19:14 +0200 Subject: [PATCH 18/39] `--tw-sort` is the property, not the value --- .../@tailwindcss-upgrade/src/codemods/template/signatures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index 5bdf70d2f7bc..f7a4306243ef 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -50,7 +50,7 @@ export const computeUtilitySignature = new DefaultMap< walk(ast, (node, { replaceWith }) => { // Optimize declarations if (node.kind === 'declaration') { - if (node.value === undefined || node.value === '--tw-sort') { + if (node.value === undefined || node.property === '--tw-sort') { replaceWith([]) } } From fe5d717b9ddea2dcfa4f044405c439616dc62fe9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 30 Apr 2025 20:19:26 +0200 Subject: [PATCH 19/39] prevent infinitely parsing the same value --- .../@tailwindcss-upgrade/src/codemods/template/signatures.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index f7a4306243ef..d29f9c571d3e 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -111,6 +111,7 @@ export const computeUtilitySignature = new DefaultMap< if (node.value.includes('var(')) { let valueAst = ValueParser.parse(node.value) + let seen = new Set() ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { if (valueNode.kind !== 'function') return if (valueNode.value !== 'var') return @@ -131,6 +132,10 @@ export const computeUtilitySignature = new DefaultMap< variable = variable.slice(`--${designSystem.theme.prefix}-`.length) } let variableValue = designSystem.resolveThemeValue(variable) + // Prevent infinite recursion when the variable value contains the + // variable itself. + if (seen.has(variable)) return + seen.add(variable) if (variableValue === undefined) return // Couldn't resolve the variable // Inject variable fallbacks when no fallback is present yet. From 457e9b56cc9d3656ad61dd5c3bbfe163c68cc7eb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 00:25:28 +0200 Subject: [PATCH 20/39] improve signature generation for variants --- .../src/codemods/template/signatures.ts | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index d29f9c571d3e..732b6080a92b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -224,17 +224,64 @@ export const computeVariantSignature = new DefaultMap< // Canonicalize selectors to their minimal form walk(ast, (node) => { + // At-rules if (node.kind === 'at-rule' && node.params.includes(' ')) { node.params = node.params.replaceAll(' ', '') - } else if (node.kind === 'rule') { + } + + // Style rules + else if (node.kind === 'rule') { let selectorAst = SelectorParser.parse(node.selector) let changed = false - SelectorParser.walk(selectorAst, (node) => { + SelectorParser.walk(selectorAst, (node, { replaceWith }) => { if (node.kind === 'separator' && node.value !== ' ') { node.value = node.value.trim() changed = true } + + // Remove unnecessary `:is(…)` selectors + else if (node.kind === 'function' && node.value === ':is') { + // A single selector inside of `:is(…)` can be replaced with the + // selector itself. + // + // E.g.: `:is(.foo)` → `.foo` + if (node.nodes.length === 1) { + changed = true + replaceWith(node.nodes) + } + + // A selector with the universal selector `*` followed by a pseudo + // class, can be replaced with the pseudo class itself. + else if ( + node.nodes.length === 2 && + node.nodes[0].kind === 'selector' && + node.nodes[0].value === '*' && + node.nodes[1].kind === 'selector' && + node.nodes[1].value[0] === ':' + ) { + changed = true + replaceWith(node.nodes[1]) + } + } + + // Ensure `*` exists before pseudo selectors inside of `:not(…)`, + // `:where(…)`, … + // + // E.g.: + // + // `:not(:first-child)` → `:not(*:first-child)` + // + else if ( + node.kind === 'function' && + node.value[0] === ':' && + node.nodes[0]?.kind === 'selector' && + node.nodes[0]?.value[0] === ':' + ) { + changed = true + node.nodes.unshift({ kind: 'selector', value: '*' }) + } }) + if (changed) { node.selector = SelectorParser.toCss(selectorAst) } From 100f524df1f8cafd0b6ab49708c35fdf2caeb415 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 00:36:34 +0200 Subject: [PATCH 21/39] update integration tests --- integrations/upgrade/index.test.ts | 2 +- integrations/upgrade/js-config.test.ts | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 264bd7ff4c3c..df86c3b6bd61 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -209,7 +209,7 @@ test( " --- ./src/index.html ---
{ " --- src/index.html ---
--- src/input.css --- @@ -1439,12 +1439,12 @@ describe('border compatibility', () => { " --- src/index.html ---
--- src/input.css --- From 0f4c72445a80828e157ce9d0d48c64264521af18 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 01:30:50 +0200 Subject: [PATCH 22/39] add tests to migrate to more specific utilities --- .../template/migrate-arbitrary-utilities.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index b477209e834f..997c0a2abdaa 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts @@ -5,6 +5,7 @@ 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) => { @@ -16,6 +17,7 @@ const designSystems = new DefaultMap((base: string) => { function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) { for (let migration of [ migrateArbitraryUtilities, + migrateDropUnnecessaryDataTypes, migrateArbitraryValueToBareValue, migrateOptimizeModifier, ]) { @@ -88,6 +90,13 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['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]'], + // Complex arbitrary property to arbitrary value [ '[grid-template-columns:repeat(2,minmax(100px,1fr))]', From 6aeecd69bddd7ed04b45cbc623f0f6817ac043db Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 01:32:43 +0200 Subject: [PATCH 23/39] try to migrate both arbitrary properties and arbitrary values This will allow us to also migrate candidates such as: `bg-[123px]` To `bg-position-[123px]` This reduces ambiguity and the need for dedicated data types --- .../template/migrate-arbitrary-utilities.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) 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 1fe7cfc9058a..74bcf7a37080 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -222,13 +222,12 @@ export function migrateArbitraryUtilities( 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. - if (candidate.kind === 'arbitrary') { + let value = + candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) + if (value !== null) { for (let root of designSystem.utilities.keys('functional')) { // Try as arbitrary value - for (let replacementCandidate of parseCandidate( - designSystem, - `${root}-[${candidate.value}]`, - )) { + for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) { yield replacementCandidate } @@ -236,17 +235,14 @@ export function migrateArbitraryUtilities( if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-[${candidate.value}]${printModifier(candidate.modifier)}`, + `${root}-[${value}]${printModifier(candidate.modifier)}`, )) { yield replacementCandidate } } // Try as bare value - for (let replacementCandidate of parseCandidate( - designSystem, - `${root}-${candidate.value}`, - )) { + for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { yield replacementCandidate } @@ -254,7 +250,7 @@ export function migrateArbitraryUtilities( if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-${candidate.value}${candidate.modifier}`, + `${root}-${value}${candidate.modifier}`, )) { yield replacementCandidate } From fa15d9132c234b8bef9ad2450214ebfc8f837ec3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 01:33:38 +0200 Subject: [PATCH 24/39] handle negative in arbitrary scale When using `-scale-[0.5]`, it resulted in `scale: 0.5` instead of `scale: calc(0.5 * -1)` --- packages/tailwindcss/src/utilities.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 1b6abc60b98e..d28a368a43f5 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1300,6 +1300,7 @@ export function createUtilities(theme: Theme) { let value if (candidate.value.kind === 'arbitrary') { value = candidate.value.value + value = negative ? `calc(${value} * -1)` : value return [decl('scale', value)] } else { value = theme.resolve(candidate.value.value, ['--scale']) From f88fec3502e68bc0268711f07ae9976efe8a52dc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 01:38:30 +0200 Subject: [PATCH 25/39] update changelog --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 101339a9e426..7f5af077ee9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- Upgrade: Improved upgrading classes ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831)) ## [4.1.5] - 2025-04-30 ### Added -- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717)) +- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.\* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717)) - Add `h-lh` / `min-h-lh` / `max-h-lh` utilities ([#17790](https://github.com/tailwindlabs/tailwindcss/pull/17790)) - Transition `display`, `visibility`, `content-visibility`, `overlay`, and `pointer-events` when using `transition` to simplify `@starting-style` usage ([#17812](https://github.com/tailwindlabs/tailwindcss/pull/17812)) From 892a41796531955afba35dbc0489f4c814ae755d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 01:44:51 +0200 Subject: [PATCH 26/39] prefer bare values over arbitrary values --- .../template/migrate-arbitrary-utilities.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 74bcf7a37080..514c484d003a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -226,31 +226,31 @@ export function migrateArbitraryUtilities( candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) if (value !== null) { for (let root of designSystem.utilities.keys('functional')) { - // Try as arbitrary value - for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) { + // Try as bare value + for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { yield replacementCandidate } - // Try as arbitrary value with modifier + // Try as bare value with modifier if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-[${value}]${printModifier(candidate.modifier)}`, + `${root}-${value}${candidate.modifier}`, )) { yield replacementCandidate } } - // Try as bare value - for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { + // Try as arbitrary value + for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) { yield replacementCandidate } - // Try as bare value with modifier + // Try as arbitrary value with modifier if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-${value}${candidate.modifier}`, + `${root}-[${value}]${printModifier(candidate.modifier)}`, )) { yield replacementCandidate } From ba360ab3225f813b5a737c09442d3d19358618cb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 18:22:15 +0200 Subject: [PATCH 27/39] convert arbitrary rem value to bare value This keeps the spacing scale modifier into account to make sure the migration is safe. --- .../migrate-arbitrary-utilities.test.ts | 56 +++++++++++++++++++ .../template/migrate-arbitrary-utilities.ts | 50 +++++++++++++++++ .../src/codemods/template/signatures.ts | 46 +++++++++++++++ 3 files changed, 152 insertions(+) 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 index 997c0a2abdaa..6a4c4166c080 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.test.ts @@ -97,6 +97,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // 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))]', @@ -291,3 +294,56 @@ test('migrate a arbitrary property without spaces, to a theme value with spaces 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 index 514c484d003a..bfaffc5a297a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -76,6 +76,34 @@ const baseReplacementsCache = new DefaultMap new Map(), ) +const spacing = new DefaultMap>((ds) => { + let spacingMultiplier = ds.resolveThemeValue('--spacing') + let value: number | null = null + let unit: string | null = null + + if (typeof spacingMultiplier === 'string') { + let match = /(?(\d*)?\.?\d+)(?.*)/.exec(spacingMultiplier) + if (match) { + value = Number(match.groups?.value) + unit = match.groups?.unit ?? null + } + } + + return new DefaultMap((input) => { + if (value === null || unit === null) return null + + let match = /(?(\d*)?\.?\d+)(?.*)/.exec(input) + if (match === null) return null + + let myValue = Number(match.groups?.value) + let myUnit = match.groups?.unit ?? null + + if (myUnit !== unit) return null + + return myValue / value + }) +}) + export function migrateArbitraryUtilities( designSystem: DesignSystem, _userConfig: Config | null, @@ -224,7 +252,10 @@ export function migrateArbitraryUtilities( // to find functional utilities that also set this property. let value = candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) + if (value !== null) { + let bareValue = spacing.get(designSystem).get(value) + for (let root of designSystem.utilities.keys('functional')) { // Try as bare value for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { @@ -241,6 +272,25 @@ export function migrateArbitraryUtilities( } } + // Try spacing scale. E.g.: + // + // - `w-[64rem]` → `w-256` + if (bareValue !== null) { + for (let replacementCandidate of parseCandidate(designSystem, `${root}-${bareValue}`)) { + yield replacementCandidate + } + + // Try spacing scale with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${bareValue}${candidate.modifier}`, + )) { + yield replacementCandidate + } + } + } + // Try as arbitrary value for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) { yield replacementCandidate diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index 732b6080a92b..ee73c986c4e6 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -173,6 +173,52 @@ export const computeUtilitySignature = new DefaultMap< node.value = ValueParser.toCss(valueAst) } + // Very basic `calc(…)` constant folding to handle the spacing scale + // multiplier: + // + // Input: `--spacing(4)` + // → `calc(var(--spacing, 0.25rem) * 4)` + // → `calc(0.25rem * 4)` ← this is the case we will see + // after inlining the variable + // → `1rem` + if (node.value.includes('calc')) { + let folded = false + let valueAst = ValueParser.parse(node.value) + ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { + if (valueNode.kind !== 'function') return + if (valueNode.value !== 'calc') return + + // [ + // { kind: 'word', value: '0.25rem' }, 0 + // { kind: 'separator', value: ' ' }, 1 + // { kind: 'word', value: '*' }, 2 + // { kind: 'separator', value: ' ' }, 3 + // { kind: 'word', value: '256' } 4 + // ] + if (valueNode.nodes.length !== 5) return + if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return + + let match = /^(?-?(\d*)?\.?\d+)(?.*)$/.exec(valueNode.nodes[0].value) + if (match === null) return + + let value = Number(match.groups?.value) + if (Number.isNaN(value)) return + + let unit = match.groups?.unit ?? null + if (unit === null) return + + let multiplier = Number(valueNode.nodes[4].value) + if (Number.isNaN(multiplier)) return + + folded = true + replaceWith(ValueParser.parse(`${value * multiplier}${unit}`)) + }) + + if (folded) { + node.value = ValueParser.toCss(valueAst) + } + } + // We will normalize the `node.value`, this is the same kind of logic // we use when printing arbitrary values. It will remove unnecessary // whitespace. From ccd8053b4231862613ce847a8465041772575392 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 18:51:54 +0200 Subject: [PATCH 28/39] abstract parsing dimensions --- .../template/migrate-arbitrary-utilities.ts | 29 +++++++------------ .../src/codemods/template/signatures.ts | 11 +++---- .../src/utils/dimension.ts | 18 ++++++++++++ 3 files changed, 33 insertions(+), 25 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/utils/dimension.ts 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 bfaffc5a297a..fd22c43575e2 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -4,6 +4,7 @@ import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import { dimensions } from '../../utils/dimension' import type { Writable } from '../../utils/types' import { printCandidate, printModifier } from './candidates' import { computeUtilitySignature } from './signatures' @@ -76,28 +77,20 @@ const baseReplacementsCache = new DefaultMap new Map(), ) -const spacing = new DefaultMap>((ds) => { +const spacing = new DefaultMap | null>((ds) => { let spacingMultiplier = ds.resolveThemeValue('--spacing') - let value: number | null = null - let unit: string | null = null - - if (typeof spacingMultiplier === 'string') { - let match = /(?(\d*)?\.?\d+)(?.*)/.exec(spacingMultiplier) - if (match) { - value = Number(match.groups?.value) - unit = match.groups?.unit ?? null - } - } + if (spacingMultiplier === undefined) return null - return new DefaultMap((input) => { - if (value === null || unit === null) return null + let parsed = dimensions.get(spacingMultiplier) + if (!parsed) return null - let match = /(?(\d*)?\.?\d+)(?.*)/.exec(input) - if (match === null) return null + let [value, unit] = parsed - let myValue = Number(match.groups?.value) - let myUnit = match.groups?.unit ?? null + 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 @@ -254,7 +247,7 @@ export function migrateArbitraryUtilities( candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) if (value !== null) { - let bareValue = spacing.get(designSystem).get(value) + let bareValue = spacing.get(designSystem)?.get(value) for (let root of designSystem.utilities.keys('functional')) { // Try as bare value diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index ee73c986c4e6..e8e1a24b6a1b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -5,6 +5,7 @@ import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { ThemeOptions } from '../../../../tailwindcss/src/theme' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import * as ValueParser from '../../../../tailwindcss/src/value-parser' +import { dimensions } from '../../utils/dimension' import { printArbitraryValue } from './candidates' // Given a utility, compute a signature that represents the utility. The @@ -198,14 +199,10 @@ export const computeUtilitySignature = new DefaultMap< if (valueNode.nodes.length !== 5) return if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return - let match = /^(?-?(\d*)?\.?\d+)(?.*)$/.exec(valueNode.nodes[0].value) - if (match === null) return + let parsed = dimensions.get(valueNode.nodes[0].value) + if (parsed === null) return - let value = Number(match.groups?.value) - if (Number.isNaN(value)) return - - let unit = match.groups?.unit ?? null - if (unit === null) return + let [value, unit] = parsed let multiplier = Number(valueNode.nodes[4].value) if (Number.isNaN(multiplier)) return diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/@tailwindcss-upgrade/src/utils/dimension.ts new file mode 100644 index 000000000000..01d442247b98 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/dimension.ts @@ -0,0 +1,18 @@ +import { DefaultMap } from '../../../tailwindcss/src/utils/default-map' + +// Parse a dimension such as `64rem` into `[64, 'rem']`. +export const dimensions = new DefaultMap((input) => { + let match = /^(?-?(\d*)?\.?\d+)(?.*)$/.exec(input) + if (!match) return null + + let value = match.groups?.value + if (value === undefined) return null + + let unit = match.groups?.unit + if (unit === undefined) return null + + let valueAsNumber = Number(value) + if (Number.isNaN(valueAsNumber)) return null + + return [valueAsNumber, unit] as const +}) From 5d8b626f111604caa1c596f4a05b85e6eb56d2fb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 20:50:34 +0200 Subject: [PATCH 29/39] Update packages/@tailwindcss-upgrade/src/utils/dimension.ts Co-authored-by: Jordan Pittman --- packages/@tailwindcss-upgrade/src/utils/dimension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/@tailwindcss-upgrade/src/utils/dimension.ts index 01d442247b98..a1dd4bded229 100644 --- a/packages/@tailwindcss-upgrade/src/utils/dimension.ts +++ b/packages/@tailwindcss-upgrade/src/utils/dimension.ts @@ -2,7 +2,7 @@ import { DefaultMap } from '../../../tailwindcss/src/utils/default-map' // Parse a dimension such as `64rem` into `[64, 'rem']`. export const dimensions = new DefaultMap((input) => { - let match = /^(?-?(\d*)?\.?\d+)(?.*)$/.exec(input) + let match = /^(?-?(?:\d*\.)?\d+)(?[a-z]+|%)$/i.exec(input) if (!match) return null let value = match.groups?.value From 34565f0b77f5a9d1d6fc4bb7453e4d57c771d326 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 22:55:12 +0200 Subject: [PATCH 30/39] rename variables / comments --- .../template/migrate-arbitrary-utilities.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 fd22c43575e2..dab767457810 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -247,7 +247,7 @@ export function migrateArbitraryUtilities( candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) if (value !== null) { - let bareValue = spacing.get(designSystem)?.get(value) + let spacingMultiplier = spacing.get(designSystem)?.get(value) for (let root of designSystem.utilities.keys('functional')) { // Try as bare value @@ -265,19 +265,22 @@ export function migrateArbitraryUtilities( } } - // Try spacing scale. E.g.: + // Try bare value based on the `--spacing` value. E.g.: // // - `w-[64rem]` → `w-256` - if (bareValue !== null) { - for (let replacementCandidate of parseCandidate(designSystem, `${root}-${bareValue}`)) { + if (spacingMultiplier !== null) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${spacingMultiplier}`, + )) { yield replacementCandidate } - // Try spacing scale with modifier + // Try bare value based on the `--spacing` value, but with a modifier if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-${bareValue}${candidate.modifier}`, + `${root}-${spacingMultiplier}${candidate.modifier}`, )) { yield replacementCandidate } From 42b621bd1764acc55aed98a9d6fba9b0f6a7b563 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 23:01:44 +0200 Subject: [PATCH 31/39] use existing `getClassList` Which essentially does the same thing... --- .../template/migrate-arbitrary-utilities.ts | 56 +++++-------------- 1 file changed, 13 insertions(+), 43 deletions(-) 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 dab767457810..733224c97d0f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -20,53 +20,23 @@ const preComputedUtilities = new DefaultMap(() => []) - // Actual static utilities - for (let root of ds.utilities.keys('static')) { - let signature = signatures.get(root) + for (let [className, meta] of ds.getClassList()) { + let signature = signatures.get(className) if (typeof signature !== 'string') continue - lookup.get(signature).push(root) - } + lookup.get(signature).push(className) - // Consider functional utilities _with_ known named values as static - // utilities. Aka pre-computed values. - for (let root of ds.utilities.keys('functional')) { - let suggestions = ds.utilities.getCompletions(root) - if (suggestions.length === 0) continue - - for (let { supportsNegative, values, modifiers } of suggestions) { - for (let value of values) { - let candidateString = value === null ? root : `${root}-${value}` - let signature = signatures.get(candidateString) - if (typeof signature === 'string') lookup.get(signature).push(candidateString) - - if (supportsNegative) { - let negativeCandidateString = `-${candidateString}` - let signature = signatures.get(negativeCandidateString) - if (typeof signature === 'string') lookup.get(signature).push(negativeCandidateString) - } + for (let modifier of meta.modifiers) { + // Modifiers representing numbers can be computed and don't need to be + // pre-computed. Doing the math and at the time of writing this, this + // would save you 250k additionally pre-computed utilities... + if (isValidSpacingMultiplier(modifier)) { + continue } - for (let modifier of modifiers) { - // Modifiers representing numbers can be computed and don't need to be - // pre-computed. Doing the math and at the time of writing this, this - // would save you 250k additionally pre-computed utilities... - if (isValidSpacingMultiplier(modifier)) { - continue - } - - for (let value of values) { - let candidateString = - value === null ? `${root}/${modifier}` : `${root}-${value}/${modifier}` - let signature = signatures.get(candidateString) - if (typeof signature === 'string') lookup.get(signature).push(candidateString) - - if (supportsNegative) { - let negativeCandidateString = `-${candidateString}` - let signature = signatures.get(negativeCandidateString) - if (typeof signature === 'string') lookup.get(signature).push(negativeCandidateString) - } - } - } + let classNameWithModifier = `${className}/${modifier}` + let signature = signatures.get(classNameWithModifier) + if (typeof signature !== 'string') continue + lookup.get(signature).push(classNameWithModifier) } } From 1e9ce4120b430f12192b51e369a4b38cc9cf2c0f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 May 2025 23:23:59 +0200 Subject: [PATCH 32/39] move printing candidate to core + expose `printCandidate` and `printVariant` on the DesignSystem --- .../src/codemods/template/candidates.test.ts | 4 +- .../src/codemods/template/candidates.ts | 288 ------------------ .../template/migrate-arbitrary-utilities.ts | 17 +- .../migrate-arbitrary-value-to-bare-value.ts | 5 +- .../template/migrate-arbitrary-variants.ts | 5 +- .../migrate-automatic-var-injection.ts | 3 +- .../codemods/template/migrate-bg-gradient.ts | 3 +- .../migrate-drop-unnecessary-data-types.ts | 3 +- .../codemods/template/migrate-important.ts | 3 +- .../migrate-legacy-arbitrary-values.ts | 3 +- .../template/migrate-legacy-classes.ts | 5 +- .../template/migrate-max-width-screen.ts | 3 +- .../migrate-modernize-arbitrary-values.ts | 9 +- .../template/migrate-optimize-modifier.ts | 7 +- .../src/codemods/template/migrate-prefix.ts | 3 +- .../template/migrate-simple-legacy-classes.ts | 3 +- .../codemods/template/migrate-theme-to-var.ts | 3 +- .../template/migrate-variant-order.ts | 3 +- .../src/codemods/template/migrate.ts | 4 +- .../src/codemods/template/signatures.ts | 2 +- packages/tailwindcss/src/candidate.ts | 282 +++++++++++++++++ packages/tailwindcss/src/design-system.ts | 20 +- 22 files changed, 337 insertions(+), 341 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts index ba8a2d0d9852..1054b56eca01 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts @@ -1,7 +1,7 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { describe, expect, test } from 'vitest' import { spliceChangesIntoString } from '../../utils/splice-changes-into-string' -import { extractRawCandidates, printCandidate } from './candidates' +import { extractRawCandidates } from './candidates' let html = String.raw @@ -190,7 +190,7 @@ describe('printCandidate()', () => { // Sometimes we will have a functional and a static candidate for the same // raw input string (e.g. `-inset-full`). Dedupe in this case. - let cleaned = new Set([...candidates].map((c) => printCandidate(designSystem, c))) + let cleaned = new Set([...candidates].map((c) => designSystem.printCandidate(c))) expect([...cleaned]).toEqual([result]) }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts index e2e0cc549988..e4f8c3720f0f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts @@ -1,13 +1,4 @@ import { Scanner } from '@tailwindcss/oxide' -import type { - ArbitraryModifier, - Candidate, - NamedModifier, - Variant, -} from '../../../../tailwindcss/src/candidate' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' export async function extractRawCandidates( content: string, @@ -22,282 +13,3 @@ export async function extractRawCandidates( } return candidates } - -export function printCandidate(designSystem: DesignSystem, candidate: Candidate) { - let parts: string[] = [] - - for (let variant of candidate.variants) { - parts.unshift(printVariant(variant)) - } - - // Handle prefix - if (designSystem.theme.prefix) { - parts.unshift(designSystem.theme.prefix) - } - - let base: string = '' - - // Handle static - if (candidate.kind === 'static') { - base += candidate.root - } - - // Handle functional - if (candidate.kind === 'functional') { - base += candidate.root - - if (candidate.value) { - if (candidate.value.kind === 'arbitrary') { - if (candidate.value !== null) { - let isVarValue = isVar(candidate.value.value) - let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value - let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] - - if (candidate.value.dataType) { - base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}` - } else { - base += `-${open}${printArbitraryValue(value)}${close}` - } - } - } else if (candidate.value.kind === 'named') { - base += `-${candidate.value.value}` - } - } - } - - // Handle arbitrary - if (candidate.kind === 'arbitrary') { - base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]` - } - - // Handle modifier - if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') { - base += printModifier(candidate.modifier) - } - - // Handle important - if (candidate.important) { - base += '!' - } - - parts.push(base) - - return parts.join(':') -} - -export function printModifier(modifier: ArbitraryModifier | NamedModifier | null) { - if (modifier === null) return '' - - let isVarValue = isVar(modifier.value) - let value = isVarValue ? modifier.value.slice(4, -1) : modifier.value - let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] - - if (modifier.kind === 'arbitrary') { - return `/${open}${printArbitraryValue(value)}${close}` - } else if (modifier.kind === 'named') { - return `/${modifier.value}` - } else { - modifier satisfies never - } -} - -export function printVariant(variant: Variant) { - // Handle static variants - if (variant.kind === 'static') { - return variant.root - } - - // Handle arbitrary variants - if (variant.kind === 'arbitrary') { - return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]` - } - - let base: string = '' - - // Handle functional variants - if (variant.kind === 'functional') { - base += variant.root - // `@` is a special case for functional variants. We want to print: `@lg` - // instead of `@-lg` - let hasDash = variant.root !== '@' - if (variant.value) { - if (variant.value.kind === 'arbitrary') { - let isVarValue = isVar(variant.value.value) - let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value - let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] - - base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}` - } else if (variant.value.kind === 'named') { - base += `${hasDash ? '-' : ''}${variant.value.value}` - } - } - } - - // Handle compound variants - if (variant.kind === 'compound') { - base += variant.root - base += '-' - base += printVariant(variant.variant) - } - - // Handle modifiers - if (variant.kind === 'functional' || variant.kind === 'compound') { - base += printModifier(variant.modifier) - } - - return base -} - -const printArbitraryValueCache = new DefaultMap((input) => { - let ast = ValueParser.parse(input) - - let drop = new Set() - - ValueParser.walk(ast, (node, { parent }) => { - let parentArray = parent === null ? ast : (parent.nodes ?? []) - - // Handle operators (e.g.: inside of `calc(…)`) - if ( - node.kind === 'word' && - // Operators - (node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/') - ) { - let idx = parentArray.indexOf(node) ?? -1 - - // This should not be possible - if (idx === -1) return - - let previous = parentArray[idx - 1] - if (previous?.kind !== 'separator' || previous.value !== ' ') return - - let next = parentArray[idx + 1] - if (next?.kind !== 'separator' || next.value !== ' ') return - - drop.add(previous) - drop.add(next) - } - - // The value parser handles `/` as a separator in some scenarios. E.g.: - // `theme(colors.red/50%)`. Because of this, we have to handle this case - // separately. - else if (node.kind === 'separator' && node.value.trim() === '/') { - node.value = '/' - } - - // Leading and trailing whitespace - else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') { - if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) { - drop.add(node) - } - } - - // Whitespace around `,` separators can be removed. - // E.g.: `min(1px , 2px)` -> `min(1px,2px)` - else if (node.kind === 'separator' && node.value.trim() === ',') { - node.value = ',' - } - }) - - if (drop.size > 0) { - ValueParser.walk(ast, (node, { replaceWith }) => { - if (drop.has(node)) { - drop.delete(node) - replaceWith([]) - } - }) - } - - recursivelyEscapeUnderscores(ast) - - return ValueParser.toCss(ast) -}) -export function printArbitraryValue(input: string) { - return printArbitraryValueCache.get(input) -} - -const simplifyArbitraryVariantCache = new DefaultMap((input) => { - let ast = ValueParser.parse(input) - - // &:is(…) - if ( - ast.length === 3 && - // & - ast[0].kind === 'word' && - ast[0].value === '&' && - // : - ast[1].kind === 'separator' && - ast[1].value === ':' && - // is(…) - ast[2].kind === 'function' && - ast[2].value === 'is' - ) { - return ValueParser.toCss(ast[2].nodes) - } - - return input -}) -function simplifyArbitraryVariant(input: string) { - return simplifyArbitraryVariantCache.get(input) -} - -function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) { - for (let node of ast) { - switch (node.kind) { - case 'function': { - if (node.value === 'url' || node.value.endsWith('_url')) { - // Don't decode underscores in url() but do decode the function name - node.value = escapeUnderscore(node.value) - break - } - - if ( - node.value === 'var' || - node.value.endsWith('_var') || - node.value === 'theme' || - node.value.endsWith('_theme') - ) { - node.value = escapeUnderscore(node.value) - for (let i = 0; i < node.nodes.length; i++) { - recursivelyEscapeUnderscores([node.nodes[i]]) - } - break - } - - node.value = escapeUnderscore(node.value) - recursivelyEscapeUnderscores(node.nodes) - break - } - case 'separator': - node.value = escapeUnderscore(node.value) - break - case 'word': { - // Dashed idents and variables `var(--my-var)` and `--my-var` should not - // have underscores escaped - if (node.value[0] !== '-' && node.value[1] !== '-') { - node.value = escapeUnderscore(node.value) - } - break - } - default: - never(node) - } - } -} - -const isVarCache = new DefaultMap((value) => { - let ast = ValueParser.parse(value) - return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var' -}) -function isVar(value: string) { - return isVarCache.get(value) -} - -function never(value: never): never { - throw new Error(`Unexpected value: ${value}`) -} - -function escapeUnderscore(value: string): string { - return value - .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is - .replaceAll(' ', '_') // Replace spaces with underscores -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts index 733224c97d0f..fc5ac89aa719 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -1,4 +1,4 @@ -import type { Candidate } from '../../../../tailwindcss/src/candidate' +import { printModifier, type Candidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' @@ -6,7 +6,6 @@ import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infe import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { dimensions } from '../../utils/dimension' import type { Writable } from '../../utils/types' -import { printCandidate, printModifier } from './candidates' import { computeUtilitySignature } from './signatures' // For all static utilities in the system, compute a lookup table that maps the @@ -102,7 +101,7 @@ export function migrateArbitraryUtilities( // [display:flex] => [display:flex] // ``` // - let canonicalizedCandidate = printCandidate(designSystem, readonlyCandidate) + let canonicalizedCandidate = designSystem.printCandidate(readonlyCandidate) if (canonicalizedCandidate !== rawCandidate) { return migrateArbitraryUtilities(designSystem, _userConfig, canonicalizedCandidate) } @@ -119,7 +118,7 @@ export function migrateArbitraryUtilities( targetCandidate.important = false targetCandidate.variants = [] - let targetCandidateString = printCandidate(designSystem, targetCandidate) + let targetCandidateString = designSystem.printCandidate(targetCandidate) if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) { let target = structuredClone( baseReplacementsCache.get(designSystem).get(targetCandidateString)!, @@ -128,7 +127,7 @@ export function migrateArbitraryUtilities( target.variants = candidate.variants target.important = candidate.important - return printCandidate(designSystem, target) + return designSystem.printCandidate(target) } // Compute the signature for the target candidate @@ -137,7 +136,7 @@ export function migrateArbitraryUtilities( // Try a few options to find a suitable replacement utility for (let replacementCandidate of tryReplacements(targetSignature, targetCandidate)) { - let replacementString = printCandidate(designSystem, replacementCandidate) + let replacementString = designSystem.printCandidate(replacementCandidate) let replacementSignature = signatures.get(replacementString) if (replacementSignature !== targetSignature) { continue @@ -161,7 +160,7 @@ export function migrateArbitraryUtilities( Object.assign(candidate, replacementCandidate) // We will re-print the candidate to get the migrated candidate out - return printCandidate(designSystem, candidate) + return designSystem.printCandidate(candidate) } } @@ -189,7 +188,7 @@ export function migrateArbitraryUtilities( if (replacements.length === 0 && candidate.modifier) { let candidateWithoutModifier = { ...candidate, modifier: null } let targetSignatureWithoutModifier = signatures.get( - printCandidate(designSystem, candidateWithoutModifier), + designSystem.printCandidate(candidateWithoutModifier), ) if (typeof targetSignatureWithoutModifier === 'string') { for (let replacementCandidate of tryReplacements( @@ -316,7 +315,7 @@ function allVariablesAreUsed( } let replacementAsCss = designSystem - .candidatesToCss([printCandidate(designSystem, replacement)]) + .candidatesToCss([designSystem.printCandidate(replacement)]) .join('\n') let isSafeMigration = true 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 d688228c8e73..116888ca39c3 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-value-to-bare-value.ts @@ -11,7 +11,6 @@ import { } from '../../../../tailwindcss/src/utils/infer-data-type' import { segment } from '../../../../tailwindcss/src/utils/segment' import { walkVariants } from '../../utils/walk-variants' -import { printCandidate } from './candidates' import { computeUtilitySignature } from './signatures' export function migrateArbitraryValueToBareValue( @@ -30,7 +29,7 @@ export function migrateArbitraryValueToBareValue( let expectedSignature = signatures.get(rawCandidate) if (expectedSignature !== null) { for (let value of tryValueReplacements(clone)) { - let newSignature = signatures.get(printCandidate(designSystem, { ...clone, value })) + let newSignature = signatures.get(designSystem.printCandidate({ ...clone, value })) if (newSignature === expectedSignature) { changed = true clone.value = value @@ -102,7 +101,7 @@ export function migrateArbitraryValueToBareValue( } } - return changed ? printCandidate(designSystem, clone) : rawCandidate + return changed ? designSystem.printCandidate(clone) : rawCandidate } return rawCandidate 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 6bdd4590bf36..1100a4a5aff6 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -4,7 +4,6 @@ import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { memcpy } from '../../utils/memcpy' import type { Writable } from '../../utils/types' import { walkVariants } from '../../utils/walk-variants' -import { printCandidate, printVariant } from './candidates' import { computeVariantSignature } from './signatures' const variantsLookup = new DefaultMap>( @@ -44,7 +43,7 @@ export function migrateArbitraryVariants( for (let [variant] of walkVariants(candidate)) { if (variant.kind === 'compound') continue - let targetString = printVariant(variant) + let targetString = designSystem.printVariant(variant) let targetSignature = signatures.get(targetString) if (typeof targetSignature !== 'string') continue @@ -58,7 +57,7 @@ export function migrateArbitraryVariants( memcpy(variant, parsedVariant) } - return printCandidate(designSystem, candidate) + return designSystem.printCandidate(candidate) } return rawCandidate diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts index 9fd1aaaf7594..62da826d9336 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts @@ -2,7 +2,6 @@ import { walk, WalkAction } from '../../../../tailwindcss/src/ast' import { type Candidate, type Variant } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { printCandidate } from './candidates' export function migrateAutomaticVarInjection( designSystem: DesignSystem, @@ -66,7 +65,7 @@ export function migrateAutomaticVarInjection( } if (didChange) { - return printCandidate(designSystem, candidate) + return designSystem.printCandidate(candidate) } } return rawCandidate diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts index 2a16524a2b1c..ca81d4f4da1d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bg-gradient.ts @@ -1,6 +1,5 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { printCandidate } from './candidates' const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] @@ -17,7 +16,7 @@ export function migrateBgGradient( continue } - return printCandidate(designSystem, { + return designSystem.printCandidate({ ...candidate, root: `bg-linear-to-${direction}`, }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-drop-unnecessary-data-types.ts index 7ace0282da03..ae32458ef275 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,5 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { printCandidate } from './candidates' import { computeUtilitySignature } from './signatures' export function migrateDropUnnecessaryDataTypes( @@ -16,7 +15,7 @@ export function migrateDropUnnecessaryDataTypes( candidate.value?.kind === 'arbitrary' && candidate.value.dataType !== null ) { - let replacement = printCandidate(designSystem, { + let replacement = designSystem.printCandidate({ ...candidate, value: { ...candidate.value, dataType: null }, }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts index 34b6d4ef2f01..27a663f47acf 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts @@ -1,7 +1,6 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { printCandidate } from './candidates' import { isSafeMigration } from './is-safe-migration' // In v3 the important modifier `!` sits in front of the utility itself, not @@ -41,7 +40,7 @@ export function migrateImportant( // The printCandidate function will already put the exclamation mark in // the right place, so we just need to mark this candidate as requiring a // migration. - return printCandidate(designSystem, candidate) + return designSystem.printCandidate(candidate) } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts index 784480521082..89afbb21ea79 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts @@ -2,7 +2,6 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { segment } from '../../../../tailwindcss/src/utils/segment' -import { printCandidate } from './candidates' export function migrateLegacyArbitraryValues( designSystem: DesignSystem, @@ -23,7 +22,7 @@ export function migrateLegacyArbitraryValues( clone.value.value = segment(clone.value.value, ',').join(' ') } - return changed ? printCandidate(designSystem, clone) : rawCandidate + return changed ? designSystem.printCandidate(clone) : rawCandidate } return rawCandidate diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts index 2a231bb3daa7..234907e8a3e0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts @@ -6,7 +6,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import * as version from '../../utils/version' -import { printCandidate } from './candidates' import { isSafeMigration } from './is-safe-migration' const __filename = url.fileURLToPath(import.meta.url) @@ -94,7 +93,7 @@ export async function migrateLegacyClasses( let baseCandidate = structuredClone(candidate) as Candidate baseCandidate.variants = [] baseCandidate.important = false - let baseCandidateString = printCandidate(designSystem, baseCandidate) + let baseCandidateString = designSystem.printCandidate(baseCandidate) // Find the new base candidate string. `blur` -> `blur-sm` let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString) @@ -171,7 +170,7 @@ export async function migrateLegacyClasses( } } - return printCandidate(designSystem, toCandidate) + return designSystem.printCandidate(toCandidate) } return rawCandidate diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts index f7e04dbf194d..cc9b7e9e260f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-max-width-screen.ts @@ -1,6 +1,5 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { printCandidate } from './candidates' export function migrateMaxWidthScreen( designSystem: DesignSystem, @@ -13,7 +12,7 @@ export function migrateMaxWidthScreen( candidate.root === 'max-w' && candidate.value?.value.startsWith('screen-') ) { - return printCandidate(designSystem, { + return designSystem.printCandidate({ ...candidate, value: { ...candidate.value, diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts index a1e7e6cb729a..72fb9c4aed33 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 @@ -6,7 +6,6 @@ import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data- import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { memcpy } from '../../utils/memcpy' import { walkVariants } from '../../utils/walk-variants' -import { printCandidate, printVariant } from './candidates' import { computeVariantSignature } from './signatures' export function migrateModernizeArbitraryValues( @@ -157,7 +156,7 @@ export function migrateModernizeArbitraryValues( (ast.nodes[0].nodes[0].value.startsWith('@media') || ast.nodes[0].nodes[0].value.startsWith('@supports')) ) { - let targetSignature = signatures.get(printVariant(variant)) + let targetSignature = signatures.get(designSystem.printVariant(variant)) let parsed = ValueParser.parse(ast.nodes[0].toString().trim()) let containsNot = false ValueParser.walk(parsed, (node, { replaceWith }) => { @@ -180,7 +179,7 @@ export function migrateModernizeArbitraryValues( if (containsNot) { let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`) if (hoistedNot === null) continue - let hoistedNotSignature = signatures.get(printVariant(hoistedNot)) + let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot)) if (targetSignature === hoistedNotSignature) { changed = true memcpy(variant, hoistedNot) @@ -306,7 +305,7 @@ export function migrateModernizeArbitraryValues( // Hoist `not` modifier if (compoundNot) { - let targetSignature = signatures.get(printVariant(variant)) + let targetSignature = signatures.get(designSystem.printVariant(variant)) let replacementSignature = signatures.get(`not-[${value}]`) if (targetSignature === replacementSignature) { return `[&${value}]` @@ -390,7 +389,7 @@ export function migrateModernizeArbitraryValues( } } - return changed ? printCandidate(designSystem, clone) : rawCandidate + return changed ? designSystem.printCandidate(clone) : rawCandidate } return rawCandidate 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 90063ca1e1d1..b4d5d58e5681 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-optimize-modifier.ts @@ -2,7 +2,6 @@ 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 { printCandidate } from './candidates' import { computeUtilitySignature } from './signatures' // Optimize the modifier @@ -33,7 +32,7 @@ export function migrateOptimizeModifier( // 1. Try to drop the modifier entirely if ( targetSignature === - signatures.get(printCandidate(designSystem, { ...candidate, modifier: null })) + signatures.get(designSystem.printCandidate({ ...candidate, modifier: null })) ) { changed = true candidate.modifier = null @@ -49,14 +48,14 @@ export function migrateOptimizeModifier( if ( targetSignature === - signatures.get(printCandidate(designSystem, { ...candidate, modifier: newModifier })) + signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier })) ) { changed = true candidate.modifier = newModifier } } - return changed ? printCandidate(designSystem, candidate) : rawCandidate + return changed ? designSystem.printCandidate(candidate) : rawCandidate } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts index 3e55dddf06d5..3e014817c6d2 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts @@ -3,7 +3,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { segment } from '../../../../tailwindcss/src/utils/segment' import * as version from '../../utils/version' -import { printCandidate } from './candidates' let seenDesignSystems = new WeakSet() @@ -48,7 +47,7 @@ export function migratePrefix( if (!candidate) return rawCandidate - return printCandidate(designSystem, candidate) + return designSystem.printCandidate(candidate) } // Parses a raw candidate with v3 compatible prefix syntax. This won't match if diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts index c87624a200b5..415ed524c2d1 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-simple-legacy-classes.ts @@ -1,7 +1,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import * as version from '../../utils/version' -import { printCandidate } from './candidates' // Classes that used to exist in Tailwind CSS v3, but do not exist in Tailwind // CSS v4 anymore. @@ -53,7 +52,7 @@ export function migrateSimpleLegacyClasses( for (let candidate of designSystem.parseCandidate(rawCandidate)) { if (candidate.kind === 'static' && Object.hasOwn(LEGACY_CLASS_MAP, candidate.root)) { - return printCandidate(designSystem, { + return designSystem.printCandidate({ ...candidate, root: LEGACY_CLASS_MAP[candidate.root as keyof typeof LEGACY_CLASS_MAP], }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts index 20c6072cc39e..6f7a3ece1fa0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts @@ -7,7 +7,6 @@ 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' -import { printCandidate } from './candidates' export const enum Convert { All = 0, @@ -71,7 +70,7 @@ export function migrateThemeToVar( } } - return changed ? printCandidate(designSystem, clone) : rawCandidate + return changed ? designSystem.printCandidate(clone) : rawCandidate } return rawCandidate diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts index f9da9ecc28ec..fb5306caa1b4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts @@ -3,7 +3,6 @@ import { type Variant } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import * as version from '../../utils/version' -import { printCandidate } from './candidates' export function migrateVariantOrder( designSystem: DesignSystem, @@ -56,7 +55,7 @@ export function migrateVariantOrder( continue } - return printCandidate(designSystem, { ...candidate, variants: newOrder }) + return designSystem.printCandidate({ ...candidate, variants: newOrder }) } return rawCandidate } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 61cc58a9b62b..029a2a97bd98 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -4,7 +4,7 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' -import { extractRawCandidates, printCandidate } from './candidates' +import { extractRawCandidates } from './candidates' import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities' import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value' import { migrateArbitraryVariants } from './migrate-arbitrary-variants' @@ -77,7 +77,7 @@ export async function migrateCandidate( // E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)` if (rawCandidate === original) { for (let candidate of parseCandidate(rawCandidate, designSystem)) { - return printCandidate(designSystem, candidate) + return designSystem.printCandidate(candidate) } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index e8e1a24b6a1b..ea8ac6f34ea4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -1,12 +1,12 @@ import { substituteAtApply } from '../../../../tailwindcss/src/apply' import { atRule, styleRule, toCss, walk, type AstNode } from '../../../../tailwindcss/src/ast' +import { printArbitraryValue } from '../../../../tailwindcss/src/candidate' import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-parser' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { ThemeOptions } from '../../../../tailwindcss/src/theme' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { dimensions } from '../../utils/dimension' -import { printArbitraryValue } from './candidates' // 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/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 8a78a8a2fde2..aff6d74dc116 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -1,7 +1,9 @@ import type { DesignSystem } from './design-system' import { decodeArbitraryValue } from './utils/decode-arbitrary-value' +import { DefaultMap } from './utils/default-map' import { isValidArbitrary } from './utils/is-valid-arbitrary' import { segment } from './utils/segment' +import * as ValueParser from './value-parser' const COLON = 0x3a const DASH = 0x2d @@ -776,3 +778,283 @@ function* findRoots(input: string, exists: (input: string) => boolean): Iterable yield ['@', input.slice(1)] } } + +export function printCandidate(designSystem: DesignSystem, candidate: Candidate) { + let parts: string[] = [] + + for (let variant of candidate.variants) { + parts.unshift(printVariant(variant)) + } + + // Handle prefix + if (designSystem.theme.prefix) { + parts.unshift(designSystem.theme.prefix) + } + + let base: string = '' + + // Handle static + if (candidate.kind === 'static') { + base += candidate.root + } + + // Handle functional + if (candidate.kind === 'functional') { + base += candidate.root + + if (candidate.value) { + if (candidate.value.kind === 'arbitrary') { + if (candidate.value !== null) { + let isVarValue = isVar(candidate.value.value) + let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value + let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] + + if (candidate.value.dataType) { + base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}` + } else { + base += `-${open}${printArbitraryValue(value)}${close}` + } + } + } else if (candidate.value.kind === 'named') { + base += `-${candidate.value.value}` + } + } + } + + // Handle arbitrary + if (candidate.kind === 'arbitrary') { + base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]` + } + + // Handle modifier + if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') { + base += printModifier(candidate.modifier) + } + + // Handle important + if (candidate.important) { + base += '!' + } + + parts.push(base) + + return parts.join(':') +} + +export function printModifier(modifier: ArbitraryModifier | NamedModifier | null) { + if (modifier === null) return '' + + let isVarValue = isVar(modifier.value) + let value = isVarValue ? modifier.value.slice(4, -1) : modifier.value + let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] + + if (modifier.kind === 'arbitrary') { + return `/${open}${printArbitraryValue(value)}${close}` + } else if (modifier.kind === 'named') { + return `/${modifier.value}` + } else { + modifier satisfies never + return '' + } +} + +export function printVariant(variant: Variant) { + // Handle static variants + if (variant.kind === 'static') { + return variant.root + } + + // Handle arbitrary variants + if (variant.kind === 'arbitrary') { + return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]` + } + + let base: string = '' + + // Handle functional variants + if (variant.kind === 'functional') { + base += variant.root + // `@` is a special case for functional variants. We want to print: `@lg` + // instead of `@-lg` + let hasDash = variant.root !== '@' + if (variant.value) { + if (variant.value.kind === 'arbitrary') { + let isVarValue = isVar(variant.value.value) + let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value + let [open, close] = isVarValue ? ['(', ')'] : ['[', ']'] + + base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}` + } else if (variant.value.kind === 'named') { + base += `${hasDash ? '-' : ''}${variant.value.value}` + } + } + } + + // Handle compound variants + if (variant.kind === 'compound') { + base += variant.root + base += '-' + base += printVariant(variant.variant) + } + + // Handle modifiers + if (variant.kind === 'functional' || variant.kind === 'compound') { + base += printModifier(variant.modifier) + } + + return base +} + +const printArbitraryValueCache = new DefaultMap((input) => { + let ast = ValueParser.parse(input) + + let drop = new Set() + + ValueParser.walk(ast, (node, { parent }) => { + let parentArray = parent === null ? ast : (parent.nodes ?? []) + + // Handle operators (e.g.: inside of `calc(…)`) + if ( + node.kind === 'word' && + // Operators + (node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/') + ) { + let idx = parentArray.indexOf(node) ?? -1 + + // This should not be possible + if (idx === -1) return + + let previous = parentArray[idx - 1] + if (previous?.kind !== 'separator' || previous.value !== ' ') return + + let next = parentArray[idx + 1] + if (next?.kind !== 'separator' || next.value !== ' ') return + + drop.add(previous) + drop.add(next) + } + + // The value parser handles `/` as a separator in some scenarios. E.g.: + // `theme(colors.red/50%)`. Because of this, we have to handle this case + // separately. + else if (node.kind === 'separator' && node.value.trim() === '/') { + node.value = '/' + } + + // Leading and trailing whitespace + else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') { + if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) { + drop.add(node) + } + } + + // Whitespace around `,` separators can be removed. + // E.g.: `min(1px , 2px)` -> `min(1px,2px)` + else if (node.kind === 'separator' && node.value.trim() === ',') { + node.value = ',' + } + }) + + if (drop.size > 0) { + ValueParser.walk(ast, (node, { replaceWith }) => { + if (drop.has(node)) { + drop.delete(node) + replaceWith([]) + } + }) + } + + recursivelyEscapeUnderscores(ast) + + return ValueParser.toCss(ast) +}) +export function printArbitraryValue(input: string) { + return printArbitraryValueCache.get(input) +} + +const simplifyArbitraryVariantCache = new DefaultMap((input) => { + let ast = ValueParser.parse(input) + + // &:is(…) + if ( + ast.length === 3 && + // & + ast[0].kind === 'word' && + ast[0].value === '&' && + // : + ast[1].kind === 'separator' && + ast[1].value === ':' && + // is(…) + ast[2].kind === 'function' && + ast[2].value === 'is' + ) { + return ValueParser.toCss(ast[2].nodes) + } + + return input +}) +function simplifyArbitraryVariant(input: string) { + return simplifyArbitraryVariantCache.get(input) +} + +function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) { + for (let node of ast) { + switch (node.kind) { + case 'function': { + if (node.value === 'url' || node.value.endsWith('_url')) { + // Don't decode underscores in url() but do decode the function name + node.value = escapeUnderscore(node.value) + break + } + + if ( + node.value === 'var' || + node.value.endsWith('_var') || + node.value === 'theme' || + node.value.endsWith('_theme') + ) { + node.value = escapeUnderscore(node.value) + for (let i = 0; i < node.nodes.length; i++) { + recursivelyEscapeUnderscores([node.nodes[i]]) + } + break + } + + node.value = escapeUnderscore(node.value) + recursivelyEscapeUnderscores(node.nodes) + break + } + case 'separator': + node.value = escapeUnderscore(node.value) + break + case 'word': { + // Dashed idents and variables `var(--my-var)` and `--my-var` should not + // have underscores escaped + if (node.value[0] !== '-' && node.value[1] !== '-') { + node.value = escapeUnderscore(node.value) + } + break + } + default: + never(node) + } + } +} + +const isVarCache = new DefaultMap((value) => { + let ast = ValueParser.parse(value) + return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var' +}) +function isVar(value: string) { + return isVarCache.get(value) +} + +function never(value: never): never { + throw new Error(`Unexpected value: ${value}`) +} + +function escapeUnderscore(value: string): string { + return value + .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is + .replaceAll(' ', '_') // Replace spaces with underscores +} diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index e18e882ba092..60fe16ecc8c1 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -1,6 +1,13 @@ import { Polyfills } from '.' import { optimizeAst, toCss } from './ast' -import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate' +import { + parseCandidate, + parseVariant, + printCandidate, + printVariant, + type Candidate, + type Variant, +} from './candidate' import { compileAstNodes, compileCandidates } from './compile' import { substituteFunctions } from './css-functions' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' @@ -29,6 +36,9 @@ export type DesignSystem = { parseVariant(variant: string): Readonly | null compileAstNodes(candidate: Candidate): ReturnType + printCandidate(candidate: Candidate): string + printVariant(variant: Variant): string + getVariantOrder(): Map resolveThemeValue(path: string, forceInline?: boolean): string | undefined @@ -127,6 +137,14 @@ export function buildDesignSystem(theme: Theme): DesignSystem { compileAstNodes(candidate: Candidate) { return compiledAstNodes.get(candidate) }, + + printCandidate(candidate: Candidate) { + return printCandidate(designSystem, candidate) + }, + printVariant(variant: Variant) { + return printVariant(variant) + }, + getVariantOrder() { let variants = Array.from(parsedVariants.values()) variants.sort((a, z) => this.variants.compare(a, z)) From 013f0b5096302956bb8a385fbbef23fce4a4b9db Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 15:29:05 +0200 Subject: [PATCH 33/39] Update CHANGELOG.md Co-authored-by: Philipp Spiess --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5af077ee9a..b79dd40267b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Upgrade: Improved upgrading classes ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831)) +- Upgrade: Automatically convert candidates with arbitrary values to their utilities ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831)) ## [4.1.5] - 2025-04-30 From 5573c88c6835faedf647c8f145d4fb6a2e6074a0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 16:26:20 +0200 Subject: [PATCH 34/39] use try/finally for extra safety This should not be necessary, but if something crashes, we can properly restore the state of the DesignSystem. --- .../src/codemods/template/signatures.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index ea8ac6f34ea4..f15cf35d4711 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -388,17 +388,17 @@ function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => return value } - // Run the callback with the `@theme inline` feature disabled - let result = cb() - - // Restore the `@theme inline` to the original value - // @ts-expect-error We are monkey-patching a method that's private - designSystem.theme.values.get = originalGet - - // Re-add the `inline` option, in case future lookups are done - for (let value of restorableInlineOptions) { - value.options |= ThemeOptions.INLINE + try { + // Run the callback with the `@theme inline` feature disabled + return cb() + } finally { + // Restore the `@theme inline` to the original value + // @ts-expect-error We are monkey-patching a method that's private + designSystem.theme.values.get = originalGet + + // Re-add the `inline` option, in case future lookups are done + for (let value of restorableInlineOptions) { + value.options |= ThemeOptions.INLINE + } } - - return result } From d5ac5cd828ae241c568c512da75a279ce3e6a479 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 16:28:03 +0200 Subject: [PATCH 35/39] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b79dd40267b3..3de214458772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgrade: Automatically convert candidates with arbitrary values to their utilities ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831)) +### Fixed + +- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831)) + ## [4.1.5] - 2025-04-30 ### Added From f2653acf9d25096205835f6da6092411e205a5eb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 17:03:20 +0200 Subject: [PATCH 36/39] drop level of nesting --- .../template/migrate-arbitrary-utilities.ts | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) 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 fc5ac89aa719..5e5478a7de41 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -214,61 +214,60 @@ export function migrateArbitraryUtilities( // to find functional utilities that also set this property. let value = candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) + if (value === null) return - if (value !== null) { - let spacingMultiplier = spacing.get(designSystem)?.get(value) + let spacingMultiplier = spacing.get(designSystem)?.get(value) - for (let root of designSystem.utilities.keys('functional')) { - // Try as bare value - for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { + for (let root of designSystem.utilities.keys('functional')) { + // Try as bare value + for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { + yield replacementCandidate + } + + // Try as bare value with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${value}${candidate.modifier}`, + )) { yield replacementCandidate } + } - // Try 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. E.g.: - // - // - `w-[64rem]` → `w-256` - if (spacingMultiplier !== null) { + // Try bare value based on the `--spacing` value, but with a modifier + if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-${spacingMultiplier}`, + `${root}-${spacingMultiplier}${candidate.modifier}`, )) { 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}${candidate.modifier}`, - )) { - yield replacementCandidate - } - } } + } - // Try as arbitrary value - for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) { - 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 - } + // Try as arbitrary value with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-[${value}]${printModifier(candidate.modifier)}`, + )) { + yield replacementCandidate } } } From cd92ff1dadd5cc943081fecdc5b100e57c10cf72 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 17:05:25 +0200 Subject: [PATCH 37/39] use `printModifier` helper --- .../src/codemods/template/migrate-arbitrary-utilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5e5478a7de41..2f3ee7e434c7 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -249,7 +249,7 @@ export function migrateArbitraryUtilities( if (candidate.modifier) { for (let replacementCandidate of parseCandidate( designSystem, - `${root}-${spacingMultiplier}${candidate.modifier}`, + `${root}-${spacingMultiplier}${printModifier(candidate.modifier)}`, )) { yield replacementCandidate } From 987b9e78b5cfd18fe6c7e05ff3551b29686f1a42 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 17:24:36 +0200 Subject: [PATCH 38/39] add basic tests for _all_ migrations --- .../src/codemods/template/migrate.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts new file mode 100644 index 000000000000..45a5a15a9b08 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts @@ -0,0 +1,66 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { describe, expect, test, vi } from 'vitest' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' +import * as versions from '../../utils/version' +import { migrateCandidate as migrate } from './migrate' +vi.spyOn(versions, 'isMajor').mockReturnValue(false) + +const designSystems = new DefaultMap((base: string) => { + return new DefaultMap((input: string) => { + return __unstable__loadDesignSystem(input, { base }) + }) +}) + +const css = String.raw + +describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => { + let testName = '%s => %s (%#)' + if (strategy === 'with-variant') { + testName = testName.replaceAll('%s', 'focus:%s') + } else if (strategy === 'important') { + testName = testName.replaceAll('%s', '%s!') + } else if (strategy === 'prefix') { + testName = testName.replaceAll('%s', 'tw:%s') + } + + // Basic input with minimal design system to keep the tests fast + let input = css` + @import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''}; + @theme { + --*: initial; + --spacing: 0.25rem; + --color-red-500: red; + + /* Equivalent of blue-500/50 */ + --color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent); + } + ` + + test.each([ + // Arbitrary property to named functional utlity + ['[color:red]', 'text-red-500'], + + // Promote data types to more specific utility if it exists + ['bg-(position:--my-value)', 'bg-position-(--my-value)'], + + // Promote inferred data type to more specific utility if it exists + ['bg-[123px]', 'bg-position-[123px]'], + ])(testName, async (candidate, result) => { + if (strategy === 'with-variant') { + candidate = `focus:${candidate}` + result = `focus:${result}` + } else if (strategy === 'important') { + candidate = `${candidate}!` + result = `${result}!` + } else if (strategy === 'prefix') { + // Not only do we need to prefix the candidate, we also have to make + // sure that we prefix all CSS variables. + candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}` + result = `tw:${result.replaceAll('var(--', 'var(--tw-')}` + } + + let designSystem = await designSystems.get(__dirname).get(input) + let migrated = await migrate(designSystem, {}, candidate) + expect(migrated).toEqual(result) + }) +}) From fbabe79056fff30122b88a90799f970bfd8c6dcf Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 May 2025 23:12:33 +0200 Subject: [PATCH 39/39] rename `memcpy` to `replaceObject` --- .../template/migrate-arbitrary-variants.ts | 4 ++-- .../migrate-modernize-arbitrary-values.ts | 20 +++++++++---------- .../utils/{memcpy.ts => replace-object.ts} | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) rename packages/@tailwindcss-upgrade/src/utils/{memcpy.ts => replace-object.ts} (65%) 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 1100a4a5aff6..d2d78931e8a9 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 { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { memcpy } from '../../utils/memcpy' +import { replaceObject } from '../../utils/replace-object' import type { Writable } from '../../utils/types' import { walkVariants } from '../../utils/walk-variants' import { computeVariantSignature } from './signatures' @@ -54,7 +54,7 @@ export function migrateArbitraryVariants( let parsedVariant = designSystem.parseVariant(foundVariant) if (parsedVariant === null) continue - memcpy(variant, parsedVariant) + replaceObject(variant, parsedVariant) } return designSystem.printCandidate(candidate) 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 72fb9c4aed33..8c75587cdcbc 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts @@ -4,7 +4,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type' import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { memcpy } from '../../utils/memcpy' +import { replaceObject } from '../../utils/replace-object' import { walkVariants } from '../../utils/walk-variants' import { computeVariantSignature } from './signatures' @@ -45,7 +45,7 @@ export function migrateModernizeArbitraryValues( // `group-[]` if (variant.modifier === null) { changed = true - memcpy( + replaceObject( variant, designSystem.parseVariant( designSystem.theme.prefix @@ -58,7 +58,7 @@ export function migrateModernizeArbitraryValues( // `group-[]/name` else if (variant.modifier.kind === 'named') { changed = true - memcpy( + replaceObject( variant, designSystem.parseVariant( designSystem.theme.prefix @@ -94,7 +94,7 @@ export function migrateModernizeArbitraryValues( ast.nodes[0].nodes[2].type === 'universal' ) { changed = true - memcpy(variant, designSystem.parseVariant('*')) + replaceObject(variant, designSystem.parseVariant('*')) continue } @@ -112,7 +112,7 @@ export function migrateModernizeArbitraryValues( ast.nodes[0].nodes[2].type === 'universal' ) { changed = true - memcpy(variant, designSystem.parseVariant('**')) + replaceObject(variant, designSystem.parseVariant('**')) continue } @@ -139,7 +139,7 @@ export function migrateModernizeArbitraryValues( // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. // // Later this gets converted to `in-data-visible`. - memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) + replaceObject(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) continue } @@ -182,7 +182,7 @@ export function migrateModernizeArbitraryValues( let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot)) if (targetSignature === hoistedNotSignature) { changed = true - memcpy(variant, hoistedNot) + replaceObject(variant, hoistedNot) continue } } @@ -325,7 +325,7 @@ export function migrateModernizeArbitraryValues( // Update original variant changed = true - memcpy(variant, structuredClone(parsed)) + replaceObject(variant, structuredClone(parsed)) } // Expecting an attribute selector @@ -350,7 +350,7 @@ export function migrateModernizeArbitraryValues( if (attributeKey.startsWith('data-')) { changed = true attributeKey = attributeKey.slice(5) // Remove `data-` - memcpy(variant, { + replaceObject(variant, { kind: 'functional', root: 'data', modifier: null, @@ -365,7 +365,7 @@ export function migrateModernizeArbitraryValues( else if (attributeKey.startsWith('aria-')) { changed = true attributeKey = attributeKey.slice(5) // Remove `aria-` - memcpy(variant, { + replaceObject(variant, { kind: 'functional', root: 'aria', modifier: null, diff --git a/packages/@tailwindcss-upgrade/src/utils/memcpy.ts b/packages/@tailwindcss-upgrade/src/utils/replace-object.ts similarity index 65% rename from packages/@tailwindcss-upgrade/src/utils/memcpy.ts rename to packages/@tailwindcss-upgrade/src/utils/replace-object.ts index 3615e3a85906..b4fc993fa809 100644 --- a/packages/@tailwindcss-upgrade/src/utils/memcpy.ts +++ b/packages/@tailwindcss-upgrade/src/utils/replace-object.ts @@ -1,4 +1,4 @@ -export function memcpy(target: T, source: U): U { +export function replaceObject(target: T, source: U): U { // Clear out the target object, otherwise inspecting the final object will // look very confusing. for (let key in target) delete target[key]