/** * pages.ts — the shell pages: landing (all classrooms), classroom (its lessons and * documents), and the markdown document view. * * Pure string builders — they take already-read data and return HTML, so they are * testable without a server or a filesystem. */ import { escapeHtml, renderMarkdown } from "./markdown.js"; import type { Classroom, Lesson } from "./store.js"; // ── Shell ───────────────────────────────────────────────────────────────────── interface ShellOptions { title: string; /** Rendered into the sticky header, left of the theme toggle. */ breadcrumb: string; body: string; /** Page-level class applied to , for layout variants. */ bodyClass?: string; } /** * Wrap page content in the common document shell. * * The theme bootstrap runs inline in deliberately: reading localStorage after * the stylesheet has painted would flash the light theme at dark-mode readers. */ export function shell(options: ShellOptions): string { return ` ${escapeHtml(options.title)}
${options.body}
`; } // ── Landing page ────────────────────────────────────────────────────────────── export function landingPage(classrooms: Classroom[]): string { const body = classrooms.length === 0 ? emptyState( "No classrooms yet", "Run /teach <topic> in your Pi session and your first classroom will appear here.", ) : `

Classrooms

Pick up where you left off, or start something new with /teach.

`; return shell({ title: "Classrooms", breadcrumb: `Classrooms`, body, }); } function classroomCard(classroom: Classroom): string { const why = missionWhy(classroom.mission); return `
  • ${escapeHtml(classroom.title)} ${why ? `${escapeHtml(why)}` : ""} ${plural(classroom.lessonCount, "lesson")}
  • `; } /** * Pull the "## Why" paragraph out of MISSION.md for the card subtitle. * * The mission is the reason the learner cares about the topic, so it is the single * most useful line to show on a card — far better than a truncated lesson title. */ export function missionWhy(mission: string | null): string | null { if (!mission) return null; const lines = mission.split("\n"); const start = lines.findIndex((line) => /^##\s+why\s*$/i.test(line.trim())); if (start === -1) return null; const body: string[] = []; for (const line of lines.slice(start + 1)) { if (/^##\s/.test(line)) break; body.push(line); } const firstParagraph = body .join("\n") .trim() .split(/\n\s*\n/)[0] ?.replace(/\s+/g, " ") .trim(); if (!firstParagraph) return null; return firstParagraph.length > 220 ? firstParagraph.slice(0, 217).trimEnd() + "…" : firstParagraph; } // ── Classroom page ──────────────────────────────────────────────────────────── export interface ClassroomPageData { classroom: Classroom; lessons: Lesson[]; docs: string[]; learningRecords: string[]; referenceDocs: string[]; } export function classroomPage(data: ClassroomPageData): string { const { classroom, lessons } = data; const lessonList = lessons.length === 0 ? emptyState( "No lessons yet", "Ask your teacher for the next lesson in your Pi session — it will show up here.", ) : `
      ${lessons.map((lesson, i) => lessonRow(lesson, i)).join("\n")}
    `; const body = `

    ${escapeHtml(classroom.title)}

    ${classroom.mission ? `

    ${escapeHtml(missionWhy(classroom.mission) ?? "")}

    ` : ""}

    Lessons

    ${lessonList}
    ${sidePanel(data)}`; return shell({ title: `${classroom.title} — Classrooms`, breadcrumb: `Classrooms${escapeHtml(classroom.title)}`, body, }); } function lessonRow(lesson: Lesson, index: number): string { const href = `/c/${encodeURIComponent(lesson.classroom)}/${encodeURIComponent(lesson.name)}`; const badges: string[] = []; if (lesson.latestScore !== null) { badges.push( `${Math.round(lesson.latestScore)}%`, ); } if (lesson.hasUngradedSubmission) { badges.push(`grading…`); } if (lesson.annotationCount > 0) { badges.push( `${lesson.annotationCount} Q`, ); } return `
  • ${String(lesson.order ?? index + 1).padStart(2, "0")} ${escapeHtml(lesson.title)} ${lesson.summary ? `${escapeHtml(lesson.summary)}` : ""} ${badges.join("")}
  • `; } function sidePanel(data: ClassroomPageData): string { const { classroom, docs, learningRecords, referenceDocs } = data; const name = encodeURIComponent(classroom.name); const sections: string[] = []; if (docs.length > 0) { sections.push( panel( "Workspace", docs.map( (doc) => `${escapeHtml(doc)}`, ), ), ); } if (referenceDocs.length > 0) { sections.push( panel( "Reference", referenceDocs.map( (file) => `${escapeHtml(prettyFileName(file))}`, ), ), ); } if (learningRecords.length > 0) { sections.push( panel( "Learning records", learningRecords.map( (file) => `${escapeHtml(prettyFileName(file))}`, ), ), ); } if (sections.length === 0) return ""; return `
    \n${sections.join("\n")}\n
    `; } function panel(title: string, links: string[]): string { return `

    ${escapeHtml(title)}

    `; } /** `0003-closures-capture-by-value.md` → `Closures capture by value`. */ export function prettyFileName(file: string): string { const base = file.replace(/\.(md|html)$/i, "").replace(/^\d+[-_]/, ""); const words = base.split(/[-_]/).filter(Boolean).join(" "); return words.charAt(0).toUpperCase() + words.slice(1); } // ── Markdown document page ──────────────────────────────────────────────────── export function documentPage(classroom: Classroom, fileName: string, markdown: string): string { return shell({ title: `${prettyFileName(fileName)} — ${classroom.title}`, breadcrumb: `Classrooms${escapeHtml(classroom.title)}${escapeHtml(prettyFileName(fileName))}`, body: `
    \n${renderMarkdown(markdown)}\n
    `, }); } // ── Bits ────────────────────────────────────────────────────────────────────── function emptyState(title: string, html: string): string { return `

    ${escapeHtml(title)}

    ${html}

    `; } export function plural(n: number, noun: string): string { return `${n} ${noun}${n === 1 ? "" : "s"}`; } export function notFoundPage(message: string): string { return shell({ title: "Not found — Classrooms", breadcrumb: `Classrooms`, body: emptyState("Not found", escapeHtml(message)), }); }