import { statSync } from "node:fs"; import type { Feature, ServerContext } from "@stc/contracts"; import { extractHints, extractSection, type Lesson, type Manifest } from "@stc/course-format"; import { progressRepo } from "@stc/db"; import type { FileRef, LessonPayload } from "../shared/types"; import { mediaUrl } from "../shared/types"; import { courseFor } from "./access"; import { createEstimator } from "./catalog"; import { moduleLocks } from "./gating"; import { json, notFound, param } from "./http"; const isMarkdown = (p: string) => /\.(md|markdown)$/i.test(p); export function lessonFiles(m: Manifest): Record { const out: Record = {}; for (const mod of m.modules) for (const l of mod.lessons) if (l.file && !l.anchor && isMarkdown(l.file) && !(l.file in out)) out[l.file] = l.id; return out; } export function lessonRoutes(ctx: ServerContext): Feature["routes"] { const minutesFor = createEstimator(ctx); const read = async (courseId: string, rel: string): Promise => { const abs = ctx.courses.resolvePath(courseId, rel); if (!abs) return null; try { return await Bun.file(abs).text(); } catch { return null; } }; const fileRef = (courseId: string, file: string, title?: string): FileRef => { const abs = ctx.courses.resolvePath(courseId, file); let size: number | null = null; try { if (abs) size = statSync(abs).size; } catch { /* missing */ } return { title: title ?? file.split("/").pop()!, file, url: mediaUrl(courseId, file), size }; }; return { "/api/courses/:courseId/lessons/:lessonId": { GET: async (_req, rc) => { const found = await courseFor(ctx, rc.user, param(rc, "courseId")); if (!found) return notFound(); const { course, manifest: m } = found; const lessonId = param(rc, "lessonId"); const entry = course.lessonIndex.get(lessonId); if (!entry) return notFound(); const { lesson: l } = entry; const modIndex = m.modules.findIndex((x) => x.id === entry.moduleId); const mod = m.modules[modIndex]!; const flat: { l: Lesson; mi: number }[] = m.modules.flatMap((x, mi) => x.lessons.map((ll) => ({ l: ll, mi }))); const pos = flat.findIndex((x) => x.l.id === l.id); const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {}; const locks = moduleLocks(m, progress, found.canEdit); const link = (x: { l: Lesson; mi: number } | undefined) => (x ? { id: x.l.id, title: x.l.title } : null); const est = await minutesFor(course); const base: LessonPayload = { courseId: m.id, courseTitle: m.title, lesson: { id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null }, module: { id: mod.id, title: mod.title, index: modIndex }, locked: locks[modIndex]!, markdown: null, baseFile: null, trackMarkdown: {}, tracks: m.tracks ?? [], video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null, prev: link(flat[pos - 1]), next: link(flat[pos + 1]), lessonFiles: lessonFiles(m), }; if (base.locked) return json(base); if (l.type === "video") { if (l.file) base.video = { url: mediaUrl(m.id, l.file), file: l.file, duration: l.duration ?? null }; if (l.transcript && isMarkdown(l.transcript)) { base.transcript = await read(m.id, l.transcript); base.baseFile = l.transcript; } } else if (l.file && isMarkdown(l.file)) { const text = await read(m.id, l.file); base.markdown = text === null ? null : l.anchor ? extractSection(text, l.anchor) : text; base.baseFile = l.file; } for (const [track, t] of Object.entries(l.tracks ?? {})) { if (t.file && isMarkdown(t.file)) { const text = await read(m.id, t.file); if (text !== null) base.trackMarkdown[track] = l.anchor ? extractSection(text, l.anchor) ?? text : text; } } if (l.hints) { const text = await read(m.id, l.hints.file); if (text !== null) base.hints = extractHints(text, l.hints.match); } const first = mod.lessons[0]?.id === l.id; base.decks = (m.decks ?? []) .filter((d) => d.attachTo === l.id || (first && d.attachTo === mod.id)) .filter((d) => ctx.courses.resolvePath(m.id, d.file)) .map((d) => ({ id: d.id, title: d.title ?? d.file.split("/").pop()!.replace(/\.pdf$/i, ""), url: mediaUrl(m.id, d.file), attachTo: d.attachTo })); if (mod.code) base.code = { moduleId: mod.id, dir: mod.code.dir, command: mod.code.command ?? null }; base.attachments = (m.attachments ?? []) .filter((a) => !a.module || a.module === mod.id) .filter((a) => ctx.courses.resolvePath(m.id, a.file)) .map((a) => fileRef(m.id, a.file, a.title)); if (l.browser) { const starter = await read(m.id, l.browser.starter); const tests = await read(m.id, l.browser.tests); if (starter !== null && tests !== null) { base.browser = { language: l.browser.language, starter, tests, starterFile: l.browser.starter.split("/").pop()!, // the solution is visible to authors only (spec section 5) solution: found.canEdit && l.browser.solution ? await read(m.id, l.browser.solution) : null, }; } } const trackCommands = Object.fromEntries(Object.entries(l.tracks ?? {}).filter(([, t]) => t.command).map(([k, t]) => [k, t.command!])); base.localCommand = l.localCommand ?? (Object.keys(trackCommands).length ? trackCommands : null); return json(base); }, }, }; }