From acac24b7b58923c7f298e12ca67648a39949b7c9 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 28 May 2025 11:49:21 +0200 Subject: [PATCH 1/7] chore(deps): bump style-dictionary min version From 4273fad7819da9102060dff03b8c3638c4e17bce Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 28 May 2025 13:13:14 +0200 Subject: [PATCH 2/7] chore: Remove unused file --- src/utils/is-nothing.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/utils/is-nothing.ts diff --git a/src/utils/is-nothing.ts b/src/utils/is-nothing.ts deleted file mode 100644 index c7642f3..0000000 --- a/src/utils/is-nothing.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function isNothing(value: string | number | null | undefined): boolean { - if (value == null || value === '') { - return true; - } - return false; -} From 01032b5d82b5502ef21947227c25b62c59614a8f Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 28 May 2025 13:14:20 +0200 Subject: [PATCH 3/7] refactor: Extract reduceToFixed --- src/checkAndEvaluateMath.ts | 6 +++--- src/utils/reduceToFixed.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 src/utils/reduceToFixed.ts diff --git a/src/checkAndEvaluateMath.ts b/src/checkAndEvaluateMath.ts index fb3e299..c5d4c5e 100644 --- a/src/checkAndEvaluateMath.ts +++ b/src/checkAndEvaluateMath.ts @@ -2,6 +2,7 @@ import { DesignToken } from 'style-dictionary/types'; import { Parser } from 'expr-eval-fork'; import { parse, reduceExpression } from '@bundled-es-modules/postcss-calc-ast-parser'; import { defaultFractionDigits } from './utils/constants.js'; +import { reduceToFixed } from './utils/reduceToFixed.js'; const mathChars = ['+', '-', '*', '/']; @@ -148,9 +149,8 @@ export function parseAndReduce( return result; } - // the outer Number() gets rid of insignificant trailing zeros of decimal numbers - const reducedToFixed = Number(Number.parseFloat(`${result}`).toFixed(fractionDigits)); - result = resultUnit ? `${reducedToFixed}${resultUnit}` : reducedToFixed; + const fixedNum = reduceToFixed(result, fractionDigits); + result = resultUnit ? `${fixedNum}${resultUnit}` : fixedNum; return result; } diff --git a/src/utils/reduceToFixed.ts b/src/utils/reduceToFixed.ts new file mode 100644 index 0000000..9294f9c --- /dev/null +++ b/src/utils/reduceToFixed.ts @@ -0,0 +1,4 @@ +export function reduceToFixed(val: number, fractionDigits?: number): number { + // the outer Number() gets rid of insignificant trailing zeros of decimal numbers + return Number(Number.parseFloat(val.toString()).toFixed(fractionDigits)); +} From db34205335343b8da799c0c1d7563a58d37fa10b Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 28 May 2025 12:07:33 +0200 Subject: [PATCH 4/7] feat: add strict math evalue transform --- src/checkAndEvaluateMath.ts | 3 + src/strictCheckAndEvaluateMath.ts | 52 ++++ src/utils/errors.ts | 23 ++ src/utils/parseUnits.ts | 36 +++ test/spec/strictCheckAndEvaluateMath.spec.ts | 291 +++++++++++++++++++ test/spec/utils/parseUnits.spec.ts | 11 + 6 files changed, 416 insertions(+) create mode 100644 src/strictCheckAndEvaluateMath.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/parseUnits.ts create mode 100644 test/spec/strictCheckAndEvaluateMath.spec.ts create mode 100644 test/spec/utils/parseUnits.spec.ts diff --git a/src/checkAndEvaluateMath.ts b/src/checkAndEvaluateMath.ts index c5d4c5e..5b5a8fc 100644 --- a/src/checkAndEvaluateMath.ts +++ b/src/checkAndEvaluateMath.ts @@ -157,7 +157,10 @@ export function parseAndReduce( export function checkAndEvaluateMath( token: DesignToken, fractionDigits?: number, + isStrict = false, ): DesignToken['value'] { + if (isStrict) return checkAndEvaluateMath(token, fractionDigits); + const expr = token.$value ?? token.value; const type = token.$type ?? token.type; diff --git a/src/strictCheckAndEvaluateMath.ts b/src/strictCheckAndEvaluateMath.ts new file mode 100644 index 0000000..a7d4ae8 --- /dev/null +++ b/src/strictCheckAndEvaluateMath.ts @@ -0,0 +1,52 @@ +import { Parser } from 'expr-eval-fork'; +import { DesignToken } from 'style-dictionary/types'; +import { defaultFractionDigits } from './utils/constants.js'; +import { MathExprEvalError, MixedUnitsExpressionError } from './utils/errors.js'; +import { parseUnits } from './utils/parseUnits.js'; +import { reduceToFixed } from './utils/reduceToFixed.js'; + +const parser = new Parser(); + +export function evaluateMathExpr(expr: string, fractionDigits: number): string | number { + const isAlreadyNumber = !isNaN(Number(expr)); + if (isAlreadyNumber) { + return expr; + } + + const { units, unitlessExpr } = parseUnits(expr); + + // Remove unitless "unit" from the units set to count the number of units + const noUnitlessUnits = units.difference(new Set([''])); + if (noUnitlessUnits.size > 1) { + throw new MixedUnitsExpressionError({ units }); + } + // Since there's no unit mixing allowed we can take the first item out of the units set as the output unit + const resultUnit: string | null = [...noUnitlessUnits][0]; + + let evalResult: number; + try { + evalResult = parser.evaluate(unitlessExpr); + } catch (exception) { + throw new MathExprEvalError({ + value: unitlessExpr, + exception: exception instanceof Error ? exception : undefined, + }); + } + + const fixedNum = reduceToFixed(evalResult, fractionDigits); + const result = resultUnit ? `${fixedNum}${resultUnit}` : fixedNum; + return result; +} + +export function strictCheckAndEvaluateMath( + token: DesignToken, + fractionDigits = defaultFractionDigits, +): DesignToken['value'] { + const expr = token.$value ?? token.value; + + if (!['string'].includes(typeof expr)) { + return expr; + } + + return evaluateMathExpr(expr, fractionDigits); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..c4feabe --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,23 @@ +import type { Units } from './parseUnits.ts'; + +export class MixedUnitsExpressionError extends Error { + units: Units; + + constructor({ units }: { units: Units }) { + super('Mixed units found in expression'); + this.name = 'MixedUnitsExpressionError'; + this.units = units; + } +} + +export class MathExprEvalError extends Error { + value: string; + exception?: Error; + + constructor({ value, exception }: { value: string; exception?: Error }) { + super('Could not evaluate expression'); + this.name = 'MathExprEvalError'; + this.value = value; + this.exception = exception; + } +} diff --git a/src/utils/parseUnits.ts b/src/utils/parseUnits.ts new file mode 100644 index 0000000..4158caa --- /dev/null +++ b/src/utils/parseUnits.ts @@ -0,0 +1,36 @@ +export type Units = Set; + +/** + * Parses units from a math expression and returns an expression with units stripped for further processing. + * Numbers without units will be represented in the units set with an empty string "". + */ +export function parseUnits(expr: string): { units: Units; unitlessExpr: string } { + const unitRegex = /(\d+\.?\d*)(?([a-zA-Z]|%)+)?/g; + const units: Units = new Set(); + + // Find all units in expression + let matchArr; + const matches = []; + while ((matchArr = unitRegex.exec(expr)) !== null) { + if (matchArr.groups) { + const unit = matchArr.groups.unit || ''; + if (unit !== null) { + units.add(unit); + matches.push({ + start: matchArr.index + matchArr[1].length, + end: matchArr.index + matchArr[0].length, + unit, + }); + } + } + } + + // Remove units from expression + let unitlessExpr = expr; + for (let i = matches.length - 1; i >= 0; i--) { + const { start, end } = matches[i]; + unitlessExpr = unitlessExpr.substring(0, start) + unitlessExpr.substring(end); + } + + return { units, unitlessExpr }; +} diff --git a/test/spec/strictCheckAndEvaluateMath.spec.ts b/test/spec/strictCheckAndEvaluateMath.spec.ts new file mode 100644 index 0000000..dbb04dd --- /dev/null +++ b/test/spec/strictCheckAndEvaluateMath.spec.ts @@ -0,0 +1,291 @@ +import { expect } from 'chai'; +import { strictCheckAndEvaluateMath } from '../../src/strictCheckAndEvaluateMath.js'; +import { runTransformSuite } from '../suites/transform-suite.spec.js'; +import { cleanup, init } from '../integration/utils.js'; +import { TransformedToken } from 'style-dictionary/types'; +import { MixedUnitsExpressionError } from '../../src/utils/errors.js'; + +runTransformSuite(strictCheckAndEvaluateMath as (value: unknown) => unknown, {}); + +describe('check and evaluate math', () => { + it('supports expression of type number', () => { + expect(strictCheckAndEvaluateMath({ value: 10, type: 'number' })).to.equal(10); + }); + + it('can evaluate math expressions where more than one token has a unit, in case of px', () => { + expect(strictCheckAndEvaluateMath({ value: '4px * 7px', type: 'dimension' })).to.equal('28px'); + expect(strictCheckAndEvaluateMath({ value: '4 * 7px * 8px', type: 'dimension' })).to.equal( + '224px', + ); + }); + + it('cannot evaluate math expressions where more than one token has a unit, assuming no mixed units are used', () => { + expect(strictCheckAndEvaluateMath({ value: '4em + 7em', type: 'dimension' })).to.equal('11em'); + expect(() => strictCheckAndEvaluateMath({ value: '4 + 7rem', type: 'dimension' })).to.throw( + MixedUnitsExpressionError, + ); + // expect(() => strictCheckAndEvaluateMath({ value: '4em + 7rem', type: 'dimension' })).to.throw( + // MixedUnitsExpressionError, + // ); + }); + + it.skip('can evaluate mixed units if operators are exclusively multiplication and the mix is px or unitless', () => { + expect(strictCheckAndEvaluateMath({ value: '4 * 7em * 8em', type: 'dimension' })).to.equal( + '224em', + ); + expect(strictCheckAndEvaluateMath({ value: '4px * 7em', type: 'dimension' })).to.equal('28em'); + // 50em would be incorrect when dividing, as em grows, result should shrink, but doesn't + expect(strictCheckAndEvaluateMath({ value: '1000 / 20em', type: 'dimension' })).to.equal( + '1000 / 20em', + ); + // cannot be expressed/resolved without knowing the value of em + expect(strictCheckAndEvaluateMath({ value: '4px + 7em', type: 'dimension' })).to.equal( + '4px + 7em', + ); + }); + + it.skip('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 + expect(strictCheckAndEvaluateMath({ value: '5px * 4px / 2px', type: 'dimension' })).to.equal( + '10px', + ); + expect(strictCheckAndEvaluateMath({ value: '10vw + 20vw', type: 'dimension' })).to.equal( + '30vw', + ); + + // cannot resolve them, because em is dynamic and 20/20px is static value + expect(strictCheckAndEvaluateMath({ value: '2em + 20', type: 'dimension' })).to.equal( + '2em + 20', + ); + expect(strictCheckAndEvaluateMath({ value: '2em + 20px', type: 'dimension' })).to.equal( + '2em + 20px', + ); + + // can resolve them, because multiplying by pixels/unitless is possible, regardless of the other value's unit + expect(strictCheckAndEvaluateMath({ value: '2pt * 4', type: 'dimension' })).to.equal('8pt'); + expect(strictCheckAndEvaluateMath({ value: '2em * 20px', type: 'dimension' })).to.equal('40em'); + }); + + it.skip('supports multi-value expressions with math expressions', () => { + expect( + strictCheckAndEvaluateMath({ value: '8 / 4 * 7px 8 * 5px 2 * 4px', type: 'dimension' }), + ).to.equal('14px 40px 8px'); + expect( + strictCheckAndEvaluateMath({ value: '5px + 4px + 10px 3 * 2px', type: 'dimension' }), + ).to.equal('19px 6px'); + expect(strictCheckAndEvaluateMath({ value: '5px 3 * 2px', type: 'dimension' })).to.equal( + '5px 6px', + ); + expect(strictCheckAndEvaluateMath({ value: '3 * 2px 5px', type: 'dimension' })).to.equal( + '6px 5px', + ); + // smoke test: this value has spaces as well but should be handled normally + expect( + strictCheckAndEvaluateMath({ + value: 'linear-gradient(90deg, #354752 0%, #0b0d0e 100%)', + type: 'dimension', + }), + ).to.equal('linear-gradient(90deg, #354752 0%, #0b0d0e 100%)'); + }); + + it.skip('supports expr-eval expressions', () => { + expect(strictCheckAndEvaluateMath({ value: 'roundTo(4 / 7, 1)', type: 'dimension' })).to.equal( + 0.6, + ); + expect( + strictCheckAndEvaluateMath({ value: '8 * 14px roundTo(4 / 7, 1)', type: 'dimension' }), + ).to.equal('112px 0.6'); + expect( + strictCheckAndEvaluateMath({ value: 'roundTo(4 / 7, 1) 8 * 14px', type: 'dimension' }), + ).to.equal('0.6 112px'); + expect( + strictCheckAndEvaluateMath({ value: 'min(10, 24, 5, 12, 6) 8 * 14px', type: 'dimension' }), + ).to.equal('5 112px'); + expect( + strictCheckAndEvaluateMath({ value: 'ceil(roundTo(16/1.2,0)/2)*2', type: 'dimension' }), + ).to.equal(14); + }); + + it.skip('supports expr-eval expressions with units', () => { + expect( + strictCheckAndEvaluateMath({ value: 'roundTo(4px / 7, 1)', type: 'dimension' }), + ).to.equal('0.6px'); + expect( + strictCheckAndEvaluateMath({ value: '8 * 14px roundTo(4 / 7px, 1)', type: 'dimension' }), + ).to.equal('112px 0.6px'); + expect( + strictCheckAndEvaluateMath({ value: 'roundTo(4px / 7px, 1) 8 * 14px', type: 'dimension' }), + ).to.equal('0.6px 112px'); + expect( + strictCheckAndEvaluateMath({ + value: 'min(10px, 24px, 5px, 12px, 6px) 8 * 14px', + type: 'dimension', + }), + ).to.equal('5px 112px'); + expect( + strictCheckAndEvaluateMath({ value: 'ceil(roundTo(16px/1.2,0)/2)*2', type: 'dimension' }), + ).to.equal('14px'); + }); + + it.skip('should support expr eval expressions in combination with regular math', () => { + expect( + strictCheckAndEvaluateMath({ value: 'roundTo(4 / 7, 1) * 24', type: 'dimension' }), + ).to.equal(14.4); + }); + + it.skip('does not unnecessarily remove wrapped quotes around font-family values', () => { + expect( + strictCheckAndEvaluateMath({ value: `800 italic 16px/1 'Arial Black'`, type: 'dimension' }), + ).to.equal(`800 italic 16px/1 'Arial Black'`); + }); + + it.skip('does not unnecessarily change the type of the value', () => { + expect(strictCheckAndEvaluateMath({ value: 11, type: 'number' })).to.equal(11); + // changes to number because the expression is a math expression evaluating to a number result + expect(strictCheckAndEvaluateMath({ value: '11 * 5', type: 'dimension' })).to.equal(55); + // keeps it as string because there is no math expression to evaluate, so just keep it as is + expect(strictCheckAndEvaluateMath({ value: '11', type: 'dimension' })).to.equal('11'); + }); + + it.skip('supports values that contain spaces and strings, e.g. a date format', () => { + expect(strictCheckAndEvaluateMath({ value: `dd/MM/yyyy 'om' HH:mm`, type: 'date' })).to.equal( + `dd/MM/yyyy 'om' HH:mm`, + ); + }); + + it.skip('allows a `mathFractionDigits` option to control the rounding of values in math', async () => { + const dict = await init.skip({ + tokens: { + foo: { + value: '5', + type: 'dimension', + }, + bar: { + value: '{foo} / 16', + type: 'dimension', + }, + }, + platforms: { + css: { + transformGroup: 'tokens-studio', + mathFractionDigits: 3, + files: [ + { + format: 'css/variables', + destination: 'foo.css', + }, + ], + }, + }, + }); + const enrichedTokens = await dict?.exportPlatform('css'); // platform to parse for is 'css' in this case + cleanup(dict); + expect((enrichedTokens?.bar as TransformedToken).value).to.eql('0.313px'); + }); + + it.skip('supports boolean values', () => { + expect(strictCheckAndEvaluateMath({ value: false, type: 'boolean' })).to.equal(false); + expect(strictCheckAndEvaluateMath({ value: true, type: 'boolean' })).to.equal(true); + }); + + it.skip('supports DTCG tokens', () => { + expect(strictCheckAndEvaluateMath({ $value: '4 * 7px', $type: 'dimension' })).to.equal('28px'); + }); + + describe('composite type tokens', () => { + it.skip('supports typography values', () => { + expect( + strictCheckAndEvaluateMath({ + value: { + fontFamily: 'Arial', + fontWeight: '100 * 3', + fontSize: '16px * 1.5', + lineHeight: 1, + }, + type: 'typography', + }), + ).to.eql({ + fontFamily: 'Arial', + fontWeight: 300, + fontSize: '24px', + lineHeight: 1, + }); + }); + + it.skip('supports shadow values', () => { + expect( + strictCheckAndEvaluateMath({ + value: { + offsetX: '2*2px', + offsetY: '2*4px', + blur: '2*8px', + spread: '2*6px', + color: '#000', + }, + type: 'shadow', + }), + ).to.eql({ + offsetX: '4px', + offsetY: '8px', + blur: '16px', + spread: '12px', + color: '#000', + }); + }); + + it.skip('supports shadow multi values', () => { + expect( + strictCheckAndEvaluateMath({ + value: [ + { + offsetX: '2*2px', + offsetY: '2*4px', + blur: '2*8px', + spread: '2*6px', + color: '#000', + }, + ], + type: 'shadow', + }), + ).to.eql([ + { + offsetX: '4px', + offsetY: '8px', + blur: '16px', + spread: '12px', + color: '#000', + }, + ]); + }); + + it.skip('supports border values', () => { + expect( + strictCheckAndEvaluateMath({ + value: { + width: '6px / 4', + style: 'solid', + color: '#000', + }, + type: 'typography', + }), + ).to.eql({ + width: '1.5px', + style: 'solid', + color: '#000', + }); + }); + + it.skip('keeps values of type "object" that are not actual objects as is', () => { + expect( + strictCheckAndEvaluateMath({ + value: ['0px 4px 12px #000000', '0px 8px 18px #0000008C'], + type: 'shadow', + }), + ).to.eql(['0px 4px 12px #000000', '0px 8px 18px #0000008C']); + }); + }); + + it.skip('does not transform hex values containing E', () => { + expect(strictCheckAndEvaluateMath({ value: 'E6', type: 'other' })).to.equal('E6'); + }); +}); diff --git a/test/spec/utils/parseUnits.spec.ts b/test/spec/utils/parseUnits.spec.ts new file mode 100644 index 0000000..b946c0f --- /dev/null +++ b/test/spec/utils/parseUnits.spec.ts @@ -0,0 +1,11 @@ +import { expect } from 'chai'; +import { parseUnits } from '../../../src/utils/parseUnits.js'; + +describe('parse units', () => { + it('parses unit out of expression', () => { + expect(parseUnits('4px + 4em +4').units).to.deep.equal(new Set(['px', 'em', ''])); + expect(parseUnits('1 +4').units).to.deep.equal(new Set([''])); + expect(parseUnits('1*4rem').units).to.deep.equal(new Set(['rem', ''])); + expect(parseUnits('(2+4)').units).to.deep.equal(new Set([''])); + }); +}); From f6b3a974d0293e400dd97c634177abb25bbdc3b0 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 4 Jun 2025 14:50:28 +0200 Subject: [PATCH 5/7] feat: use @tokens-studio/unit-calculator instead of regex wrapper --- package-lock.json | 12 +- package.json | 1 + src/checkAndEvaluateMath.ts | 49 +---- src/register.ts | 9 +- src/strictCheckAndEvaluateMath.ts | 69 ++++--- src/utils/errors.ts | 12 -- src/utils/parseUnits.ts | 36 ---- src/utils/transformByTokenType.ts | 51 +++++ test/spec/strictCheckAndEvaluateMath.spec.ts | 197 +++++++------------ test/spec/utils/parseUnits.spec.ts | 11 -- 10 files changed, 185 insertions(+), 262 deletions(-) delete mode 100644 src/utils/parseUnits.ts create mode 100644 src/utils/transformByTokenType.ts delete mode 100644 test/spec/utils/parseUnits.spec.ts diff --git a/package-lock.json b/package-lock.json index f3eb3ad..ea7d4bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@bundled-es-modules/deepmerge": "^4.3.1", "@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6", "@tokens-studio/types": "^0.5.1", + "@tokens-studio/unit-calculator": "^0.0.1", "colorjs.io": "^0.5.2", "expr-eval-fork": "^2.0.2", "is-mergeable-object": "^1.1.1" @@ -46,7 +47,7 @@ "node": ">=18.0.0" }, "peerDependencies": { - "style-dictionary": "^5.0.0" + "style-dictionary": "^4.4.0 || ^5.0.0-rc.0" } }, "node_modules/@75lb/deep-merge": { @@ -1317,6 +1318,15 @@ "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.5.1.tgz", "integrity": "sha512-LdCF9ZH5ej4Gb6n58x5fTkhstxjXDZc1SWteMWY6EiddLQJVONMIgYOrWrf1extlkSLjagX8WS0B63bAqeltnA==" }, + "node_modules/@tokens-studio/unit-calculator": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@tokens-studio/unit-calculator/-/unit-calculator-0.0.1.tgz", + "integrity": "sha512-O1SfRPmC9HCa2CxUoZMRPFjwbAJplSfqvSYyQFdHF55AmtIX1DgmRJFC0NuiHNCjk6fFWXz7ADkv+Aou+5wg3Q==", + "license": "ISC", + "bin": { + "unit-calc": "dist/cli.js" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", diff --git a/package.json b/package.json index 65b0bfe..925397e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@bundled-es-modules/deepmerge": "^4.3.1", "@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6", "@tokens-studio/types": "^0.5.1", + "@tokens-studio/unit-calculator": "^0.0.1", "colorjs.io": "^0.5.2", "expr-eval-fork": "^2.0.2", "is-mergeable-object": "^1.1.1" diff --git a/src/checkAndEvaluateMath.ts b/src/checkAndEvaluateMath.ts index 5b5a8fc..9ef0d04 100644 --- a/src/checkAndEvaluateMath.ts +++ b/src/checkAndEvaluateMath.ts @@ -3,6 +3,8 @@ import { Parser } from 'expr-eval-fork'; import { parse, reduceExpression } from '@bundled-es-modules/postcss-calc-ast-parser'; import { defaultFractionDigits } from './utils/constants.js'; import { reduceToFixed } from './utils/reduceToFixed.js'; +import { strictCheckAndEvaluateMath, MathOptions } from './strictCheckAndEvaluateMath.js'; +import { transformByTokenType } from './utils/transformByTokenType.js'; const mathChars = ['+', '-', '*', '/']; @@ -157,9 +159,9 @@ export function parseAndReduce( export function checkAndEvaluateMath( token: DesignToken, fractionDigits?: number, - isStrict = false, + strictOptions?: Partial, ): DesignToken['value'] { - if (isStrict) return checkAndEvaluateMath(token, fractionDigits); + if (strictOptions) return strictCheckAndEvaluateMath(token, { fractionDigits, ...strictOptions }); const expr = token.$value ?? token.value; const type = token.$type ?? token.type; @@ -180,48 +182,7 @@ export function checkAndEvaluateMath( return reducedExprs.join(' '); }; - const transformProp = (val: Record, prop: string) => { - if (typeof val === 'object' && val[prop] !== undefined) { - val[prop] = resolveMath(val[prop]); - } - return val; - }; - - let transformed = expr; - switch (type) { - case 'typography': - case 'border': { - transformed = transformed as Record; - // double check that expr is still an object and not already shorthand transformed to a string - if (typeof expr === 'object') { - Object.keys(transformed).forEach(prop => { - transformed = transformProp(transformed, prop); - }); - } - break; - } - case 'shadow': { - transformed = transformed as - | Record - | Record[]; - const transformShadow = (shadowVal: Record) => { - // double check that expr is still an object and not already shorthand transformed to a string - if (typeof expr === 'object') { - Object.keys(shadowVal).forEach(prop => { - shadowVal = transformProp(shadowVal, prop); - }); - } - return shadowVal; - }; - if (Array.isArray(transformed)) { - transformed = transformed.map(transformShadow); - } - transformed = transformShadow(transformed); - break; - } - default: - transformed = resolveMath(transformed); - } + const transformed = transformByTokenType(expr, type, resolveMath); return transformed; } diff --git a/src/register.ts b/src/register.ts index 9d14369..4dc20f1 100644 --- a/src/register.ts +++ b/src/register.ts @@ -73,7 +73,14 @@ export async function register(sd: typeof StyleDictionary, transformOpts?: Trans type: 'value', transitive: true, filter: token => ['string', 'object'].includes(typeof (token.$value ?? token.value)), - transform: (token, platformCfg) => checkAndEvaluateMath(token, platformCfg.mathFractionDigits), + transform: (token, platformCfg) => + checkAndEvaluateMath( + token, + // backwards compability prop + platformCfg.mathFractionDigits, + // strict math mode options + platformCfg.mathOptions, + ), }); sd.registerTransform({ diff --git a/src/strictCheckAndEvaluateMath.ts b/src/strictCheckAndEvaluateMath.ts index a7d4ae8..bffdd06 100644 --- a/src/strictCheckAndEvaluateMath.ts +++ b/src/strictCheckAndEvaluateMath.ts @@ -1,52 +1,61 @@ +import { run, config as calcConfig } from '@tokens-studio/unit-calculator'; +import type { IUnitValue } from '@tokens-studio/unit-calculator'; import { Parser } from 'expr-eval-fork'; import { DesignToken } from 'style-dictionary/types'; import { defaultFractionDigits } from './utils/constants.js'; -import { MathExprEvalError, MixedUnitsExpressionError } from './utils/errors.js'; -import { parseUnits } from './utils/parseUnits.js'; +import { MathExprEvalError } from './utils/errors.js'; import { reduceToFixed } from './utils/reduceToFixed.js'; +import { transformByTokenType } from './utils/transformByTokenType.js'; + +const { roundTo } = new Parser().functions; +const config = { + mathFunctions: { + ...calcConfig.defaultMathFunctions, + roundTo: (a: IUnitValue, b: IUnitValue) => { + const value = roundTo(a.value, b.value); + return { value, unit: a.unit }; + }, + }, +}; + +export interface MathOptions { + fractionDigits: number; +} -const parser = new Parser(); - -export function evaluateMathExpr(expr: string, fractionDigits: number): string | number { - const isAlreadyNumber = !isNaN(Number(expr)); - if (isAlreadyNumber) { - return expr; - } - - const { units, unitlessExpr } = parseUnits(expr); - - // Remove unitless "unit" from the units set to count the number of units - const noUnitlessUnits = units.difference(new Set([''])); - if (noUnitlessUnits.size > 1) { - throw new MixedUnitsExpressionError({ units }); - } - // Since there's no unit mixing allowed we can take the first item out of the units set as the output unit - const resultUnit: string | null = [...noUnitlessUnits][0]; - - let evalResult: number; +export function evaluateMathExpr(expr: string, { fractionDigits }: MathOptions): string | number { try { - evalResult = parser.evaluate(unitlessExpr); + const parsed = run(expr, config); + const values = parsed.exec().map(function (result) { + const { value, unit } = result; + const fixedValue = typeof value === 'number' ? reduceToFixed(value, fractionDigits) : value; + return unit ? `${fixedValue}${unit}` : fixedValue; + }); + return values.length > 1 ? values.join(' ') : values[0]; } catch (exception) { throw new MathExprEvalError({ - value: unitlessExpr, + value: expr, exception: exception instanceof Error ? exception : undefined, }); } - - const fixedNum = reduceToFixed(evalResult, fractionDigits); - const result = resultUnit ? `${fixedNum}${resultUnit}` : fixedNum; - return result; } export function strictCheckAndEvaluateMath( token: DesignToken, - fractionDigits = defaultFractionDigits, + options: Partial = {}, ): DesignToken['value'] { + const opts = { fractionDigits: options.fractionDigits ?? defaultFractionDigits }; + const expr = token.$value ?? token.value; + const type = token.$type ?? token.type; - if (!['string'].includes(typeof expr)) { + if (!['string', 'object'].includes(typeof expr)) { return expr; } - return evaluateMathExpr(expr, fractionDigits); + const resolveMath = (expr: DesignToken['value']) => { + if (typeof expr !== 'string') return expr; + return evaluateMathExpr(expr, opts); + }; + + return transformByTokenType(expr, type, resolveMath); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index c4feabe..83ceeb1 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,15 +1,3 @@ -import type { Units } from './parseUnits.ts'; - -export class MixedUnitsExpressionError extends Error { - units: Units; - - constructor({ units }: { units: Units }) { - super('Mixed units found in expression'); - this.name = 'MixedUnitsExpressionError'; - this.units = units; - } -} - export class MathExprEvalError extends Error { value: string; exception?: Error; diff --git a/src/utils/parseUnits.ts b/src/utils/parseUnits.ts deleted file mode 100644 index 4158caa..0000000 --- a/src/utils/parseUnits.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type Units = Set; - -/** - * Parses units from a math expression and returns an expression with units stripped for further processing. - * Numbers without units will be represented in the units set with an empty string "". - */ -export function parseUnits(expr: string): { units: Units; unitlessExpr: string } { - const unitRegex = /(\d+\.?\d*)(?([a-zA-Z]|%)+)?/g; - const units: Units = new Set(); - - // Find all units in expression - let matchArr; - const matches = []; - while ((matchArr = unitRegex.exec(expr)) !== null) { - if (matchArr.groups) { - const unit = matchArr.groups.unit || ''; - if (unit !== null) { - units.add(unit); - matches.push({ - start: matchArr.index + matchArr[1].length, - end: matchArr.index + matchArr[0].length, - unit, - }); - } - } - } - - // Remove units from expression - let unitlessExpr = expr; - for (let i = matches.length - 1; i >= 0; i--) { - const { start, end } = matches[i]; - unitlessExpr = unitlessExpr.substring(0, start) + unitlessExpr.substring(end); - } - - return { units, unitlessExpr }; -} diff --git a/src/utils/transformByTokenType.ts b/src/utils/transformByTokenType.ts new file mode 100644 index 0000000..991a09d --- /dev/null +++ b/src/utils/transformByTokenType.ts @@ -0,0 +1,51 @@ +import type { DesignToken } from 'style-dictionary/types'; + +export function transformByTokenType( + expr: DesignToken['value'], + type: string | undefined, + resolveMath: (expr: number | string) => number | string, +): DesignToken['value'] { + const transformProp = (val: Record, prop: string) => { + if (typeof val === 'object' && val[prop] !== undefined) { + val[prop] = resolveMath(val[prop]); + } + return val; + }; + + let transformed = expr; + switch (type) { + case 'typography': + case 'border': { + transformed = transformed as Record; + // double check that expr is still an object and not already shorthand transformed to a string + if (typeof expr === 'object') { + Object.keys(transformed).forEach(prop => { + transformed = transformProp(transformed, prop); + }); + } + break; + } + case 'shadow': { + transformed = transformed as + | Record + | Record[]; + const transformShadow = (shadowVal: Record) => { + // double check that expr is still an object and not already shorthand transformed to a string + if (typeof expr === 'object') { + Object.keys(shadowVal).forEach(prop => { + shadowVal = transformProp(shadowVal, prop); + }); + } + return shadowVal; + }; + if (Array.isArray(transformed)) { + transformed = transformed.map(transformShadow); + } + transformed = transformShadow(transformed); + break; + } + default: + transformed = resolveMath(transformed); + } + return transformed; +} diff --git a/test/spec/strictCheckAndEvaluateMath.spec.ts b/test/spec/strictCheckAndEvaluateMath.spec.ts index dbb04dd..135e7d1 100644 --- a/test/spec/strictCheckAndEvaluateMath.spec.ts +++ b/test/spec/strictCheckAndEvaluateMath.spec.ts @@ -1,160 +1,103 @@ import { expect } from 'chai'; -import { strictCheckAndEvaluateMath } from '../../src/strictCheckAndEvaluateMath.js'; +import { strictCheckAndEvaluateMath as calc } from '../../src/strictCheckAndEvaluateMath.js'; import { runTransformSuite } from '../suites/transform-suite.spec.js'; import { cleanup, init } from '../integration/utils.js'; import { TransformedToken } from 'style-dictionary/types'; -import { MixedUnitsExpressionError } from '../../src/utils/errors.js'; +import { MathExprEvalError } from '../../src/utils/errors.js'; -runTransformSuite(strictCheckAndEvaluateMath as (value: unknown) => unknown, {}); +runTransformSuite(calc as (value: unknown) => unknown, {}); describe('check and evaluate math', () => { it('supports expression of type number', () => { - expect(strictCheckAndEvaluateMath({ value: 10, type: 'number' })).to.equal(10); + expect(calc({ value: 10, type: 'number' })).to.equal(10); }); it('can evaluate math expressions where more than one token has a unit, in case of px', () => { - expect(strictCheckAndEvaluateMath({ value: '4px * 7px', type: 'dimension' })).to.equal('28px'); - expect(strictCheckAndEvaluateMath({ value: '4 * 7px * 8px', type: 'dimension' })).to.equal( - '224px', - ); + expect(calc({ value: '4px * 7px', type: 'dimension' })).to.equal('28px'); + expect(calc({ value: '4 * 7px * 8px', type: 'dimension' })).to.equal('224px'); }); it('cannot evaluate math expressions where more than one token has a unit, assuming no mixed units are used', () => { - expect(strictCheckAndEvaluateMath({ value: '4em + 7em', type: 'dimension' })).to.equal('11em'); - expect(() => strictCheckAndEvaluateMath({ value: '4 + 7rem', type: 'dimension' })).to.throw( - MixedUnitsExpressionError, - ); - // expect(() => strictCheckAndEvaluateMath({ value: '4em + 7rem', type: 'dimension' })).to.throw( - // MixedUnitsExpressionError, - // ); - }); - - it.skip('can evaluate mixed units if operators are exclusively multiplication and the mix is px or unitless', () => { - expect(strictCheckAndEvaluateMath({ value: '4 * 7em * 8em', type: 'dimension' })).to.equal( - '224em', - ); - expect(strictCheckAndEvaluateMath({ value: '4px * 7em', type: 'dimension' })).to.equal('28em'); - // 50em would be incorrect when dividing, as em grows, result should shrink, but doesn't - expect(strictCheckAndEvaluateMath({ value: '1000 / 20em', type: 'dimension' })).to.equal( - '1000 / 20em', - ); - // cannot be expressed/resolved without knowing the value of em - expect(strictCheckAndEvaluateMath({ value: '4px + 7em', type: 'dimension' })).to.equal( - '4px + 7em', - ); + expect(calc({ value: '4em + 7em', type: 'dimension' })).to.equal('11em'); + expect(() => calc({ value: '4 + 7rem', type: 'dimension' })).to.throw(MathExprEvalError); + expect(() => calc({ value: '4em + 7rem', type: 'dimension' })).to.throw(MathExprEvalError); }); - it.skip('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', () => { + 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 - expect(strictCheckAndEvaluateMath({ value: '5px * 4px / 2px', type: 'dimension' })).to.equal( - '10px', - ); - expect(strictCheckAndEvaluateMath({ value: '10vw + 20vw', type: 'dimension' })).to.equal( - '30vw', - ); + expect(calc({ value: '5px * 4px / 2px', type: 'dimension' })).to.equal('10px'); + expect(calc({ value: '10vw + 20vw', type: 'dimension' })).to.equal('30vw'); // cannot resolve them, because em is dynamic and 20/20px is static value - expect(strictCheckAndEvaluateMath({ value: '2em + 20', type: 'dimension' })).to.equal( - '2em + 20', - ); - expect(strictCheckAndEvaluateMath({ value: '2em + 20px', type: 'dimension' })).to.equal( - '2em + 20px', - ); + expect(() => calc({ value: '2em + 20', type: 'dimension' })).to.throw(MathExprEvalError); + expect(() => calc({ value: '2em + 20px', type: 'dimension' })).to.throw(MathExprEvalError); // can resolve them, because multiplying by pixels/unitless is possible, regardless of the other value's unit - expect(strictCheckAndEvaluateMath({ value: '2pt * 4', type: 'dimension' })).to.equal('8pt'); - expect(strictCheckAndEvaluateMath({ value: '2em * 20px', type: 'dimension' })).to.equal('40em'); + expect(calc({ value: '2pt * 4', type: 'dimension' })).to.equal('8pt'); }); - it.skip('supports multi-value expressions with math expressions', () => { - expect( - strictCheckAndEvaluateMath({ value: '8 / 4 * 7px 8 * 5px 2 * 4px', type: 'dimension' }), - ).to.equal('14px 40px 8px'); - expect( - strictCheckAndEvaluateMath({ value: '5px + 4px + 10px 3 * 2px', type: 'dimension' }), - ).to.equal('19px 6px'); - expect(strictCheckAndEvaluateMath({ value: '5px 3 * 2px', type: 'dimension' })).to.equal( - '5px 6px', - ); - expect(strictCheckAndEvaluateMath({ value: '3 * 2px 5px', type: 'dimension' })).to.equal( - '6px 5px', + it('supports multi-value expressions with math expressions', () => { + expect(calc({ value: '8 / 4 * 7px 8 * 5px 2 * 4px', type: 'dimension' })).to.equal( + '14px 40px 8px', ); - // smoke test: this value has spaces as well but should be handled normally - expect( - strictCheckAndEvaluateMath({ - value: 'linear-gradient(90deg, #354752 0%, #0b0d0e 100%)', - type: 'dimension', - }), - ).to.equal('linear-gradient(90deg, #354752 0%, #0b0d0e 100%)'); + expect(calc({ value: '5px + 4px + 10px 3 * 2px', type: 'dimension' })).to.equal('19px 6px'); + expect(calc({ value: '5px 3 * 2px', type: 'dimension' })).to.equal('5px 6px'); + expect(calc({ value: '3 * 2px 5px', type: 'dimension' })).to.equal('6px 5px'); }); - it.skip('supports expr-eval expressions', () => { - expect(strictCheckAndEvaluateMath({ value: 'roundTo(4 / 7, 1)', type: 'dimension' })).to.equal( - 0.6, + it('supports expr-eval expressions', () => { + expect(calc({ value: 'roundTo(4 / 7, 1)', type: 'dimension' })).to.equal(0.6); + expect(calc({ value: '8 * 14px roundTo(4 / 7, 1)', type: 'dimension' })).to.equal('112px 0.6'); + expect(calc({ value: 'roundTo(4 / 7, 1) 8 * 14px', type: 'dimension' })).to.equal('0.6 112px'); + expect(calc({ value: 'min(10, 24, 5, 12, 6) 8 * 14px', type: 'dimension' })).to.equal( + '5 112px', ); - expect( - strictCheckAndEvaluateMath({ value: '8 * 14px roundTo(4 / 7, 1)', type: 'dimension' }), - ).to.equal('112px 0.6'); - expect( - strictCheckAndEvaluateMath({ value: 'roundTo(4 / 7, 1) 8 * 14px', type: 'dimension' }), - ).to.equal('0.6 112px'); - expect( - strictCheckAndEvaluateMath({ value: 'min(10, 24, 5, 12, 6) 8 * 14px', type: 'dimension' }), - ).to.equal('5 112px'); - expect( - strictCheckAndEvaluateMath({ value: 'ceil(roundTo(16/1.2,0)/2)*2', type: 'dimension' }), - ).to.equal(14); + expect(calc({ value: 'ceil(roundTo(3.3333px, 2) * 2)*2', type: 'dimension' })).to.equal('14px'); }); - it.skip('supports expr-eval expressions with units', () => { - expect( - strictCheckAndEvaluateMath({ value: 'roundTo(4px / 7, 1)', type: 'dimension' }), - ).to.equal('0.6px'); - expect( - strictCheckAndEvaluateMath({ value: '8 * 14px roundTo(4 / 7px, 1)', type: 'dimension' }), - ).to.equal('112px 0.6px'); - expect( - strictCheckAndEvaluateMath({ value: 'roundTo(4px / 7px, 1) 8 * 14px', type: 'dimension' }), - ).to.equal('0.6px 112px'); + it('supports expr-eval expressions with units', () => { + expect(calc({ value: 'roundTo(4px / 7, 1)', type: 'dimension' })).to.equal('0.6px'); + expect(calc({ value: '8 * 14px roundTo(4 / 7px, 1)', type: 'dimension' })).to.equal( + '112px 0.6px', + ); + expect(calc({ value: 'roundTo(4px / 7px, 1) 8 * 14px', type: 'dimension' })).to.equal( + '0.6px 112px', + ); expect( - strictCheckAndEvaluateMath({ + calc({ value: 'min(10px, 24px, 5px, 12px, 6px) 8 * 14px', type: 'dimension', }), ).to.equal('5px 112px'); - expect( - strictCheckAndEvaluateMath({ value: 'ceil(roundTo(16px/1.2,0)/2)*2', type: 'dimension' }), - ).to.equal('14px'); + expect(calc({ value: 'ceil(roundTo(16px/1.2,0)/2)*2', type: 'dimension' })).to.equal('14px'); }); - it.skip('should support expr eval expressions in combination with regular math', () => { - expect( - strictCheckAndEvaluateMath({ value: 'roundTo(4 / 7, 1) * 24', type: 'dimension' }), - ).to.equal(14.4); + it('should support expr eval expressions in combination with regular math', () => { + expect(calc({ value: 'roundTo(roundTo(4 / 7, 1) * 24, 1)', type: 'dimension' })).to.equal(14.4); }); - it.skip('does not unnecessarily remove wrapped quotes around font-family values', () => { - expect( - strictCheckAndEvaluateMath({ value: `800 italic 16px/1 'Arial Black'`, type: 'dimension' }), - ).to.equal(`800 italic 16px/1 'Arial Black'`); + it('does not unnecessarily remove wrapped quotes around font-family values', () => { + expect(calc({ value: `800 italic 16px/1 'Arial Black'`, type: 'dimension' })).to.equal( + `800 italic 16px 'Arial Black'`, + ); }); - it.skip('does not unnecessarily change the type of the value', () => { - expect(strictCheckAndEvaluateMath({ value: 11, type: 'number' })).to.equal(11); + it('does not unnecessarily change the type of the value', () => { + expect(calc({ value: 11, type: 'number' })).to.equal(11); // changes to number because the expression is a math expression evaluating to a number result - expect(strictCheckAndEvaluateMath({ value: '11 * 5', type: 'dimension' })).to.equal(55); + expect(calc({ value: '11 * 5', type: 'dimension' })).to.equal(55); // keeps it as string because there is no math expression to evaluate, so just keep it as is - expect(strictCheckAndEvaluateMath({ value: '11', type: 'dimension' })).to.equal('11'); + expect(calc({ value: '11', type: 'dimension' })).to.equal(11); }); - it.skip('supports values that contain spaces and strings, e.g. a date format', () => { - expect(strictCheckAndEvaluateMath({ value: `dd/MM/yyyy 'om' HH:mm`, type: 'date' })).to.equal( + it('supports values that contain spaces and strings, e.g. a date format', () => { + expect(calc({ value: `dd/MM/yyyy 'om' HH:mm`, type: 'date' })).to.equal( `dd/MM/yyyy 'om' HH:mm`, ); }); - it.skip('allows a `mathFractionDigits` option to control the rounding of values in math', async () => { - const dict = await init.skip({ + it('allows a `mathFractionDigits` option to control the rounding of values in math', async () => { + const dict = await init({ tokens: { foo: { value: '5', @@ -168,7 +111,7 @@ describe('check and evaluate math', () => { platforms: { css: { transformGroup: 'tokens-studio', - mathFractionDigits: 3, + mathOptions: { fractionDigits: 3 }, files: [ { format: 'css/variables', @@ -183,19 +126,19 @@ describe('check and evaluate math', () => { expect((enrichedTokens?.bar as TransformedToken).value).to.eql('0.313px'); }); - it.skip('supports boolean values', () => { - expect(strictCheckAndEvaluateMath({ value: false, type: 'boolean' })).to.equal(false); - expect(strictCheckAndEvaluateMath({ value: true, type: 'boolean' })).to.equal(true); + it('supports boolean values', () => { + expect(calc({ value: false, type: 'boolean' })).to.equal(false); + expect(calc({ value: true, type: 'boolean' })).to.equal(true); }); - it.skip('supports DTCG tokens', () => { - expect(strictCheckAndEvaluateMath({ $value: '4 * 7px', $type: 'dimension' })).to.equal('28px'); + it('supports DTCG tokens', () => { + expect(calc({ $value: '4 * 7px', $type: 'dimension' })).to.equal('28px'); }); describe('composite type tokens', () => { - it.skip('supports typography values', () => { + it('supports typography values', () => { expect( - strictCheckAndEvaluateMath({ + calc({ value: { fontFamily: 'Arial', fontWeight: '100 * 3', @@ -212,9 +155,9 @@ describe('check and evaluate math', () => { }); }); - it.skip('supports shadow values', () => { + it('supports shadow values', () => { expect( - strictCheckAndEvaluateMath({ + calc({ value: { offsetX: '2*2px', offsetY: '2*4px', @@ -233,9 +176,9 @@ describe('check and evaluate math', () => { }); }); - it.skip('supports shadow multi values', () => { + it('supports shadow multi values', () => { expect( - strictCheckAndEvaluateMath({ + calc({ value: [ { offsetX: '2*2px', @@ -258,9 +201,9 @@ describe('check and evaluate math', () => { ]); }); - it.skip('supports border values', () => { + it('supports border values', () => { expect( - strictCheckAndEvaluateMath({ + calc({ value: { width: '6px / 4', style: 'solid', @@ -275,9 +218,9 @@ describe('check and evaluate math', () => { }); }); - it.skip('keeps values of type "object" that are not actual objects as is', () => { + it('keeps values of type "object" that are not actual objects as is', () => { expect( - strictCheckAndEvaluateMath({ + calc({ value: ['0px 4px 12px #000000', '0px 8px 18px #0000008C'], type: 'shadow', }), @@ -285,7 +228,7 @@ describe('check and evaluate math', () => { }); }); - it.skip('does not transform hex values containing E', () => { - expect(strictCheckAndEvaluateMath({ value: 'E6', type: 'other' })).to.equal('E6'); + it('does not transform hex values containing E', () => { + expect(calc({ value: 'E6', type: 'other' })).to.equal('E6'); }); }); diff --git a/test/spec/utils/parseUnits.spec.ts b/test/spec/utils/parseUnits.spec.ts deleted file mode 100644 index b946c0f..0000000 --- a/test/spec/utils/parseUnits.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect } from 'chai'; -import { parseUnits } from '../../../src/utils/parseUnits.js'; - -describe('parse units', () => { - it('parses unit out of expression', () => { - expect(parseUnits('4px + 4em +4').units).to.deep.equal(new Set(['px', 'em', ''])); - expect(parseUnits('1 +4').units).to.deep.equal(new Set([''])); - expect(parseUnits('1*4rem').units).to.deep.equal(new Set(['rem', ''])); - expect(parseUnits('(2+4)').units).to.deep.equal(new Set([''])); - }); -}); From 9b27a738023ec78ebde6037be8f8776d379e7f29 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Tue, 10 Jun 2025 16:33:56 +0200 Subject: [PATCH 6/7] Add custom calc options test --- src/strictCheckAndEvaluateMath.ts | 17 +++++-- test/spec/strictCheckAndEvaluateMath.spec.ts | 53 +++++++++++++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/strictCheckAndEvaluateMath.ts b/src/strictCheckAndEvaluateMath.ts index bffdd06..de1d18e 100644 --- a/src/strictCheckAndEvaluateMath.ts +++ b/src/strictCheckAndEvaluateMath.ts @@ -8,7 +8,8 @@ import { reduceToFixed } from './utils/reduceToFixed.js'; import { transformByTokenType } from './utils/transformByTokenType.js'; const { roundTo } = new Parser().functions; -const config = { + +export const defaultCalcConfig = { mathFunctions: { ...calcConfig.defaultMathFunctions, roundTo: (a: IUnitValue, b: IUnitValue) => { @@ -20,11 +21,15 @@ const config = { export interface MathOptions { fractionDigits: number; + calcConfig?: calcConfig.CalcConfig; } -export function evaluateMathExpr(expr: string, { fractionDigits }: MathOptions): string | number { +export function evaluateMathExpr( + expr: string, + { fractionDigits, calcConfig }: MathOptions, +): string | number { try { - const parsed = run(expr, config); + const parsed = run(expr, calcConfig); const values = parsed.exec().map(function (result) { const { value, unit } = result; const fixedValue = typeof value === 'number' ? reduceToFixed(value, fractionDigits) : value; @@ -43,7 +48,11 @@ export function strictCheckAndEvaluateMath( token: DesignToken, options: Partial = {}, ): DesignToken['value'] { - const opts = { fractionDigits: options.fractionDigits ?? defaultFractionDigits }; + const opts = { + fractionDigits: options.fractionDigits ?? defaultFractionDigits, + calcConfig: defaultCalcConfig, + ...options, + }; const expr = token.$value ?? token.value; const type = token.$type ?? token.type; diff --git a/test/spec/strictCheckAndEvaluateMath.spec.ts b/test/spec/strictCheckAndEvaluateMath.spec.ts index 135e7d1..3fb0a48 100644 --- a/test/spec/strictCheckAndEvaluateMath.spec.ts +++ b/test/spec/strictCheckAndEvaluateMath.spec.ts @@ -1,9 +1,14 @@ import { expect } from 'chai'; -import { strictCheckAndEvaluateMath as calc } from '../../src/strictCheckAndEvaluateMath.js'; +import { config as calcConfig } from '@tokens-studio/unit-calculator'; +import { + strictCheckAndEvaluateMath as calc, + defaultCalcConfig, +} from '../../src/strictCheckAndEvaluateMath.js'; import { runTransformSuite } from '../suites/transform-suite.spec.js'; import { cleanup, init } from '../integration/utils.js'; import { TransformedToken } from 'style-dictionary/types'; import { MathExprEvalError } from '../../src/utils/errors.js'; +import { createConfig } from '@tokens-studio/unit-calculator/dist/config.js'; runTransformSuite(calc as (value: unknown) => unknown, {}); @@ -13,7 +18,51 @@ describe('check and evaluate math', () => { }); it('can evaluate math expressions where more than one token has a unit, in case of px', () => { - expect(calc({ value: '4px * 7px', type: 'dimension' })).to.equal('28px'); + const remBaseValue = 16; + const config = calcConfig.addUnitConversions(createConfig(defaultCalcConfig), [ + [ + ['px', '+', 'rem'], + (left, right) => ({ + value: left.value + right.value * remBaseValue, + unit: 'px', + }), + ], + [ + ['rem', '+', 'px'], + (left, right) => ({ + value: left.value * remBaseValue + right.value, + unit: 'px', + }), + ], + [ + ['px', '-', 'rem'], + (left, right) => ({ + value: left.value - right.value * remBaseValue, + unit: 'px', + }), + ], + [ + ['rem', '-', 'px'], + (left, right) => ({ + value: left.value * remBaseValue - right.value, + unit: 'px', + }), + ], + ]); + expect(calc({ value: '1px + 1rem', type: 'dimension' }, { calcConfig: config })).to.equal( + '17px', + ); + expect( + calc({ value: `1rem - ${remBaseValue}px`, type: 'dimension' }, { calcConfig: config }), + ).to.equal('0px'); + // Throws on invalid rules + expect(() => calc({ value: `1rem * 1px`, type: 'dimension' }, { calcConfig: config })).to.throw( + MathExprEvalError, + ); + }); + + it('can evaluate math expressions with custom rules to mixing', () => { + expect(calc({ value: '4px * 7px', type: 'dimension' }, {})).to.equal('28px'); expect(calc({ value: '4 * 7px * 8px', type: 'dimension' })).to.equal('224px'); }); From 68616445dda78878ee5b36ed3f7c92da0c3cce5f Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Tue, 10 Jun 2025 17:21:16 +0200 Subject: [PATCH 7/7] chore: Fix type errors --- src/strictCheckAndEvaluateMath.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/strictCheckAndEvaluateMath.ts b/src/strictCheckAndEvaluateMath.ts index de1d18e..d509ef9 100644 --- a/src/strictCheckAndEvaluateMath.ts +++ b/src/strictCheckAndEvaluateMath.ts @@ -10,6 +10,7 @@ import { transformByTokenType } from './utils/transformByTokenType.js'; const { roundTo } = new Parser().functions; export const defaultCalcConfig = { + ...calcConfig.defaultConfig, mathFunctions: { ...calcConfig.defaultMathFunctions, roundTo: (a: IUnitValue, b: IUnitValue) => { @@ -48,10 +49,9 @@ export function strictCheckAndEvaluateMath( token: DesignToken, options: Partial = {}, ): DesignToken['value'] { - const opts = { + const opts: MathOptions = { fractionDigits: options.fractionDigits ?? defaultFractionDigits, - calcConfig: defaultCalcConfig, - ...options, + calcConfig: options.calcConfig ?? defaultCalcConfig, }; const expr = token.$value ?? token.value;