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.
636 lines (532 loc) • 25.5 kB
JavaScript
class CSSCleaner {
constructor() {
this.selectors = {};
this.stats = {};
this.files = { html: [], css: [] };
this.filteredSelectors = [];
this.init();
}
async init() {
this.setupEventListeners();
await this.loadSelectors();
this.updateUI();
}
setupEventListeners() {
// Control buttons
document.getElementById('enable-all').addEventListener('click', () => this.toggleAll(true));
document.getElementById('disable-all').addEventListener('click', () => this.toggleAll(false));
document.getElementById('restore-original').addEventListener('click', () => this.restore('original'));
document.getElementById('save-new-css').addEventListener('click', () => this.showSaveModal('new'));
document.getElementById('overwrite-css').addEventListener('click', () => this.showSaveModal('overwrite'));
// Search and filter
document.getElementById('search-input').addEventListener('input', (e) => this.filterSelectors());
document.getElementById('show-unused').addEventListener('change', () => this.filterSelectors());
document.getElementById('show-disabled').addEventListener('change', () => this.filterSelectors());
// HTML file selector
document.getElementById('html-file-select').addEventListener('change', (e) => {
this.loadPreview(e.target.value);
});
// Save modal
document.getElementById('modal-close').addEventListener('click', () => this.hideSaveModal());
document.getElementById('cancel-save').addEventListener('click', () => this.hideSaveModal());
document.getElementById('confirm-save').addEventListener('click', () => this.saveCSS());
// Close modal on outside click
document.getElementById('save-modal').addEventListener('click', (e) => {
if (e.target.id === 'save-modal') {
this.hideSaveModal();
}
});
}
async loadSelectors() {
try {
this.setStatus('Loading selectors...', 'loading');
const response = await fetch('/api/selectors');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.selectors = data.selectors;
this.stats = data.stats;
this.files = data.files;
// Populate HTML file selector
this.populateHtmlSelector();
this.setStatus('Selectors loaded successfully', 'success');
} catch (error) {
console.error('Error loading selectors:', error);
this.setStatus(`Error loading selectors: ${error.message}`, 'error');
}
}
populateHtmlSelector() {
const select = document.getElementById('html-file-select');
select.innerHTML = '<option value="">Select HTML file to preview</option>';
this.files.html.forEach((file, index) => {
const option = document.createElement('option');
option.value = file;
option.textContent = file;
// Select the first HTML file by default
if (index === 0) {
option.selected = true;
}
select.appendChild(option);
});
// Auto-load the first HTML file if available
if (this.files.html.length > 0) {
this.loadPreview(this.files.html[0]);
}
}
filterSelectors() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const showUnused = document.getElementById('show-unused').checked;
const showDisabled = document.getElementById('show-disabled').checked;
this.filteredSelectors = Object.keys(this.selectors).filter(selector => {
const selectorData = this.selectors[selector];
// Search filter
if (searchTerm && !selector.toLowerCase().includes(searchTerm)) {
return false;
}
// Unused filter
if (showUnused && !selectorData.unused) {
return false;
}
// Disabled filter
if (showDisabled && selectorData.active) {
return false;
}
return true;
});
this.renderSelectorList();
}
renderSelectorList() {
const container = document.getElementById('selector-list');
if (this.filteredSelectors.length === 0) {
container.innerHTML = '<div class="no-results">No selectors match your filters</div>';
return;
}
// Categorize selectors
const categories = this.categorizeSelectors(this.filteredSelectors);
const categoryIcons = {
'Layout & Structure': '🏗️',
'Typography': '📝',
'Header': '🎯',
'Navigation': '🧭',
'Cards & Components': '📦',
'Buttons': '🔘',
'Forms': '📋',
'Footer': '⬇️',
'Utilities': '🔧',
'Animations': '✨',
'Responsive': '📱',
'Legacy/Unused': '🗑️',
'Other': '📄'
};
const html = Object.keys(categories).map(categoryName => {
const selectors = categories[categoryName];
const categoryId = categoryName.toLowerCase().replace(/\s+/g, '-');
const unusedCount = selectors.filter(s => this.selectors[s].unused).length;
const icon = categoryIcons[categoryName] || '📄';
return `
<div class="selector-category">
<div class="category-header" data-category="${categoryId}">
<span class="category-toggle">▼</span>
<span class="category-icon">${icon}</span>
<span class="category-name">${categoryName}</span>
<span class="category-count">(${selectors.length})</span>
${unusedCount > 0 ? `<span class="category-unused-count">${unusedCount} unused</span>` : ''}
</div>
<div class="category-content" id="category-${categoryId}">
${selectors.map(selector => {
const selectorData = this.selectors[selector];
const unusedClass = selectorData.unused ? 'unused' : '';
const activeClass = selectorData.active ? 'active' : 'inactive';
return `
<div class="selector-item ${unusedClass} ${activeClass}">
<label class="selector-label">
<input type="checkbox"
${selectorData.active ? 'checked' : ''}
data-selector="${selector}"
class="selector-checkbox">
<span class="selector-name">${selector}</span>
${selectorData.unused ? '<span class="unused-badge">❗</span>' : ''}
</label>
<div class="selector-info">
<span class="file-info">Files: ${selectorData.files.join(', ')}</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('');
container.innerHTML = html;
// Add event listeners to checkboxes
container.querySelectorAll('.selector-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
this.toggleSelector(e.target.dataset.selector, e.target.checked);
});
});
// Add event listeners to category headers
container.querySelectorAll('.category-header').forEach(header => {
header.addEventListener('click', (e) => {
this.toggleCategory(e.target.closest('.category-header').dataset.category);
});
});
// Collapse unused categories by default, keep used ones open
Object.keys(categories).forEach(categoryName => {
const categoryId = categoryName.toLowerCase().replace(/\s+/g, '-');
const selectors = categories[categoryName];
const hasUsedSelectors = selectors.some(s => !this.selectors[s].unused);
if (!hasUsedSelectors) {
this.toggleCategory(categoryId, false);
}
});
}
categorizeSelectors(selectors) {
const categories = {
'Layout & Structure': [],
'Typography': [],
'Header': [],
'Navigation': [],
'Cards & Components': [],
'Buttons': [],
'Forms': [],
'Footer': [],
'Utilities': [],
'Animations': [],
'Responsive': [],
'Legacy/Unused': [],
'Other': []
};
selectors.forEach(selector => {
const category = this.getSelectorCategory(selector);
categories[category].push(selector);
});
// Remove empty categories
Object.keys(categories).forEach(key => {
if (categories[key].length === 0) {
delete categories[key];
}
});
return categories;
}
getSelectorCategory(selector) {
const selectorData = this.selectors[selector];
const name = selector.toLowerCase();
// Check if it's unused first
if (selectorData.unused) {
// But still categorize properly for better organization
if (name.includes('old-') || name.includes('deprecated-') || name.includes('legacy-') ||
name.includes('unused-') || name.includes('beta-') || name.includes('temp-') ||
name.includes('debug-') || name.includes('experimental-')) {
return 'Legacy/Unused';
}
}
// Layout & Structure
if (name.includes('container') || name.includes('wrapper') || name.includes('layout') ||
name.includes('grid') || name.includes('row') || name.includes('col') ||
name.includes('section') || name.includes('main') || name.includes('sidebar') ||
name.includes('content') || name.includes('app-') || name.includes('page-') ||
selector === '*' || selector === 'body' || selector === 'html' ||
name.includes('inner') || name.includes('outer') || name.includes('flex')) {
return 'Layout & Structure';
}
// Navigation
if (name.includes('nav') || name.includes('menu') || name.includes('breadcrumb') ||
(name.includes('link') && !name.includes('footer-link')) ||
name.includes('primary-nav') || name.includes('secondary-nav')) {
return 'Navigation';
}
// Header
if (name.includes('header') || name.includes('masthead') || name.includes('site-branding') ||
name.includes('logo') || name.includes('brand') || name.includes('site-title') ||
name.includes('site-description') || name.includes('hero-') || name.includes('banner-')) {
return 'Header';
}
// Footer
if (name.includes('footer') || name.includes('colophon') || name.includes('site-info') ||
name.includes('copyright') || name.includes('footer-')) {
return 'Footer';
}
// Forms
if (name.includes('form') || name.includes('input') || name.includes('search') ||
name.includes('field') || name.includes('submit') || name.includes('textarea') ||
name.includes('select') || name.includes('checkbox') || name.includes('radio') ||
name.includes('label') || name.includes('form-') || name.includes('contact-')) {
return 'Forms';
}
// Buttons
if (name.includes('btn') || name.includes('button') || name.includes('cta') ||
name.includes('btn-') || name.includes('button-')) {
return 'Buttons';
}
// Cards & Components
if (name.includes('card') || name.includes('widget') || name.includes('component') ||
name.includes('modal') || name.includes('dropdown') || name.includes('accordion') ||
name.includes('tab') || name.includes('alert') || name.includes('badge') ||
name.includes('tooltip') || name.includes('popover') || name.includes('progress') ||
name.includes('panel') || name.includes('box') || name.includes('media-') ||
name.includes('feature-') || name.includes('stat-') || name.includes('team-')) {
return 'Cards & Components';
}
// Typography
if (/^h[1-6]$/.test(selector) || name.includes('title') || name.includes('heading') ||
name.includes('text') || name.includes('font') || name.includes('lead') ||
name.includes('subtitle') || name.includes('description') || name.includes('excerpt') ||
name.includes('quote') || name.includes('blockquote') || name.includes('intro') ||
name.includes('-title') || name.includes('-heading') || selector === 'p') {
return 'Typography';
}
// Utilities
if (name.includes('hidden') || name.includes('visible') || name.includes('sr-only') ||
name.includes('clearfix') || name.includes('center') || name.includes('left') ||
name.includes('right') || name.includes('margin') || name.includes('padding') ||
name.includes('border') || name.includes('shadow') || name.includes('text-') ||
name.includes('bg-') || name.includes('d-') || name.includes('position-') ||
name.includes('overflow-') || name.includes('display-')) {
return 'Utilities';
}
// Animations
if (name.includes('animate') || name.includes('transition') || name.includes('fade') ||
name.includes('slide') || name.includes('spin') || name.includes('pulse') ||
name.includes('bounce') || name.includes('hover') || name.includes(':hover') ||
name.includes('transform') || name.includes('scale') || name.includes('rotate')) {
return 'Animations';
}
// Responsive
if (name.includes('mobile') || name.includes('tablet') || name.includes('desktop') ||
name.includes('responsive') || name.includes('xs-') || name.includes('sm-') ||
name.includes('md-') || name.includes('lg-') || name.includes('xl-') ||
name.match(//) || name.includes('breakpoint')) {
return 'Responsive';
}
return 'Other';
}
toggleCategory(categoryId, forceState = null) {
const categoryContent = document.getElementById(`category-${categoryId}`);
const categoryHeader = document.querySelector(`[data-category="${categoryId}"]`);
const toggle = categoryHeader.querySelector('.category-toggle');
if (forceState !== null) {
// Force specific state
if (forceState) {
categoryContent.classList.add('expanded');
toggle.textContent = '▼';
} else {
categoryContent.classList.remove('expanded');
toggle.textContent = '▶';
}
} else {
// Toggle current state
if (categoryContent.classList.contains('expanded')) {
categoryContent.classList.remove('expanded');
toggle.textContent = '▶';
} else {
categoryContent.classList.add('expanded');
toggle.textContent = '▼';
}
}
}
async toggleSelector(selector, active) {
try {
const response = await fetch('/api/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ selector, active })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.stats = data.stats;
this.selectors[selector] = data.selector;
this.updateStats();
this.refreshPreview();
this.setStatus(`${active ? 'Enabled' : 'Disabled'} selector: ${selector}`, 'success');
} catch (error) {
console.error('Error toggling selector:', error);
this.setStatus(`Error toggling selector: ${error.message}`, 'error');
}
}
async toggleAll(active) {
try {
this.setStatus(`${active ? 'Enabling' : 'Disabling'} all selectors...`, 'loading');
const response = await fetch('/api/toggle-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ active })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.stats = data.stats;
// Update local selector states
Object.keys(this.selectors).forEach(selector => {
this.selectors[selector].active = active;
});
this.updateUI();
this.refreshPreview();
this.setStatus(`${active ? 'Enabled' : 'Disabled'} all selectors`, 'success');
} catch (error) {
console.error('Error toggling all selectors:', error);
this.setStatus(`Error toggling selectors: ${error.message}`, 'error');
}
}
async restore(type) {
try {
this.setStatus('Restoring selector states...', 'loading');
const response = await fetch('/api/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ type })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
await this.loadSelectors(); // Reload to get updated states
this.updateUI();
this.refreshPreview();
this.setStatus('Selector states restored', 'success');
} catch (error) {
console.error('Error restoring selectors:', error);
this.setStatus(`Error restoring selectors: ${error.message}`, 'error');
}
}
loadPreview(filename) {
const iframe = document.getElementById('preview-iframe');
const noPreview = document.querySelector('.no-preview');
if (!filename) {
iframe.style.display = 'none';
noPreview.style.display = 'flex';
return;
}
iframe.src = `/api/preview/${filename}`;
iframe.style.display = 'block';
noPreview.style.display = 'none';
this.setStatus(`Loading preview: ${filename}`, 'loading');
iframe.onload = () => {
this.setStatus(`Preview loaded: ${filename}`, 'success');
};
iframe.onerror = () => {
this.setStatus(`Error loading preview: ${filename}`, 'error');
};
}
refreshPreview() {
const iframe = document.getElementById('preview-iframe');
if (iframe.src && iframe.src !== 'about:blank') {
// Force reload by changing src
const currentSrc = iframe.src;
iframe.src = 'about:blank';
setTimeout(() => {
iframe.src = currentSrc;
}, 100);
}
}
showSaveModal(mode = 'new') {
const modal = document.getElementById('save-modal');
const modalTitle = document.getElementById('modal-title');
const filenameSection = document.getElementById('filename-section');
const overwriteWarning = document.getElementById('overwrite-warning');
const confirmButton = document.getElementById('confirm-save');
// Update modal based on mode
if (mode === 'overwrite') {
modalTitle.textContent = 'Overwrite Original CSS Files';
filenameSection.style.display = 'none';
overwriteWarning.style.display = 'flex';
confirmButton.textContent = 'Overwrite Files';
confirmButton.className = 'btn btn-warning';
// Show files that will be overwritten
const filesList = document.getElementById('files-to-overwrite');
filesList.innerHTML = '';
this.files.css.forEach(file => {
const li = document.createElement('li');
li.textContent = file;
filesList.appendChild(li);
});
this.currentSaveMode = 'overwrite';
} else {
modalTitle.textContent = 'Save as New CSS File';
filenameSection.style.display = 'block';
overwriteWarning.style.display = 'none';
confirmButton.textContent = 'Save';
confirmButton.className = 'btn btn-success';
this.currentSaveMode = 'new';
}
// Update statistics
document.getElementById('save-active-count').textContent = this.stats.active || 0;
document.getElementById('save-disabled-count').textContent = this.stats.disabled || 0;
const reductionPercent = this.stats.total > 0
? Math.round((this.stats.disabled / this.stats.total) * 100)
: 0;
document.getElementById('size-reduction').textContent = `${reductionPercent}%`;
modal.classList.add('show');
}
hideSaveModal() {
document.getElementById('save-modal').classList.remove('show');
}
async saveCSS() {
try {
let filename = 'cleaned.css';
let overwrite = false;
if (this.currentSaveMode === 'overwrite') {
overwrite = true;
this.setStatus('Overwriting original CSS files...', 'loading');
} else {
filename = document.getElementById('filename-input').value || 'cleaned.css';
this.setStatus('Saving CSS...', 'loading');
}
this.hideSaveModal();
const response = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename, overwrite })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (overwrite) {
this.setStatus(`Original CSS files overwritten successfully (${data.size} bytes total)`, 'success');
} else {
this.setStatus(`CSS saved to ${data.outputPath} (${data.size} bytes)`, 'success');
}
} catch (error) {
console.error('Error saving CSS:', error);
this.setStatus(`Error saving CSS: ${error.message}`, 'error');
}
}
updateUI() {
this.updateStats();
this.filterSelectors();
}
updateStats() {
document.getElementById('total-count').textContent = this.stats.total || 0;
document.getElementById('unused-count').textContent = this.stats.unused || 0;
document.getElementById('active-count').textContent = this.stats.active || 0;
document.getElementById('disabled-count').textContent = this.stats.disabled || 0;
}
setStatus(message, type = 'info') {
const statusElement = document.getElementById('status-message');
statusElement.textContent = message;
statusElement.className = `status-message ${type}`;
// Auto-clear success/error messages
if (type === 'success' || type === 'error') {
setTimeout(() => {
statusElement.textContent = 'Ready';
statusElement.className = 'status-message';
}, 3000);
}
}
}
// Initialize the application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new CSSCleaner();
});