Skip to content
File

Blob: scripts/build-icons.ts

typescript128 lines
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 */
8import { mkdirSync, writeFileSync } from "node:fs";
9import { resolve } from "node:path";
10import { fileURLToPath } from "node:url";
11import sharp from "sharp";
12 
13const ROOT = resolve(fileURLToPath(import.meta.url), "../..");
14const OUT = resolve(ROOT, "public/icons");
15 
16const BG = "#221f21";
17const FG = "#c89738";
18 
19type Variant = {
20 contentScale: number;
21 strokeScale: number;
22 strokeLinecap: "round" | "butt" | "square";
23};
24 
25const FULL: Variant = { contentScale: 0.82, strokeScale: 1, strokeLinecap: "round" };
26const 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).
29const GLYPH_W = 16;
30const GLYPH_H = 20;
31 
32function 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 
49function resizeSvgString(svg: string, newSize: number): string {
50 return svg.replace(/width="\d+"\s+height="\d+"/, `width="${newSize}" height="${newSize}"`);
51}
52 
53async 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 
65function 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 
91async 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 
124main().catch((err) => {
125 console.error(err);
126 process.exit(1);
127});