diff --git a/apps/api/prisma/migrations/20251215163900_add_tutorial_model/migration.sql b/apps/api/prisma/migrations/20251215163900_add_tutorial_model/migration.sql new file mode 100644 index 00000000..c78a107a --- /dev/null +++ b/apps/api/prisma/migrations/20251215163900_add_tutorial_model/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "Tutorial" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "repoUrl" TEXT NOT NULL, + "projectName" TEXT NOT NULL, + "language" TEXT NOT NULL DEFAULT 'english', + "indexContent" TEXT NOT NULL, + "mermaidDiagram" TEXT NOT NULL, + "chapters" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Tutorial_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Tutorial_userId_idx" ON "Tutorial"("userId"); + +-- CreateIndex +CREATE INDEX "Tutorial_repoUrl_idx" ON "Tutorial"("repoUrl"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2bf9695f..59735341 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -102,3 +102,19 @@ model Plan { updatedAt DateTime @updatedAt subscriptions Subscription[] } + +model Tutorial { + id String @id @default(cuid()) + userId String + repoUrl String + projectName String + language String @default("english") + indexContent String @db.Text + mermaidDiagram String @db.Text + chapters Json // Array of {filename: string, content: string} + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([repoUrl]) +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f1925322..91856fcb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -62,8 +62,12 @@ const apiLimiter = rateLimit({ // Request size limits (except for webhook - needs raw body) app.use("/webhook/razorpay", express.raw({ type: "application/json" })); -app.use(express.json({ limit: "10kb" })); -app.use(express.urlencoded({ limit: "10kb", extended: true })); +// Higher limit for tRPC (tutorial generation sends file content up to 3.0MB) +app.use("/trpc", express.json({ limit: "5mb" })); +app.use("/trpc", express.urlencoded({ limit: "5mb", extended: true })); +// Default limit for other endpoints +app.use(express.json({ limit: "100kb" })); +app.use(express.urlencoded({ limit: "100kb", extended: true })); // CORS configuration const corsOptions: CorsOptionsType = { diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index 782b4361..d2d53047 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -4,6 +4,7 @@ import { userRouter } from "./user.js"; import { projectRouter } from "./projects.js"; import { authRouter } from "./auth.js"; import { paymentRouter } from "./payment.js"; +import { tutorialRouter } from "./tutorial.js"; import { z } from "zod"; const testRouter = router({ @@ -21,6 +22,7 @@ export const appRouter = router({ project: projectRouter, auth: authRouter, payment: paymentRouter, + tutorial: tutorialRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/api/src/routers/tutorial.ts b/apps/api/src/routers/tutorial.ts new file mode 100644 index 00000000..37284346 --- /dev/null +++ b/apps/api/src/routers/tutorial.ts @@ -0,0 +1,183 @@ +import { router, publicProcedure } from "../trpc.js"; +import { z } from "zod"; +import { tutorialService, type GenerateTutorialOptions } from "../services/tutorial.service.js"; +import { listRepoFiles } from "../services/github-crawler.service.js"; +import prismaModule from "../prisma.js"; + +const { prisma } = prismaModule; + +const generateTutorialInputSchema = z.object({ + repoUrl: z.string().url().refine( + (url) => url.includes("github.com"), + { message: "Must be a valid GitHub repository URL" } + ), + language: z.string().optional().default("english"), + maxAbstractions: z.number().min(3).max(15).optional().default(8), + maxFiles: z.number().min(5).max(100).optional().default(30), + selectedFiles: z.array(z.string()).optional(), // Optional: specific file paths +}); + +export const tutorialRouter = router({ + /** + * List files in a repository for the file browser + */ + listRepoFiles: publicProcedure + .input(z.object({ + repoUrl: z.string().url().refine( + (url) => url.includes("github.com"), + { message: "Must be a valid GitHub repository URL" } + ), + })) + .query(async ({ input }) => { + const result = await listRepoFiles(input.repoUrl); + return result; + }), + + /** + * Check if a tutorial already exists for a repo URL (shows all public tutorials) + */ + checkExisting: publicProcedure + .input(z.object({ repoUrl: z.string() })) + .query(async ({ input, ctx }) => { + // @ts-ignore + const currentUserId = ctx.user?.id || "anonymous"; + + const tutorials = await prisma.tutorial.findMany({ + where: { + repoUrl: input.repoUrl, + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + projectName: true, + language: true, + createdAt: true, + userId: true, + }, + }); + + return { + exists: tutorials.length > 0, + tutorials: tutorials.map(t => ({ + ...t, + isOwnTutorial: t.userId === currentUserId, + })), + }; + }), + + /** + * Get a specific tutorial by ID + */ + getById: publicProcedure + .input(z.object({ id: z.string() })) + .query(async ({ input }) => { + const tutorial = await prisma.tutorial.findFirst({ + where: { + id: input.id, + }, + }); + + if (!tutorial) { + throw new Error("Tutorial not found"); + } + + return tutorial; + }), + + /** + * Get all tutorials for the current user + */ + getUserTutorials: publicProcedure.query(async ({ ctx }) => { + // @ts-ignore + const userId = ctx.user?.id || "anonymous"; + + const tutorials = await prisma.tutorial.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + projectName: true, + repoUrl: true, + language: true, + createdAt: true, + }, + }); + + return tutorials; + }), + + /** + * Generate a tutorial from a GitHub repository and save to DB + */ + generate: publicProcedure + .input(generateTutorialInputSchema) + .mutation(async ({ input, ctx }) => { + // @ts-ignore + const userId = ctx.user?.id || "anonymous"; + + console.log(`Generating tutorial for: ${input.repoUrl}`); + + const options: GenerateTutorialOptions = { + repoUrl: input.repoUrl, + language: input.language, + maxAbstractions: input.maxAbstractions, + maxFiles: input.maxFiles, + selectedFiles: input.selectedFiles, + }; + + const result = await tutorialService.generateTutorial(options); + + // Save to database + const tutorial = await prisma.tutorial.create({ + data: { + userId, + repoUrl: input.repoUrl, + projectName: result.projectName, + language: input.language || "english", + indexContent: result.indexContent, + mermaidDiagram: result.mermaidDiagram, + chapters: result.chapters, + }, + }); + + return { + success: true, + id: tutorial.id, + projectName: result.projectName, + indexContent: result.indexContent, + chapters: result.chapters, + mermaidDiagram: result.mermaidDiagram, + createdAt: tutorial.createdAt, + }; + }), + + /** + * Delete a tutorial + */ + delete: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + // @ts-ignore + const userId = ctx.user?.id || "anonymous"; + + await prisma.tutorial.deleteMany({ + where: { + id: input.id, + userId: userId, + }, + }); + + return { success: true }; + }), + + /** + * Health check for tutorial service + */ + healthCheck: publicProcedure.query(() => { + return { + status: "ok", + service: "tutorial", + timestamp: new Date().toISOString(), + }; + }), +}); diff --git a/apps/api/src/services/github-crawler.service.ts b/apps/api/src/services/github-crawler.service.ts new file mode 100644 index 00000000..f637d86e --- /dev/null +++ b/apps/api/src/services/github-crawler.service.ts @@ -0,0 +1,421 @@ +import { graphql } from "@octokit/graphql"; +import dotenv from "dotenv"; + +dotenv.config(); + +const GH_PAT = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + +// File patterns to include by default +const DEFAULT_INCLUDE_PATTERNS = new Set([ + "*.py", "*.js", "*.jsx", "*.ts", "*.tsx", "*.go", "*.java", + "*.c", "*.cpp", "*.h", "*.md", "*.rst", "*.yaml", "*.yml" +]); + +// Patterns to exclude by default +const DEFAULT_EXCLUDE_PATTERNS = new Set([ + "node_modules/*", "*test*", "*tests/*", "*__tests__/*", + "dist/*", "build/*", ".git/*", ".github/*", ".vscode/*", + "*.min.js", "*.min.css", "package-lock.json", "pnpm-lock.yaml", + "yarn.lock", "*.log", "coverage/*", ".next/*" +]); + +interface CrawlOptions { + includePatterns?: Set; + excludePatterns?: Set; + maxFileSize?: number; + maxFiles?: number; +} + +interface CrawlResult { + files: Array<{ path: string; content: string }>; + stats: { + totalFiles: number; + skippedFiles: number; + repoName: string; + owner: string; + }; +} + +/** + * Check if a filename matches a pattern (simple glob matching) + */ +function matchesPattern(filename: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, "\\.") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${regexPattern}$`).test(filename); +} + +/** + * Check if file should be included based on patterns + */ +function shouldIncludeFile( + filePath: string, + fileName: string, + includePatterns: Set, + excludePatterns: Set +): boolean { + // Check include patterns + const includeFile = Array.from(includePatterns).some(pattern => + matchesPattern(fileName, pattern) || matchesPattern(filePath, pattern) + ); + + if (!includeFile) return false; + + // Check exclude patterns + const excludeFile = Array.from(excludePatterns).some(pattern => + matchesPattern(filePath, pattern) || matchesPattern(fileName, pattern) + ); + + return !excludeFile; +} + +/** + * Parse GitHub URL to extract owner and repo + */ +export function parseGitHubUrl(url: string): { owner: string; repo: string; branch?: string } { + const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); + if (!match) { + throw new Error(`Invalid GitHub URL: ${url}`); + } + + const owner = match[1] || ""; + let repo = (match[2] || "").replace(/\.git$/, ""); + + // Check for branch in URL + const branchMatch = url.match(/\/tree\/([^\/]+)/); + const branch = branchMatch ? branchMatch[1] : undefined; + + if (!owner || !repo) { + throw new Error(`Invalid GitHub URL: ${url}`); + } + + // Return with optional branch (only include if defined) + const result: { owner: string; repo: string; branch?: string } = { owner, repo }; + if (branch) { + result.branch = branch; + } + return result; +} + +/** + * Fetch file content from GitHub + */ +async function fetchFileContent( + owner: string, + repo: string, + path: string, + ref?: string +): Promise { + const token = GH_PAT; + const headers: Record = { + Accept: "application/vnd.github.v3.raw", + }; + + if (token) { + headers.Authorization = `token ${token}`; + } + + const refParam = ref ? `?ref=${ref}` : ""; + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}${refParam}`; + + try { + const response = await fetch(url, { headers }); + + if (!response.ok) { + console.warn(`Failed to fetch ${path}: ${response.status}`); + return null; + } + + return await response.text(); + } catch (error) { + console.warn(`Error fetching ${path}:`, error); + return null; + } +} + +/** + * Get all files in a repository using Git Trees API (single API call) + * Much faster than recursive contents API + */ +export async function getRepoFiles( + owner: string, + repo: string, + path: string = "", + ref?: string +): Promise> { + const token = GH_PAT; + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + + if (token) { + headers.Authorization = `token ${token}`; + } + + // First, get the default branch if no ref specified + let branch = ref; + if (!branch) { + try { + const repoResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { headers } + ); + if (repoResponse.ok) { + const repoData = await repoResponse.json(); + branch = repoData.default_branch || "main"; + } else { + branch = "main"; + } + } catch { + branch = "main"; + } + } + + // Use Git Trees API with recursive=1 to get entire tree in one call + const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`; + + try { + const response = await fetch(treeUrl, { headers }); + + if (!response.ok) { + console.warn(`Failed to get tree: ${response.status}`); + // Fallback to contents API for the root + return getRepoFilesLegacy(owner, repo, path, ref); + } + + const data = await response.json(); + const files: Array<{ path: string; size: number; type: string }> = []; + + if (data.tree) { + for (const item of data.tree) { + if (item.type === "blob") { + files.push({ + path: item.path, + size: item.size || 0, + type: "file", + }); + } + } + } + + console.log(`Git Trees API returned ${files.length} files`); + return files; + } catch (error) { + console.warn(`Error getting tree:`, error); + return getRepoFilesLegacy(owner, repo, path, ref); + } +} + +/** + * Legacy recursive method (fallback) + */ +async function getRepoFilesLegacy( + owner: string, + repo: string, + path: string = "", + ref?: string +): Promise> { + const token = GH_PAT; + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + + if (token) { + headers.Authorization = `token ${token}`; + } + + const refParam = ref ? `?ref=${ref}` : ""; + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}${refParam}`; + + try { + const response = await fetch(url, { headers }); + + if (!response.ok) { + console.warn(`Failed to list ${path}: ${response.status}`); + return []; + } + + const data = await response.json(); + const files: Array<{ path: string; size: number; type: string }> = []; + + for (const item of data) { + if (item.type === "file") { + files.push({ + path: item.path, + size: item.size, + type: "file", + }); + } else if (item.type === "dir") { + // Recursively get files in subdirectory + const subFiles = await getRepoFilesLegacy(owner, repo, item.path, ref); + files.push(...subFiles); + } + } + + return files; + } catch (error) { + console.warn(`Error listing ${path}:`, error); + return []; + } +} + +/** + * Crawl GitHub repository files + */ +export async function crawlGitHubFiles( + repoUrl: string, + options: CrawlOptions = {} +): Promise { + const { + includePatterns = DEFAULT_INCLUDE_PATTERNS, + excludePatterns = DEFAULT_EXCLUDE_PATTERNS, + maxFileSize = 100 * 1024, // 100KB default + maxFiles = 50, + } = options; + + const { owner, repo, branch } = parseGitHubUrl(repoUrl); + + console.log(`Crawling repository: ${owner}/${repo}`); + + // Get all files in the repository + const allFiles = await getRepoFiles(owner, repo, "", branch); + + const files: Array<{ path: string; content: string }> = []; + let skippedFiles = 0; + + for (const file of allFiles) { + // Check file count limit + if (files.length >= maxFiles) { + console.log(`Reached max file limit: ${maxFiles}`); + break; + } + + // Check file size + if (file.size > maxFileSize) { + console.log(`Skipping ${file.path}: size ${file.size} exceeds limit ${maxFileSize}`); + skippedFiles++; + continue; + } + + // Check patterns + const fileName = file.path.split("/").pop() || ""; + if (!shouldIncludeFile(file.path, fileName, includePatterns, excludePatterns)) { + continue; + } + + // Fetch file content + const content = await fetchFileContent(owner, repo, file.path, branch); + if (content) { + files.push({ path: file.path, content }); + console.log(`Added ${file.path} (${file.size} bytes)`); + } + } + + console.log(`Crawled ${files.length} files from ${owner}/${repo}`); + + return { + files, + stats: { + totalFiles: files.length, + skippedFiles, + repoName: repo, + owner, + }, + }; +} + +/** + * List all files in a repository without fetching content + * Used for file browser UI + */ +export async function listRepoFiles( + repoUrl: string, + options: { maxFileSize?: number } = {} +): Promise<{ + files: Array<{ path: string; size: number; type: "file" | "dir" }>; + repoName: string; + owner: string; +}> { + const { maxFileSize = 500 * 1024 } = options; // 500KB default limit for display + + const { owner, repo, branch } = parseGitHubUrl(repoUrl); + + console.log(`Listing files in repository: ${owner}/${repo}`); + + const allFiles = await getRepoFiles(owner, repo, "", branch); + + // Filter and map files + const files = allFiles + .filter(file => { + const fileName = file.path.split("/").pop() || ""; + return shouldIncludeFile(file.path, fileName, DEFAULT_INCLUDE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS); + }) + .map(file => ({ + path: file.path, + size: file.size, + type: "file" as const, + })); + + console.log(`Found ${files.length} matching files in ${owner}/${repo}`); + + return { + files, + repoName: repo, + owner, + }; +} + +/** + * Crawl specific files from a GitHub repository + * Used when user selects specific files from the file browser + */ +export async function crawlSelectedFiles( + repoUrl: string, + selectedPaths: string[], + options: { maxFileSize?: number } = {} +): Promise { + const { maxFileSize = 100 * 1024 } = options; + + const { owner, repo, branch } = parseGitHubUrl(repoUrl); + + console.log(`Crawling ${selectedPaths.length} selected files from ${owner}/${repo}`); + + const files: Array<{ path: string; content: string }> = []; + let skippedFiles = 0; + + for (const filePath of selectedPaths) { + // Fetch file content + const content = await fetchFileContent(owner, repo, filePath, branch); + if (content) { + files.push({ path: filePath, content }); + console.log(`Added ${filePath}`); + } else { + skippedFiles++; + } + } + + console.log(`Crawled ${files.length} files from ${owner}/${repo}`); + + return { + files, + stats: { + totalFiles: files.length, + skippedFiles, + repoName: repo, + owner, + }, + }; +} + +export const githubCrawlerService = { + crawlGitHubFiles, + crawlSelectedFiles, + listRepoFiles, + getRepoFiles, + parseGitHubUrl, + DEFAULT_INCLUDE_PATTERNS, + DEFAULT_EXCLUDE_PATTERNS, +}; diff --git a/apps/api/src/services/llm.service.ts b/apps/api/src/services/llm.service.ts new file mode 100644 index 00000000..6f048555 --- /dev/null +++ b/apps/api/src/services/llm.service.ts @@ -0,0 +1,91 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +// Simple in-memory cache for LLM responses +const llmCache: Map = new Map(); + +/** + * Call OpenRouter API with Gemini 2.5 Flash + * Uses OpenAI-compatible API format with full 1M token context + */ +async function callOpenRouter(prompt: string): Promise { + const apiKey = process.env.OPEN_ROUTER_API_KEY; + if (!apiKey) { + throw new Error("OPEN_ROUTER_API_KEY is required but not set in environment variables"); + } + + const model = process.env.OPENROUTER_MODEL || "google/gemini-2.5-flash"; + const url = "https://openrouter.ai/api/v1/chat/completions"; + + // Log prompt size for debugging + const promptSizeKB = (prompt.length / 1024).toFixed(1); + const estimatedTokens = Math.ceil(prompt.length / 4); + console.log(`Calling OpenRouter API with model: ${model}`); + console.log(`Prompt size: ${promptSizeKB}KB (~${estimatedTokens.toLocaleString()} tokens)`); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + "HTTP-Referer": "https://opensox.dev", + "X-Title": "OpenSox Tutorial Generator", + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + max_tokens: 16000, + temperature: 0.7, + }), + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorBody = await response.text(); + console.error(`OpenRouter API Error Response:`, errorBody); + const errorJson = JSON.parse(errorBody); + errorMessage = errorJson.error?.message || errorJson.message || errorBody; + } catch (e) { + // Couldn't parse error body + } + throw new Error(`OpenRouter API error: ${errorMessage}`); + } + + const data = await response.json(); + + if (!data.choices?.[0]?.message?.content) { + console.error(`OpenRouter API unexpected response:`, JSON.stringify(data, null, 2)); + throw new Error(`OpenRouter API returned unexpected response format`); + } + + console.log(`OpenRouter API response received (${(data.choices[0].message.content.length / 1024).toFixed(1)}KB)`); + return data.choices[0].message.content; +} + +/** + * Main LLM calling function with caching support + * Uses OpenRouter with Gemini 2.5 Flash (1M token context) + */ +export async function callLLM(prompt: string, useCache: boolean = true): Promise { + // Check cache if enabled + if (useCache && llmCache.has(prompt)) { + console.log("LLM cache hit"); + return llmCache.get(prompt)!; + } + + console.log(`Calling LLM provider: OpenRouter`); + const response = await callOpenRouter(prompt); + + // Update cache if enabled + if (useCache) { + llmCache.set(prompt, response); + } + + return response; +} + +export const llmService = { + callLLM, +}; diff --git a/apps/api/src/services/tutorial.service.ts b/apps/api/src/services/tutorial.service.ts new file mode 100644 index 00000000..5b9c5f7d --- /dev/null +++ b/apps/api/src/services/tutorial.service.ts @@ -0,0 +1,700 @@ +import { callLLM } from "./llm.service.js"; +import { crawlGitHubFiles, crawlSelectedFiles, parseGitHubUrl } from "./github-crawler.service.js"; + +// Types for tutorial generation +interface FileData { + path: string; + content: string; +} + +interface Abstraction { + name: string; + description: string; + files: number[]; // indices into files array +} + +interface Relationship { + from: number; + to: number; + label: string; +} + +interface RelationshipsData { + summary: string; + details: Relationship[]; +} + +export interface ChapterFile { + filename: string; + content: string; +} + +interface TutorialResult { + projectName: string; + indexContent: string; + chapters: ChapterFile[]; + mermaidDiagram: string; +} + +export interface GenerateTutorialOptions { + repoUrl: string; + language?: string; + maxAbstractions?: number; + maxFiles?: number; + selectedFiles?: string[] | undefined; // Optional: specific file paths to use +} + +/** + * Helper to get content for specific file indices + */ +function getContentForIndices( + files: FileData[], + indices: number[] +): Map { + const contentMap = new Map(); + for (const i of indices) { + if (i >= 0 && i < files.length) { + const file = files[i]; + if (file) { + const { path, content } = file; + contentMap.set(`${i} # ${path}`, content); + } + } + } + return contentMap; +} + +/** + * Parse JSON from LLM response + */ +function parseJsonFromResponse(response: string): any { + // Try to find JSON block in markdown code fence + const jsonMatch = response.match(/```json\n?([\s\S]*?)```/); + if (jsonMatch && jsonMatch[1]) { + return JSON.parse(jsonMatch[1].trim()); + } + + // Try to find raw JSON array or object + const rawJsonMatch = response.match(/(\[[\s\S]*\]|\{[\s\S]*\})/); + if (rawJsonMatch && rawJsonMatch[1]) { + return JSON.parse(rawJsonMatch[1]); + } + + throw new Error("No JSON block found in LLM response"); +} + +/** + * Step 1: Identify core abstractions in the codebase + */ +async function identifyAbstractions( + files: FileData[], + projectName: string, + language: string, + maxAbstractions: number +): Promise { + console.log("Identifying abstractions using LLM..."); + + // Send full file content + const codeContext = files.map((f, i) => `--- File ${i}: ${f.path} ---\n${f.content}`).join("\n\n"); + console.log(`Full content context: ${(codeContext.length / 1024).toFixed(1)}KB`); + + // Build file listing for reference + const fileListingForPrompt = files + .map((f, i) => `- ${i} # ${f.path}`) + .join("\n"); + + const languageInstruction = language.toLowerCase() !== "english" + ? `IMPORTANT: Generate the \`name\` and \`description\` for each abstraction in **${language}** language. Do NOT use English for these fields.\n\n` + : ""; + + const prompt = ` +For the project \`${projectName}\`: + +${codeContext} + +${languageInstruction}Based on the codebase above, identify the top 5-${maxAbstractions} core most important abstractions to help those new to the codebase understand it. + +For each abstraction, provide: +1. A concise \`name\`. +2. A beginner-friendly \`description\` explaining what it is with a simple analogy, in around 100 words. +3. A list of relevant \`file_indices\` (integers only). + +File indices reference: +${fileListingForPrompt} + +Format the output as a JSON array: + +\`\`\`json +[ + { + "name": "Query Processing", + "description": "Explains what the abstraction does. It's like a central dispatcher routing requests.", + "file_indices": [0, 3] + }, + { + "name": "Query Optimization", + "description": "Another core concept, similar to a blueprint for objects.", + "file_indices": [5] + } +] +\`\`\` + +Now, provide ONLY the JSON output (up to ${maxAbstractions} abstractions):`; + + const response = await callLLM(prompt); + const abstractionsRaw = parseJsonFromResponse(response); + + if (!Array.isArray(abstractionsRaw)) { + throw new Error("LLM output is not a list"); + } + + const validatedAbstractions: Abstraction[] = []; + + for (const item of abstractionsRaw) { + if (!item.name || !item.description || !item.file_indices) { + throw new Error(`Missing keys in abstraction item: ${JSON.stringify(item)}`); + } + + const validatedIndices: number[] = []; + for (const idxEntry of item.file_indices) { + let idx: number; + if (typeof idxEntry === "number") { + idx = idxEntry; + } else if (typeof idxEntry === "string" && idxEntry.includes("#")) { + const parts = idxEntry.split("#"); + const firstPart = parts[0]; + idx = firstPart ? parseInt(firstPart.trim(), 10) : -1; + } else { + idx = parseInt(String(idxEntry).trim(), 10); + } + + if (idx >= 0 && idx < files.length) { + validatedIndices.push(idx); + } + } + + validatedAbstractions.push({ + name: String(item.name).trim(), + description: String(item.description).trim(), + files: [...new Set(validatedIndices)].sort((a, b) => a - b), + }); + } + + console.log(`Identified ${validatedAbstractions.length} abstractions.`); + return validatedAbstractions; +} + +/** + * Step 2: Analyze relationships between abstractions + */ +async function analyzeRelationships( + abstractions: Abstraction[], + files: FileData[], + projectName: string, + language: string +): Promise { + console.log("Analyzing relationships using LLM..."); + + const numAbstractions = abstractions.length; + const abstractionInfoForPrompt: string[] = []; + + // Create context with abstractions info + let context = "Identified Abstractions:\n"; + for (let i = 0; i < abstractions.length; i++) { + const abstr = abstractions[i]; + if (!abstr) continue; + const fileIndicesStr = abstr.files.join(", "); + context += `- Index ${i}: ${abstr.name} (Relevant file indices: [${fileIndicesStr}])\n Description: ${abstr.description}\n`; + abstractionInfoForPrompt.push(`${i} # ${abstr.name}`); + } + + // Get relevant file indices + const seenIndices = new Set(); + for (const abstr of abstractions) { + if (!abstr) continue; + for (const idx of abstr.files) { + seenIndices.add(idx); + } + } + + // Send full content + context += "\nRelevant Files Content:\n"; + for (const idx of seenIndices) { + const file = files[idx]; + if (file) { + context += `\n--- File ${idx}: ${file.path} ---\n${file.content}\n`; + } + } + console.log(`Relationships context: ${(context.length / 1024).toFixed(1)}KB`); + + const languageInstruction = language.toLowerCase() !== "english" + ? `IMPORTANT: Generate the \`summary\` and relationship \`label\` fields in **${language}** language. Do NOT use English for these fields.\n\n` + : ""; + + const prompt = ` +Based on the following abstractions and their code structure from the project \`${projectName}\`: + +List of Abstraction Indices and Names: +${abstractionInfoForPrompt.join("\n")} + +Context (Abstractions, Descriptions, File Structure): +${context} + +${languageInstruction}Please provide: +1. A high-level \`summary\` of the project's main purpose and functionality in a few beginner-friendly sentences. +2. A list (\`relationships\`) describing the key interactions between these abstractions. For each relationship, specify: + - \`from\`: Index of the source abstraction (integer) + - \`to\`: Index of the target abstraction (integer) + - \`label\`: A brief label for the interaction in just a few words (e.g., "Manages", "Inherits", "Uses"). + +IMPORTANT: Make sure EVERY abstraction is involved in at least ONE relationship (either as source or target). + +Format the output as JSON: + +\`\`\`json +{ + "summary": "A brief, simple explanation of the project.", + "relationships": [ + { "from": 0, "to": 1, "label": "Manages" }, + { "from": 2, "to": 0, "label": "Provides config" } + ] +} +\`\`\` + +Now, provide ONLY the JSON output: +`; + + const response = await callLLM(prompt); + const relationshipsData = parseJsonFromResponse(response); + + if (!relationshipsData.summary || !relationshipsData.relationships) { + throw new Error("LLM output missing 'summary' or 'relationships'"); + } + + const validatedRelationships: Relationship[] = []; + + for (const rel of relationshipsData.relationships) { + if (rel.from === undefined || rel.to === undefined) continue; + + let fromIdx = -1; + if (typeof rel.from === 'number') { + fromIdx = rel.from; + } else { + const parts = String(rel.from).split("#"); + if (parts.length > 0 && parts[0]) { + fromIdx = parseInt(parts[0].trim(), 10); + } + } + + let toIdx = -1; + if (typeof rel.to === 'number') { + toIdx = rel.to; + } else { + const parts = String(rel.to).split("#"); + if (parts.length > 0 && parts[0]) { + toIdx = parseInt(parts[0].trim(), 10); + } + } + + if (fromIdx >= 0 && fromIdx < numAbstractions && toIdx >= 0 && toIdx < numAbstractions) { + validatedRelationships.push({ + from: fromIdx, + to: toIdx, + label: String(rel.label), + }); + } + } + + console.log("Generated project summary and relationship details."); + return { + summary: String(relationshipsData.summary), + details: validatedRelationships, + }; +} + +/** + * Step 3: Determine chapter order + */ +async function orderChapters( + abstractions: Abstraction[], + relationships: RelationshipsData, + projectName: string +): Promise { + console.log("Determining chapter order using LLM..."); + + const abstractionListing = abstractions + .map((a, i) => `- ${i} # ${a.name}`) + .join("\n"); + + let context = `Project Summary:\n${relationships.summary}\n\n`; + context += "Relationships (Indices refer to abstractions above):\n"; + + for (const rel of relationships.details) { + const fromAbstr = abstractions[rel.from]; + const toAbstr = abstractions[rel.to]; + + if (fromAbstr && toAbstr) { + const fromName = fromAbstr.name; + const toName = toAbstr.name; + context += `- From ${rel.from} (${fromName}) to ${rel.to} (${toName}): ${rel.label}\n`; + } + } + + const prompt = ` +Given the following project abstractions and their relationships for the project \`${projectName}\`: + +Abstractions (Index # Name): +${abstractionListing} + +Context about relationships and project summary: +${context} + +If you are going to make a tutorial for \`${projectName}\`, what is the best order to explain these abstractions, from first to last? +Ideally, first explain those that are the most important or foundational, perhaps user-facing concepts or entry points. Then move to more detailed, lower-level implementation details or supporting concepts. + +Output the ordered list of abstraction indices as a JSON array of integers. + +\`\`\`json +[2, 0, 1, 3] +\`\`\` + +Now, provide ONLY the JSON array: +`; + + const response = await callLLM(prompt); + const orderedIndicesRaw = parseJsonFromResponse(response); + + if (!Array.isArray(orderedIndicesRaw)) { + throw new Error("LLM output is not a list"); + } + + const orderedIndices: number[] = []; + const seenIndices = new Set(); + + for (const entry of orderedIndicesRaw) { + let idx: number; + if (typeof entry === "number") { + idx = entry; + } else if (typeof entry === "string" && entry.includes("#")) { + const parts = entry.split("#"); + const firstPart = parts[0]; + idx = firstPart ? parseInt(firstPart.trim(), 10) : -1; + } else { + idx = parseInt(String(entry).trim(), 10); + } + + if (idx >= 0 && idx < abstractions.length && !seenIndices.has(idx)) { + orderedIndices.push(idx); + seenIndices.add(idx); + } + } + + // Add any missing abstractions at the end + for (let i = 0; i < abstractions.length; i++) { + if (!seenIndices.has(i)) { + orderedIndices.push(i); + } + } + + console.log(`Determined chapter order: ${orderedIndices}`); + return orderedIndices; +} + +/** + * Step 4: Write individual chapters + */ +async function writeChapters( + chapterOrder: number[], + abstractions: Abstraction[], + files: FileData[], + projectName: string, + language: string +): Promise { + console.log(`Writing ${chapterOrder.length} chapters...`); + + // Create chapter filename mapping + const chapterFilenames: Map = new Map(); + const allChapters: string[] = []; + + for (let i = 0; i < chapterOrder.length; i++) { + const abstractionIndex = chapterOrder[i]; + if (abstractionIndex === undefined) continue; + + const chapterNum = i + 1; + const abstraction = abstractions[abstractionIndex]; + + if (!abstraction) continue; + + const chapterName = abstraction.name; + const safeName = chapterName.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); + const filename = `${String(chapterNum).padStart(2, "0")}_${safeName}.md`; + + chapterFilenames.set(abstractionIndex, { num: chapterNum, name: chapterName, filename }); + allChapters.push(`${chapterNum}. [${chapterName}](${filename})`); + } + + const fullChapterListing = allChapters.join("\n"); + const chapters: string[] = []; + const chaptersWrittenSoFar: string[] = []; + + for (let i = 0; i < chapterOrder.length; i++) { + const abstractionIndex = chapterOrder[i]; + if (abstractionIndex === undefined) continue; + + const abstraction = abstractions[abstractionIndex]; + + if (!abstraction) continue; + + const chapterNum = i + 1; + + console.log(`Writing chapter ${chapterNum} for: ${abstraction.name}`); + + // Build context with full file content + let fileContextStr = "## Relevant Code Files\n\n"; + let totalContextSize = 0; + + for (const fileIdx of abstraction.files) { + const file = files[fileIdx]; + if (file) { + fileContextStr += `### ${file.path}\n\n\`\`\`\n${file.content}\n\`\`\`\n\n`; + totalContextSize += file.content.length; + } + } + + console.log(`Chapter ${chapterNum} context: ${(totalContextSize / 1024).toFixed(1)}KB`); + + // Get previous and next chapter info + const prevIdx = i > 0 ? chapterOrder[i - 1] : undefined; + const nextIdx = i < chapterOrder.length - 1 ? chapterOrder[i + 1] : undefined; + + const prevChapter = prevIdx !== undefined ? chapterFilenames.get(prevIdx) : null; + const nextChapter = nextIdx !== undefined ? chapterFilenames.get(nextIdx) : null; + + // Summarize previous chapters instead of including full content + const previousChaptersSummary = chaptersWrittenSoFar.length > 0 + ? `Previous chapters covered: ${chaptersWrittenSoFar.map((_, idx) => { + const prevAbstIdx = chapterOrder[idx]; + const prevAbst = prevAbstIdx !== undefined ? abstractions[prevAbstIdx] : null; + return prevAbst ? prevAbst.name : ""; + }).filter(Boolean).join(", ")}` + : "This is the first chapter."; + + const languageInstruction = language.toLowerCase() !== "english" + ? `IMPORTANT: Write this ENTIRE tutorial chapter in **${language}**. Translate ALL content including explanations, examples, and technical terms. DO NOT use English except in code syntax.\n\n` + : ""; + + const prompt = ` +${languageInstruction}Write a very beginner-friendly tutorial chapter (in Markdown format) for the project \`${projectName}\` about the concept: "${abstraction.name}". This is Chapter ${chapterNum}. + +Concept Details: +- Name: ${abstraction.name} +- Description: +${abstraction.description} + +Complete Tutorial Structure: +${fullChapterListing} + +Context from previous chapters: +${previousChaptersSummary} + +${fileContextStr} + +Instructions for the chapter: +- Start with a clear heading (e.g., \`# Chapter ${chapterNum}: ${abstraction.name}\`). + +${prevChapter ? `- Begin with a brief transition from the previous chapter, referencing [${prevChapter.name}](${prevChapter.filename}).` : ""} + +- Begin with a high-level motivation explaining what problem this abstraction solves. Start with a central use case as a concrete example. + +- If complex, break it down into key concepts. Explain each concept one-by-one in a very beginner-friendly way. + +- Each code block should be BELOW 10 lines! If longer, break them down. Use comments to skip non-important implementation details. + +- Describe the internal implementation. First provide a non-code walkthrough, then use a simple mermaid sequenceDiagram with at most 5 participants. + +- Use mermaid diagrams (\`\`\`mermaid\`\`\` format) to illustrate complex concepts. + +- Heavily use analogies and examples to help beginners understand. + +${nextChapter ? `- End with a conclusion and transition to the next chapter: [${nextChapter.name}](${nextChapter.filename}).` : "- End with a conclusion summarizing what was learned."} + +- Output *only* the Markdown content for this chapter. + +Now, directly provide a super beginner-friendly Markdown output (DON'T need \`\`\`markdown\`\`\` tags): +`; + + const chapterContent = await callLLM(prompt, false); // Don't cache chapter content + + // Ensure proper heading + let finalContent = chapterContent; + const actualHeading = `# Chapter ${chapterNum}: ${abstraction.name}`; + if (!finalContent.trim().startsWith(`# Chapter ${chapterNum}`)) { + const lines = finalContent.trim().split("\n"); + const firstLine = lines[0]; + if (lines.length > 0 && firstLine && firstLine.startsWith("#")) { + lines[0] = actualHeading; + finalContent = lines.join("\n"); + } else { + finalContent = `${actualHeading}\n\n${finalContent}`; + } + } + + chapters.push(finalContent); + chaptersWrittenSoFar.push(finalContent); + } + + console.log(`Finished writing ${chapters.length} chapters.`); + return chapters; +} + +/** + * Step 5: Combine everything into a tutorial + */ +function combineTutorial( + projectName: string, + repoUrl: string, + abstractions: Abstraction[], + relationships: RelationshipsData, + chapterOrder: number[], + chaptersContent: string[] +): TutorialResult { + console.log("Combining tutorial..."); + + // Generate Mermaid diagram + const mermaidLines = ["flowchart TD"]; + + for (let i = 0; i < abstractions.length; i++) { + const abstr = abstractions[i]; + if (!abstr) continue; + const nodeId = `A${i}`; + const sanitizedName = abstr.name.replace(/"/g, ""); + mermaidLines.push(` ${nodeId}["${sanitizedName}"]`); + } + + for (const rel of relationships.details) { + const fromNodeId = `A${rel.from}`; + const toNodeId = `A${rel.to}`; + let edgeLabel = rel.label.replace(/"/g, "").replace(/\n/g, " "); + if (edgeLabel.length > 30) { + edgeLabel = edgeLabel.substring(0, 27) + "..."; + } + mermaidLines.push(` ${fromNodeId} -- "${edgeLabel}" --> ${toNodeId}`); + } + + const mermaidDiagram = mermaidLines.join("\n"); + + // Generate index.md content + let indexContent = `# Tutorial: ${projectName}\n\n`; + indexContent += `${relationships.summary}\n\n`; + indexContent += `**Source Repository:** [${repoUrl}](${repoUrl})\n\n`; + indexContent += "```mermaid\n"; + indexContent += mermaidDiagram + "\n"; + indexContent += "```\n\n"; + indexContent += "## Chapters\n\n"; + + const chapterFiles: ChapterFile[] = []; + + for (let i = 0; i < chapterOrder.length; i++) { + const abstractionIndex = chapterOrder[i]; + if (abstractionIndex === undefined) continue; + + const abstraction = abstractions[abstractionIndex]; + + if (abstractionIndex < abstractions.length && i < chaptersContent.length && abstraction) { + const abstractionName = abstraction.name; + const safeName = abstractionName.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); + const filename = `${String(i + 1).padStart(2, "0")}_${safeName}.md`; + + indexContent += `${i + 1}. [${abstractionName}](${filename})\n`; + + let chapterContent = chaptersContent[i] || ""; + if (!chapterContent.endsWith("\n\n")) { + chapterContent += "\n\n"; + } + chapterFiles.push({ filename, content: chapterContent }); + } + } + console.log("Tutorial generation complete!"); + + return { + projectName, + indexContent, + chapters: chapterFiles, + mermaidDiagram, + }; +} + +/** + * Main function to generate tutorial from a GitHub repository + */ +export async function generateTutorial( + options: GenerateTutorialOptions +): Promise { + const { + repoUrl, + language = "english", + maxAbstractions = 8, + maxFiles = 30, + selectedFiles, + } = options; + + console.log(`\n${"=".repeat(60)}`); + console.log(`Starting tutorial generation for: ${repoUrl}`); + console.log(`Language: ${language}, Max Abstractions: ${maxAbstractions}`); + if (selectedFiles) { + console.log(`Using ${selectedFiles.length} user-selected files`); + } + console.log(`${"=".repeat(60)}\n`); + + // Step 0: Parse repo URL and get project name + const { repo: projectName } = parseGitHubUrl(repoUrl); + + // Step 1: Fetch repository files + console.log("\n--- Step 1: Fetching repository files ---"); + let files: FileData[]; + + if (selectedFiles && selectedFiles.length > 0) { + // Use user-selected files + const crawlResult = await crawlSelectedFiles(repoUrl, selectedFiles); + files = crawlResult.files; + } else { + // Use automatic file selection + const crawlResult = await crawlGitHubFiles(repoUrl, { maxFiles }); + files = crawlResult.files; + } + + if (files.length === 0) { + throw new Error("No files found in repository"); + } + + // Step 2: Identify abstractions + console.log("\n--- Step 2: Identifying abstractions ---"); + const abstractions = await identifyAbstractions(files, projectName, language, maxAbstractions); + + // Step 3: Analyze relationships + console.log("\n--- Step 3: Analyzing relationships ---"); + const relationships = await analyzeRelationships(abstractions, files, projectName, language); + + // Step 4: Order chapters + console.log("\n--- Step 4: Ordering chapters ---"); + const chapterOrder = await orderChapters(abstractions, relationships, projectName); + + // Step 5: Write chapters + console.log("\n--- Step 5: Writing chapters ---"); + const chaptersContent = await writeChapters(chapterOrder, abstractions, files, projectName, language); + + // Step 6: Combine tutorial + console.log("\n--- Step 6: Combining tutorial ---"); + const tutorial = combineTutorial(projectName, repoUrl, abstractions, relationships, chapterOrder, chaptersContent); + + return tutorial; +} + +export const tutorialService = { + generateTutorial, + identifyAbstractions, + analyzeRelationships, + orderChapters, + writeChapters, + combineTutorial, +}; diff --git a/apps/web/package.json b/apps/web/package.json index c302d2b6..b0103573 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", "@trpc/server": "^11.5.1", + "@types/react-syntax-highlighter": "^15.5.13", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "class-variance-authority": "^0.7.0", @@ -28,16 +29,23 @@ "framer-motion": "^11.15.0", "geist": "^1.5.1", "gray-matter": "^4.0.3", + "highlight.js": "^11.11.1", "lucide-react": "^0.456.0", "marked": "^17.0.0", + "mermaid": "^11.12.2", "next": "16.0.10", "next-auth": "^4.24.11", "next-themes": "^0.4.3", "posthog-js": "^1.203.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-qr-code": "^2.0.18", + "react-syntax-highlighter": "^16.1.0", "react-tweet": "^3.2.1", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "sanitize-html": "^2.11.0", "superjson": "^2.2.5", "tailwind-merge": "^2.5.4", diff --git a/apps/web/src/app/(main)/dashboard/tutorial/page.tsx b/apps/web/src/app/(main)/dashboard/tutorial/page.tsx new file mode 100644 index 00000000..880fd88d --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/tutorial/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import TutorialGenerator from "@/components/tutorial/TutorialGenerator"; +import TutorialViewer from "@/components/tutorial/TutorialViewer"; +import TutorialHistory from "@/components/tutorial/TutorialHistory"; +import { BookOpenIcon } from "@heroicons/react/24/outline"; + +export default function TutorialPage() { + const [selectedTutorialId, setSelectedTutorialId] = useState(null); + const [generatedTutorial, setGeneratedTutorial] = useState(null); + + // Fetch user's tutorial history + const { data: tutorials, refetch: refetchTutorials } = trpc.tutorial.getUserTutorials.useQuery(); + + const handleTutorialGenerated = (tutorial: any) => { + setGeneratedTutorial(tutorial); + setSelectedTutorialId(tutorial.id); + refetchTutorials(); + }; + + const handleSelectTutorial = (id: string) => { + setSelectedTutorialId(id); + setGeneratedTutorial(null); // Clear any generated tutorial to load from DB + }; + + const handleBack = () => { + setSelectedTutorialId(null); + setGeneratedTutorial(null); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

+ Code Tutorial Generator +

+ + AI Powered + +
+

+ Transform any GitHub repository into a beginner-friendly tutorial with AI-powered analysis +

+
+ + {/* Main Content */} + {selectedTutorialId ? ( + + ) : ( +
+ {/* Generator Panel */} +
+ +
+ + {/* History Panel */} +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 244508f1..19562063 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -72,3 +72,123 @@ html { scroll-behavior: smooth; } +/* Tutorial Content Styling */ +.tutorial-content { + @apply text-base leading-relaxed; +} + +.tutorial-content .hljs { + background: transparent !important; + padding: 0 !important; +} + +.tutorial-content pre { + @apply bg-[#0d1117] !important; +} + +.tutorial-content pre code { + @apply text-sm leading-relaxed; + font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace; +} + +/* Mermaid diagram styling */ +.tutorial-content .mermaid-diagram svg { + @apply max-w-full; +} + +/* Sequence diagram styling */ +.tutorial-content .mermaid svg .actor { + fill: #1f2937 !important; + stroke: #6d28d9 !important; +} + +.tutorial-content .mermaid svg .actor-line { + stroke: #6b7280 !important; +} + +.tutorial-content .mermaid svg text.actor { + fill: #fff !important; + font-size: 14px !important; +} + +.tutorial-content .mermaid svg .messageLine0, +.tutorial-content .mermaid svg .messageLine1 { + stroke: #6b7280 !important; +} + +.tutorial-content .mermaid svg .messageText { + fill: #d1d5db !important; + font-size: 12px !important; +} + +.tutorial-content .mermaid svg .note { + fill: #1f2937 !important; + stroke: #6d28d9 !important; +} + +.tutorial-content .mermaid svg .noteText { + fill: #d1d5db !important; +} + +/* Flowchart styling */ +.tutorial-content .mermaid svg .node rect, +.tutorial-content .mermaid svg .node polygon, +.tutorial-content .mermaid svg .node circle { + fill: #1f2937 !important; + stroke: #6d28d9 !important; +} + +.tutorial-content .mermaid svg .nodeLabel { + fill: #fff !important; +} + +.tutorial-content .mermaid svg .edgePath path { + stroke: #6b7280 !important; +} + +.tutorial-content .mermaid svg .edgeLabel { + background-color: #1f2937 !important; + fill: #d1d5db !important; +} + +/* Code highlighting improvements */ +.tutorial-content .hljs-keyword { + @apply text-purple-400; +} + +.tutorial-content .hljs-string { + @apply text-green-400; +} + +.tutorial-content .hljs-number { + @apply text-orange-400; +} + +.tutorial-content .hljs-function { + @apply text-blue-400; +} + +.tutorial-content .hljs-comment { + @apply text-gray-500 italic; +} + +.tutorial-content .hljs-built_in { + @apply text-cyan-400; +} + +.tutorial-content .hljs-class { + @apply text-yellow-400; +} + +.tutorial-content .hljs-variable { + @apply text-red-300; +} + +.tutorial-content .hljs-attr { + @apply text-purple-300; +} + +.tutorial-content .hljs-meta { + @apply text-gray-400; +} + diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx index 22265205..3c1e3072 100644 --- a/apps/web/src/components/dashboard/Sidebar.tsx +++ b/apps/web/src/components/dashboard/Sidebar.tsx @@ -21,6 +21,7 @@ import { ChevronDownIcon, LockClosedIcon, AcademicCapIcon, + BookOpenIcon, } from "@heroicons/react/24/outline"; import { useShowSidebar } from "@/store/useShowSidebar"; import { signOut, useSession } from "next-auth/react"; @@ -49,6 +50,12 @@ const FREE_ROUTES: RouteConfig[] = [ label: "OSS Projects", icon: , }, + { + path: "/dashboard/tutorial", + label: "Code Tutorial", + icon: , + badge: "AI", + }, { path: "/dashboard/sheet", label: "OSS Sheet", diff --git a/apps/web/src/components/tutorial/FileBrowser.tsx b/apps/web/src/components/tutorial/FileBrowser.tsx new file mode 100644 index 00000000..d1c3e665 --- /dev/null +++ b/apps/web/src/components/tutorial/FileBrowser.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + FolderIcon, + FolderOpenIcon, + DocumentIcon, + ChevronRightIcon, + ChevronDownIcon, + CheckIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { Button } from "@/components/ui/button"; + +interface FileItem { + path: string; + size: number; + type: "file" | "dir"; +} + +interface TreeNode { + name: string; + path: string; + type: "file" | "dir"; + size?: number; + children: TreeNode[]; +} + +interface FileBrowserProps { + files: FileItem[]; + maxTotalSize: number; // in bytes + onConfirm: (selectedPaths: string[]) => void; + onCancel: () => void; + isLoading?: boolean; +} + +// Build tree structure from flat file list +function buildFileTree(files: FileItem[]): TreeNode[] { + const root: TreeNode[] = []; + + for (const file of files) { + const parts = file.path.split("/"); + let currentLevel = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const currentPath = parts.slice(0, i + 1).join("/"); + + let existing = currentLevel.find(n => n.name === part); + + if (!existing) { + existing = { + name: part, + path: currentPath, + type: isLast ? file.type : "dir", + size: isLast ? file.size : undefined, + children: [], + }; + currentLevel.push(existing); + } + + if (!isLast) { + currentLevel = existing.children; + } + } + } + + // Sort: folders first, then files, alphabetically + const sortTree = (nodes: TreeNode[]): TreeNode[] => { + return nodes + .map(node => ({ + ...node, + children: sortTree(node.children), + })) + .sort((a, b) => { + if (a.type === "dir" && b.type === "file") return -1; + if (a.type === "file" && b.type === "dir") return 1; + return a.name.localeCompare(b.name); + }); + }; + + return sortTree(root); +} + +// Format file size +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +// Get file extension for icon coloring +function getFileColor(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase(); + const colors: Record = { + ts: "text-blue-400", + tsx: "text-blue-400", + js: "text-yellow-400", + jsx: "text-yellow-400", + py: "text-green-400", + go: "text-cyan-400", + rs: "text-orange-400", + java: "text-red-400", + md: "text-gray-400", + json: "text-yellow-300", + yaml: "text-pink-400", + yml: "text-pink-400", + css: "text-purple-400", + html: "text-orange-300", + }; + return colors[ext || ""] || "text-text-tertiary"; +} + +// Tree node component +function TreeNodeItem({ + node, + selectedPaths, + onToggle, + expandedPaths, + onToggleExpand, + level = 0, +}: { + node: TreeNode; + selectedPaths: Set; + onToggle: (path: string, type: "file" | "dir", children?: TreeNode[]) => void; + expandedPaths: Set; + onToggleExpand: (path: string) => void; + level?: number; +}) { + const isExpanded = expandedPaths.has(node.path); + const isSelected = selectedPaths.has(node.path); + const isFolder = node.type === "dir"; + + // Check if folder is partially selected + const getSelectionState = (): "none" | "partial" | "full" => { + if (node.type === "file") { + return isSelected ? "full" : "none"; + } + + const allFiles = getAllFilePaths(node); + const selectedCount = allFiles.filter(p => selectedPaths.has(p)).length; + + if (selectedCount === 0) return "none"; + if (selectedCount === allFiles.length) return "full"; + return "partial"; + }; + + const selectionState = getSelectionState(); + + return ( +
+
0 ? "ml-4" : "" + }`} + style={{ paddingLeft: `${level * 16 + 8}px` }} + > + {/* Expand/Collapse for folders */} + {isFolder ? ( + + ) : ( + + )} + + {/* Checkbox */} + + + {/* Icon */} + {isFolder ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + {/* Name */} + onToggle(node.path, node.type, node.children)} + > + {node.name} + + + {/* Size for files */} + {node.size !== undefined && ( + + {formatSize(node.size)} + + )} +
+ + {/* Children */} + {isFolder && isExpanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +// Get all file paths under a node +function getAllFilePaths(node: TreeNode): string[] { + if (node.type === "file") { + return [node.path]; + } + return node.children.flatMap(getAllFilePaths); +} + +// Get all file nodes under a node +function getAllFileNodes(node: TreeNode): TreeNode[] { + if (node.type === "file") { + return [node]; + } + return node.children.flatMap(getAllFileNodes); +} + +export default function FileBrowser({ + files, + maxTotalSize, + onConfirm, + onCancel, + isLoading = false, +}: FileBrowserProps) { + const [selectedPaths, setSelectedPaths] = useState>(() => { + // Pre-select files up to limit + const paths = new Set(); + let totalSize = 0; + + for (const file of files) { + if (file.type === "file" && totalSize + file.size <= maxTotalSize) { + paths.add(file.path); + totalSize += file.size; + } + } + + return paths; + }); + + const [expandedPaths, setExpandedPaths] = useState>(() => { + // Expand first level by default + const expanded = new Set(); + const tree = buildFileTree(files); + tree.forEach(node => { + if (node.type === "dir") { + expanded.add(node.path); + } + }); + return expanded; + }); + + const tree = useMemo(() => buildFileTree(files), [files]); + + // Calculate total selected size + const selectedSize = useMemo(() => { + return files + .filter(f => f.type === "file" && selectedPaths.has(f.path)) + .reduce((sum, f) => sum + f.size, 0); + }, [files, selectedPaths]); + + const selectedCount = useMemo(() => { + return files.filter(f => f.type === "file" && selectedPaths.has(f.path)).length; + }, [files, selectedPaths]); + + const totalFiles = files.filter(f => f.type === "file").length; + const isOverLimit = selectedSize > maxTotalSize; + + const handleToggle = (path: string, type: "file" | "dir", children?: TreeNode[]) => { + setSelectedPaths(prev => { + const next = new Set(prev); + + if (type === "file") { + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + } else if (children) { + // For folders, toggle all children + const allFiles = children.flatMap(c => getAllFilePaths({ + ...{ name: "", path, type: "dir", children }, + children, + } as TreeNode)); + + // Get actual file paths from the folder + const folderNode: TreeNode = { name: "", path, type: "dir", children }; + const filePaths = getAllFilePaths(folderNode); + + const allSelected = filePaths.every(p => next.has(p)); + + if (allSelected) { + filePaths.forEach(p => next.delete(p)); + } else { + filePaths.forEach(p => next.add(p)); + } + } + + return next; + }); + }; + + const handleToggleExpand = (path: string) => { + setExpandedPaths(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const handleSelectAll = () => { + const allPaths = files.filter(f => f.type === "file").map(f => f.path); + setSelectedPaths(new Set(allPaths)); + }; + + const handleDeselectAll = () => { + setSelectedPaths(new Set()); + }; + + const handleExpandAll = () => { + const allDirs = files.filter(f => f.type === "dir").map(f => f.path); + // Also add parent paths + const allPaths = new Set(); + files.forEach(f => { + const parts = f.path.split("/"); + for (let i = 1; i < parts.length; i++) { + allPaths.add(parts.slice(0, i).join("/")); + } + }); + setExpandedPaths(allPaths); + }; + + const handleCollapseAll = () => { + setExpandedPaths(new Set()); + }; + + return ( +
+ {/* Header */} +
+
+
+

+ Select Files to Analyze +

+

+ Choose which files to include in the tutorial generation +

+
+ +
+
+ + {/* Toolbar */} +
+
+ + +
+
+
+ + +
+
+ + {/* File Tree */} +
+ {tree.map((node) => ( + + ))} +
+ + {/* Footer */} +
+
+
+ + {selectedCount} of {totalFiles} files selected + + + + {formatSize(selectedSize)} / {formatSize(maxTotalSize)} + + {isOverLimit && ( + + (exceeds limit) + + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/tutorial/TutorialGenerator.tsx b/apps/web/src/components/tutorial/TutorialGenerator.tsx new file mode 100644 index 00000000..637c93fe --- /dev/null +++ b/apps/web/src/components/tutorial/TutorialGenerator.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + SparklesIcon, + ArrowPathIcon, + ExclamationCircleIcon, + FolderOpenIcon, + EyeIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/24/outline"; +import FileBrowser from "./FileBrowser"; + +interface TutorialGeneratorProps { + onTutorialGenerated: (tutorial: any) => void; +} + +// Max total size for selected files (3.0MB) +const MAX_TOTAL_SIZE = 3.0 * 1024 * 1024; + +type ViewMode = "form" | "file-browser" | "generating"; + +export default function TutorialGenerator({ onTutorialGenerated }: TutorialGeneratorProps) { + const [repoUrl, setRepoUrl] = useState(""); + const [language, setLanguage] = useState("english"); + const [viewMode, setViewMode] = useState("form"); + const [error, setError] = useState(null); + const [existingTutorials, setExistingTutorials] = useState([]); + const [showExistingModal, setShowExistingModal] = useState(false); + const [repoFiles, setRepoFiles] = useState>([]); + const [isFetchingFiles, setIsFetchingFiles] = useState(false); + + // Check for existing tutorials + const checkExisting = trpc.tutorial.checkExisting.useQuery( + { repoUrl }, + { enabled: false } + ); + + // List repo files query + const listFilesQuery = trpc.tutorial.listRepoFiles.useQuery( + { repoUrl }, + { enabled: false } + ); + + // Generate mutation + const generateMutation = trpc.tutorial.generate.useMutation({ + onSuccess: (data: any) => { + setViewMode("form"); + onTutorialGenerated(data); + }, + onError: (err: any) => { + setViewMode("form"); + setError(err.message); + }, + }); + + const handleFetchFiles = async () => { + setError(null); + + if (!repoUrl.includes("github.com")) { + setError("Please enter a valid GitHub repository URL"); + return; + } + + // First check for existing tutorials + const existingResult = await checkExisting.refetch(); + if (existingResult.data?.exists && existingResult.data.tutorials.length > 0) { + setExistingTutorials(existingResult.data.tutorials); + setShowExistingModal(true); + return; + } + + // Fetch file list + setIsFetchingFiles(true); + try { + const result = await listFilesQuery.refetch(); + if (result.data?.files) { + setRepoFiles(result.data.files); + setViewMode("file-browser"); + } + } catch (err: any) { + setError(err.message || "Failed to fetch repository files"); + } finally { + setIsFetchingFiles(false); + } + }; + + const handleConfirmFiles = (selectedPaths: string[]) => { + startGeneration(selectedPaths); + }; + + const handleCancelFileBrowser = () => { + setViewMode("form"); + setRepoFiles([]); + }; + + const startGeneration = (selectedFiles?: string[]) => { + setViewMode("generating"); + setShowExistingModal(false); + + generateMutation.mutate({ + repoUrl, + language, + selectedFiles, + }); + }; + + const handleViewExisting = (tutorialId: string) => { + setShowExistingModal(false); + onTutorialGenerated({ id: tutorialId, fromHistory: true }); + }; + + const handleGenerateNewFromModal = async () => { + setShowExistingModal(false); + + // Fetch file list + setIsFetchingFiles(true); + try { + const result = await listFilesQuery.refetch(); + if (result.data?.files) { + setRepoFiles(result.data.files); + setViewMode("file-browser"); + } + } catch (err: any) { + setError(err.message || "Failed to fetch repository files"); + } finally { + setIsFetchingFiles(false); + } + }; + + // File Browser View + if (viewMode === "file-browser") { + return ( + + ); + } + + return ( +
+
+ +

Generate Tutorial

+
+ + {/* Existing Tutorial Modal */} + {showExistingModal && ( +
+
+ +
+

+ {existingTutorials.some(t => t.isOwnTutorial) + ? "Existing tutorials found (including yours)" + : "Existing tutorials found from other users"} +

+
+ {existingTutorials.map((tutorial) => ( +
+
+
+

{tutorial.projectName}

+ {tutorial.isOwnTutorial && ( + + Your tutorial + + )} +
+

+ {new Date(tutorial.createdAt).toLocaleDateString()} • {tutorial.language} +

+
+ +
+ ))} +
+
+ + +
+
+
+
+ )} + + {/* Form */} +
{ e.preventDefault(); handleFetchFiles(); }} className="space-y-4"> +
+ + setRepoUrl(e.target.value)} + disabled={viewMode === "generating" || isFetchingFiles} + className="bg-dash-base border-dash-border text-text-primary placeholder:text-text-tertiary focus:border-brand-purple focus:ring-brand-purple" + /> +
+ +
+ + +
+ + {error && ( +
+ +

{error}

+
+ )} + + +
+ + {/* Loading State */} + {viewMode === "generating" && ( +
+ + Loading... +
+ )} + + {/* Tips */} + {viewMode === "form" && ( +
+

How it works

+
    +
  • 1. Enter a GitHub repository URL
  • +
  • 2. Browse and select which files to analyze
  • +
  • 3. AI generates a beginner-friendly tutorial
  • +
  • 4. View chapters with code explanations & diagrams
  • +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/tutorial/TutorialHistory.tsx b/apps/web/src/components/tutorial/TutorialHistory.tsx new file mode 100644 index 00000000..877496b9 --- /dev/null +++ b/apps/web/src/components/tutorial/TutorialHistory.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { ClockIcon, TrashIcon, FolderOpenIcon } from "@heroicons/react/24/outline"; +import { trpc } from "@/lib/trpc"; + +interface Tutorial { + id: string; + projectName: string; + repoUrl: string; + language: string; + createdAt: string | Date; +} + +interface TutorialHistoryProps { + tutorials: Tutorial[]; + onSelectTutorial: (id: string) => void; +} + +export default function TutorialHistory({ + tutorials, + onSelectTutorial, +}: TutorialHistoryProps) { + const utils = trpc.useUtils(); + + const deleteMutation = trpc.tutorial.delete.useMutation({ + onSuccess: () => { + utils.tutorial.getUserTutorials.invalidate(); + }, + }); + + const handleDelete = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (confirm("Are you sure you want to delete this tutorial?")) { + deleteMutation.mutate({ id }); + } + }; + + const formatDate = (dateInput: string | Date) => { + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return "Today"; + } else if (diffDays === 1) { + return "Yesterday"; + } else if (diffDays < 7) { + return `${diffDays} days ago`; + } else { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + } + }; + + const extractRepoName = (url: string) => { + const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); + return match ? `${match[1]}/${match[2]}` : url; + }; + + return ( +
+
+ +

Recent Tutorials

+
+ + {tutorials.length === 0 ? ( +
+ +

No tutorials generated yet

+

+ Generate your first tutorial to see it here +

+
+ ) : ( +
+ {tutorials.map((tutorial) => ( +
onSelectTutorial(tutorial.id)} + className="group p-4 bg-dash-base hover:bg-dash-hover border border-dash-border rounded-lg cursor-pointer transition-all hover:border-brand-purple/30" + > +
+
+

+ {tutorial.projectName} +

+

+ {extractRepoName(tutorial.repoUrl)} +

+
+ + {formatDate(tutorial.createdAt)} + + + + {tutorial.language} + +
+
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/tutorial/TutorialViewer.tsx b/apps/web/src/components/tutorial/TutorialViewer.tsx new file mode 100644 index 00000000..c39d8133 --- /dev/null +++ b/apps/web/src/components/tutorial/TutorialViewer.tsx @@ -0,0 +1,575 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { + ArrowLeftIcon, + DocumentTextIcon, + ArrowDownTrayIcon, + LinkIcon, + ChevronLeftIcon, + ChevronRightIcon, + ClipboardIcon, + CheckIcon, +} from "@heroicons/react/24/outline"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import rehypeHighlight from "rehype-highlight"; +import mermaid from "mermaid"; +import hljs from "highlight.js"; + +// @ts-ignore - highlight.js CSS import +import "highlight.js/styles/github-dark.css"; + +// Helper function to extract text from React children +function extractTextFromChildren(children: React.ReactNode): string { + if (typeof children === 'string') { + return children; + } + if (typeof children === 'number') { + return String(children); + } + if (Array.isArray(children)) { + return children.map(extractTextFromChildren).join(''); + } + if (children && typeof children === 'object' && 'props' in children) { + return extractTextFromChildren((children as React.ReactElement).props.children); + } + return ''; +} + +interface Chapter { + filename: string; + content: string; +} + +interface TutorialData { + id: string; + projectName: string; + repoUrl?: string; + indexContent: string; + chapters: Chapter[] | unknown; + mermaidDiagram: string; + createdAt?: string | Date; + fromHistory?: boolean; +} + +interface TutorialViewerProps { + tutorialId: string; + generatedTutorial?: TutorialData | null; + onBack: () => void; +} + +// Mermaid component for rendering diagrams +function MermaidDiagram({ chart }: { chart: string }) { + const containerRef = useRef(null); + const [svg, setSvg] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + const renderChart = async () => { + if (!chart.trim()) return; + + try { + const id = `mermaid-${Math.random().toString(36).substring(7)}`; + const { svg: renderedSvg } = await mermaid.render(id, chart); + setSvg(renderedSvg); + setError(null); + } catch (err) { + console.error("Mermaid render error:", err); + setError("Failed to render diagram"); + } + }; + + renderChart(); + }, [chart]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ ); +} + +// Code block component with copy functionality +function CodeBlock({ + children, + className, + language +}: { + children: string; + className?: string; + language?: string; +}) { + const [copied, setCopied] = useState(false); + const codeRef = useRef(null); + + const handleCopy = async () => { + await navigator.clipboard.writeText(children); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // Apply syntax highlighting + useEffect(() => { + if (codeRef.current && language && children) { + try { + if (hljs.getLanguage(language)) { + const highlighted = hljs.highlight(children, { language }).value; + codeRef.current.innerHTML = highlighted; + } + } catch (e) { + // Fallback: just show plain text + console.error("Highlight error:", e); + } + } + }, [children, language]); + + return ( +
+ {language && ( +
+ {language} +
+ )} + +
+        
+          {children}
+        
+      
+
+ ); +} + +interface TutorialViewerProps { + tutorialId: string; + generatedTutorial?: TutorialData | null; + onBack: () => void; +} + +export default function TutorialViewer({ + tutorialId, + generatedTutorial, + onBack, +}: TutorialViewerProps) { + const [selectedChapter, setSelectedChapter] = useState(-1); // -1 = index + + // Fetch tutorial if not provided or if viewing from history + const shouldFetchFromDb = !generatedTutorial || generatedTutorial.fromHistory; + const { data: fetchedTutorial, isLoading } = trpc.tutorial.getById.useQuery( + { id: tutorialId }, + { enabled: shouldFetchFromDb } + ); + + const tutorial: TutorialData | null = generatedTutorial?.fromHistory + ? (fetchedTutorial as unknown as TutorialData | null) + : generatedTutorial || (fetchedTutorial as unknown as TutorialData | null); + + // Initialize mermaid + useEffect(() => { + mermaid.initialize({ + startOnLoad: false, + theme: "dark", + themeVariables: { + primaryColor: "#7c3aed", + primaryTextColor: "#fff", + primaryBorderColor: "#6d28d9", + lineColor: "#6b7280", + secondaryColor: "#1f2937", + tertiaryColor: "#111827", + background: "#0d1117", + mainBkg: "#161b22", + nodeBorder: "#6d28d9", + clusterBkg: "#1f2937", + titleColor: "#fff", + edgeLabelBackground: "#1f2937", + }, + flowchart: { + htmlLabels: true, + curve: "basis", + padding: 15, + }, + sequence: { + actorMargin: 50, + boxMargin: 10, + boxTextMargin: 5, + noteMargin: 10, + messageMargin: 35, + mirrorActors: true, + }, + }); + }, []); + + const chapters: Chapter[] = tutorial?.chapters + ? (typeof tutorial.chapters === "string" + ? JSON.parse(tutorial.chapters) + : Array.isArray(tutorial.chapters) + ? tutorial.chapters as Chapter[] + : []) + : []; + + const currentContent = + selectedChapter === -1 + ? tutorial?.indexContent || "" + : chapters[selectedChapter]?.content || ""; + + const handleDownload = () => { + if (!tutorial) return; + + // Create a zip-like structure as a single markdown file + let fullContent = `# ${tutorial.projectName} Tutorial\n\n`; + fullContent += tutorial.indexContent + "\n\n---\n\n"; + + chapters.forEach((chapter, index) => { + fullContent += `\n\n---\n\n${chapter.content}`; + }); + + const blob = new Blob([fullContent], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${tutorial.projectName}-tutorial.md`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleCopyLink = () => { + if (tutorial?.repoUrl) { + navigator.clipboard.writeText(tutorial.repoUrl); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!tutorial) { + return ( +
+

Tutorial not found

+ +
+ ); + } + + return ( +
+ {/* Sidebar - Chapter Navigation */} +
+
+ + +

+ {tutorial.projectName} +

+ + {tutorial.repoUrl && ( + + )} + +
+ + {chapters.map((chapter, index) => ( + + ))} +
+ +
+ +
+
+
+ + {/* Main Content */} +
+
+ {/* Navigation */} +
+
+ + + {selectedChapter === -1 + ? "Overview" + : `Chapter ${selectedChapter + 1} of ${chapters.length}`} + +
+
+ + +
+
+ + {/* Markdown Content */} +
+ ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + h4: ({ children }) => ( +

+ {children} +

+ ), + + // Paragraphs + p: ({ children }) => ( +

+ {children} +

+ ), + + // Strong/Bold text + strong: ({ children }) => ( + + {children} + + ), + + // Emphasis/Italic + em: ({ children }) => ( + + {children} + + ), + + // Links + a: ({ href, children }) => ( + + {children} + + ), + + // Lists + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • + {children} +
  • + ), + + // Blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + + // Horizontal rule + hr: () => ( +
    + ), + + // Tables + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + + // Code blocks + code({ node, inline, className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : ""; + const codeString = extractTextFromChildren(children).replace(/\n$/, ""); + + // Handle mermaid diagrams + if (language === "mermaid") { + return ; + } + + // Inline code + if (inline) { + return ( + + {children} + + ); + } + + // Code blocks with syntax highlighting + return ( + + {codeString} + + ); + }, + + // Pre (code block wrapper) + pre: ({ children }) => <>{children}, + + // Images + img: ({ src, alt }) => ( + {alt + ), + }} + > + {currentContent} +
    +
    + + {/* Chapter Navigation Footer */} +
    + + +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/components/tutorial/index.ts b/apps/web/src/components/tutorial/index.ts new file mode 100644 index 00000000..57944f40 --- /dev/null +++ b/apps/web/src/components/tutorial/index.ts @@ -0,0 +1,3 @@ +export { default as TutorialGenerator } from "./TutorialGenerator"; +export { default as TutorialViewer } from "./TutorialViewer"; +export { default as TutorialHistory } from "./TutorialHistory";