UNPKG

hackages

Version:

CLI tool for learning software development concepts through test-driven development

1,051 lines (1,002 loc) โ€ข 44 kB
import fs from "fs"; import path from "path"; import { printInfo, printError, printSuccess } from "../utils/console.js"; import chalk from "chalk"; import { execSync } from "child_process"; import open from "open"; import { getTechnologyConfigWithRepository } from "./tech.service.js"; export function createDirectories() { const testDir = path.join(process.cwd(), "tests"); const srcDir = path.join(process.cwd(), "src"); if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } if (!fs.existsSync(srcDir)) { fs.mkdirSync(srcDir, { recursive: true }); } } export function generateImplementationTemplate(tech, goal) { const templates = { javascript: `// ${goal} - Implementation // TODO: Implement the functions to make the tests pass // Read the instructions and test file to understand what functions you need to create // Your implementation goes here... `, typescript: `// ${goal} - Implementation // TODO: Implement the functions to make the tests pass // Read the instructions and test file to understand what functions you need to create // Your implementation goes here... export {}; `, python: `# ${goal} - Implementation # TODO: Implement the functions to make the tests pass # Read the instructions and test file to understand what functions you need to create # Your implementation goes here... `, java: `// ${goal} - Implementation // TODO: Implement the functions to make the tests pass // Read the instructions and test file to understand what functions you need to create public class Exercise { // Your implementation goes here... } `, }; return templates[tech] || templates.javascript; } export function createExerciseFiles(goal, exerciseContent, techConfig) { printInfo("๐Ÿ“ Creating project structure..."); createDirectories(); // Create test file const testFileName = `exercise${techConfig.extension}`; const testFilePath = path.join(process.cwd(), "tests", testFileName); printInfo(`๐Ÿ“ Creating test file: tests/${testFileName}`); console.log({ exerciseContent }); fs.writeFileSync(testFilePath, exerciseContent.testContent); // Create implementation file const srcFileName = `exercise${techConfig.srcExt}`; const srcFilePath = path.join(process.cwd(), "src", srcFileName); const srcContent = generateImplementationTemplate(techConfig.tech, goal); printInfo(`๐Ÿ’ป Creating implementation file: src/${srcFileName}`); fs.writeFileSync(srcFilePath, srcContent); return { testFilePath, srcFilePath, testFileName, srcFileName, }; } export function getImplementationFile() { // Check current directory first const currentSrcDir = path.join(process.cwd(), "src"); const possibleFiles = [ "exercise.js", "exercise.ts", "exercise.jsx", "exercise.tsx", "exercise-1.js", "exercise-1.ts", "exercise-1.jsx", "exercise-1.tsx", "exercise-2.js", "exercise-2.ts", "exercise-2.jsx", "exercise-2.tsx", "exercise-3.js", "exercise-3.ts", "exercise-3.jsx", "exercise-3.tsx", ]; // Check in current directory for (const file of possibleFiles) { const filePath = path.join(currentSrcDir, file); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, "utf-8"); return content; } } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirSrcPath = path.join(process.cwd(), subdir, "src"); if (fs.existsSync(subdirSrcPath)) { for (const file of possibleFiles) { const filePath = path.join(subdirSrcPath, file); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, "utf-8"); return content; } } } } return null; } /** * Gets all source files from the src directory for code review * @returns Object containing all source files with their content */ export function getAllSourceFiles() { // Check current directory first const currentSrcDir = path.join(process.cwd(), "src"); const sourceFiles = {}; if (fs.existsSync(currentSrcDir)) { try { const files = fs.readdirSync(currentSrcDir); const codeFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.tsx')); if (codeFiles.length > 0) { for (const file of codeFiles) { const filePath = path.join(currentSrcDir, file); const content = fs.readFileSync(filePath, "utf-8"); sourceFiles[file] = content; } return sourceFiles; } } catch (error) { console.error("Error reading src directory:", error); } } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirSrcPath = path.join(process.cwd(), subdir, "src"); if (fs.existsSync(subdirSrcPath)) { try { const files = fs.readdirSync(subdirSrcPath); const codeFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.tsx')); if (codeFiles.length > 0) { for (const file of codeFiles) { const filePath = path.join(subdirSrcPath, file); const content = fs.readFileSync(filePath, "utf-8"); sourceFiles[file] = content; } return sourceFiles; } } catch (error) { console.error(`Error reading src directory in ${subdir}:`, error); } } } return null; } export function exerciseFilesExist() { // Check current directory const currentSrcExists = fs.existsSync(path.join(process.cwd(), "src", "exercise.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.ts")); const currentTestExists = fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.ts")); if (currentSrcExists && currentTestExists) { return true; } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirPath = path.join(process.cwd(), subdir); const subdirSrcPath = path.join(subdirPath, "src"); const subdirTestPath = path.join(subdirPath, "tests"); const subdirSrcExists = fs.existsSync(path.join(subdirSrcPath, "exercise.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.ts")); const subdirTestExists = fs.existsSync(path.join(subdirTestPath, "exercise.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.ts")); if (subdirSrcExists && subdirTestExists) { return true; } } return false; } /** * Elegantly displays feedback in the terminal, supporting markdown-like headers, code blocks, and lists. * @param feedback The feedback string to display */ export function displayFeedback(feedback) { // Split into lines for processing const lines = feedback.split(/\r?\n/); let inCodeBlock = false; let codeBlockBuffer = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Handle code blocks if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock; if (!inCodeBlock) { // Print code block console.log(chalk.bgBlackBright.whiteBright(codeBlockBuffer.join('\n'))); codeBlockBuffer = []; } continue; } if (inCodeBlock) { codeBlockBuffer.push(line); continue; } // Headers if (/^#{1,6} /.test(line)) { const headerLevel = line?.match(/^#+/)?.[0]?.length || 0; const headerText = line.replace(/^#+ /, ''); let styled; if (headerLevel === 1) styled = chalk.bold.bgBlue.white(`\n${headerText}\n`); else if (headerLevel === 2) styled = chalk.bold.blue(`\n${headerText}\n`); else if (headerLevel === 3) styled = chalk.bold.cyan(headerText); else styled = chalk.cyan(headerText); console.log(styled); continue; } // Lists if (/^\s*[-*+] /.test(line)) { const item = line.replace(/^\s*[-*+] /, 'โ€ข '); console.log(chalk.yellow(item)); continue; } if (/^\s*\d+\. /.test(line)) { // Numbered list const item = line.replace(/^\s*(\d+\.) /, (m, n) => chalk.magenta(n + ' ')); console.log(item); continue; } // Blockquotes if (/^> /.test(line)) { const quote = line.replace(/^> /, ''); console.log(chalk.gray.italic('โ ' + quote)); continue; } // Emphasis let formatted = line .replace(/\*\*(.*?)\*\*/g, (m, p1) => chalk.bold(p1)) .replace(/\*(.*?)\*/g, (m, p1) => chalk.italic(p1)) .replace(/`([^`]+)`/g, (m, p1) => chalk.bgBlack.whiteBright(p1)); // Emojis and special icons formatted = formatted .replace(/๐ŸŽฏ/g, chalk.green('๐ŸŽฏ')) .replace(/โœ…/g, chalk.green('โœ…')) .replace(/โŒ/g, chalk.red('โŒ')) .replace(/๐Ÿš€/g, chalk.yellow('๐Ÿš€')) .replace(/๐Ÿ’ก/g, chalk.cyan('๐Ÿ’ก')) .replace(/๐Ÿ”ฅ/g, chalk.red('๐Ÿ”ฅ')) .replace(/๐Ÿ“/g, chalk.blue('๐Ÿ“')) .replace(/๐Ÿ“/g, chalk.blue('๐Ÿ“')) .replace(/\n{2,}/g, '\n'); // Print non-empty lines, add spacing for sections if (formatted.trim() === '' && (i === 0 || lines[i - 1].trim() === '')) { continue; } if (formatted.trim() === '') { console.log(''); } else { console.log(formatted); } } } /** * Saves feedback to a markdown file in the user's ~/.hackages directory. * The file is unique per learning session (by identifier). * Code blocks in the feedback are tagged with the technology for syntax highlighting. * * @param feedback The feedback string to save * @param technology The technology name (e.g., 'typescript', 'javascript') for code block highlighting * @param identifier A unique identifier for the learning session (e.g., exercise name, timestamp, or hash) * @returns The path to the saved markdown file */ export function saveFeedbackToMarkdown(feedback, technology, identifier) { // Get home directory cross-platform const homeDir = process.env.HOME || process.env.USERPROFILE || ""; const hackagesDir = path.join(homeDir, ".hackages"); if (!fs.existsSync(hackagesDir)) { fs.mkdirSync(hackagesDir, { recursive: true }); } // Prepare filename (safe, unique) const safeId = identifier.replace(/[^a-zA-Z0-9_-]/g, "_"); const fileName = `feedback_${safeId}.md`; const filePath = path.join(hackagesDir, fileName); // Enhance code blocks with technology for syntax highlighting let inCodeBlock = false; const lines = feedback.split(/\r?\n/); const processedLines = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith('```')) { if (!inCodeBlock) { processedLines.push(`\`\`\`${technology}`); // Open code block with language inCodeBlock = true; } else { processedLines.push('```'); // Close code block inCodeBlock = false; } continue; } processedLines.push(line); } // Write to file fs.writeFileSync(filePath, processedLines.join('\n'), { encoding: 'utf8' }); return filePath; } /** * Detects if the user prefers SSH or HTTPS for Git operations * @returns 'ssh' or 'https' based on user's git configuration */ function detectGitProtocol() { try { // Check if user has SSH keys configured const sshKeyCheck = execSync('ssh-add -l', { stdio: 'pipe' }).toString(); if (sshKeyCheck.includes('no identities') || sshKeyCheck.includes('The agent has no identities')) { return 'https'; } // Check git remote origin to see what protocol is being used try { const remoteOrigin = execSync('git remote get-url origin', { stdio: 'pipe' }).toString().trim(); if (remoteOrigin.startsWith('git@')) { return 'ssh'; } else if (remoteOrigin.startsWith('https://')) { return 'https'; } } catch { // No git repo or no origin, default to HTTPS } return 'https'; } catch { // If SSH check fails, default to HTTPS return 'https'; } } /** * Clones a repository template for the given technology * @param techConfig Technology configuration with repository URLs * @param exerciseName Name for the exercise directory * @returns Path to the cloned repository */ export function cloneRepositoryTemplate(techConfig, exerciseName) { if (!techConfig.repository) { throw new Error(`No repository template available for technology: ${techConfig.tech}`); } const protocol = detectGitProtocol(); const repoUrl = protocol === 'ssh' ? techConfig.repository.ssh : techConfig.repository.https; // Create a safe directory name const safeExerciseName = exerciseName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); const targetDir = path.join(process.cwd(), safeExerciseName); // Remove existing directory if it exists if (fs.existsSync(targetDir)) { fs.rmSync(targetDir, { recursive: true, force: true }); } printInfo(`๐Ÿ“ฅ Cloning ${techConfig.tech} template repository...`); try { execSync(`git clone ${repoUrl} "${targetDir}"`, { stdio: 'pipe', cwd: process.cwd() }); // Remove .git directory to make it a fresh start const gitDir = path.join(targetDir, '.git'); if (fs.existsSync(gitDir)) { fs.rmSync(gitDir, { recursive: true, force: true }); } printSuccess(`โœ… Repository template cloned to: ${safeExerciseName}`); return targetDir; } catch (error) { printError(`โŒ Failed to clone repository: ${error}`); throw error; } } /** * Generates instructions content for the exercise * @param goal Learning goal * @param exerciseContent Generated exercise content * @param techConfig Technology configuration * @returns Instructions content in MDX format */ function generateInstructionsContent(goal, exerciseContent, techConfig) { return `# ${goal} ## ๐ŸŽฏ Learning Objective In this exercise, you'll learn about **${goal.toLowerCase()}** using ${techConfig.tech}. ## ๐Ÿ“‹ Exercise Overview This exercise focuses on implementing functions that will make the tests pass. The tests are already written for you - your job is to understand what the tests expect and implement the functions accordingly. ## ๐Ÿงช Test Cases The test file contains the following test cases: \`\`\`${techConfig.tech.toLowerCase()} ${exerciseContent.testContent} \`\`\` ## ๐Ÿ’ป Implementation You need to implement the functions in \`src/exercise${techConfig.srcExt}\`. The file is already created with a basic template. ## ๐Ÿš€ Getting Started 1. **Read the instructions and tests first** - They tell you exactly what your functions should do 2. **Understand the requirements** - Each test case describes the expected behavior 3. **Implement step by step** - Start with the simplest function and work your way up 4. **Run tests frequently** - Use \`npm test\` to check your progress ## ๐Ÿ’ก Tips - Focus on making one test pass at a time - Don't worry about perfect code initially - get it working first - Read the error messages carefully - they often give helpful hints - If you're stuck, try to understand what the test is actually testing ## ๐ŸŽ‰ Success Criteria You'll know you've completed the exercise when all tests pass with \`npm test\`. Good luck! ๐Ÿš€ `; } /** * Creates exercise files in a cloned repository template * @param goal Learning goal * @param exerciseContent Generated exercise content * @param techConfig Technology configuration * @param repoPath Path to the cloned repository * @returns Exercise files information */ export function createExerciseFilesInRepository(goal, exerciseContent, techConfig, repoPath) { printInfo("๐Ÿ“ Setting up exercise files in repository..."); // Use correct file extensions based on technology const testFileExt = techConfig.extension.replace('.spec', ''); const srcFileExt = techConfig.srcExt; // Write to existing test file (exercise-1.spec.js or exercise-1.spec.ts) const testFilePath = path.join(repoPath, "tests", `exercise-1.spec${testFileExt}`); if (!fs.existsSync(testFilePath)) { printError(`โŒ Test file not found: ${testFilePath}`); throw new Error(`Test file not found in template: ${testFilePath}`); } printInfo(`๐Ÿ“ Writing test content to: tests/exercise-1.spec${testFileExt}`); fs.writeFileSync(testFilePath, exerciseContent.testContent); // Write to existing implementation file (exercise-1.js or exercise-1.ts) const srcFilePath = path.join(repoPath, "src", `exercise-1${srcFileExt}`); if (!fs.existsSync(srcFilePath)) { printError(`โŒ Implementation file not found: ${srcFilePath}`); throw new Error(`Implementation file not found in template: ${srcFilePath}`); } const srcContent = generateImplementationTemplate(techConfig.tech, goal); printInfo(`๐Ÿ’ป Writing implementation template to: src/exercise-1${srcFileExt}`); fs.writeFileSync(srcFilePath, srcContent); // Write instructions to existing instructions file const instructionsDir = path.join(repoPath, "instructions"); const instructionsFilePath = path.join(instructionsDir, "exercise-1.mdx"); if (!fs.existsSync(instructionsFilePath)) { printError(`โŒ Instructions file not found: ${instructionsFilePath}`); throw new Error(`Instructions file not found in template: ${instructionsFilePath}`); } const instructionsContent = generateInstructionsContent(goal, exerciseContent, techConfig); printInfo(`๐Ÿ“– Writing instructions to: instructions/exercise-1.mdx`); fs.writeFileSync(instructionsFilePath, instructionsContent); return { testFilePath, srcFilePath, testFileName: `exercise-1.spec${testFileExt}`, srcFileName: `exercise-1${srcFileExt}`, }; } function getCurrentLearningGoal() { const learningJson = fs.readFileSync(path.join(process.env.HOME || process.env.USERPROFILE || "", ".hackages", "learning.json"), "utf8"); const learning = JSON.parse(learningJson); return learning.current; } export function getLearningInformation() { const currentLearningGoal = getCurrentLearningGoal(); const learningJson = fs.readFileSync(path.join(process.env.HOME || process.env.USERPROFILE || "", ".hackages", "learning.json"), "utf8"); const learning = JSON.parse(learningJson); return learning[currentLearningGoal]; } /** * Find the directory containing exercise files (either current directory or a subdirectory) * @returns Path to the directory containing exercise files, or current directory if not found */ export function findExerciseDirectory() { // Check current directory first const currentSrcExists = fs.existsSync(path.join(process.cwd(), "src", "exercise.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.ts")); const currentTestExists = fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.ts")); if (currentSrcExists && currentTestExists) { return process.cwd(); } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirPath = path.join(process.cwd(), subdir); const subdirSrcPath = path.join(subdirPath, "src"); const subdirTestPath = path.join(subdirPath, "tests"); const subdirSrcExists = fs.existsSync(path.join(subdirSrcPath, "exercise.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.ts")); const subdirTestExists = fs.existsSync(path.join(subdirTestPath, "exercise.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.ts")); if (subdirSrcExists && subdirTestExists) { return subdirPath; } } return process.cwd(); } /** * Saves feedback to the template folder's feedback directory * @param feedback The feedback string to save * @param exerciseNumber The exercise number (default: 1) * @returns Path to the saved feedback file */ export function saveFeedbackToTemplate(feedback, exerciseNumber = 1, selectedTech) { const exerciseDir = findExerciseDirectory(); if (exerciseDir === process.cwd()) { // Not in a cloned repository, save to .hackages instead return saveFeedbackToMarkdown(feedback, selectedTech, `exercise-${exerciseNumber}`); } const feedbackDir = path.join(exerciseDir, "feedback"); // Create feedback directory if it doesn't exist if (!fs.existsSync(feedbackDir)) { fs.mkdirSync(feedbackDir, { recursive: true }); } const feedbackFilePath = path.join(feedbackDir, `exercise-${exerciseNumber}.mdx`); // Enhance code blocks with TypeScript for syntax highlighting let inCodeBlock = false; const lines = feedback.split(/\r?\n/); const processedLines = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith('```')) { if (!inCodeBlock) { // TODO: get the correct language from the feedback processedLines.push(`\`\`\`${selectedTech.toLowerCase()}`); // Open code block with language inCodeBlock = true; } else { processedLines.push('```'); // Close code block inCodeBlock = false; } continue; } processedLines.push(line); } // Write to file fs.writeFileSync(feedbackFilePath, processedLines.join('\n'), { encoding: 'utf8' }); printInfo(`๐Ÿ“ Feedback saved to: feedback/exercise-${exerciseNumber}.mdx`); return feedbackFilePath; } /** * Generates a beautiful HTML page for displaying feedback * @param feedback The feedback content * @param exerciseNumber The exercise number (default: 1) * @returns Path to the generated HTML file */ export function generateFeedbackHTML(feedback, exerciseNumber = 1) { const exerciseDir = findExerciseDirectory(); const htmlFilePath = path.join(exerciseDir, `feedback-exercise-${exerciseNumber}.html`); // Process feedback for HTML display const processedFeedback = feedback .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/`([^`]+)`/g, '<code>$1</code>') .replace(/\n/g, '<br>') .replace(/```typescript\n([\s\S]*?)```/g, '<pre><code class="language-typescript">$1</code></pre>') .replace(/```javascript\n([\s\S]*?)```/g, '<pre><code class="language-javascript">$1</code></pre>') .replace(/```\n([\s\S]*?)```/g, '<pre><code>$1</code></pre>'); const htmlContent = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Code Review - Exercise ${exerciseNumber}</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #333; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .container { max-width: 900px; margin: 0 auto; padding: 2rem; } .header { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 20px; padding: 2rem; margin-bottom: 2rem; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); text-align: center; } .header h1 { color: #2d3748; font-size: 2.5rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .header p { color: #718096; font-size: 1.1rem; } .content { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 20px; padding: 2rem; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); } .feedback-section { margin-bottom: 2rem; } .feedback-section h2 { color: #2d3748; font-size: 1.5rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 2px solid #e2e8f0; } .feedback-section h3 { color: #4a5568; font-size: 1.2rem; margin: 1.5rem 0 0.5rem 0; } .feedback-content { color: #4a5568; font-size: 1rem; line-height: 1.7; } .feedback-content p { margin-bottom: 1rem; } .feedback-content ul, .feedback-content ol { margin: 1rem 0; padding-left: 2rem; } .feedback-content li { margin-bottom: 0.5rem; } .code-block { background: #2d3748; border-radius: 10px; padding: 1rem; margin: 1rem 0; overflow-x: auto; } .code-block pre { margin: 0; color: #e2e8f0; } .code-block code { font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; font-size: 0.9rem; } .highlight { background: linear-gradient(120deg, #a8edea 0%, #fed6e3 100%); padding: 0.2rem 0.4rem; border-radius: 4px; } .emoji { font-size: 1.2em; margin-right: 0.5rem; } .footer { text-align: center; margin-top: 2rem; color: rgba(255, 255, 255, 0.8); font-size: 0.9rem; } @media (max-width: 768px) { .container { padding: 1rem; } .header h1 { font-size: 2rem; } .content { padding: 1.5rem; } } </style> </head> <body> <div class="container"> <div class="header"> <h1><span class="emoji">๐ŸŽฏ</span>Code Review</h1> <p>Exercise ${exerciseNumber} - AI-Powered Feedback</p> </div> <div class="content"> <div class="feedback-section"> <h2><span class="emoji">๐Ÿ“‹</span>Feedback Summary</h2> <div class="feedback-content"> ${processedFeedback} </div> </div> </div> </div> <div class="footer"> <p>Generated by Hackages CLI โ€ข ${new Date().toLocaleDateString()}</p> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script> </body> </html>`; fs.writeFileSync(htmlFilePath, htmlContent, { encoding: 'utf8' }); printInfo(`๐ŸŒ HTML feedback page generated: feedback-exercise-${exerciseNumber}.html`); return htmlFilePath; } /** * Opens the feedback HTML page in the default browser * @param htmlFilePath Path to the HTML file */ export function openFeedbackHTML(htmlFilePath) { try { open(htmlFilePath); printInfo(`๐ŸŒ Opening feedback page in browser...`); } catch (error) { printError(`โŒ Failed to open HTML file: ${error}`); printInfo(`๐Ÿ’ก You can manually open: ${htmlFilePath}`); } } /** * Creates the next exercise in the same repository * @param learningGoal The learning goal for the next exercise * @param exerciseNumber The exercise number (e.g., 2 for exercise-2) */ export async function createNextExercise(learningGoal, exerciseNumber) { const exerciseDir = findExerciseDirectory(); // Check if we're in an exercise directory (either current directory or subdirectory) const isInExerciseDir = exerciseDir !== process.cwd() || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.js")); if (!isInExerciseDir) { printError(`โŒ Current directory: ${process.cwd()}`); printError(`โŒ Exercise directory found: ${exerciseDir}`); printError(`โŒ Looking for exercise files in: ${path.join(process.cwd(), "src")}`); throw new Error("No exercise directory found. Please make sure you're in an exercise directory with exercise files (src/exercise-1.ts, tests/exercise-1.spec.ts)."); } printInfo(`๐Ÿ“ Creating next exercise in: ${exerciseDir}`); // Get technology configuration const techConfig = getTechnologyConfigWithRepository(learningGoal.selectedTech.toLowerCase()); // Generate exercise content with Claude const { generateLearningExercise } = await import("./claude.service.js"); const exerciseContent = await generateLearningExercise(learningGoal); // Create test file const testFilePath = path.join(exerciseDir, "tests", `exercise-${exerciseNumber}.spec.ts`); printInfo(`๐Ÿ“ Creating test file: tests/exercise-${exerciseNumber}.spec.ts`); fs.writeFileSync(testFilePath, exerciseContent.testContent); // Create implementation file const srcFilePath = path.join(exerciseDir, "src", `exercise-${exerciseNumber}.ts`); const srcContent = generateImplementationTemplate(techConfig.tech, learningGoal.topic); printInfo(`๐Ÿ’ป Creating implementation file: src/exercise-${exerciseNumber}.ts`); fs.writeFileSync(srcFilePath, srcContent); // Create instructions file const instructionsDir = path.join(exerciseDir, "instructions"); const instructionsFilePath = path.join(instructionsDir, `exercise-${exerciseNumber}.mdx`); if (!fs.existsSync(instructionsDir)) { fs.mkdirSync(instructionsDir, { recursive: true }); } const instructionsContent = generateInstructionsContent(learningGoal.topic, exerciseContent, techConfig); printInfo(`๐Ÿ“– Creating instructions: instructions/exercise-${exerciseNumber}.mdx`); fs.writeFileSync(instructionsFilePath, instructionsContent); } /** * Checks if there's previous feedback available for the current exercise * @returns Array of learning suggestions if feedback exists, null otherwise */ export function getPreviousLearningSuggestions() { const exerciseDir = findExerciseDirectory(); if (exerciseDir === process.cwd()) { return null; } // Check for feedback files in the exercise directory const feedbackDir = path.join(exerciseDir, "feedback"); if (!fs.existsSync(feedbackDir)) { return null; } // Look for feedback files (exercise-1.mdx, exercise-2.mdx, etc.) const feedbackFiles = fs.readdirSync(feedbackDir) .filter(file => file.startsWith('exercise-') && file.endsWith('.mdx')) .sort((a, b) => { const numA = parseInt(a.match(/exercise-(\d+)/)?.[1] || '0'); const numB = parseInt(b.match(/exercise-(\d+)/)?.[1] || '0'); return numB - numA; // Sort by exercise number, newest first }); if (feedbackFiles.length === 0) { return null; } // Read the most recent feedback file const latestFeedbackFile = feedbackFiles[0]; const feedbackPath = path.join(feedbackDir, latestFeedbackFile); try { const feedbackContent = fs.readFileSync(feedbackPath, 'utf8'); return extractLearningSuggestions(feedbackContent); } catch (error) { printError(`โŒ Error reading feedback file: ${error}`); return null; } } /** * Extracts learning suggestions from feedback text * @param feedback The feedback string * @returns Array of learning suggestions */ function extractLearningSuggestions(feedback) { const suggestions = []; // Find the "Next Learning Steps" section const nextLearningStepsMatch = feedback.match(/### Next Learning Steps\s*\n([\s\S]*?)(?=\n### |$)/); if (nextLearningStepsMatch && nextLearningStepsMatch[1]) { const nextLearningSection = nextLearningStepsMatch[1]; // Look for numbered list items with bold titles in this section only const numberedListPattern = /^\d+\.\s+\*\*([^*]+)\*\*:/gm; const matches = nextLearningSection.match(numberedListPattern); if (matches) { matches.forEach(match => { // Extract the title from the bold text const titleMatch = match.match(/\*\*([^*]+)\*\*:/); if (titleMatch && titleMatch[1]) { const title = titleMatch[1].trim(); if (title && !suggestions.includes(title)) { suggestions.push(title); } } }); } } // If no suggestions found, provide default ones if (suggestions.length === 0) { return [ "Explore Different Number Types", "Learn About Function Overloading", "Error Handling", "Generic Functions", "Advanced TypeScript Features" ]; } return suggestions.slice(0, 3); // Limit to 3 suggestions for menu } /** * Handles the next learning flow with prompts and exercise generation * @param suggestions Array of learning suggestions */ export async function handleNextLearning(suggestions) { const { getLearningInformation } = await import("./file-manager.js"); const { createNextExercise } = await import("./file-manager.js"); const prompts = await import("prompts"); const chalk = await import("chalk"); console.log("\n" + chalk.default.cyan("=".repeat(50))); console.log(chalk.default.bold.yellow("๐Ÿš€ Continue Your Learning Journey")); console.log(chalk.default.cyan("=".repeat(50))); console.log(chalk.default.gray("Choose your next learning step:\n")); const choices = [ ...suggestions.map((suggestion, index) => ({ title: `${index + 1}. ${suggestion}`, value: suggestion })), { title: `${suggestions.length + 1}. Write your next learning goal`, value: "custom" } ]; const response = await prompts.default({ type: "select", name: "nextStep", message: "Select your next learning step:", choices, initial: 0, }); if (!response.nextStep) { console.log(chalk.default.gray("\n๐Ÿ‘‹ Happy learning! Come back when you're ready for more.")); return; } let nextLearningGoal; if (response.nextStep === "custom") { const customResponse = await prompts.default({ type: "text", name: "customGoal", message: "Enter your next learning goal:", validate: (value) => value.length > 0 ? true : "Please enter a learning goal", }); if (!customResponse.customGoal) { console.log(chalk.default.gray("\n๐Ÿ‘‹ Happy learning! Come back when you're ready for more.")); return; } nextLearningGoal = customResponse.customGoal; } else { nextLearningGoal = response.nextStep; } console.log("\n" + chalk.default.yellow("๐Ÿ”„ Generating your next exercise...")); try { // Get current learning information const learningInformation = getLearningInformation(); // Create new learning goal for the next exercise const nextGoal = { ...learningInformation, id: learningInformation.id + "-next", topic: nextLearningGoal, }; // Find the next exercise number const exerciseDir = findExerciseDirectory(); const existingExercises = fs.readdirSync(path.join(exerciseDir, "src")) .filter(file => file.startsWith('exercise-') && file.endsWith('.ts')) .map(file => parseInt(file.match(/exercise-(\d+)/)?.[1] || '0')) .sort((a, b) => b - a); const nextExerciseNumber = existingExercises.length > 0 ? Math.max(...existingExercises) + 1 : 2; // Generate next exercise await createNextExercise(nextGoal, nextExerciseNumber); console.log("\n" + chalk.default.bold.green("๐ŸŽ‰ Your next exercise is ready!")); console.log(chalk.default.cyan("=".repeat(50))); console.log(chalk.default.bold("๐Ÿ“ Next exercise created:")); console.log(chalk.default.green(` โœ“ tests/exercise-${nextExerciseNumber}.spec.ts`), chalk.default.dim("(Your test cases)")); console.log(chalk.default.green(` โœ“ src/exercise-${nextExerciseNumber}.ts`), chalk.default.dim("(Your implementation file)")); console.log(chalk.default.green(` โœ“ instructions/exercise-${nextExerciseNumber}.mdx`), chalk.default.dim("(Exercise instructions)")); console.log("\n" + chalk.default.bold.yellow("๐Ÿš€ Next steps:")); console.log(chalk.default.white("1. Read the instructions and test file to understand what to implement")); console.log(chalk.default.white(`2. Open src/exercise-${nextExerciseNumber}.ts and start coding`)); console.log(chalk.default.white("3. Run 'npx hackages test' to validate your implementation")); console.log(chalk.default.white("4. Run 'npx hackages review' to get AI feedback on your code")); } catch (error) { console.log(chalk.default.red(`โŒ Error generating next exercise: ${error}`)); } }