Initial Robot U site prototype

This commit is contained in:
Kacper 2026-04-08 06:03:48 -04:00
commit fe19f200d7
27 changed files with 3677 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
FORGEJO_BASE_URL=https://aksal.cloud
FORGEJO_TOKEN=
FORGEJO_REPO_SCAN_LIMIT=30
FORGEJO_RECENT_ISSUE_LIMIT=6
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
CALENDAR_FEED_URLS=
CALENDAR_EVENT_LIMIT=6

6
.githooks/pre-commit Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/check_python_quality.sh
./scripts/check_frontend_quality.sh

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
frontend/node_modules/
frontend/dist/
frontend/.vite/
examples/quadrature-encoder-course/

3
.ruff.toml Normal file
View file

@ -0,0 +1,3 @@
line-length = 100
target-version = "py312"

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# Robot U Site Prototype
Thin frontend layer over Forgejo for community learning content, discussions, and events.
## Stack
- FastAPI backend
- Preact + TypeScript frontend built with Vite
- `bun` for frontend tooling
- `uv` + `ruff` for Python checks
## Local Development
### Backend
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python -m uvicorn app:app --reload
```
Optional live Forgejo configuration:
```bash
export FORGEJO_BASE_URL="https://aksal.cloud"
export FORGEJO_TOKEN="your-forgejo-api-token"
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
```
### Frontend
```bash
cd frontend
~/.bun/bin/bun install
~/.bun/bin/bun run dev
```
### Quality Checks
```bash
./scripts/check_python_quality.sh
./scripts/check_frontend_quality.sh
```

55
app.py Normal file
View file

@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from live_prototype import build_live_prototype_payload
from settings import get_settings
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI:
app = FastAPI(title="Robot U Community Prototype")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health() -> JSONResponse:
return JSONResponse({"status": "ok"})
@app.get("/api/prototype")
async def prototype() -> JSONResponse:
return JSONResponse(await build_live_prototype_payload(get_settings()))
if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse:
candidate = DIST_DIR / full_path
if candidate.is_file():
return FileResponse(str(candidate))
response = FileResponse(str(DIST_DIR / "index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return app
app = create_app()

149
calendar_feeds.py Normal file
View file

@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, time
from urllib.parse import urlparse
import httpx
class CalendarFeedError(RuntimeError):
pass
@dataclass(frozen=True)
class CalendarEvent:
title: str
starts_at: datetime
source: str
mode: str
@dataclass(frozen=True)
class CalendarFeed:
url: str
source_name: str
events: list[CalendarEvent]
async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed:
normalized_url = _normalize_calendar_url(url)
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
response = await client.get(normalized_url)
if not response.is_success:
raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}")
calendar_name, events = _parse_ics(response.text, normalized_url)
return CalendarFeed(url=normalized_url, source_name=calendar_name, events=events)
def _normalize_calendar_url(raw_url: str) -> str:
value = raw_url.strip()
if not value:
raise CalendarFeedError("Calendar feed URL is empty.")
parsed = urlparse(value)
if parsed.scheme == "webcal":
return parsed._replace(scheme="https").geturl()
if parsed.scheme in {"http", "https"}:
return value
raise CalendarFeedError(f"Unsupported calendar feed scheme: {parsed.scheme or 'missing'}")
def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
lines = _unfold_ics_lines(raw_text)
calendar_name = _calendar_name(lines, feed_url)
now = datetime.now(UTC)
events: list[CalendarEvent] = []
current_event: dict[str, str] | None = None
for line in lines:
if line == "BEGIN:VEVENT":
current_event = {}
continue
if line == "END:VEVENT":
parsed_event = _event_from_properties(current_event or {}, calendar_name)
if parsed_event and parsed_event.starts_at >= now:
events.append(parsed_event)
current_event = None
continue
if current_event is None or ":" not in line:
continue
raw_key, value = line.split(":", 1)
current_event[raw_key] = value.strip()
events.sort(key=lambda event: event.starts_at)
return calendar_name, events
def _unfold_ics_lines(raw_text: str) -> list[str]:
lines = raw_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
unfolded: list[str] = []
for line in lines:
if not line:
continue
if unfolded and line[:1] in {" ", "\t"}:
unfolded[-1] = f"{unfolded[-1]}{line[1:]}"
else:
unfolded.append(line)
return unfolded
def _calendar_name(lines: list[str], feed_url: str) -> str:
for line in lines:
if line.startswith("X-WR-CALNAME:"):
return _decode_ics_text(line.split(":", 1)[1].strip()) or _calendar_host(feed_url)
return _calendar_host(feed_url)
def _calendar_host(feed_url: str) -> str:
parsed = urlparse(feed_url)
return parsed.hostname or "Calendar"
def _event_from_properties(properties: dict[str, str], calendar_name: str) -> CalendarEvent | None:
title = _decode_ics_text(properties.get("SUMMARY", "").strip())
start_key = next((key for key in properties if key.startswith("DTSTART")), None)
if not title or not start_key:
return None
starts_at = _parse_ics_datetime(start_key, properties[start_key])
if starts_at is None:
return None
location = _decode_ics_text(properties.get("LOCATION", "").strip())
return CalendarEvent(
title=title,
starts_at=starts_at,
source=calendar_name,
mode=location or "Calendar",
)
def _parse_ics_datetime(key: str, value: str) -> datetime | None:
try:
if "VALUE=DATE" in key:
parsed_date = datetime.strptime(value, "%Y%m%d").date()
return datetime.combine(parsed_date, time.min, tzinfo=UTC)
if value.endswith("Z"):
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
if "T" in value:
return datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
parsed_date = datetime.strptime(value, "%Y%m%d").date()
return datetime.combine(parsed_date, time.min, tzinfo=UTC)
except ValueError:
return None
def _decode_ics_text(value: str) -> str:
return (
value.replace("\\n", "\n")
.replace("\\N", "\n")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
)

View file

@ -0,0 +1,470 @@
# Community Learning Platform Design Doc
Status: Draft 0.2
Date: 2026-04-07
Owner: Product / Founding Team
## 1. Summary
This project is a web application for a small robotics-focused community that mixes structured learning with community participation. Members should be able to read lessons, share technical content, discuss blockers, and track progress through a course, while all core community content remains first-class data in Forgejo.
The app is intentionally not the source of truth for content. Instead, it acts as:
- a polished public frontend
- a better reading and discovery experience
- a better discussion experience
- a progress-tracking layer
- an integration layer over Forgejo and calendar feeds
If the custom frontend disappeared, the core community content should still exist in Forgejo.
## 2. Product Goals
### Primary Goals
- Teach members through bite-sized lessons that build toward larger projects
- Make it easy to browse courses, posts, and related resources
- Give members a place to discuss projects, blockers, and ideas
- Keep the publishing and discussion model aligned with Forgejo
- Provide a strong public landing page for discovery
### Secondary Goals
- Support both official org-authored content and user-authored public content
- Aggregate upcoming community events
- Track per-user lesson completion
## 3. Product Principles
### Forgejo-First
Forgejo is the system of record for:
- identity
- content repositories
- issue-backed discussions
- team-based permissions
### Frontend-First Experience
This app should provide a much better user experience than the raw Forgejo UI for:
- reading courses
- browsing posts
- finding discussions
- seeing upcoming events
### Public By Default
Reading should not require an account. Authentication is required for participation.
### Minimal App-Owned Data
The app should own only the state that does not naturally belong in Forgejo, such as:
- login/session state
- lesson completion state
- cached indexing metadata
- app configuration
## 4. MVP Scope
### Included
- Public landing page
- Public course browsing
- Public post browsing
- Public discussion reading
- Public event calendar
- Forgejo-only sign-in
- 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
### Excluded
- In-browser editing of posts or lessons
- In-browser media uploads
- Non-Forgejo authentication providers
- Search
- Rich public profiles
- Private repo indexing
- Admin-created calendar events in this app
- Review or approval workflow before publishing
## 5. Core User Experience
### New Visitor
1. Lands on homepage
2. Understands the community mission quickly
3. Sees featured courses, recent posts, upcoming events, and recent discussions
4. Opens a course, post, or discussion without signing in
### Signed-In Member
1. Signs in with Forgejo
2. Reads lessons and marks them complete
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
### Content Author
1. Creates or edits content directly in Forgejo
2. Pushes changes to a public repo
3. The app reindexes the repo via webhook
4. Updated content appears in the frontend
## 6. Source of Truth Matrix
| Concern | System of Record |
| --- | --- |
| User identity | Forgejo |
| Authentication | Forgejo OAuth/OIDC |
| Roles / moderation authority | Forgejo org/team membership |
| Courses / lessons / blog content | Forgejo repos |
| Content assets / downloads | Forgejo repos |
| Discussions / comments | Forgejo issues and issue comments |
| Events | External ICS feeds |
| Lesson progress | App database |
| Sessions | App backend |
| Cached index metadata | App database |
## 7. Information Architecture
### Public Sections
- Home
- Courses
- Posts
- Discussions
- Events
### Authenticated Capabilities
- Mark lesson complete
- Create discussion threads
- Reply to discussions
- Edit supported discussion content through the app
### Post-MVP Sections
- Profiles
- Search
- Rich dashboards
## 8. Content and Repo Model
### 8.1 Repo Discovery Rules
The app should automatically discover content from:
- all public repos on the configured Forgejo instance
- org-owned repos
- user-owned repos
The app should exclude:
- forks
- repos without recognized content folders
- private repos
### 8.2 Repo Classification Rules
A repo is considered content-bearing if it contains one or both of:
- `/blogs/`
- `/lessons/`
Interpretation:
- repos with `/lessons/` are course repos
- repos with `/blogs/` are blog/project repos
- a single repo may contain both and therefore appear in both the course and post experiences
### 8.3 Ownership Expectations
- Official lesson plans and course content will usually live in org repos
- Users may publish content through their own public repos
- User repos should be auto-discovered if they match the folder conventions
## 9. Content Folder Conventions
### 9.1 Blog / Post Repos
Posts live under:
- `/blogs/<post-folder>/`
Each post folder contains:
- exactly one markdown file
- optional images, downloads, or other supporting assets
### 9.2 Course / Lesson Repos
Lessons live under:
- `/lessons/<chapter-folder>/<lesson-folder>/`
Each lesson folder contains:
- exactly one markdown file
- optional supporting assets or downloadable materials
This structure supports chapters while keeping lesson resources colocated with the lesson content.
### 9.3 File Naming Rules
- The markdown filename may be any name
- The app should treat the single markdown file in the folder as the primary content file
- Frontmatter `title` should be used when present
- The filename should be the fallback title when frontmatter `title` is missing
### 9.4 Frontmatter
Markdown files should support frontmatter. Initial expected fields:
- `title`
- `summary`
- `date`
- `tags`
- `order`
Additional lesson-specific fields will likely be needed, but the exact schema still needs review.
## 10. Course Model
### Course Definition
- A course is represented by a repo
- Multiple courses must be supported from day one
- Courses should be presented as linear learning experiences, but users may enter any lesson directly
### Chapter and Lesson Structure
- Chapters are inferred from the folder structure under `/lessons/`
- Lessons are individual markdown-backed units with colocated assets
- Lesson order should be linear within a chapter
- The UI should provide clear previous/next navigation while still allowing free access
## 11. Posts and Lessons
The product will use one publishing system for markdown content, but the app should expose distinct user experiences:
- `Courses` for structured lesson content
- `Posts` for general blog/project content
In practice:
- lessons are a flavor of posts
- the difference comes from repo structure and organization, not a separate authoring tool
Publishing model:
- public repo content appears automatically
- no review gate or approval step in MVP
- content authoring and updates happen in Forgejo, not in this app
## 12. Discussion Model
### 12.1 General Principle
The site should provide its own frontend for discussions, but the underlying records must be Forgejo issues and comments.
### 12.2 General Discussions
General discussion threads should live in a dedicated org-level discussions repo.
### 12.3 Content-Linked Discussions
Post- and lesson-linked discussions should live in the same repo as the content they discuss.
Requirements:
- multiple discussion threads may be linked to the same repo
- multiple discussion threads may be linked to the same lesson or post
- manually created Forgejo issues should appear in the app if they include the expected content link
### 12.4 Linking Convention
The app should detect that an issue belongs to a specific post or lesson by parsing links contained in the issue body.
Recommended MVP behavior:
- support canonical app URLs to posts/lessons
- support Forgejo file or content URLs when feasible
This keeps linkage issue-driven rather than requiring authors to update post frontmatter with discussion IDs.
### 12.5 Write Permissions
Any signed-in user with a Forgejo account should be able to:
- create general discussion threads
- create post-linked or lesson-linked discussion threads
- reply to discussions
- edit their discussion content to the extent supported by Forgejo
Because Forgejo remains accessible directly, the app should mirror the practical capabilities available through Forgejo where possible.
## 13. Authentication and Authorization
### Authentication
- Forgejo is the only authentication provider for MVP
- Users sign in using Forgejo OAuth/OIDC
- The app creates its own local authenticated session after login
### Session Model
Recommended MVP implementation:
- app session stored in a secure HttpOnly cookie
- Forgejo access token stored encrypted server-side
- frontend JavaScript never directly receives the raw Forgejo token
### Authorization
App roles should be derived from Forgejo org/team membership rather than maintained separately in this app.
Expected role mapping:
- member
- moderator
- admin
Exact team-to-role mapping should be configured in app settings.
## 14. Calendar Model
The event calendar should be read-only in MVP.
Requirements:
- support one or more ICS feed URLs
- display upcoming events publicly
- surface upcoming events on the homepage
Non-goal for MVP:
- creating or editing events in the app
Events should continue to be managed in external calendar tools.
## 15. Progress Tracking
The app should track lesson progress per signed-in user.
MVP state model:
- not started
- completed
Storage:
- progress should live in the app database, not in Forgejo
Reason:
- this is app-specific state
- it is easier to query and render
- it avoids polluting content repos with user interaction data
## 16. Landing Page
The homepage should prioritize:
- featured courses
- recent posts
- upcoming events
- recent discussions
The page should clearly communicate that this is:
- a technical learning space
- a community space
- a place to follow structured project-building content
## 17. Recommended Technical Direction
The exact stack is still open, but the current recommendation is:
- Next.js for the frontend and server layer
- PostgreSQL for app state and indexing metadata
- background jobs for indexing and webhook processing
- markdown rendering pipeline for repo content
- SSE for live updates to connected clients
Additional integration needs:
- Forgejo API client
- webhook receiver for repo and issue changes
- ICS feed ingestion
## 18. Sync Model
Primary sync mechanism:
- Forgejo webhooks
Expected webhook-driven updates:
- repo content changes
- issue changes
- issue comment changes
Realtime UX:
- the app should push relevant updates to open sessions using SSE
Recommended safety net:
- periodic reconciliation job to recover from missed webhook deliveries
## 19. MVP Non-Goals
The following are explicitly out of scope for MVP:
- browser-based content editing
- browser-based asset uploads
- search
- rich user profiles
- non-Forgejo auth providers
- private repo support
- complex moderation workflows
- custom event authoring
## 20. Risks
- Forgejo OAuth tokens may be broader in power than ideal for third-party app writes
- Auto-discovering all public repos on the instance may create indexing and relevance challenges
- Content conventions need to be strict enough to keep rendering predictable
- Issue-to-content linking needs a clear convention so manually created issues are detected reliably
- Team-based permissions require stable org/team structure in Forgejo
## 21. Open Questions For Review
These are the main questions still worth reviewing before implementation starts:
1. What is the exact frontmatter schema for posts vs lessons?
2. How should chapter ordering be defined: folder naming, frontmatter, or both?
3. What exact issue-body link formats should count as a post/lesson association?
4. What is the moderator toolkit needed on day one?
5. How should featured courses/posts be chosen for the homepage?
6. Which Forgejo teams should map to moderator and admin?
7. How should multiple ICS feeds be labeled and grouped in the UI?
8. Do we want a dedicated course metadata file later, or is repo/folder inference enough?
## 22. Immediate Next Steps
1. Review this draft and resolve the open questions in Section 21.
2. Define the exact repo conventions and frontmatter schema.
3. Define the Forgejo integration contract for auth, repo indexing, and issue-backed discussions.
4. Create wireframes for home, courses, posts, discussions, and events.
5. Choose the implementation stack and start the technical design.

149
forgejo_client.py Normal file
View file

@ -0,0 +1,149 @@
from __future__ import annotations
import base64
from collections.abc import Mapping
from typing import Any
import httpx
from settings import Settings
class ForgejoClientError(RuntimeError):
pass
class ForgejoClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
async def __aenter__(self) -> ForgejoClient:
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self._client.aclose()
async def fetch_openid_configuration(self) -> dict[str, Any]:
return await self._get_json(
f"{self._settings.forgejo_base_url}/.well-known/openid-configuration",
absolute_url=True,
)
async def fetch_current_user(self) -> dict[str, Any]:
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,
},
auth_required=True,
)
data = payload.get("data", [])
return [repo for repo in data if isinstance(repo, dict)]
async def search_recent_issues(self) -> list[dict[str, Any]]:
payload = await self._get_json(
"/api/v1/repos/issues/search",
params={
"state": "open",
"page": 1,
"limit": self._settings.forgejo_recent_issue_limit,
"type": "issues",
},
auth_required=True,
)
if isinstance(payload, list):
return [issue for issue in payload if isinstance(issue, dict)]
return []
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:
endpoint = f"{endpoint}/{path.strip('/')}"
payload = await self._get_json(endpoint, auth_required=True)
if isinstance(payload, list):
return [entry for entry in payload if isinstance(entry, dict)]
if isinstance(payload, dict):
return [payload]
return []
async def list_issue_comments(
self,
owner: str,
repo: str,
issue_number: int,
) -> list[dict[str, Any]]:
payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
auth_required=True,
)
if isinstance(payload, list):
return [comment for comment in payload if isinstance(comment, dict)]
return []
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('/')}",
auth_required=True,
)
if not isinstance(payload, dict):
raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}")
raw_content = payload.get("content", "")
encoding = payload.get("encoding", "")
decoded_content = ""
if isinstance(raw_content, str):
if encoding == "base64":
decoded_content = base64.b64decode(raw_content).decode("utf-8")
else:
decoded_content = raw_content
return {
"name": str(payload.get("name", "")),
"path": str(payload.get("path", path)),
"html_url": str(payload.get("html_url", "")),
"content": decoded_content,
}
async def _get_json(
self,
path: str,
*,
absolute_url: bool = False,
params: Mapping[str, str | int] | None = None,
auth_required: bool = False,
) -> dict[str, Any] | list[Any]:
if auth_required and not self._settings.forgejo_token:
raise ForgejoClientError(
"This Forgejo instance requires an authenticated API token for repo and issue access.",
)
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
headers = {}
if self._settings.forgejo_token:
headers["Authorization"] = f"token {self._settings.forgejo_token}"
response = await self._client.get(url, params=params, headers=headers)
if response.is_success:
return response.json()
message = self._extract_error_message(response)
raise ForgejoClientError(f"{response.status_code} from Forgejo: {message}")
@staticmethod
def _extract_error_message(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return response.text or response.reason_phrase
if isinstance(payload, dict):
raw_message = payload.get("message")
if isinstance(raw_message, str) and raw_message:
return raw_message
return response.reason_phrase

71
frontend/biome.json Normal file
View file

@ -0,0 +1,71 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"includes": ["**", "!!**/dist", "!!**/node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useGenericFontNames": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"noUnusedFunctionParameters": "warn",
"noUnusedPrivateClassMembers": "error",
"noUnreachable": "error",
"noConstantCondition": "error"
},
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "warn",
"options": { "maxAllowedComplexity": 15 }
},
"noExcessiveLinesPerFunction": {
"level": "warn",
"options": { "maxLines": 80 }
},
"useMaxParams": {
"level": "warn",
"options": { "max": 5 }
}
},
"suspicious": {
"noExplicitAny": "warn"
},
"style": {
"useConst": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all",
"arrowParentheses": "always"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

427
frontend/bun.lock Normal file
View file

@ -0,0 +1,427 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "robot-u-frontend",
"dependencies": {
"preact": "^10.27.2",
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@preact/preset-vite": "^2.10.2",
"@types/node": "^24.7.2",
"knip": "^5.86.0",
"typescript": "^5.9.3",
"vite": "^7.1.9",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-jsx": "^7.28.6", "@babel/types": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow=="],
"@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="],
"@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="],
"@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="],
"@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="],
"@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="],
"@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A=="],
"@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="],
"@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="],
"@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="],
"@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="],
"@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w=="],
"@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw=="],
"@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="],
"@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ=="],
"@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw=="],
"@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="],
"@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.19.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg=="],
"@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="],
"@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="],
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="],
"@preact/preset-vite": ["@preact/preset-vite@2.10.5", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw=="],
"@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.3", "", {}, "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="],
"@prefresh/core": ["@prefresh/core@1.5.9", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ=="],
"@prefresh/utils": ["@prefresh/utils@1.2.1", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="],
"@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"babel-plugin-transform-hook-names": ["babel-plugin-transform-hook-names@1.0.2", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001786", "", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.332", "", {}, "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"knip": ["knip@5.88.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg=="],
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stack-trace": ["stack-trace@1.0.0-pre2", "", {}, "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="],
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Robot U Community Prototype</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
frontend/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "robot-u-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "biome lint ./src",
"format": "biome format --write ./src",
"check": "biome check --error-on-warnings ./src",
"hooks:install": "../scripts/install_git_hooks.sh",
"postinstall": "../scripts/install_git_hooks.sh"
},
"dependencies": {
"preact": "^10.27.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@preact/preset-vite": "^2.10.2",
"@types/node": "^24.7.2",
"knip": "^5.86.0",
"typescript": "^5.9.3",
"vite": "^7.1.9"
}
}

674
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,674 @@
import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type {
CourseCard,
CourseChapter,
CourseLesson,
DiscussionCard,
DiscussionReply,
EventCard,
PostCard,
PrototypeData,
} from "./types";
function formatTimestamp(value: string): string {
if (!value) {
return "Unknown time";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "Unknown time";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
function normalizePathname(pathname: string): string {
if (!pathname || pathname === "/") {
return "/";
}
return pathname.replace(/\/+$/, "") || "/";
}
function parseDiscussionRoute(pathname: string): number | null {
const match = normalizePathname(pathname).match(/^\/discussions\/(\d+)$/);
if (!match) {
return null;
}
const issueId = Number(match[1]);
return Number.isFinite(issueId) ? issueId : null;
}
function parseCourseRoute(pathname: string): { owner: string; repo: string } | null {
const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/);
if (!match) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
};
}
function parseLessonRoute(pathname: string): {
owner: string;
repo: string;
chapter: string;
lesson: string;
} | null {
const match = normalizePathname(pathname).match(
/^\/courses\/([^/]+)\/([^/]+)\/lessons\/([^/]+)\/([^/]+)$/,
);
if (!match) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
chapter: decodeURIComponent(match[3]),
lesson: decodeURIComponent(match[4]),
};
}
function normalizeRouteKey(value: string): string {
return decodeURIComponent(value).trim().toLowerCase();
}
function findCourseByRoute(
courses: CourseCard[],
route: { owner: string; repo: string },
): CourseCard | undefined {
const routeOwner = normalizeRouteKey(route.owner);
const routeRepo = normalizeRouteKey(route.repo);
const routeFullName = `${routeOwner}/${routeRepo}`;
return courses.find((course) => {
const [repoOwner = "", repoName = ""] = course.repo.split("/", 2);
const courseOwner = normalizeRouteKey(course.owner || repoOwner);
const courseName = normalizeRouteKey(course.name || repoName);
const courseFullName = normalizeRouteKey(course.repo);
return (
(courseOwner === routeOwner && courseName === routeRepo) ||
courseFullName === routeFullName ||
courseName === routeRepo
);
});
}
function findLessonByRoute(
course: CourseCard,
route: { chapter: string; lesson: string },
): { chapter: CourseChapter; lesson: CourseLesson } | undefined {
const routeChapter = normalizeRouteKey(route.chapter);
const routeLesson = normalizeRouteKey(route.lesson);
for (const chapter of course.outline) {
if (normalizeRouteKey(chapter.slug) !== routeChapter) {
continue;
}
const lesson = chapter.lessons.find((entry) => normalizeRouteKey(entry.slug) === routeLesson);
if (lesson) {
return { chapter, lesson };
}
}
return undefined;
}
function usePathname() {
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
useEffect(() => {
function handlePopState() {
setPathname(normalizePathname(window.location.pathname));
}
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
function navigate(nextPath: string) {
const normalized = normalizePathname(nextPath);
if (normalized === pathname) {
return;
}
window.history.pushState({}, "", normalized);
window.scrollTo({ top: 0, behavior: "auto" });
setPathname(normalized);
}
return { pathname, navigate };
}
function SectionHeader(props: { title: string }) {
return (
<header className="section-header">
<h2>{props.title}</h2>
</header>
);
}
function EmptyState(props: { copy: string }) {
return <p className="empty-state">{props.copy}</p>;
}
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
const { course, onOpenCourse } = props;
return (
<button
type="button"
className="card course-card-button"
onClick={() => {
onOpenCourse(course);
}}
>
<h3>{course.title}</h3>
<p className="muted-copy">{course.summary}</p>
<p className="meta-line">
{course.repo} · {course.lessons} lessons · {course.chapters} chapters
</p>
</button>
);
}
function PostItem(props: { post: PostCard }) {
const { post } = props;
return (
<article className="card">
<h3>{post.title}</h3>
<p className="muted-copy">{post.summary}</p>
<p className="meta-line">{post.repo}</p>
</article>
);
}
function EventItem(props: { event: EventCard }) {
const { event } = props;
return (
<article className="card">
<h3>{event.title}</h3>
<p className="muted-copy">{event.when}</p>
<p className="meta-line">{event.source}</p>
</article>
);
}
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
onOpenDiscussion: (id: number) => void;
}) {
const { discussion, onOpenDiscussion } = props;
return (
<button
type="button"
className="discussion-preview-card"
onClick={() => {
onOpenDiscussion(discussion.id);
}}
>
<h3>{discussion.title}</h3>
<p className="meta-line">
{discussion.repo} · {discussion.author} · {formatTimestamp(discussion.updated_at)} ·{" "}
{discussion.replies} replies
</p>
</button>
);
}
function DiscussionReplyCard(props: { reply: DiscussionReply }) {
const { reply } = props;
return (
<article className="reply-card">
<p className="reply-author">{reply.author}</p>
<p className="meta-line">{formatTimestamp(reply.created_at)}</p>
<MarkdownContent markdown={reply.body} className="thread-copy" />
</article>
);
}
function ComposeBox() {
return (
<form
className="compose-box"
onSubmit={(event) => {
event.preventDefault();
}}
>
<textarea className="compose-input" placeholder="Write a reply" />
<div className="compose-actions">
<button type="submit" className="compose-button" disabled>
Post reply
</button>
</div>
</form>
);
}
function CoursePage(props: {
course: CourseCard;
onGoHome: () => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
}) {
const { course, onGoHome, onOpenLesson } = props;
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoHome}>
Back to courses
</button>
<article className="panel">
<header className="thread-header">
<h1>{course.title}</h1>
<p className="meta-line">
{course.repo} · {course.lessons} lessons · {course.chapters} chapters
</p>
</header>
<p className="muted-copy">{course.summary}</p>
</article>
<article className="panel">
<header className="subsection-header">
<h2>Course outline</h2>
</header>
{course.outline.length > 0 ? (
<div className="outline-list">
{course.outline.map((chapter) => (
<section key={chapter.slug} className="outline-chapter">
<h3>{chapter.title}</h3>
<div className="lesson-list">
{chapter.lessons.map((lesson, index) => (
<button
key={lesson.path}
type="button"
className="lesson-row lesson-row-button"
onClick={() => {
onOpenLesson(course, chapter, lesson);
}}
>
<p className="lesson-index">{index + 1 < 10 ? `0${index + 1}` : index + 1}</p>
<div>
<p className="lesson-title">{lesson.title}</p>
{lesson.summary ? <p className="lesson-summary">{lesson.summary}</p> : null}
<p className="meta-line">{lesson.file_path || lesson.path}</p>
</div>
</button>
))}
</div>
</section>
))}
</div>
) : (
<EmptyState copy="This course repo has no lesson folders yet." />
)}
</article>
</section>
);
}
function LessonPage(props: {
course: CourseCard;
chapter: CourseChapter;
lesson: CourseLesson;
onGoCourse: () => void;
}) {
const { course, chapter, lesson, onGoCourse } = props;
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoCourse}>
Back to course
</button>
<article className="panel">
<header className="thread-header">
<h1>{lesson.title}</h1>
<p className="meta-line">
{course.repo} · {chapter.title}
</p>
</header>
{lesson.summary ? <p className="muted-copy">{lesson.summary}</p> : null}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Lesson</h2>
</header>
{lessonBody ? (
<MarkdownContent markdown={lessonBody} className="lesson-body" />
) : (
<EmptyState copy="This lesson file is empty or could not be read from Forgejo." />
)}
</article>
</section>
);
}
function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => void }) {
const { discussion, onGoHome } = props;
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoHome}>
Back to discussions
</button>
<article className="panel">
<header className="thread-header">
<h1>{discussion.title}</h1>
<p className="meta-line">
{discussion.repo} · Issue #{discussion.number} · {discussion.author} ·{" "}
{formatTimestamp(discussion.updated_at)}
</p>
</header>
<MarkdownContent markdown={discussion.body} className="thread-copy" />
</article>
<article className="panel">
<header className="subsection-header">
<h2>Replies</h2>
<p className="meta-line">{discussion.comments.length}</p>
</header>
{discussion.comments.length > 0 ? (
<div className="reply-list">
{discussion.comments.map((reply) => (
<DiscussionReplyCard key={reply.id} reply={reply} />
))}
</div>
) : (
<EmptyState copy="No replies yet." />
)}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Reply</h2>
</header>
<ComposeBox />
</article>
</section>
);
}
function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
onOpenDiscussion: (id: number) => void;
}) {
const { data, onOpenCourse, onOpenDiscussion } = props;
return (
<>
<section className="page-header">
<p className="page-kicker">Robot U</p>
<h1>Courses, projects, and discussions.</h1>
<p className="muted-copy">
A single place for lessons, member work, and community conversation.
</p>
</section>
<section className="page-section">
<SectionHeader title="Courses" />
{data.featured_courses.length > 0 ? (
<div className="card-grid">
{data.featured_courses.map((course) => (
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
))}
</div>
) : (
<EmptyState copy="No public repos with `/lessons/` were found." />
)}
</section>
<section className="two-column-grid">
<div className="page-section">
<SectionHeader title="Posts" />
{data.recent_posts.length > 0 ? (
<div className="stack">
{data.recent_posts.map((post) => (
<PostItem key={`${post.repo}:${post.title}`} post={post} />
))}
</div>
) : (
<EmptyState copy="No public repos with `/blogs/` were found." />
)}
</div>
<div className="page-section">
<SectionHeader title="Events" />
{data.upcoming_events.length > 0 ? (
<div className="stack">
{data.upcoming_events.map((event) => (
<EventItem key={`${event.source}:${event.title}`} event={event} />
))}
</div>
) : (
<EmptyState copy="ICS feeds are not configured yet." />
)}
</div>
</section>
<section className="page-section">
<SectionHeader title="Discussions" />
{data.recent_discussions.length > 0 ? (
<div className="stack">
{data.recent_discussions.map((discussion) => (
<DiscussionPreviewItem
key={discussion.id}
discussion={discussion}
onOpenDiscussion={onOpenDiscussion}
/>
))}
</div>
) : (
<EmptyState copy="No visible Forgejo issues were returned for this account." />
)}
</section>
</>
);
}
function AppContent(props: {
data: PrototypeData;
pathname: string;
onOpenCourse: (course: CourseCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void;
onGoHome: () => void;
}) {
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, lessonRoute);
if (!selectedCourse) {
return (
<section className="page-message">
<h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to courses
</button>
</section>
);
}
const selectedLesson = findLessonByRoute(selectedCourse, lessonRoute);
if (!selectedLesson) {
return (
<section className="page-message">
<h1>Lesson not found.</h1>
<button
type="button"
className="back-link"
onClick={() => {
props.onOpenCourse(selectedCourse);
}}
>
Back to course
</button>
</section>
);
}
return (
<LessonPage
course={selectedCourse}
chapter={selectedLesson.chapter}
lesson={selectedLesson.lesson}
onGoCourse={() => {
props.onOpenCourse(selectedCourse);
}}
/>
);
}
const courseRoute = parseCourseRoute(props.pathname);
if (courseRoute !== null) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, courseRoute);
if (!selectedCourse) {
return (
<section className="page-message">
<h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to courses
</button>
</section>
);
}
return (
<CoursePage
course={selectedCourse}
onGoHome={props.onGoHome}
onOpenLesson={props.onOpenLesson}
/>
);
}
const discussionId = parseDiscussionRoute(props.pathname);
if (discussionId === null) {
return (
<HomeView
data={props.data}
onOpenCourse={props.onOpenCourse}
onOpenDiscussion={props.onOpenDiscussion}
/>
);
}
const selectedDiscussion = props.data.recent_discussions.find(
(discussion) => discussion.id === discussionId,
);
if (!selectedDiscussion) {
return (
<section className="page-message">
<h1>Discussion not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to discussions
</button>
</section>
);
}
return <DiscussionPage discussion={selectedDiscussion} onGoHome={props.onGoHome} />;
}
export default function App() {
const [data, setData] = useState<PrototypeData | null>(null);
const [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname();
useEffect(() => {
const controller = new AbortController();
async function loadPrototype() {
try {
const response = await fetch("/api/prototype", {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Prototype request failed with ${response.status}`);
}
const payload = (await response.json()) as PrototypeData;
setData(payload);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
const message =
loadError instanceof Error ? loadError.message : "Unknown prototype loading error";
setError(message);
}
}
loadPrototype();
return () => {
controller.abort();
};
}, []);
function openDiscussion(id: number) {
navigate(`/discussions/${id}`);
}
function openCourse(course: CourseCard) {
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
}
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate(
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
);
}
function goHome() {
navigate("/");
}
return (
<main className="app-shell">
{error ? (
<section className="page-message">
<h1>Backend data did not load.</h1>
<p className="muted-copy">{error}</p>
</section>
) : data ? (
<AppContent
data={data}
pathname={pathname}
onOpenCourse={openCourse}
onOpenLesson={openLesson}
onOpenDiscussion={openDiscussion}
onGoHome={goHome}
/>
) : (
<section className="page-message">
<h1>Loading content.</h1>
</section>
)}
</main>
);
}

