"""
HoffDesk Blog — Dev Server
Serves the blog frontend, proxies API calls to real hoffdesk-api.
Run: python dev_server.py
"""
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
from pathlib import Path
from datetime import datetime, timezone, timedelta
import httpx
import asyncio
API_BASE = "http://127.0.0.1:8000"
app = FastAPI()
BASE = Path(file).parent
CST = timezone(timedelta(hours=-5))
Jinja2 setup with direct Environment
def format_date_filter(value):
"""Simple date formatter for Jinja2"""
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.replace('Z', '+00:00'))
except:
return value
if isinstance(value, datetime):
return value.strftime("%B %d, %Y")
return value
Main blog templates
blog_env = Environment(
loader=FileSystemLoader(str(BASE / "templates")),
autoescape=select_autoescape(['html', 'xml'])
)
blog_env.filters["format_date"] = format_date_filter
Admin templates
admin_env = Environment(
loader=FileSystemLoader(str(BASE / "admin" / "templates")),
autoescape=select_autoescape(['html', 'xml'])
)
admin_env.filters["format_date"] = format_date_filter
CATEGORY_LABELS = {
"homelab": "Home Lab",
"openclaw": "OpenClaw",
"ai-news": "AI News"
}
--- Mock Data ---
MOCK_POSTS = [
{
"slug": "the-night-i-broke-dns",
"title": "The Night I Broke DNS and My Wife Couldn't Reach Facebook",
"category": "homelab",
"status": "published",
"tags": "dns, pihole, homelab, fail, marriage",
"excerpt": "A cautionary tale about late-night infrastructure changes and the importance of testing your DNS failover before your wife tries to scroll Instagram.",
"cover_image": "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800&h=400&fit=crop",
"content": """# The Night I Broke DNS
It was 11:47 PM...""",
"created_at": "2026-04-20T08:00:00Z",
"updated_at": "2026-04-21T10:30:00Z",
"updated_relative": "2 hours ago",
"reading_time_minutes": 4
},
{
"slug": "openclaw-agent-setup",
"title": "Teaching OpenClaw to Recognize Itself",
"category": "openclaw",
"status": "published",
"tags": "openclaw, agents, llm, self-hosting",
"excerpt": "How we gave OpenClaw agents persistent identity through SOUL.md and why it matters for sovereign AI.",
"cover_image": "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&h=400&fit=crop",
"content": "# Teaching OpenClaw to Recognize Itself\n\nDraft content here...",
"created_at": "2026-04-18T14:00:00Z",
"updated_at": "2026-04-20T16:00:00Z",
"updated_relative": "1 day ago",
"reading_time_minutes": 8
},
{
"slug": "glm-5-vs-local-models",
"title": "GLM-5 vs Local 70B: A Week of Testing",
"category": "ai-news",
"status": "published",
"tags": "glm-5, local-llm, benchmarks, comparison",
"excerpt": "I ran GLM-5 and Qwen2.5-72B side by side for a week. The results surprised me.",
"cover_image": "https://images.unsplash.com/photo-1620712943543-bcc4688e7485?w=800&h=400&fit=crop",
"content": "# GLM-5 vs Local 70B\n\nTesting results here...",
"created_at": "2026-04-15T09:00:00Z",
"updated_at": "2026-04-15T09:00:00Z",
"updated_relative": "6 days ago",
"reading_time_minutes": 12
},
{
"slug": "tailscale-acl-lessons",
"title": "The Day I Accidentally Exposed My CalDAV to the Internet",
"category": "homelab",
"status": "published",
"tags": "tailscale, security, caldav, homelab",
"excerpt": "I thought I had set up Tailscale ACLs correctly. Then I ran nmap. Here's what I found and how I fixed it.",
"cover_image": "https://images.unsplash.com/photo-1551808525-51a94da548ce?w=800&h=400&fit=crop",
"content": "# Tailscale ACL Lessons\n\nIt started with a simple question...",
"created_at": "2026-04-12T10:00:00Z",
"updated_at": "2026-04-12T10:00:00Z",
"updated_relative": "1 week ago",
"reading_time_minutes": 6
},
{
"slug": "building-family-dashboard",
"title": "Building a Family Dashboard That Actually Gets Used",
"category": "homelab",
"status": "published",
"tags": "family, dashboard, python, fastapi",
"excerpt": "Most family dashboards fail within a week. Here's how I built one that Aundrea actually opens every morning.",
"cover_image": "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=400&fit=crop",
"content": "# Building a Family Dashboard\n\nThe first version died in three days...",
"created_at": "2026-04-08T08:00:00Z",
"updated_at": "2026-04-10T12:00:00Z",
"updated_relative": "2 weeks ago",
"reading_time_minutes": 9
},
{
"slug": "openclaw-agents-101",
"title": "OpenClaw Agents Explained: Why Your Butler Needs a SOUL",
"category": "openclaw",
"status": "published",
"tags": "openclaw, agents, tutorial, self-hosting",
"excerpt": "The difference between a stateless chatbot and a persistent assistant comes down to one file: SOUL.md.",
"cover_image": "https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=400&fit=crop",
"content": "# OpenClaw Agents 101\n\nMost AI assistants are stateless...",
"created_at": "2026-04-05T14:00:00Z",
"updated_at": "2026-04-05T14:00:00Z",
"updated_relative": "2 weeks ago",
"reading_time_minutes": 7
},
{
"slug": "local-llm-ama",
"title": "I Hosted an LLM Locally for a Month. Here's What I Learned.",
"category": "ai-news",
"status": "published",
"tags": "llm, local-llm, ollama, homelab",
"excerpt": "Spoiler: it's not about the GPU. It's about the workflow. A month with Qwen2.5 on my Beelink.",
"cover_image": "https://images.unsplash.com/photo-1591696205602-2f950c417cb9?w=800&h=400&fit=crop",
"content": "# Local LLM AMA\n\nEveryone asks about the GPU...",
"created_at": "2026-04-01T09:00:00Z",
"updated_at": "2026-04-01T09:00:00Z",
"updated_relative": "3 weeks ago",
"reading_time_minutes": 11
},
{
"slug": "beelink-upgrade-story",
"title": "Why I Upgraded from a Raspberry Pi to a Beelink",
"category": "homelab",
"status": "published",
"tags": "beelink, homelab, hardware, raspberry-pi",
"excerpt": "The Pi was fine until it wasn't. Three use cases that made me finally switch to a real x86 box.",
"cover_image": "https://images.unsplash.com/photo-1587831990711-23ca6441447b?w=800&h=400&fit=crop",
"content": "# Beelink Upgrade Story\n\nThe Pi held on for four years...",
"created_at": "2026-03-25T10:00:00Z",
"updated_at": "2026-03-25T10:00:00Z",
"updated_relative": "1 month ago",
"reading_time_minutes": 5
},
{
"slug": "openclaw-skill-writing",
"title": "Writing Your First OpenClaw Skill: A Practical Guide",
"category": "openclaw",
"status": "published",
"tags": "openclaw, skills, tutorial, python",
"excerpt": "Skills are how you extend OpenClaw. Here's how I wrote a skill to control my Hue lights in 30 minutes.",
"cover_image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&h=400&fit=crop",
"content": "# Writing Your First OpenClaw Skill\n\nSkills are the building blocks...",
"created_at": "2026-03-20T14:00:00Z",
"updated_at": "2026-03-20T14:00:00Z",
"updated_relative": "1 month ago",
"reading_time_minutes": 8
}
]
MOCK_STATS = {
"total": 3,
"published": 2,
"drafts": 1,
"images": 5,
"last_build_time": "2026-04-21T10:30:00Z",
"last_build_iso": "2026-04-21T10:30:00+00:00"
}
MOCK_IMAGES = [
{
"path": "/blog/posts/the-night-i-broke-dns/images/hero.svg",
"filename": "hero.svg",
"relative_path": "posts/the-night-i-broke-dns/images/hero.svg",
"size_bytes": 4500,
"dimensions": "1200x600",
"modified": "2026-04-20T08:00:00Z"
},
{
"path": "/blog/images/diagrams/network-topology.svg",
"filename": "network-topology.svg",
"relative_path": "images/diagrams/network-topology.svg",
"size_bytes": 8200,
"dimensions": "800x600",
"modified": "2026-04-19T10:00:00Z"
}
]
--- Helper Functions ---
def get_post(slug):
for post in MOCK_POSTS:
if post["slug"] == slug:
return post
return None
def render_template(env, template_name, context):
"""Render a Jinja2 template with context"""
template = env.get_template(template_name)
return template.render(**context)
--- Public Blog Routes ---
@app.get("/", response_class=HTMLResponse)
async def blog_index(request: Request):
published = [p for p in MOCK_POSTS if p["status"] == "published"]
featured = published[0] if published else None
html = render_template(blog_env, "blog_index.html.j2", {
"request": request,
"posts": published,
"featured_post": featured,
"categories": ["homelab", "openclaw", "ai-news"],
"current_category": None,
"category_labels": CATEGORY_LABELS,
"page_title": "HoffDesk Blog",
"page_description": "Home lab experiments, OpenClaw tutorials, and AI insights from a sovereign infrastructure journey.",
"current_page": 1,
"total_pages": 1
})
return HTMLResponse(html)
@app.get("/category/{category}", response_class=HTMLResponse)
async def blog_category(request: Request, category: str):
posts = [p for p in MOCK_POSTS if p["category"] == category and p["status"] == "published"]
html = render_template(blog_env, "blog_category.html.j2", {
"request": request,
"posts": posts,
"category": category,
"category_label": CATEGORY_LABELS.get(category, category),
"categories": ["homelab", "openclaw", "ai-news"],
"current_category": category,
"category_labels": CATEGORY_LABELS,
"page_title": f"{CATEGORY_LABELS.get(category, category)} — HoffDesk Blog",
"page_description": f"Posts about {CATEGORY_LABELS.get(category, category)}",
"current_page": 1,
"total_pages": 1
})
return HTMLResponse(html)
@app.get("/tag/{tag}", response_class=HTMLResponse)
async def blog_tag(request: Request, tag: str):
posts = [p for p in MOCK_POSTS if tag in p.get("tags", "") and p["status"] == "published"]
html = render_template(blog_env, "blog_tag.html.j2", {
"request": request,
"posts": posts,
"tag": tag,
"categories": ["homelab", "openclaw", "ai-news"],
"category_labels": CATEGORY_LABELS,
"page_title": f"Posts tagged '{tag}' — HoffDesk Blog",
"page_description": f"All posts tagged with {tag}",
"current_page": 1,
"total_pages": 1
})
return HTMLResponse(html)
@app.get("/article/{slug}", response_class=HTMLResponse)
async def blog_article(request: Request, slug: str):
post = get_post(slug)
if not post or post["status"] != "published":
return HTMLResponse("Not found", status_code=404)
related = [p for p in MOCK_POSTS if p["category"] == post["category"] and p["slug"] != slug and p["status"] == "published"][:3]
html = render_template(blog_env, "blog_article.html.j2", {
"request": request,
"post": post,
"related_posts": related,
"categories": ["homelab", "openclaw", "ai-news"],
"category_labels": CATEGORY_LABELS,
"page_title": f"{post['title']} — HoffDesk Blog",
"page_description": post.get("excerpt", ""),
"author_name": "Matt Hoffmann",
"site_name": "HoffDesk Blog"
})
return HTMLResponse(html)
@app.get("/feed.xml", response_class=HTMLResponse)
async def blog_feed(request: Request):
published = [p for p in MOCK_POSTS if p["status"] == "published"]
html = render_template(blog_env, "feed.xml.j2", {
"request": request,
"posts": published,
"last_build_date": MOCK_STATS["last_build_time"]
})
return HTMLResponse(html, media_type="application/xml")
@app.get("/sitemap.xml", response_class=HTMLResponse)
async def blog_sitemap(request: Request):
published = [p for p in MOCK_POSTS if p["status"] == "published"]
html = render_template(blog_env, "sitemap.xml.j2", {
"request": request,
"posts": published
})
return HTMLResponse(html, media_type="application/xml")
--- Admin Routes ---
@app.get("/admin", response_class=HTMLResponse)
@app.get("/admin/", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
recent = sorted(MOCK_POSTS, key=lambda p: p["updated_at"], reverse=True)[:5]
html = render_template(admin_env, "admin_dashboard.html.j2", {
"request": request,
"stats": MOCK_STATS,
"recent_posts": recent,
"draft_count": MOCK_STATS["drafts"],
"current_view": "dashboard"
})
return HTMLResponse(html)
@app.get("/admin/posts", response_class=HTMLResponse)
async def admin_post_list(request: Request, status: str = None, category: str = None, q: str = None):
posts = MOCK_POSTS
if status:
posts = [p for p in posts if p["status"] == status]
if category:
posts = [p for p in posts if p["category"] == category]
if q:
posts = [p for p in posts if q.lower() in p["title"].lower()]
html = render_template(admin_env, "admin_post_list.html.j2", {
"request": request,
"posts": posts,
"filter_status": status,
"filter_category": category,
"search_query": q,
"draft_count": MOCK_STATS["drafts"],
"current_view": "all_posts" if not status else ("drafts" if status == "draft" else "published")
})
return HTMLResponse(html)
@app.get("/admin/posts/new", response_class=HTMLResponse)
async def admin_editor_new(request: Request):
html = render_template(admin_env, "admin_editor.html.j2", {
"request": request,
"post": None,
"draft_count": MOCK_STATS["drafts"],
"current_view": "editor"
})
return HTMLResponse(html)
@app.get("/admin/posts/{slug}/edit", response_class=HTMLResponse)
async def admin_editor_edit(request: Request, slug: str):
post = get_post(slug)
if not post:
return HTMLResponse("Not found", status_code=404)
html = render_template(admin_env, "admin_editor.html.j2", {
"request": request,
"post": post,
"draft_count": MOCK_STATS["drafts"],
"current_view": "editor"
})
return HTMLResponse(html)
@app.get("/admin/images", response_class=HTMLResponse)
async def admin_images(request: Request):
html = render_template(admin_env, "admin_images.html.j2", {
"request": request,
"images": MOCK_IMAGES,
"draft_count": MOCK_STATS["drafts"],
"current_view": "images"
})
return HTMLResponse(html)
--- Admin API Endpoints (Mock) ---
@app.get("/api/blog/admin/stats")
async def api_admin_stats():
return MOCK_STATS
@app.get("/api/blog/admin/recent")
async def api_admin_recent():
recent = sorted(MOCK_POSTS, key=lambda p: p["updated_at"], reverse=True)[:5]
return {"posts": recent}
@app.get("/api/blog/admin/posts")
async def api_admin_posts(status: str = None, category: str = None, q: str = None, page: int = 1, per_page: int = 20):
posts = MOCK_POSTS
if status:
posts = [p for p in posts if p["status"] == status]
if category:
posts = [p for p in posts if p["category"] == category]
if q:
posts = [p for p in posts if q.lower() in p["title"].lower()]
return {
"posts": posts,
"pagination": {
"page": page,
"per_page": per_page,
"total": len(posts),
"pages": 1
}
}
@app.get("/api/blog/admin/posts/{slug}")
async def api_admin_post(slug: str):
post = get_post(slug)
if not post:
return JSONResponse({"error": "Not found"}, status_code=404)
return post
@app.post("/api/blog/admin/posts/{slug}")
async def api_admin_save_post(slug: str, request: Request):
return HTMLResponse('')
@app.post("/api/blog/admin/posts/{slug}/publish")
async def api_admin_publish_post(slug: str, request: Request):
post = get_post(slug)
if post:
post["status"] = "published"
html = render_template(admin_env, "components/status_widget.html.j2", {
"request": request,
"post": post or {"status": "published"}
})
return HTMLResponse(html)
@app.post("/api/blog/admin/posts/{slug}/unpublish")
async def api_admin_unpublish_post(slug: str, request: Request):
post = get_post(slug)
if post:
post["status"] = "draft"
html = render_template(admin_env, "components/status_widget.html.j2", {
"request": request,
"post": post or {"status": "draft"}
})
return HTMLResponse(html)
@app.post("/api/blog/admin/posts/{slug}/delete")
async def api_admin_delete_post(slug: str):
return HTMLResponse('')
@app.post("/api/blog/admin/posts/{slug}/preview")
async def api_admin_preview_post(slug: str, request: Request):
return {"rendered_html": "
Preview
Content would render here...
"}@app.post("/api/blog/admin/rebuild")
async def api_admin_rebuild():
return HTMLResponse('')
@app.post("/api/blog/admin/deploy")
async def api_admin_deploy():
return HTMLResponse('')
@app.get("/api/blog/admin/images")
async def api_admin_images():
return {"images": MOCK_IMAGES}
--- Content Generation Proxy (proxies to real API) ---
@app.get("/admin/content/health")
async def content_health():
"""Proxy health check to real API"""
async with httpx.AsyncClient() as client:
resp = await client.get(f"{API_BASE}/admin/content/health")
return resp.json()
@app.post("/admin/content/generate")
async def content_generate(request: Request):
"""Proxy generation request to real API"""
data = await request.json()
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{API_BASE}/admin/content/generate", json=data)
return resp.json()
@app.get("/admin/content/jobs/{job_id}")
async def content_job_status(job_id: str):
"""Proxy job status polling to real API"""
async with httpx.AsyncClient() as client:
resp = await client.get(f"{API_BASE}/admin/content/jobs/{job_id}")
if resp.status_code == 404:
return JSONResponse({"error": "Job not found", "status": "not_found"}, status_code=404)
return resp.json()
@app.post("/admin/content/jobs/{job_id}/cancel")
async def content_cancel_job(job_id: str):
"""Proxy cancel request to real API"""
async with httpx.AsyncClient() as client:
resp = await client.post(f"{API_BASE}/admin/content/jobs/{job_id}/cancel")
return resp.json()
--- Static Files ---
app.mount("/blog/static", StaticFiles(directory=str(BASE / "static")), name="blog_static")
app.mount("/blog/admin/static", StaticFiles(directory=str(BASE / "admin" / "static")), name="blog_admin_static")
app.mount("/blog/posts", StaticFiles(directory=str(BASE / "posts")), name="posts")
if name == "main":
import uvicorn
print("🎨 HoffDesk Blog Dev Server")
print("Public blog: http://localhost:8080/")
print("Admin panel: http://localhost:8080/admin/")
uvicorn.run(app, host="0.0.0.0", port=8080)