fix: don't break CSS keywords when formatting math expressions (#18220)

Fixes #18219

## Summary

In an arbitrary value, if there's a non-numeric character both before
and after a hyphen, there's no need for a space.

## Test plan

`decodeArbitraryValue` will correctly format special CSS values like
`fit-content`. I believe spaces are only necessary if there's a digit
either before or after the hyphen.

```js
decodeArbitraryValue('min(fit-content,calc(100dvh-4rem))')
```

This way, the result of the following arbitrary value will also be
correct:

```html
<div class="min-h-[min(fit-content,calc(100dvh-4rem))]"></div>
```

```css
.min-h-\[min\(fit-content\,calc\(100dvh-4rem\)\)\] {
  min-height: min(fit-content, calc(100dvh - 4rem));
}
```

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Rózsa Zoltán 2025-06-10 16:38:31 +02:00 committed by GitHub
parent da0895655e
commit aa817fb6de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 109 additions and 55 deletions

View File

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgrade: migrate CSS variable shorthand if fallback value contains function call ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
- Upgrade: Migrate negative arbitrary values to negative bare values, e.g.: `mb-[-32rem]``-mb-128` ([#18212](https://github.com/tailwindlabs/tailwindcss/pull/18212))
- Upgrade: Do not migrate `blur` in `wire:model.blur` ([#18216](https://github.com/tailwindlabs/tailwindcss/pull/18216))
- Don't add spaces around CSS dashed idents when formatting math expressions ([#18220](https://github.com/tailwindlabs/tailwindcss/pull/18220))
## [4.1.8] - 2025-05-27

View File

@ -84,6 +84,22 @@ describe('adds spaces around math operators', () => {
['calc(theme(spacing.foo-2))', 'calc(theme(spacing.foo-2))'],
['calc(theme(spacing.foo-bar))', 'calc(theme(spacing.foo-bar))'],
// Preserving CSS keyword tokens like fit-content without splitting around hyphens in complex expressions
['min(fit-content,calc(100dvh-4rem))', 'min(fit-content, calc(100dvh - 4rem))'],
[
'min(theme(spacing.foo-bar),fit-content,calc(20*calc(40-30)))',
'min(theme(spacing.foo-bar), fit-content, calc(20 * calc(40 - 30)))',
],
[
'min(fit-content,calc(100dvh-4rem)-calc(50dvh--2px))',
'min(fit-content, calc(100dvh - 4rem) - calc(50dvh - -2px))',
],
['min(-3.4e-2-var(--foo),calc-size(auto))', 'min(-3.4e-2 - var(--foo), calc-size(auto))'],
[
'clamp(-10e3-var(--foo),calc-size(max-content),var(--foo)+-10e3)',
'clamp(-10e3 - var(--foo), calc-size(max-content), var(--foo) + -10e3)',
],
// A negative number immediately after a `,` should not have spaces inserted
['clamp(-3px+4px,-3px+4px,-3px+4px)', 'clamp(-3px + 4px, -3px + 4px, -3px + 4px)'],
@ -93,6 +109,12 @@ describe('adds spaces around math operators', () => {
// Prevent formatting inside `env()` functions
['calc(env(safe-area-inset-bottom)*2)', 'calc(env(safe-area-inset-bottom) * 2)'],
// Handle dashed functions that look like known dashed idents
[
'fit-content(min(max-content,max(min-content,calc(20px+1em))))',
'fit-content(min(max-content, max(min-content, calc(20px + 1em))))',
],
// Should format inside `calc()` nested in `env()`
[
'env(safe-area-inset-bottom,calc(10px+20px))',
@ -122,7 +144,7 @@ describe('adds spaces around math operators', () => {
// round(…) function
['round(1+2,1+3)', 'round(1 + 2, 1 + 3)'],
['round(to-zero,1+2,1+3)', 'round(to-zero,1 + 2, 1 + 3)'],
['round(to-zero,1+2,1+3)', 'round(to-zero, 1 + 2, 1 + 3)'],
// Nested parens in non-math functions don't format their contents
['env((safe-area-inset-bottom))', 'env((safe-area-inset-bottom))'],

View File

@ -1,3 +1,18 @@
const LOWER_A = 0x61
const LOWER_Z = 0x7a
const LOWER_E = 0x65
const UPPER_E = 0x45
const ZERO = 0x30
const NINE = 0x39
const ADD = 0x2b
const SUB = 0x2d
const MUL = 0x2a
const DIV = 0x2f
const OPEN_PAREN = 0x28
const CLOSE_PAREN = 0x29
const COMMA = 0x2c
const SPACE = 0x20
const MATH_FUNCTIONS = [
'calc',
'min',
@ -20,9 +35,6 @@ const MATH_FUNCTIONS = [
'round',
]
const KNOWN_DASHED_FUNCTIONS = ['anchor-size']
const DASHED_FUNCTIONS_REGEX = new RegExp(`(${KNOWN_DASHED_FUNCTIONS.join('|')})\\(`, 'g')
export function hasMathFn(input: string) {
return input.indexOf('(') !== -1 && MATH_FUNCTIONS.some((fn) => input.includes(`${fn}(`))
}
@ -33,25 +45,36 @@ export function addWhitespaceAroundMathOperators(input: string) {
return input
}
// Replace known functions with a placeholder
let hasKnownFunctions = false
if (KNOWN_DASHED_FUNCTIONS.some((fn) => input.includes(fn))) {
DASHED_FUNCTIONS_REGEX.lastIndex = 0
input = input.replace(DASHED_FUNCTIONS_REGEX, (_, fn) => {
hasKnownFunctions = true
return `$${KNOWN_DASHED_FUNCTIONS.indexOf(fn)}$(`
})
}
let result = ''
let formattable: boolean[] = []
let valuePos = null
let lastValuePos = null
for (let i = 0; i < input.length; i++) {
let char = input[i]
let char = input.charCodeAt(i)
// Track if we see a number followed by a unit, then we know for sure that
// this is not a function call.
if (char >= ZERO && char <= NINE) {
valuePos = i
}
// If we saw a number before, and we see normal a-z character, then we
// assume this is a value such as `123px`
else if (valuePos !== null && char >= LOWER_A && char <= LOWER_Z) {
valuePos = i
}
// Once we see something else, we reset the value position
else {
lastValuePos = valuePos
valuePos = null
}
// Determine if we're inside a math function
if (char === '(') {
result += char
if (char === OPEN_PAREN) {
result += input[i]
// Scan backwards to determine the function name. This assumes math
// functions are named with lowercase alphanumeric characters.
@ -60,9 +83,9 @@ export function addWhitespaceAroundMathOperators(input: string) {
for (let j = i - 1; j >= 0; j--) {
let inner = input.charCodeAt(j)
if (inner >= 48 && inner <= 57) {
if (inner >= ZERO && inner <= NINE) {
start = j // 0-9
} else if (inner >= 97 && inner <= 122) {
} else if (inner >= LOWER_A && inner <= LOWER_Z) {
start = j // a-z
} else {
break
@ -91,76 +114,84 @@ export function addWhitespaceAroundMathOperators(input: string) {
// We've exited the function so format according to the parent function's
// type.
else if (char === ')') {
result += char
else if (char === CLOSE_PAREN) {
result += input[i]
formattable.shift()
}
// Add spaces after commas in math functions
else if (char === ',' && formattable[0]) {
else if (char === COMMA && formattable[0]) {
result += `, `
continue
}
// Skip over consecutive whitespace
else if (char === ' ' && formattable[0] && result[result.length - 1] === ' ') {
else if (char === SPACE && formattable[0] && result.charCodeAt(result.length - 1) === SPACE) {
continue
}
// Add whitespace around operators inside math functions
else if ((char === '+' || char === '*' || char === '/' || char === '-') && formattable[0]) {
else if ((char === ADD || char === MUL || char === DIV || char === SUB) && formattable[0]) {
let trimmed = result.trimEnd()
let prev = trimmed[trimmed.length - 1]
let prev = trimmed.charCodeAt(trimmed.length - 1)
let prevPrev = trimmed.charCodeAt(trimmed.length - 2)
let next = input.charCodeAt(i + 1)
// Do not add spaces for scientific notation, e.g.: `-3.4e-2`
if ((prev === LOWER_E || prev === UPPER_E) && prevPrev >= ZERO && prevPrev <= NINE) {
result += input[i]
continue
}
// If we're preceded by an operator don't add spaces
if (prev === '+' || prev === '*' || prev === '/' || prev === '-') {
result += char
else if (prev === ADD || prev === MUL || prev === DIV || prev === SUB) {
result += input[i]
continue
}
// If we're at the beginning of an argument don't add spaces
else if (prev === '(' || prev === ',') {
result += char
else if (prev === OPEN_PAREN || prev === COMMA) {
result += input[i]
continue
}
// Add spaces only after the operator if we already have spaces before it
else if (input[i - 1] === ' ') {
result += `${char} `
else if (input.charCodeAt(i - 1) === SPACE) {
result += `${input[i]} `
}
// Add spaces around the operator
// Add spaces around the operator, if...
else if (
// Previous is a digit
(prev >= ZERO && prev <= NINE) ||
// Next is a digit
(next >= ZERO && next <= NINE) ||
// Previous is end of a function call (or parenthesized expression)
prev === CLOSE_PAREN ||
// Next is start of a parenthesized expression
next === OPEN_PAREN ||
// Next is an operator
next === ADD ||
next === MUL ||
next === DIV ||
next === SUB ||
// Previous position was a value (+ unit)
(lastValuePos !== null && lastValuePos === i - 1)
) {
result += ` ${input[i]} `
}
// Everything else
else {
result += ` ${char} `
result += input[i]
}
}
// Skip over `to-zero` when in a math function.
//
// This is specifically to handle this value in the round(…) function:
//
// ```
// round(to-zero, 1px)
// ^^^^^^^
// ```
//
// This is because the first argument is optionally a keyword and `to-zero`
// contains a hyphen and we want to avoid adding spaces inside it.
else if (formattable[0] && input.startsWith('to-zero', i)) {
let start = i
i += 7
result += input.slice(start, i + 1)
}
// Handle all other characters
else {
result += char
result += input[i]
}
}
if (hasKnownFunctions) {
return result.replace(/\$(\d+)\$/g, (fn, idx) => KNOWN_DASHED_FUNCTIONS[idx] ?? fn)
}
return result
}