Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Allow spaces spaces around operators in attribute selector variants ([#14703](https://github.com/tailwindlabs/tailwindcss/pull/14703))
- _Upgrade (experimental)_: Migrate `flex-grow` to `grow` and `flex-shrink` to `shrink` ([#14721](https://github.com/tailwindlabs/tailwindcss/pull/14721))
- _Upgrade (experimental)_: Minify arbitrary values when printing candidates ([#14720](https://github.com/tailwindlabs/tailwindcss/pull/14720))

### Changed

Expand Down
71 changes: 48 additions & 23 deletions packages/@tailwindcss-upgrade/src/template/candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,40 +108,65 @@ const candidates = [
['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'],
['bg-[#0088cc]!', 'bg-[#0088cc]!'],
['!bg-[#0088cc]', 'bg-[#0088cc]!'],
['bg-[var(--spacing)-1px]', 'bg-[var(--spacing)-1px]'],
['bg-[var(--spacing)_-_1px]', 'bg-[var(--spacing)-1px]'],
['bg-[-1px_-1px]', 'bg-[-1px_-1px]'],
['p-[round(to-zero,1px)]', 'p-[round(to-zero,1px)]'],
['w-1/2', 'w-1/2'],
['p-[calc((100vw-theme(maxWidth.2xl))_/_2)]', 'p-[calc((100vw-theme(maxWidth.2xl))/2)]'],

// Keep spaces in strings
['content-["hello_world"]', 'content-["hello_world"]'],
['content-[____"hello_world"___]', 'content-["hello_world"]'],
]

const variants = [
'', // no variant
'*:',
'focus:',
'group-focus:',

'hover:focus:',
'hover:group-focus:',
'group-hover:focus:',
'group-hover:group-focus:',

'min-[10px]:',
// TODO: This currently expands `calc(1000px+12em)` to `calc(1000px_+_12em)`
'min-[calc(1000px_+_12em)]:',

'peer-[&_p]:',
'peer-[&_p]:hover:',
'hover:peer-[&_p]:',
'hover:peer-[&_p]:focus:',
'peer-[&:hover]:peer-[&_p]:',
['', ''], // no variant
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a tuple with in and out (similar to the candidate above)

['*:', '*:'],
['focus:', 'focus:'],
['group-focus:', 'group-focus:'],

['hover:focus:', 'hover:focus:'],
['hover:group-focus:', 'hover:group-focus:'],
['group-hover:focus:', 'group-hover:focus:'],
['group-hover:group-focus:', 'group-hover:group-focus:'],

['min-[10px]:', 'min-[10px]:'],

// Normalize spaces
['min-[calc(1000px_+_12em)]:', 'min-[calc(1000px+12em)]:'],
['min-[calc(1000px_+12em)]:', 'min-[calc(1000px+12em)]:'],
['min-[calc(1000px+_12em)]:', 'min-[calc(1000px+12em)]:'],
['min-[calc(1000px___+___12em)]:', 'min-[calc(1000px+12em)]:'],

['peer-[&_p]:', 'peer-[&_p]:'],
['peer-[&_p]:hover:', 'peer-[&_p]:hover:'],
['hover:peer-[&_p]:', 'hover:peer-[&_p]:'],
['hover:peer-[&_p]:focus:', 'hover:peer-[&_p]:focus:'],
['peer-[&:hover]:peer-[&_p]:', 'peer-[&:hover]:peer-[&_p]:'],

['[p]:', '[p]:'],
['[_p_]:', '[p]:'],
['has-[p]:', 'has-[p]:'],
['has-[_p_]:', 'has-[p]:'],

// Simplify `&:is(p)` to `p`
['[&:is(p)]:', '[p]:'],
['[&:is(_p_)]:', '[p]:'],
['has-[&:is(p)]:', 'has-[p]:'],
['has-[&:is(_p_)]:', 'has-[p]:'],
]

let combinations: [string, string][] = []
for (let variant of variants) {
for (let [input, output] of candidates) {
combinations.push([`${variant}${input}`, `${variant}${output}`])

for (let [inputVariant, outputVariant] of variants) {
for (let [inputCandidate, outputCandidate] of candidates) {
combinations.push([`${inputVariant}${inputCandidate}`, `${outputVariant}${outputCandidate}`])
}
}

describe('printCandidate()', () => {
test.each(combinations)('%s', async (candidate: string, result: string) => {
test.each(combinations)('%s -> %s', async (candidate: string, result: string) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
Expand Down
93 changes: 84 additions & 9 deletions packages/@tailwindcss-upgrade/src/template/candidates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Scanner } from '@tailwindcss/oxide'
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
import * as ValueParser from '../../../tailwindcss/src/value-parser'

export async function extractRawCandidates(
content: string,
Expand Down Expand Up @@ -51,9 +52,9 @@ export function printCandidate(designSystem: DesignSystem, candidate: Candidate)
if (candidate.value === null) {
base += ''
} else if (candidate.value.dataType) {
base += `-[${candidate.value.dataType}:${escapeArbitrary(candidate.value.value)}]`
base += `-[${candidate.value.dataType}:${printArbitraryValue(candidate.value.value)}]`
} else {
base += `-[${escapeArbitrary(candidate.value.value)}]`
base += `-[${printArbitraryValue(candidate.value.value)}]`
}
} else if (candidate.value.kind === 'named') {
base += `-${candidate.value.value}`
Expand All @@ -63,14 +64,14 @@ export function printCandidate(designSystem: DesignSystem, candidate: Candidate)

// Handle arbitrary
if (candidate.kind === 'arbitrary') {
base += `[${candidate.property}:${escapeArbitrary(candidate.value)}]`
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
}

// Handle modifier
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
if (candidate.modifier) {
if (candidate.modifier.kind === 'arbitrary') {
base += `/[${escapeArbitrary(candidate.modifier.value)}]`
base += `/[${printArbitraryValue(candidate.modifier.value)}]`
} else if (candidate.modifier.kind === 'named') {
base += `/${candidate.modifier.value}`
}
Expand All @@ -95,7 +96,7 @@ function printVariant(variant: Variant) {

// Handle arbitrary variants
if (variant.kind === 'arbitrary') {
return `[${escapeArbitrary(variant.selector)}]`
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
}

let base: string = ''
Expand All @@ -105,7 +106,7 @@ function printVariant(variant: Variant) {
base += variant.root
if (variant.value) {
if (variant.value.kind === 'arbitrary') {
base += `-[${escapeArbitrary(variant.value.value)}]`
base += `-[${printArbitraryValue(variant.value.value)}]`
} else if (variant.value.kind === 'named') {
base += `-${variant.value.value}`
}
Expand All @@ -123,7 +124,7 @@ function printVariant(variant: Variant) {
if (variant.kind === 'functional' || variant.kind === 'compound') {
if (variant.modifier) {
if (variant.modifier.kind === 'arbitrary') {
base += `/[${escapeArbitrary(variant.modifier.value)}]`
base += `/[${printArbitraryValue(variant.modifier.value)}]`
} else if (variant.modifier.kind === 'named') {
base += `/${variant.modifier.value}`
}
Expand All @@ -133,8 +134,82 @@ function printVariant(variant: Variant) {
return base
}

function escapeArbitrary(input: string) {
return input
function printArbitraryValue(input: string) {
let ast = ValueParser.parse(input)

let drop = new Set<ValueParser.ValueAstNode>()

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)
}
}
})

if (drop.size > 0) {
ValueParser.walk(ast, (node, { replaceWith }) => {
if (drop.has(node)) {
drop.delete(node)
replaceWith([])
}
})
}

return ValueParser.toCss(ast)
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
.replaceAll(' ', '_') // Replace spaces with underscores
}

function simplifyArbitraryVariant(input: string) {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { important } from './important'

test.each([
['!flex', 'flex!'],
['min-[calc(1000px+12em)]:!flex', 'min-[calc(1000px_+_12em)]:flex!'],
['min-[calc(1000px+12em)]:!flex', 'min-[calc(1000px+12em)]:flex!'],
['md:!block', 'md:block!'],

// Does not change non-important candidates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ test.each([
],

// Use `theme(…)` (deeply nested) inside of a `calc(…)` function
['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--font-size-xs)_*_2)]'],
['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--font-size-xs)*2)]'],

// Multiple `theme(… / …)` calls should result in modern syntax of `theme(…)`
// - Can't convert to `var(…)` because that would lose the modifier.
Expand Down
8 changes: 5 additions & 3 deletions packages/tailwindcss/src/value-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type ValueSeparatorNode = {
}

export type ValueAstNode = ValueWordNode | ValueFunctionNode | ValueSeparatorNode
type ValueParentNode = ValueFunctionNode | null

function word(value: string): ValueWordNode {
return {
Expand Down Expand Up @@ -54,11 +55,11 @@ export function walk(
visit: (
node: ValueAstNode,
utils: {
parent: ValueAstNode | null
parent: ValueParentNode
replaceWith(newNode: ValueAstNode | ValueAstNode[]): void
},
) => void | ValueWalkAction,
parent: ValueAstNode | null = null,
parent: ValueParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
Expand Down Expand Up @@ -149,7 +150,7 @@ export function parse(input: string) {
case GREATER_THAN:
case EQUALS: {
// 1. Handle everything before the separator as a word
// Handle everything before the closing paren a word
// Handle everything before the closing paren as a word
if (buffer.length > 0) {
let node = word(buffer)
if (parent) {
Expand All @@ -169,6 +170,7 @@ export function parse(input: string) {
peekChar !== COLON &&
peekChar !== COMMA &&
peekChar !== SPACE &&
peekChar !== SLASH &&
peekChar !== LESS_THAN &&
peekChar !== GREATER_THAN &&
peekChar !== EQUALS
Expand Down