pyrtan / sourcev0.13.0 · main

Browse the source on pyrtan.com instead of linking out to GitHub

Claude · 2026-10-27 · f0c735261bde443eba9f37d56115ad18a1bb4cfc · parents 6384f6b

site/source.py builds /source/ from the checkout's git: a page per directory and per file (README and Markdown rendered, line anchors on code), the releases (each commit that changed the version in pyproject.toml, its message as the notes, a tarball of the tree), the branches with what each holds that main does not, and the history with a page per commit and its diff. The pages workflow checks out with full history so every branch and release is there; the hourly build keeps the branches current. The landing page's "Source on GitHub" becomes "Browse the source", and the catalog and console footers point at /source/ too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jg3PznHuEYvaxmCdv1

Changes

 .github/workflows/pages.yml |   5 +-
 README.md                   |   3 +-
 site/build.sh               |   6 +-
 site/catalog/index.html     |   2 +-
 site/index.html             |   2 +-
 site/source.py              | 473 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pyrtan/page.py          |   2 +-
 tests/test_site_source.py   |  44 ++++++
 8 files changed, 530 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 80ca37e..f8454fe 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,6 +1,7 @@
# pyrtan.com: this repository's GitHub Pages site. On every push to main # and once an hour, build the landing page, the console (from this
-# checkout's pyrtan), the catalog (from examples/ and catalog/, through the
+# checkout's pyrtan), the source browser (files, releases, branches and
+# history from git), the catalog (from examples/ and catalog/, through the
# gate), the seed list and a snapshot of the directory and of the gap # reports as the seeds know them, and publish them. Set the # repository's Pages source to "GitHub Actions" and its custom domain to
@@ -29,6 +30,8 @@ jobs:
runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # every branch and the whole history, for /source
- uses: astral-sh/setup-uv@v5 - run: uv sync - run: uv run pytest -q diff --git a/README.md b/README.md index 3d63a8b..6766a77 100644 --- a/README.md +++ b/README.md
@@ -476,7 +476,8 @@ _meta/ the design record, the tree format reference, and the
deploy/ Dockerfile at the root; a compose stack for OpenMediaVault and a systemd unit site/ pyrtan.com: the landing page, the seed list, the catalog
- viewer, and the build the pages workflow publishes
+ viewer, the source browser (files, releases, branches and
+ history), and the build the pages workflow publishes
``` ## Status diff --git a/site/build.sh b/site/build.sh index fcc60bb..6a7a6ae 100755 --- a/site/build.sh +++ b/site/build.sh
@@ -1,14 +1,16 @@
#!/bin/sh # Assemble pyrtan.com into one static directory: the landing page, the
-# console, the catalog (built from examples/ and catalog/), the seed list. Run from the
+# console, the catalog (built from examples/ and catalog/), the seed list, the source. Run from the
# repository root with pyrtan installed (`uv run site/build.sh`); the Pages # workflow runs the same thing. set -eu out="${1:-out}" rm -rf "$out"
-mkdir -p "$out/console" "$out/catalog" "$out/rendezvous"
+mkdir -p "$out/console" "$out/catalog" "$out/rendezvous" "$out/source"
out="$(cd "$out" && pwd)" cp site/CNAME "$out/"
+# The source: files, releases, branches and history, from this checkout's git.
+python3 site/source.py "$out/source"
# The landing page carries the version of the pyrtan it was built with. version="$(pyrtan --version | cut -d' ' -f2)" sed "s/__VERSION__/$version/g" site/index.html > "$out/index.html" diff --git a/site/catalog/index.html b/site/catalog/index.html index 892c031..09a432d 100644 --- a/site/catalog/index.html +++ b/site/catalog/index.html
@@ -28,7 +28,7 @@
<p class="lede">Trees you can pull into your own swarm. Each is one TOML file, checked by the repository's tests, with the licence its author chose, under the namespace of whoever published it. <code>pyrtan pull NAMESPACE/NAME</code> verifies the file against <a href="index.json">index.json</a> before writing it. Where hubs that opted in have met a value a tree had no branch for, the <a href="../rendezvous/reports.json">gap reports</a> say so beside it.</p> <input type="search" id="q" placeholder="filter: weather, github, read-only…" autocomplete="off"> <div id="list"><p class="mute">Loading index.json…</p></div>
- <footer>To publish a tree: <code>pyrtan publish my-tree.toml --into &lt;checkout&gt; --owner &lt;your GitHub login&gt;</code>, then open a pull request; the gate checks it, a maintainer merges, and it is here on the next build. <a href="../">pyrtan.com</a> · <a href="https://github.com/b-i-t-8/pyrtan/tree/main/catalog">how</a> · <a href="https://github.com/b-i-t-8/pyrtan">source</a></footer>
+ <footer>To publish a tree: <code>pyrtan publish my-tree.toml --into &lt;checkout&gt; --owner &lt;your GitHub login&gt;</code>, then open a pull request; the gate checks it, a maintainer merges, and it is here on the next build. <a href="../">pyrtan.com</a> · <a href="../source/blob/catalog/README.md.html">how</a> · <a href="../source/">source</a></footer>
</main> <script> const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); diff --git a/site/index.html b/site/index.html index f86f25d..2169e21 100644 --- a/site/index.html +++ b/site/index.html
@@ -40,7 +40,7 @@
<a class="primary" href="console/">Open the console</a> <a href="catalog/">Browse the catalog</a> <a href="#directory">The directory</a>
- <a href="https://github.com/b-i-t-8/pyrtan">Source on GitHub</a>
+ <a href="source/">Browse the source</a>
</div> <pre>$ pyrtan run "what's the weather in Oslo" --trees examples diff --git a/site/source.py b/site/source.py new file mode 100644 index 0000000..c464e12 --- /dev/null +++ b/site/source.py
@@ -0,0 +1,473 @@
+"""The source, as pyrtan.com shows it.
+
+ python3 site/source.py OUT [--branch main] [--max-commits 200]
+
+Writes a static browser of this checkout into OUT (the site's ``source/``):
+the files at HEAD, a page per directory and per file, README and Markdown
+rendered; the releases, one per commit that changed the version in
+``pyproject.toml``, each with its commit message as its notes and a
+tarball of the tree at that commit; the branches the checkout knows
+(``refs/remotes/origin/*``), each with what it holds that HEAD does not;
+and the history, a page per commit with its diff. Only git and the
+standard library; the Pages workflow checks out with full history so the
+branches and releases are all there. Commits show their author's name,
+not the address.
+"""
+
+from __future__ import annotations
+
+import argparse
+import html
+import posixpath
+import re
+import subprocess
+import sys
+import tomllib
+from pathlib import Path
+from urllib.parse import quote
+
+ROOT = Path(__file__).resolve().parents[1]
+CLONE = "https://github.com/b-i-t-8/pyrtan.git"
+MAX_BLOB = 1 << 20 # a file larger than this is listed, not shown
+MAX_PATCH = 200_000 # a commit's diff is cut here
+
+esc = html.escape
+
+
+def git(*args: str, check: bool = True) -> str:
+ return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, errors="replace", check=check).stdout
+
+
+def git_bytes(*args: str) -> bytes:
+ return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, check=True).stdout
+
+
+# -- pages ---------------------------------------------------------------
+
+CSS = """
+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --accent:#2b5db8; --soft:#f1efe9; --add:#e6f4ea; --del:#fbe9e7 }
+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --accent:#7fa6f0; --soft:#222420; --add:#16301f; --del:#3a1d19 } }
+ * { box-sizing:border-box }
+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif }
+ main { max-width:980px; margin:0 auto; padding:32px 16px 64px }
+ 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) }
+ header b { font-size:18px } header nav { display:flex; flex-wrap:wrap; gap:4px 14px }
+ header nav a.on { color:var(--fg); font-weight:600; text-decoration:none }
+ h1 { font-size:22px; margin:0 0 6px; word-break:break-word } h2 { font-size:18px; margin:28px 0 8px }
+ a { color:var(--accent) } .mute { color:var(--mute) } .small { font-size:14px }
+ code, pre { font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace }
+ code { background:var(--soft); padding:1px 5px; border-radius:4px }
+ pre { padding:12px 14px; background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:auto }
+ .box { background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:auto }
+ table { width:100%; border-collapse:collapse } td, th { text-align:left; padding:6px 10px; border-top:1px solid var(--line); vertical-align:top }
+ .box tr:first-child td { border-top:0 } th { color:var(--mute); font-weight:600; font-size:14px }
+ td.when { white-space:nowrap; color:var(--mute); font-size:14px; text-align:right }
+ td.msg { color:var(--mute); font-size:14px; max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
+ table.code td { border:0; padding:0 10px; white-space:pre; font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace }
+ 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 }
+ table.code tr:target { background:var(--soft) }
+ .add { background:var(--add) } .del { background:var(--del) } .hunk { color:var(--mute) }
+ .notes { white-space:pre-wrap } .crumbs { font-size:15px; margin:0 0 12px; word-break:break-word }
+ .md { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 22px 16px; margin-top:20px }
+ .md table { margin:10px 0 } .md td, .md th { border:1px solid var(--line) } .md pre { background:var(--soft) }
+ .release { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:14px 18px; margin:0 0 14px }
+ .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 }
+ details summary { cursor:pointer }
+ @media (max-width:640px) { td.msg { display:none } }
+"""
+
+
+class Site:
+ def __init__(self, out: Path, branch: str, version: str):
+ self.out, self.branch, self.version = out, branch, version
+
+ def link(self, cur: str, target: str) -> str:
+ """A relative href from page ``cur`` to page ``target`` (both under source/)."""
+ rel = posixpath.relpath(target, posixpath.dirname(cur) or ".")
+ return quote(rel, safe="/#.-_~")
+
+ def write(self, page: str, title: str, body: str, on: str = "") -> None:
+ nav = "".join(
+ f'<a href="{self.link(page, href)}"{" class=on" if key == on else ""}>{label}</a>'
+ for key, label, href in [("code", "Code", "index.html"), ("releases", "Releases", "releases.html"),
+ ("branches", "Branches", "branches.html"), ("history", "History", "commits.html")])
+ doc = f"""<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{esc(title)} · Pyrtan source</title>
+<style>{CSS}</style>
+</head>
+<body>
+<main>
+<header><b><a href="{self.link(page, '../index.html')}">Pyrtan</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>
+{body}
+</main>
+</body>
+</html>
+"""
+ path = self.out / page
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(doc, encoding="utf-8")
+
+
+def blob_page(path: str) -> str:
+ return f"blob/{path}.html"
+
+
+def tree_page(path: str) -> str:
+ return f"tree/{path}/index.html" if path else "index.html"
+
+
+def commit_page(sha: str) -> str:
+ return f"commit/{sha}.html"
+
+
+# -- Markdown, enough for this repository's documents ---------------------
+
+def md_inline(text: str, resolve) -> str:
+ out, pos = [], 0
+ for m in re.finditer(r"`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*", text):
+ out.append(esc(text[pos:m.start()]))
+ if m.group(1) is not None:
+ out.append(f"<code>{esc(m.group(1))}</code>")
+ elif m.group(2) is not None:
+ out.append(f'<a href="{esc(resolve(m.group(3)), quote=True)}">{md_inline(m.group(2), resolve)}</a>')
+ else:
+ out.append(f"<b>{md_inline(m.group(4), resolve)}</b>")
+ pos = m.end()
+ out.append(esc(text[pos:]))
+ return "".join(out)
+
+
+def markdown(src: str, resolve) -> str:
+ lines, out, i = src.splitlines(), [], 0
+ para: list[str] = []
+
+ def flush():
+ if para:
+ out.append(f"<p>{md_inline(' '.join(para), resolve)}</p>")
+ para.clear()
+
+ while i < len(lines):
+ line = lines[i]
+ if line.startswith("```"):
+ flush()
+ j = i + 1
+ while j < len(lines) and not lines[j].startswith("```"):
+ j += 1
+ out.append(f"<pre>{esc(chr(10).join(lines[i + 1:j]))}</pre>")
+ i = j + 1
+ continue
+ if m := re.match(r"(#{1,6})\s+(.*)", line):
+ flush()
+ n = len(m.group(1))
+ out.append(f"<h{n}>{md_inline(m.group(2), resolve)}</h{n}>")
+ elif line.startswith("|"):
+ flush()
+ rows = []
+ while i < len(lines) and lines[i].startswith("|"):
+ cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
+ if not all(re.fullmatch(r":?-+:?", c) for c in cells if c):
+ rows.append(cells)
+ i += 1
+ out.append("<table>" + "".join("<tr>" + "".join(f"<td>{md_inline(c, resolve)}</td>" for c in r) + "</tr>" for r in rows) + "</table>")
+ continue
+ elif m := re.match(r"\s*([-*]|\d+\.)\s+(.*)", line):
+ flush()
+ tag = "ol" if m.group(1)[0].isdigit() else "ul"
+ items = []
+ while i < len(lines) and (m := re.match(r"\s*([-*]|\d+\.)\s+(.*)", lines[i])):
+ item = [m.group(2)]
+ i += 1
+ while i < len(lines) and lines[i].startswith(" ") and not re.match(r"\s*([-*]|\d+\.)\s+", lines[i]):
+ item.append(lines[i].strip())
+ i += 1
+ items.append(f"<li>{md_inline(' '.join(item), resolve)}</li>")
+ out.append(f"<{tag}>{''.join(items)}</{tag}>")
+ continue
+ elif not line.strip():
+ flush()
+ else:
+ para.append(line.strip())
+ i += 1
+ flush()
+ return "".join(out)
+
+
+# -- what git knows --------------------------------------------------------
+
+def files_at(ref: str) -> list[tuple[str, str, int]]:
+ """(path, blob sha, size) for every file at ``ref``."""
+ out = []
+ for rec in git("ls-tree", "-r", "-l", "-z", ref).split("\0"):
+ if not rec:
+ continue
+ meta, path = rec.split("\t", 1)
+ mode, kind, sha, size = meta.split()
+ if kind == "blob":
+ out.append((path, sha, int(size) if size.isdigit() else 0))
+ return out
+
+
+def last_commit(ref: str, path: str) -> tuple[str, str, str]:
+ """(sha, subject, date) of the last commit at ``ref`` that touched ``path``."""
+ rec = git("log", "-1", "--format=%H%x00%s%x00%as", ref, "--", path or ".").strip()
+ return tuple(rec.split("\0")) if rec else ("", "", "") # type: ignore[return-value]
+
+
+def commits(*revs: str, limit: int, paths: tuple[str, ...] = ()) -> list[dict]:
+ raw = git("log", f"-n{limit}", "--format=%H%x00%P%x00%an%x00%as%x00%s%x00%b%x1e", *revs, "--", *paths, check=False)
+ out = []
+ for rec in raw.split("\x1e"):
+ rec = rec.strip("\n")
+ if not rec:
+ continue
+ sha, parents, author, date, subject, body = rec.split("\0", 5)
+ out.append({"sha": sha, "parents": parents.split(), "author": author, "date": date, "subject": subject, "body": body.strip()})
+ return out
+
+
+def version_at(sha: str) -> str | None:
+ text = git("show", f"{sha}:pyproject.toml", check=False)
+ try:
+ return tomllib.loads(text)["project"]["version"]
+ except (tomllib.TOMLDecodeError, KeyError):
+ return None
+
+
+def releases(ref: str) -> list[dict]:
+ """The commits at ``ref`` that changed the project's version, newest first."""
+ shallow = set(_shallow())
+ out = []
+ for c in commits("--no-merges", ref, limit=1000, paths=("pyproject.toml",)):
+ if c["sha"] in shallow:
+ continue # its parent is not in this checkout; the change is unknowable
+ v = version_at(c["sha"])
+ before = version_at(c["parents"][0]) if c["parents"] else None
+ if v and v != before:
+ out.append({**c, "version": v})
+ return out
+
+
+def _shallow() -> list[str]:
+ path = ROOT / git("rev-parse", "--git-path", "shallow").strip()
+ return path.read_text().split() if path.is_file() else []
+
+
+def branches(default: str) -> list[str]:
+ """The remote branches, the default first."""
+ default = f"origin/{default}"
+ refs = git("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin").split()
+ return sorted((r for r in refs if r not in ("origin", "origin/HEAD")), key=lambda r: (r != default, r))
+
+
+# -- building --------------------------------------------------------------
+
+def notes(body: str, trailers: bool = True) -> str:
+ """A commit message's body as HTML: wrapped paragraphs joined, lists, indented lines and
+ trailers (``Co-Authored-By: ...``) kept line by line; ``trailers=False`` leaves trailers out."""
+ out = []
+ for para in re.split(r"\n\s*\n", body.strip()):
+ lines = para.splitlines()
+ if lines and all(re.match(r"[A-Za-z][\w-]*: \S", line) for line in lines):
+ if trailers:
+ out.append(f'<p class="notes mute small">{esc(para)}</p>')
+ elif any(re.match(r"\s|[-*] |\d+\. ", line) for line in lines):
+ out.append(f'<p class="notes">{esc(para)}</p>')
+ else:
+ out.append(f"<p>{esc(' '.join(line.strip() for line in lines))}</p>")
+ return "".join(out)
+
+
+def when(date: str) -> str:
+ return f'<td class="when">{esc(date)}</td>'
+
+
+def build(out: Path, branch: str, max_commits: int) -> int:
+ version = version_at("HEAD") or "?"
+ site = Site(out, branch, version)
+ files = files_at("HEAD")
+ head = git("rev-parse", "HEAD").strip()
+ shown: dict[str, dict] = {} # every commit that gets a page
+
+ # history: HEAD, then what each branch holds that HEAD does not
+ history = commits("HEAD", limit=max_commits)
+ for c in history:
+ shown[c["sha"]] = c
+ branch_rows = []
+ for ref in branches(branch):
+ name = ref.removeprefix("origin/")
+ behind, ahead = (int(n) for n in git("rev-list", "--left-right", "--count", f"HEAD...{ref}").split())
+ tip = commits(ref, limit=1)[0]
+ own = commits(f"HEAD..{ref}", limit=50)
+ for c in [tip, *own]:
+ shown.setdefault(c["sha"], c)
+ branch_rows.append((name, tip, ahead, behind, own))
+ rels = releases("HEAD")
+ for c in rels:
+ shown.setdefault(c["sha"], c)
+
+ def commit_link(cur: str, c: dict, text: str | None = None) -> str:
+ return f'<a href="{site.link(cur, commit_page(c["sha"]))}">{esc(text or c["subject"])}</a>'
+
+ def resolver(cur: str, base_dir: str):
+ known = {p for p, _, _ in files}
+ dirs = {posixpath.dirname(p) for p in known}
+ dirs |= {d for p in list(dirs) for d in _parents(p)}
+
+ def resolve(url: str) -> str:
+ if re.match(r"[a-z]+:|#", url):
+ return url
+ target, _, frag = url.partition("#")
+ target = posixpath.normpath(posixpath.join(base_dir, target)) if target else ""
+ target = "" if target == "." else target.rstrip("/")
+ if target in known:
+ return site.link(cur, blob_page(target)) + (f"#{frag}" if frag else "")
+ if target in dirs or target == "":
+ return site.link(cur, tree_page(target))
+ return url
+ return resolve
+
+ # Code: a page per directory, a page per file
+ tree: dict[str, list[tuple[str, bool]]] = {}
+ for path, _, _ in files:
+ parts = path.split("/")
+ for k in range(len(parts)):
+ parent = "/".join(parts[:k])
+ entry = ("/".join(parts[:k + 1]), k < len(parts) - 1)
+ tree.setdefault(parent, [])
+ if entry not in tree[parent]:
+ tree[parent].append(entry)
+ sizes = {p: (sha, size) for p, sha, size in files}
+
+ for d, entries in tree.items():
+ page = tree_page(d)
+ rows = []
+ if d:
+ up = posixpath.dirname(d)
+ rows.append(f'<tr><td><a href="{site.link(page, tree_page(up))}">..</a></td><td></td><td></td></tr>')
+ for path, is_dir in sorted(entries, key=lambda e: (not e[1], e[0].lower())):
+ sha, subject, date = last_commit("HEAD", path)
+ name = posixpath.basename(path) + ("/" if is_dir else "")
+ href = site.link(page, tree_page(path) if is_dir else blob_page(path))
+ msg = f'<a href="{site.link(page, commit_page(sha))}">{esc(subject)}</a>' if sha in shown else esc(subject)
+ rows.append(f'<tr><td><a href="{href}">{esc(name)}</a></td><td class="msg">{msg}</td>{when(date)}</tr>')
+ top = ""
+ if not d:
+ latest = rels[0] if rels else None
+ top = (f'<p class="mute small">{len(files)} files · {len(history)} commits · {len(branch_rows)} branches'
+ + (f' · latest release <a href="releases.html">v{esc(latest["version"])}</a>' if latest else "")
+ + (f' · last change {commit_link(page, history[0], history[0]["date"])}' if history else "")
+ + f'</p><pre>git clone {esc(CLONE)}</pre>')
+ crumbs = _crumbs(site, page, d)
+ readme = next((p for p, is_dir in entries if not is_dir and posixpath.basename(p).lower() == "readme.md"), None)
+ body = f'{crumbs}{top}<div class="box"><table>{"".join(rows)}</table></div>'
+ if readme:
+ text = git_bytes("cat-file", "blob", sizes[readme][0]).decode("utf-8", "replace")
+ body += f'<div class="md">{markdown(text, resolver(page, d))}</div>'
+ site.write(page, d or "Code", body, on="code")
+
+ for path, sha, size in files:
+ page = blob_page(path)
+ c_sha, subject, date = last_commit("HEAD", path)
+ meta = f'<p class="mute small">{size:,} bytes · last changed {date} in ' + (
+ f'<a href="{site.link(page, commit_page(c_sha))}">{esc(subject)}</a>' if c_sha in shown else esc(subject)) + "</p>"
+ if size > MAX_BLOB:
+ content = '<p class="mute">Too large to show here; it is in the clone.</p>'
+ else:
+ data = git_bytes("cat-file", "blob", sha)
+ if b"\0" in data[:8000]:
+ content = '<p class="mute">A binary file; it is in the clone.</p>'
+ else:
+ text = data.decode("utf-8", "replace")
+ if path.lower().endswith(".md"):
+ content = f'<div class="md">{markdown(text, resolver(page, posixpath.dirname(path)))}</div>'
+ else:
+ rows = "".join(f'<tr id="L{n}"><td class="n"><a href="#L{n}">{n}</a></td><td>{esc(line)}</td></tr>'
+ for n, line in enumerate(text.splitlines(), 1))
+ content = f'<div class="box"><table class="code">{rows}</table></div>'
+ site.write(page, path, _crumbs(site, page, path) + meta + content, on="code")
+
+ # Releases
+ page = "releases.html"
+ (out / "releases").mkdir(parents=True, exist_ok=True)
+ blocks = []
+ for k, r in enumerate(rels):
+ name = f"pyrtan-{r['version']}"
+ subprocess.run(["git", "archive", "--format=tar.gz", f"--prefix={name}/", "-o", str(out / "releases" / f"{name}.tar.gz"), r["sha"]],
+ cwd=ROOT, check=True)
+ 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>'
+ f'<p class="mute small">{esc(r["date"])} · {commit_link(page, r, r["sha"][:7])} · '
+ f'<a href="releases/{quote(name)}.tar.gz">{esc(name)}.tar.gz</a></p>'
+ f'<p><b>{esc(r["subject"])}</b></p>{notes(r["body"], trailers=False)}</div>')
+ site.write(page, "Releases", "<h1>Releases</h1>" + ("".join(blocks) or '<p class="mute">None yet.</p>'), on="releases")
+
+ # Branches
+ page = "branches.html"
+ rows = []
+ for name, tip, ahead, behind, own in branch_rows:
+ mark = '<span class="tag">default</span>' if name == branch else ""
+ detail = ""
+ if own:
+ 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)
+ detail = f'<details><summary class="small">{len(own)} commit{"s" if len(own) != 1 else ""} not on {esc(branch)}</summary><table>{items}</table></details>'
+ rows.append(f'<tr><td><code>{esc(name)}</code>{mark}<div class="small">{commit_link(page, tip)}</div>{detail}</td>'
+ f'<td class="mute small">{ahead} ahead · {behind} behind</td>{when(tip["date"])}</tr>')
+ 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>'
+ f'<div class="box"><table>{"".join(rows) or "<tr><td class=mute>None in this checkout.</td></tr>"}</table></div>', on="branches")
+
+ # History, and a page per commit
+ page = "commits.html"
+ 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)
+ site.write(page, "History", f'<h1>History of {esc(branch)}</h1><div class="box"><table>{rows}</table></div>', on="history")
+
+ for sha, c in shown.items():
+ page = commit_page(sha)
+ 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"])
+ first = c["parents"][0] if c["parents"] else None
+ stat = git("diff", "--stat=100", first, sha, check=False) if first else git("show", "--stat=100", "--format=", sha)
+ patch = git("diff", first, sha, check=False) if first else git("show", "--format=", sha)
+ cut = len(patch) > MAX_PATCH
+ lines = []
+ for line in patch[:MAX_PATCH].splitlines():
+ cls = "hunk" if line.startswith("@@") else "add" if line.startswith("+") and not line.startswith("+++") else "del" if line.startswith("-") and not line.startswith("---") else ""
+ lines.append(f'<div class="{cls}">{esc(line) or " "}</div>' if cls else esc(line) + "\n")
+ body = (f'<h1>{esc(c["subject"])}</h1><p class="mute small">{esc(c["author"])} · {esc(c["date"])} · <code>{sha}</code>'
+ + (f" · parents {parents}" if parents else "") + "</p>"
+ + notes(c["body"])
+ + f"<h2>Changes</h2><pre>{esc(stat)}</pre><pre>{''.join(lines)}</pre>"
+ + ('<p class="mute">The diff is cut here; the rest is in the clone.</p>' if cut else ""))
+ site.write(page, c["subject"], body, on="history")
+
+ return len(files) + len(tree) + len(shown) + 3
+
+
+def _parents(path: str) -> list[str]:
+ parts = path.split("/")
+ return ["/".join(parts[:k]) for k in range(1, len(parts))]
+
+
+def _crumbs(site: Site, page: str, path: str) -> str:
+ parts = path.split("/") if path else []
+ links = [f'<a href="{site.link(page, "index.html")}">pyrtan</a>']
+ for k, part in enumerate(parts):
+ sub = "/".join(parts[:k + 1])
+ links.append(esc(part) if k == len(parts) - 1 else f'<a href="{site.link(page, tree_page(sub))}">{esc(part)}</a>')
+ return f'<p class="crumbs">{" / ".join(links)}</p>'
+
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("out", type=Path, help="where to write the browser (the site's source/)")
+ ap.add_argument("--branch", default="main", help="the name of the branch HEAD is (default main)")
+ ap.add_argument("--max-commits", type=int, default=200, help="how much of HEAD's history to show")
+ args = ap.parse_args(argv)
+ n = build(args.out, args.branch, args.max_commits)
+ print(f"wrote {args.out}: {n} pages")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 0aa3cf1..c5f6792 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -213,7 +213,7 @@ GET /peers (hub only)</pre>
</div> </section>
- <footer><a href="https://pyrtan.com">pyrtan.com</a> · <a href="https://github.com/b-i-t-8/pyrtan">source</a> · AGPL-3.0-or-later · console v__VERSION__<span id="hub-version"></span></footer>
+ <footer><a href="https://pyrtan.com">pyrtan.com</a> · <a href="https://pyrtan.com/source/">source</a> · AGPL-3.0-or-later · console v__VERSION__<span id="hub-version"></span></footer>
</main> <script> const $ = (s) => document.querySelector(s); diff --git a/tests/test_site_source.py b/tests/test_site_source.py new file mode 100644 index 0000000..4c6c969 --- /dev/null +++ b/tests/test_site_source.py
@@ -0,0 +1,44 @@
+"""site/source.py: the source browser pyrtan.com publishes at /source/."""
+
+import importlib.util
+import subprocess
+import sys
+
+from conftest import ROOT
+
+spec = importlib.util.spec_from_file_location("site_source", ROOT / "site" / "source.py")
+source = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(source)
+
+
+def test_builds_from_this_checkout(tmp_path):
+ """Works on whatever git this checkout has, a shallow clone included."""
+ out = tmp_path / "source"
+ run = subprocess.run([sys.executable, "site/source.py", str(out)], capture_output=True, text=True, cwd=str(ROOT))
+ assert run.returncode == 0, run.stderr
+ for page in ("index.html", "releases.html", "branches.html", "commits.html", "tree/src/pyrtan/index.html", "blob/pyproject.toml.html"):
+ assert (out / page).is_file(), page
+ blob = (out / "blob/pyproject.toml.html").read_text()
+ assert 'id="L1"' in blob and "[project]" in blob
+ head = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(ROOT)).stdout.strip()
+ assert (out / f"commit/{head}.html").is_file()
+ assert 'class="md"' in (out / "index.html").read_text() # the README, rendered
+
+
+def test_markdown_escapes_and_links():
+ resolve = lambda url: "R:" + url
+ html = source.markdown("# T <b>\n\nSee [the `docs`](docs/x.md) and **<i>**.\n\n```\n<script>\n```\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |", resolve)
+ assert "<h1>T &lt;b&gt;</h1>" in html
+ assert '<a href="R:docs/x.md">the <code>docs</code></a>' in html
+ assert "<b>&lt;i&gt;</b>" in html
+ assert "<pre>&lt;script&gt;</pre>" in html
+ assert "<ul><li>one</li><li>two</li></ul>" in html
+ assert "<tr><td>1</td><td>2</td></tr>" in html and "---" not in html
+
+
+def test_notes_reflow_and_trailers():
+ body = "One line\nwrapped here.\n\n- a list\n- kept\n\nCo-Authored-By: X <x@example.com>"
+ assert "<p>One line wrapped here.</p>" in source.notes(body)
+ assert "- a list\n- kept" in source.notes(body)
+ assert "Co-Authored-By" in source.notes(body)
+ assert "Co-Authored-By" not in source.notes(body, trailers=False)