UNPKG

roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

500 lines (444 loc) 17.3 kB
/** * Configuration management system for RoadKit project scaffolding. * * This module provides comprehensive configuration management including validation, * processing of user choices, configuration file generation, and settings persistence. * It ensures all project configurations are properly validated and consistently applied * throughout the scaffolding process. */ import { z } from 'zod'; import path from 'path'; import { ProjectConfigSchema, DEFAULT_CONFIG } from '../types/config'; import type { ProjectConfig, UserChoices, ConfigValidationResult, TemplateContext, Logger, } from '../types/config'; /** * Configuration manager class that handles all configuration-related operations * This class provides a centralized way to manage project configurations, * validate user input, and generate template contexts for project scaffolding. */ export class ConfigManager { private logger: Logger; /** * Initialize the configuration manager with a logger instance * @param logger - Logger instance for tracking configuration operations */ constructor(logger: Logger) { this.logger = logger; } /** * Validates user choices against the project configuration schema * * This method takes raw user input and validates it against our strict schema, * providing detailed error messages for any validation failures. It ensures * that all required fields are present and all values meet our constraints. * * @param choices - Raw user choices from CLI interaction * @returns Validation result with success status and either config or errors */ public validateConfiguration(choices: UserChoices): ConfigValidationResult { try { this.logger.debug('Validating user configuration choices', choices); // Transform user choices into the expected configuration format const configData = this.transformUserChoicesToConfig(choices); // Validate against the Zod schema const result = ProjectConfigSchema.safeParse(configData); if (result.success) { this.logger.success('Configuration validation successful'); return { success: true, config: result.data, }; } else { // Transform Zod errors into our error format const errors = result.error.issues.map(issue => ({ field: issue.path.join('.'), message: issue.message, code: issue.code, })); this.logger.error('Configuration validation failed', { errors }); return { success: false, errors, }; } } catch (error) { this.logger.error('Unexpected error during configuration validation', error); return { success: false, errors: [{ field: 'general', message: 'Unexpected validation error occurred', code: 'VALIDATION_ERROR', }], }; } } /** * Transforms user choices into the proper configuration format * * This method handles the mapping between the user-friendly CLI interface * and the internal configuration structure, applying defaults and normalizing * values as needed. * * @param choices - Raw user choices from CLI * @returns Transformed configuration data ready for validation */ private transformUserChoicesToConfig(choices: UserChoices): unknown { return { name: choices.projectName, description: choices.description, version: '1.0.0', author: { name: choices.author.name, email: choices.author.email, url: choices.author.url, }, template: choices.template, theme: choices.theme, features: { analytics: choices.features.analytics ?? false, seo: choices.features.seo ?? true, pwa: choices.features.pwa ?? false, authentication: choices.features.authentication ?? false, database: choices.features.database ?? false, api: choices.features.api ?? true, testing: choices.features.testing ?? true, deployment: choices.features.deployment ?? true, }, customization: { primaryColor: choices.customization.primaryColor ?? '#3b82f6', secondaryColor: choices.customization.secondaryColor ?? '#64748b', fontFamily: choices.customization.fontFamily ?? 'inter', logoUrl: choices.customization.logoUrl, faviconUrl: choices.customization.faviconUrl, }, technical: { nodeVersion: '20', bunVersion: 'latest', typescript: choices.technical.typescript ?? true, eslint: choices.technical.eslint ?? true, prettier: choices.technical.prettier ?? true, tailwind: choices.technical.tailwind ?? true, shadcnUi: choices.technical.shadcnUi ?? true, }, output: { directory: choices.outputDirectory, overwrite: choices.overwrite ?? false, gitInit: choices.gitInit ?? true, installDependencies: choices.installDependencies ?? true, }, metadata: { created: new Date(), generator: 'roadkit', version: '1.0.0', }, }; } /** * Generates template context from project configuration * * This method creates a comprehensive template context that can be used * for string replacement in template files. It includes various name formats, * colors, technical settings, and metadata that templates can reference. * * @param config - Validated project configuration * @returns Template context with all necessary replacement variables */ public generateTemplateContext(config: ProjectConfig): TemplateContext { this.logger.debug('Generating template context from configuration'); // Generate different name formats for template flexibility const projectNamePascal = this.toPascalCase(config.name); const projectNameKebab = this.toKebabCase(config.name); const projectNameSnake = this.toSnakeCase(config.name); const context: TemplateContext = { // Project naming in various formats projectName: config.name, projectNamePascal, projectNameKebab, projectNameSnake, // Basic project information description: config.description, author: config.author, version: config.version, // Visual customization primaryColor: config.customization.primaryColor, secondaryColor: config.customization.secondaryColor, fontFamily: config.customization.fontFamily, // Date and time information currentYear: new Date().getFullYear(), timestamp: new Date().toISOString(), // Feature flags and technical configuration features: config.features, technical: config.technical, // Additional template variables can be added here logoUrl: config.customization.logoUrl, faviconUrl: config.customization.faviconUrl, }; this.logger.success('Template context generated successfully'); return context; } /** * Generates a package.json configuration from project config * * This method creates a complete package.json object that can be serialized * and written to the generated project. It includes all necessary dependencies, * scripts, and metadata based on the project configuration. * * @param config - Validated project configuration * @returns Package.json object ready for serialization */ public generatePackageJson(config: ProjectConfig): Record<string, unknown> { this.logger.debug('Generating package.json configuration'); const packageJson: Record<string, unknown> = { name: config.name, version: config.version, description: config.description, private: true, author: config.author.email ? `${config.author.name} <${config.author.email}>` : config.author.name, scripts: { dev: 'bun --hot ./src/index.ts', build: 'bun build ./src/index.ts --outdir ./dist', start: 'bun ./dist/index.js', test: 'bun test', 'check-types': 'tsc --noEmit', }, dependencies: { 'next': '^14.0.0', 'react': '^18.0.0', 'react-dom': '^18.0.0', }, devDependencies: { '@types/node': '^20.0.0', '@types/react': '^18.0.0', '@types/react-dom': '^18.0.0', 'typescript': '^5.0.0', }, engines: { node: `>=${config.technical.nodeVersion}`, bun: config.technical.bunVersion, }, packageManager: 'bun@1.2.17', }; // Add conditional dependencies based on features if (config.features.testing) { (packageJson.devDependencies as Record<string, string>)['@types/jest'] = '^29.0.0'; (packageJson.scripts as Record<string, string>)['test:watch'] = 'bun test --watch'; } if (config.technical.eslint) { Object.assign(packageJson.devDependencies as Record<string, string>, { 'eslint': '^8.0.0', '@typescript-eslint/parser': '^6.0.0', '@typescript-eslint/eslint-plugin': '^6.0.0', }); (packageJson.scripts as Record<string, string>).lint = 'eslint src --ext .ts,.tsx'; (packageJson.scripts as Record<string, string>)['lint:fix'] = 'eslint src --ext .ts,.tsx --fix'; } if (config.technical.prettier) { (packageJson.devDependencies as Record<string, string>).prettier = '^3.0.0'; (packageJson.scripts as Record<string, string>).format = 'prettier --write src'; } if (config.technical.tailwind) { Object.assign(packageJson.dependencies as Record<string, string>, { 'tailwindcss': '^3.0.0', 'autoprefixer': '^10.0.0', 'postcss': '^8.0.0', }); } if (config.technical.shadcnUi) { Object.assign(packageJson.dependencies as Record<string, string>, { '@radix-ui/react-slot': '^1.0.0', 'class-variance-authority': '^0.7.0', 'clsx': '^2.0.0', 'tailwind-merge': '^2.0.0', }); } if (config.features.analytics) { (packageJson.dependencies as Record<string, string>)['@vercel/analytics'] = '^1.0.0'; } this.logger.success('Package.json configuration generated successfully'); return packageJson; } /** * Saves configuration to a JSON file for persistence and later reference * * This method serializes the project configuration to a JSON file that can * be stored alongside the generated project or used for debugging purposes. * * @param config - Project configuration to save * @param filePath - Path where the configuration should be saved */ public async saveConfigurationFile(config: ProjectConfig, filePath: string): Promise<void> { try { this.logger.debug(`Saving configuration to ${filePath}`); const configJson = JSON.stringify(config, null, 2); await Bun.write(filePath, configJson); this.logger.success(`Configuration saved to ${filePath}`); } catch (error) { this.logger.error(`Failed to save configuration to ${filePath}`, error); throw new Error(`Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Loads configuration from a JSON file for reuse or modification * * This method reads and validates a previously saved configuration file, * ensuring it still meets current schema requirements. * * @param filePath - Path to the configuration file to load * @returns Validated project configuration */ public async loadConfigurationFile(filePath: string): Promise<ProjectConfig> { try { this.logger.debug(`Loading configuration from ${filePath}`); const configFile = Bun.file(filePath); if (!await configFile.exists()) { throw new Error(`Configuration file not found: ${filePath}`); } const configJson = await configFile.json(); const result = ProjectConfigSchema.safeParse(configJson); if (!result.success) { throw new Error(`Invalid configuration file: ${result.error.message}`); } this.logger.success(`Configuration loaded from ${filePath}`); return result.data; } catch (error) { this.logger.error(`Failed to load configuration from ${filePath}`, error); throw new Error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Validates that the output directory is safe and available for use * * This method performs comprehensive validation of the output directory, * checking for existence, permissions, and potential conflicts. * * @param outputPath - Path to validate * @param allowOverwrite - Whether to allow overwriting existing directories * @returns Validation result with detailed feedback */ public async validateOutputDirectory(outputPath: string, allowOverwrite = false): Promise<{ valid: boolean; exists: boolean; writable: boolean; empty: boolean; errors: string[]; }> { const errors: string[] = []; let exists = false; let writable = false; let empty = true; try { this.logger.debug(`Validating output directory: ${outputPath}`); // Check if directory exists try { const stat = await Bun.file(outputPath).exists(); exists = stat; if (exists) { // Check if directory is empty const files = await Array.fromAsync(new Bun.Glob('*').scan({ cwd: outputPath })); empty = files.length === 0; if (!empty && !allowOverwrite) { errors.push(`Directory ${outputPath} is not empty. Use --overwrite flag to proceed.`); } } } catch (error) { // Directory doesn't exist, which is fine for creation exists = false; } // Check if we can write to the parent directory const parentDir = path.dirname(outputPath); try { const testFile = path.join(parentDir, '.roadkit-test'); await Bun.write(testFile, 'test'); await Bun.unlink(testFile); writable = true; } catch (error) { errors.push(`Cannot write to parent directory ${parentDir}. Check permissions.`); } // Validate path format if (!path.isAbsolute(outputPath)) { // Convert to absolute path for validation outputPath = path.resolve(outputPath); } const valid = errors.length === 0; if (valid) { this.logger.success(`Output directory validation passed: ${outputPath}`); } else { this.logger.warn(`Output directory validation failed: ${outputPath}`, { errors }); } return { valid, exists, writable, empty, errors }; } catch (error) { this.logger.error(`Failed to validate output directory: ${outputPath}`, error); return { valid: false, exists: false, writable: false, empty: false, errors: [`Failed to validate directory: ${error instanceof Error ? error.message : 'Unknown error'}`], }; } } // Utility methods for string case conversion /** * Converts a string to PascalCase format * @param str - Input string to convert * @returns PascalCase formatted string */ private toPascalCase(str: string): string { return str .replace(/[^a-zA-Z0-9]+/g, ' ') .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(''); } /** * Converts a string to kebab-case format * @param str - Input string to convert * @returns kebab-case formatted string */ private toKebabCase(str: string): string { return str .replace(/[^a-zA-Z0-9]+/g, '-') .replace(/([a-z])([A-Z])/g, '$1-$2') .toLowerCase() .replace(/^-+|-+$/g, ''); } /** * Converts a string to snake_case format * @param str - Input string to convert * @returns snake_case formatted string */ private toSnakeCase(str: string): string { return str .replace(/[^a-zA-Z0-9]+/g, '_') .replace(/([a-z])([A-Z])/g, '$1_$2') .toLowerCase() .replace(/^_+|_+$/g, ''); } } /** * Creates a default logger implementation for configuration operations * This provides basic logging capabilities when a custom logger is not provided */ export const createDefaultLogger = (): Logger => ({ info: (message: string, data?: unknown) => console.log(`[INFO] ${message}`, data || ''), warn: (message: string, data?: unknown) => console.warn(`[WARN] ${message}`, data || ''), error: (message: string, error?: Error | unknown) => console.error(`[ERROR] ${message}`, error || ''), debug: (message: string, data?: unknown) => console.debug(`[DEBUG] ${message}`, data || ''), success: (message: string, data?: unknown) => console.log(`[SUCCESS] ${message}`, data || ''), }); /** * Factory function to create a configured ConfigManager instance * @param logger - Optional logger instance, will create default if not provided * @returns Configured ConfigManager instance */ export const createConfigManager = (logger?: Logger): ConfigManager => { return new ConfigManager(logger || createDefaultLogger()); };