xgit simple git

xgit

this project — static git page generator by polymath

kb.hax.al
clone git clone https://kb.hax.al/xgit

commit 5e04fb864ce81490ac04e46c2f3c080339cad2ed
author: polymath <polymath@localhost>
date: 2026-08-26 11:52
parents: (none)

Initial release of xgit by polymath.

Static git page generator with auto-fetch, Markdown/code views,
author profiles, and root demo/build/serve runners.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3af7977
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+__pycache__/
+*.py[cod]
+*$py.class
+.venv/
+venv/
+.env
+data/
+html/
+*.egg-info/
+dist/
+build/
+.DS_Store
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..71fadc1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 polymath
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3b42f2a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,122 @@
+# xgit
+
+**Author:** polymath  
+
+Python static git page generator inspired by [stagit](https://git.codemadness.org/stagit/).  
+Auto-fetches git repos from `config.toml`, builds static HTML (logs, files, refs, diffs, Markdown, syntax-colored code, author profiles, stats), then serves them.
+
+## Requirements
+
+- Python **3.10+**
+- `git` on `PATH`
+- No third-party packages
+
+## Quick start
+
+```bash
+chmod +x build serve sync demo   # once
+./demo                           # clean seed + fetch + build + serve
+```
+
+Open **http://127.0.0.1:3121/**
+
+## Main run files (repo root)
+
+| File | What it does |
+|------|----------------|
+| `./demo` | Wipe `data/` + `html/`, seed, fetch, build, serve `:3121` |
+| `./sync` | Seed (if needed) + fetch remotes + build |
+| `./build` | Generate static pages into `html/` |
+| `./serve` | Build then serve (`--no-build` to skip build) |
+| `config.toml` | Site settings + list of repositories |
+| `python -m xgit …` | Full CLI (same as above) |
+
+```bash
+./sync                 # update mirrors + rebuild
+./build                # rebuild only
+./build --fetch        # fetch then rebuild
+./serve                # build + http://0.0.0.0:3121
+./serve --no-build     # serve existing html/
+./serve --port 8080
+```
+
+Or without the wrappers:
+
+```bash
+python -m xgit seed
+python -m xgit fetch
+python -m xgit build
+python -m xgit sync
+python -m xgit serve
+```
+
+## Configure repos
+
+Edit `config.toml`:
+
+```toml
+site_name = "xgit"
+owner = "polymath"
+base_url = "http://127.0.0.1:3121"
+
+[[repos]]
+name = "hello"
+url = "local:hello"          # created by seed / demo
+description = "demo repo"
+owner = "polymath"
+
+[[repos]]
+name = "myproject"
+url = "https://github.com/you/myproject.git"
+description = "…"
+owner = "polymath"
+```
+
+Then:
+
+```bash
+./sync
+./serve --no-build
+```
+
+- Remote URLs → bare mirrors in `data/repos/<name>.git`
+- `local:*` → mirrored from `data/seed/<name>/`
+
+## What you get
+
+Per repository:
+
+- `log.html` — commits  
+- `files.html` — tree  
+- `refs.html` — branches & tags  
+- `commit/<sha>.html` — diffstat + diff  
+- `file/<path>.html` — Markdown or syntax-colored code  
+- `file/<path>.raw.html` — raw + **Copy**  
+- `file/<path>` — download  
+- `atom.xml` / `tags.xml`
+
+Site-wide:
+
+- `index.html` — repos (+ owner → author profile)  
+- `authors/` — profiles (same email = same profile) + commit stats  
+- `stats.html` — overall totals & leaderboard  
+
+## Layout
+
+```
+xgit/                 # Python package
+  cli.py              # CLI entry
+  generate.py         # HTML generator
+  fetch.py            # clone/pull + seed
+  … 
+config.toml           # your repos
+build | serve | sync | demo   # root runners
+LICENSE
+README.md
+```
+
+Generated (gitignored): `data/`, `html/`
+
+## License
+
+MIT © polymath
diff --git a/build b/build
new file mode 100755
index 0000000..1b9ba6a
--- /dev/null
+++ b/build
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# Build static HTML from config.toml
+# Usage: ./build [--fetch]
+set -euo pipefail
+cd "$(dirname "$0")"
+exec python -m xgit build "$@"
diff --git a/config.toml b/config.toml
new file mode 100644
index 0000000..7adf03d
--- /dev/null
+++ b/config.toml
@@ -0,0 +1,30 @@
+# xgit — config
+# Author: polymath
+#
+# Quick start (from repo root):
+#   ./demo          # seed + fetch + build + serve
+#   ./build         # generate html/
+#   ./serve         # build then serve on :3121
+
+site_name = "xgit"
+site_description = "static git page generator by polymath"
+owner = "polymath"
+base_url = "http://127.0.0.1:3121"
+clone_base = "http://127.0.0.1:3121"
+
+repos_dir = "data/repos"
+html_dir = "html"
+max_commits = 200
+max_diff_bytes = 1048576
+
+[[repos]]
+name = "hello"
+url = "local:hello"
+description = "demo repository seeded by xgit"
+owner = "polymath"
+
+[[repos]]
+name = "stagit"
+url = "git://git.codemadness.org/stagit"
+description = "static git page generator (upstream inspiration)"
+owner = "Hiltjo Posthuma"
diff --git a/demo b/demo
new file mode 100755
index 0000000..92cfc84
--- /dev/null
+++ b/demo
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Clean demo: wipe generated data, seed, sync, serve on :3121
+set -euo pipefail
+cd "$(dirname "$0")"
+
+echo "==> cleaning generated data"
+rm -rf data html
+
+echo "==> seeding + fetching + building"
+python -m xgit sync
+
+echo "==> serving http://0.0.0.0:3121"
+exec python -m xgit serve --no-build --host 0.0.0.0 --port 3121
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..2ed0939
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,26 @@
+[project]
+name = "xgit"
+version = "1.0.0"
+description = "Python static git page generator with auto-fetch (stagit-inspired)"
+readme = "README.md"
+requires-python = ">=3.10"
+license = { text = "MIT" }
+authors = [{ name = "polymath" }]
+dependencies = []
+
+[project.scripts]
+xgit = "xgit.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/polymath/xgit"
+
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["xgit*"]
+
+[tool.setuptools.package-data]
+xgit = ["static/*"]
diff --git a/serve b/serve
new file mode 100755
index 0000000..4277279
--- /dev/null
+++ b/serve
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# Build (unless --no-build) then serve html/ on 0.0.0.0:3121
+# Usage: ./serve [--no-build] [--port N] [--host ADDR] [--fetch]
+set -euo pipefail
+cd "$(dirname "$0")"
+exec python -m xgit serve "$@"
diff --git a/sync b/sync
new file mode 100755
index 0000000..6397b99
--- /dev/null
+++ b/sync
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# Seed local demo + fetch remotes + build html/
+set -euo pipefail
+cd "$(dirname "$0")"
+exec python -m xgit sync "$@"
diff --git a/xgit/__init__.py b/xgit/__init__.py
new file mode 100644
index 0000000..24ec451
--- /dev/null
+++ b/xgit/__init__.py
@@ -0,0 +1,3 @@
+"""xgit — Python static git page generator with auto-fetch (stagit-inspired)."""
+
+__version__ = "1.0.0"
diff --git a/xgit/__main__.py b/xgit/__main__.py
new file mode 100644
index 0000000..2f2d601
--- /dev/null
+++ b/xgit/__main__.py
@@ -0,0 +1,4 @@
+from xgit.cli import main
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/xgit/cli.py b/xgit/cli.py
new file mode 100644
index 0000000..65ad66f
--- /dev/null
+++ b/xgit/cli.py
@@ -0,0 +1,110 @@
+"""CLI for xgit."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+from xgit import __version__
+from xgit.config import load_config
+from xgit.fetch import fetch_all, seed_hello
+from xgit.generate import generate_all
+from xgit.serve import serve
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="xgit",
+        description="Python static git page generator with auto-fetch (stagit-inspired)",
+    )
+    parser.add_argument(
+        "-c",
+        "--config",
+        default="config.toml",
+        help="path to config.toml (default: ./config.toml)",
+    )
+    parser.add_argument("--version", action="version", version=f"xgit {__version__}")
+    sub = parser.add_subparsers(dest="cmd", required=True)
+
+    sub.add_parser("seed", help="create local demo repository (hello)")
+    sub.add_parser("fetch", help="clone/update all repos from config")
+
+    p_build = sub.add_parser(
+        "build",
+        help="generate static HTML (logs, files, commits, markdown, feeds)",
+    )
+    p_build.add_argument(
+        "--fetch",
+        action="store_true",
+        help="fetch/update mirrors before building",
+    )
+
+    # alias kept for compatibility
+    sub.add_parser("generate", help="alias for build")
+
+    p_sync = sub.add_parser("sync", help="seed (if needed) + fetch + build")
+    p_sync.add_argument("--no-seed", action="store_true", help="skip auto-seed of local repos")
+
+    p_serve = sub.add_parser(
+        "serve",
+        help="build static HTML then serve it (default http://0.0.0.0:3121)",
+    )
+    p_serve.add_argument("--host", default="0.0.0.0")
+    p_serve.add_argument("--port", type=int, default=3121)
+    p_serve.add_argument(
+        "--no-build",
+        action="store_true",
+        help="skip build and only serve existing html/",
+    )
+    p_serve.add_argument(
+        "--fetch",
+        action="store_true",
+        help="fetch/update mirrors before building",
+    )
+
+    args = parser.parse_args(argv)
+    cfg_path = Path(args.config)
+    if not cfg_path.is_file():
+        print(f"config not found: {cfg_path}", file=sys.stderr)
+        return 1
+    cfg = load_config(cfg_path)
+
+    try:
+        if args.cmd == "seed":
+            seed_hello(cfg)
+        elif args.cmd == "fetch":
+            fetch_all(cfg)
+        elif args.cmd in ("build", "generate"):
+            if getattr(args, "fetch", False):
+                _maybe_seed(cfg)
+                fetch_all(cfg)
+            generate_all(cfg)
+        elif args.cmd == "sync":
+            if not args.no_seed:
+                _maybe_seed(cfg)
+            fetch_all(cfg)
+            generate_all(cfg)
+        elif args.cmd == "serve":
+            if not args.no_build:
+                if args.fetch:
+                    _maybe_seed(cfg)
+                    fetch_all(cfg)
+                print("building static pages…")
+                generate_all(cfg)
+            serve(cfg.html_dir, host=args.host, port=args.port)
+        else:
+            parser.error(f"unknown command {args.cmd}")
+    except Exception as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 1
+    return 0
+
+
+def _maybe_seed(cfg) -> None:
+    if any(r.url.startswith("local:hello") for r in cfg.repos):
+        seed_hello(cfg)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/xgit/config.py b/xgit/config.py
new file mode 100644
index 0000000..04632bc
--- /dev/null
+++ b/xgit/config.py
@@ -0,0 +1,87 @@
+"""Load and validate xgit config.toml."""
+
+from __future__ import annotations
+
+import tomllib
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+
+@dataclass
+class RepoConfig:
+    name: str
+    url: str
+    description: str = ""
+    owner: str = ""
+
+
+@dataclass
+class Config:
+    site_name: str = "xgit"
+    site_description: str = "static git page generator"
+    owner: str = ""
+    base_url: str = "http://127.0.0.1:3121"
+    clone_base: str = "http://127.0.0.1:3121"
+    repos_dir: Path = Path("data/repos")
+    html_dir: Path = Path("html")
+    max_commits: int = 200
+    max_diff_bytes: int = 1_048_576
+    repos: list[RepoConfig] = field(default_factory=list)
+    root: Path = Path(".")
+
+    def repo_path(self, name: str) -> Path:
+        return self.repos_dir / f"{name}.git"
+
+    def html_repo_dir(self, name: str) -> Path:
+        return self.html_dir / name
+
+
+def _as_path(root: Path, value: str | Path) -> Path:
+    p = Path(value)
+    return p if p.is_absolute() else (root / p).resolve()
+
+
+def load_config(path: Path) -> Config:
+    path = path.resolve()
+    root = path.parent
+    with path.open("rb") as f:
+        data: dict[str, Any] = tomllib.load(f)
+
+    repos: list[RepoConfig] = []
+    for entry in data.get("repos") or []:
+        name = str(entry["name"]).strip()
+        url = str(entry["url"]).strip()
+        if not name or not url:
+            raise ValueError("each [[repos]] entry needs name and url")
+        if not _safe_name(name):
+            raise ValueError(f"invalid repo name: {name!r}")
+        repos.append(
+            RepoConfig(
+                name=name,
+                url=url,
+                description=str(entry.get("description") or ""),
+                owner=str(entry.get("owner") or data.get("owner") or ""),
+            )
+        )
+
+    return Config(
+        site_name=str(data.get("site_name") or "xgit"),
+        site_description=str(data.get("site_description") or ""),
+        owner=str(data.get("owner") or ""),
+        base_url=str(data.get("base_url") or "http://127.0.0.1:3121").rstrip("/"),
+        clone_base=str(data.get("clone_base") or data.get("base_url") or "http://127.0.0.1:3121").rstrip("/"),
+        repos_dir=_as_path(root, data.get("repos_dir") or "data/repos"),
+        html_dir=_as_path(root, data.get("html_dir") or "html"),
+        max_commits=int(data.get("max_commits") or 200),
+        max_diff_bytes=int(data.get("max_diff_bytes") or 1_048_576),
+        repos=repos,
+        root=root,
+    )
+
+
+def _safe_name(name: str) -> bool:
+    if not name or len(name) > 64:
+        return False
+    allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
+    return all(c in allowed for c in name) and name[0].isalnum()
diff --git a/xgit/fetch.py b/xgit/fetch.py
new file mode 100644
index 0000000..7753ba4
--- /dev/null
+++ b/xgit/fetch.py
@@ -0,0 +1,127 @@
+"""Clone / update repositories listed in config."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+from xgit.config import Config, RepoConfig
+from xgit import gitutil
+
+
+def fetch_all(cfg: Config) -> None:
+    cfg.repos_dir.mkdir(parents=True, exist_ok=True)
+    for repo in cfg.repos:
+        print(f"fetch: {repo.name}")
+        fetch_one(cfg, repo)
+
+
+def fetch_one(cfg: Config, repo: RepoConfig) -> Path:
+    dest = cfg.repo_path(repo.name)
+    if repo.url.startswith("local:"):
+        local_name = repo.url.split(":", 1)[1]
+        seed_dir = cfg.root / "data" / "seed" / local_name
+        if not dest.exists():
+            if not seed_dir.exists():
+                raise gitutil.GitError(
+                    f"local repo {local_name!r} missing — run: python -m xgit seed"
+                )
+            _mirror_from_workdir(seed_dir, dest)
+        else:
+            # Refresh mirror from seed if seed exists
+            if seed_dir.exists():
+                _mirror_from_workdir(seed_dir, dest)
+        if repo.description:
+            gitutil.set_description(dest, repo.description)
+        return dest
+
+    if dest.exists() and gitutil.is_git_repo(dest):
+        gitutil.fetch_all(dest)
+    else:
+        if dest.exists():
+            shutil.rmtree(dest)
+        print(f"  cloning {repo.url}")
+        gitutil.clone_mirror(repo.url, dest)
+
+    if repo.description:
+        gitutil.set_description(dest, repo.description)
+    return dest
+
+
+def _mirror_from_workdir(workdir: Path, dest: Path) -> None:
+    """Create/update a bare mirror from a local non-bare repo."""
+    if dest.exists():
+        shutil.rmtree(dest)
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    gitutil.run_git(["clone", "--mirror", str(workdir), str(dest)])
+
+
+def seed_hello(cfg: Config) -> Path:
+    """Create a tiny local demo repository."""
+    seed = cfg.root / "data" / "seed" / "hello"
+    if seed.exists() and gitutil.is_git_repo(seed):
+        print(f"seed already exists: {seed}")
+        return seed
+
+    if seed.exists():
+        shutil.rmtree(seed)
+    seed.mkdir(parents=True, exist_ok=True)
+
+    gitutil.run_git(["init", "-b", "main"], cwd=seed)
+    gitutil.run_git(["config", "user.name", "polymath"], cwd=seed)
+    gitutil.run_git(["config", "user.email", "polymath@localhost"], cwd=seed)
+
+    (seed / "README.md").write_text(
+        "# hello\n\n"
+        "Tiny demo repository generated by **xgit**.\n\n"
+        "This project mirrors [stagit](https://git.codemadness.org/stagit/) in Python:\n"
+        "static HTML for logs, files, refs, commits, and Atom feeds.\n\n"
+        "## Features\n\n"
+        "- Auto-fetch mirrors from `config.toml`\n"
+        "- Markdown rendering for `.md` files\n"
+        "- Raw source view with line anchors\n\n"
+        "```python\n"
+        "print('hello from xgit')\n"
+        "```\n\n"
+        "> Built by **polymath** · white + royal purple.\n\n"
+        "| Page | Description |\n"
+        "| --- | --- |\n"
+        "| Log | commits |\n"
+        "| Files | tree |\n"
+        "| Refs | branches & tags |\n",
+        encoding="utf-8",
+    )
+    (seed / "docs").mkdir(exist_ok=True)
+    (seed / "docs" / "guide.md").write_text(
+        "# Guide\n\n"
+        "Welcome to the *hello* guide.\n\n"
+        "1. Clone the repo\n"
+        "2. Run `python hello.py`\n"
+        "3. Enjoy\n\n"
+        "Inline `code` and a [link](https://example.com).\n",
+        encoding="utf-8",
+    )
+    (seed / "LICENSE").write_text(
+        "MIT License\n\nCopyright (c) 2026 polymath\n\n"
+        "Permission is hereby granted, free of charge, to any person obtaining a copy "
+        "of this software and associated documentation files (the \"Software\"), to deal "
+        "in the Software without restriction.\n",
+        encoding="utf-8",
+    )
+    (seed / "hello.py").write_text(
+        '#!/usr/bin/env python3\n"""Say hello."""\n\n\ndef main() -> None:\n    print("hello from xgit")\n\n\nif __name__ == "__main__":\n    main()\n',
+        encoding="utf-8",
+    )
+    gitutil.run_git(["add", "."], cwd=seed)
+    gitutil.run_git(["commit", "-m", "initial commit: hello world"], cwd=seed)
+
+    (seed / "hello.py").write_text(
+        '#!/usr/bin/env python3\n"""Say hello — louder."""\n\n\ndef main() -> None:\n    print("hello from xgit!")\n    print("static git pages, auto-fetched.")\n\n\nif __name__ == "__main__":\n    main()\n',
+        encoding="utf-8",
+    )
+    gitutil.run_git(["add", "hello.py"], cwd=seed)
+    gitutil.run_git(["commit", "-m", "make greeting a bit louder"], cwd=seed)
+
+    gitutil.run_git(["tag", "-a", "v0.1.0", "-m", "first tag"], cwd=seed)
+    print(f"seeded {seed}")
+    return seed
diff --git a/xgit/generate.py b/xgit/generate.py
new file mode 100644
index 0000000..33fc808
--- /dev/null
+++ b/xgit/generate.py
@@ -0,0 +1,822 @@
+"""Generate stagit-like static HTML for configured repositories."""
+
+from __future__ import annotations
+
+import re
+import shutil
+from pathlib import Path
+
+from xgit.config import Config, RepoConfig
+from xgit import gitutil
+from xgit import highlight as hl
+from xgit import markdown as md
+from xgit.htmlutil import author_link, esc, fmt_atom, fmt_date, human_size, page, repo_nav
+from xgit.stats import SiteStats
+
+
+def generate_all(cfg: Config) -> None:
+    cfg.html_dir.mkdir(parents=True, exist_ok=True)
+    _copy_assets(cfg)
+
+    site = SiteStats()
+    metas: list[tuple[RepoConfig, dict]] = []
+    for repo_cfg in cfg.repos:
+        meta = generate_repo(cfg, repo_cfg, site)
+        metas.append((repo_cfg, meta))
+
+    _write_authors(cfg, site)
+    _write_site_stats(cfg, site)
+
+    index_rows = [_index_row(cfg, repo_cfg, meta, site) for repo_cfg, meta in metas]
+    summary = _overall_summary_html(site, prefix="")
+    body = (
+        summary
+        + '<table id="index">\n'
+        "<thead><tr><td>Name</td><td>Description</td><td>Owner</td><td>Last commit</td></tr></thead>\n"
+        "<tbody>\n"
+        + "".join(index_rows)
+        + "</tbody>\n</table>\n"
+    )
+    html = page(
+        title=f"{cfg.site_name} - {cfg.site_description}" if cfg.site_description else cfg.site_name,
+        site_name=cfg.site_name,
+        nav="",
+        body=body,
+        css_href="style.css",
+        heading="Repositories",
+        subtitle=cfg.site_description or None,
+        site_nav_active="repos",
+    )
+    (cfg.html_dir / "index.html").write_text(html, encoding="utf-8")
+    print(f"wrote {cfg.html_dir / 'index.html'}")
+    print(f"  authors: {len(site.authors)} · commits: {site.total_commits}")
+
+
+def generate_repo(cfg: Config, repo_cfg: RepoConfig, site: SiteStats) -> dict:
+    repo = cfg.repo_path(repo_cfg.name)
+    if not gitutil.is_git_repo(repo):
+        raise gitutil.GitError(f"missing repo {repo} — run fetch first")
+
+    out = cfg.html_repo_dir(repo_cfg.name)
+    out.mkdir(parents=True, exist_ok=True)
+    (out / "commit").mkdir(exist_ok=True)
+    (out / "file").mkdir(exist_ok=True)
+
+    branch = gitutil.default_branch(repo)
+    commits = gitutil.list_commits(repo, "HEAD", limit=cfg.max_commits)
+    tree = gitutil.list_tree(repo, "HEAD")
+    paths = [p for _, p, _ in tree]
+    special = gitutil.find_special_files(paths)
+    branches = gitutil.list_branches(repo)
+    tags = gitutil.list_tags(repo)
+    description = repo_cfg.description or gitutil.read_description(repo)
+    owner = repo_cfg.owner or cfg.owner
+    clone_url = f"{cfg.clone_base}/{repo_cfg.name}"
+
+    nav_kwargs = dict(
+        name=repo_cfg.name,
+        readme=_file_page_name(special.get("readme")),
+        license_path=_file_page_name(special.get("license")),
+        gitmodules=_file_page_name(special.get("gitmodules")),
+    )
+
+    stats_by_hash: dict[str, list] = {
+        c.hash: gitutil.commit_diffstat(repo, c.hash) for c in commits
+    }
+    site.ingest_repo(
+        repo_name=repo_cfg.name,
+        commits=commits,
+        stats_by_hash=stats_by_hash,
+        file_count=len(paths),
+    )
+
+    author_prefix = "../"
+    _write_log(cfg, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, author_prefix)
+    _write_files(cfg, repo_cfg, out, tree, description, clone_url, nav_kwargs)
+    _write_refs(cfg, repo_cfg, out, branches, tags, description, clone_url, nav_kwargs)
+    _write_commits(
+        cfg, repo, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, "../../"
+    )
+    _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kwargs, special)
+    _write_atom(cfg, repo_cfg, out, commits, description)
+    _write_tags_atom(cfg, repo_cfg, out, tags, description)
+
+    (out / "index.html").write_text(
+        page(
+            title=f"{repo_cfg.name} - {description}" if description else repo_cfg.name,
+            site_name=cfg.site_name,
+            nav=repo_nav(**nav_kwargs, active="log"),
+            body='<p>Redirecting to <a href="log.html">log</a>…</p>\n'
+            '<meta http-equiv="refresh" content="0; url=log.html"/>\n',
+            css_href="../style.css",
+            clone_url=clone_url,
+            subtitle=description or None,
+            site_nav_active="repos",
+        ),
+        encoding="utf-8",
+    )
+
+    last = commits[0] if commits else None
+    print(f"  generated {repo_cfg.name} ({len(commits)} commits, {len(paths)} files)")
+    return {
+        "description": description,
+        "owner": owner,
+        "last_commit": last,
+        "branch": branch,
+    }
+
+
+def _copy_assets(cfg: Config) -> None:
+    static = Path(__file__).parent / "static"
+    shutil.copyfile(static / "style.css", cfg.html_dir / "style.css")
+    shutil.copyfile(static / "app.js", cfg.html_dir / "app.js")
+
+
+def _index_row(cfg: Config, repo_cfg: RepoConfig, meta: dict, site: SiteStats) -> str:
+    last = meta.get("last_commit")
+    date = fmt_date(last.author_date) if last else ""
+    desc = meta.get("description") or ""
+    owner = meta.get("owner") or ""
+    owner_html = _owner_link(owner, site, repo_cfg.name)
+    return (
+        "<tr>"
+        f'<td><a href="{esc(repo_cfg.name)}/log.html">{esc(repo_cfg.name)}</a></td>'
+        f'<td class="desc">{esc(desc)}</td>'
+        f"<td>{owner_html}</td>"
+        f"<td>{esc(date)}</td>"
+        "</tr>\n"
+    )
+
+
+def _owner_link(owner: str, site: SiteStats, repo_name: str) -> str:
+    """Link index owner to author profile when name/email matches."""
+    if not owner:
+        return ""
+    profile = _match_owner_profile(owner, site, repo_name)
+    if profile is None:
+        return esc(owner)
+    return (
+        f'<a class="author-link" href="authors/{esc(profile.slug)}.html">{esc(owner)}</a>'
+    )
+
+
+def _match_owner_profile(owner: str, site: SiteStats, repo_name: str):
+    key = owner.strip().lower()
+    if not key:
+        return None
+    # Email match first
+    for a in site.authors.values():
+        if a.email.strip().lower() == key:
+            return a
+    # Name match — prefer author with most commits in this repo
+    candidates = [
+        a for a in site.authors.values() if a.name.strip().lower() == key
+    ]
+    if not candidates:
+        return None
+    candidates.sort(
+        key=lambda a: (a.repos.get(repo_name, 0), a.commits),
+        reverse=True,
+    )
+    return candidates[0]
+
+
+def _write_log(
+    cfg, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, author_prefix
+) -> None:
+    rows = []
+    for c in commits:
+        stats = stats_by_hash.get(c.hash) or []
+        files_n = len(stats)
+        adds = sum(s.additions for s in stats)
+        dels = sum(s.deletions for s in stats)
+        rows.append(
+            "<tr>"
+            f"<td>{esc(fmt_date(c.author_date))}</td>"
+            f'<td><a href="commit/{esc(c.hash)}.html">{esc(c.subject)}</a></td>'
+            f"<td>{author_link(c.author_name, c.author_email, author_prefix)}</td>"
+            f'<td class="num">{files_n}</td>'
+            f'<td class="num A">+{adds}</td>'
+            f'<td class="num D">-{dels}</td>'
+            "</tr>\n"
+        )
+    body = (
+        '<table id="log">\n'
+        "<thead><tr><td>Date</td><td>Commit message</td><td>Author</td>"
+        "<td>Files</td><td>+</td><td>-</td></tr></thead>\n"
+        "<tbody>\n"
+        + "".join(rows)
+        + "</tbody>\n</table>\n"
+    )
+    (out / "log.html").write_text(
+        page(
+            title=f"Log - {repo_cfg.name}" + (f" - {description}" if description else ""),
+            site_name=cfg.site_name,
+            nav=repo_nav(**nav_kwargs, active="log"),
+            body=body,
+            css_href="../style.css",
+            clone_url=clone_url,
+            subtitle=description or None,
+            site_nav_active="repos",
+        ),
+        encoding="utf-8",
+    )
+
+
+def _write_files(cfg, repo_cfg, out, tree, description, clone_url, nav_kwargs) -> None:
+    rows = []
+    for typ, path, size in tree:
+        if typ != "blob":
+            continue
+        href = f"file/{_file_page_name(path)}.html"
+        badges = []
+        if md.is_markdown_path(path):
+            badges.append('<span class="badge">md</span>')
+        elif hl.language_for(path):
+            badges.append(f'<span class="badge">{esc(hl.language_for(path) or "code")}</span>')
+        badge = (" " + " ".join(badges)) if badges else ""
+        rows.append(
+            "<tr>"
+            f'<td><a href="{esc(href)}">{esc(path)}</a>{badge}</td>'
+            f'<td class="num">{esc(human_size(size))}</td>'
+            "</tr>\n"
+        )
+    body = (
+        '<table id="files">\n'
+        "<thead><tr><td>Name</td><td>Size</td></tr></thead>\n"
+        "<tbody>\n"
+        + "".join(rows)
+        + "</tbody>\n</table>\n"
+    )
+    (out / "files.html").write_text(
+        page(
+            title=f"Files - {repo_cfg.name}" + (f" - {description}" if description else ""),
+            site_name=cfg.site_name,
+            nav=repo_nav(**nav_kwargs, active="files"),
+            body=body,
+            css_href="../style.css",
+            clone_url=clone_url,
+            subtitle=description or None,
+            site_nav_active="repos",
+        ),
+        encoding="utf-8",
+    )
+
+
+def _write_refs(cfg, repo_cfg, out, branches, tags, description, clone_url, nav_kwargs) -> None:
+    b_rows = []
+    for r in branches:
+        b_rows.append(
+            "<tr>"
+            f"<td>{esc(r.name)}</td>"
+            f'<td><a href="commit/{esc(r.hash)}.html">{esc(r.short)}</a></td>'
+            f"<td>{esc(fmt_date(r.date))}</td>"
+            f"<td>{esc(r.subject)}</td>"
+            "</tr>\n"
+        )
+    t_rows = []
+    for r in tags:
+        t_rows.append(
+            "<tr>"
+            f"<td>{esc(r.name)}</td>"
+            f'<td><a href="commit/{esc(r.hash)}.html">{esc(r.short)}</a></td>'
+            f"<td>{esc(fmt_date(r.date))}</td>"
+            f"<td>{esc(r.subject)}</td>"
+            "</tr>\n"
+        )
+    body = (
+        "<h2>Branches</h2>\n"
+        '<table id="branches">\n'
+        "<thead><tr><td>Name</td><td>Commit</td><td>Date</td><td>Message</td></tr></thead>\n"
+        f"<tbody>\n{''.join(b_rows)}</tbody>\n</table>\n"
+        "<h2>Tags</h2>\n"
+        '<table id="tags">\n'
+        "<thead><tr><td>Name</td><td>Commit</td><td>Date</td><td>Message</td></tr></thead>\n"
+        f"<tbody>\n{''.join(t_rows)}</tbody>\n</table>\n"
+    )
+    (out / "refs.html").write_text(
+        page(
+            title=f"Refs - {repo_cfg.name}" + (f" - {description}" if description else ""),
+            site_name=cfg.site_name,
+            nav=repo_nav(**nav_kwargs, active="refs"),
+            body=body,
+            css_href="../style.css",
+            clone_url=clone_url,
+            subtitle=description or None,
+            site_nav_active="repos",
+        ),
+        encoding="utf-8",
+    )
+
+
+def _write_commits(
+    cfg, repo, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, author_prefix
+) -> None:
+    for c in commits:
+        dest = out / "commit" / f"{c.hash}.html"
+        stats = stats_by_hash.get(c.hash) or []
+        diff = gitutil.commit_diff(repo, c.hash, cfg.max_diff_bytes)
+
+        stat_rows = []
+        for s in stats:
+            st = esc(s.status)
+            cls = {"A": "A", "D": "D"}.get(s.status, "")
+            file_href = f'../file/{esc(s.path)}.html'
+            stat_rows.append(
+                "<tr>"
+                f'<td class="{cls}">{st}</td>'
+                f'<td><a href="{file_href}">{esc(s.path)}</a></td>'
+                f'<td class="num A">+{s.additions}</td>'
+                f'<td class="num D">-{s.deletions}</td>'
+                "</tr>\n"
+            )
+
+        parents = ", ".join(
+            f'<a href="{esc(p)}.html">{esc(p[:8])}</a>' for p in c.parents
+        ) or "(none)"
+
+        if diff is None:
+            diff_html = "<pre>Diff is too large, output suppressed</pre>\n"
+        else:
+            diff_html = f'<pre id="diff">{_colorize_diff(diff)}</pre>\n'
+
+        body = (
+            f"<p>commit <code>{esc(c.hash)}</code><br/>\n"
+            f"author: {author_link(c.author_name, c.author_email, author_prefix)}"
+            f" &lt;{esc(c.author_email)}&gt;<br/>\n"
+            f"date: {esc(fmt_date(c.author_date))}<br/>\n"
+            f"parents: {parents}</p>\n"
+            f"<pre>{esc(c.subject)}"
+            + (f"\n\n{esc(c.body)}" if c.body else "")
+            + "</pre>\n"
+            '<table id="diffstat">\n'
+            + "".join(stat_rows)
+            + "</table>\n"
+            + diff_html
+        )
+        html = page(
+            title=f"Commit {c.short} - {repo_cfg.name}",
+            site_name=cfg.site_name,
+            nav=repo_nav(**nav_kwargs, active=""),
+            body=body,
+            css_href="../../style.css",
+            clone_url=clone_url,
+            subtitle=description or None,
+            site_nav_active="repos",
+        )
+        html = html.replace('href="log.html"', 'href="../log.html"')
+        html = html.replace('href="files.html"', 'href="../files.html"')
+        html = html.replace('href="refs.html"', 'href="../refs.html"')
+        html = html.replace('href="file/', 'href="../file/')
+        dest.write_text(html, encoding="utf-8")
+
+
+def _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kwargs, special) -> None:
+    for path in paths:
+        page_name = _file_page_name(path)
+        assert page_name is not None
+        dest = out / "file" / f"{page_name}.html"
+        dest.parent.mkdir(parents=True, exist_ok=True)
+
+        rel_under_repo = Path("file") / f"{page_name}.html"
+        up = "/".join([".."] * len(rel_under_repo.parts[:-1])) or ".."
+        css_href = f"{up}/../style.css"
+
+        active = ""
+        if special.get("readme") == path:
+            active = "readme"
+        elif special.get("license") == path:
+            active = "license"
+        elif special.get("gitmodules") == path:
+            active = "gitmodules"
+
+        try:
+            raw_bytes = gitutil.show_blob(repo, "HEAD", path)
+        except Exception:
+            continue
+
+        nav = _fix_nav(repo_nav(**nav_kwargs, active=active), up)
+
+        # Always write downloadable raw bytes beside the HTML view
+        download_path = out / "file" / page_name
+        download_path.parent.mkdir(parents=True, exist_ok=True)
+        download_path.write_bytes(raw_bytes)
+        download_leaf = Path(page_name).name
+
+        if _is_binary(raw_bytes):
+            body = (
+                f'<div class="file-head"><h2 class="file-title">{esc(path)}</h2>'
+                f'{_view_toggle(mode="bin", download_href=download_leaf)}</div>\n'
+                "<pre>Binary file</pre>\n"
+            )
+            dest.write_text(
+                _file_page(cfg, repo_cfg, nav, body, css_href, clone_url, description, path),
+                encoding="utf-8",
+            )
+            continue
+
+        text = raw_bytes.decode("utf-8", errors="replace")
+        as_markdown = md.is_markdown_path(path) or (
+            path in special.values() and md.looks_like_markdown(text)
+        )
+        as_code = (not as_markdown) and (hl.is_code_path(path) or hl.language_for(path))
+
+        base_leaf = Path(page_name).name + ".html"
+        raw_leaf = Path(page_name).name + ".raw.html"
+        raw_dest = out / "file" / f"{page_name}.raw.html"
+
+        if as_markdown:
+            primary_label = "Markdown"
+            primary_body = f'<article class="markdown">\n{md.render(text)}\n</article>\n'
+            mode_primary = "md"
+        elif as_code:
+            primary_label = "Code"
+            primary_body = hl.highlight(text, path)
+            mode_primary = "code"
+        else:
+            primary_label = "View"
+            primary_body = _numbered_blob(text)
+            mode_primary = "code"
+
+        # Primary view
+        toggle = _view_toggle(
+            mode=mode_primary,
+            view_href=base_leaf,
+            raw_href=raw_leaf,
+            download_href=download_leaf,
+            view_label=primary_label,
+        )
+        body = (
+            f'<div class="file-head"><h2 class="file-title">{esc(path)}</h2>{toggle}</div>\n'
+            f"{primary_body}"
+        )
+        dest.write_text(
+            _file_page(cfg, repo_cfg, nav, body, css_href, clone_url, description, path),
+            encoding="utf-8",
+        )
+
+        # Raw view page
+        toggle_raw = _view_toggle(
+            mode="raw",
+            view_href=base_leaf,
+            raw_href=raw_leaf,
+            download_href=download_leaf,
+            view_label=primary_label,
+            copy_raw=True,
+        )
+        raw_body = (
+            f'<div class="file-head"><h2 class="file-title">{esc(path)}</h2>{toggle_raw}</div>\n'
+            f"{_numbered_blob(text, copyable=True)}"
+        )
+        raw_dest.write_text(
+            _file_page(
+                cfg,
+                repo_cfg,
+                nav,
+                raw_body,
+                css_href,
+                clone_url,
+                description,
+                f"{path} · raw",
+            ),
+            encoding="utf-8",
+        )
+
+
+def _file_page(cfg, repo_cfg, nav, body, css_href, clone_url, description, heading) -> str:
+    return page(
+        title=f"{heading} - {repo_cfg.name}",
+        site_name=cfg.site_name,
+        nav=nav,
+        body=body,
+        css_href=css_href,
+        clone_url=clone_url,
+        subtitle=description or None,
+        heading=heading,
+        site_nav_active="repos",
+    )
+
+
+def _fix_nav(nav: str, up: str) -> str:
+    return (
+        nav.replace('href="log.html"', f'href="{up}/log.html"')
+        .replace('href="files.html"', f'href="{up}/files.html"')
+        .replace('href="refs.html"', f'href="{up}/refs.html"')
+        .replace('href="file/', f'href="{up}/file/')
+    )
+
+
+def _view_toggle(
+    *,
+    mode: str,
+    view_href: str = "",
+    raw_href: str = "",
+    download_href: str = "",
+    view_label: str = "View",
+    copy_raw: bool = False,
+) -> str:
+    parts = []
+    if mode != "bin":
+        view_cls = ' class="active"' if mode in ("md", "code") else ""
+        raw_cls = ' class="active"' if mode == "raw" else ""
+        parts.append(f'<a{view_cls} href="{esc(view_href)}">{esc(view_label)}</a>')
+        parts.append(f'<a{raw_cls} href="{esc(raw_href)}">Raw</a>')
+    parts.append(
+        f'<a class="download" href="{esc(download_href)}" download>Download</a>'
+    )
+    if copy_raw:
+        parts.append(
+            '<button type="button" class="copy-btn copy-btn-toggle" '
+            'data-copy-target="#raw-source" aria-label="Copy raw file">Copy</button>'
+        )
+    return (
+        '<div class="view-toggle" role="tablist" aria-label="View mode">'
+        + "".join(parts)
+        + "</div>\n"
+    )
+
+
+def _numbered_blob(text: str, *, copyable: bool = False) -> str:
+    lines = text.splitlines()
+    numbered = [
+        f'<a class="line" href="#l{i}" id="l{i}">{i}</a> {esc(line)}'
+        for i, line in enumerate(lines, 1)
+    ]
+    blob = '<pre id="blob">\n' + "\n".join(numbered) + "\n</pre>\n"
+    if not copyable:
+        return blob
+    # Preserve exact file text for clipboard (including trailing newline if present)
+    return (
+        f'<textarea id="raw-source" class="copy-source" readonly hidden>'
+        f"{esc(text)}"
+        f"</textarea>\n"
+        f"{blob}"
+    )
+
+
+def _write_authors(cfg: Config, site: SiteStats) -> None:
+    authors_dir = cfg.html_dir / "authors"
+    authors_dir.mkdir(parents=True, exist_ok=True)
+    ranked = site.ranked_authors()
+
+    rows = []
+    for a in ranked:
+        rows.append(
+            "<tr>"
+            f'<td><a href="{esc(a.slug)}.html">{esc(a.name)}</a></td>'
+            f'<td class="desc">{esc(a.email)}</td>'
+            f'<td class="num">{a.commits}</td>'
+            f'<td class="num A">+{a.additions}</td>'
+            f'<td class="num D">-{a.deletions}</td>'
+            f'<td class="num">{len(a.repos)}</td>'
+            "</tr>\n"
+        )
+    index_body = (
+        _overall_summary_html(site, prefix="../")
+        + '<table id="authors">\n'
+        "<thead><tr><td>Author</td><td>Email</td><td>Commits</td>"
+        "<td>+</td><td>-</td><td>Repos</td></tr></thead>\n"
+        "<tbody>\n"
+        + "".join(rows)
+        + "</tbody>\n</table>\n"
+    )
+    (authors_dir / "index.html").write_text(
+        page(
+            title=f"Authors - {cfg.site_name}",
+            site_name=cfg.site_name,
+            nav="",
+            body=index_body,
+            css_href="../style.css",
+            heading="Authors",
+            subtitle="profiles shared across repositories",
+            site_nav_active="authors",
+        ),
+        encoding="utf-8",
+    )
+
+    for a in ranked:
+        repo_rows = "".join(
+            "<tr>"
+            f'<td><a href="../{esc(repo)}/log.html">{esc(repo)}</a></td>'
+            f'<td class="num">{count}</td>'
+            "</tr>\n"
+            for repo, count in sorted(a.repos.items(), key=lambda x: -x[1])
+        )
+        commit_rows = "".join(
+            "<tr>"
+            f"<td>{esc(fmt_date(c.date))}</td>"
+            f'<td><a href="../{esc(c.repo)}/commit/{esc(c.hash)}.html">{esc(c.subject)}</a></td>'
+            f'<td><a href="../{esc(c.repo)}/log.html">{esc(c.repo)}</a></td>'
+            f'<td class="num A">+{c.additions}</td>'
+            f'<td class="num D">-{c.deletions}</td>'
+            "</tr>\n"
+            for c in a.recent
+        )
+        max_commits = max((x.commits for x in ranked), default=1) or 1
+        bar_pct = max(4, int(100 * a.commits / max_commits))
+        body = (
+            f'<div class="profile-card">'
+            f'<div class="avatar" aria-hidden="true">{esc((a.name or "?")[:1].upper())}</div>'
+            f"<div>"
+            f'<p class="profile-name">{esc(a.name)}</p>'
+            f'<p class="profile-email">{esc(a.email)}</p>'
+            f"</div></div>\n"
+            f'<div class="stat-grid">'
+            f'<div class="stat"><span class="stat-val">{a.commits}</span><span class="stat-label">commits</span></div>'
+            f'<div class="stat"><span class="stat-val A">+{a.additions}</span><span class="stat-label">additions</span></div>'
+            f'<div class="stat"><span class="stat-val D">-{a.deletions}</span><span class="stat-label">deletions</span></div>'
+            f'<div class="stat"><span class="stat-val">{len(a.repos)}</span><span class="stat-label">repos</span></div>'
+            f"</div>\n"
+            f'<div class="bar-track" title="share of top author"><div class="bar-fill" style="width:{bar_pct}%"></div></div>\n'
+            f'<p class="desc">Active {esc(fmt_date(a.first_date))} → {esc(fmt_date(a.last_date))} · '
+            f"{a.files_touched} file changes</p>\n"
+            "<h2>Repositories</h2>\n"
+            '<table id="author-repos"><thead><tr><td>Repo</td><td>Commits</td></tr></thead>'
+            f"<tbody>\n{repo_rows}</tbody></table>\n"
+            "<h2>Recent commits</h2>\n"
+            '<table id="author-log"><thead><tr><td>Date</td><td>Message</td><td>Repo</td><td>+</td><td>-</td></tr></thead>'
+            f"<tbody>\n{commit_rows}</tbody></table>\n"
+        )
+        (authors_dir / f"{a.slug}.html").write_text(
+            page(
+                title=f"{a.name} - Authors - {cfg.site_name}",
+                site_name=cfg.site_name,
+                nav="",
+                body=body,
+                css_href="../style.css",
+                heading=a.name,
+                subtitle=a.email or None,
+                site_nav_active="authors",
+            ),
+            encoding="utf-8",
+        )
+
+
+def _write_site_stats(cfg: Config, site: SiteStats) -> None:
+    ranked = site.ranked_authors()
+    max_c = max((a.commits for a in ranked), default=1) or 1
+    author_bars = "".join(
+        f'<div class="leader">'
+        f'<a href="authors/{esc(a.slug)}.html">{esc(a.name)}</a>'
+        f'<div class="bar-track"><div class="bar-fill" style="width:{max(4, int(100 * a.commits / max_c))}%"></div></div>'
+        f'<span class="num">{a.commits}</span>'
+        f"</div>\n"
+        for a in ranked[:15]
+    )
+    repo_rows = "".join(
+        "<tr>"
+        f'<td><a href="{esc(r.name)}/log.html">{esc(r.name)}</a></td>'
+        f'<td class="num">{r.commits}</td>'
+        f'<td class="num A">+{r.additions}</td>'
+        f'<td class="num D">-{r.deletions}</td>'
+        f'<td class="num">{r.authors}</td>'
+        f'<td class="num">{r.files}</td>'
+        "</tr>\n"
+        for r in sorted(site.repos.values(), key=lambda x: -x.commits)
+    )
+    body = (
+        _overall_summary_html(site, prefix="")
+        + "<h2>Commits by author</h2>\n"
+        + '<div class="leaderboard">'
+        + (author_bars or '<p class="desc">No commits yet.</p>')
+        + "</div>\n"
+        + "<h2>Repositories</h2>\n"
+        + '<table id="stats-repos">\n'
+        + "<thead><tr><td>Repo</td><td>Commits</td><td>+</td><td>-</td><td>Authors</td><td>Files</td></tr></thead>\n"
+        + f"<tbody>\n{repo_rows}</tbody></table>\n"
+        + "<h2>Top authors</h2>\n"
+        + '<table id="stats-authors">\n'
+        + "<thead><tr><td>Author</td><td>Commits</td><td>+</td><td>-</td><td>Repos</td></tr></thead>\n"
+        + "<tbody>\n"
+        + "".join(
+            "<tr>"
+            f'<td><a href="authors/{esc(a.slug)}.html">{esc(a.name)}</a></td>'
+            f'<td class="num">{a.commits}</td>'
+            f'<td class="num A">+{a.additions}</td>'
+            f'<td class="num D">-{a.deletions}</td>'
+            f'<td class="num">{len(a.repos)}</td>'
+            "</tr>\n"
+            for a in ranked[:30]
+        )
+        + "</tbody></table>\n"
+    )
+    (cfg.html_dir / "stats.html").write_text(
+        page(
+            title=f"Stats - {cfg.site_name}",
+            site_name=cfg.site_name,
+            nav="",
+            body=body,
+            css_href="style.css",
+            heading="Stats",
+            subtitle="overall commit activity across all repositories",
+            site_nav_active="stats",
+        ),
+        encoding="utf-8",
+    )
+
+
+def _overall_summary_html(site: SiteStats, *, prefix: str) -> str:
+    return (
+        '<div class="stat-grid overall">'
+        f'<div class="stat"><span class="stat-val">{len(site.repos)}</span><span class="stat-label">repos</span></div>'
+        f'<div class="stat"><span class="stat-val">{site.total_commits}</span><span class="stat-label">commits</span></div>'
+        f'<div class="stat"><span class="stat-val">{len(site.authors)}</span><span class="stat-label">authors</span></div>'
+        f'<div class="stat"><span class="stat-val A">+{site.total_additions}</span><span class="stat-label">additions</span></div>'
+        f'<div class="stat"><span class="stat-val D">-{site.total_deletions}</span><span class="stat-label">deletions</span></div>'
+        f'<a class="stat stat-link" href="{esc(prefix)}stats.html"><span class="stat-val">→</span><span class="stat-label">full stats</span></a>'
+        "</div>\n"
+    )
+
+
+def _write_atom(cfg, repo_cfg, out, commits, description) -> None:
+    base = f"{cfg.base_url}/{repo_cfg.name}"
+    entries = []
+    for c in commits[:100]:
+        entries.append(
+            "  <entry>\n"
+            f"    <title>{esc(c.subject)}</title>\n"
+            f'    <id>urn:sha1:{esc(c.hash)}</id>\n'
+            f'    <link rel="alternate" type="text/html" href="{esc(base)}/commit/{esc(c.hash)}.html"/>\n'
+            f"    <updated>{esc(fmt_atom(c.author_date))}</updated>\n"
+            f"    <author><name>{esc(c.author_name)}</name></author>\n"
+            f"    <content>{esc(c.subject)}"
+            + (f"\n\n{esc(c.body)}" if c.body else "")
+            + "</content>\n"
+            "  </entry>\n"
+        )
+    updated = fmt_atom(commits[0].author_date) if commits else ""
+    feed = (
+        '<?xml version="1.0" encoding="UTF-8"?>\n'
+        '<feed xmlns="http://www.w3.org/2005/Atom">\n'
+        f"  <title>Log - {esc(repo_cfg.name)}</title>\n"
+        f"  <subtitle>{esc(description)}</subtitle>\n"
+        f'  <link rel="self" type="application/atom+xml" href="{esc(base)}/atom.xml"/>\n'
+        f'  <link rel="alternate" type="text/html" href="{esc(base)}/log.html"/>\n'
+        f"  <id>{esc(base)}/atom.xml</id>\n"
+        f"  <updated>{esc(updated)}</updated>\n"
+        + "".join(entries)
+        + "</feed>\n"
+    )
+    (out / "atom.xml").write_text(feed, encoding="utf-8")
+
+
+def _write_tags_atom(cfg, repo_cfg, out, tags, description) -> None:
+    base = f"{cfg.base_url}/{repo_cfg.name}"
+    entries = []
+    for t in tags[:100]:
+        entries.append(
+            "  <entry>\n"
+            f"    <title>{esc(t.name)}</title>\n"
+            f'    <id>urn:tag:{esc(repo_cfg.name)}:{esc(t.name)}</id>\n'
+            f'    <link rel="alternate" type="text/html" href="{esc(base)}/commit/{esc(t.hash)}.html"/>\n'
+            f"    <updated>{esc(fmt_atom(t.date))}</updated>\n"
+            f"    <content>{esc(t.subject)}</content>\n"
+            "  </entry>\n"
+        )
+    updated = fmt_atom(tags[0].date) if tags else ""
+    feed = (
+        '<?xml version="1.0" encoding="UTF-8"?>\n'
+        '<feed xmlns="http://www.w3.org/2005/Atom">\n'
+        f"  <title>Tags - {esc(repo_cfg.name)}</title>\n"
+        f"  <subtitle>{esc(description)}</subtitle>\n"
+        f'  <link rel="self" type="application/atom+xml" href="{esc(base)}/tags.xml"/>\n'
+        f'  <link rel="alternate" type="text/html" href="{esc(base)}/refs.html"/>\n'
+        f"  <id>{esc(base)}/tags.xml</id>\n"
+        f"  <updated>{esc(updated)}</updated>\n"
+        + "".join(entries)
+        + "</feed>\n"
+    )
+    (out / "tags.xml").write_text(feed, encoding="utf-8")
+
+
+def _file_page_name(path: str | None) -> str | None:
+    if path is None:
+        return None
+    return path
+
+
+def _is_binary(data: bytes) -> bool:
+    if b"\x00" in data[:8000]:
+        return True
+    try:
+        data.decode("utf-8")
+    except UnicodeDecodeError:
+        return True
+    return False
+
+
+_DIFF_HEADER = re.compile(r"^(diff --git |index |--- |\+\+\+ |@@ )")
+
+
+def _colorize_diff(diff: str) -> str:
+    out_lines = []
+    for line in diff.splitlines():
+        if line.startswith("+") and not line.startswith("+++"):
+            out_lines.append(f'<a class="i">{esc(line)}</a>')
+        elif line.startswith("-") and not line.startswith("---"):
+            out_lines.append(f'<a class="d">{esc(line)}</a>')
+        elif _DIFF_HEADER.match(line):
+            out_lines.append(f'<a class="h">{esc(line)}</a>')
+        else:
+            out_lines.append(esc(line))
+    return "\n".join(out_lines) + ("\n" if diff.endswith("\n") else "")
diff --git a/xgit/gitutil.py b/xgit/gitutil.py
new file mode 100644
index 0000000..6ebb251
--- /dev/null
+++ b/xgit/gitutil.py
@@ -0,0 +1,326 @@
+"""Thin git CLI wrappers used by fetch + generate."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+
+_GIT_ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
+
+
+class GitError(RuntimeError):
+    pass
+
+
+def run_git(
+    args: list[str],
+    *,
+    cwd: Path | None = None,
+    check: bool = True,
+    input_text: str | None = None,
+) -> str:
+    try:
+        proc = subprocess.run(
+            ["git", *args],
+            cwd=str(cwd) if cwd else None,
+            input=input_text,
+            capture_output=True,
+            text=True,
+            env=_GIT_ENV,
+            check=False,
+        )
+    except FileNotFoundError as e:
+        raise GitError("git executable not found") from e
+    if check and proc.returncode != 0:
+        err = (proc.stderr or proc.stdout or "").strip()
+        raise GitError(f"git {' '.join(args)} failed: {err}")
+    return (proc.stdout or "").rstrip("\n")
+
+
+@dataclass
+class CommitInfo:
+    hash: str
+    short: str
+    subject: str
+    body: str
+    author_name: str
+    author_email: str
+    author_date: datetime
+    committer_name: str
+    committer_date: datetime
+    parents: list[str]
+
+
+@dataclass
+class DiffStatEntry:
+    path: str
+    status: str  # A M D R C T
+    additions: int
+    deletions: int
+
+
+@dataclass
+class RefInfo:
+    name: str
+    hash: str
+    short: str
+    date: datetime | None
+    subject: str
+
+
+def is_git_repo(path: Path) -> bool:
+    if not path.exists():
+        return False
+    try:
+        run_git(["rev-parse", "--git-dir"], cwd=path)
+        return True
+    except GitError:
+        return False
+
+
+def clone_mirror(url: str, dest: Path) -> None:
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    if dest.exists():
+        raise GitError(f"destination already exists: {dest}")
+    run_git(["clone", "--mirror", url, str(dest)])
+
+
+def fetch_all(repo: Path) -> None:
+    run_git(["fetch", "--all", "--prune"], cwd=repo)
+
+
+def default_branch(repo: Path) -> str:
+    # Prefer HEAD symbolic ref
+    try:
+        ref = run_git(["symbolic-ref", "--short", "HEAD"], cwd=repo)
+        if ref:
+            return ref
+    except GitError:
+        pass
+    for candidate in ("main", "master"):
+        try:
+            run_git(["rev-parse", "--verify", f"refs/heads/{candidate}"], cwd=repo)
+            return candidate
+        except GitError:
+            continue
+    # First local branch
+    out = run_git(["for-each-ref", "--format=%(refname:short)", "refs/heads/"], cwd=repo)
+    lines = [ln for ln in out.splitlines() if ln.strip()]
+    if not lines:
+        raise GitError(f"no branches in {repo}")
+    return lines[0]
+
+
+def head_commit(repo: Path) -> str:
+    return run_git(["rev-parse", "HEAD"], cwd=repo)
+
+
+def read_description(repo: Path) -> str:
+    for candidate in (repo / "description", repo / ".git" / "description"):
+        if candidate.is_file():
+            text = candidate.read_text(encoding="utf-8", errors="replace").strip()
+            if text and text != "Unnamed repository; edit this file 'description' to name the repository.":
+                return text
+    return ""
+
+
+def set_description(repo: Path, description: str) -> None:
+    path = repo / "description"
+    path.write_text(description + "\n", encoding="utf-8")
+
+
+def list_commits(repo: Path, ref: str = "HEAD", limit: int = 200) -> list[CommitInfo]:
+    fmt = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%aI%x00%cn%x00%cI%x00%P%x1e"
+    out = run_git(
+        ["log", f"-n{limit}", f"--format={fmt}", ref],
+        cwd=repo,
+        check=False,
+    )
+    if not out.strip():
+        return []
+    commits: list[CommitInfo] = []
+    for entry in out.split("\x1e"):
+        entry = entry.strip("\n")
+        if not entry.strip():
+            continue
+        parts = entry.split("\x00")
+        if len(parts) < 10:
+            continue
+        parents = [p for p in parts[9].split() if p]
+        commits.append(
+            CommitInfo(
+                hash=parts[0],
+                short=parts[1],
+                subject=parts[2],
+                body=parts[3].strip(),
+                author_name=parts[4],
+                author_email=parts[5],
+                author_date=_parse_iso(parts[6]),
+                committer_name=parts[7],
+                committer_date=_parse_iso(parts[8]),
+                parents=parents,
+            )
+        )
+    return commits
+
+
+def commit_diffstat(repo: Path, commit: str) -> list[DiffStatEntry]:
+    # numstat against first parent (or empty tree for root)
+    args = ["diff-tree", "--no-commit-id", "--numstat", "-r", "-M", "-C", commit]
+    out = run_git(args, cwd=repo, check=False)
+    entries: list[DiffStatEntry] = []
+    name_status = run_git(
+        ["diff-tree", "--no-commit-id", "--name-status", "-r", "-M", "-C", commit],
+        cwd=repo,
+        check=False,
+    )
+    status_map: dict[str, str] = {}
+    for line in name_status.splitlines():
+        if not line.strip():
+            continue
+        parts = line.split("\t")
+        status = parts[0][0] if parts else "M"
+        path = parts[-1] if parts else ""
+        if path:
+            status_map[path] = status
+
+    for line in out.splitlines():
+        if not line.strip():
+            continue
+        parts = line.split("\t")
+        if len(parts) < 3:
+            continue
+        add_s, del_s, path = parts[0], parts[1], parts[2]
+        additions = 0 if add_s == "-" else int(add_s)
+        deletions = 0 if del_s == "-" else int(del_s)
+        entries.append(
+            DiffStatEntry(
+                path=path,
+                status=status_map.get(path, "M"),
+                additions=additions,
+                deletions=deletions,
+            )
+        )
+    return entries
+
+
+def commit_diff(repo: Path, commit: str, max_bytes: int) -> str | None:
+    out = run_git(
+        ["show", "--format=", "--find-renames", "--find-copies", commit],
+        cwd=repo,
+        check=False,
+    )
+    raw = out.encode("utf-8", errors="replace")
+    if len(raw) > max_bytes:
+        return None
+    return out
+
+
+def list_tree(repo: Path, ref: str = "HEAD") -> list[tuple[str, str, int | None]]:
+    """Return list of (mode_type, path, size). mode_type like 'blob' or 'tree'."""
+    out = run_git(["ls-tree", "-r", "-l", ref], cwd=repo)
+    rows: list[tuple[str, str, int | None]] = []
+    for line in out.splitlines():
+        # <mode> <type> <object> <size>\t<path>
+        try:
+            meta, path = line.split("\t", 1)
+        except ValueError:
+            continue
+        bits = meta.split()
+        if len(bits) < 4:
+            continue
+        typ = bits[1]
+        size_s = bits[3]
+        size = None if size_s == "-" else int(size_s)
+        rows.append((typ, path, size))
+    return rows
+
+
+def show_blob(repo: Path, ref: str, path: str) -> bytes:
+    return subprocess.run(
+        ["git", "show", f"{ref}:{path}"],
+        cwd=str(repo),
+        capture_output=True,
+        check=True,
+        env=_GIT_ENV,
+    ).stdout
+
+
+def list_branches(repo: Path) -> list[RefInfo]:
+    fmt = "%(refname:short)%00%(objectname)%00%(objectname:short)%00%(committerdate:iso-strict)%00%(subject)"
+    out = run_git(["for-each-ref", f"--format={fmt}", "--sort=-committerdate", "refs/heads/"], cwd=repo)
+    return _parse_refs(out)
+
+
+def list_tags(repo: Path) -> list[RefInfo]:
+    fmt = "%(refname:short)%00%(objectname)%00%(objectname:short)%00%(creatordate:iso-strict)%00%(subject)"
+    out = run_git(["for-each-ref", f"--format={fmt}", "--sort=-creatordate", "refs/tags/"], cwd=repo)
+    return _parse_refs(out)
+
+
+def _parse_refs(out: str) -> list[RefInfo]:
+    refs: list[RefInfo] = []
+    for line in out.splitlines():
+        if not line.strip():
+            continue
+        parts = line.split("\x00")
+        if len(parts) < 5:
+            continue
+        date = _parse_iso(parts[3]) if parts[3] else None
+        refs.append(
+            RefInfo(
+                name=parts[0],
+                hash=parts[1],
+                short=parts[2],
+                date=date,
+                subject=parts[4],
+            )
+        )
+    return refs
+
+
+def find_special_files(paths: list[str]) -> dict[str, str]:
+    """Detect README / LICENSE / .gitmodules paths (case-insensitive)."""
+    readme_names = {
+        "readme",
+        "readme.md",
+        "readme.markdown",
+        "readme.txt",
+        "readme.rst",
+    }
+    license_names = {
+        "license",
+        "license.md",
+        "license.txt",
+        "copying",
+        "copying.md",
+        "copying.txt",
+        "copyright",
+    }
+    found: dict[str, str] = {}
+    for path in paths:
+        base = path.split("/")[-1]
+        lower = base.lower()
+        if "readme" not in found and lower in readme_names:
+            found["readme"] = path
+        if "license" not in found and lower in license_names:
+            found["license"] = path
+        if "gitmodules" not in found and base == ".gitmodules":
+            found["gitmodules"] = path
+    return found
+
+
+def _parse_iso(value: str) -> datetime:
+    value = value.strip()
+    if value.endswith("Z"):
+        value = value[:-1] + "+00:00"
+    try:
+        dt = datetime.fromisoformat(value)
+    except ValueError:
+        return datetime.now(timezone.utc)
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt
diff --git a/xgit/highlight.py b/xgit/highlight.py
new file mode 100644
index 0000000..4f99bb0
--- /dev/null
+++ b/xgit/highlight.py
@@ -0,0 +1,440 @@
+"""Lightweight syntax highlighting by file extension (stdlib only)."""
+
+from __future__ import annotations
+
+import html
+import re
+from pathlib import Path
+
+
+# extension -> language id
+LANG_BY_EXT: dict[str, str] = {
+    ".py": "python",
+    ".pyw": "python",
+    ".pyi": "python",
+    ".js": "javascript",
+    ".jsx": "javascript",
+    ".mjs": "javascript",
+    ".cjs": "javascript",
+    ".ts": "typescript",
+    ".tsx": "typescript",
+    ".json": "json",
+    ".css": "css",
+    ".scss": "css",
+    ".less": "css",
+    ".html": "html",
+    ".htm": "html",
+    ".xml": "xml",
+    ".svg": "xml",
+    ".c": "c",
+    ".h": "c",
+    ".cc": "cpp",
+    ".cpp": "cpp",
+    ".cxx": "cpp",
+    ".hpp": "cpp",
+    ".hh": "cpp",
+    ".go": "go",
+    ".rs": "rust",
+    ".rb": "ruby",
+    ".java": "java",
+    ".kt": "kotlin",
+    ".kts": "kotlin",
+    ".cs": "csharp",
+    ".php": "php",
+    ".sh": "shell",
+    ".bash": "shell",
+    ".zsh": "shell",
+    ".fish": "shell",
+    ".ps1": "shell",
+    ".yml": "yaml",
+    ".yaml": "yaml",
+    ".toml": "toml",
+    ".ini": "ini",
+    ".cfg": "ini",
+    ".conf": "ini",
+    ".sql": "sql",
+    ".r": "r",
+    ".R": "r",
+    ".swift": "swift",
+    ".scala": "scala",
+    ".lua": "lua",
+    ".pl": "perl",
+    ".pm": "perl",
+    ".ex": "elixir",
+    ".exs": "elixir",
+    ".erl": "erlang",
+    ".hs": "haskell",
+    ".ml": "ocaml",
+    ".mli": "ocaml",
+    ".clj": "clojure",
+    ".dart": "dart",
+    ".vim": "vim",
+    ".makefile": "make",
+    ".mk": "make",
+    ".cmake": "cmake",
+    ".dockerfile": "docker",
+    ".tf": "hcl",
+    ".hcl": "hcl",
+    ".graphql": "graphql",
+    ".gql": "graphql",
+    ".vue": "html",
+    ".svelte": "html",
+}
+
+CODE_EXTS = set(LANG_BY_EXT) | {
+    ".md",
+    ".markdown",
+    ".mdown",
+    ".mkd",
+    ".mdx",
+    ".txt",
+    ".text",
+    ".log",
+    ".csv",
+    ".tsv",
+    ".diff",
+    ".patch",
+}
+
+
+def language_for(path: str) -> str | None:
+    name = Path(path).name.lower()
+    if name in ("makefile", "gnumakefile", "dockerfile", "cmakelists.txt"):
+        return {
+            "makefile": "make",
+            "gnumakefile": "make",
+            "dockerfile": "docker",
+            "cmakelists.txt": "cmake",
+        }[name]
+    ext = Path(path).suffix.lower()
+    return LANG_BY_EXT.get(ext)
+
+
+def is_code_path(path: str) -> bool:
+    if language_for(path):
+        return True
+    ext = Path(path).suffix.lower()
+    return ext in CODE_EXTS
+
+
+def highlight(text: str, path: str) -> str:
+    """Return HTML for a highlighted, line-numbered blob."""
+    lang = language_for(path) or "text"
+    lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+    # drop trailing empty line from split if file ended with newline only once
+    if lines and lines[-1] == "" and text.endswith("\n"):
+        lines = lines[:-1]
+
+    colored = [_highlight_line(line, lang) for line in lines]
+    out = ['<pre id="blob" class="code hl"><code class="language-%s">' % html.escape(lang)]
+    for i, line_html in enumerate(colored, 1):
+        out.append(
+            f'<span class="code-line"><a class="line" href="#l{i}" id="l{i}">{i}</a>'
+            f"<span class=\"code-src\">{line_html or ' '}</span></span>"
+        )
+    out.append("</code></pre>")
+    return "\n".join(out) + "\n"
+
+
+def _highlight_line(line: str, lang: str) -> str:
+    if not line:
+        return ""
+    rules = _RULES.get(lang) or _RULES["text"]
+    return _tokenize(line, rules)
+
+
+def _tokenize(line: str, rules: list[tuple[str, re.Pattern[str]]]) -> str:
+    i = 0
+    n = len(line)
+    parts: list[str] = []
+    while i < n:
+        matched = False
+        for cls, pat in rules:
+            m = pat.match(line, i)
+            if not m:
+                continue
+            chunk = m.group(0)
+            parts.append(f'<span class="tok-{cls}">{html.escape(chunk)}</span>')
+            i = m.end()
+            matched = True
+            break
+        if not matched:
+            parts.append(html.escape(line[i]))
+            i += 1
+    return "".join(parts)
+
+
+def _kw(*words: str) -> re.Pattern[str]:
+    return re.compile(r"\b(?:" + "|".join(re.escape(w) for w in words) + r")\b")
+
+
+_COMMON_STRING = [
+    ("str", re.compile(r'"(?:\\.|[^"\\])*"')),
+    ("str", re.compile(r"'(?:\\.|[^'\\])*'")),
+    ("str", re.compile(r"`(?:\\.|[^`\\])*`")),
+]
+_COMMON_NUM = [("num", re.compile(r"\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b"))]
+_COMMON_COMMENT_SLASH = [("comment", re.compile(r"//.*$"))]
+_COMMON_COMMENT_HASH = [("comment", re.compile(r"#.*$"))]
+
+_PY_KW = _kw(
+    "False", "None", "True", "and", "as", "assert", "async", "await", "break",
+    "class", "continue", "def", "del", "elif", "else", "except", "finally",
+    "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal",
+    "not", "or", "pass", "raise", "return", "try", "while", "with", "yield",
+    "match", "case", "type",
+)
+_JS_KW = _kw(
+    "break", "case", "catch", "class", "const", "continue", "debugger", "default",
+    "delete", "do", "else", "export", "extends", "finally", "for", "function",
+    "if", "import", "in", "instanceof", "let", "new", "return", "static", "super",
+    "switch", "this", "throw", "try", "typeof", "var", "void", "while", "with",
+    "yield", "async", "await", "of", "from", "as", "true", "false", "null",
+    "undefined", "type", "interface", "enum", "implements", "private", "public",
+    "protected", "readonly", "namespace", "declare", "abstract",
+)
+_C_KW = _kw(
+    "auto", "break", "case", "char", "const", "continue", "default", "do",
+    "double", "else", "enum", "extern", "float", "for", "goto", "if", "int",
+    "long", "register", "return", "short", "signed", "sizeof", "static",
+    "struct", "switch", "typedef", "union", "unsigned", "void", "volatile",
+    "while", "inline", "restrict", "_Bool", "true", "false", "NULL",
+    "class", "namespace", "template", "typename", "public", "private",
+    "protected", "virtual", "override", "using", "new", "delete", "this",
+    "try", "catch", "throw", "constexpr", "noexcept", "nullptr",
+)
+_GO_KW = _kw(
+    "break", "case", "chan", "const", "continue", "default", "defer", "else",
+    "fallthrough", "for", "func", "go", "goto", "if", "import", "interface",
+    "map", "package", "range", "return", "select", "struct", "switch", "type",
+    "var", "true", "false", "nil", "iota",
+)
+_RS_KW = _kw(
+    "as", "async", "await", "break", "const", "continue", "crate", "dyn",
+    "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let",
+    "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self",
+    "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
+    "use", "where", "while", "abstract", "become", "box", "do", "final",
+    "macro", "override", "priv", "typeof", "unsized", "virtual", "yield",
+)
+_JAVA_KW = _kw(
+    "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
+    "class", "const", "continue", "default", "do", "double", "else", "enum",
+    "extends", "final", "finally", "float", "for", "goto", "if", "implements",
+    "import", "instanceof", "int", "interface", "long", "native", "new",
+    "package", "private", "protected", "public", "return", "short", "static",
+    "strictfp", "super", "switch", "synchronized", "this", "throw", "throws",
+    "transient", "try", "void", "volatile", "while", "true", "false", "null",
+    "var", "record", "sealed", "permits", "yield",
+)
+_SQL_KW = _kw(
+    "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "INSERT", "INTO", "VALUES",
+    "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "INDEX", "DROP", "ALTER",
+    "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AS", "ORDER", "BY",
+    "GROUP", "HAVING", "LIMIT", "OFFSET", "DISTINCT", "NULL", "TRUE", "FALSE",
+    "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "CONSTRAINT", "UNIQUE", "WITH",
+)
+_SHELL_KW = _kw(
+    "if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case",
+    "esac", "function", "return", "in", "select", "time", "until", "export",
+    "local", "readonly", "declare", "typeset", "unset", "shift", "break",
+    "continue", "exit", "source", "true", "false",
+)
+_RUBY_KW = _kw(
+    "BEGIN", "END", "alias", "and", "begin", "break", "case", "class", "def",
+    "defined?", "do", "else", "elsif", "end", "ensure", "false", "for", "if",
+    "in", "module", "next", "nil", "not", "or", "redo", "rescue", "retry",
+    "return", "self", "super", "then", "true", "undef", "unless", "until",
+    "when", "while", "yield",
+)
+
+_RULES: dict[str, list[tuple[str, re.Pattern[str]]]] = {
+    "python": [
+        ("comment", re.compile(r"#.*$")),
+        ("str", re.compile(r'"""[\s\S]*?"""')),
+        ("str", re.compile(r"'''[\s\S]*?'''")),
+        ("str", re.compile(r'"(?:\\.|[^"\\])*"')),
+        ("str", re.compile(r"'(?:\\.|[^'\\])*'")),
+        ("kw", _PY_KW),
+        ("fn", re.compile(r"\b([A-Za-z_]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],    "javascript": [
+        *_COMMON_COMMENT_SLASH,
+        ("comment", re.compile(r"/\*.*?\*/")),
+        *_COMMON_STRING,
+        ("kw", _JS_KW),
+        ("fn", re.compile(r"\b([A-Za-z_$]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],
+    "typescript": [],  # filled below
+    "json": [
+        ("str", re.compile(r'"(?:\\.|[^"\\])*"')),
+        ("kw", re.compile(r"\b(?:true|false|null)\b")),
+        *_COMMON_NUM,
+    ],
+    "css": [
+        ("comment", re.compile(r"/\*.*?\*/")),
+        ("str", re.compile(r'"(?:\\.|[^"\\])*"')),
+        ("str", re.compile(r"'(?:\\.|[^'\\])*'")),
+        ("kw", re.compile(r"@[a-zA-Z-]+")),
+        ("fn", re.compile(r"\b([a-zA-Z-]+)\s*(?=\()")),
+        ("num", re.compile(r"\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms)?\b")),
+    ],
+    "html": [
+        ("comment", re.compile(r"<!--.*?-->")),
+        ("kw", re.compile(r"</?[a-zA-Z][\w:-]*")),
+        ("str", re.compile(r'"(?:\\.|[^"\\])*"')),
+        ("str", re.compile(r"'(?:\\.|[^'\\])*'")),
+    ],
+    "xml": [],
+    "c": [
+        *_COMMON_COMMENT_SLASH,
+        ("comment", re.compile(r"/\*.*?\*/")),
+        ("comment", re.compile(r"#\s*\w+.*$")),
+        *_COMMON_STRING[:2],
+        ("kw", _C_KW),
+        ("fn", re.compile(r"\b([A-Za-z_]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],
+    "cpp": [],
+    "go": [
+        *_COMMON_COMMENT_SLASH,
+        ("comment", re.compile(r"/\*.*?\*/")),
+        *_COMMON_STRING[:2],
+        ("str", re.compile(r"`[^`]*`")),
+        ("kw", _GO_KW),
+        ("fn", re.compile(r"\b([A-Za-z_]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],
+    "rust": [
+        *_COMMON_COMMENT_SLASH,
+        ("comment", re.compile(r"/\*.*?\*/")),
+        *_COMMON_STRING[:2],
+        ("kw", _RS_KW),
+        ("fn", re.compile(r"\b([A-Za-z_]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],
+    "java": [
+        *_COMMON_COMMENT_SLASH,
+        ("comment", re.compile(r"/\*.*?\*/")),
+        *_COMMON_STRING[:2],
+        ("kw", _JAVA_KW),
+        ("fn", re.compile(r"\b([A-Za-z_]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],
+    "kotlin": [],
+    "csharp": [],
+    "ruby": [
+        *_COMMON_COMMENT_HASH,
+        *_COMMON_STRING[:2],
+        ("kw", _RUBY_KW),
+        ("fn", re.compile(r"\b([A-Za-z_]\w*)\s*(?=\()")),
+        *_COMMON_NUM,
+    ],
+    "shell": [
+        *_COMMON_COMMENT_HASH,
+        *_COMMON_STRING[:2],
+        ("kw", _SHELL_KW),
+        ("str", re.compile(r"\$\{[^}]+\}")),
+        ("str", re.compile(r"\$[A-Za-z_]\w*")),
+        *_COMMON_NUM,
+    ],
+    "yaml": [
+        *_COMMON_COMMENT_HASH,
+        ("kw", re.compile(r"^\s*[A-Za-z0-9_.-]+(?=\s*:)")),
+        *_COMMON_STRING[:2],
+        ("kw", re.compile(r"\b(?:true|false|null|yes|no)\b")),
+        *_COMMON_NUM,
+    ],
+    "toml": [
+        *_COMMON_COMMENT_HASH,
+        ("kw", re.compile(r"^\s*\[[^\]]+\]")),
+        ("kw", re.compile(r"^[A-Za-z0-9_.-]+(?=\s*=)")),
+        *_COMMON_STRING[:2],
+        ("kw", re.compile(r"\b(?:true|false)\b")),
+        *_COMMON_NUM,
+    ],
+    "ini": [
+        *_COMMON_COMMENT_HASH,
+        ("comment", re.compile(r";.*$")),
+        ("kw", re.compile(r"^\s*\[[^\]]+\]")),
+        ("kw", re.compile(r"^[^=\n]+(?=\s*=)")),
+        *_COMMON_STRING[:2],
+        *_COMMON_NUM,
+    ],
+    "sql": [
+        ("comment", re.compile(r"--.*$")),
+        ("comment", re.compile(r"/\*.*?\*/")),
+        *_COMMON_STRING[:2],
+        ("kw", _SQL_KW),
+        *_COMMON_NUM,
+    ],
+    "php": [
+        *_COMMON_COMMENT_SLASH,
+        *_COMMON_COMMENT_HASH,
+        ("comment", re.compile(r"/\*.*?\*/")),
+        *_COMMON_STRING,
+        ("kw", _kw(
+            "abstract", "and", "array", "as", "break", "callable", "case", "catch",
+            "class", "clone", "const", "continue", "declare", "default", "do",
+            "echo", "else", "elseif", "empty", "enddeclare", "endfor", "endforeach",
+            "endif", "endswitch", "endwhile", "eval", "exit", "extends", "final",
+            "finally", "fn", "for", "foreach", "function", "global", "goto", "if",
+            "implements", "include", "include_once", "instanceof", "insteadof",
+            "interface", "isset", "list", "match", "namespace", "new", "or",
+            "print", "private", "protected", "public", "require", "require_once",
+            "return", "static", "switch", "throw", "trait", "try", "unset", "use",
+            "var", "while", "xor", "yield", "true", "false", "null",
+        )),
+        *_COMMON_NUM,
+    ],
+    "make": [
+        *_COMMON_COMMENT_HASH,
+        ("kw", re.compile(r"^\s*[A-Za-z_][\w-]*(?=:)")),
+        ("str", re.compile(r"\$\([^)]+\)")),
+        *_COMMON_STRING[:2],
+    ],
+    "docker": [
+        *_COMMON_COMMENT_HASH,
+        ("kw", _kw(
+            "FROM", "RUN", "CMD", "LABEL", "EXPOSE", "ENV", "ADD", "COPY",
+            "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "ONBUILD",
+            "STOPSIGNAL", "HEALTHCHECK", "SHELL", "AS",
+        )),
+        *_COMMON_STRING[:2],
+    ],
+    "text": [
+        *_COMMON_NUM,
+    ],
+}
+
+# aliases
+_RULES["typescript"] = list(_RULES["javascript"])
+_RULES["xml"] = list(_RULES["html"])
+_RULES["cpp"] = list(_RULES["c"])
+_RULES["kotlin"] = list(_RULES["java"])
+_RULES["csharp"] = list(_RULES["java"])
+_RULES["r"] = list(_RULES["python"])
+_RULES["swift"] = list(_RULES["java"])
+_RULES["scala"] = list(_RULES["java"])
+_RULES["lua"] = [
+    ("comment", re.compile(r"--.*$")),
+    *_COMMON_STRING[:2],
+    ("kw", _kw(
+        "and", "break", "do", "else", "elseif", "end", "false", "for", "function",
+        "goto", "if", "in", "local", "nil", "not", "or", "repeat", "return",
+        "then", "true", "until", "while",
+    )),
+    *_COMMON_NUM,
+]
+_RULES["perl"] = list(_RULES["ruby"])
+_RULES["elixir"] = list(_RULES["ruby"])
+_RULES["erlang"] = list(_RULES["ruby"])
+_RULES["haskell"] = list(_RULES["python"])
+_RULES["ocaml"] = list(_RULES["rust"])
+_RULES["clojure"] = list(_RULES["ruby"])
+_RULES["dart"] = list(_RULES["java"])
+_RULES["vim"] = list(_RULES["shell"])
+_RULES["cmake"] = list(_RULES["make"])
+_RULES["hcl"] = list(_RULES["yaml"])
+_RULES["graphql"] = list(_RULES["javascript"])
diff --git a/xgit/htmlutil.py b/xgit/htmlutil.py
new file mode 100644
index 0000000..e622a74
--- /dev/null
+++ b/xgit/htmlutil.py
@@ -0,0 +1,175 @@
+"""HTML chrome helpers for xgit pages."""
+
+from __future__ import annotations
+
+import html
+from datetime import datetime
+
+
+def esc(value: object) -> str:
+    return html.escape("" if value is None else str(value), quote=True)
+
+
+def fmt_date(dt: datetime | None) -> str:
+    if dt is None:
+        return ""
+    return dt.strftime("%Y-%m-%d %H:%M")
+
+
+def fmt_atom(dt: datetime | None) -> str:
+    if dt is None:
+        return ""
+    return (
+        dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+        if dt.tzinfo is None
+        else dt.astimezone().strftime("%Y-%m-%dT%H:%M:%SZ")
+    )
+
+
+def human_size(n: int | None) -> str:
+    if n is None:
+        return "-"
+    units = ["B", "K", "M", "G"]
+    size = float(n)
+    for unit in units:
+        if size < 1024 or unit == units[-1]:
+            if unit == "B":
+                return f"{int(size)}{unit}"
+            return f"{size:.1f}{unit}"
+        size /= 1024
+    return f"{n}B"
+
+
+def page(
+    *,
+    title: str,
+    site_name: str,
+    nav: str,
+    body: str,
+    css_href: str,
+    clone_url: str | None = None,
+    subtitle: str | None = None,
+    heading: str | None = None,
+    site_nav_active: str = "",
+) -> str:
+    index_href = _index_href(css_href)
+    root = _root_prefix(css_href)
+    head = heading if heading is not None else (title.split(" - ")[0] if " - " in title else title)
+    sub = f'<p class="subtitle">{esc(subtitle)}</p>' if subtitle else ""
+    clone_block = ""
+    if clone_url:
+        cmd = f"git clone {clone_url}"
+        clone_block = f"""<div class="clone">
+  <span class="clone-label">clone</span>
+  <code id="clone-cmd">{esc(cmd)}</code>
+  <button type="button" class="copy-btn" data-copy-target="#clone-cmd" aria-label="Copy clone command">Copy</button>
+</div>
+"""
+    site_nav = _site_nav(root, site_nav_active)
+    js_href = f"{root}app.js"
+    return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8"/>
+<meta name="viewport" content="width=device-width, initial-scale=1"/>
+<meta name="theme-color" content="#6B21A8"/>
+<title>{esc(title)}</title>
+<link rel="preconnect" href="https://fonts.googleapis.com"/>
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
+<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
+<link rel="stylesheet" type="text/css" href="{esc(css_href)}"/>
+<script src="{esc(js_href)}" defer></script>
+</head>
+<body>
+<div class="bg-glow" aria-hidden="true"></div>
+<div class="shell">
+  <header class="top">
+    <a class="brand" href="{esc(index_href)}">
+      <span class="brand-mark" aria-hidden="true"></span>
+      <span class="brand-text">{esc(site_name)}</span>
+      <span class="brand-tag">polymath</span>
+    </a>
+    <div class="heading-block">
+      <h1 class="page-title">{esc(head)}</h1>
+      {sub}
+    </div>
+  </header>
+  {site_nav}
+  {clone_block}{nav}
+  <main id="content">
+{body}
+  </main>
+  <footer class="foot">
+    <span>static pages · xgit</span>
+  </footer>
+</div>
+</body>
+</html>
+"""
+
+
+def _index_href(css_href: str) -> str:
+    if "/" not in css_href:
+        return "index.html"
+    return css_href.rsplit("/", 1)[0] + "/index.html"
+
+
+def _root_prefix(css_href: str) -> str:
+    """Prefix to reach html root from current page ('' or '../' or '../../'...)."""
+    if "/" not in css_href:
+        return ""
+    depth = css_href.count("..")
+    return "../" * depth
+
+
+def _site_nav(root: str, active: str) -> str:
+    links = [
+        ("Repos", f"{root}index.html", "repos"),
+        ("Authors", f"{root}authors/index.html", "authors"),
+        ("Stats", f"{root}stats.html", "stats"),
+    ]
+    parts = []
+    for label, href, key in links:
+        cls = ' class="active"' if key == active else ""
+        if key == active:
+            parts.append(f'<a{cls} href="{esc(href)}" aria-current="page">{esc(label)}</a>')
+        else:
+            parts.append(f'<a{cls} href="{esc(href)}">{esc(label)}</a>')
+    return f'<nav class="site-nav" aria-label="Site">{"".join(parts)}</nav>\n'
+
+
+def repo_nav(
+    *,
+    name: str,
+    readme: str | None,
+    license_path: str | None,
+    gitmodules: str | None,
+    active: str,
+) -> str:
+    links = [
+        ("Log", "log.html", "log"),
+        ("Files", "files.html", "files"),
+        ("Refs", "refs.html", "refs"),
+    ]
+    if readme:
+        links.append(("README", f"file/{readme}.html", "readme"))
+    if license_path:
+        links.append(("LICENSE", f"file/{license_path}.html", "license"))
+    if gitmodules:
+        links.append(("gitmodules", f"file/{gitmodules}.html", "gitmodules"))
+
+    parts = []
+    for label, href, key in links:
+        cls = ' class="active"' if key == active else ""
+        if key == active:
+            parts.append(f'<a{cls} href="{esc(href)}" aria-current="page">{esc(label)}</a>')
+        else:
+            parts.append(f'<a{cls} href="{esc(href)}">{esc(label)}</a>')
+    return f'<nav class="nav" aria-label="Repository">{"".join(parts)}</nav>\n'
+
+
+def author_link(name: str, email: str, href_prefix: str = "") -> str:
+    from xgit.stats import author_slug
+
+    slug = author_slug(name, email)
+    return f'<a class="author-link" href="{esc(href_prefix)}authors/{esc(slug)}.html">{esc(name)}</a>'
diff --git a/xgit/markdown.py b/xgit/markdown.py
new file mode 100644
index 0000000..59566c5
--- /dev/null
+++ b/xgit/markdown.py
@@ -0,0 +1,277 @@
+"""Small Markdown → HTML renderer (stdlib only).
+
+Covers common GitHub-flavored-ish constructs used in READMEs:
+headings, emphasis, links, images, fenced/indented code, lists,
+blockquotes, hr, tables, autolinks, and paragraphs.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+
+
+_MD_EXTS = {".md", ".markdown", ".mdown", ".mkd", ".mdx"}
+
+
+def is_markdown_path(path: str) -> bool:
+    lower = path.lower()
+    for ext in _MD_EXTS:
+        if lower.endswith(ext):
+            return True
+    # bare README / CHANGELOG often markdown-ish; only treat as md if content looks like it
+    return False
+
+
+def looks_like_markdown(text: str) -> bool:
+    """Heuristic for extensionless README-style files."""
+    sample = text.lstrip()[:4000]
+    if not sample:
+        return False
+    patterns = (
+        r"^#{1,6}\s+\S",
+        r"^```",
+        r"^\*\s+\S",
+        r"^-\s+\S",
+        r"^\d+\.\s+\S",
+        r"\[.+\]\(.+\)",
+        r"^>\s+\S",
+        r"^\|(.+\|)+",
+    )
+    return any(re.search(p, sample, re.M) for p in patterns)
+
+
+def render(text: str) -> str:
+    text = text.replace("\r\n", "\n").replace("\r", "\n")
+    if text.startswith("\ufeff"):
+        text = text[1:]
+    lines = text.split("\n")
+    out: list[str] = []
+    i = 0
+    n = len(lines)
+
+    while i < n:
+        line = lines[i]
+
+        # fenced code
+        fence = re.match(r"^(`{3,}|~{3,})(.*)$", line)
+        if fence:
+            mark = fence.group(1)[0]
+            fence_len = len(fence.group(1))
+            lang = fence.group(2).strip().split()[0] if fence.group(2).strip() else ""
+            i += 1
+            code_lines: list[str] = []
+            while i < n:
+                if re.match(rf"^{re.escape(mark * fence_len)}\s*$", lines[i]):
+                    i += 1
+                    break
+                code_lines.append(lines[i])
+                i += 1
+            cls = f' class="language-{html.escape(lang)}"' if lang else ""
+            out.append(
+                f"<pre><code{cls}>{html.escape(chr(10).join(code_lines))}</code></pre>"
+            )
+            continue
+
+        # hr
+        if re.match(r"^(\*{3,}|-{3,}|_{3,})\s*$", line):
+            out.append("<hr/>")
+            i += 1
+            continue
+
+        # heading
+        hm = re.match(r"^(#{1,6})\s+(.*)$", line)
+        if hm:
+            level = len(hm.group(1))
+            content = _inline(hm.group(2).rstrip("#").rstrip())
+            slug = _slug(hm.group(2))
+            out.append(f'<h{level} id="{html.escape(slug)}">{content}</h{level}>')
+            i += 1
+            continue
+
+        # blockquote
+        if line.startswith(">"):
+            quote_lines: list[str] = []
+            while i < n and lines[i].startswith(">"):
+                quote_lines.append(re.sub(r"^>\s?", "", lines[i]))
+                i += 1
+            inner = render("\n".join(quote_lines))
+            out.append(f"<blockquote>{inner}</blockquote>")
+            continue
+
+        # table
+        if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:-]+\|[\s|:-]*$", lines[i + 1]):
+            header = _split_table_row(line)
+            i += 2  # skip separator
+            rows: list[list[str]] = []
+            while i < n and "|" in lines[i] and lines[i].strip():
+                rows.append(_split_table_row(lines[i]))
+                i += 1
+            thead = "".join(f"<th>{_inline(c)}</th>" for c in header)
+            body_rows = []
+            for row in rows:
+                # pad/truncate to header width
+                cells = (row + [""] * len(header))[: len(header)]
+                body_rows.append("<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in cells) + "</tr>")
+            out.append(
+                "<table class=\"md-table\"><thead><tr>"
+                + thead
+                + "</tr></thead><tbody>"
+                + "".join(body_rows)
+                + "</tbody></table>"
+            )
+            continue
+
+        # unordered / ordered list
+        if re.match(r"^(\s*)([-*+]|\d+\.)\s+", line):
+            i = _render_list(lines, i, out)
+            continue
+
+        # blank
+        if not line.strip():
+            i += 1
+            continue
+
+        # paragraph
+        para: list[str] = []
+        while i < n and lines[i].strip():
+            if (
+                re.match(r"^(#{1,6})\s+", lines[i])
+                or re.match(r"^(`{3,}|~{3,})", lines[i])
+                or re.match(r"^(\*{3,}|-{3,}|_{3,})\s*$", lines[i])
+                or lines[i].startswith(">")
+                or re.match(r"^(\s*)([-*+]|\d+\.)\s+", lines[i])
+            ):
+                break
+            para.append(lines[i].rstrip())
+            i += 1
+        out.append(f"<p>{_inline(' '.join(para))}</p>")
+
+    return "\n".join(out)
+
+
+def _split_table_row(line: str) -> list[str]:
+    line = line.strip()
+    if line.startswith("|"):
+        line = line[1:]
+    if line.endswith("|"):
+        line = line[:-1]
+    return [c.strip() for c in line.split("|")]
+
+
+def _render_list(lines: list[str], start: int, out: list[str]) -> int:
+    i = start
+    n = len(lines)
+    first = re.match(r"^(\s*)([-*+]|\d+\.)\s+", lines[i])
+    assert first
+    ordered = first.group(2)[-1] == "."
+    tag = "ol" if ordered else "ul"
+    items: list[str] = []
+    while i < n:
+        m = re.match(r"^(\s*)([-*+]|\d+\.)\s+(.*)$", lines[i])
+        if not m:
+            break
+        if (m.group(2)[-1] == ".") != ordered:
+            break
+        item_bits = [m.group(3)]
+        i += 1
+        while i < n and lines[i].startswith("  ") and not re.match(
+            r"^(\s*)([-*+]|\d+\.)\s+", lines[i]
+        ):
+            item_bits.append(lines[i].strip())
+            i += 1
+        # nested list?
+        nested = ""
+        if i < n and re.match(r"^(\s+)([-*+]|\d+\.)\s+", lines[i]):
+            nest_out: list[str] = []
+            i = _render_list(lines, i, nest_out)
+            nested = "".join(nest_out)
+        items.append(f"<li>{_inline(' '.join(item_bits))}{nested}</li>")
+    out.append(f"<{tag}>{''.join(items)}</{tag}>")
+    return i
+
+
+def _slug(text: str) -> str:
+    text = re.sub(r"<[^>]+>", "", text)
+    text = text.strip().lower()
+    text = re.sub(r"[^\w\s-]", "", text)
+    text = re.sub(r"[-\s]+", "-", text)
+    return text or "section"
+
+
+def _inline(text: str) -> str:
+    """Process inline markdown. Order matters."""
+    # Extract code spans first to protect them
+    placeholders: list[str] = []
+
+    def hold(html_snip: str) -> str:
+        placeholders.append(html_snip)
+        return f"\x00{len(placeholders) - 1}\x00"
+
+    def code_repl(m: re.Match[str]) -> str:
+        return hold(f"<code>{html.escape(m.group(1))}</code>")
+
+    text = re.sub(r"`([^`]+)`", code_repl, text)
+
+    def img_repl(m: re.Match[str]) -> str:
+        alt, url = m.group(1), m.group(2)
+        return hold(
+            f'<img src="{html.escape(_safe_url(url), quote=True)}" alt="{html.escape(alt)}"/>'
+        )
+
+    text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", img_repl, text)
+
+    def link_repl(m: re.Match[str]) -> str:
+        label, url = m.group(1), m.group(2)
+        return hold(
+            f'<a href="{html.escape(_safe_url(url), quote=True)}">{_inline_format(label)}</a>'
+        )
+
+    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link_repl, text)
+
+    # autolink bare URLs
+    def auto_repl(m: re.Match[str]) -> str:
+        url = m.group(0)
+        return hold(f'<a href="{html.escape(_safe_url(url), quote=True)}">{html.escape(url)}</a>')
+
+    text = re.sub(r"https?://[^\s<>\)]+", auto_repl, text)
+
+    text = html.escape(text)
+    text = re.sub(
+        r"\x00(\d+)\x00",
+        lambda m: placeholders[int(m.group(1))],
+        text,
+    )
+    text = _inline_format_escaped(text)
+    return text
+
+
+def _inline_format(text: str) -> str:
+    """Inline format for link labels (already will be escaped by caller path)."""
+    text = html.escape(text)
+    return _inline_format_escaped(text)
+
+
+def _inline_format_escaped(text: str) -> str:
+    # Skip replacements inside tags by splitting
+    parts = re.split(r"(<[^>]+>)", text)
+    for idx, part in enumerate(parts):
+        if part.startswith("<"):
+            continue
+        part = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", part)
+        part = re.sub(r"__(.+?)__", r"<strong>\1</strong>", part)
+        part = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", part)
+        part = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"<em>\1</em>", part)
+        part = re.sub(r"~~(.+?)~~", r"<del>\1</del>", part)
+        parts[idx] = part
+    return "".join(parts)
+
+
+def _safe_url(url: str) -> str:
+    url = url.strip()
+    if re.match(r"^(https?|mailto|\/|\.\/|\.\.\/|#)", url, re.I):
+        return url
+    # disallow javascript: etc.
+    if ":" in url.split("/")[0]:
+        return "#"
+    return url
diff --git a/xgit/serve.py b/xgit/serve.py
new file mode 100644
index 0000000..c1f13ca
--- /dev/null
+++ b/xgit/serve.py
@@ -0,0 +1,35 @@
+"""Static file HTTP server."""
+
+from __future__ import annotations
+
+import functools
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+
+def serve(html_dir: Path, host: str = "0.0.0.0", port: int = 3121) -> None:
+    html_dir = html_dir.resolve()
+    if not html_dir.is_dir():
+        raise SystemExit(f"html dir missing: {html_dir} (run sync first)")
+
+    handler = functools.partial(QuietHandler, directory=str(html_dir))
+    httpd = ThreadingHTTPServer((host, port), handler)
+    print(f"xgit serving {html_dir} at http://{host}:{port}")
+    try:
+        httpd.serve_forever()
+    except KeyboardInterrupt:
+        print("\nstopped")
+    finally:
+        httpd.server_close()
+
+
+class QuietHandler(SimpleHTTPRequestHandler):
+    extensions_map = {
+        **SimpleHTTPRequestHandler.extensions_map,
+        ".html": "text/html; charset=utf-8",
+        ".css": "text/css; charset=utf-8",
+        ".xml": "application/atom+xml; charset=utf-8",
+    }
+
+    def log_message(self, fmt: str, *args) -> None:
+        print(f"{self.address_string()} - {fmt % args}")
diff --git a/xgit/static/app.js b/xgit/static/app.js
new file mode 100644
index 0000000..aa6136e
--- /dev/null
+++ b/xgit/static/app.js
@@ -0,0 +1,50 @@
+/* Copy-to-clipboard for clone commands and raw sources */
+(function () {
+  function label(btn, text) {
+    const prev = btn.getAttribute("data-label") || btn.textContent;
+    if (!btn.getAttribute("data-label")) btn.setAttribute("data-label", prev);
+    btn.textContent = text;
+    btn.classList.add("copied");
+    window.clearTimeout(btn._copyTimer);
+    btn._copyTimer = window.setTimeout(function () {
+      btn.textContent = btn.getAttribute("data-label") || "Copy";
+      btn.classList.remove("copied");
+    }, 1400);
+  }
+
+  async function write(text) {
+    if (navigator.clipboard && window.isSecureContext) {
+      await navigator.clipboard.writeText(text);
+      return;
+    }
+    const ta = document.createElement("textarea");
+    ta.value = text;
+    ta.setAttribute("readonly", "");
+    ta.style.position = "fixed";
+    ta.style.left = "-9999px";
+    document.body.appendChild(ta);
+    ta.select();
+    document.execCommand("copy");
+    document.body.removeChild(ta);
+  }
+
+  document.addEventListener("click", function (ev) {
+    const btn = ev.target.closest("[data-copy], [data-copy-target]");
+    if (!btn) return;
+    ev.preventDefault();
+    let text = btn.getAttribute("data-copy") || "";
+    const target = btn.getAttribute("data-copy-target");
+    if (target) {
+      const el = document.querySelector(target);
+      if (el) text = "value" in el ? el.value : el.textContent;
+    }
+    text = (text || "").replace(/\u00a0/g, " ");
+    write(text)
+      .then(function () {
+        label(btn, "Copied");
+      })
+      .catch(function () {
+        label(btn, "Failed");
+      });
+  });
+})();
diff --git a/xgit/static/style.css b/xgit/static/style.css
new file mode 100644
index 0000000..c3722ac
--- /dev/null
+++ b/xgit/static/style.css
@@ -0,0 +1,1056 @@
+/* xgit — Purple King · white + royal purple */
+
+:root {
+  --king: #6b21a8;
+  --king-deep: #4c1d7a;
+  --king-soft: #f3e8ff;
+  --king-mid: #a855f7;
+  --king-glow: rgba(107, 33, 168, 0.14);
+  --ink: #1e1230;
+  --muted: #6b5b7a;
+  --line: #ebe4f2;
+  --line-strong: #d8cce6;
+  --surface: #ffffff;
+  --surface-2: #fcfaff;
+  --hover: #f7f0ff;
+  --add: #15803d;
+  --del: #be123c;
+  --head: #5b21b6;
+  --radius: 14px;
+  --radius-sm: 10px;
+  --shadow: 0 1px 2px rgba(76, 29, 122, 0.04), 0 12px 32px rgba(76, 29, 122, 0.06);
+  --font: "Outfit", "Avenir Next", "Segoe UI", sans-serif;
+  --mono: "JetBrains Mono", "SF Mono", ui-monospace, monospace;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html {
+  -webkit-text-size-adjust: 100%;
+  scroll-behavior: smooth;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  color: var(--ink);
+  font-family: var(--font);
+  font-size: 15px;
+  line-height: 1.5;
+  background:
+    radial-gradient(1200px 600px at 10% -10%, rgba(168, 85, 247, 0.12), transparent 55%),
+    radial-gradient(900px 500px at 100% 0%, rgba(107, 33, 168, 0.08), transparent 50%),
+    linear-gradient(180deg, #ffffff 0%, #faf7ff 45%, #ffffff 100%);
+  background-attachment: fixed;
+}
+
+.bg-glow {
+  pointer-events: none;
+  position: fixed;
+  inset: auto -10% -20% auto;
+  width: min(520px, 70vw);
+  height: min(520px, 70vw);
+  border-radius: 50%;
+  background: radial-gradient(circle, rgba(168, 85, 247, 0.16), transparent 68%);
+  filter: blur(8px);
+  animation: drift 18s ease-in-out infinite alternate;
+  z-index: 0;
+}
+
+@keyframes drift {
+  from { transform: translate(-4%, 2%) scale(1); }
+  to { transform: translate(-12%, -6%) scale(1.08); }
+}
+
+.shell {
+  position: relative;
+  z-index: 1;
+  width: min(1080px, calc(100% - 2rem));
+  margin: 0 auto;
+  padding: 1.5rem 0 3rem;
+}
+
+/* —— Header —— */
+.top {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 1rem 1.5rem;
+  margin-bottom: 1.25rem;
+  animation: rise 0.55s ease both;
+}
+
+@keyframes rise {
+  from { opacity: 0; transform: translateY(8px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.brand {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.55rem;
+  text-decoration: none;
+  color: var(--ink);
+  padding: 0.35rem 0.55rem 0.35rem 0.35rem;
+  border-radius: 999px;
+  transition: background 0.2s ease, transform 0.2s ease;
+}
+
+.brand:hover {
+  background: var(--king-soft);
+  transform: translateY(-1px);
+}
+
+.brand-mark {
+  width: 2rem;
+  height: 2rem;
+  border-radius: 10px;
+  background:
+    linear-gradient(145deg, var(--king-mid) 0%, var(--king) 55%, var(--king-deep) 100%);
+  box-shadow: 0 6px 16px var(--king-glow);
+  position: relative;
+}
+
+.brand-mark::after {
+  content: "";
+  position: absolute;
+  inset: 28% 30% 34% 30%;
+  background: #fff;
+  clip-path: polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%);
+  opacity: 0.95;
+}
+
+.brand-text {
+  font-weight: 700;
+  font-size: 1.2rem;
+  letter-spacing: -0.02em;
+}
+
+.brand-tag {
+  font-size: 0.68rem;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--king);
+  background: var(--king-soft);
+  border: 1px solid #e9d5ff;
+  padding: 0.22rem 0.5rem;
+  border-radius: 999px;
+}
+
+.heading-block {
+  text-align: right;
+  min-width: 0;
+  flex: 1 1 12rem;
+}
+
+.page-title {
+  margin: 0;
+  font-size: clamp(1.35rem, 3vw, 1.85rem);
+  font-weight: 700;
+  letter-spacing: -0.03em;
+  line-height: 1.15;
+  color: var(--king-deep);
+}
+
+.subtitle {
+  margin: 0.25rem 0 0;
+  color: var(--muted);
+  font-size: 0.95rem;
+  overflow-wrap: anywhere;
+}
+
+/* —— Clone —— */
+.clone {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.55rem 0.75rem;
+  padding: 0.7rem 0.9rem;
+  margin-bottom: 0.9rem;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius-sm);
+  box-shadow: var(--shadow);
+  animation: rise 0.55s ease 0.05s both;
+}
+
+.clone-label {
+  font-size: 0.7rem;
+  font-weight: 700;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+  color: #fff;
+  background: var(--king);
+  padding: 0.28rem 0.5rem;
+  border-radius: 6px;
+}
+
+.clone code {
+  font-family: var(--mono);
+  font-size: 0.82rem;
+  color: var(--ink);
+  overflow-wrap: anywhere;
+  flex: 1 1 auto;
+}
+
+.copy-btn {
+  appearance: none;
+  border: 1px solid #e9d5ff;
+  background: var(--king-soft);
+  color: var(--king);
+  font-family: var(--font);
+  font-size: 0.72rem;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  padding: 0.35rem 0.7rem;
+  border-radius: 999px;
+  cursor: pointer;
+  transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
+}
+
+.copy-btn:hover {
+  background: var(--king);
+  color: #fff;
+  transform: translateY(-1px);
+}
+
+.copy-btn.copied {
+  background: var(--king);
+  color: #fff;
+  border-color: var(--king);
+}
+
+.copy-btn-toggle {
+  margin-left: 0.1rem;
+}
+
+.copy-source {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
+/* —— Nav —— */
+.nav {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.4rem;
+  margin: 0 0 1.25rem;
+  padding: 0.35rem;
+  background: rgba(255, 255, 255, 0.72);
+  border: 1px solid var(--line);
+  border-radius: 999px;
+  backdrop-filter: blur(8px);
+  box-shadow: var(--shadow);
+  animation: rise 0.55s ease 0.1s both;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+  scrollbar-width: none;
+}
+
+.nav::-webkit-scrollbar {
+  display: none;
+}
+
+.nav a {
+  flex: 0 0 auto;
+  text-decoration: none;
+  color: var(--muted);
+  font-weight: 600;
+  font-size: 0.88rem;
+  padding: 0.45rem 0.85rem;
+  border-radius: 999px;
+  transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
+}
+
+.nav a:hover {
+  color: var(--king);
+  background: var(--king-soft);
+}
+
+.nav a.active {
+  color: #fff;
+  background: linear-gradient(135deg, var(--king-mid), var(--king));
+  box-shadow: 0 6px 14px var(--king-glow);
+}
+
+/* —— Content —— */
+#content {
+  animation: rise 0.55s ease 0.15s both;
+}
+
+#content > h2 {
+  margin: 1.4rem 0 0.65rem;
+  font-size: 0.78rem;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: var(--king);
+}
+
+a {
+  color: var(--king);
+  text-decoration-thickness: 1px;
+  text-underline-offset: 0.15em;
+  transition: color 0.15s ease;
+}
+
+a:hover {
+  color: var(--king-deep);
+}
+
+a:target {
+  background: var(--king-soft);
+  border-radius: 4px;
+}
+
+/* —— Tables —— */
+#content table {
+  width: 100%;
+  border-collapse: separate;
+  border-spacing: 0;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+  overflow: hidden;
+}
+
+#content thead td {
+  font-size: 0.72rem;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--muted);
+  background: var(--surface-2);
+  border-bottom: 1px solid var(--line);
+  padding: 0.75rem 1rem;
+  white-space: nowrap;
+}
+
+#content tbody td {
+  padding: 0.8rem 1rem;
+  border-bottom: 1px solid var(--line);
+  vertical-align: top;
+}
+
+#content tbody tr:last-child td {
+  border-bottom: 0;
+}
+
+#branches tr:hover td,
+#tags tr:hover td,
+#index tr:hover td,
+#log tr:hover td,
+#files tr:hover td {
+  background: var(--hover);
+}
+
+#content table td {
+  white-space: nowrap;
+}
+
+#index tr td:nth-child(2),
+#tags tr td:nth-child(4),
+#branches tr td:nth-child(4),
+#log tr td:nth-child(2) {
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+
+#index tr td:first-child a,
+#files tr td:first-child a,
+#log tr td:nth-child(2) a {
+  font-weight: 600;
+  text-decoration: none;
+}
+
+#index tr td:first-child a:hover,
+#files tr td:first-child a:hover,
+#log tr td:nth-child(2) a:hover {
+  text-decoration: underline;
+}
+
+.desc {
+  color: var(--muted);
+}
+
+td.num {
+  text-align: right;
+  font-family: var(--mono);
+  font-size: 0.85rem;
+  font-variant-numeric: tabular-nums;
+}
+
+.A,
+td.A {
+  color: var(--add);
+  font-weight: 600;
+}
+
+.D,
+td.D {
+  color: var(--del);
+  font-weight: 600;
+}
+
+/* scroll wrapper feel on small screens */
+#content {
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+/* —— Code / blobs / diffs —— */
+pre {
+  font-family: var(--mono);
+  font-size: 0.82rem;
+  line-height: 1.55;
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+  padding: 1rem 1.1rem;
+  margin: 0.85rem 0;
+  overflow-x: auto;
+}
+
+#content > p {
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+  padding: 1rem 1.1rem;
+  margin: 0 0 0.85rem;
+}
+
+#content > p code {
+  font-family: var(--mono);
+  font-size: 0.85em;
+  background: var(--king-soft);
+  color: var(--king-deep);
+  padding: 0.1em 0.35em;
+  border-radius: 5px;
+}
+
+#blob {
+  tab-size: 4;
+}
+
+#blob a.line {
+  color: #a394b3;
+  text-decoration: none;
+  display: inline-block;
+  min-width: 2.6em;
+  text-align: right;
+  margin-right: 0.85em;
+  user-select: none;
+}
+
+#blob a.line:hover {
+  color: var(--king);
+}
+
+pre a.h {
+  color: var(--head);
+  text-decoration: none;
+}
+
+span.i,
+pre a.i {
+  color: var(--add);
+  text-decoration: none;
+}
+
+span.d,
+pre a.d {
+  color: var(--del);
+  text-decoration: none;
+}
+
+#diffstat {
+  margin-bottom: 0.85rem;
+}
+
+/* —— Footer —— */
+.foot {
+  margin-top: 2rem;
+  padding-top: 1rem;
+  border-top: 1px solid var(--line);
+  color: var(--muted);
+  font-size: 0.82rem;
+  display: flex;
+  justify-content: space-between;
+  gap: 0.5rem;
+}
+
+.foot span::before {
+  content: "";
+  display: inline-block;
+  width: 0.45rem;
+  height: 0.45rem;
+  margin-right: 0.45rem;
+  border-radius: 50%;
+  background: var(--king-mid);
+  box-shadow: 0 0 0 3px var(--king-soft);
+  vertical-align: middle;
+}
+
+/* —— File / markdown views —— */
+.file-head {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.75rem;
+  margin-bottom: 0.85rem;
+}
+
+.file-title {
+  margin: 0 !important;
+  font-size: 1rem !important;
+  letter-spacing: -0.01em !important;
+  text-transform: none !important;
+  color: var(--king-deep) !important;
+  font-family: var(--mono);
+  font-weight: 600 !important;
+  overflow-wrap: anywhere;
+}
+
+.view-toggle {
+  display: inline-flex;
+  gap: 0.25rem;
+  padding: 0.25rem;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: 999px;
+  box-shadow: var(--shadow);
+}
+
+.view-toggle a {
+  text-decoration: none;
+  font-size: 0.78rem;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--muted);
+  padding: 0.35rem 0.75rem;
+  border-radius: 999px;
+}
+
+.view-toggle a:hover {
+  color: var(--king);
+  background: var(--king-soft);
+}
+
+.view-toggle a.active {
+  color: #fff;
+  background: linear-gradient(135deg, var(--king-mid), var(--king));
+}
+
+.view-toggle a.download {
+  color: var(--king);
+}
+
+.view-toggle a.download:hover {
+  background: var(--king-soft);
+}
+
+.view-toggle .copy-btn {
+  border: 0;
+  background: transparent;
+  color: var(--muted);
+  padding: 0.35rem 0.75rem;
+  box-shadow: none;
+}
+
+.view-toggle .copy-btn:hover {
+  color: var(--king);
+  background: var(--king-soft);
+  transform: none;
+}
+
+.view-toggle .copy-btn.copied {
+  color: #fff;
+  background: linear-gradient(135deg, var(--king-mid), var(--king));
+}
+
+.badge {
+  display: inline-block;
+  margin-left: 0.45rem;
+  font-size: 0.65rem;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--king);
+  background: var(--king-soft);
+  border: 1px solid #e9d5ff;
+  padding: 0.12rem 0.4rem;
+  border-radius: 999px;
+  vertical-align: middle;
+}
+
+.markdown {
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+  padding: 1.35rem 1.5rem;
+  overflow-wrap: anywhere;
+}
+
+.markdown > :first-child {
+  margin-top: 0;
+}
+
+.markdown > :last-child {
+  margin-bottom: 0;
+}
+
+.markdown h1,
+.markdown h2,
+.markdown h3,
+.markdown h4,
+.markdown h5,
+.markdown h6 {
+  font-family: var(--font);
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  color: var(--king-deep);
+  margin: 1.35em 0 0.5em;
+  line-height: 1.25;
+  text-transform: none;
+}
+
+.markdown h1 { font-size: 1.7rem; }
+.markdown h2 {
+  font-size: 1.3rem;
+  padding-bottom: 0.3rem;
+  border-bottom: 1px solid var(--line);
+}
+.markdown h3 { font-size: 1.1rem; }
+.markdown h4 { font-size: 1rem; }
+
+.markdown p {
+  margin: 0.75em 0;
+}
+
+.markdown a {
+  font-weight: 500;
+}
+
+.markdown ul,
+.markdown ol {
+  margin: 0.75em 0;
+  padding-left: 1.4em;
+}
+
+.markdown li {
+  margin: 0.25em 0;
+}
+
+.markdown blockquote {
+  margin: 1em 0;
+  padding: 0.15em 0 0.15em 1em;
+  border-left: 3px solid var(--king-mid);
+  color: var(--muted);
+  background: linear-gradient(90deg, var(--king-soft), transparent);
+  border-radius: 0 8px 8px 0;
+}
+
+.markdown hr {
+  border: 0;
+  border-top: 1px solid var(--line);
+  margin: 1.5em 0;
+}
+
+.markdown code {
+  font-family: var(--mono);
+  font-size: 0.88em;
+  background: var(--king-soft);
+  color: var(--king-deep);
+  padding: 0.12em 0.4em;
+  border-radius: 5px;
+}
+
+.markdown pre {
+  margin: 1em 0;
+  padding: 1rem 1.1rem;
+  background: #1e1230;
+  color: #f3e8ff;
+  border: 0;
+  border-radius: var(--radius-sm);
+  box-shadow: none;
+  overflow-x: auto;
+}
+
+.markdown pre code {
+  background: transparent;
+  color: inherit;
+  padding: 0;
+  font-size: 0.84rem;
+}
+
+.markdown img {
+  max-width: 100%;
+  height: auto;
+  border-radius: 8px;
+}
+
+.markdown table.md-table {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 1em 0;
+  font-size: 0.92rem;
+  box-shadow: none;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.markdown table.md-table th,
+.markdown table.md-table td {
+  border: 1px solid var(--line);
+  padding: 0.55rem 0.75rem;
+  text-align: left;
+  white-space: normal;
+}
+
+.markdown table.md-table th {
+  background: var(--king-soft);
+  color: var(--king-deep);
+  font-weight: 700;
+}
+
+.markdown strong {
+  font-weight: 700;
+  color: var(--ink);
+}
+
+.view-toggle a.download {
+  color: var(--king);
+}
+
+.view-toggle a.download:hover {
+  background: var(--king-soft);
+}
+
+/* —— Site nav —— */
+.site-nav {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.35rem;
+  margin: 0 0 1rem;
+}
+
+.site-nav a {
+  text-decoration: none;
+  font-size: 0.82rem;
+  font-weight: 600;
+  color: var(--muted);
+  padding: 0.35rem 0.7rem;
+  border-radius: 999px;
+  border: 1px solid transparent;
+  transition: background 0.15s ease, color 0.15s ease;
+}
+
+.site-nav a:hover {
+  color: var(--king);
+  background: var(--king-soft);
+}
+
+.site-nav a.active {
+  color: var(--king-deep);
+  background: var(--king-soft);
+  border-color: #e9d5ff;
+}
+
+/* —— Stats / profiles —— */
+.stat-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
+  gap: 0.65rem;
+  margin: 0 0 1.25rem;
+}
+
+.stat {
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius-sm);
+  box-shadow: var(--shadow);
+  padding: 0.85rem 1rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.15rem;
+}
+
+.stat-link {
+  text-decoration: none;
+  color: inherit;
+}
+
+.stat-link:hover {
+  border-color: #e9d5ff;
+  background: var(--hover);
+}
+
+.stat-val {
+  font-size: 1.35rem;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  color: var(--king-deep);
+  font-variant-numeric: tabular-nums;
+}
+
+.stat-val.A { color: var(--add); }
+.stat-val.D { color: var(--del); }
+
+.stat-label {
+  font-size: 0.72rem;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--muted);
+}
+
+.profile-card {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  margin-bottom: 1rem;
+  padding: 1rem 1.1rem;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+}
+
+.avatar {
+  width: 3rem;
+  height: 3rem;
+  border-radius: 12px;
+  display: grid;
+  place-items: center;
+  font-weight: 700;
+  font-size: 1.25rem;
+  color: #fff;
+  background: linear-gradient(145deg, var(--king-mid), var(--king-deep));
+  box-shadow: 0 8px 18px var(--king-glow);
+}
+
+.profile-name {
+  margin: 0;
+  font-size: 1.2rem;
+  font-weight: 700;
+  color: var(--king-deep);
+}
+
+.profile-email {
+  margin: 0.15rem 0 0;
+  color: var(--muted);
+  font-family: var(--mono);
+  font-size: 0.85rem;
+}
+
+.bar-track {
+  height: 0.45rem;
+  background: var(--king-soft);
+  border-radius: 999px;
+  overflow: hidden;
+  margin: 0.5rem 0 1rem;
+}
+
+.bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, var(--king-mid), var(--king));
+  border-radius: 999px;
+}
+
+.leaderboard {
+  display: flex;
+  flex-direction: column;
+  gap: 0.55rem;
+  margin-bottom: 1.25rem;
+}
+
+.leader {
+  display: grid;
+  grid-template-columns: minmax(6rem, 12rem) 1fr auto;
+  gap: 0.65rem;
+  align-items: center;
+  padding: 0.55rem 0.75rem;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius-sm);
+}
+
+.leader .bar-track {
+  margin: 0;
+}
+
+.leader a {
+  font-weight: 600;
+  text-decoration: none;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.author-link {
+  font-weight: 600;
+  text-decoration: none;
+}
+
+.author-link:hover {
+  text-decoration: underline;
+}
+
+/* —— Syntax highlighting —— */
+pre.code.hl,
+#blob.code {
+  background: #1a1028;
+  color: #ebe4f5;
+  border-color: #2e2040;
+  padding: 0.85rem 0;
+  line-height: 1.55;
+}
+
+pre.code.hl code {
+  font-family: var(--mono);
+  font-size: 0.82rem;
+  display: block;
+}
+
+.code-line {
+  display: block;
+  padding: 0 1rem 0 0;
+}
+
+.code-line:hover {
+  background: rgba(168, 85, 247, 0.08);
+}
+
+pre.code.hl a.line,
+#blob.code a.line {
+  color: #7a6a8a;
+  min-width: 2.8em;
+  margin-right: 0.9em;
+}
+
+.tok-kw { color: #d4a5ff; font-weight: 600; }
+.tok-str { color: #f0c674; }
+.tok-comment { color: #8b7a9e; font-style: italic; }
+.tok-num { color: #7dd3a8; }
+.tok-fn { color: #8ec7ff; }
+
+#authors tr:hover td,
+#author-repos tr:hover td,
+#author-log tr:hover td,
+#stats-repos tr:hover td,
+#stats-authors tr:hover td {
+  background: var(--hover);
+}
+
+/* —— Responsive —— */
+@media (max-width: 820px) {
+  .shell {
+    width: min(100% - 1.25rem, 1080px);
+    padding-top: 1.1rem;
+  }
+
+  .top {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .heading-block {
+    text-align: left;
+    width: 100%;
+  }
+
+  .nav {
+    border-radius: var(--radius);
+    width: 100%;
+  }
+
+  #content thead td,
+  #content tbody td {
+    padding: 0.7rem 0.75rem;
+  }
+
+  /* stack-ish denser log on phones: hide less critical cols */
+  #log thead td:nth-child(3),
+  #log tbody td:nth-child(3),
+  #log thead td:nth-child(4),
+  #log tbody td:nth-child(4) {
+    display: none;
+  }
+
+  #index thead td:nth-child(3),
+  #index tbody td:nth-child(3) {
+    display: none;
+  }
+
+  #branches thead td:nth-child(3),
+  #branches tbody td:nth-child(3),
+  #tags thead td:nth-child(3),
+  #tags tbody td:nth-child(3) {
+    display: none;
+  }
+}
+
+@media (max-width: 520px) {
+  .brand-tag {
+    display: none;
+  }
+
+  .page-title {
+    font-size: 1.35rem;
+  }
+
+  #log thead td:nth-child(5),
+  #log tbody td:nth-child(5),
+  #log thead td:nth-child(6),
+  #log tbody td:nth-child(6) {
+    display: none;
+  }
+
+  #index thead td:nth-child(4),
+  #index tbody td:nth-child(4) {
+    display: none;
+  }
+
+  pre,
+  #content > p {
+    padding: 0.85rem;
+    border-radius: var(--radius-sm);
+  }
+
+  #blob a.line {
+    min-width: 2em;
+    margin-right: 0.55em;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    animation: none !important;
+    transition: none !important;
+  }
+}
diff --git a/xgit/stats.py b/xgit/stats.py
new file mode 100644
index 0000000..31cbec7
--- /dev/null
+++ b/xgit/stats.py
@@ -0,0 +1,154 @@
+"""Author identity + commit stats aggregation across repositories."""
+
+from __future__ import annotations
+
+import hashlib
+import re
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from xgit.gitutil import CommitInfo, DiffStatEntry
+
+
+def author_slug(name: str, email: str) -> str:
+    """Stable profile id: prefer email, else name."""
+    key = (email or "").strip().lower() or (name or "unknown").strip().lower()
+    slug = re.sub(r"[^a-z0-9]+", "-", key).strip("-")
+    if not slug:
+        slug = hashlib.sha1(key.encode()).hexdigest()[:12]
+    if len(slug) > 80:
+        slug = slug[:80].rstrip("-")
+    return slug
+
+
+@dataclass
+class CommitRef:
+    repo: str
+    hash: str
+    short: str
+    subject: str
+    date: datetime
+    additions: int
+    deletions: int
+    files: int
+
+
+@dataclass
+class AuthorProfile:
+    slug: str
+    name: str
+    email: str
+    commits: int = 0
+    additions: int = 0
+    deletions: int = 0
+    files_touched: int = 0
+    repos: dict[str, int] = field(default_factory=dict)
+    recent: list[CommitRef] = field(default_factory=list)
+    first_date: datetime | None = None
+    last_date: datetime | None = None
+
+    def add(
+        self,
+        *,
+        repo: str,
+        commit: CommitInfo,
+        stats: list[DiffStatEntry],
+        keep_recent: int = 40,
+    ) -> None:
+        adds = sum(s.additions for s in stats)
+        dels = sum(s.deletions for s in stats)
+        files_n = len(stats)
+        self.commits += 1
+        self.additions += adds
+        self.deletions += dels
+        self.files_touched += files_n
+        self.repos[repo] = self.repos.get(repo, 0) + 1
+        # Prefer non-empty newer display name
+        if commit.author_name:
+            self.name = commit.author_name
+        if commit.author_email:
+            self.email = commit.author_email
+        d = commit.author_date
+        if self.first_date is None or d < self.first_date:
+            self.first_date = d
+        if self.last_date is None or d > self.last_date:
+            self.last_date = d
+        self.recent.append(
+            CommitRef(
+                repo=repo,
+                hash=commit.hash,
+                short=commit.short,
+                subject=commit.subject,
+                date=d,
+                additions=adds,
+                deletions=dels,
+                files=files_n,
+            )
+        )
+        self.recent.sort(key=lambda c: c.date, reverse=True)
+        if len(self.recent) > keep_recent:
+            self.recent = self.recent[:keep_recent]
+
+
+@dataclass
+class RepoStats:
+    name: str
+    commits: int = 0
+    additions: int = 0
+    deletions: int = 0
+    authors: int = 0
+    files: int = 0
+
+
+@dataclass
+class SiteStats:
+    authors: dict[str, AuthorProfile] = field(default_factory=dict)
+    repos: dict[str, RepoStats] = field(default_factory=dict)
+    total_commits: int = 0
+    total_additions: int = 0
+    total_deletions: int = 0
+    total_files: int = 0
+
+    def ingest_repo(
+        self,
+        *,
+        repo_name: str,
+        commits: list[CommitInfo],
+        stats_by_hash: dict[str, list[DiffStatEntry]],
+        file_count: int,
+    ) -> None:
+        rs = RepoStats(name=repo_name, files=file_count)
+        seen_authors: set[str] = set()
+        for c in commits:
+            stats = stats_by_hash.get(c.hash) or []
+            adds = sum(s.additions for s in stats)
+            dels = sum(s.deletions for s in stats)
+            rs.commits += 1
+            rs.additions += adds
+            rs.deletions += dels
+            self.total_commits += 1
+            self.total_additions += adds
+            self.total_deletions += dels
+
+            slug = author_slug(c.author_name, c.author_email)
+            seen_authors.add(slug)
+            profile = self.authors.get(slug)
+            if profile is None:
+                profile = AuthorProfile(
+                    slug=slug,
+                    name=c.author_name or slug,
+                    email=c.author_email or "",
+                )
+                self.authors[slug] = profile
+            profile.add(repo=repo_name, commit=c, stats=stats)
+
+        rs.authors = len(seen_authors)
+        self.repos[repo_name] = rs
+        self.total_files += file_count
+
+    def ranked_authors(self) -> list[AuthorProfile]:
+        return sorted(
+            self.authors.values(),
+            key=lambda a: (a.commits, a.additions),
+            reverse=True,
+        )