// Phase 6A §3 — loads a module_activity's Markdown content file. Same read-only cache pattern // as curriculum.js (scenarios/bundles): loaded from disk, cached, exposed via a reload for // tests/dev. Long-form lesson prose lives in git-tracked Markdown (content/root/...), not // Postgres — see docs/COURSE_CONTENT_MODEL.md for why. // // A single convention splits shared (self-paced-visible) content from instructor-only content // in the SAME file, instead of two files that can drift apart: everything before the first // "## Instructor Notes" heading is shared; that heading and everything after it is // instructor-only. Self-paced/student responses never include the instructor_notes field // regardless of any request parameter — this mirrors the RBAC convention used everywhere else // in this codebase (identity decides visibility, never a client-supplied flag). const fs = require('fs'); const path = require('path'); // Same convention as curriculum.js's SCENARIOS_DIR: one level up from src/, where the // Dockerfile COPYs both scenarios/ and content/ (see platform/api/Dockerfile) — 'content/...' // paths stored in module_activities.content_path resolve against this, not the actual repo // root, since that's what the running image actually has on disk. const ROOT_DIR = process.env.CONTENT_ROOT_DIR || path.join(__dirname, '..'); const INSTRUCTOR_HEADING = /\n## Instructor Notes\b/; const cache = new Map(); // content_path -> { shared, instructorNotes } | null (missing file) function splitContent(raw) { const match = raw.match(INSTRUCTOR_HEADING); if (!match) return { shared: raw.trim(), instructorNotes: null }; return { shared: raw.slice(0, match.index).trim(), instructorNotes: raw.slice(match.index).replace(/^\n/, '').trim(), }; } // contentPath is always a repo-relative path (module_activities.content_path, e.g. // 'content/root/root-10-.../m01-health-probes/01-lesson-....md') — resolved against the repo // root, never against anything a request supplies, so there's no path-traversal surface here. function loadContent(contentPath) { if (!contentPath) return null; if (cache.has(contentPath)) return cache.get(contentPath); const abs = path.join(ROOT_DIR, contentPath); let result = null; try { result = splitContent(fs.readFileSync(abs, 'utf8')); } catch { result = null; // missing file — a PARTIAL/PLANNED activity may legitimately have no content yet } cache.set(contentPath, result); return result; } function reloadCache() { cache.clear(); } module.exports = { loadContent, reloadCache };