From aee0de097e683d202fa5f43bd03c2ac621aae374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralf=20D=2E=20M=C3=BCller?= Date: Sun, 4 Oct 2026 10:31:36 +0200 Subject: [PATCH 1/4] feat: generate project overview, llms.txt and shortlinks Static org site built from the GitHub API: index.html lists all LLM-Coding repos with GitHub Pages (icon, description, link), llms.txt mirrors the list, links.yaml becomes // redirect pages plus the sl.html overview. A workflow rebuilds daily and on push and deploys to Pages. First shortlink: f -> fahrrad-reparatur-architektur. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pages.yml | 47 ++++++ VERSION | 1 + descriptions.yaml | 5 + generate.py | 305 ++++++++++++++++++++++++++++++++++++ links.yaml | 3 + static/favicon.svg | 1 + static/style.css | 34 ++++ tests/test_generate.py | 105 +++++++++++++ 8 files changed, 501 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 VERSION create mode 100644 descriptions.yaml create mode 100644 generate.py create mode 100644 links.yaml create mode 100644 static/favicon.svg create mode 100644 static/style.css create mode 100644 tests/test_generate.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a25007b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Build and deploy site + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 4 * * *" # daily, picks up new repos and descriptions + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pyyaml pytest + - run: python -m pytest -q + - run: python generate.py --out _site + env: + GH_TOKEN: ${{ github.token }} + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/descriptions.yaml b/descriptions.yaml new file mode 100644 index 0000000..2f7abab --- /dev/null +++ b/descriptions.yaml @@ -0,0 +1,5 @@ +# Fallback descriptions for repositories whose GitHub description is empty. +# The GitHub description wins as soon as one is set. +Semantic-Anchors: Shared vocabulary for LLM communication – curated semantic anchors and semantic contracts for precise prompts. +vibe-coding-risk-radar: Risk framework for AI-generated code – five dimensions mapped to four risk tiers, with Claude Code skills for assessment and mitigation. +ultrathink.club: An AI think-tank community. Coming soon. diff --git a/generate.py b/generate.py new file mode 100644 index 0000000..24d3b98 --- /dev/null +++ b/generate.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Generate the static LLM-Coding org site: project overview, llms.txt and shortlinks. + +Data comes from the GitHub API via the `gh` CLI. Output goes to `_site/`. +Usage: python3 generate.py [--out _site] [--org LLM-Coding] +""" +import argparse +import hashlib +import html +import json +import re +import shutil +import subprocess +import sys +import urllib.request +from datetime import datetime, timezone +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urljoin, urlparse + +import yaml + +ROOT = Path(__file__).resolve().parent +SITE_URL = "https://llm-coding.github.io/" +ORG_SITE_REPO = "llm-coding.github.io" +RESERVED_KEYS = {"icons", "sl", "index", "llms", "style"} +KEY_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +ICON_TYPES = {"image/png": "png", "image/svg+xml": "svg", "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", "image/jpeg": "jpg", "image/webp": "webp"} +USER_AGENT = "llm-coding-site-generator" +esc = html.escape + + +# --- data ----------------------------------------------------------------- + +def gh_api(path): + out = subprocess.run(["gh", "api", path], check=True, capture_output=True, text=True).stdout + return json.loads(out) + + +def gh_api_list(path): + out = subprocess.run(["gh", "api", "--paginate", path, "--jq", ".[]"], check=True, + capture_output=True, text=True).stdout + return [json.loads(line) for line in out.splitlines() if line.strip()] + + +def pages_url(repo): + try: + url = gh_api(f"repos/{repo['full_name']}/pages").get("html_url") + except (subprocess.CalledProcessError, AttributeError): + url = None + return url or repo.get("homepage") or f"{SITE_URL}{repo['name']}/" + + +def select_projects(repos, org_site_repo=ORG_SITE_REPO): + chosen = [r for r in repos if r.get("has_pages") and not r.get("archived") + and r["name"].lower() != org_site_repo.lower()] + return sorted(chosen, key=lambda r: r["name"].lower()) + + +def description(repo, overrides): + return (repo.get("description") or "").strip() or overrides.get(repo["name"], "") + + +def load_yaml(path): + data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else None + return {str(k): str(v) for k, v in (data or {}).items()} + + +def validate_links(links, repo_names): + names = {n.lower() for n in repo_names} + for key, url in links.items(): + if not KEY_RE.match(key): + raise ValueError(f"Invalid shortlink key {key!r}: use a-z, 0-9 and '-'") + if key in RESERVED_KEYS: + raise ValueError(f"Shortlink key {key!r} is reserved") + if key in names: + raise ValueError(f"Shortlink key {key!r} collides with a repository page") + if urlparse(url).scheme not in ("http", "https"): + raise ValueError(f"Shortlink {key!r} needs an http(s) URL, got {url!r}") + + +# --- icons ---------------------------------------------------------------- + +class _IconParser(HTMLParser): + def __init__(self): + super().__init__() + self.apple, self.icons = [], [] + + def handle_starttag(self, tag, attrs): + a = dict(attrs) + if tag != "link" or not a.get("href"): + return + rel = (a.get("rel") or "").lower().split() + size = max((int(s.split("x")[0]) for s in (a.get("sizes") or "").split() + if re.match(r"^\d+x\d+$", s)), default=0) + if "apple-touch-icon" in rel: + self.apple.append((size, a["href"])) + elif "icon" in rel: + self.icons.append((size, a["href"])) + + +def find_icons(page_html, base_url): + """Icon URL candidates, best first: largest apple-touch-icon, rel=icon, /favicon.ico.""" + p = _IconParser() + p.feed(page_html) + hrefs = [h for _, h in sorted(p.apple, key=lambda x: -x[0])] + hrefs += [h for _, h in sorted(p.icons, key=lambda x: -x[0])] + urls = [urljoin(base_url, h) for h in hrefs] + [urljoin(base_url, "/favicon.ico")] + return list(dict.fromkeys(urls)) + + +def fetch(url, timeout=15): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.headers.get_content_type(), resp.read() + + +def fetch_icon(site_url): + """Return (extension, bytes) of the site's icon, or None.""" + try: + _, page = fetch(site_url) + candidates = find_icons(page.decode("utf-8", "replace"), site_url) + except Exception as e: # noqa: BLE001 — a dead site must not break the build + print(f" ! {site_url}: {e}", file=sys.stderr) + candidates = [urljoin(site_url, "/favicon.ico")] + for url in candidates: + try: + ctype, data = fetch(url) + except Exception: # noqa: BLE001 + continue + ext = ICON_TYPES.get(ctype) or Path(urlparse(url).path).suffix.lstrip(".").lower() + if data and ext in ICON_TYPES.values(): + return ext, data + return None + + +def drop_shared_icons(icons): + """Drop icons used by several sites (e.g. the docToolchain default) — they tell nothing apart.""" + digests = {} + for name, (_, data) in icons.items(): + digests.setdefault(hashlib.sha256(data).hexdigest(), []).append(name) + shared = {n for names in digests.values() if len(names) > 1 for n in names} + return {n: v for n, v in icons.items() if n not in shared} + + +def initial_icon(name): + hue = int(hashlib.sha256(name.encode()).hexdigest(), 16) % 360 + letter = esc(next((c for c in name if c.isalnum()), "?").upper()) + return (f'') + + +# --- rendering ------------------------------------------------------------ + +def page(title, body, version, generated, description_text=""): + meta = f'\n' if description_text else "" + return f""" + + + + +{esc(title)} +{meta} + + + + +
LLM-Coding + +
+
+{body} +
+
Version {esc(version)} · generated {esc(generated)} · +llms.txt · +Source
+ + +""" + + +def render_index(projects, links, version, generated): + items = [] + for p in projects: + icon = (f'' + if p.get("icon") else initial_icon(p["name"])) + items.append(f"""
  • +{icon} +

    {esc(p["name"])}

    +

    {esc(p["description"])}

    +

    {esc(p["url"])} · Repository

    +
  • """) + body = f"""

    LLM-Coding projects

    +

    Open projects about software development with large language models. +Each one has its own website.

    +
      +{chr(10).join(items)} +
    +

    {len(links)} shortlink{"s" if len(links) != 1 else ""}: overview.

    """ + return page("LLM-Coding – projects", body, version, generated, + "Overview of all LLM-Coding projects with their websites.") + + +def render_llms(projects, links, version): + lines = ["# LLM-Coding", "", + "> Open projects about software development with large language models.", "", + f"Version {version}. Source: https://github.com/LLM-Coding/{ORG_SITE_REPO}", "", + "## Projects", ""] + lines += [f"- [{p['name']}]({p['url']}): {p['description']}" for p in projects] + lines += ["", "## Shortlinks", ""] + lines += [f"- {SITE_URL}{k}/ -> {u}" for k, u in links.items()] + return "\n".join(lines) + "\n" + + +def render_redirect(key, url): + u = esc(url) + return f""" + + + + + + + +Redirecting to {u} + + + +

    Redirecting to {u} …

    + + +""" + + +def render_shortlinks(links, version, generated): + rows = "\n".join( + f'{esc(SITE_URL)}{esc(k)}' + f'{esc(u)}' for k, u in sorted(links.items())) + body = f"""

    Shortlinks

    +

    Short addresses for slides and QR codes.

    +
    + + +{rows} + +
    ShortlinkTarget
    """ + return page("LLM-Coding – shortlinks", body, version, generated) + + +# --- build ---------------------------------------------------------------- + +def write_site(out, projects, icons, links, version, generated): + out = Path(out) + if out.exists(): + shutil.rmtree(out) + (out / "icons").mkdir(parents=True) + for p in projects: + if p["name"] in icons: + ext, data = icons[p["name"]] + p["icon"] = f"icons/{p['name']}.{ext}" + (out / p["icon"]).write_bytes(data) + (out / "index.html").write_text(render_index(projects, links, version, generated), "utf-8") + (out / "llms.txt").write_text(render_llms(projects, links, version), "utf-8") + (out / "sl.html").write_text(render_shortlinks(links, version, generated), "utf-8") + for key, url in links.items(): + (out / key).mkdir() + (out / key / "index.html").write_text(render_redirect(key, url), "utf-8") + for asset in ("style.css", "favicon.svg"): + shutil.copy(ROOT / "static" / asset, out / asset) + (out / ".nojekyll").write_text("") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", default=str(ROOT / "_site")) + ap.add_argument("--org", default="LLM-Coding") + args = ap.parse_args() + + repos = select_projects(gh_api_list(f"orgs/{args.org}/repos?per_page=100")) + overrides = load_yaml(ROOT / "descriptions.yaml") + links = load_yaml(ROOT / "links.yaml") + validate_links(links, [r["name"] for r in repos]) + + projects, icons = [], {} + for r in repos: + url = pages_url(r) + print(f"{r['name']}: {url}") + projects.append({"name": r["name"], "description": description(r, overrides), + "url": url, "repo_url": r["html_url"], "icon": None}) + icon = fetch_icon(url) + if icon: + icons[r["name"]] = icon + icons = drop_shared_icons(icons) + + version = (ROOT / "VERSION").read_text().strip() + generated = datetime.now(timezone.utc).strftime("%Y-%m-%d") + write_site(args.out, projects, icons, links, version, generated) + print(f"Wrote {len(projects)} projects and {len(links)} shortlinks to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/links.yaml b/links.yaml new file mode 100644 index 0000000..deed6e0 --- /dev/null +++ b/links.yaml @@ -0,0 +1,3 @@ +# Shortlinks: key -> target URL. Each key becomes https://llm-coding.github.io// +# Keys: lowercase letters, digits and hyphens. They must not collide with a repository name. +f: https://llm-coding.github.io/fahrrad-reparatur-architektur/ diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..39ff1d6 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ +L diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..437dd64 --- /dev/null +++ b/static/style.css @@ -0,0 +1,34 @@ +:root { + color-scheme: light dark; + --bg: #fafafa; --fg: #1d1d1f; --muted: #5f6368; --card: #fff; + --border: #e2e2e6; --link: #0b57d0; +} +@media (prefers-color-scheme: dark) { + :root { --bg: #121214; --fg: #e8e8ea; --muted: #a0a0a8; --card: #1c1c20; + --border: #2e2e34; --link: #8ab4f8; } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); + font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: var(--link); } +header, main, footer { max-width: 56rem; margin: 0 auto; padding: 1rem; } +header { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; align-items: baseline; + justify-content: space-between; border-bottom: 1px solid var(--border); } +.brand { font-weight: 700; font-size: 1.15rem; color: var(--fg); text-decoration: none; } +nav a { margin-right: 1rem; } +nav a:last-child { margin-right: 0; } +h1 { font-size: 1.8rem; margin: 1rem 0 .25rem; } +.lead { color: var(--muted); margin-top: 0; } +.projects { list-style: none; padding: 0; display: grid; gap: 1rem; } +.card { display: flex; gap: 1rem; align-items: flex-start; background: var(--card); + border: 1px solid var(--border); border-radius: 12px; padding: 1rem; } +.icon { flex: 0 0 48px; width: 48px; height: 48px; border-radius: 10px; object-fit: contain; } +.card h2 { font-size: 1.15rem; margin: 0 0 .25rem; } +.card p { margin: 0 0 .25rem; overflow-wrap: anywhere; } +.meta { font-size: .875rem; color: var(--muted); } +.table-wrap { overflow-x: auto; } +table { border-collapse: collapse; width: 100%; background: var(--card); } +th, td { text-align: left; padding: .5rem .75rem; border: 1px solid var(--border); + overflow-wrap: anywhere; } +footer { color: var(--muted); font-size: .875rem; border-top: 1px solid var(--border); + margin-top: 2rem; } diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 0000000..251f398 --- /dev/null +++ b/tests/test_generate.py @@ -0,0 +1,105 @@ +"""Tests for the site generator. Network-free: repositories and icons are injected.""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import generate as g # noqa: E402 + +ORG = "LLM-Coding" + + +def repo(name, desc="", pages=True, archived=False, homepage=""): + return {"name": name, "description": desc, "has_pages": pages, + "archived": archived, "homepage": homepage, + "html_url": f"https://github.com/{ORG}/{name}"} + + +def test_select_projects_filters_pages_archived_and_org_site(): + repos = [repo("b"), repo("a"), repo("no-pages", pages=False), + repo("old", archived=True), repo("llm-coding.github.io")] + names = [r["name"] for r in g.select_projects(repos, "llm-coding.github.io")] + assert names == ["a", "b"] + + +def test_description_falls_back_to_overrides(): + assert g.description(repo("x", "From GitHub"), {"x": "Fallback"}) == "From GitHub" + assert g.description(repo("x"), {"x": "Fallback"}) == "Fallback" + assert g.description(repo("x"), {}) == "" + + +def test_find_icon_prefers_largest_apple_touch_icon(): + html = """ + + """ + assert g.find_icons(html, "https://x.org/p/")[0] == "https://x.org/p/a180.png" + + +def test_find_icon_uses_rel_icon_and_falls_back_to_favicon_ico(): + html = '' + assert g.find_icons(html, "https://x.org/p/") == [ + "https://x.org/p/favicon.svg", "https://x.org/favicon.ico"] + assert g.find_icons("", "https://x.org/p/") == ["https://x.org/favicon.ico"] + + +def test_initial_icon_is_inline_svg_with_first_letter(): + svg = g.initial_icon("vibe-coding-risk-radar") + assert svg.startswith("V" in svg + + +def test_shared_icons_are_dropped(): + icons = {"a": ("png", b"same"), "b": ("png", b"same"), "c": ("svg", b"own")} + assert g.drop_shared_icons(icons) == {"c": ("svg", b"own")} + + +def project(name, desc="Desc", url=None, icon=None): + return {"name": name, "description": desc, + "url": url or f"https://llm-coding.github.io/{name}/", + "repo_url": f"https://github.com/{ORG}/{name}", "icon": icon} + + +def test_index_lists_projects_statically_with_version(): + html = g.render_index([project("a", "Alpha ", icon="icons/a.png"), project("b")], + {"f": "https://x"}, "1.2.3", "2026-10-04") + assert 'href="https://llm-coding.github.io/a/"' in html + assert "Alpha <b>" in html + assert 'src="icons/a.png"' in html + assert ">B" in html # generated initial for b + assert "1.2.3" in html and " https://t/" in txt + + +def test_redirect_page_works_without_js(): + html = g.render_redirect("f", "https://t/?a=1&b=2") + assert '' in html + assert '' in html + assert '' in html and " Date: Sun, 4 Oct 2026 10:34:58 +0200 Subject: [PATCH 2/4] fix: harden icon fetching and pin build dependencies Review findings on PR #1: - hotlink SVG icons instead of serving foreign SVGs from our origin - fetch only http(s) URLs; ignore non-http(s) repo homepages - check shortlink keys against all repo names, archived included - bump actions to current majors, pin pyyaml/pytest in requirements.txt - note that GitHub pauses the daily schedule after 60 idle days Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pages.yml | 12 ++++++------ VERSION | 2 +- generate.py | 35 +++++++++++++++++++++++++---------- requirements.txt | 2 ++ tests/test_generate.py | 31 +++++++++++++++++++++++++++++-- 5 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 requirements.txt diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a25007b..d759e45 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - schedule: + schedule: # GitHub pauses this after 60 days without repo activity; re-enable in the Actions tab - cron: "17 4 * * *" # daily, picks up new repos and descriptions workflow_dispatch: @@ -19,16 +19,16 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - - run: pip install pyyaml pytest + - run: pip install -r requirements.txt - run: python -m pytest -q - run: python generate.py --out _site env: GH_TOKEN: ${{ github.token }} - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: _site @@ -44,4 +44,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/VERSION b/VERSION index 6e8bf73..17e51c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/generate.py b/generate.py index 24d3b98..d6b1acf 100644 --- a/generate.py +++ b/generate.py @@ -49,7 +49,14 @@ def pages_url(repo): url = gh_api(f"repos/{repo['full_name']}/pages").get("html_url") except (subprocess.CalledProcessError, AttributeError): url = None - return url or repo.get("homepage") or f"{SITE_URL}{repo['name']}/" + homepage = repo.get("homepage") or "" + if not is_http(homepage): + homepage = "" + return url or homepage or f"{SITE_URL}{repo['name']}/" + + +def is_http(url): + return urlparse(url).scheme in ("http", "https") def select_projects(repos, org_site_repo=ORG_SITE_REPO): @@ -76,7 +83,7 @@ def validate_links(links, repo_names): raise ValueError(f"Shortlink key {key!r} is reserved") if key in names: raise ValueError(f"Shortlink key {key!r} collides with a repository page") - if urlparse(url).scheme not in ("http", "https"): + if not is_http(url): raise ValueError(f"Shortlink {key!r} needs an http(s) URL, got {url!r}") @@ -111,13 +118,15 @@ def find_icons(page_html, base_url): def fetch(url, timeout=15): + if not is_http(url): + raise ValueError(f"Refusing non-http(s) URL {url!r}") req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.headers.get_content_type(), resp.read() def fetch_icon(site_url): - """Return (extension, bytes) of the site's icon, or None.""" + """Return (extension, bytes, url) of the site's icon, or None.""" try: _, page = fetch(site_url) candidates = find_icons(page.decode("utf-8", "replace"), site_url) @@ -131,14 +140,14 @@ def fetch_icon(site_url): continue ext = ICON_TYPES.get(ctype) or Path(urlparse(url).path).suffix.lstrip(".").lower() if data and ext in ICON_TYPES.values(): - return ext, data + return ext, data, url return None def drop_shared_icons(icons): """Drop icons used by several sites (e.g. the docToolchain default) — they tell nothing apart.""" digests = {} - for name, (_, data) in icons.items(): + for name, (_, data, *_) in icons.items(): digests.setdefault(hashlib.sha256(data).hexdigest(), []).append(name) shared = {n for names in digests.values() if len(names) > 1 for n in names} return {n: v for n, v in icons.items() if n not in shared} @@ -259,9 +268,13 @@ def write_site(out, projects, icons, links, version, generated): (out / "icons").mkdir(parents=True) for p in projects: if p["name"] in icons: - ext, data = icons[p["name"]] - p["icon"] = f"icons/{p['name']}.{ext}" - (out / p["icon"]).write_bytes(data) + ext, data, url = icons[p["name"]] + if ext == "svg": + # A foreign SVG served from our origin could run scripts — hotlink it instead + p["icon"] = url + else: + p["icon"] = f"icons/{p['name']}.{ext}" + (out / p["icon"]).write_bytes(data) (out / "index.html").write_text(render_index(projects, links, version, generated), "utf-8") (out / "llms.txt").write_text(render_llms(projects, links, version), "utf-8") (out / "sl.html").write_text(render_shortlinks(links, version, generated), "utf-8") @@ -279,10 +292,12 @@ def main(): ap.add_argument("--org", default="LLM-Coding") args = ap.parse_args() - repos = select_projects(gh_api_list(f"orgs/{args.org}/repos?per_page=100")) + all_repos = gh_api_list(f"orgs/{args.org}/repos?per_page=100") + repos = select_projects(all_repos) overrides = load_yaml(ROOT / "descriptions.yaml") links = load_yaml(ROOT / "links.yaml") - validate_links(links, [r["name"] for r in repos]) + # Archived repos keep serving their Pages, so check against every repo name + validate_links(links, [r["name"] for r in all_repos]) projects, icons = [], {} for r in repos: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01bddab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyyaml==6.0.3 +pytest==9.1.1 diff --git a/tests/test_generate.py b/tests/test_generate.py index 251f398..c2e3b8e 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -49,8 +49,8 @@ def test_initial_icon_is_inline_svg_with_first_letter(): def test_shared_icons_are_dropped(): - icons = {"a": ("png", b"same"), "b": ("png", b"same"), "c": ("svg", b"own")} - assert g.drop_shared_icons(icons) == {"c": ("svg", b"own")} + icons = {"a": ("png", b"same", "u"), "b": ("png", b"same", "u"), "c": ("svg", b"own", "u")} + assert g.drop_shared_icons(icons) == {"c": ("svg", b"own", "u")} def project(name, desc="Desc", url=None, icon=None): @@ -103,3 +103,30 @@ def test_build_writes_all_files(tmp_path): g.write_site(tmp_path, projects, {}, {"f": "https://t/"}, "1.0.0", "2026-10-04") for f in ["index.html", "llms.txt", "sl.html", "f/index.html", "style.css", "favicon.svg", ".nojekyll"]: assert (tmp_path / f).exists(), f + + +def test_svg_icons_are_hotlinked_not_copied(tmp_path): + """Review #1: a foreign SVG served from our origin could run scripts.""" + projects = [project("s"), project("p")] + icons = {"s": ("svg", b"", "https://ext.org/i.svg"), + "p": ("png", b"\x89PNG", "https://ext.org/i.png")} + g.write_site(tmp_path, projects, icons, {}, "1.0.0", "2026-10-04") + assert not list((tmp_path / "icons").glob("*.svg")) + assert (tmp_path / "icons" / "p.png").exists() + html = (tmp_path / "index.html").read_text() + assert 'src="https://ext.org/i.svg"' in html and 'src="icons/p.png"' in html + + +def test_fetch_refuses_non_http_urls(): + """Review #2: icon hrefs come from foreign HTML.""" + with pytest.raises(ValueError): + g.fetch("file:///etc/passwd") + + +def test_pages_url_ignores_non_http_homepage(monkeypatch): + """Review #3: a javascript: homepage must not become a card link.""" + def no_pages(path): + raise g.subprocess.CalledProcessError(1, "gh") + monkeypatch.setattr(g, "gh_api", no_pages) + r = {"name": "x", "full_name": "LLM-Coding/x", "homepage": "javascript:alert(1)"} + assert g.pages_url(r) == "https://llm-coding.github.io/x/" From 3ce473aea841fd38b8c92ec69a07b4d494571e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralf=20D=2E=20M=C3=BCller?= Date: Sun, 4 Oct 2026 10:43:39 +0200 Subject: [PATCH 3/4] feat: per-repo display name and description overrides overrides.yaml replaces descriptions.yaml: name and description per repo, both winning over the GitHub values so the daily rebuild keeps them. mistral-vibe now shows as "Mistral Vibe Doku" with a description of the documentation site. Co-Authored-By: Claude Opus 5.5 --- VERSION | 2 +- descriptions.yaml | 5 ----- generate.py | 23 ++++++++++++++++++----- overrides.yaml | 17 +++++++++++++++++ tests/test_generate.py | 19 ++++++++++++++++--- 5 files changed, 52 insertions(+), 14 deletions(-) delete mode 100644 descriptions.yaml create mode 100644 overrides.yaml diff --git a/VERSION b/VERSION index 17e51c3..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.2.0 diff --git a/descriptions.yaml b/descriptions.yaml deleted file mode 100644 index 2f7abab..0000000 --- a/descriptions.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# Fallback descriptions for repositories whose GitHub description is empty. -# The GitHub description wins as soon as one is set. -Semantic-Anchors: Shared vocabulary for LLM communication – curated semantic anchors and semantic contracts for precise prompts. -vibe-coding-risk-radar: Risk framework for AI-generated code – five dimensions mapped to four risk tiers, with Claude Code skills for assessment and mitigation. -ultrathink.club: An AI think-tank community. Coming soon. diff --git a/generate.py b/generate.py index d6b1acf..6eb4532 100644 --- a/generate.py +++ b/generate.py @@ -65,8 +65,19 @@ def select_projects(repos, org_site_repo=ORG_SITE_REPO): return sorted(chosen, key=lambda r: r["name"].lower()) +def display_name(repo, overrides): + return overrides.get(repo["name"], {}).get("name") or repo["name"] + + def description(repo, overrides): - return (repo.get("description") or "").strip() or overrides.get(repo["name"], "") + return (overrides.get(repo["name"], {}).get("description") + or (repo.get("description") or "").strip()) + + +def load_overrides(path): + data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else None + return {str(k): {f: str(v[f]) for f in ("name", "description") if v and v.get(f)} + for k, v in (data or {}).items()} def load_yaml(path): @@ -198,7 +209,7 @@ def render_index(projects, links, version, generated): if p.get("icon") else initial_icon(p["name"])) items.append(f"""
  • {icon} -
  • """) @@ -218,7 +229,8 @@ def render_llms(projects, links, version): "> Open projects about software development with large language models.", "", f"Version {version}. Source: https://github.com/LLM-Coding/{ORG_SITE_REPO}", "", "## Projects", ""] - lines += [f"- [{p['name']}]({p['url']}): {p['description']}" for p in projects] + lines += [f"- [{p.get('title') or p['name']}]({p['url']}): {p['description']}" + for p in projects] lines += ["", "## Shortlinks", ""] lines += [f"- {SITE_URL}{k}/ -> {u}" for k, u in links.items()] return "\n".join(lines) + "\n" @@ -294,7 +306,7 @@ def main(): all_repos = gh_api_list(f"orgs/{args.org}/repos?per_page=100") repos = select_projects(all_repos) - overrides = load_yaml(ROOT / "descriptions.yaml") + overrides = load_overrides(ROOT / "overrides.yaml") links = load_yaml(ROOT / "links.yaml") # Archived repos keep serving their Pages, so check against every repo name validate_links(links, [r["name"] for r in all_repos]) @@ -303,7 +315,8 @@ def main(): for r in repos: url = pages_url(r) print(f"{r['name']}: {url}") - projects.append({"name": r["name"], "description": description(r, overrides), + projects.append({"name": r["name"], "title": display_name(r, overrides), + "description": description(r, overrides), "url": url, "repo_url": r["html_url"], "icon": None}) icon = fetch_icon(url) if icon: diff --git a/overrides.yaml b/overrides.yaml new file mode 100644 index 0000000..f63cb0f --- /dev/null +++ b/overrides.yaml @@ -0,0 +1,17 @@ +# Per-repository overrides for the overview page and llms.txt. +# Keys are repository names. Both fields are optional and win over the GitHub values, +# so the daily regeneration keeps them. URL and repository link stay unchanged. +# +# : +# name: Display name +# description: Short description + +mistral-vibe: + name: Mistral Vibe Doku + description: Documentation of Mistral Vibe, the minimal CLI coding agent – specification, arc42 architecture and ADRs, reverse-engineered from the source code. +Semantic-Anchors: + description: Shared vocabulary for LLM communication – curated semantic anchors and semantic contracts for precise prompts. +vibe-coding-risk-radar: + description: Risk framework for AI-generated code – five dimensions mapped to four risk tiers, with Claude Code skills for assessment and mitigation. +ultrathink.club: + description: An AI think-tank community. Coming soon. diff --git a/tests/test_generate.py b/tests/test_generate.py index c2e3b8e..dfcc723 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -23,12 +23,25 @@ def test_select_projects_filters_pages_archived_and_org_site(): assert names == ["a", "b"] -def test_description_falls_back_to_overrides(): - assert g.description(repo("x", "From GitHub"), {"x": "Fallback"}) == "From GitHub" - assert g.description(repo("x"), {"x": "Fallback"}) == "Fallback" +def test_overrides_win_over_github_for_name_and_description(): + ov = {"x": {"name": "X Docs", "description": "Override"}} + assert g.display_name(repo("x"), ov) == "X Docs" + assert g.description(repo("x", "From GitHub"), ov) == "Override" + + +def test_without_override_github_values_are_used(): + assert g.display_name(repo("x"), {}) == "x" + assert g.description(repo("x", "From GitHub"), {"x": {"name": "X"}}) == "From GitHub" assert g.description(repo("x"), {}) == "" +def test_index_and_llms_show_display_name(): + p = project("mistral-vibe") + p["title"] = "Mistral Vibe Doku" + assert ">Mistral Vibe Doku" in g.render_index([p], {}, "1", "d") + assert "- [Mistral Vibe Doku](" in g.render_llms([p], {}, "1") + + def test_find_icon_prefers_largest_apple_touch_icon(): html = """ From f35d51c79ed8ff1fbcbc78709b1851aa4e07804b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralf=20D=2E=20M=C3=BCller?= Date: Sun, 4 Oct 2026 10:45:05 +0200 Subject: [PATCH 4/4] feat: leave out forks and excluded repos Forks are skipped automatically unless overrides.yaml sets include: true (mistral-vibe keeps its documentation site listed); exclude: true hides a repo for good. Removes arc42-generator (fork) and brownfield-experiment from the overview and llms.txt. Co-Authored-By: Claude Opus 5.5 --- VERSION | 2 +- generate.py | 22 ++++++++++++++++------ overrides.yaml | 10 ++++++++-- tests/test_generate.py | 20 +++++++++++++++++--- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/generate.py b/generate.py index 6eb4532..6494b41 100644 --- a/generate.py +++ b/generate.py @@ -59,9 +59,14 @@ def is_http(url): return urlparse(url).scheme in ("http", "https") -def select_projects(repos, org_site_repo=ORG_SITE_REPO): - chosen = [r for r in repos if r.get("has_pages") and not r.get("archived") - and r["name"].lower() != org_site_repo.lower()] +def select_projects(repos, overrides, org_site_repo=ORG_SITE_REPO): + """Repos with Pages, minus archived, the org site, excluded ones and forks (unless included).""" + def wanted(r): + ov = overrides.get(r["name"], {}) + return (r.get("has_pages") and not r.get("archived") + and r["name"].lower() != org_site_repo.lower() + and not ov.get("exclude") and (not r.get("fork") or ov.get("include"))) + chosen = [r for r in repos if wanted(r)] return sorted(chosen, key=lambda r: r["name"].lower()) @@ -76,8 +81,13 @@ def description(repo, overrides): def load_overrides(path): data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else None - return {str(k): {f: str(v[f]) for f in ("name", "description") if v and v.get(f)} - for k, v in (data or {}).items()} + result = {} + for key, v in (data or {}).items(): + v = v or {} + entry = {f: str(v[f]) for f in ("name", "description") if v.get(f)} + entry.update({f: True for f in ("include", "exclude") if v.get(f) is True}) + result[str(key)] = entry + return result def load_yaml(path): @@ -305,8 +315,8 @@ def main(): args = ap.parse_args() all_repos = gh_api_list(f"orgs/{args.org}/repos?per_page=100") - repos = select_projects(all_repos) overrides = load_overrides(ROOT / "overrides.yaml") + repos = select_projects(all_repos, overrides) links = load_yaml(ROOT / "links.yaml") # Archived repos keep serving their Pages, so check against every repo name validate_links(links, [r["name"] for r in all_repos]) diff --git a/overrides.yaml b/overrides.yaml index f63cb0f..33f42e6 100644 --- a/overrides.yaml +++ b/overrides.yaml @@ -1,13 +1,17 @@ # Per-repository overrides for the overview page and llms.txt. -# Keys are repository names. Both fields are optional and win over the GitHub values, -# so the daily regeneration keeps them. URL and repository link stay unchanged. +# Keys are repository names. All fields are optional; name and description win over +# the GitHub values, so the daily regeneration keeps them. URL and repository link stay unchanged. +# Forks are left out automatically unless they set include: true. # # : # name: Display name # description: Short description +# include: true # list this repo although it is a fork +# exclude: true # never list this repo mistral-vibe: name: Mistral Vibe Doku + include: true # a fork, but the documentation site is ours description: Documentation of Mistral Vibe, the minimal CLI coding agent – specification, arc42 architecture and ADRs, reverse-engineered from the source code. Semantic-Anchors: description: Shared vocabulary for LLM communication – curated semantic anchors and semantic contracts for precise prompts. @@ -15,3 +19,5 @@ vibe-coding-risk-radar: description: Risk framework for AI-generated code – five dimensions mapped to four risk tiers, with Claude Code skills for assessment and mitigation. ultrathink.club: description: An AI think-tank community. Coming soon. +brownfield-experiment: + exclude: true diff --git a/tests/test_generate.py b/tests/test_generate.py index dfcc723..c72b856 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -10,19 +10,33 @@ ORG = "LLM-Coding" -def repo(name, desc="", pages=True, archived=False, homepage=""): +def repo(name, desc="", pages=True, archived=False, homepage="", fork=False): return {"name": name, "description": desc, "has_pages": pages, - "archived": archived, "homepage": homepage, + "archived": archived, "homepage": homepage, "fork": fork, "html_url": f"https://github.com/{ORG}/{name}"} def test_select_projects_filters_pages_archived_and_org_site(): repos = [repo("b"), repo("a"), repo("no-pages", pages=False), repo("old", archived=True), repo("llm-coding.github.io")] - names = [r["name"] for r in g.select_projects(repos, "llm-coding.github.io")] + names = [r["name"] for r in g.select_projects(repos, {}, "llm-coding.github.io")] assert names == ["a", "b"] +def test_select_projects_drops_forks_and_excluded_unless_included(): + repos = [repo("own"), repo("fork", fork=True), repo("kept-fork", fork=True), + repo("hidden")] + ov = {"kept-fork": {"include": True}, "hidden": {"exclude": True}} + assert [r["name"] for r in g.select_projects(repos, ov)] == ["kept-fork", "own"] + + +def test_load_overrides_reads_flags(tmp_path): + f = tmp_path / "o.yaml" + f.write_text("a:\n name: A\n exclude: true\nb:\n include: true\n") + assert g.load_overrides(f) == {"a": {"name": "A", "exclude": True}, + "b": {"include": True}} + + def test_overrides_win_over_github_for_name_and_description(): ov = {"x": {"name": "X Docs", "description": "Override"}} assert g.display_name(repo("x"), ov) == "X Docs"