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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Don’t break `sibling-*()` functions when used inside `calc(…)` ([#19335](https://github.com/tailwindlabs/tailwindcss/pull/19335))

## [3.4.18] - 2024-10-01

Expand Down
119 changes: 2 additions & 117 deletions src/util/dataTypes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseColor } from './color'
import { addWhitespaceAroundMathOperators } from './math-operators'
import { parseBoxShadowValue } from './parseBoxShadowValue'
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'

Expand Down Expand Up @@ -76,7 +77,7 @@ export function normalize(value, context = null, isRoot = true) {
value = value.trim()
}

value = normalizeMathOperatorSpacing(value)
value = addWhitespaceAroundMathOperators(value)

return value
}
Expand Down Expand Up @@ -109,122 +110,6 @@ export function normalizeAttributeSelectors(value) {
return value
}

/**
* Add spaces around operators inside math functions
* like calc() that do not follow an operator, '(', or `,`.
*
* @param {string} value
* @returns {string}
*/
function normalizeMathOperatorSpacing(value) {
let preventFormattingInFunctions = ['theme']
let preventFormattingKeywords = [
'min-content',
'max-content',
'fit-content',

// Env
'safe-area-inset-top',
'safe-area-inset-right',
'safe-area-inset-bottom',
'safe-area-inset-left',

'titlebar-area-x',
'titlebar-area-y',
'titlebar-area-width',
'titlebar-area-height',

'keyboard-inset-top',
'keyboard-inset-right',
'keyboard-inset-bottom',
'keyboard-inset-left',
'keyboard-inset-width',
'keyboard-inset-height',

'radial-gradient',
'linear-gradient',
'conic-gradient',
'repeating-radial-gradient',
'repeating-linear-gradient',
'repeating-conic-gradient',

'anchor-size',
]

return value.replace(/(calc|min|max|clamp)\(.+\)/g, (match) => {
let result = ''

function lastChar() {
let char = result.trimEnd()
return char[char.length - 1]
}

for (let i = 0; i < match.length; i++) {
function peek(word) {
return word.split('').every((char, j) => match[i + j] === char)
}

function consumeUntil(chars) {
let minIndex = Infinity
for (let char of chars) {
let index = match.indexOf(char, i)
if (index !== -1 && index < minIndex) {
minIndex = index
}
}

let result = match.slice(i, minIndex)
i += result.length - 1
return result
}

let char = match[i]

// Handle `var(--variable)`
if (peek('var')) {
// When we consume until `)`, then we are dealing with this scenario:
// `var(--example)`
//
// When we consume until `,`, then we are dealing with this scenario:
// `var(--example, 1rem)`
//
// In this case we do want to "format", the default value as well
result += consumeUntil([')', ','])
}

// Skip formatting of known keywords
else if (preventFormattingKeywords.some((keyword) => peek(keyword))) {
let keyword = preventFormattingKeywords.find((keyword) => peek(keyword))
result += keyword
i += keyword.length - 1
}

// Skip formatting inside known functions
else if (preventFormattingInFunctions.some((fn) => peek(fn))) {
result += consumeUntil([')'])
}

// Don't break CSS grid track names
else if (peek('[')) {
result += consumeUntil([']'])
}

// Handle operators
else if (
['+', '-', '*', '/'].includes(char) &&
!['(', '+', '-', '*', '/', ','].includes(lastChar())
) {
result += ` ${char} `
} else {
result += char
}
}

// Simplify multiple spaces
return result.replace(/\s+/g, ' ')
})
}

export function url(value) {
return value.startsWith('url(')
}
Expand Down
205 changes: 205 additions & 0 deletions src/util/math-operators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
const LOWER_A = 0x61
const LOWER_Z = 0x7a
const UPPER_A = 0x41
const UPPER_Z = 0x5a
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 PERCENT = 0x25

const MATH_FUNCTIONS = [
'calc',
'min',
'max',
'clamp',
'mod',
'rem',
'sin',
'cos',
'tan',
'asin',
'acos',
'atan',
'atan2',
'pow',
'sqrt',
'hypot',
'log',
'exp',
'round',
]

export function hasMathFn(input: string) {
return input.indexOf('(') !== -1 && MATH_FUNCTIONS.some((fn) => input.includes(`${fn}(`))
}

export function addWhitespaceAroundMathOperators(input: string) {
// Bail early if there are no math functions in the input
if (!MATH_FUNCTIONS.some((fn) => input.includes(fn))) {
return input
}

let result = ''
let formattable: boolean[] = []

let valuePos = null
let lastValuePos = null

for (let i = 0; i < input.length; 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 === PERCENT ||
(char >= LOWER_A && char <= LOWER_Z) ||
(char >= UPPER_A && char <= UPPER_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 === OPEN_PAREN) {
result += input[i]

// Scan backwards to determine the function name. This assumes math
// functions are named with lowercase alphanumeric characters.
let start = i

for (let j = i - 1; j >= 0; j--) {
let inner = input.charCodeAt(j)

if (inner >= ZERO && inner <= NINE) {
start = j // 0-9
} else if (inner >= LOWER_A && inner <= LOWER_Z) {
start = j // a-z
} else {
break
}
}

let fn = input.slice(start, i)

// This is a known math function so start formatting
if (MATH_FUNCTIONS.includes(fn)) {
formattable.unshift(true)
continue
}

// We've encountered nested parens inside a math function, record that and
// keep formatting until we've closed all parens.
else if (formattable[0] && fn === '') {
formattable.unshift(true)
continue
}

// This is not a known math function so don't format it
formattable.unshift(false)
continue
}

// We've exited the function so format according to the parent function's
// type.
else if (char === CLOSE_PAREN) {
result += input[i]
formattable.shift()
}

// Add spaces after commas in math functions
else if (char === COMMA && formattable[0]) {
result += `, `
continue
}

// Skip over consecutive whitespace
else if (char === SPACE && formattable[0] && result.charCodeAt(result.length - 1) === SPACE) {
continue
}

// Add whitespace around operators inside math functions
else if ((char === ADD || char === MUL || char === DIV || char === SUB) && formattable[0]) {
let trimmed = result.trimEnd()
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
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 === OPEN_PAREN || prev === COMMA) {
result += input[i]
continue
}

// Add spaces only after the operator if we already have spaces before it
else if (input.charCodeAt(i - 1) === SPACE) {
result += `${input[i]} `
}

// 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 += input[i]
}
}

// Handle all other characters
else {
result += input[i]
}
}

return result
}
8 changes: 6 additions & 2 deletions tests/normalize-data-types.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ let table = [
],
['min(1+2)', 'min(1 + 2)'],
['max(1+2)', 'max(1 + 2)'],
['clamp(1+2,1+3,1+4)', 'clamp(1 + 2,1 + 3,1 + 4)'],
['clamp(1+2,1+3,1+4)', 'clamp(1 + 2, 1 + 3, 1 + 4)'],
['var(--heading-h1-font-size)', 'var(--heading-h1-font-size)'],
['var(--my-var-with-more-than-3-words)', 'var(--my-var-with-more-than-3-words)'],
['var(--width, calc(100%+1rem))', 'var(--width, calc(100% + 1rem))'],
Expand Down Expand Up @@ -69,7 +69,7 @@ let table = [
['calc(theme(spacing.foo-bar))', 'calc(theme(spacing.foo-bar))'],

// 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)'],
['clamp(-3px+4px,-3px+4px,-3px+4px)', 'clamp(-3px + 4px, -3px + 4px, -3px + 4px)'],

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

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

// Misc
['color(0_0_0/1.0)', 'color(0 0 0/1.0)'],
Expand Down