// 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" },
});
}