pyrtan / sourcev0.14.0 · main

pyrtan / site / source.py

23,823 bytes · last changed 2026-10-27 in Lowercase the wordmark, and let tan's picker set light, dark or auto mode

1"""The source, as pyrtan.com shows it.
2
3 python3 site/source.py OUT [--branch main] [--max-commits 200]
4
5Writes a static browser of this checkout into OUT (the site's ``source/``):
6the files at HEAD, a page per directory and per file, README and Markdown
7rendered; the releases, one per commit that changed the version in
8``pyproject.toml``, each with its commit message as its notes and a
9tarball of the tree at that commit; the branches the checkout knows
10(``refs/remotes/origin/*``), each with what it holds that HEAD does not;
11and the history, a page per commit with its diff. Only git and the
12standard library; the Pages workflow checks out with full history so the
13branches and releases are all there. Commits show their author's name,
14not the address.
15"""
16
17from __future__ import annotations
18
19import argparse
20import html
21import posixpath
22import re
23import subprocess
24import sys
25import tomllib
26from pathlib import Path
27from urllib.parse import quote
28
29ROOT = Path(__file__).resolve().parents[1]
30CLONE = "https://github.com/b-i-t-8/pyrtan.git"
31MAX_BLOB = 1 << 20 # a file larger than this is listed, not shown
32MAX_PATCH = 200_000 # a commit's diff is cut here
33
34esc = html.escape
35
36
37def git(*args: str, check: bool = True) -> str:
38 return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, errors="replace", check=check).stdout
39
40
41def git_bytes(*args: str) -> bytes:
42 return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, check=True).stdout
43
44
45# -- pages ---------------------------------------------------------------
46
47CSS = """
48 :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9; --add:#e6f4ea; --del:#fbe9e7 }
49 @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 } }
50 :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 }
51 :root { --accent:var(--pyr) }
52 * { box-sizing:border-box }
53 body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif }
54 main { max-width:980px; margin:0 auto; padding:32px 16px 64px }
55 header { display:flex; flex-wrap:wrap; gap:6px 18px; align-items:baseline; margin:0 0 20px; padding-bottom:12px; border-bottom:1px solid var(--line) }
56 header b { font-size:18px } header nav { display:flex; flex-wrap:wrap; gap:4px 14px }
57 header nav a.on { color:var(--fg); font-weight:600; text-decoration:none }
58 h1 { font-size:22px; margin:0 0 6px; word-break:break-word } h2 { font-size:18px; margin:28px 0 8px }
59 a { color:var(--accent) } .mute { color:var(--mute) } .small { font-size:14px }
60 .pyr { color:var(--pyr) } .tan { color:var(--tan) }
61 :focus-visible { outline:2px solid var(--tan); outline-offset:2px }
62 ::selection { background:var(--tan); color:var(--tan-fg) }
63 code, pre { font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace }
64 code { background:var(--soft); padding:1px 5px; border-radius:4px }
65 pre { padding:12px 14px; background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:auto }
66 .box { background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:auto }
67 table { width:100%; border-collapse:collapse } td, th { text-align:left; padding:6px 10px; border-top:1px solid var(--line); vertical-align:top }
68 .box tr:first-child td { border-top:0 } th { color:var(--mute); font-weight:600; font-size:14px }
69 td.when { white-space:nowrap; color:var(--mute); font-size:14px; text-align:right }
70 td.msg { color:var(--mute); font-size:14px; max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
71 table.code td { border:0; padding:0 10px; white-space:pre; font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace }
72 table.code td.n { text-align:right; user-select:none; width:1%; color:var(--mute) } table.code td.n a { color:inherit; text-decoration:none }
73 table.code tr:target { background:var(--soft) }
74 .add { background:var(--add) } .del { background:var(--del) } .hunk { color:var(--mute) }
75 .notes { white-space:pre-wrap } .crumbs { font-size:15px; margin:0 0 12px; word-break:break-word }
76 .md { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 22px 16px; margin-top:20px }
77 .md table { margin:10px 0 } .md td, .md th { border:1px solid var(--line) } .md pre { background:var(--soft) }
78 .release { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:14px 18px; margin:0 0 14px }
79 .release h2 { margin:0 0 4px } .tag { display:inline-block; font-size:12px; padding:1px 8px; border-radius:10px; background:var(--soft); color:var(--mute); margin-left:6px }
80 details summary { cursor:pointer }
81 @media (max-width:640px) { td.msg { display:none } }
82"""
83
84
85class Site:
86 def __init__(self, out: Path, branch: str, version: str):
87 self.out, self.branch, self.version = out, branch, version
88
89 def link(self, cur: str, target: str) -> str:
90 """A relative href from page ``cur`` to page ``target`` (both under source/)."""
91 rel = posixpath.relpath(target, posixpath.dirname(cur) or ".")
92 return quote(rel, safe="/#.-_~")
93
94 def write(self, page: str, title: str, body: str, on: str = "") -> None:
95 nav = "".join(
96 f'<a href="{self.link(page, href)}"{" class=on" if key == on else ""}>{label}</a>'
97 for key, label, href in [("code", "Code", "index.html"), ("releases", "Releases", "releases.html"),
98 ("branches", "Branches", "branches.html"), ("history", "History", "commits.html")])
99 doc = f"""<!doctype html>
100<html lang="en">
101<head>
102<meta charset="utf-8">
103<meta name="viewport" content="width=device-width, initial-scale=1">
104<title>{esc(title)} · Pyrtan source</title>
105<meta name="theme-color" content="#c2410c">
106<script src="{self.link(page, '../theme.js')}"></script>
107<style>{CSS}</style>
108</head>
109<body>
110<main>
111<header><b><a href="{self.link(page, '../index.html')}"><span class="pyr">pyr</span><span class="tan">tan</span></a> / <a href="{self.link(page, 'index.html')}">source</a></b><nav>{nav}</nav><span class="mute small">v{esc(self.version)} · {esc(self.branch)}</span></header>
112{body}
113</main>
114</body>
115</html>
116"""
117 path = self.out / page
118 path.parent.mkdir(parents=True, exist_ok=True)
119 path.write_text(doc, encoding="utf-8")
120
121
122def blob_page(path: str) -> str:
123 return f"blob/{path}.html"
124
125
126def tree_page(path: str) -> str:
127 return f"tree/{path}/index.html" if path else "index.html"
128
129
130def commit_page(sha: str) -> str:
131 return f"commit/{sha}.html"
132
133
134# -- Markdown, enough for this repository's documents ---------------------
135
136def md_inline(text: str, resolve) -> str:
137 out, pos = [], 0
138 for m in re.finditer(r"`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*", text):
139 out.append(esc(text[pos:m.start()]))
140 if m.group(1) is not None:
141 out.append(f"<code>{esc(m.group(1))}</code>")
142 elif m.group(2) is not None:
143 out.append(f'<a href="{esc(resolve(m.group(3)), quote=True)}">{md_inline(m.group(2), resolve)}</a>')
144 else:
145 out.append(f"<b>{md_inline(m.group(4), resolve)}</b>")
146 pos = m.end()
147 out.append(esc(text[pos:]))
148 return "".join(out)
149
150
151def markdown(src: str, resolve) -> str:
152 lines, out, i = src.splitlines(), [], 0
153 para: list[str] = []
154
155 def flush():
156 if para:
157 out.append(f"<p>{md_inline(' '.join(para), resolve)}</p>")
158 para.clear()
159
160 while i < len(lines):
161 line = lines[i]
162 if line.startswith("```"):
163 flush()
164 j = i + 1
165 while j < len(lines) and not lines[j].startswith("```"):
166 j += 1
167 out.append(f"<pre>{esc(chr(10).join(lines[i + 1:j]))}</pre>")
168 i = j + 1
169 continue
170 if m := re.match(r"(#{1,6})\s+(.*)", line):
171 flush()
172 n = len(m.group(1))
173 out.append(f"<h{n}>{md_inline(m.group(2), resolve)}</h{n}>")
174 elif line.startswith("|"):
175 flush()
176 rows = []
177 while i < len(lines) and lines[i].startswith("|"):
178 cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
179 if not all(re.fullmatch(r":?-+:?", c) for c in cells if c):
180 rows.append(cells)
181 i += 1
182 out.append("<table>" + "".join("<tr>" + "".join(f"<td>{md_inline(c, resolve)}</td>" for c in r) + "</tr>" for r in rows) + "</table>")
183 continue
184 elif m := re.match(r"\s*([-*]|\d+\.)\s+(.*)", line):
185 flush()
186 tag = "ol" if m.group(1)[0].isdigit() else "ul"
187 items = []
188 while i < len(lines) and (m := re.match(r"\s*([-*]|\d+\.)\s+(.*)", lines[i])):
189 item = [m.group(2)]
190 i += 1
191 while i < len(lines) and lines[i].startswith(" ") and not re.match(r"\s*([-*]|\d+\.)\s+", lines[i]):
192 item.append(lines[i].strip())
193 i += 1
194 items.append(f"<li>{md_inline(' '.join(item), resolve)}</li>")
195 out.append(f"<{tag}>{''.join(items)}</{tag}>")
196 continue
197 elif not line.strip():
198 flush()
199 else:
200 para.append(line.strip())
201 i += 1
202 flush()
203 return "".join(out)
204
205
206# -- what git knows --------------------------------------------------------
207
208def files_at(ref: str) -> list[tuple[str, str, int]]:
209 """(path, blob sha, size) for every file at ``ref``."""
210 out = []
211 for rec in git("ls-tree", "-r", "-l", "-z", ref).split("\0"):
212 if not rec:
213 continue
214 meta, path = rec.split("\t", 1)
215 mode, kind, sha, size = meta.split()
216 if kind == "blob":
217 out.append((path, sha, int(size) if size.isdigit() else 0))
218 return out
219
220
221def last_commit(ref: str, path: str) -> tuple[str, str, str]:
222 """(sha, subject, date) of the last commit at ``ref`` that touched ``path``."""
223 rec = git("log", "-1", "--format=%H%x00%s%x00%as", ref, "--", path or ".").strip()
224 return tuple(rec.split("\0")) if rec else ("", "", "") # type: ignore[return-value]
225
226
227def commits(*revs: str, limit: int, paths: tuple[str, ...] = ()) -> list[dict]:
228 raw = git("log", f"-n{limit}", "--format=%H%x00%P%x00%an%x00%as%x00%s%x00%b%x1e", *revs, "--", *paths, check=False)
229 out = []
230 for rec in raw.split("\x1e"):
231 rec = rec.strip("\n")
232 if not rec:
233 continue
234 sha, parents, author, date, subject, body = rec.split("\0", 5)
235 out.append({"sha": sha, "parents": parents.split(), "author": author, "date": date, "subject": subject, "body": body.strip()})
236 return out
237
238
239def version_at(sha: str) -> str | None:
240 text = git("show", f"{sha}:pyproject.toml", check=False)
241 try:
242 return tomllib.loads(text)["project"]["version"]
243 except (tomllib.TOMLDecodeError, KeyError):
244 return None
245
246
247def releases(ref: str) -> list[dict]:
248 """The commits at ``ref`` that changed the project's version, newest first."""
249 shallow = set(_shallow())
250 out = []
251 for c in commits("--no-merges", ref, limit=1000, paths=("pyproject.toml",)):
252 if c["sha"] in shallow:
253 continue # its parent is not in this checkout; the change is unknowable
254 v = version_at(c["sha"])
255 before = version_at(c["parents"][0]) if c["parents"] else None
256 if v and v != before:
257 out.append({**c, "version": v})
258 return out
259
260
261def _shallow() -> list[str]:
262 path = ROOT / git("rev-parse", "--git-path", "shallow").strip()
263 return path.read_text().split() if path.is_file() else []
264
265
266def branches(default: str) -> list[str]:
267 """The remote branches, the default first."""
268 default = f"origin/{default}"
269 refs = git("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin").split()
270 return sorted((r for r in refs if r not in ("origin", "origin/HEAD")), key=lambda r: (r != default, r))
271
272
273# -- building --------------------------------------------------------------
274
275def notes(body: str, trailers: bool = True) -> str:
276 """A commit message's body as HTML: wrapped paragraphs joined, lists, indented lines and
277 trailers (``Co-Authored-By: ...``) kept line by line; ``trailers=False`` leaves trailers out."""
278 out = []
279 for para in re.split(r"\n\s*\n", body.strip()):
280 lines = para.splitlines()
281 if lines and all(re.match(r"[A-Za-z][\w-]*: \S", line) for line in lines):
282 if trailers:
283 out.append(f'<p class="notes mute small">{esc(para)}</p>')
284 elif any(re.match(r"\s|[-*] |\d+\. ", line) for line in lines):
285 out.append(f'<p class="notes">{esc(para)}</p>')
286 else:
287 out.append(f"<p>{esc(' '.join(line.strip() for line in lines))}</p>")
288 return "".join(out)
289
290
291def when(date: str) -> str:
292 return f'<td class="when">{esc(date)}</td>'
293
294
295def build(out: Path, branch: str, max_commits: int) -> int:
296 version = version_at("HEAD") or "?"
297 site = Site(out, branch, version)
298 files = files_at("HEAD")
299 head = git("rev-parse", "HEAD").strip()
300 shown: dict[str, dict] = {} # every commit that gets a page
301
302 # history: HEAD, then what each branch holds that HEAD does not
303 history = commits("HEAD", limit=max_commits)
304 for c in history:
305 shown[c["sha"]] = c
306 branch_rows = []
307 for ref in branches(branch):
308 name = ref.removeprefix("origin/")
309 behind, ahead = (int(n) for n in git("rev-list", "--left-right", "--count", f"HEAD...{ref}").split())
310 tip = commits(ref, limit=1)[0]
311 own = commits(f"HEAD..{ref}", limit=50)
312 for c in [tip, *own]:
313 shown.setdefault(c["sha"], c)
314 branch_rows.append((name, tip, ahead, behind, own))
315 rels = releases("HEAD")
316 for c in rels:
317 shown.setdefault(c["sha"], c)
318
319 def commit_link(cur: str, c: dict, text: str | None = None) -> str:
320 return f'<a href="{site.link(cur, commit_page(c["sha"]))}">{esc(text or c["subject"])}</a>'
321
322 def resolver(cur: str, base_dir: str):
323 known = {p for p, _, _ in files}
324 dirs = {posixpath.dirname(p) for p in known}
325 dirs |= {d for p in list(dirs) for d in _parents(p)}
326
327 def resolve(url: str) -> str:
328 if re.match(r"[a-z]+:|#", url):
329 return url
330 target, _, frag = url.partition("#")
331 target = posixpath.normpath(posixpath.join(base_dir, target)) if target else ""
332 target = "" if target == "." else target.rstrip("/")
333 if target in known:
334 return site.link(cur, blob_page(target)) + (f"#{frag}" if frag else "")
335 if target in dirs or target == "":
336 return site.link(cur, tree_page(target))
337 return url
338 return resolve
339
340 # Code: a page per directory, a page per file
341 tree: dict[str, list[tuple[str, bool]]] = {}
342 for path, _, _ in files:
343 parts = path.split("/")
344 for k in range(len(parts)):
345 parent = "/".join(parts[:k])
346 entry = ("/".join(parts[:k + 1]), k < len(parts) - 1)
347 tree.setdefault(parent, [])
348 if entry not in tree[parent]:
349 tree[parent].append(entry)
350 sizes = {p: (sha, size) for p, sha, size in files}
351
352 for d, entries in tree.items():
353 page = tree_page(d)
354 rows = []
355 if d:
356 up = posixpath.dirname(d)
357 rows.append(f'<tr><td><a href="{site.link(page, tree_page(up))}">..</a></td><td></td><td></td></tr>')
358 for path, is_dir in sorted(entries, key=lambda e: (not e[1], e[0].lower())):
359 sha, subject, date = last_commit("HEAD", path)
360 name = posixpath.basename(path) + ("/" if is_dir else "")
361 href = site.link(page, tree_page(path) if is_dir else blob_page(path))
362 msg = f'<a href="{site.link(page, commit_page(sha))}">{esc(subject)}</a>' if sha in shown else esc(subject)
363 rows.append(f'<tr><td><a href="{href}">{esc(name)}</a></td><td class="msg">{msg}</td>{when(date)}</tr>')
364 top = ""
365 if not d:
366 latest = rels[0] if rels else None
367 top = (f'<p class="mute small">{len(files)} files · {len(history)} commits · {len(branch_rows)} branches'
368 + (f' · latest release <a href="releases.html">v{esc(latest["version"])}</a>' if latest else "")
369 + (f' · last change {commit_link(page, history[0], history[0]["date"])}' if history else "")
370 + f'</p><pre>git clone {esc(CLONE)}</pre>')
371 crumbs = _crumbs(site, page, d)
372 readme = next((p for p, is_dir in entries if not is_dir and posixpath.basename(p).lower() == "readme.md"), None)
373 body = f'{crumbs}{top}<div class="box"><table>{"".join(rows)}</table></div>'
374 if readme:
375 text = git_bytes("cat-file", "blob", sizes[readme][0]).decode("utf-8", "replace")
376 body += f'<div class="md">{markdown(text, resolver(page, d))}</div>'
377 site.write(page, d or "Code", body, on="code")
378
379 for path, sha, size in files:
380 page = blob_page(path)
381 c_sha, subject, date = last_commit("HEAD", path)
382 meta = f'<p class="mute small">{size:,} bytes · last changed {date} in ' + (
383 f'<a href="{site.link(page, commit_page(c_sha))}">{esc(subject)}</a>' if c_sha in shown else esc(subject)) + "</p>"
384 if size > MAX_BLOB:
385 content = '<p class="mute">Too large to show here; it is in the clone.</p>'
386 else:
387 data = git_bytes("cat-file", "blob", sha)
388 if b"\0" in data[:8000]:
389 content = '<p class="mute">A binary file; it is in the clone.</p>'
390 else:
391 text = data.decode("utf-8", "replace")
392 if path.lower().endswith(".md"):
393 content = f'<div class="md">{markdown(text, resolver(page, posixpath.dirname(path)))}</div>'
394 else:
395 rows = "".join(f'<tr id="L{n}"><td class="n"><a href="#L{n}">{n}</a></td><td>{esc(line)}</td></tr>'
396 for n, line in enumerate(text.splitlines(), 1))
397 content = f'<div class="box"><table class="code">{rows}</table></div>'
398 site.write(page, path, _crumbs(site, page, path) + meta + content, on="code")
399
400 # Releases
401 page = "releases.html"
402 (out / "releases").mkdir(parents=True, exist_ok=True)
403 blocks = []
404 for k, r in enumerate(rels):
405 name = f"pyrtan-{r['version']}"
406 subprocess.run(["git", "archive", "--format=tar.gz", f"--prefix={name}/", "-o", str(out / "releases" / f"{name}.tar.gz"), r["sha"]],
407 cwd=ROOT, check=True)
408 blocks.append(f'<div class="release" id="v{esc(r["version"])}"><h2>v{esc(r["version"])}{"<span class=tag>latest</span>" if k == 0 else ""}</h2>'
409 f'<p class="mute small">{esc(r["date"])} · {commit_link(page, r, r["sha"][:7])} · '
410 f'<a href="releases/{quote(name)}.tar.gz">{esc(name)}.tar.gz</a></p>'
411 f'<p><b>{esc(r["subject"])}</b></p>{notes(r["body"], trailers=False)}</div>')
412 site.write(page, "Releases", "<h1>Releases</h1>" + ("".join(blocks) or '<p class="mute">None yet.</p>'), on="releases")
413
414 # Branches
415 page = "branches.html"
416 rows = []
417 for name, tip, ahead, behind, own in branch_rows:
418 mark = '<span class="tag">default</span>' if name == branch else ""
419 detail = ""
420 if own:
421 items = "".join(f'<tr><td>{commit_link(page, c)}</td><td class="mute small">{esc(c["author"])}</td>{when(c["date"])}</tr>' for c in own)
422 detail = f'<details><summary class="small">{len(own)} commit{"s" if len(own) != 1 else ""} not on {esc(branch)}</summary><table>{items}</table></details>'
423 rows.append(f'<tr><td><code>{esc(name)}</code>{mark}<div class="small">{commit_link(page, tip)}</div>{detail}</td>'
424 f'<td class="mute small">{ahead} ahead · {behind} behind</td>{when(tip["date"])}</tr>')
425 site.write(page, "Branches", f'<h1>Branches</h1><p class="mute small">Ahead and behind are counted against {esc(branch)} at {commit_link(page, history[0], head[:7]) if history else esc(head[:7])}.</p>'
426 f'<div class="box"><table>{"".join(rows) or "<tr><td class=mute>None in this checkout.</td></tr>"}</table></div>', on="branches")
427
428 # History, and a page per commit
429 page = "commits.html"
430 rows = "".join(f'<tr><td>{commit_link(page, c)}<div class="mute small">{esc(c["author"])} · <code>{c["sha"][:7]}</code></div></td>{when(c["date"])}</tr>' for c in history)
431 site.write(page, "History", f'<h1>History of {esc(branch)}</h1><div class="box"><table>{rows}</table></div>', on="history")
432
433 for sha, c in shown.items():
434 page = commit_page(sha)
435 parents = " ".join(f'<a href="{site.link(page, commit_page(p))}">{p[:7]}</a>' if p in shown else f"<code>{p[:7]}</code>" for p in c["parents"])
436 first = c["parents"][0] if c["parents"] else None
437 stat = git("diff", "--stat=100", first, sha, check=False) if first else git("show", "--stat=100", "--format=", sha)
438 patch = git("diff", first, sha, check=False) if first else git("show", "--format=", sha)
439 cut = len(patch) > MAX_PATCH
440 lines = []
441 for line in patch[:MAX_PATCH].splitlines():
442 cls = "hunk" if line.startswith("@@") else "add" if line.startswith("+") and not line.startswith("+++") else "del" if line.startswith("-") and not line.startswith("---") else ""
443 lines.append(f'<div class="{cls}">{esc(line) or " "}</div>' if cls else esc(line) + "\n")
444 body = (f'<h1>{esc(c["subject"])}</h1><p class="mute small">{esc(c["author"])} · {esc(c["date"])} · <code>{sha}</code>'
445 + (f" · parents {parents}" if parents else "") + "</p>"
446 + notes(c["body"])
447 + f"<h2>Changes</h2><pre>{esc(stat)}</pre><pre>{''.join(lines)}</pre>"
448 + ('<p class="mute">The diff is cut here; the rest is in the clone.</p>' if cut else ""))
449 site.write(page, c["subject"], body, on="history")
450
451 return len(files) + len(tree) + len(shown) + 3
452
453
454def _parents(path: str) -> list[str]:
455 parts = path.split("/")
456 return ["/".join(parts[:k]) for k in range(1, len(parts))]
457
458
459def _crumbs(site: Site, page: str, path: str) -> str:
460 parts = path.split("/") if path else []
461 links = [f'<a href="{site.link(page, "index.html")}">pyrtan</a>']
462 for k, part in enumerate(parts):
463 sub = "/".join(parts[:k + 1])
464 links.append(esc(part) if k == len(parts) - 1 else f'<a href="{site.link(page, tree_page(sub))}">{esc(part)}</a>')
465 return f'<p class="crumbs">{" / ".join(links)}</p>'
466
467
468def main(argv: list[str] | None = None) -> int:
469 ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
470 ap.add_argument("out", type=Path, help="where to write the browser (the site's source/)")
471 ap.add_argument("--branch", default="main", help="the name of the branch HEAD is (default main)")
472 ap.add_argument("--max-commits", type=int, default=200, help="how much of HEAD's history to show")
473 args = ap.parse_args(argv)
474 n = build(args.out, args.branch, args.max_commits)
475 print(f"wrote {args.out}: {n} pages")
476 return 0
477
478
479if __name__ == "__main__":
480 sys.exit(main())