From a754e06b69dd12b6ac8e8e06061fd7c67de2fbcd Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Thu, 26 Sep 2024 11:06:46 +0200 Subject: [PATCH] Template migrations: Add variant order codemods --- .../template/codemods/migrate-important.ts | 6 +- .../template/codemods/variant-order.test.ts | 47 ++++++++ .../src/template/codemods/variant-order.ts | 102 ++++++++++++++++++ .../src/template/migrate.ts | 8 +- 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts index 44a23a1cb4b7..b3c93bb9776f 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts @@ -1,4 +1,5 @@ import type { Candidate } from '../../../../tailwindcss/src/candidate' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' // In v3 the important modifier `!` sits in front of the utility itself, not // before any of the variants. In v4, we want it to be at the end of the utility @@ -12,7 +13,10 @@ import type { Candidate } from '../../../../tailwindcss/src/candidate' // Should turn into: // // flex! md:block! -export function migrateImportant(candidate: Candidate): Candidate | null { +export function migrateImportant( + _designSystem: DesignSystem, + candidate: Candidate, +): Candidate | null { if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') { // The printCandidate function will already put the exclamation mark in the // right place, so we just need to mark this candidate as requiring a 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 000000000000..fc104c5a555b --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts @@ -0,0 +1,47 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { expect, test } from 'vitest' +import { printCandidate } from '../candidates' +import { variantOrder } from './variant-order' + +test.each([ + // Does nothing unless there are at least two variants + ['flex', null], + ['hover:flex', null], + ['[color:red]', null], + ['[&:focus]:[color:red]', null], + + // // Reorders simple variants + ['data-[invalid]:data-[hover]:flex', 'data-[hover]:data-[invalid]:flex'], + + // // Does not reorder some known combinations where the order does not matter + ['hover:focus:flex', null], + ['focus:hover:flex', null], + ['[&:hover]:[&:focus]:flex', null], + ['[&:focus]:[&:hover]:flex', null], + + // 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:*:after:before:flex'], + ['dark:before:after:*:flex', 'dark:*:after:before:flex'], + + // Some pseudo-elements are treated as regular variants + ['dark:*:hover:file:focus:underline', 'dark:focus:file:hover:*:underline'], + + // Keeps @media-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:hover:data-[b]:data-[a]:flex'], +])('%s => %s', async (candidate, result) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + let migrated = variantOrder(designSystem, designSystem.parseCandidate(candidate)[0]!) + expect(migrated ? printCandidate(migrated) : migrated).toEqual(result) +}) 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 000000000000..a4fd32799917 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts @@ -0,0 +1,102 @@ +import { walk, type AstNode } from '../../../../tailwindcss/src/ast' +import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' + +export function variantOrder(designSystem: DesignSystem, candidate: Candidate): Candidate | null { + if (candidate.variants.length > 1) { + // If the variant stack is made of only variants where the order does not + // matter, we can skip the reordering + if (candidate.variants.every((v) => isOrderIndependentVariant(designSystem, v))) { + return null + } + + let mediaVariants = [] + let regularVariants = [] + let pseudoElementVariants = [] + + for (let variant of candidate.variants) { + if (isMediaVariant(designSystem, variant)) { + mediaVariants.push(variant) + } else if (isEndOfSelectorPseudoElement(variant)) { + pseudoElementVariants.push(variant) + } else { + regularVariants.push(variant) + } + } + + // The candidate list in the AST need to be in reverse order + candidate.variants = [ + ...pseudoElementVariants.reverse(), + ...regularVariants.reverse(), + ...mediaVariants, + ] + return candidate + } + return null +} + +function isOrderIndependentVariant(designSystem: DesignSystem, variant: Variant) { + let stack = getAppliedNodeStack(designSystem, variant) + // Remove media variants from the stack, these are hoisted and don't affect the order of the + // below pseudos + .filter((node) => !(node.kind === 'rule' && node.selector.startsWith('@media (hover:'))) + return stack.every( + (node) => node.kind === 'rule' && (node.selector === '&:hover' || node.selector === '&:focus'), + ) +} + +function isMediaVariant(designSystem: DesignSystem, variant: Variant) { + // Handle the dark variant as a media variant + if (variant.kind === 'static' && variant.root === 'dark') { + return true + } + let stack = getAppliedNodeStack(designSystem, variant) + return stack.every((node) => node.kind === 'rule' && node.selector.startsWith('@media')) +} + +function isEndOfSelectorPseudoElement(variant: Variant) { + if (variant.kind !== 'static') { + return false + } + switch (variant.root) { + case 'after': + case 'backdrop': + case 'before': + case 'first-letter': + case 'first-line': + case 'marker': + case 'placeholder': + case 'selection': + return true + default: + return false + } +} + +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 +} diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index c46be2afa1a4..9fc011e58b3d 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -3,14 +3,14 @@ import path from 'node:path' import type { Candidate } from '../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../tailwindcss/src/design-system' import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates' -import { migrateImportant } from './codemods/migrate-important' +import { variantOrder } from './codemods/variant-order' -export type Migration = (candidate: Candidate) => Candidate | null +export type Migration = (designSystem: DesignSystem, candidate: Candidate) => Candidate | null export default async function migrateContents( designSystem: DesignSystem, contents: string, - migrations: Migration[] = [migrateImportant], + migrations: Migration[] = [variantOrder], ): Promise { let candidates = await extractCandidates(designSystem, contents) @@ -21,7 +21,7 @@ export default async function migrateContents( for (let { candidate, start, end } of candidates) { let needsMigration = false for (let migration of migrations) { - let migrated = migration(candidate) + let migrated = migration(designSystem, candidate) if (migrated) { candidate = migrated needsMigration = true