muddle/modules/amezing/index.ts
2026-10-23 09:12:49 -04:00

288 lines
7 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)] };
}