UNPKG

@tokens-studio/sd-transforms

Version:

Custom transforms for Style-Dictionary, to work with Design Tokens that are exported from Tokens Studio

196 lines (195 loc) 8.25 kB
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 = ['+', '-', '*', '/']; const parser = new Parser(); function checkIfInsideGroup(expr, fullExpr) { // make sure all regex-specific characters are escaped by backslashes const exprEscaped = expr.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); // Reg which checks whether the sub expression is fitted inside of a group ( ) in the full expression const reg = new RegExp(`\\(.*?${exprEscaped}.*?\\)`, 'g'); return !!fullExpr.match(reg) || !!expr.match(/\(/g); // <-- latter is needed because an expr piece might be including the opening '(' character } /** * Checks expressions like: 8 / 4 * 7px 8 * 5px 2 * 4px * and divides them into 3 single values: * ['8 / 4 * 7px', '8 * 5px', '2 * 4px'] * * It splits everything by " " spaces, then checks in which places * there is a space but with no math operater left or right of it, * then determines this must mean it's a multi-value separator */ function splitMultiIntoSingleValues(expr) { const tokens = expr.split(' '); // indexes in the string at which a space separator exists that is a multi-value space separator const indexes = []; let skipNextIteration = false; tokens.forEach((tok, i) => { const left = i > 0 ? tokens[i - 1] : ''; const right = tokens[i + 1] ?? ''; // 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 tokens.length <= 1, // expr is valid if it's a simple 1 token expression Boolean(tok.match(/\)$/) && mathChars.includes(right)), // end of group ), right is math char checkIfInsideGroup(tok, expr), // exprs that aren't math expressions are okay within ( ) groups ]; if (conditions.every(cond => !cond)) { if (!skipNextIteration) { indexes.push(i); // if the current token itself does not also contain a math character // 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))) { skipNextIteration = true; } } else { skipNextIteration = false; } } }); if (indexes.length > 0) { indexes.push(tokens.length); const exprArr = []; let currIndex = 0; indexes.forEach(i => { const singleValue = tokens.slice(currIndex, i + 1).join(' '); if (singleValue) { exprArr.push(singleValue); } currIndex = i + 1; }); return exprArr; } return [expr]; } export function parseAndReduce(expr, fractionDigits = defaultFractionDigits) { let result = expr; // Check if expression is already a number if (!isNaN(Number(result))) { 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; let matchArr; const foundUnits = new Set(); while ((matchArr = unitRegex.exec(noPixExpr)) !== null) { if (matchArr?.groups) { foundUnits.add(matchArr.groups.unit); } } // multiple units (besides px) found, cannot parse the expression if (foundUnits.size > 1) { return result; } const resultUnit = Array.from(foundUnits)[0] ?? (hasPx ? 'px' : ''); if (!isNaN(Number(noPixExpr))) { result = Number(noPixExpr); } if (typeof result !== 'number') { // Try to evaluate as expr-eval expression let evaluated; try { evaluated = parser.evaluate(`${noPixExpr}`); if (typeof evaluated === 'number') { result = evaluated; } } catch (ex) { // no-op } } 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; } // 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; return result; } export function checkAndEvaluateMath(token, fractionDigits) { const expr = token.$value ?? token.value; const type = token.$type ?? token.type; if (!['string', 'object'].includes(typeof expr)) { return expr; } const resolveMath = (expr) => { if (typeof expr !== 'string') { return expr; } const exprs = splitMultiIntoSingleValues(expr); const reducedExprs = exprs.map(_expr => parseAndReduce(_expr, fractionDigits)); if (reducedExprs.length === 1) { return reducedExprs[0]; } return reducedExprs.join(' '); }; const transformProp = (val, prop) => { 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; // 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; const transformShadow = (shadowVal) => { // 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; }