From 9a0506568a00ee1d226a3bed25d8763bf7887e89 Mon Sep 17 00:00:00 2001 From: "asamuzaK (Kazz)" Date: Sat, 8 Aug 2026 11:36:00 +0900 Subject: [PATCH] Add sortMathFnTerms and tests Introduce sortMathFnTerms to tokenize and sort terms inside math-style functions. --- src/js/css-calc-var.ts | 82 ++++++++++++++++++++++++++++++++++----- test/css-calc-var.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/js/css-calc-var.ts b/src/js/css-calc-var.ts index f817140..de0fd6f 100644 --- a/src/js/css-calc-var.ts +++ b/src/js/css-calc-var.ts @@ -52,7 +52,6 @@ const REG_TYPE_DIM = new RegExp(`^(${NUM})(${ANGLE}|${LENGTH})$`); const REG_TYPE_DIM_PCT = new RegExp(`^(${NUM})(${ANGLE}|${LENGTH}|%)$`); const REG_TYPE_PCT = new RegExp(`^(${NUM})%$`); const REG_CSS_WIDE_KEYWORD = /^(?:inherit|initial|revert(?:-layer)?|unset)$/; -const REG_SORT_MINUS = /^(\S+)\s+-\s+(\S+)$/; /** * @type CalcASTNode - AST node for calc() @@ -699,6 +698,72 @@ export const sortCalcValues = ( return `${start}${resolvedValue}${end}`; }; +/** + * sort math function terms + * @param expr - unwrapped calc expression + * @returns sorted expression + */ +export const sortMathFnTerms = (expr: string): string => { + const tokens = tokenize({ css: expr }); + const terms: string[] = []; + let currentTerm = ''; + let currentSign = ''; + let depth = 0; + for (const token of tokens) { + const [type, value] = token as [TokenType, string]; + if (type === PAREN_OPEN || type === FUNC) { + depth++; + } else if (type === PAREN_CLOSE) { + depth--; + } + if (depth === 0 && (value === '+' || value === '-')) { + if (currentTerm.trim()) { + terms.push((currentSign === '-' ? '-' : '') + currentTerm.trim()); + } + currentTerm = ''; + currentSign = value; + continue; + } + currentTerm += value; + } + if (currentTerm.trim()) { + terms.push((currentSign === '-' ? '-' : '') + currentTerm.trim()); + } + terms.sort((a, b) => { + const reg = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/i; + const matchA = a.match(reg); + const matchB = b.match(reg); + if (matchA && matchB) { + const numA = Number(matchA[1] || '0'); + const numB = Number(matchB[1] || '0'); + const unitA = matchA[2] || ''; + const unitB = matchB[2] || ''; + if (unitA === unitB) { + return numA - numB; + } + return unitA > unitB ? 1 : -1; + } + if (a === b) return 0; + return a > b ? 1 : -1; + }); + const firstTerm = terms[0]; + if (firstTerm === undefined) { + return expr; + } + let res: string = firstTerm; + for (let i = 1; i < terms.length; i++) { + const term = terms[i]; + if (term !== undefined) { + if (term.startsWith('-')) { + res += ' - ' + term.substring(1); + } else { + res += ' + ' + term; + } + } + } + return res; +}; + /** * resolve AST node * @param node - AST node @@ -719,8 +784,9 @@ const resolveNode = (node: CalcASTNode[], isRoot: boolean): string => { } const hasComma = flatItems.includes(','); const firstItem = flatItems[0] || ''; - const isMathFn = isString(firstItem) && REG_FN_MATH_START.test(firstItem); - if (hasComma && isMathFn && flatItems[flatItems.length - 1] === ')') { + const isCommaMathFn = + isString(firstItem) && REG_FN_MATH_START.test(firstItem); + if (hasComma && isCommaMathFn && flatItems[flatItems.length - 1] === ')') { const fnName = flatItems.shift() as string; const fnEnd = flatItems.pop() as string; const args: string[][] = []; @@ -740,15 +806,13 @@ const resolveNode = (node: CalcASTNode[], isRoot: boolean): string => { if (argNodes.length >= TRIA) { const temp = ['calc(', ...argNodes, ')']; const sorted = sortCalcValues(temp, true); - let unwrapped = sorted.substring(5, sorted.length - 1); - unwrapped = unwrapped.replace(REG_SORT_MINUS, '-$2 + $1'); - return unwrapped; + const unwrapped = sorted.substring(5, sorted.length - 1); + return sortMathFnTerms(unwrapped); } else if (argNodes.length === 1) { const item = argNodes[0]; if (isString(item) && item.startsWith('calc(') && item.endsWith(')')) { - let unwrapped = item.substring(5, item.length - 1); - unwrapped = unwrapped.replace(REG_SORT_MINUS, '-$2 + $1'); - return unwrapped; + const unwrapped = item.substring(5, item.length - 1); + return sortMathFnTerms(unwrapped); } } return argNodes.join(''); diff --git a/test/css-calc-var.test.ts b/test/css-calc-var.test.ts index 0fc1dfd..a785310 100644 --- a/test/css-calc-var.test.ts +++ b/test/css-calc-var.test.ts @@ -1298,6 +1298,54 @@ describe('parse CSS calc() tokens', () => { }); }); +describe('sort math function terms', () => { + const func = css.sortMathFnTerms; + + it('should return original expression if terms array is empty', () => { + const res = func(''); + assert.strictEqual(res, '', 'result'); + + const res2 = func(' '); + assert.strictEqual(res2, ' ', 'result'); + }); + + it('should get original value if only one term', () => { + const res = func('100px'); + assert.strictEqual(res, '100px', 'result'); + }); + + it('should sort by units', () => { + const res = func('80vw - 24px'); + assert.strictEqual(res, '-24px + 80vw', 'result'); + }); + + it('should sort different units alphabetically', () => { + const res = func('80vw - 24px + 1em'); + assert.strictEqual(res, '1em - 24px + 80vw', 'result'); + }); + + it('should sort by units and handle numerical values', () => { + const res = func('1vw + 1px - 1em'); + assert.strictEqual(res, '-1em + 1px + 1vw', 'result'); + }); + + it('should sort same units by numerical value', () => { + const res = func('30px - 10px + 20px'); + assert.strictEqual(res, '-10px + 20px + 30px', 'result'); + }); + + it('should sort correctly with percentages', () => { + const res = func('10px - 5% + 2em'); + assert.strictEqual(res, '-5% + 2em + 10px', 'result'); + }); + + it('should not split terms inside parentheses or nested functions', () => { + const res = func('10px - calc(5vw + 10px)'); + const resIncludesCalc = res.includes('calc(5vw + 10px)'); + assert.strictEqual(resIncludesCalc, true, 'result'); + }); +}); + describe('CSS calc()', () => { const func = css.cssCalc;