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.summary ? {post.summary}
: null}
+
+
+
+
+ {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()