@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
840 lines (731 loc) • 23 kB
JavaScript
/**
* HTML Report Generator
* Generate standalone HTML report with embedded CSS for SunLint results
*/
/**
* Generate HTML report from violations data
* @param {Array} violations - Array of violation objects
* @param {Object} metadata - Report metadata
* @param {Object} metadata.score - Scoring summary
* @param {Object} metadata.gitInfo - Git information
* @param {string} metadata.timestamp - Report timestamp
* @returns {string} Complete HTML report
*/
function generateHTMLReport(violations, metadata = {}) {
const {
score = {},
gitInfo = {},
timestamp = new Date().toISOString()
} = metadata;
// Calculate statistics
const stats = calculateStatistics(violations);
// Generate HTML sections
const html = `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SunLint Report - ${gitInfo.repository_name || 'Project'}</title>
${embedCSS()}
</head>
<body>
<div class="container">
${generateHeader(gitInfo, timestamp)}
${generateSummary(stats, score)}
${generateByFileSection(violations, stats.fileGroups)}
${generateByRuleSection(violations, stats.ruleGroups)}
${generateFooter(timestamp)}
</div>
${embedJavaScript()}
</body>
</html>`;
return html;
}
/**
* Calculate statistics from violations
* @param {Array} violations - Violations array
* @returns {Object} Statistics object
*/
function calculateStatistics(violations) {
const totalViolations = violations.length;
const errorCount = violations.filter(v => v.severity === 'error').length;
const warningCount = violations.filter(v => v.severity === 'warning').length;
// Group by file
const fileGroups = {};
for (const v of violations) {
if (!fileGroups[v.file]) {
fileGroups[v.file] = [];
}
fileGroups[v.file].push(v);
}
// Group by rule
const ruleGroups = {};
for (const v of violations) {
if (!ruleGroups[v.rule]) {
ruleGroups[v.rule] = [];
}
ruleGroups[v.rule].push(v);
}
const filesWithIssues = Object.keys(fileGroups).length;
return {
totalViolations,
errorCount,
warningCount,
filesWithIssues,
fileGroups,
ruleGroups
};
}
/**
* Generate HTML header
* @param {Object} gitInfo - Git information
* @param {string} timestamp - Report timestamp
* @returns {string} Header HTML
*/
function generateHeader(gitInfo, timestamp) {
const repoName = gitInfo.repository_name || 'Project';
const branch = gitInfo.branch || 'Unknown';
const commit = gitInfo.commit_hash ? gitInfo.commit_hash.substring(0, 7) : 'N/A';
return `
<header class="header">
<div class="header-content">
<h1>
<span class="logo">🌟</span>
SunLint Code Quality Report
</h1>
<div class="header-meta">
<div class="meta-item">
<span class="meta-label">Repository:</span>
<span class="meta-value">${escapeHTML(repoName)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Branch:</span>
<span class="meta-value">${escapeHTML(branch)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Commit:</span>
<span class="meta-value">${escapeHTML(commit)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Generated:</span>
<span class="meta-value">${new Date(timestamp).toLocaleString()}</span>
</div>
</div>
</div>
</header>`;
}
/**
* Generate summary section
* @param {Object} stats - Statistics object
* @param {Object} score - Score information
* @returns {string} Summary HTML
*/
function generateSummary(stats, score) {
const { totalViolations, errorCount, warningCount, filesWithIssues } = stats;
const status = errorCount > 0 ? 'failed' : warningCount > 0 ? 'warning' : 'passed';
const statusIcon = errorCount > 0 ? '❌' : warningCount > 0 ? '⚠️' : '✅';
const statusText = errorCount > 0 ? 'Failed' : warningCount > 0 ? 'Passed with Warnings' : 'Passed';
const scoreValue = score.score !== undefined ? score.score : 'N/A';
const grade = score.grade || 'N/A';
return `
<section class="summary">
<div class="summary-header">
<div class="status-badge status-${status}">
<span class="status-icon">${statusIcon}</span>
<span class="status-text">${statusText}</span>
</div>
${scoreValue !== 'N/A' ? `
<div class="score-display">
<div class="score-value">${scoreValue}</div>
<div class="score-grade">${grade}</div>
</div>
` : ''}
</div>
<div class="summary-stats">
<div class="stat-card">
<div class="stat-value">${totalViolations}</div>
<div class="stat-label">Total Violations</div>
</div>
<div class="stat-card stat-error">
<div class="stat-value">${errorCount}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-card stat-warning">
<div class="stat-value">${warningCount}</div>
<div class="stat-label">Warnings</div>
</div>
<div class="stat-card">
<div class="stat-value">${filesWithIssues}</div>
<div class="stat-label">Files with Issues</div>
</div>
</div>
</section>`;
}
/**
* Generate violations by file section
* @param {Array} violations - Violations array
* @param {Object} fileGroups - Grouped by file
* @returns {string} By file HTML
*/
function generateByFileSection(violations, fileGroups) {
if (violations.length === 0) {
return `
<section class="section">
<h2>✅ Great Job!</h2>
<p class="no-violations">No coding standard violations found.</p>
</section>`;
}
const sortedFiles = Object.entries(fileGroups)
.sort((a, b) => b[1].length - a[1].length);
let html = `
<section class="section">
<h2>📁 Violations by File</h2>
<div class="controls">
<input type="text" id="fileSearch" class="search-box" placeholder="Search files...">
<select id="fileFilter" class="filter-select">
<option value="all">All Severities</option>
<option value="error">Errors Only</option>
<option value="warning">Warnings Only</option>
</select>
</div>
<table class="violations-table" id="fileTable">
<thead>
<tr>
<th onclick="sortTable('fileTable', 0)">File <span class="sort-icon">↕</span></th>
<th onclick="sortTable('fileTable', 1)">Errors <span class="sort-icon">↕</span></th>
<th onclick="sortTable('fileTable', 2)">Warnings <span class="sort-icon">↕</span></th>
<th onclick="sortTable('fileTable', 3)">Total <span class="sort-icon">↕</span></th>
<th>Details</th>
</tr>
</thead>
<tbody>`;
for (const [file, fileViolations] of sortedFiles) {
const fileErrors = fileViolations.filter(v => v.severity === 'error').length;
const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length;
const total = fileViolations.length;
const fileId = `file-${Buffer.from(file).toString('base64').replace(/=/g, '')}`;
html += `
<tr class="file-row" data-errors="${fileErrors}" data-warnings="${fileWarnings}">
<td class="file-path"><code>${escapeHTML(file)}</code></td>
<td class="count-cell ${fileErrors > 0 ? 'has-errors' : ''}">${fileErrors}</td>
<td class="count-cell ${fileWarnings > 0 ? 'has-warnings' : ''}">${fileWarnings}</td>
<td class="count-cell">${total}</td>
<td>
<button class="details-btn" onclick="toggleDetails('${fileId}')">
Show Details <span class="arrow">▼</span>
</button>
</td>
</tr>
<tr class="details-row" id="${fileId}" style="display: none;">
<td colspan="5">
<div class="details-content">
${generateFileDetails(fileViolations)}
</div>
</td>
</tr>`;
}
html += `
</tbody>
</table>
</section>`;
return html;
}
/**
* Generate file details
* @param {Array} violations - Violations for a file
* @returns {string} Details HTML
*/
function generateFileDetails(violations) {
let html = '<ul class="violation-list">';
for (const v of violations) {
const severityClass = v.severity === 'error' ? 'severity-error' : 'severity-warning';
const severityIcon = v.severity === 'error' ? '🔴' : '🟡';
html += `
<li class="violation-item">
<span class="${severityClass}">${severityIcon} ${v.severity.toUpperCase()}</span>
<span class="violation-line">Line ${v.line}</span>
<span class="violation-rule">[${escapeHTML(v.rule)}]</span>
<span class="violation-message">${escapeHTML(v.message)}</span>
</li>`;
}
html += '</ul>';
return html;
}
/**
* Generate violations by rule section
* @param {Array} violations - Violations array
* @param {Object} ruleGroups - Grouped by rule
* @returns {string} By rule HTML
*/
function generateByRuleSection(violations, ruleGroups) {
if (violations.length === 0) {
return '';
}
const sortedRules = Object.entries(ruleGroups)
.sort((a, b) => b[1].length - a[1].length);
let html = `
<section class="section">
<h2>📋 Violations by Rule</h2>
<table class="violations-table" id="ruleTable">
<thead>
<tr>
<th onclick="sortTable('ruleTable', 0)">Rule <span class="sort-icon">↕</span></th>
<th onclick="sortTable('ruleTable', 1)">Errors <span class="sort-icon">↕</span></th>
<th onclick="sortTable('ruleTable', 2)">Warnings <span class="sort-icon">↕</span></th>
<th onclick="sortTable('ruleTable', 3)">Total <span class="sort-icon">↕</span></th>
<th>Locations</th>
</tr>
</thead>
<tbody>`;
for (const [ruleId, ruleViolations] of sortedRules) {
const ruleErrors = ruleViolations.filter(v => v.severity === 'error').length;
const ruleWarnings = ruleViolations.filter(v => v.severity === 'warning').length;
const total = ruleViolations.length;
const ruleIdSafe = `rule-${Buffer.from(ruleId).toString('base64').replace(/=/g, '')}`;
html += `
<tr class="rule-row">
<td class="rule-id"><code>${escapeHTML(ruleId)}</code></td>
<td class="count-cell ${ruleErrors > 0 ? 'has-errors' : ''}">${ruleErrors}</td>
<td class="count-cell ${ruleWarnings > 0 ? 'has-warnings' : ''}">${ruleWarnings}</td>
<td class="count-cell">${total}</td>
<td>
<button class="details-btn" onclick="toggleDetails('${ruleIdSafe}')">
Show Locations <span class="arrow">▼</span>
</button>
</td>
</tr>
<tr class="details-row" id="${ruleIdSafe}" style="display: none;">
<td colspan="5">
<div class="details-content">
${generateRuleDetails(ruleViolations)}
</div>
</td>
</tr>`;
}
html += `
</tbody>
</table>
</section>`;
return html;
}
/**
* Generate rule details
* @param {Array} violations - Violations for a rule
* @returns {string} Details HTML
*/
function generateRuleDetails(violations) {
let html = '<ul class="location-list">';
for (const v of violations) {
const severityClass = v.severity === 'error' ? 'severity-error' : 'severity-warning';
const severityIcon = v.severity === 'error' ? '🔴' : '🟡';
html += `
<li class="location-item">
<span class="${severityClass}">${severityIcon}</span>
<code class="location-path">${escapeHTML(v.file)}:${v.line}</code>
<span class="location-message">${escapeHTML(v.message)}</span>
</li>`;
}
html += '</ul>';
return html;
}
/**
* Generate footer
* @param {string} timestamp - Report timestamp
* @returns {string} Footer HTML
*/
function generateFooter(timestamp) {
return `
<footer class="footer">
<p>
Generated by <a href="https://github.com/sun-asterisk/engineer-excellence" target="_blank">SunLint</a>
on ${new Date(timestamp).toLocaleString()}
</p>
</footer>`;
}
/**
* Embed CSS styles
* @returns {string} Style tag
*/
function embedCSS() {
return `<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
}
.header-content h1 {
font-size: 28px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.logo { font-size: 32px; }
.header-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.meta-item {
background: rgba(255,255,255,0.1);
padding: 10px 15px;
border-radius: 5px;
}
.meta-label {
display: block;
font-size: 12px;
opacity: 0.8;
margin-bottom: 5px;
}
.meta-value {
display: block;
font-size: 14px;
font-weight: 600;
}
.summary {
padding: 30px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
border-radius: 8px;
font-size: 20px;
font-weight: 600;
}
.status-passed { background: #d1fae5; color: #065f46; }
.status-warning { background: #fef3c7; color: #92400e; }
.status-failed { background: #fee2e2; color: #991b1b; }
.score-display {
text-align: center;
}
.score-value {
font-size: 48px;
font-weight: bold;
color: #667eea;
}
.score-grade {
font-size: 24px;
color: #666;
margin-top: -5px;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 2px solid #e5e7eb;
}
.stat-card.stat-error { border-color: #fca5a5; }
.stat-card.stat-warning { border-color: #fcd34d; }
.stat-value {
font-size: 36px;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
}
.stat-card.stat-error .stat-value { color: #dc2626; }
.stat-card.stat-warning .stat-value { color: #f59e0b; }
.stat-label {
font-size: 14px;
color: #666;
}
.section {
padding: 30px;
border-bottom: 1px solid #e5e7eb;
}
.section h2 {
font-size: 22px;
margin-bottom: 20px;
color: #1f2937;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.search-box, .filter-select {
padding: 10px 15px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
}
.search-box {
flex: 1;
max-width: 400px;
}
.filter-select {
min-width: 150px;
}
.violations-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.violations-table th {
background: #f9fafb;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #e5e7eb;
cursor: pointer;
user-select: none;
}
.violations-table th:hover { background: #f3f4f6; }
.sort-icon {
font-size: 10px;
opacity: 0.5;
margin-left: 5px;
}
.violations-table td {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
.file-path, .rule-id {
font-family: 'Courier New', monospace;
font-size: 13px;
}
.count-cell {
text-align: center;
font-weight: 600;
}
.count-cell.has-errors { color: #dc2626; }
.count-cell.has-warnings { color: #f59e0b; }
.details-btn {
background: #667eea;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 5px;
}
.details-btn:hover { background: #5568d3; }
.arrow {
font-size: 10px;
transition: transform 0.2s;
}
.details-row td {
background: #f9fafb;
padding: 0;
}
.details-content {
padding: 20px;
}
.violation-list, .location-list {
list-style: none;
}
.violation-item, .location-item {
padding: 10px;
margin-bottom: 8px;
background: white;
border-left: 3px solid #e5e7eb;
border-radius: 4px;
display: flex;
gap: 10px;
align-items: center;
}
.severity-error { color: #dc2626; font-weight: 600; }
.severity-warning { color: #f59e0b; font-weight: 600; }
.violation-line {
background: #e5e7eb;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
font-family: monospace;
}
.violation-rule {
color: #667eea;
font-weight: 600;
font-size: 12px;
}
.violation-message, .location-message {
flex: 1;
color: #666;
font-size: 14px;
}
.location-path {
font-family: 'Courier New', monospace;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
}
.no-violations {
text-align: center;
padding: 40px;
font-size: 18px;
color: #10b981;
}
.footer {
padding: 20px;
text-align: center;
color: #666;
font-size: 14px;
background: #f9fafb;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover { text-decoration: underline; }
@media print {
body { background: white; padding: 0; }
.container { box-shadow: none; }
.details-btn { display: none; }
.details-row { display: table-row ; }
}
@media (max-width: 768px) {
.header-meta { grid-template-columns: 1fr; }
.summary-stats { grid-template-columns: 1fr; }
.controls { flex-direction: column; }
.search-box { max-width: 100%; }
}
</style>`;
}
/**
* Embed JavaScript
* @returns {string} Script tag
*/
function embedJavaScript() {
return `<script>
// Toggle details row
function toggleDetails(id) {
const row = document.getElementById(id);
const btn = event.target.closest('.details-btn');
const arrow = btn.querySelector('.arrow');
if (row.style.display === 'none') {
row.style.display = 'table-row';
arrow.style.transform = 'rotate(180deg)';
btn.innerHTML = 'Hide Details <span class="arrow" style="transform: rotate(180deg);">▼</span>';
} else {
row.style.display = 'none';
arrow.style.transform = 'rotate(0deg)';
btn.innerHTML = 'Show Details <span class="arrow">▼</span>';
}
}
// Sort table
function sortTable(tableId, columnIndex) {
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr')).filter(r => !r.classList.contains('details-row'));
const isNumeric = columnIndex > 0;
rows.sort((a, b) => {
const aVal = a.cells[columnIndex].textContent.trim();
const bVal = b.cells[columnIndex].textContent.trim();
if (isNumeric) {
return parseInt(bVal) - parseInt(aVal);
}
return aVal.localeCompare(bVal);
});
rows.forEach(row => {
const detailsRow = row.nextElementSibling;
tbody.appendChild(row);
if (detailsRow && detailsRow.classList.contains('details-row')) {
tbody.appendChild(detailsRow);
}
});
}
// File search
const fileSearch = document.getElementById('fileSearch');
if (fileSearch) {
fileSearch.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const rows = document.querySelectorAll('#fileTable tbody tr.file-row');
rows.forEach(row => {
const file = row.querySelector('.file-path').textContent.toLowerCase();
const detailsRow = row.nextElementSibling;
if (file.includes(searchTerm)) {
row.style.display = '';
if (detailsRow && detailsRow.style.display !== 'none') {
detailsRow.style.display = '';
}
} else {
row.style.display = 'none';
if (detailsRow) {
detailsRow.style.display = 'none';
}
}
});
});
}
// File filter
const fileFilter = document.getElementById('fileFilter');
if (fileFilter) {
fileFilter.addEventListener('change', function() {
const filter = this.value;
const rows = document.querySelectorAll('#fileTable tbody tr.file-row');
rows.forEach(row => {
const errors = parseInt(row.dataset.errors);
const warnings = parseInt(row.dataset.warnings);
const detailsRow = row.nextElementSibling;
let show = true;
if (filter === 'error' && errors === 0) show = false;
if (filter === 'warning' && warnings === 0) show = false;
if (show) {
row.style.display = '';
} else {
row.style.display = 'none';
if (detailsRow) {
detailsRow.style.display = 'none';
}
}
});
});
}
</script>`;
}
/**
* Escape HTML special characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHTML(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
module.exports = {
generateHTMLReport
};