Skip to content
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"is-mergeable-object": "^1.1.1"
},
"peerDependencies": {
"style-dictionary": "^4.3.0 || ^5.0.0-rc.0"
"style-dictionary": "^5.0.0"
},
"devDependencies": {
"@changesets/cli": "^2.27.6",
Expand Down
130 changes: 82 additions & 48 deletions src/checkAndEvaluateMath.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
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';

const mathChars = ['+', '-', '*', '/'];
export type Units = Set<string>;

export class MixedUnitsExpressionError extends Error {
units: Units;

constructor({ units }: { units: Units }) {
super('Mixed units found in expression');
this.name = 'MixedUnitsExpressionError';
this.units = units;
}
}

const mathChars = new Set(['+', '-', '*', '/']);
const mathCharsRegexp = /[+\-*/]/g;

const parser = new Parser();

Expand Down Expand Up @@ -35,12 +47,12 @@ function splitMultiIntoSingleValues(expr: string): string[] {

// conditions under which math expr is valid
const conditions = [
mathChars.includes(tok), // current token is a math char
mathChars.includes(right) && mathChars.includes(left), // left/right are both math chars
left === '' && mathChars.includes(right), // tail of expr, right is math char
right === '' && mathChars.includes(left), // head of expr, left is math char
mathChars.has(tok), // current token is a math char
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice optimization 👍🏻

mathChars.has(right) && mathChars.has(left), // left/right are both math chars
left === '' && mathChars.has(right), // tail of expr, right is math char
right === '' && mathChars.has(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
Boolean(tok.match(/\)$/) && mathChars.has(right)), // end of group ), right is math char
checkIfInsideGroup(tok, expr), // exprs that aren't math expressions are okay within ( ) groups
];

Expand All @@ -51,7 +63,7 @@ function splitMultiIntoSingleValues(expr: string): string[] {
// make sure we skip the next iteration, because otherwise the conditions
// will be all false again for the next char which is essentially a "duplicate" hit
// meaning we would unnecessarily push another index to split our multi-value by
if (!mathChars.find(char => tok.includes(char))) {
if (!mathChars.values().find(char => tok.includes(char))) {
skipNextIteration = true;
}
} else {
Expand All @@ -75,6 +87,50 @@ function splitMultiIntoSingleValues(expr: string): string[] {
return [expr];
}

export function findMathOperators(expr: string) {
const operators = new Set();
const matches = expr.match(mathCharsRegexp);
if (matches) {
matches.forEach(op => operators.add(op));
}
return operators;
}

/**
* 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*)(?<unit>([a-zA-Z]|%)+)?/g;
Copy link
Author

@floscr floscr May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally I would throw on any unsupported units which should be definable by the user in the platform config.
For penpot we would allow unitless|px|rem for example

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 };
}

export function parseAndReduce(
expr: string,
fractionDigits = defaultFractionDigits,
Expand All @@ -86,34 +142,33 @@ export function parseAndReduce(
return result;
}

// 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');
const noPixExpr = expr.replace(/px/g, '');
const unitRegex = /(\d+\.?\d*)(?<unit>([a-zA-Z]|%)+)/g;
const { units, unitlessExpr } = parseUnits(expr);
const unitsNoUnitless = units.difference(new Set(['']));

let matchArr;
const foundUnits: Set<string> = new Set();
while ((matchArr = unitRegex.exec(noPixExpr)) !== null) {
if (matchArr?.groups) {
foundUnits.add(matchArr.groups.unit);
}
if (unitsNoUnitless.size > 1) {
throw new MixedUnitsExpressionError({ units });
}
// multiple units (besides px) found, cannot parse the expression
if (foundUnits.size > 1) {
return result;
}
const resultUnit = Array.from(foundUnits)[0] ?? (hasPx ? 'px' : '');

if (!isNaN(Number(noPixExpr))) {
result = Number(noPixExpr);
// Dont allow adding or subtracting to unitless
// TODO: Find a better interface here something like an allowance intersection chart of units and operators.
const mathOperators = findMathOperators(expr);
const isMixingRelativeUnitsWithUnitless =
(unitsNoUnitless.has('rem') || unitsNoUnitless.has('em')) &&
(mathOperators.has('+') || mathOperators.has('-')) &&
units.size > 1;
if (isMixingRelativeUnitsWithUnitless) {
throw new MixedUnitsExpressionError({ units });
}

const resultUnit = [...unitsNoUnitless][0];

// TODO: We can't throw here as we still need to support non-string value types that get parsed in multiple steps
// e.g.: `value: {width: '6px / 4', style: 'solid', color: '#000',},`
if (typeof result !== 'number') {
// Try to evaluate as expr-eval expression
let evaluated;
try {
evaluated = parser.evaluate(`${noPixExpr}`);
evaluated = parser.evaluate(`${unitlessExpr}`);
if (typeof evaluated === 'number') {
result = evaluated;
}
Expand All @@ -122,27 +177,6 @@ export function parseAndReduce(
}
}

if (typeof result !== 'number') {
let exprToParse = noPixExpr;
// math operators, excluding *
// (** or ^ exponent would theoretically be fine, but postcss-calc-ast-parser does not support it
const operatorsRegex = /[/+%-]/g;
// if we only have * operator, we can consider expression as unitless and compute it that way
// we already know we dont have mixed units from (foundUnits.size > 1) guard above
if (!exprToParse.match(operatorsRegex)) {
exprToParse = exprToParse.replace(new RegExp(resultUnit, 'g'), '');
}
// Try to evaluate as postcss-calc-ast-parser expression
const calcParsed = parse(exprToParse, { allowInlineCommnets: false });

// Attempt to reduce the math expression
const reduced = reduceExpression(calcParsed);
// E.g. if type is Length, like 4 * 7rem would be 28rem
if (reduced && !isNaN(reduced.value)) {
result = reduced.value;
}
}

if (typeof result !== 'number') {
// parsing failed, return the original expression
return result;
Expand Down
14 changes: 7 additions & 7 deletions test/integration/cross-file-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,19 @@
it('supports cross file references e.g. expanding typography', async () => {
const file = await promises.readFile(outputFilePath, 'utf-8');
const content = excerpt(file, { start: ':root {', end: '}' });
const expectedOutput = `--sdTypoFontWeight: 400;
--sdTypoFontStyle: italic;
--sdPrimaryFont: Inter;
const expectedOutput = `--sdPrimaryFont: Inter;
--sdFontWeight: 800;
--sdLineHeight: 1.5;
--sdDimensionScale: 2;
--sdDimensionXs: 4px;
--sdWeightWeight: 400;
--sdWeightStyle: italic;
--sdTypoFontWeight: 400;
--sdTypoFontStyle: italic;
--sdTypo2FontFamily: Inter;
--sdTypo2FontWeight: 800;
--sdTypo2LineHeight: 1.5;
--sdTypo2FontSize: 8px;
--sdDimensionScale: 2;
--sdDimensionXs: 4px;
--sdTestCompositeFancyCardColor: #fff;
--sdTestCompositeFancyCardBorderRadius: 18px;
--sdTestCompositeFancyCardBorderColor: #999;
Expand All @@ -69,14 +71,12 @@
--sdTestTypographyTextFontSize: 25px;
--sdTestTypographyTextLineHeight: 32px;
--sdTestTypographyTextFontWeight: 700;
--sdWeightWeight: 400;
--sdWeightStyle: italic;
--sdTypoAliasFontWeight: 400;
--sdTypoAliasFontStyle: italic;
--sdTypo3FontFamily: Inter;
--sdTypo3FontWeight: 800;
--sdTypo3LineHeight: 1.5;
--sdTypo3FontSize: 8px;`;
expect(content).toBe(expectedOutput);

Check failure on line 80 in test/integration/cross-file-refs.test.ts

View workflow job for this annotation

GitHub Actions / Verify changes (18.x)

test/integration/cross-file-refs.test.ts > cross file references > supports cross file references e.g. expanding typography

AssertionError: expected '--sdPrimaryFont: Inter;\n--sdFontWeig…' to be '--sdPrimaryFont: Inter;\n--sdFontWeig…' // Object.is equality - Expected + Received --sdPrimaryFont: Inter; --sdFontWeight: 800; --sdLineHeight: 1.5; --sdDimensionScale: 2; --sdDimensionXs: 4px; --sdWeightWeight: 400; --sdWeightStyle: italic; --sdTypoFontWeight: 400; --sdTypoFontStyle: italic; --sdTypo2FontFamily: Inter; --sdTypo2FontWeight: 800; --sdTypo2LineHeight: 1.5; - --sdTypo2FontSize: 8px; + --sdTypo2FontSize: 2*4px; --sdTestCompositeFancyCardColor: #fff; --sdTestCompositeFancyCardBorderRadius: 18px; --sdTestCompositeFancyCardBorderColor: #999; --sdTestCompositeCardColor: #fff; --sdTestCompositeCardBorderRadius: 18px; --sdTestCompositeCardBorderColor: #999; --sdTestTypographyFancyTextFontFamily: Arial; --sdTestTypographyFancyTextFontSize: 25px; --sdTestTypographyFancyTextLineHeight: 32px; --sdTestTypographyFancyTextFontWeight: 700; --sdTestTypographyTextFontFamily: Arial; --sdTestTypographyTextFontSize: 25px; --sdTestTypographyTextLineHeight: 32px; --sdTestTypographyTextFontWeight: 700; --sdTypoAliasFontWeight: 400; --sdTypoAliasFontStyle: italic; --sdTypo3FontFamily: Inter; --sdTypo3FontWeight: 800; --sdTypo3LineHeight: 1.5; - --sdTypo3FontSize: 8px; + --sdTypo3FontSize: 2*4px; ❯ test/integration/cross-file-refs.test.ts:80:21

Check failure on line 80 in test/integration/cross-file-refs.test.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

test/integration/cross-file-refs.test.ts > cross file references > supports cross file references e.g. expanding typography

AssertionError: expected '--sdPrimaryFont: Inter;\n--sdFontWeig…' to be '--sdPrimaryFont: Inter;\n--sdFontWeig…' // Object.is equality - Expected + Received --sdPrimaryFont: Inter; --sdFontWeight: 800; --sdLineHeight: 1.5; --sdDimensionScale: 2; --sdDimensionXs: 4px; --sdWeightWeight: 400; --sdWeightStyle: italic; --sdTypoFontWeight: 400; --sdTypoFontStyle: italic; --sdTypo2FontFamily: Inter; --sdTypo2FontWeight: 800; --sdTypo2LineHeight: 1.5; - --sdTypo2FontSize: 8px; + --sdTypo2FontSize: 2*4px; --sdTestCompositeFancyCardColor: #fff; --sdTestCompositeFancyCardBorderRadius: 18px; --sdTestCompositeFancyCardBorderColor: #999; --sdTestCompositeCardColor: #fff; --sdTestCompositeCardBorderRadius: 18px; --sdTestCompositeCardBorderColor: #999; --sdTestTypographyFancyTextFontFamily: Arial; --sdTestTypographyFancyTextFontSize: 25px; --sdTestTypographyFancyTextLineHeight: 32px; --sdTestTypographyFancyTextFontWeight: 700; --sdTestTypographyTextFontFamily: Arial; --sdTestTypographyTextFontSize: 25px; --sdTestTypographyTextLineHeight: 32px; --sdTestTypographyTextFontWeight: 700; --sdTypoAliasFontWeight: 400; --sdTypoAliasFontStyle: italic; --sdTypo3FontFamily: Inter; --sdTypo3FontWeight: 800; --sdTypo3LineHeight: 1.5; - --sdTypo3FontSize: 8px; + --sdTypo3FontSize: 2*4px; ❯ test/integration/cross-file-refs.test.ts:80:21
});
});
19 changes: 16 additions & 3 deletions test/integration/expand-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@
start: ':root {',
end: '--sdDeepRef: italic 800 26px/1.25 Arial;',
});
const expectedOutput = `--sdCompositionSize: 24px;
// TODO: Why did this change?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SD v5 refactored quite a few things about composite token expansion, optimizing it a lot. I suspect this is why the order of some tokens has changed because in the old SD we expanded tokens and then flattened, in the new SD we first flatten and then expand.

const _expectedOutput = `--sdCompositionSize: 24px;

Check warning on line 71 in test/integration/expand-composition.test.ts

View workflow job for this annotation

GitHub Actions / Verify changes (18.x)

'_expectedOutput' is assigned a value but never used

Check warning on line 71 in test/integration/expand-composition.test.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

'_expectedOutput' is assigned a value but never used
--sdCompositionOpacity: 0.5;
--sdCompositionFontSize: 96px;
--sdCompositionFontFamily: Roboto;
Expand All @@ -83,6 +84,18 @@
--sdBorder: 4px solid #FFFF00;
--sdShadowSingle: inset 0 4px 10px 0 rgba(0,0,0,0.4);
--sdShadowDouble: inset 0 4px 10px 0 rgba(0,0,0,0.4), 0 8px 12px 5px rgba(0,0,0,0.4);
--sdRef: italic 800 26px/1.25 Arial;`;
const expectedOutput = `--sdCompositionSize: 24px;
--sdCompositionOpacity: 0.5;
--sdCompositionFontSize: 96px;
--sdCompositionFontFamily: Roboto;
--sdCompositionFontWeight: 700;
--sdTypography: italic 800 26px/1.25 Arial;
--sdFontWeightRefWeight: 800;
--sdFontWeightRefStyle: italic;
--sdBorder: 4px solid #FFFF00;
--sdShadowSingle: inset 0 4px 10px 0 rgba(0,0,0,0.4);
--sdShadowDouble: inset 0 4px 10px 0 rgba(0,0,0,0.4), 0 8px 12px 5px rgba(0,0,0,0.4);
--sdRef: italic 800 26px/1.25 Arial;`;
expect(content).toBe(expectedOutput);
});
Expand All @@ -95,6 +108,8 @@
--sdCompositionFontSize: 96px;
--sdCompositionFontFamily: Roboto;
--sdCompositionFontWeight: 700;
--sdFontWeightRefWeight: 800;
--sdFontWeightRefStyle: italic;
--sdCompositionHeaderFontFamily: Roboto;
--sdCompositionHeaderFontSize: 96px;
--sdCompositionHeaderFontWeight: 700;
Expand All @@ -110,8 +125,6 @@
--sdTypographyTextDecoration: none;
--sdTypographyTextCase: none;
--sdTypographyFontStyle: italic;
--sdFontWeightRefWeight: 800;
--sdFontWeightRefStyle: italic;
--sdBorderColor: #ffff00;
--sdBorderWidth: 4px;
--sdBorderStyle: solid;
Expand Down
4 changes: 2 additions & 2 deletions test/integration/sd-transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('sd-transforms smoke tests', () => {
--sdOpacity: 0.25;
--sdSpacingSm: 8px;
--sdSpacingXl: 64px;
--sdSpacingMultiValue: 8px 64px; /* You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens */
--sdSpacingMultiValue: 8px 64px; /** You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens */
--sdColorsBlack: #000000;
--sdColorsWhite: #ffffff;
--sdColorsBlue: #0000ff;
Expand Down Expand Up @@ -111,7 +111,7 @@ describe('sd-transforms smoke tests', () => {
--sd-opacity: 0.25;
--sd-spacing-sm: 8px;
--sd-spacing-xl: 64px;
--sd-spacing-multi-value: 8px 64px; /* You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens */
--sd-spacing-multi-value: 8px 64px; /** You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens */
--sd-colors-black: #000000;
--sd-colors-white: #ffffff;
--sd-colors-blue: #0000ff;
Expand Down
2 changes: 1 addition & 1 deletion test/integration/w3c-spec-compliance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('w3c spec compliance smoke test', () => {
--sdOpacity: 0.25;
--sdSpacingSm: 8px;
--sdSpacingXl: 64px;
--sdSpacingMultiValue: 8px 64px; /* You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-$value-spacing-tokens */
--sdSpacingMultiValue: 8px 64px; /** You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-$value-spacing-tokens */
--sdColorsBlack: #000000;
--sdColorsWhite: #ffffff;
--sdColorsBlue: #0000FF;
Expand Down
Loading
Loading