muddle/modules/amezing/index.ts
2026-10-23 08:53:07 -04:00

161 lines
6.6 KiB
TypeScript

/**
* Muddle integration of the Ametrine theme.
*
* Registers the theme's component set, exposes the compiled theme css and
* serves the vendored behavior scripts / self-hosted fonts.
*/
import { fileURLToPath } from "node:url";
import { readFile } from "node:fs/promises";
import type {
Integration,
Mounts,
} from "muddle";
import type { Views } from "muddle/views";
export interface AmezingAccentPreset { name: string; light: string; dark: string }
/** Accent presets from ametrine/data/accent_presets.toml. */
export const ACCENT_PRESETS: Record<string, AmezingAccentPreset> = {
red: { name: "red", light: "hsl(342.0779, 72%, 58%)", dark: "hsl(345.18988, 100%, 69%)" },
orange: { name: "orange", light: "hsl(15.771428, 74%, 54%)", dark: "hsl(19.731544, 96%, 70%)" },
yellow: { name: "yellow", light: "hsl(34.639175, 91%, 42%)", dark: "hsl(44.70588, 100%, 70%)" },
green: { name: "green", light: "hsl(153.7815, 61%, 38%)", dark: "hsl(90, 59%, 66%)" },
blue: { name: "blue", light: "hsl(192, 71%, 38%)", dark: "hsl(186.42857, 71%, 69%)" },
purple: { name: "purple", light: "hsl(254.11766, 44%, 55%)", dark: "hsl(249.88234, 77%, 78%)" },
teal: { name: "teal", light: "hsl(180, 65%, 35%)", dark: "hsl(183, 85%, 65%)" },
pink: { name: "pink", light: "hsl(333.88236, 69%, 58%)", dark: "hsl(336.78634, 100%, 70%)" },
cyan: { name: "cyan", light: "hsl(195, 71%, 35%)", dark: "hsl(193.41046, 72%, 70%)" },
indigo: { name: "indigo", light: "hsl(232.05884, 61%, 42%)", dark: "hsl(234.61538, 73%, 71%)" },
amber: { name: "amber", light: "hsl(38.57695, 94%, 44%)", dark: "hsl(40.35484, 100%, 72%)" },
ochrea: { name: "ochrea", light: "hsl(45.75298, 95%, 45%)", dark: "hsl(46.09091, 100%, 78%)" },
lime: { name: "lime", light: "hsl(86.25406, 60%, 42%)", dark: "hsl(86.84211, 93%, 71%)" },
maroon: { name: "maroon", light: "hsl(321.81819, 26%, 41%)", dark: "hsl(322.10526, 66%, 66%)" },
navy: { name: "navy", light: "hsl(216, 44%, 38%)", dark: "hsl(218, 69%, 69%)" },
olive: { name: "olive", light: "hsl(81, 36%, 41%)", dark: "hsl(81, 64%, 65%)" },
slate: { name: "slate", light: "hsl(212.53197, 15%, 44%)", dark: "hsl(212.90323, 61%, 72%)" },
};
function parseHsl(value: string): [string, string, string] | undefined {
const match = /hsl\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*\)/.exec(value);
return match ? [match[1]!, match[2]!, match[3]!] : undefined;
}
/** Builds the inline accent variables used by the theme, mirrors components/accent_color.html. */
export function accentStyle(accent: string | [string, string?]): string {
const inputs = Array.isArray(accent) ? accent : [accent];
const light = ACCENT_PRESETS[inputs[0] ?? ""]?.light ?? inputs[0];
const dark = ACCENT_PRESETS[inputs[1] ?? inputs[0] ?? ""]?.dark ?? inputs[1] ?? ACCENT_PRESETS[inputs[0] ?? ""]?.dark ?? inputs[0];
const lightHsl = parseHsl(light ?? "");
const darkHsl = parseHsl(dark ?? "");
if (!lightHsl || !darkHsl) throw new Error(`Unknown accent color: ${accent}`);
return `--accent-light-h:${lightHsl[0]};--accent-light-s:${lightHsl[1]};--accent-light-l:${lightHsl[2]};--accent-dark-h:${darkHsl[0]};--accent-dark-s:${darkHsl[1]};--accent-dark-l:${darkHsl[2]};`;
}
const COMPONENTS = [
"base",
"article",
"article-list",
"article-details",
"article-nav",
"paginator",
"alert",
"banner",
"icon",
"search-modal",
"tag-chips",
"audio",
"image",
"video",
"vimeo",
"youtube",
"mastodon",
"link",
"overflow-auto",
"crt",
"emoji",
"statement",
"comments",
"footnotes",
"skip-to-content",
"marquee",
"audio-button",
"deanchor",
"fuse",
"search-fuse",
] as const;
function componentTemplates(): [string, string][] {
return COMPONENTS.map((name) => [`amezing-${name}`, fileURLToPath(new URL(`./components/${name}.spug`, import.meta.url))]);
}
interface TemplateViews {
component(name: string, component: unknown): unknown;
engine?: { renderComponent(path: string, props: Record<string, unknown>, context: { locals: Record<string, unknown>; params: Record<string, string> }): Promise<string> };
}
function registerAll(views: TemplateViews, accent: string | undefined): void {
for (const [name, template] of componentTemplates()) {
views.component(name, {
async render(props: Record<string, unknown>, context: { locals: Record<string, unknown>; params: Record<string, string> }) {
return views.engine!.renderComponent(template, { accentStyle: accent, ...props }, context);
},
});
}
}
/** Registers the Ametrine components on the views registry. */
export function registerAmezing(views: Views, options: { accentColor?: string | [string, string?] } = {}): void {
registerAll(views as unknown as Parameters<typeof registerAll>[0], options.accentColor ? accentStyle(options.accentColor) : undefined);
}
/** Compiled theme stylesheet (Citrine base + Ametrine layout). */
export async function amezingStyles(): Promise<string> {
const [theme, vars] = await Promise.all([
readFile(new URL("./styles/ametrine.css", import.meta.url), "utf8"),
readFile(new URL("./styles/vars.css", import.meta.url), "utf8"),
]);
return vars + "\n" + theme;
}
export const amezingScripts: Record<string, string> = {
"closable.js": "scripts/closable.js",
"paginator.js": "scripts/paginator.js",
"deanchor.js": "scripts/deanchor.js",
"marquee.js": "scripts/marquee.js",
"audio-button.js": "scripts/audio-button.js",
"fuse.js": "scripts/fuse.js",
"search-fuse.js": "scripts/search-fuse.js",
};
export const amezingFonts: Record<string, string> = {
"/fonts/commissioner.woff2": "fonts/commissioner.woff2",
"/fonts/commit-mono.woff2": "fonts/commit-mono.woff2",
};
export function amezingMount(options: { accentColor?: string | [string, string?] } = {}): Mounts {
const accent = options.accentColor ? accentStyle(options.accentColor) : undefined;
return {
views: (views) => registerAll(views as unknown as Parameters<typeof registerAll>[0], accent),
routes: (route) => {
for (const [routePath, file] of Object.entries(amezingScripts)) {
route("GET", `/_muddle/amezing/${routePath}`, async () =>
new Response(await readFile(new URL(`./${file}`, import.meta.url), "utf8"), {
headers: { "content-type": "text/javascript; charset=utf-8" },
}));
}
for (const [routePath, file] of Object.entries(amezingFonts)) {
route("GET", routePath, async () =>
new Response(await readFile(new URL(`./${file}`, import.meta.url)), {
headers: { "content-type": "font/woff2" },
}));
}
},
styles: { "/_muddle/amezing.css": () => amezingStyles() },
};
}
/** Convenience integration. */
export function amezingIntegration(options: { accentColor?: string | [string, string?] } = {}): Integration {
return { name: "amezing", mounts: [amezingMount(options)] };
}