knip-html-reporter
Version:
A beautiful HTML reporter for Knip that transforms analysis results into interactive reports
363 lines (360 loc) • 14.4 kB
JavaScript
import { getDefaultStyles } from './styles.js';
import { getInteractiveScript } from './interactive.js';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// SVG Icons for theme toggle
const THEME_ICONS = {
light: /* html */ `<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M0 0h24v24H0z"></path><path d="M12 9c1.65 0 3 1.35 3 3s-1.35 3-3 3-3-1.35-3-3 1.35-3 3-3m0-2c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"></path></svg>`,
dark: /* html */ `<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M0 0h24v24H0z"></path><path d="M9.37 5.51A7.35 7.35 0 0 0 9.1 7.5c0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27A7.014 7.014 0 0 1 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49zM12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"></path></svg>`,
system: /* html */ `<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 20 20" aria-hidden="true" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 4.25A2.25 2.25 0 0 1 4.25 2h11.5A2.25 2.25 0 0 1 18 4.25v8.5A2.25 2.25 0 0 1 15.75 15h-3.105a3.501 3.501 0 0 0 1.1 1.677A.75.75 0 0 1 13.26 18H6.74a.75.75 0 0 1-.484-1.323A3.501 3.501 0 0 0 7.355 15H4.25A2.25 2.25 0 0 1 2 12.75v-8.5Zm1.5 0a.75.75 0 0 1 .75-.75h11.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75v-7.5Z" clip-rule="evenodd"></path></svg>`,
};
/** Utility: make a file path relative to cwd (without leading slash) */
function toRelativePath(filePath, cwd) {
return filePath.startsWith(cwd) ? filePath.slice(cwd.length).replace(/^\//, '') : filePath;
}
/**
* Generate a single issue table row
*/
function generateIssueTableRow(symbol, issue, filePath, cwd, isFileIssue = false) {
const line = issue.line ?? 1;
const col = issue.col ?? 1;
const relativePath = toRelativePath(filePath, cwd);
const symbolType = issue.symbolType ? ` <span class="symbol-type">${issue.symbolType}</span>` : '';
const locationContent = isFileIssue
? `<span class="file-path">${escapeHtml(relativePath)}</span>`
: `<span class="file-path">${escapeHtml(relativePath)}</span>
<span class="position">${line}:${col}</span>`;
return /* html */ `<tr>
<td class="issue-name">
<span class="symbol">${escapeHtml(symbol)}${symbolType}</span>
</td>
<td class="issue-location">
<div class="location-content">
${locationContent}
</div>
</td>
<td class="issue-actions">
${generateIdeButton(filePath, line, col)}
</td>
</tr>`;
}
/**
* Generate IDE button HTML
*/
function generateIdeButton(filePath, line, col) {
return /* html */ `<button class="ide-btn-inline" onclick="openInIDE('${escapeHtml(filePath)}', ${line}, ${col})" title="Open in IDE" aria-label="Open in IDE">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.793 8.5H2a.5.5 0 0 1-.5-.5z"/>
</svg>
</button>`;
}
/**
* Generate HTML report from Knip results
*/
export function generateHtml(options) {
const { issues, counters, config, cwd } = options;
const styles = getStyles(config);
const summary = generateSummary(counters);
const controls = generateControls();
const issuesHtml = generateIssuesSection(issues, cwd);
const script = getInteractiveScript();
return /* html */ `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(config.title)}</title>
<script>
// Set theme immediately to prevent flash
(function() {
const savedTheme = localStorage.getItem('knip-report-theme') || 'system';
const root = document.documentElement;
if (savedTheme === 'system') {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
root.setAttribute('data-theme', savedTheme);
}
})();
</script>
${styles}
</head>
<body data-cwd="${escapeHtml(cwd)}">
<div class="container">
<header>
<div class="header-content">
<h1>${escapeHtml(config.title)}</h1>
<p class="timestamp">Generated on ${new Date().toLocaleString()}</p>
</div>
<div class="theme-toggle" role="radiogroup" aria-label="Theme">
<button class="theme-toggle-btn active" data-theme="light" aria-label="Light theme">
${THEME_ICONS.light}
<span>Light</span>
</button>
<button class="theme-toggle-btn" data-theme="dark" aria-label="Dark theme">
${THEME_ICONS.dark}
<span>Dark</span>
</button>
<button class="theme-toggle-btn" data-theme="system" aria-label="System theme">
${THEME_ICONS.system}
<span>System</span>
</button>
</div>
</header>
${summary}
${controls}
${issuesHtml}
</div>
${script}
</body>
</html>`;
}
/**
* Get styles for the HTML report
*/
function getStyles(config) {
let styles = '';
if (config.autoStyles) {
styles += `<style>${getDefaultStyles()}</style>`;
}
if (config.customStyles) {
try {
const customCss = readFileSync(resolve(config.customStyles), 'utf-8');
styles += `<style>${customCss}</style>`;
}
catch {
console.warn(`Warning: Could not load custom styles from ${config.customStyles}`);
}
}
return styles;
}
/**
* Generate controls (search and filters)
*/
function generateControls() {
return /* html */ `
<div class="controls">
<div class="search-container">
<input
type="text"
id="search-input"
placeholder="Search issues, files, or symbols..."
aria-label="Search"
/>
<button id="clear-search" aria-label="Clear search" style="display: none;">×</button>
</div>
</div>
`;
}
/**
* Generate summary section with counters
*/
function generateSummary(counters) {
const EXCLUDED_KEYS = ['_files', 'processed', 'total'];
const total = Object.entries(counters)
.filter(([key]) => !EXCLUDED_KEYS.includes(key))
.reduce((sum, [, count]) => sum + count, 0);
if (total === 0) {
return /* html */ `
<div class="summary">
<div class="success-state">
<svg class="success-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<h2>All Clear!</h2>
<p>No issues found in your project.</p>
</div>
</div>
`;
}
const issueData = Object.entries(counters)
.filter(([key, count]) => !EXCLUDED_KEYS.includes(key) && count > 0)
.map(([type, count]) => ({
type,
count: count,
percentage: (count / total) * 100,
}))
.sort((a, b) => b.count - a.count);
const items = issueData
.map(({ type, count, percentage }) => `
<div class="summary-card" data-filter="${formatIssueType(type).toLowerCase()}" role="button" tabindex="0" aria-label="Filter by ${formatIssueType(type)}">
<div class="summary-card-header">
<div class="summary-card-info">
<div class="summary-card-label">${formatIssueType(type)}</div>
<div class="summary-card-description">${getIssueTypeDescription(type)}</div>
<div class="summary-card-count">${count}</div>
</div>
</div>
<div class="summary-card-bar">
<div class="summary-card-bar-fill" style="width: ${percentage.toFixed(1)}%"></div>
</div>
<div class="summary-card-percentage">${percentage.toFixed(1)}%</div>
</div>
`)
.join('');
return /* html */ `
<div class="summary">
<div class="summary-header">
<div class="summary-title">
<h2>Analysis Overview</h2>
<p class="summary-subtitle">Found ${total} issue${total === 1 ? '' : 's'} across ${issueData.length} categor${issueData.length === 1 ? 'y' : 'ies'}</p>
</div>
<div class="summary-total-badge">
<span class="summary-total-count">${total}</span>
<span class="summary-total-label">Total Issues</span>
</div>
</div>
<div class="summary-cards">
${items}
</div>
</div>
`;
}
/**
* Generate issues section organized by category
*/
function generateIssuesSection(issues, cwd) {
const categorizedIssues = new Map();
// Handle unused files
if (issues.files && issues.files.size > 0) {
const filesArray = Array.from(issues.files);
categorizedIssues.set('files', filesArray.map((file) => {
const relativeFile = toRelativePath(file, cwd);
// Extract just the filename for display
const fileName = relativeFile.split('/').pop() || relativeFile;
return {
symbol: fileName,
issue: {
type: 'files',
symbol: fileName,
filePath: file,
workspace: '.',
line: 1,
col: 1,
},
filePath: file,
};
}));
}
// Helper to push into map
const pushIssue = (categoryKey, payload) => {
if (!categorizedIssues.has(categoryKey))
categorizedIssues.set(categoryKey, []);
categorizedIssues.get(categoryKey).push(payload);
};
// Process other issue types
const issueTypes = [
'dependencies',
'devDependencies',
'optionalPeerDependencies',
'unlisted',
'binaries',
'unresolved',
'exports',
'types',
'nsExports',
'nsTypes',
'duplicates',
'enumMembers',
'classMembers',
];
for (const issueType of issueTypes) {
const issueRecords = issues[issueType];
if (issueRecords && Object.keys(issueRecords).length > 0) {
for (const [file, symbolIssues] of Object.entries(issueRecords)) {
for (const [symbol, issue] of Object.entries(symbolIssues)) {
pushIssue(issueType, { symbol, issue: issue, filePath: file });
}
}
}
}
if (categorizedIssues.size === 0)
return '';
// Generate category sections with tables
const sections = Array.from(categorizedIssues.entries())
.map(([category, issueList]) => {
const title = formatIssueType(category);
const description = getIssueTypeDescription(category);
const isFileCategory = category === 'files';
const rows = issueList
.map(({ symbol, issue, filePath }) => generateIssueTableRow(symbol, issue, filePath, cwd, isFileCategory))
.join('');
return /* html */ `
<div class="category-section" data-category="${category.toLowerCase()}">
<div class="category-header">
<h3>
<span class="issue-badge">${title}</span>
<span class="issue-count">${issueList.length}</span>
</h3>
<p class="category-description">${description}</p>
</div>
<div class="table-container">
<table class="issues-table">
<thead>
<tr>
<th>Name</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
</div>
`;
})
.join('');
return /* html */ `
<div class="issues-section">
<h2>Issues by Category</h2>
${sections}
</div>
`;
}
/**
* Format issue type name for display
*/
function formatIssueType(type) {
return type
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
}
/**
* Get descriptive text for issue type
*/
function getIssueTypeDescription(type) {
const descriptions = {
dependencies: 'Unused dependency',
devDependencies: 'Unused dev dependency',
optionalPeerDependencies: 'Unused optional peer dependency',
unlisted: 'Used but not listed in package.json',
binaries: 'Unused binary',
unresolved: 'Import cannot be resolved',
exports: 'Exported but never used',
types: 'Type export is unused',
nsExports: 'Namespace export is unused',
nsTypes: 'Namespace type is unused',
duplicates: 'Duplicate export',
enumMembers: 'Enum member is unused',
classMembers: 'Class member is unused',
files: 'Unused file',
};
return descriptions[type] || 'Unused';
}
/**
* Escape HTML special characters
*/
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}