152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
/**
|
||
* Renders the site's favicons and web manifest from its emoji. The `icons`
|
||
* task (`api/scripts/generate-icons.ts`) runs it before the server starts, so
|
||
* the emoji can change without a rebuild.
|
||
*
|
||
* The glyph comes from Twemoji as plain vector paths, so rendering it needs no
|
||
* emoji font, which the Alpine runtime image lacks.
|
||
*
|
||
* @module
|
||
*/
|
||
|
||
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
||
import { fetchTwemojiSvg } from "../remote/twemoji.ts";
|
||
|
||
const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512];
|
||
const APPLE_SIZE = 180;
|
||
const ICO_SIZES = [16, 32, 48];
|
||
|
||
/**
|
||
* Returns the name Twemoji files `emoji` under: its codepoints in hex, joined
|
||
* with "-", without variation selectors (FE0F) unless the emoji is a ZWJ
|
||
* sequence. `lib/index-html.ts` also uses it as the icon URLs' cache-busting
|
||
* version.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||
const resvg = new Resvg(svg, {
|
||
fitTo: { mode: "width", value: size },
|
||
background,
|
||
});
|
||
return resvg.render().asPng();
|
||
}
|
||
|
||
/** Packs PNG images into an .ico file, which may hold PNGs as they are. */
|
||
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";
|
||
}
|
||
|
||
/**
|
||
* Passed to {@link generateSiteIcons} by `api/scripts/generate-icons.ts`, from
|
||
* the site's config.
|
||
*/
|
||
export interface GenerateSiteIconsOptions {
|
||
emoji: string;
|
||
/** Created if missing. */
|
||
outDir: string;
|
||
siteName: string;
|
||
/** Fills the Apple touch icon, and the manifest's theme and background. */
|
||
bgColor: string;
|
||
}
|
||
|
||
/**
|
||
* Writes `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16 to 512),
|
||
* `apple-touch-icon.png` and `manifest.webmanifest` into `opts.outDir`, the
|
||
* directory the site's static files are served from.
|
||
*
|
||
* @throws {Error} When the emoji's glyph can't be fetched.
|
||
*/
|
||
export async function generateSiteIcons(
|
||
opts: GenerateSiteIconsOptions,
|
||
): Promise<void> {
|
||
const { emoji, outDir, siteName, bgColor } = opts;
|
||
const svg = await fetchTwemojiSvg(emoji, emojiToCodepoint(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)!);
|
||
}
|
||
// iOS draws a transparent touch icon on black.
|
||
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),
|
||
);
|
||
}
|