diff --git a/.changeset/short-doors-warn.md b/.changeset/short-doors-warn.md new file mode 100644 index 0000000..2d6e30a --- /dev/null +++ b/.changeset/short-doors-warn.md @@ -0,0 +1,5 @@ +--- +'@tokens-studio/sd-transforms': patch +--- + +Restructure evaluate math util to support expr eval expressions in combination with regular math. diff --git a/.changeset/ten-icons-argue.md b/.changeset/ten-icons-argue.md new file mode 100644 index 0000000..e173e2c --- /dev/null +++ b/.changeset/ten-icons-argue.md @@ -0,0 +1,5 @@ +--- +'@tokens-studio/sd-transforms': patch +--- + +Allow math expressions where multiple components contain units, as long as they are still computable. diff --git a/src/checkAndEvaluateMath.ts b/src/checkAndEvaluateMath.ts index 8e14657..ab522bd 100644 --- a/src/checkAndEvaluateMath.ts +++ b/src/checkAndEvaluateMath.ts @@ -38,6 +38,7 @@ function splitMultiIntoSingleValues(expr: string): string[] { left === '' && mathChars.includes(right), // tail of expr, right is math char right === '' && mathChars.includes(left), // head of expr, left is math char tokens.length <= 1, // expr is valid if it's a simple 1 token expression + Boolean(tok.match(/\)$/) && mathChars.includes(right)), // end of group ), right is math char checkIfInsideGroup(tok, expr), // exprs that aren't math expressions are okay within ( ) groups ]; @@ -73,44 +74,59 @@ function splitMultiIntoSingleValues(expr: string): string[] { } function parseAndReduce(expr: string): string | boolean | number { - // We check for px unit, then remove it + let result: string | number = expr; + + let evaluated; + // Try to evaluate as expr-eval expression + try { + evaluated = parser.evaluate(`${result}`); + if (typeof evaluated === 'number') { + result = evaluated; + } + } catch (ex) { + // + } + + // We check for px unit, then remove it, since these are essentially numbers in tokens context + // We remember that we had px in there so we can put it back in the end result const hasPx = expr.match('px'); - let unitlessExpr = expr.replace(/px/g, ''); + const noPixExpr = expr.replace(/px/g, ''); + const unitRegex = /(\d+\.?\d*)(?([a-zA-Z]|%)+)/g; + + let matchArr; + const foundUnits: Set = new Set(); + while ((matchArr = unitRegex.exec(noPixExpr)) !== null) { + foundUnits.add(matchArr.groups.unit); + } + // multiple units (besides px) found, cannot parse the expression + if (foundUnits.size > 1) { + return result; + } + const resultUnit = Array.from(foundUnits)[0] ?? (hasPx ? 'px' : ''); + // Remove it here so we can evaluate expressions like 16px + 24px - const calcParsed = parse(unitlessExpr, { allowInlineCommnets: false }); + const calcParsed = parse(noPixExpr, { allowInlineCommnets: false }); // No expression to evaluate, just return it (in case of number as string e.g. '10') if (calcParsed.nodes.length === 1 && calcParsed.nodes[0].type === 'Number') { - return expr; + return `${result}`; } // Attempt to reduce the math expression const reduced = reduceExpression(calcParsed); - let unit; - // E.g. if type is Length, like 4 * 7rem would be 28rem - if (reduced && reduced.type !== 'Number') { - unitlessExpr = `${reduced.value}`.replace(new RegExp(reduced.unit, 'ig'), ''); - unit = reduced.unit; + if (reduced) { + result = reduced.value; } - // Try to evaluate expression (minus unit) with expr-eval - let evaluated; - try { - evaluated = parser.evaluate(unitlessExpr); - if (typeof evaluated !== 'number') { - return expr; - } - } catch (ex) { - return expr; + if (typeof result !== 'number') { + return result; } - const formatted = Number.parseFloat(evaluated.toFixed(3)); - // Put back the px unit if needed and if reduced doesn't come with one - const formattedUnit = unit ?? (hasPx ? 'px' : ''); - - // This ensures stringification is not done when not needed (e.g. type number or boolean kept intact) - return formattedUnit ? `${formatted}${formattedUnit}` : formatted; + // the outer Number() gets rid of insignificant trailing zeros of decimal numbers + const reducedTo3Fixed = Number(Number.parseFloat(`${result}`).toFixed(3)); + result = resultUnit ? `${reducedTo3Fixed}${resultUnit}` : reducedTo3Fixed; + return result; } export function checkAndEvaluateMath( diff --git a/test/spec/checkAndEvaluateMath.spec.ts b/test/spec/checkAndEvaluateMath.spec.ts index d0f2afc..254ec88 100644 --- a/test/spec/checkAndEvaluateMath.spec.ts +++ b/test/spec/checkAndEvaluateMath.spec.ts @@ -29,15 +29,11 @@ describe('check and evaluate math', () => { // exception for pixels, it strips px, making it 4 * 7em = 28em = 448px, where 4px * 7em would be 4px * 112px = 448px as well expect(checkAndEvaluateMath('4px * 7em')).to.equal('28em'); }); - // TODO: we can make this smarter in the future. If every piece of the expression shares the same unit, - // we can strip the unit, do the calculation, and add back the unit. - // However, there's not really a good way to do calculations with mixed units, - // e.g. 2em * 4rem is not possible + it('can evaluate math expressions where more than one token has a unit, as long as for each piece of the expression the unit is the same', () => { // can resolve them, because all values share the same unit - // TODO: implement tests below, failing atm - expect(checkAndEvaluateMath('5rem * 4rem / 2rem')).to.equal('10rem'); // current: '5rem * 4rem / 2rem' - expect(checkAndEvaluateMath('10vw + 20vw')).to.equal('10vw'); // current: '10vw + 20vw' + expect(checkAndEvaluateMath('5px * 4px / 2px')).to.equal('10px'); + expect(checkAndEvaluateMath('10vw + 20vw')).to.equal('30vw'); // cannot resolve them, because em is dynamic and 20/20px is static value expect(checkAndEvaluateMath('2em + 20')).to.equal('2em + 20'); @@ -67,6 +63,10 @@ describe('check and evaluate math', () => { expect(checkAndEvaluateMath('ceil(roundTo(16/1.2,0)/2)*2')).to.equal(14); }); + it('should support expr eval expressions in combination with regular math', () => { + expect(checkAndEvaluateMath('roundTo(4 / 7, 1) * 24')).to.equal(14.4); + }); + it('does not unnecessarily remove wrapped quotes around font-family values', () => { expect(checkAndEvaluateMath(`800 italic 16px/1 'Arial Black'`)).to.equal( `800 italic 16px/1 'Arial Black'`, @@ -75,7 +75,7 @@ describe('check and evaluate math', () => { it('does not unnecessarily change the type of the value', () => { expect(checkAndEvaluateMath(11)).to.equal(11); - // qchanges to number because the expression is a math expression evaluating to a number result + // changes to number because the expression is a math expression evaluating to a number result expect(checkAndEvaluateMath('11 * 5')).to.equal(55); // keeps it as string because there is no math expression to evaluate, so just keep it as is expect(checkAndEvaluateMath('11')).to.equal('11');