UNPKG

@codewithdan/ai-repo-adventure-generator

Version:

Generate beautiful HTML adventure websites from your codebase

1,087 lines (1,083 loc) • 57.4 kB
#!/usr/bin/env node /** * Standalone CLI tool for generating HTML adventure files * Refactored for simplicity and maintainability */ import * as readline from 'readline'; import * as path from 'path'; import * as fs from 'fs'; import * as http from 'http'; import { spawn } from 'child_process'; import chalk from 'chalk'; import { marked } from 'marked'; import { repoAnalyzer } from '@ai-repo-adventures/core/analyzer'; import { AdventureManager } from '@ai-repo-adventures/core/adventure'; import { getAllThemes, getThemeByKey, parseAdventureConfig, LLM_MODEL } from '@ai-repo-adventures/core/shared'; import { LLMClient } from '@ai-repo-adventures/core/llm'; import { createProjectInfo } from '@ai-repo-adventures/core'; import { TemplateEngine } from './template-engine.js'; import { AssetManager } from './asset-manager.js'; class HTMLAdventureGenerator { rl; adventureManager; templateEngine; projectPath = process.cwd(); outputDir = ''; selectedTheme = 'space'; customThemeData; quests = []; repoUrl = null; maxQuests; logLlmOutput = false; serve = false; isMultiTheme = false; constructor() { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); this.adventureManager = new AdventureManager(); this.templateEngine = new TemplateEngine(); } async start() { console.clear(); console.log(chalk.bgBlue.white.bold(' 🌟 AI Repo Adventures HTML Generator 🌟 ')); console.log(chalk.dim('─'.repeat(50))); console.log(); console.log(chalk.yellow('Generate a complete HTML adventure website from your codebase!')); console.log(); try { await this.selectTheme(); await this.selectOutputDirectory(); await this.generateAdventure(); console.log(); console.log(chalk.green.bold('šŸŽ‰ Adventure website generated successfully!')); console.log(chalk.cyan(`šŸ“ Location: ${this.outputDir}`)); console.log(chalk.cyan(`🌐 Open: ${path.join(this.outputDir, 'index.html')}`)); } catch (error) { console.error(chalk.red('āŒ Error generating adventure:'), error); this.rl.close(); process.exit(1); } this.rl.close(); process.exit(0); } async startWithArgs(args) { this.printHeader(); try { // Handle theme configuration const shouldGenerateAllThemes = this.configureTheme(args); if (shouldGenerateAllThemes) { await this.generateAllThemes(args); return; } // Configure output and options this.configureOutputDirectory(args); const overwrite = this.configureOptions(args); // Setup directories and generate this.setupOutputDirectories(overwrite); await this.generateAdventure(); this.printSuccessMessage(); if (this.serve) { await this.startHttpServer(); } } catch (error) { console.error(chalk.red('āŒ Error generating adventure:'), error); this.rl.close(); process.exit(1); } this.rl.close(); process.exit(0); } printHeader() { console.log(chalk.bgBlue.white.bold(' 🌟 AI Repo Adventures HTML Generator 🌟 ')); console.log(chalk.dim('─'.repeat(50))); console.log(); } configureTheme(args) { const themeArg = args.get('theme'); if (!themeArg) return false; const theme = this.parseThemeArg(themeArg); if (!theme) { throw new Error(`Invalid theme: ${themeArg}. Valid themes: space, mythical, ancient, developer, custom, all`); } if (theme === 'all') { return true; // Signal that all themes should be generated } this.selectedTheme = theme; console.log(chalk.green(`āœ… Theme: ${themeArg}`)); return false; } configureOutputDirectory(args) { const outputArg = args.get('output'); this.outputDir = outputArg || './public'; console.log(chalk.green(`āœ… Output: ${this.outputDir}`)); } configureOptions(args) { // Configure overwrite setting const overwrite = args.has('overwrite'); if (overwrite) { console.log(chalk.green('āœ… Overwrite: enabled')); } // Configure max-quests setting const maxQuestsArg = args.get('max-quests'); const maxQuests = maxQuestsArg ? parseInt(maxQuestsArg, 10) : undefined; if (maxQuests !== undefined && (isNaN(maxQuests) || maxQuests < 0)) { throw new Error(`Invalid max-quests value: ${maxQuestsArg}. Must be a positive number.`); } if (maxQuests !== undefined) { console.log(chalk.green(`āœ… Max quests: ${maxQuests}`)); this.maxQuests = maxQuests; } // Configure logging and serving this.logLlmOutput = args.has('log-llm-output'); if (this.logLlmOutput) { console.log(chalk.green('āœ… LLM output logging: enabled')); } this.serve = args.has('serve'); if (this.serve) { console.log(chalk.green('āœ… HTTP server: will start after generation')); } return overwrite; } setupOutputDirectories(overwrite) { // Check if output directory exists and handle overwrite if (fs.existsSync(this.outputDir) && !overwrite) { const files = fs.readdirSync(this.outputDir).filter(f => f.endsWith('.html')); if (files.length > 0) { throw new Error(`Output directory ${this.outputDir} contains HTML files. Use --overwrite to replace them.`); } } // Create directories if they don't exist or if overwrite is enabled if (overwrite && fs.existsSync(this.outputDir)) { fs.rmSync(this.outputDir, { recursive: true, force: true }); } // Create output directories fs.mkdirSync(this.outputDir, { recursive: true }); fs.mkdirSync(path.join(this.outputDir, 'assets'), { recursive: true }); fs.mkdirSync(path.join(this.outputDir, 'assets', 'images'), { recursive: true }); } printSuccessMessage() { console.log(); console.log(chalk.green.bold('šŸŽ‰ Adventure website generated successfully!')); console.log(chalk.cyan(`šŸ“ Location: ${this.outputDir}`)); if (!this.serve) { console.log(chalk.cyan(`🌐 Open: ${path.join(this.outputDir, 'index.html')}`)); } } parseThemeArg(themeArg) { const lowerTheme = themeArg.toLowerCase(); switch (lowerTheme) { case 'space': case '1': return 'space'; case 'mythical': case '2': return 'mythical'; case 'ancient': case '3': return 'ancient'; case 'developer': case '4': return 'developer'; case 'custom': case '5': return 'custom'; case 'all': return 'all'; default: return null; } } async selectTheme() { console.log(chalk.yellow.bold('šŸ“š Choose Your Adventure Theme:')); console.log(); const themes = getAllThemes(); themes.forEach((theme) => { console.log(`${theme.emoji} ${chalk.bold(theme.id.toString())}. ${theme.displayName} - ${theme.description}`); }); console.log(); const choice = await this.prompt('Enter theme number or name: '); // Parse theme choice const themeNumber = parseInt(choice.trim()); if (!isNaN(themeNumber)) { const theme = themes.find((t) => t.id === themeNumber); if (theme) { this.selectedTheme = theme.key; } else { console.log(chalk.red('Invalid theme number. Using space theme.')); this.selectedTheme = 'space'; } } else { const theme = getThemeByKey(choice.trim().toLowerCase()); if (theme) { this.selectedTheme = theme.key; } else { console.log(chalk.red('Invalid theme name. Using space theme.')); this.selectedTheme = 'space'; } } // Handle custom theme if (this.selectedTheme === 'custom') { await this.createCustomTheme(); } const selectedThemeInfo = getThemeByKey(this.selectedTheme); console.log(chalk.green(`āœ… Selected: ${selectedThemeInfo?.displayName || this.selectedTheme}`)); console.log(); } async createCustomTheme() { console.log(chalk.cyan('\nšŸŽØ Creating Custom Theme...')); console.log(); const name = await this.prompt('Theme name (e.g., "Cyberpunk", "Pirate Adventure"): '); const description = await this.prompt('Theme description: '); const keywordsInput = await this.prompt('Keywords (comma-separated): '); const keywords = keywordsInput.split(',').map(k => k.trim()).filter(k => k.length > 0); if (!name.trim() || !description.trim() || keywords.length === 0) { console.log(chalk.red('āŒ Invalid custom theme data. Using space theme instead.')); this.selectedTheme = 'space'; return; } this.customThemeData = { name: name.trim(), description: description.trim(), keywords }; console.log(chalk.green('āœ… Custom theme created!')); } async selectOutputDirectory() { console.log(chalk.yellow.bold('šŸ“ Output Directory:')); console.log(chalk.dim(`Current directory: ${process.cwd()}`)); console.log(); const dir = await this.prompt('Enter output directory (or press Enter for ./public): '); this.outputDir = dir.trim() || path.join(process.cwd(), 'public'); // Check if directory exists and has content if (fs.existsSync(this.outputDir)) { const files = fs.readdirSync(this.outputDir); if (files.length > 0) { console.log(chalk.yellow(`āš ļø Directory '${this.outputDir}' already exists and contains files.`)); console.log(chalk.dim('Files found:')); files.slice(0, 5).forEach(file => { console.log(chalk.dim(` - ${file}`)); }); if (files.length > 5) { console.log(chalk.dim(` ... and ${files.length - 5} more files`)); } console.log(); const overwrite = await this.prompt('Do you want to overwrite this directory? (y/N): '); if (!overwrite.toLowerCase().startsWith('y')) { console.log(chalk.red('āŒ Operation cancelled.')); process.exit(0); } console.log(chalk.yellow('šŸ—‘ļø Clearing existing directory...')); fs.rmSync(this.outputDir, { recursive: true, force: true }); } } // Create directories fs.mkdirSync(this.outputDir, { recursive: true }); fs.mkdirSync(path.join(this.outputDir, 'assets'), { recursive: true }); fs.mkdirSync(path.join(this.outputDir, 'assets', 'images'), { recursive: true }); console.log(chalk.green(`āœ… Output directory: ${this.outputDir}`)); console.log(); } async generateAdventure() { console.log(chalk.yellow.bold('šŸš€ Generating Adventure...')); console.log(); // Load repository URL from adventure.config.json const config = parseAdventureConfig(this.projectPath); if (config && typeof config === 'object' && 'adventure' in config) { const adventure = config.adventure; if (adventure && typeof adventure.url === 'string') { this.repoUrl = adventure.url.replace(/\/$/, ''); // Remove trailing slash } } // Step 1: Generate project analysis console.log(chalk.dim('šŸ“Š Analyzing codebase...')); const repomixContent = await repoAnalyzer.generateRepomixContext(this.projectPath); const projectInfo = createProjectInfo(repomixContent); // Step 2: Initialize adventure console.log(chalk.dim('✨ Generating themed story and quests...')); const storyContent = await this.adventureManager.initializeAdventure(projectInfo, this.selectedTheme, this.projectPath, this.customThemeData); // Save story content if logging is enabled this.saveLlmOutput('story.output.md', storyContent); // Step 3: Extract quest information this.extractQuestInfo(); // Step 3.5: Trim quests array if max-quests is specified (before homepage generation) const questsToGenerate = this.maxQuests !== undefined ? Math.min(this.maxQuests, this.quests.length) : this.quests.length; if (questsToGenerate < this.quests.length) { this.quests = this.quests.slice(0, questsToGenerate); } // Step 4: Generate all files console.log(chalk.dim('šŸŽØ Creating theme styling...')); this.generateThemeCSS(); console.log(chalk.dim('🧭 Adding quest navigator...')); if (!this.isMultiTheme) { this.copyQuestNavigator(); } console.log(chalk.dim('šŸ–¼ļø Copying images...')); this.copyImages(); console.log(chalk.dim('šŸ“ Creating main adventure page...')); this.generateIndexHTML(); console.log(chalk.dim('šŸ“– Generating quest pages...')); await this.generateQuestPages(); console.log(chalk.dim('šŸŽ‰ Creating adventure summary page...')); await this.generateSummaryHTML(); } extractQuestInfo() { this.quests = this.adventureManager.getAllQuests().map((quest, index) => ({ id: quest.id, title: quest.title, filename: `quest-${index + 1}.html` })); } /** * Determines if a theme is light-colored and requires dark GitHub logo */ isLightTheme(theme) { // Light themes that need dark GitHub logo (github-mark.svg) const lightThemes = ['mythical', 'developer']; return lightThemes.includes(theme); } /** * Get appropriate GitHub logo based on theme brightness */ getGitHubLogo() { const sharedPath = this.isMultiTheme ? '../assets/shared' : 'assets/shared'; return this.isLightTheme(this.selectedTheme) ? `${sharedPath}/github-mark.svg` // Dark logo for light themes : `${sharedPath}/github-mark-white.svg`; // White logo for dark themes } /** * Save LLM output to tests/llm-output directory if logging is enabled */ saveLlmOutput(baseFilename, content) { if (!this.logLlmOutput) return; const llmOutputDir = path.join('tests', 'llm-output'); // Create directory if it doesn't exist if (!fs.existsSync(llmOutputDir)) { fs.mkdirSync(llmOutputDir, { recursive: true }); } // Extract the file extension and base name const parts = baseFilename.split('.'); const extension = parts.pop(); // Get the extension (e.g., 'md') const baseName = parts.join('.'); // Get the base name (e.g., 'story.output') // Include the model name in the filename const modelName = LLM_MODEL.replace(/[^a-zA-Z0-9-]/g, '-'); // Sanitize model name for filename const filename = `${baseName}.${modelName}.${extension}`; const outputPath = path.join(llmOutputDir, filename); fs.writeFileSync(outputPath, content, 'utf-8'); console.log(chalk.dim(`šŸ“ LLM output saved: ${outputPath}`)); } /** * Start an HTTP server in the output directory */ async startHttpServer(port = 8080) { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { let filePath = path.join(this.outputDir, req.url === '/' ? 'index.html' : req.url || ''); // Security check - ensure we stay within the directory if (!filePath.startsWith(this.outputDir)) { res.writeHead(403); res.end('Forbidden'); return; } // Set content type based on file extension const extname = path.extname(filePath); let contentType = 'text/html'; switch (extname) { case '.js': contentType = 'text/javascript'; break; case '.css': contentType = 'text/css'; break; case '.json': contentType = 'application/json'; break; case '.png': contentType = 'image/png'; break; case '.jpg': contentType = 'image/jpg'; break; case '.svg': contentType = 'image/svg+xml'; break; } fs.readFile(filePath, (err, content) => { if (err) { if (err.code === 'ENOENT') { res.writeHead(404); res.end('File not found'); } else { res.writeHead(500); res.end('Server error'); } } else { res.writeHead(200, { 'Content-Type': contentType }); res.end(content, 'utf-8'); } }); }); server.listen(port, () => { const url = `http://localhost:${port}`; console.log(chalk.green(`🌐 HTTP server started on ${url}`)); // Open browser after a brief delay setTimeout(() => { this.openBrowser(url); }, 1000); console.log(chalk.dim('\nšŸ’” Press Ctrl+C to stop the server when you\'re done exploring')); // Keep the server running - user will stop with Ctrl+C process.on('SIGINT', () => { console.log(chalk.yellow('\nšŸ‘‹ Shutting down HTTP server...')); server.close(() => { console.log(chalk.green('āœ… Server stopped successfully!')); process.exit(0); }); }); process.on('SIGTERM', () => { server.close(() => process.exit(0)); }); resolve(); }); server.on('error', (err) => { console.error(chalk.red('āŒ Failed to start HTTP server:'), err); console.log(chalk.yellow('\nšŸ“ Files are still available at:')); console.log(chalk.cyan(` ${this.outputDir}`)); reject(err); }); }); } /** * Open URL in default browser */ openBrowser(url) { const platform = process.platform; let command; switch (platform) { case 'darwin': command = 'open'; break; case 'win32': command = 'start'; break; default: command = 'xdg-open'; } try { spawn(command, [url], { detached: true, stdio: 'ignore' }); console.log(chalk.cyan(`šŸš€ Opening ${url} in default browser`)); } catch (error) { console.log(chalk.yellow(`āš ļø Could not automatically open browser. Please visit: ${url}`)); } } /** * Get common template variables used across all pages */ getCommonTemplateVariables() { const adventureTitle = this.adventureManager.getTitle(); const config = parseAdventureConfig(this.projectPath); let repoName = 'Repository'; let repoUrl = '#'; if (config && typeof config === 'object' && 'adventure' in config) { const adventure = config.adventure; if (adventure) { repoName = adventure.name || 'Repository'; repoUrl = adventure.url || '#'; } } // Theme-appropriate emoticons (using safe emojis) const themeIcons = { space: { theme: 'šŸš€', quest: '⭐' }, ancient: { theme: 'šŸ›ļø', quest: 'šŸ“œ' }, mythical: { theme: 'šŸ§™ā€ā™‚ļø', quest: 'āš”ļø' }, developer: { theme: 'šŸ’»', quest: 'šŸ“‹' }, custom: { theme: 'šŸŽØ', quest: '⭐' } }; const icons = themeIcons[this.selectedTheme] || themeIcons.space; // Add "Change Theme" link only when in multi-theme mode const changeThemeLink = this.isMultiTheme ? '<a href="../index.html" class="nav-link">Change Theme</a>' : ''; return { ADVENTURE_TITLE: adventureTitle, INDEX_LINK: 'index.html', CURRENT_THEME: this.selectedTheme, REPO_NAME: repoName, REPO_URL: repoUrl, THEME_ICON: icons.theme, QUEST_ICON: icons.quest, GITHUB_LOGO: this.getGitHubLogo(), CHANGE_THEME_LINK: changeThemeLink, ASSETS_PATH: 'assets', NAVIGATOR_ASSETS_PATH: this.isMultiTheme ? '../assets/shared' : 'assets', TOGGLE_ASSETS_PATH: this.isMultiTheme ? '../assets' : 'assets', IMAGES_PATH: this.isMultiTheme ? '../assets/images' : 'assets/images', SHARED_PATH: this.isMultiTheme ? '../assets/shared' : 'assets/shared' }; } generateThemeCSS() { const themeCSS = this.loadThemeCSS(this.selectedTheme); const baseCSS = this.loadBaseCSS(); const animationsCSS = this.loadAnimationsCSS(); // Combine CSS in the correct order: theme variables, base styles, animations let combinedCSS = themeCSS + '\n\n' + baseCSS + '\n\n' + animationsCSS; // Fix image paths based on theme mode // In multi-theme: theme CSS is at ./theme/assets/theme.css, images at root ./assets/images/ // In single-theme: theme CSS is at ./assets/theme.css, images at ./assets/images/ const imagePath = this.isMultiTheme ? '../../assets/images/' : 'images/'; combinedCSS = combinedCSS.replace(/url\('images\//g, `url('${imagePath}`); const cssPath = path.join(this.outputDir, 'assets', 'theme.css'); fs.writeFileSync(cssPath, combinedCSS); } copyImages() { // Skip copying images in multi-theme mode for individual themes // Images are copied once at the root level if (this.isMultiTheme) { return; } const __dirname = path.dirname(new URL(import.meta.url).pathname); const sourceImagesDir = path.join(__dirname, 'assets', 'images'); const sourceSharedDir = path.join(__dirname, 'assets', 'shared'); const targetImagesDir = path.join(this.outputDir, 'assets', 'images'); const targetSharedDir = path.join(this.outputDir, 'assets', 'shared'); try { // Copy theme-specific images if (fs.existsSync(sourceImagesDir)) { fs.mkdirSync(targetImagesDir, { recursive: true }); const imageFiles = fs.readdirSync(sourceImagesDir); imageFiles.forEach(file => { const sourcePath = path.join(sourceImagesDir, file); const targetPath = path.join(targetImagesDir, file); fs.copyFileSync(sourcePath, targetPath); }); } // Copy shared images if (fs.existsSync(sourceSharedDir)) { fs.mkdirSync(targetSharedDir, { recursive: true }); const sharedFiles = fs.readdirSync(sourceSharedDir); sharedFiles.forEach(file => { const sourcePath = path.join(sourceSharedDir, file); const targetPath = path.join(targetSharedDir, file); fs.copyFileSync(sourcePath, targetPath); }); } } catch (error) { console.log(chalk.yellow('āš ļø Warning: Could not copy images from source directory')); } } copyQuestNavigator() { const __dirname = path.dirname(new URL(import.meta.url).pathname); const assetManager = new AssetManager(__dirname); assetManager.copyQuestNavigator(this.outputDir); } loadThemeCSS(theme) { return this.loadCSSFile(`themes/${theme}.css`, 'themes/default.css'); } loadBaseCSS() { return this.loadCSSFile('themes/base.css', null) || '/* Base CSS not found */'; } loadAnimationsCSS() { return this.loadCSSFile('themes/animations.css', null) || '/* Animations CSS not found */'; } loadCSSFile(relativePath, fallbackPath) { const __dirname = path.dirname(new URL(import.meta.url).pathname); try { return fs.readFileSync(path.join(__dirname, relativePath), 'utf-8'); } catch { if (fallbackPath) { try { return fs.readFileSync(path.join(__dirname, fallbackPath), 'utf-8'); } catch { return this.getFallbackCSS(); } } return ''; } } getFallbackCSS() { try { return this.loadCSSFile('themes/fallback.css', null) || '/* No fallback CSS available */'; } catch { return '/* Fallback CSS load failed */'; } } generateIndexHTML() { const html = this.buildIndexHTML(); const indexPath = path.join(this.outputDir, 'index.html'); fs.writeFileSync(indexPath, html); } async generateSummaryHTML() { const html = await this.buildSummaryHTML(); const summaryPath = path.join(this.outputDir, 'summary.html'); fs.writeFileSync(summaryPath, html); } async generateQuestPages() { const questsToGenerate = this.quests.length; for (let i = 0; i < questsToGenerate; i++) { const quest = this.quests[i]; if (!quest) continue; console.log(chalk.dim(` šŸ“– Generating quest ${i + 1}/${questsToGenerate} [${this.selectedTheme}]: ${quest.title}`)); try { const questContent = await this.generateQuestContentWithRetry(quest.id); // Save quest content if logging is enabled this.saveLlmOutput('quest.output.md', questContent); const html = this.buildQuestHTML(quest, questContent, i); const questPath = path.join(this.outputDir, quest.filename); fs.writeFileSync(questPath, html); } catch (error) { console.log(chalk.red(` āŒ Failed to generate quest [${this.selectedTheme}]: ${quest.title}`)); const placeholderHTML = this.buildQuestHTML(quest, 'Quest content could not be generated.', i); const questPath = path.join(this.outputDir, quest.filename); fs.writeFileSync(questPath, placeholderHTML); } } } async generateQuestContentWithRetry(questId, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const result = await this.adventureManager.exploreQuest(questId); return result.narrative; } catch (error) { console.log(chalk.yellow(` āš ļø Attempt ${attempt}/${maxRetries} failed, retrying...`)); if (attempt === maxRetries) { throw error; } await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error('Max retries exceeded'); } buildIndexHTML() { const adventureQuests = this.adventureManager.getAllQuests(); const questLinks = this.quests.map((quest, index) => { const questData = adventureQuests[index]; let description = questData?.description || ''; // Remove code files section from description if (description) { description = description.replace(/\*?\*?Code Files:.*$/si, '').trim(); } const questLinkVariables = { QUEST_FILENAME: quest.filename, QUEST_TITLE: this.formatInlineMarkdown(quest.title), QUEST_DESCRIPTION: description ? `<p>${this.formatInlineMarkdown(description)}</p>` : '' }; return this.templateEngine.renderTemplate('quest-link.html', questLinkVariables); }).join('\n'); const cleanStoryContent = this.adventureManager.getStoryContent(); const variables = { ...this.getCommonTemplateVariables(), PAGE_TITLE: this.adventureManager.getTitle(), STORY_CONTENT: this.formatMarkdown(cleanStoryContent), QUEST_LINKS: questLinks }; return this.templateEngine.renderPage('index-template.html', variables); } buildQuestHTML(quest, content, questIndex) { const prevQuest = questIndex > 0 ? this.quests[questIndex - 1] : null; const nextQuest = questIndex < this.quests.length - 1 ? this.quests[questIndex + 1] : null; let bottomNavigation = ''; const isLastQuest = questIndex === this.quests.length - 1; if (prevQuest || nextQuest || isLastQuest) { // Determine navigation CSS class based on which buttons are present let navClass = 'quest-navigation quest-navigation-bottom'; const hasCompleteButton = isLastQuest; // Last quest always has complete button if (prevQuest && nextQuest) { // Both buttons present - use default space-between } else if (prevQuest && !nextQuest && !hasCompleteButton) { navClass += ' nav-prev-only'; } else if (!prevQuest && nextQuest) { navClass += ' nav-next-only'; } else if (!prevQuest && hasCompleteButton) { // Single quest with complete button only navClass += ' nav-next-only'; } // Note: when hasCompleteButton is true with prevQuest, we use default space-between for proper alignment bottomNavigation = ` <div class="${navClass}">`; if (prevQuest) { bottomNavigation += ` <a href="${prevQuest.filename}" class="prev-quest-btn">← Previous: Quest ${questIndex}</a>`; } if (nextQuest) { bottomNavigation += ` <a href="${nextQuest.filename}" class="next-quest-btn">Next: Quest ${questIndex + 2} →</a>`; } else if (isLastQuest) { // On the last quest, add a button to go to summary page bottomNavigation += ` <a href="summary.html" class="next-quest-btn complete-btn">Complete Adventure →</a>`; } bottomNavigation += ` </div> `; } const variables = { ...this.getCommonTemplateVariables(), PAGE_TITLE: this.stripHTML(this.formatInlineMarkdown(quest.title)), QUEST_CONTENT: this.formatMarkdown(content), BOTTOM_NAVIGATION: bottomNavigation }; return this.templateEngine.renderPage('quest-template.html', variables); } async buildSummaryHTML() { const lastQuest = this.quests[this.quests.length - 1]; const questCount = this.quests.length; // Get theme-specific data const themeData = this.getThemeData(); // Generate meaningful journey summary const journeySummary = this.generateJourneySummary(questCount, themeData); // Generate key concepts from quest content const keyConcepts = await this.generateKeyConcepts(); const variables = { ...this.getCommonTemplateVariables(), PAGE_TITLE: `${themeData.name} Adventure - Complete!`, JOURNEY_SUMMARY: journeySummary, QUEST_SUMMARY_LIST: keyConcepts, LAST_QUEST_FILENAME: lastQuest.filename, LAST_QUEST_TITLE: `Quest ${questCount}` }; return this.templateEngine.renderTemplate('summary-template.html', variables); } getThemeData() { return this.selectedTheme === 'developer' ? { name: 'Developer', emoji: 'šŸ’»', context: 'technical documentation and modern development practices', journey: 'development workflow' } : this.selectedTheme === 'space' ? { name: 'Space', emoji: 'šŸš€', context: 'cosmic starship operations and galactic exploration systems', journey: 'interstellar mission' } : this.selectedTheme === 'mythical' ? { name: 'Mythical', emoji: 'šŸ°', context: 'enchanted kingdoms and magical code artifacts', journey: 'mystical quest' } : this.selectedTheme === 'ancient' ? { name: 'Ancient', emoji: 'šŸ›ļø', context: 'archaeological discoveries and ancient coding wisdom', journey: 'archaeological expedition' } : { name: 'Adventure', emoji: 'āš”ļø', context: 'epic code exploration and discovery', journey: 'heroic adventure' }; } generateJourneySummary(questCount, themeData) { if (this.selectedTheme === 'developer') { return `You've analyzed the MCP (Model Context Protocol) architecture and learned how this repository powers AI-driven code exploration. You've examined server implementation, tool orchestration, and request handling patterns that enable dynamic storytelling from codebases.`; } else { return `You've journeyed through ${themeData.context} to uncover the secrets of the MCP architecture. Your ${themeData.journey} revealed the intricate systems that power AI-driven code exploration and transform repositories into interactive adventures.`; } } async generateKeyConcepts() { try { // Load adventure configuration to understand project structure const config = parseAdventureConfig(this.projectPath); if (!config || typeof config !== 'object' || !('adventure' in config)) { // Fallback to hardcoded concepts if no config available return this.generateFallbackKeyConcepts(); } const adventure = config.adventure; if (!adventure || !adventure.quests) { return this.generateFallbackKeyConcepts(); } // Extract quest titles and descriptions for analysis const questInfo = adventure.quests.map((quest) => ({ title: quest.title, description: quest.description, files: quest.files?.map((file) => ({ path: file.path, description: file.description })) || [] })); const projectName = adventure.name || 'Project'; const projectDescription = adventure.description || ''; // Create prompt for LLM to generate key concepts const prompt = `Based on the following project information, generate 4-5 key architectural or technical concepts that users would learn from exploring this codebase. Focus on the most important technical aspects and patterns. Project: ${projectName} Description: ${projectDescription} Quest Information: ${questInfo.map((quest) => `- ${quest.title}: ${quest.description}\n Files: ${quest.files.map((f) => `${f.path} (${f.description})`).join(', ')}`).join('\n')} Format your response as a JSON object with a "concepts" array: { "concepts": [ {"name": "Concept Name", "description": "Brief description of what was learned"}, {"name": "Another Concept", "description": "Another brief description"} ] } Focus on architectural patterns, technical systems, frameworks, and development practices actually present in the codebase.`; // Generate concepts using LLM const llmClient = new LLMClient(); const llmResponse = await llmClient.generateResponse(prompt, { responseFormat: 'json_object' }); const parsed = JSON.parse(llmResponse.content); const concepts = parsed.concepts; if (Array.isArray(concepts) && concepts.length > 0) { return `<ul>\n${concepts.map((concept) => `<li><strong>${concept.name}</strong>: ${concept.description}</li>`).join('\n')}\n</ul>`; } } catch (error) { console.log(chalk.yellow('āš ļø LLM concept generation failed, using fallback concepts')); console.log(chalk.dim(`Error: ${error}`)); } // Fallback to original hardcoded concepts return this.generateFallbackKeyConcepts(); } generateFallbackKeyConcepts() { const concepts = [ '<strong>MCP Server Architecture</strong>: Dynamic tool registration, schema validation, and request handling patterns', '<strong>Tool Orchestration</strong>: How individual tools are dynamically loaded, validated, and executed safely', '<strong>Error Handling & Reliability</strong>: Graceful shutdown procedures, signal handling, and promise rejection management', '<strong>Performance Optimization</strong>: Content pre-generation and caching strategies for responsive user experiences' ]; if (this.quests.length > 1) { concepts.push('<strong>Adventure Generation</strong>: Story creation, theme management, and quest progression systems'); } return `<ul>\n${concepts.map(concept => `<li>${concept}</li>`).join('\n')}\n</ul>`; } formatInlineMarkdown(text) { // This could use marked.parseInline() but keeping it simple for title formatting return text .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/`(.*?)`/g, '<code class="inline-code">$1</code>'); } formatMarkdown(content) { // Pure markdown to HTML conversion - no post-processing let htmlContent = marked(content); // Only add CSS class to inline code and hyperlinks - nothing else htmlContent = htmlContent .replace(/<code>/g, '<code class="inline-code">'); // Add CSS class to inline code // Highlight file path prefixes (e.g., "src/tools/tools.ts:") htmlContent = this.highlightFilePathPrefixes(htmlContent); // Add hyperlinks to file references if we have a repo URL if (this.repoUrl) { htmlContent = this.addFileHyperlinksToHTML(htmlContent); } return htmlContent; } /** * Highlights prefixes in headings that contain colons * Matches everything up to and including the first colon in h3-h6 headings only * Excludes h1 and h2 tags to avoid affecting quest titles */ highlightFilePathPrefixes(htmlContent) { // Pattern to match content before and including the first colon in h3-h6 headings only const headingColonPattern = /(<h[3-6][^>]*>)([^<]*?)(:)([^<]*?)(<\/h[3-6]>)/g; return htmlContent.replace(headingColonPattern, (_match, openTag, beforeColon, colon, afterColon, closeTag) => { return `${openTag}<span class="header-prefix">${beforeColon}${colon}</span>${afterColon}${closeTag}`; }); } stripHTML(html) { return html.replace(/<[^>]*>/g, ''); } /** * Converts file paths in HTML content to GitHub URLs * Handles file paths within code tags and plain text */ addFileHyperlinksToHTML(htmlContent) { if (!this.repoUrl) return htmlContent; // Pattern to match file paths: packages/core/src/file.ts, src/file.ts, ./packages/mcp/src/file.ts, etc. const filePathPattern = /\.?\/?(?:packages\/[\w-]+\/)?src\/[\w-/]+\.(ts|js|tsx|jsx|css|json|md)/; // Convert file paths in <code> tags to hyperlinks htmlContent = htmlContent.replace(/<code class="inline-code">([^<]*)<\/code>/g, (match, codeContent) => { const fileMatch = codeContent.match(filePathPattern); if (fileMatch) { const normalizedPath = fileMatch[0].replace(/^\.?\//, ''); const githubUrl = `${this.repoUrl}/blob/main/${normalizedPath}`; return `<a href="${githubUrl}" target="_blank" rel="noopener noreferrer"><code class="inline-code">${normalizedPath}</code></a>`; } return match; }); return htmlContent; } async generateAllThemes(args) { console.log(chalk.green('āœ… Generating all themes')); this.isMultiTheme = true; // Set output directory from args const outputArg = args.get('output'); this.outputDir = outputArg || './public'; console.log(chalk.green(`āœ… Output: ${this.outputDir}`)); // Handle overwrite setting const overwrite = args.has('overwrite'); if (overwrite) { console.log(chalk.green('āœ… Overwrite: enabled')); } // Handle max-quests setting const maxQuestsArg = args.get('max-quests'); const maxQuests = maxQuestsArg ? parseInt(maxQuestsArg, 10) : undefined; if (maxQuests !== undefined && (isNaN(maxQuests) || maxQuests < 0)) { throw new Error(`Invalid max-quests value: ${maxQuestsArg}. Must be a positive number.`); } if (maxQuests !== undefined) { console.log(chalk.green(`āœ… Max quests: ${maxQuests}`)); this.maxQuests = maxQuests; } // Handle log-llm-output setting this.logLlmOutput = args.has('log-llm-output'); if (this.logLlmOutput) { console.log(chalk.green('āœ… LLM output logging: enabled')); } // Handle serve setting this.serve = args.has('serve'); if (this.serve) { console.log(chalk.green('āœ… HTTP server: will start after generation')); } // Check if output directory exists and handle overwrite if (fs.existsSync(this.outputDir) && !overwrite) { const files = fs.readdirSync(this.outputDir).filter(f => f.endsWith('.html')); if (files.length > 0) { throw new Error(`Output directory ${this.outputDir} contains HTML files. Use --overwrite to replace them.`); } } // Create directories if they don't exist or if overwrite is enabled if (overwrite && fs.existsSync(this.outputDir)) { fs.rmSync(this.outputDir, { recursive: true, force: true }); } // Create root output directory fs.mkdirSync(this.outputDir, { recursive: true }); fs.mkdirSync(path.join(this.outputDir, 'assets'), { recursive: true }); fs.mkdirSync(path.join(this.outputDir, 'assets', 'images'), { recursive: true }); // Copy shared assets to avoid duplication across themes const __dirname = path.dirname(new URL(import.meta.url).pathname); const assetManager = new (await import('./asset-manager.js')).AssetManager(__dirname); assetManager.copySharedNavigator(this.outputDir); assetManager.copyGlobalAssets(this.outputDir); // Copy all images once to the root assets directory const sourceImagesDir = path.join(__dirname, 'assets', 'images'); const targetImagesDir = path.join(this.outputDir, 'assets', 'images'); if (fs.existsSync(sourceImagesDir)) { fs.mkdirSync(targetImagesDir, { recursive: true }); const imageFiles = fs.readdirSync(sourceImagesDir); console.log(chalk.green(`āœ… Copying ${imageFiles.length} images to shared assets directory`)); imageFiles.forEach(file => { const sourcePath = path.join(sourceImagesDir, file); const targetPath = path.join(targetImagesDir, file); fs.copyFileSync(sourcePath, targetPath); }); } // Generate each theme in its own subdirectory const themes = ['space', 'mythical', 'ancient', 'developer']; console.log(chalk.blue(`\nšŸŽÆ Starting parallel generation of ${themes.length} themes...`)); console.log(chalk.dim('Themes will be generated concurrently for faster completion')); // Track progress const progress = { completed: 0, total: themes.length, results: [] }; // Create all theme generation promises const themePromises = themes.map(async (theme, index) => { const startTime = Date.now(); console.log(chalk.yellow(`šŸš€ [${index + 1}/${themes.length}] Starting ${theme} theme generation...`)); // Create theme-specific directory const themeDir = path.join(this.outputDir, theme); fs.mkdirSync(themeDir, { recursive: true }); fs.mkdirSync(path.join(themeDir, 'assets'), { recursive: true }); // Images are now shared at root level, no need for theme-specific images directory // Create a new generator instance for this theme to avoid state conflicts const themeGenerator = new HTMLAdventureGenerator(); try { themeGenerator['selectedTheme'] = theme; themeGenerator['outputDir'] = themeDir; themeGenerator['maxQuests'] = this.maxQuests; themeGenerator['logLlmOutput'] = this.logLlmOutput; themeGenerator['isMultiTheme'] = this.isMultiTheme; await themeGenerator.generateAdventure(); const duration = ((Date.now() - startTime) / 1000).toFixed(1); progress.completed++; console.log(chalk.green(`āœ… [${progress.completed}/${progress.total}] ${theme} theme completed in ${duration}s`)); return { theme, success: true }; } catch (error) { const duration = ((Date.now() - startTime) / 1000).toFixed(1); progress.completed++; console.log(chalk.red(`āŒ [${progress.completed}/${progress.total}] ${theme} theme failed after ${duration}s:`, error instanceof Error ? error.message : error)); return { theme, success: false, error }; } finally { // Always close the readline interface to allow process to exit try { themeGenerator['rl'].close(); } catch (cleanupError) { // Ignore cleanup errors } } }); // Wait for all themes to complete console.log(chalk.blue('\nā³ Generating themes in parallel...')); const results = await Promise.allSettled(themePromises); // Process results and show summary const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; const failed = results.length - successful; console.log(chalk.blue('\nšŸ“Š Generation Summary:')); console.log(chalk.green(` āœ… Successful: ${successful}/${themes.length}`)); if (failed > 0) { console.log(chalk.red(` āŒ Failed: ${failed}/${themes.length}`)); } results.forEach((result, index) => { if (result.status === 'fulfilled') { const { theme, success } = result.value; if (success) { console.log(chalk.dim(` āœ“ ${theme}`)); } else { console.log(chalk.dim(` āœ— ${theme} - generation failed`)); } } else { console.log(chalk.dim(` āœ— ${themes[index]} - promise rejected`)); } }); // Generate homepage index.html console.log(chalk.yellow('\nšŸ  Generating homepage...')); this.generateHomepageIndex(); // Copy global assets this.copyGlobalAssets(); console.log(); console.log(chalk.green.bold('šŸŽ‰ All themes generated successfully!')); console.log(chalk.cyan(`šŸ“ Location: ${this.outputDir}`)); if (this.serve) { await this.startHttpServer(); } else { console.log(chalk.cyan(`🌐 Open: ${path.join(this.outputDir, 'index.html')}`)); } } generateHomepageIndex() { // Get repo URL from adventure.config.json co