Files
hermes-wiki-static/generator.py
T
agentandClaude d0e4e8ab9e Phase 2: Touch-First CSS + Touch-First JS + Graph-Modal
Touch-First Design (style.css, 17 KB):
- Mobile-First CSS mit Safe-Area-Insets für iOS Dynamic Island
- Bottom-Nav (Files / Search / Graph) auf <768px
- Hamburger-Tree slidet von links rein mit Backdrop
- Graph-Modal als Full-Screen-Overlay mit Title-Bar + Close
- Tap-Targets ≥ 44px (--tap-min)
- Tablet-Mode (768-1023px): 2-Panel + Bottom-Graph 240px
- Desktop (≥1024px): 3-Panel mit Tree 280px / Graph 320px
- Touch-Action-Manipulation verhindert 300ms-Tap-Delay
- Word-wrap + overflow-wrap für Mobile-Content
- Frontmatter-Card als Grid-Layout
- TOC + Backlinks Styling

Touch-First JS (app.js, 21 KB):
- Tree-Render mit auf-/zuklappbaren Ordnern (auto-open concepts/entities)
- Search mit Live-Dropdown, Keyboard-Navigation (↑↓ Enter Esc)
- 3D Graph in Three.js, KEIN Auto-Rotation, drag-to-rotate + wheel-zoom
- Click-zentriert: Click auf Node → navigiert zur Page
- Modal-Close via X-Button oder Escape
- Touch/Click-Handler: pointerdown/move/up + Raycaster
- Backlinks-Section aus data.backlinks
- TOC aus h2/h3-Headings der aktuellen Page
- Hash-anchor highlighting via :target CSS

Template-Patch (generator.py):
- Search-Wrap-Container für absolute Dropdown-Position
- Bottom-Nav mit inner-Flex
- Graph-Modal-Container
- Three.js-CDN-Script im HEAD

Assets werden jetzt aus dem Repo kopiert (style.css, app.js, manifest.json)
statt eingebettete Placeholder-Strings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 17:20:34 +00:00

