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" <{esc(c.author_email)}><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, + )