apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
416 lines (374 loc) • 10.8 kB
JavaScript
/**
* Formats inspect results as static HTML.
*/
export function formatInspectHtmlOutput(result) {
return generateHtmlReport(result);
}
function generateHtmlReport(result) {
const timestamp = new Date().toISOString();
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>
${getStyles()}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>API Surface Report</h1>
<p class="subtitle">${result.packageName}@${result.version}</p>
${result.repositoryUrl ? `<p class="repository">Repository: <a href="${result.repositoryUrl}" target="_blank">${result.repositoryUrl}</a></p>` : ''}
<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:</h3>
<ul>
${result.errors.map(error => `<li>${escapeHtml(error)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
${result.success ? generateApiSurfaces(result) : ''}
<div class="footer">
Generated by apisurf • ${timestamp}
</div>
</div>
</body>
</html>`;
}
function getStyles() {
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;
}
.subtitle {
font-size: 20px;
color: #0078d4;
margin: 0 0 8px 0;
}
.repository {
color: #605e5c;
margin: 0 0 20px 0;
}
.repository a {
color: #0078d4;
text-decoration: none;
}
.repository a:hover {
text-decoration: underline;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.summary-card {
background: #faf9f8;
padding: 16px;
border-radius: 4px;
text-align: center;
border: 1px solid #edebe9;
}
.summary-card .number {
font-size: 28px;
font-weight: 600;
margin: 4px 0;
color: #0078d4;
}
.summary-card .label {
color: #605e5c;
font-size: 12px;
}
.status {
font-size: 16px;
padding: 12px;
border-radius: 4px;
margin: 16px 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;
}
.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;
}
.api-surface-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: #323130;
}
.api-surface-content {
padding: 24px;
}
.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-count {
font-size: 14px;
font-weight: normal;
color: #605e5c;
}
.export-list {
list-style: none;
padding: 0;
margin: 0;
}
.export-item {
padding: 8px 12px;
margin-bottom: 4px;
background: #faf9f8;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
display: flex;
align-items: baseline;
gap: 8px;
}
.export-name {
font-weight: 600;
color: #0078d4;
}
.export-type {
font-size: 12px;
color: #605e5c;
font-style: italic;
}
.type-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
}
.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; }
.code-block {
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;
}
.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; }
`;
}
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">
<div class="label">Entry Points</div>
<div class="number">${result.apiSurfaces.size}</div>
</div>
<div class="summary-card">
<div class="label">Named Exports</div>
<div class="number">${totalExports}</div>
</div>
<div class="summary-card">
<div class="label">Type Exports</div>
<div class="number">${totalTypes}</div>
</div>
`;
}
function generateApiSurfaces(result) {
const sections = [];
for (const [entryPoint, surface] of result.apiSurfaces) {
sections.push(`
<div class="api-surface">
<div class="api-surface-header">
<h2 class="api-surface-title">${entryPoint === 'main' ? '📄 Main Export' : `📄 ${entryPoint}`}</h2>
</div>
<div class="api-surface-content">
${generateSurfaceContent(surface)}
</div>
</div>
`);
}
return sections.join('');
}
function generateSurfaceContent(surface) {
const sections = [];
// 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 <span class="export-count">(${exports.length})</span></h3>
<ul class="export-list">
${exports.map(name => {
const typeDef = surface.typeDefinitions?.get(name);
return `
<li class="export-item">
<span class="export-name">${name}</span>
${typeDef ? `<span class="type-badge ${typeDef.kind}">${typeDef.kind}</span>` : ''}
</li>
`;
}).join('')}
</ul>
</div>
`);
}
// Type exports
if (surface.typeOnlyExports.size > 0) {
const typeExports = Array.from(surface.typeOnlyExports).sort();
sections.push(`
<div class="export-section">
<h3>Type Exports <span class="export-count">(${typeExports.length})</span></h3>
<ul class="export-list">
${typeExports.map(name => {
const typeDef = surface.typeDefinitions?.get(name);
return `
<li class="export-item">
<span class="export-name">${name}</span>
${typeDef ? `<span class="type-badge ${typeDef.kind}">${typeDef.kind}</span>` : ''}
</li>
`;
}).join('')}
</ul>
</div>
`);
}
// Re-exports
if (surface.starExports.length > 0) {
sections.push(`
<div class="export-section">
<h3>Re-exports <span class="export-count">(${surface.starExports.length})</span></h3>
<ul class="export-list">
${surface.starExports.map(module => `
<li class="export-item">
<span class="export-type">export * from "${module}"</span>
</li>
`).join('')}
</ul>
</div>
`);
}
return sections.join('');
}
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}