Skip to content

Commit 4751c51

Browse files
feat: add the Loaf registry (#14)
Loaves were absent from the site: the daily index generation covered Slice_* and Grain_* repos but not Loaf_*, so the two real backplanes never appeared -- while the Grain registry, which has no repos, did. - scripts/generate_loaf_index.py: builds _data/loaf-index.json from loaf.yaml in each Loaf_* repo, mirroring the Slice and Grain generators - _pages/loaves.md: Loaf Registry, grouped by role - pages.yml: run the loaf generator in the daily build - grains.md: nav_order 2 -> 3, so Projects reads Slice, Loaf, Grain Grouped by `role` rather than category, because that is the axis that matters for a Loaf: a backplane provides Slice attachment; a controller provides the controller interface and chains onto a backplane, with no Slice slots of its own. The table surfaces slot count and bus type.
1 parent 0f2c0b1 commit 4751c51

5 files changed

Lines changed: 282 additions & 1 deletion

File tree

.github/workflows/pages.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ jobs:
4747
env:
4848
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4949

50+
- name: Generate loaf registry index
51+
run: python3 scripts/generate_loaf_index.py
52+
env:
53+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54+
5055
- name: Generate grain registry index
5156
run: python3 scripts/generate_grain_index.py
5257
env:

_data/loaf-index.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"generated": "2026-07-13T04:39:09Z",
3+
"total": 2,
4+
"summary": {
5+
"prototype": 2
6+
},
7+
"loaves": [
8+
{
9+
"repo": "Loaf_x004",
10+
"id": "loaf-x004",
11+
"name": "x004 Loaf",
12+
"status": "prototype",
13+
"role": "backplane",
14+
"summary": "Four-Slice backplane distributing 12V and I2C over the Slice bus.",
15+
"slice_slots": 4,
16+
"bus_type": "i2c",
17+
"hw_version": "0.3.0",
18+
"hw_gen_current": 2,
19+
"pcb_layers": 2,
20+
"schema_version": "1.0",
21+
"tags": [
22+
"backplane",
23+
"four-slice",
24+
"i2c"
25+
],
26+
"updated": "2026-07-13",
27+
"url": "https://feastorg.github.io/Loaf_x004/"
28+
},
29+
{
30+
"repo": "Loaf_ESPT",
31+
"id": "loaf-espt",
32+
"name": "ESP32 Thing Plus Controller Loaf",
33+
"status": "prototype",
34+
"role": "controller",
35+
"summary": "Controller Loaf carrying a SparkFun ESP32 Thing Plus, chaining onto a backplane.",
36+
"slice_slots": 0,
37+
"bus_type": "i2c",
38+
"hw_version": "0.2.0",
39+
"hw_gen_current": 2,
40+
"pcb_layers": 2,
41+
"schema_version": "1.0",
42+
"tags": [
43+
"controller",
44+
"esp32",
45+
"thing-plus",
46+
"i2c"
47+
],
48+
"updated": "2026-07-13",
49+
"url": "https://feastorg.github.io/Loaf_ESPT/"
50+
}
51+
]
52+
}

_pages/grains.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
layout: default
33
title: Grain Registry
44
parent: Projects
5-
nav_order: 2
5+
nav_order: 3
66
has_children: true
77
permalink: /grains/
88
---

