Render blog posts from Forgejo
This commit is contained in:
parent
6671a01d26
commit
0a9b052beb
6 changed files with 366 additions and 48 deletions
|
|
@ -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:
|
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.
|
- Add user profiles and progress tracking.
|
||||||
- Improve course navigation for longer curricula.
|
- Improve course navigation for longer curricula.
|
||||||
- Add search across courses, posts, and discussions.
|
- Add search across courses, posts, and discussions.
|
||||||
|
|
|
||||||
|
|
@ -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): {
|
function parseLessonRoute(pathname: string): {
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: 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(
|
function findLessonByRoute(
|
||||||
course: CourseCard,
|
course: CourseCard,
|
||||||
route: { chapter: string; lesson: string },
|
route: { chapter: string; lesson: string },
|
||||||
|
|
@ -356,15 +390,23 @@ function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCa
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostItem(props: { post: PostCard }) {
|
function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void }) {
|
||||||
const { post } = props;
|
const { post, onOpenPost } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="card">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="card post-card-button"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenPost(post);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3>{post.title}</h3>
|
<h3>{post.title}</h3>
|
||||||
<p className="muted-copy">{post.summary}</p>
|
<p className="muted-copy">{post.summary}</p>
|
||||||
<p className="meta-line">{post.repo}</p>
|
<p className="meta-line">
|
||||||
</article>
|
{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: {
|
function DiscussionPage(props: {
|
||||||
discussion: DiscussionCard;
|
discussion: DiscussionCard;
|
||||||
auth: AuthState;
|
auth: AuthState;
|
||||||
|
|
@ -739,9 +815,10 @@ function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: nu
|
||||||
function HomeView(props: {
|
function HomeView(props: {
|
||||||
data: PrototypeData;
|
data: PrototypeData;
|
||||||
onOpenCourse: (course: CourseCard) => void;
|
onOpenCourse: (course: CourseCard) => void;
|
||||||
|
onOpenPost: (post: PostCard) => void;
|
||||||
onOpenDiscussion: (id: number) => void;
|
onOpenDiscussion: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data, onOpenCourse, onOpenDiscussion } = props;
|
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -761,7 +838,7 @@ function HomeView(props: {
|
||||||
{data.recent_posts.length > 0 ? (
|
{data.recent_posts.length > 0 ? (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{data.recent_posts.map((post) => (
|
{data.recent_posts.map((post) => (
|
||||||
<PostItem key={`${post.repo}:${post.title}`} post={post} />
|
<PostItem key={`${post.repo}:${post.slug}`} post={post} onOpenPost={onOpenPost} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -886,6 +963,10 @@ function coursePath(course: CourseCard): string {
|
||||||
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`;
|
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[] {
|
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
|
||||||
const activities: ActivityEntry[] = [];
|
const activities: ActivityEntry[] = [];
|
||||||
|
|
||||||
|
|
@ -904,11 +985,11 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
|
||||||
for (const post of data.recent_posts) {
|
for (const post of data.recent_posts) {
|
||||||
if (post.updated_at) {
|
if (post.updated_at) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `post:${post.repo}`,
|
id: `post:${post.repo}:${post.slug}`,
|
||||||
title: `Post repo updated: ${post.title}`,
|
title: `Post updated: ${post.title}`,
|
||||||
detail: post.repo,
|
detail: post.repo,
|
||||||
timestamp: post.updated_at,
|
timestamp: post.updated_at,
|
||||||
route: null,
|
route: postPath(post),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -991,6 +1072,7 @@ interface AppContentProps {
|
||||||
data: PrototypeData;
|
data: PrototypeData;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
onOpenCourse: (course: CourseCard) => void;
|
onOpenCourse: (course: CourseCard) => void;
|
||||||
|
onOpenPost: (post: PostCard) => void;
|
||||||
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
||||||
onOpenDiscussion: (id: number) => void;
|
onOpenDiscussion: (id: number) => void;
|
||||||
onOpenRoute: (route: string) => void;
|
onOpenRoute: (route: string) => void;
|
||||||
|
|
@ -1001,6 +1083,24 @@ interface AppContentProps {
|
||||||
onGoDiscussions: () => void;
|
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(
|
function LessonRouteView(
|
||||||
props: AppContentProps & {
|
props: AppContentProps & {
|
||||||
route: { owner: string; repo: string; chapter: string; lesson: string };
|
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} />;
|
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);
|
const lessonRoute = parseLessonRoute(props.pathname);
|
||||||
if (lessonRoute !== null) {
|
if (lessonRoute !== null) {
|
||||||
return <LessonRouteView {...props} route={lessonRoute} />;
|
return <LessonRouteView {...props} route={lessonRoute} />;
|
||||||
|
|
@ -1129,6 +1234,7 @@ function AppContent(props: AppContentProps) {
|
||||||
<HomeView
|
<HomeView
|
||||||
data={props.data}
|
data={props.data}
|
||||||
onOpenCourse={props.onOpenCourse}
|
onOpenCourse={props.onOpenCourse}
|
||||||
|
onOpenPost={props.onOpenPost}
|
||||||
onOpenDiscussion={props.onOpenDiscussion}
|
onOpenDiscussion={props.onOpenDiscussion}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -1160,6 +1266,7 @@ function LoadedApp(
|
||||||
data={props.data}
|
data={props.data}
|
||||||
pathname={props.pathname}
|
pathname={props.pathname}
|
||||||
onOpenCourse={props.onOpenCourse}
|
onOpenCourse={props.onOpenCourse}
|
||||||
|
onOpenPost={props.onOpenPost}
|
||||||
onOpenLesson={props.onOpenLesson}
|
onOpenLesson={props.onOpenLesson}
|
||||||
onOpenDiscussion={props.onOpenDiscussion}
|
onOpenDiscussion={props.onOpenDiscussion}
|
||||||
onOpenRoute={props.onOpenRoute}
|
onOpenRoute={props.onOpenRoute}
|
||||||
|
|
@ -1214,30 +1321,24 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function openDiscussion(id: number) {
|
const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
|
||||||
navigate(`/discussions/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goSignIn() {
|
function goSignIn() {
|
||||||
window.location.assign(forgejoSignInUrl(pathname));
|
window.location.assign(forgejoSignInUrl(pathname));
|
||||||
}
|
}
|
||||||
|
|
||||||
function goCourses() {
|
const goCourses = () => navigate("/courses");
|
||||||
navigate("/courses");
|
|
||||||
}
|
|
||||||
|
|
||||||
function goDiscussions() {
|
const goDiscussions = () => navigate("/discussions");
|
||||||
navigate("/discussions");
|
|
||||||
}
|
|
||||||
|
|
||||||
function goActivity() {
|
const goActivity = () => navigate("/activity");
|
||||||
navigate("/activity");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCourse(course: CourseCard) {
|
function openCourse(course: CourseCard) {
|
||||||
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
|
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openPost = (post: PostCard) => navigate(postPath(post));
|
||||||
|
|
||||||
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
|
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
|
||||||
navigate(
|
navigate(
|
||||||
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
|
`/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));
|
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
const goHome = () => navigate("/");
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await fetch("/api/auth/session", {
|
await fetch("/api/auth/session", {
|
||||||
|
|
@ -1273,6 +1372,7 @@ export default function App() {
|
||||||
data={data}
|
data={data}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
onOpenCourse={openCourse}
|
onOpenCourse={openCourse}
|
||||||
|
onOpenPost={openPost}
|
||||||
onOpenLesson={openLesson}
|
onOpenLesson={openLesson}
|
||||||
onOpenDiscussion={openDiscussion}
|
onOpenDiscussion={openDiscussion}
|
||||||
onOpenRoute={navigate}
|
onOpenRoute={navigate}
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,8 @@ textarea {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-card-button {
|
.course-card-button,
|
||||||
|
.post-card-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -276,7 +277,9 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-card-button:hover,
|
.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);
|
background: var(--panel-hover);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,16 @@ export interface CourseLesson {
|
||||||
|
|
||||||
export interface PostCard {
|
export interface PostCard {
|
||||||
title: string;
|
title: string;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
|
slug: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
path: string;
|
||||||
|
file_path: string;
|
||||||
|
html_url: string;
|
||||||
|
body: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,11 @@ async def build_live_prototype_payload(
|
||||||
content_repos = [summary for summary in repo_summaries if summary is not None]
|
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]
|
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]
|
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(
|
public_issues = await _recent_public_issues(
|
||||||
client,
|
client,
|
||||||
public_repos,
|
public_repos,
|
||||||
|
|
@ -128,7 +133,7 @@ async def build_live_prototype_payload(
|
||||||
),
|
),
|
||||||
"source_of_truth": source_cards,
|
"source_of_truth": source_cards,
|
||||||
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
|
"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),
|
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
|
||||||
"recent_discussions": await asyncio.gather(
|
"recent_discussions": await asyncio.gather(
|
||||||
*[_discussion_card(client, issue) for issue in public_issues],
|
*[_discussion_card(client, issue) for issue in public_issues],
|
||||||
|
|
@ -232,9 +237,25 @@ async def _summarize_repo(
|
||||||
)
|
)
|
||||||
|
|
||||||
blog_count = 0
|
blog_count = 0
|
||||||
|
blog_posts: list[dict[str, object]] = []
|
||||||
if has_blogs:
|
if has_blogs:
|
||||||
blog_entries = await client.list_directory(owner_login, repo_name, "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 {
|
return {
|
||||||
"name": repo_name,
|
"name": repo_name,
|
||||||
|
|
@ -245,6 +266,7 @@ async def _summarize_repo(
|
||||||
"lesson_count": lesson_count,
|
"lesson_count": lesson_count,
|
||||||
"chapter_count": chapter_count,
|
"chapter_count": chapter_count,
|
||||||
"blog_count": blog_count,
|
"blog_count": blog_count,
|
||||||
|
"blog_posts": blog_posts,
|
||||||
"updated_at": repo.get("updated_at", ""),
|
"updated_at": repo.get("updated_at", ""),
|
||||||
"course_outline": course_outline,
|
"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]:
|
def _post_card(post: 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"
|
|
||||||
return {
|
return {
|
||||||
"title": summary["name"],
|
"title": post["title"],
|
||||||
"repo": summary["full_name"],
|
"owner": post["owner"],
|
||||||
"kind": "Repo with /blogs/",
|
"name": post["name"],
|
||||||
"summary": f"{label}. {summary['description']}",
|
"repo": post["repo"],
|
||||||
"updated_at": summary["updated_at"],
|
"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(
|
async def _summarize_lesson(
|
||||||
client: ForgejoClient,
|
client: ForgejoClient,
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|
@ -489,16 +590,7 @@ async def _summarize_lesson(
|
||||||
except ForgejoClientError:
|
except ForgejoClientError:
|
||||||
return _empty_lesson(lesson_name, fallback_title, lesson_path)
|
return _empty_lesson(lesson_name, fallback_title, lesson_path)
|
||||||
|
|
||||||
markdown_files = sorted(
|
markdown_files = _markdown_file_entries(lesson_entries)
|
||||||
[
|
|
||||||
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"]),
|
|
||||||
)
|
|
||||||
if not markdown_files:
|
if not markdown_files:
|
||||||
return _empty_lesson(lesson_name, fallback_title, lesson_path)
|
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:
|
def _display_name(value: str) -> str:
|
||||||
cleaned = value.strip().rsplit(".", 1)[0]
|
cleaned = value.strip().rsplit(".", 1)[0]
|
||||||
cleaned = cleaned.replace("_", " ").replace("-", " ")
|
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]:
|
def _parse_frontmatter(markdown: str) -> tuple[dict[str, str], str]:
|
||||||
if not markdown.startswith("---\n"):
|
if not markdown.startswith("---\n"):
|
||||||
return {}, markdown.strip()
|
return {}, markdown.strip()
|
||||||
|
|
|
||||||
75
tests/test_live_prototype.py
Normal file
75
tests/test_live_prototype.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue