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 fb3e299..9ef0d04 100644 --- a/src/checkAndEvaluateMath.ts +++ b/src/checkAndEvaluateMath.ts @@ -2,6 +2,9 @@ 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'; +import { strictCheckAndEvaluateMath, MathOptions } from './strictCheckAndEvaluateMath.js'; +import { transformByTokenType } from './utils/transformByTokenType.js'; const mathChars = ['+', '-', '*', '/']; @@ -148,16 +151,18 @@ 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; } export function checkAndEvaluateMath( token: DesignToken, fractionDigits?: number, + strictOptions?: Partial, ): DesignToken['value'] { + if (strictOptions) return strictCheckAndEvaluateMath(token, { fractionDigits, ...strictOptions }); + const expr = token.$value ?? token.value; const type = token.$type ?? token.type; @@ -177,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 new file mode 100644 index 0000000..d509ef9 --- /dev/null +++ b/src/strictCheckAndEvaluateMath.ts @@ -0,0 +1,70 @@ +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 } from './utils/errors.js'; +import { reduceToFixed } from './utils/reduceToFixed.js'; +import { transformByTokenType } from './utils/transformByTokenType.js'; + +const { roundTo } = new Parser().functions; + +export const defaultCalcConfig = { + ...calcConfig.defaultConfig, + 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; + calcConfig?: calcConfig.CalcConfig; +} + +export function evaluateMathExpr( + expr: string, + { fractionDigits, calcConfig }: MathOptions, +): string | number { + try { + 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; + return unit ? `${fixedValue}${unit}` : fixedValue; + }); + return values.length > 1 ? values.join(' ') : values[0]; + } catch (exception) { + throw new MathExprEvalError({ + value: expr, + exception: exception instanceof Error ? exception : undefined, + }); + } +} + +export function strictCheckAndEvaluateMath( + token: DesignToken, + options: Partial = {}, +): DesignToken['value'] { + const opts: MathOptions = { + fractionDigits: options.fractionDigits ?? defaultFractionDigits, + calcConfig: options.calcConfig ?? defaultCalcConfig, + }; + + const expr = token.$value ?? token.value; + const type = token.$type ?? token.type; + + if (!['string', 'object'].includes(typeof expr)) { + return expr; + } + + 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 new file mode 100644 index 0000000..83ceeb1 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,11 @@ +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/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; -} 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)); +} 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 new file mode 100644 index 0000000..3fb0a48 --- /dev/null +++ b/test/spec/strictCheckAndEvaluateMath.spec.ts @@ -0,0 +1,283 @@ +import { expect } from 'chai'; +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, {}); + +describe('check and evaluate math', () => { + it('supports expression of type number', () => { + 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', () => { + 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'); + }); + + it('cannot evaluate math expressions where more than one token has a unit, assuming no mixed units are used', () => { + 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('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(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(() => 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(calc({ value: '2pt * 4', type: 'dimension' })).to.equal('8pt'); + }); + + 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', + ); + 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('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(calc({ value: 'ceil(roundTo(3.3333px, 2) * 2)*2', type: 'dimension' })).to.equal('14px'); + }); + + 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( + calc({ + value: 'min(10px, 24px, 5px, 12px, 6px) 8 * 14px', + type: 'dimension', + }), + ).to.equal('5px 112px'); + expect(calc({ value: 'ceil(roundTo(16px/1.2,0)/2)*2', type: 'dimension' })).to.equal('14px'); + }); + + 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('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('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(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(calc({ value: '11', type: 'dimension' })).to.equal(11); + }); + + 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('allows a `mathFractionDigits` option to control the rounding of values in math', async () => { + const dict = await init({ + tokens: { + foo: { + value: '5', + type: 'dimension', + }, + bar: { + value: '{foo} / 16', + type: 'dimension', + }, + }, + platforms: { + css: { + transformGroup: 'tokens-studio', + mathOptions: { fractionDigits: 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('supports boolean values', () => { + expect(calc({ value: false, type: 'boolean' })).to.equal(false); + expect(calc({ value: true, type: 'boolean' })).to.equal(true); + }); + + it('supports DTCG tokens', () => { + expect(calc({ $value: '4 * 7px', $type: 'dimension' })).to.equal('28px'); + }); + + describe('composite type tokens', () => { + it('supports typography values', () => { + expect( + calc({ + 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('supports shadow values', () => { + expect( + calc({ + 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('supports shadow multi values', () => { + expect( + calc({ + 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('supports border values', () => { + expect( + calc({ + value: { + width: '6px / 4', + style: 'solid', + color: '#000', + }, + type: 'typography', + }), + ).to.eql({ + width: '1.5px', + style: 'solid', + color: '#000', + }); + }); + + it('keeps values of type "object" that are not actual objects as is', () => { + expect( + calc({ + value: ['0px 4px 12px #000000', '0px 8px 18px #0000008C'], + type: 'shadow', + }), + ).to.eql(['0px 4px 12px #000000', '0px 8px 18px #0000008C']); + }); + }); + + it('does not transform hex values containing E', () => { + expect(calc({ value: 'E6', type: 'other' })).to.equal('E6'); + }); +});