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