import os import shlex from pathlib import Path from fastapi import APIRouter, HTTPException from pydantic import BaseModel import uframe router = APIRouter() PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) class PageMeta(BaseModel): name: str title: str | None = None published: bool = False has_source: bool = False last_modified: float | None = None size: int | None = None class PageDetail(BaseModel): name: str source: str | None = None class SaveRequest(BaseModel): source: str publish: bool = False def _extract_title(source: str) -> str | None: """Extract title from µFrame source or legacy Micron.""" for line in source.splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#"): continue # µFrame: page "Title" [width] if stripped.lower().startswith("page "): try: parts = shlex.split(stripped) if len(parts) >= 2: return parts[1] except ValueError: pass break # µFrame: heading 1 "Title" if stripped.lower().startswith("heading "): try: parts = shlex.split(stripped) if len(parts) >= 3: return parts[2] except ValueError: pass break # Legacy Micron: >Title if stripped.startswith(">") and not stripped.startswith(">>"): return stripped[1:].strip() break return None def _list_all_page_names() -> set[str]: names: set[str] = set() if PAGES_DIR.is_dir(): for f in PAGES_DIR.iterdir(): if f.suffix == ".mu" and f.is_file(): names.add(f.stem) if SOURCES_DIR.is_dir(): for f in SOURCES_DIR.iterdir(): if f.suffix in (".uf", ".mu") and f.is_file(): names.add(f.stem) return names def _source_path(name: str) -> Path: """Get source file path, preferring .uf over legacy .mu.""" uf = SOURCES_DIR / f"{name}.uf" if uf.is_file(): return uf mu = SOURCES_DIR / f"{name}.mu" return mu if mu.is_file() else uf # default to .uf for new files def _page_meta(name: str) -> PageMeta: src_path = _source_path(name) mu_path = PAGES_DIR / f"{name}.mu" title = None if src_path.is_file(): title = _extract_title(src_path.read_text(encoding="utf-8")) elif mu_path.is_file(): title = _extract_title(mu_path.read_text(encoding="utf-8")) published = mu_path.is_file() last_modified = mu_path.stat().st_mtime if published else None size = mu_path.stat().st_size if published else None return PageMeta( name=name, title=title, published=published, has_source=src_path.is_file(), last_modified=last_modified, size=size, ) @router.get("/pages", response_model=list[PageMeta]) async def list_pages(): return [_page_meta(n) for n in sorted(_list_all_page_names())] @router.get("/pages/{name}", response_model=PageDetail) async def get_page(name: str): src_path = _source_path(name) mu_path = PAGES_DIR / f"{name}.mu" if not src_path.is_file() and not mu_path.is_file(): raise HTTPException(status_code=404, detail="Page not found") source = ( src_path.read_text(encoding="utf-8") if src_path.is_file() else mu_path.read_text(encoding="utf-8") ) return PageDetail(name=name, source=source) @router.post("/pages/{name}", response_model=PageMeta) async def save_page(name: str, req: SaveRequest): SOURCES_DIR.mkdir(parents=True, exist_ok=True) PAGES_DIR.mkdir(parents=True, exist_ok=True) # Save source as .uf src_path = SOURCES_DIR / f"{name}.uf" src_path.write_text(req.source, encoding="utf-8") # Remove legacy .mu source if it exists legacy_mu = SOURCES_DIR / f"{name}.mu" if legacy_mu.is_file(): legacy_mu.unlink() # Publish: compile .uf → .mu and write to pages dir if req.publish: try: result = uframe.compile(req.source) mu_path = PAGES_DIR / f"{name}.mu" if result.is_dynamic and result.script: # Dynamic page: write executable Python script mu_path.write_text(result.script, encoding="utf-8") mu_path.chmod(0o755) # Set execute bit for NomadNet else: # Static page: write compiled Micron mu_path.write_text(result.micron, encoding="utf-8") # Remove execute bit if it was previously dynamic mu_path.chmod(0o644) except Exception as e: raise HTTPException( status_code=422, detail=f"Compile failed during publish: {e}", ) return _page_meta(name) @router.delete("/pages/{name}") async def delete_page(name: str): src_path = _source_path(name) mu_path = PAGES_DIR / f"{name}.mu" if not src_path.is_file() and not mu_path.is_file(): raise HTTPException(status_code=404, detail="Page not found") if src_path.is_file(): src_path.unlink() if mu_path.is_file(): mu_path.unlink() return {"deleted": name}