muddle/views/css/evaluate.ts
2026-10-23 08:53:07 -04:00

318 lines
8.7 KiB
TypeScript

/**
* 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<string, string>;
export interface EvaluateOptions {
/** Variables of the current scope. */
scope?: Scope;
/** Names of the mixins that can be called. */
functions?: Record<string, (...args: string[]) => 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, (...args: string[]) => 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();
}