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