#!/usr/bin/env python3 """Generate per-rack elevation SVG + page from hardware frontmatter. Reads `docs/hardware/*.md`, selects files that declare a `rack` field, validates rack placement, and writes for each rack: docs/infrastructure/racks/-elevation.svg docs/infrastructure/racks/.md Deterministic, offline, stdlib + PyYAML. Non-zero exit on schema violation. The physical rack is labeled U1 at the top; the SVG renders U1 at the top. """ from __future__ import annotations import re import sys from pathlib import Path import yaml REPO_ROOT = Path(__file__).resolve().parent.parent HARDWARE_DIR = REPO_ROOT / "docs" / "hardware" OUTPUT_DIR = REPO_ROOT / "docs" / "infrastructure" / "racks" RACK_UNITS = 48 FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) FACES = {"front", "rear", "both", "left", "right"} ZERO_U_FACES = {"left", "right"} SHELF_FACES = {"front", "rear"} # Shown at the bottom of every error report so a newcomer knows where to look. GUIDE_URL = "https://docs.makerfloss.eu/guides/editing-hardware-docs/" KIND_COLORS = { "server": "#4c78a8", "switch": "#59a14f", "patch-panel": "#9c755f", "pdu": "#e15759", "ups": "#edc948", "shelf": "#bab0ac", "kvm": "#b07aa1", "blank": "#d4d4d4", } DEFAULT_COLOR = "#888888" class SchemaError(Exception): pass def parse_frontmatter(path: Path) -> dict | None: text = path.read_text(encoding="utf-8") m = FRONTMATTER_RE.match(text) if not m: return None data = yaml.safe_load(m.group(1)) if not isinstance(data, dict): raise SchemaError(f"{path}: frontmatter is not a mapping") return data def load_rack_items(hardware_dir: Path) -> list[dict]: """Return frontmatter dicts for hardware files that declare a rack.""" items: list[dict] = [] for path in sorted(hardware_dir.glob("*.md")): if path.name == "index.md": continue fm = parse_frontmatter(path) if fm is None or "rack" not in fm: continue fm = dict(fm) fm["_path"] = str(path) items.append(fm) return items def validate_item(fm: dict) -> None: name = fm.get("hostname") or fm.get("_path", "?") rack = fm.get("rack") if not isinstance(rack, str) or not rack: raise SchemaError(f"{name}: 'rack' must name a rack, e.g. 'rack: rack01'.") if "mounted_on" in fm: mounted_on = fm.get("mounted_on") if not isinstance(mounted_on, str) or not mounted_on: raise SchemaError( f"{name}: 'mounted_on' must name the shelf it sits on, " f"e.g. 'mounted_on: shf01'." ) for forbidden in ("rack_u", "u_height", "rack_face"): if forbidden in fm: raise SchemaError( f"{name}: a shelf-mounted device must not set '{forbidden}' — " f"it takes its position from the shelf. Use 'shelf_face' and " f"'shelf_slot' instead." ) sface = fm.get("shelf_face") if sface not in SHELF_FACES: raise SchemaError( f"{name}: shelf_face {sface!r} must be 'front' or 'rear'." ) slot = fm.get("shelf_slot") if not isinstance(slot, int) or slot < 1: raise SchemaError( f"{name}: 'shelf_slot' must be a whole number 1 or higher " f"(got {slot!r})." ) if "chassis_u" in fm: cu = fm.get("chassis_u") if isinstance(cu, bool) or not isinstance(cu, int) or cu < 1: raise SchemaError( f"{name}: 'chassis_u' is the device's height in U where it " f"stands on the shelf — it must be a whole number 1 or " f"higher (got {cu!r})." ) return face = fm.get("rack_face") if face not in FACES: raise SchemaError( f"{name}: rack_face {face!r} is not valid. Use 'front', 'rear' or " f"'both' for a U-mounted device, or 'left'/'right' for a 0U side rail." ) if face in ZERO_U_FACES: if "rack_u" in fm or "u_height" in fm: raise SchemaError( f"{name}: a side-rail device (rack_face: {face}) is 0U — remove " f"'rack_u' and 'u_height'." ) return u = fm.get("rack_u") h = fm.get("u_height") if not isinstance(u, int) or not isinstance(h, int): raise SchemaError( f"{name}: a {face}-mounted device needs whole-number 'rack_u' and " f"'u_height' (e.g. 'rack_u: 12' and 'u_height: 2')." ) if u < 1 or u > RACK_UNITS: raise SchemaError( f"{name}: rack_u={u} is outside the rack — it must be between 1 " f"and {RACK_UNITS}." ) if h < 1: raise SchemaError(f"{name}: u_height={h} must be at least 1.") if u + h - 1 > RACK_UNITS: raise SchemaError( f"{name}: a {h}U device starting at U{u} runs off the top of the rack " f"(it would need U{u}–U{u + h - 1}, but the rack is only {RACK_UNITS}U). " f"Lower 'rack_u' or 'u_height'." ) def check_overlaps(items: list[dict]) -> None: """Raise if two items share a U on the same face within one rack.""" occupied: dict[tuple[str, int], str] = {} for fm in items: if "mounted_on" in fm: continue face = fm.get("rack_face") if face in ZERO_U_FACES: continue faces = ("front", "rear") if face == "both" else (face,) u = fm["rack_u"] h = fm["u_height"] name = fm.get("hostname", "?") for f in faces: for uu in range(u, u + h): key = (f, uu) if key in occupied: raise SchemaError( f"U{uu} {f}: {name} overlaps {occupied[key]} — two devices " f"can't share the same U on the same face. Move one to a " f"free U or to the other face." ) occupied[key] = name def check_shelves(items: list[dict]) -> None: """Validate shelf-mounted devices within one rack. Every mounted_on resolves to a placed kind:shelf item in the same rack; no two devices share (shelf, face, slot). """ by_host = {fm.get("hostname"): fm for fm in items} occupied: dict[tuple[str, str, int], str] = {} for fm in items: if "mounted_on" not in fm: continue name = fm.get("hostname", "?") shelf_name = fm["mounted_on"] target = by_host.get(shelf_name) if target is None: raise SchemaError( f"{name}: mounted_on={shelf_name!r} — no device with that id is in " f"this rack. Check the shelf's hostname." ) if target.get("kind") != "shelf": raise SchemaError( f"{name}: mounted_on={shelf_name!r} is a {target.get('kind')!r}, " f"not a shelf. Only kind:shelf devices can hold mounted gear." ) if not isinstance(target.get("rack_u"), int) or not isinstance( target.get("u_height"), int ): raise SchemaError( f"{name}: the shelf {shelf_name!r} has no position yet — give it " f"'rack_u' and 'u_height' first." ) key = (shelf_name, fm["shelf_face"], fm["shelf_slot"]) if key in occupied: raise SchemaError( f"{shelf_name} {fm['shelf_face']} slot {fm['shelf_slot']}: " f"{name} overlaps {occupied[key]} — each shelf face and slot holds " f"one device." ) occupied[key] = name def load_hardware_index(hardware_dir: Path) -> dict[str, dict]: """Map hostname -> frontmatter for every hardware file (global peer lookup).""" index: dict[str, dict] = {} for path in sorted(hardware_dir.glob("*.md")): if path.name == "index.md": continue fm = parse_frontmatter(path) if fm is None: continue name = fm.get("hostname") if isinstance(name, str) and name: index[name] = fm return index def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None: """Validate `links` cable declarations (rule 4). Every links[].peer must resolve to a real hardware file (global lookup via hw_index); peer_port must fall within the peer's declared `ports` when it declares an integer count. """ for fm in items: links = fm.get("links") if links is None: continue name = fm.get("hostname", "?") if not isinstance(links, list): raise SchemaError( f"{name}: 'links' must be a list of cables like " f"'- {{ local: eth0, peer: sw01, peer_port: 1 }}'." ) for link in links: if not isinstance(link, dict): raise SchemaError( f"{name}: each 'links' entry must look like " f"'{{ local: eth0, peer: sw01, peer_port: 1 }}'." ) local = link.get("local") peer = link.get("peer") peer_port = link.get("peer_port") if not isinstance(local, str) or not local: raise SchemaError( f"{name}: a 'links' entry needs a 'local' port name, " f"e.g. 'local: eth0'." ) if not isinstance(peer, str) or not peer: raise SchemaError( f"{name}: a 'links' entry needs a 'peer' device, " f"e.g. 'peer: sw01'." ) if not isinstance(peer_port, int): raise SchemaError( f"{name}: the link to {peer} needs a whole-number 'peer_port'." ) target = hw_index.get(peer) if target is None: raise SchemaError( f"{name}: link points at peer={peer!r}, but no hardware file " f"has that id. Check the peer hostname." ) ports = target.get("ports") if isinstance(ports, int) and (peer_port < 1 or peer_port > ports): raise SchemaError( f"{name}: peer_port {peer_port} doesn't exist on {peer} — it " f"has {ports} port(s) (valid 1–{ports})." ) def _pdu_index(items: list[dict]) -> dict[str, dict]: """Map hostname -> frontmatter for every kind:pdu item.""" return { fm.get("hostname"): fm for fm in items if fm.get("kind") == "pdu" } def validate_power(items: list[dict]) -> None: """Validate PDU outlet declarations and `power` feeds within one rack. Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is within that PDU's `outlets` count. """ pdus = _pdu_index(items) for name, fm in pdus.items(): outlets = fm.get("outlets") if not isinstance(outlets, int) or outlets < 1: raise SchemaError( f"{name}: a PDU must say how many outlets it has, e.g. 'outlets: 8'." ) for fm in items: feeds = fm.get("power") if feeds is None: continue name = fm.get("hostname", "?") if not isinstance(feeds, list): raise SchemaError( f"{name}: 'power' must be a list of feeds like " f"'- {{ pdu: pdu01, outlet: 1 }}'." ) for feed in feeds: if not isinstance(feed, dict): raise SchemaError( f"{name}: each 'power' feed must look like " f"'{{ pdu: pdu01, outlet: 1 }}'." ) pdu = feed.get("pdu") outlet = feed.get("outlet") if not isinstance(pdu, str) or not pdu: raise SchemaError( f"{name}: a 'power' feed needs a 'pdu' name, " f"e.g. '{{ pdu: pdu01, outlet: 1 }}'." ) if not isinstance(outlet, int): raise SchemaError( f"{name}: the 'power' feed to {pdu} needs a whole-number 'outlet'." ) target = pdus.get(pdu) if target is None: raise SchemaError( f"{name}: power feed points at pdu={pdu!r}, but no kind:pdu " f"device has that id. Check the PDU hostname." ) count = target["outlets"] if outlet < 1 or outlet > count: raise SchemaError( f"{name}: outlet {outlet} doesn't exist on {pdu} — it has " f"{count} outlet(s) (valid 1–{count})." ) def _esc(s: object) -> str: return str(s).replace("&", "&").replace("<", "<").replace(">", ">") STATUS_STROKE: dict[str, tuple[str, float, str]] = { "in-use": ("#333333", 1.5, ""), "staging": ("#333333", 1.5, "4 2"), "broken": ("#e15759", 3, ""), "spare": ("#bbbbbb", 1.5, ""), "donated": ("#bbbbbb", 1.5, ""), } DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "") def _status_stroke(status: object) -> tuple[str, float, str]: return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE) def _stroke_attrs(status: object) -> str: stroke, sw, dash = _status_stroke(status) dash_attr = f' stroke-dasharray="{dash}"' if dash else "" return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}' def _host_url(host: object) -> str: return f"/hardware/{host}/" def _placement(fm: dict) -> str: if "mounted_on" in fm: return ( f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/" f"slot {fm.get('shelf_slot', '')}" ) face = fm.get("rack_face") if face in ZERO_U_FACES: return f"0U {face}" u = fm.get("rack_u") h = fm.get("u_height") if isinstance(u, int) and isinstance(h, int): return f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}" return "?" def _tooltip(fm: dict) -> str: host = fm.get("hostname", "?") return _esc( f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · " f"cluster: {fm.get('cluster', '—')} · {_placement(fm)}" ) def _sorted_items(items: list[dict]) -> list[dict]: """Deterministic order: faced items by U then hostname, 0U items last.""" return sorted( items, key=lambda i: ( 0 if i.get("rack_face") not in ZERO_U_FACES else 1, i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0, i.get("hostname", ""), ), ) def render_svg(rack: str, items: list[dict]) -> str: U_H = 20 COL_W = 240 LABEL_W = 30 RAIL_W = 16 PAD = 12 GAP = 50 TITLE_H = 28 LEGEND_H = 56 items = _sorted_items(items) left_items = [i for i in items if i.get("rack_face") == "left"] right_items = [i for i in items if i.get("rack_face") == "right"] body_h = RACK_UNITS * U_H height = PAD + TITLE_H + body_h + PAD + LEGEND_H front_x = PAD + len(left_items) * RAIL_W + LABEL_W rear_x = front_x + COL_W + GAP right_gutter_x = rear_x + COL_W width = right_gutter_x + LABEL_W + len(right_items) * RAIL_W + PAD top = PAD + TITLE_H def u_y(u: int) -> int: # U1 at the top; U numbers increase downward. return top + (u - 1) * U_H p: list[str] = [] p.append( f'' ) p.append(f'') p.append( f'Rack {_esc(rack)}' ) for col_x, col_label in ((front_x, "front"), (rear_x, "rear")): p.append( f'{col_label}' ) for u in range(1, RACK_UNITS + 1): y = u_y(u) p.append( f'' ) # U numbers in the gutter left of the front column. for u in range(1, RACK_UNITS + 1): y = u_y(u) p.append( f'{u}' ) for u in range(1, RACK_UNITS + 1): y = u_y(u) p.append( f'{u}' ) for col_x in (front_x, rear_x): p.append( f'' ) def draw_device(fm: dict, col_x: int) -> None: u = fm["rack_u"] h = fm["u_height"] y = u_y(u) box_h = h * U_H color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR) name = fm.get("hostname", "?") urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}" p.append(f'') p.append(f"{_tooltip(fm)}") p.append( f'" ) p.append( f'' f"{_esc(name)} ({urange})" ) p.append("") def draw_rail(fm: dict, x: int) -> None: color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR) name = fm.get("hostname", "?") cx = x + RAIL_W // 2 cy = top + body_h // 2 p.append(f'') p.append(f"{_tooltip(fm)}") p.append( f'" ) p.append( f'{_esc(name)}' ) p.append("") SHELF_STRIP_H = 6 shelves = [i for i in items if i.get("kind") == "shelf"] mounted = [i for i in items if "mounted_on" in i] def draw_shelf(fm: dict) -> None: u = fm["rack_u"] h = fm["u_height"] y = u_y(u) block_h = h * U_H strip_y = y + block_h - SHELF_STRIP_H shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR) sname = fm.get("hostname", "?") for col_x, sface in ((front_x, "front"), (rear_x, "rear")): occ = sorted( (m for m in mounted if m.get("mounted_on") == sname and m.get("shelf_face") == sface), key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")), ) n = len(occ) for idx, m in enumerate(occ): sub_w = COL_W // n bx = col_x + idx * sub_w bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR) mname = m.get("hostname", "?") # The device stands on the shelf strip and rises chassis_u U's # upward; without chassis_u it fills the shelf block (legacy). dev_u = m.get("chassis_u") if not isinstance(dev_u, int) or isinstance(dev_u, bool) or dev_u < 1: dev_u = h dev_h = dev_u * U_H - SHELF_STRIP_H by = strip_y - dev_h p.append(f'') p.append(f"{_tooltip(m)}") p.append( f'" ) p.append( f'{_esc(mname)}' ) p.append("") p.append(f'') p.append(f"{_tooltip(fm)}") for col_x in (front_x, rear_x): p.append( f'' ) p.append( f'{_esc(sname)}' ) p.append("") # Paint order (bottom → top): shelves and their towers first, then # U-mounted devices (so a rail-mounted PDU stays visible over a tower), # then 0U side rails. for fm in sorted(shelves, key=lambda s: s.get("hostname", "")): draw_shelf(fm) for fm in items: if fm.get("kind") == "shelf" or "mounted_on" in fm: continue face = fm.get("rack_face") if face in ("front", "both"): draw_device(fm, front_x) if face in ("rear", "both"): draw_device(fm, rear_x) for idx, fm in enumerate(left_items): draw_rail(fm, PAD + idx * RAIL_W) for idx, fm in enumerate(right_items): draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W) legend_y = top + body_h + PAD + 8 p.append( f'Legend' ) present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")}) kx = front_x ky = legend_y + 18 for kind in present_kinds: color = KIND_COLORS.get(kind, DEFAULT_COLOR) p.append( f'' ) p.append(f'{_esc(kind)}') kx += 28 + 7 * len(kind) sx = front_x sy = ky + 18 for label in ("in-use", "staging", "broken", "spare"): stroke, sw, dash = _status_stroke(label) dash_attr = f' stroke-dasharray="{dash}"' if dash else "" p.append( f'' ) p.append(f'{_esc(label)}') sx += 28 + 7 * len(label) p.append("") return "\n".join(p) + "\n" def _node_id(name: str) -> str: """A mermaid-safe node id derived from a hostname.""" return re.sub(r"[^0-9A-Za-z]", "_", str(name)) def render_power(rack: str, items: list[dict]) -> str: """Return a mermaid power-distribution flowchart, or '' if no feeds. Assumes `validate_power(items)` has already passed: every referenced PDU resolves to a kind:pdu item with a positive-int `outlets`. `generate` guarantees this by validating before any render call. """ powered = [fm for fm in items if fm.get("power")] if not powered: return "" pdus = _pdu_index(items) edges: list[tuple[str, int, str]] = [] for fm in powered: device = fm.get("hostname", "?") for feed in fm["power"]: edges.append((feed["pdu"], feed["outlet"], device)) edges.sort() lines: list[str] = ["```mermaid", "flowchart LR"] for pdu in sorted(pdus): outlets = pdus[pdu].get("outlets") lines.append(f' {_node_id(pdu)}["{pdu}
{outlets} outlets"]') devices = sorted( powered, key=lambda i: ( i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0, i.get("hostname", ""), ), ) for fm in devices: device = fm.get("hostname", "?") lines.append(f' {_node_id(device)}["{device}"]') for pdu, outlet, device in edges: lines.append( f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}" ) by_host = {fm.get("hostname"): fm for fm in items} node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered}) for host in node_hosts: kind = by_host.get(host, {}).get("kind", "") color = KIND_COLORS.get(kind, DEFAULT_COLOR) nid = _node_id(host) lines.append( f" style {nid} fill:{color},stroke:#333,color:#ffffff" ) lines.append(f' click {nid} "{_host_url(host)}"') lines.append("```") return "\n".join(lines) + "\n" def render_network(rack: str, items: list[dict]) -> str: """Return a mermaid network-cabling flowchart, or '' if no links. Assumes `validate_links` has already passed: every link has a non-empty `local`/`peer` and an integer `peer_port`, and `peer` resolves to a real hardware file. `generate` validates before any render call. """ linked = [fm for fm in items if fm.get("links")] if not linked: return "" by_host = {fm.get("hostname"): fm for fm in items} edges: list[tuple[str, str, str, int, object]] = [] nodes: set[str] = set() for fm in linked: source = fm.get("hostname", "?") nodes.add(source) for link in fm["links"]: peer = link["peer"] nodes.add(peer) edges.append( (source, link["local"], peer, link["peer_port"], link.get("speed_gbps")) ) edges.sort(key=lambda e: (e[0], e[1], e[2], e[3])) def node_label(name: str) -> str: fm = by_host.get(name) kind = fm.get("kind") if fm else None if kind in ("switch", "patch-panel"): return f"{name}
{kind}" return name lines: list[str] = ["```mermaid", "flowchart LR"] for name in sorted(nodes): lines.append(f' {_node_id(name)}["{node_label(name)}"]') for source, local, peer, peer_port, speed in edges: label = f"{local} → p{peer_port}" if speed is not None: label += f" · {speed}G" lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}") for host in sorted(nodes): kind = by_host.get(host, {}).get("kind", "") color = KIND_COLORS.get(kind, DEFAULT_COLOR) nid = _node_id(host) lines.append( f" style {nid} fill:{color},stroke:#333,color:#ffffff" ) if host in by_host: lines.append(f' click {nid} "{_host_url(host)}"') lines.append("```") return "\n".join(lines) + "\n" def render_page(rack: str, items: list[dict]) -> str: items = _sorted_items(items) lines: list[str] = [] lines.append(f"# Rack {rack}") lines.append("") lines.append( f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) " f"— do not edit by hand. Run `make docs-index` after changing a " f"source file._" ) lines.append("") lines.append("## Elevation") lines.append("") lines.append('
') lines.append(render_svg(rack, items).rstrip()) lines.append("
") lines.append("") lines.append(f"[Download SVG]({rack}-elevation.svg)") lines.append("") power = render_power(rack, items) if power: lines.append("## Power") lines.append("") lines.append(power.rstrip()) lines.append("") network = render_network(rack, items) if network: lines.append("## Network") lines.append("") lines.append(network.rstrip()) lines.append("") lines.append("## Occupancy") lines.append("") lines.append("| U | Device | Kind | Face | Status |") lines.append("|---|---|---|---|---|") by_host = {fm.get("hostname"): fm for fm in items} mounted_by_shelf: dict[str, list[dict]] = {} for fm in items: if "mounted_on" in fm: mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm) def occ_row(fm: dict) -> str: name = fm.get("hostname", "?") link = f"[{name}](../../hardware/{name}.md)" if "mounted_on" in fm: target = by_host.get(fm["mounted_on"]) if target and isinstance(target.get("rack_u"), int): su = target["rack_u"] sh = target["u_height"] cu = fm.get("chassis_u") if isinstance(cu, int) and not isinstance(cu, bool) and cu >= 1: base = su + sh - 1 # the shelf's bottom U; towers rise from it top = base - cu + 1 urange = f"U{base}" if cu == 1 else f"U{top}–U{base}" else: urange = f"U{su}" if sh == 1 else f"U{su}–U{su + sh - 1}" else: urange = "—" face = ( f"{fm.get('shelf_face', '')} · " f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}" ) else: face = fm.get("rack_face", "") if face in ZERO_U_FACES: urange = "0U" else: u = fm["rack_u"] h = fm["u_height"] urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}" return ( f"| {urange} | {link} | {fm.get('kind', '')} | {face} " f"| {fm.get('status', '')} |" ) for fm in _sorted_items([i for i in items if "mounted_on" not in i]): lines.append(occ_row(fm)) if fm.get("kind") == "shelf": occ = sorted( mounted_by_shelf.get(fm.get("hostname"), []), key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)), ) for m in occ: lines.append(occ_row(m)) lines.append("") return "\n".join(lines).rstrip() + "\n" def report_errors(errors: list[str]) -> None: """Print a collected list of problems with orientation for newcomers.""" print( f"\ngen_rack: found {len(errors)} problem(s) in docs/hardware/:", file=sys.stderr, ) for err in errors: print(f" ✗ {err}", file=sys.stderr) print( "\nEach line is ': what's wrong'. Fix the named frontmatter " "field(s),\nthen run 'make docs-index' again.\n" f"Guide: {GUIDE_URL}", file=sys.stderr, ) def generate(hardware_dir: Path, output_dir: Path) -> int: items = load_rack_items(hardware_dir) hw_index = load_hardware_index(hardware_dir) errors: list[str] = [] for fm in items: try: validate_item(fm) except SchemaError as e: errors.append(str(e)) racks: dict[str, list[dict]] = {} for fm in items: racks.setdefault(fm["rack"], []).append(fm) if not errors: # only check overlaps once placements are individually valid for rack, ritems in racks.items(): try: check_overlaps(ritems) validate_power(ritems) validate_links(ritems, hw_index) check_shelves(ritems) except SchemaError as e: errors.append(f"{rack}: {e}") if errors: report_errors(errors) return 1 output_dir.mkdir(parents=True, exist_ok=True) for rack in sorted(racks): ritems = racks[rack] (output_dir / f"{rack}-elevation.svg").write_text( render_svg(rack, ritems), encoding="utf-8" ) (output_dir / f"{rack}.md").write_text( render_page(rack, ritems), encoding="utf-8" ) print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))") return 0 def main() -> int: return generate(HARDWARE_DIR, OUTPUT_DIR) if __name__ == "__main__": sys.exit(main())