UNPKG

vibe-janitor

Version:

A CLI tool that cleans AI-generated JavaScript/TypeScript projects efficiently and intelligently

298 lines (297 loc) 10.4 kB
import fs from 'fs-extra'; import path from 'path'; import glob from 'fast-glob'; import { Logger } from '../utils/logger.js'; /** * Manages detection and cleanup of unused static assets */ export class AssetSweeper { targetDir; options; sourceFiles = []; assets = { images: [], fonts: [], styles: [], }; constructor(targetDir, options = {}) { this.targetDir = targetDir; this.options = { dryRun: options.dryRun ?? false, verbose: options.verbose ?? false, includeImages: options.includeImages ?? true, includeFonts: options.includeFonts ?? true, includeStyles: options.includeStyles ?? true, deleteUnused: options.deleteUnused ?? false, }; } /** * Find all source files that might reference assets */ async findSourceFiles() { const sourcePatterns = [ // JavaScript/TypeScript files '**/*.{js,jsx,ts,tsx}', // Markdown and HTML files '**/*.{md,html,htm,vue,svelte}', // JSON files (could contain asset references) '**/*.json', // CSS/SCSS/LESS files '**/*.{css,scss,sass,less}', ]; const ignorePatterns = [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.git/**', '**/public/**', '**/static/**', '**/assets/**', '**/vendor/**', '**/out/**', '**/tmp/**', '**/temp/**', '**/cache/**', ]; if (this.options.verbose) { Logger.info('Finding source files that might reference assets...'); } try { this.sourceFiles = await glob(sourcePatterns, { cwd: this.targetDir, ignore: ignorePatterns, absolute: true, }); if (this.options.verbose) { Logger.info(`Found ${this.sourceFiles.length} source files to scan for asset references`); } } catch (error) { Logger.error(`Error finding source files: ${error instanceof Error ? error.message : String(error)}`); } } /** * Find all static assets in the project */ async findAssets() { const imagePatterns = this.options.includeImages ? ['**/*.{png,jpg,jpeg,gif,svg,webp,ico}'] : []; const fontPatterns = this.options.includeFonts ? ['**/*.{woff,woff2,eot,ttf,otf}'] : []; const stylePatterns = this.options.includeStyles ? ['**/*.{css,scss,sass,less}'] : []; const ignorePatterns = [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.git/**', '**/public/**', '**/static/**', '**/assets/**', ]; if (this.options.verbose) { Logger.info('Finding assets in project...'); } try { if (this.options.includeImages) { this.assets.images = await glob(imagePatterns, { cwd: this.targetDir, ignore: ignorePatterns, absolute: true, }); if (this.options.verbose) { Logger.info(`Found ${this.assets.images.length} image files`); } } if (this.options.includeFonts) { this.assets.fonts = await glob(fontPatterns, { cwd: this.targetDir, ignore: ignorePatterns, absolute: true, }); if (this.options.verbose) { Logger.info(`Found ${this.assets.fonts.length} font files`); } } if (this.options.includeStyles) { this.assets.styles = await glob(stylePatterns, { cwd: this.targetDir, ignore: ignorePatterns, absolute: true, }); if (this.options.verbose) { Logger.info(`Found ${this.assets.styles.length} style files`); } } } catch (error) { Logger.error(`Error finding assets: ${error instanceof Error ? error.message : String(error)}`); } } /** * Check if an asset is referenced in any source file */ isAssetReferenced(assetPath) { // Get all possible ways this asset might be referenced const assetName = path.basename(assetPath); const assetExt = path.extname(assetPath); const assetNameWithoutExt = path.basename(assetPath, assetExt); // Get relative paths that might be used in imports const relativeToProject = path.relative(this.targetDir, assetPath).replace(/\\/g, '/'); // Different ways the asset might be referenced const possibleReferences = [ assetName, relativeToProject, `/${relativeToProject}`, `./${relativeToProject}`, `${assetNameWithoutExt}${assetExt}`, ]; // For each source file, check if it contains a reference to the asset for (const sourceFile of this.sourceFiles) { try { const content = fs.readFileSync(sourceFile, 'utf8'); for (const ref of possibleReferences) { if (content.includes(ref)) { return true; } } } catch { // Skip files we can't read continue; } } return false; } /** * Find unused assets by checking for references in source files */ async findUnusedAssets() { const result = { unusedImages: [], unusedFonts: [], unusedStyles: [], totalSize: 0, deletedAssets: [], }; if (this.options.verbose) { Logger.info('Analyzing assets for usage...'); } // Process images for (const imagePath of this.assets.images) { if (!this.isAssetReferenced(imagePath)) { result.unusedImages.push(imagePath); try { const stats = await fs.stat(imagePath); result.totalSize += stats.size; } catch { // Skip files we can't stat } } } // Process fonts for (const fontPath of this.assets.fonts) { if (!this.isAssetReferenced(fontPath)) { result.unusedFonts.push(fontPath); try { const stats = await fs.stat(fontPath); result.totalSize += stats.size; } catch { // Skip files we can't stat } } } // Process styles for (const stylePath of this.assets.styles) { if (!this.isAssetReferenced(stylePath)) { result.unusedStyles.push(stylePath); try { const stats = await fs.stat(stylePath); result.totalSize += stats.size; } catch { // Skip files we can't stat } } } return result; } /** * Format file size in a human-readable way */ formatSize(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } /** * Check if an asset is protected and should not be deleted */ isProtectedAsset(assetPath) { const protectedPatterns = ['**/static/**', '**/public/**', '**/assets/**', '**/global.css']; // Check if the path contains any of the protected directories for (const pattern of protectedPatterns) { if (assetPath.includes(pattern.replace(/\*/g, ''))) { return true; } } return false; } /** * Delete unused assets if configured to do so */ async deleteUnusedAssets(result) { if (!this.options.deleteUnused || this.options.dryRun) { return; } const allUnused = [...result.unusedImages, ...result.unusedFonts, ...result.unusedStyles]; if (this.options.verbose) { Logger.info(`Deleting ${allUnused.length} unused assets...`); } for (const assetPath of allUnused) { try { // Skip protected assets if (this.isProtectedAsset(assetPath)) { if (this.options.verbose) { Logger.info(`Skipping protected asset: ${assetPath}`); } continue; } await fs.remove(assetPath); result.deletedAssets.push(assetPath); if (this.options.verbose) { Logger.success(`Deleted unused asset: ${assetPath}`); } } catch (error) { Logger.error(`Failed to delete asset ${assetPath}: ${error instanceof Error ? error.message : String(error)}`); } } } /** * Run the asset sweeping process */ async sweep() { await this.findSourceFiles(); await this.findAssets(); const result = await this.findUnusedAssets(); if (this.options.verbose) { Logger.info(`Found ${result.unusedImages.length} unused images`); Logger.info(`Found ${result.unusedFonts.length} unused fonts`); Logger.info(`Found ${result.unusedStyles.length} unused style files`); Logger.info(`Total potential space savings: ${this.formatSize(result.totalSize)}`); } if (this.options.deleteUnused) { await this.deleteUnusedAssets(result); } return result; } }