Files
ClubLogos/scripts/optimize-assets.js
T
Tomas Dvorak e6bc2eedb3 enhance
2025-10-22 20:35:22 +02:00

371 lines
9.8 KiB
JavaScript

import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { optimize as optimizeSvg } from 'svgo';
import sharp from 'sharp';
import chalk from 'chalk';
import { exec } from 'child_process';
import { promisify } from 'util';
const execPromise = promisify(exec);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Configuration
const CONFIG = {
// File size reporting thresholds (in KB)
sizeThresholds: {
small: 10,
medium: 100,
large: 500,
},
// Compression settings
compression: {
png: {
quality: 80, // 1-100
effort: 6, // 1-9 (higher is slower but better compression)
},
jpg: {
quality: 80, // 1-100
mozjpeg: true,
},
pdf: {
quality: 'printer', // 'screen', 'ebook', 'printer', 'prepress', or 'default'
},
},
// Directories to scan (relative to project root)
scanDirs: [
'backend/logos',
'data/logos',
'frontend/dist',
],
// File extensions to process
fileExtensions: ['svg', 'png', 'jpg', 'jpeg', 'pdf'],
};
// Statistics
const stats = {
totalFiles: 0,
processedFiles: 0,
skippedFiles: 0,
totalSaved: 0,
byType: {},
};
/**
* Format file size in human-readable format
*/
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/**
* Get file size in bytes
*/
async function getFileSize(filePath) {
const stats = await fs.stat(filePath);
return stats.size;
}
/**
* Optimize SVG file
*/
async function optimizeSvgFile(filePath) {
const svg = await fs.readFile(filePath, 'utf8');
const result = optimizeSvg(svg, {
path: filePath,
multipass: true,
plugins: [
'preset-default',
{
name: 'removeViewBox',
active: false,
},
{
name: 'addAttributesToSVGElement',
params: {
attributes: [{ 'data-optimized': 'true' }],
},
},
],
});
if (result.error) {
throw new Error(result.error);
}
await fs.writeFile(filePath, result.data);
return true;
}
async function convertSvgToPng(svgPath) {
try {
const normalized = path.normalize(svgPath);
let pngPath = normalized.replace(/\.svg$/i, '.png');
const mappings = [
{
from: path.join(path.sep, 'data', 'logos', 'svg') + path.sep,
to: path.join(path.sep, 'data', 'logos', 'png') + path.sep,
},
{
from: path.join(path.sep, 'backend', 'logos', 'svg') + path.sep,
to: path.join(path.sep, 'backend', 'logos', 'png') + path.sep,
},
];
for (const { from, to } of mappings) {
if (normalized.includes(from)) {
pngPath = normalized.replace(from, to).replace(/\.svg$/i, '.png');
break;
}
}
await fs.mkdir(path.dirname(pngPath), { recursive: true });
const image = sharp(svgPath, { density: 300 });
await image
.resize({ width: 512, fit: 'inside', withoutEnlargement: true })
.png({ quality: CONFIG.compression.png.quality, effort: CONFIG.compression.png.effort })
.toFile(pngPath);
console.log(chalk.green(` ✓ Converted to PNG: ${pngPath}`));
return true;
} catch (error) {
console.warn(chalk.yellow(` ⚠️ SVG to PNG conversion skipped: ${error.message}`));
return false;
}
}
/**
* Optimize PNG/JPG file
*/
async function optimizeImageFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
const isJpeg = ['.jpg', '.jpeg'].includes(ext);
const pipeline = sharp(filePath);
// Get image metadata
const metadata = await pipeline.metadata();
// Skip if already optimized
if (metadata.optimizationLevel) {
return false;
}
// Optimize based on file type
if (isJpeg) {
await pipeline.jpeg({
quality: CONFIG.compression.jpg.quality,
mozjpeg: CONFIG.compression.jpg.mozjpeg,
});
} else {
await pipeline.png({
quality: CONFIG.compression.png.quality,
effort: CONFIG.compression.png.effort,
});
}
// Write optimized file
await pipeline.toFile(filePath);
return true;
}
/**
* Optimize PDF file using Ghostscript
*/
async function optimizePdfFile(filePath) {
try {
// Create a temporary file
const tempPath = `${filePath}.optimized`;
// Use Ghostscript to optimize the PDF
const { stderr } = await execPromise(
`gswin64c -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 ` +
`-dPDFSETTINGS=/${CONFIG.compression.pdf.quality} ` +
`-dNOPAUSE -dQUIET -dBATCH ` +
`-sOutputFile="${tempPath}" "${filePath}"`
);
if (stderr && !stderr.includes('Error')) {
// Replace original with optimized version
await fs.rename(tempPath, filePath);
return true;
} else {
// Clean up temp file if there was an error
await fs.unlink(tempPath).catch(() => {});
throw new Error(stderr || 'Unknown error optimizing PDF');
}
} catch (error) {
if (error.code === 'ENOENT') {
console.warn(chalk.yellow(' ⚠️ Ghostscript not found. Install it from https://ghostscript.com/ for PDF optimization.'));
}
return false;
}
}
/**
* Process a single file
*/
async function processFile(filePath) {
const ext = path.extname(filePath).toLowerCase().slice(1);
const fileName = path.basename(filePath);
// Skip unsupported file types
if (!CONFIG.fileExtensions.includes(ext)) {
return { status: 'skipped', message: 'Unsupported file type' };
}
// Get original size
const originalSize = await getFileSize(filePath);
const originalSizeFormatted = formatSize(originalSize);
// Skip empty files
if (originalSize === 0) {
return { status: 'skipped', message: 'Empty file' };
}
try {
let optimized = false;
// Optimize based on file type
switch (ext) {
case 'svg':
{
const didOptimize = await optimizeSvgFile(filePath);
const didConvert = await convertSvgToPng(filePath);
optimized = didOptimize || didConvert;
}
break;
case 'png':
case 'jpg':
case 'jpeg':
optimized = await optimizeImageFile(filePath);
break;
case 'pdf':
optimized = await optimizePdfFile(filePath);
break;
}
// Get new size
const newSize = await getFileSize(filePath);
const newSizeFormatted = formatSize(newSize);
const saved = originalSize - newSize;
const savedPercent = ((saved / originalSize) * 100).toFixed(2);
// Update statistics
stats.processedFiles++;
stats.totalSaved += saved;
if (!stats.byType[ext]) {
stats.byType[ext] = { count: 0, saved: 0 };
}
stats.byType[ext].count++;
stats.byType[ext].saved += saved;
return {
status: 'optimized',
originalSize,
newSize,
saved,
savedPercent,
message: `${originalSizeFormatted}${newSizeFormatted} (saved ${savedPercent}%)`,
};
} catch (error) {
console.error(chalk.red(` ✗ Error optimizing ${fileName}: ${error.message}`));
stats.skippedFiles++;
return { status: 'error', message: error.message };
}
}
/**
* Process a directory recursively
*/
async function processDirectory(directory) {
try {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await processDirectory(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase().slice(1);
if (CONFIG.fileExtensions.includes(ext)) {
stats.totalFiles++;
console.log(chalk.blue(`\n🔍 Processing: ${fullPath}`));
const result = await processFile(fullPath);
switch (result.status) {
case 'optimized':
console.log(chalk.green(` ✓ Optimized: ${result.message}`));
break;
case 'skipped':
console.log(chalk.yellow(` ⏭️ Skipped: ${result.message}`));
break;
case 'error':
console.error(chalk.red(` ✗ Error: ${result.message}`));
break;
}
}
}
}
} catch (error) {
console.error(chalk.red(`Error reading directory ${directory}: ${error.message}`));
}
}
/**
* Main function
*/
async function main() {
console.log(chalk.cyan('🚀 Starting asset optimization...\n'));
const startTime = Date.now();
// Process each directory
for (const dir of CONFIG.scanDirs) {
const fullPath = path.resolve(__dirname, '..', dir);
console.log(chalk.cyan(`📂 Processing directory: ${fullPath}`));
await processDirectory(fullPath);
}
// Calculate total time
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
// Print summary
console.log('\n' + chalk.cyan('📊 Optimization Summary:'));
console.log(chalk.cyan('='.repeat(50)));
console.log(chalk.cyan(`Total files processed: ${stats.totalFiles}`));
console.log(chalk.green(`✓ Successfully optimized: ${stats.processedFiles}`));
if (stats.skippedFiles > 0) {
console.log(chalk.yellow(`⏭️ Skipped: ${stats.skippedFiles}`));
}
console.log(chalk.cyan(`💾 Total space saved: ${formatSize(stats.totalSaved)}`));
// Print stats by file type
console.log('\n' + chalk.cyan('📂 By file type:'));
for (const [type, data] of Object.entries(stats.byType)) {
console.log(` ${type.toUpperCase()}: ${data.count} files, saved ${formatSize(data.saved)}`);
}
console.log('\n' + chalk.green(`✨ Optimization completed in ${totalTime} seconds!`));
}
// Run the script
main().catch(error => {
console.error(chalk.red('❌ Fatal error:'), error);
process.exit(1);
});