Generator-Skeleton (Phase 1): static HTML + watchdog + HTTP server
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>
This commit is contained in:
+745
@@ -0,0 +1,745 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user