UNPKG

apisurf

Version:

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

1,276 lines (1,190 loc) 45.1 kB
import { detectSemverViolation } from './detectSemverViolation.js'; 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); /** * Renders a semver violation warning if detected */ function renderSemverViolation(result) { if (!result.fromVersion || !result.toVersion) { return ''; } const violation = detectSemverViolation(result.fromVersion, result.toVersion, result.semverImpact.minimumBump); if (violation.hasViolation && violation.actualBump) { return `<div class="semver-violation">(But they shipped a <span class="version-type">${violation.actualBump}</span>! <span class="emoji">🤦</span>)</div>`; } return ''; } // Strip ANSI color codes from text function stripAnsiCodes(text) { // eslint-disable-next-line no-control-regex return text.replace(/\x1b\[[0-9;]*m/g, ''); } // Simple TypeScript syntax highlighting function highlightTypeScript(code) { // First strip ANSI codes and escape HTML const cleanCode = stripAnsiCodes(code); // Check if the code already contains HTML tags (double highlighting issue) if (cleanCode.includes('class="hljs-')) { console.warn('Warning: Code already contains HTML highlighting:', cleanCode); // Strip out any existing HTML tags const strippedCode = cleanCode.replace(/<[^>]*>/g, ''); return highlightTypeScript(strippedCode); } const escaped = cleanCode.replace(/[&<>"']/g, m => { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; return map[m]; }); // 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; // Strings const strings = /(['"`])(?:(?=(\\?))\\2[\s\S])*?\1/g; // Comments - be careful with multiline const comments = /(\/\/[^\n]*$|\/\*[\s\S]*?\*\/)/gm; // Numbers const numbers = /\b(\d+(\.\d+)?([eE][+-]?\d+)?|0x[0-9a-fA-F]+|0b[01]+|0o[0-7]+)\b/g; // Generic parameters - match already escaped angle brackets const generics = /&lt;([^&]+)&gt;/g; // Apply replacements in order - comments and strings first to avoid replacing inside them let highlighted = escaped; // First, mark comments and strings so we don't replace inside them const commentMatches = []; const stringMatches = []; highlighted = highlighted.replace(comments, (match, _p1, offset) => { const replacement = `\0COMMENT${commentMatches.length}\0`; commentMatches.push({ start: offset, end: offset + match.length, content: `<span class="hljs-comment">${match}</span>` }); return replacement; }); highlighted = highlighted.replace(strings, (match, _p1, offset) => { const replacement = `\0STRING${stringMatches.length}\0`; stringMatches.push({ start: offset, end: offset + match.length, content: `<span class="hljs-string">${match}</span>` }); return replacement; }); // Now apply other replacements highlighted = highlighted .replace(keywords, '<span class="hljs-keyword">$1</span>') .replace(types, '<span class="hljs-type">$1</span>') .replace(numbers, '<span class="hljs-number">$1</span>') .replace(generics, '&lt;<span class="hljs-type">$1</span>&gt;'); // Restore comments and strings commentMatches.forEach((match, i) => { highlighted = highlighted.replace(`\0COMMENT${i}\0`, match.content); }); stringMatches.forEach((match, i) => { highlighted = highlighted.replace(`\0STRING${i}\0`, match.content); }); return highlighted; } /** * Formats the diff results as an HTML report with FluentUI styling */ export function formatHtmlOutput(result) { const timestamp = new Date().toISOString(); const reportPath = join(tmpdir(), `apisurf-report-${Date.now()}.html`); const html = generateHtmlReport(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 generateHtmlReport(result, timestamp) { const breakingChangeCount = result.packages.reduce((sum, pkg) => sum + pkg.breakingChanges.length, 0); const nonBreakingChangeCount = result.packages.reduce((sum, pkg) => sum + pkg.nonBreakingChanges.length, 0); return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Package Diff Report</title> <style> @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 { color: #605e5c; margin: 0 0 20px 0; } .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.breaking .number { color: #d13438; } .summary-card.non-breaking .number { color: #107c10; } .summary-card.impact .number { color: #0078d4; } .package-section { 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; } .package-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; } .package-header:hover { background: #f3f2f1; } .package-title { font-size: 18px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 12px; } .badge { font-size: 12px; padding: 2px 8px; border-radius: 12px; font-weight: 400; } .badge.breaking { background: #fde7e9; color: #a80000; } .badge.non-breaking { background: #dff6dd; color: #0b6a0b; } .chevron { transition: transform 0.2s; color: #605e5c; display: inline-block; font-size: 12px; } .chevron.expanded { transform: rotate(90deg); } /* Syntax highlighting styles */ .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; } /* Diff view styles */ .diff-section { margin-bottom: 20px; } .diff-section-title { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: #323130; } .diff-list { list-style: none; padding: 0; margin: 0; } .diff-list li { padding: 8px 12px; margin-bottom: 4px; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 13px; } .diff-list.removed li { background: #ffeef0; border-left: 3px solid #d13438; } .diff-list.added li { background: #e6ffed; border-left: 3px solid #107c10; } .diff-item { margin-bottom: 16px; padding: 12px; background: #faf9f8; border-radius: 4px; } .diff-item-name { font-weight: 600; font-size: 14px; margin-bottom: 8px; color: #0078d4; } .diff-before, .diff-after { margin: 4px 0; font-size: 13px; } .diff-before code { background: #ffeef0; padding: 2px 6px; border-radius: 3px; } .diff-after code { background: #e6ffed; padding: 2px 6px; border-radius: 3px; } .diff-note { font-size: 13px; color: #605e5c; margin: 8px 0; font-style: italic; } .package-content { display: none; padding: 24px; } .package-content.expanded { display: block; } .changes-section { margin-bottom: 24px; } .changes-section h3 { font-size: 16px; font-weight: 600; margin: 0 0 16px 0; color: #323130; } .change-item { background: #faf9f8; border: 1px solid #edebe9; border-radius: 4px; margin-bottom: 12px; overflow: hidden; } .change-header { padding: 12px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .change-header:hover { background: #f3f2f1; } .change-type { font-size: 12px; font-weight: 600; text-transform: uppercase; padding: 2px 8px; border-radius: 4px; margin-right: 12px; } .change-type.breaking { background: #d13438; color: white; } .change-type.non-breaking { background: #107c10; color: white; } .change-description { flex: 1; color: #323130; } .change-details { display: none; padding: 16px; background: white; border-top: 1px solid #edebe9; } .change-details.expanded { display: block; } .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; } .code-before { background: #ffeef0; border-color: #fdb8c0; } .code-after { background: #e6ffed; border-color: #acf2bd; } .code-label { font-size: 12px; font-weight: 600; color: #605e5c; margin-bottom: 4px; } .no-changes { color: #605e5c; font-style: italic; padding: 20px; text-align: center; } .footer { text-align: center; color: #605e5c; font-size: 12px; margin-top: 40px; padding: 20px; } .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-package { font-weight: 600; color: #0078d4; } .npm-info-text a { color: #0078d4; text-decoration: none; } .npm-info-text a:hover { text-decoration: underline; } /* Code chicklets for inline code */ .code-chicklet { display: inline-block; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 13px; background: #f3f2f1; color: #323130; padding: 2px 8px; border-radius: 4px; margin: 0 2px; font-weight: normal; vertical-align: baseline; border: 1px solid #e1e1e1; } /* Semver violation styles */ .semver-violation { margin-top: 8px; font-size: 14px; color: #a80000; font-weight: 500; } .semver-violation .emoji { font-size: 26px; vertical-align: middle; } .version-type { display: inline-block; background: #d13438; color: white; padding: 3px 8px; border-radius: 3px; font-weight: 600; text-transform: uppercase; font-size: 13px; } /* Debug info styles */ .debug-info { margin-top: 16px; padding: 12px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; font-size: 12px; display: none; white-space: pre-wrap; } .debug-info.expanded { display: block; } .debug-toggle { font-size: 12px; color: #0078d4; cursor: pointer; text-decoration: underline; margin-top: 8px; display: inline-block; } .debug-toggle:hover { color: #005a9e; } </style> </head> <body> <div class="container"> <div class="header"> <h1>Package API Diff Report</h1> <p class="subtitle">Generated on ${new Date(timestamp).toLocaleString()}</p> ${result.npmPackage ? ` <div class="npm-info"> <span class="npm-info-icon">📦</span> <div class="npm-info-text"> NPM Package: <span class="npm-info-package">${result.npmPackage}</span> ${result.fromVersion && result.toVersion ? `(${result.fromVersion} → ${result.toVersion})` : ''} ${result.repositoryUrl ? `<br>Repository: <a href="${result.repositoryUrl}" target="_blank" rel="noopener noreferrer">${result.repositoryUrl}</a>` : ''} </div> </div> ` : ''} <div class="summary-cards"> <div class="summary-card breaking"> <div class="label">Breaking Changes</div> <div class="number">${breakingChangeCount}</div> </div> <div class="summary-card non-breaking"> <div class="label">Non-Breaking Changes</div> <div class="number">${nonBreakingChangeCount}</div> </div> <div class="summary-card impact"> <div class="label">Semver Impact</div> <div class="number">${result.semverImpact.minimumBump.toUpperCase()}</div> ${renderSemverViolation(result)} </div> </div> <p>${result.summary}</p> </div> ${result.packages.map((pkg, index) => generatePackageSection(pkg, index)).join('')} ${result.packages.length === 0 ? '<div class="no-changes">No packages with changes found.</div>' : ''} <div class="footer"> Generated by apisurf • ${timestamp} </div> </div> <script> // Toggle package sections document.querySelectorAll('.package-header').forEach(header => { header.addEventListener('click', () => { const content = header.nextElementSibling; const chevron = header.querySelector('.chevron'); content.classList.toggle('expanded'); chevron.classList.toggle('expanded'); }); }); // Toggle change details (only for items with chevrons) document.querySelectorAll('.change-header').forEach(header => { const chevron = header.querySelector('.chevron'); if (chevron) { header.addEventListener('click', () => { const details = header.nextElementSibling; details.classList.toggle('expanded'); chevron.classList.toggle('expanded'); }); } }); // Expand first package by default if it has changes const firstPackage = document.querySelector('.package-content'); const firstChevron = document.querySelector('.package-header .chevron'); if (firstPackage) { firstPackage.classList.add('expanded'); firstChevron.classList.add('expanded'); } </script> </body> </html>`; } function generatePackageSection(pkg, _index) { const hasChanges = pkg.breakingChanges.length > 0 || pkg.nonBreakingChanges.length > 0; if (!hasChanges) { return ''; } return ` <div class="package-section"> <div class="package-header"> <h2 class="package-title"> ${pkg.name} ${pkg.breakingChanges.length > 0 ? `<span class="badge breaking">${pkg.breakingChanges.length} breaking</span>` : ''} ${pkg.nonBreakingChanges.length > 0 ? `<span class="badge non-breaking">${pkg.nonBreakingChanges.length} non-breaking</span>` : ''} </h2> <span class="chevron"></span> </div> <div class="package-content"> ${pkg.breakingChanges.length > 0 ? ` <div class="changes-section"> <h3>Breaking Changes</h3> ${pkg.breakingChanges.map(change => generateBreakingChangeItem(change)).join('')} </div> ` : ''} ${pkg.nonBreakingChanges.length > 0 ? ` <div class="changes-section"> <h3>Non-Breaking Changes</h3> ${pkg.nonBreakingChanges.map(change => generateNonBreakingChangeItem(change)).join('')} </div> ` : ''} </div> </div>`; } function generateBreakingChangeItem(change) { // Check if we have actual code details to show // Don't show details if: // 1. No before value // 2. Before is just a simple identifier (no spaces, parentheses, etc.) // 3. The description already contains all the information const isSimpleIdentifier = change.before && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(change.before.trim()); const hasDetails = change.before && !isSimpleIdentifier && change.before.length > 50; if (!hasDetails) { // No expandable details needed return ` <div class="change-item"> <div class="change-header" style="cursor: default;"> <div> <span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> </div> </div>`; } // For complex types, provide a better diff view if (change.type === 'type-changed' && change.after) { return generateTypeDiffItem(change); } return ` <div class="change-item"> <div class="change-header"> <div> <span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> <span class="chevron"></span> </div> <div class="change-details"> <div class="code-label">Before:</div> <div class="code-block code-before">${highlightTypeScript(change.before)}</div> ${change.after ? ` <div class="code-label">After:</div> <div class="code-block code-after">${highlightTypeScript(change.after)}</div> ` : ''} </div> </div>`; } // Generate a better diff view for type changes function generateTypeDiffItem(change) { const before = stripAnsiCodes(change.before); const after = stripAnsiCodes(change.after || ''); // Parse the type definitions to find differences const diff = analyzeTypeDifferences(before, after); // If no specific changes were detected, it means parsing failed if (diff.html === '<div class="no-changes">No specific changes detected</div>') { console.warn(`Failed to parse granular diff for: ${change.description}`); console.warn('Before:', before.substring(0, 200) + (before.length > 200 ? '...' : '')); console.warn('After:', after.substring(0, 200) + (after.length > 200 ? '...' : '')); // For debugging - let's see what kind of definition this is if (!before.includes('{') || !after.includes('{')) { console.warn('Missing braces in type definition'); } // Fall back - show minimal info with debug details return ` <div class="change-item"> <div class="change-header"> <div> <span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> <span class="chevron"></span> </div> <div class="change-details"> <div class="diff-note">Unable to parse detailed changes. The signature has been modified.</div> <span class="debug-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">Show full signatures</span> <div class="debug-info"> Parsing failed - showing full signatures: Before: ${before} After: ${after} </div> </div> </div>`; } return ` <div class="change-item"> <div class="change-header"> <div> <span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> <span class="chevron"></span> </div> <div class="change-details"> ${diff.html} <span class="debug-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">Show debug info</span> <div class="debug-info"> Type: ${change.type} Description: ${stripAnsiCodes(change.description)} Before signature: ${before} After signature: ${after} Parsed diff: ${JSON.stringify(diff, null, 2)} </div> </div> </div>`; } // Analyze differences between two type definitions function analyzeTypeDifferences(before, after) { // Check if it's an enum if (before.includes('enum ') || after.includes('enum ')) { return analyzeEnumDifferences(before, after); } // Check if it's a class or interface (including export/declare modifiers) const classOrInterfaceRegex = /\b(class|interface)\s+\w+/; if (classOrInterfaceRegex.test(before) || classOrInterfaceRegex.test(after)) { return analyzeClassOrInterfaceDifferences(before, after); } // Check if it's a function signature if (before.includes('(') && after.includes('(')) { return analyzeFunctionDifferences(before, after); } // Default to showing before/after return { html: ` <div class="code-label">Before:</div> <div class="code-block code-before">${highlightTypeScript(before)}</div> <div class="code-label">After:</div> <div class="code-block code-after">${highlightTypeScript(after)}</div> ` }; } // Analyze enum differences function analyzeEnumDifferences(before, after) { // Extract enum values const beforeValues = extractEnumValues(before); const afterValues = extractEnumValues(after); const removed = beforeValues.filter(v => !afterValues.includes(v)); const added = afterValues.filter(v => !beforeValues.includes(v)); let html = ''; if (removed.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">❌ Removed Values</h4> <ul class="diff-list removed"> ${removed.map(v => `<li>${escapeHtml(v)}</li>`).join('')} </ul> </div> `; } if (added.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">✅ Added Values</h4> <ul class="diff-list added"> ${added.map(v => `<li>${escapeHtml(v)}</li>`).join('')} </ul> </div> `; } return { html: html || '<div class="no-changes">No specific changes detected</div>' }; } // Normalize a signature for comparison (remove comments and extra whitespace) function normalizeForComparison(signature) { // Remove single-line comments let normalized = signature.replace(/\/\/.*$/gm, ''); // Remove multi-line comments normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, ''); // Collapse multiple spaces/newlines into single spaces normalized = normalized.replace(/\s+/g, ' '); // Trim whitespace return normalized.trim(); } // Extract enum values from enum definition function extractEnumValues(enumDef) { const values = []; // Find the opening brace const startIdx = enumDef.indexOf('{'); if (startIdx === -1) return values; // Find the matching closing brace let braceCount = 0; let endIdx = -1; for (let i = startIdx; i < enumDef.length; i++) { if (enumDef[i] === '{') braceCount++; if (enumDef[i] === '}') braceCount--; if (braceCount === 0) { endIdx = i; break; } } if (endIdx === -1) return values; const enumBody = enumDef.substring(startIdx + 1, endIdx); const lines = enumBody.split(/[,\n]/).map(line => line.trim()).filter(Boolean); for (let i = 0; i < lines.length; i++) { let line = lines[i]; // Remove inline comments but keep the rest of the line line = line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '').trim(); // Skip empty lines or lines that were only comments if (!line || line.startsWith('/*')) continue; // Match enum values (with or without assignment) const match = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:=|,|$)/); if (match) { values.push(match[1]); } } return values; } // Analyze class or interface differences function analyzeClassOrInterfaceDifferences(before, after) { // Extract properties and methods const beforeMembers = extractClassMembers(before); const afterMembers = extractClassMembers(after); const removedProps = beforeMembers.properties.filter(p => !afterMembers.properties.some(ap => ap.name === p.name)); const addedProps = afterMembers.properties.filter(p => !beforeMembers.properties.some(bp => bp.name === p.name)); const changedProps = beforeMembers.properties.filter(p => { const afterProp = afterMembers.properties.find(ap => ap.name === p.name); if (!afterProp) return false; // Normalize signatures to ignore comment changes const beforeNorm = normalizeForComparison(p.signature); const afterNorm = normalizeForComparison(afterProp.signature); return beforeNorm !== afterNorm; }); const removedMethods = beforeMembers.methods.filter(m => !afterMembers.methods.some(am => am.name === m.name)); const addedMethods = afterMembers.methods.filter(m => !beforeMembers.methods.some(bm => bm.name === m.name)); const changedMethods = beforeMembers.methods.filter(m => { const afterMethod = afterMembers.methods.find(am => am.name === m.name); if (!afterMethod) return false; // Normalize signatures to ignore comment changes const beforeNorm = normalizeForComparison(m.signature); const afterNorm = normalizeForComparison(afterMethod.signature); return beforeNorm !== afterNorm; }); let html = ''; if (removedProps.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">❌ Removed Properties</h4> <ul class="diff-list removed"> ${removedProps.map(p => `<li><code>${highlightTypeScript(p.signature)}</code></li>`).join('')} </ul> </div> `; } if (removedMethods.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">❌ Removed Methods</h4> <ul class="diff-list removed"> ${removedMethods.map(m => `<li><code>${highlightTypeScript(m.signature)}</code></li>`).join('')} </ul> </div> `; } if (changedProps.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">🔄 Changed Properties</h4> ${changedProps.map(p => { const afterProp = afterMembers.properties.find(ap => ap.name === p.name); return ` <div class="diff-item"> <div class="diff-item-name">${escapeHtml(p.name)}</div> <div class="diff-before">Before: <code>${highlightTypeScript(p.signature)}</code></div> <div class="diff-after">After: <code>${highlightTypeScript(afterProp.signature)}</code></div> </div> `; }).join('')} </div> `; } if (changedMethods.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">🔄 Changed Method Signatures</h4> ${changedMethods.map(m => { const afterMethod = afterMembers.methods.find(am => am.name === m.name); return ` <div class="diff-item"> <div class="diff-item-name">${escapeHtml(m.name)}</div> <div class="diff-before">Before: <code>${highlightTypeScript(m.signature)}</code></div> <div class="diff-after">After: <code>${highlightTypeScript(afterMethod.signature)}</code></div> </div> `; }).join('')} </div> `; } if (addedProps.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">✅ Added Properties</h4> <ul class="diff-list added"> ${addedProps.map(p => `<li><code>${highlightTypeScript(p.signature)}</code></li>`).join('')} </ul> </div> `; } if (addedMethods.length > 0) { html += ` <div class="diff-section"> <h4 class="diff-section-title">✅ Added Methods</h4> <ul class="diff-list added"> ${addedMethods.map(m => `<li><code>${highlightTypeScript(m.signature)}</code></li>`).join('')} </ul> </div> `; } return { html: html || '<div class="no-changes">No specific changes detected</div>' }; } // Extract class/interface members function extractClassMembers(classDef) { const properties = []; const methods = []; // Remove block comments but preserve structure let cleanedDef = classDef; // Remove JSDoc comments while preserving line structure cleanedDef = cleanedDef.replace(/\/\*\*[\s\S]*?\*\//g, (match) => { // Replace with same number of newlines to preserve line numbers const newlines = match.match(/\n/g) || []; return newlines.join(''); }); // Find the opening brace const startIdx = cleanedDef.indexOf('{'); if (startIdx === -1) return { properties, methods }; // Find the matching closing brace let braceCount = 0; let endIdx = -1; for (let i = startIdx; i < cleanedDef.length; i++) { if (cleanedDef[i] === '{') braceCount++; if (cleanedDef[i] === '}') braceCount--; if (braceCount === 0) { endIdx = i; break; } } if (endIdx === -1) return { properties, methods }; const body = cleanedDef.substring(startIdx + 1, endIdx); // Split by lines and process each potential property/method const lines = body.split('\n'); let i = 0; while (i < lines.length) { const line = lines[i].trim(); // Skip empty lines and comments if (!line || line.startsWith('//') || line.startsWith('/*')) { i++; continue; } // Check if this line contains a property or method declaration // Remove modifiers for matching let cleanLine = line; const modifiers = /(public|private|protected|static|readonly|async|abstract|override|get|set)\s+/g; cleanLine = cleanLine.replace(modifiers, ''); // Match property: name: type or name?: type const propMatch = cleanLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\??\s*:/); if (propMatch) { // Get the full property declaration (might span multiple lines) let fullDeclaration = line; let j = i + 1; // If line doesn't end with semicolon, look for it on next lines while (!fullDeclaration.includes(';') && j < lines.length) { fullDeclaration += ' ' + lines[j].trim(); j++; } // Remove the semicolon and any trailing whitespace fullDeclaration = fullDeclaration.replace(/;.*$/, '').trim(); properties.push({ name: propMatch[1], signature: fullDeclaration }); i = j; continue; } // Match method: name() or name<T>() const methodMatch = cleanLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:<[^>]+>)?\s*\(/); if (methodMatch) { // Get the full method declaration let fullDeclaration = line; let j = i + 1; let parenCount = 1; // We already found the opening paren // Find the closing parenthesis let foundEnd = false; for (let k = line.indexOf('(') + 1; k < line.length; k++) { if (line[k] === '(') parenCount++; if (line[k] === ')') parenCount--; if (parenCount === 0) { foundEnd = true; break; } } // If not found on this line, check next lines while (!foundEnd && j < lines.length) { const nextLine = lines[j].trim(); fullDeclaration += ' ' + nextLine; for (let k = 0; k < nextLine.length; k++) { if (nextLine[k] === '(') parenCount++; if (nextLine[k] === ')') parenCount--; if (parenCount === 0) { foundEnd = true; break; } } j++; } // Find the end of the return type (usually ends with ; or {) while (j < lines.length && !fullDeclaration.includes(';') && !fullDeclaration.includes('{')) { fullDeclaration += ' ' + lines[j].trim(); j++; } // Remove any body or semicolon fullDeclaration = fullDeclaration.replace(/[;{].*$/, '').trim(); methods.push({ name: methodMatch[1], signature: fullDeclaration }); i = j; continue; } i++; } return { properties, methods }; } // Analyze function signature differences function analyzeFunctionDifferences(before, after) { // Extract function name and parameters const beforeMatch = before.match(/^([^(]+)\(([^)]*)\)/); const afterMatch = after.match(/^([^(]+)\(([^)]*)\)/); if (!beforeMatch || !afterMatch) { return { html: ` <div class="code-label">Before:</div> <div class="code-block code-before">${highlightTypeScript(before)}</div> <div class="code-label">After:</div> <div class="code-block code-after">${highlightTypeScript(after)}</div> ` }; } const beforeParams = beforeMatch[2].split(',').map(p => p.trim()).filter(Boolean); const afterParams = afterMatch[2].split(',').map(p => p.trim()).filter(Boolean); let html = '<div class="diff-section">'; html += '<h4 class="diff-section-title">Function Signature Changed</h4>'; if (beforeParams.length !== afterParams.length) { html += `<p class="diff-note">Parameter count changed: ${beforeParams.length} → ${afterParams.length}</p>`; } html += ` <div class="diff-item"> <div class="diff-before">Before: <code>${highlightTypeScript(before)}</code></div> <div class="diff-after">After: <code>${highlightTypeScript(after)}</code></div> </div> `; html += '</div>'; return { html }; } function generateNonBreakingChangeItem(change) { // Check if we have actual details to show // Don't show details if: // 1. No details value // 2. Details is just a simple identifier (no spaces, parentheses, etc.) // 3. The description already contains all the information const isSimpleIdentifier = change.details && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(change.details.trim()); const hasDetails = change.details && !isSimpleIdentifier && change.details.length > 50; // For type-updated changes, try to show a diff view if (change.type === 'type-updated' && hasDetails) { const details = stripAnsiCodes(change.details); // Check if it's an enum if (details.includes('enum ')) { const enumValues = extractEnumValues(details); return ` <div class="change-item"> <div class="change-header"> <div> <span class="change-type non-breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> <span class="chevron"></span> </div> <div class="change-details"> <div class="diff-section"> <h4 class="diff-section-title">✅ Added Values</h4> <ul class="diff-list added"> ${enumValues.map((v) => `<li>${highlightTypeScript(v)}</li>`).join('')} </ul> </div> <span class="debug-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">Show debug info</span> <div class="debug-info"> Type: ${change.type} Description: ${stripAnsiCodes(change.description)} Full signature: ${details} Extracted enum values: ${JSON.stringify(enumValues, null, 2)} </div> </div> </div>`; } } if (!hasDetails) { // No expandable details needed return ` <div class="change-item"> <div class="change-header" style="cursor: default;"> <div> <span class="change-type non-breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> </div> </div>`; } return ` <div class="change-item"> <div class="change-header"> <div> <span class="change-type non-breaking">${change.type.replace(/-/g, ' ')}</span> <span class="change-description">${formatDescriptionWithCode(change.description)}</span> </div> <span class="chevron"></span> </div> <div class="change-details"> <div class="code-block">${highlightTypeScript(change.details)}</div> </div> </div>`; } function escapeHtml(text) { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; return text.replace(/[&<>"']/g, m => map[m]); } /** * Format a description string by converting code identifiers in quotes to code chicklets */ function formatDescriptionWithCode(description) { // First strip ANSI codes const stripped = stripAnsiCodes(description); // Replace 'identifier' patterns with code chicklets BEFORE escaping HTML // This regex matches single-quoted identifiers that look like code const withChicklets = stripped.replace(/'([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)'/g, (_match, identifier) => `<code-chicklet>${identifier}</code-chicklet>`); // Now escape HTML but preserve our special tags const parts = withChicklets.split(/(<code-chicklet>[^<]+<\/code-chicklet>)/g); const escaped = parts.map(part => { if (part.startsWith('<code-chicklet>') && part.endsWith('</code-chicklet>')) { // Don't escape our special tags return part; } else { // Escape HTML in regular text return part.replace(/[&<>"']/g, m => { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; return map[m]; }); } }).join(''); // Replace our special tags with actual span elements const formatted = escaped.replace(/<code-chicklet>([^<]+)<\/code-chicklet>/g, '<span class="code-chicklet">$1</span>'); return formatted; }