#!/usr/bin/env python3 """make-indexes.py — regenerate Digital-Rust-styled directory indexes for the katsuricata.tngl.io pages repo after a release lands. 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 /tangled/workspace/pages-deploy \ --project sheaf --version v1.0.0 \ --tag-sha \ --source-url https://tangled.org/katsuricata.com/Sheaf Python 3.9+, stdlib only. """ 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 # -------------------------------------------------------------------------- # Digital Rust tokens (trimmed excerpt of frontend/src/dr-tokens.css; the # pages repo has no build step, so these are baked in here). CSS = r""" :root{ --event-horizon:#110402; --null-pointer:#230905; --core-meltdown:#340d07; --bad-sector:#46120a; --dark-packet:#691b0f; --digital-rust:#af2e1a; --firewall-breach:#b74230; --fatal-error:#cf8175; --amber-alert:#bf5747; --phantom-current:#e7c0ba; --phosphor-smoke:#efd5d1; --ghost-pixel:#f7eae8; --memory-leek:#8fa667; --stack-overglow:#d4a759; --surface:var(--null-pointer); --text:var(--ghost-pixel); --text-dim:rgba(239,213,209,.55); --border:var(--dark-packet); --accent:var(--fatal-error); --well:rgba(70,18,10,.45); } *{box-sizing:border-box} html{color-scheme:dark} body{margin:0;min-height:100vh;background:var(--surface);color:var(--text); font-family:ui-serif,'Iowan Old Style','Source Serif 4',Charter,Cambria,Georgia,serif; font-size:17px;line-height:1.6; background-image:repeating-linear-gradient(0deg,rgba(175,46,26,.025) 0 1px,transparent 1px 3px)} .wrap{max-width:58rem;margin:0 auto;padding:3rem 1.25rem 6rem} header.crumb{border-bottom:2px solid var(--bad-sector);padding-bottom:.6rem; margin-bottom:1.75rem;display:flex;flex-wrap:wrap;gap:.35rem;align-items:baseline} .crumb a,.ver a,.file a{color:var(--phantom-current);text-decoration:none} .crumb a:hover,.ver a:hover,.file a:hover{color:var(--fatal-error)} .crumb .sep{color:var(--text-dim);user-select:none} .crumb .cap{margin-left:auto;font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace; 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(--accent); font-weight:700;letter-spacing:-.01em} h2{font-size:1.05rem;margin:2.2rem 0 .4rem;color:var(--phosphor-smoke); font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;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:2px solid var(--bad-sector);margin:2rem 0 1.4rem} a{color:var(--phantom-current)} a:hover{color:var(--fatal-error)} code{font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;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:'IBM Plex Mono','Space Mono',ui-monospace,monospace;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(--bad-sector)} .dir td{padding:.5rem .75rem;border-bottom:1px solid rgba(105,27,15,.5);vertical-align:baseline} .dir td.size,.dir td.date{color:var(--text-dim);white-space:nowrap;text-align:right; font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;font-size:.8rem} .dir tr:hover td{background:rgba(175,46,26,.05)} .dir .name a{font-weight:600} .dir .note{color:var(--text-dim);font-size:.85rem} .pane{border:2px solid var(--border);border-radius:4px;background:rgba(35,9,5,.6); box-shadow:3px 3px 0 0 var(--bad-sector),inset 0 1px 0 rgba(255,255,255,.04); 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:'IBM Plex Mono','Space Mono',ui-monospace,monospace; font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--memory-leek); border:1px solid rgba(143,166,103,.4);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 rgba(70,18,10,.6); font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;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(--fatal-error)} """ # -------------------------------------------------------------------------- SEMVER_RE = re.compile( r"^v?(\d+)\.(\d+)\.(\d+)" r"(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$") def _pre_key(pre: str) -> tuple: # Semver 2.0 §11: numeric identifiers compare numerically and rank below # alphanumeric ones (compared lexically in ASCII); a shorter identifier # set ranks below a longer one when all preceding identifiers are equal. return tuple((0, int(x), "") if x.isdigit() else (1, 0, x) for x in pre.split(".")) def semver_key(name: str) -> tuple: """Sort key implementing semver 2.0 precedence. A stable release ranks above any of its prereleases (flag 1 > 0 in the fourth slot); build metadata is ignored. Non-semver names fall back to sorting lowest, by name. """ m = SEMVER_RE.match(name) if not m: return (-1, name) major, minor, patch = int(m[1]), int(m[2]), int(m[3]) pre = m[4] if pre is None: return (0, major, minor, patch, 1, ()) return (0, major, minor, patch, 0, _pre_key(pre)) 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) # -------------------------------------------------------------------------- @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)) # prune meta versions whose dir no longer exists on disk so a # deleted version dir is not resurrected into the index for vname in list(proj.versions): if not (child / vname).is_dir(): del proj.versions[vname] for vdir in sorted(vdirs): proj.versions.setdefault(vdir.name, {"version": vdir.name, "date": ""}) # drop meta for projects whose directory disappeared entirely for name in [n for n, p in meta.items() if not (root / n).is_dir() and not p.versions]: del meta[name] 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, base_url: 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)} — katsuricata.tngl.io
{crumbs}tangled pages

{esc(title)}

{lede}


{body}
served by tangled pages · built
""" def render_root(projects: dict[str, Project], base_url: 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="katsuricata.tngl.io", crumb=[("katsuricata.tngl.io", "")], lede="Release files for software published from tangled.org/katsuricata.com.", body=body, base_url=base_url) def render_project(p: Project, root: Path, base_url: 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=[("katsuricata.tngl.io", "/"), (p.name, "")], lede=f"All published releases of {esc(p.name)}.{src}", body=body, base_url=base_url) def render_version(p: Project, vname: str, root: Path, base_url: 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=[("katsuricata.tngl.io", "/"), (p.name, f"/{p.name}/"), (vname, "")], lede=f"Download files for {esc(p.name)} {esc(vname)}.{src}", body=body, base_url=base_url) # -------------------------------------------------------------------------- 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. sheaf)") 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", default="https://katsuricata.tngl.io") args = ap.parse_args() root: Path = args.repo_root 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 existing = proj.versions.get(args.version) if existing is None: existing = proj.versions[args.version] = {"version": args.version, "date": ""} # genuinely new versions (no date yet) get stamped once; re-runs keep # the original release date and only refresh cheap provenance fields if not existing.get("date"): existing["date"] = now if args.tag_sha: existing["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, args.base_url)) for p in meta.values(): pdir = root / p.name pdir.mkdir(exist_ok=True) (pdir / "index.html").write_text(render_project(p, root, args.base_url)) 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, args.base_url)) print(f"indexes written for {len(meta)} project(s); {args.project}/{args.version} stamped {now}") if __name__ == "__main__": main()