import os import re from pathlib import Path from fastapi import APIRouter from pydantic import BaseModel router = APIRouter() SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) # Matches Micron links: [label`slug] or [label`slug.mu] _INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]') class GraphNode(BaseModel): id: str published: bool title: str | None = None class GraphEdge(BaseModel): source: str target: str class GraphData(BaseModel): nodes: list[GraphNode] edges: list[GraphEdge] def _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 == ".mu" and f.is_file(): names.add(f.stem) return names def _extract_title(micron: str) -> str | None: for line in micron.splitlines(): stripped = line.strip() if stripped.startswith(">") and not stripped.startswith(">>"): return stripped[1:].strip() return None @router.get("/graph", response_model=GraphData) async def get_graph(): all_names = _all_page_names() nodes: list[GraphNode] = [] edges: list[GraphEdge] = [] for name in sorted(all_names): src_path = SOURCES_DIR / f"{name}.mu" mu_path = PAGES_DIR / f"{name}.mu" title = None if src_path.is_file(): content = src_path.read_text(encoding="utf-8") title = _extract_title(content) for match in _INTERNAL_LINK.finditer(content): target = match.group(2) if target in all_names: edges.append(GraphEdge(source=name, target=target)) nodes.append(GraphNode( id=name, published=mu_path.is_file(), title=title, )) return GraphData(nodes=nodes, edges=edges)