UNPKG

css-unused-cleaner

Version:

Detect and remove unused CSS selectors with intuitive browser UI. Analyze HTML/CSS files and safely clean up unused styles with real-time preview.

320 lines (273 loc) 9.49 kB
const fs = require('fs-extra'); const path = require('path'); const cheerio = require('cheerio'); const postcss = require('postcss'); const { PurgeCSS } = require('purgecss'); const globby = require('globby'); const debug = require('debug')('css-cleaner:analyzer'); /** * Analyze project to find HTML and CSS files and determine unused selectors * @param {string} projectRoot - Root directory to analyze * @returns {Object} Analysis result with selectors, stats, and file info */ async function analyzeProject(projectRoot) { debug(`Starting analysis of project: ${projectRoot}`); try { // Find HTML files const htmlFiles = await findHtmlFiles(projectRoot); debug(`Found ${htmlFiles.length} HTML files:`, htmlFiles); if (htmlFiles.length === 0) { throw new Error('No HTML files found in the project'); } // Extract CSS file references from HTML const cssFiles = await extractCssFiles(htmlFiles, projectRoot); debug(`Found ${cssFiles.length} CSS files:`, cssFiles); if (cssFiles.length === 0) { throw new Error('No CSS files found in HTML references'); } // Parse CSS files and extract selectors const allSelectors = await parseAllCssFiles(cssFiles); debug(`Extracted ${Object.keys(allSelectors).length} selectors from CSS files`); // Analyze selector usage with PurgeCSS const unusedSelectors = await findUnusedSelectors(htmlFiles, cssFiles); debug(`Found ${unusedSelectors.length} potentially unused selectors`); // Build selector state object const selectorStates = buildSelectorStates(allSelectors, unusedSelectors); // Calculate statistics const stats = calculateStats(selectorStates); const result = { timestamp: new Date().toISOString(), projectRoot, selectors: selectorStates, stats, files: { html: htmlFiles.map(f => path.relative(projectRoot, f)), css: cssFiles.map(f => path.relative(projectRoot, f)) } }; debug('Analysis complete', stats); return result; } catch (error) { debug('Analysis failed:', error.message); throw error; } } /** * Find all HTML files in the project directory * @param {string} projectRoot - Root directory to search * @returns {Array<string>} Array of HTML file paths */ async function findHtmlFiles(projectRoot) { const pattern = path.join(projectRoot, '**/*.html').replace(/\\/g, '/'); const files = await globby([pattern], { ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'], absolute: true }); return files; } /** * Extract CSS file references from HTML files * @param {Array<string>} htmlFiles - Array of HTML file paths * @param {string} projectRoot - Project root directory * @returns {Array<string>} Array of CSS file paths */ async function extractCssFiles(htmlFiles, projectRoot) { const cssFiles = new Set(); for (const htmlFile of htmlFiles) { try { const htmlContent = await fs.readFile(htmlFile, 'utf8'); const $ = cheerio.load(htmlContent); // Find CSS link tags $('link[rel="stylesheet"]').each((_, element) => { const href = $(element).attr('href'); if (href && !href.startsWith('http')) { const cssPath = path.resolve(path.dirname(htmlFile), href); if (fs.existsSync(cssPath)) { cssFiles.add(cssPath); } } }); // Find inline style tags (for future enhancement) $('style').each((_, element) => { // Could extract inline CSS here }); } catch (error) { debug(`Error reading HTML file ${htmlFile}:`, error.message); } } return Array.from(cssFiles); } /** * Parse all CSS files and extract selectors * @param {Array<string>} cssFiles - Array of CSS file paths * @returns {Object} Object mapping selectors to their CSS content and metadata */ async function parseAllCssFiles(cssFiles) { const allSelectors = {}; for (const cssFile of cssFiles) { try { const cssContent = await fs.readFile(cssFile, 'utf8'); const root = postcss.parse(cssContent); const fileName = path.basename(cssFile); root.walkRules(rule => { const selectors = rule.selector.split(',').map(s => s.trim()); selectors.forEach(selector => { if (!allSelectors[selector]) { allSelectors[selector] = { css: '', files: [], rules: [] }; } allSelectors[selector].css += rule.toString() + '\n'; if (!allSelectors[selector].files.includes(fileName)) { allSelectors[selector].files.push(fileName); } allSelectors[selector].rules.push({ selector: rule.selector, declarations: rule.toString() }); }); }); } catch (error) { debug(`Error parsing CSS file ${cssFile}:`, error.message); } } return allSelectors; } /** * Find unused selectors using PurgeCSS * @param {Array<string>} htmlFiles - Array of HTML file paths * @param {Array<string>} cssFiles - Array of CSS file paths * @returns {Array<string>} Array of unused selector names */ async function findUnusedSelectors(htmlFiles, cssFiles) { try { const purgeCSSResults = await new PurgeCSS().purge({ content: htmlFiles, css: cssFiles, rejected: true, variables: true }); const rejectedSelectors = []; purgeCSSResults.forEach(result => { if (result.rejected) { result.rejected.forEach(selector => { rejectedSelectors.push(selector); }); } }); return rejectedSelectors; } catch (error) { debug('PurgeCSS analysis failed, using fallback method:', error.message); return await findUnusedSelectorsBasic(htmlFiles, cssFiles); } } /** * Basic fallback method to find unused selectors * @param {Array<string>} htmlFiles - Array of HTML file paths * @param {Array<string>} cssFiles - Array of CSS file paths * @returns {Array<string>} Array of potentially unused selector names */ async function findUnusedSelectorsBasic(htmlFiles, cssFiles) { const usedClasses = new Set(); const usedIds = new Set(); const usedTags = new Set(); // Extract used selectors from HTML for (const htmlFile of htmlFiles) { try { const htmlContent = await fs.readFile(htmlFile, 'utf8'); const $ = cheerio.load(htmlContent); // Extract classes $('[class]').each((_, element) => { const classes = $(element).attr('class').split(/\s+/); classes.forEach(cls => { if (cls.trim()) usedClasses.add(cls.trim()); }); }); // Extract IDs $('[id]').each((_, element) => { const id = $(element).attr('id'); if (id) usedIds.add(id); }); // Extract tag names $('*').each((_, element) => { usedTags.add(element.tagName?.toLowerCase()); }); } catch (error) { debug(`Error analyzing HTML file ${htmlFile}:`, error.message); } } // Get all CSS selectors const allSelectors = await parseAllCssFiles(cssFiles); const unusedSelectors = []; // Simple heuristic to find unused selectors Object.keys(allSelectors).forEach(selector => { const cleanSelector = selector.replace(/[:\[\]>~+\s]/g, ''); if (selector.startsWith('.')) { const className = selector.substring(1).split(/[:\[\]>~+\s]/)[0]; if (!usedClasses.has(className)) { unusedSelectors.push(selector); } } else if (selector.startsWith('#')) { const idName = selector.substring(1).split(/[:\[\]>~+\s]/)[0]; if (!usedIds.has(idName)) { unusedSelectors.push(selector); } } else { const tagName = selector.split(/[:\[\]>~+\s]/)[0].toLowerCase(); if (!usedTags.has(tagName) && !['html', 'body', '*'].includes(tagName)) { unusedSelectors.push(selector); } } }); return unusedSelectors; } /** * Build selector state object for UI * @param {Object} allSelectors - All parsed selectors * @param {Array<string>} unusedSelectors - List of unused selectors * @returns {Object} Selector states for UI */ function buildSelectorStates(allSelectors, unusedSelectors) { const selectorStates = {}; const unusedSet = new Set(unusedSelectors); Object.keys(allSelectors).forEach(selector => { const isUnused = unusedSet.has(selector); selectorStates[selector] = { unused: isUnused, active: !isUnused, // Default: enable used selectors, disable unused ones css: allSelectors[selector].css, files: allSelectors[selector].files, usedIn: [] // To be populated by more detailed analysis if needed }; }); return selectorStates; } /** * Calculate statistics for the analysis * @param {Object} selectorStates - Selector states object * @returns {Object} Statistics object */ function calculateStats(selectorStates) { const total = Object.keys(selectorStates).length; let unused = 0; let disabled = 0; Object.values(selectorStates).forEach(state => { if (state.unused) unused++; if (!state.active) disabled++; }); return { total, unused, disabled, used: total - unused }; } module.exports = { analyzeProject, findHtmlFiles, extractCssFiles, parseAllCssFiles, findUnusedSelectors };