Skip to content

Commit aa817fb

Browse files
rozsazoltanthecrypticaceRobinMalfait
authored
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>
1 parent da08956 commit aa817fb

File tree

3 files changed

+109
-55
lines changed

3 files changed

+109
-55
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Upgrade: migrate CSS variable shorthand if fallback value contains function call ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
1515
- Upgrade: Migrate negative arbitrary values to negative bare values, e.g.: `mb-[-32rem]``-mb-128` ([#18212](https://github.com/tailwindlabs/tailwindcss/pull/18212))
1616
- Upgrade: Do not migrate `blur` in `wire:model.blur` ([#18216](https://github.com/tailwindlabs/tailwindcss/pull/18216))
17+
- Don't add spaces around CSS dashed idents when formatting math expressions ([#18220](https://github.com/tailwindlabs/tailwindcss/pull/18220))
1718

1819
## [4.1.8] - 2025-05-27
1920

packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ describe('adds spaces around math operators', () => {
8484
['calc(theme(spacing.foo-2))', 'calc(theme(spacing.foo-2))'],
8585
['calc(theme(spacing.foo-bar))', 'calc(theme(spacing.foo-bar))'],
8686

87+
// Preserving CSS keyword tokens like fit-content without splitting around hyphens in complex expressions
88+
['min(fit-content,calc(100dvh-4rem))', 'min(fit-content, calc(100dvh - 4rem))'],
89+
[
90+
'min(theme(spacing.foo-bar),fit-content,calc(20*calc(40-30)))',
91+
'min(theme(spacing.foo-bar), fit-content, calc(20 * calc(40 - 30)))',
92+
],
93+
[
94+
'min(fit-content,calc(100dvh-4rem)-calc(50dvh--2px))',
95+
'min(fit-content, calc(100dvh - 4rem) - calc(50dvh - -2px))',
96+
],
97+
['min(-3.4e-2-var(--foo),calc-size(auto))', 'min(-3.4e-2 - var(--foo), calc-size(auto))'],
98+
[
99+
'clamp(-10e3-var(--foo),calc-size(max-content),var(--foo)+-10e3)',
100+
'clamp(-10e3 - var(--foo), calc-size(max-content), var(--foo) + -10e3)',
101+
],
102+
87103
// A negative number immediately after a `,` should not have spaces inserted
88104
['clamp(-3px+4px,-3px+4px,-3px+4px)', 'clamp(-3px + 4px, -3px + 4px, -3px + 4px)'],
89105

@@ -93,6 +109,12 @@ describe('adds spaces around math operators', () => {
93109
// Prevent formatting inside `env()` functions
94110
['calc(env(safe-area-inset-bottom)*2)', 'calc(env(safe-area-inset-bottom) * 2)'],
95111

112+
// Handle dashed functions that look like known dashed idents
113+
[
114+
'fit-content(min(max-content,max(min-content,calc(20px+1em))))',
115+
'fit-content(min(max-content, max(min-content, calc(20px + 1em))))',
116+
],
117+
96118
// Should format inside `calc()` nested in `env()`
97119
[
98120
'env(safe-area-inset-bottom,calc(10px+20px))',
@@ -122,7 +144,7 @@ describe('adds spaces around math operators', () => {
122144

123145
// round(…) function
124146
['round(1+2,1+3)', 'round(1 + 2, 1 + 3)'],
125-
['round(to-zero,1+2,1+3)', 'round(to-zero,1 + 2, 1 + 3)'],
147+
['round(to-zero,1+2,1+3)', 'round(to-zero, 1 + 2, 1 + 3)'],
126148

127149
// Nested parens in non-math functions don't format their contents
128150
['env((safe-area-inset-bottom))', 'env((safe-area-inset-bottom))'],
Lines changed: 85 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
const LOWER_A = 0x61
2+
const LOWER_Z = 0x7a
3+
const LOWER_E = 0x65
4+
const UPPER_E = 0x45
5+
const ZERO = 0x30
6+
const NINE = 0x39
7+
const ADD = 0x2b
8+
const SUB = 0x2d
9+
const MUL = 0x2a
10+
const DIV = 0x2f
11+
const OPEN_PAREN = 0x28
12+
const CLOSE_PAREN = 0x29
13+
const COMMA = 0x2c
14+
const SPACE = 0x20
15+
116
const MATH_FUNCTIONS = [
217
'calc',
318
'min',
@@ -20,9 +35,6 @@ const MATH_FUNCTIONS = [
2035
'round',
2136
]
2237

23-
const KNOWN_DASHED_FUNCTIONS = ['anchor-size']
24-
const DASHED_FUNCTIONS_REGEX = new RegExp(`(${KNOWN_DASHED_FUNCTIONS.join('|')})\\(`, 'g')
25-
2638
export function hasMathFn(input: string) {
2739
return input.indexOf('(') !== -1 && MATH_FUNCTIONS.some((fn) => input.includes(`${fn}(`))
2840
}
@@ -33,25 +45,36 @@ export function addWhitespaceAroundMathOperators(input: string) {
3345
return input
3446
}
3547

36-
// Replace known functions with a placeholder
37-
let hasKnownFunctions = false
38-
if (KNOWN_DASHED_FUNCTIONS.some((fn) => input.includes(fn))) {
39-
DASHED_FUNCTIONS_REGEX.lastIndex = 0
40-
input = input.replace(DASHED_FUNCTIONS_REGEX, (_, fn) => {
41-
hasKnownFunctions = true
42-
return `$${KNOWN_DASHED_FUNCTIONS.indexOf(fn)}$(`
43-
})
44-
}
45-
4648
let result = ''
4749
let formattable: boolean[] = []
4850

51+
let valuePos = null
52+
let lastValuePos = null
53+
4954
for (let i = 0; i < input.length; i++) {
50-
let char = input[i]
55+
let char = input.charCodeAt(i)
56+
57+
// Track if we see a number followed by a unit, then we know for sure that
58+
// this is not a function call.
59+
if (char >= ZERO && char <= NINE) {
60+
valuePos = i
61+
}
62+
63+
// If we saw a number before, and we see normal a-z character, then we
64+
// assume this is a value such as `123px`
65+
else if (valuePos !== null && char >= LOWER_A && char <= LOWER_Z) {
66+
valuePos = i
67+
}
68+
69+
// Once we see something else, we reset the value position
70+
else {
71+
lastValuePos = valuePos
72+
valuePos = null
73+
}
5174

5275
// Determine if we're inside a math function
53-
if (char === '(') {
54-
result += char
76+
if (char === OPEN_PAREN) {
77+
result += input[i]
5578

5679
// Scan backwards to determine the function name. This assumes math
5780
// functions are named with lowercase alphanumeric characters.
@@ -60,9 +83,9 @@ export function addWhitespaceAroundMathOperators(input: string) {
6083
for (let j = i - 1; j >= 0; j--) {
6184
let inner = input.charCodeAt(j)
6285

63-
if (inner >= 48 && inner <= 57) {
86+
if (inner >= ZERO && inner <= NINE) {
6487
start = j // 0-9
65-
} else if (inner >= 97 && inner <= 122) {
88+
} else if (inner >= LOWER_A && inner <= LOWER_Z) {
6689
start = j // a-z
6790
} else {
6891
break
@@ -91,76 +114,84 @@ export function addWhitespaceAroundMathOperators(input: string) {
91114

92115
// We've exited the function so format according to the parent function's
93116
// type.
94-
else if (char === ')') {
95-
result += char
117+
else if (char === CLOSE_PAREN) {
118+
result += input[i]
96119
formattable.shift()
97120
}
98121

99122
// Add spaces after commas in math functions
100-
else if (char === ',' && formattable[0]) {
123+
else if (char === COMMA && formattable[0]) {
101124
result += `, `
102125
continue
103126
}
104127

105128
// Skip over consecutive whitespace
106-
else if (char === ' ' && formattable[0] && result[result.length - 1] === ' ') {
129+
else if (char === SPACE && formattable[0] && result.charCodeAt(result.length - 1) === SPACE) {
107130
continue
108131
}
109132

110133
// Add whitespace around operators inside math functions
111-
else if ((char === '+' || char === '*' || char === '/' || char === '-') && formattable[0]) {
134+
else if ((char === ADD || char === MUL || char === DIV || char === SUB) && formattable[0]) {
112135
let trimmed = result.trimEnd()
113-
let prev = trimmed[trimmed.length - 1]
136+
let prev = trimmed.charCodeAt(trimmed.length - 1)
137+
let prevPrev = trimmed.charCodeAt(trimmed.length - 2)
138+
let next = input.charCodeAt(i + 1)
139+
140+
// Do not add spaces for scientific notation, e.g.: `-3.4e-2`
141+
if ((prev === LOWER_E || prev === UPPER_E) && prevPrev >= ZERO && prevPrev <= NINE) {
142+
result += input[i]
143+
continue
144+
}
114145

115146
// If we're preceded by an operator don't add spaces
116-
if (prev === '+' || prev === '*' || prev === '/' || prev === '-') {
117-
result += char
147+
else if (prev === ADD || prev === MUL || prev === DIV || prev === SUB) {
148+
result += input[i]
118149
continue
119150
}
120151

121152
// If we're at the beginning of an argument don't add spaces
122-
else if (prev === '(' || prev === ',') {
123-
result += char
153+
else if (prev === OPEN_PAREN || prev === COMMA) {
154+
result += input[i]
124155
continue
125156
}
126157

127158
// Add spaces only after the operator if we already have spaces before it
128-
else if (input[i - 1] === ' ') {
129-
result += `${char} `
159+
else if (input.charCodeAt(i - 1) === SPACE) {
160+
result += `${input[i]} `
130161
}
131162

132-
// Add spaces around the operator
133-
else {
134-
result += ` ${char} `
163+
// Add spaces around the operator, if...
164+
else if (
165+
// Previous is a digit
166+
(prev >= ZERO && prev <= NINE) ||
167+
// Next is a digit
168+
(next >= ZERO && next <= NINE) ||
169+
// Previous is end of a function call (or parenthesized expression)
170+
prev === CLOSE_PAREN ||
171+
// Next is start of a parenthesized expression
172+
next === OPEN_PAREN ||
173+
// Next is an operator
174+
next === ADD ||
175+
next === MUL ||
176+
next === DIV ||
177+
next === SUB ||
178+
// Previous position was a value (+ unit)
179+
(lastValuePos !== null && lastValuePos === i - 1)
180+
) {
181+
result += ` ${input[i]} `
135182
}
136-
}
137183

138-
// Skip over `to-zero` when in a math function.
139-
//
140-
// This is specifically to handle this value in the round(…) function:
141-
//
142-
// ```
143-
// round(to-zero, 1px)
144-
// ^^^^^^^
145-
// ```
146-
//
147-
// This is because the first argument is optionally a keyword and `to-zero`
148-
// contains a hyphen and we want to avoid adding spaces inside it.
149-
else if (formattable[0] && input.startsWith('to-zero', i)) {
150-
let start = i
151-
i += 7
152-
result += input.slice(start, i + 1)
184+
// Everything else
185+
else {
186+
result += input[i]
187+
}
153188
}
154189

155190
// Handle all other characters
156191
else {
157-
result += char
192+
result += input[i]
158193
}
159194
}
160195

161-
if (hasKnownFunctions) {
162-
return result.replace(/\$(\d+)\$/g, (fn, idx) => KNOWN_DASHED_FUNCTIONS[idx] ?? fn)
163-
}
164-
165196
return result
166197
}

0 commit comments

Comments
 (0)