#!/usr/bin/env python3 """make-indexes.py — regenerate directory indexes for a Tangled Pages site after a release lands in the pages repo. Three tiers, all self-contained (no JS, inline CSS, no webfonts): /index.html root: every project + its versions //index.html project: its releases, newest first ///index.html version: the downloadable files A tiny ``.meta/.json`` in the pages repo records each published version (semver + publish date) so the indexes can sort releases by real semver and show a human "released" date even after later releases land. The ``--project``/``--version`` flags only stamp *this* release into the meta and guarantee its directory exists; everything else is derived by scanning the tree, so re-running on an existing checkout is idempotent. Usage (from .tangled/workflows/release.yml): python3 scripts/make-indexes.py \ --repo-root /workspace/repo/.release/pages-deploy \ --project myapp --version v1.0.0 \ --tag-sha \ --source-url https://tangled.org/example.com/MyApp \ --base-url https://example.tngl.io Python 3.9+, stdlib only. Styling is a single neutral dark theme baked into the CSS constant below — restyle by editing it in one place. """ from __future__ import annotations import argparse import html import json import re import subprocess from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Iterable # -------------------------------------------------------------------------- # Neutral dark theme. The pages repo has no build step, so the CSS is baked # in here; edit this one constant to restyle every generated page. CSS = r""" :root{ --bg:#14171c; --surface:#1a1e25; --well:#101318; --text:#e8eaed; --text-dim:rgba(232,234,237,.55); --border:#2b313c; --border-strong:#39404e; --accent:#d3e3fd; --link:#a8c7fa; --link-hover:#d3e3fd; --ok:#8fd18b; --mono:ui-monospace,'SF Mono','Cascadia Mono',Menlo,Consolas,monospace; } *{box-sizing:border-box} html{color-scheme:dark} body{margin:0;min-height:100vh;background:var(--bg);color:var(--text); font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; font-size:17px;line-height:1.6} .wrap{max-width:58rem;margin:0 auto;padding:3rem 1.25rem 6rem} header.crumb{border-bottom:1px solid var(--border-strong);padding-bottom:.6rem; margin-bottom:1.75rem;display:flex;flex-wrap:wrap;gap:.35rem;align-items:baseline} .crumb a{color:var(--link);text-decoration:none} .crumb a:hover{color:var(--link-hover)} .crumb .sep{color:var(--text-dim);user-select:none} .crumb .cap{margin-left:auto;font-family:var(--mono); font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:var(--text-dim)} h1{font-size:1.9rem;line-height:1.2;margin:.2em 0 .1em;color:var(--text); font-weight:700;letter-spacing:-.01em} h2{font-size:1.05rem;margin:2.2rem 0 .4rem;color:var(--text); font-family:var(--mono);font-weight:600; letter-spacing:.14em;text-transform:uppercase} p.lede{margin:.35rem 0 0;color:var(--text-dim);max-width:46rem} .rule{border:0;border-top:1px solid var(--border-strong);margin:2rem 0 1.4rem} a{color:var(--link)} a:hover{color:var(--link-hover)} code{font-family:var(--mono);font-size:.92em; background:var(--well);border:1px solid var(--border);border-radius:3px;padding:.08em .35em} /* index lists: a plain directory listing, not marketing cards */ table.dir{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums} .dir th{font-family:var(--mono);font-weight:600; font-size:.68rem;letter-spacing:.16em;text-transform:uppercase;color:var(--text-dim); text-align:left;padding:.4rem .75rem;border-bottom:2px solid var(--border-strong)} .dir td{padding:.5rem .75rem;border-bottom:1px solid var(--border);vertical-align:baseline} .dir td.size,.dir td.date{color:var(--text-dim);white-space:nowrap;text-align:right; font-family:var(--mono);font-size:.8rem} .dir tr:hover td{background:var(--well)} .dir .name a{font-weight:600} .dir .note{color:var(--text-dim);font-size:.85rem} .pane{border:1px solid var(--border);border-radius:6px;background:var(--surface); overflow:hidden} .pane table.dir td:first-child{padding-left:1.1rem} .pane table.dir th:first-child{padding-left:1.1rem} .badge{display:inline-block;font-family:var(--mono); font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--ok); border:1px solid var(--border);border-radius:3px;padding:.1rem .45rem;margin-left:.5rem} .empty{color:var(--text-dim);font-style:italic} footer{margin-top:3.5rem;padding-top:1rem;border-top:1px solid var(--border); font-family:var(--mono);font-size:.72rem; color:var(--text-dim);letter-spacing:.06em} footer a{color:var(--text-dim);text-decoration:underline;text-underline-offset:2px} footer a:hover{color:var(--link-hover)} """ # -------------------------------------------------------------------------- SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.+-]*)?$") def semver_key(name: str) -> tuple: m = SEMVER_RE.match(name) if not m: return (-1, name) return (tuple(int(g) for g in m.groups()), name) def human_size(n: int) -> str: units = ["B", "KiB", "MiB", "GiB"] v = float(n) for u in units: if v < 1024 or u == units[-1]: return f"{v:.1f} {u}" if u != "B" else f"{int(v)} B" v /= 1024 return f"{v:.1f} GiB" def esc(s: str) -> str: return html.escape(s, quote=True) def site_name(base_url: str) -> str: """'https://example.tngl.io' -> 'example.tngl.io'""" return re.sub(r"^https?://", "", base_url).rstrip("/") # -------------------------------------------------------------------------- @dataclass class Project: name: str source_url: str = "" # version-dir-name -> {"version": "v1.0.0", "date": "2026-08-12T.."} versions: dict = field(default_factory=dict) def load_meta(root: Path) -> dict[str, Project]: meta_dir = root / ".meta" out: dict[str, Project] = {} if not meta_dir.is_dir(): return out for f in sorted(meta_dir.glob("*.json")): try: d = json.loads(f.read_text()) except Exception: continue p = Project(name=d.get("name", f.stem), source_url=d.get("source_url", ""), versions=d.get("versions", {})) out[p.name] = p return out def save_meta(root: Path, projects: dict[str, Project]) -> None: meta_dir = root / ".meta" meta_dir.mkdir(exist_ok=True) for p in projects.values(): (meta_dir / f"{p.name}.json").write_text(json.dumps( {"name": p.name, "source_url": p.source_url, "versions": p.versions}, indent=2, sort_keys=True) + "\n") def scan_projects(root: Path, meta: dict[str, Project]) -> dict[str, Project]: """Fold on-disk project dirs into the loaded meta (disk wins for version list; meta supplies dates/source urls for previously published dirs). A child directory only counts as a project if it holds at least one semver-looking version dir — that keeps helper dirs like ``scripts/`` from being promoted as projects. """ for child in sorted(root.iterdir()): if not child.is_dir() or child.name.startswith("."): continue vdirs = [d for d in child.iterdir() if d.is_dir() and SEMVER_RE.match(d.name)] if not vdirs and child.name not in meta: continue proj = meta.setdefault(child.name, Project(name=child.name)) for vdir in sorted(vdirs): proj.versions.setdefault(vdir.name, {"version": vdir.name, "date": ""}) return meta def git_last_commit_iso(repo: Path, path: Path) -> str: try: out = subprocess.check_output( ["git", "log", "-1", "--format=%cI", "--", str(path.relative_to(repo))], cwd=repo, stderr=subprocess.DEVNULL) return out.decode().strip() except Exception: return "" def fmt_date(iso: str) -> str: if not iso: return "—" try: dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) except ValueError: return iso return dt.strftime("%Y-%m-%d") # -------------------------------------------------------------------------- def page(*, title: str, crumb: Iterable[tuple[str, str]], lede: str, body: str, site: str) -> str: parts = [] first = True sep = ' / ' for label, href in crumb: if not first: parts.append(sep) parts.append(f'{esc(label)}' if href else esc(label)) first = False crumbs = "".join(parts) # `lede` is trusted HTML (we build the source-url fragment ourselves); # only its dynamic interpolations are already esc()'d at the call site. return f""" {esc(title)} — {esc(site)}
{crumbs}tangled pages