_pages/loaves.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
layout: default
3+
title: Loaf Registry
4+
parent: Projects
5+
nav_order: 2
6+
has_children: true
7+
permalink: /loaves/
8+
---
9+
10+
# Loaf Registry
11+
12+
All BREADS-compatible Loaves, auto-generated from each repo's `loaf.yaml` manifest.
13+
14+
A **Loaf** is the attachment and interconnect layer that lets Slices operate together as
15+
one BREAD. A `backplane` provides Slice attachment; a `controller` provides the controller
16+
interface and chains onto a backplane, with no Slice slots of its own.
17+
18+
{% assign loaves = site.data['loaf-index'].loaves %}
19+
{% assign summary = site.data['loaf-index'].summary %}
20+
{% assign generated = site.data['loaf-index'].generated %}
21+
{% assign total = site.data['loaf-index'].total %}
22+
23+
{% if loaves %}
24+
25+
**{{ total }} loaves** · Generated {{ generated | slice: 0, 10 }}
26+
{%- if summary.released and summary.released > 0 %} · <span class="slice-badge slice-badge--released">released {{ summary.released }}</span>{% endif %}
27+
{%- if summary.validated and summary.validated > 0 %} · <span class="slice-badge slice-badge--validated">validated {{ summary.validated }}</span>{% endif %}
28+
{%- if summary.prototype and summary.prototype > 0 %} · <span class="slice-badge slice-badge--prototype">prototype {{ summary.prototype }}</span>{% endif %}
29+
{%- if summary.concept and summary.concept > 0 %} · <span class="slice-badge slice-badge--concept">concept {{ summary.concept }}</span>{% endif %}
30+
{%- if summary.deprecated and summary.deprecated > 0 %} · <span class="slice-badge slice-badge--deprecated">deprecated {{ summary.deprecated }}</span>{% endif %}
31+
32+
---
33+
34+
{% assign roles = "backplane,hybrid,controller" | split: "," %}
35+
36+
{% for role in roles %}
37+
{% assign role_loaves = loaves | where: "role", role %}
38+
{% if role_loaves.size > 0 %}
39+
40+
## {{ role | capitalize }}
41+
42+
<div class="slice-registry" markdown="block">
43+
44+
| Loaf | Summary | Slots | Bus | Status | HW | Tags |
45+
|---|---|---|---|---|---|---|
46+
{% for l in role_loaves -%}
47+
| [{{ l.name | default: l.repo }}]({{ l.url }}) | {{ l.summary | default: "—" }} | {{ l.slice_slots }} | {{ l.bus_type | default: "—" }} | <span class="slice-badge slice-badge--{{ l.status }}">{{ l.status }}</span> | {% 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 %} |
48+
{% endfor %}
49+
50+
</div>
51+
52+
{% endif %}
53+
{% endfor %}
54+
55+
{% else %}
56+
57+
_No Loaf manifests found. The index is regenerated daily from `loaf.yaml` in each
58+
`Loaf_*` repo._
59+
60+
{% endif %}