View file

@ -0,0 +1,296 @@
type ListType = "ol" | "ul";
type ParserState = {
output: string[];
paragraphLines: string[];
blockquoteLines: string[];
listItems: string[];
listType: ListType | null;
inCodeBlock: boolean;
codeLanguage: string;
codeLines: string[];
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function normalizeLinkTarget(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("/")) {
return escapeHtml(trimmed);
}
try {
const url = new URL(trimmed);
if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString());
}
} catch {
return null;
}
return null;
}
function renderInline(markdown: string): string {
const codeTokens: string[] = [];
let rendered = escapeHtml(markdown);
rendered = rendered.replace(/`([^`]+)`/g, (_match, code: string) => {
const token = `__CODE_TOKEN_${codeTokens.length}__`;
codeTokens.push(`<code>${code}</code>`);
return token;
});
rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href);
if (!safeHref) {
return label;
}
return `<a href="${safeHref}" target="_blank" rel="noreferrer">${label}</a>`;
},
);
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/__CODE_TOKEN_(\d+)__/g, (_match, index: string) => {
return codeTokens[Number(index)] ?? "";
});
}
function createParserState(): ParserState {
return {
output: [],
paragraphLines: [],
blockquoteLines: [],
listItems: [],
listType: null,
inCodeBlock: false,
codeLanguage: "",
codeLines: [],
};
}
function flushParagraph(state: ParserState) {
if (state.paragraphLines.length === 0) {
return;
}
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "))}</p>`);
state.paragraphLines.length = 0;
}
function flushList(state: ParserState) {
if (state.listItems.length === 0 || !state.listType) {
return;
}
state.output.push(
`<${state.listType}>${state.listItems.map((item) => `<li>${item}</li>`).join("")}</${state.listType}>`,
);
state.listItems.length = 0;
state.listType = null;
}
function flushBlockquote(state: ParserState) {
if (state.blockquoteLines.length === 0) {
return;
}
state.output.push(
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "))}</p></blockquote>`,
);
state.blockquoteLines.length = 0;
}
function flushCodeBlock(state: ParserState) {
if (!state.inCodeBlock) {
return;
}
const languageClass = state.codeLanguage
? ` class="language-${escapeHtml(state.codeLanguage)}"`
: "";
state.output.push(
`<pre><code${languageClass}>${escapeHtml(state.codeLines.join("\n"))}</code></pre>`,
);
state.inCodeBlock = false;
state.codeLanguage = "";
state.codeLines.length = 0;
}
function flushInlineBlocks(state: ParserState) {
flushParagraph(state);
flushList(state);
flushBlockquote(state);
}
function handleCodeBlockLine(state: ParserState, line: string): boolean {
if (!state.inCodeBlock) {
return false;
}
if (line.trim().startsWith("```")) {
flushCodeBlock(state);
} else {
state.codeLines.push(line);
}
return true;
}
function handleFenceStart(state: ParserState, line: string): boolean {
if (!line.trim().startsWith("```")) {
return false;
}
flushInlineBlocks(state);
state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim();
return true;
}
function handleBlankLine(state: ParserState, line: string): boolean {
if (line.trim()) {
return false;
}
flushInlineBlocks(state);
return true;
}
function handleHeadingLine(state: ParserState, line: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
return false;
}
flushInlineBlocks(state);
const level = headingMatch[1].length;
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim())}</h${level}>`);
return true;
}
function handleRuleLine(state: ParserState, line: string): boolean {
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
return false;
}
flushInlineBlocks(state);
state.output.push("<hr />");
return true;
}
function handleListLine(state: ParserState, line: string, listType: ListType): boolean {
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const match = line.match(pattern);
if (!match) {
return false;
}
flushParagraph(state);
flushBlockquote(state);
if (state.listType !== listType) {
flushList(state);
state.listType = listType;
}
state.listItems.push(renderInline(match[1].trim()));
return true;
}
function handleBlockquoteLine(state: ParserState, line: string): boolean {
const match = line.match(/^>\s?(.*)$/);
if (!match) {
return false;
}
flushParagraph(state);
flushList(state);
state.blockquoteLines.push(match[1].trim());
return true;
}
function handleParagraphLine(state: ParserState, line: string) {
flushList(state);
flushBlockquote(state);
state.paragraphLines.push(line.trim());
}
function processMarkdownLine(state: ParserState, line: string) {
if (handleCodeBlockLine(state, line)) {
return;
}
if (handleFenceStart(state, line)) {
return;
}
if (handleBlankLine(state, line)) {
return;
}
if (handleHeadingLine(state, line)) {
return;
}
if (handleRuleLine(state, line)) {
return;
}
if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) {
return;
}
if (handleBlockquoteLine(state, line)) {
return;
}
handleParagraphLine(state, line);
}
function markdownToHtml(markdown: string): string {
const state = createParserState();
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const line of lines) {
processMarkdownLine(state, line);
}
flushInlineBlocks(state);
flushCodeBlock(state);
return state.output.join("");
}
export function stripLeadingTitleHeading(markdown: string, title: string): string {
const trimmed = markdown.trimStart();
if (!trimmed.startsWith("#")) {
return markdown;
}
const lines = trimmed.split("\n");
const firstLine = lines[0]?.trim() ?? "";
if (firstLine === `# ${title}`) {
return lines.slice(1).join("\n").trimStart();
}
return markdown;
}
export function MarkdownContent(props: { markdown: string; className?: string }) {
const html = markdownToHtml(props.markdown);
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}

