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

427 lines
14 KiB
TypeScript

import { builtinFunctions, evaluate, type Scope } from "./evaluate.ts";
import {
parseCss,
type CssAtRule,
type CssMixin,
type CssNode,
} from "./parser.ts";
import { createHooks, type MuddleHooks } from "../hooks.ts";
export interface CompileOptions {
/** Variables added to the stylesheet, e.g. from the theme. */
variables?: Record<string, string>;
/** Named viewport breakpoints used by `@responsive`. */
breakpoints?: Record<string, string>;
/** Receives the `@measure` prediction targets of the stylesheet. */
measured?: string[];
/** Contextual STCSS expansions, including optional application-defined hooks. */
hooks?: MuddleHooks;
}
interface Context {
scope: Scope;
mixins: Map<string, CssMixin>;
breakpoints: Record<string, string>;
measured: string[];
hooks: MuddleHooks;
}
/** Compiles a stylesheet into plain css. */
export function compileCss(
source: string,
options: CompileOptions = {},
): string {
const scripted = source.replace(/(?:javascript|typescript)!\s*\n([\s\S]*?)\nend\s*/g, (_match, code: string) => {
for (const entry of code.matchAll(/(?:const|let)\s+([\w-]+)\s*=\s*([^;\n]+);?/g)) {
options.variables ??= {};
options.variables[`$${entry[1]}`] = String(entry[2]).replace(/^['"]|['"]$/g, "");
}
return "";
});
const stylesheet = parseCss(scripted);
const context: Context = {
scope: { ...(options.variables ?? {}) },
mixins: new Map(stylesheet.mixins.map((mixin) => [mixin.name, mixin])),
breakpoints: options.breakpoints ?? {},
measured: options.measured ?? [],
hooks: options.hooks ?? createHooks(),
};
for (const variable of stylesheet.variables) {
context.scope[variable.name] = resolveValue(variable.value, context);
}
const compiled = compileNodes(stylesheet.rules, "", context, 0);
return context.measured.length > 0
? `/* muddle-predict: ${context.measured.join(" | ")} */\n${compiled}`
: compiled;
}
function compileNodes(
nodes: CssNode[],
selector: string,
context: Context,
depth: number,
): string {
const output: string[] = [];
for (const node of nodes) {
if (node.kind === "rule") {
const resolved = resolveSelector(
resolveValue(node.selector, context),
selector,
);
const declarationInput = node.declarations.map((declaration) => ({ ...declaration }));
const expansions = context.hooks.expandStcssDeclarations({
selector: resolved,
declarations: declarationInput,
depth,
resolve: (value) => resolveValue(value, context),
});
const declarations = declarationInput.map((declaration) =>
`${declaration.property}: ${resolveValue(declaration.value, context)};`,
);
const nested = compileNodes(node.children, resolved, context, depth + 1);
const indent = "\t".repeat(depth);
if (resolved.length === 0) {
for (const declaration of declarations) {
output.push(`${indent}${declaration}`);
}
if (expansions.length > 0) output.push(...expansions);
if (nested.length > 0) output.push(nested);
continue;
}
if (declarations.length > 0) {
output.push(`${indent}${resolved} {`);
for (const declaration of declarations) {
output.push(`${indent}\t${declaration}`);
}
output.push(`${indent}}`);
}
if (expansions.length > 0) {
if (declarations.length > 0) output.push("");
output.push(...expansions);
}
if (nested.length > 0) {
if (declarations.length > 0 || expansions.length > 0) output.push("");
output.push(nested);
}
continue;
}
output.push(compileAtRule(node, selector, context, depth));
}
return output.filter((entry) => entry.length > 0).join("\n");
}
function compileAtRule(
node: CssAtRule,
selector: string,
context: Context,
depth: number,
): string {
const indent = "\t".repeat(depth);
const expanded = context.hooks.expandStcssAtRule({
name: node.name,
params: node.params,
selector,
depth,
children: node.children,
resolve: (value) => resolveValue(value, context),
renderChildren: () => compileNodes(node.children, selector, context, depth + 1),
});
if (expanded !== undefined) return expanded;
switch (node.name) {
case "each": {
const match = /^\$([\w-]+)\s+in\s+(.+)$/.exec(node.params);
if (!match) return "";
const name = `$${match[1] as string}`;
const items = splitValues(match[2] as string);
const output: string[] = [];
for (const item of items) {
const scope = { ...context.scope };
scope[name] = resolveValue(item, context);
const nested = compileNodes(
node.children,
selector,
{ ...context, scope },
depth,
);
if (nested.length > 0) output.push(nested);
}
return output.join("\n");
}
case "for": {
const match = /^\$([\w-]+)\s+from\s+([\d.]+)\s+through\s+([\d.]+)$/.exec(
node.params,
);
if (!match) return "";
const name = `$${match[1] as string}`;
const from = Number(match[2]);
const to = Number(match[3]);
const output: string[] = [];
for (let value = from; value <= to; value += 1) {
const scope = { ...context.scope };
scope[name] = String(value);
const nested = compileNodes(
node.children,
selector,
{ ...context, scope },
depth,
);
if (nested.length > 0) output.push(nested);
}
return output.join("\n");
}
case "include": {
const match = /^([\w-]+)\s*(?:\(([\s\S]*)\))?$/.exec(node.params.trim());
if (!match) return "";
const mixin = context.mixins.get(match[1] as string);
if (!mixin) {
throw new Error(`Unknown mixin "${match[1] as string}"`);
}
const arguments_ = splitList(match[2] ?? "");
const scope = { ...context.scope };
mixin.params.forEach((param, index) => {
const value = arguments_[index] ?? param.value;
if (value !== undefined) {
scope[param.name] = resolveValue(value, context);
}
});
return compileNodes(
mixin.children,
selector,
{ ...context, scope },
depth,
);
}
case "view-transition": {
const name = node.params.trim() || "none";
return selector ? `${indent}${selector} {\n${indent}\tview-transition-name: ${name};\n${indent}}` : "";
}
case "predict": {
const mobile = /\bmobile\b/.test(node.params);
const properties = node.params.replace(/\bmobile\b/g, "").trim() || "transform, max-width, max-height";
const transitions = properties.split(/[\s,]+/).filter(Boolean).map((property) => `${property} var(--muddle-predict-duration, 280ms) var(--muddle-predict-easing, ease)`).join(", ");
const css = selector ? `${indent}${selector} {\n${indent}\ttransition: ${transitions};\n${indent}}` : "";
return mobile ? `@media (max-width: 48rem) {\n${css}\n}` : css;
}
case "measure": {
const spec = node.params.trim();
const candidates = spec.length > 0 ? splitList(spec) : [selector];
for (const candidate of candidates) {
if (candidate.length === 0) continue;
const resolved = resolveValue(candidate, context);
const related = resolveRelationSelector(resolved).replace(/@([\w.-]+)/g, (_all, name: string) => `[data-muddle-anchor="${name}"]`);
context.measured.push(related);
}
return "";
}
case "media":
case "supports": {
const nested = compileNodes(node.children, selector, context, depth + 1);
if (nested.length === 0) return "";
return `${indent}@${node.name} ${node.params} {\n${nested}\n${indent}}`;
}
case "responsive": {
const width = context.breakpoints[node.params.trim()];
if (!width) throw new Error(`Unknown responsive breakpoint "${node.params.trim()}"`);
const nested = compileNodes(node.children, selector, context, depth + 1);
return nested ? `${indent}@media (min-width: ${width}) {\n${nested}\n${indent}}` : "";
}
default:
return "";
}
}
/** Resolves the selector of a nested rule. */
export function resolveSelector(selector: string, parent: string): string {
selector = resolveRelationSelector(selector);
const parts = selector
.split(",")
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => {
if (part.includes("&")) {
return part.replace(/&/g, parent);
}
return parent ? `${parent} ${part}` : part;
});
return parts.join(", ");
}
/** Resolves the variables, the interpolations and the functions of a value. */
export function resolveValue(value: string, context: Context): string {
let result = value;
result = result.replace(/#\{([^}]*)\}/g, (_match, expression: string) => {
return evaluate(expression, { scope: context.scope });
});
result = result.replace(/d(["'])(.*?)\1/g, (_match, quote: string, name: string) => {
const resolved = name.replace(/\{([^}]*)\}/g, (_entry: string, expression: string) => evaluate(expression, { scope: context.scope }));
return `d${quote}${resolved}${quote}`;
});
result = result.replace(/\$[\w-]+/g, (match) => {
const resolved = context.scope[match];
return resolved ?? match;
});
result = replaceFunctions(result, context);
result = resolveRelationValue(result);
return result.replace(/\s+/g, " ").trim();
}
function relationKey(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
}
function relationState(name: string, state: string): string {
const anchor = `[data-muddle-anchor="${name}"]`;
if (state === "clicked") return `${anchor}:active`;
if (state === "active") return `${anchor}:active`;
if (state === "focus") return `${anchor}:focus-visible`;
return `${anchor}[data-muddle-${state}]`;
}
/** Turns relation pseudo selectors into native relational selectors. */
function resolveRelationSelector(selector: string): string {
let result = selector;
result = result.replace(/:reflect\(\s*@([\w.-]+)\s*\)/g, (_all, group: string) =>
`[data-muddle-anchor^="${group}."]`,
);
result = result.replace(/:with\(\s*@([\w.-]+):([\w-]+)\s*\)/g, (_all, name: string, state: string) =>
`:has(${relationState(name, state)})`,
);
result = result.replace(/([^,{]+?):when\(\s*([^\s()]+)\s+@([\w.-]+):([\w-]+)\s*\)/g, (_all, target: string, root: string, name: string, state: string) => {
const normalized = target.trim();
const suffix = normalized.startsWith(root)
? normalized.slice(root.length).trim()
: normalized;
return `${root}:has(${relationState(name, state)})${suffix ? ` ${suffix}` : ""}`;
});
return result.replace(/@([\w.-]+):(active|clicked|focus)\b/g, (_all, name: string, state: string) => relationState(name, state));
}
/** Resolves precomputed anchor metrics and live computed-style mirrors. */
function resolveRelationValue(value: string): string {
const self = value.replace(/\$self\[([a-zA-Z][\w-]*)\]/g, (_all, property: string) => {
return `var(--muddle-self-${relationKey(property)})`;
});
let result = self.replace(/\$(?:relate|refer)(?:\[([\w-]+)\])?\(\s*d?["']?@([^\s)"']+)["']?\s*\)/g, (_all, field: string | undefined, name: string) => {
const metric = ({ left: "x", top: "y" } as Record<string, string>)[field ?? ""] ?? field ?? "x";
return `var(--muddle-relate-${relationKey(name)}-${metric})`;
});
result = result.replace(/@([A-Za-z_][\w.-]*)\.computed\.([\w-]+)(?:\.replicate\(&\))?/g, (_all, name: string, property: string) => {
const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
return `var(--muddle-relate-${relationKey(name)}-${cssProperty})`;
});
result = result.replace(/@([A-Za-z_][\w-]*)\.([\w-]+)(?:\.replicate\(&\))?/g, (_all, name: string, property: string) => {
const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
return `var(--muddle-relate-${relationKey(name)}-${cssProperty})`;
});
return result;
}
function replaceFunctions(value: string, context: Context): string {
let result = value;
let index = result.indexOf("(");
while (index !== -1) {
const nameMatch = /(\$?[\w-]+)\s*$/.exec(result.slice(0, index));
if (!nameMatch) {
index = result.indexOf("(", index + 1);
continue;
}
const name = nameMatch[1] as string;
const depth = matchParens(result, index);
if (depth === -1) break;
const inner = result.slice(index + 1, depth);
if (name.startsWith("$")) {
const expandedCalculation = context.hooks.expandStcssCalculation(name.slice(1), inner, (entry) => resolveValue(entry, context));
if (expandedCalculation !== undefined) {
result = `${result.slice(0, index - name.length)}${expandedCalculation}${result.slice(depth + 1)}`;
index = result.indexOf("(", index + 1);
continue;
}
}
const expanded = context.hooks.expandStcssValue(name, inner, (value) => resolveValue(value, context));
if (expanded !== undefined) {
result = `${result.slice(0, index - name.length)}${expanded}${result.slice(depth + 1)}`;
index = result.indexOf("(", index + 1);
continue;
}
if (!builtinFunctions[name]) {
// unknown functions are css functions, they stay untouched
index = result.indexOf("(", index + 1);
continue;
}
const evaluated = evaluate(`${name}(${inner})`, { scope: context.scope });
result = `${result.slice(0, index - name.length)}${evaluated}${result.slice(depth + 1)}`;
index = result.indexOf("(", index + 1);
}
return context.hooks.expandStcssCalculationTokens(result, (entry) => resolveValue(entry, context));
}
function matchParens(source: string, start: number): number {
let depth = 0;
for (let index = start; index < source.length; index++) {
const char = source[index];
if (char === "(") depth += 1;
if (char === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
/**
* Splits a list of values.
*
* The values are separated by commas or by spaces, e.g. `1 2 3` and
* `red, green, blue`.
*/
function splitValues(value: string): string[] {
const range = /^\[\s*(-?\d+)\s*\.\.\s*(-?\d+)\s*\]$/.exec(value.trim());
if (range) {
const from = Number(range[1]);
const to = Number(range[2]);
const step = from <= to ? 1 : -1;
const values: string[] = [];
for (let current = from; step > 0 ? current <= to : current >= to; current += step) values.push(String(current));
return values;
}
const items: string[] = [];
for (const part of splitList(value)) {
for (const entry of part.split(/\s+/)) {
if (entry.length > 0) items.push(entry);
}
}
return items;
}
function splitList(value: string): string[] {
const items: string[] = [];
let depth = 0;
let current = "";
for (const char of value) {
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (char === "," && depth === 0) {
items.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim().length > 0) items.push(current.trim());
return items;
}