spotify-web-app / scripts /build-minify.js
findEthics
Implement Phase 5: Enterprise-grade code quality and testing framework
99d9688
Raw
History Blame Contribute Delete
15.5 kB
#!/usr/bin/env node
/**
* Build and Minification Script for Spotify Web Player
* Optimizes JavaScript and CSS for production deployment
*/
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
// Promisify file system operations
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const readdir = promisify(fs.readdir);
const stat = promisify(fs.stat);
const mkdir = promisify(fs.mkdir);
class BuildMinifier {
constructor() {
this.sourceDir = path.join(__dirname, '..', 'public');
this.outputDir = path.join(__dirname, '..', 'public', 'dist');
this.stats = {
filesProcessed: 0,
originalSize: 0,
compressedSize: 0,
compressionRatio: 0
};
}
/**
* Run the complete build process
*/
async build() {
console.log('🚀 Starting build process...\n');
try {
// Ensure output directory exists
await this.ensureOutputDir();
// Build JavaScript bundle
await this.buildJavaScript();
// Build CSS bundle
await this.buildCSS();
// Copy critical assets
await this.copyAssets();
// Generate manifest
await this.generateManifest();
// Display build statistics
this.displayStats();
console.log('\n✅ Build completed successfully!');
} catch (error) {
console.error('\n❌ Build failed:', error.message);
process.exit(1);
}
}
/**
* Ensure output directory exists
*/
async ensureOutputDir() {
try {
await mkdir(this.outputDir, { recursive: true });
console.log('📁 Output directory created');
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
}
/**
* Build and minify JavaScript files
*/
async buildJavaScript() {
console.log('📦 Building JavaScript bundle...');
// Define the order of JavaScript files for proper dependency loading
const jsFiles = [
// Core services first
'js/services/StateManager.js',
'js/services/DebounceService.js',
'js/services/CacheService.js',
'js/services/SecurityService.js',
'js/services/DOMSanitizer.js',
// Enhanced services
'js/services/AccessibilityService.js',
'js/services/SkeletonService.js',
'js/services/OfflineService.js',
'js/services/TouchGestureService.js',
'js/services/ErrorRecoveryService.js',
'js/services/ErrorBoundaryService.js',
'js/services/SpotifyAPIService.js',
'js/services/StateChangeDetectionService.js',
// Components
'js/components/VirtualScrollComponent.js',
'js/components/PlaylistComponent.js',
'js/components/PlayerComponent.js',
'js/components/DeviceComponent.js',
'js/components/NavigationComponent.js',
// Main application
'js/SpotifyWebPlayer.js'
];
let combinedJS = '';
let originalSize = 0;
// Add header comment
combinedJS += `/*!\n * Spotify Web Player - Production Bundle\n * Built on ${new Date().toISOString()}\n */\n\n`;
// Combine all JavaScript files
for (const file of jsFiles) {
const filePath = path.join(this.sourceDir, file);
try {
const content = await readFile(filePath, 'utf8');
const fileSize = Buffer.byteLength(content, 'utf8');
originalSize += fileSize;
// Add file separator comment
combinedJS += `\n/* === ${file} === */\n`;
combinedJS += content;
combinedJS += '\n';
console.log(` ✓ ${file} (${this.formatBytes(fileSize)})`);
} catch (error) {
console.warn(` ⚠️ Warning: Could not read ${file}: ${error.message}`);
}
}
// Simple minification (remove comments and extra whitespace)
const minifiedJS = this.minifyJavaScript(combinedJS);
const compressedSize = Buffer.byteLength(minifiedJS, 'utf8');
// Write minified bundle
const outputPath = path.join(this.outputDir, 'app.min.js');
await writeFile(outputPath, minifiedJS, 'utf8');
// Update stats
this.stats.originalSize += originalSize;
this.stats.compressedSize += compressedSize;
this.stats.filesProcessed += jsFiles.length;
console.log(` 📄 Bundle created: app.min.js (${this.formatBytes(compressedSize)})`);
console.log(` 💾 Compression: ${this.formatBytes(originalSize)}${this.formatBytes(compressedSize)} (${Math.round((1 - compressedSize/originalSize) * 100)}% reduction)\n`);
}
/**
* Build and minify CSS files
*/
async buildCSS() {
console.log('🎨 Building CSS bundle...');
const cssFiles = [
'css/styles.css'
];
let combinedCSS = '';
let originalSize = 0;
// Add header comment
combinedCSS += `/*!\n * Spotify Web Player - Styles\n * Built on ${new Date().toISOString()}\n */\n\n`;
// Combine all CSS files
for (const file of cssFiles) {
const filePath = path.join(this.sourceDir, file);
try {
const content = await readFile(filePath, 'utf8');
const fileSize = Buffer.byteLength(content, 'utf8');
originalSize += fileSize;
combinedCSS += content;
combinedCSS += '\n';
console.log(` ✓ ${file} (${this.formatBytes(fileSize)})`);
} catch (error) {
console.warn(` ⚠️ Warning: Could not read ${file}: ${error.message}`);
}
}
// Simple CSS minification
const minifiedCSS = this.minifyCSS(combinedCSS);
const compressedSize = Buffer.byteLength(minifiedCSS, 'utf8');
// Write minified bundle
const outputPath = path.join(this.outputDir, 'styles.min.css');
await writeFile(outputPath, minifiedCSS, 'utf8');
// Update stats
this.stats.originalSize += originalSize;
this.stats.compressedSize += compressedSize;
this.stats.filesProcessed += cssFiles.length;
console.log(` 📄 Bundle created: styles.min.css (${this.formatBytes(compressedSize)})`);
console.log(` 💾 Compression: ${this.formatBytes(originalSize)}${this.formatBytes(compressedSize)} (${Math.round((1 - compressedSize/originalSize) * 100)}% reduction)\n`);
}
/**
* Copy critical assets
*/
async copyAssets() {
console.log('📋 Copying critical assets...');
// Copy service worker (no minification needed)
const swSource = path.join(this.sourceDir, 'sw.js');
const swDest = path.join(this.outputDir, 'sw.js');
try {
const swContent = await readFile(swSource, 'utf8');
await writeFile(swDest, swContent, 'utf8');
console.log(' ✓ Service Worker copied');
} catch (error) {
console.warn(' ⚠️ Warning: Could not copy service worker:', error.message);
}
// Create production HTML file
await this.createProductionHTML();
}
/**
* Create production HTML file with minified assets
*/
async createProductionHTML() {
const htmlTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Security Headers -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="DENY">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<meta name="robots" content="noindex, nofollow">
<title>Spotify Web Player</title>
<link rel="stylesheet" href="dist/styles.min.css">
</head>
<body>
<!-- Skip link for accessibility -->
<a href="#main-content" class="skip-link" style="position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden;">Skip to main content</a>
<div class="nav-header">
<div class="nav-title">Spotify Web Player</div>
<div class="nav-buttons">
<button class="nav-btn" title="Back">Back</button>
<button class="nav-btn" title="Logout">Logout</button>
</div>
</div>
<div class="container">
<div class="header">
<div class="filter-section" id="filter-section">
<div class="filter-buttons">
<div class="filter-left">
<button class="filter-btn active" data-filter="mine">My Playlists</button>
<button class="filter-btn" data-filter="others">Others</button>
</div>
<div class="filter-right">
<button class="nav-btn" title="Back">◀</button>
<button class="nav-btn" title="Logout">↗</button>
</div>
</div>
</div>
</div>
<div class="player-section" id="player-section">
<div class="current-track">
<div class="track-info">
<div class="track-title" id="current-title">Track</div>
<div class="track-artist" id="current-artist">Artist</div>
</div>
</div>
<div class="controls">
<button class="control-btn" id="prev-btn" disabled title="Previous">⏮</button>
<button class="control-btn" id="play-btn" disabled title="Play/Pause">⏯</button>
<button class="control-btn" id="next-btn" disabled title="Next">⏭</button>
<button class="control-btn" id="devices-btn" disabled title="Available devices">📱</button>
</div>
<div id="device-list" style="display: none;">
<div class="device-header">Available Devices</div>
<div id="devices-container"></div>
</div>
</div>
<div class="playlist-section" id="main-content" role="main" aria-label="Music playlists and tracks">
<div id="loading" class="loading">Loading your playlists...</div>
<div id="error" class="error" style="display: none;" role="alert" aria-live="assertive"></div>
<div id="playlist-list" role="list" aria-label="Music playlists"></div>
<div id="track-list" style="display: none;" role="list" aria-label="Playlist tracks"></div>
</div>
</div>
<!-- Load minified application bundle -->
<script src="dist/app.min.js"></script>
<!-- Load Spotify SDK after our code is defined -->
<script src="https://sdk.scdn.co/spotify-player.js"></script>
</body>
</html>`;
const outputPath = path.join(this.outputDir, 'index.html');
await writeFile(outputPath, htmlTemplate, 'utf8');
console.log(' ✓ Production HTML created');
}
/**
* Generate build manifest
*/
async generateManifest() {
console.log('📋 Generating build manifest...');
const manifest = {
buildTime: new Date().toISOString(),
version: '1.0.0',
files: {
'app.min.js': await this.getFileHash(path.join(this.outputDir, 'app.min.js')),
'styles.min.css': await this.getFileHash(path.join(this.outputDir, 'styles.min.css')),
'index.html': await this.getFileHash(path.join(this.outputDir, 'index.html'))
},
stats: this.stats
};
const manifestPath = path.join(this.outputDir, 'manifest.json');
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
console.log(' ✓ Build manifest created');
}
/**
* Simple JavaScript minification
*/
minifyJavaScript(code) {
return code
// Remove single-line comments (but preserve URLs)
.replace(/\/\/.*$/gm, '')
// Remove multi-line comments
.replace(/\/\*[\s\S]*?\*\//g, '')
// Remove extra whitespace
.replace(/\s+/g, ' ')
// Remove whitespace around operators
.replace(/\s*([{}();,])\s*/g, '$1')
// Remove leading/trailing whitespace
.trim();
}
/**
* Simple CSS minification
*/
minifyCSS(code) {
return code
// Remove comments
.replace(/\/\*[\s\S]*?\*\//g, '')
// Remove extra whitespace
.replace(/\s+/g, ' ')
// Remove whitespace around CSS syntax
.replace(/\s*([{}:;,>+~])\s*/g, '$1')
// Remove trailing semicolons
.replace(/;}/g, '}')
// Remove leading/trailing whitespace
.trim();
}
/**
* Get file hash for cache busting
*/
async getFileHash(filePath) {
try {
const content = await readFile(filePath, 'utf8');
const crypto = require('crypto');
return crypto.createHash('md5').update(content).digest('hex').substring(0, 8);
} catch (error) {
return 'unknown';
}
}
/**
* Format bytes for display
*/
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
/**
* Display build statistics
*/
displayStats() {
console.log('\n📊 BUILD STATISTICS');
console.log('===================');
console.log(`Files processed: ${this.stats.filesProcessed}`);
console.log(`Original size: ${this.formatBytes(this.stats.originalSize)}`);
console.log(`Compressed size: ${this.formatBytes(this.stats.compressedSize)}`);
if (this.stats.originalSize > 0) {
const ratio = Math.round((1 - this.stats.compressedSize / this.stats.originalSize) * 100);
console.log(`Compression ratio: ${ratio}%`);
}
}
}
// Run the build process if this script is executed directly
if (require.main === module) {
const builder = new BuildMinifier();
builder.build().catch(error => {
console.error('Build failed:', error);
process.exit(1);
});
}
module.exports = BuildMinifier;