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; /** Named viewport breakpoints used by `@responsive`. */ breakpoints?: Record; /** 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; breakpoints: Record; 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)[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; }