import { rm, writeFile } from 'node:fs/promises' import process from 'node:process' import { resolve } from 'pathe' import type { PngOptions, ResizeOptions } from 'sharp' import sharp from 'sharp' import ico from 'sharp-ico' interface Icon { sizes: number[] padding: number resizeOptions?: ResizeOptions } type IconType = 'transparent' | 'maskable' | 'apple' /** * PWA Icons definition: * - transparent: [{ sizes: [192, 512], padding: 0.05, resizeOptions: { fit: 'contain', background: 'transparent' } }] * - maskable: [{ sizes: [512], padding: 0.3 }, resizeOptions: { fit: 'contain', background: 'white' } }] * - apple: [{ sizes: [180], padding: 0.3 }, resizeOptions: { fit: 'contain', background: 'white' } }] */ interface Icons extends Record { /** * @default: `{ compressionLevel: 9, quality: 60 }` */ png?: PngOptions /** * @default `pwa-x.png`, `maskable-icon-x.png`, `apple-touch-icon-x.png` */ iconName?: (type: IconType, size: number) => string /** * Generate `favicon.ico` from transparent icons (from `pwa-x.png` ones) */ ico?: { /** * @default `favicon-x.ico` */ icoName?: (size: number) => string sizes: number[] } } interface ResolvedIcons extends Required> { ico?: { /** * @default `favicon-x.ico` */ icoName?: (size: number) => string sizes: number[] } } const defaultIcons: Icons = { transparent: { sizes: [192, 512], padding: 0.05, resizeOptions: { fit: 'contain', background: 'transparent', }, }, maskable: { sizes: [512], padding: 0.3, resizeOptions: { fit: 'contain', background: 'white', }, }, apple: { sizes: [180], padding: 0.3, resizeOptions: { fit: 'contain', background: 'white', }, }, } const root = process.cwd() const publicFolders = ['public', 'public-dev', 'public-staging'].map(folder => resolve(root, folder)) async function optimizePng(filePath: string, png: PngOptions) { await sharp(filePath).png(png).toFile(`${filePath.replace(/-temp\.png$/, '.png')}`) await rm(filePath) } async function generateTransparentIcons(icons: ResolvedIcons, svgLogo: string, folder: string) { const { sizes, padding, resizeOptions } = icons.transparent await Promise.all(sizes.map(async (size) => { const filePath = resolve(folder, icons.iconName('transparent', size)) await sharp({ create: { width: size, height: size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }).composite([{ input: await sharp(svgLogo) .resize( Math.round(size * (1 - padding)), Math.round(size * (1 - padding)), resizeOptions, ).toBuffer(), }]).toFile(filePath) await optimizePng(filePath, icons.png) })) } async function generateMaskableIcons(type: IconType, icons: ResolvedIcons, svgLogo: string, folder: string) { const { sizes, padding, resizeOptions } = icons[type] await Promise.all(sizes.map(async (size) => { const filePath = resolve(folder, icons.iconName(type, size)) await sharp({ create: { width: size, height: size, channels: 4, background: resizeOptions?.background ?? 'white', }, }).composite([{ input: await sharp(svgLogo) .resize( Math.round(size * (1 - padding)), Math.round(size * (1 - padding)), resizeOptions, ).toBuffer(), }]).toFile(filePath) await optimizePng(filePath, icons.png) })) } async function generatePWAIconForEnv(folder: string, icons: ResolvedIcons) { const svgLogo = resolve(folder, 'logo.svg') await Promise.all([ generateTransparentIcons(icons, svgLogo, folder), generateMaskableIcons('maskable', icons, svgLogo, folder), generateMaskableIcons('apple', icons, svgLogo, folder), ]) if (icons.ico) { const { icoName = size => `favicon-${size}x${size}.ico`, } = icons.ico await Promise.all(icons.ico.sizes.map(async (size) => { const png = await sharp( resolve(folder, icons.iconName('transparent', size).replace(/-temp\.png$/, '.png')), ).toFormat('png').toBuffer() await writeFile(resolve(folder, icoName(size)), ico.encode([png])) })) } } async function generatePWAIcons(folders: string[], icons: Icons) { const { png = { compressionLevel: 9, quality: 60 }, iconName = (type, size) => { switch (type) { case 'transparent': return `pwa-${size}x${size}.png` case 'maskable': return `maskable-icon-${size}x${size}.png` case 'apple': return `apple-touch-icon-${size}x${size}.png` } }, transparent = { ...defaultIcons.transparent }, maskable = { ...defaultIcons.maskable }, apple = { ...defaultIcons.apple }, ico, } = icons if (!transparent.resizeOptions) transparent.resizeOptions = { ...defaultIcons.transparent.resizeOptions } if (!maskable.resizeOptions) maskable.resizeOptions = { ...defaultIcons.maskable.resizeOptions } if (!apple.resizeOptions) apple.resizeOptions = { ...defaultIcons.apple.resizeOptions } await Promise.all(folders.map(folder => generatePWAIconForEnv(folder, { png, iconName, transparent, maskable, apple, ico, }))) } console.log('Generating Elk PWA Icons...') generatePWAIcons(publicFolders, { transparent: { ...defaultIcons.transparent, sizes: [64, 192, 512] }, ico: { sizes: [64], icoName: _ => 'favicon.ico' }, iconName: (type, size) => { switch (type) { case 'transparent': return `pwa-${size}x${size}-temp.png` case 'maskable': return 'maskable-icon-temp.png' case 'apple': return 'apple-touch-icon-temp.png' } }, }).then(() => console.log('Elk PWA Icons generated')).catch(console.error)