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")) DEFAULT_INDEX_SOURCE = '''\ page "Welcome" 60 bigtitle "uFrame" thin box rounded "Micronomicon" align center text "Decentralized Page Server" text "Powered by @bold{Reticulum} and @bold{NomadNet}" spacer heading 2 "Pages" text "This node is serving pages built with the uFrame DSL." text "Use the web IDE to create and publish new pages." spacer divider light label "Node" "Micronomicon" label "Engine" "uFrame v1" status "Node" online ''' def ensure_default_pages(): """Create a default index page if none exists.""" PAGES_DIR.mkdir(parents=True, exist_ok=True) SOURCES_DIR.mkdir(parents=True, exist_ok=True) index_mu = PAGES_DIR / "index.mu" index_src = SOURCES_DIR / "index.uf" if not index_mu.is_file(): # Compile and publish the default index result = uframe.compile(DEFAULT_INDEX_SOURCE) index_mu.write_text(result.micron, encoding="utf-8") index_mu.chmod(0o644) if not index_src.is_file(): index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8") 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}