perf-lens
Version:
AI-powered frontend performance optimizer
229 lines (220 loc) • 9.18 kB
JavaScript
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import { generateHtmlReport } from './htmlReport.js';
/**
* Generates a markdown report from performance analysis data
* @param {PerformanceReport} data - The performance analysis data to format
* @returns {string} A formatted markdown report
*/
export function generateMarkdownReport(data) {
return `# Performance Analysis Report
${data.metadata
? `
Generated on: ${new Date(data.metadata.timestamp).toLocaleString()}
Analysis Duration: ${Math.round(data.metadata.duration / 1000)}s
`
: ''}
## 🌟 Lighthouse Performance Report
### Metrics
\`\`\`
${data.lighthouse.metrics}
\`\`\`
### Detailed Report
${data.lighthouse.report}
### Analysis
${typeof data.lighthouse.analysis === 'object'
? Object.entries(data.lighthouse.analysis)
.map(([key, value]) => `#### ${key}\n${value}`)
.join('\n\n')
: data.lighthouse.analysis || 'No analysis available'}
## 🧠 Code Analysis
### Critical Issues
${data.codeAnalysis.critical.length > 0
? data.codeAnalysis.critical.join('\n\n')
: '_No critical issues found_'}
### Warnings
${data.codeAnalysis.warnings.length > 0
? data.codeAnalysis.warnings.join('\n\n')
: '_No warnings found_'}
### Suggestions
${data.codeAnalysis.suggestions.length > 0
? data.codeAnalysis.suggestions.join('\n\n')
: '_No suggestions found_'}
`;
}
/**
* Converts markdown text to HTML with proper formatting and syntax highlighting
* @param {string} text - The markdown text to convert
* @returns {string} The converted HTML text
*/
export function formatMarkdownToHtml(text) {
// First, escape any existing HTML to prevent injection
text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// Store code blocks temporarily with a unique marker
const codeBlocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
// Clean up the code block content
code = code
.trim()
.replace(/<\/?p>/g, '') // Remove paragraph tags
.replace(/^\s{4}/gm, '') // Remove common leading indentation
.replace(/<(\/?(?:em|strong|code|pre)>)/g, '<$1') // Restore intended HTML tags
.replace(/&([lg]t;)/g, '&$1'); // Fix HTML entities
codeBlocks.push(`<pre><code class="language-${lang}">${code}</code></pre>`);
return placeholder;
});
// Convert inline code with proper escaping
const inlineCodeBlocks = [];
text = text.replace(/`([^`]+)`/g, (match, code) => {
const placeholder = `__INLINE_CODE_${inlineCodeBlocks.length}__`;
inlineCodeBlocks.push(`<code>${code.replace(/<(\/?(?:em|strong|code|pre)>)/g, '<$1')}</code>`);
return placeholder;
});
// Convert headers (ensuring proper hierarchy)
text = text
.replace(/^# (.*$)/gm, '<h2>$1</h2>')
.replace(/^## (.*$)/gm, '<h3>$1</h3>')
.replace(/^### (.*$)/gm, '<h4>$1</h4>')
.replace(/^#### (.*$)/gm, '<h5>$1</h5>')
.replace(/^##### (.*$)/gm, '<h6>$1</h6>');
// Convert bold text
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Convert italic text
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Handle lists with better nesting support
const processLists = (input) => {
const lines = input.split('\n');
const output = [];
const listStack = [];
let currentListType = null;
lines.forEach(line => {
const trimmedLine = line.trimStart();
const indentLevel = Math.floor((line.length - trimmedLine.length) / 2);
// Check for list items
const bulletMatch = trimmedLine.match(/^[-*]\s+(.+)/);
const numberMatch = trimmedLine.match(/^(\d+)\.\s+(.+)/);
if (bulletMatch || numberMatch) {
const content = bulletMatch ? bulletMatch[1] : numberMatch[2];
const listType = bulletMatch ? 'ul' : 'ol';
// If we're starting a new list
if (!currentListType) {
currentListType = listType;
output.push(`<${listType}>`);
listStack.push({ type: listType, level: indentLevel });
}
// If we're changing list types at the same level
else if (currentListType !== listType &&
listStack[listStack.length - 1]?.level === indentLevel) {
while (listStack.length > 0 && listStack[listStack.length - 1].level >= indentLevel) {
const last = listStack.pop();
output.push(`</li>\n</${last.type}>`);
}
currentListType = listType;
output.push(`<${listType}>`);
listStack.push({ type: listType, level: indentLevel });
}
// Handle nesting
else if (indentLevel > (listStack[listStack.length - 1]?.level ?? -1)) {
output.push(`<${listType}>`);
listStack.push({ type: listType, level: indentLevel });
currentListType = listType;
}
// If we're un-nesting
else if (indentLevel < (listStack[listStack.length - 1]?.level ?? 0)) {
while (listStack.length > 0 && listStack[listStack.length - 1].level > indentLevel) {
const last = listStack.pop();
output.push(`</li>\n</${last.type}>`);
}
if (listStack.length > 0) {
output.push('</li>');
}
}
// Same level, same type
else if (listStack.length > 0) {
output.push('</li>');
}
output.push(`<li>${content}`);
}
else if (trimmedLine.trim()) {
// Close all open lists when hitting non-list content
while (listStack.length > 0) {
const last = listStack.pop();
output.push(`</li>\n</${last.type}>`);
}
currentListType = null;
output.push(trimmedLine);
}
});
// Close any remaining open lists
while (listStack.length > 0) {
const last = listStack.pop();
output.push(`</li>\n</${last.type}>`);
}
return output.join('\n');
};
// Process lists
text = processLists(text);
// Convert paragraphs (lines with content)
text = text
.split('\n')
.map(line => {
if (line.trim() &&
!line.match(/^<(div|pre|p|h[1-6]|ul|ol|li)[^>]*>/i) && // Only exclude block-level elements
!line.includes('__CODE_BLOCK_') &&
!line.includes('__INLINE_CODE_')) {
return `<p>${line}</p>`;
}
return line;
})
.join('\n');
// Restore code blocks
codeBlocks.forEach((block, i) => {
text = text.replace(`__CODE_BLOCK_${i}__`, block);
});
// Restore inline code blocks
inlineCodeBlocks.forEach((block, i) => {
text = text.replace(`__INLINE_CODE_${i}__`, block);
});
// Clean up formatting
return text
.replace(/\n{3,}/g, '\n\n')
.replace(/<p>\s*<\/p>/g, '')
.replace(/(<\/(ul|ol)>)\s*<p>\s*<\/p>/g, '$1')
.replace(/<p>\s*<(ul|ol)>/g, '<$1>')
.replace(/<\/(ul|ol)>\s*<\/p>/g, '</$1>')
.replace(/<li>\s+/g, '<li>')
.replace(/\s+<\/li>/g, '</li>')
.replace(/<\/(ul|ol)>\s*<(ul|ol)>/g, '</$1>\n<$2>')
.replace(/\n+/g, '\n')
.replace(/>\s+</g, '><') // Remove extra spaces between tags
.replace(/>>+/g, '>') // Remove duplicate closing brackets
.trim();
}
/**
* Saves a performance report in the specified format
* @param {PerformanceReport} data - The performance analysis data to save
* @param {'md' | 'html'} format - The output format (markdown or HTML)
* @param {string} [outputPath] - Optional path to save the report
* @returns {string} The path where the report was saved
*/
export function saveReport(data, format = 'html', outputPath) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const defaultFileName = `performance-report-${timestamp}`;
const filePath = outputPath || path.join(process.cwd(), defaultFileName + '.' + format);
// Create output directory if it doesn't exist
const outputDir = path.dirname(filePath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const content = format === 'md' ? generateMarkdownReport(data) : generateHtmlReport(data);
try {
fs.writeFileSync(filePath, content);
return filePath;
}
catch (error) {
console.error(chalk.red('Error saving report:'), error);
throw error;
}
}