const fs = require('node:fs'); const path = require('node:path'); const sharp = require('sharp'); const distAssetRoots = [ path.join(process.cwd(), 'dist', 'assets'), path.join(process.cwd(), 'dist', 'browser', 'assets'), ]; const losslessTransformers = { '.jpg': (pipeline) => pipeline.jpeg({ quality: 90, mozjpeg: true, chromaSubsampling: '4:4:4', optimizeScans: true, }), '.jpeg': (pipeline) => pipeline.jpeg({ quality: 90, mozjpeg: true, chromaSubsampling: '4:4:4', optimizeScans: true, }), '.png': (pipeline) => pipeline.png({ compressionLevel: 9, effort: 10 }), '.gif': (pipeline) => pipeline.gif({ effort: 10, colours: 256 }), '.webp': (pipeline) => pipeline.webp({ lossless: true, effort: 6 }), '.avif': (pipeline) => pipeline.avif({ lossless: true, effort: 9 }), '.tif': (pipeline) => pipeline.tiff({ compression: 'lzw' }), '.tiff': (pipeline) => pipeline.tiff({ compression: 'lzw' }), }; const imageExtensions = new Set([ '.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif', '.svg', '.tif', '.tiff', '.bmp', ]); const skippedNoLosslessEncoder = new Set(['.svg', '.bmp']); function getAllFilesRecursively(directory) { const entries = fs.readdirSync(directory, { withFileTypes: true }); const files = []; for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { files.push(...getAllFilesRecursively(fullPath)); continue; } if (entry.isFile()) { files.push(fullPath); } } return files; } async function compressLossless(filePath) { const ext = path.extname(filePath).toLowerCase(); const transform = losslessTransformers[ext]; if (!transform) { return { status: 'unsupported', beforeBytes: 0, afterBytes: 0 }; } const beforeBuffer = fs.readFileSync(filePath); const beforeBytes = beforeBuffer.byteLength; const optimizedBuffer = await transform(sharp(beforeBuffer)).toBuffer(); const afterBytes = optimizedBuffer.byteLength; if (afterBytes < beforeBytes) { fs.writeFileSync(filePath, optimizedBuffer); return { status: 'optimized', beforeBytes, afterBytes }; } return { status: 'unchanged', beforeBytes, afterBytes: beforeBytes }; } async function run() { const existingRoots = distAssetRoots .filter((rootPath) => fs.existsSync(rootPath)) .filter((rootPath, index, list) => list.indexOf(rootPath) === index); if (existingRoots.length === 0) { console.log('Asset compression skipped: no dist asset directory found.'); return; } const imageFiles = existingRoots .flatMap((rootPath) => getAllFilesRecursively(rootPath)) .filter((filePath) => imageExtensions.has(path.extname(filePath).toLowerCase())); let optimizedCount = 0; let unchangedCount = 0; let skippedCount = 0; let totalSavedBytes = 0; for (const filePath of imageFiles) { const ext = path.extname(filePath).toLowerCase(); if (skippedNoLosslessEncoder.has(ext)) { skippedCount += 1; console.log(`Skip (no lossless encoder configured): ${path.relative(process.cwd(), filePath)}`); continue; } try { const result = await compressLossless(filePath); if (result.status === 'optimized') { optimizedCount += 1; const savedBytes = result.beforeBytes - result.afterBytes; totalSavedBytes += savedBytes; console.log(`Optimized: ${path.relative(process.cwd(), filePath)} (-${savedBytes} bytes)`); } else if (result.status === 'unchanged') { unchangedCount += 1; console.log(`Unchanged: ${path.relative(process.cwd(), filePath)}`); } else { skippedCount += 1; } } catch (error) { skippedCount += 1; const message = error instanceof Error ? error.message : String(error); console.warn(`Skip (error): ${path.relative(process.cwd(), filePath)} (${message})`); } } console.log( `Asset compression complete. Optimized: ${optimizedCount}, unchanged: ${unchangedCount}, skipped: ${skippedCount}, saved: ${totalSavedBytes} bytes.`, ); } run() .then(() => { process.exit(0); }) .catch((error) => { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); console.error(`Asset compression failed: ${message}`); process.exit(1); });