|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate the static LLM-Coding org site: project overview, llms.txt and shortlinks. |
| 3 | +
|
| 4 | +Data comes from the GitHub API via the `gh` CLI. Output goes to `_site/`. |
| 5 | +Usage: python3 generate.py [--out _site] [--org LLM-Coding] |
| 6 | +""" |
| 7 | +import argparse |
| 8 | +import hashlib |
| 9 | +import html |
| 10 | +import json |
| 11 | +import re |
| 12 | +import shutil |
| 13 | +import subprocess |
| 14 | +import sys |
| 15 | +import urllib.request |
| 16 | +from datetime import datetime, timezone |
| 17 | +from html.parser import HTMLParser |
| 18 | +from pathlib import Path |
| 19 | +from urllib.parse import urljoin, urlparse |
| 20 | + |
| 21 | +import yaml |
| 22 | + |
| 23 | +ROOT = Path(__file__).resolve().parent |
| 24 | +SITE_URL = "https://llm-coding.github.io/" |
| 25 | +ORG_SITE_REPO = "llm-coding.github.io" |
| 26 | +RESERVED_KEYS = {"icons", "sl", "index", "llms", "style"} |
| 27 | +KEY_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") |
| 28 | +ICON_TYPES = {"image/png": "png", "image/svg+xml": "svg", "image/x-icon": "ico", |
| 29 | + "image/vnd.microsoft.icon": "ico", "image/jpeg": "jpg", "image/webp": "webp"} |
| 30 | +USER_AGENT = "llm-coding-site-generator" |
| 31 | +esc = html.escape |
| 32 | + |
| 33 | + |
| 34 | +# --- data ----------------------------------------------------------------- |
| 35 | + |
| 36 | +def gh_api(path): |
| 37 | + out = subprocess.run(["gh", "api", path], check=True, capture_output=True, text=True).stdout |
| 38 | + return json.loads(out) |
| 39 | + |
| 40 | + |
| 41 | +def gh_api_list(path): |
| 42 | + out = subprocess.run(["gh", "api", "--paginate", path, "--jq", ".[]"], check=True, |
| 43 | + capture_output=True, text=True).stdout |
| 44 | + return [json.loads(line) for line in out.splitlines() if line.strip()] |
| 45 | + |
| 46 | + |
| 47 | +def pages_url(repo): |
| 48 | + try: |
| 49 | + url = gh_api(f"repos/{repo['full_name']}/pages").get("html_url") |
| 50 | + except (subprocess.CalledProcessError, AttributeError): |
| 51 | + url = None |
| 52 | + homepage = repo.get("homepage") or "" |
| 53 | + if not is_http(homepage): |
| 54 | + homepage = "" |
| 55 | + return url or homepage or f"{SITE_URL}{repo['name']}/" |
| 56 | + |
| 57 | + |
| 58 | +def is_http(url): |
| 59 | + return urlparse(url).scheme in ("http", "https") |
| 60 | + |
| 61 | + |
| 62 | +def select_projects(repos, overrides, org_site_repo=ORG_SITE_REPO): |
| 63 | + """Repos with Pages, minus archived, the org site, excluded ones and forks (unless included).""" |
| 64 | + def wanted(r): |
| 65 | + ov = overrides.get(r["name"], {}) |
| 66 | + return (r.get("has_pages") and not r.get("archived") |
| 67 | + and r["name"].lower() != org_site_repo.lower() |
| 68 | + and not ov.get("exclude") and (not r.get("fork") or ov.get("include"))) |
| 69 | + chosen = [r for r in repos if wanted(r)] |
| 70 | + return sorted(chosen, key=lambda r: r["name"].lower()) |
| 71 | + |
| 72 | + |
| 73 | +def display_name(repo, overrides): |
| 74 | + return overrides.get(repo["name"], {}).get("name") or repo["name"] |
| 75 | + |
| 76 | + |
| 77 | +def description(repo, overrides): |
| 78 | + return (overrides.get(repo["name"], {}).get("description") |
| 79 | + or (repo.get("description") or "").strip()) |
| 80 | + |
| 81 | + |
| 82 | +def load_overrides(path): |
| 83 | + data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else None |
| 84 | + result = {} |
| 85 | + for key, v in (data or {}).items(): |
| 86 | + v = v or {} |
| 87 | + entry = {f: str(v[f]) for f in ("name", "description") if v.get(f)} |
| 88 | + entry.update({f: True for f in ("include", "exclude") if v.get(f) is True}) |
| 89 | + result[str(key)] = entry |
| 90 | + return result |
| 91 | + |
| 92 | + |
| 93 | +def load_yaml(path): |
| 94 | + data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else None |
| 95 | + return {str(k): str(v) for k, v in (data or {}).items()} |
| 96 | + |
| 97 | + |
| 98 | +def validate_links(links, repo_names): |
| 99 | + names = {n.lower() for n in repo_names} |
| 100 | + for key, url in links.items(): |
| 101 | + if not KEY_RE.match(key): |
| 102 | + raise ValueError(f"Invalid shortlink key {key!r}: use a-z, 0-9 and '-'") |
| 103 | + if key in RESERVED_KEYS: |
| 104 | + raise ValueError(f"Shortlink key {key!r} is reserved") |
| 105 | + if key in names: |
| 106 | + raise ValueError(f"Shortlink key {key!r} collides with a repository page") |
| 107 | + if not is_http(url): |
| 108 | + raise ValueError(f"Shortlink {key!r} needs an http(s) URL, got {url!r}") |
| 109 | + |
| 110 | + |
| 111 | +# --- icons ---------------------------------------------------------------- |
| 112 | + |
| 113 | +class _IconParser(HTMLParser): |
| 114 | + def __init__(self): |
| 115 | + super().__init__() |
| 116 | + self.apple, self.icons = [], [] |
| 117 | + |
| 118 | + def handle_starttag(self, tag, attrs): |
| 119 | + a = dict(attrs) |
| 120 | + if tag != "link" or not a.get("href"): |
| 121 | + return |
| 122 | + rel = (a.get("rel") or "").lower().split() |
| 123 | + size = max((int(s.split("x")[0]) for s in (a.get("sizes") or "").split() |
| 124 | + if re.match(r"^\d+x\d+$", s)), default=0) |
| 125 | + if "apple-touch-icon" in rel: |
| 126 | + self.apple.append((size, a["href"])) |
| 127 | + elif "icon" in rel: |
| 128 | + self.icons.append((size, a["href"])) |
| 129 | + |
| 130 | + |
| 131 | +def find_icons(page_html, base_url): |
| 132 | + """Icon URL candidates, best first: largest apple-touch-icon, rel=icon, /favicon.ico.""" |
| 133 | + p = _IconParser() |
| 134 | + p.feed(page_html) |
| 135 | + hrefs = [h for _, h in sorted(p.apple, key=lambda x: -x[0])] |
| 136 | + hrefs += [h for _, h in sorted(p.icons, key=lambda x: -x[0])] |
| 137 | + urls = [urljoin(base_url, h) for h in hrefs] + [urljoin(base_url, "/favicon.ico")] |
| 138 | + return list(dict.fromkeys(urls)) |
| 139 | + |
| 140 | + |
| 141 | +def fetch(url, timeout=15): |
| 142 | + if not is_http(url): |
| 143 | + raise ValueError(f"Refusing non-http(s) URL {url!r}") |
| 144 | + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) |
| 145 | + with urllib.request.urlopen(req, timeout=timeout) as resp: |
| 146 | + return resp.headers.get_content_type(), resp.read() |
| 147 | + |
| 148 | + |
| 149 | +def fetch_icon(site_url): |
| 150 | + """Return (extension, bytes, url) of the site's icon, or None.""" |
| 151 | + try: |
| 152 | + _, page = fetch(site_url) |
| 153 | + candidates = find_icons(page.decode("utf-8", "replace"), site_url) |
| 154 | + except Exception as e: # noqa: BLE001 — a dead site must not break the build |
| 155 | + print(f" ! {site_url}: {e}", file=sys.stderr) |
| 156 | + candidates = [urljoin(site_url, "/favicon.ico")] |
| 157 | + for url in candidates: |
| 158 | + try: |
| 159 | + ctype, data = fetch(url) |
| 160 | + except Exception: # noqa: BLE001 |
| 161 | + continue |
| 162 | + ext = ICON_TYPES.get(ctype) or Path(urlparse(url).path).suffix.lstrip(".").lower() |
| 163 | + if data and ext in ICON_TYPES.values(): |
| 164 | + return ext, data, url |
| 165 | + return None |
| 166 | + |
| 167 | + |
| 168 | +def drop_shared_icons(icons): |
| 169 | + """Drop icons used by several sites (e.g. the docToolchain default) — they tell nothing apart.""" |
| 170 | + digests = {} |
| 171 | + for name, (_, data, *_) in icons.items(): |
| 172 | + digests.setdefault(hashlib.sha256(data).hexdigest(), []).append(name) |
| 173 | + shared = {n for names in digests.values() if len(names) > 1 for n in names} |
| 174 | + return {n: v for n, v in icons.items() if n not in shared} |
| 175 | + |
| 176 | + |
| 177 | +def initial_icon(name): |
| 178 | + hue = int(hashlib.sha256(name.encode()).hexdigest(), 16) % 360 |
| 179 | + letter = esc(next((c for c in name if c.isalnum()), "?").upper()) |
| 180 | + return (f'<svg class="icon" viewBox="0 0 64 64" width="48" height="48" aria-hidden="true">' |
| 181 | + f'<rect width="64" height="64" rx="14" fill="hsl({hue} 55% 45%)"/>' |
| 182 | + f'<text x="32" y="43" text-anchor="middle" font-size="32" font-weight="600" ' |
| 183 | + f'font-family="system-ui, sans-serif" fill="#fff">{letter}</text></svg>') |
| 184 | + |
| 185 | + |
| 186 | +# --- rendering ------------------------------------------------------------ |
| 187 | + |
| 188 | +def page(title, body, version, generated, description_text=""): |
| 189 | + meta = f'<meta name="description" content="{esc(description_text)}">\n' if description_text else "" |
| 190 | + return f"""<!doctype html> |
| 191 | +<html lang="en"> |
| 192 | +<head> |
| 193 | +<meta charset="utf-8"> |
| 194 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 195 | +<title>{esc(title)}</title> |
| 196 | +{meta}<link rel="stylesheet" href="/style.css"> |
| 197 | +<link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
| 198 | +<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt"> |
| 199 | +</head> |
| 200 | +<body> |
| 201 | +<header><a class="brand" href="/">LLM-Coding</a> |
| 202 | +<nav><a href="/">Projects</a> <a href="/sl.html">Shortlinks</a> <a href="https://github.com/LLM-Coding">GitHub</a></nav> |
| 203 | +</header> |
| 204 | +<main> |
| 205 | +{body} |
| 206 | +</main> |
| 207 | +<footer>Version {esc(version)} · generated {esc(generated)} · |
| 208 | +<a href="/llms.txt">llms.txt</a> · |
| 209 | +<a href="https://github.com/LLM-Coding/{ORG_SITE_REPO}">Source</a></footer> |
| 210 | +</body> |
| 211 | +</html> |
| 212 | +""" |
| 213 | + |
| 214 | + |
| 215 | +def render_index(projects, links, version, generated): |
| 216 | + items = [] |
| 217 | + for p in projects: |
| 218 | + icon = (f'<img class="icon" src="{esc(p["icon"])}" alt="" width="48" height="48">' |
| 219 | + if p.get("icon") else initial_icon(p["name"])) |
| 220 | + items.append(f"""<li class="card"> |
| 221 | +{icon} |
| 222 | +<div><h2><a href="{esc(p["url"])}">{esc(p.get("title") or p["name"])}</a></h2> |
| 223 | +<p>{esc(p["description"])}</p> |
| 224 | +<p class="meta"><a href="{esc(p["url"])}">{esc(p["url"])}</a> · <a href="{esc(p["repo_url"])}">Repository</a></p></div> |
| 225 | +</li>""") |
| 226 | + body = f"""<h1>LLM-Coding projects</h1> |
| 227 | +<p class="lead">Open projects about software development with large language models. |
| 228 | +Each one has its own website.</p> |
| 229 | +<ul class="projects"> |
| 230 | +{chr(10).join(items)} |
| 231 | +</ul> |
| 232 | +<p>{len(links)} shortlink{"s" if len(links) != 1 else ""}: <a href="/sl.html">overview</a>.</p>""" |
| 233 | + return page("LLM-Coding – projects", body, version, generated, |
| 234 | + "Overview of all LLM-Coding projects with their websites.") |
| 235 | + |
| 236 | + |
| 237 | +def render_llms(projects, links, version): |
| 238 | + lines = ["# LLM-Coding", "", |
| 239 | + "> Open projects about software development with large language models.", "", |
| 240 | + f"Version {version}. Source: https://github.com/LLM-Coding/{ORG_SITE_REPO}", "", |
| 241 | + "## Projects", ""] |
| 242 | + lines += [f"- [{p.get('title') or p['name']}]({p['url']}): {p['description']}" |
| 243 | + for p in projects] |
| 244 | + lines += ["", "## Shortlinks", ""] |
| 245 | + lines += [f"- {SITE_URL}{k}/ -> {u}" for k, u in links.items()] |
| 246 | + return "\n".join(lines) + "\n" |
| 247 | + |
| 248 | + |
| 249 | +def render_redirect(key, url): |
| 250 | + u = esc(url) |
| 251 | + return f"""<!doctype html> |
| 252 | +<html lang="en"> |
| 253 | +<head> |
| 254 | +<meta charset="utf-8"> |
| 255 | +<meta http-equiv="refresh" content="0; url={u}"> |
| 256 | +<link rel="canonical" href="{u}"> |
| 257 | +<meta name="robots" content="noindex"> |
| 258 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 259 | +<title>Redirecting to {u}</title> |
| 260 | +<link rel="stylesheet" href="/style.css"> |
| 261 | +</head> |
| 262 | +<body> |
| 263 | +<main><p>Redirecting to <a href="{u}">{u}</a> …</p></main> |
| 264 | +</body> |
| 265 | +</html> |
| 266 | +""" |
| 267 | + |
| 268 | + |
| 269 | +def render_shortlinks(links, version, generated): |
| 270 | + rows = "\n".join( |
| 271 | + f'<tr><td><a href="{esc(k)}/">{esc(SITE_URL)}{esc(k)}</a></td>' |
| 272 | + f'<td><a href="{esc(u)}">{esc(u)}</a></td></tr>' for k, u in sorted(links.items())) |
| 273 | + body = f"""<h1>Shortlinks</h1> |
| 274 | +<p class="lead">Short addresses for slides and QR codes.</p> |
| 275 | +<div class="table-wrap"><table> |
| 276 | +<thead><tr><th>Shortlink</th><th>Target</th></tr></thead> |
| 277 | +<tbody> |
| 278 | +{rows} |
| 279 | +</tbody> |
| 280 | +</table></div>""" |
| 281 | + return page("LLM-Coding – shortlinks", body, version, generated) |
| 282 | + |
| 283 | + |
| 284 | +# --- build ---------------------------------------------------------------- |
| 285 | + |
| 286 | +def write_site(out, projects, icons, links, version, generated): |
| 287 | + out = Path(out) |
| 288 | + if out.exists(): |
| 289 | + shutil.rmtree(out) |
| 290 | + (out / "icons").mkdir(parents=True) |
| 291 | + for p in projects: |
| 292 | + if p["name"] in icons: |
| 293 | + ext, data, url = icons[p["name"]] |
| 294 | + if ext == "svg": |
| 295 | + # A foreign SVG served from our origin could run scripts — hotlink it instead |
| 296 | + p["icon"] = url |
| 297 | + else: |
| 298 | + p["icon"] = f"icons/{p['name']}.{ext}" |
| 299 | + (out / p["icon"]).write_bytes(data) |
| 300 | + (out / "index.html").write_text(render_index(projects, links, version, generated), "utf-8") |
| 301 | + (out / "llms.txt").write_text(render_llms(projects, links, version), "utf-8") |
| 302 | + (out / "sl.html").write_text(render_shortlinks(links, version, generated), "utf-8") |
| 303 | + for key, url in links.items(): |
| 304 | + (out / key).mkdir() |
| 305 | + (out / key / "index.html").write_text(render_redirect(key, url), "utf-8") |
| 306 | + for asset in ("style.css", "favicon.svg"): |
| 307 | + shutil.copy(ROOT / "static" / asset, out / asset) |
| 308 | + (out / ".nojekyll").write_text("") |
| 309 | + |
| 310 | + |
| 311 | +def main(): |
| 312 | + ap = argparse.ArgumentParser(description=__doc__) |
| 313 | + ap.add_argument("--out", default=str(ROOT / "_site")) |
| 314 | + ap.add_argument("--org", default="LLM-Coding") |
| 315 | + args = ap.parse_args() |
| 316 | + |
| 317 | + all_repos = gh_api_list(f"orgs/{args.org}/repos?per_page=100") |
| 318 | + overrides = load_overrides(ROOT / "overrides.yaml") |
| 319 | + repos = select_projects(all_repos, overrides) |
| 320 | + links = load_yaml(ROOT / "links.yaml") |
| 321 | + # Archived repos keep serving their Pages, so check against every repo name |
| 322 | + validate_links(links, [r["name"] for r in all_repos]) |
| 323 | + |
| 324 | + projects, icons = [], {} |
| 325 | + for r in repos: |
| 326 | + url = pages_url(r) |
| 327 | + print(f"{r['name']}: {url}") |
| 328 | + projects.append({"name": r["name"], "title": display_name(r, overrides), |
| 329 | + "description": description(r, overrides), |
| 330 | + "url": url, "repo_url": r["html_url"], "icon": None}) |
| 331 | + icon = fetch_icon(url) |
| 332 | + if icon: |
| 333 | + icons[r["name"]] = icon |
| 334 | + icons = drop_shared_icons(icons) |
| 335 | + |
| 336 | + version = (ROOT / "VERSION").read_text().strip() |
| 337 | + generated = datetime.now(timezone.utc).strftime("%Y-%m-%d") |
| 338 | + write_site(args.out, projects, icons, links, version, generated) |
| 339 | + print(f"Wrote {len(projects)} projects and {len(links)} shortlinks to {args.out}") |
| 340 | + |
| 341 | + |
| 342 | +if __name__ == "__main__": |
| 343 | + main() |
0 commit comments