Replaces obv-Fork. New architecture: - Python watchdog observes /home/admin/my-karpathy-wiki/ - On .md change → regenerate single HTML + update index - On startup → full regen + tree.json/graph.json/tags.json/backlinks.json - HTTP server on 127.0.0.1:8765 + tailscale proxy - 265 static HTML pages with full Markdown rendering - WikiLink resolution: [[entity]] → <a href='/path/entity.html'> - Frontmatter as styled card (type, status, updated, sources) - TOC auto-generated via markdown.extensions.toc - PWA manifest for native-feeling install - data.js with all metadata for offline use Touch-first design (CSS+JS for tree/graph/search) follows in Phase 2. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
745 lines
27 KiB
Python
745 lines
27 KiB
Python
#!/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>">
|
|
</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>
|
|
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
|
|
<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">
|
|
<button data-action="toggle-tree">☰<br><small>Files</small></button>
|
|
<button data-action="focus-search">🔍<br><small>Search</small></button>
|
|
<button data-action="toggle-graph">⊕<br><small>Graph</small></button>
|
|
</nav>
|
|
</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:
|
|
"""Write static assets (CSS, JS, manifest)."""
|
|
assets_dir = site_dir / "__"
|
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
(assets_dir / "style.css").write_text(CSS_PLACEHOLDER, encoding="utf-8")
|
|
(assets_dir / "app.js").write_text(JS_PLACEHOLDER, encoding="utf-8")
|
|
(assets_dir / "manifest.json").write_text(PWA_MANIFEST, encoding="utf-8")
|
|
# 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() |