161 lines
6.6 KiB
TypeScript
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)] };
|
|
}
|