Something went wrong. Try again.
A warm, tech-dystopian color scheme inspired by corrupted hardware and failing systems. digitalrust.katsuricata.com
theme color-scheme color-theme warm color
Something went wrong. Try again.
19 kB · 415 lines
Python
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416#!/usr/bin/env python3"""make-indexes.py — regenerate Digital-Rust-styled directory indexes for thekatsuricata.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 /<project>/index.html project: its releases, newest first /<project>/<ver>/index.html version: the downloadable files
A tiny ``.meta/<project>.json`` in the pages repo records each publishedversion (semver + publish date) so the indexes can sort releases by realsemver and show a human "released" date even after later releases land. The``--project``/``--version`` flags only stamp *this* release into the meta andguarantee its directory exists; everything else is derived by scanning thetree, 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 <full sha of the tagged commit> \ --source-url https://tangled.org/katsuricata.com/Sheaf
Python 3.9+, stdlib only."""from __future__ import annotations
import argparseimport htmlimport jsonimport reimport subprocessfrom dataclasses import dataclass, fieldfrom datetime import datetime, timezonefrom pathlib import Pathfrom 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)
# --------------------------------------------------------------------------@dataclassclass 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 = '<span class="sep"> / </span>' for label, href in crumb: if not first: parts.append(sep) parts.append(f'<a href="{esc(href)}">{esc(label)}</a>' 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"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="{esc(lede)}"><title>{esc(title)} — katsuricata.tngl.io</title><style>{CSS}</style></head><body><div class="wrap"><header class="crumb">{crumbs}<span class="cap">tangled pages</span></header><h1>{esc(title)}</h1><p class="lede">{lede}</p><hr class="rule">{body}<footer>served by <a href="https://tangled.org">tangled</a> pages ·built <time datetime="{datetime.now(timezone.utc).isoformat(timespec='seconds')}">{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</time></footer></div></body></html>"""
def render_root(projects: dict[str, Project], base_url: str) -> str: if not projects: body = '<p class="empty">Nothing published yet.</p>' 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' · <a class="note" href="{esc(p.source_url)}">source</a>' if p.source_url else "" ver_count = f"{len(versions)} release{'s' if len(versions) != 1 else ''}" latest_td = (f'<a href="/{esc(name)}/{esc(latest)}/">{esc(latest)}</a>' if latest else '<span class="empty">—</span>') rows.append( f'<tr><td class="name"><a href="/{esc(name)}/">{esc(name)}</a>{src}</td>' f'<td>{latest_td}</td>' f'<td class="size">{ver_count}</td>' f"</tr>") body = ('<div class="pane"><table class="dir">' "<thead><tr><th>project</th><th>latest</th><th>releases</th></tr></thead>" f"<tbody>{''.join(rows)}</tbody></table></div>") 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 = '<p class="empty">No releases yet.</p>' 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 = '<span class="badge">latest</span>' if i == 0 else "" rows.append( f'<tr><td class="name"><a href="/{esc(p.name)}/{esc(vname)}/">{esc(vname)}</a>{badge}</td>' f'<td class="date">{esc(fmt_date(date))}</td></tr>') body = ('<div class="pane"><table class="dir">' "<thead><tr><th>release</th><th>published</th></tr></thead>" f"<tbody>{''.join(rows)}</tbody></table></div>") src = f' · <a href="{esc(p.source_url)}">source & history</a>' 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 = '<p class="empty">No downloadable artifacts recorded here yet.</p>' 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'<p style="margin:.9rem 0 0"><a href="{esc(sums)}">{esc(sums)}</a>' f' · verify with <code>sha256sum -c {esc(sums)}</code></p>' 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'<tr><td class="name"><a href="{esc(f.name)}">{esc(f.name)}</a>' f'<span class="note">{note}</span></td>' f'<td class="size">{human_size(st.st_size)}</td></tr>') body = ('<div class="pane"><table class="dir">' "<thead><tr><th>file</th><th>size</th></tr></thead>" f"<tbody>{''.join(rows)}</tbody></table></div>{sums_link}") src = f' · <a href="{esc(p.source_url)}">source</a>' 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()