43 lines
1.7 KiB
TypeScript
43 lines
1.7 KiB
TypeScript
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<void>;
|
|
readLessonMarkdown(courseId: string, lessonId: string): Promise<string | null>;
|
|
/** 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<CourseRepository> {
|
|
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;
|
|
}
|