/** * 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 = { 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, context: { locals: Record; params: Record }): Promise }; } function registerAll(views: TemplateViews, accent: string | undefined): void { for (const [name, template] of componentTemplates()) { views.component(name, { async render(props: Record, context: { locals: Record; params: Record }) { 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[0], options.accentColor ? accentStyle(options.accentColor) : undefined); } /** Compiled theme stylesheet (Citrine base + Ametrine layout). */ export async function amezingStyles(): Promise { 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 = { "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 = { "/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[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)] }; }