509 lines
15 KiB
TypeScript
509 lines
15 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;
|
|
}
|