!/usr/bin/env python3
"""HoffDoc — a dead-simple mobile-first markdown file viewer.
Serves rendered markdown from configured workspace directories.
Dark mode, zero JS framework, Jinja2 + Python-Markdown + Pygments.
"""
import os
from pathlib import Path
from datetime import datetime
from fastapi import FastAPI, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import markdown
from markdown.extensions import codehilite, fenced_code, tables, toc
── Configuration ────────────────────────────────────────────────────
Which directories to expose (label → path)
These are the workspaces you want to browse from your phone.
DIRECTORIES = {
"📋 Wadsworth": "/home/hoffmann_admin/.openclaw/workspace",
"🧠 Socrates": "/home/hoffmann_admin/.openclaw/workspace-socrates",
"🎨 Daedalus": "/home/hoffmann_admin/.openclaw/workspace-daedalus",
"🪙 Midas": "/home/hoffmann_admin/.openclaw/workspace-midas",
"🚀 Icarus": "/home/hoffmann_admin/icarus",
"🏗️ Shared": "/home/hoffmann_admin/.openclaw/shared",
}
Files to skip (hide from listing)
SKIP_FILES = {".gitignore", ".DS_Store", "HEARTBEAT.md", "TOOLS.md", "*.json"}
SKIP_DIRS = {"pycache", ".git", ".archive", ".venv", "node_modules", ".clawhub"}
APP_TITLE = "HoffDoc"
APP_PORT = int(os.environ.get("HOFFDOC_PORT", "8004"))
APP_HOST = os.environ.get("HOFFDOC_HOST", "127.0.0.1")
── App ──────────────────────────────────────────────────────────────
BASE_DIR = Path(file).parent
app = FastAPI(title=APP_TITLE, docs_url=None, redoc_url=None)
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
md = markdown.Markdown(
extensions=[
"fenced_code",
"codehilite",
"tables",
"toc",
"nl2br",
"sane_lists",
],
extension_configs={
"codehilite": {
"css_class": "highlight",
"guess_lang": True,
"use_pygments": True,
},
"toc": {"permalink": False},
},
output_format="html",
)
── Helpers ──────────────────────────────────────────────────────────
def should_skip_name(name: str, is_dir: bool = False) -> bool:
if name.startswith("."):
return True
if is_dir and name in SKIP_DIRS:
return True
if not is_dir and name in SKIP_FILES:
return True
return False
def get_file_tree(dir_label: str, dir_path: str, subpath: str = "") -> dict:
"""Build a recursive tree of files/dirs for sidebar navigation."""
root = Path(dir_path) / subpath
if not root.exists():
return None
items = []
for entry in sorted(root.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if should_skip_name(entry.name, entry.is_dir()):
continue
if entry.is_dir():
# Only recurse one level for memory/ and other key dirs
children = []
for child in sorted(entry.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if should_skip_name(child.name, child.is_dir()):
continue
children.append({
"name": child.name,
"path": f"{subpath}/{entry.name}/{child.name}".lstrip("/"),
"is_dir": child.is_dir(),
"children": [],
})
items.append({
"name": entry.name,
"path": f"{subpath}/{entry.name}".lstrip("/"),
"is_dir": True,
"children": children[:30], # cap per directory
})
elif entry.suffix in (".md", ".markdown", ".txt", ".yaml", ".yml", ".py", ".json", ".toml", ".cfg", ".ini"):
items.append({
"name": entry.name,
"path": f"{subpath}/{entry.name}".lstrip("/"),
"is_dir": False,
"children": [],
})
return {"label": dir_label, "root": dir_path, "items": items[:50]}
def render_markdown(filepath: Path) -> str:
"""Read a markdown file and return rendered HTML."""
text = filepath.read_text(encoding="utf-8", errors="replace")
# Reset the markdown instance for each render
md.reset()
return md.convert(text)
def format_mtime(ts: float) -> str:
dt = datetime.fromtimestamp(ts)
now = datetime.now()
diff = now - dt
if diff.days == 0:
return dt.strftime("Today %H:%M")
elif diff.days == 1:
return dt.strftime("Yesterday %H:%M")
elif diff.days < 7:
return dt.strftime("%A %H:%M")
return dt.strftime("%b %d, %Y")
── Routes ───────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Home: show overview of all directories with file counts."""
trees = []
for label, path in DIRECTORIES.items():
p = Path(path)
if p.exists():
md_files = list(p.rglob("*.md"))
# Count non-skipped
count = sum(1 for f in md_files if not should_skip_name(f.name) and not any(s in str(f) for s in SKIP_DIRS))
trees.append({"label": label, "path": path, "file_count": count})
return templates.TemplateResponse("index.html.j2", {
"request": request,
"title": APP_TITLE,
"trees": trees,
"directories": DIRECTORIES,
})
@app.get("/browse/{dir_key:path}", response_class=HTMLResponse)
async def browse(request: Request, dir_key: str, file: str = Query(None)):
"""Browse a directory and optionally view a file.
dir_key format: label/path/to/file.md
The label must match one of the DIRECTORIES keys.
"""
# Parse dir_key: first segment is the directory label, rest is subpath
parts = dir_key.split("/", 1)
label = parts[0]
subpath = parts[1] if len(parts) > 1 else ""
# Decode URL-encoded label
from urllib.parse import unquote
label = unquote(label)
if label not in DIRECTORIES:
return HTMLResponse(f"<h1>Unknown directory: {label}</h1>", status_code=404)
root = Path(DIRECTORIES[label])
# Determine which file to view
viewing_file = None
rendered_content = ""
file_meta = {}
if file:
filepath = root / file
if filepath.exists() and filepath.is_file() and filepath.suffix in (".md", ".markdown", ".txt", ".yaml", ".yml", ".py", ".json", ".toml"):
viewing_file = str(file)
rendered_content = render_markdown(filepath)
file_meta = {
"name": filepath.name,
"size": filepath.stat().st_size,
"modified": format_mtime(filepath.stat().st_mtime),
}
elif subpath:
# Subpath might be a file itself
target = root / subpath
if target.exists() and target.is_file() and target.suffix in (".md", ".markdown", ".txt", ".yaml", ".yml", ".py", ".json", ".toml"):
viewing_file = subpath
rendered_content = render_markdown(target)
file_meta = {
"name": target.name,
"size": target.stat().st_size,
"modified": format_mtime(target.stat().st_mtime),
}
# Build tree for this directory
tree = get_file_tree(label, str(root))
# Determine breadcrumbs
breadcrumbs = [{"label": label, "path": f"/browse/{label}"}]
if "/" in (viewing_file or subpath or ""):
accum = ""
for segment in (viewing_file or subpath).split("/")[:-1]:
accum = f"{accum}/{segment}".lstrip("/")
breadcrumbs.append({"label": segment, "path": f"/browse/{label}/{accum}"})
return templates.TemplateResponse("browse.html.j2", {
"request": request,
"title": f"{label} — {APP_TITLE}",
"dir_label": label,
"tree": tree,
"breadcrumbs": breadcrumbs,
"viewing_file": viewing_file,
"rendered_content": rendered_content,
"file_meta": file_meta,
"directories": DIRECTORIES,
})
@app.get("/view/{dir_key:path}", response_class=HTMLResponse)
async def view_file(request: Request, dir_key: str):
"""Direct file view — full page markdown render."""
parts = dir_key.split("/", 1)
label = parts[0]
filepath_str = parts[1] if len(parts) > 1 else ""
from urllib.parse import unquote
label = unquote(label)
if label not in DIRECTORIES:
return HTMLResponse(f"<h1>Unknown directory</h1>", status_code=404)
root = Path(DIRECTORIES[label])
filepath = root / filepath_str
if not filepath.exists():
return HTMLResponse(f"<h1>File not found: {filepath_str}</h1>", status_code=404)
rendered = render_markdown(filepath)
return templates.TemplateResponse("view.html.j2", {
"request": request,
"title": f"{filepath.name} — {APP_TITLE}",
"file_name": filepath.name,
"dir_label": label,
"rendered_content": rendered,
"file_size": filepath.stat().st_size,
"file_modified": format_mtime(filepath.stat().st_mtime),
"directories": DIRECTORIES,
})
@app.get("/raw/{dir_key:path}")
async def raw_file(dir_key: str):
"""Serve raw file content."""
parts = dir_key.split("/", 1)
label = parts[0]
filepath_str = parts[1] if len(parts) > 1 else ""
from urllib.parse import unquote
label = unquote(label)
if label not in DIRECTORIES:
return PlainTextResponse("Not found", status_code=404)
root = Path(DIRECTORIES[label])
filepath = root / filepath_str
if not filepath.exists():
return PlainTextResponse("Not found", status_code=404)
return PlainTextResponse(filepath.read_text(encoding="utf-8", errors="replace"))
── Main ─────────────────────────────────────────────────────────────
if name == "main":
import uvicorn
print(f"HoffDoc starting on {APP_HOST}:{APP_PORT}")
print(f"Exposing {len(DIRECTORIES)} directories:")
for label, path in DIRECTORIES.items():
exists = "✅" if Path(path).exists() else "❌"
print(f" {exists} {label}: {path}")
uvicorn.run(app, host=APP_HOST, port=APP_PORT, log_level="warning")