muddle/views/hooks.ts
2026-10-23 09:12:49 -04:00

353 lines
9.9 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;
}