769 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/tmp/ea-venv/bin/python3
"""
hermes-wiki-generator: Static HTML generator for Lukas' karpathy-style Wiki.
Architecture:
1. Watchdog observes /home/admin/my-karpathy-wiki/ for changes
2. On .md change → re-render single HTML file to /home/admin/.local/share/hermes-wiki/site/
3. On Wiki startup → generate tree.json, graph.json, tag-cloud.json (one-time)
4. Python http.server serves /home/admin/.local/share/hermes-wiki/site/ on 127.0.0.1:8765
Why static HTML:
- Mobile-first: load once, works offline (with service worker)
- Fast: zero JS for content rendering, JSON data lazy-loaded
- Bookmarkable: each page is a real URL like /concepts/agent-reference-model.html
- Touch-friendly: no animation that fights tap-targets
Run:
python3 ~/repos/hermes-wiki-static/generator.py --start
python3 ~/repos/hermes-wiki-static/generator.py --stop
python3 ~/repos/hermes-wiki-static/generator.py --status
python3 ~/repos/hermes-wiki-static/generator.py --once (one-shot regen, no watcher)
"""
import argparse
import json
import logging
import os
import re
import signal
import socket
import sys
import time
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from threading import Lock, Thread
from urllib.parse import unquote
import frontmatter
import markdown as md
import yaml
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
# ============================================================
# Configuration
# ============================================================
WIKI_DIR = Path("/home/admin/my-karpathy-wiki")
SITE_DIR = Path("/home/admin/.local/share/hermes-wiki/site")
LOG_DIR = Path("/home/admin/.local/share/hermes-wiki/log")
PID_FILE = Path("/tmp/hermes-wiki-static.pid")
PORT = 8765
BIND = "127.0.0.1"
LOG_DIR.mkdir(parents=True, exist_ok=True)
SITE_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_DIR / "generator.log"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("hermes-wiki")
# ============================================================
# Markdown → HTML rendering
# ============================================================
# WikiLink regex: [[entity-name]] or [[path/to/entity|display-text]]
WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
# Hashtag: #tag-name (max 30 chars, alphanumeric + dash)
TAG_RE = re.compile(r"(?<![\w/])#([a-zA-Z][a-z0-9-]{1,30})")
# Markdown extensions: TOC, tables, fenced code, footnotes, attr lists
MD_EXTENSIONS = [
"toc",
"tables",
"fenced_code",
"footnotes",
"attr_list",
"def_list",
"sane_lists",
]
def slugify(path: Path) -> str:
"""Convert /concepts/agent-reference-model.md → concepts/agent-reference-model"""
rel = path.relative_to(WIKI_DIR).with_suffix("")
return str(rel).replace(os.sep, "/")
def read_markdown(path: Path) -> frontmatter.Post:
"""Read .md with YAML frontmatter, fallback to plain text."""
try:
return frontmatter.load(path)
except yaml.YAMLError as e:
log.warning(f"YAML-Fehler in {path}: {e} — versuche ohne Frontmatter")
text = path.read_text(encoding="utf-8", errors="replace")
return frontmatter.Post(text)
def resolve_wikilinks(text: str, slug_to_path: dict) -> str:
"""Replace [[entity-name]] with <a href="/path/entity.html">entity-name</a>."""
def replace(m: re.Match) -> str:
target = m.group(1).strip()
display = (m.group(2) or target).strip()
# Try exact match first
if target in slug_to_path:
slug = slug_to_path[target]
return f'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
# Try with .md suffix stripped
target_no_ext = target.replace(".md", "")
if target_no_ext in slug_to_path:
slug = slug_to_path[target_no_ext]
return f'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
# Not found — render as broken link
return f'<a class="wikilink wikilink-missing" href="/__missing.html?target={quote(target)}">{display}</a>'
return WIKILINK_RE.sub(replace, text)
def quote(s: str) -> str:
"""URL-encode path components."""
import urllib.parse
return urllib.parse.quote(s, safe="/-_~.")
def render_page(md_path: Path, slug_to_path: dict, all_meta: list) -> dict:
"""Render one .md to (HTML + metadata). Returns dict with html, title, slug, frontmatter."""
post = read_markdown(md_path)
slug = slugify(md_path)
# Replace wikilinks before markdown rendering
body_with_links = resolve_wikilinks(post.content, slug_to_path)
html_body = md.markdown(
body_with_links,
extensions=MD_EXTENSIONS,
extension_configs={"toc": {"permalink": True}},
)
# Extract title from H1 if not in frontmatter
title = post.metadata.get("title") or md_path.stem.replace("-", " ").title()
if not post.metadata.get("title"):
h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", html_body, re.IGNORECASE)
if h1_match:
title = re.sub(r"<[^>]+>", "", h1_match.group(1))
return {
"slug": slug,
"title": title,
"html": html_body,
"frontmatter": dict(post.metadata),
"path": str(md_path.relative_to(WIKI_DIR)),
}
# ============================================================
# Index data: tree, graph, tags, backlinks
# ============================================================
def build_index(slug_meta_list: list) -> dict:
"""Build tree.json, graph.json, tags.json, backlinks.json."""
slug_to_meta = {s["slug"]: s for s in slug_meta_list}
# Tree: hierarchical folder structure
tree = {"name": "Vault", "children": {}, "files": []}
for s in slug_meta_list:
parts = s["slug"].split("/")
node = tree
for folder in parts[:-1]:
node = node["children"].setdefault(folder, {"name": folder, "children": {}, "files": []})
node["files"].append({"name": parts[-1], "title": s["title"], "slug": s["slug"]})
# Recursively convert dict-of-children to list-of-children (easier JSON)
def tree_to_list(node):
result = {
"name": node["name"],
"files": sorted(node["files"], key=lambda f: f["name"].lower()),
}
result["folders"] = sorted(
[tree_to_list(child) for child in node["children"].values()],
key=lambda f: f["name"].lower()
)
return result
tree_list = tree_to_list(tree)
# Graph: extract [[wikilinks]] from each rendered HTML
nodes = []
edges = []
for s in slug_meta_list:
nodes.append({"id": s["slug"], "title": s["title"], "size": 1})
# Find all wikilinks in the source
wikilinks = WIKILINK_RE.findall(s["html"] + " " + str(s["frontmatter"]))
for target, _disp in wikilinks:
target_slug = target.replace(".md", "")
if target_slug in slug_to_meta:
edges.append({"source": s["slug"], "target": target_slug})
# Tags: collect from frontmatter.tags + inline #tags
tags = {} # tag → [slugs]
for s in slug_meta_list:
# Frontmatter tags
fm_tags = s["frontmatter"].get("tags", [])
if isinstance(fm_tags, str):
fm_tags = [t.strip() for t in fm_tags.split(",")]
for tag in fm_tags or []:
tags.setdefault(str(tag).lower(), []).append(s["slug"])
# Inline #tags in content
inline_tags = TAG_RE.findall(s["html"])
for tag in inline_tags:
tags.setdefault(tag.lower(), []).append(s["slug"])
# Backlinks: for each page, list of pages that link TO it
backlinks = {s["slug"]: [] for s in slug_meta_list}
for edge in edges:
target = edge["target"]
source = edge["source"]
if source != target: # skip self-links
backlinks[target].append(source)
return {
"tree": tree_list,
"graph": {"nodes": nodes, "edges": edges},
"tags": tags,
"backlinks": backlinks,
}
# ============================================================
# HTML page template
# ============================================================
PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0a0a14">
<title>{title} · Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
<link rel="manifest" href="/__/manifest.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body class="page-{kind}">
<div class="app-shell">
<header class="app-header">
<button class="icon-btn menu-toggle" aria-label="Toggle tree" data-action="toggle-tree">☰</button>
<a href="/__/index.html" class="brand">📓 Hermes Wiki</a>
<div class="search-wrap">
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
</div>
<button class="icon-btn graph-toggle" aria-label="Toggle graph" data-action="toggle-graph">⊕</button>
</header>
<aside class="app-tree" id="tree" aria-label="File tree"></aside>
<main class="app-content">
<article class="note">
{frontmatter_card}
<div class="note-body">{body}</div>
<nav class="note-toc" id="toc" aria-label="Table of contents"></nav>
<section class="note-backlinks" id="backlinks" aria-label="Backlinks"></section>
<footer class="note-footer">
<span class="note-path">{path}</span>
</footer>
</article>
</main>
<aside class="app-graph" id="graph" aria-label="3D graph"></aside>
<nav class="app-bottom-nav" aria-label="Bottom navigation">
<div class="app-bottom-nav-inner">
<button data-action="toggle-tree"><span class="icon">☰</span><small>Files</small></button>
<button data-action="focus-search"><span class="icon">🔍</span><small>Search</small></button>
<button data-action="toggle-graph"><span class="icon">⊕</span><small>Graph</small></button>
</div>
</nav>
<div class="graph-modal" aria-label="3D graph (full screen)">
<div class="graph-modal-header">
<span class="graph-modal-title">GRAPH VIEW · Drag to rotate · Tap a node to navigate</span>
<button class="graph-modal-close" aria-label="Close">×</button>
</div>
<div class="graph-modal-canvas"><canvas></canvas></div>
<div class="graph-legend">
<div class="graph-legend-title">Legend</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700"></span>Current page</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700;opacity:0.8"></span>Connected</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#6c7086;opacity:0.5"></span>Other notes</div>
</div>
</div>
</div>
<script src="/__/data.js"></script>
<script src="/__/app.js"></script>
</body>
</html>"""
def render_frontmatter_card(meta: dict) -> str:
"""Render frontmatter as a Catppuccin-styled card."""
if not meta:
return ""
rows = []
for key, value in meta.items():
if key in ("title", "tags"):
continue # shown elsewhere
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
rows.append(f'<div class="meta-row"><span class="meta-key">{key}</span><span class="meta-value">{value}</span></div>')
if not rows:
return ""
return f'<aside class="meta-card">{"".join(rows)}</aside>'
def write_page(meta: dict, site_dir: Path) -> None:
"""Write a single HTML page to SITE_DIR/<slug>.html."""
target = site_dir / (quote(meta["slug"]) + ".html")
target.parent.mkdir(parents=True, exist_ok=True)
kind = "concept" if "concepts" in meta["slug"] else \
"entity" if "entities" in meta["slug"] else \
"scratch" if "scratch" in meta["slug"] else "note"
html = PAGE_TEMPLATE.format(
title=meta["title"],
body=meta["html"],
frontmatter_card=render_frontmatter_card(meta["frontmatter"]),
kind=kind,
path=meta["path"],
)
target.write_text(html, encoding="utf-8")
def write_index_files(index_data: dict, site_dir: Path) -> None:
"""Write tree.json, graph.json, tags.json, backlinks.json."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / "tree.json").write_text(
json.dumps(index_data["tree"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
(assets_dir / "graph.json").write_text(
json.dumps(index_data["graph"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
(assets_dir / "tags.json").write_text(
json.dumps(index_data["tags"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
(assets_dir / "backlinks.json").write_text(
json.dumps(index_data["backlinks"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
def write_data_js(slug_meta_list: list, index_data: dict, site_dir: Path) -> None:
"""Single data.js with all metadata + index baked in (avoids CORS issues)."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
pages = [{
"slug": s["slug"],
"title": s["title"],
"tags": s["frontmatter"].get("tags", []) if isinstance(s["frontmatter"].get("tags"), list) else [],
"type": s["frontmatter"].get("type", "note"),
"path": s["path"],
} for s in slug_meta_list]
js = f"""// Auto-generated by hermes-wiki-generator
window.HERMES_DATA = {{
pages: {json.dumps(pages, ensure_ascii=False)},
tree: {json.dumps(index_data['tree'], ensure_ascii=False)},
graph: {json.dumps(index_data['graph'], ensure_ascii=False)},
tags: {json.dumps(index_data['tags'], ensure_ascii=False)},
backlinks: {json.dumps(index_data['backlinks'], ensure_ascii=False)},
currentSlug: "{slug_meta_list[0]['slug'] if slug_meta_list else ''}",
}};
"""
(site_dir / "__/data.js").write_text(js, encoding="utf-8")
# ============================================================
# Static assets (CSS, JS, PWA manifest)
# ============================================================
# CSS is a placeholder — touch-first design comes in next commit
CSS_PLACEHOLDER = """/* Hermes Wiki — placeholder CSS */
:root {
--bg-primary: #0a0a14;
--bg-secondary: #0e0e1a;
--bg-surface: #1a1a2e;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-muted: #6c7086;
--accent: #FFD700;
--link: #FFD700;
--border: #313244;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; }
.app-shell { display: grid; grid-template-columns: 280px 1fr 260px; grid-template-rows: 48px 1fr; height: 100vh; }
.app-header { grid-column: 1 / -1; background: var(--bg-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
.brand { color: var(--accent); font-weight: 700; }
#search { flex: 1; max-width: 400px; padding: 6px 12px; background: rgba(30,30,50,0.6); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); }
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-secondary); padding: 4px 10px; border-radius: 4px; cursor: pointer; }
.app-tree { background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; padding: 8px; }
.app-content { overflow-y: auto; padding: 32px 48px; }
.app-graph { background: var(--bg-secondary); border-left: 1px solid var(--border); }
.meta-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 12px; }
.meta-row { display: flex; gap: 8px; padding: 2px 0; }
.meta-key { color: var(--accent); font-weight: 600; min-width: 80px; }
.wikilink-missing { color: var(--text-muted); text-decoration: line-through; }
.note-body { line-height: 1.7; }
.note-body h1, .note-body h2, .note-body h3 { margin-top: 1.5em; margin-bottom: 0.5em; }
.note-body code { background: var(--bg-surface); padding: 2px 6px; border-radius: 3px; }
.note-body pre { background: var(--bg-surface); padding: 12px; border-radius: 6px; overflow-x: auto; }
.app-bottom-nav { display: none; }
"""
JS_PLACEHOLDER = """// Hermes Wiki — placeholder JS
(function() {
'use strict';
// Bootstrap from data.js
const data = window.HERMES_DATA || { pages: [], tree: [], graph: { nodes: [], edges: [] }, tags: {}, backlinks: {} };
// Render tree into sidebar
function renderTree(node, container, basePath = '') {
const ul = document.createElement('ul');
if (node.folders) {
for (const folder of node.folders) {
const li = document.createElement('li');
const header = document.createElement('div');
header.textContent = '📁 ' + folder.name;
header.style.cursor = 'pointer';
const childrenContainer = document.createElement('div');
header.onclick = () => {
childrenContainer.style.display = childrenContainer.style.display === 'none' ? 'block' : 'none';
};
childrenContainer.style.display = 'none';
childrenContainer.style.paddingLeft = '12px';
li.appendChild(header);
li.appendChild(childrenContainer);
renderTree(folder, childrenContainer, basePath + '/' + folder.name);
ul.appendChild(li);
}
}
if (node.files) {
for (const file of node.files) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '/' + file.slug + '.html';
a.textContent = file.title;
a.style.color = 'var(--text-primary)';
a.style.textDecoration = 'none';
a.style.display = 'block';
a.style.padding = '2px 8px';
li.appendChild(a);
ul.appendChild(li);
}
}
container.appendChild(ul);
}
const treeEl = document.getElementById('tree');
if (treeEl && data.tree) {
renderTree(data.tree, treeEl);
}
// Search (client-side)
const search = document.getElementById('search');
if (search) {
search.addEventListener('input', (e) => {
const q = e.target.value.toLowerCase().trim();
if (!q) return;
// Simple substring search on titles + tags
const matches = data.pages.filter(p =>
p.title.toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
);
console.log('[Search]', q, '→', matches.length, 'results');
// TODO: show results dropdown
});
}
// Toggle buttons (placeholder)
document.addEventListener('click', (e) => {
if (e.target.dataset.action === 'toggle-tree') {
document.querySelector('.app-tree').classList.toggle('open');
}
if (e.target.dataset.action === 'toggle-graph') {
document.querySelector('.app-graph').classList.toggle('open');
}
});
})();
"""
PWA_MANIFEST = """{
"name": "Hermes Wiki",
"short_name": "Wiki",
"description": "Lukas Huber's knowledge vault — mobile-first static wiki",
"start_url": "/__/index.html",
"display": "standalone",
"background_color": "#0a0a14",
"theme_color": "#0a0a14",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
"sizes": "any",
"type": "image/svg+xml"
}
]
}"""
def write_assets(site_dir: Path) -> None:
"""Copy static assets (CSS, JS, manifest) from repo to site dir."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
repo_root = Path(__file__).parent
for asset in ("style.css", "app.js", "manifest.json"):
src = repo_root / asset
if src.exists():
content = src.read_text(encoding="utf-8")
(assets_dir / asset).write_text(content, encoding="utf-8")
else:
log.warning(f"Asset not found in repo: {src}")
# index page (redirect to first page or show all)
index_html = """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
<meta http-equiv="refresh" content="0; url=/{slug}.html"></head>
<body><a href="/{slug}.html">Open Wiki</a></body></html>"""
first_slug = "index" # fall back to index.md
(site_dir / "__/index.html").write_text(
index_html.replace("{slug}", first_slug), encoding="utf-8"
)
# ============================================================
# Generator orchestration
# ============================================================
class WikiState:
"""Holds in-memory state of generated pages and index."""
def __init__(self):
self.lock = Lock()
self.slug_to_path = {} # slug → original .md path
self.slug_meta_list = [] # list of {slug, title, html, frontmatter, path}
self.index_data = {} # tree, graph, tags, backlinks
def regenerate_all(self):
"""Full rebuild — used on startup."""
log.info(f"Full regenerate: scanning {WIKI_DIR}")
with self.lock:
self.slug_to_path.clear()
self.slug_meta_list.clear()
md_files = sorted(WIKI_DIR.rglob("*.md"))
skipped_raw = 0
for md_file in md_files:
rel = md_file.relative_to(WIKI_DIR)
if rel.parts[0] == "raw":
skipped_raw += 1
continue
slug = slugify(md_file)
self.slug_to_path[slug] = slug
self.slug_to_path[md_file.stem] = slug # for [[agent-hermes]] lookups
# Also index by basename without .md
base = md_file.name[:-3]
if base not in self.slug_to_path:
self.slug_to_path[base] = slug
log.info(f"Found {len(self.slug_to_path)} slugs ({skipped_raw} skipped in raw/)")
# Render each page
for md_file in md_files:
rel = md_file.relative_to(WIKI_DIR)
if rel.parts[0] == "raw":
continue
meta = render_page(md_file, self.slug_to_path, self.slug_meta_list)
self.slug_meta_list.append(meta)
write_page(meta, SITE_DIR)
log.info(f"Rendered {len(self.slug_meta_list)} HTML pages")
# Build index data
self.index_data = build_index(self.slug_meta_list)
write_index_files(self.index_data, SITE_DIR)
write_data_js(self.slug_meta_list, self.index_data, SITE_DIR)
write_assets(SITE_DIR)
log.info("Index files written: tree.json, graph.json, tags.json, backlinks.json, data.js")
def regenerate_one(self, md_path: Path):
"""Re-render one page (and its reverse-link targets if needed)."""
rel = md_path.relative_to(WIKI_DIR)
if rel.parts[0] == "raw":
return
with self.lock:
slug = slugify(md_path)
meta = render_page(md_path, self.slug_to_path, self.slug_meta_list)
# Update or append
for i, existing in enumerate(self.slug_meta_list):
if existing["slug"] == slug:
self.slug_meta_list[i] = meta
break
else:
self.slug_meta_list.append(meta)
write_page(meta, SITE_DIR)
log.info(f"Re-rendered: {slug}")
# Rebuild index (cheap for small wikis, ensures backlinks/tags stay consistent)
self.index_data = build_index(self.slug_meta_list)
write_index_files(self.index_data, SITE_DIR)
write_data_js(self.slug_meta_list, self.index_data, SITE_DIR)
class WikiFileWatcher(FileSystemEventHandler):
def __init__(self, state: WikiState):
self.state = state
def on_modified(self, event: FileSystemEvent):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix == ".md":
log.info(f"File modified: {path}")
self.state.regenerate_one(path)
def on_created(self, event: FileSystemEvent):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix == ".md":
log.info(f"File created: {path}")
self.state.regenerate_one(path)
def on_deleted(self, event: FileSystemEvent):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix == ".md":
log.info(f"File deleted: {path} (TODO: remove HTML)")
# ============================================================
# HTTP server
# ============================================================
class WikiHandler(SimpleHTTPRequestHandler):
"""Serves from SITE_DIR. Logs requests briefly."""
def log_message(self, format, *args):
log.debug(f"HTTP {self.address_string()} {format % args}")
def end_headers(self):
# Cache headers for static assets
if self.path.startswith("/__/"):
self.send_header("Cache-Control", "public, max-age=300")
super().end_headers()
def start_server():
"""Run HTTP server on BIND:PORT serving from SITE_DIR."""
os.chdir(SITE_DIR)
server = HTTPServer((BIND, PORT), WikiHandler)
log.info(f"HTTP server: http://{BIND}:{PORT} serving {SITE_DIR}")
server.serve_forever()
# ============================================================
# Daemon mode (PID file, signals)
# ============================================================
def write_pid():
PID_FILE.write_text(str(os.getpid()))
def is_running():
if not PID_FILE.exists():
return False
try:
pid = int(PID_FILE.read_text())
os.kill(pid, 0) # check if alive
return True
except (ValueError, ProcessLookupError, PermissionError):
return False
def stop_daemon():
if not PID_FILE.exists():
log.info("No PID file — daemon not running")
return
pid = int(PID_FILE.read_text())
try:
os.kill(pid, signal.SIGTERM)
log.info(f"Sent SIGTERM to PID {pid}")
except ProcessLookupError:
log.info(f"PID {pid} not found — already stopped?")
PID_FILE.unlink(missing_ok=True)
# ============================================================
# Main entry point
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Hermes Wiki Static Generator")
parser.add_argument("--start", action="store_true", help="Run as daemon (watcher + HTTP server)")
parser.add_argument("--stop", action="store_true", help="Stop daemon")
parser.add_argument("--status", action="store_true", help="Show status")
parser.add_argument("--once", action="store_true", help="Generate once and exit (no watcher)")
parser.add_argument("--foreground", action="store_true", help="Run in foreground (default: --start)")
args = parser.parse_args()
if args.stop:
stop_daemon()
return
if args.status:
if is_running():
pid = int(PID_FILE.read_text())
print(f"Running: PID {pid}")
else:
print(f"Not running (no PID file: {PID_FILE})")
return
if args.once:
state = WikiState()
state.regenerate_all()
print(f"Generated {len(state.slug_meta_list)} pages to {SITE_DIR}")
return
# Daemon mode
if is_running():
log.error(f"Already running (PID {int(PID_FILE.read_text())}). Use --stop first.")
sys.exit(1)
# Initial full regen
state = WikiState()
state.regenerate_all()
# Start file watcher
observer = Observer()
observer.schedule(WikiFileWatcher(state), str(WIKI_DIR), recursive=True)
observer.start()
log.info(f"Filesystem watcher started on {WIKI_DIR}")
# Start HTTP server in background thread
server_thread = Thread(target=start_server, daemon=True)
server_thread.start()
# Write PID
write_pid()
log.info(f"Daemon PID: {os.getpid()}")
# Handle signals
def handle_term(signum, frame):
log.info(f"Received signal {signum}, shutting down")
observer.stop()
PID_FILE.unlink(missing_ok=True)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_term)
signal.signal(signal.SIGINT, handle_term)
# Keep main thread alive
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
handle_term(0, None)
if __name__ == "__main__":
main()