hackages
Version:
CLI tool for learning software development concepts through test-driven development
1,051 lines (1,002 loc) โข 44 kB
JavaScript
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}`));
}
}