440 lines
14 KiB
JavaScript
440 lines
14 KiB
JavaScript
|
|
import { PurgeCSS } from 'purgecss';
|
|||
|
|
import fs from 'fs';
|
|||
|
|
import path from 'path';
|
|||
|
|
import { fileURLToPath } from 'url';
|
|||
|
|
|
|||
|
|
const __filename = fileURLToPath(import.meta.url);
|
|||
|
|
const __dirname = path.dirname(__filename);
|
|||
|
|
|
|||
|
|
// Create reports directory if it doesn't exist
|
|||
|
|
const reportsDir = './reports';
|
|||
|
|
if (!fs.existsSync(reportsDir)) {
|
|||
|
|
fs.mkdirSync(reportsDir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('🔍 Starting CSS Analysis for PlanTempus...\n');
|
|||
|
|
|
|||
|
|
// 1. Run PurgeCSS to find unused CSS
|
|||
|
|
console.log('📊 Running PurgeCSS analysis...');
|
|||
|
|
async function runPurgeCSS() {
|
|||
|
|
const purgeCSSResults = await new PurgeCSS().purge({
|
|||
|
|
content: [
|
|||
|
|
'./Features/**/*.cshtml',
|
|||
|
|
'./wwwroot/ts/**/*.ts'
|
|||
|
|
],
|
|||
|
|
css: [
|
|||
|
|
'./wwwroot/css/*.css'
|
|||
|
|
],
|
|||
|
|
rejected: true,
|
|||
|
|
rejectedCss: true,
|
|||
|
|
safelist: {
|
|||
|
|
standard: [
|
|||
|
|
/^swp-/, // All custom web components
|
|||
|
|
/^ph-/, // Phosphor icons
|
|||
|
|
'active', // Tab states
|
|||
|
|
'checked', // Checkbox states
|
|||
|
|
'collapsed',
|
|||
|
|
'expanded',
|
|||
|
|
'hidden',
|
|||
|
|
'has-demo-banner',
|
|||
|
|
/^owner$/, // Role badges
|
|||
|
|
/^admin$/,
|
|||
|
|
/^leader$/,
|
|||
|
|
/^employee$/,
|
|||
|
|
/^purple$/, // Avatar colors
|
|||
|
|
/^blue$/,
|
|||
|
|
/^amber$/,
|
|||
|
|
/^teal$/,
|
|||
|
|
/^master$/, // Employee tags
|
|||
|
|
/^senior$/,
|
|||
|
|
/^junior$/,
|
|||
|
|
/^cert$/,
|
|||
|
|
/^draft$/, // Status badges
|
|||
|
|
/^approved$/,
|
|||
|
|
/^invited$/,
|
|||
|
|
/^danger$/, // Button variants
|
|||
|
|
/^primary$/,
|
|||
|
|
/^secondary$/
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Calculate statistics
|
|||
|
|
let totalOriginalSize = 0;
|
|||
|
|
let totalPurgedSize = 0;
|
|||
|
|
let totalRejected = 0;
|
|||
|
|
const rejectedByFile = {};
|
|||
|
|
|
|||
|
|
purgeCSSResults.forEach(result => {
|
|||
|
|
const fileName = path.basename(result.file);
|
|||
|
|
const originalSize = result.css.length + (result.rejected ? result.rejected.join('').length : 0);
|
|||
|
|
const purgedSize = result.css.length;
|
|||
|
|
const rejectedSize = result.rejected ? result.rejected.length : 0;
|
|||
|
|
|
|||
|
|
totalOriginalSize += originalSize;
|
|||
|
|
totalPurgedSize += purgedSize;
|
|||
|
|
totalRejected += rejectedSize;
|
|||
|
|
|
|||
|
|
rejectedByFile[fileName] = {
|
|||
|
|
originalSize,
|
|||
|
|
purgedSize,
|
|||
|
|
rejectedCount: rejectedSize,
|
|||
|
|
rejected: result.rejected || []
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const report = {
|
|||
|
|
summary: {
|
|||
|
|
totalFiles: purgeCSSResults.length,
|
|||
|
|
totalOriginalSize,
|
|||
|
|
totalPurgedSize,
|
|||
|
|
totalRejected,
|
|||
|
|
percentageRemoved: ((totalRejected / (totalOriginalSize || 1)) * 100).toFixed(2) + '%',
|
|||
|
|
potentialSavings: totalOriginalSize - totalPurgedSize
|
|||
|
|
},
|
|||
|
|
fileDetails: rejectedByFile
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
fs.writeFileSync(
|
|||
|
|
path.join(reportsDir, 'purgecss-report.json'),
|
|||
|
|
JSON.stringify(report, null, 2)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
console.log('✅ PurgeCSS analysis complete');
|
|||
|
|
console.log(` - Total CSS rules analyzed: ${totalOriginalSize}`);
|
|||
|
|
console.log(` - Unused CSS rules found: ${totalRejected}`);
|
|||
|
|
console.log(` - Potential removal: ${report.summary.percentageRemoved}`);
|
|||
|
|
|
|||
|
|
return report;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Analyze CSS with basic stats
|
|||
|
|
console.log('\n📊 Running CSS Stats analysis...');
|
|||
|
|
function runCSSStats() {
|
|||
|
|
const cssDir = './wwwroot/css';
|
|||
|
|
const cssFiles = fs.readdirSync(cssDir)
|
|||
|
|
.filter(file => file.endsWith('.css'))
|
|||
|
|
.map(file => path.join(cssDir, file));
|
|||
|
|
|
|||
|
|
const stats = {};
|
|||
|
|
|
|||
|
|
cssFiles.forEach(file => {
|
|||
|
|
if (fs.existsSync(file)) {
|
|||
|
|
const fileName = path.basename(file);
|
|||
|
|
const content = fs.readFileSync(file, 'utf8');
|
|||
|
|
|
|||
|
|
// Basic statistics
|
|||
|
|
const lines = content.split('\n').length;
|
|||
|
|
const size = Buffer.byteLength(content, 'utf8');
|
|||
|
|
const rules = (content.match(/\{[^}]*\}/g) || []).length;
|
|||
|
|
const selectors = (content.match(/[^{]+(?=\{)/g) || []).length;
|
|||
|
|
const properties = (content.match(/[^:]+:[^;]+;/g) || []).length;
|
|||
|
|
const colors = [...new Set(content.match(/#[0-9a-fA-F]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|var\(--color-[^)]+\)/g) || [])];
|
|||
|
|
const mediaQueries = (content.match(/@media[^{]+/g) || []).length;
|
|||
|
|
const cssVariables = [...new Set(content.match(/var\(--[^)]+\)/g) || [])];
|
|||
|
|
|
|||
|
|
stats[fileName] = {
|
|||
|
|
lines,
|
|||
|
|
size: `${(size / 1024).toFixed(2)} KB`,
|
|||
|
|
sizeBytes: size,
|
|||
|
|
rules,
|
|||
|
|
selectors,
|
|||
|
|
properties,
|
|||
|
|
uniqueColors: colors.length,
|
|||
|
|
colors: colors.slice(0, 10), // First 10 colors
|
|||
|
|
mediaQueries,
|
|||
|
|
cssVariables: cssVariables.length
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
fs.writeFileSync(
|
|||
|
|
path.join(reportsDir, 'css-stats.json'),
|
|||
|
|
JSON.stringify(stats, null, 2)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
console.log('✅ CSS Stats analysis complete');
|
|||
|
|
console.log(` - Files analyzed: ${Object.keys(stats).length}`);
|
|||
|
|
|
|||
|
|
return stats;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Generate HTML report
|
|||
|
|
function generateHTMLReport(purgeReport, statsReport) {
|
|||
|
|
const totalSize = Object.values(statsReport).reduce((sum, stat) => sum + stat.sizeBytes, 0);
|
|||
|
|
const totalSizeKB = (totalSize / 1024).toFixed(2);
|
|||
|
|
const totalLines = Object.values(statsReport).reduce((sum, stat) => sum + stat.lines, 0);
|
|||
|
|
|
|||
|
|
const html = `
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="da">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>CSS Analysis Report - PlanTempus</title>
|
|||
|
|
<style>
|
|||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
color: #333;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
padding: 20px;
|
|||
|
|
}
|
|||
|
|
.container {
|
|||
|
|
max-width: 1200px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
background: white;
|
|||
|
|
padding: 40px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|||
|
|
}
|
|||
|
|
h1 {
|
|||
|
|
color: #14b8a6;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
font-size: 2.5em;
|
|||
|
|
}
|
|||
|
|
.subtitle {
|
|||
|
|
color: #666;
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
font-size: 1.1em;
|
|||
|
|
}
|
|||
|
|
.summary {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|||
|
|
gap: 20px;
|
|||
|
|
margin-bottom: 40px;
|
|||
|
|
}
|
|||
|
|
.stat-card {
|
|||
|
|
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
|
|||
|
|
color: white;
|
|||
|
|
padding: 20px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|||
|
|
}
|
|||
|
|
.stat-card.warning {
|
|||
|
|
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
|||
|
|
}
|
|||
|
|
.stat-card.info {
|
|||
|
|
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
|||
|
|
}
|
|||
|
|
.stat-card.success {
|
|||
|
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
|||
|
|
}
|
|||
|
|
.stat-value {
|
|||
|
|
font-size: 2.5em;
|
|||
|
|
font-weight: bold;
|
|||
|
|
margin: 10px 0;
|
|||
|
|
}
|
|||
|
|
.stat-label {
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
opacity: 0.9;
|
|||
|
|
}
|
|||
|
|
section {
|
|||
|
|
margin-bottom: 40px;
|
|||
|
|
}
|
|||
|
|
h2 {
|
|||
|
|
color: #333;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
padding-bottom: 10px;
|
|||
|
|
border-bottom: 2px solid #14b8a6;
|
|||
|
|
}
|
|||
|
|
table {
|
|||
|
|
width: 100%;
|
|||
|
|
border-collapse: collapse;
|
|||
|
|
margin-top: 20px;
|
|||
|
|
}
|
|||
|
|
th, td {
|
|||
|
|
padding: 12px;
|
|||
|
|
text-align: left;
|
|||
|
|
border-bottom: 1px solid #ddd;
|
|||
|
|
}
|
|||
|
|
th {
|
|||
|
|
background: #f8f9fa;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #555;
|
|||
|
|
}
|
|||
|
|
tr:hover {
|
|||
|
|
background: #f8f9fa;
|
|||
|
|
}
|
|||
|
|
.file-detail {
|
|||
|
|
background: #f8f9fa;
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
margin-bottom: 15px;
|
|||
|
|
}
|
|||
|
|
.rejected-list {
|
|||
|
|
max-height: 200px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
background: white;
|
|||
|
|
padding: 10px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
margin-top: 10px;
|
|||
|
|
font-family: 'Courier New', monospace;
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
}
|
|||
|
|
.badge {
|
|||
|
|
display: inline-block;
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-size: 0.85em;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.badge-danger { background: #ffebee; color: #c62828; }
|
|||
|
|
.badge-warning { background: #fff3e0; color: #ef6c00; }
|
|||
|
|
.badge-success { background: #e8f5e9; color: #2e7d32; }
|
|||
|
|
.timestamp {
|
|||
|
|
color: #999;
|
|||
|
|
font-size: 0.9em;
|
|||
|
|
margin-top: 30px;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
.color-palette {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 5px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
margin-top: 10px;
|
|||
|
|
}
|
|||
|
|
.color-swatch {
|
|||
|
|
width: 30px;
|
|||
|
|
height: 30px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
border: 1px solid #ddd;
|
|||
|
|
}
|
|||
|
|
details {
|
|||
|
|
margin-top: 10px;
|
|||
|
|
}
|
|||
|
|
summary {
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: #14b8a6;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="container">
|
|||
|
|
<h1>📊 CSS Analysis Report</h1>
|
|||
|
|
<p class="subtitle">PlanTempus - Production CSS Analysis</p>
|
|||
|
|
|
|||
|
|
<div class="summary">
|
|||
|
|
<div class="stat-card">
|
|||
|
|
<div class="stat-label">Total CSS Size</div>
|
|||
|
|
<div class="stat-value">${totalSizeKB} KB</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-card info">
|
|||
|
|
<div class="stat-label">CSS Files</div>
|
|||
|
|
<div class="stat-value">${purgeReport.summary.totalFiles}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-card info">
|
|||
|
|
<div class="stat-label">Total Lines</div>
|
|||
|
|
<div class="stat-value">${totalLines.toLocaleString()}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-card warning">
|
|||
|
|
<div class="stat-label">Unused CSS Rules</div>
|
|||
|
|
<div class="stat-value">${purgeReport.summary.totalRejected}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section>
|
|||
|
|
<h2>📈 CSS Statistics by File</h2>
|
|||
|
|
<table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>File</th>
|
|||
|
|
<th>Size</th>
|
|||
|
|
<th>Lines</th>
|
|||
|
|
<th>Rules</th>
|
|||
|
|
<th>Selectors</th>
|
|||
|
|
<th>Properties</th>
|
|||
|
|
<th>CSS Vars</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
${Object.entries(statsReport)
|
|||
|
|
.sort((a, b) => b[1].sizeBytes - a[1].sizeBytes)
|
|||
|
|
.map(([file, stats]) => `
|
|||
|
|
<tr>
|
|||
|
|
<td><strong>${file}</strong></td>
|
|||
|
|
<td>${stats.size}</td>
|
|||
|
|
<td>${stats.lines}</td>
|
|||
|
|
<td>${stats.rules}</td>
|
|||
|
|
<td>${stats.selectors}</td>
|
|||
|
|
<td>${stats.properties}</td>
|
|||
|
|
<td>${stats.cssVariables}</td>
|
|||
|
|
</tr>
|
|||
|
|
`).join('')}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section>
|
|||
|
|
<h2>🗑️ Unused CSS by File</h2>
|
|||
|
|
${Object.entries(purgeReport.fileDetails)
|
|||
|
|
.sort((a, b) => b[1].rejectedCount - a[1].rejectedCount)
|
|||
|
|
.map(([file, details]) => `
|
|||
|
|
<div class="file-detail">
|
|||
|
|
<h3>${file}</h3>
|
|||
|
|
<p>
|
|||
|
|
<span class="badge ${details.rejectedCount > 50 ? 'badge-danger' : details.rejectedCount > 20 ? 'badge-warning' : 'badge-success'}">
|
|||
|
|
${details.rejectedCount} unused rules
|
|||
|
|
</span>
|
|||
|
|
<span style="margin-left: 10px; color: #666;">
|
|||
|
|
Original: ${details.originalSize} | After purge: ${details.purgedSize}
|
|||
|
|
</span>
|
|||
|
|
</p>
|
|||
|
|
${details.rejectedCount > 0 ? `
|
|||
|
|
<details>
|
|||
|
|
<summary>Show unused selectors</summary>
|
|||
|
|
<div class="rejected-list">
|
|||
|
|
${details.rejected.slice(0, 50).join('<br>')}
|
|||
|
|
${details.rejected.length > 50 ? `<br><em>... and ${details.rejected.length - 50} more</em>` : ''}
|
|||
|
|
</div>
|
|||
|
|
</details>
|
|||
|
|
` : '<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>'}
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section>
|
|||
|
|
<h2>💡 Recommendations</h2>
|
|||
|
|
<ul style="line-height: 2;">
|
|||
|
|
${purgeReport.summary.totalRejected > 100 ?
|
|||
|
|
'<li>⚠️ <strong>High number of unused CSS rules detected.</strong> Consider removing unused styles to improve performance.</li>' :
|
|||
|
|
'<li>✅ CSS usage is relatively clean.</li>'}
|
|||
|
|
${Object.values(purgeReport.fileDetails).some(d => d.rejectedCount > 50) ?
|
|||
|
|
'<li>⚠️ Some files have significant unused CSS. Review these files for optimization opportunities.</li>' : ''}
|
|||
|
|
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
|
|||
|
|
<li>🎨 Review color usage - ensure all colors use CSS variables from design-tokens.css.</li>
|
|||
|
|
<li>📋 Reference COMPONENT-CATALOG.md when adding new components to avoid duplication.</li>
|
|||
|
|
</ul>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<p class="timestamp">Report generated: ${new Date().toLocaleString('da-DK')}</p>
|
|||
|
|
</div>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
fs.writeFileSync(path.join(reportsDir, 'css-analysis-report.html'), html);
|
|||
|
|
console.log('\n✅ HTML report generated: reports/css-analysis-report.html');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Run all analyses
|
|||
|
|
(async () => {
|
|||
|
|
try {
|
|||
|
|
const purgeReport = await runPurgeCSS();
|
|||
|
|
const statsReport = runCSSStats();
|
|||
|
|
generateHTMLReport(purgeReport, statsReport);
|
|||
|
|
|
|||
|
|
console.log('\n🎉 CSS Analysis Complete!');
|
|||
|
|
console.log('📄 Reports generated in ./reports/ directory');
|
|||
|
|
console.log(' - purgecss-report.json (detailed unused CSS data)');
|
|||
|
|
console.log(' - css-stats.json (CSS statistics)');
|
|||
|
|
console.log(' - css-analysis-report.html (visual report)');
|
|||
|
|
console.log('\n💡 Open reports/css-analysis-report.html in your browser to view the full report');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('❌ Error during analysis:', error);
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
})();
|