import type { CssDeclaration, CssNode } from "./css/parser.ts"; export interface SpugHookContext { html: string; path: string; locals: Record; } export type SpugExpansion = ( context: SpugHookContext, ) => string | Promise; 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(); private readonly valueExpansions = new Map(); private readonly calculationExpansions = new Map< string, StcssCalculationExpansion >(); private readonly declarationExpansions: StcssDeclarationExpansion[] = []; private readonly atRuleExpansions = new Map(); 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 { 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, ): 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; }