Skip to content

Commit 37b4e19

Browse files
authored
Merge pull request #1 from LLM-Coding/feat/site
feat: org site with project overview, llms.txt and shortlinks
2 parents 8fce055 + f35d51c commit 37b4e19

9 files changed

Lines changed: 613 additions & 0 deletions

File tree

‎.github/workflows/pages.yml‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Build and deploy site
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
schedule: # GitHub pauses this after 60 days without repo activity; re-enable in the Actions tab
8+
- cron: "17 4 * * *" # daily, picks up new repos and descriptions
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: pages-${{ github.ref }}
16+
cancel-in-progress: false
17+
18+
jobs:
19+
build:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v7
23+
- uses: actions/setup-python@v7
24+
with:
25+
python-version: "3.12"
26+
- run: pip install -r requirements.txt
27+
- run: python -m pytest -q
28+
- run: python generate.py --out _site
29+
env:
30+
GH_TOKEN: ${{ github.token }}
31+
- uses: actions/upload-pages-artifact@v5
32+
with:
33+
path: _site
34+
35+
deploy:
36+
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
37+
needs: build
38+
runs-on: ubuntu-latest
39+
permissions:
40+
pages: write
41+
id-token: write
42+
environment:
43+
name: github-pages
44+
url: ${{ steps.deployment.outputs.page_url }}
45+
steps:
46+
- id: deployment
47+
uses: actions/deploy-pages@v5

‎VERSION‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.3.0

‎generate.py‎

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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()

‎links.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Shortlinks: key -> target URL. Each key becomes https://llm-coding.github.io/<key>/
2+
# Keys: lowercase letters, digits and hyphens. They must not collide with a repository name.
3+
f: https://llm-coding.github.io/fahrrad-reparatur-architektur/

0 commit comments

Comments
 (0)