diff --git a/CHANGELOG.md b/CHANGELOG.md index 264b0bcb9ea6..35320ebafbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/util/dataTypes.js b/src/util/dataTypes.js index 9447732938cd..e3210e9b98fd 100644 --- a/src/util/dataTypes.js +++ b/src/util/dataTypes.js @@ -1,4 +1,5 @@ import { parseColor } from './color' +import { addWhitespaceAroundMathOperators } from './math-operators' import { parseBoxShadowValue } from './parseBoxShadowValue' import { splitAtTopLevelOnly } from './splitAtTopLevelOnly' @@ -76,7 +77,7 @@ export function normalize(value, context = null, isRoot = true) { value = value.trim() } - value = normalizeMathOperatorSpacing(value) + value = addWhitespaceAroundMathOperators(value) return value } @@ -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(') } diff --git a/src/util/math-operators.ts b/src/util/math-operators.ts new file mode 100644 index 000000000000..d31cc3c526c9 --- /dev/null +++ b/src/util/math-operators.ts @@ -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 +} diff --git a/tests/normalize-data-types.test.js b/tests/normalize-data-types.test.js index 1c302ec28e68..8135a8ced477 100644 --- a/tests/normalize-data-types.test.js +++ b/tests/normalize-data-types.test.js @@ -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))'], @@ -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)'], @@ -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)'],