420
frontend/src/index.css Normal file
View file

@ -0,0 +1,420 @@
:root {
color-scheme: dark;
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
--bg: #0d1117;
--panel: #151b23;
--panel-hover: #1a2230;
--border: #2b3442;
--text: #edf2f7;
--muted: #9aa6b2;
--accent: #84d7ff;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
}
body {
min-width: 20rem;
}
a {
color: inherit;
}
h1,
h2,
h3,
p {
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
.app-shell {
width: min(72rem, 100%);
margin: 0 auto;
padding: 2rem 1rem 3rem;
}
.page-header,
.page-section,
.panel,
.page-message {
border: 0.0625rem solid var(--border);
background: var(--panel);
border-radius: 0.9rem;
}
.page-header,
.page-section,
.page-message {
padding: 1.25rem;
}
.page-header {
margin-bottom: 1rem;
}
.page-header h1,
.thread-header h1,
.page-message h1 {
font-size: clamp(1.6rem, 3vw, 2.4rem);
line-height: 1.1;
}
.page-kicker {
margin-bottom: 0.35rem;
color: var(--accent);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.page-section {
margin-bottom: 1rem;
}
.section-header {
margin-bottom: 0.9rem;
}
.section-header h2,
.subsection-header h2 {
font-size: 1.1rem;
}
.subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.card-grid,
.stack,
.reply-list {
display: grid;
gap: 0.75rem;
}
.card-grid,
.two-column-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.card,
.discussion-preview-card,
.reply-card {
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
background: #111722;
}
.card,
.reply-card {
padding: 1rem;
}
.course-card-button {
width: 100%;
cursor: pointer;
text-align: left;
color: inherit;
}
.course-card-button:hover,
.course-card-button:focus-visible {
background: var(--panel-hover);
outline: none;
}
.card h3,
.discussion-preview-card h3 {
margin-bottom: 0.35rem;
font-size: 1rem;
}
.discussion-preview-card {
width: 100%;
padding: 1rem;
cursor: pointer;
text-align: left;
color: inherit;
}
.discussion-preview-card:hover,
.discussion-preview-card:focus-visible {
background: var(--panel-hover);
outline: none;
}
.muted-copy,
.meta-line,
.empty-state {
color: var(--muted);
}
.muted-copy {
max-width: 44rem;
}
.meta-line {
margin-top: 0.5rem;
font-size: 0.9rem;
}
.empty-state {
padding: 0.25rem 0;
}
.back-link,
.secondary-link,
.compose-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.65rem;
border: 0.0625rem solid var(--border);
padding: 0.65rem 0.85rem;
text-decoration: none;
}
.back-link {
margin-bottom: 1rem;
background: transparent;
color: var(--text);
cursor: pointer;
}
.thread-view {
display: grid;
gap: 1rem;
}
.panel {
padding: 1.25rem;
}
.thread-header {
margin-bottom: 1rem;
}
.panel-actions {
margin-top: 1rem;
}
.thread-copy {
display: grid;
gap: 0.85rem;
}
.reply-author {
font-weight: 600;
}
.outline-list,
.lesson-list {
display: grid;
gap: 1rem;
}
.outline-chapter h3 {
margin-bottom: 0.75rem;
font-size: 1rem;
}
.lesson-row {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
border-top: 0.0625rem solid var(--border);
padding-top: 0.75rem;
}
.lesson-row-button {
width: 100%;
cursor: pointer;
text-align: left;
color: inherit;
background: transparent;
}
.lesson-row-button:hover,
.lesson-row-button:focus-visible {
background: var(--panel-hover);
outline: none;
border-radius: 0.5rem;
}
.lesson-row:first-child {
border-top: 0;
padding-top: 0;
}
.lesson-index {
color: var(--muted);
font-size: 0.9rem;
}
.lesson-title {
font-weight: 600;
}
.lesson-summary {
margin-top: 0.35rem;
color: var(--muted);
}
.markdown-content {
display: grid;
gap: 0.85rem;
}
.markdown-content > :first-child {
margin-top: 0;
}
.markdown-content > :last-child {
margin-bottom: 0;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4 {
line-height: 1.15;
}
.markdown-content h1 {
font-size: 1.8rem;
}
.markdown-content h2 {
font-size: 1.35rem;
}
.markdown-content h3 {
font-size: 1.1rem;
}
.markdown-content p,
.markdown-content li,
.markdown-content blockquote {
color: var(--text);
}
.markdown-content ul,
.markdown-content ol {
margin: 0;
padding-left: 1.4rem;
}
.markdown-content li + li {
margin-top: 0.35rem;
}
.markdown-content code {
border-radius: 0.35rem;
padding: 0.12rem 0.35rem;
background: #0b1017;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.92em;
}
.markdown-content pre {
overflow-x: auto;
margin: 0;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem 0.95rem;
background: #0b1017;
}
.markdown-content pre code {
padding: 0;
background: transparent;
}
.markdown-content blockquote {
margin: 0;
border-left: 0.2rem solid var(--accent);
padding-left: 0.9rem;
}
.markdown-content a {
color: var(--accent);
}
.markdown-content hr {
width: 100%;
height: 0.0625rem;
margin: 0;
border: 0;
background: var(--border);
}
.compose-box {
display: grid;
gap: 0.85rem;
}
.compose-input {
width: 100%;
min-height: 9rem;
resize: vertical;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem 0.95rem;
color: var(--text);
background: #0f141d;
}
.compose-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.compose-button {
color: #7a8696;
background: #101722;
cursor: not-allowed;
}
.secondary-link {
color: var(--accent);
}
@media (max-width: 48rem) {
.card-grid,
.two-column-grid {
grid-template-columns: 1fr;
}
.compose-actions,
.subsection-header {
align-items: flex-start;
flex-direction: column;
}
}

12
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,12 @@
import { render } from "preact";
import App from "./App";
import "./index.css";
const rootElement = document.getElementById("app");
if (!rootElement) {
throw new Error("App root element was not found.");
}
render(<App />, rootElement);

90
frontend/src/types.ts Normal file
View file

@ -0,0 +1,90 @@
export interface HeroData {
eyebrow: string;
title: string;
summary: string;
highlights: string[];
}
export interface SourceOfTruthCard {
title: string;
description: string;
}
export interface CourseCard {
title: string;
owner: string;
name: string;
repo: string;
html_url: string;
lessons: number;
chapters: number;
summary: string;
status: string;
outline: CourseChapter[];
}
export interface CourseChapter {
slug: string;
title: string;
lessons: CourseLesson[];
}
export interface CourseLesson {
slug: string;
title: string;
path: string;
file_path: string;
html_url: string;
summary: string;
body: string;
}
export interface PostCard {
title: string;
repo: string;
kind: string;
summary: string;
}
export interface EventCard {
title: string;
when: string;
source: string;
mode: string;
}
export interface DiscussionCard {
id: number;
title: string;
repo: string;
replies: number;
context: string;
author: string;
author_avatar_url: string;
state: string;
body: string;
number: number;
updated_at: string;
html_url: string;
labels: string[];
comments: DiscussionReply[];
}
export interface DiscussionReply {
id: number;
author: string;
avatar_url: string;
body: string;
created_at: string;
html_url: string;
}
export interface PrototypeData {
hero: HeroData;
source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[];
recent_posts: PostCard[];
upcoming_events: EventCard[];
recent_discussions: DiscussionCard[];
implementation_notes: string[];
}

20
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig, loadEnv } from "vite";
import preact from "@preact/preset-vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const backendUrl = env.VITE_BACKEND_URL || "http://localhost:8000";
return {
plugins: [preact()],
server: {
proxy: {
"/api": backendUrl,
"/health": backendUrl,
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
};
});

515
live_prototype.py Normal file
View file

@ -0,0 +1,515 @@
from __future__ import annotations
import asyncio
from typing import Any
from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
from forgejo_client import ForgejoClient, ForgejoClientError
from settings import Settings
async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
warnings: list[str] = []
source_cards = [
{
"title": "Forgejo base URL",
"description": settings.forgejo_base_url,
},
{
"title": "Access mode",
"description": (
"Server token configured for live API reads."
if settings.forgejo_token
else "Instance API requires auth. Set FORGEJO_TOKEN for live repo discovery."
),
},
]
calendar_feeds = await _load_calendar_feeds(settings, warnings)
if settings.calendar_feed_urls:
source_cards.append(
{
"title": "Calendar feeds",
"description": f"{len(calendar_feeds)} configured feed(s)",
},
)
async with ForgejoClient(settings) as client:
try:
oidc = await client.fetch_openid_configuration()
except ForgejoClientError as error:
warnings.append(str(error))
oidc = {}
issuer = oidc.get("issuer", "Unavailable")
source_cards.append(
{
"title": "OIDC issuer",
"description": str(issuer),
},
)
if not settings.forgejo_token:
warnings.append(
"aksal.cloud blocks anonymous API calls, so the prototype needs FORGEJO_TOKEN "
"before it can load repos or issues.",
)
source_cards.append(
{
"title": "Discovery state",
"description": "Waiting for FORGEJO_TOKEN to enable live repo and issue reads.",
},
)
return _empty_payload(
source_cards=source_cards,
warnings=warnings,
hero_summary=(
"Connected to aksal.cloud for identity and OIDC discovery, but live repo content "
"is gated until a Forgejo API token is configured on the backend."
),
)
try:
current_user, repos, issues = await asyncio.gather(
client.fetch_current_user(),
client.search_repositories(),
client.search_recent_issues(),
)
except ForgejoClientError as error:
warnings.append(str(error))
source_cards.append(
{
"title": "Discovery state",
"description": "Forgejo connection exists, but live repo discovery failed.",
},
)
return _empty_payload(
source_cards=source_cards,
warnings=warnings,
hero_summary=(
"The backend reached aksal.cloud, but the configured token could not complete "
"the repo discovery flow."
),
)
repo_summaries = await asyncio.gather(
*[
_summarize_repo(client, repo)
for repo in repos
if not repo.get("fork") and not repo.get("private")
],
)
content_repos = [summary for summary in repo_summaries if summary is not None]
course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0]
post_repos = [summary for summary in content_repos if summary["blog_count"] > 0]
source_cards.append(
{
"title": "Signed-in API identity",
"description": str(current_user.get("login", "Unknown user")),
},
)
source_cards.append(
{
"title": "Discovery state",
"description": (
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
f"and {len(issues)} recent issues."
),
},
)
return {
"hero": {
"eyebrow": "Live Forgejo integration",
"title": "Robot U is reading from your aksal.cloud Forgejo instance.",
"summary": (
"This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, "
"and recent issues available to the configured backend token."
),
"highlights": [
"Repo discovery filters to public, non-fork repositories only",
"Course repos are detected from /lessons/, post repos from /blogs/",
"Recent discussions are loaded from live Forgejo issues",
],
},
"source_of_truth": source_cards,
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
"recent_posts": [_post_card(summary) for summary in post_repos[:6]],
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
"recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in issues],
),
"implementation_notes": [
"Live repo discovery is now driven by the Forgejo API instead of mock content.",
"Issues shown here are real Forgejo issues visible to the configured token.",
*warnings,
],
}
async def _summarize_repo(
client: ForgejoClient,
repo: dict[str, Any],
) -> dict[str, Any] | None:
owner = repo.get("owner", {})
owner_login = owner.get("login")
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None
try:
root_entries = await client.list_directory(owner_login, repo_name)
except ForgejoClientError:
return None
entry_names = {
entry.get("name")
for entry in root_entries
if entry.get("type") == "dir" and isinstance(entry.get("name"), str)
}
has_lessons = "lessons" in entry_names
has_blogs = "blogs" in entry_names
if not has_lessons and not has_blogs:
return None
chapter_count = 0
lesson_count = 0
course_outline: list[dict[str, object]] = []
if has_lessons:
lesson_entries = await client.list_directory(owner_login, repo_name, "lessons")
chapter_dirs = _sorted_dir_entries(lesson_entries)
chapter_count = len(chapter_dirs)
chapter_entry_lists = await asyncio.gather(
*[
client.list_directory(owner_login, repo_name, f"lessons/{entry['name']}")
for entry in chapter_dirs
if isinstance(entry.get("name"), str)
],
)
lesson_count = sum(
1
for chapter_entries in chapter_entry_lists
for entry in chapter_entries
if entry.get("type") == "dir"
)
for chapter_dir, chapter_entries in zip(chapter_dirs, chapter_entry_lists, strict=False):
chapter_name = str(chapter_dir.get("name", ""))
lesson_dirs = _sorted_dir_entries(chapter_entries)
lesson_summaries = await asyncio.gather(
*[
_summarize_lesson(
client,
owner_login,
repo_name,
chapter_name,
str(lesson_dir.get("name", "")),
)
for lesson_dir in lesson_dirs
],
)
course_outline.append(
{
"slug": chapter_name,
"title": _display_name(chapter_name),
"lessons": lesson_summaries,
},
)
blog_count = 0
if has_blogs:
blog_entries = await client.list_directory(owner_login, repo_name, "blogs")
blog_count = sum(1 for entry in blog_entries if entry.get("type") == "dir")
return {
"name": repo_name,
"owner": owner_login,
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
"html_url": repo.get("html_url", ""),
"description": repo.get("description") or "No repository description yet.",
"lesson_count": lesson_count,
"chapter_count": chapter_count,
"blog_count": blog_count,
"updated_at": repo.get("updated_at", ""),
"course_outline": course_outline,
}
def _course_card(summary: dict[str, Any]) -> dict[str, object]:
return {
"title": summary["name"],
"owner": summary["owner"],
"name": summary["name"],
"repo": summary["full_name"],
"html_url": summary["html_url"],
"lessons": summary["lesson_count"],
"chapters": summary["chapter_count"],
"summary": summary["description"],
"status": "Live course repo",
"outline": summary["course_outline"],
}
def _post_card(summary: dict[str, Any]) -> dict[str, object]:
post_count = int(summary["blog_count"])
label = "1 post folder detected" if post_count == 1 else f"{post_count} post folders detected"
return {
"title": summary["name"],
"repo": summary["full_name"],
"kind": "Repo with /blogs/",
"summary": f"{label}. {summary['description']}",
}
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
upcoming_events = sorted(
[event for feed in calendar_feeds for event in feed.events],
key=lambda event: event.starts_at,
)[:limit]
return [
{
"title": event.title,
"when": _format_event_datetime(event.starts_at),
"source": event.source,
"mode": event.mode,
}
for event in upcoming_events
]
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:
comment_items = [
_discussion_reply(comment)
for comment in await client.list_issue_comments(
owner,
repository["name"],
issue_number,
)
]
except ForgejoClientError:
comment_items = []
body = str(issue.get("body", "") or "").strip()
if not body:
body = "No issue description yet. Right now the conversation starts in the replies."
return {
"id": int(issue.get("id", 0)),
"title": issue.get("title", "Untitled issue"),
"repo": full_name,
"replies": comments,
"context": "Live Forgejo issue",
"author": issue_author.get("login", "Unknown author"),
"author_avatar_url": issue_author.get("avatar_url", ""),
"state": issue.get("state", "open"),
"body": body,
"number": issue_number,
"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,
}
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
if not body:
body = "No comment body provided."
return {
"id": int(comment.get("id", 0)),
"author": author.get("login", "Unknown author"),
"avatar_url": author.get("avatar_url", ""),
"body": body,
"created_at": comment.get("created_at", ""),
"html_url": comment.get("html_url", ""),
}
def _empty_payload(
*,
source_cards: list[dict[str, str]],
warnings: list[str],
hero_summary: str,
) -> dict[str, object]:
return {
"hero": {
"eyebrow": "Forgejo connection status",
"title": "Robot U is configured for aksal.cloud.",
"summary": hero_summary,
"highlights": [
"Forgejo remains the source of truth for content and discussions",
"The prototype now targets aksal.cloud by default",
"Live repo discovery unlocks as soon as a backend token is configured",
],
},
"source_of_truth": source_cards,
"featured_courses": [],
"recent_posts": [],
"upcoming_events": [],
"recent_discussions": [],
"implementation_notes": warnings
or ["Live repo discovery is ready, but no Forgejo token has been configured yet."],
}
async def _summarize_lesson(
client: ForgejoClient,
owner: str,
repo: str,
chapter_name: str,
lesson_name: str,
) -> dict[str, object]:
lesson_path = f"lessons/{chapter_name}/{lesson_name}"
fallback_title = _display_name(lesson_name)
try:
lesson_entries = await client.list_directory(owner, repo, lesson_path)
except ForgejoClientError:
return _empty_lesson(lesson_name, fallback_title, lesson_path)
markdown_files = sorted(
[
entry
for entry in lesson_entries
if entry.get("type") == "file"
and isinstance(entry.get("name"), str)
and str(entry.get("name", "")).lower().endswith(".md")
],
key=lambda entry: str(entry["name"]),
)
if not markdown_files:
return _empty_lesson(lesson_name, fallback_title, lesson_path)
markdown_name = str(markdown_files[0]["name"])
markdown_path = f"{lesson_path}/{markdown_name}"
try:
file_payload = await client.get_file_content(owner, repo, markdown_path)
except ForgejoClientError:
return _empty_lesson(
lesson_name,
fallback_title,
lesson_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": lesson_name,
"title": str(metadata.get("title") or _display_name(markdown_name) or fallback_title),
"summary": str(metadata.get("summary") or ""),
"path": lesson_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
"body": body,
}
def _sorted_dir_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
[
entry
for entry in entries
if entry.get("type") == "dir" and isinstance(entry.get("name"), str)
],
key=lambda entry: str(entry["name"]),
)
def _display_name(value: str) -> str:
cleaned = value.strip().rsplit(".", 1)[0]
cleaned = cleaned.replace("_", " ").replace("-", " ")
cleaned = " ".join(cleaned.split())
cleaned = cleaned.lstrip("0123456789 ").strip()
return cleaned.title() or value
async def _load_calendar_feeds(settings: Settings, warnings: list[str]) -> list[CalendarFeed]:
if not settings.calendar_feed_urls:
return []
results = await asyncio.gather(
*[
fetch_calendar_feed(url, settings.forgejo_request_timeout_seconds)
for url in settings.calendar_feed_urls
],
return_exceptions=True,
)
feeds: list[CalendarFeed] = []
for url, result in zip(settings.calendar_feed_urls, results, strict=False):
if isinstance(result, CalendarFeed):
feeds.append(result)
continue
if isinstance(result, CalendarFeedError):
warnings.append(str(result))
continue
if isinstance(result, Exception):
warnings.append(f"Calendar feed failed for {url}: {result}")
return feeds
def _format_event_datetime(value: Any) -> str:
return value.strftime("%b %-d, %-I:%M %p UTC")
def _empty_lesson(
lesson_name: str,
title: str,
lesson_path: str,
*,
file_path: str = "",
html_url: str = "",
) -> dict[str, object]:
return {
"slug": lesson_name,
"title": title,
"summary": "",
"path": lesson_path,
"file_path": file_path,
"html_url": html_url,
"body": "",
}
def _parse_frontmatter(markdown: str) -> tuple[dict[str, str], str]:
if not markdown.startswith("---\n"):
return {}, markdown.strip()
lines = markdown.splitlines()
if not lines or lines[0].strip() != "---":
return {}, markdown.strip()
metadata: dict[str, str] = {}
for index, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
body = "\n".join(lines[index + 1 :]).strip()
return metadata, body
if ":" not in line:
continue
key, raw_value = line.split(":", 1)
key = key.strip()
value = raw_value.strip().strip("\"'")
if key and value:
metadata[key] = value
return {}, markdown.strip()

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
fastapi>=0.116.0,<1.0.0
uvicorn[standard]>=0.35.0,<1.0.0
httpx>=0.28.0,<1.0.0

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
frontend_dir="${root_dir}/frontend"
if command -v bun >/dev/null 2>&1; then
bun_cmd=(bun)
elif [[ -x "${HOME}/.bun/bin/bun" ]]; then
bun_cmd=("${HOME}/.bun/bin/bun")
else
echo "bun is required to run frontend checks" >&2
exit 1
fi
cd "${frontend_dir}"
"${bun_cmd[@]}" run check
"${bun_cmd[@]}" run build

