@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
569 lines (568 loc) • 20.9 kB
JavaScript
/**
* Color utility for consistent styled output across the application
* Uses ANSI escape codes for maximum compatibility
*/
// ANSI escape codes for colors and styles
import process from 'node:process';
const ansiColors = {
// Reset
reset: '\x1b[0m',
// Text colors
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
grey: '\x1b[90m',
// Bright colors
redBright: '\x1b[91m',
greenBright: '\x1b[92m',
yellowBright: '\x1b[93m',
blueBright: '\x1b[94m',
magentaBright: '\x1b[95m',
cyanBright: '\x1b[96m',
whiteBright: '\x1b[97m',
// Styles
bold: '\x1b[1m',
dim: '\x1b[2m',
italic: '\x1b[3m',
underline: '\x1b[4m',
inverse: '\x1b[7m',
strikethrough: '\x1b[9m',
};
/**
* Color utility class
*/
class Colors {
constructor() {
this.enabled = this.shouldEnableColors();
this.setupColors();
}
shouldEnableColors() {
// Don't use colors if explicitly disabled
if (process.env.NO_COLOR || process.env.NODE_DISABLE_COLORS) {
return false;
}
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') {
return true;
}
// Don't use colors in dumb terminals
if (process.env.TERM === 'dumb') {
return false;
}
// Allow explicit TERM capabilities to enable colors even in non-TTY test environments.
if (process.env.TERM && process.env.TERM !== 'unknown') {
return true;
}
// Don't use colors if not in a TTY when there is no stronger signal.
if (process.stdout && process.stdout.isTTY === false) {
return false;
}
// Enable colors by default
return true;
}
colorize(color, text) {
if (!(this.enabled && text)) {
return text;
}
return `${ansiColors[color] || ''}${text}${ansiColors.reset}`;
}
setupColors() {
if (!this.enabled) {
this.setupFallbackColors();
return;
}
// Status colors
this.success = (text) => this.colorize('green', text);
this.error = (text) => this.colorize('red', text);
this.warning = (text) => this.colorize('yellow', text);
this.info = (text) => this.colorize('blue', text);
this.secondary = (text) => this.colorize('gray', text);
this.highlight = (text) => this.colorize('cyan', text);
this.bold = (text) => this.colorize('bold', text);
this.dim = (text) => this.colorize('dim', text);
// Semantic colors for changelog
this.feature = (text) => this.colorize('greenBright', text);
this.fix = (text) => this.colorize('redBright', text);
this.security = (text) => this.colorize('magentaBright', text);
this.breaking = (text) => this.colorize('bold', this.colorize('red', text));
this.docs = (text) => this.colorize('blueBright', text);
this.style = (text) => this.colorize('magenta', text);
this.refactor = (text) => this.colorize('yellow', text);
this.perf = (text) => this.colorize('cyan', text);
this.test = (text) => this.colorize('blue', text);
this.chore = (text) => this.colorize('gray', text);
// UI elements
this.header = (text) => this.colorize('underline', this.colorize('bold', text));
this.subheader = (text) => this.colorize('bold', text);
this.label = (text) => this.colorize('cyan', text);
this.value = (text) => this.colorize('white', text);
this.code = (text) => this.colorize('inverse', text);
this.file = (text) => this.colorize('yellowBright', text);
this.path = (text) => this.colorize('green', text);
this.hash = (text) => this.colorize('magenta', text);
// Metrics and stats
this.metric = (text) => this.colorize('cyan', text);
this.number = (text) => this.colorize('yellowBright', text);
this.percentage = (text) => this.colorize('green', text);
// Risk levels
this.riskLow = (text) => this.colorize('green', text);
this.riskMedium = (text) => this.colorize('yellow', text);
this.riskHigh = (text) => this.colorize('red', text);
this.riskCritical = (text) => this.colorize('inverse', this.colorize('bold', this.colorize('red', text)));
// Impact levels
this.impactMinimal = (text) => this.colorize('gray', text);
this.impactLow = (text) => this.colorize('blue', text);
this.impactMedium = (text) => this.colorize('yellow', text);
this.impactHigh = (text) => this.colorize('red', text);
this.impactCritical = (text) => this.colorize('bold', this.colorize('red', text));
}
setupFallbackColors() {
// Fallback to identity functions when colors are disabled
const identity = (text) => text;
// Status colors
this.success = identity;
this.error = identity;
this.warning = identity;
this.info = identity;
this.secondary = identity;
this.highlight = identity;
this.bold = identity;
this.dim = identity;
// Semantic colors for changelog
this.feature = identity;
this.fix = identity;
this.security = identity;
this.breaking = identity;
this.docs = identity;
this.style = identity;
this.refactor = identity;
this.perf = identity;
this.test = identity;
this.chore = identity;
// UI elements
this.header = identity;
this.subheader = identity;
this.label = identity;
this.value = identity;
this.code = identity;
this.file = identity;
this.path = identity;
this.hash = identity;
// Metrics and stats
this.metric = identity;
this.number = identity;
this.percentage = identity;
// Risk levels
this.riskLow = identity;
this.riskMedium = identity;
this.riskHigh = identity;
this.riskCritical = identity;
// Impact levels
this.impactMinimal = identity;
this.impactLow = identity;
this.impactMedium = identity;
this.impactHigh = identity;
this.impactCritical = identity;
}
disable() {
this.enabled = false;
this.setupFallbackColors();
}
enable() {
this.enabled = true;
this.setupColors();
}
// Utility methods for common patterns
emoji(text) {
return text; // Emojis work without colors
}
status(type, message) {
const colorMap = {
success: this.success,
error: this.error,
warning: this.warning,
info: this.info,
};
const color = colorMap[type] || this.info;
return color(message);
}
commitType(type) {
const colorMap = {
feat: this.feature,
fix: this.fix,
security: this.security,
breaking: this.breaking,
docs: this.docs,
style: this.style,
refactor: this.refactor,
perf: this.perf,
test: this.test,
chore: this.chore,
};
return (colorMap[type] || this.secondary)(type);
}
risk(level) {
const colorMap = {
low: this.riskLow,
medium: this.riskMedium,
high: this.riskHigh,
critical: this.riskCritical,
};
return (colorMap[level] || this.secondary)(level.toUpperCase());
}
impact(level) {
const colorMap = {
minimal: this.impactMinimal,
low: this.impactLow,
medium: this.impactMedium,
high: this.impactHigh,
critical: this.impactCritical,
};
return (colorMap[level] || this.secondary)(level);
}
// Diff highlighting
diffAdd(text) {
return this.success(`+ ${text}`);
}
diffRemove(text) {
return this.error(`- ${text}`);
}
diffContext(text) {
return this.dim(` ${text}`);
}
// Progress indicators
progress(current, total, label = '') {
if (total <= 0) {
return `${this.info('[░░░░░░░░░░░░░░░░░░░░]')} ${this.percentage('0%')} ${label}`;
}
const percentage = Math.max(0, Math.min(100, Math.round((current / total) * 100)));
const filledBars = Math.max(0, Math.round(percentage / 5));
const emptyBars = Math.max(0, 20 - filledBars);
const bar = '█'.repeat(filledBars) + '░'.repeat(emptyBars);
return `${this.info(`[${bar}]`)} ${this.percentage(`${percentage}%`)} ${label}`;
}
// Helper to get actual visible length of string (without ANSI codes)
getVisibleLength(str) {
// Remove ANSI escape sequences to get actual visible length.
const ansiPrefix = String.fromCharCode(27);
const ansiPattern = new RegExp(`${ansiPrefix}\\[[0-9;]*m`, 'g');
return str.replace(ansiPattern, '').length;
}
// Box drawing for sections with dynamic width calculation
box(title, content, minWidth = 60) {
const lines = content.split('\n');
const titleVisibleLength = this.getVisibleLength(title);
// Calculate required width based on content
const maxContentLength = Math.max(titleVisibleLength + 4, // title + padding
...lines.map((line) => this.getVisibleLength(line) + 4), // content + padding
minWidth);
const width = Math.min(maxContentLength, 80); // Cap at 80 chars for readability
const topBorder = `┌${'─'.repeat(width - 2)}┐`;
const bottomBorder = `└${'─'.repeat(width - 2)}┘`;
// Title line with proper padding accounting for ANSI codes
const titlePadding = width - titleVisibleLength - 3;
const titleLine = `│ ${this.header(title)}${' '.repeat(Math.max(0, titlePadding))}│`;
const contentLines = lines.map((line) => {
const visibleLength = this.getVisibleLength(line);
const padding = width - visibleLength - 4;
return `│ ${line}${' '.repeat(Math.max(0, padding))} │`;
});
return [
this.secondary(topBorder),
titleLine,
this.secondary(`├${'─'.repeat(width - 2)}┤`),
...contentLines,
this.secondary(bottomBorder),
].join('\n');
}
// Quick access to common formatted messages
successMessage(message) {
return `${this.success('✅')} ${message}`;
}
errorMessage(message) {
return `${this.error('❌')} ${message}`;
}
warningMessage(message) {
return `${this.warning('⚠️')} ${message}`;
}
infoMessage(message) {
return `${this.info('ℹ️')} ${message}`;
}
processingMessage(message) {
return `${this.highlight('🔍')} ${message}`;
}
aiMessage(message) {
return `${this.highlight('🤖')} ${message}`;
}
metricsMessage(message) {
return `${this.metric('📊')} ${message}`;
}
separator(char = '─', length = 50) {
return this.dim(char.repeat(length));
}
sectionHeader(text) {
return this.header(text);
}
// Format file lists with syntax highlighting
formatFileList(files, maxDisplay = 10) {
const displayed = files.slice(0, maxDisplay);
const result = displayed.map((file) => {
const ext = file.split('.').pop()?.toLowerCase();
let color = this.file;
// Color by file type
if (['ts', 'tsx', 'js', 'jsx'].includes(ext)) {
color = this.feature;
}
else if (['css', 'scss', 'sass'].includes(ext)) {
color = this.style;
}
else if (['md', 'txt'].includes(ext)) {
color = this.docs;
}
else if (['json', 'yaml', 'yml'].includes(ext)) {
color = this.warning;
}
else if (['sql'].includes(ext)) {
color = this.fix;
}
return ` - ${color(file)}`;
});
if (files.length > maxDisplay) {
result.push(` ${this.dim(`... and ${files.length - maxDisplay} more`)}`);
}
return result.join('\n');
}
// Format metrics table
formatMetrics(metrics) {
const entries = Object.entries(metrics);
const maxKeyLength = Math.max(...entries.map(([k]) => k.length));
return entries
.map(([key, value]) => {
const paddedKey = key.padEnd(maxKeyLength);
return `${this.label(paddedKey)}: ${this.value(value)}`;
})
.join('\n');
}
// Unicode symbols for cross-platform compatibility
get symbols() {
return {
success: '✓',
error: '✗',
warning: '⚠',
info: 'ℹ',
arrow: '→',
bullet: '•',
check: '✓',
cross: '✗',
star: '★',
heart: '♥',
diamond: '♦',
circle: '●',
square: '■',
triangle: '▲',
play: '▶',
pause: '⏸',
stop: '⏹',
refresh: '↻',
sync: '⟲',
upload: '↑',
download: '↓',
plus: '+',
minus: '-',
multiply: '×',
divide: '÷',
equals: '=',
pipe: '|',
hash: '#',
at: '@',
dollar: '$',
percent: '%',
ampersand: '&',
question: '?',
exclamation: '!',
ellipsis: '…',
middot: '·',
section: '§',
paragraph: '¶',
copyright: '©',
registered: '®',
trademark: '™',
degree: '°',
plusminus: '±',
micro: 'µ',
alpha: 'α',
beta: 'β',
gamma: 'γ',
delta: 'δ',
lambda: 'λ',
mu: 'μ',
pi: 'π',
sigma: 'σ',
tau: 'τ',
phi: 'φ',
omega: 'ω',
};
}
// Enhanced status messages with symbols
statusSymbol(type, message) {
const symbolMap = {
success: this.success(this.symbols.success),
error: this.error(this.symbols.error),
warning: this.warning(this.symbols.warning),
info: this.info(this.symbols.info),
processing: this.highlight(this.symbols.refresh),
ai: this.highlight('🤖'),
metrics: this.metric('📊'),
};
const symbol = symbolMap[type] || symbolMap.info;
return `${symbol} ${message}`;
}
// Gradient text support (will work with gradient-string if available)
async gradient(text, colors = ['#FF6B6B', '#4ECDC4']) {
try {
// Try to use gradient-string if available
const { default: gradient } = await import('gradient-string');
return gradient(colors)(text);
}
catch {
// Fallback to regular coloring
return this.highlight(text);
}
}
// Spinner-like text animation frames
get spinnerFrames() {
return ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
}
// Loading dots animation frames
get dotsFrames() {
return [' ', '. ', '.. ', '...'];
}
// Enhanced box with options
boxed(content, options = {}) {
const { title = '', padding = 1, margin = 0, borderStyle = 'single', borderColor = 'secondary', titleColor = 'header', } = options;
const borders = {
single: {
top: '─',
bottom: '─',
left: '│',
right: '│',
topLeft: '┌',
topRight: '┐',
bottomLeft: '└',
bottomRight: '┘',
},
double: {
top: '═',
bottom: '═',
left: '║',
right: '║',
topLeft: '╔',
topRight: '╗',
bottomLeft: '╚',
bottomRight: '╝',
},
rounded: {
top: '─',
bottom: '─',
left: '│',
right: '│',
topLeft: '╭',
topRight: '╮',
bottomLeft: '╰',
bottomRight: '╯',
},
};
const border = borders[borderStyle] || borders.single;
const lines = content.split('\n');
const maxContentLength = Math.max(...lines.map((line) => this.getVisibleLength(line)));
const titleLength = title ? this.getVisibleLength(title) + 2 : 0;
const width = Math.max(maxContentLength, titleLength) + padding * 2;
const colorBorder = this[borderColor] || this.secondary;
const colorTitle = this[titleColor] || this.header;
const result = [];
// Top margin
if (margin > 0) {
result.push(''
.repeat(margin)
.split('')
.map(() => '')
.join('\n'));
}
// Top border
if (title) {
const titlePadding = Math.max(0, width - titleLength);
result.push(colorBorder(border.topLeft + border.top.repeat(2)) +
` ${colorTitle(title)} ` +
colorBorder(border.top.repeat(titlePadding - 2) + border.topRight));
}
else {
result.push(colorBorder(border.topLeft + border.top.repeat(width) + border.topRight));
}
// Content
lines.forEach((line) => {
const contentPadding = Math.max(0, width - this.getVisibleLength(line));
result.push(colorBorder(border.left) +
' '.repeat(padding) +
line +
' '.repeat(contentPadding - padding) +
colorBorder(border.right));
});
// Bottom border
result.push(colorBorder(border.bottomLeft + border.bottom.repeat(width) + border.bottomRight));
// Bottom margin
if (margin > 0) {
result.push(''
.repeat(margin)
.split('')
.map(() => '')
.join('\n'));
}
return result.join('\n');
}
// Table formatting helper
table(data, options = {}) {
if (!Array.isArray(data) || data.length === 0) {
return '';
}
const { headers = Object.keys(data[0]), align = 'left' } = options;
const rows = data.map((row) => headers.map((header) => String(row[header] || '')));
// Calculate column widths
const colWidths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => this.getVisibleLength(row[i]))));
const formatRow = (row, isHeader = false) => {
const cells = row.map((cell, i) => {
const width = colWidths[i];
const visibleLength = this.getVisibleLength(String(cell));
const paddingNeeded = width - visibleLength;
if (align === 'right') {
return ' '.repeat(paddingNeeded) + cell;
}
if (align === 'center') {
const leftPad = Math.floor(paddingNeeded / 2);
const rightPad = paddingNeeded - leftPad;
return ' '.repeat(leftPad) + cell + ' '.repeat(rightPad);
}
return cell + ' '.repeat(paddingNeeded);
});
const colorFunc = isHeader ? this.header : this.secondary;
return colorFunc('│') + cells.map((cell) => ` ${cell} `).join(colorFunc('│')) + colorFunc('│');
};
const separator = this.secondary(`├${colWidths.map((w) => '─'.repeat(w + 2)).join('┼')}┤`);
const topBorder = this.secondary(`┌${colWidths.map((w) => '─'.repeat(w + 2)).join('┬')}┐`);
const bottomBorder = this.secondary(`└${colWidths.map((w) => '─'.repeat(w + 2)).join('┴')}┘`);
return [
topBorder,
formatRow(headers, true),
separator,
...rows.map((row) => formatRow(row)),
bottomBorder,
].join('\n');
}
}
// Export singleton instance
const colors = new Colors();
export default colors;