Initial Robot U site prototype
This commit is contained in:
commit
fe19f200d7
27 changed files with 3677 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
6
.githooks/pre-commit
Executable 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
8
.gitignore
vendored
Normal 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
3
.ruff.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
43
README.md
Normal file
43
README.md
Normal 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
55
app.py
Normal 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
149
calendar_feeds.py
Normal 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("\\\\", "\\")
|
||||
)
|
||||
470
docs/community-platform-design-doc.md
Normal file
470
docs/community-platform-design-doc.md
Normal 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
149
forgejo_client.py
Normal 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
71
frontend/biome.json
Normal 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
427
frontend/bun.lock
Normal 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
16
frontend/index.html
Normal 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
28
frontend/package.json
Normal 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
674
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
frontend/src/MarkdownContent.tsx
Normal file
296
frontend/src/MarkdownContent.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
420
frontend/src/index.css
Normal 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
12
frontend/src/main.tsx
Normal 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
90
frontend/src/types.ts
Normal 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
20
frontend/tsconfig.json
Normal 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
22
frontend/vite.config.ts
Normal 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
515
live_prototype.py
Normal 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
3
requirements.txt
Normal 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
|
||||
19
scripts/check_frontend_quality.sh
Executable file
19
scripts/check_frontend_quality.sh
Executable 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
59
scripts/check_python_quality.sh
Executable 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
12
scripts/install_git_hooks.sh
Executable 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
48
settings.py
Normal 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
55
tests/test_app.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue