UNPKG

roadkit

Version:

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

704 lines (628 loc) 24.9 kB
/** * Theme Injection System for roadkit * * This module handles the dynamic injection of themes into generated projects. * It processes theme templates, applies variable substitution, and generates * the necessary files for a themed Next.js application. */ import * as fs from 'fs/promises'; import * as path from 'path'; import { z } from 'zod'; import type { Theme, ThemeInjectionOptions, ThemeGenerationOptions, CSSVariable } from '../themes/types'; import { ThemeValidationUtils, ThemeInjectionOptionsSchema, ThemeGenerationOptionsSchema } from '../themes/types'; /** * Template substitution context * Contains all the variables that can be substituted in template files */ interface TemplateContext { THEME_ID: string; THEME_NAME: string; COLOR_SCHEME: string; SHADCN_STYLE: string; DEFAULT_THEME_MODE: string; THEME_VERSION: string; THEME_AUTHOR: string; THEME_DESCRIPTION: string; BORDER_RADIUS: string; // Light mode variables LIGHT_BACKGROUND: string; LIGHT_FOREGROUND: string; LIGHT_CARD: string; LIGHT_CARD_FOREGROUND: string; LIGHT_POPOVER: string; LIGHT_POPOVER_FOREGROUND: string; LIGHT_PRIMARY: string; LIGHT_PRIMARY_FOREGROUND: string; LIGHT_SECONDARY: string; LIGHT_SECONDARY_FOREGROUND: string; LIGHT_MUTED: string; LIGHT_MUTED_FOREGROUND: string; LIGHT_ACCENT: string; LIGHT_ACCENT_FOREGROUND: string; LIGHT_DESTRUCTIVE: string; LIGHT_DESTRUCTIVE_FOREGROUND: string; LIGHT_BORDER: string; LIGHT_INPUT: string; LIGHT_RING: string; LIGHT_CHART_1: string; LIGHT_CHART_2: string; LIGHT_CHART_3: string; LIGHT_CHART_4: string; LIGHT_CHART_5: string; // Dark mode variables DARK_BACKGROUND: string; DARK_FOREGROUND: string; DARK_CARD: string; DARK_CARD_FOREGROUND: string; DARK_POPOVER: string; DARK_POPOVER_FOREGROUND: string; DARK_PRIMARY: string; DARK_PRIMARY_FOREGROUND: string; DARK_SECONDARY: string; DARK_SECONDARY_FOREGROUND: string; DARK_MUTED: string; DARK_MUTED_FOREGROUND: string; DARK_ACCENT: string; DARK_ACCENT_FOREGROUND: string; DARK_DESTRUCTIVE: string; DARK_DESTRUCTIVE_FOREGROUND: string; DARK_BORDER: string; DARK_INPUT: string; DARK_RING: string; DARK_CHART_1: string; DARK_CHART_2: string; DARK_CHART_3: string; DARK_CHART_4: string; DARK_CHART_5: string; } /** * File generation result */ interface GenerationResult { /** Whether the operation was successful */ success: boolean; /** Generated files with their paths */ files: Array<{ path: string; content: string; size: number; }>; /** Any errors that occurred */ errors: string[]; /** Warning messages */ warnings: string[]; } /** * Theme Injector Class * * Handles all theme injection operations including: * - Template processing and variable substitution * - File generation for themed projects * - Configuration updates * - Error handling and validation */ export class ThemeInjector { private templatesDir: string; constructor(templatesDir?: string) { this.templatesDir = templatesDir || path.join(__dirname, '../../templates/themes'); } /** * Inject theme into a project * * This is the main entry point for theme injection. It processes all * theme templates and generates the necessary files in the target project. * Includes comprehensive validation using Zod schemas. * * @param options - Theme injection configuration * @returns Promise resolving to generation result */ async injectTheme(options: ThemeInjectionOptions): Promise<GenerationResult> { const result: GenerationResult = { success: true, files: [], errors: [], warnings: [], }; try { // Validate options with Zod schema const validationResult = ThemeValidationUtils.validateThemeInjectionOptions(options); if (!validationResult.success) { result.success = false; result.errors.push(...ThemeValidationUtils.formatValidationErrors(validationResult.error!)); return result; } // Additional file system validations await this.validateFileSystemOptions(options, result); if (!result.success) return result; // Create template context from theme const context = this.createTemplateContext(options.theme); // Generate CSS files await this.generateCSSFiles(options, context, result); // Generate Tailwind configuration await this.generateTailwindConfig(options, context, result); // Generate theme provider components if (options.enableThemeSwitching) { await this.generateThemeComponents(options, context, result); } // Update project configuration files await this.updateProjectConfig(options, result); } catch (error) { result.success = false; result.errors.push(`Theme injection failed: ${error instanceof Error ? error.message : String(error)}`); } return result; } /** * Generate theme files without injecting into a project * * Useful for previewing themes or generating theme packages. * Includes comprehensive validation using Zod schemas. * * @param options - Theme generation configuration * @returns Promise resolving to generation result */ async generateThemeFiles(options: ThemeGenerationOptions): Promise<GenerationResult> { const result: GenerationResult = { success: true, files: [], errors: [], warnings: [], }; try { // Validate options with Zod schema const validationResult = ThemeValidationUtils.validateThemeGenerationOptions(options); if (!validationResult.success) { result.success = false; result.errors.push(...ThemeValidationUtils.formatValidationErrors(validationResult.error!)); return result; } const context = this.createTemplateContext(options.theme); // Ensure output directory exists await fs.mkdir(options.outputDir, { recursive: true }); if (options.includeCSSVariables) { await this.generateCSSFiles( { projectDir: options.outputDir, theme: options.theme, enableThemeSwitching: false, enableDarkMode: true, cssFilePath: 'globals.css' }, context, result ); } if (options.includeTailwindConfig) { await this.generateTailwindConfig( { projectDir: options.outputDir, theme: options.theme, enableThemeSwitching: false, enableDarkMode: true, tailwindConfigPath: 'tailwind.config.js' }, context, result ); } if (options.includeComponentOverrides) { await this.generateThemeComponents( { projectDir: options.outputDir, theme: options.theme, enableThemeSwitching: true, enableDarkMode: true, }, context, result ); } } catch (error) { result.success = false; result.errors.push(`Theme generation failed: ${error instanceof Error ? error.message : String(error)}`); } return result; } /** * Create template context from theme * * Converts theme configuration into template variables for substitution. * * @param theme - Theme configuration * @returns Template context with all variables */ private createTemplateContext(theme: Theme): TemplateContext { return { THEME_ID: theme.id, THEME_NAME: theme.name, COLOR_SCHEME: theme.colorScheme, SHADCN_STYLE: theme.style, DEFAULT_THEME_MODE: 'light', // Default to light mode THEME_VERSION: theme.meta.version, THEME_AUTHOR: theme.meta.author, THEME_DESCRIPTION: theme.meta.description, BORDER_RADIUS: theme.light.borderRadius.md, // Light mode variables LIGHT_BACKGROUND: theme.light.palette.background.value, LIGHT_FOREGROUND: theme.light.palette.foreground.value, LIGHT_CARD: theme.light.palette.card.value, LIGHT_CARD_FOREGROUND: theme.light.palette['card-foreground'].value, LIGHT_POPOVER: theme.light.palette.popover.value, LIGHT_POPOVER_FOREGROUND: theme.light.palette['popover-foreground'].value, LIGHT_PRIMARY: theme.light.palette.primary.value, LIGHT_PRIMARY_FOREGROUND: theme.light.palette['primary-foreground'].value, LIGHT_SECONDARY: theme.light.palette.secondary.value, LIGHT_SECONDARY_FOREGROUND: theme.light.palette['secondary-foreground'].value, LIGHT_MUTED: theme.light.palette.muted.value, LIGHT_MUTED_FOREGROUND: theme.light.palette['muted-foreground'].value, LIGHT_ACCENT: theme.light.palette.accent.value, LIGHT_ACCENT_FOREGROUND: theme.light.palette['accent-foreground'].value, LIGHT_DESTRUCTIVE: theme.light.palette.destructive.value, LIGHT_DESTRUCTIVE_FOREGROUND: theme.light.palette['destructive-foreground'].value, LIGHT_BORDER: theme.light.palette.border.value, LIGHT_INPUT: theme.light.palette.input.value, LIGHT_RING: theme.light.palette.ring.value, LIGHT_CHART_1: theme.light.palette['chart-1']?.value || theme.light.palette.primary.value, LIGHT_CHART_2: theme.light.palette['chart-2']?.value || theme.light.palette.secondary.value, LIGHT_CHART_3: theme.light.palette['chart-3']?.value || theme.light.palette.accent.value, LIGHT_CHART_4: theme.light.palette['chart-4']?.value || theme.light.palette.muted.value, LIGHT_CHART_5: theme.light.palette['chart-5']?.value || theme.light.palette.destructive.value, // Dark mode variables DARK_BACKGROUND: theme.dark.palette.background.value, DARK_FOREGROUND: theme.dark.palette.foreground.value, DARK_CARD: theme.dark.palette.card.value, DARK_CARD_FOREGROUND: theme.dark.palette['card-foreground'].value, DARK_POPOVER: theme.dark.palette.popover.value, DARK_POPOVER_FOREGROUND: theme.dark.palette['popover-foreground'].value, DARK_PRIMARY: theme.dark.palette.primary.value, DARK_PRIMARY_FOREGROUND: theme.dark.palette['primary-foreground'].value, DARK_SECONDARY: theme.dark.palette.secondary.value, DARK_SECONDARY_FOREGROUND: theme.dark.palette['secondary-foreground'].value, DARK_MUTED: theme.dark.palette.muted.value, DARK_MUTED_FOREGROUND: theme.dark.palette['muted-foreground'].value, DARK_ACCENT: theme.dark.palette.accent.value, DARK_ACCENT_FOREGROUND: theme.dark.palette['accent-foreground'].value, DARK_DESTRUCTIVE: theme.dark.palette.destructive.value, DARK_DESTRUCTIVE_FOREGROUND: theme.dark.palette['destructive-foreground'].value, DARK_BORDER: theme.dark.palette.border.value, DARK_INPUT: theme.dark.palette.input.value, DARK_RING: theme.dark.palette.ring.value, DARK_CHART_1: theme.dark.palette['chart-1']?.value || theme.dark.palette.primary.value, DARK_CHART_2: theme.dark.palette['chart-2']?.value || theme.dark.palette.secondary.value, DARK_CHART_3: theme.dark.palette['chart-3']?.value || theme.dark.palette.accent.value, DARK_CHART_4: theme.dark.palette['chart-4']?.value || theme.dark.palette.muted.value, DARK_CHART_5: theme.dark.palette['chart-5']?.value || theme.dark.palette.destructive.value, }; } /** * Template variable validation schema * Ensures only safe, expected template variables are processed */ private readonly templateVariableSchema = z.enum([ 'THEME_ID', 'THEME_NAME', 'COLOR_SCHEME', 'SHADCN_STYLE', 'DEFAULT_THEME_MODE', 'THEME_VERSION', 'THEME_AUTHOR', 'THEME_DESCRIPTION', 'BORDER_RADIUS', 'LIGHT_BACKGROUND', 'LIGHT_FOREGROUND', 'LIGHT_CARD', 'LIGHT_CARD_FOREGROUND', 'LIGHT_POPOVER', 'LIGHT_POPOVER_FOREGROUND', 'LIGHT_PRIMARY', 'LIGHT_PRIMARY_FOREGROUND', 'LIGHT_SECONDARY', 'LIGHT_SECONDARY_FOREGROUND', 'LIGHT_MUTED', 'LIGHT_MUTED_FOREGROUND', 'LIGHT_ACCENT', 'LIGHT_ACCENT_FOREGROUND', 'LIGHT_DESTRUCTIVE', 'LIGHT_DESTRUCTIVE_FOREGROUND', 'LIGHT_BORDER', 'LIGHT_INPUT', 'LIGHT_RING', 'LIGHT_CHART_1', 'LIGHT_CHART_2', 'LIGHT_CHART_3', 'LIGHT_CHART_4', 'LIGHT_CHART_5', 'DARK_BACKGROUND', 'DARK_FOREGROUND', 'DARK_CARD', 'DARK_CARD_FOREGROUND', 'DARK_POPOVER', 'DARK_POPOVER_FOREGROUND', 'DARK_PRIMARY', 'DARK_PRIMARY_FOREGROUND', 'DARK_SECONDARY', 'DARK_SECONDARY_FOREGROUND', 'DARK_MUTED', 'DARK_MUTED_FOREGROUND', 'DARK_ACCENT', 'DARK_ACCENT_FOREGROUND', 'DARK_DESTRUCTIVE', 'DARK_DESTRUCTIVE_FOREGROUND', 'DARK_BORDER', 'DARK_INPUT', 'DARK_RING', 'DARK_CHART_1', 'DARK_CHART_2', 'DARK_CHART_3', 'DARK_CHART_4', 'DARK_CHART_5' ]); /** * Sanitize template variable value * * Removes potentially dangerous characters that could be used for injection attacks. * Only allows alphanumeric characters, spaces, percentages, hyphens, and periods. * * @param value - Raw template variable value * @returns Sanitized value safe for template substitution */ private sanitizeTemplateValue(value: string): string { // Remove any potentially dangerous characters that could be used for code injection // Allow: alphanumeric, spaces, %, -, ., #, (, ), comma const sanitized = value.replace(/[^a-zA-Z0-9\s%\-\.#\(\),]/g, ''); // Limit length to prevent extremely long values that could cause issues const maxLength = 200; if (sanitized.length > maxLength) { throw new Error(`Template variable value exceeds maximum length of ${maxLength} characters`); } return sanitized.trim(); } /** * Validate and sanitize template variable * * @param variable - Template variable name * @param value - Template variable value * @returns Sanitized value * @throws Error if variable name is not in the allowlist */ private validateAndSanitizeVariable(variable: string, value: string): string { // Validate that the variable name is in our allowlist const validationResult = this.templateVariableSchema.safeParse(variable); if (!validationResult.success) { throw new Error(`Invalid template variable '${variable}'. Only predefined variables are allowed.`); } // Sanitize the value to prevent injection attacks return this.sanitizeTemplateValue(value); } /** * Process template with variable substitution * * Replaces all template variables ({{VARIABLE}}) with values from context. * Implements security measures to prevent template injection attacks: * - Variable name validation against allowlist * - Value sanitization to remove dangerous characters * - Length limits to prevent resource exhaustion * * @param template - Template content with variables * @param context - Template context with variable values * @returns Processed template content * @throws Error if template contains invalid variables or values */ private processTemplate(template: string, context: TemplateContext): string { // First, validate that the template doesn't contain potentially dangerous patterns const dangerousPatterns = [ /\$\{[^}]*\}/g, // ES6 template literals /<script[^>]*>/gi, // Script tags /javascript:/gi, // JavaScript protocols /vbscript:/gi, // VBScript protocols /data:.*base64/gi, // Base64 data URIs that could contain scripts /\bon\w+\s*=/gi, // Event handlers (onclick, onload, etc.) ]; for (const pattern of dangerousPatterns) { if (pattern.test(template)) { throw new Error(`Template contains potentially dangerous pattern: ${pattern.source}`); } } // Process template variables with validation and sanitization return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { const value = context[variable as keyof TemplateContext]; if (value === undefined) { throw new Error(`Template variable ${variable} not found in context`); } // Validate and sanitize the variable and its value const sanitizedValue = this.validateAndSanitizeVariable(variable, value); return sanitizedValue; }); } /** * Validate file system aspects of injection options * * This validates aspects that can't be covered by Zod schemas, * such as file system existence and permissions. * * @param options - Options to validate * @param result - Result object to add errors to */ private async validateFileSystemOptions( options: ThemeInjectionOptions, result: GenerationResult ): Promise<void> { // Check if project directory exists try { const stat = await fs.stat(options.projectDir); if (!stat.isDirectory()) { result.errors.push(`Project directory ${options.projectDir} is not a directory`); result.success = false; } } catch (error) { result.errors.push(`Project directory ${options.projectDir} does not exist`); result.success = false; } // Theme structure validation is now handled by Zod schemas // This method only validates file system aspects } /** * Generate CSS files with theme variables * * @param options - Injection options * @param context - Template context * @param result - Result object to update */ private async generateCSSFiles( options: ThemeInjectionOptions, context: TemplateContext, result: GenerationResult ): Promise<void> { try { // Read CSS template const templatePath = path.join(this.templatesDir, 'globals.css.template'); const template = await fs.readFile(templatePath, 'utf-8'); // Process template const processedCSS = this.processTemplate(template, context); // Determine output path const cssPath = options.cssFilePath || 'app/globals.css'; const outputPath = path.join(options.projectDir, cssPath); // Ensure directory exists await fs.mkdir(path.dirname(outputPath), { recursive: true }); // Write file await fs.writeFile(outputPath, processedCSS, 'utf-8'); result.files.push({ path: outputPath, content: processedCSS, size: processedCSS.length, }); } catch (error) { result.errors.push(`Failed to generate CSS files: ${error instanceof Error ? error.message : String(error)}`); } } /** * Generate Tailwind configuration * * @param options - Injection options * @param context - Template context * @param result - Result object to update */ private async generateTailwindConfig( options: ThemeInjectionOptions, context: TemplateContext, result: GenerationResult ): Promise<void> { try { // Read Tailwind config template const templatePath = path.join(this.templatesDir, 'tailwind.config.js.template'); const template = await fs.readFile(templatePath, 'utf-8'); // Process template const processedConfig = this.processTemplate(template, context); // Determine output path const configPath = options.tailwindConfigPath || 'tailwind.config.js'; const outputPath = path.join(options.projectDir, configPath); // Write file await fs.writeFile(outputPath, processedConfig, 'utf-8'); result.files.push({ path: outputPath, content: processedConfig, size: processedConfig.length, }); } catch (error) { result.errors.push(`Failed to generate Tailwind config: ${error instanceof Error ? error.message : String(error)}`); } } /** * Generate theme provider components * * @param options - Injection options * @param context - Template context * @param result - Result object to update */ private async generateThemeComponents( options: ThemeInjectionOptions, context: TemplateContext, result: GenerationResult ): Promise<void> { try { const componentsDir = path.join(options.projectDir, 'components', 'theme'); await fs.mkdir(componentsDir, { recursive: true }); // Generate theme provider const providerTemplate = await fs.readFile( path.join(this.templatesDir, 'theme-provider.tsx.template'), 'utf-8' ); const providerContent = this.processTemplate(providerTemplate, context); const providerPath = path.join(componentsDir, 'theme-provider.tsx'); await fs.writeFile(providerPath, providerContent, 'utf-8'); result.files.push({ path: providerPath, content: providerContent, size: providerContent.length, }); // Generate theme toggle const toggleTemplate = await fs.readFile( path.join(this.templatesDir, 'theme-toggle.tsx.template'), 'utf-8' ); const toggleContent = this.processTemplate(toggleTemplate, context); const togglePath = path.join(componentsDir, 'theme-toggle.tsx'); await fs.writeFile(togglePath, toggleContent, 'utf-8'); result.files.push({ path: togglePath, content: toggleContent, size: toggleContent.length, }); } catch (error) { result.errors.push(`Failed to generate theme components: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update project configuration files * * @param options - Injection options * @param result - Result object to update */ private async updateProjectConfig( options: ThemeInjectionOptions, result: GenerationResult ): Promise<void> { try { // Update package.json dependencies if needed await this.updatePackageJson(options, result); // Update components.json for shadcn/ui await this.updateComponentsConfig(options, result); } catch (error) { result.errors.push(`Failed to update project config: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update package.json with theme dependencies * * @param options - Injection options * @param result - Result object to update */ private async updatePackageJson( options: ThemeInjectionOptions, result: GenerationResult ): Promise<void> { const packageJsonPath = path.join(options.projectDir, 'package.json'); try { const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); // Add theme switching dependencies if enabled if (options.enableThemeSwitching && !packageJson.dependencies['next-themes']) { packageJson.dependencies = packageJson.dependencies || {}; packageJson.dependencies['next-themes'] = '^0.4.6'; await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8'); result.warnings.push('Added next-themes dependency. Run `npm install` to install.'); } } catch (error) { // Package.json might not exist or be malformed result.warnings.push(`Could not update package.json: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update components.json configuration * * @param options - Injection options * @param result - Result object to update */ private async updateComponentsConfig( options: ThemeInjectionOptions, result: GenerationResult ): Promise<void> { const configPath = path.join(options.projectDir, 'components.json'); try { const config = JSON.parse(await fs.readFile(configPath, 'utf-8')); // Update theme-related settings config.tailwind = config.tailwind || {}; config.tailwind.baseColor = options.theme.colorScheme; config.tailwind.cssVariables = true; await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); } catch (error) { // components.json might not exist result.warnings.push(`Could not update components.json: ${error instanceof Error ? error.message : String(error)}`); } } } /** * Convenience function to inject theme into a project * * @param options - Theme injection options * @returns Promise resolving to generation result */ export async function injectTheme(options: ThemeInjectionOptions): Promise<GenerationResult> { const injector = new ThemeInjector(); return injector.injectTheme(options); } /** * Convenience function to generate theme files * * @param options - Theme generation options * @returns Promise resolving to generation result */ export async function generateThemeFiles(options: ThemeGenerationOptions): Promise<GenerationResult> { const injector = new ThemeInjector(); return injector.generateThemeFiles(options); }