-
Notifications
You must be signed in to change notification settings - Fork 37
feat: New checkAndEvaluateMath function that prevents unit mixing #357
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fd1a798
d2bc2c1
1d7f09f
06e4938
1ffe794
10033b6
f2eeb9a
e305740
7abe34e
e6d1b21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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(); | ||
|
|
||
|
|
@@ -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 | ||
| 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 | ||
| ]; | ||
|
|
||
|
|
@@ -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 { | ||
|
|
@@ -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; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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, | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,7 +67,8 @@ | |
| start: ':root {', | ||
| end: '--sdDeepRef: italic 800 26px/1.25 Arial;', | ||
| }); | ||
| const expectedOutput = `--sdCompositionSize: 24px; | ||
| // TODO: Why did this change? | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
| --sdCompositionOpacity: 0.5; | ||
| --sdCompositionFontSize: 96px; | ||
| --sdCompositionFontFamily: Roboto; | ||
|
|
@@ -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); | ||
| }); | ||
|
|
@@ -95,6 +108,8 @@ | |
| --sdCompositionFontSize: 96px; | ||
| --sdCompositionFontFamily: Roboto; | ||
| --sdCompositionFontWeight: 700; | ||
| --sdFontWeightRefWeight: 800; | ||
| --sdFontWeightRefStyle: italic; | ||
| --sdCompositionHeaderFontFamily: Roboto; | ||
| --sdCompositionHeaderFontSize: 96px; | ||
| --sdCompositionHeaderFontWeight: 700; | ||
|
|
@@ -110,8 +125,6 @@ | |
| --sdTypographyTextDecoration: none; | ||
| --sdTypographyTextCase: none; | ||
| --sdTypographyFontStyle: italic; | ||
| --sdFontWeightRefWeight: 800; | ||
| --sdFontWeightRefStyle: italic; | ||
| --sdBorderColor: #ffff00; | ||
| --sdBorderWidth: 4px; | ||
| --sdBorderStyle: solid; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice optimization 👍🏻