import { useEffect, useState } from "preact/hooks"; import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent"; import type { CourseCard, CourseChapter, CourseLesson, DiscussionCard, DiscussionReply, EventCard, PostCard, PrototypeData, } from "./types"; function formatTimestamp(value: string): string { if (!value) { return "Unknown time"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return "Unknown time"; } return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(date); } function normalizePathname(pathname: string): string { if (!pathname || pathname === "/") { return "/"; } return pathname.replace(/\/+$/, "") || "/"; } function parseDiscussionRoute(pathname: string): number | null { const match = normalizePathname(pathname).match(/^\/discussions\/(\d+)$/); if (!match) { return null; } const issueId = Number(match[1]); return Number.isFinite(issueId) ? issueId : null; } function parseCourseRoute(pathname: string): { owner: string; repo: string } | null { const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/); if (!match) { return null; } return { owner: decodeURIComponent(match[1]), repo: decodeURIComponent(match[2]), }; } function parseLessonRoute(pathname: string): { owner: string; repo: string; chapter: string; lesson: string; } | null { const match = normalizePathname(pathname).match( /^\/courses\/([^/]+)\/([^/]+)\/lessons\/([^/]+)\/([^/]+)$/, ); if (!match) { return null; } return { owner: decodeURIComponent(match[1]), repo: decodeURIComponent(match[2]), chapter: decodeURIComponent(match[3]), lesson: decodeURIComponent(match[4]), }; } function normalizeRouteKey(value: string): string { return decodeURIComponent(value).trim().toLowerCase(); } function findCourseByRoute( courses: CourseCard[], route: { owner: string; repo: string }, ): CourseCard | undefined { const routeOwner = normalizeRouteKey(route.owner); const routeRepo = normalizeRouteKey(route.repo); const routeFullName = `${routeOwner}/${routeRepo}`; return courses.find((course) => { const [repoOwner = "", repoName = ""] = course.repo.split("/", 2); const courseOwner = normalizeRouteKey(course.owner || repoOwner); const courseName = normalizeRouteKey(course.name || repoName); const courseFullName = normalizeRouteKey(course.repo); return ( (courseOwner === routeOwner && courseName === routeRepo) || courseFullName === routeFullName || courseName === routeRepo ); }); } function findLessonByRoute( course: CourseCard, route: { chapter: string; lesson: string }, ): { chapter: CourseChapter; lesson: CourseLesson } | undefined { const routeChapter = normalizeRouteKey(route.chapter); const routeLesson = normalizeRouteKey(route.lesson); for (const chapter of course.outline) { if (normalizeRouteKey(chapter.slug) !== routeChapter) { continue; } const lesson = chapter.lessons.find((entry) => normalizeRouteKey(entry.slug) === routeLesson); if (lesson) { return { chapter, lesson }; } } return undefined; } function usePathname() { const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname)); useEffect(() => { function handlePopState() { setPathname(normalizePathname(window.location.pathname)); } window.addEventListener("popstate", handlePopState); return () => { window.removeEventListener("popstate", handlePopState); }; }, []); function navigate(nextPath: string) { const normalized = normalizePathname(nextPath); if (normalized === pathname) { return; } window.history.pushState({}, "", normalized); window.scrollTo({ top: 0, behavior: "auto" }); setPathname(normalized); } return { pathname, navigate }; } function SectionHeader(props: { title: string }) { return (

{props.title}

); } function EmptyState(props: { copy: string }) { return

{props.copy}

; } function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) { const { course, onOpenCourse } = props; return ( ); } function PostItem(props: { post: PostCard }) { const { post } = props; return (

{post.title}

{post.summary}

{post.repo}

); } function EventItem(props: { event: EventCard }) { const { event } = props; return (

{event.title}

{event.when}

{event.source}

); } function DiscussionPreviewItem(props: { discussion: DiscussionCard; onOpenDiscussion: (id: number) => void; }) { const { discussion, onOpenDiscussion } = props; return ( ); } function DiscussionReplyCard(props: { reply: DiscussionReply }) { const { reply } = props; return (

{reply.author}

{formatTimestamp(reply.created_at)}

); } function ComposeBox() { return (
{ event.preventDefault(); }} >