fuzzyworld

word-waltz / files / scripts / generate-assets.ts

generate-assets.ts

4.0 KB · history · blame

1#!/usr/bin/env tsx
2/**
3 * scripts/generate-assets.ts
4 *
5 * Generates favicon and icon assets from src/assets/logo.png.
6 * Outputs to src/public/ which Vite copies to dist/ verbatim.
7 *
8 * Run with: npm run generate-assets
9 * Re-run whenever the source logo changes.
10 */
11
12import sharp from 'sharp';
13import { writeFileSync, mkdirSync } from 'fs';
14import { resolve, dirname } from 'path';
15import { fileURLToPath } from 'url';
16
17const __dirname = dirname(fileURLToPath(import.meta.url));
18const LOGO = resolve(__dirname, '../src/assets/logo.png');
19const OUT = resolve(__dirname, '../src/public');
20
21mkdirSync(OUT, { recursive: true });
22
23// Transparent background so the logo shape shows on any page background
24const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
25
26const PNG_SIZES: { name: string; size: number }[] = [
27 { name: 'favicon-16x16.png', size: 16 },
28 { name: 'favicon-32x32.png', size: 32 },
29 { name: 'apple-touch-icon.png', size: 180 },
30 { name: 'icon-192.png', size: 192 },
31 { name: 'icon-512.png', size: 512 },
32];
33
34async function resizePng(size: number): Promise<Buffer> {
35 return sharp(LOGO)
36 .resize(size, size, { fit: 'contain', background: TRANSPARENT })
37 .png()
38 .toBuffer();
39}
40
41/**
42 * Build a minimal ICO file containing embedded PNGs.
43 * ICO supports PNG payloads since Windows Vista; all modern browsers accept them.
44 */
45function buildIco(pngBuffers: Buffer[]): Buffer {
46 const count = pngBuffers.length;
47 const headerSize = 6 + count * 16;
48
49 let dataOffset = headerSize;
50 const offsets = pngBuffers.map((buf) => {
51 const off = dataOffset;
52 dataOffset += buf.length;
53 return off;
54 });
55
56 const header = Buffer.alloc(headerSize);
57 header.writeUInt16LE(0, 0); // reserved
58 header.writeUInt16LE(1, 2); // type: 1 = icon
59 header.writeUInt16LE(count, 4); // number of images
60
61 for (let i = 0; i < count; i++) {
62 const base = 6 + i * 16;
63 const buf = pngBuffers[i];
64 // PNG IHDR: bytes 16–19 = width, 20–23 = height (big-endian)
65 const width = buf.readUInt32BE(16);
66 const height = buf.readUInt32BE(20);
67 header.writeUInt8(width >= 256 ? 0 : width, base); // 0 means 256
68 header.writeUInt8(height >= 256 ? 0 : height, base + 1);
69 header.writeUInt8(0, base + 2); // color count (0 = no palette)
70 header.writeUInt8(0, base + 3); // reserved
71 header.writeUInt16LE(1, base + 4); // color planes
72 header.writeUInt16LE(32, base + 6); // bits per pixel
73 header.writeUInt32LE(buf.length, base + 8); // image data size
74 header.writeUInt32LE(offsets[i], base + 12); // image data offset
75 }
76
77 return Buffer.concat([header, ...pngBuffers]);
78}
79
80async function main(): Promise<void> {
81 console.log(`Source: ${LOGO}`);
82 console.log(`Output: ${OUT}\n`);
83
84 // Generate all PNG sizes
85 const buffers: Record<string, Buffer> = {};
86 for (const { name, size } of PNG_SIZES) {
87 buffers[name] = await resizePng(size);
88 writeFileSync(resolve(OUT, name), buffers[name]);
89 console.log(` ✓ ${name} (${size}×${size})`);
90 }
91
92 // Build favicon.ico from 16×16 + 32×32
93 const ico = buildIco([buffers['favicon-16x16.png']!, buffers['favicon-32x32.png']!]);
94 writeFileSync(resolve(OUT, 'favicon.ico'), ico);
95 console.log(' ✓ favicon.ico (16×16 + 32×32)');
96
97 // OG / social-preview image: 1200×630, logo centred on a warm cream background
98 const OG_W = 1200;
99 const OG_H = 630;
100 const logoOnOg = await resizePng(420);
101 await sharp({
102 create: {
103 width: OG_W,
104 height: OG_H,
105 channels: 4,
106 background: { r: 255, g: 250, b: 240, alpha: 1 },
107 },
108 })
109 .composite([{ input: logoOnOg, gravity: 'centre' }])
110 .png()
111 .toFile(resolve(OUT, 'og-image.png'));
112 console.log(' ✓ og-image.png (1200×630)');
113
114 console.log('\nAll assets generated.');
115}
116
117main().catch((err: unknown) => {
118 console.error('generate-assets failed:', err);
119 process.exit(1);
120});