#!/usr/bin/env node // Renders the brand SVGs in brand/ into the icons and social card in public/. // Run with: npm run icons (also runs as part of npm run build) // // sharp's SVG renderer only sees system fonts, and Literata / IBM Plex Sans aren't installed on most // machines. So every element is first converted to outlined paths using the real font files, // which makes the PNGs look identical on any computer or build server. import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs"; import { createRequire } from "node:module"; import opentype from "opentype.js"; import sharp from "sharp"; const require = createRequire(import.meta.url); const out = "public"; mkdirSync(out, { recursive: true }); const loadFont = (pkgFile) => { const buf = readFileSync(require.resolve(pkgFile)); return opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); }; const fonts = { literata: loadFont("@fontsource/literata/files/literata-latin-600-normal.woff"), plex: loadFont("@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff"), }; const attr = (tag, name) => tag.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1]; // Serialize path commands ourselves: opentype.js 2.0's toPathData() occasionally emits "NaN" // for some coordinates, which makes the SVG renderer stop drawing the rest of the text. const n = (v) => Number(v.toFixed(2)); const pathData = (path) => path.commands .map((c) => { switch (c.type) { case "M": case "L": return `${c.type}${n(c.x)} ${n(c.y)}`; case "Q": return `Q${n(c.x1)} ${n(c.y1)} ${n(c.x)} ${n(c.y)}`; case "C": return `C${n(c.x1)} ${n(c.y1)} ${n(c.x2)} ${n(c.y2)} ${n(c.x)} ${n(c.y)}`; default: return "Z"; } }) .join(""); /** Replaces each with a outlined in the font its font-family names. */ function outlineText(svg) { return svg.replace(/]*)>([^<]*)<\/text>/g, (_, attrs, raw) => { const family = attr(attrs, "font-family") ?? ""; const font = /literata/i.test(family) ? fonts.literata : fonts.plex; const size = Number(attr(attrs, "font-size") ?? 16); const text = raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); // SVG letter-spacing is in user units; opentype.js wants a fraction of the em. const letterSpacing = Number(attr(attrs, "letter-spacing") ?? 0) / size; const path = font.getPath( text, Number(attr(attrs, "x") ?? 0), Number(attr(attrs, "y") ?? 0), size, { kerning: true, letterSpacing }, ); const d = pathData(path); if (d.includes("NaN")) throw new Error(`Could not outline "${raw}"`); return `${raw}`; }); } const brand = (name) => readFileSync(`brand/${name}`, "utf8"); const png = (svg, size) => sharp(Buffer.from(svg), { density: 384 }).resize(size, size).png().toBuffer(); /** Packs PNG images into a .ico container (PNG-in-ICO, supported by every current browser). */ function toIco(images) { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); // 1 = icon header.writeUInt16LE(images.length, 4); let offset = 6 + 16 * images.length; const entries = images.map(({ size, data }) => { const e = Buffer.alloc(16); e.writeUInt8(size >= 256 ? 0 : size, 0); e.writeUInt8(size >= 256 ? 0 : size, 1); e.writeUInt16LE(1, 4); // colour planes e.writeUInt16LE(32, 6); // bits per pixel e.writeUInt32LE(data.length, 8); e.writeUInt32LE(offset, 12); offset += data.length; return e; }); return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]); } const favicon = brand("favicon.svg"); // iOS draws its own rounded corners and fills transparency with black, so give it a square tile. const squareTile = favicon.replace(/\s+rx="[^"]*"/, ""); copyFileSync("brand/favicon.svg", `${out}/favicon.svg`); writeFileSync( `${out}/favicon.ico`, toIco( await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(favicon, size) }))), ), ); writeFileSync(`${out}/apple-touch-icon.png`, await png(squareTile, 180)); writeFileSync(`${out}/icon-192.png`, await png(favicon, 192)); writeFileSync(`${out}/icon-512.png`, await png(favicon, 512)); const og = outlineText(brand("og-image.svg")); writeFileSync( `${out}/og-image.png`, await sharp(Buffer.from(og)).resize(1200, 630).png().toBuffer(), ); // An outlined copy of the full logo, safe to use anywhere the web fonts aren't loaded. writeFileSync(`${out}/logo-full.svg`, outlineText(brand("logo-full.svg"))); console.log( "Wrote favicon.svg, favicon.ico, apple-touch-icon.png, icon-192.png, icon-512.png, og-image.png and logo-full.svg to public/", );