| 1 | # hgweb-static |
| 2 | |
| 3 | Serve a directory of Mercurial repositories with `hg clone` support but a |
| 4 | fully static web rendering. |
| 5 | |
| 6 | This became necessary for me due to the volume of requests my hgweb UI was |
| 7 | receiving from AI scrapers. The static HTML is much faster to serve, though |
| 8 | doesn't have all the features of hgweb. Currently it generates a repo index |
| 9 | with dates and descriptions, a rendered README for each project, and source |
| 10 | viewer for the latest (tip) revision, with line numbering and syntax |
| 11 | highlighting. |
| 12 | |
| 13 | ## How it works |
| 14 | |
| 15 | While `hg` technically supports pulling a plain static repository served over |
| 16 | HTTP, this is less efficient than the Mercurial wire protocol, which e.g. |
| 17 | allows a hg client to discover and fetch only changesets that it doesn't |
| 18 | already have locally. |
| 19 | |
| 20 | The core "trick" used in this project is that a normal `hg clone`/`hg pull` |
| 21 | over HTTP always request to repo root with a `?cmd=` query parameter (i.e. the |
| 22 | "smart" protocol). The web server can be configured to route those requests to |
| 23 | a minimal hgweb instance, but serves everything else as static files |
| 24 | regenerated by a push hook. |
| 25 | |
| 26 | ## Components |
| 27 | |
| 28 | - `hgstatic.py`: the static site generator. Requires only `hg`; uses |
| 29 | `pygments` (highlighting), `markdown-it-py` or `markdown`, and `docutils` |
| 30 | (README rendering) when installed (falling back to `<pre>` without them). |
| 31 | Settings are read from the command line or from a `hgstatic.ini` (see below). |
| 32 | - `examples/hgweb_wsgi.py` + `examples/hgweb.config`: wire-protocol-only |
| 33 | hgweb, run under gunicorn (or any WSGI server). The config path comes |
| 34 | from `$HGWEB_CONFIG`. `examples/hgweb@.service` is a per-domain systemd |
| 35 | template for it. |
| 36 | - `examples/nginx.conf` / `examples/apache.conf`: the routing: `?cmd=` → |
| 37 | hgweb, everything else → static files. |
| 38 | |
| 39 | ## Setup |
| 40 | |
| 41 | Assumes repos in `/srv/hg` (nested directories are fine), static output in |
| 42 | `/var/www/hg`, site at `https://code.example.org`. |
| 43 | |
| 44 | 1. Install the generator and its optional deps: |
| 45 | |
| 46 | ```sh |
| 47 | install -m 755 hgstatic.py /usr/local/bin/hgstatic |
| 48 | apt install python3-pygments python3-markdown-it python3-docutils |
| 49 | ``` |
| 50 | |
| 51 | 2. Initial full generation (rerun any time to rebuild everything): |
| 52 | |
| 53 | ```sh |
| 54 | hgstatic --root /srv/hg --out /var/www/hg \ |
| 55 | --clone-base https://code.example.org --title "my repositories" |
| 56 | ``` |
| 57 | |
| 58 | 3. Add a hook to regenerate on push. Hooks run with the repo as cwd, so one |
| 59 | global snippet covers all repos, e.g. in `/etc/mercurial/hgrc.d/hgstatic.rc` |
| 60 | (or per-repo `.hg/hgrc`): |
| 61 | |
| 62 | ```ini |
| 63 | [hooks] |
| 64 | changegroup.hgstatic = hgstatic --only "$PWD" |
| 65 | ``` |
| 66 | |
| 67 | `--only` regenerates just the pushed repo plus the top-level index. |
| 68 | Given only `--only`, hgstatic locates an `hgstatic.ini` by walking up |
| 69 | from the repo (also checking `config/` subdirectories), and exits |
| 70 | quietly if there is none. The ini holds the settings from step 2: |
| 71 | |
| 72 | ```ini |
| 73 | [hgstatic] |
| 74 | root = /srv/hg |
| 75 | out = /var/www/hg |
| 76 | clone-base = https://code.example.org |
| 77 | title = my repositories |
| 78 | ``` |
| 79 | |
| 80 | With the ini in place, step 2's full run is just |
| 81 | `hgstatic -c /path/to/hgstatic.ini`. Command-line options can be used to |
| 82 | override ini values if necessary. |
| 83 | |
| 84 | 4. hgweb for the wire protocol: copy `examples/hgweb.config` to |
| 85 | `/etc/hgweb.config` and `examples/hgweb_wsgi.py` to `/opt/hgweb/`, then |
| 86 | run it as the user owning the repos: |
| 87 | |
| 88 | ```ini |
| 89 | # /etc/systemd/system/hgweb.service |
| 90 | [Unit] |
| 91 | Description=hgweb (Mercurial wire protocol) |
| 92 | After=network.target |
| 93 | |
| 94 | [Service] |
| 95 | User=hg |
| 96 | RuntimeDirectory=hgweb |
| 97 | ExecStart=/usr/bin/gunicorn --workers 2 \ |
| 98 | --bind unix:/run/hgweb/hgweb.sock --chdir /opt/hgweb \ |
| 99 | hgweb_wsgi:application |
| 100 | |
| 101 | [Install] |
| 102 | WantedBy=multi-user.target |
| 103 | ``` |
| 104 | |
| 105 | The app reads its config path from `$HGWEB_CONFIG` (default |
| 106 | `/etc/hgweb.config`). For several independent instances on one |
| 107 | machine, use the `examples/hgweb@.service` template instead. |
| 108 | |
| 109 | 5. Web server, either: |
| 110 | - nginx: adapt `examples/nginx.conf` (server name, TLS, paths). |
| 111 | - Apache: adapt `examples/apache.conf`; needs |
| 112 | `a2enmod rewrite proxy proxy_http`, and gunicorn bound to |
| 113 | `127.0.0.1:8420` instead of the unix socket. |
| 114 | |
| 115 | ### Sympl |
| 116 | |
| 117 | For a complete from-scratch walk-through on a [Sympl](https://sympl.io/) |
| 118 | host, including multiple domains on one machine, see |
| 119 | [docs/sympl.md](docs/sympl.md). |
| 120 | |
| 121 | Pushes happen over ssh. HTTP push is disabled in `hgweb.config`. |
| 122 | |
| 123 | ## Notes |
| 124 | |
| 125 | - Repos are listed newest-first; descriptions come from `web.description` |
| 126 | in each repo's `.hg/hgrc`. |
| 127 | - A `README`/`README.md`/`README.rst`/`README.txt` at the repo root is |
| 128 | rendered on the repo page. |
| 129 | - Binary files are listed but not rendered; text files over 512 KiB get a |
| 130 | placeholder page. |
| 131 | - Each repo is generated into a temp directory and swapped in with a |
| 132 | rename, so the live site never serves a half-written repo. |