#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Configuration
const MASKABLE_PADDING = 0.2; // 20% padding for maskable icons
// Asset definitions
const ASSETS = [
{ name: 'icon-144x144.png', size: 144 },
{ name: 'icon-192x192.png', size: 192 },
{ name: 'icon-512x512.png', size: 512 },
{ name: 'icon-192x192-safe.png', size: 192, maskable: true },
{ name: 'apple-touch-icon.png', size: 180 },
{ name: 'badge.png', size: 96, monochrome: true },
];
const SCREENSHOTS = [
{ name: 'screenshots/desktop-wide.png', width: 1280, height: 720 },
{ name: 'screenshots/mobile-narrow.png', width: 375, height: 812 },
];
const SHORTCUTS = [
{ name: 'shortcuts/start.png', size: 96, overlay: 'play' },
{ name: 'shortcuts/settings.png', size: 96, overlay: 'gear' },
];
// Check and install dependencies
function ensureDependencies() {
const requiredPackages = ['sharp', 'png-to-ico'];
const missingPackages = [];
requiredPackages.forEach(pkg => {
try {
require.resolve(pkg);
} catch {
missingPackages.push(pkg);
}
});
if (missingPackages.length > 0) {
console.log('š¦ Installing required dependencies...');
try {
execSync(`npm install ${missingPackages.join(' ')}`, { stdio: 'inherit' });
console.log('ā
Dependencies installed successfully!\n');
} catch (error) {
console.error('ā Failed to install dependencies. Please run:');
console.error(` npm install ${missingPackages.join(' ')}`);
process.exit(1);
}
}
}
// Main function
async function generatePWAAssets(sourcePath, outputDir) {
ensureDependencies();
const sharp = require('sharp');
const pngToIco = require('png-to-ico');
// Validate source image
if (!fs.existsSync(sourcePath)) {
throw new Error(`Source image not found: ${sourcePath}`);
}
// Get image metadata
const metadata = await sharp(sourcePath).metadata();
if (metadata.width < 1024 || metadata.height < 1024) {
throw new Error(`Source image must be at least 1024x1024px. Current size: ${metadata.width}x${metadata.height}`);
}
// Create output directories
const dirs = [
outputDir,
path.join(outputDir, 'screenshots'),
path.join(outputDir, 'shortcuts'),
];
dirs.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
console.log('šØ Generating PWA Assets...\n');
// Generate standard icons
for (const asset of ASSETS) {
const outputPath = path.join(outputDir, asset.name);
console.log(`š± Creating ${asset.name}...`);
let pipeline = sharp(sourcePath);
if (asset.maskable) {
// Add padding for maskable icon
const paddedSize = Math.round(asset.size * (1 + MASKABLE_PADDING * 2));
pipeline = pipeline
.resize(asset.size, asset.size)
.extend({
top: Math.round(asset.size * MASKABLE_PADDING),
bottom: Math.round(asset.size * MASKABLE_PADDING),
left: Math.round(asset.size * MASKABLE_PADDING),
right: Math.round(asset.size * MASKABLE_PADDING),
background: { r: 255, g: 255, b: 255, alpha: 0 }
})
.resize(asset.size, asset.size);
} else if (asset.monochrome) {
// Create monochrome white badge
pipeline = pipeline
.resize(asset.size, asset.size)
.grayscale()
.negate()
.threshold(128);
} else {
// Standard resize
pipeline = pipeline.resize(asset.size, asset.size);
}
await pipeline.png().toFile(outputPath);
}
// Generate favicon.ico
console.log('š Creating favicon.ico...');
const faviconSizes = [16, 32, 48];
const faviconBuffers = [];
for (const size of faviconSizes) {
const buffer = await sharp(sourcePath)
.resize(size, size)
.png()
.toBuffer();
faviconBuffers.push(buffer);
}
const icoBuffer = await pngToIco(faviconBuffers);
fs.writeFileSync(path.join(outputDir, 'favicon.ico'), icoBuffer);
// Generate screenshot placeholders
for (const screenshot of SCREENSHOTS) {
const outputPath = path.join(outputDir, screenshot.name);
console.log(`šø Creating ${screenshot.name}...`);
// Create a placeholder with the source image centered
const maxDimension = Math.min(screenshot.width, screenshot.height) * 0.4;
await sharp(sourcePath)
.resize(Math.round(maxDimension), Math.round(maxDimension), { fit: 'inside' })
.toBuffer()
.then(async (logoBuffer) => {
// Create background with gradient
const svg = `
`;
const background = await sharp(Buffer.from(svg))
.png()
.toBuffer();
await sharp(background)
.composite([{
input: logoBuffer,
gravity: 'center'
}])
.toFile(outputPath);
});
}
// Generate shortcut icons with overlays
for (const shortcut of SHORTCUTS) {
const outputPath = path.join(outputDir, shortcut.name);
console.log(`ā” Creating ${shortcut.name}...`);
// Resize base icon
const resizedIcon = await sharp(sourcePath)
.resize(shortcut.size, shortcut.size)
.toBuffer();
// Create overlay SVG based on type
let overlaySvg;
if (shortcut.overlay === 'play') {
overlaySvg = `
`;
} else if (shortcut.overlay === 'gear') {
overlaySvg = `
`;
}
const overlay = await sharp(Buffer.from(overlaySvg))
.png()
.toBuffer();
await sharp(resizedIcon)
.composite([{ input: overlay }])
.toFile(outputPath);
}
console.log('\nā
All PWA assets generated successfully!');
console.log(`š Output directory: ${outputDir}`);
console.log('\nš Generated files:');
console.log(' ā App icons (144x144, 192x192, 512x512)');
console.log(' ā Maskable icon (192x192-safe)');
console.log(' ā Apple touch icon (180x180)');
console.log(' ā Favicon.ico (multi-resolution)');
console.log(' ā Badge icon (96x96, monochrome)');
console.log(' ā Screenshot placeholders (desktop & mobile)');
console.log(' ā Shortcut icons (start & settings)');
console.log('\nš” Remember to replace screenshot placeholders with actual app screenshots!');
}
// CLI handling
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: node generate-pwa-assets.js ');
console.log('\nExample:');
console.log(' node generate-pwa-assets.js ./logo.png ./public');
process.exit(1);
}
const [sourcePath, outputDir] = args;
generatePWAAssets(sourcePath, outputDir)
.catch(error => {
console.error('\nā Error:', error.message);
process.exit(1);
});
}
module.exports = { generatePWAAssets };