427 lines
14 KiB
TypeScript
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;
|
|
}
|