Skip to content

Commit

Permalink
Template migrations: Add variant order codemods
Browse files Browse the repository at this point in the history
  • Loading branch information
philipp-spiess committed Sep 26, 2024
1 parent 732147a commit a754e06
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
102 changes: 102 additions & 0 deletions packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 4 additions & 4 deletions packages/@tailwindcss-upgrade/src/template/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
let candidates = await extractCandidates(designSystem, contents)

Expand All @@ -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
Expand Down

0 comments on commit a754e06

Please sign in to comment.