diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..d759e45
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,47 @@
+name: Build and deploy site
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ 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:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pages-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+ - 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@v5
+ 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@v5
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..0d91a54
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.3.0
diff --git a/generate.py b/generate.py
new file mode 100644
index 0000000..6494b41
--- /dev/null
+++ b/generate.py
@@ -0,0 +1,343 @@
+#!/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
+ 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, 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())
+
+
+def display_name(repo, overrides):
+ return overrides.get(repo["name"], {}).get("name") or repo["name"]
+
+
+def description(repo, overrides):
+ 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
+ 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):
+ 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 not is_http(url):
+ 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):
+ 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, url) 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, 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():
+ 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}
+
+
+
+
+
+
+{body}
+
+
+
+
+"""
+
+
+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}
+
+""")
+ body = f"""LLM-Coding projects
+Open projects about software development with large language models.
+Each one has its own website.
+
+{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.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"
+
+
+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.
+
+| Shortlink | Target |
+
+{rows}
+
+
"""
+ 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, 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")
+ 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()
+
+ all_repos = gh_api_list(f"orgs/{args.org}/repos?per_page=100")
+ 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])
+
+ projects, icons = [], {}
+ for r in repos:
+ url = pages_url(r)
+ print(f"{r['name']}: {url}")
+ 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:
+ 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/overrides.yaml b/overrides.yaml
new file mode 100644
index 0000000..33f42e6
--- /dev/null
+++ b/overrides.yaml
@@ -0,0 +1,23 @@
+# Per-repository overrides for the overview page and llms.txt.
+# 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.
+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/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/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..39ff1d6
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1 @@
+
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..c72b856
--- /dev/null
+++ b/tests/test_generate.py
@@ -0,0 +1,159 @@
+"""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="", fork=False):
+ return {"name": name, "description": desc, "has_pages": pages,
+ "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")]
+ 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"
+ 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 = """
+
+ """
+ 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("