#!/usr/bin/env tsx /** * TimeSafari Asset Configuration Validator * Validates capacitor-assets configuration against schema and source files * Author: Matthew Raymer * * Usage: tsx scripts/assets-validator.ts [config-path] */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PROJECT_ROOT = path.dirname(__dirname); // TypeScript interfaces for validation interface ValidationError { message: string; } interface AssetConfig { icon?: { source?: string; android?: { adaptive?: { foreground?: string; background?: string; monochrome?: string; }; }; }; splash?: { source?: string; darkSource?: string; }; } /** * Load and parse JSON file * @param filePath - Path to JSON file * @returns Parsed JSON object */ function loadJsonFile(filePath: string): AssetConfig { try { const content = fs.readFileSync(filePath, 'utf8'); return JSON.parse(content); } catch (error) { throw new Error(`Failed to load ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Validate configuration against schema * @param config - Configuration object to validate * @returns Array of validation errors */ function validateAgainstSchema(config: AssetConfig): ValidationError[] { const errors: ValidationError[] = []; // Basic structure validation if (!config.icon || !config.splash) { errors.push({ message: 'Configuration must contain both "icon" and "splash" sections' }); } // Icon validation if (config.icon) { if (!config.icon.source) { errors.push({ message: 'Icon section must contain "source" field' }); } else if (!/^resources\/.*\.(png|svg)$/.test(config.icon.source)) { errors.push({ message: 'Icon source must be a PNG or SVG file in resources/ directory' }); } // Android adaptive icon validation if (config.icon.android?.adaptive) { const adaptive = config.icon.android.adaptive; if (!adaptive.foreground || !adaptive.background) { errors.push({ message: 'Android adaptive icon must have both foreground and background' }); } if (adaptive.foreground && !/^resources\/.*\.(png|svg)$/.test(adaptive.foreground)) { errors.push({ message: 'Android adaptive foreground must be a PNG or SVG file in resources/ directory' }); } } } // Splash validation if (config.splash) { if (!config.splash.source) { errors.push({ message: 'Splash section must contain "source" field' }); } else if (!/^resources\/.*\.(png|svg)$/.test(config.splash.source)) { errors.push({ message: 'Splash source must be a PNG or SVG file in resources/ directory' }); } if (config.splash.darkSource && !/^resources\/.*\.(png|svg)$/.test(config.splash.darkSource)) { errors.push({ message: 'Dark splash source must be a PNG or SVG file in resources/ directory' }); } } return errors; } /** * Validate that source files exist * @param config - Configuration object * @returns Array of missing file errors */ function validateSourceFiles(config: AssetConfig): ValidationError[] { const errors: ValidationError[] = []; const requiredFiles = new Set(); // Collect all required source files if (config.icon?.source) requiredFiles.add(config.icon.source); if (config.icon?.android?.adaptive?.foreground) requiredFiles.add(config.icon.android.adaptive.foreground); if (config.icon?.android?.adaptive?.monochrome) requiredFiles.add(config.icon.android.adaptive.monochrome); if (config.splash?.source) requiredFiles.add(config.splash.source); if (config.splash?.darkSource) requiredFiles.add(config.splash.darkSource); // Check each file exists requiredFiles.forEach(file => { const filePath = path.join(PROJECT_ROOT, file); if (!fs.existsSync(filePath)) { errors.push({ message: `Source file not found: ${file}` }); } }); return errors; } /** * Validate target directories are writable * @param config - Configuration object * @returns Array of directory validation errors */ function validateTargetDirectories(config: AssetConfig): ValidationError[] { const errors: ValidationError[] = []; const targetDirs = new Set(); // Collect all target directories if (config.icon?.android?.target) targetDirs.add(config.icon.android.target); if (config.icon?.ios?.target) targetDirs.add(config.icon.ios.target); if (config.icon?.web?.target) targetDirs.add(config.icon.web.target); if (config.splash?.android?.target) targetDirs.add(config.splash.android.target); if (config.splash?.ios?.target) targetDirs.add(config.splash.ios.target); // Check each target directory targetDirs.forEach(dir => { const dirPath = path.join(PROJECT_ROOT, dir); const parentDir = path.dirname(dirPath); if (!fs.existsSync(parentDir)) { errors.push({ message: `Parent directory does not exist: ${parentDir}` }); } else if (!fs.statSync(parentDir).isDirectory()) { errors.push({ message: `Parent path is not a directory: ${parentDir}` }); } }); return errors; } /** * Main validation function * @param configPath - Path to configuration file * @returns True if validation passes */ function validateConfiguration(configPath: string): boolean { console.log('🔍 Validating TimeSafari asset configuration...'); console.log(`📁 Config file: ${configPath}`); console.log(`📁 Project root: ${PROJECT_ROOT}`); try { // Load configuration const config = loadJsonFile(configPath); console.log('✅ Configuration file loaded successfully'); // Load schema const schemaPath = path.join(PROJECT_ROOT, 'config', 'assets', 'schema.json'); const schema = loadJsonFile(schemaPath); console.log('✅ Schema file loaded successfully'); // Perform validations const schemaErrors = validateAgainstSchema(config); const fileErrors = validateSourceFiles(config); const dirErrors = validateTargetDirectories(config); // Report results const allErrors = [...schemaErrors, ...fileErrors, ...dirErrors]; if (allErrors.length === 0) { console.log('🎉 All validations passed successfully!'); console.log(''); console.log('📋 Configuration summary:'); console.log(` Icon source: ${config.icon?.source || 'NOT SET'}`); console.log(` Splash source: ${config.splash?.source || 'NOT SET'}`); console.log(` Dark splash: ${config.splash?.darkSource || 'NOT SET'}`); console.log(` Android adaptive: ${config.icon?.android?.adaptive ? 'ENABLED' : 'DISABLED'}`); console.log(` iOS LaunchScreen: ${config.splash?.ios?.useStoryBoard ? 'ENABLED' : 'DISABLED'}`); return true; } else { console.error('❌ Validation failed with the following errors:'); allErrors.forEach((error, index) => { console.error(` ${index + 1}. ${error.message}`); }); return false; } } catch (error) { console.error('❌ Validation failed:', error instanceof Error ? error.message : String(error)); return false; } } /** * Main execution function */ function main(): void { const configPath = process.argv[2] || path.join(PROJECT_ROOT, 'capacitor-assets.config.json'); if (!fs.existsSync(configPath)) { console.error(`❌ Configuration file not found: ${configPath}`); console.log(''); console.log('💡 Available options:'); console.log(' - Use default: capacitor-assets.config.json'); console.log(' - Specify path: tsx scripts/assets-validator.ts path/to/config.json'); console.log(' - Generate config: npm run assets:config'); process.exit(1); } const success = validateConfiguration(configPath); process.exit(success ? 0 : 1); } // Run if called directly if (import.meta.url === `file://${process.argv[1]}`) { main(); } export { validateConfiguration, validateAgainstSchema, validateSourceFiles, validateTargetDirectories, AssetConfig };