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

293 lines 10.4 kB
import * as fs from 'fs'; import * as path from 'path'; import * as glob from 'glob'; export class SourceScanner { constructor() { this.dynamicPatterns = [ // Tailwind JIT patterns { pattern: /class(?:Name)?=["'`]([^"'`]*\$\{[^}]+\}[^"'`]*)["'`]/g, description: 'Template literal class names', framework: 'tailwind' }, { pattern: /class(?:Name)?=["'`]([^"'`]*(?:bg|text|border|p|m|w|h)-\[[^\]]+\][^"'`]*)["'`]/g, description: 'Tailwind arbitrary values', framework: 'tailwind' }, // CSS-in-JS patterns { pattern: /styled\.[a-zA-Z]+`([^`]*)`/g, description: 'Styled-components', framework: 'custom' }, { pattern: /css`([^`]*)`/g, description: 'CSS template literals', framework: 'custom' } ]; } /** * Scan source directories for class usage */ async scanSourceDirectories(directories) { const usedClasses = new Set(); for (const directory of directories) { const files = await this.getSourceFiles(directory); for (const file of files) { try { const content = fs.readFileSync(file, 'utf-8'); const fileInfo = { path: file, content, type: this.getFileType(file) }; const classes = await this.extractClassesFromFile(fileInfo); classes.forEach(className => usedClasses.add(className)); } catch (error) { console.warn(`Warning: Could not scan file ${file}:`, error); } } } return usedClasses; } /** * Get all source files from directories */ async getSourceFiles(directory) { const patterns = [ '**/*.{js,jsx,ts,tsx,vue,html,htm}', '**/*.{php,py,rb,java,cs}' // Additional server-side templates ]; const files = []; for (const pattern of patterns) { try { const matches = await glob.glob(pattern, { cwd: directory, absolute: true, ignore: [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/.git/**', '**/coverage/**' ] }); files.push(...matches); } catch (error) { console.warn(`Warning: Could not scan directory ${directory}:`, error); } } return [...new Set(files)]; // Remove duplicates } /** * Extract classes from a single file */ async extractClassesFromFile(fileInfo) { const classes = new Set(); // Extract static class names const staticClasses = this.extractStaticClasses(fileInfo.content); staticClasses.forEach(className => classes.add(className)); // Extract dynamic class names const dynamicClasses = this.extractDynamicClasses(fileInfo.content); dynamicClasses.forEach(className => classes.add(className)); // File type specific extraction switch (fileInfo.type) { case 'vue': const vueClasses = this.extractVueClasses(fileInfo.content); vueClasses.forEach(className => classes.add(className)); break; case 'jsx': case 'tsx': const jsxClasses = this.extractJSXClasses(fileInfo.content); jsxClasses.forEach(className => classes.add(className)); break; case 'html': const htmlClasses = this.extractHTMLClasses(fileInfo.content); htmlClasses.forEach(className => classes.add(className)); break; } return classes; } /** * Extract static class names using regex patterns */ extractStaticClasses(content) { const classes = new Set(); // Common class attribute patterns const patterns = [ /class(?:Name)?=["']([^"']*)["']/g, /class(?:Name)?=\{["']([^"']*)["']\}/g, /class(?:Name)?=\{`([^`]*)`\}/g, /@apply\s+([^;\n]+)/g, // Tailwind @apply /classList\.(?:add|toggle)\(["']([^"']*)["']\)/g // JavaScript classList ]; for (const pattern of patterns) { let match; while ((match = pattern.exec(content)) !== null) { const classString = match[1]; if (classString) { const individualClasses = classString.split(/\s+/).filter(Boolean); individualClasses.forEach(className => { if (this.isValidClassName(className)) { classes.add(className); } }); } } } return classes; } /** * Extract dynamic class names using patterns */ extractDynamicClasses(content) { const classes = new Set(); for (const pattern of this.dynamicPatterns) { let match; while ((match = pattern.pattern.exec(content)) !== null) { const classString = match[1]; if (classString) { // For template literals, try to extract static parts const staticParts = this.extractStaticPartsFromTemplate(classString); staticParts.forEach(className => { if (this.isValidClassName(className)) { classes.add(className); } }); } } } return classes; } /** * Extract classes from Vue Single File Components */ extractVueClasses(content) { const classes = new Set(); // Extract from template section const templateMatch = content.match(/<template[^>]*>([\s\S]*?)<\/template>/i); if (templateMatch) { const templateContent = templateMatch[1]; const templateClasses = this.extractStaticClasses(templateContent); templateClasses.forEach(className => classes.add(className)); } // Extract from script section const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/i); if (scriptMatch) { const scriptContent = scriptMatch[1]; const scriptClasses = this.extractStaticClasses(scriptContent); scriptClasses.forEach(className => classes.add(className)); } return classes; } /** * Extract classes from JSX/TSX files */ extractJSXClasses(content) { const classes = new Set(); // JSX className patterns const jsxPatterns = [ /className=["']([^"']*)["']/g, /className=\{["']([^"']*)["']\}/g, /className=\{`([^`]*)`\}/g, /className=\{([^}]+)\}/g // Complex expressions ]; for (const pattern of jsxPatterns) { let match; while ((match = pattern.exec(content)) !== null) { const classString = match[1]; if (classString) { const individualClasses = classString.split(/\s+/).filter(Boolean); individualClasses.forEach(className => { if (this.isValidClassName(className)) { classes.add(className); } }); } } } return classes; } /** * Extract classes from HTML files */ extractHTMLClasses(content) { const classes = new Set(); const classPattern = /class=["']([^"']*)["']/g; let match; while ((match = classPattern.exec(content)) !== null) { const classString = match[1]; if (classString) { const individualClasses = classString.split(/\s+/).filter(Boolean); individualClasses.forEach(className => { if (this.isValidClassName(className)) { classes.add(className); } }); } } return classes; } /** * Extract static parts from template literals */ extractStaticPartsFromTemplate(template) { const classes = []; // Remove template literal expressions and extract static parts const staticParts = template.split(/\$\{[^}]+\}/); for (const part of staticParts) { const partClasses = part.split(/\s+/).filter(Boolean); classes.push(...partClasses); } return classes; } /** * Check if a string is a valid CSS class name */ isValidClassName(className) { // Basic validation for CSS class names return /^[a-zA-Z_-][a-zA-Z0-9_-]*$/.test(className) && className.length > 0; } /** * Get file type from extension */ getFileType(filePath) { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case '.css': return 'css'; case '.js': return 'js'; case '.ts': return 'ts'; case '.jsx': return 'jsx'; case '.tsx': return 'tsx'; case '.vue': return 'vue'; case '.html': case '.htm': return 'html'; default: return 'js'; // Default fallback } } /** * Add custom dynamic class pattern */ addDynamicPattern(pattern) { this.dynamicPatterns.push(pattern); } /** * Get all dynamic patterns */ getDynamicPatterns() { return [...this.dynamicPatterns]; } } //# sourceMappingURL=source-scanner.js.map