UNPKG

codebase-asset-optimizer

Version:

Professional CLI development tool for optimizing and managing assets in codebases. Detects unused assets, optimizes images to WebP, optimizes videos, and automatically replaces asset references. GIFs are preserved unchanged to maintain animation functiona

757 lines • 33.5 kB
import fs from "fs-extra"; import path from "path"; import os from "os"; import { execSync } from "child_process"; import sharp from "sharp"; import fastGlob from "fast-glob"; import mimeTypes from "mime-types"; /** * Main asset optimizer class with smart size detection */ export class AssetOptimizer { constructor(config) { this.assets = []; this.results = { totalAssets: 0, totalSize: 0, usedAssets: [], unusedAssets: [], largeAssets: [], optimizableAssets: [], summary: { usedCount: 0, usedSize: 0, unusedCount: 0, unusedSize: 0, largeUnusedCount: 0, largeUsedCount: 0, optimizableCount: 0, optimizableSize: 0, potentialSavings: 0, assetsByType: { images: { total: 0, used: 0, unused: 0, optimizable: 0 }, videos: { total: 0, used: 0, unused: 0, optimizable: 0 }, }, }, }; this.config = config; } /** * Reset audit results to initial state */ resetResults() { this.results = { totalAssets: 0, totalSize: 0, usedAssets: [], unusedAssets: [], largeAssets: [], optimizableAssets: [], summary: { usedCount: 0, usedSize: 0, unusedCount: 0, unusedSize: 0, largeUnusedCount: 0, largeUsedCount: 0, optimizableCount: 0, optimizableSize: 0, potentialSavings: 0, assetsByType: { images: { total: 0, used: 0, unused: 0, optimizable: 0 }, videos: { total: 0, used: 0, unused: 0, optimizable: 0 }, }, }, }; } /** * Run complete audit of assets */ async audit() { console.log("šŸ” Starting asset audit...\n"); // Reset results from previous audits this.resetResults(); await this.discoverAssets(); await this.checkAssetUsage(); await this.identifyOptimizableAssets(); await this.generateReport(); return this.results; } /** * Discover all image and video assets */ async discoverAssets() { console.log("šŸ“ Discovering assets..."); const patterns = [ ...this.config.imageExtensions.map((ext) => `**/*${ext}`), ...this.config.videoExtensions.map((ext) => `**/*${ext}`), ]; const files = await fastGlob(patterns, { cwd: this.config.publicDir, ignore: this.config.excludePatterns, absolute: false, caseSensitiveMatch: false, }); this.assets = []; for (const relativePath of files) { const fullPath = path.join(this.config.publicDir, relativePath); const stats = await fs.stat(fullPath); const extension = path.extname(relativePath).toLowerCase(); const assetType = this.getAssetType(extension); const mimeType = mimeTypes.lookup(extension) || undefined; const asset = { filename: path.basename(relativePath), fullPath, relativePath, size: stats.size, extension, assetType, used: false, references: [], optimizable: await this.isOptimizable(fullPath, extension, assetType), mimeType, }; this.assets.push(asset); this.results.totalSize += stats.size; } this.results.totalAssets = this.assets.length; console.log(` Found ${this.results.totalAssets} assets (${this.formatBytes(this.results.totalSize)})`); } /** * Check if each asset is used in the codebase */ async checkAssetUsage() { console.log("šŸ”Ž Checking asset usage..."); let processed = 0; for (const asset of this.assets) { const isUsed = await this.isAssetUsed(asset); asset.used = isUsed; if (isUsed) { this.results.usedAssets.push(asset); } else { this.results.unusedAssets.push(asset); } if (asset.size > this.config.largeFileThreshold) { this.results.largeAssets.push(asset); } processed++; if (processed % 10 === 0) { process.stdout.write(` Processed ${processed}/${this.assets.length} assets...\r`); } } console.log(` Processed ${processed}/${this.assets.length} assets`); } /** * Check if an asset is used by searching for references */ async isAssetUsed(asset) { try { const searchPatterns = [ asset.filename, path.parse(asset.filename).name, asset.relativePath.replace(/\\/g, "/"), asset.relativePath.replace(/\\/g, "/").replace(/^\//, ""), ]; for (const pattern of searchPatterns) { // Use fast-glob to search for files containing the pattern const files = await fastGlob(["**/*.{ts,tsx,js,jsx,css,scss,json,md,html}"], { cwd: this.config.sourceDir, ignore: this.config.excludePatterns, absolute: true, }); for (const file of files) { try { const content = await fs.readFile(file, "utf8"); if (content.includes(pattern)) { asset.references.push({ file: path.relative(this.config.projectRoot, file), pattern, context: `Found pattern: ${pattern}`, }); return true; } } catch (error) { // Skip files that can't be read continue; } } } return false; } catch (error) { console.warn(` āš ļø Error checking ${asset.filename}: ${error}`); return true; // Assume used to be safe } } /** * Identify assets that can be optimized */ async identifyOptimizableAssets() { console.log("šŸŽÆ Identifying optimization opportunities..."); this.results.optimizableAssets = this.assets.filter((asset) => { if (!asset.used) return false; // Don't optimize unused assets return asset.optimizable; }); console.log(` Found ${this.results.optimizableAssets.length} assets that can be optimized`); } /** * Check if an asset can be optimized */ async isOptimizable(filePath, extension, assetType) { if (assetType === "image") { // Don't optimize SVGs as they're already optimized vectors if (extension === ".svg") return false; // Don't optimize GIFs - they are often animations and conversion could break functionality if (extension === ".gif") return false; // Only optimize if target format is different and likely to be smaller return extension !== `.${this.config.optimization.images.targetFormat}`; } if (assetType === "video") { // Always try to optimize videos that are larger than 1MB, regardless of format // Even MP4/WebM files can often be compressed further with better encoding settings const stats = await fs.stat(filePath); const fileSizeThreshold = 1 * 1024 * 1024; // 1MB return stats.size > fileSizeThreshold; } return false; } /** * Optimize assets with smart size detection */ async optimizeAssets(createBackup = true) { const startTime = Date.now(); const stats = { processed: 0, optimized: 0, failed: 0, sizeBefore: 0, sizeAfter: 0, duration: 0, }; if (this.results.optimizableAssets.length === 0) { console.log("āœ… No assets need optimization!"); return stats; } console.log(`\nšŸŽÆ Optimizing ${this.results.optimizableAssets.length} assets...`); // Create backup if requested let backupInfo = null; if (createBackup) { backupInfo = await this.createBackup(this.results.optimizableAssets); } for (const asset of this.results.optimizableAssets) { try { console.log(` šŸ”„ Optimizing: ${asset.filename}`); stats.processed++; stats.sizeBefore += asset.size; let result; if (asset.assetType === "image") { result = await this.optimizeImage(asset); } else { result = await this.optimizeVideo(asset); } if (result.success) { // Critical: Only use optimization if it actually reduces size if (result.newSize < result.originalSize) { stats.optimized++; stats.sizeAfter += result.newSize; // Replace the original file with optimized version await this.replaceAsset(asset, result.outputPath); console.log(` āœ“ ${this.formatBytes(result.originalSize)} → ${this.formatBytes(result.newSize)} (${result.savings}% smaller)`); } else { // Optimization made file larger, keep original stats.sizeAfter += result.originalSize; if (result.outputPath && (await fs.pathExists(result.outputPath))) { await fs.remove(result.outputPath); } console.log(` ↪ Kept original (optimization would increase size)`); } } else { stats.failed++; stats.sizeAfter += asset.size; console.log(` āŒ Failed: ${result.error}`); } } catch (error) { stats.failed++; stats.sizeAfter += asset.size; console.error(` āŒ Error optimizing ${asset.filename}: ${error}`); } } stats.duration = Date.now() - startTime; const totalSavings = stats.sizeBefore - stats.sizeAfter; const savingsPercent = stats.sizeBefore > 0 ? Math.round((totalSavings / stats.sizeBefore) * 100) : 0; console.log(`\nāœ… Optimization complete:`); console.log(` Assets processed: ${stats.processed}`); console.log(` Successfully optimized: ${stats.optimized}`); console.log(` Failed: ${stats.failed}`); console.log(` Total savings: ${this.formatBytes(totalSavings)} (${savingsPercent}% reduction)`); console.log(` Duration: ${(stats.duration / 1000).toFixed(1)}s`); if (backupInfo) { console.log(` Backup created: ${backupInfo.backupPath}`); } return stats; } /** * Optimize a single image using Sharp with size verification */ async optimizeImage(asset) { try { // Create a temporary file in the OS temp directory const tempDir = os.tmpdir(); const tempFileName = `optimized-${Date.now()}-${Math.random().toString(36).substring(7)}.${this.config.optimization.images.targetFormat}`; const outputPath = path.join(tempDir, tempFileName); // Use Sharp for reliable image optimization const sharpInstance = sharp(asset.fullPath); // Get original metadata to check if resize is needed const metadata = await sharpInstance.metadata(); const pipeline = sharpInstance.clone(); // Only resize if image is larger than max dimensions if (metadata.width && metadata.height) { if (metadata.width > this.config.optimization.images.maxWidth || metadata.height > this.config.optimization.images.maxHeight) { pipeline.resize(this.config.optimization.images.maxWidth, this.config.optimization.images.maxHeight, { fit: "inside", withoutEnlargement: true }); } } // Apply format-specific optimization if (this.config.optimization.images.targetFormat === "webp") { pipeline.webp({ quality: this.config.optimization.images.quality, effort: 6, // Better compression smartSubsample: true, }); } else if (this.config.optimization.images.targetFormat === "jpeg") { pipeline.jpeg({ quality: this.config.optimization.images.quality, progressive: true, mozjpeg: true, // Better compression }); } else if (this.config.optimization.images.targetFormat === "png") { pipeline.png({ quality: this.config.optimization.images.quality, compressionLevel: 9, // Maximum compression progressive: true, }); } await pipeline.toFile(outputPath); const newStats = await fs.stat(outputPath); const savings = Math.round(((asset.size - newStats.size) / asset.size) * 100); return { success: true, originalSize: asset.size, newSize: newStats.size, savings, format: this.config.optimization.images.targetFormat, outputPath, }; } catch (error) { return { success: false, originalSize: asset.size, newSize: asset.size, savings: 0, error: String(error), }; } } /** * Optimize a single video using FFmpeg with size verification */ async optimizeVideo(asset) { try { let bestResult = null; for (const format of this.config.optimization.videos.targetFormats) { // Create a temporary file in the OS temp directory const tempDir = os.tmpdir(); const tempFileName = `optimized-${Date.now()}-${Math.random().toString(36).substring(7)}.${format}`; const outputPath = path.join(tempDir, tempFileName); try { let ffmpegCommand; if (format === "webm") { ffmpegCommand = `ffmpeg -i "${asset.fullPath}" -c:v libvpx-vp9 -crf 30 -b:v 0 -b:a 128k -c:a libopus -vf "scale=${this.config.optimization.videos.maxWidth}:${this.config.optimization.videos.maxHeight}:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2" "${outputPath}" -y`; } else if (format === "mp4") { ffmpegCommand = `ffmpeg -i "${asset.fullPath}" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -vf "scale=${this.config.optimization.videos.maxWidth}:${this.config.optimization.videos.maxHeight}:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2" "${outputPath}" -y`; } else { continue; } execSync(ffmpegCommand, { stdio: "pipe" }); if (await fs.pathExists(outputPath)) { const newStats = await fs.stat(outputPath); const savings = Math.round(((asset.size - newStats.size) / asset.size) * 100); const result = { success: true, originalSize: asset.size, newSize: newStats.size, savings, format, outputPath, }; // Keep the best (smallest) result if (!bestResult || newStats.size < bestResult.newSize) { // Remove previous best if it exists if (bestResult?.outputPath && bestResult.outputPath !== outputPath) { await fs.remove(bestResult.outputPath); } bestResult = result; } else { // Remove this one since it's not the best await fs.remove(outputPath); } } } catch (formatError) { console.warn(` āš ļø Failed to convert to ${format}: ${formatError}`); } } return (bestResult || { success: false, originalSize: asset.size, newSize: asset.size, savings: 0, error: "All video optimization formats failed", }); } catch (error) { return { success: false, originalSize: asset.size, newSize: asset.size, savings: 0, error: String(error), }; } } /** * Replace original asset with optimized version and update references */ async replaceAsset(originalAsset, optimizedPath) { const originalExt = path.extname(originalAsset.filename); const newExt = path.extname(optimizedPath); if (originalExt !== newExt) { // Extension changed, we need to rename the file and update references const newFilename = path.parse(originalAsset.filename).name + newExt; const newFullPath = path.join(path.dirname(originalAsset.fullPath), newFilename); // Move optimized file to new location with new extension await fs.move(optimizedPath, newFullPath, { overwrite: true }); // Remove original file if it exists if (await fs.pathExists(originalAsset.fullPath)) { await fs.remove(originalAsset.fullPath); } // Update all references to use new extension await this.updateAssetReferences(originalAsset, newExt); } else { // Same extension, just replace the file await fs.move(optimizedPath, originalAsset.fullPath, { overwrite: true }); } } /** * Update all references to an asset with new extension */ async updateAssetReferences(asset, newExtension) { const operations = []; const newFilename = path.parse(asset.filename).name + newExtension; const newRelativePath = path .join(path.dirname(asset.relativePath), newFilename) .replace(/\\/g, "/"); console.log(` šŸ“ Updating references from ${asset.filename} to ${newFilename}`); // Find all files that might reference this asset const sourceFiles = await fastGlob(["**/*.{ts,tsx,js,jsx,css,scss,json,md,html,vue,svelte}"], { cwd: this.config.sourceDir, ignore: this.config.excludePatterns, absolute: true, }); for (const file of sourceFiles) { try { const content = await fs.readFile(file, "utf8"); let updatedContent = content; let totalReplacements = 0; // Create multiple pattern variations to catch different reference styles const searchPatterns = [ // Exact filename asset.filename, // Relative path with leading slash "/" + asset.relativePath.replace(/\\/g, "/"), // Relative path without leading slash asset.relativePath.replace(/\\/g, "/").replace(/^\//, ""), // Just the path part for imports asset.relativePath.replace(/\\/g, "/"), // Relative paths with parent directory navigation "../public/" + asset.relativePath.replace(/\\/g, "/"), "../../public/" + asset.relativePath.replace(/\\/g, "/"), "../" + asset.relativePath.replace(/\\/g, "/"), // URL-encoded versions encodeURI(asset.filename), encodeURI(asset.relativePath.replace(/\\/g, "/")), ]; const replacementPatterns = [ newFilename, "/" + newRelativePath, newRelativePath.replace(/^\//, ""), newRelativePath, // Relative paths with parent directory navigation "../public/" + newRelativePath, "../../public/" + newRelativePath, "../" + newRelativePath, encodeURI(newFilename), encodeURI(newRelativePath), ]; // Remove duplicates const uniquePatterns = [...new Set(searchPatterns)]; const uniqueReplacements = [...new Set(replacementPatterns)]; for (let i = 0; i < uniquePatterns.length && i < uniqueReplacements.length; i++) { const searchPattern = uniquePatterns[i]; const replacement = uniqueReplacements[i]; if (searchPattern && replacement && updatedContent.includes(searchPattern)) { // Use word boundaries and quotes to avoid partial matches const escapedPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Try different quote patterns const patterns = [ new RegExp(`(['"\`])${escapedPattern}\\1`, "g"), // Quoted strings new RegExp(`(src=["'])${escapedPattern}(["'])`, "g"), // src attributes new RegExp(`(href=["'])${escapedPattern}(["'])`, "g"), // href attributes new RegExp(`(import.*["'])${escapedPattern}(["'])`, "g"), // imports new RegExp(`(\\burl\\(["']?)${escapedPattern}(["']?\\))`, "g"), // CSS url() ]; const replacements_arr = [ `$1${replacement}$1`, `$1${replacement}$2`, `$1${replacement}$2`, `$1${replacement}$2`, `$1${replacement}$2`, ]; for (let j = 0; j < patterns.length; j++) { const regex = patterns[j]; const repl = replacements_arr[j]; const matches = updatedContent.match(regex); if (matches) { updatedContent = updatedContent.replace(regex, repl); totalReplacements += matches.length; } } } } if (totalReplacements > 0) { await fs.writeFile(file, updatedContent); operations.push({ filePath: path.relative(this.config.projectRoot, file), originalPath: asset.relativePath, newPath: newRelativePath, replacements: totalReplacements, success: true, }); console.log(` āœ“ Updated ${totalReplacements} references in ${path.relative(this.config.projectRoot, file)}`); } } catch (error) { operations.push({ filePath: path.relative(this.config.projectRoot, file), originalPath: asset.relativePath, newPath: newRelativePath, replacements: 0, success: false, error: String(error), }); console.warn(` āš ļø Failed to update ${path.relative(this.config.projectRoot, file)}: ${error}`); } } return operations; } /** * Create backup of assets before optimization */ async createBackup(assets) { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupPath = path.join(this.config.backupDir, `backup-${timestamp}`); await fs.ensureDir(backupPath); const backedUpFiles = []; for (const asset of assets) { const backupFilePath = path.join(backupPath, asset.relativePath); await fs.ensureDir(path.dirname(backupFilePath)); await fs.copy(asset.fullPath, backupFilePath); backedUpFiles.push(asset.relativePath); } const backupInfo = { timestamp, files: backedUpFiles, backupPath, }; // Save backup manifest await fs.writeJson(path.join(backupPath, "manifest.json"), backupInfo, { spaces: 2, }); return backupInfo; } /** * Remove unused assets safely */ async removeUnusedAssets(createBackup = true) { if (this.results.unusedAssets.length === 0) { console.log("āœ… No unused assets to remove!"); return { removed: 0, savedSpace: 0 }; } console.log(`\nšŸ—‘ļø Removing ${this.results.unusedAssets.length} unused assets...`); let backupPath; if (createBackup) { const backupInfo = await this.createBackup(this.results.unusedAssets); backupPath = backupInfo.backupPath; } let removed = 0; let savedSpace = 0; for (const asset of this.results.unusedAssets) { try { await fs.remove(asset.fullPath); removed++; savedSpace += asset.size; console.log(` āœ“ Removed: ${asset.relativePath}`); } catch (error) { console.error(` āŒ Failed to remove ${asset.relativePath}: ${error}`); } } console.log(`\nāœ… Removed ${removed} assets, freed ${this.formatBytes(savedSpace)}`); if (backupPath) { console.log(`šŸ“¦ Backup created: ${backupPath}`); } return { removed, savedSpace, backupPath }; } /** * Generate detailed audit report */ async generateReport() { console.log("šŸ“Š Generating report..."); // Calculate summary statistics const unusedSize = this.results.unusedAssets.reduce((sum, asset) => sum + asset.size, 0); const usedSize = this.results.usedAssets.reduce((sum, asset) => sum + asset.size, 0); const optimizableSize = this.results.optimizableAssets.reduce((sum, asset) => sum + asset.size, 0); const assetsByType = { images: this.assets.filter((a) => a.assetType === "image"), videos: this.assets.filter((a) => a.assetType === "video"), }; this.results.summary = { usedCount: this.results.usedAssets.length, usedSize, unusedCount: this.results.unusedAssets.length, unusedSize, largeUnusedCount: this.results.largeAssets.filter((a) => !a.used).length, largeUsedCount: this.results.largeAssets.filter((a) => a.used).length, optimizableCount: this.results.optimizableAssets.length, optimizableSize, potentialSavings: unusedSize, assetsByType: { images: { total: assetsByType.images.length, used: assetsByType.images.filter((a) => a.used).length, unused: assetsByType.images.filter((a) => !a.used).length, optimizable: assetsByType.images.filter((a) => a.optimizable && a.used).length, }, videos: { total: assetsByType.videos.length, used: assetsByType.videos.filter((a) => a.used).length, unused: assetsByType.videos.filter((a) => !a.used).length, optimizable: assetsByType.videos.filter((a) => a.optimizable && a.used).length, }, }, }; // Sort assets by size for better reporting this.results.unusedAssets.sort((a, b) => b.size - a.size); this.results.usedAssets.sort((a, b) => b.size - a.size); this.results.optimizableAssets.sort((a, b) => b.size - a.size); // Save detailed report const reportData = { timestamp: new Date().toISOString(), config: this.config, summary: this.results.summary, assets: this.assets.map((asset) => ({ filename: asset.filename, relativePath: asset.relativePath, size: asset.size, extension: asset.extension, assetType: asset.assetType, used: asset.used, optimizable: asset.optimizable, references: asset.references.length, mimeType: asset.mimeType, })), }; await fs.writeJson(this.config.reportFile, reportData, { spaces: 2 }); console.log(` Report saved: ${this.config.reportFile}`); } /** * Display audit results */ displayResults() { const stats = this.results.summary; console.log("\nšŸ“ˆ ASSET AUDIT RESULTS"); console.log("=".repeat(50)); console.log(`Total Assets: ${this.results.totalAssets} (${this.formatBytes(this.results.totalSize)})`); console.log("\nšŸ“Š BY TYPE:"); console.log(`Images: ${stats.assetsByType.images.total} total (${stats.assetsByType.images.used} used, ${stats.assetsByType.images.unused} unused)`); console.log(`Videos: ${stats.assetsByType.videos.total} total (${stats.assetsByType.videos.used} used, ${stats.assetsByType.videos.unused} unused)`); console.log("\nšŸ’¾ USAGE:"); console.log(`Used: ${stats.usedCount} (${this.formatBytes(stats.usedSize)})`); console.log(`Unused: ${stats.unusedCount} (${this.formatBytes(stats.unusedSize)})`); console.log(`Large files (>500KB): ${stats.largeUsedCount} used, ${stats.largeUnusedCount} unused`); console.log("\nšŸŽÆ OPTIMIZATION:"); console.log(`Optimizable: ${stats.optimizableCount} (${this.formatBytes(stats.optimizableSize)})`); console.log(` - Images → WebP: ${stats.assetsByType.images.optimizable} (GIFs excluded)`); console.log(` - Videos → efficient: ${stats.assetsByType.videos.optimizable}`); if (stats.unusedCount > 0) { console.log("\nšŸ—‘ļø TOP UNUSED ASSETS:"); this.results.unusedAssets.slice(0, 10).forEach((asset, i) => { console.log(`${i + 1}. ${asset.filename} - ${this.formatBytes(asset.size)}`); }); } if (stats.optimizableCount > 0) { console.log("\nšŸŽÆ TOP OPTIMIZATION OPPORTUNITIES:"); this.results.optimizableAssets.slice(0, 10).forEach((asset, i) => { console.log(`${i + 1}. ${asset.filename} - ${this.formatBytes(asset.size)}`); }); } } // Helper methods getAssetType(extension) { if (this.config.imageExtensions.includes(extension)) return "image"; if (this.config.videoExtensions.includes(extension)) return "video"; return "image"; // fallback } formatBytes(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]; } // Getters for results get auditResults() { return this.results; } get unusedAssets() { return this.results.unusedAssets; } get optimizableAssets() { return this.results.optimizableAssets; } } //# sourceMappingURL=optimizer.js.map