246 lines
9.5 KiB
TypeScript
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;
|
|
}
|