Merge pull request #14 from b-i-t-8/claude/lucid-galileo-0wlvce
b-i-t-8 · 2026-10-27 · 801959af9937dcb9fb7e1f2680af7f485aef0870 · parents ad3c1f6 f0c7352
pyrtan.com: inspectable tagline, version in the footer, and the source browsed on the site
Changes
.github/workflows/pages.yml | 5 +- Dockerfile | 2 +- README.md | 7 +- _meta/design.md | 2 +- pyproject.toml | 2 +- site/build.sh | 11 +- site/catalog/index.html | 2 +- site/index.html | 8 +- site/source.py | 473 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/pyrtan/__init__.py | 4 +- src/pyrtan/cli.py | 2 +- src/pyrtan/page.py | 4 +- tests/test_site_source.py | 44 ++++++ 13 files changed, 545 insertions(+), 21 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/Dockerfile b/Dockerfile index 0c6c422..66b0fda 100644 --- a/Dockerfile +++ b/Dockerfile@@ -12,7 +12,7 @@ RUN pip install --no-cache-dir --prefix=/install .FROM python:3.12-slim LABEL org.opencontainers.image.title="pyrtan" \- org.opencontainers.image.description="A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees." \+ org.opencontainers.image.description="A core set of expandable services that act on all forms of I/O through inspectable decision trees." \org.opencontainers.image.source="https://github.com/b-i-t-8/pyrtan" \ org.opencontainers.image.licenses="AGPL-3.0-or-later" COPY --from=build /install /usr/local diff --git a/README.md b/README.md index 87fa026..6766a77 100644 --- a/README.md +++ b/README.md@@ -1,7 +1,7 @@# Pyrtan **Open-source total automation: a core set of expandable services that act on all forms of I/O-through hardcoded, inspectable decision trees.**+through inspectable decision trees.**Pyrtan looks like an assistant from the outside. You give it a sentence; it reads or changes something through whatever form of I/O it has an adapter@@ -476,7 +476,8 @@ _meta/ the design record, the tree format reference, and thedeploy/ 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@@ -496,8 +497,6 @@ and its own box, and follow other swarms. Not yet built: hosting.See [`_meta/design.md`](_meta/design.md) for the runtime's design and [`_meta/platform.md`](_meta/platform.md) for pyrtan.com.-Pyrtan is a product of [Hillvon.io](https://hillvon.io).-## License Pyrtan is free software under the GNU Affero General Public License, diff --git a/_meta/design.md b/_meta/design.md index 494c4bb..9792584 100644 --- a/_meta/design.md +++ b/_meta/design.md@@ -1,4 +1,4 @@-# Design — Pyrtan: a core set of expandable services that act on all forms of I/O through hardcoded decision trees+# Design — Pyrtan: a core set of expandable services that act on all forms of I/O through inspectable decision trees| | | |---|---| diff --git a/pyproject.toml b/pyproject.toml index 165fe23..294de3b 100644 --- a/pyproject.toml +++ b/pyproject.toml@@ -1,7 +1,7 @@[project] name = "pyrtan" version = "0.9.0"-description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees."+description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees."readme = "README.md" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] diff --git a/site/build.sh b/site/build.sh index e2f584f..6a7a6ae 100755 --- a/site/build.sh +++ b/site/build.sh@@ -1,14 +1,19 @@#!/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/index.html site/CNAME "$out/"+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"cp site/rendezvous/seeds.json "$out/rendezvous/" # The directory as the seeds know it now, keeping the last published # snapshot's entries when a seed is down (SNAPSHOT_PREVIOUS may add more). 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 <checkout> --owner <your GitHub login></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 <checkout> --owner <your GitHub login></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 => ({"&":"&","<":"<",">":">",'"':"""}[c])); diff --git a/site/index.html b/site/index.html index 37bc9ef..2169e21 100644 --- a/site/index.html +++ b/site/index.html@@ -4,7 +4,7 @@<meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pyrtan</title>-<meta name="description" content="Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. No model in the loop.">+<meta name="description" content="Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees. No model in the loop."><style> :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --accent:#2b5db8; --accent-fg:#fff; --soft:#f1efe9 } @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --accent:#7fa6f0; --accent-fg:#101318; --soft:#222420 } }@@ -34,13 +34,13 @@<body> <main> <h1>Pyrtan</h1>- <p class="lede">Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. It answers like an assistant. Inside, there is no model.</p>+ <p class="lede">Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees. It answers like an assistant. Inside, there is no model.</p><div class="actions"> <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@@ -82,7 +82,7 @@ pyrtan directory # who th<h2>Hosting</h2> <p>Self-hosting is the product. A hosted option, running the same open-source hub per customer with secrets, retention, scheduled re-runs and alerts around it, is planned; it will speak the same API, so a swarm can move either way without changing a caller.</p>- <footer>Pyrtan is free software under the AGPL-3.0-or-later, a product of <a href="https://hillvon.io">Hillvon.io</a>. Trees you write are your own.</footer>+ <footer>Pyrtan v__VERSION__ is free software under the AGPL-3.0-or-later. Trees you write are your own.</footer></main> <script> const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); 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/__init__.py b/src/pyrtan/__init__.py index 2d7a756..50a8ec5 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py@@ -1,5 +1,5 @@-"""Pyrtan: a core set of expandable services that act on all forms of I/O through hardcoded,-inspectable decision trees.+"""Pyrtan: a core set of expandable services that act on all forms of I/O through inspectable+decision trees.The public surface is small on purpose: diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index c9e27fe..5590949 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -56,7 +56,7 @@ from .tree import TreeError, check, load_treedef main(argv: list[str] | None = None) -> int:- parser = argparse.ArgumentParser(prog="pyrtan", description="A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees.")+ parser = argparse.ArgumentParser(prog="pyrtan", description="A core set of expandable services that act on all forms of I/O through inspectable decision trees.")parser.add_argument("--version", action="version", version=f"pyrtan {__version__}") sub = parser.add_subparsers(dest="command", required=True) diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 5d33d40..c5f6792 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -81,7 +81,7 @@ PAGE = r"""<!doctype html><h1>Pyrtan <small id="who">…</small></h1> <span class="mute" id="where"></span> </header>- <p class="lede">A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved.</p>+ <p class="lede">A core set of expandable services that act on all forms of I/O through inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved.</p><section id="connect" hidden> <div class="card">@@ -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 <b></h1>" in html+ assert '<a href="R:docs/x.md">the <code>docs</code></a>' in html+ assert "<b><i></b>" in html+ assert "<pre><script></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)