UNPKG

apisurf

Version:

Analyze API surface changes between npm package versions to catch breaking changes

714 lines (642 loc) 20.1 kB
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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; 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; }