diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 34c8297..2f40043 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -47,6 +47,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Generate loaf registry index + run: python3 scripts/generate_loaf_index.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Generate grain registry index run: python3 scripts/generate_grain_index.py env: diff --git a/_data/loaf-index.json b/_data/loaf-index.json new file mode 100644 index 0000000..7c405b3 --- /dev/null +++ b/_data/loaf-index.json @@ -0,0 +1,52 @@ +{ + "generated": "2026-07-13T04:39:09Z", + "total": 2, + "summary": { + "prototype": 2 + }, + "loaves": [ + { + "repo": "Loaf_x004", + "id": "loaf-x004", + "name": "x004 Loaf", + "status": "prototype", + "role": "backplane", + "summary": "Four-Slice backplane distributing 12V and I2C over the Slice bus.", + "slice_slots": 4, + "bus_type": "i2c", + "hw_version": "0.3.0", + "hw_gen_current": 2, + "pcb_layers": 2, + "schema_version": "1.0", + "tags": [ + "backplane", + "four-slice", + "i2c" + ], + "updated": "2026-07-13", + "url": "https://feastorg.github.io/Loaf_x004/" + }, + { + "repo": "Loaf_ESPT", + "id": "loaf-espt", + "name": "ESP32 Thing Plus Controller Loaf", + "status": "prototype", + "role": "controller", + "summary": "Controller Loaf carrying a SparkFun ESP32 Thing Plus, chaining onto a backplane.", + "slice_slots": 0, + "bus_type": "i2c", + "hw_version": "0.2.0", + "hw_gen_current": 2, + "pcb_layers": 2, + "schema_version": "1.0", + "tags": [ + "controller", + "esp32", + "thing-plus", + "i2c" + ], + "updated": "2026-07-13", + "url": "https://feastorg.github.io/Loaf_ESPT/" + } + ] +} diff --git a/_pages/grains.md b/_pages/grains.md index e41e22b..2b38358 100644 --- a/_pages/grains.md +++ b/_pages/grains.md @@ -2,7 +2,7 @@ layout: default title: Grain Registry parent: Projects -nav_order: 2 +nav_order: 3 has_children: true permalink: /grains/ --- diff --git a/_pages/loaves.md b/_pages/loaves.md new file mode 100644 index 0000000..3c3b865 --- /dev/null +++ b/_pages/loaves.md @@ -0,0 +1,60 @@ +--- +layout: default +title: Loaf Registry +parent: Projects +nav_order: 2 +has_children: true +permalink: /loaves/ +--- + +# Loaf Registry + +All BREADS-compatible Loaves, auto-generated from each repo's `loaf.yaml` manifest. + +A **Loaf** is the attachment and interconnect layer that lets Slices operate together as +one BREAD. A `backplane` provides Slice attachment; a `controller` provides the controller +interface and chains onto a backplane, with no Slice slots of its own. + +{% assign loaves = site.data['loaf-index'].loaves %} +{% assign summary = site.data['loaf-index'].summary %} +{% assign generated = site.data['loaf-index'].generated %} +{% assign total = site.data['loaf-index'].total %} + +{% if loaves %} + +**{{ total }} loaves** · Generated {{ generated | slice: 0, 10 }} +{%- if summary.released and summary.released > 0 %} · released {{ summary.released }}{% endif %} +{%- if summary.validated and summary.validated > 0 %} · validated {{ summary.validated }}{% endif %} +{%- if summary.prototype and summary.prototype > 0 %} · prototype {{ summary.prototype }}{% endif %} +{%- if summary.concept and summary.concept > 0 %} · concept {{ summary.concept }}{% endif %} +{%- if summary.deprecated and summary.deprecated > 0 %} · deprecated {{ summary.deprecated }}{% endif %} + +--- + +{% assign roles = "backplane,hybrid,controller" | split: "," %} + +{% for role in roles %} +{% assign role_loaves = loaves | where: "role", role %} +{% if role_loaves.size > 0 %} + +## {{ role | capitalize }} + +
+ +| Loaf | Summary | Slots | Bus | Status | HW | Tags | +|---|---|---|---|---|---|---| +{% for l in role_loaves -%} +| [{{ l.name | default: l.repo }}]({{ l.url }}) | {{ l.summary | default: "—" }} | {{ l.slice_slots }} | {{ l.bus_type | default: "—" }} | {{ l.status }} | {% if l.hw_version %}v{{ l.hw_version }} (gen {{ l.hw_gen_current }}){% else %}—{% endif %} | {% if l.tags and l.tags.size > 0 %}{{ l.tags | join: ", " }}{% else %}—{% endif %} | +{% endfor %} + +
+ +{% endif %} +{% endfor %} + +{% else %} + +_No Loaf manifests found. The index is regenerated daily from `loaf.yaml` in each +`Loaf_*` repo._ + +{% endif %} diff --git a/scripts/generate_loaf_index.py b/scripts/generate_loaf_index.py new file mode 100644 index 0000000..3ae2f7a --- /dev/null +++ b/scripts/generate_loaf_index.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +Generate _data/loaf-index.json from loaf.yaml manifests across all Loaf_* repos +in the feastorg GitHub organization. + +Usage: + python3 scripts/generate_loaf_index.py + +Environment: + GITHUB_TOKEN — required; a token with read access to the feastorg org. + GITHUB_TOKEN is available automatically in Actions. + +Output: + _data/loaf-index.json +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +try: + import requests +except ImportError: + sys.exit("Error: 'requests' not installed. Run: pip install requests") + +try: + import yaml +except ImportError: + sys.exit("Error: 'pyyaml' not installed. Run: pip install pyyaml") + +ORG = "feastorg" +OUTPUT = Path("_data/loaf-index.json") +API = "https://api.github.com" + +# Fields to extract from each loaf.yaml (all nullable) +EXTRACT = [ + ("id", lambda m: m.get("id")), + ("name", lambda m: m.get("name")), + ("status", lambda m: m.get("status")), + ("role", lambda m: m.get("role")), + ("summary", lambda m: m.get("summary")), + ("slice_slots", lambda m: (m.get("interconnect") or {}).get("slice_slots")), + ("bus_type", lambda m: ((m.get("interconnect") or {}).get("bus") or {}).get("type")), + ("hw_version", lambda m: (m.get("version") or {}).get("hardware")), + ("hw_gen_current", lambda m: (m.get("hardware") or {}).get("hw_gen_current")), + ("pcb_layers", lambda m: ((m.get("hardware") or {}).get("pcb") or {}).get("layers")), + ("schema_version", lambda m: m.get("schema_version")), + ("tags", lambda m: (m.get("metadata") or {}).get("tags", [])), + ("updated", lambda m: (m.get("metadata") or {}).get("updated")), +] + +STATUS_ORDER = ["released", "validated", "prototype", "concept", "deprecated"] +ROLE_ORDER = ["backplane", "hybrid", "controller"] + + +def gh_session() -> requests.Session: + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + sys.exit("Error: GITHUB_TOKEN environment variable not set.") + s = requests.Session() + s.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + return s + + +def list_loaf_repos(session: requests.Session) -> list[str]: + """Return names of all public Loaf_* repos in the org.""" + repos = [] + page = 1 + while True: + r = session.get( + f"{API}/orgs/{ORG}/repos", + params={"type": "public", "per_page": 100, "page": page}, + ) + r.raise_for_status() + batch = r.json() + if not batch: + break + repos.extend(repo["name"] for repo in batch if repo["name"].startswith("Loaf_")) + page += 1 + return sorted(repos) + + +def fetch_manifest(session: requests.Session, repo: str) -> dict | None: + """Fetch and parse loaf.yaml from the repo's default branch.""" + url = f"https://raw.githubusercontent.com/{ORG}/{repo}/main/loaf.yaml" + r = session.get(url) + if r.status_code == 404: + print(f" SKIP {repo}: no loaf.yaml", flush=True) + return None + r.raise_for_status() + try: + return yaml.safe_load(r.text) + except yaml.YAMLError as e: + print(f" WARN {repo}: YAML parse error — {e}", flush=True) + return None + + +def extract(manifest: dict, repo: str) -> dict: + entry = {"repo": repo} + for key, fn in EXTRACT: + try: + entry[key] = fn(manifest) + except Exception: + entry[key] = None + entry["url"] = f"https://feastorg.github.io/{repo}/" + return entry + + +def sort_key(entry: dict) -> tuple: + status_rank = ( + STATUS_ORDER.index(entry["status"]) if entry["status"] in STATUS_ORDER else 99 + ) + role_rank = ROLE_ORDER.index(entry["role"]) if entry["role"] in ROLE_ORDER else 99 + return (status_rank, role_rank, entry.get("id") or "") + + +def main() -> None: + session = gh_session() + + print(f"Listing Loaf_* repos in {ORG}...", flush=True) + repos = list_loaf_repos(session) + print(f"Found {len(repos)} repos.", flush=True) + + loaves = [] + for repo in repos: + print(f" Fetching {repo}...", flush=True) + manifest = fetch_manifest(session, repo) + if manifest is None: + continue + loaves.append(extract(manifest, repo)) + + loaves.sort(key=sort_key) + + summary: dict[str, int] = {} + for entry in loaves: + key = entry.get("status") or "unknown" + summary[key] = summary.get(key, 0) + 1 + + output = { + "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "total": len(loaves), + "summary": summary, + "loaves": loaves, + } + + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n") + print(f"\nWrote {len(loaves)} loaves to {OUTPUT}", flush=True) + for status, count in sorted(summary.items()): + print(f" {status}: {count}", flush=True) + + +if __name__ == "__main__": + main()