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