Skip to content

Commit e2b47e8

Browse files
v3: Don’t break sibling-*() functions when used inside calc(…) (#19335)
The implementation of v3's math operator normalization uses a safe-list of function names. Need to add `sibling-index()` and `sibling-count()` to this list otherwise when used inside math functions like `calc()` they'll get spaces around the `-`.
1 parent 7e2dd53 commit e2b47e8

File tree

4 files changed

+216
-120
lines changed

4 files changed

+216
-120
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Don’t break `sibling-*()` functions when used inside `calc(…)` ([#19335](https://github.com/tailwindlabs/tailwindcss/pull/19335))
1113

1214
## [3.4.18] - 2024-10-01
1315

src/util/dataTypes.js

Lines changed: 2 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { parseColor } from './color'
2+
import { addWhitespaceAroundMathOperators } from './math-operators'
23
import { parseBoxShadowValue } from './parseBoxShadowValue'
34
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'
45

@@ -76,7 +77,7 @@ export function normalize(value, context = null, isRoot = true) {
7677
value = value.trim()
7778
}
7879

79-
value = normalizeMathOperatorSpacing(value)
80+
value = addWhitespaceAroundMathOperators(value)
8081

8182
return value
8283
}
@@ -109,122 +110,6 @@ export function normalizeAttributeSelectors(value) {
109110
return value
110111
}
111112

112-
/**
113-
* Add spaces around operators inside math functions
114-
* like calc() that do not follow an operator, '(', or `,`.
115-
*
116-
* @param {string} value
117-
* @returns {string}
118-
*/
119-
function normalizeMathOperatorSpacing(value) {
120-
let preventFormattingInFunctions = ['theme']
121-
let preventFormattingKeywords = [
122-
'min-content',
123-
'max-content',
124-
'fit-content',
125-
126-
// Env
127-
'safe-area-inset-top',
128-
'safe-area-inset-right',
129-
'safe-area-inset-bottom',
130-
'safe-area-inset-left',
131-
132-
'titlebar-area-x',
133-
'titlebar-area-y',
134-
'titlebar-area-width',
135-
'titlebar-area-height',
136-
137-
'keyboard-inset-top',
138-
'keyboard-inset-right',
139-
'keyboard-inset-bottom',
140-
'keyboard-inset-left',
141-
'keyboard-inset-width',
142-
'keyboard-inset-height',
143-
144-
'radial-gradient',
145-
'linear-gradient',
146-
'conic-gradient',
147-
'repeating-radial-gradient',
148-
'repeating-linear-gradient',
149-
'repeating-conic-gradient',
150-
151-
'anchor-size',
152-
]
153-
154-
return value.replace(/(calc|min|max|clamp)\(.+\)/g, (match) => {
155-
let result = ''
156-
157-
function lastChar() {
158-
let char = result.trimEnd()
159-
return char[char.length - 1]
160-
}
161-
162-
for (let i = 0; i < match.length; i++) {
163-
function peek(word) {
164-
return word.split('').every((char, j) => match[i + j] === char)
165-
}
166-
167-
function consumeUntil(chars) {
168-
let minIndex = Infinity
169-
for (let char of chars) {
170-
let index = match.indexOf(char, i)
171-
if (index !== -1 && index < minIndex) {
172-
minIndex = index
173-
}
174-
}
175-
176-
let result = match.slice(i, minIndex)
177-
i += result.length - 1
178-
return result
179-
}
180-
181-
let char = match[i]
182-
183-
// Handle `var(--variable)`
184-
if (peek('var')) {
185-
// When we consume until `)`, then we are dealing with this scenario:
186-
// `var(--example)`
187-
//
188-
// When we consume until `,`, then we are dealing with this scenario:
189-
// `var(--example, 1rem)`
190-
//
191-
// In this case we do want to "format", the default value as well
192-
result += consumeUntil([')', ','])
193-
}
194-
195-
// Skip formatting of known keywords
196-
else if (preventFormattingKeywords.some((keyword) => peek(keyword))) {
197-
let keyword = preventFormattingKeywords.find((keyword) => peek(keyword))
198-
result += keyword
199-
i += keyword.length - 1
200-
}
201-
202-
// Skip formatting inside known functions
203-
else if (preventFormattingInFunctions.some((fn) => peek(fn))) {
204-
result += consumeUntil([')'])
205-
}
206-
207-
// Don't break CSS grid track names
208-
else if (peek('[')) {
209-
result += consumeUntil([']'])
210-
}
211-
212-
// Handle operators
213-
else if (
214-
['+', '-', '*', '/'].includes(char) &&
215-
!['(', '+', '-', '*', '/', ','].includes(lastChar())
216-
) {
217-
result += ` ${char} `
218-
} else {
219-
result += char
220-
}
221-
}
222-
223-
// Simplify multiple spaces
224-
return result.replace(/\s+/g, ' ')
225-
})
226-
}
227-
228113
export function url(value) {
229114
return value.startsWith('url(')
230115
}

src/util/math-operators.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
const LOWER_A = 0x61
2+
const LOWER_Z = 0x7a
3+
const UPPER_A = 0x41
4+
const UPPER_Z = 0x5a
5+
const LOWER_E = 0x65
6+
const UPPER_E = 0x45
7+
const ZERO = 0x30
8+
const NINE = 0x39
9+
const ADD = 0x2b
10+
const SUB = 0x2d
11+
const MUL = 0x2a
12+
const DIV = 0x2f
13+
const OPEN_PAREN = 0x28
14+
const CLOSE_PAREN = 0x29
15+
const COMMA = 0x2c
16+
const SPACE = 0x20
17+
const PERCENT = 0x25
18+
19+
const MATH_FUNCTIONS = [
20+
'calc',
21+
'min',
22+
'max',
23+
'clamp',
24+
'mod',
25+
'rem',
26+
'sin',
27+
'cos',
28+
'tan',
29+
'asin',
30+
'acos',
31+
'atan',
32+
'atan2',
33+
'pow',
34+
'sqrt',
35+
'hypot',
36+
'log',
37+
'exp',
38+
'round',
39+
]
40+
41+
export function hasMathFn(input: string) {
42+
return input.indexOf('(') !== -1 && MATH_FUNCTIONS.some((fn) => input.includes(`${fn}(`))
43+
}
44+
45+
export function addWhitespaceAroundMathOperators(input: string) {
46+
// Bail early if there are no math functions in the input
47+
if (!MATH_FUNCTIONS.some((fn) => input.includes(fn))) {
48+
return input
49+
}
50+
51+
let result = ''
52+
let formattable: boolean[] = []
53+
54+
let valuePos = null
55+
let lastValuePos = null
56+
57+
for (let i = 0; i < input.length; i++) {
58+
let char = input.charCodeAt(i)
59+
60+
// Track if we see a number followed by a unit, then we know for sure that
61+
// this is not a function call.
62+
if (char >= ZERO && char <= NINE) {
63+
valuePos = i
64+
}
65+
66+
// If we saw a number before, and we see normal a-z character, then we
67+
// assume this is a value such as `123px`
68+
else if (
69+
valuePos !== null &&
70+
(char === PERCENT ||
71+
(char >= LOWER_A && char <= LOWER_Z) ||
72+
(char >= UPPER_A && char <= UPPER_Z))
73+
) {
74+
valuePos = i
75+
}
76+
77+
// Once we see something else, we reset the value position
78+
else {
79+
lastValuePos = valuePos
80+
valuePos = null
81+
}
82+
83+
// Determine if we're inside a math function
84+
if (char === OPEN_PAREN) {
85+
result += input[i]
86+
87+
// Scan backwards to determine the function name. This assumes math
88+
// functions are named with lowercase alphanumeric characters.
89+
let start = i
90+
91+
for (let j = i - 1; j >= 0; j--) {
92+
let inner = input.charCodeAt(j)
93+
94+
if (inner >= ZERO && inner <= NINE) {
95+
start = j // 0-9
96+
} else if (inner >= LOWER_A && inner <= LOWER_Z) {
97+
start = j // a-z
98+
} else {
99+
break
100+
}
101+
}
102+
103+
let fn = input.slice(start, i)
104+
105+
// This is a known math function so start formatting
106+
if (MATH_FUNCTIONS.includes(fn)) {
107+
formattable.unshift(true)
108+
continue
109+
}
110+
111+
// We've encountered nested parens inside a math function, record that and
112+
// keep formatting until we've closed all parens.
113+
else if (formattable[0] && fn === '') {
114+
formattable.unshift(true)
115+
continue
116+
}
117+
118+
// This is not a known math function so don't format it
119+
formattable.unshift(false)
120+
continue
121+
}
122+
123+
// We've exited the function so format according to the parent function's
124+
// type.
125+
else if (char === CLOSE_PAREN) {
126+
result += input[i]
127+
formattable.shift()
128+
}
129+
130+
// Add spaces after commas in math functions
131+
else if (char === COMMA && formattable[0]) {
132+
result += `, `
133+
continue
134+
}
135+
136+
// Skip over consecutive whitespace
137+
else if (char === SPACE && formattable[0] && result.charCodeAt(result.length - 1) === SPACE) {
138+
continue
139+
}
140+
141+
// Add whitespace around operators inside math functions
142+
else if ((char === ADD || char === MUL || char === DIV || char === SUB) && formattable[0]) {
143+
let trimmed = result.trimEnd()
144+
let prev = trimmed.charCodeAt(trimmed.length - 1)
145+
let prevPrev = trimmed.charCodeAt(trimmed.length - 2)
146+
let next = input.charCodeAt(i + 1)
147+
148+
// Do not add spaces for scientific notation, e.g.: `-3.4e-2`
149+
if ((prev === LOWER_E || prev === UPPER_E) && prevPrev >= ZERO && prevPrev <= NINE) {
150+
result += input[i]
151+
continue
152+
}
153+
154+
// If we're preceded by an operator don't add spaces
155+
else if (prev === ADD || prev === MUL || prev === DIV || prev === SUB) {
156+
result += input[i]
157+
continue
158+
}
159+
160+
// If we're at the beginning of an argument don't add spaces
161+
else if (prev === OPEN_PAREN || prev === COMMA) {
162+
result += input[i]
163+
continue
164+
}
165+
166+
// Add spaces only after the operator if we already have spaces before it
167+
else if (input.charCodeAt(i - 1) === SPACE) {
168+
result += `${input[i]} `
169+
}
170+
171+
// Add spaces around the operator, if...
172+
else if (
173+
// Previous is a digit
174+
(prev >= ZERO && prev <= NINE) ||
175+
// Next is a digit
176+
(next >= ZERO && next <= NINE) ||
177+
// Previous is end of a function call (or parenthesized expression)
178+
prev === CLOSE_PAREN ||
179+
// Next is start of a parenthesized expression
180+
next === OPEN_PAREN ||
181+
// Next is an operator
182+
next === ADD ||
183+
next === MUL ||
184+
next === DIV ||
185+
next === SUB ||
186+
// Previous position was a value (+ unit)
187+
(lastValuePos !== null && lastValuePos === i - 1)
188+
) {
189+
result += ` ${input[i]} `
190+
}
191+
192+
// Everything else
193+
else {
194+
result += input[i]
195+
}
196+
}
197+
198+
// Handle all other characters
199+
else {
200+
result += input[i]
201+
}
202+
}
203+
204+
return result
205+
}

tests/normalize-data-types.test.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ let table = [
3737
],
3838
['min(1+2)', 'min(1 + 2)'],
3939
['max(1+2)', 'max(1 + 2)'],
40-
['clamp(1+2,1+3,1+4)', 'clamp(1 + 2,1 + 3,1 + 4)'],
40+
['clamp(1+2,1+3,1+4)', 'clamp(1 + 2, 1 + 3, 1 + 4)'],
4141
['var(--heading-h1-font-size)', 'var(--heading-h1-font-size)'],
4242
['var(--my-var-with-more-than-3-words)', 'var(--my-var-with-more-than-3-words)'],
4343
['var(--width, calc(100%+1rem))', 'var(--width, calc(100% + 1rem))'],
@@ -69,7 +69,7 @@ let table = [
6969
['calc(theme(spacing.foo-bar))', 'calc(theme(spacing.foo-bar))'],
7070

7171
// A negative number immediately after a `,` should not have spaces inserted
72-
['clamp(-3px+4px,-3px+4px,-3px+4px)', 'clamp(-3px + 4px,-3px + 4px,-3px + 4px)'],
72+
['clamp(-3px+4px,-3px+4px,-3px+4px)', 'clamp(-3px + 4px, -3px + 4px, -3px + 4px)'],
7373

7474
// Prevent formatting inside `var()` functions
7575
['calc(var(--foo-bar-bar)*2)', 'calc(var(--foo-bar-bar) * 2)'],
@@ -98,6 +98,10 @@ let table = [
9898

9999
// Prevent formatting functions that are not math functions
100100
['w-[calc(anchor-size(width)+8px)]', 'w-[calc(anchor-size(width) + 8px)]'],
101+
['w-[calc(sibling-index()*1%)]', 'w-[calc(sibling-index() * 1%)]'],
102+
['w-[calc(sibling-count()*1%)]', 'w-[calc(sibling-count() * 1%)]'],
103+
['w-[calc(--custom()*1%)]', 'w-[calc(--custom() * 1%)]'],
104+
['w-[calc(--custom-fn()*1%)]', 'w-[calc(--custom-fn() * 1%)]'],
101105

102106
// Misc
103107
['color(0_0_0/1.0)', 'color(0 0 0/1.0)'],

0 commit comments

Comments
 (0)