import { extname } from "node:path"; import { loadAll, resolveInside, type LoadedCourse } from "./load"; import { extractSection } from "./markdown"; export interface CourseRepository { list(): LoadedCourse[]; get(id: string): LoadedCourse | null; /** Re-scans the course dirs. `id` is accepted for API symmetry; a rescan is cheap and keeps overlay shadowing correct. */ reload(id?: string): Promise; readLessonMarkdown(courseId: string, lessonId: string): Promise; /** Absolute real path if inside the course root (symlinks followed), else null. */ resolvePath(courseId: string, relPath: string): string | null; } const MD = new Set([".md", ".markdown"]); export async function createCourseRepository(courseDirs: string[]): Promise { let courses = await loadAll(courseDirs); const repo: CourseRepository = { list: () => courses, get: (id) => courses.find((c) => c.id === id) ?? null, async reload() { courses = await loadAll(courseDirs); }, async readLessonMarkdown(courseId, lessonId) { const entry = repo.get(courseId)?.lessonIndex.get(lessonId); if (!entry) return null; const { lesson } = entry; const rel = lesson.type === "video" ? lesson.transcript : lesson.file; if (!rel || !MD.has(extname(rel).toLowerCase())) return null; const abs = repo.resolvePath(courseId, rel); if (!abs) return null; const text = await Bun.file(abs).text(); return lesson.anchor ? extractSection(text, lesson.anchor) : text; }, resolvePath(courseId, relPath) { const c = repo.get(courseId); return c ? resolveInside(c.root, relPath) : null; }, }; return repo; }