// 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 | undefined; function ensureWasm(): Promise { 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(); async function fetchGlyph(emoji: string): Promise { 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-x.png` (16–512), * `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 { 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(); 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)); }