diff --git a/.env.example b/.env.example
index 79c34f9..f3cd181 100644
--- a/.env.example
+++ b/.env.example
@@ -6,8 +6,11 @@ FORGEJO_TOKEN=
FORGEJO_OAUTH_CLIENT_ID=
FORGEJO_OAUTH_CLIENT_SECRET=
FORGEJO_OAUTH_SCOPES=openid profile
+FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum
+FORGEJO_WEBHOOK_SECRET=
FORGEJO_REPO_SCAN_LIMIT=30
-FORGEJO_RECENT_ISSUE_LIMIT=6
+FORGEJO_RECENT_ISSUE_LIMIT=50
+FORGEJO_CACHE_TTL_SECONDS=60.0
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
CALENDAR_FEED_URLS=
CALENDAR_EVENT_LIMIT=3
diff --git a/AGENTS.md b/AGENTS.md
index f4dbff8..597773a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,8 @@ It is a thin application layer over Forgejo:
- `app.py`: FastAPI app and SPA/static serving
- `live_prototype.py`: live payload assembly for courses, lessons, discussions, and events
+- `prototype_cache.py`: server-side cache for the public Forgejo content payload
+- `update_events.py`: in-process SSE broker for content update notifications
- `forgejo_client.py`: Forgejo API client
- `calendar_feeds.py`: ICS/webcal feed loading and parsing
- `settings.py`: env-driven runtime settings
@@ -69,6 +71,9 @@ Useful variables:
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile`
- `FORGEJO_TOKEN=...`
+- `FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum`
+- `FORGEJO_WEBHOOK_SECRET=...`
+- `FORGEJO_CACHE_TTL_SECONDS=60.0`
- `CALENDAR_FEED_URLS=webcal://...`
- `HOST=0.0.0.0`
- `PORT=8800`
@@ -76,8 +81,11 @@ Useful variables:
Notes:
- Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL.
-- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks.
-- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public.
+- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks for writes.
+- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback for the public content cache. Browser sessions and API token calls may write issues/comments only after verifying the target repo is public.
+- `/api/prototype` uses a server-side cache for public Forgejo content. `FORGEJO_CACHE_TTL_SECONDS=0` disables it; successful discussion replies invalidate it.
+- General discussion creation requires `FORGEJO_GENERAL_DISCUSSION_REPO`. Linked discussions are created in the content repo and include canonical app URLs in the Forgejo issue body.
+- Forgejo webhooks should POST to `/api/forgejo/webhook`; when `FORGEJO_WEBHOOK_SECRET` is set, the backend validates Forgejo/Gitea-style HMAC headers.
- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`.
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
- Do not commit `.env` or `.env.local`.
@@ -142,6 +150,7 @@ Run both before pushing:
- Each lesson folder is expected to contain one markdown file plus optional assets.
- Frontmatter is used when present for `title` and `summary`.
- Discussions are loaded from Forgejo issues and comments.
+- Issue bodies are scanned for canonical post/lesson URLs and Forgejo file URLs to connect discussions back to content.
- Calendar events are loaded from ICS feeds, not managed in-app.
## UI Expectations
diff --git a/README.md b/README.md
index 8d05bf9..4a6a6e4 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,9 @@ export FORGEJO_BASE_URL="https://aksal.cloud"
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
export FORGEJO_OAUTH_SCOPES="openid profile"
+export FORGEJO_GENERAL_DISCUSSION_REPO="Robot-U/general_forum"
+export FORGEJO_WEBHOOK_SECRET="shared-webhook-secret"
+export FORGEJO_CACHE_TTL_SECONDS="60.0"
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
```
@@ -55,7 +58,13 @@ http://kacper-dev-pod:8800/api/auth/forgejo/callback
`AUTH_SECRET_KEY` is required for Forgejo OAuth sign-in. It encrypts the `HttpOnly` browser session cookie that carries the signed-in user's Forgejo token and identity. Set `AUTH_COOKIE_SECURE=true` when serving over HTTPS.
-`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments.
+`FORGEJO_TOKEN` is optional. When set, it is a read fallback for the public content cache. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public discussion creation and replies. The backend must verify repositories are public before reading discussion data or writing issues/comments.
+
+`FORGEJO_CACHE_TTL_SECONDS` controls how long the server reuses the public Forgejo content scan for `/api/prototype`. Set it to `0` to disable caching while debugging discovery behavior.
+
+`FORGEJO_GENERAL_DISCUSSION_REPO` should point at the public `owner/repo` used for general discussion threads. Post- and lesson-linked discussions are created in the same repo as the content being discussed.
+
+`FORGEJO_WEBHOOK_SECRET` is optional but recommended. Configure Forgejo webhooks to POST to `/api/forgejo/webhook`; matching webhook events invalidate the public content cache and notify open browsers over `/api/events/stream`.
Or put those values in `.env`:
diff --git a/app.py b/app.py
index 9e24488..27ebcef 100644
--- a/app.py
+++ b/app.py
@@ -1,12 +1,14 @@
from __future__ import annotations
+import hmac
+from hashlib import sha256
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
+from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from auth import (
@@ -19,8 +21,10 @@ from auth import (
resolve_forgejo_token,
)
from forgejo_client import ForgejoClient, ForgejoClientError
-from live_prototype import build_live_prototype_payload
+from live_prototype import discussion_card_from_issue
+from prototype_cache import PrototypePayloadCache
from settings import Settings, get_settings
+from update_events import UpdateBroker, stream_sse_events
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist"
@@ -28,6 +32,8 @@ DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI:
app = FastAPI(title="Robot U Community Prototype")
+ prototype_cache = PrototypePayloadCache()
+ update_broker = UpdateBroker()
app.add_middleware(
CORSMiddleware,
@@ -45,14 +51,14 @@ def create_app() -> FastAPI:
settings = get_settings()
session_user = current_session_user(request, settings)
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
- return JSONResponse(
- await build_live_prototype_payload(
- settings,
- forgejo_token=forgejo_token,
- auth_source=auth_source,
- session_user=session_user,
- ),
+ payload = await prototype_cache.get(settings)
+ payload["auth"] = await _auth_payload_for_request(
+ settings,
+ forgejo_token=forgejo_token,
+ auth_source=auth_source,
+ session_user=session_user,
)
+ return JSONResponse(payload)
@app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse:
@@ -73,6 +79,28 @@ def create_app() -> FastAPI:
return JSONResponse(_auth_payload(user, auth_source))
+ @app.get("/api/events/stream")
+ async def events_stream() -> StreamingResponse:
+ return StreamingResponse(
+ stream_sse_events(update_broker),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-store",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+ @app.post("/api/forgejo/webhook")
+ async def forgejo_webhook(request: Request) -> JSONResponse:
+ settings = get_settings()
+ body = await request.body()
+ if not _valid_webhook_signature(request, settings, body):
+ raise HTTPException(status_code=401, detail="Invalid Forgejo webhook signature.")
+
+ prototype_cache.invalidate()
+ await update_broker.publish("content-updated", {"reason": "forgejo-webhook"})
+ return JSONResponse({"status": "accepted"})
+
@app.get("/api/auth/forgejo/start")
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
settings = get_settings()
@@ -140,6 +168,31 @@ def create_app() -> FastAPI:
clear_login_session(request, response)
return response
+ @app.get("/api/discussions/{owner}/{repo}/{issue_number}")
+ async def discussion_detail(owner: str, repo: str, issue_number: int) -> JSONResponse:
+ if issue_number < 1:
+ raise HTTPException(status_code=400, detail="issue_number must be positive.")
+
+ settings = get_settings()
+ async with ForgejoClient(settings, forgejo_token=settings.forgejo_token) as client:
+ try:
+ repo_payload = await client.fetch_repository(owner, repo)
+ if repo_payload.get("private"):
+ raise HTTPException(
+ status_code=403,
+ detail="This site only reads public Forgejo repositories.",
+ )
+ issue = await client.fetch_issue(owner, repo, issue_number)
+ comments = [
+ _discussion_reply(comment)
+ for comment in await client.list_issue_comments(owner, repo, issue_number)
+ ]
+ except ForgejoClientError as error:
+ raise HTTPException(status_code=502, detail=str(error)) from error
+
+ issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
+ return JSONResponse(discussion_card_from_issue(issue, comments=comments))
+
@app.post("/api/discussions/replies")
async def create_discussion_reply(
request: Request,
@@ -169,8 +222,59 @@ def create_app() -> FastAPI:
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
+ prototype_cache.invalidate()
+ await update_broker.publish(
+ "content-updated",
+ {"reason": "discussion-reply", "repo": f"{owner}/{repo}", "number": issue_number},
+ )
return JSONResponse(_discussion_reply(comment))
+ @app.post("/api/discussions")
+ async def create_discussion(
+ request: Request,
+ payload: dict[str, object] = Body(...),
+ ) -> JSONResponse:
+ title = _required_string(payload, "title")
+ body = _required_string(payload, "body")
+ owner, repo = _discussion_target(payload, get_settings())
+ context_url = _optional_string(payload, "context_url")
+ context_title = _optional_string(payload, "context_title")
+ context_path = _optional_string(payload, "context_path")
+ issue_body = _discussion_issue_body(
+ body,
+ context_url=context_url,
+ context_title=context_title,
+ context_path=context_path,
+ )
+
+ settings = get_settings()
+ forgejo_token, auth_source = resolve_forgejo_token(request, settings)
+ if not forgejo_token or auth_source == "server":
+ raise HTTPException(
+ status_code=401,
+ detail="Sign in or send an Authorization token before starting a discussion.",
+ )
+
+ async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
+ try:
+ repo_payload = await client.fetch_repository(owner, repo)
+ if repo_payload.get("private"):
+ raise HTTPException(
+ status_code=403,
+ detail="This site only writes to public Forgejo repositories.",
+ )
+ issue = await client.create_issue(owner, repo, title, issue_body)
+ except ForgejoClientError as error:
+ raise HTTPException(status_code=502, detail=str(error)) from error
+
+ issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
+ prototype_cache.invalidate()
+ await update_broker.publish(
+ "content-updated",
+ {"reason": "discussion-created", "repo": f"{owner}/{repo}"},
+ )
+ return JSONResponse(discussion_card_from_issue(issue, comments=[]))
+
if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
@@ -201,6 +305,16 @@ def _required_string(payload: dict[str, object], key: str) -> str:
return value.strip()
+def _optional_string(payload: dict[str, object], key: str) -> str | None:
+ value = payload.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise HTTPException(status_code=400, detail=f"{key} must be a string.")
+ stripped = value.strip()
+ return stripped or None
+
+
def _required_positive_int(payload: dict[str, object], key: str) -> int:
value = payload.get(key)
if isinstance(value, bool):
@@ -218,6 +332,62 @@ def _required_positive_int(payload: dict[str, object], key: str) -> int:
return parsed
+def _discussion_target(payload: dict[str, object], settings: Settings) -> tuple[str, str]:
+ owner = _optional_string(payload, "owner")
+ repo = _optional_string(payload, "repo")
+ if owner and repo:
+ return owner, repo
+ if owner or repo:
+ raise HTTPException(status_code=400, detail="owner and repo must be provided together.")
+
+ configured_repo = settings.forgejo_general_discussion_repo
+ if configured_repo:
+ parts = [part for part in configured_repo.strip().split("/", 1) if part]
+ if len(parts) == 2:
+ return parts[0], parts[1]
+
+ raise HTTPException(
+ status_code=400,
+ detail="General discussion repo is not configured.",
+ )
+
+
+def _discussion_issue_body(
+ body: str,
+ *,
+ context_url: str | None,
+ context_title: str | None,
+ context_path: str | None,
+) -> str:
+ related_lines: list[str] = []
+ if context_title:
+ related_lines.append(f"Related content: {context_title}")
+ if context_url:
+ related_lines.append(f"Canonical URL: {context_url}")
+ if context_path:
+ related_lines.append(f"Source path: {context_path}")
+
+ if not related_lines:
+ return body
+
+ return f"{body}\n\n---\n" + "\n".join(related_lines)
+
+
+def _issue_repository_payload(
+ repo_payload: dict[str, Any],
+ owner: str,
+ repo: str,
+) -> dict[str, object]:
+ repo_owner = repo_payload.get("owner") or {}
+ owner_login = repo_owner.get("login") if isinstance(repo_owner, dict) else owner
+ return {
+ "owner": owner_login or owner,
+ "name": repo_payload.get("name") or repo,
+ "full_name": repo_payload.get("full_name") or f"{owner}/{repo}",
+ "private": False,
+ }
+
+
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
@@ -254,6 +424,42 @@ def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]
}
+async def _auth_payload_for_request(
+ settings: Settings,
+ *,
+ forgejo_token: str | None,
+ auth_source: str,
+ session_user: dict[str, Any] | None,
+) -> dict[str, object]:
+ if session_user:
+ return _auth_payload(session_user, "session")
+
+ if not forgejo_token or auth_source == "server":
+ return _auth_payload(None, "none")
+
+ async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
+ try:
+ user = await client.fetch_current_user()
+ except ForgejoClientError as error:
+ raise HTTPException(status_code=401, detail=str(error)) from error
+
+ return _auth_payload(user, auth_source)
+
+
+def _valid_webhook_signature(request: Request, settings: Settings, body: bytes) -> bool:
+ secret = settings.forgejo_webhook_secret
+ if not secret:
+ return True
+
+ expected = hmac.new(secret.encode("utf-8"), body, sha256).hexdigest()
+ candidates = [
+ request.headers.get("x-forgejo-signature", ""),
+ request.headers.get("x-gitea-signature", ""),
+ request.headers.get("x-hub-signature-256", "").removeprefix("sha256="),
+ ]
+ return any(hmac.compare_digest(expected, candidate.strip()) for candidate in candidates)
+
+
def _oauth_configured(settings: Settings) -> bool:
return bool(
settings.auth_secret_key
diff --git a/docs/community-platform-design-doc.md b/docs/community-platform-design-doc.md
index 6c2d2e3..c1c3370 100644
--- a/docs/community-platform-design-doc.md
+++ b/docs/community-platform-design-doc.md
@@ -80,7 +80,6 @@ The app should own only the state that does not naturally belong in Forgejo, suc
- Auto-discovery of eligible public repos
- Rendering markdown content from Forgejo repos
- Discussion creation and replies from this app, backed by Forgejo issues/comments
-- Per-user lesson completion tracking
- Team-derived admin/moderator permissions
- Webhook-driven sync from Forgejo
- SSE updates for open sessions when content/discussions change
@@ -92,6 +91,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc
- Non-Forgejo authentication providers
- Search
- Rich public profiles
+- Per-user lesson completion tracking
- Private repo indexing
- Admin-created calendar events in this app
- Review or approval workflow before publishing
@@ -108,7 +108,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc
### Signed-In Member
1. Signs in with Forgejo
-2. Reads lessons and marks them complete
+2. Reads lessons
3. Creates general discussion threads
4. Creates post- or lesson-linked discussion threads from content pages
5. Replies, edits, and otherwise interacts through the app UI
@@ -131,7 +131,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc
| Content assets / downloads | Forgejo repos |
| Discussions / comments | Forgejo issues and issue comments |
| Events | External ICS feeds |
-| Lesson progress | App database |
+| Lesson progress | Post-MVP app database |
| Sessions | App backend |
| Cached index metadata | App database |
@@ -147,7 +147,6 @@ The app should own only the state that does not naturally belong in Forgejo, suc
### Authenticated Capabilities
-- Mark lesson complete
- Create discussion threads
- Reply to discussions
- Edit supported discussion content through the app
@@ -359,9 +358,9 @@ Events should continue to be managed in external calendar tools.
## 15. Progress Tracking
-The app should track lesson progress per signed-in user.
+Lesson progress is no longer part of the MVP.
-MVP state model:
+Post-MVP state model:
- not started
- completed
@@ -435,6 +434,7 @@ The following are explicitly out of scope for MVP:
- browser-based asset uploads
- search
- rich user profiles
+- per-user lesson completion tracking
- non-Forgejo auth providers
- private repo support
- complex moderation workflows
diff --git a/forgejo_client.py b/forgejo_client.py
index 5482341..ebe5443 100644
--- a/forgejo_client.py
+++ b/forgejo_client.py
@@ -83,17 +83,32 @@ class ForgejoClient:
return await self._get_json("/api/v1/user", auth_required=True)
async def search_repositories(self) -> list[dict[str, Any]]:
- payload = await self._get_json(
- "/api/v1/repos/search",
- params={
- "limit": self._settings.forgejo_repo_scan_limit,
- "page": 1,
- "private": "false",
- "is_private": "false",
- },
- )
- data = payload.get("data", [])
- return [repo for repo in data if isinstance(repo, dict)]
+ scan_limit = max(self._settings.forgejo_repo_scan_limit, 1)
+ page_limit = min(scan_limit, 50)
+ repos: list[dict[str, Any]] = []
+ page = 1
+
+ while len(repos) < scan_limit:
+ payload = await self._get_json(
+ "/api/v1/repos/search",
+ params={
+ "limit": page_limit,
+ "page": page,
+ "private": "false",
+ "is_private": "false",
+ },
+ )
+ if not isinstance(payload, dict):
+ break
+
+ data = payload.get("data", [])
+ page_repos = [repo for repo in data if isinstance(repo, dict)]
+ repos.extend(page_repos)
+ if len(page_repos) < page_limit:
+ break
+ page += 1
+
+ return repos[:scan_limit]
async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]:
payload = await self._get_json(
@@ -124,6 +139,12 @@ class ForgejoClient:
return [issue for issue in payload if isinstance(issue, dict)]
return []
+ async def fetch_issue(self, owner: str, repo: str, issue_number: int) -> dict[str, Any]:
+ payload = await self._get_json(f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}")
+ if isinstance(payload, dict):
+ return payload
+ raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}#{issue_number}")
+
async def list_directory(self, owner: str, repo: str, path: str = "") -> list[dict[str, Any]]:
endpoint = f"/api/v1/repos/{owner}/{repo}/contents"
if path:
@@ -166,6 +187,23 @@ class ForgejoClient:
return payload
raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}")
+ async def create_issue(
+ self,
+ owner: str,
+ repo: str,
+ title: str,
+ body: str,
+ ) -> dict[str, Any]:
+ payload = await self._request_json(
+ "POST",
+ f"/api/v1/repos/{owner}/{repo}/issues",
+ json_payload={"title": title, "body": body},
+ auth_required=True,
+ )
+ if isinstance(payload, dict):
+ return payload
+ raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}")
+
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 08ef21a..100c093 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type {
AuthState,
+ ContentAsset,
CourseCard,
CourseChapter,
CourseLesson,
@@ -49,6 +50,26 @@ function parseDiscussionRoute(pathname: string): number | null {
return Number.isFinite(issueId) ? issueId : null;
}
+function parseDiscussionRepoRoute(
+ pathname: string,
+): { owner: string; repo: string; number: number } | null {
+ const match = normalizePathname(pathname).match(/^\/discussions\/([^/]+)\/([^/]+)\/(\d+)$/);
+ if (!match) {
+ return null;
+ }
+
+ const issueNumber = Number(match[3]);
+ if (!Number.isFinite(issueNumber)) {
+ return null;
+ }
+
+ return {
+ owner: decodeURIComponent(match[1]),
+ repo: decodeURIComponent(match[2]),
+ number: issueNumber,
+ };
+}
+
function isSignInRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/signin";
}
@@ -179,6 +200,17 @@ function findLessonByRoute(
return undefined;
}
+function findDiscussionByRoute(
+ discussions: DiscussionCard[],
+ route: { owner: string; repo: string; number: number },
+): DiscussionCard | undefined {
+ const routeRepo = `${normalizeRouteKey(route.owner)}/${normalizeRouteKey(route.repo)}`;
+ return discussions.find(
+ (discussion) =>
+ normalizeRouteKey(discussion.repo) === routeRepo && discussion.number === route.number,
+ );
+}
+
function usePathname() {
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
@@ -424,7 +456,7 @@ function EventItem(props: { event: EventCard }) {
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
- onOpenDiscussion: (id: number) => void;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
}) {
const { discussion, onOpenDiscussion } = props;
@@ -433,7 +465,7 @@ function DiscussionPreviewItem(props: {
type="button"
className="discussion-preview-card"
onClick={() => {
- onOpenDiscussion(discussion.id);
+ onOpenDiscussion(discussion);
}}
>
{discussion.title}
@@ -500,6 +532,42 @@ async function postDiscussionReply(
return (await response.json()) as DiscussionReply;
}
+interface DiscussionCreateContext {
+ owner?: string;
+ repo?: string;
+ contextPath?: string;
+ contextUrl?: string;
+ contextTitle?: string;
+}
+
+async function postDiscussion(
+ title: string,
+ body: string,
+ context?: DiscussionCreateContext,
+): Promise {
+ const response = await fetch("/api/discussions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ title,
+ body,
+ owner: context?.owner,
+ repo: context?.repo,
+ context_path: context?.contextPath,
+ context_url: context?.contextUrl,
+ context_title: context?.contextTitle,
+ }),
+ });
+
+ if (!response.ok) {
+ throw await responseError(response, `Discussion creation failed with ${response.status}`);
+ }
+
+ return (await response.json()) as DiscussionCard;
+}
+
function ComposeBox(props: {
discussion: DiscussionCard;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
@@ -578,6 +646,193 @@ function ComposeBox(props: {
);
}
+function DiscussionCreateBox(props: {
+ auth: AuthState;
+ context?: DiscussionCreateContext;
+ titlePlaceholder: string;
+ bodyPlaceholder: string;
+ submitLabel: string;
+ onGoSignIn: () => void;
+ onDiscussionCreated: (discussion: DiscussionCard) => void;
+}) {
+ const {
+ auth,
+ context,
+ titlePlaceholder,
+ bodyPlaceholder,
+ submitLabel,
+ onGoSignIn,
+ onDiscussionCreated,
+ } = props;
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const trimmedTitle = title.trim();
+ const trimmedBody = body.trim();
+ const canCreate = canUseInteractiveAuth(auth);
+
+ async function submitDiscussion(event: SubmitEvent) {
+ event.preventDefault();
+ if (!canCreate) {
+ setError("Sign in before starting a discussion.");
+ return;
+ }
+ if (!trimmedTitle || !trimmedBody || isSubmitting) {
+ return;
+ }
+
+ setError(null);
+ setIsSubmitting(true);
+ try {
+ const discussion = await postDiscussion(trimmedTitle, trimmedBody, context);
+ setTitle("");
+ setBody("");
+ onDiscussionCreated(discussion);
+ } catch (createError) {
+ const message =
+ createError instanceof Error ? createError.message : "Discussion could not be created.";
+ setError(message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+function AssetsPanel(props: { assets: ContentAsset[] }) {
+ if (props.assets.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+function lessonPath(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson): string {
+ return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`;
+}
+
+function absoluteSiteUrl(path: string): string {
+ return new URL(path, window.location.origin).toString();
+}
+
+function discussionMatchesContent(
+ discussion: DiscussionCard,
+ routePath: string,
+ contentPath: string,
+): boolean {
+ const normalizedRoute = normalizeRouteKey(routePath);
+ const normalizedContentPath = normalizeRouteKey(contentPath);
+ return (discussion.links || []).some((link) => {
+ const linkPath = normalizeRouteKey(link.path || "");
+ const linkContentPath = normalizeRouteKey(link.content_path || "");
+ return (
+ linkPath === normalizedRoute ||
+ linkContentPath === normalizedContentPath ||
+ normalizedContentPath.startsWith(`${linkContentPath}/`)
+ );
+ });
+}
+
+function RelatedDiscussionsPanel(props: {
+ discussions: DiscussionCard[];
+ routePath: string;
+ contentPath: string;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
+}) {
+ const relatedDiscussions = props.discussions.filter((discussion) =>
+ discussionMatchesContent(discussion, props.routePath, props.contentPath),
+ );
+
+ return (
+
+
+ {relatedDiscussions.length > 0 ? (
+
+ {relatedDiscussions.map((discussion) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ );
+}
+
function CoursePage(props: {
course: CourseCard;
onGoHome: () => void;
@@ -644,10 +899,26 @@ function LessonPage(props: {
course: CourseCard;
chapter: CourseChapter;
lesson: CourseLesson;
+ auth: AuthState;
+ discussions: DiscussionCard[];
onGoCourse: () => void;
+ onGoSignIn: () => void;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
+ onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
- const { course, chapter, lesson, onGoCourse } = props;
+ const {
+ course,
+ chapter,
+ lesson,
+ auth,
+ discussions,
+ onGoCourse,
+ onGoSignIn,
+ onOpenDiscussion,
+ onDiscussionCreated,
+ } = props;
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
+ const routePath = lessonPath(course, chapter, lesson);
return (
@@ -670,18 +941,60 @@ function LessonPage(props: {
Lesson
{lessonBody ? (
-
+
) : (
)}
+
+
+
+
+
+
+
+ Start a lesson discussion
+
+
+
);
}
-function PostPage(props: { post: PostCard }) {
- const { post } = props;
+function PostPage(props: {
+ post: PostCard;
+ auth: AuthState;
+ discussions: DiscussionCard[];
+ onGoSignIn: () => void;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
+ onDiscussionCreated: (discussion: DiscussionCard) => void;
+}) {
+ const { post, auth, discussions, onGoSignIn, onOpenDiscussion, onDiscussionCreated } = props;
const postBody = stripLeadingTitleHeading(post.body, post.title);
+ const routePath = postPath(post);
return (
@@ -700,11 +1013,45 @@ function PostPage(props: { post: PostCard }) {
Post
{postBody ? (
-
+
) : (
)}
+
+
+
+
+
+
+
+ Start a post discussion
+
+
+
);
}
@@ -785,12 +1132,42 @@ function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: Course
);
}
-function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
- const { data, onOpenDiscussion } = props;
+function DiscussionsView(props: {
+ data: PrototypeData;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
+ onGoSignIn: () => void;
+ onDiscussionCreated: (discussion: DiscussionCard) => void;
+ showComposer?: boolean;
+}) {
+ const { data, onOpenDiscussion, onGoSignIn, onDiscussionCreated, showComposer = true } = props;
+ const generalDiscussionConfigured =
+ data.discussion_settings?.general_discussion_configured ?? false;
return (
+ {showComposer && generalDiscussionConfigured ? (
+
+
+
+
+ ) : null}
+ {showComposer && !generalDiscussionConfigured ? (
+
+
+ General discussion creation needs a configured public org repo.
+
+
+ ) : null}
{data.recent_discussions.length > 0 ? (
{data.recent_discussions.map((discussion) => (
@@ -812,9 +1189,12 @@ function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
- onOpenDiscussion: (id: number) => void;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
+ onGoSignIn: () => void;
+ onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
- const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
+ const { data, onOpenCourse, onOpenPost, onOpenDiscussion, onGoSignIn, onDiscussionCreated } =
+ props;
return (
<>
@@ -856,7 +1236,13 @@ function HomeView(props: {
-
+
>
);
}
@@ -912,6 +1298,22 @@ async function fetchPrototypeData(signal?: AbortSignal): Promise
return (await response.json()) as PrototypeData;
}
+async function fetchDiscussionDetail(
+ route: { owner: string; repo: string; number: number },
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetch(
+ `/api/discussions/${encodeURIComponent(route.owner)}/${encodeURIComponent(route.repo)}/${route.number}`,
+ { signal },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Discussion request failed with ${response.status}`);
+ }
+
+ return (await response.json()) as DiscussionCard;
+}
+
function appendDiscussionReply(
currentData: PrototypeData | null,
discussionId: number,
@@ -938,6 +1340,23 @@ function appendDiscussionReply(
};
}
+function prependDiscussion(
+ currentData: PrototypeData | null,
+ discussion: DiscussionCard,
+): PrototypeData | null {
+ if (!currentData) {
+ return currentData;
+ }
+
+ return {
+ ...currentData,
+ recent_discussions: [
+ discussion,
+ ...currentData.recent_discussions.filter((entry) => entry.id !== discussion.id),
+ ],
+ };
+}
+
interface ActivityEntry {
id: string;
title: string;
@@ -959,6 +1378,15 @@ function postPath(post: PostCard): string {
return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`;
}
+function discussionPath(discussion: DiscussionCard): string {
+ const repo = repoParts(discussion.repo);
+ if (!repo || discussion.number < 1) {
+ return `/discussions/${discussion.id}`;
+ }
+
+ return `/discussions/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/${discussion.number}`;
+}
+
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
const activities: ActivityEntry[] = [];
@@ -992,7 +1420,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `Discussion updated: ${discussion.title}`,
detail: `${discussion.repo} ยท ${discussion.replies} replies`,
timestamp: discussion.updated_at,
- route: `/discussions/${discussion.id}`,
+ route: discussionPath(discussion),
});
for (const reply of discussion.comments) {
@@ -1001,7 +1429,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `${reply.author} replied`,
detail: discussion.title,
timestamp: reply.created_at,
- route: `/discussions/${discussion.id}`,
+ route: discussionPath(discussion),
});
}
}
@@ -1066,10 +1494,11 @@ interface AppContentProps {
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
- onOpenDiscussion: (id: number) => void;
+ onOpenDiscussion: (discussion: DiscussionCard) => void;
onOpenRoute: (route: string) => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
+ onDiscussionCreated: (discussion: DiscussionCard) => void;
onGoHome: () => void;
onGoCourses: () => void;
onGoDiscussions: () => void;
@@ -1087,7 +1516,16 @@ function PostRouteView(
);
}
- return ;
+ return (
+
+ );
}
function LessonRouteView(
@@ -1130,9 +1568,14 @@ function LessonRouteView(
course={selectedCourse}
chapter={selectedLesson.chapter}
lesson={selectedLesson.lesson}
+ auth={props.data.auth}
+ discussions={props.data.recent_discussions}
onGoCourse={() => {
props.onOpenCourse(selectedCourse);
}}
+ onGoSignIn={props.onGoSignIn}
+ onOpenDiscussion={props.onOpenDiscussion}
+ onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
@@ -1185,6 +1628,66 @@ function DiscussionRouteView(props: AppContentProps & { discussionId: number })
);
}
+function DiscussionRepoRouteView(
+ props: AppContentProps & { route: { owner: string; repo: string; number: number } },
+) {
+ const indexedDiscussion = findDiscussionByRoute(props.data.recent_discussions, props.route);
+ const [loadedDiscussion, setLoadedDiscussion] = useState(null);
+ const [error, setError] = useState(null);
+ const selectedDiscussion = indexedDiscussion || loadedDiscussion;
+
+ useEffect(() => {
+ if (indexedDiscussion) {
+ setLoadedDiscussion(null);
+ setError(null);
+ return;
+ }
+
+ const controller = new AbortController();
+ fetchDiscussionDetail(props.route, controller.signal)
+ .then((discussion) => {
+ setLoadedDiscussion(discussion);
+ setError(null);
+ })
+ .catch((fetchError) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ setError(fetchError instanceof Error ? fetchError.message : "Discussion did not load.");
+ });
+ return () => {
+ controller.abort();
+ };
+ }, [indexedDiscussion, props.route.owner, props.route.repo, props.route.number]);
+
+ if (error) {
+ return (
+
+ Discussion did not load.
+ {error}
+
+ );
+ }
+
+ if (!selectedDiscussion) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
function AppContent(props: AppContentProps) {
if (isSignInRoute(props.pathname)) {
return ;
@@ -1199,7 +1702,19 @@ function AppContent(props: AppContentProps) {
}
if (isDiscussionsIndexRoute(props.pathname)) {
- return ;
+ return (
+
+ );
+ }
+
+ const discussionRepoRoute = parseDiscussionRepoRoute(props.pathname);
+ if (discussionRepoRoute !== null) {
+ return ;
}
const postRoute = parsePostRoute(props.pathname);
@@ -1225,6 +1740,8 @@ function AppContent(props: AppContentProps) {
onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion}
+ onGoSignIn={props.onGoSignIn}
+ onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
@@ -1261,6 +1778,7 @@ function LoadedApp(
onOpenRoute={props.onOpenRoute}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
+ onDiscussionCreated={props.onDiscussionCreated}
onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions}
@@ -1281,10 +1799,9 @@ function AppStatusPage(props: { title: string; copy?: string }) {
);
}
-export default function App() {
+function usePrototypeData() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
- const { pathname, navigate } = usePathname();
async function loadPrototype(signal?: AbortSignal) {
try {
@@ -1310,7 +1827,24 @@ export default function App() {
};
}, []);
- const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
+ useEffect(() => {
+ const eventSource = new EventSource("/api/events/stream");
+ eventSource.addEventListener("content-updated", () => {
+ loadPrototype();
+ });
+ return () => {
+ eventSource.close();
+ };
+ }, []);
+
+ return { data, setData, error, loadPrototype };
+}
+
+export default function App() {
+ const { data, setData, error, loadPrototype } = usePrototypeData();
+ const { pathname, navigate } = usePathname();
+
+ const openDiscussion = (discussion: DiscussionCard) => navigate(discussionPath(discussion));
function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname));
@@ -1329,15 +1863,18 @@ export default function App() {
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)}`,
- );
+ navigate(lessonPath(course, chapter, lesson));
}
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
}
+ function addDiscussion(discussion: DiscussionCard) {
+ setData((currentData) => prependDiscussion(currentData, discussion));
+ navigate(discussionPath(discussion));
+ }
+
const goHome = () => navigate("/");
async function signOut() {
@@ -1367,6 +1904,7 @@ export default function App() {
onOpenRoute={navigate}
onGoSignIn={goSignIn}
onReplyCreated={addReplyToDiscussion}
+ onDiscussionCreated={addDiscussion}
onGoHome={goHome}
onGoCourses={goCourses}
onGoDiscussions={goDiscussions}
diff --git a/frontend/src/MarkdownContent.tsx b/frontend/src/MarkdownContent.tsx
index 1704090..23eb0c2 100644
--- a/frontend/src/MarkdownContent.tsx
+++ b/frontend/src/MarkdownContent.tsx
@@ -20,7 +20,7 @@ function escapeHtml(value: string): string {
.replace(/'/g, "'");
}
-function normalizeLinkTarget(value: string): string | null {
+function normalizeLinkTarget(value: string, baseUrl?: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
@@ -31,7 +31,7 @@ function normalizeLinkTarget(value: string): string | null {
}
try {
- const url = new URL(trimmed);
+ const url = new URL(trimmed, baseUrl || undefined);
if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString());
}
@@ -42,7 +42,7 @@ function normalizeLinkTarget(value: string): string | null {
return null;
}
-function renderInline(markdown: string): string {
+function renderInline(markdown: string, baseUrl?: string): string {
const codeTokens: string[] = [];
let rendered = escapeHtml(markdown);
@@ -51,10 +51,21 @@ function renderInline(markdown: string): string {
codeTokens.push(`${code}`);
return token;
});
+ rendered = rendered.replace(
+ /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
+ (_match, label: string, href: string) => {
+ const safeHref = normalizeLinkTarget(href, baseUrl);
+ if (!safeHref) {
+ return label;
+ }
+
+ return `
`;
+ },
+ );
rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
- const safeHref = normalizeLinkTarget(href);
+ const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) {
return label;
}
@@ -83,12 +94,12 @@ function createParserState(): ParserState {
};
}
-function flushParagraph(state: ParserState) {
+function flushParagraph(state: ParserState, baseUrl?: string) {
if (state.paragraphLines.length === 0) {
return;
}
- state.output.push(`${renderInline(state.paragraphLines.join(" "))}
`);
+ state.output.push(`${renderInline(state.paragraphLines.join(" "), baseUrl)}
`);
state.paragraphLines.length = 0;
}
@@ -104,13 +115,13 @@ function flushList(state: ParserState) {
state.listType = null;
}
-function flushBlockquote(state: ParserState) {
+function flushBlockquote(state: ParserState, baseUrl?: string) {
if (state.blockquoteLines.length === 0) {
return;
}
state.output.push(
- `${renderInline(state.blockquoteLines.join(" "))}
`,
+ `${renderInline(state.blockquoteLines.join(" "), baseUrl)}
`,
);
state.blockquoteLines.length = 0;
}
@@ -131,10 +142,10 @@ function flushCodeBlock(state: ParserState) {
state.codeLines.length = 0;
}
-function flushInlineBlocks(state: ParserState) {
- flushParagraph(state);
+function flushInlineBlocks(state: ParserState, baseUrl?: string) {
+ flushParagraph(state, baseUrl);
flushList(state);
- flushBlockquote(state);
+ flushBlockquote(state, baseUrl);
}
function handleCodeBlockLine(state: ParserState, line: string): boolean {
@@ -151,124 +162,129 @@ function handleCodeBlockLine(state: ParserState, line: string): boolean {
return true;
}
-function handleFenceStart(state: ParserState, line: string): boolean {
+function handleFenceStart(state: ParserState, line: string, baseUrl?: string): boolean {
if (!line.trim().startsWith("```")) {
return false;
}
- flushInlineBlocks(state);
+ flushInlineBlocks(state, baseUrl);
state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim();
return true;
}
-function handleBlankLine(state: ParserState, line: string): boolean {
+function handleBlankLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (line.trim()) {
return false;
}
- flushInlineBlocks(state);
+ flushInlineBlocks(state, baseUrl);
return true;
}
-function handleHeadingLine(state: ParserState, line: string): boolean {
+function handleHeadingLine(state: ParserState, line: string, baseUrl?: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
return false;
}
- flushInlineBlocks(state);
+ flushInlineBlocks(state, baseUrl);
const level = headingMatch[1].length;
- state.output.push(`${renderInline(headingMatch[2].trim())}`);
+ state.output.push(`${renderInline(headingMatch[2].trim(), baseUrl)}`);
return true;
}
-function handleRuleLine(state: ParserState, line: string): boolean {
+function handleRuleLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
return false;
}
- flushInlineBlocks(state);
+ flushInlineBlocks(state, baseUrl);
state.output.push("
");
return true;
}
-function handleListLine(state: ParserState, line: string, listType: ListType): boolean {
+function handleListLine(
+ state: ParserState,
+ line: string,
+ listType: ListType,
+ baseUrl?: string,
+): boolean {
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const match = line.match(pattern);
if (!match) {
return false;
}
- flushParagraph(state);
- flushBlockquote(state);
+ flushParagraph(state, baseUrl);
+ flushBlockquote(state, baseUrl);
if (state.listType !== listType) {
flushList(state);
state.listType = listType;
}
- state.listItems.push(renderInline(match[1].trim()));
+ state.listItems.push(renderInline(match[1].trim(), baseUrl));
return true;
}
-function handleBlockquoteLine(state: ParserState, line: string): boolean {
+function handleBlockquoteLine(state: ParserState, line: string, baseUrl?: string): boolean {
const match = line.match(/^>\s?(.*)$/);
if (!match) {
return false;
}
- flushParagraph(state);
+ flushParagraph(state, baseUrl);
flushList(state);
state.blockquoteLines.push(match[1].trim());
return true;
}
-function handleParagraphLine(state: ParserState, line: string) {
+function handleParagraphLine(state: ParserState, line: string, baseUrl?: string) {
flushList(state);
- flushBlockquote(state);
+ flushBlockquote(state, baseUrl);
state.paragraphLines.push(line.trim());
}
-function processMarkdownLine(state: ParserState, line: string) {
+function processMarkdownLine(state: ParserState, line: string, baseUrl?: string) {
if (handleCodeBlockLine(state, line)) {
return;
}
- if (handleFenceStart(state, line)) {
+ if (handleFenceStart(state, line, baseUrl)) {
return;
}
- if (handleBlankLine(state, line)) {
+ if (handleBlankLine(state, line, baseUrl)) {
return;
}
- if (handleHeadingLine(state, line)) {
+ if (handleHeadingLine(state, line, baseUrl)) {
return;
}
- if (handleRuleLine(state, line)) {
+ if (handleRuleLine(state, line, baseUrl)) {
return;
}
- if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) {
+ if (handleListLine(state, line, "ul", baseUrl) || handleListLine(state, line, "ol", baseUrl)) {
return;
}
- if (handleBlockquoteLine(state, line)) {
+ if (handleBlockquoteLine(state, line, baseUrl)) {
return;
}
- handleParagraphLine(state, line);
+ handleParagraphLine(state, line, baseUrl);
}
-function markdownToHtml(markdown: string): string {
+function markdownToHtml(markdown: string, baseUrl?: string): string {
const state = createParserState();
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const line of lines) {
- processMarkdownLine(state, line);
+ processMarkdownLine(state, line, baseUrl);
}
- flushInlineBlocks(state);
+ flushInlineBlocks(state, baseUrl);
flushCodeBlock(state);
return state.output.join("");
}
@@ -288,8 +304,8 @@ export function stripLeadingTitleHeading(markdown: string, title: string): strin
return markdown;
}
-export function MarkdownContent(props: { markdown: string; className?: string }) {
- const html = markdownToHtml(props.markdown);
+export function MarkdownContent(props: { markdown: string; className?: string; baseUrl?: string }) {
+ const html = markdownToHtml(props.markdown, props.baseUrl);
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
return ;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c757fc5..80efae0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -296,6 +296,14 @@ textarea {
gap: 0.75rem;
}
+.discussion-create-panel {
+ margin-bottom: 1rem;
+ border: 0.0625rem solid var(--accent-border);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ background: var(--accent-soft);
+}
+
.discussion-preview-card {
width: 100%;
padding: 1rem;
@@ -546,6 +554,12 @@ textarea {
color: var(--accent);
}
+.markdown-content img {
+ max-width: 100%;
+ border: 0.0625rem solid var(--border);
+ border-radius: 0.75rem;
+}
+
.markdown-content hr {
width: 100%;
height: 0.0625rem;
@@ -559,6 +573,32 @@ textarea {
gap: 0.85rem;
}
+.asset-list {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.asset-link {
+ display: grid;
+ gap: 0.2rem;
+ border: 0.0625rem solid var(--border);
+ border-radius: 0.75rem;
+ padding: 0.85rem;
+ color: inherit;
+ background: var(--card);
+ text-decoration: none;
+}
+
+.asset-link:hover,
+.asset-link:focus-visible {
+ background: var(--panel-hover);
+ outline: none;
+}
+
+.asset-link .meta-line {
+ margin-top: 0;
+}
+
.signin-page {
display: grid;
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 8e69d0b..80f79c3 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -18,6 +18,13 @@ export interface AuthState {
oauth_configured: boolean;
}
+export interface ContentAsset {
+ name: string;
+ path: string;
+ html_url: string;
+ download_url: string;
+}
+
export interface CourseCard {
title: string;
owner: string;
@@ -44,6 +51,8 @@ export interface CourseLesson {
path: string;
file_path: string;
html_url: string;
+ raw_base_url: string;
+ assets: ContentAsset[];
summary: string;
body: string;
}
@@ -59,6 +68,8 @@ export interface PostCard {
path: string;
file_path: string;
html_url: string;
+ raw_base_url: string;
+ assets: ContentAsset[];
body: string;
updated_at: string;
}
@@ -85,6 +96,7 @@ export interface DiscussionCard {
html_url: string;
labels: string[];
comments: DiscussionReply[];
+ links: DiscussionLink[];
}
export interface DiscussionReply {
@@ -96,9 +108,25 @@ export interface DiscussionReply {
html_url: string;
}
+export interface DiscussionLink {
+ kind: "post" | "lesson";
+ path: string;
+ owner: string;
+ repo: string;
+ slug?: string;
+ chapter?: string;
+ lesson?: string;
+ content_path: string;
+}
+
+export interface DiscussionSettings {
+ general_discussion_configured: boolean;
+}
+
export interface PrototypeData {
hero: HeroData;
auth: AuthState;
+ discussion_settings: DiscussionSettings;
source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[];
recent_posts: PostCard[];
diff --git a/live_prototype.py b/live_prototype.py
index b1ec223..8f8ac8f 100644
--- a/live_prototype.py
+++ b/live_prototype.py
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
+import re
from typing import Any
+from urllib.parse import unquote, urlparse
from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
from forgejo_client import ForgejoClient, ForgejoClientError
@@ -75,6 +77,7 @@ async def build_live_prototype_payload(
),
)
+ repos = await _with_configured_discussion_repo(client, repos, settings, warnings)
current_user = await _current_user_for_auth_source(client, has_user_token, warnings)
public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")]
repo_summaries = await asyncio.gather(
@@ -132,8 +135,9 @@ async def build_live_prototype_payload(
settings,
),
"source_of_truth": source_cards,
- "featured_courses": [_course_card(summary) for summary in course_repos[:6]],
- "recent_posts": [_post_card(post) for post in blog_posts[:6]],
+ "discussion_settings": _discussion_settings(settings),
+ "featured_courses": [_course_card(summary) for summary in course_repos],
+ "recent_posts": [_post_card(post) for post in blog_posts],
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
"recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in public_issues],
@@ -169,6 +173,49 @@ async def _current_user_for_auth_source(
return None
+async def _with_configured_discussion_repo(
+ client: ForgejoClient,
+ repos: list[dict[str, Any]],
+ settings: Settings,
+ warnings: list[str],
+) -> list[dict[str, Any]]:
+ owner_repo = _configured_owner_repo(settings.forgejo_general_discussion_repo)
+ if owner_repo is None:
+ return repos
+
+ owner, repo = owner_repo
+ full_name = f"{owner}/{repo}".lower()
+ if any(str(candidate.get("full_name", "")).lower() == full_name for candidate in repos):
+ return repos
+
+ try:
+ configured_repo = await client.fetch_repository(owner, repo)
+ except ForgejoClientError as error:
+ warnings.append(f"General discussion repo could not be loaded: {error}")
+ return repos
+
+ return [*repos, configured_repo]
+
+
+def _configured_owner_repo(value: str | None) -> tuple[str, str] | None:
+ if not value:
+ return None
+ owner, separator, repo = value.strip().partition("/")
+ if not separator or not owner or not repo or "/" in repo:
+ return None
+ return owner, repo
+
+
+def _discussion_settings(settings: Settings) -> dict[str, object]:
+ return _discussion_settings_from_configured(
+ _configured_owner_repo(settings.forgejo_general_discussion_repo) is not None,
+ )
+
+
+def _discussion_settings_from_configured(general_discussion_configured: bool) -> dict[str, object]:
+ return {"general_discussion_configured": general_discussion_configured}
+
+
async def _summarize_repo(
client: ForgejoClient,
repo: dict[str, Any],
@@ -177,6 +224,7 @@ async def _summarize_repo(
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None
+ default_branch = str(repo.get("default_branch") or "main")
try:
root_entries = await client.list_directory(owner_login, repo_name)
@@ -222,6 +270,8 @@ async def _summarize_repo(
client,
owner_login,
repo_name,
+ default_branch,
+ str(repo.get("html_url", "")),
chapter_name,
str(lesson_dir.get("name", "")),
)
@@ -251,6 +301,8 @@ async def _summarize_repo(
str(repo.get("full_name", f"{owner_login}/{repo_name}")),
str(repo.get("description") or ""),
str(repo.get("updated_at", "")),
+ default_branch,
+ str(repo.get("html_url", "")),
str(blog_dir.get("name", "")),
)
for blog_dir in blog_dirs
@@ -300,6 +352,8 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
"path": post["path"],
"file_path": post["file_path"],
"html_url": post["html_url"],
+ "raw_base_url": post["raw_base_url"],
+ "assets": post["assets"],
"body": post["body"],
"updated_at": post["updated_at"],
}
@@ -379,15 +433,7 @@ def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[st
async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict[str, object]:
repository = issue.get("repository") or {}
owner = repository.get("owner", "")
- full_name = repository.get("full_name", "Unknown repo")
- comments = issue.get("comments", 0)
issue_number = int(issue.get("number", 0))
- issue_author = issue.get("user") or {}
- labels = [
- label.get("name")
- for label in issue.get("labels", [])
- if isinstance(label, dict) and isinstance(label.get("name"), str)
- ]
comment_items: list[dict[str, object]] = []
if isinstance(owner, str) and isinstance(repository.get("name"), str) and issue_number > 0:
try:
@@ -402,7 +448,25 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
except ForgejoClientError:
comment_items = []
+ return discussion_card_from_issue(issue, comments=comment_items)
+
+
+def discussion_card_from_issue(
+ issue: dict[str, Any],
+ *,
+ comments: list[dict[str, object]] | None = None,
+) -> dict[str, object]:
+ repository = issue.get("repository") or {}
+ full_name = repository.get("full_name", "Unknown repo")
+ issue_author = issue.get("user") or {}
+ issue_number = int(issue.get("number", 0) or 0)
+ labels = [
+ label.get("name")
+ for label in issue.get("labels", [])
+ if isinstance(label, dict) and isinstance(label.get("name"), str)
+ ]
body = str(issue.get("body", "") or "").strip()
+ links = discussion_links_from_text(body)
if not body:
body = "No issue description yet. Right now the conversation starts in the replies."
@@ -410,8 +474,8 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
"id": int(issue.get("id", 0)),
"title": issue.get("title", "Untitled issue"),
"repo": full_name,
- "replies": comments,
- "context": "Live Forgejo issue",
+ "replies": int(issue.get("comments", 0) or 0),
+ "context": "Linked discussion" if links else "Live Forgejo issue",
"author": issue_author.get("login", "Unknown author"),
"author_avatar_url": issue_author.get("avatar_url", ""),
"state": issue.get("state", "open"),
@@ -420,10 +484,122 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
"updated_at": issue.get("updated_at", ""),
"html_url": issue.get("html_url", ""),
"labels": [label for label in labels if isinstance(label, str)],
- "comments": comment_items,
+ "comments": comments or [],
+ "links": links,
}
+def discussion_links_from_text(text: str) -> list[dict[str, object]]:
+ links: list[dict[str, object]] = []
+ seen: set[tuple[str, str, str, str]] = set()
+
+ for match in re.finditer(
+ r"(?:https?://[^\s)]+)?(/posts/([^/\s)]+)/([^/\s)]+)/([^/\s)#?]+))", text
+ ):
+ owner = unquote(match.group(2))
+ repo = unquote(match.group(3))
+ slug = unquote(match.group(4).rstrip(".,"))
+ path = f"/posts/{owner}/{repo}/{slug}"
+ _append_discussion_link(
+ links,
+ seen,
+ {
+ "kind": "post",
+ "path": path,
+ "owner": owner,
+ "repo": repo,
+ "slug": slug,
+ "content_path": f"blogs/{slug}",
+ },
+ )
+
+ lesson_pattern = (
+ r"(?:https?://[^\s)]+)?"
+ r"(/courses/([^/\s)]+)/([^/\s)]+)/lessons/([^/\s)]+)/([^/\s)#?]+))"
+ )
+ for match in re.finditer(lesson_pattern, text):
+ owner = unquote(match.group(2))
+ repo = unquote(match.group(3))
+ chapter = unquote(match.group(4))
+ lesson = unquote(match.group(5).rstrip(".,"))
+ path = f"/courses/{owner}/{repo}/lessons/{chapter}/{lesson}"
+ _append_discussion_link(
+ links,
+ seen,
+ {
+ "kind": "lesson",
+ "path": path,
+ "owner": owner,
+ "repo": repo,
+ "chapter": chapter,
+ "lesson": lesson,
+ "content_path": f"lessons/{chapter}/{lesson}",
+ },
+ )
+
+ for raw_url in re.findall(r"https?://[^\s)]+", text):
+ file_link = _forgejo_file_link(raw_url)
+ if file_link is not None:
+ _append_discussion_link(links, seen, file_link)
+
+ return links
+
+
+def _append_discussion_link(
+ links: list[dict[str, object]],
+ seen: set[tuple[str, str, str, str]],
+ link: dict[str, object],
+) -> None:
+ key = (
+ str(link.get("kind", "")),
+ str(link.get("owner", "")),
+ str(link.get("repo", "")),
+ str(link.get("content_path", "")),
+ )
+ if key in seen:
+ return
+ seen.add(key)
+ links.append(link)
+
+
+def _forgejo_file_link(raw_url: str) -> dict[str, object] | None:
+ parsed = urlparse(raw_url.rstrip(".,"))
+ path_parts = [unquote(part) for part in parsed.path.strip("/").split("/") if part]
+ if len(path_parts) < 6 or path_parts[2:4] != ["src", "branch"]:
+ return None
+
+ owner, repo = path_parts[0], path_parts[1]
+ content_parts = path_parts[5:]
+ if len(content_parts) < 2:
+ return None
+
+ if content_parts[0] == "blogs":
+ slug = content_parts[1]
+ return {
+ "kind": "post",
+ "path": f"/posts/{owner}/{repo}/{slug}",
+ "owner": owner,
+ "repo": repo,
+ "slug": slug,
+ "content_path": f"blogs/{slug}",
+ }
+
+ if content_parts[0] == "lessons" and len(content_parts) >= 3:
+ chapter = content_parts[1]
+ lesson = content_parts[2]
+ return {
+ "kind": "lesson",
+ "path": f"/courses/{owner}/{repo}/lessons/{chapter}/{lesson}",
+ "owner": owner,
+ "repo": repo,
+ "chapter": chapter,
+ "lesson": lesson,
+ "content_path": f"lessons/{chapter}/{lesson}",
+ }
+
+ return None
+
+
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
@@ -466,6 +642,7 @@ def _empty_payload(
},
"auth": auth,
"source_of_truth": source_cards,
+ "discussion_settings": _discussion_settings_from_configured(False),
"featured_courses": [],
"recent_posts": [],
"upcoming_events": [],
@@ -510,10 +687,13 @@ async def _summarize_blog_post(
full_name: str,
repo_description: str,
updated_at: str,
+ default_branch: str,
+ repo_html_url: str,
post_name: str,
) -> dict[str, object]:
post_path = f"blogs/{post_name}"
fallback_title = _display_name(post_name)
+ raw_base_url = _raw_folder_url(repo_html_url, default_branch, post_path)
try:
post_entries = await client.list_directory(owner, repo, post_path)
@@ -527,8 +707,10 @@ async def _summarize_blog_post(
repo_description,
updated_at,
post_path,
+ raw_base_url=raw_base_url,
)
+ assets = _content_assets(post_entries, raw_base_url, post_path)
markdown_files = _markdown_file_entries(post_entries)
if not markdown_files:
return _empty_blog_post(
@@ -540,6 +722,8 @@ async def _summarize_blog_post(
repo_description,
updated_at,
post_path,
+ raw_base_url=raw_base_url,
+ assets=assets,
)
markdown_name = str(markdown_files[0]["name"])
@@ -559,6 +743,8 @@ async def _summarize_blog_post(
post_path,
file_path=markdown_path,
html_url=str(markdown_files[0].get("html_url", "")),
+ raw_base_url=raw_base_url,
+ assets=assets,
)
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@@ -572,6 +758,8 @@ async def _summarize_blog_post(
"path": post_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
+ "raw_base_url": raw_base_url,
+ "assets": assets,
"body": body,
"updated_at": updated_at,
}
@@ -581,20 +769,30 @@ async def _summarize_lesson(
client: ForgejoClient,
owner: str,
repo: str,
+ default_branch: str,
+ repo_html_url: str,
chapter_name: str,
lesson_name: str,
) -> dict[str, object]:
lesson_path = f"lessons/{chapter_name}/{lesson_name}"
fallback_title = _display_name(lesson_name)
+ raw_base_url = _raw_folder_url(repo_html_url, default_branch, lesson_path)
try:
lesson_entries = await client.list_directory(owner, repo, lesson_path)
except ForgejoClientError:
- return _empty_lesson(lesson_name, fallback_title, lesson_path)
+ return _empty_lesson(lesson_name, fallback_title, lesson_path, raw_base_url=raw_base_url)
+ assets = _content_assets(lesson_entries, raw_base_url, lesson_path)
markdown_files = _markdown_file_entries(lesson_entries)
if not markdown_files:
- return _empty_lesson(lesson_name, fallback_title, lesson_path)
+ return _empty_lesson(
+ lesson_name,
+ fallback_title,
+ lesson_path,
+ raw_base_url=raw_base_url,
+ assets=assets,
+ )
markdown_name = str(markdown_files[0]["name"])
markdown_path = f"{lesson_path}/{markdown_name}"
@@ -608,6 +806,8 @@ async def _summarize_lesson(
lesson_path,
file_path=markdown_path,
html_url=str(markdown_files[0].get("html_url", "")),
+ raw_base_url=raw_base_url,
+ assets=assets,
)
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@@ -618,6 +818,8 @@ async def _summarize_lesson(
"path": lesson_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
+ "raw_base_url": raw_base_url,
+ "assets": assets,
"body": body,
}
@@ -646,6 +848,45 @@ def _markdown_file_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]
)
+def _content_assets(
+ entries: list[dict[str, Any]],
+ raw_base_url: str,
+ folder_path: str,
+) -> list[dict[str, object]]:
+ assets: list[dict[str, object]] = []
+ for entry in entries:
+ if entry.get("type") != "file" or not isinstance(entry.get("name"), str):
+ continue
+ name = str(entry["name"])
+ if name.lower().endswith(".md"):
+ continue
+
+ path = str(entry.get("path") or f"{folder_path}/{name}")
+ assets.append(
+ {
+ "name": name,
+ "path": path,
+ "html_url": str(entry.get("html_url", "")),
+ "download_url": str(entry.get("download_url") or _raw_file_url(raw_base_url, name)),
+ },
+ )
+
+ return sorted(assets, key=lambda asset: str(asset["name"]))
+
+
+def _raw_folder_url(repo_html_url: str, default_branch: str, folder_path: str) -> str:
+ if not repo_html_url:
+ return ""
+ branch = default_branch.strip("/") or "main"
+ return f"{repo_html_url.rstrip('/')}/raw/branch/{branch}/{folder_path.strip('/')}/"
+
+
+def _raw_file_url(raw_base_url: str, name: str) -> str:
+ if not raw_base_url:
+ return ""
+ return f"{raw_base_url.rstrip('/')}/{name}"
+
+
def _display_name(value: str) -> str:
cleaned = value.strip().rsplit(".", 1)[0]
cleaned = cleaned.replace("_", " ").replace("-", " ")
@@ -693,6 +934,8 @@ def _empty_lesson(
*,
file_path: str = "",
html_url: str = "",
+ raw_base_url: str = "",
+ assets: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"slug": lesson_name,
@@ -701,6 +944,8 @@ def _empty_lesson(
"path": lesson_path,
"file_path": file_path,
"html_url": html_url,
+ "raw_base_url": raw_base_url,
+ "assets": assets or [],
"body": "",
}
@@ -717,6 +962,8 @@ def _empty_blog_post(
*,
file_path: str = "",
html_url: str = "",
+ raw_base_url: str = "",
+ assets: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"slug": post_name,
@@ -728,6 +975,8 @@ def _empty_blog_post(
"path": post_path,
"file_path": file_path,
"html_url": html_url,
+ "raw_base_url": raw_base_url,
+ "assets": assets or [],
"body": "",
"updated_at": updated_at,
}
diff --git a/prototype_cache.py b/prototype_cache.py
new file mode 100644
index 0000000..ad77171
--- /dev/null
+++ b/prototype_cache.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import asyncio
+import copy
+import time
+
+from live_prototype import build_live_prototype_payload
+from settings import Settings
+
+
+class PrototypePayloadCache:
+ def __init__(self) -> None:
+ self._payload: dict[str, object] | None = None
+ self._expires_at = 0.0
+ self._lock = asyncio.Lock()
+
+ async def get(self, settings: Settings) -> dict[str, object]:
+ if settings.forgejo_cache_ttl_seconds <= 0:
+ return copy.deepcopy(await self._build_public_payload(settings))
+
+ now = time.monotonic()
+ if self._payload is not None and now < self._expires_at:
+ return copy.deepcopy(self._payload)
+
+ async with self._lock:
+ now = time.monotonic()
+ if self._payload is not None and now < self._expires_at:
+ return copy.deepcopy(self._payload)
+
+ payload = await self._build_public_payload(settings)
+ self._payload = copy.deepcopy(payload)
+ self._expires_at = time.monotonic() + settings.forgejo_cache_ttl_seconds
+ return copy.deepcopy(self._payload)
+
+ def invalidate(self) -> None:
+ self._payload = None
+ self._expires_at = 0.0
+
+ async def _build_public_payload(self, settings: Settings) -> dict[str, object]:
+ read_token = settings.forgejo_token
+ auth_source = "server" if read_token else "none"
+ return await build_live_prototype_payload(
+ settings,
+ forgejo_token=read_token,
+ auth_source=auth_source,
+ session_user=None,
+ )
diff --git a/scripts/check_python_quality.sh b/scripts/check_python_quality.sh
index f6615d2..9c555da 100755
--- a/scripts/check_python_quality.sh
+++ b/scripts/check_python_quality.sh
@@ -9,7 +9,9 @@ python_files=(
"calendar_feeds.py"
"forgejo_client.py"
"live_prototype.py"
+ "prototype_cache.py"
"settings.py"
+ "update_events.py"
"tests"
)
@@ -45,7 +47,7 @@ run_check \
uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \
--requirements-files requirements.txt \
- --known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,settings \
+ --known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,prototype_cache,settings,update_events \
--per-rule-ignores DEP002=uvicorn \
--extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \
@@ -53,7 +55,7 @@ run_check \
run_check \
"Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \
- vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
+ vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py update_events.py tests --min-confidence 80
run_check \
"Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
diff --git a/settings.py b/settings.py
index a2686ad..721c50b 100644
--- a/settings.py
+++ b/settings.py
@@ -15,8 +15,11 @@ class Settings:
forgejo_oauth_client_id: str | None
forgejo_oauth_client_secret: str | None
forgejo_oauth_scopes: tuple[str, ...]
+ forgejo_general_discussion_repo: str | None
+ forgejo_webhook_secret: str | None
forgejo_repo_scan_limit: int
forgejo_recent_issue_limit: int
+ forgejo_cache_ttl_seconds: float
forgejo_request_timeout_seconds: float
calendar_feed_urls: tuple[str, ...]
calendar_event_limit: int
@@ -66,8 +69,11 @@ def get_settings() -> Settings:
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
forgejo_oauth_client_secret=os.getenv("FORGEJO_OAUTH_CLIENT_SECRET") or None,
forgejo_oauth_scopes=_parse_scopes(os.getenv("FORGEJO_OAUTH_SCOPES")),
+ forgejo_general_discussion_repo=os.getenv("FORGEJO_GENERAL_DISCUSSION_REPO") or None,
+ forgejo_webhook_secret=os.getenv("FORGEJO_WEBHOOK_SECRET") or None,
forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")),
- forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "6")),
+ forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "50")),
+ forgejo_cache_ttl_seconds=float(os.getenv("FORGEJO_CACHE_TTL_SECONDS", "60.0")),
forgejo_request_timeout_seconds=float(
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
),
diff --git a/tests/test_app.py b/tests/test_app.py
index 833b114..0f0b03b 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,7 +1,9 @@
from __future__ import annotations
+import hmac
import os
import unittest
+from hashlib import sha256
from urllib.parse import parse_qs, urlparse
from unittest.mock import AsyncMock, patch
@@ -55,7 +57,7 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
- with patch("app.build_live_prototype_payload", new=builder):
+ with patch("prototype_cache.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
response_payload = response.json()
@@ -70,14 +72,14 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
- def test_prototype_accepts_authorization_token(self) -> None:
+ def test_prototype_reuses_cached_public_payload(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
- "authenticated": True,
- "login": "kacper",
- "source": "authorization",
- "can_reply": True,
+ "authenticated": False,
+ "login": None,
+ "source": "none",
+ "can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
@@ -87,15 +89,98 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
- with patch("app.build_live_prototype_payload", new=builder):
+ with patch("prototype_cache.build_live_prototype_payload", new=builder):
+ first_response = self.client.get("/api/prototype")
+ second_response = self.client.get("/api/prototype")
+
+ self.assertEqual(first_response.status_code, 200)
+ self.assertEqual(second_response.status_code, 200)
+ self.assertEqual(builder.await_count, 1)
+
+ def test_forgejo_webhook_invalidates_prototype_cache(self) -> None:
+ initial_payload = {
+ "hero": {"title": "Before"},
+ "auth": {
+ "authenticated": False,
+ "login": None,
+ "source": "none",
+ "can_reply": False,
+ "oauth_configured": True,
+ },
+ "featured_courses": [],
+ "recent_posts": [],
+ "recent_discussions": [],
+ "upcoming_events": [],
+ "source_of_truth": [],
+ }
+ refreshed_payload = {
+ **initial_payload,
+ "hero": {"title": "After"},
+ }
+ builder = AsyncMock(side_effect=[initial_payload, refreshed_payload])
+ with patch("prototype_cache.build_live_prototype_payload", new=builder):
+ first_response = self.client.get("/api/prototype")
+ webhook_response = self.client.post("/api/forgejo/webhook", json={"ref": "main"})
+ second_response = self.client.get("/api/prototype")
+
+ self.assertEqual(first_response.json()["hero"]["title"], "Before")
+ self.assertEqual(webhook_response.status_code, 200)
+ self.assertEqual(second_response.json()["hero"]["title"], "After")
+ self.assertEqual(builder.await_count, 2)
+
+ def test_forgejo_webhook_validates_signature_when_secret_is_configured(self) -> None:
+ get_settings.cache_clear()
+ body = b'{"ref":"main"}'
+ signature = hmac.new(b"webhook-secret", body, sha256).hexdigest()
+ with patch.dict(os.environ, {"FORGEJO_WEBHOOK_SECRET": "webhook-secret"}):
+ bad_response = self.client.post(
+ "/api/forgejo/webhook",
+ content=body,
+ headers={"X-Forgejo-Signature": "bad-signature"},
+ )
+ good_response = self.client.post(
+ "/api/forgejo/webhook",
+ content=body,
+ headers={"X-Forgejo-Signature": signature},
+ )
+
+ self.assertEqual(bad_response.status_code, 401)
+ self.assertEqual(good_response.status_code, 200)
+
+ def test_prototype_accepts_authorization_token(self) -> None:
+ payload = {
+ "hero": {"title": "Robot U"},
+ "auth": {
+ "authenticated": False,
+ "login": None,
+ "source": "none",
+ "can_reply": False,
+ "oauth_configured": True,
+ },
+ "featured_courses": [],
+ "recent_posts": [],
+ "recent_discussions": [],
+ "upcoming_events": [],
+ "source_of_truth": [],
+ }
+ builder = AsyncMock(return_value=payload)
+ fake_client = _FakeForgejoClient(user={"login": "kacper"})
+ with (
+ patch("prototype_cache.build_live_prototype_payload", new=builder),
+ patch("app.ForgejoClient", return_value=fake_client),
+ ):
response = self.client.get(
"/api/prototype",
headers={"Authorization": "token test-token"},
)
+ response_payload = response.json()
self.assertEqual(response.status_code, 200)
- self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token")
- self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization")
+ self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
+ self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
+ self.assertEqual(response_payload["auth"]["authenticated"], True)
+ self.assertEqual(response_payload["auth"]["login"], "kacper")
+ self.assertEqual(response_payload["auth"]["source"], "authorization")
def test_prototype_can_use_server_token_without_user_session(self) -> None:
payload = {
@@ -117,7 +202,7 @@ class AppTestCase(unittest.TestCase):
get_settings.cache_clear()
with (
patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}),
- patch("app.build_live_prototype_payload", new=builder),
+ patch("prototype_cache.build_live_prototype_payload", new=builder),
):
response = self.client.get("/api/prototype")
@@ -213,13 +298,17 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
- with patch("app.build_live_prototype_payload", new=builder):
+ with patch("prototype_cache.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
+ response_payload = response.json()
self.assertEqual(response.status_code, 200)
- self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token")
- self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
- self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
+ self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
+ self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
+ self.assertIsNone(builder.call_args.kwargs["session_user"])
+ self.assertEqual(response_payload["auth"]["authenticated"], True)
+ self.assertEqual(response_payload["auth"]["login"], "kacper")
+ self.assertEqual(response_payload["auth"]["source"], "session")
def test_encrypted_session_cookie_survives_new_app_instance(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
@@ -278,6 +367,187 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(payload["author"], "Kacper")
self.assertEqual(payload["body"], "Thanks, this helped.")
+ def test_discussion_detail_fetches_public_issue(self) -> None:
+ fake_client = _FakeForgejoClient(
+ issue={
+ "id": 456,
+ "number": 9,
+ "title": "Encoder math question",
+ "body": "Canonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
+ "comments": 1,
+ "updated_at": "2026-04-11T12:00:00Z",
+ "html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
+ "user": {"login": "Kacper", "avatar_url": ""},
+ "labels": [],
+ "state": "open",
+ },
+ comments=[
+ {
+ "id": 777,
+ "body": "Reply body",
+ "created_at": "2026-04-11T12:30:00Z",
+ "html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9#issuecomment-777",
+ "user": {"login": "Ada", "avatar_url": ""},
+ }
+ ],
+ )
+ with patch("app.ForgejoClient", return_value=fake_client):
+ response = self.client.get("/api/discussions/Robot-U/robot-u-site/9")
+
+ payload = response.json()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(payload["title"], "Encoder math question")
+ self.assertEqual(payload["comments"][0]["body"], "Reply body")
+ self.assertEqual(payload["links"][0]["kind"], "post")
+
+ def test_create_discussion_reply_invalidates_prototype_cache(self) -> None:
+ initial_payload = {
+ "hero": {"title": "Before"},
+ "auth": {
+ "authenticated": False,
+ "login": None,
+ "source": "none",
+ "can_reply": False,
+ "oauth_configured": True,
+ },
+ "featured_courses": [],
+ "recent_posts": [],
+ "recent_discussions": [],
+ "upcoming_events": [],
+ "source_of_truth": [],
+ }
+ refreshed_payload = {
+ **initial_payload,
+ "hero": {"title": "After"},
+ }
+ builder = AsyncMock(side_effect=[initial_payload, refreshed_payload])
+ fake_client = _FakeForgejoClient(
+ comment={
+ "id": 123,
+ "body": "Thanks, this helped.",
+ "created_at": "2026-04-11T12:00:00Z",
+ "html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
+ "user": {"login": "Kacper", "avatar_url": ""},
+ },
+ )
+ with (
+ patch("prototype_cache.build_live_prototype_payload", new=builder),
+ patch("app.ForgejoClient", return_value=fake_client),
+ ):
+ first_response = self.client.get("/api/prototype")
+ reply_response = self.client.post(
+ "/api/discussions/replies",
+ json={
+ "owner": "Robot-U",
+ "repo": "RobotClass",
+ "number": 2,
+ "body": "Thanks, this helped.",
+ },
+ headers={"Authorization": "token test-token"},
+ )
+ second_response = self.client.get("/api/prototype")
+
+ self.assertEqual(first_response.json()["hero"]["title"], "Before")
+ self.assertEqual(reply_response.status_code, 200)
+ self.assertEqual(second_response.json()["hero"]["title"], "After")
+ self.assertEqual(builder.await_count, 2)
+
+ def test_create_linked_discussion(self) -> None:
+ fake_client = _FakeForgejoClient(
+ issue={
+ "id": 456,
+ "number": 9,
+ "title": "Encoder math question",
+ "body": "How should I debounce this?\n\n---\nCanonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
+ "comments": 0,
+ "updated_at": "2026-04-11T12:00:00Z",
+ "html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
+ "user": {"login": "Kacper", "avatar_url": ""},
+ "labels": [],
+ "state": "open",
+ },
+ )
+ with patch("app.ForgejoClient", return_value=fake_client) as client_factory:
+ response = self.client.post(
+ "/api/discussions",
+ json={
+ "owner": "Robot-U",
+ "repo": "robot-u-site",
+ "title": "Encoder math question",
+ "body": "How should I debounce this?",
+ "context_url": "http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
+ "context_path": "blogs/building-robot-u-site/index.md",
+ "context_title": "Building Robot U",
+ },
+ headers={"Authorization": "token test-token"},
+ )
+
+ payload = response.json()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "test-token")
+ self.assertIsNotNone(fake_client.created_issue)
+ assert fake_client.created_issue is not None
+ self.assertEqual(
+ fake_client.created_issue[:3], ("Robot-U", "robot-u-site", "Encoder math question")
+ )
+ self.assertIn(
+ "Canonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
+ fake_client.created_issue[3],
+ )
+ self.assertEqual(payload["id"], 456)
+ self.assertEqual(payload["repo"], "Robot-U/robot-u-site")
+ self.assertEqual(payload["links"][0]["kind"], "post")
+
+ def test_create_general_discussion_uses_configured_repo(self) -> None:
+ get_settings.cache_clear()
+ fake_client = _FakeForgejoClient(
+ issue={
+ "id": 457,
+ "number": 10,
+ "title": "General project help",
+ "body": "I need help choosing motors.",
+ "comments": 0,
+ "updated_at": "2026-04-11T12:00:00Z",
+ "html_url": "https://aksal.cloud/Robot-U/community/issues/10",
+ "user": {"login": "Kacper", "avatar_url": ""},
+ "labels": [],
+ "state": "open",
+ },
+ )
+ with (
+ patch.dict(os.environ, {"FORGEJO_GENERAL_DISCUSSION_REPO": "Robot-U/community"}),
+ patch("app.ForgejoClient", return_value=fake_client),
+ ):
+ response = self.client.post(
+ "/api/discussions",
+ json={
+ "title": "General project help",
+ "body": "I need help choosing motors.",
+ },
+ headers={"Authorization": "token test-token"},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ fake_client.created_issue,
+ ("Robot-U", "community", "General project help", "I need help choosing motors."),
+ )
+
+ def test_create_discussion_rejects_server_token_fallback(self) -> None:
+ get_settings.cache_clear()
+ with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
+ response = self.client.post(
+ "/api/discussions",
+ json={
+ "owner": "Robot-U",
+ "repo": "robot-u-site",
+ "title": "General project help",
+ "body": "I need help choosing motors.",
+ },
+ )
+
+ self.assertEqual(response.status_code, 401)
+
def test_create_discussion_reply_uses_signed_in_identity(self) -> None:
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=sign_in_client):
@@ -354,15 +624,20 @@ class _FakeForgejoClient:
def __init__(
self,
comment: dict[str, object] | None = None,
+ comments: list[dict[str, object]] | None = None,
+ issue: dict[str, object] | None = None,
user: dict[str, object] | None = None,
access_token: str = "test-oauth-token",
repo_private: bool = False,
) -> None:
self._comment = comment
+ self._comments = comments or []
+ self._issue = issue
self._user = user or {"login": "test-user"}
self._access_token = access_token
self._repo_private = repo_private
self.created_comment: tuple[str, str, int, str] | None = None
+ self.created_issue: tuple[str, str, str, str] | None = None
self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient:
@@ -383,6 +658,23 @@ class _FakeForgejoClient:
raise AssertionError("Fake comment was not configured.")
return self._comment
+ async def create_issue(
+ self,
+ owner: str,
+ repo: str,
+ title: str,
+ body: str,
+ ) -> dict[str, object]:
+ self.created_issue = (owner, repo, title, body)
+ if self._issue is None:
+ raise AssertionError("Fake issue was not configured.")
+ return self._issue
+
+ async def fetch_issue(self, _owner: str, _repo: str, _issue_number: int) -> dict[str, object]:
+ if self._issue is None:
+ raise AssertionError("Fake issue was not configured.")
+ return self._issue
+
async def fetch_current_user(self) -> dict[str, object]:
return self._user
@@ -394,6 +686,14 @@ class _FakeForgejoClient:
"private": self._repo_private,
}
+ async def list_issue_comments(
+ self,
+ _owner: str,
+ _repo: str,
+ _issue_number: int,
+ ) -> list[dict[str, object]]:
+ return self._comments
+
async def fetch_openid_configuration(self) -> dict[str, object]:
return {
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",
diff --git a/tests/test_live_prototype.py b/tests/test_live_prototype.py
index 736f9bf..98a2d7c 100644
--- a/tests/test_live_prototype.py
+++ b/tests/test_live_prototype.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import unittest
from typing import Any
-from live_prototype import _post_card, _summarize_repo
+from live_prototype import _post_card, _summarize_repo, discussion_links_from_text
class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
@@ -29,6 +29,25 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
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"])
+ self.assertEqual(post["assets"][0]["name"], "worksheet.pdf")
+
+ def test_discussion_links_detect_app_and_forgejo_urls(self) -> None:
+ links = discussion_links_from_text(
+ "\n".join(
+ [
+ "Canonical URL: https://robot-u.test/courses/Robot-U/encoder/lessons/01-basics/01-counts",
+ "Source: https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md",
+ ],
+ ),
+ )
+
+ self.assertEqual(links[0]["kind"], "lesson")
+ self.assertEqual(
+ links[0]["path"],
+ "/courses/Robot-U/encoder/lessons/01-basics/01-counts",
+ )
+ self.assertEqual(links[1]["kind"], "post")
+ self.assertEqual(links[1]["path"], "/posts/Robot-U/robot-u-site/building-robot-u-site")
class _FakeContentClient:
@@ -45,8 +64,15 @@ class _FakeContentClient:
{
"type": "file",
"name": "index.md",
+ "path": "blogs/building-robot-u-site/index.md",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md",
},
+ {
+ "type": "file",
+ "name": "worksheet.pdf",
+ "path": "blogs/building-robot-u-site/worksheet.pdf",
+ "download_url": "https://aksal.cloud/Robot-U/robot-u-site/raw/branch/main/blogs/building-robot-u-site/worksheet.pdf",
+ },
],
}
return entries.get(path, [])
diff --git a/update_events.py b/update_events.py
new file mode 100644
index 0000000..5e326c8
--- /dev/null
+++ b/update_events.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from collections.abc import AsyncIterator
+from typing import Any
+
+
+class UpdateBroker:
+ def __init__(self) -> None:
+ self._subscribers: set[asyncio.Queue[dict[str, object]]] = set()
+
+ def subscribe(self) -> asyncio.Queue[dict[str, object]]:
+ queue: asyncio.Queue[dict[str, object]] = asyncio.Queue(maxsize=20)
+ self._subscribers.add(queue)
+ return queue
+
+ def unsubscribe(self, queue: asyncio.Queue[dict[str, object]]) -> None:
+ self._subscribers.discard(queue)
+
+ async def publish(self, event: str, payload: dict[str, object] | None = None) -> None:
+ message = {
+ "event": event,
+ "data": {
+ "type": event,
+ "published_at": time.time(),
+ **(payload or {}),
+ },
+ }
+ stale_queues: list[asyncio.Queue[dict[str, object]]] = []
+ for queue in tuple(self._subscribers):
+ try:
+ queue.put_nowait(message)
+ except asyncio.QueueFull:
+ stale_queues.append(queue)
+
+ for queue in stale_queues:
+ self.unsubscribe(queue)
+
+
+async def stream_sse_events(
+ broker: UpdateBroker,
+) -> AsyncIterator[str]:
+ queue = broker.subscribe()
+ try:
+ yield _sse_event("connected", {"type": "connected", "published_at": time.time()})
+ while True:
+ try:
+ message = await asyncio.wait_for(queue.get(), timeout=25)
+ except TimeoutError:
+ yield ": keepalive\n\n"
+ continue
+
+ yield _sse_event(
+ str(message.get("event", "message")),
+ message.get("data") if isinstance(message.get("data"), dict) else {},
+ )
+ finally:
+ broker.unsubscribe(queue)
+
+
+def _sse_event(event: str, payload: dict[str, Any]) -> str:
+ return f"event: {event}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"