UNPKG

@fe-fast/unused-css-pruner

Version:

A powerful CSS pruning tool that removes unused styles with support for dynamic class names, CSS-in-JS, and component-level analysis

375 lines (363 loc) 11.1 kB
import * as fs from 'fs'; import * as path from 'path'; import { fileExists, readFile } from './utils/index.js'; /** * Default configuration */ export const DEFAULT_CONFIG = { cssFiles: ['**/*.css'], sourceDirectories: ['src'], ignorePatterns: [ 'node_modules/**', 'dist/**', 'build/**', '.git/**', '**/*.min.css', '**/*.map' ], whitelist: [ // Common utility classes that might be used dynamically 'sr-only', 'visually-hidden', 'clearfix', // Framework classes /^wp-/, /^woocommerce-/, /^elementor-/, // State classes /^is-/, /^has-/, /^js-/, // Responsive classes /^sm:/, /^md:/, /^lg:/, /^xl:/, /^2xl:/ ], blacklist: [], dynamicClassPatterns: [ // Tailwind CSS JIT patterns /\b[a-z-]+:\w+/, /\b(bg|text|border|ring)-(red|blue|green|yellow|purple|pink|gray|indigo|orange)-(\d{2,3})/, /\b(w|h|p|m|px|py|mx|my|pt|pb|pl|pr|mt|mb|ml|mr)-(\d+|auto|full|screen)/, /\b(grid-cols|col-span|row-span)-(\d+)/, /\b(gap|space)-(x|y)?-(\d+)/, // CSS-in-JS patterns /\$\{[^}]+\}/, /`[^`]*\$\{[^}]+\}[^`]*/, // Template literal classes /\$\{.*?\}/, // Dynamic class construction /\b\w+\s*\+\s*['"`]/, /['"`]\s*\+\s*\w+/, // Conditional classes /\?\s*['"`][^'"` ]+['"`]/, /:\s*['"`][^'"` ]+['"`]/ ], fileExtensions: ['.vue', '.jsx', '.tsx', '.js', '.ts', '.html', '.php', '.twig'], reportFormat: 'console', outputFile: undefined, verbose: false, dryRun: false }; /** * Configuration file names to search for */ const CONFIG_FILES = [ 'css-pruner.config.js', 'css-pruner.config.json', '.css-prunerrc', '.css-prunerrc.json', '.css-prunerrc.js' ]; /** * Load configuration from file or use defaults */ export async function loadConfig(configPath) { let config = { ...DEFAULT_CONFIG }; // If specific config path provided if (configPath) { if (fileExists(configPath)) { const loadedConfig = await loadConfigFile(configPath); if (loadedConfig) { config = mergeConfig(config, loadedConfig); } } else { throw new Error(`Configuration file not found: ${configPath}`); } } else { // Search for config files in current directory const foundConfig = await findConfigFile(); if (foundConfig) { const loadedConfig = await loadConfigFile(foundConfig); if (loadedConfig) { config = mergeConfig(config, loadedConfig); } } } // Validate configuration validateConfig(config); return config; } /** * Find configuration file in current directory */ async function findConfigFile() { for (const configFile of CONFIG_FILES) { if (fileExists(configFile)) { return configFile; } } return null; } /** * Load configuration from a specific file */ async function loadConfigFile(filePath) { try { const ext = path.extname(filePath); if (ext === '.js') { // For .js files, we need to require them const absolutePath = path.resolve(filePath); delete require.cache[absolutePath]; // Clear cache const configModule = require(absolutePath); return configModule.default || configModule; } else { // For JSON files const content = readFile(filePath); if (content) { return JSON.parse(content); } } } catch (error) { console.warn(`Warning: Failed to load config file ${filePath}:`, error); } return null; } /** * Merge configuration objects */ function mergeConfig(defaultConfig, userConfig) { const merged = { ...defaultConfig }; // Handle backward compatibility: sourceFiles -> sourceDirectories if (userConfig.sourceFiles && !userConfig.sourceDirectories) { console.warn('Warning: "sourceFiles" is deprecated, please use "sourceDirectories" instead.'); userConfig.sourceDirectories = userConfig.sourceFiles; } // Simple merge for most properties Object.keys(userConfig).forEach(key => { const userValue = userConfig[key]; // Skip deprecated sourceFiles field if (key === 'sourceFiles') { return; } if (userValue !== undefined) { if (Array.isArray(userValue)) { // For arrays, replace completely merged[key] = [...userValue]; } else if (typeof userValue === 'object' && userValue !== null) { // For objects, merge recursively merged[key] = { ...merged[key], ...userValue }; } else { // For primitives, replace merged[key] = userValue; } } }); return merged; } /** * Validate configuration */ function validateConfig(config) { // Validate required fields if (!config.cssFiles || config.cssFiles.length === 0) { throw new Error('Configuration error: cssFiles cannot be empty'); } if (!config.sourceDirectories || config.sourceDirectories.length === 0) { throw new Error('Configuration error: sourceDirectories cannot be empty'); } // Validate report format const validFormats = ['console', 'json', 'html']; if (!validFormats.includes(config.reportFormat)) { throw new Error(`Configuration error: reportFormat must be one of: ${validFormats.join(', ')}`); } // Validate file extensions if (config.fileExtensions.some((ext) => !ext.startsWith('.'))) { throw new Error('Configuration error: fileExtensions must start with a dot (e.g., ".js")'); } // Validate patterns try { config.whitelist.forEach((pattern, index) => { if (typeof pattern === 'string') { // String patterns are OK } else if (pattern instanceof RegExp) { // Test the regex pattern.test('test'); } else { throw new Error(`Invalid whitelist pattern at index ${index}`); } }); config.blacklist.forEach((pattern, index) => { if (typeof pattern === 'string') { // String patterns are OK } else if (pattern instanceof RegExp) { // Test the regex pattern.test('test'); } else { throw new Error(`Invalid blacklist pattern at index ${index}`); } }); config.dynamicClassPatterns.forEach((pattern, index) => { if (pattern instanceof RegExp) { // Test the regex pattern.test('test'); } else { throw new Error(`Invalid dynamic class pattern at index ${index}`); } }); } catch (error) { throw new Error(`Configuration error: ${error}`); } } /** * Create a sample configuration file */ export function createSampleConfig(filePath = 'css-pruner.config.js') { const configContent = `module.exports = { // CSS files to analyze (glob patterns supported) cssFiles: ['**/*.css'], // Source directories to scan for class usage sourceDirectories: ['src'], // Patterns to ignore ignorePatterns: [ 'node_modules/**', 'dist/**', 'build/**', '.git/**', '**/*.min.css', '**/*.map' ], // Selectors to always keep (whitelist) whitelist: [ 'sr-only', 'visually-hidden', 'clearfix', /^wp-/, // WordPress classes /^woocommerce-/, // WooCommerce classes /^is-/, // State classes /^has-/, // State classes /^js-/, // JavaScript hooks /^sm:/, // Responsive prefixes /^md:/, /^lg:/, /^xl:/, /^2xl:/ ], // Selectors to always remove (blacklist) blacklist: [], // Patterns for dynamic class generation dynamicClassPatterns: [ // Tailwind CSS JIT patterns /\\b[a-z-]+:\\w+/, /\\b(bg|text|border|ring)-(red|blue|green|yellow|purple|pink|gray|indigo|orange)-(\\d{2,3})/, /\\b(w|h|p|m|px|py|mx|my|pt|pb|pl|pr|mt|mb|ml|mr)-(\\d+|auto|full|screen)/, // CSS-in-JS patterns /\\\$\\{[^}]+\\}/, /\\\`[^\\\`]*\\\$\\{[^}]+\\}[^\\\`]*\\\`/, // Dynamic class construction /\\b\\w+\\s*\\+\\s*['\"\\\`]/, /['\"\\\`]\\s*\\+\\s*\\w+/ ], // File extensions to scan fileExtensions: ['.vue', '.jsx', '.tsx', '.js', '.ts', '.html', '.php', '.twig'], // Report format: 'console', 'json', 'html' reportFormat: 'console', // Output file for reports (optional) outputFile: undefined, // Verbose logging verbose: false, // Dry run mode (don't actually remove CSS) dryRun: false };`; fs.writeFileSync(filePath, configContent); console.log('Sample configuration created: ' + filePath); } /** * Get configuration schema for validation */ export function getConfigSchema() { return { type: 'object', properties: { cssFiles: { type: 'array', items: { type: 'string' }, minItems: 1 }, sourceDirectories: { type: 'array', items: { type: 'string' }, minItems: 1 }, ignorePatterns: { type: 'array', items: { type: 'string' } }, whitelist: { type: 'array', items: { oneOf: [ { type: 'string' }, { type: 'object' } // RegExp ] } }, blacklist: { type: 'array', items: { oneOf: [ { type: 'string' }, { type: 'object' } // RegExp ] } }, dynamicClassPatterns: { type: 'array', items: { type: 'object' } // RegExp }, fileExtensions: { type: 'array', items: { type: 'string' } }, reportFormat: { type: 'string', enum: ['console', 'json', 'html'] }, outputFile: { oneOf: [ { type: 'string' }, { type: 'null' } ] }, verbose: { type: 'boolean' }, dryRun: { type: 'boolean' } }, required: ['cssFiles', 'sourceDirectories'], additionalProperties: false }; } //# sourceMappingURL=config.js.map