59
scripts/check_python_quality.sh Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -uo pipefail
status=0
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python_files=(
"app.py"
"forgejo_client.py"
"live_prototype.py"
"settings.py"
"tests"
)
if [[ -x "${root_dir}/.venv/bin/python" ]]; then
python_cmd=("${root_dir}/.venv/bin/python")
else
python_cmd=(python3)
fi
run_check() {
local label="$1"
shift
echo "==> ${label}"
if ! "$@"; then
status=1
fi
echo
}
cd "${root_dir}"
run_check \
"Ruff format" \
uv run --with "ruff>=0.15.0,<1.0.0" \
ruff format --check "${python_files[@]}"
run_check \
"Ruff lint" \
uv run --with "ruff>=0.15.0,<1.0.0" \
ruff check "${python_files[@]}"
run_check \
"Deptry" \
uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \
--requirements-files requirements.txt \
--known-first-party app,forgejo_client,live_prototype,settings \
--per-rule-ignores DEP002=uvicorn \
--extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \
--extend-exclude ".*/__pycache__/.*"
run_check \
"Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \
vulture app.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
run_check \
"Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
exit "${status}"

12
scripts/install_git_hooks.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! git -C "${root_dir}" rev-parse --git-dir >/dev/null 2>&1; then
echo "Skipping git hook install because ${root_dir} is not a git repository."
exit 0
fi
git -C "${root_dir}" config core.hooksPath .githooks
echo "Installed git hooks from ${root_dir}/.githooks"

