diff --git a/CHANGELOG.md b/CHANGELOG.md index f75160c1b0c3..1d0d5697bab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts b/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts index 2dccd897f5e2..19025d5b4bfa 100644 --- a/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts +++ b/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts @@ -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))'], diff --git a/packages/tailwindcss/src/utils/math-operators.ts b/packages/tailwindcss/src/utils/math-operators.ts index e6e83fe79b0c..523fcce208cf 100644 --- a/packages/tailwindcss/src/utils/math-operators.ts +++ b/packages/tailwindcss/src/utils/math-operators.ts @@ -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 - else { - result += ` ${char} ` + // 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]} ` } - } - // 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) + // Everything else + else { + result += input[i] + } } // 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 }