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

246 lines
9.5 KiB
TypeScript

import type { CssDeclaration, CssNode } from "./css/parser.ts";
export interface SpugHookContext {
html: string;
path: string;
locals: Record<string, unknown>;
}
export type SpugExpansion = (context: SpugHookContext) => string | Promise<string>;
export interface StcssValueHookContext {
name: string;
inner: string;
resolve(value: string): string;
}
export type StcssValueExpansion = (context: StcssValueHookContext) => string | undefined;
export interface StcssCalculationHookContext {
name: string;
args?: string;
resolve(value: string): string;
}
export type StcssCalculationExpansion = (context: StcssCalculationHookContext) => string | undefined;
export interface StcssDeclarationHookContext {
selector: string;
declaration: CssDeclaration;
declarations: CssDeclaration[];
depth: number;
resolve(value: string): string;
}
export type StcssDeclarationExpansion = (context: StcssDeclarationHookContext) => string[] | undefined;
export interface StcssAtRuleHookContext {
name: string;
params: string;
selector: string;
depth: number;
children: CssNode[];
resolve(value: string): string;
renderChildren(): string;
}
export type StcssAtRuleExpansion = (context: StcssAtRuleHookContext) => string | undefined;
/**
* A contextual registry for transformations performed while Spug renders or
* STCSS compiles. Registrations are scoped to an explicit registry instance,
* so applications can opt into a transformation without changing global state.
*/
export class MuddleHooks {
private readonly spugExpansions = new Map<string, SpugExpansion>();
private readonly valueExpansions = new Map<string, StcssValueExpansion>();
private readonly calculationExpansions = new Map<string, StcssCalculationExpansion>();
private readonly declarationExpansions: StcssDeclarationExpansion[] = [];
private readonly atRuleExpansions = new Map<string, StcssAtRuleExpansion>();
spug(name: string, expansion: SpugExpansion): this {
this.spugExpansions.set(name, expansion);
return this;
}
stcssValue(name: string, expansion: StcssValueExpansion): this {
this.valueExpansions.set(name, expansion);
return this;
}
stcssCalculation(name: string, expansion: StcssCalculationExpansion): this {
this.calculationExpansions.set(name.replace(/^\$/, ""), expansion);
return this;
}
stcssDeclaration(expansion: StcssDeclarationExpansion): this {
this.declarationExpansions.push(expansion);
return this;
}
stcssAtRule(name: string, expansion: StcssAtRuleExpansion): this {
this.atRuleExpansions.set(name, expansion);
return this;
}
async renderSpug(context: SpugHookContext): Promise<string> {
let html = context.html;
for (const expansion of this.spugExpansions.values()) {
html = await expansion({ ...context, html });
}
return html;
}
expandStcssValue(name: string, inner: string, resolve: (value: string) => string): string | undefined {
return this.valueExpansions.get(name)?.({ name, inner, resolve });
}
expandStcssCalculation(name: string, args: string | undefined, resolve: (value: string) => string): string | undefined {
return this.calculationExpansions.get(name.replace(/^\$/, ""))?.({ name, args, resolve });
}
expandStcssCalculationTokens(value: string, resolve: (value: string) => string): string {
return value.replace(/\$([A-Za-z][\w-]*)\b(?!\s*\()/g, (token, name: string) =>
this.expandStcssCalculation(name, undefined, resolve) ?? token);
}
expandStcssDeclarations(context: Omit<StcssDeclarationHookContext, "declaration">): string[] {
return context.declarations.flatMap((declaration) => this.declarationExpansions.flatMap((expansion) =>
expansion({ ...context, declaration }) ?? []));
}
expandStcssAtRule(context: StcssAtRuleHookContext): string | undefined {
return this.atRuleExpansions.get(context.name)?.(context);
}
}
export interface HookBuilder {
spug(name: string, expansion: SpugExpansion): void;
stcss: {
value(name: string, expansion: StcssValueExpansion): void;
calculation(name: string, expansion: StcssCalculationExpansion): void;
declaration(expansion: StcssDeclarationExpansion): void;
atRule(name: string, expansion: StcssAtRuleExpansion): void;
};
}
/** Creates an application-local hook registry through contextual builders. */
export function defineHooks(build: (hooks: HookBuilder) => void, defaults = true): MuddleHooks {
const hooks = createHooks(defaults);
build({
spug: (name, expansion) => { hooks.spug(name, expansion); },
stcss: {
value: (name, expansion) => { hooks.stcssValue(name, expansion); },
calculation: (name, expansion) => { hooks.stcssCalculation(name, expansion); },
declaration: (expansion) => { hooks.stcssDeclaration(expansion); },
atRule: (name, expansion) => { hooks.stcssAtRule(name, expansion); },
},
});
return hooks;
}
export function createHooks(defaults = true): MuddleHooks {
const hooks = new MuddleHooks();
if (defaults) registerBuiltinStcssExpansions(hooks);
return hooks;
}
function registerBuiltinStcssExpansions(hooks: MuddleHooks): void {
hooks.stcssCalculation("center", ({ args, resolve }) => {
const size = args?.trim();
return size ? `calc(50% - (${resolve(size)} / 2))` : "50%";
});
hooks.stcssCalculation("offset", ({ args, resolve }) => {
const values = splitList(args ?? "").map((value) => resolve(value.trim())).filter(Boolean);
if (values.length === 0) return "0px";
return values.length === 1 ? values[0] : `calc(${values.join(" + ")})`;
});
hooks.stcssCalculation("pad", ({ args, resolve }) => {
const multiplier = args?.trim();
return multiplier ? `calc(var(--muddle-pad, 1rem) * ${resolve(multiplier)})` : "var(--muddle-pad, 1rem)";
});
hooks.stcssValue("freeform-gradient", ({ inner, resolve }) => {
return splitList(resolve(inner))
.filter((item) => item.trim().length > 0)
.map((item) => {
const tokens = gradientTokens(item.trim());
if (tokens.length !== 3 && tokens.length !== 4) throw new Error(`Invalid freeform-gradient item "${item.trim()}"`);
const [color, x, y, radius] = tokens as [string, string, string, string?];
return radius !== undefined
? `radial-gradient(circle at ${x} ${y}, ${color}, ${color} calc(${radius} * 0.97), transparent ${radius})`
: `radial-gradient(circle farthest-side at ${x} ${y}, ${color}, transparent)`;
})
.join(", ");
});
hooks.stcssDeclaration(({ selector, declaration, declarations, depth, resolve }) => {
if (declaration.property !== "border-radius" || !resolve(declaration.value).startsWith("concentric") || !selector) return undefined;
const outer = resolve(declaration.value).slice("concentric".length).trim() || "var(--concentric-radius, 1rem)";
declaration.value = outer;
const spacing = concentricSpacing(declarations, resolve);
const childRadius = spacing.length > 0 ? `calc(${[outer, ...spacing].map((token) => `(${token})`).join(" - ")})` : "inherit";
const indent = "\t".repeat(depth);
return [`${indent}${selector} > * {\n${indent}\tborder-radius: ${childRadius};\n${indent}}`];
});
hooks.stcssDeclaration(({ declaration, declarations }) => {
if (declaration.property !== "position" || (declaration.value !== "fixed-center" && declaration.value !== "page-center")) return undefined;
declaration.value = declaration.value === "fixed-center" ? "fixed" : "absolute";
const set = (property: string, value: string) => {
const current = declarations.find((entry) => entry.property === property);
if (current) current.value = value;
else declarations.push({ property, value });
};
set("left", "50%");
set("top", "50%");
const transform = declarations.find((entry) => entry.property === "transform");
if (transform) transform.value = `translate(-50%, -50%) ${transform.value}`;
else declarations.push({ property: "transform", value: "translate(-50%, -50%)" });
return undefined;
});
hooks.stcssAtRule("concentric", ({ params, selector, depth, resolve }) => {
if (!selector) return "";
const indent = "\t".repeat(depth);
const radius = resolve(params.trim() || "1rem");
return `${indent}${selector} {\n${indent}\tborder-radius: ${radius};\n${indent}}\n${indent}${selector} > * {\n${indent}\tborder-radius: inherit;\n${indent}}`;
});
}
function concentricSpacing(declarations: CssDeclaration[], resolve: (value: string) => string): string[] {
const find = (property: string): string | undefined => declarations
.filter((declaration) => declaration.property === property || declaration.property === `${property}-top` || declaration.property === `${property}-left` || declaration.property === `${property}-block-start` || declaration.property === `${property}-inline-start`)
.flatMap((declaration) => gradientTokens(resolve(declaration.value).trim()))
.find((value) => value.length > 0 && value !== "solid" && value !== "none" && value !== "0");
const spacing = [find("padding"), find("border-width") ?? find("border")].filter((value): value is string => value !== undefined);
return spacing;
}
function gradientTokens(item: string): string[] {
const tokens: string[] = [];
let current = "";
let depth = 0;
for (const char of item) {
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (/\s/.test(char) && depth === 0) {
if (current) tokens.push(current);
current = "";
} else current += char;
}
if (current) tokens.push(current);
return tokens;
}
function splitList(value: string): string[] {
const items: string[] = [];
let current = "";
let depth = 0;
for (const char of value) {
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (char === "," && depth === 0) {
items.push(current);
current = "";
} else current += char;
}
items.push(current);
return items;
}