48
settings.py Normal file
View file

@ -0,0 +1,48 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
@dataclass(frozen=True)
class Settings:
forgejo_base_url: str
forgejo_token: str | None
forgejo_repo_scan_limit: int
forgejo_recent_issue_limit: int
forgejo_request_timeout_seconds: float
calendar_feed_urls: tuple[str, ...]
calendar_event_limit: int
def _normalize_base_url(raw_value: str | None) -> str:
value = (raw_value or "").strip()
if not value:
return "https://aksal.cloud"
if value.startswith(("http://", "https://")):
return value.rstrip("/")
return f"https://{value.rstrip('/')}"
def _parse_calendar_feed_urls(raw_value: str | None) -> tuple[str, ...]:
value = (raw_value or "").strip()
if not value:
return ()
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings(
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
forgejo_token=os.getenv("FORGEJO_TOKEN") 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_request_timeout_seconds=float(
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
),
calendar_feed_urls=_parse_calendar_feed_urls(os.getenv("CALENDAR_FEED_URLS")),
calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "6")),
)

55
tests/test_app.py Normal file
View file

@ -0,0 +1,55 @@
from __future__ import annotations
import asyncio
import json
import unittest
from unittest.mock import AsyncMock, patch
from fastapi.responses import JSONResponse
from app import create_app
from settings import get_settings
class AppTestCase(unittest.TestCase):
def setUp(self) -> None:
get_settings.cache_clear()
self.app = create_app()
def tearDown(self) -> None:
get_settings.cache_clear()
def _get_route_response(self, path: str) -> JSONResponse:
route = next(route for route in self.app.routes if getattr(route, "path", None) == path)
return asyncio.run(route.endpoint())
def test_health(self) -> None:
response = self._get_route_response("/health")
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.body), {"status": "ok"})
def test_prototype_payload_shape(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
with patch("app.build_live_prototype_payload", new=AsyncMock(return_value=payload)):
response = self._get_route_response("/api/prototype")
payload = json.loads(response.body)
self.assertEqual(response.status_code, 200)
self.assertIn("hero", payload)
self.assertIn("featured_courses", payload)
self.assertIn("recent_posts", payload)
self.assertIn("recent_discussions", payload)
self.assertIn("upcoming_events", payload)
self.assertIn("source_of_truth", payload)
if __name__ == "__main__":
unittest.main()