Render blog posts from Forgejo

This commit is contained in:
kacper 2026-04-12 20:23:05 -04:00
parent 6671a01d26
commit 0a9b052beb
6 changed files with 366 additions and 48 deletions

View file

@ -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.

View file

@ -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 (
<article className="card">
<button
type="button"
className="card post-card-button"
onClick={() => {
onOpenPost(post);
}}
>
<h3>{post.title}</h3>
<p className="muted-copy">{post.summary}</p>
<p className="meta-line">{post.repo}</p>
</article>
<p className="meta-line">
{post.repo} · {post.file_path || post.path}
</p>
</button>
);
}
@ -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 (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoHome}>
Back to home
</button>
<article className="panel">
<header className="thread-header">
<h1>{post.title}</h1>
<p className="meta-line">
{post.repo} · {formatTimestamp(post.updated_at)}
</p>
</header>
{post.summary ? <p className="muted-copy">{post.summary}</p> : null}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Post</h2>
</header>
{postBody ? (
<MarkdownContent markdown={postBody} className="thread-copy" />
) : (
<EmptyState copy="This post file is empty or could not be read from Forgejo." />
)}
</article>
</section>
);
}
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 ? (
<div className="stack">
{data.recent_posts.map((post) => (
<PostItem key={`${post.repo}:${post.title}`} post={post} />
<PostItem key={`${post.repo}:${post.slug}`} post={post} onOpenPost={onOpenPost} />
))}
</div>
) : (
@ -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 (
<section className="page-message">
<h1>Post not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to home
</button>
</section>
);
}
return <PostPage post={selectedPost} onGoHome={props.onGoHome} />;
}
function LessonRouteView(
props: AppContentProps & {
route: { owner: string; repo: string; chapter: string; lesson: string };
@ -1113,6 +1213,11 @@ function AppContent(props: AppContentProps) {
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
}
const postRoute = parsePostRoute(props.pathname);
if (postRoute !== null) {
return <PostRouteView {...props} route={postRoute} />;
}
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
return <LessonRouteView {...props} route={lessonRoute} />;
@ -1129,6 +1234,7 @@ function AppContent(props: AppContentProps) {
<HomeView
data={props.data}
onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion}
/>
);
@ -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}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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()

View file

@ -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()