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
text/typescript
/**
* 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());
};