Files
gerbeur/api/lib/site-icons.ts
Vincent Lannurien f171027673
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
v3: added customizable site emoji (logo), small visual tweaks
2026-06-29 15:12:25 +02:00

162 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}