@codewithdan/ai-repo-adventure-generator
Version:
Generate beautiful HTML adventure websites from your codebase
1,087 lines (1,083 loc) ⢠57.4 kB
JavaScript
#!/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