UNPKG

vibe-janitor

Version:

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

385 lines (384 loc) 16.6 kB
import fs from 'fs-extra'; import path from 'path'; import glob from 'fast-glob'; import * as cssTree from 'css-tree'; import { Logger } from '../utils/logger.js'; /** * Manages detection and cleanup of unused CSS classes and selectors */ export class StyleCleaner { targetDir; options; sourceFiles = []; styleFiles = []; classUsagePatterns = [ /className=["|'](.*?)["|']/g, // React className /class=["|'](.*?)["|']/g, // HTML class /classList\.add\(["|'](.*?)["|']\)/g, // DOM classList.add /\.([\w-]+)/g, // CSS class reference in JS files /\bclass: ['|"](.*?)['|"]/g, // Vue class binding ]; constructor(targetDir, options = {}) { this.targetDir = targetDir; this.options = { dryRun: options.dryRun ?? false, verbose: options.verbose ?? false, removeUnused: options.removeUnused ?? false, scanComponents: options.scanComponents ?? true, }; } /** * Find all source and style files in the project */ async findFiles() { const sourcePatterns = [ // JavaScript/TypeScript files '**/*.{js,jsx,ts,tsx}', // HTML files and templating systems '**/*.{html,htm,vue,svelte}', // JSX in MD files (for MDX) '**/*.{mdx,md}', ]; const stylePatterns = [ // CSS file types '**/*.{css,scss,sass,less}', // CSS-in-JS files '**/*.styles.{js,ts}', ]; const ignorePatterns = [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.git/**', '**/public/**', '**/static/**', '**/assets/**', '**/global.css', ]; if (this.options.verbose) { Logger.info('Finding source and style files...'); } try { // Find all source files that might use CSS classes this.sourceFiles = await glob(sourcePatterns, { cwd: this.targetDir, ignore: ignorePatterns, absolute: true, }); // Find all style files that might contain CSS class definitions this.styleFiles = await glob(stylePatterns, { cwd: this.targetDir, ignore: ignorePatterns, absolute: true, }); if (this.options.verbose) { Logger.info(`Found ${this.sourceFiles.length} source files and ${this.styleFiles.length} style files`); } } catch (error) { Logger.error(`Error finding files: ${error instanceof Error ? error.message : String(error)}`); } } /** * Parse CSS files to extract all class selectors */ async extractStyleDefinitions() { const styleDefinitions = []; for (const file of this.styleFiles) { try { const content = await fs.readFile(file, 'utf8'); const definition = { file, selectors: [], }; // Parse the CSS content try { // Use css-tree to parse the CSS and get position information const ast = cssTree.parse(content, { positions: true, filename: file, }); // Walk through all selectors in the CSS cssTree.walk(ast, { visit: 'Rule', enter: (node) => { if (node.prelude && node.prelude.type === 'SelectorList') { cssTree.walk(node.prelude, { visit: 'ClassSelector', enter: (classNode) => { const location = classNode.loc; if (location && classNode.name) { definition.selectors.push({ selector: `.${classNode.name}`, file, line: location.start.line, column: location.start.column, used: false, // Will be marked as used during scanning }); } }, }); } }, }); styleDefinitions.push(definition); } catch (parseError) { Logger.warn(`Error parsing CSS file ${file}: ${parseError instanceof Error ? parseError.message : String(parseError)}`); } } catch (error) { Logger.error(`Error reading CSS file ${file}: ${error instanceof Error ? error.message : String(error)}`); } } return styleDefinitions; } /** * Scan source files to find class usages */ async findClassUsages(styleDefinitions) { // Create a flattened map of all selectors for quick lookup const classMap = new Map(); // First, populate the map with all the classes we've found for (const definition of styleDefinitions) { for (const selector of definition.selectors) { // Only look at actual class selectors (starting with .) if (selector.selector.startsWith('.')) { // Extract the class name without the dot const className = selector.selector.substring(1); classMap.set(className, selector); } } } // Now search for class usages in source files for (const file of this.sourceFiles) { try { const content = await fs.readFile(file, 'utf8'); // Scan for different class usage patterns for (const pattern of this.classUsagePatterns) { const matches = content.matchAll(pattern); for (const match of matches) { if (match[1]) { // Handle space-separated class lists const classes = match[1].split(/\s+/); for (const className of classes) { if (classMap.get(className?.trim())) { const selector = classMap.get(className.trim()); if (selector) { selector.used = true; } } } } } } // Process potential dynamic classes (className={variable} patterns) // For these, we'll mark any matching class selectors as potentially used // This is a conservative approach to avoid false positives if (this.options.scanComponents) { const dynamicClassMatches = content.match(/className={([^}]+)}/g); if (dynamicClassMatches) { // If we find dynamic class assignment, mark all selectors from this file as used // This is conservative to avoid removing potentially used classes for (const definition of styleDefinitions) { if (path.dirname(definition.file) === path.dirname(file)) { for (const selector of definition.selectors) { selector.used = true; } } } } } } catch (error) { Logger.info(`Error reading source file ${file}: ${error instanceof Error ? error.message : String(error)}`); } } } /** * Find unused CSS selectors */ findUnusedSelectors(styleDefinitions) { const result = []; for (const definition of styleDefinitions) { const unusedSelectors = definition.selectors .filter((selector) => !selector.used) .map((selector) => selector.selector); if (unusedSelectors.length > 0) { result.push({ file: definition.file, selectors: unusedSelectors, }); } } return result; } /** * Remove unused CSS selectors from style files */ async removeUnusedSelectors(unusedSelectors) { const modifiedFiles = []; let bytesRemoved = 0; if (this.options.dryRun || !this.options.removeUnused) { return { modifiedFiles, bytesRemoved }; } for (const entry of unusedSelectors) { try { const { file, selectors } = entry; // Skip protected files if (this.isProtectedFile(file)) { if (this.options.verbose) { Logger.info(`Skipping protected file: ${file}`); } continue; } const content = await fs.readFile(file, 'utf8'); const originalSize = content.length; // Parse the CSS content const ast = cssTree.parse(content); // Go through the AST and remove rules with any of the unused selectors cssTree.walk(ast, { visit: 'Rule', enter: function (node, item, list) { if (node.prelude && node.prelude.type === 'SelectorList') { // Check if any selectors need to be removed const toRemove = new Set(); let i = 0; cssTree.walk(node.prelude, { visit: 'Selector', enter: function (selector) { // Check if this selector contains any of our unused class selectors let containsUnusedClass = false; cssTree.walk(selector, { visit: 'ClassSelector', enter: function (classNode) { const fullSelector = `.${classNode.name}`; if (selectors.includes(fullSelector)) { containsUnusedClass = true; } }, }); if (containsUnusedClass) { toRemove.add(i); } i++; }, }); // If all selectors for this rule are unused, remove the rule if (toRemove.size === i) { list.remove(item); } } }, }); // Generate the cleaned CSS const cleanedContent = cssTree.generate(ast); // Calculate bytes saved const newSize = cleanedContent.length; bytesRemoved += originalSize - newSize; // Write the file if there were changes if (cleanedContent !== content) { await fs.writeFile(file, cleanedContent, 'utf8'); modifiedFiles.push(file); if (this.options.verbose) { Logger.success(`Cleaned ${file} (removed ${originalSize - newSize} bytes)`); } } } catch (error) { Logger.error(`Failed to clean file ${entry.file}: ${error instanceof Error ? error.message : String(error)}`); } } return { modifiedFiles, bytesRemoved }; } /** * 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 a file is protected and should not be modified */ isProtectedFile(filePath) { const protectedPatterns = ['**/global.css', '**/public/**', '**/static/**', '**/assets/**']; // Check if the file path contains any of the protected patterns for (const pattern of protectedPatterns) { // Convert glob pattern to a simple string check const simplePattern = pattern.replace(/\*\*\//g, '').replace(/\*/g, ''); if (filePath.includes(simplePattern)) { return true; } } return false; } /** * Run the style cleaning process */ async clean() { // Initialize result object const result = { analyzedFiles: 0, styleDefinitions: [], unusedSelectors: [], modifiedFiles: [], totalSelectorsFound: 0, totalUnusedSelectors: 0, bytesRemoved: 0, }; try { // Find all files to process await this.findFiles(); result.analyzedFiles = this.styleFiles.length; if (this.styleFiles.length === 0) { if (this.options.verbose) { Logger.info('No style files found to analyze.'); } return result; } // Extract style definitions from CSS files const styleDefinitions = await this.extractStyleDefinitions(); result.styleDefinitions = styleDefinitions; result.totalSelectorsFound = styleDefinitions.reduce((total, def) => total + def.selectors.length, 0); if (this.options.verbose) { Logger.info(`Found ${result.totalSelectorsFound} CSS selectors across ${styleDefinitions.length} files`); } // Find which class selectors are used in source files await this.findClassUsages(styleDefinitions); // Identify unused selectors const unusedSelectors = this.findUnusedSelectors(styleDefinitions); result.unusedSelectors = unusedSelectors; result.totalUnusedSelectors = unusedSelectors.reduce((total, file) => total + file.selectors.length, 0); if (this.options.verbose) { Logger.info(`Found ${result.totalUnusedSelectors} unused CSS selectors`); } // Remove unused selectors if requested if (!this.options.dryRun && this.options.removeUnused && result.totalUnusedSelectors > 0) { const { modifiedFiles, bytesRemoved } = await this.removeUnusedSelectors(unusedSelectors); result.modifiedFiles = modifiedFiles; result.bytesRemoved = bytesRemoved; if (this.options.verbose && modifiedFiles.length > 0) { Logger.success(`Cleaned ${modifiedFiles.length} CSS files (${this.formatSize(bytesRemoved)} removed)`); } } else if (result.totalUnusedSelectors > 0) { if (this.options.verbose) { Logger.info('Dry run mode: No CSS files were modified'); Logger.info('To remove unused CSS selectors, run with --remove-unused flag'); } } } catch (error) { Logger.error(`Error during style cleaning: ${error instanceof Error ? error.message : String(error)}`); } return result; } }