File
Blob: scripts/build-icons.ts
| 1 | #!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json |
| 2 | /** |
| 3 | * Generate tessera site icons (favicon + PWA) from a single source glyph. |
| 4 | * Outputs to public/icons. Run after brand mark or palette changes: |
| 5 | * |
| 6 | * npx tsx scripts/build-icons.ts |
| 7 | */ |
| 8 | import { mkdirSync, writeFileSync } from "node:fs"; |
| 9 | import { resolve } from "node:path"; |
| 10 | import { fileURLToPath } from "node:url"; |
| 11 | import sharp from "sharp"; |
| 12 | |
| 13 | const ROOT = resolve(fileURLToPath(import.meta.url), "../.."); |
| 14 | const OUT = resolve(ROOT, "public/icons"); |
| 15 | |
| 16 | const BG = "#221f21"; |
| 17 | const FG = "#c89738"; |
| 18 | |
| 19 | type Variant = { |
| 20 | contentScale: number; |
| 21 | strokeScale: number; |
| 22 | strokeLinecap: "round" | "butt" | "square"; |
| 23 | }; |
| 24 | |
| 25 | const FULL: Variant = { contentScale: 0.82, strokeScale: 1, strokeLinecap: "round" }; |
| 26 | const TINY: Variant = { contentScale: 0.92, strokeScale: 1.45, strokeLinecap: "butt" }; |
| 27 | |
| 28 | // Lucide ShieldCheck lives in a 24x24 viewBox; visible content is ~16x20 centered on (12,12). |
| 29 | const GLYPH_W = 16; |
| 30 | const GLYPH_H = 20; |
| 31 | |
| 32 | function buildSvg(size: number, v: Variant, transparent = false): string { |
| 33 | const scale = (size * v.contentScale) / Math.max(GLYPH_W, GLYPH_H); |
| 34 | const tx = size / 2 - 12 * scale; |
| 35 | const ty = size / 2 - 12 * scale; |
| 36 | const stroke = 2 * v.strokeScale; |
| 37 | const bgRect = transparent ? "" : ` <rect width="${size}" height="${size}" fill="${BG}"/>`; |
| 38 | return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> |
| 39 | ${bgRect} |
| 40 | <g transform="translate(${tx.toFixed(3)},${ty.toFixed(3)}) scale(${scale.toFixed(4)})" |
| 41 | fill="none" stroke="${FG}" stroke-width="${stroke}" |
| 42 | stroke-linecap="${v.strokeLinecap}" stroke-linejoin="round"> |
| 43 | <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1Z"/> |
| 44 | <path d="m9 12 2 2 4-4"/> |
| 45 | </g> |
| 46 | </svg>`; |
| 47 | } |
| 48 | |
| 49 | function resizeSvgString(svg: string, newSize: number): string { |
| 50 | return svg.replace(/width="\d+"\s+height="\d+"/, `width="${newSize}" height="${newSize}"`); |
| 51 | } |
| 52 | |
| 53 | async function renderPng(svg: string, size: number, opts: { alpha?: boolean } = {}): Promise<Buffer> { |
| 54 | const supersample = size < 64 ? 4 : 2; |
| 55 | const source = resizeSvgString(svg, size * supersample); |
| 56 | let pipeline = sharp(Buffer.from(source)).resize(size, size, { |
| 57 | kernel: sharp.kernel.lanczos3, |
| 58 | }); |
| 59 | if (opts.alpha === false) { |
| 60 | pipeline = pipeline.flatten({ background: BG }); |
| 61 | } |
| 62 | return pipeline.png({ compressionLevel: 9 }).toBuffer(); |
| 63 | } |
| 64 | |
| 65 | function buildIco(pngs: Buffer[]): Buffer { |
| 66 | const header = Buffer.alloc(6); |
| 67 | header.writeUInt16LE(0, 0); |
| 68 | header.writeUInt16LE(1, 2); |
| 69 | header.writeUInt16LE(pngs.length, 4); |
| 70 | |
| 71 | const entries = Buffer.alloc(16 * pngs.length); |
| 72 | const sizes = [16, 32, 48]; |
| 73 | let offset = header.length + entries.length; |
| 74 | pngs.forEach((png, i) => { |
| 75 | const size = sizes[i]; |
| 76 | const base = i * 16; |
| 77 | entries.writeUInt8(size === 256 ? 0 : size, base + 0); |
| 78 | entries.writeUInt8(size === 256 ? 0 : size, base + 1); |
| 79 | entries.writeUInt8(0, base + 2); |
| 80 | entries.writeUInt8(0, base + 3); |
| 81 | entries.writeUInt16LE(1, base + 4); |
| 82 | entries.writeUInt16LE(32, base + 6); |
| 83 | entries.writeUInt32LE(png.length, base + 8); |
| 84 | entries.writeUInt32LE(offset, base + 12); |
| 85 | offset += png.length; |
| 86 | }); |
| 87 | |
| 88 | return Buffer.concat([header, entries, ...pngs]); |
| 89 | } |
| 90 | |
| 91 | async function main() { |
| 92 | mkdirSync(OUT, { recursive: true }); |
| 93 | |
| 94 | const canonicalSvg = buildSvg(512, FULL, true); |
| 95 | writeFileSync(resolve(OUT, "icon.svg"), canonicalSvg); |
| 96 | |
| 97 | const targets: Array<{ name: string; size: number; variant: Variant; alpha: boolean }> = [ |
| 98 | { name: "favicon-16x16.png", size: 16, variant: TINY, alpha: true }, |
| 99 | { name: "favicon-32x32.png", size: 32, variant: FULL, alpha: true }, |
| 100 | { name: "apple-touch-icon.png", size: 180, variant: FULL, alpha: false }, |
| 101 | { name: "oauth-logo-120.png", size: 120, variant: FULL, alpha: false }, |
| 102 | ]; |
| 103 | |
| 104 | for (const t of targets) { |
| 105 | const svg = buildSvg(t.size, t.variant, t.alpha); |
| 106 | const png = await renderPng(svg, t.size, { alpha: t.alpha }); |
| 107 | writeFileSync(resolve(OUT, t.name), png); |
| 108 | console.log(`wrote ${t.name} (${png.length} bytes)`); |
| 109 | } |
| 110 | |
| 111 | const icoSizes: Array<{ size: number; variant: Variant }> = [ |
| 112 | { size: 16, variant: TINY }, |
| 113 | { size: 32, variant: FULL }, |
| 114 | { size: 48, variant: FULL }, |
| 115 | ]; |
| 116 | const icoPngs = await Promise.all( |
| 117 | icoSizes.map(({ size, variant }) => renderPng(buildSvg(size, variant, true), size, { alpha: true })), |
| 118 | ); |
| 119 | const ico = buildIco(icoPngs); |
| 120 | writeFileSync(resolve(OUT, "favicon.ico"), ico); |
| 121 | console.log(`wrote favicon.ico (${ico.length} bytes, ${icoPngs.length} sizes)`); |
| 122 | } |
| 123 | |
| 124 | main().catch((err) => { |
| 125 | console.error(err); |
| 126 | process.exit(1); |
| 127 | }); |