{esc(title)}

{lede}


{body}
served by tangled pages · built
""" def render_root(projects: dict[str, Project], site: str) -> str: if not projects: body = '

Nothing published yet.

' else: rows = [] for name in sorted(projects): p = projects[name] versions = sorted(p.versions, key=semver_key, reverse=True) latest = versions[0] if versions else "" src = f' · source' if p.source_url else "" ver_count = f"{len(versions)} release{'s' if len(versions) != 1 else ''}" latest_td = (f'{esc(latest)}' if latest else '—') rows.append( f'{esc(name)}{src}' f'{latest_td}' f'{ver_count}' f"") body = ('
' "" f"{''.join(rows)}
projectlatestreleases
") return page( title=site, crumb=[(site, "")], lede=f"Release files for software published from this Tangled Pages site.", body=body, site=site) def render_project(p: Project, root: Path, site: str) -> str: versions = sorted(p.versions, key=semver_key, reverse=True) if not versions: body = '

No releases yet.

' else: rows = [] for i, vname in enumerate(versions): info = p.versions[vname] date = info.get("date") or git_last_commit_iso(root, root / p.name / vname) badge = 'latest' if i == 0 else "" rows.append( f'{esc(vname)}{badge}' f'{esc(fmt_date(date))}') body = ('
' "" f"{''.join(rows)}
releasepublished
") src = f' · source & history' if p.source_url else "" return page( title=p.name, crumb=[(site, "/"), (p.name, "")], lede=f"All published releases of {esc(p.name)}.{src}", body=body, site=site) def render_version(p: Project, vname: str, root: Path, site: str) -> str: vdir = root / p.name / vname files = [f for f in sorted(vdir.iterdir()) if f.is_file() and f.name != "index.html"] if not files: body = '

No downloadable artifacts recorded here yet.

' else: # Tangled Pages 404s extensionless paths, so checksum files are # published with an extension; accept any SHA256SUMS* name. sums = next((f.name for f in files if f.name.startswith("SHA256SUMS")), None) sums_link = (f'

{esc(sums)}' f' · verify with sha256sum -c {esc(sums)}

' if sums else "") rows = [] for f in files: st = f.stat() note = "" if f.name.endswith(".pkg.tar.zst"): note = " · pacman package" elif f.name.endswith("source.tar.gz"): note = " · source" elif f.name.endswith(".tar.gz"): note = " · tarball" elif f.name.endswith(".zip"): note = " · zip" elif "." not in f.name: # heads-up, not decoration: extensionless files 404 on Pages note = " · raw binary (no extension — won't download, L13)" rows.append( f'{esc(f.name)}' f'{note}' f'{human_size(st.st_size)}') body = ('
' "" f"{''.join(rows)}
filesize
{sums_link}") src = f' · source' if p.source_url else "" return page( title=f"{p.name} {vname}", crumb=[(site, "/"), (p.name, f"/{p.name}/"), (vname, "")], lede=f"Download files for {esc(p.name)} {esc(vname)}.{src}", body=body, site=site) # -------------------------------------------------------------------------- def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--repo-root", required=True, type=Path, help="pages repo checkout") ap.add_argument("--project", required=True, help="project dir just published (e.g. myapp)") ap.add_argument("--version", required=True, help="version dir just published (e.g. v1.0.0)") ap.add_argument("--tag-sha", default="", help="sha of the tagged commit (for provenance)") ap.add_argument("--source-url", default="", help="tangled repo url, recorded in meta") ap.add_argument("--base-url", required=True, help="public base URL of the pages site (e.g. https://example.tngl.io)") args = ap.parse_args() root: Path = args.repo_root site = site_name(args.base_url) now = datetime.now(timezone.utc).isoformat(timespec="seconds") meta = scan_projects(root, load_meta(root)) proj = meta.setdefault(args.project, Project(name=args.project)) # guarantee the just-published version dir exists (empty releases still # get an index page; CI has already copied artifacts into it) (root / proj.name / args.version).mkdir(parents=True, exist_ok=True) if args.source_url: proj.source_url = args.source_url proj.versions[args.version] = {"version": args.version, "date": now, "tag_sha": args.tag_sha} save_meta(root, meta) # write indexes; abs hrefs keep every page working at any depth (root / "index.html").write_text(render_root(meta, site)) for p in meta.values(): pdir = root / p.name pdir.mkdir(exist_ok=True) (pdir / "index.html").write_text(render_project(p, root, site)) for vname in p.versions: vdir = pdir / vname vdir.mkdir(parents=True, exist_ok=True) (vdir / "index.html").write_text(render_version(p, vname, root, site)) print(f"indexes written for {len(meta)} project(s); {args.project}/{args.version} stamped {now}") if __name__ == "__main__": main()