2023-05-29 15:52:27 +01:00
|
|
|
import { rm, writeFile } from 'node:fs/promises'
|
2023-08-02 11:28:18 +01:00
|
|
|
import process from 'node:process'
|
2023-05-29 15:52:27 +01:00
|
|
|
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<IconType, Icon> {
|
|
|
|
/**
|
|
|
|
* @default: { compressionLevel: 9, quality: 60 }`
|
|
|
|
*/
|
|
|
|
png?: PngOptions
|
|
|
|
/**
|
|
|
|
* @default `pwa-<size>x<size>.png`, `maskable-icon-<size>x<size>.png`, `apple-touch-icon-<size>x<size>.png`
|
|
|
|
*/
|
|
|
|
iconName?: (type: IconType, size: number) => string
|
|
|
|
/**
|
|
|
|
* Generate `favicon.ico` from transparent icons (from `pwa-<size>x<size>.png` ones)
|
|
|
|
*/
|
|
|
|
ico?: {
|
|
|
|
/**
|
|
|
|
* @default `favicon-<size>x<size>.ico`
|
|
|
|
*/
|
|
|
|
icoName?: (size: number) => string
|
|
|
|
sizes: number[]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ResolvedIcons extends Required<Omit<Icons, 'ico'>> {
|
|
|
|
ico?: {
|
|
|
|
/**
|
|
|
|
* @default `favicon-<size>x<size>.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, <Icons>{
|
|
|
|
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)
|