// THE ICON FILES — every favicon, app icon and touch icon the family ships, // rendered from the one mark (lib/brand.ts markSvg) at build time instead of // committed as binaries. A site's icons are lit with its own accent, so they // cannot be committed once for everyone. // // Served by a static GET route handler in each app (export/app/icons/[file], // homepage/app/icons/[file], and a favicon.ico route beside them). Under // `output: "export"` Next copies such a handler's body byte-for-byte to its // exact path (next/dist/export/index.js, the `.body` branch), so // `out/icons/icon-192.png` is a real PNG at a stable URL — `app/icon.tsx` // cannot do this: its URLs are always hashed. // // SERVER-ONLY, and imported ONLY by those route handlers: `next/og` (satori + // resvg, bundled with Next) renders the PNGs on the node runtime. The SVG goes // in as an data URI, so the raster is exactly markSvg's geometry — no // second drawing of the mark to drift. The icon set as data (ICON_FILES, // ICON_METADATA, FAVICON_SIZES, siteIconPalette) is lib/brandIconFiles.ts, which // is pure, so the layouts and the header never load this module. import { createElement } from "react"; import { ImageResponse } from "next/og"; import { markSvg, type IconPalette, type MarkVariant } from "./brand"; import { FAVICON_SIZES, iconFile } from "./brandIconFiles"; export function svgDataUri(svg: string): string { return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } // Any SVG document as a PNG of `width` × `height`. satori lays out one // of the SVG; resvg rasterises it. export async function renderSvgPng( svg: string, opts: { width: number; height: number }, ): Promise { const { width, height } = opts; const res = new ImageResponse( createElement("img", { src: svgDataUri(svg), width, height, alt: "" }), { width, height }, ); return new Uint8Array(await res.arrayBuffer()); } // The mark as a PNG of `size` × `size`. export async function renderIconPng( palette: IconPalette, opts: { variant?: MarkVariant; size: number }, ): Promise { const { size } = opts; return renderSvgPng(markSvg(palette, { variant: opts.variant ?? "any" }), { width: size, height: size }); } // A PNG-payload ICO: the 6-byte ICONDIR, one 16-byte ICONDIRENTRY per image, // then the PNG files verbatim (Vista+ and every current browser read PNG // entries). A width or height of 256 is written as 0, per the format. export function pngToIco(images: ReadonlyArray<{ size: number; png: Uint8Array }>): Uint8Array { const HEADER = 6; const ENTRY = 16; const total = HEADER + ENTRY * images.length + images.reduce((n, i) => n + i.png.byteLength, 0); const out = new Uint8Array(total); const view = new DataView(out.buffer); view.setUint16(0, 0, true); // reserved view.setUint16(2, 1, true); // type 1 = icon view.setUint16(4, images.length, true); let offset = HEADER + ENTRY * images.length; images.forEach(({ size, png }, i) => { if (!Number.isInteger(size) || size < 1 || size > 256) { throw new Error(`pngToIco: size must be 1..256, got ${size}`); } const e = HEADER + ENTRY * i; view.setUint8(e, size === 256 ? 0 : size); // width view.setUint8(e + 1, size === 256 ? 0 : size); // height view.setUint8(e + 2, 0); // palette colours (none) view.setUint8(e + 3, 0); // reserved view.setUint16(e + 4, 1, true); // colour planes view.setUint16(e + 6, 32, true); // bits per pixel view.setUint32(e + 8, png.byteLength, true); // payload size view.setUint32(e + 12, offset, true); // payload offset out.set(png, offset); offset += png.byteLength; }); return out; } export async function renderFaviconIco(palette: IconPalette): Promise { const images = await Promise.all( FAVICON_SIZES.map(async (size) => ({ size, png: await renderIconPng(palette, { size }) })), ); return pngToIco(images); } // One /icons/'s bytes and type, or null for a name that is not in // ICON_FILES. export async function renderIconFile( name: string, palette: IconPalette, ): Promise<{ body: Uint8Array | string; contentType: string } | null> { const f = iconFile(name); if (!f) return null; if (f.format === "svg") { return { body: markSvg(palette, { variant: f.variant }), contentType: f.contentType }; } return { body: await renderIconPng(palette, { variant: f.variant, size: f.size }), contentType: f.contentType, }; } // A route handler's Response for a rendered body. Revalidating, because a // site's accent (and so its icons) can change between deploys at the same URL. // In a static export only the body survives; the host sets the real headers. export function iconResponse(body: Uint8Array | string, contentType: string): Response { return new Response(typeof body === "string" ? body : new Blob([body as BlobPart]), { headers: { "content-type": contentType, "cache-control": "public, max-age=0, must-revalidate" }, }); }