diff --git a/blogs/building-robot-u-site/index.md b/blogs/building-robot-u-site/index.md index 806cd9f..e3b49d8 100644 --- a/blogs/building-robot-u-site/index.md +++ b/blogs/building-robot-u-site/index.md @@ -60,7 +60,7 @@ Writing is different. Replies and future content actions should require a real F This prototype is enough to prove the shape of the system, but there is plenty left to improve: -- Render individual blog posts as first-class pages. +- Add a dedicated post index for browsing the full archive. - Add user profiles and progress tracking. - Improve course navigation for longer curricula. - Add search across courses, posts, and discussions. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a9494b..bb6644c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,6 +77,19 @@ function parseCourseRoute(pathname: string): { owner: string; repo: string } | n }; } +function parsePostRoute(pathname: string): { owner: string; repo: string; post: string } | null { + const match = normalizePathname(pathname).match(/^\/posts\/([^/]+)\/([^/]+)\/([^/]+)$/); + if (!match) { + return null; + } + + return { + owner: decodeURIComponent(match[1]), + repo: decodeURIComponent(match[2]), + post: decodeURIComponent(match[3]), + }; +} + function parseLessonRoute(pathname: string): { owner: string; repo: string; @@ -124,6 +137,27 @@ function findCourseByRoute( }); } +function findPostByRoute( + posts: PostCard[], + route: { owner: string; repo: string; post: string }, +): PostCard | undefined { + const routeOwner = normalizeRouteKey(route.owner); + const routeRepo = normalizeRouteKey(route.repo); + const routePost = normalizeRouteKey(route.post); + const routeFullName = `${routeOwner}/${routeRepo}`; + + return posts.find((post) => { + const postOwner = normalizeRouteKey(post.owner); + const postRepo = normalizeRouteKey(post.name); + const postFullName = normalizeRouteKey(post.repo); + return ( + postOwner === routeOwner && + (postRepo === routeRepo || postFullName === routeFullName) && + normalizeRouteKey(post.slug) === routePost + ); + }); +} + function findLessonByRoute( course: CourseCard, route: { chapter: string; lesson: string }, @@ -356,15 +390,23 @@ function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCa ); } -function PostItem(props: { post: PostCard }) { - const { post } = props; +function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void }) { + const { post, onOpenPost } = props; return ( -
+
+

+ {post.repo} · {post.file_path || post.path} +

+ ); } @@ -637,6 +679,40 @@ function LessonPage(props: { ); } +function PostPage(props: { post: PostCard; onGoHome: () => void }) { + const { post, onGoHome } = props; + const postBody = stripLeadingTitleHeading(post.body, post.title); + + return ( +
+ + +
+
+

{post.title}

+

+ {post.repo} · {formatTimestamp(post.updated_at)} +

+
+ {post.summary ?

{post.summary}

: null} +
+ +
+
+

Post

+
+ {postBody ? ( + + ) : ( + + )} +
+
+ ); +} + function DiscussionPage(props: { discussion: DiscussionCard; auth: AuthState; @@ -739,9 +815,10 @@ function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: nu function HomeView(props: { data: PrototypeData; onOpenCourse: (course: CourseCard) => void; + onOpenPost: (post: PostCard) => void; onOpenDiscussion: (id: number) => void; }) { - const { data, onOpenCourse, onOpenDiscussion } = props; + const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props; return ( <> @@ -761,7 +838,7 @@ function HomeView(props: { {data.recent_posts.length > 0 ? (
{data.recent_posts.map((post) => ( - + ))}
) : ( @@ -886,6 +963,10 @@ function coursePath(course: CourseCard): string { return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`; } +function postPath(post: PostCard): string { + return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`; +} + function buildActivityFeed(data: PrototypeData): ActivityEntry[] { const activities: ActivityEntry[] = []; @@ -904,11 +985,11 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] { for (const post of data.recent_posts) { if (post.updated_at) { activities.push({ - id: `post:${post.repo}`, - title: `Post repo updated: ${post.title}`, + id: `post:${post.repo}:${post.slug}`, + title: `Post updated: ${post.title}`, detail: post.repo, timestamp: post.updated_at, - route: null, + route: postPath(post), }); } } @@ -991,6 +1072,7 @@ interface AppContentProps { data: PrototypeData; pathname: string; onOpenCourse: (course: CourseCard) => void; + onOpenPost: (post: PostCard) => void; onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void; onOpenDiscussion: (id: number) => void; onOpenRoute: (route: string) => void; @@ -1001,6 +1083,24 @@ interface AppContentProps { onGoDiscussions: () => void; } +function PostRouteView( + props: AppContentProps & { route: { owner: string; repo: string; post: string } }, +) { + const selectedPost = findPostByRoute(props.data.recent_posts, props.route); + if (!selectedPost) { + return ( +
+

Post not found.

+ +
+ ); + } + + return ; +} + function LessonRouteView( props: AppContentProps & { route: { owner: string; repo: string; chapter: string; lesson: string }; @@ -1113,6 +1213,11 @@ function AppContent(props: AppContentProps) { return ; } + const postRoute = parsePostRoute(props.pathname); + if (postRoute !== null) { + return ; + } + const lessonRoute = parseLessonRoute(props.pathname); if (lessonRoute !== null) { return ; @@ -1129,6 +1234,7 @@ function AppContent(props: AppContentProps) { ); @@ -1160,6 +1266,7 @@ function LoadedApp( data={props.data} pathname={props.pathname} onOpenCourse={props.onOpenCourse} + onOpenPost={props.onOpenPost} onOpenLesson={props.onOpenLesson} onOpenDiscussion={props.onOpenDiscussion} onOpenRoute={props.onOpenRoute} @@ -1214,30 +1321,24 @@ export default function App() { }; }, []); - function openDiscussion(id: number) { - navigate(`/discussions/${id}`); - } + const openDiscussion = (id: number) => navigate(`/discussions/${id}`); function goSignIn() { window.location.assign(forgejoSignInUrl(pathname)); } - function goCourses() { - navigate("/courses"); - } + const goCourses = () => navigate("/courses"); - function goDiscussions() { - navigate("/discussions"); - } + const goDiscussions = () => navigate("/discussions"); - function goActivity() { - navigate("/activity"); - } + const goActivity = () => navigate("/activity"); function openCourse(course: CourseCard) { navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`); } + const openPost = (post: PostCard) => navigate(postPath(post)); + function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) { navigate( `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`, @@ -1248,9 +1349,7 @@ export default function App() { setData((currentData) => appendDiscussionReply(currentData, discussionId, reply)); } - function goHome() { - navigate("/"); - } + const goHome = () => navigate("/"); async function signOut() { await fetch("/api/auth/session", { @@ -1273,6 +1372,7 @@ export default function App() { data={data} pathname={pathname} onOpenCourse={openCourse} + onOpenPost={openPost} onOpenLesson={openLesson} onOpenDiscussion={openDiscussion} onOpenRoute={navigate} diff --git a/frontend/src/index.css b/frontend/src/index.css index a45911a..c757fc5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -268,7 +268,8 @@ textarea { padding: 1rem; } -.course-card-button { +.course-card-button, +.post-card-button { width: 100%; cursor: pointer; text-align: left; @@ -276,7 +277,9 @@ textarea { } .course-card-button:hover, -.course-card-button:focus-visible { +.course-card-button:focus-visible, +.post-card-button:hover, +.post-card-button:focus-visible { background: var(--panel-hover); outline: none; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 02257ff..8e69d0b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -50,9 +50,16 @@ export interface CourseLesson { export interface PostCard { title: string; + owner: string; + name: string; repo: string; + slug: string; kind: string; summary: string; + path: string; + file_path: string; + html_url: string; + body: string; updated_at: string; } diff --git a/live_prototype.py b/live_prototype.py index f45e817..d52658b 100644 --- a/live_prototype.py +++ b/live_prototype.py @@ -83,6 +83,11 @@ async def build_live_prototype_payload( content_repos = [summary for summary in repo_summaries if summary is not None] course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0] post_repos = [summary for summary in content_repos if summary["blog_count"] > 0] + blog_posts = sorted( + [post for summary in post_repos for post in summary["blog_posts"]], + key=lambda post: str(post.get("updated_at", "")), + reverse=True, + ) public_issues = await _recent_public_issues( client, public_repos, @@ -128,7 +133,7 @@ async def build_live_prototype_payload( ), "source_of_truth": source_cards, "featured_courses": [_course_card(summary) for summary in course_repos[:6]], - "recent_posts": [_post_card(summary) for summary in post_repos[:6]], + "recent_posts": [_post_card(post) for post in blog_posts[:6]], "upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit), "recent_discussions": await asyncio.gather( *[_discussion_card(client, issue) for issue in public_issues], @@ -232,9 +237,25 @@ async def _summarize_repo( ) blog_count = 0 + blog_posts: list[dict[str, object]] = [] if has_blogs: blog_entries = await client.list_directory(owner_login, repo_name, "blogs") - blog_count = sum(1 for entry in blog_entries if entry.get("type") == "dir") + blog_dirs = _sorted_dir_entries(blog_entries) + blog_count = len(blog_dirs) + blog_posts = await asyncio.gather( + *[ + _summarize_blog_post( + client, + owner_login, + repo_name, + str(repo.get("full_name", f"{owner_login}/{repo_name}")), + str(repo.get("description") or ""), + str(repo.get("updated_at", "")), + str(blog_dir.get("name", "")), + ) + for blog_dir in blog_dirs + ], + ) return { "name": repo_name, @@ -245,6 +266,7 @@ async def _summarize_repo( "lesson_count": lesson_count, "chapter_count": chapter_count, "blog_count": blog_count, + "blog_posts": blog_posts, "updated_at": repo.get("updated_at", ""), "course_outline": course_outline, } @@ -266,15 +288,20 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]: } -def _post_card(summary: dict[str, Any]) -> dict[str, object]: - post_count = int(summary["blog_count"]) - label = "1 post folder detected" if post_count == 1 else f"{post_count} post folders detected" +def _post_card(post: dict[str, Any]) -> dict[str, object]: return { - "title": summary["name"], - "repo": summary["full_name"], - "kind": "Repo with /blogs/", - "summary": f"{label}. {summary['description']}", - "updated_at": summary["updated_at"], + "title": post["title"], + "owner": post["owner"], + "name": post["name"], + "repo": post["repo"], + "slug": post["slug"], + "kind": "Blog post", + "summary": post["summary"], + "path": post["path"], + "file_path": post["file_path"], + "html_url": post["html_url"], + "body": post["body"], + "updated_at": post["updated_at"], } @@ -474,6 +501,80 @@ def _auth_payload( } +async def _summarize_blog_post( + client: ForgejoClient, + owner: str, + repo: str, + full_name: str, + repo_description: str, + updated_at: str, + post_name: str, +) -> dict[str, object]: + post_path = f"blogs/{post_name}" + fallback_title = _display_name(post_name) + + try: + post_entries = await client.list_directory(owner, repo, post_path) + except ForgejoClientError: + return _empty_blog_post( + owner, + repo, + full_name, + post_name, + fallback_title, + repo_description, + updated_at, + post_path, + ) + + markdown_files = _markdown_file_entries(post_entries) + if not markdown_files: + return _empty_blog_post( + owner, + repo, + full_name, + post_name, + fallback_title, + repo_description, + updated_at, + post_path, + ) + + markdown_name = str(markdown_files[0]["name"]) + markdown_path = f"{post_path}/{markdown_name}" + + try: + file_payload = await client.get_file_content(owner, repo, markdown_path) + except ForgejoClientError: + return _empty_blog_post( + owner, + repo, + full_name, + post_name, + fallback_title, + repo_description, + updated_at, + post_path, + file_path=markdown_path, + html_url=str(markdown_files[0].get("html_url", "")), + ) + + metadata, body = _parse_frontmatter(str(file_payload.get("content", ""))) + return { + "slug": post_name, + "title": str(metadata.get("title") or _display_name(markdown_name) or fallback_title), + "owner": owner, + "name": repo, + "repo": full_name, + "summary": str(metadata.get("summary") or repo_description or ""), + "path": post_path, + "file_path": str(file_payload.get("path", markdown_path)), + "html_url": str(file_payload.get("html_url", "")), + "body": body, + "updated_at": updated_at, + } + + async def _summarize_lesson( client: ForgejoClient, owner: str, @@ -489,16 +590,7 @@ async def _summarize_lesson( except ForgejoClientError: return _empty_lesson(lesson_name, fallback_title, lesson_path) - markdown_files = sorted( - [ - entry - for entry in lesson_entries - if entry.get("type") == "file" - and isinstance(entry.get("name"), str) - and str(entry.get("name", "")).lower().endswith(".md") - ], - key=lambda entry: str(entry["name"]), - ) + markdown_files = _markdown_file_entries(lesson_entries) if not markdown_files: return _empty_lesson(lesson_name, fallback_title, lesson_path) @@ -539,6 +631,19 @@ def _sorted_dir_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: ) +def _markdown_file_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + [ + entry + for entry in entries + if entry.get("type") == "file" + and isinstance(entry.get("name"), str) + and str(entry.get("name", "")).lower().endswith(".md") + ], + key=lambda entry: str(entry["name"]), + ) + + def _display_name(value: str) -> str: cleaned = value.strip().rsplit(".", 1)[0] cleaned = cleaned.replace("_", " ").replace("-", " ") @@ -598,6 +703,34 @@ def _empty_lesson( } +def _empty_blog_post( + owner: str, + repo: str, + full_name: str, + post_name: str, + title: str, + summary: str, + updated_at: str, + post_path: str, + *, + file_path: str = "", + html_url: str = "", +) -> dict[str, object]: + return { + "slug": post_name, + "title": title, + "owner": owner, + "name": repo, + "repo": full_name, + "summary": summary, + "path": post_path, + "file_path": file_path, + "html_url": html_url, + "body": "", + "updated_at": updated_at, + } + + def _parse_frontmatter(markdown: str) -> tuple[dict[str, str], str]: if not markdown.startswith("---\n"): return {}, markdown.strip() diff --git a/tests/test_live_prototype.py b/tests/test_live_prototype.py new file mode 100644 index 0000000..736f9bf --- /dev/null +++ b/tests/test_live_prototype.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import unittest +from typing import Any + +from live_prototype import _post_card, _summarize_repo + + +class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase): + async def test_blog_folder_becomes_renderable_post_card(self) -> None: + client = _FakeContentClient() + summary = await _summarize_repo( + client, + { + "name": "robot-u-site", + "full_name": "Robot-U/robot-u-site", + "description": "Robot U site source", + "updated_at": "2026-04-13T00:00:00Z", + "owner": {"login": "Robot-U"}, + }, + ) + + self.assertIsNotNone(summary) + assert summary is not None + self.assertEqual(summary["blog_count"], 1) + post = _post_card(summary["blog_posts"][0]) + self.assertEqual(post["title"], "Building Robot U") + self.assertEqual(post["slug"], "building-robot-u-site") + self.assertEqual(post["repo"], "Robot-U/robot-u-site") + self.assertEqual(post["path"], "blogs/building-robot-u-site") + self.assertIn("thin layer over Forgejo", post["body"]) + + +class _FakeContentClient: + async def list_directory( + self, + _owner: str, + _repo: str, + path: str = "", + ) -> list[dict[str, Any]]: + entries = { + "": [{"type": "dir", "name": "blogs"}], + "blogs": [{"type": "dir", "name": "building-robot-u-site"}], + "blogs/building-robot-u-site": [ + { + "type": "file", + "name": "index.md", + "html_url": "https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md", + }, + ], + } + return entries.get(path, []) + + async def get_file_content(self, _owner: str, _repo: str, path: str) -> dict[str, str]: + return { + "name": "index.md", + "path": path, + "html_url": "https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md", + "content": "\n".join( + [ + "---", + "title: Building Robot U", + "summary: How the site became a thin layer over Forgejo.", + "---", + "", + "# Building Robot U", + "", + "The site is a thin layer over Forgejo.", + ], + ), + } + + +if __name__ == "__main__": + unittest.main()