apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
714 lines (642 loc) • 20.1 kB
JavaScript
import { writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Formats inspect results as an interactive HTML report and opens it in the browser.
*/
export function formatInspectReportOutput(result) {
const timestamp = new Date().toISOString();
const reportPath = join(tmpdir(), `apisurf-inspect-${Date.now()}.html`);
const html = generateInteractiveHtmlReport(result, timestamp);
writeFileSync(reportPath, html);
// Open the report in the default browser
openInBrowser(reportPath);
return `HTML report generated at: ${reportPath}`;
}
async function openInBrowser(filePath) {
const platform = process.platform;
let command;
if (platform === 'darwin') {
command = `open "${filePath}"`;
}
else if (platform === 'win32') {
command = `start "${filePath}"`;
}
else {
command = `xdg-open "${filePath}"`;
}
try {
await execAsync(command);
}
catch (error) {
console.error('Failed to open browser:', error);
}
}
function generateInteractiveHtmlReport(result, timestamp) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Surface: ${result.packageName}@${result.version}</title>
<style>
${getInteractiveStyles()}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>API Surface Inspector</h1>
<p class="package-name">${result.packageName}@${result.version}</p>
${result.repositoryUrl ? `
<div class="npm-info">
<span class="npm-info-icon">📦</span>
<div class="npm-info-text">
Repository: <a href="${result.repositoryUrl}" target="_blank" rel="noopener noreferrer">${result.repositoryUrl}</a>
</div>
</div>
` : ''}
<div class="summary-cards">
${generateSummaryCards(result)}
</div>
<p class="status ${result.success ? 'success' : 'error'}">${result.summary}</p>
${result.errors && result.errors.length > 0 ? `
<div class="errors">
<h3>⚠️ Errors Encountered</h3>
<ul>
${result.errors.map(error => `<li>${escapeHtml(error)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
${result.success ? generateInteractiveApiSurfaces(result) : ''}
<div class="footer">
Generated by apisurf • ${timestamp}
</div>
</div>
<script>
${getInteractiveScript()}
</script>
</body>
</html>`;
}
function getInteractiveStyles() {
return `
@font-face {
font-family: 'Segoe UI Web';
src: local('Segoe UI'), local('Segoe UI Web Regular'), local('Segoe UI Regular');
font-weight: 400;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI Web', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 1.5;
color: #323130;
background-color: #f3f2f1;
margin: 0;
padding: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
margin-bottom: 20px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0 0 8px 0;
color: #323130;
}
.package-name {
font-size: 24px;
color: #0078d4;
margin: 0 0 16px 0;
font-weight: 500;
}
.npm-info {
background: #e1f5fe;
border: 1px solid #81d4fa;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.npm-info-icon {
font-size: 20px;
}
.npm-info-text {
flex: 1;
}
.npm-info-text a {
color: #0078d4;
text-decoration: none;
}
.npm-info-text a:hover {
text-decoration: underline;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.summary-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
text-align: center;
}
.summary-card .number {
font-size: 36px;
font-weight: 600;
margin: 8px 0;
}
.summary-card .label {
color: #605e5c;
font-size: 14px;
}
.summary-card.primary .number {
color: #0078d4;
}
.summary-card.exports .number {
color: #107c10;
}
.summary-card.types .number {
color: #5c2d91;
}
.status {
font-size: 16px;
padding: 12px 16px;
border-radius: 4px;
margin: 0;
}
.status.success {
background: #dff6dd;
color: #0b6a0b;
border: 1px solid #92d390;
}
.status.error {
background: #fde7e9;
color: #a80000;
border: 1px solid #f1707b;
}
.errors {
background: #fde7e9;
border: 1px solid #f1707b;
border-radius: 4px;
padding: 16px;
margin-top: 16px;
}
.errors h3 {
margin: 0 0 8px 0;
color: #a80000;
font-size: 16px;
}
.errors ul {
margin: 0;
padding-left: 20px;
}
.api-surface {
background: white;
border-radius: 8px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
margin-bottom: 16px;
overflow: hidden;
}
.api-surface-header {
padding: 16px 24px;
background: #faf9f8;
border-bottom: 1px solid #edebe9;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
}
.api-surface-header:hover {
background: #f3f2f1;
}
.api-surface-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: #323130;
display: flex;
align-items: center;
gap: 12px;
}
.entry-stats {
display: flex;
gap: 12px;
align-items: center;
}
.stat-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
font-weight: 400;
}
.stat-badge.exports {
background: #dff6dd;
color: #0b6a0b;
}
.stat-badge.types {
background: #f3e5f5;
color: #6a1b9a;
}
.chevron {
transition: transform 0.2s;
color: #605e5c;
display: inline-block;
font-size: 12px;
}
.chevron.expanded {
transform: rotate(90deg);
}
.api-surface-content {
display: none;
padding: 24px;
}
.api-surface-content.expanded {
display: block;
}
.export-section {
margin-bottom: 32px;
}
.export-section:last-child {
margin-bottom: 0;
}
.export-section h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
color: #323130;
display: flex;
align-items: center;
gap: 8px;
}
.export-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 8px;
}
.export-item {
padding: 12px 16px;
background: #faf9f8;
border: 1px solid #edebe9;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.export-item:hover {
background: #f3f2f1;
border-color: #c8c6c4;
}
.export-item.expanded {
grid-column: 1 / -1;
background: white;
border-color: #0078d4;
}
.export-name {
font-weight: 600;
color: #0078d4;
flex: 1;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.type-badge.function { background: #e3f2fd; color: #1565c0; }
.type-badge.class { background: #f3e5f5; color: #6a1b9a; }
.type-badge.interface { background: #e8f5e9; color: #2e7d32; }
.type-badge.type { background: #fff3e0; color: #e65100; }
.type-badge.enum { background: #fce4ec; color: #c2185b; }
.type-badge.variable { background: #f1f8e9; color: #558b2f; }
.export-details {
display: none;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #edebe9;
}
.export-item.expanded .export-details {
display: block;
}
.code-signature {
background: #f8f8f8;
border: 1px solid #e1e1e1;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.property-list {
margin: 8px 0;
}
.property-item {
padding: 4px 0;
font-size: 13px;
color: #605e5c;
}
.property-name {
color: #0078d4;
font-weight: 600;
}
.search-box {
margin-bottom: 16px;
position: relative;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 36px;
border: 1px solid #edebe9;
border-radius: 4px;
font-size: 14px;
background: white;
}
.search-input:focus {
outline: none;
border-color: #0078d4;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #605e5c;
}
.no-results {
text-align: center;
color: #605e5c;
padding: 40px;
font-style: italic;
}
.footer {
text-align: center;
color: #605e5c;
font-size: 12px;
margin-top: 40px;
padding: 20px;
}
/* Syntax highlighting */
.hljs-keyword { color: #0000ff; font-weight: 600; }
.hljs-type { color: #2b91af; }
.hljs-string { color: #a31515; }
.hljs-number { color: #098658; }
.hljs-comment { color: #008000; font-style: italic; }
.hljs-function { color: #795e26; }
.hljs-class { color: #267f99; }
`;
}
function generateSummaryCards(result) {
let totalExports = 0;
let totalTypes = 0;
for (const surface of result.apiSurfaces.values()) {
totalExports += surface.namedExports.size;
totalTypes += surface.typeOnlyExports.size;
}
return `
<div class="summary-card primary">
<div class="label">Entry Points</div>
<div class="number">${result.apiSurfaces.size}</div>
</div>
<div class="summary-card exports">
<div class="label">Named Exports</div>
<div class="number">${totalExports}</div>
</div>
<div class="summary-card types">
<div class="label">Type Exports</div>
<div class="number">${totalTypes}</div>
</div>
`;
}
function generateInteractiveApiSurfaces(result) {
const sections = [];
// Add search box
sections.push(`
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" placeholder="Search exports..." id="searchInput">
</div>
`);
let index = 0;
for (const [entryPoint, surface] of result.apiSurfaces) {
const exportCount = surface.namedExports.size;
const typeCount = surface.typeOnlyExports.size;
sections.push(`
<div class="api-surface" data-entry-point="${escapeHtml(entryPoint)}">
<div class="api-surface-header" onclick="toggleSection(${index})">
<h2 class="api-surface-title">
${entryPoint === 'main' ? '📄 Main Export' : `📄 ${escapeHtml(entryPoint)}`}
</h2>
<div class="entry-stats">
${exportCount > 0 ? `<span class="stat-badge exports">${exportCount} exports</span>` : ''}
${typeCount > 0 ? `<span class="stat-badge types">${typeCount} types</span>` : ''}
<span class="chevron" id="chevron-${index}">❯</span>
</div>
</div>
<div class="api-surface-content" id="content-${index}">
${generateInteractiveSurfaceContent(surface, index)}
</div>
</div>
`);
index++;
}
return sections.join('');
}
function generateInteractiveSurfaceContent(surface, surfaceIndex) {
const sections = [];
let itemIndex = 0;
// Default export
if (surface.defaultExport) {
sections.push(`
<div class="export-section">
<h3>Default Export</h3>
<p>This module has a default export.</p>
</div>
`);
}
// Named exports
if (surface.namedExports.size > 0) {
const exports = Array.from(surface.namedExports).sort();
sections.push(`
<div class="export-section">
<h3>Named Exports (${exports.length})</h3>
<div class="export-grid">
${exports.map(name => {
const typeDef = surface.typeDefinitions?.get(name);
const id = `export-${surfaceIndex}-${itemIndex++}`;
return generateExportItem(name, typeDef, id, false);
}).join('')}
</div>
</div>
`);
}
// Type exports
if (surface.typeOnlyExports.size > 0) {
const typeExports = Array.from(surface.typeOnlyExports).sort();
sections.push(`
<div class="export-section">
<h3>Type Exports (${typeExports.length})</h3>
<div class="export-grid">
${typeExports.map(name => {
const typeDef = surface.typeDefinitions?.get(name);
const id = `export-${surfaceIndex}-${itemIndex++}`;
return generateExportItem(name, typeDef, id, true);
}).join('')}
</div>
</div>
`);
}
// Re-exports
if (surface.starExports.length > 0) {
sections.push(`
<div class="export-section">
<h3>Re-exports (${surface.starExports.length})</h3>
<div class="export-grid">
${surface.starExports.map(module => `
<div class="export-item" style="cursor: default;">
<span style="color: #605e5c;">export * from "${module}"</span>
</div>
`).join('')}
</div>
</div>
`);
}
return sections.join('');
}
function generateExportItem(name, typeDef, id, _isTypeOnly) {
const hasDetails = typeDef && (typeDef.signature || typeDef.properties || typeDef.members);
return `
<div class="export-item" id="${id}" ${hasDetails ? `onclick="toggleExport('${id}')"` : ''} data-name="${name.toLowerCase()}">
<span class="export-name">${escapeHtml(name)}</span>
${typeDef ? `<span class="type-badge ${typeDef.kind}">${typeDef.kind}</span>` : ''}
${hasDetails ? generateExportDetails(typeDef) : ''}
</div>
`;
}
function generateExportDetails(typeDef) {
const details = [];
details.push('<div class="export-details">');
if (typeDef.signature) {
details.push(`
<div class="code-signature">${highlightTypeScript(typeDef.signature)}</div>
`);
}
// Show properties for interfaces and classes
if (typeDef.properties && typeDef.properties.size > 0) {
details.push('<div class="property-list">');
details.push('<strong>Properties:</strong>');
const props = Array.from(typeDef.properties.entries()).slice(0, 10);
props.forEach(([propName, propType]) => {
details.push(`<div class="property-item"><span class="property-name">${propName}</span>: ${escapeHtml(propType)}</div>`);
});
if (typeDef.properties.size > 10) {
details.push(`<div class="property-item">... and ${typeDef.properties.size - 10} more</div>`);
}
details.push('</div>');
}
// Show members for enums
if (typeDef.members && typeDef.members.length > 0) {
details.push('<div class="property-list">');
details.push('<strong>Members:</strong>');
typeDef.members.slice(0, 10).forEach(member => {
details.push(`<div class="property-item">${member}</div>`);
});
if (typeDef.members.length > 10) {
details.push(`<div class="property-item">... and ${typeDef.members.length - 10} more</div>`);
}
details.push('</div>');
}
details.push('</div>');
return details.join('');
}
function getInteractiveScript() {
return `
function toggleSection(index) {
const content = document.getElementById('content-' + index);
const chevron = document.getElementById('chevron-' + index);
content.classList.toggle('expanded');
chevron.classList.toggle('expanded');
}
function toggleExport(id) {
const element = document.getElementById(id);
element.classList.toggle('expanded');
}
// Search functionality
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const allExports = document.querySelectorAll('.export-item[data-name]');
allExports.forEach(item => {
const name = item.getAttribute('data-name');
if (name.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
// Update section visibility
document.querySelectorAll('.export-section').forEach(section => {
const visibleItems = section.querySelectorAll('.export-item[data-name]:not([style*="display: none"])');
section.style.display = visibleItems.length > 0 ? '' : 'none';
});
});
// Expand first section by default
if (document.querySelector('.api-surface-content')) {
toggleSection(0);
}
`;
}
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// Simple TypeScript syntax highlighting
function highlightTypeScript(code) {
// First escape HTML
let highlighted = escapeHtml(code);
// Keywords
const keywords = /\b(export|import|from|class|interface|type|enum|const|let|var|function|async|await|return|if|else|for|while|do|switch|case|break|continue|try|catch|finally|throw|new|this|super|extends|implements|private|public|protected|static|readonly|abstract|namespace|module|declare|as|is|in|of|typeof|keyof|never|any|void|null|undefined|true|false)\b/g;
// Types
const types = /\b(string|number|boolean|object|symbol|bigint|unknown|any|void|never|null|undefined|Promise|Array|Map|Set|Date|RegExp|Error|Function)\b/g;
// Apply highlighting
highlighted = highlighted
.replace(keywords, '<span class="hljs-keyword">$1</span>')
.replace(types, '<span class="hljs-type">$1</span>');
return highlighted;
}