v3: added customizable site emoji (logo), small visual tweaks
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
2026-06-29 15:12:25 +02:00
parent aab5b5bfd5
commit f171027673
18 changed files with 342 additions and 78 deletions

161
api/lib/site-icons.ts Normal file
View File

@@ -0,0 +1,161 @@
// Generates the full favicon set + web manifest from a single emoji.
//
// Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without
// any system emoji font (which matters in the minimal Alpine runtime image).
// @resvg/resvg-wasm does the rasterization; the .ico is assembled by hand.
//
// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in
// prod, public/ in dev) — see that script for how the output dir is chosen.
import { initWasm, Resvg } from "@resvg/resvg-wasm";
// Pinned Twemoji release.
const TWEMOJI_VERSION = "16.0.1";
const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512];
const APPLE_SIZE = 180;
const ICO_SIZES = [16, 32, 48];
/**
* Mirror Twemoji's filename convention: strip variation selectors (FE0F) unless
* the emoji is a ZWJ sequence, then join codepoints with "-". Also doubles as a
* stable cache-busting version string for the icon URLs.
*/
export function emojiToCodepoint(emoji: string): string {
const cleaned = emoji.includes("") ? emoji : emoji.replace(//g, "");
return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-");
}
let wasmReady: Promise<unknown> | undefined;
function ensureWasm(): Promise<unknown> {
if (!wasmReady) {
const wasmUrl = new URL(import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"));
wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes));
}
return wasmReady;
}
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
const glyphCache = new Map<string, string>();
async function fetchGlyph(emoji: string): Promise<string> {
const cp = emojiToCodepoint(emoji);
const cached = glyphCache.get(cp);
if (cached) return cached;
const url =
`https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
let res: Response;
try {
res = await fetch(url);
} catch (cause) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,
{ cause },
);
}
if (!res.ok) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`,
);
}
const svg = await res.text();
glyphCache.set(cp, svg);
return svg;
}
function renderPng(svg: string, size: number, background?: string): Uint8Array {
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, background });
return resvg.render().asPng();
}
// Pack PNG payloads into a (PNG-compressed) .ico container.
function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array {
const HEADER = 6;
const ENTRY = 16;
const offsetBase = HEADER + ENTRY * images.length;
const total = offsetBase + images.reduce((sum, i) => sum + i.png.length, 0);
const buf = new Uint8Array(total);
const dv = new DataView(buf.buffer);
dv.setUint16(2, 1, true); // type: icon
dv.setUint16(4, images.length, true);
let offset = offsetBase;
images.forEach((img, idx) => {
const e = HEADER + idx * ENTRY;
buf[e] = img.size >= 256 ? 0 : img.size; // width (0 means 256)
buf[e + 1] = img.size >= 256 ? 0 : img.size; // height
dv.setUint16(e + 4, 1, true); // color planes
dv.setUint16(e + 6, 32, true); // bits per pixel
dv.setUint32(e + 8, img.png.length, true); // size of image data
dv.setUint32(e + 12, offset, true); // offset of image data
buf.set(img.png, offset);
offset += img.png.length;
});
return buf;
}
function buildManifest(siteName: string, bgColor: string): string {
const manifest = {
name: siteName,
short_name: siteName,
start_url: "/",
display: "standalone",
background_color: bgColor,
theme_color: bgColor,
icons: [
{ src: "/favicon.svg", type: "image/svg+xml", sizes: "any" },
{ src: "/favicon-192x192.png", type: "image/png", sizes: "192x192" },
{
src: "/favicon-512x512.png",
type: "image/png",
sizes: "512x512",
purpose: "any maskable",
},
],
share_target: {
action: "/",
method: "GET",
params: { url: "share_url", title: "share_title", text: "share_text" },
},
};
return JSON.stringify(manifest, null, 2) + "\n";
}
export interface GenerateSiteIconsOptions {
/** Emoji to render the icons from. */
emoji: string;
/** Directory to write the assets into (created if missing). */
outDir: string;
/** Site name written into the manifest. */
siteName: string;
/** Background color for the Apple touch icon and manifest (iOS ignores transparency). */
bgColor: string;
}
/**
* Generate `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16512),
* `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`.
* Throws if the emoji glyph can't be fetched.
*/
export async function generateSiteIcons(
opts: GenerateSiteIconsOptions,
): Promise<void> {
const { emoji, outDir, siteName, bgColor } = opts;
const svg = await fetchGlyph(emoji);
await ensureWasm();
await Deno.mkdir(outDir, { recursive: true });
const out = (name: string) => `${outDir}/${name}`;
await Deno.writeTextFile(out("favicon.svg"), svg);
const pngs = new Map<number, Uint8Array>();
for (const size of PNG_SIZES) pngs.set(size, renderPng(svg, size));
for (const size of PNG_SIZES) {
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
}
// Apple touch icon gets a solid background — iOS doesn't honor transparency.
await Deno.writeFile(out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor));
await Deno.writeFile(
out("favicon.ico"),
buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))),
);
await Deno.writeTextFile(out("manifest.webmanifest"), buildManifest(siteName, bgColor));
}

View File

@@ -1,5 +1,8 @@
import { Context, Next, send } from "@oak/oak";
import { OG_SITE_NAME } from "../config.ts";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "./site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
async function serveIndexHtml(
context: Context<Record<string, object>>,
@@ -7,7 +10,10 @@ async function serveIndexHtml(
) {
const filePath = `${root}/index.html`;
const raw = await Deno.readTextFile(filePath);
const html = raw.replaceAll("__SITE_NAME__", OG_SITE_NAME);
const html = raw
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION);
context.response.type = "text/html";
context.response.body = html;
}