diff --git a/CHANGELOG.md b/CHANGELOG.md index f02aa1fc4..04b99390a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for `blocklist` in config files ([#14556](https://github.com/tailwindlabs/tailwindcss/pull/14556)) - Add `color-scheme` utilities ([#14567](https://github.com/tailwindlabs/tailwindcss/pull/14567)) - _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514)) +- _Experimental_: Add template codemods for migrating variant order ([#14524](https://github.com/tailwindlabs/tailwindcss/pull/14524])) - _Experimental_: Add template codemods for migrating `bg-gradient-*` utilities to `bg-linear-*` ([#14537](https://github.com/tailwindlabs/tailwindcss/pull/14537])) - _Experimental_: Add template codemods for migrating prefixes ([#14557](https://github.com/tailwindlabs/tailwindcss/pull/14557])) - _Experimental_: Add template codemods for removal of automatic `var(…)` injection ([#14526](https://github.com/tailwindlabs/tailwindcss/pull/14526)) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts new file mode 100644 index 000000000..e42595645 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts @@ -0,0 +1,89 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import dedent from 'dedent' +import { expect, test } from 'vitest' +import { variantOrder } from './variant-order' + +let css = dedent + +test.each([ + // Does nothing unless there are at least two variants + ['flex', 'flex'], + ['hover:flex', 'hover:flex'], + ['[color:red]', '[color:red]'], + ['[&:focus]:[color:red]', '[&:focus]:[color:red]'], + + // Reorders simple variants that include combinators + ['*:first:flex', 'first:*:flex'], + + // Does not reorder variants without combinators + ['data-[invalid]:data-[hover]:flex', 'data-[invalid]:data-[hover]:flex'], + + // Does not reorder some known combinations where the order does not matter + ['hover:focus:flex', 'hover:focus:flex'], + ['focus:hover:flex', 'focus:hover:flex'], + ['[&:hover]:[&:focus]:flex', '[&:hover]:[&:focus]:flex'], + ['[&:focus]:[&:hover]:flex', '[&:focus]:[&:hover]:flex'], + ['data-[a]:data-[b]:flex', 'data-[a]:data-[b]:flex'], + + // Handles pseudo-elements that cannot have anything after them + // c.f. https://github.com/tailwindlabs/tailwindcss/pull/13478/files#diff-7779a0eebf6b980dd3abd63b39729b3023cf9a31c91594f5a25ea020b066e1c0 + ['dark:before:flex', 'dark:before:flex'], + ['before:dark:flex', 'dark:before:flex'], + + // Puts some pseudo-elements that must appear at the end of the selector at + // the end of the candidate + ['dark:*:before:after:flex', 'dark:*:before:after:flex'], + ['dark:before:after:*:flex', 'dark:*:before:after:flex'], + + // Some pseudo-elements are treated as regular variants + ['dark:*:hover:file:focus:underline', 'dark:focus:file:hover:*:underline'], + + // Keeps at-rule-variants and the dark variant in the beginning and keeps their + // order + ['sm:dark:hover:flex', 'sm:dark:hover:flex'], + ['[@media(print)]:group-hover:flex', '[@media(print)]:group-hover:flex'], + ['sm:max-xl:data-[a]:data-[b]:dark:hover:flex', 'sm:max-xl:dark:data-[a]:data-[b]:hover:flex'], + [ + 'sm:data-[root]:*:data-[a]:even:*:data-[b]:even:before:underline', + 'sm:even:data-[b]:*:even:data-[a]:*:data-[root]:before:underline', + ], + ['hover:[@supports(display:grid)]:flex', '[@supports(display:grid)]:hover:flex'], +])('%s => %s', async (candidate, result) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(variantOrder(designSystem, {}, candidate)).toEqual(result) +}) + +test('it works with custom variants', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + @variant atrule { + @media (print) { + @slot; + } + } + + @variant combinator { + > * { + @slot; + } + } + + @variant pseudo { + &::before { + @slot; + } + } + `, + { + base: __dirname, + }, + ) + + expect(variantOrder(designSystem, {}, 'combinator:pseudo:atrule:underline')).toEqual( + 'atrule:combinator:pseudo:underline', + ) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts new file mode 100644 index 000000000..12bdfbbb5 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts @@ -0,0 +1,127 @@ +import type { Config } from 'tailwindcss' +import { walk, type AstNode } from '../../../../tailwindcss/src/ast' +import type { Variant } from '../../../../tailwindcss/src/candidate' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { printCandidate } from '../candidates' + +export function variantOrder( + designSystem: DesignSystem, + _userConfig: Config, + rawCandidate: string, +): string { + for (let candidate of designSystem.parseCandidate(rawCandidate)) { + if (candidate.variants.length <= 1) { + continue + } + + let atRuleVariants = [] + let regularVariants = [] + let pseudoElementVariants = [] + + let originalOrder = candidate.variants + + for (let variant of candidate.variants) { + if (isAtRuleVariant(designSystem, variant)) { + atRuleVariants.push(variant) + } else if (isEndOfSelectorPseudoElement(designSystem, variant)) { + pseudoElementVariants.push(variant) + } else { + regularVariants.push(variant) + } + } + + // We only need to reorder regular variants if order is important + let regularVariantsNeedReordering = regularVariants.some((v) => + isCombinatorVariant(designSystem, v), + ) + + // The candidate list in the AST need to be in reverse order + let newOrder = [ + ...pseudoElementVariants, + ...(regularVariantsNeedReordering ? regularVariants.reverse() : regularVariants), + ...atRuleVariants, + ] + + if (orderMatches(originalOrder, newOrder)) { + continue + } + + return printCandidate(designSystem, { ...candidate, variants: newOrder }) + } + return rawCandidate +} + +function isAtRuleVariant(designSystem: DesignSystem, variant: Variant) { + // Handle the dark variant as an at-rule variant + if (variant.kind === 'static' && variant.root === 'dark') { + return true + } + let stack = getAppliedNodeStack(designSystem, variant) + return stack.every((node) => node.kind === 'rule' && node.selector[0] === '@') +} + +function isCombinatorVariant(designSystem: DesignSystem, variant: Variant) { + let stack = getAppliedNodeStack(designSystem, variant) + return stack.some( + (node) => + node.kind === 'rule' && + // Ignore at-rules as they are hoisted + node.selector[0] !== '@' && + // Combinators include any of the following characters + (node.selector.includes(' ') || + node.selector.includes('>') || + node.selector.includes('+') || + node.selector.includes('~')), + ) +} + +function isEndOfSelectorPseudoElement(designSystem: DesignSystem, variant: Variant) { + let stack = getAppliedNodeStack(designSystem, variant) + return stack.some( + (node) => + node.kind === 'rule' && + (node.selector.includes('::after') || + node.selector.includes('::backdrop') || + node.selector.includes('::before') || + node.selector.includes('::first-letter') || + node.selector.includes('::first-line') || + node.selector.includes('::marker') || + node.selector.includes('::placeholder') || + node.selector.includes('::selection')), + ) +} + +function getAppliedNodeStack(designSystem: DesignSystem, variant: Variant): AstNode[] { + let stack: AstNode[] = [] + let ast = designSystem + .compileAstNodes({ + kind: 'arbitrary', + property: 'color', + value: 'red', + modifier: null, + variants: [variant], + important: false, + raw: 'candidate', + }) + .map((c) => c.node) + + walk(ast, (node) => { + // Ignore the variant root class + if (node.kind === 'rule' && node.selector === '.candidate') { + return + } + // Ignore the dummy declaration + if (node.kind === 'declaration' && node.property === 'color' && node.value === 'red') { + return + } + stack.push(node) + }) + return stack +} + +function orderMatches(a: T[], b: T[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((v, i) => b[i] === v) +} diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index 738fd5ac4..b6076d80d 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -7,6 +7,7 @@ import { automaticVarInjection } from './codemods/automatic-var-injection' import { bgGradient } from './codemods/bg-gradient' import { important } from './codemods/important' import { prefix } from './codemods/prefix' +import { variantOrder } from './codemods/variant-order' export type Migration = ( designSystem: DesignSystem, @@ -18,7 +19,7 @@ export default async function migrateContents( designSystem: DesignSystem, userConfig: Config, contents: string, - migrations: Migration[] = [prefix, important, automaticVarInjection, bgGradient], + migrations: Migration[] = [prefix, important, bgGradient, automaticVarInjection, variantOrder], ): Promise { let candidates = await extractRawCandidates(contents)