All files / packages/sds-metadata/src/utils value-resolver.ts

94.91% Statements 56/59
91.3% Branches 42/46
100% Functions 8/8
100% Lines 45/45

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121              2x 2x 2x     3606x 3602x       3600x                                                                         23x   23x           3616x 3616x 3616x   3616x 3584x 3584x 3584x 3583x 3583x     3616x 3720x   3719x 10163x 10163x 10162x   3706x 2x 2x 2x 2x 2x     3704x 3600x 3600x 4x   3596x 3600x 3591x           3591x 3582x 3581x     104x   3598x     3616x 3595x 3595x     23x    
import valueParser from 'postcss-value-parser';
import type { StylingHookMetadata } from '../types.js';
 
type Theme = 'slds' | 'cosmos';
type ParsedNode = ReturnType<typeof valueParser>['nodes'][number];
type FunctionNode = Extract<ParsedNode, { type: 'function' }>;
 
const MAX_FUNCTION_DEPTH = 100;
const MAX_RESOLUTION_WORK = 10_000;
const MAX_RESOLVED_LENGTH = 65_536;
 
function firstArgument(nodes: ParsedNode[]): ParsedNode[] {
  const comma = nodes.findIndex((node) => node.type === 'div' && node.value === ',');
  return nodes.slice(0, comma < 0 ? nodes.length : comma);
}
 
function customProperty(node: FunctionNode): string | undefined {
  return firstArgument(node.nodes).find((child) => child.type === 'word')?.value;
}
 
 
/**
 * Resolve a styling-hook value to its theme-specific "pivot" (concrete) form.
 *
 * High-level flow:
 * 1. Parse `original` as a CSS value (via postcss-value-parser).
 * 2. Walk every function node:
 *    - `light-dark(A, B)` → keep only A (light), then resolve A further.
 *    - `var(--token)` → look up `tokens[--token].values[theme]` and resolve that.
 *    - other functions (e.g. `calc(...)`) → recurse into their arguments.
 * 3. Replace each resolved node in place; if anything fails (missing token, cycle,
 *    depth/work/length limits), return the original string unchanged.
 *
 * Example:
 *   tokens = {
 *     '--slds-r-base':   { values: { slds: '#123456' } },
 *     '--slds-g-middle': { values: { slds: 'var(--slds-r-base)' } },
 *     '--slds-g-top':    { values: { slds: 'var(--slds-g-middle)' } },
 *   }
 *   resolvePivotValue('var(--slds-g-top)', 'slds', tokens)
 *     → var(--slds-g-top)
 *     → var(--slds-g-middle)
 *     → var(--slds-r-base)
 *     → '#123456'
 *
 *   resolvePivotValue('light-dark(var(--slds-r-base), #000)', 'slds', tokens)
 *     → light-dark picks light arg → var(--slds-r-base) → '#123456'
 */
export function resolvePivotValue(
  original: string,
  theme: Theme,
  tokens: Record<string, StylingHookMetadata>,
  maxDepth = 20
): string {
  let remainingWork = MAX_RESOLUTION_WORK;
 
  const resolveExpression = (
    expression: string,
    depth: number,
    visited: Set<string>,
    functionDepth = 0
  ): string | null => {
    Iif (expression.length > MAX_RESOLVED_LENGTH || functionDepth > MAX_FUNCTION_DEPTH) return null;
    const parsed = valueParser(expression);
    let projectedLength = expression.length;
 
    const replaceNode = (nodes: ParsedNode[], index: number, value: string): boolean => {
      const originalLength = valueParser.stringify(nodes[index]).length;
      projectedLength += value.length - originalLength;
      if (projectedLength > MAX_RESOLVED_LENGTH) return false;
      nodes[index] = { type: 'word', value } as ParsedNode;
      return true;
    };
 
    const resolveNodes = (nodes: ParsedNode[], nestedDepth: number): boolean => {
      if (nestedDepth > MAX_FUNCTION_DEPTH) return false;
 
      for (const [index, node] of nodes.entries()) {
        remainingWork -= 1;
        if (remainingWork < 0) return false;
        if (node.type !== 'function') continue;
 
        if (node.value.toLowerCase() === 'light-dark') {
          const lightValue = valueParser.stringify(firstArgument(node.nodes)).trim();
          const resolved = resolveExpression(lightValue, depth, visited, nestedDepth + 1);
          Iif (resolved === null) return false;
          Iif (!replaceNode(nodes, index, resolved)) return false;
          continue;
        }
 
        if (node.value.toLowerCase() === 'var') {
          const property = customProperty(node);
          if (!property || property.startsWith('--lwc-') || depth >= maxDepth || visited.has(property)) {
            return false;
          }
          const value = tokens[property]?.values[theme];
          if (!value) return false;
          const resolved = resolveExpression(
            value,
            depth + 1,
            new Set(visited).add(property),
            nestedDepth + 1
          );
          if (resolved === null) return false;
          if (!replaceNode(nodes, index, resolved)) return false;
          continue;
        }
 
        if (!resolveNodes(node.nodes, nestedDepth + 1)) return false;
      }
      return true;
    };
 
    if (!resolveNodes(parsed.nodes, functionDepth)) return null;
    const resolved = parsed.toString();
    return resolved.length <= MAX_RESOLVED_LENGTH ? resolved : null;
  };
 
  return resolveExpression(original, 0, new Set()) ?? original;
}