/** * Expression evaluation of the structured css. * * The evaluator understands numbers, strings, variables, arithmetic and a * small set of colour functions. It is used by the interpolations * (`#{$gap * 2}`) and by the functions of the stylesheets. */ export type Scope = Record; export interface EvaluateOptions { /** Variables of the current scope. */ scope?: Scope; /** Names of the mixins that can be called. */ functions?: Record string>; } type Token = | { type: "number"; value: number } | { type: "string"; value: string } | { type: "variable"; value: string } | { type: "operator"; value: string } | { type: "function"; value: string } | { type: "punctuation"; value: string }; const OPERATORS = ["+", "-", "*", "/", "(", ")", ","]; function tokenize(source: string): Token[] { const tokens: Token[] = []; let index = 0; while (index < source.length) { const char = source[index] as string; if (/\s/.test(char)) { index += 1; continue; } if (char === "$") { const match = /^\$[\w-]+/.exec(source.slice(index)); if (!match) throw new Error(`Invalid variable in "${source}"`); tokens.push({ type: "variable", value: match[0] }); index += match[0].length; continue; } if (char === "#") { const match = /^#[\da-fA-F]{3,8}/.exec(source.slice(index)); if (match) { tokens.push({ type: "string", value: match[0] }); index += match[0].length; continue; } } if (/[\d.]/.test(char)) { const match = /^[\d.]+/.exec(source.slice(index)); if (!match) throw new Error(`Invalid number in "${source}"`); tokens.push({ type: "number", value: Number(match[0]) }); index += match[0].length; continue; } if (/[a-zA-Z_]/.test(char)) { const match = /^[a-zA-Z_][\w-]*/.exec(source.slice(index)); if (!match) throw new Error(`Invalid identifier in "${source}"`); const word = match[0]; let cursor = index + word.length; while (cursor < source.length && /\s/.test(source[cursor] as string)) { cursor += 1; } if (source[cursor] === "(") { tokens.push({ type: "function", value: word }); index = cursor; continue; } tokens.push({ type: "string", value: word }); index += word.length; continue; } if (char === '"' || char === "'") { const end = source.indexOf(char, index + 1); const value = end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); tokens.push({ type: "string", value }); index = end === -1 ? source.length : end + 1; continue; } const operator = OPERATORS.find((candidate) => source.startsWith(candidate, index), ); if (operator) { tokens.push({ type: operator === "(" || operator === ")" || operator === "," ? "punctuation" : "operator", value: operator, }); index += operator.length; continue; } throw new Error(`Unexpected character "${char}" in "${source}"`); } return tokens; } class Parser { private index = 0; constructor( private readonly tokens: Token[], private readonly options: EvaluateOptions, ) {} parse(): string { const value = this.expression(); if (this.index < this.tokens.length) { throw new Error("Unexpected token in expression"); } return value; } private peek(): Token | undefined { return this.tokens[this.index]; } private expression(): string { let left = this.term(); while (this.peek()?.type === "operator") { const operator = (this.peek() as Token).value; if (operator !== "+" && operator !== "-") break; this.index += 1; const right = this.term(); left = operator === "+" ? add(left, right) : subtract(left, right); } return left; } private term(): string { let left = this.factor(); while (this.peek()?.type === "operator") { const operator = (this.peek() as Token).value; if (operator !== "*" && operator !== "/") break; this.index += 1; const right = this.factor(); left = operator === "*" ? multiply(left, right) : divide(left, right); } return left; } private factor(): string { const token = this.peek(); if (!token) throw new Error("Unexpected end of expression"); this.index += 1; if (token.type === "number") return String(token.value); if (token.type === "string") { if (token.value.startsWith("#")) return token.value; return token.value; } if (token.type === "variable") { const value = this.options.scope?.[token.value]; if (value === undefined) { throw new Error(`Unknown variable ${token.value}`); } return value; } if (token.type === "punctuation" && token.value === "(") { const value = this.expression(); const closing = this.peek(); if (closing?.type === "punctuation" && closing.value === ")") { this.index += 1; } return value; } if (token.type === "function") { const args: string[] = []; const opening = this.peek(); if (opening?.type === "punctuation" && opening.value === "(") { this.index += 1; while (this.index < this.tokens.length) { const current = this.peek() as Token; if (current.type === "punctuation" && current.value === ")") { this.index += 1; break; } if (current.type === "punctuation" && current.value === ",") { this.index += 1; continue; } args.push(this.expression()); } } const fn = this.options.functions?.[token.value]; if (!fn) throw new Error(`Unknown function ${token.value}`); return fn(...args); } throw new Error(`Unexpected token ${token.value}`); } } function toNumber(value: string): number { const numeric = Number(value); return Number.isNaN(numeric) ? 0 : numeric; } function add(left: string, right: string): string { if (left.startsWith("#") || right.startsWith("#")) { return `${left}${right}`; } const result = toNumber(left) + toNumber(right); return Number.isInteger(result) ? String(result) : String(result); } function subtract(left: string, right: string): string { const result = toNumber(left) - toNumber(right); return Number.isInteger(result) ? String(result) : String(result); } function multiply(left: string, right: string): string { const result = toNumber(left) * toNumber(right); return Number.isInteger(result) ? String(result) : String(result); } function divide(left: string, right: string): string { const result = toNumber(left) / toNumber(right); return Number.isInteger(result) ? String(result) : String(result); } /** Parses a colour into its channels. */ export function parseColor(value: string): | { r: number; g: number; b: number; } | undefined { const hex = value.replace("#", ""); if (hex.length !== 3 && hex.length !== 6) return undefined; const expanded = hex.length === 3 ? hex .split("") .map((char) => char + char) .join("") : hex; return { r: Number.parseInt(expanded.slice(0, 2), 16), g: Number.parseInt(expanded.slice(2, 4), 16), b: Number.parseInt(expanded.slice(4, 6), 16), }; } function toHex(value: number): string { return Math.max(0, Math.min(255, Math.round(value))) .toString(16) .padStart(2, "0"); } /** Darkens a colour by the given percentage. */ export function darken(color: string, amount: number): string { const parsed = parseColor(color); if (!parsed) return color; const factor = 1 - amount / 100; return `#${toHex(parsed.r * factor)}${toHex(parsed.g * factor)}${toHex(parsed.b * factor)}`; } /** Lightens a colour by the given percentage. */ export function lighten(color: string, amount: number): string { const parsed = parseColor(color); if (!parsed) return color; const factor = 1 + amount / 100; return `#${toHex(parsed.r * factor)}${toHex(parsed.g * factor)}${toHex(parsed.b * factor)}`; } /** Builds a rgba colour. */ export function rgba(color: string, alpha: number): string { const parsed = parseColor(color); if (!parsed) return color; return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`; } /** The functions available inside a stylesheet. */ export const builtinFunctions: Record string> = { darken: (...args) => String(args[0]).startsWith("#") ? darken(String(args[0]), Number(args[1])) : `darken(${args.join(", ")})`, lighten: (...args) => String(args[0]).startsWith("#") ? lighten(String(args[0]), Number(args[1])) : `lighten(${args.join(", ")})`, rgba: (...args) => String(args[0]).startsWith("#") ? rgba(String(args[0]), Number(args[1])) : `rgba(${args.join(", ")})`, }; /** Evaluates an expression of a stylesheet. */ export function evaluate( source: string, options: EvaluateOptions = {}, ): string { const trimmed = source.trim(); if (trimmed.length === 0) return ""; const parser = new Parser(tokenize(trimmed), { scope: options.scope ?? {}, functions: { ...builtinFunctions, ...(options.functions ?? {}) }, }); return parser.parse(); }