|
| 1 | +#!/usr/bin/env node |
| 2 | +// generate-favicons.mjs — create the full favicon set referenced by index.html, brand-colored |
| 3 | +// from _brand.json (brandHue), with ZERO external deps (pure node: zlib for PNG, BMP-in-ICO). |
| 4 | +// Fixes the bug where index.html referenced 5 favicons that didn't exist (404 on every site). |
| 5 | +// Run before `vite build` (writes into public/, which vite copies to dist/). Regenerate per brand. |
| 6 | +import { readFileSync, writeFileSync } from 'node:fs'; |
| 7 | +import { deflateSync } from 'node:zlib'; |
| 8 | +import { resolve, dirname } from 'node:path'; |
| 9 | +import { fileURLToPath } from 'node:url'; |
| 10 | + |
| 11 | +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); |
| 12 | +const pub = resolve(root, 'public'); |
| 13 | +const brand = JSON.parse(readFileSync(resolve(root, '_brand.json'), 'utf8')); |
| 14 | +const val = (n) => (n && typeof n === 'object' && '$value' in n ? n.$value : n); |
| 15 | +const hue = Number(val(brand?.color?.brandHue)) || 240; |
| 16 | +const name = String(val(brand?.business?.name) || val(brand?.name) || 'A'); |
| 17 | +const initial = (name.match(/[A-Za-z0-9]/)?.[0] || 'A').toUpperCase(); |
| 18 | + |
| 19 | +// HSL → RGB (brand bg + a dark variant for the rounded square) |
| 20 | +function hsl(h, s, l) { |
| 21 | + s /= 100; l /= 100; |
| 22 | + const k = (n) => (n + h / 30) % 12, a = s * Math.min(l, 1 - l); |
| 23 | + const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); |
| 24 | + return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; |
| 25 | +} |
| 26 | +const [r, g, b] = hsl(hue, 70, 55); |
| 27 | +const hex = '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join(''); |
| 28 | + |
| 29 | +// ---- pure-node PNG encoder (solid rounded square, RGBA) ---- |
| 30 | +const crcTable = (() => { const t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); |
| 31 | +const crc32 = (buf) => { let c = 0xffffffff; for (const x of buf) c = crcTable[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; |
| 32 | +function chunk(type, data) { |
| 33 | + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); |
| 34 | + const td = Buffer.concat([Buffer.from(type), data]); |
| 35 | + const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td)); |
| 36 | + return Buffer.concat([len, td, c]); |
| 37 | +} |
| 38 | +function pngSolid(size) { |
| 39 | + const px = Buffer.alloc(size * size * 4); |
| 40 | + const rad = Math.floor(size * 0.18); |
| 41 | + const inCorner = (x, y) => { // rounded-rect alpha mask |
| 42 | + const cx = x < rad ? rad : x >= size - rad ? size - 1 - rad : x; |
| 43 | + const cy = y < rad ? rad : y >= size - rad ? size - 1 - rad : y; |
| 44 | + return Math.hypot(x - cx, y - cy) <= rad; |
| 45 | + }; |
| 46 | + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { |
| 47 | + const i = (y * size + x) * 4, on = inCorner(x, y); |
| 48 | + px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = on ? 255 : 0; |
| 49 | + } |
| 50 | + // add filter byte (0) per scanline |
| 51 | + const raw = Buffer.alloc(size * (size * 4 + 1)); |
| 52 | + for (let y = 0; y < size; y++) { raw[y * (size * 4 + 1)] = 0; px.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); } |
| 53 | + const ihdr = Buffer.alloc(13); |
| 54 | + ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA |
| 55 | + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); |
| 56 | + return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); |
| 57 | +} |
| 58 | +// ICO wrapping a 32x32 PNG (ICO supports embedded PNG since Vista) |
| 59 | +function ico(png32) { |
| 60 | + const hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4); |
| 61 | + const ent = Buffer.alloc(16); ent[0] = 32; ent[1] = 32; ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6); |
| 62 | + ent.writeUInt32LE(png32.length, 8); ent.writeUInt32LE(22, 12); |
| 63 | + return Buffer.concat([hdr, ent, png32]); |
| 64 | +} |
| 65 | + |
| 66 | +const png32 = pngSolid(32); |
| 67 | +writeFileSync(resolve(pub, 'favicon-16x16.png'), pngSolid(16)); |
| 68 | +writeFileSync(resolve(pub, 'favicon-32x32.png'), png32); |
| 69 | +writeFileSync(resolve(pub, 'apple-touch-icon.png'), pngSolid(180)); |
| 70 | +writeFileSync(resolve(pub, 'favicon.ico'), ico(png32)); |
| 71 | +// rich SVG favicon (brand bg + initial) + monochrome safari mask |
| 72 | +writeFileSync(resolve(pub, 'favicon.svg'), `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${hex}"/><text x="16" y="22" font-family="system-ui,sans-serif" font-size="18" font-weight="700" fill="#fff" text-anchor="middle">${initial}</text></svg>`); |
| 73 | +writeFileSync(resolve(pub, 'safari-pinned-tab.svg'), `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6"/><text x="16" y="22" font-family="system-ui" font-size="18" font-weight="700" fill="#fff" text-anchor="middle">${initial}</text></svg>`); |
| 74 | +console.log(`[generate-favicons] wrote 6 favicons · brand=${hex} initial=${initial}`); |
0 commit comments