scripts/generate_loaf_index.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate _data/loaf-index.json from loaf.yaml manifests across all Loaf_* repos
4+
in the feastorg GitHub organization.
5+
6+
Usage:
7+
python3 scripts/generate_loaf_index.py
8+
9+
Environment:
10+
GITHUB_TOKEN — required; a token with read access to the feastorg org.
11+
GITHUB_TOKEN is available automatically in Actions.
12+
13+
Output:
14+
_data/loaf-index.json
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import os
21+
import sys
22+
from datetime import datetime, timezone
23+
from pathlib import Path
24+
25+
try:
26+
import requests
27+
except ImportError:
28+
sys.exit("Error: 'requests' not installed. Run: pip install requests")
29+
30+
try:
31+
import yaml
32+
except ImportError:
33+
sys.exit("Error: 'pyyaml' not installed. Run: pip install pyyaml")
34+
35+
ORG = "feastorg"
36+
OUTPUT = Path("_data/loaf-index.json")
37+
API = "https://api.github.com"
38+
39+
# Fields to extract from each loaf.yaml (all nullable)
40+
EXTRACT = [
41+
("id", lambda m: m.get("id")),
42+
("name", lambda m: m.get("name")),
43+
("status", lambda m: m.get("status")),
44+
("role", lambda m: m.get("role")),
45+
("summary", lambda m: m.get("summary")),
46+
("slice_slots", lambda m: (m.get("interconnect") or {}).get("slice_slots")),
47+
("bus_type", lambda m: ((m.get("interconnect") or {}).get("bus") or {}).get("type")),
48+
("hw_version", lambda m: (m.get("version") or {}).get("hardware")),
49+
("hw_gen_current", lambda m: (m.get("hardware") or {}).get("hw_gen_current")),
50+
("pcb_layers", lambda m: ((m.get("hardware") or {}).get("pcb") or {}).get("layers")),
51+
("schema_version", lambda m: m.get("schema_version")),
52+
("tags", lambda m: (m.get("metadata") or {}).get("tags", [])),
53+
("updated", lambda m: (m.get("metadata") or {}).get("updated")),
54+
]
55+
56+
STATUS_ORDER = ["released", "validated", "prototype", "concept", "deprecated"]
57+
ROLE_ORDER = ["backplane", "hybrid", "controller"]
58+
59+
60+
def gh_session() -> requests.Session:
61+
token = os.environ.get("GITHUB_TOKEN", "")
62+
if not token:
63+
sys.exit("Error: GITHUB_TOKEN environment variable not set.")
64+
s = requests.Session()
65+
s.headers.update(
66+
{
67+
"Authorization": f"Bearer {token}",
68+
"Accept": "application/vnd.github+json",
69+
"X-GitHub-Api-Version": "2022-11-28",
70+
}
71+
)
72+
return s
73+
74+
75+
def list_loaf_repos(session: requests.Session) -> list[str]:
76+
"""Return names of all public Loaf_* repos in the org."""
77+
repos = []
78+
page = 1
79+
while True:
80+
r = session.get(
81+
f"{API}/orgs/{ORG}/repos",
82+
params={"type": "public", "per_page": 100, "page": page},
83+
)
84+
r.raise_for_status()
85+
batch = r.json()
86+
if not batch:
87+
break
88+
repos.extend(repo["name"] for repo in batch if repo["name"].startswith("Loaf_"))
89+
page += 1
90+
return sorted(repos)
91+
92+
93+
def fetch_manifest(session: requests.Session, repo: str) -> dict | None:
94+
"""Fetch and parse loaf.yaml from the repo's default branch."""
95+
url = f"https://raw.githubusercontent.com/{ORG}/{repo}/main/loaf.yaml"
96+
r = session.get(url)
97+
if r.status_code == 404:
98+
print(f" SKIP {repo}: no loaf.yaml", flush=True)
99+
return None
100+
r.raise_for_status()
101+
try:
102+
return yaml.safe_load(r.text)
103+
except yaml.YAMLError as e:
104+
print(f" WARN {repo}: YAML parse error — {e}", flush=True)
105+
return None
106+
107+
108+
def extract(manifest: dict, repo: str) -> dict:
109+
entry = {"repo": repo}
110+
for key, fn in EXTRACT:
111+
try:
112+
entry[key] = fn(manifest)
113+
except Exception:
114+
entry[key] = None
115+
entry["url"] = f"https://feastorg.github.io/{repo}/"
116+
return entry
117+
118+
119+
def sort_key(entry: dict) -> tuple:
120+
status_rank = (
121+
STATUS_ORDER.index(entry["status"]) if entry["status"] in STATUS_ORDER else 99
122+
)
123+
role_rank = ROLE_ORDER.index(entry["role"]) if entry["role"] in ROLE_ORDER else 99
124+
return (status_rank, role_rank, entry.get("id") or "")
125+
126+
127+
def main() -> None:
128+
session = gh_session()
129+
130+
print(f"Listing Loaf_* repos in {ORG}...", flush=True)
131+
repos = list_loaf_repos(session)
132+
print(f"Found {len(repos)} repos.", flush=True)
133+
134+
loaves = []
135+
for repo in repos:
136+
print(f" Fetching {repo}...", flush=True)
137+
manifest = fetch_manifest(session, repo)
138+
if manifest is None:
139+
continue
140+
loaves.append(extract(manifest, repo))
141+
142+
loaves.sort(key=sort_key)
143+
144+
summary: dict[str, int] = {}
145+
for entry in loaves:
146+
key = entry.get("status") or "unknown"
147+
summary[key] = summary.get(key, 0) + 1
148+
149+
output = {
150+
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
151+
"total": len(loaves),
152+
"summary": summary,
153+
"loaves": loaves,
154+
}
155+
156+
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
157+
OUTPUT.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
158+
print(f"\nWrote {len(loaves)} loaves to {OUTPUT}", flush=True)
159+
for status, count in sorted(summary.items()):
160+
print(f" {status}: {count}", flush=True)
161+
162+
163+
if __name__ == "__main__":
164+
main()

0 commit comments

Comments
 (0)