From 58f8c38cdd789ff63148374aef6dd11fd535d487 Mon Sep 17 00:00:00 2001
From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com>
Date: Tue, 18 Aug 2026 18:10:57 +0100
Subject: [PATCH 1/3] changed api maps to use the yaml files
---
.github/workflows/api-map.yml | 75 +++
web2/data/APIMap.json | 551 ++++++++++++++++++
web2/scripts/generate-api-map.js | 177 ++++++
.../app/api-tracker/api-tracker-client.tsx | 87 +--
web2/src/app/api-tracker/page.tsx | 19 +-
web2/src/lib/yaml.ts | 26 +
6 files changed, 894 insertions(+), 41 deletions(-)
create mode 100644 .github/workflows/api-map.yml
create mode 100644 web2/data/APIMap.json
create mode 100644 web2/scripts/generate-api-map.js
diff --git a/.github/workflows/api-map.yml b/.github/workflows/api-map.yml
new file mode 100644
index 0000000..b843d26
--- /dev/null
+++ b/.github/workflows/api-map.yml
@@ -0,0 +1,75 @@
+name: Generate API map
+
+on:
+ schedule:
+ # Weekly refresh — API-map.yaml files change far less often than commits.
+ - cron: "20 4 * * 1"
+ workflow_dispatch: {}
+
+permissions:
+ contents: write
+
+# Two runs committing to the same branch would race on push.
+concurrency:
+ group: api-map
+ cancel-in-progress: false
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ HAS_MIRROR_PAT: ${{ secrets.MIRROR_PAT != '' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: true
+ fetch-depth: 0
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ # Shared with the monthly-updates generator, which also reads this repo.
+ - name: Cache physlib clone
+ uses: actions/cache@v4
+ with:
+ path: web2/.cache/physlib.git
+ key: physlib-clone-${{ github.run_id }}
+ restore-keys: physlib-clone-
+
+ - name: Run generator
+ working-directory: web2
+ run: node scripts/generate-api-map.js
+
+ - name: Commit & push updated API map
+ id: commit
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add web2/data/APIMap.json
+ if git diff --cached --quiet; then
+ echo "No API map changes to commit."
+ echo "committed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ git commit -m "chore(api-map): auto-generated update"
+ git pull --rebase --autostash origin "${GITHUB_REF_NAME}"
+ git push origin "HEAD:${GITHUB_REF_NAME}"
+ echo "committed=true" >> "$GITHUB_OUTPUT"
+
+ # See monthly-updates.yml for why this step exists: pushes made with the
+ # default GITHUB_TOKEN don't trigger mirror-to-personal.yml, so without
+ # this the API map would never reach the deployed (Vercel) site.
+ - name: Mirror to personal repo (Vercel)
+ if: steps.commit.outputs.committed == 'true' && env.HAS_MIRROR_PAT == 'true'
+ env:
+ MIRROR_PAT: ${{ secrets.MIRROR_PAT }}
+ run: |
+ git config --unset-all http.https://github.com/.extraheader || true
+ git config --global credential.helper store
+ echo "https://x-access-token:${MIRROR_PAT}@github.com" > ~/.git-credentials
+ git remote add mirror https://github.com/Gabrielebattimelli/Physlib-Website.git
+ git push mirror "HEAD:refs/heads/${GITHUB_REF_NAME}"
diff --git a/web2/data/APIMap.json b/web2/data/APIMap.json
new file mode 100644
index 0000000..d5ab0f9
--- /dev/null
+++ b/web2/data/APIMap.json
@@ -0,0 +1,551 @@
+{
+ "repo": "leanprover-community/physlib",
+ "branch": "master",
+ "generatedAt": "2026-08-18T16:52:46.574Z",
+ "nodes": [
+ {
+ "path": "Physlib/ClassicalFieldTheory/GaugeTheory",
+ "title": "Gauge theory",
+ "requirementsDone": 8,
+ "requirementsTotal": 15,
+ "parents": [
+ {
+ "name": "Electromagnetic potential",
+ "path": "Physlib/Electromagnetism/Kinematics/EMPotential.lean"
+ },
+ {
+ "name": "Field strength",
+ "path": "Physlib/Electromagnetism/Kinematics/FieldStrength.lean"
+ },
+ {
+ "name": "Standard Model gauge group",
+ "path": "Physlib/Particles/StandardModel/Basic.lean"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/ClassicalFieldTheory/GaugeTheory/API-map.yaml"
+ },
+ {
+ "path": "Physlib/ClassicalMechanics/DampedHarmonicOscillator",
+ "title": "Damped harmonic oscillator",
+ "requirementsDone": 14,
+ "requirementsTotal": 14,
+ "parents": [
+ {
+ "name": "Harmonic oscillator",
+ "path": "Physlib/ClassicalMechanics/HarmonicOscillator"
+ },
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/ClassicalMechanics/DampedHarmonicOscillator/API-map.yaml"
+ },
+ {
+ "path": "Physlib/ClassicalMechanics/HarmonicOscillator/Geometric",
+ "title": "Configuration space of the harmonic oscillator",
+ "requirementsDone": 9,
+ "requirementsTotal": 9,
+ "parents": [
+ {
+ "name": "Harmonic oscillator",
+ "path": "Physlib/ClassicalMechanics/HarmonicOscillator"
+ },
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/ClassicalMechanics/HarmonicOscillator/Geometric/API-map.yaml"
+ },
+ {
+ "path": "Physlib/ClassicalMechanics/Lagrangian",
+ "title": "Configuration Space",
+ "requirementsDone": 0,
+ "requirementsTotal": 2,
+ "parents": [
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/ClassicalMechanics/Lagrangian/API-map.yaml"
+ },
+ {
+ "path": "Physlib/ClassicalMechanics/Pendulum",
+ "title": "Configuration space for pendulum",
+ "requirementsDone": 1,
+ "requirementsTotal": 5,
+ "parents": [
+ {
+ "name": "Classical mechanics Lagrangian",
+ "path": "Physlib/ClassicalMechanics/Lagrangian"
+ },
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/ClassicalMechanics/Pendulum/API-map.yaml"
+ },
+ {
+ "path": "Physlib/ClassicalMechanics/RigidBody",
+ "title": "Rigid body",
+ "requirementsDone": 10,
+ "requirementsTotal": 13,
+ "parents": [
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/ClassicalMechanics/RigidBody/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Cosmology/FLRW",
+ "title": "FLRW cosmology",
+ "requirementsDone": 5,
+ "requirementsTotal": 7,
+ "parents": [
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Cosmology/FLRW/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Electromagnetism/Kinematics",
+ "title": "Electromagnetic Potential",
+ "requirementsDone": 7,
+ "requirementsTotal": 8,
+ "parents": [
+ {
+ "name": "SpaceTime",
+ "path": "Physlib/SpaceAndTime/SpaceTime"
+ },
+ {
+ "name": "LorentzGroup",
+ "path": "Physlib/Relativity/LorentzGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Electromagnetism/Kinematics/API-map.yaml"
+ },
+ {
+ "path": "Physlib/FluidDynamics",
+ "title": "Fluid",
+ "requirementsDone": 5,
+ "requirementsTotal": 9,
+ "parents": [
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/FluidDynamics/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Particles/BeyondTheStandardModel/TwoHDM",
+ "title": "Two Higgs Doublet Model",
+ "requirementsDone": 13,
+ "requirementsTotal": 15,
+ "parents": [
+ {
+ "name": "Higgs Field",
+ "path": "Physlib/Particles/StandardModel/HiggsBoson"
+ },
+ {
+ "name": "Pauli matrices",
+ "path": "Physlib/Relativity/PauliMatrices"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Particles/BeyondTheStandardModel/TwoHDM/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Particles/StandardModel",
+ "title": "Standard Model gauge group",
+ "requirementsDone": 10,
+ "requirementsTotal": 15,
+ "parents": [],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Particles/StandardModel/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Particles/StandardModel/HiggsBoson",
+ "title": "Higgs Field",
+ "requirementsDone": 16,
+ "requirementsTotal": 21,
+ "parents": [
+ {
+ "name": "SpaceTime",
+ "path": "Physlib/SpaceAndTime/SpaceTime"
+ },
+ {
+ "name": "Standard Model gauge group",
+ "path": "Physlib/Particles/StandardModel"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Particles/StandardModel/HiggsBoson/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QFT/PerturbationTheory/FeynmanDiagrams",
+ "title": "Feynman diagrams",
+ "requirementsDone": 0,
+ "requirementsTotal": 6,
+ "parents": [
+ {
+ "name": "Field specification",
+ "path": "Physlib/QFT/PerturbationTheory/FieldSpecification"
+ },
+ {
+ "name": "Field operator free algebra",
+ "path": "Physlib/QFT/PerturbationTheory/FieldOpFreeAlgebra"
+ },
+ {
+ "name": "Wick algebra",
+ "path": "Physlib/QFT/PerturbationTheory/WickAlgebra"
+ },
+ {
+ "name": "Wick contractions",
+ "path": "Physlib/QFT/PerturbationTheory/WickContraction"
+ },
+ {
+ "name": "Koszul signs",
+ "path": "Physlib/QFT/PerturbationTheory/Koszul"
+ },
+ {
+ "name": "Field statistics",
+ "path": "Physlib/QFT/PerturbationTheory/FieldStatistics"
+ },
+ {
+ "name": "Lorentz group",
+ "path": "Physlib/Relativity/LorentzGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QFT/PerturbationTheory/FeynmanDiagrams/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QFT/TopologicalFieldTheory",
+ "title": "Topological field theory",
+ "requirementsDone": 0,
+ "requirementsTotal": 17,
+ "parents": [
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QFT/TopologicalFieldTheory/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QuantumMechanics/HilbertSpaces/SpaceD",
+ "title": "Hilbert spaces on Space",
+ "requirementsDone": 8,
+ "requirementsTotal": 15,
+ "parents": [
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QuantumMechanics/HilbertSpaces/SpaceD/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QuantumMechanics/HilbertSpaces/TensorProducts",
+ "title": "Hilbert space tensor products",
+ "requirementsDone": 3,
+ "requirementsTotal": 7,
+ "parents": [],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QuantumMechanics/HilbertSpaces/TensorProducts/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QuantumMechanics/Operators",
+ "title": "Operator algebra",
+ "requirementsDone": 25,
+ "requirementsTotal": 29,
+ "parents": [
+ {
+ "name": "Hilbert spaces on Space",
+ "path": "Physlib/QuantumMechanics/HilbertSpaces/SpaceD"
+ },
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Planck constant",
+ "path": "Physlib/QuantumMechanics/PlanckConstant.lean"
+ },
+ {
+ "name": "Partially defined linear maps",
+ "path": "Physlib/Mathematics/LinearPMap.lean"
+ },
+ {
+ "name": "Inner product spaces",
+ "path": "Physlib/Mathematics/InnerProductSpace"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QuantumMechanics/Operators/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QuantumMechanics/Operators/SpectralTheory",
+ "title": "Spectral theory of unbounded operators",
+ "requirementsDone": 14,
+ "requirementsTotal": 17,
+ "parents": [
+ {
+ "name": "Operator algebra",
+ "path": "Physlib/QuantumMechanics/Operators"
+ },
+ {
+ "name": "Partially defined linear maps",
+ "path": "Physlib/Mathematics/LinearPMap.lean"
+ },
+ {
+ "name": "Inner product spaces",
+ "path": "Physlib/Mathematics/InnerProductSpace"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QuantumMechanics/Operators/SpectralTheory/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QuantumMechanics/QuantumSystem",
+ "title": "QuantumSystem",
+ "requirementsDone": 3,
+ "requirementsTotal": 9,
+ "parents": [],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QuantumMechanics/QuantumSystem/API-map.yaml"
+ },
+ {
+ "path": "Physlib/QuantumMechanics/Qubit",
+ "title": "Qubits",
+ "requirementsDone": 7,
+ "requirementsTotal": 21,
+ "parents": [
+ {
+ "name": "Kets and bras of a finite dimensional system",
+ "path": "QuantumInfo/States/Pure"
+ },
+ {
+ "name": "Mixed states",
+ "path": "QuantumInfo/States/Mixed"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/QuantumMechanics/Qubit/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Relativity/Fermions/Dirac",
+ "title": "Dirac Fermions",
+ "requirementsDone": 9,
+ "requirementsTotal": 16,
+ "parents": [
+ {
+ "name": "Weyl Fermions",
+ "path": "Physlib/Relativity/Fermions/Weyl"
+ },
+ {
+ "name": "Lorentz Group",
+ "path": "Physlib/Relativity/LorentzGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Relativity/Fermions/Dirac/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Relativity/Fermions/Weyl",
+ "title": "Weyl Fermions",
+ "requirementsDone": 14,
+ "requirementsTotal": 18,
+ "parents": [
+ {
+ "name": "Lorentz Group",
+ "path": "Physlib/Relativity/LorentzGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Relativity/Fermions/Weyl/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Relativity/LorentzGroup",
+ "title": "Lorentz Group",
+ "requirementsDone": 9,
+ "requirementsTotal": 12,
+ "parents": [
+ {
+ "name": "MinkowskiMatrix",
+ "path": "Physlib/Relativity/MinkowskiMatrix.lean"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Relativity/LorentzGroup/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Relativity/Tensors",
+ "title": "Tensors",
+ "requirementsDone": 16,
+ "requirementsTotal": 22,
+ "parents": [
+ {
+ "name": "Lorentz Group",
+ "path": "Physlib/Relativity/LorentzGroup"
+ },
+ {
+ "name": "Weyl Fermions",
+ "path": "Physlib/Relativity/Fermions/Weyl"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Relativity/Tensors/API-map.yaml"
+ },
+ {
+ "path": "Physlib/SpaceAndTime/GalileanGroup",
+ "title": "Galilean group",
+ "requirementsDone": 8,
+ "requirementsTotal": 9,
+ "parents": [
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ },
+ {
+ "name": "Euclidean group",
+ "path": "Physlib/SpaceAndTime/Space/EuclideanGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/SpaceAndTime/GalileanGroup/API-map.yaml"
+ },
+ {
+ "path": "Physlib/SpaceAndTime/Space",
+ "title": "Space",
+ "requirementsDone": 16,
+ "requirementsTotal": 18,
+ "parents": [
+ {
+ "name": "Distribution",
+ "path": "Physlib/Mathematics/Distribution"
+ },
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ },
+ {
+ "name": "Lorentz vectors",
+ "path": "Physlib/Relativity/Tensors/RealTensor/Vector"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/SpaceAndTime/Space/API-map.yaml"
+ },
+ {
+ "path": "Physlib/SpaceAndTime/SpaceTime",
+ "title": "SpaceTime",
+ "requirementsDone": 9,
+ "requirementsTotal": 10,
+ "parents": [
+ {
+ "name": "Lorentz vectors",
+ "path": "Physlib/Relativity/Tensors/RealTensor/Vector"
+ },
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ },
+ {
+ "name": "Lorentz group",
+ "path": "Physlib/Relativity/LorentzGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/SpaceAndTime/SpaceTime/API-map.yaml"
+ },
+ {
+ "path": "Physlib/SpaceAndTime/Time",
+ "title": "Time",
+ "requirementsDone": 11,
+ "requirementsTotal": 16,
+ "parents": [
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Lorentz vectors",
+ "path": "Physlib/Relativity/Tensors/RealTensor/Vector"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/SpaceAndTime/Time/API-map.yaml"
+ },
+ {
+ "path": "Physlib/SpaceAndTime/TimeAndSpace",
+ "title": "TimeAndSpace",
+ "requirementsDone": 6,
+ "requirementsTotal": 6,
+ "parents": [
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ },
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Euclidean group",
+ "path": "Physlib/SpaceAndTime/Space/EuclideanGroup"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/SpaceAndTime/TimeAndSpace/API-map.yaml"
+ },
+ {
+ "path": "Physlib/StatisticalMechanics/CanonicalEnsemble",
+ "title": "Canonical ensemble",
+ "requirementsDone": 11,
+ "requirementsTotal": 13,
+ "parents": [
+ {
+ "name": "Temperature",
+ "path": "Physlib/Thermodynamics/Temperature"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/StatisticalMechanics/CanonicalEnsemble/API-map.yaml"
+ },
+ {
+ "path": "Physlib/Units",
+ "title": "Units",
+ "requirementsDone": 63,
+ "requirementsTotal": 67,
+ "parents": [
+ {
+ "name": "Time",
+ "path": "Physlib/SpaceAndTime/Time"
+ },
+ {
+ "name": "Space",
+ "path": "Physlib/SpaceAndTime/Space"
+ },
+ {
+ "name": "Mass",
+ "path": "Physlib/ClassicalMechanics/Mass"
+ },
+ {
+ "name": "Charge",
+ "path": "Physlib/Electromagnetism/Charge"
+ },
+ {
+ "name": "Temperature",
+ "path": "Physlib/Thermodynamics/Temperature"
+ }
+ ],
+ "url": "https://github.com/leanprover-community/physlib/blob/master/Physlib/Units/API-map.yaml"
+ }
+ ]
+}
diff --git a/web2/scripts/generate-api-map.js b/web2/scripts/generate-api-map.js
new file mode 100644
index 0000000..6185165
--- /dev/null
+++ b/web2/scripts/generate-api-map.js
@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+/*
+ * Generate the API dependency graph for the API Tracker page.
+ *
+ * Physlib records the design status of each API in an `API-map.yaml` file
+ * next to it (Title, ParentAPIs, Requirements with done/location, ...). This
+ * script walks a clone of leanprover-community/physlib for every such file,
+ * parses it, and writes the resulting graph to data/APIMap.json.
+ *
+ * Why not fetch this at request time (as the page used to, from GitHub
+ * issues): there is no API that lists "every API-map.yaml in the repo" apart
+ * from walking the git tree, and doing that from the browser on every page
+ * load would mean dozens of raw-content fetches against GitHub's anonymous
+ * rate limit. A local clone makes it one `ls-tree` plus one `show` per file,
+ * and reuses the same .cache/physlib.git clone the monthly-updates generator
+ * already keeps around.
+ *
+ * Usage:
+ * node scripts/generate-api-map.js
+ * node scripts/generate-api-map.js --repo # reuse an existing clone
+ */
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { execFileSync } = require("node:child_process");
+const yaml = require("js-yaml");
+
+const OWNER = "leanprover-community";
+const REPO = "physlib";
+const CLONE_URL = `https://github.com/${OWNER}/${REPO}.git`;
+const CACHE_REPO = path.join(__dirname, "..", ".cache", "physlib.git");
+const OUT_PATH = path.join(__dirname, "..", "data", "APIMap.json");
+const FILE_NAME = "API-map.yaml";
+
+const args = process.argv.slice(2);
+function argValue(name) {
+ const i = args.indexOf(name);
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
+}
+const repoArg = argValue("--repo") || process.env.PHYSLIB_REPO || "";
+
+// ─── Local git (same approach as generate-monthly-updates.js) ─────────────
+let REPO_DIR = "";
+
+function git(gitArgs) {
+ return execFileSync("git", ["-C", REPO_DIR, ...gitArgs], {
+ encoding: "utf8",
+ maxBuffer: 1 << 28,
+ });
+}
+
+function ensureRepo() {
+ if (repoArg) {
+ REPO_DIR = path.resolve(repoArg);
+ if (!fs.existsSync(path.join(REPO_DIR, ".git")) && !fs.existsSync(path.join(REPO_DIR, "HEAD"))) {
+ throw new Error(`--repo ${REPO_DIR} is not a git repository`);
+ }
+ console.log(`• using existing clone at ${REPO_DIR}`);
+ } else {
+ REPO_DIR = CACHE_REPO;
+ if (!fs.existsSync(REPO_DIR)) {
+ console.log(`• cloning ${CLONE_URL} → ${path.relative(process.cwd(), REPO_DIR)} (once)`);
+ fs.mkdirSync(path.dirname(REPO_DIR), { recursive: true });
+ execFileSync("git", ["clone", "--bare", CLONE_URL, REPO_DIR], { stdio: "inherit" });
+ }
+ }
+ try {
+ git(["fetch", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*", "--tags"]);
+ } catch {
+ try {
+ git(["fetch", "--quiet", "--all"]);
+ } catch {
+ console.warn(" ⚠ could not fetch; working from whatever the clone already has");
+ }
+ }
+}
+
+function resolveBranchRef() {
+ const candidates = [];
+ try {
+ const remotes = git(["remote", "-v"])
+ .split("\n")
+ .filter(Boolean)
+ .map((l) => l.split(/\s+/))
+ .filter(([, url]) => url && new RegExp(`[/:]${OWNER}/${REPO}(\\.git)?$`, "i").test(url))
+ .map(([name]) => name);
+ for (const r of [...new Set(remotes)]) {
+ candidates.push({ rev: `${r}/master`, name: "master" });
+ candidates.push({ rev: `${r}/main`, name: "main" });
+ }
+ } catch {
+ /* fall through to the generic candidates */
+ }
+ candidates.push(
+ { rev: "origin/master", name: "master" },
+ { rev: "origin/main", name: "main" },
+ { rev: "master", name: "master" },
+ { rev: "main", name: "main" },
+ );
+ for (const candidate of candidates) {
+ try {
+ git(["rev-parse", "--verify", "--quiet", candidate.rev]);
+ return candidate;
+ } catch {
+ /* try next */
+ }
+ }
+ throw new Error(`could not resolve ${OWNER}/${REPO}'s default branch in ${REPO_DIR}`);
+}
+
+// ─── ParentAPIs parsing ─────────────────────────────────────────────────────
+// Each entry is normally "Name (path/to/parent)". Some entries are a bare
+// path with no name (e.g. "Physlib/Relativity/MinkowskiMatrix.lean") - fall
+// back to the path's basename as the display name for those.
+function parseParentEntry(entry) {
+ const trimmed = String(entry).trim();
+ const match = trimmed.match(/^(.*)\s\(([^()]+)\)$/);
+ if (match) {
+ return { name: match[1].trim(), path: match[2].trim() };
+ }
+ const base = trimmed.split("/").pop().replace(/\.lean$/i, "");
+ return { name: base, path: trimmed };
+}
+
+// ─── Main ───────────────────────────────────────────────────────────────────
+(function main() {
+ ensureRepo();
+ const branch = resolveBranchRef();
+ console.log(`• reading API-map.yaml files from ${OWNER}/${REPO}@${branch.name}`);
+
+ const files = git(["ls-tree", "-r", branch.rev, "--name-only"])
+ .split("\n")
+ .filter((f) => f.toLowerCase().endsWith(`/${FILE_NAME.toLowerCase()}`) || f.toLowerCase() === FILE_NAME.toLowerCase());
+
+ const nodes = [];
+ for (const file of files) {
+ const raw = git(["show", `${branch.rev}:${file}`]);
+ let doc;
+ try {
+ doc = yaml.load(raw);
+ } catch (err) {
+ console.warn(` ⚠ skipping ${file}: ${err.message}`);
+ continue;
+ }
+ if (!doc || typeof doc !== "object") {
+ console.warn(` ⚠ skipping ${file}: empty or invalid document`);
+ continue;
+ }
+
+ const dir = file.slice(0, file.length - FILE_NAME.length - 1);
+ const requirements = Array.isArray(doc.Requirements) ? doc.Requirements : [];
+ const requirementsDone = requirements.filter((r) => r && r.done === true).length;
+ const parents = (Array.isArray(doc.ParentAPIs) ? doc.ParentAPIs : []).map(parseParentEntry);
+
+ nodes.push({
+ path: dir,
+ title: doc.Title ? String(doc.Title).trim() : dir,
+ requirementsDone,
+ requirementsTotal: requirements.length,
+ parents,
+ url: `https://github.com/${OWNER}/${REPO}/blob/${branch.name}/${file}`,
+ });
+ }
+
+ nodes.sort((a, b) => a.path.localeCompare(b.path));
+
+ const out = {
+ repo: `${OWNER}/${REPO}`,
+ branch: branch.name,
+ generatedAt: new Date().toISOString(),
+ nodes,
+ };
+
+ fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true });
+ fs.writeFileSync(OUT_PATH, JSON.stringify(out, null, 2) + "\n", "utf8");
+ console.log(`• wrote ${nodes.length} API nodes → ${path.relative(process.cwd(), OUT_PATH)}`);
+})();
diff --git a/web2/src/app/api-tracker/api-tracker-client.tsx b/web2/src/app/api-tracker/api-tracker-client.tsx
index 1ce4a5b..acd045a 100644
--- a/web2/src/app/api-tracker/api-tracker-client.tsx
+++ b/web2/src/app/api-tracker/api-tracker-client.tsx
@@ -2,43 +2,64 @@
import { useCallback } from "react";
import { GraphvizView } from "@/components/graphviz-view";
+import type { ApiMapNode } from "@/lib/yaml";
-async function buildDot(): Promise {
- const res = await fetch(
- "https://api.github.com/repos/leanprover-community/Physlib/issues?labels=API&per_page=100",
- );
- const data = await res.json();
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
- if (!Array.isArray(data)) throw new Error("Unexpected API response");
+function escapeDotId(str: string): string {
+ return str.replace(/"/g, '\\"');
+}
- const apiIssues = data.filter((issue: { labels: { name: string }[] }) =>
- issue.labels.some((l: { name: string }) => l.name === "API"),
- );
+function buildDot(nodes: ApiMapNode[]): string {
+ const byPath = new Map(nodes.map((n) => [n.path, n]));
+ // Parents referenced by path but with no API-map.yaml of their own get a
+ // plain node using the first name they were introduced under.
+ const phantomNames = new Map();
+ for (const node of nodes) {
+ for (const parent of node.parents) {
+ if (!byPath.has(parent.path) && !phantomNames.has(parent.path)) {
+ phantomNames.set(parent.path, parent.name);
+ }
+ }
+ }
let dot = "digraph G {\n rankdir=TB;\n node [shape=box, style=filled];\n";
- for (const issue of apiIssues) {
- const needsReqs = issue.labels.some(
- (l: { name: string }) => l.name === "requirements-needed",
- );
- const body: string = issue.body ?? "";
- const total = (body.match(/- \[[ x]\]/g) ?? []).length;
- const checked = (body.match(/- \[x\]/g) ?? []).length;
-
- const fillColor = needsReqs ? "#FFCCCC" : "#CCFFCC";
- const rawLabel = issue.title.replace(/"/g, '\\"').replace("API: ", "");
- const subLabel = needsReqs
- ? "Next step: Specify requirements"
- : `Next step: Build requirements (${checked}/${total})`;
-
- dot += ` "${issue.number}" [label=<${rawLabel}${subLabel} >, fillcolor="${fillColor}", URL="${issue.html_url}"];\n`;
-
- const parentMatch = body.match(/## Parent APIs\s+((?:#\d+\s*)+)/);
- if (parentMatch) {
- const parents = parentMatch[1].match(/#(\d+)/g) ?? [];
- for (const p of parents) {
- dot += ` "${parseInt(p.slice(1))}" -> "${issue.number}";\n`;
- }
+ for (const node of nodes) {
+ let subLabel: string;
+ let fillColor: string;
+ if (node.requirementsTotal === 0) {
+ subLabel = "No requirements defined yet";
+ fillColor = "#EEEEEE";
+ } else if (node.requirementsDone === node.requirementsTotal) {
+ subLabel = "Complete";
+ fillColor = "#CCFFCC";
+ } else if (node.requirementsDone === 0) {
+ subLabel = `Next step: build requirements (0/${node.requirementsTotal})`;
+ fillColor = "#FFCCCC";
+ } else {
+ subLabel = `${node.requirementsDone}/${node.requirementsTotal} requirements done`;
+ fillColor = "#FFE8A3";
+ }
+
+ const label = escapeHtml(node.title);
+ const id = escapeDotId(node.path);
+ dot += ` "${id}" [label=<${label}${subLabel} >, fillcolor="${fillColor}", URL="${node.url}"];\n`;
+ }
+
+ for (const [nodePath, name] of phantomNames) {
+ const id = escapeDotId(nodePath);
+ dot += ` "${id}" [label=<${escapeHtml(name)}>, fillcolor="#FFFFFF"];\n`;
+ }
+
+ for (const node of nodes) {
+ for (const parent of node.parents) {
+ dot += ` "${escapeDotId(parent.path)}" -> "${escapeDotId(node.path)}";\n`;
}
}
@@ -46,7 +67,7 @@ async function buildDot(): Promise {
return dot;
}
-export function APITrackerClient() {
- const getDot = useCallback(() => buildDot(), []);
+export function APITrackerClient({ nodes }: { nodes: ApiMapNode[] }) {
+ const getDot = useCallback(() => Promise.resolve(buildDot(nodes)), [nodes]);
return ;
}
diff --git a/web2/src/app/api-tracker/page.tsx b/web2/src/app/api-tracker/page.tsx
index 54fb2e6..e75a7d7 100644
--- a/web2/src/app/api-tracker/page.tsx
+++ b/web2/src/app/api-tracker/page.tsx
@@ -1,24 +1,27 @@
import type { Metadata } from "next";
+import { getApiMap } from "@/lib/yaml";
import { APITrackerClient } from "./api-tracker-client";
export const metadata: Metadata = {
title: "API Tracker | Physlib",
description:
- "Visualize the API dependency graph for Physlib based on GitHub issues.",
+ "Visualize the API dependency graph for Physlib based on its API-map.yaml files.",
};
-export default function APITrackerPage() {
+export default async function APITrackerPage() {
+ const { nodes } = await getApiMap();
+
return (
API Tracker
- This graph visualizes the API-related issues for Physlib. Feel free to
- explore the issues and help where you can! This graph does not currently
- show progress of the APIs being built, as some issues have not yet
- defined their requirements, even though the requirements may already
- exist in Physlib.
+ This graph visualizes the APIs defined across Physlib, built from each
+ API's API-map.yaml file. Green nodes have all their
+ requirements done, yellow nodes are in progress, and red nodes have no
+ requirements done yet. White nodes are referenced as a parent API but
+ don't have their own API-map.yaml yet.
-
+
);
}
diff --git a/web2/src/lib/yaml.ts b/web2/src/lib/yaml.ts
index cd39a34..f628b6a 100644
--- a/web2/src/lib/yaml.ts
+++ b/web2/src/lib/yaml.ts
@@ -76,6 +76,32 @@ export async function getTodo(): Promise {
return loadYaml("TODO.yml");
}
+// ─── API map ────────────────────────────────────────────────────────────
+export type ApiMapParent = {
+ name: string;
+ path: string;
+};
+
+export type ApiMapNode = {
+ path: string;
+ title: string;
+ requirementsDone: number;
+ requirementsTotal: number;
+ parents: ApiMapParent[];
+ url: string;
+};
+
+export type ApiMap = {
+ repo: string;
+ branch: string;
+ generatedAt: string;
+ nodes: ApiMapNode[];
+};
+
+export async function getApiMap(): Promise {
+ return loadJson(path.join(dataDir, "APIMap.json"));
+}
+
// ─── Monthly updates ──────────────────────────────────────────────────────
export type MonthlyContributor = {
login: string | null;
From 78fe175d458a0120ca322ab8900721698c401b02 Mon Sep 17 00:00:00 2001
From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com>
Date: Tue, 18 Aug 2026 18:44:29 +0100
Subject: [PATCH 2/3] added sidebar
---
.../skills/physlib-design-language/SKILL.md | 137 ++++++++++++++++++
.../app/api-tracker/api-tracker-client.tsx | 76 ++++++++--
web2/src/app/api-tracker/page.tsx | 3 +-
web2/src/app/api/api-map/route.ts | 11 ++
.../api-tracker/api-tracker-sidebar.tsx | 101 +++++++++++++
web2/src/components/conditional-sidebar.tsx | 2 +
web2/src/lib/api-map-tree.ts | 74 ++++++++++
7 files changed, 394 insertions(+), 10 deletions(-)
create mode 100644 .claude/skills/physlib-design-language/SKILL.md
create mode 100644 web2/src/app/api/api-map/route.ts
create mode 100644 web2/src/components/api-tracker/api-tracker-sidebar.tsx
create mode 100644 web2/src/lib/api-map-tree.ts
diff --git a/.claude/skills/physlib-design-language/SKILL.md b/.claude/skills/physlib-design-language/SKILL.md
new file mode 100644
index 0000000..aeecb05
--- /dev/null
+++ b/.claude/skills/physlib-design-language/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: physlib-design-language
+description: Design language, layout, and data-generation conventions for the Physlib website (web2 Next.js app). Load before adding or modifying any page, component, or data-generation script in web2, so new UI matches the rest of the site instead of inventing a one-off layout.
+---
+
+# Physlib website design language
+
+This site (`web2/`) has established, repeated conventions. New pages should
+reuse them rather than build ad-hoc layouts — a page that looks or is wired
+differently from its siblings is a design bug, not a stylistic choice.
+
+## Page shell
+
+Every content page wraps its content the same way:
+
+```tsx
+
+
Page Title
+
Intro copy.
+ {/* page content */}
+
+```
+
+`max-w-5xl` is the default. Don't widen it to fit extra UI (e.g. a sidebar) —
+see below, sidebars are not page content.
+
+## Root layout & the left sidebar slot
+
+`src/app/layout.tsx` renders a persistent flex row:
+
+```
+
+
+
+ {children}
+
+```
+
+`ConditionalSidebar` (`src/components/conditional-sidebar.tsx`) picks a
+sidebar by route:
+
+```ts
+if (pathname === "/") return null;
+if (pathname.startsWith("/monthly-updates")) return ;
+if (pathname.startsWith("/api-tracker")) return ;
+return ; // default: auto-built from h2/h3 on the page
+```
+
+**If a new page needs left-rail navigation (a list of items, a tree, a table
+of contents), add a branch here and a dedicated sidebar component.** Do not
+build the sidebar inline inside the page/client component with a local flex
+layout — that duplicates the shell, breaks the shared sticky/scroll behavior,
+and is the mistake this skill exists to prevent (it's what the API Tracker
+page did on the first pass, before being corrected to match this pattern).
+
+### Sidebar visual pattern
+
+Every left sidebar (`TableOfContents`, `MonthSidebar`, `ApiTrackerSidebar`)
+uses the same markup:
+
+```tsx
+
+
+
+ Section label
+
+
+
+
+```
+
+- Hidden below `lg`; sticky at `top-16`, independently scrollable
+ (`max-h-[calc(100vh-4rem)] overflow-y-auto`).
+- Active item = `text-accent font-medium` + a small accent dot prefix. Inactive
+ = `text-muted hover:text-foreground`.
+- Nested/indented levels use extra left padding (either a Tailwind step like
+ `pl-6` for a fixed two-level case, or `style={{ paddingLeft: 8 + depth * 16 }}`
+ for an arbitrary-depth tree) — never a second nested ``.
+- If the sidebar's content lives in a different part of the tree than the
+ page it controls (e.g. it's rendered by `ConditionalSidebar`, sibling to
+ ``, not a child of the page component), coordinate selection through
+ the URL — `router`/`pathname` for route-based active state (`MonthSidebar`),
+ or `window.location.hash` + a `hashchange` listener for in-page selection
+ that isn't a route change (`ApiTrackerSidebar` / `api-tracker-client.tsx`).
+ Don't reach for cross-tree React context for this — the existing sidebars
+ don't use it, and the URL is already the natural shared channel.
+
+## Color tokens
+
+Defined in `src/app/globals.css` as CSS variables, both light and dark:
+
+- `--background` / `--foreground` — page background / primary text.
+- `--accent` — the one brand blue, used for links, active states, primary
+ buttons (`text-accent`, `bg-accent`, `border-accent`).
+- `--muted` — secondary text (`text-muted`).
+- `--border` — hairlines (`border-border`).
+- `--surface` / `--surface-secondary` — card/table backgrounds.
+- Status colors (`success` / `warning` / `danger`) exist for state badges,
+ used as e.g. `bg-success/15 text-success border-success/30` (see the status
+ pills on `/apis`).
+
+Always use these Tailwind utility classes (`text-accent`, `text-muted`,
+`border-border`, etc.), never hardcoded hex colors, for anything that should
+adapt to light/dark mode. Hardcoded hex is only acceptable for content that
+is itself color-coded data (e.g. Graphviz node fill colors representing
+status) rather than UI chrome.
+
+## Generated data pattern
+
+Several pages are backed by data generated from the `leanprover-community/physlib`
+source repo rather than hand-authored:
+
+- A script in `web2/scripts/generate-*.js` clones/reuses a bare mirror at
+ `web2/.cache/physlib.git` (gitignored), reads what it needs via `git`
+ plumbing, and writes a committed file under `web2/data/`.
+- `src/lib/yaml.ts` exposes a typed accessor (`getX()`) that reads that file
+ at request/build time.
+- A GitHub Actions workflow (`.github/workflows/*.yml`, modeled on
+ `monthly-updates.yml`) runs the generator on a schedule, commits the result,
+ and explicitly mirrors the commit to the Vercel deploy repo (pushes made
+ with the default `GITHUB_TOKEN` don't trigger `mirror-to-personal.yml`).
+
+Follow this pattern for any new page whose data comes from the Physlib
+source repo rather than GitHub's live API — see `generate-api-map.js` /
+`data/APIMap.json` / `api-map.yml` (workflow) for the most recent example.
diff --git a/web2/src/app/api-tracker/api-tracker-client.tsx b/web2/src/app/api-tracker/api-tracker-client.tsx
index acd045a..c94ad6b 100644
--- a/web2/src/app/api-tracker/api-tracker-client.tsx
+++ b/web2/src/app/api-tracker/api-tracker-client.tsx
@@ -1,8 +1,12 @@
"use client";
-import { useCallback } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { GraphvizView } from "@/components/graphviz-view";
import type { ApiMapNode } from "@/lib/yaml";
+import { buildApiMapIdMap } from "@/lib/api-map-tree";
+
+const HIGHLIGHT_STROKE = "#2563eb";
+const HIGHLIGHT_WIDTH = "3";
function escapeHtml(str: string): string {
return str
@@ -11,11 +15,11 @@ function escapeHtml(str: string): string {
.replace(/>/g, ">");
}
-function escapeDotId(str: string): string {
+function escapeDotString(str: string): string {
return str.replace(/"/g, '\\"');
}
-function buildDot(nodes: ApiMapNode[]): string {
+function buildDot(nodes: ApiMapNode[], idOf: Map): string {
const byPath = new Map(nodes.map((n) => [n.path, n]));
// Parents referenced by path but with no API-map.yaml of their own get a
// plain node using the first name they were introduced under.
@@ -48,18 +52,19 @@ function buildDot(nodes: ApiMapNode[]): string {
}
const label = escapeHtml(node.title);
- const id = escapeDotId(node.path);
- dot += ` "${id}" [label=<${label}${subLabel} >, fillcolor="${fillColor}", URL="${node.url}"];\n`;
+ const dotId = escapeDotString(node.path);
+ const svgId = idOf.get(node.path)!;
+ dot += ` "${dotId}" [id="${svgId}", label=<${label}${subLabel} >, fillcolor="${fillColor}", URL="${node.url}"];\n`;
}
for (const [nodePath, name] of phantomNames) {
- const id = escapeDotId(nodePath);
- dot += ` "${id}" [label=<${escapeHtml(name)}>, fillcolor="#FFFFFF"];\n`;
+ const dotId = escapeDotString(nodePath);
+ dot += ` "${dotId}" [label=<${escapeHtml(name)}>, fillcolor="#FFFFFF"];\n`;
}
for (const node of nodes) {
for (const parent of node.parents) {
- dot += ` "${escapeDotId(parent.path)}" -> "${escapeDotId(node.path)}";\n`;
+ dot += ` "${escapeDotString(parent.path)}" -> "${escapeDotString(node.path)}";\n`;
}
}
@@ -68,6 +73,59 @@ function buildDot(nodes: ApiMapNode[]): string {
}
export function APITrackerClient({ nodes }: { nodes: ApiMapNode[] }) {
- const getDot = useCallback(() => Promise.resolve(buildDot(nodes)), [nodes]);
+ const idOf = useMemo(() => buildApiMapIdMap(nodes), [nodes]);
+ const getDot = useCallback(
+ () => Promise.resolve(buildDot(nodes, idOf)),
+ [nodes, idOf],
+ );
+
+ const highlightedRef = useRef(null);
+
+ // The sidebar (ApiTrackerSidebar) is mounted outside this component's tree
+ // - see the design-language skill - so selection is coordinated through
+ // the URL hash rather than shared React state.
+ useEffect(() => {
+ function highlight(svgId: string): boolean {
+ const shape = document
+ .getElementById(svgId)
+ ?.querySelector("polygon");
+ if (!shape) return false;
+ if (highlightedRef.current) {
+ highlightedRef.current.style.stroke = "";
+ highlightedRef.current.style.strokeWidth = "";
+ }
+ shape.style.stroke = HIGHLIGHT_STROKE;
+ shape.style.strokeWidth = HIGHLIGHT_WIDTH;
+ highlightedRef.current = shape;
+ return true;
+ }
+
+ let observer: MutationObserver | null = null;
+ let timeoutId: ReturnType | null = null;
+
+ function applyFromHash() {
+ const nodePath = decodeURIComponent(window.location.hash.replace(/^#/, ""));
+ const svgId = nodePath ? idOf.get(nodePath) : undefined;
+ if (!svgId) return;
+ if (highlight(svgId)) return;
+ // The graph renders asynchronously (Graphviz WASM), so the target node
+ // may not exist yet - wait for it rather than polling on a guess.
+ observer?.disconnect();
+ observer = new MutationObserver(() => {
+ if (highlight(svgId)) observer?.disconnect();
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ timeoutId = setTimeout(() => observer?.disconnect(), 10_000);
+ }
+
+ applyFromHash();
+ window.addEventListener("hashchange", applyFromHash);
+ return () => {
+ window.removeEventListener("hashchange", applyFromHash);
+ observer?.disconnect();
+ if (timeoutId) clearTimeout(timeoutId);
+ };
+ }, [idOf]);
+
return ;
}
diff --git a/web2/src/app/api-tracker/page.tsx b/web2/src/app/api-tracker/page.tsx
index e75a7d7..251dd97 100644
--- a/web2/src/app/api-tracker/page.tsx
+++ b/web2/src/app/api-tracker/page.tsx
@@ -19,7 +19,8 @@ export default async function APITrackerPage() {
API's API-map.yaml file. Green nodes have all their
requirements done, yellow nodes are in progress, and red nodes have no
requirements done yet. White nodes are referenced as a parent API but
- don't have their own API-map.yaml yet.
+ don't have their own API-map.yaml yet. Click a name in the list to
+ highlight it in the graph.
diff --git a/web2/src/app/api/api-map/route.ts b/web2/src/app/api/api-map/route.ts
new file mode 100644
index 0000000..09154b0
--- /dev/null
+++ b/web2/src/app/api/api-map/route.ts
@@ -0,0 +1,11 @@
+import { NextResponse } from "next/server";
+import { getApiMap } from "@/lib/yaml";
+
+// The sidebar mounts outside the /api-tracker page tree (it's rendered by
+// ConditionalSidebar at the root layout, see the design-language skill), so
+// it can't receive the page's server-fetched props and needs its own way to
+// reach data/APIMap.json client-side.
+export async function GET() {
+ const apiMap = await getApiMap();
+ return NextResponse.json(apiMap);
+}
diff --git a/web2/src/components/api-tracker/api-tracker-sidebar.tsx b/web2/src/components/api-tracker/api-tracker-sidebar.tsx
new file mode 100644
index 0000000..8229d11
--- /dev/null
+++ b/web2/src/components/api-tracker/api-tracker-sidebar.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ApiMap } from "@/lib/yaml";
+import { buildApiMapForest, type ApiMapTreeEntry } from "@/lib/api-map-tree";
+
+function decodeHash(): string {
+ return decodeURIComponent(window.location.hash.replace(/^#/, ""));
+}
+
+function TreeList({
+ entries,
+ depth,
+ activePath,
+}: {
+ entries: ApiMapTreeEntry[];
+ depth: number;
+ activePath: string;
+}) {
+ return (
+
+ );
+}
+
+export function ApiTrackerSidebar() {
+ const [forest, setForest] = useState(null);
+ const [activePath, setActivePath] = useState("");
+
+ useEffect(() => {
+ let cancelled = false;
+ fetch("/api/api-map")
+ .then((res) => res.json())
+ .then((data: ApiMap) => {
+ if (!cancelled) setForest(buildApiMapForest(data.nodes));
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ const onHashChange = () => setActivePath(decodeHash());
+ onHashChange();
+ window.addEventListener("hashchange", onHashChange);
+ return () => window.removeEventListener("hashchange", onHashChange);
+ }, []);
+
+ if (!forest || forest.length === 0) return null;
+
+ return (
+
+ {/*
+ * Widens to fit its content on hover instead of truncating every long
+ * title - w-52 -> w-96 is a fixed pixel target (not w-max) so the
+ * width transition actually animates; browsers don't tween to/from
+ * intrinsic-size keywords like max-content.
+ *
+ * dir="rtl" moves the vertical scrollbar to this edge - the one
+ * against the viewport - rather than the one against , which is
+ * also the edge that grows outward on hover; dir="ltr" on the inner
+ * wrapper undoes it for the actual content so text stays normal LTR.
+ */}
+
+
+
+
+ );
+}
diff --git a/web2/src/components/conditional-sidebar.tsx b/web2/src/components/conditional-sidebar.tsx
index 20aca12..e7ba736 100644
--- a/web2/src/components/conditional-sidebar.tsx
+++ b/web2/src/components/conditional-sidebar.tsx
@@ -3,10 +3,12 @@
import { usePathname } from "next/navigation";
import { TableOfContents } from "./table-of-contents";
import { MonthSidebar } from "./monthly-updates/month-sidebar";
+import { ApiTrackerSidebar } from "./api-tracker/api-tracker-sidebar";
export function ConditionalSidebar() {
const pathname = usePathname();
if (pathname === "/") return null;
if (pathname.startsWith("/monthly-updates")) return ;
+ if (pathname.startsWith("/api-tracker")) return ;
return ;
}
diff --git a/web2/src/lib/api-map-tree.ts b/web2/src/lib/api-map-tree.ts
new file mode 100644
index 0000000..15ba9aa
--- /dev/null
+++ b/web2/src/lib/api-map-tree.ts
@@ -0,0 +1,74 @@
+import type { ApiMapNode } from "@/lib/yaml";
+
+export type ApiMapTreeEntry = { node: ApiMapNode; children: ApiMapTreeEntry[] };
+
+/** Stable per-node id used to link a sidebar entry to its rendered SVG node. */
+export function buildApiMapIdMap(nodes: ApiMapNode[]): Map {
+ const map = new Map();
+ nodes.forEach((n, i) => map.set(n.path, `api-node-${i}`));
+ return map;
+}
+
+/**
+ * Nests nodes under their first parent that also has its own API-map.yaml
+ * (ParentAPIs is a DAG, not a tree - picking the first real parent avoids a
+ * node appearing under more than one branch). Nodes with no real parent, or
+ * only phantom parents, become top-level entries.
+ */
+export function buildApiMapForest(nodes: ApiMapNode[]): ApiMapTreeEntry[] {
+ const byPath = new Map(nodes.map((n) => [n.path, n]));
+ const childrenOf = new Map();
+ const primaryParentOf = new Map();
+
+ for (const node of nodes) {
+ const primaryParent = node.parents.find((p) => byPath.has(p.path));
+ if (primaryParent) {
+ primaryParentOf.set(node.path, primaryParent.path);
+ const siblings = childrenOf.get(primaryParent.path) ?? [];
+ siblings.push(node);
+ childrenOf.set(primaryParent.path, siblings);
+ }
+ }
+
+ // ParentAPIs can contain a cycle (e.g. two APIs each listing the other as
+ // a parent - Space and Time do). Each node's primary-parent pointer forms
+ // a functional graph (out-degree <= 1), so every chain either terminates
+ // at a true root or loops back on itself; find each loop's single entry
+ // point so *it* - not an arbitrary node inside or hanging off the loop -
+ // becomes the top-level entry the rest nests under. Without this, a node
+ // stuck in (or only reachable through) a cycle has no root to hang off of
+ // and silently disappears from the sidebar.
+ const walkState = new Map();
+ const cycleEntries = new Set();
+ function findCycleEntries(path: string): void {
+ if (walkState.has(path)) return;
+ walkState.set(path, "visiting");
+ const parent = primaryParentOf.get(path);
+ if (parent !== undefined) {
+ if (walkState.get(parent) === "visiting") cycleEntries.add(parent);
+ else findCycleEntries(parent);
+ }
+ walkState.set(path, "done");
+ }
+ for (const node of nodes) findCycleEntries(node.path);
+
+ const byTitle = (a: ApiMapNode, b: ApiMapNode) => a.title.localeCompare(b.title);
+ // Global backstop so a node is never rendered under more than one
+ // top-level entry; `pathVisited` below only guards a single branch against
+ // re-entering its own ancestors (the cycle itself).
+ const placed = new Set();
+
+ function toEntry(node: ApiMapNode, pathVisited: Set): ApiMapTreeEntry {
+ placed.add(node.path);
+ const children = (childrenOf.get(node.path) ?? [])
+ .filter((c) => !pathVisited.has(c.path) && !placed.has(c.path))
+ .sort(byTitle)
+ .map((c) => toEntry(c, new Set(pathVisited).add(c.path)));
+ return { node, children };
+ }
+
+ const roots = nodes.filter(
+ (n) => !primaryParentOf.has(n.path) || cycleEntries.has(n.path),
+ );
+ return roots.sort(byTitle).map((n) => toEntry(n, new Set([n.path])));
+}
From 113446b92bd2c8cece4d8e6ae22adcdd456da4f5 Mon Sep 17 00:00:00 2001
From: Alex-Zughaid <117576511+Alex-Zughaid@users.noreply.github.com>
Date: Tue, 18 Aug 2026 19:08:44 +0100
Subject: [PATCH 3/3] better sidebar + flowchart components
---
.../skills/physlib-design-language/SKILL.md | 18 ++-
.../app/api-tracker/api-tracker-client.tsx | 131 ------------------
web2/src/app/api-tracker/page.tsx | 19 +--
web2/src/app/globals.css | 79 ++++++++++-
.../components/api-tracker/api-flowchart.tsx | 100 +++++++++++++
.../api-tracker/api-tracker-sidebar.tsx | 39 ++++--
web2/src/lib/api-map-tree.ts | 127 ++++++++++++-----
7 files changed, 322 insertions(+), 191 deletions(-)
delete mode 100644 web2/src/app/api-tracker/api-tracker-client.tsx
create mode 100644 web2/src/components/api-tracker/api-flowchart.tsx
diff --git a/.claude/skills/physlib-design-language/SKILL.md b/.claude/skills/physlib-design-language/SKILL.md
index aeecb05..16f4820 100644
--- a/.claude/skills/physlib-design-language/SKILL.md
+++ b/.claude/skills/physlib-design-language/SKILL.md
@@ -93,9 +93,25 @@ uses the same markup:
``, not a child of the page component), coordinate selection through
the URL — `router`/`pathname` for route-based active state (`MonthSidebar`),
or `window.location.hash` + a `hashchange` listener for in-page selection
- that isn't a route change (`ApiTrackerSidebar` / `api-tracker-client.tsx`).
+ that isn't a route change (`ApiTrackerSidebar` / `api-flowchart.tsx`).
Don't reach for cross-tree React context for this — the existing sidebars
don't use it, and the URL is already the natural shared channel.
+- `` should normally stay a plain fixed `w-52` (that's what the flex
+ layout actually reserves, so it doesn't push ``'s centered
+ `max-w-5xl` content over) while `` inside it is free to be visually
+ wider - it can overflow past ``'s edge with no layout effect, which
+ `ApiTrackerSidebar` uses twice: by default, `` is sized with
+ `clamp(13rem, calc(100vw - 992px), 20rem)` so it fills the gutter up to
+ where the centered content actually starts instead of leaving it blank
+ (992 = max-w-5xl's 1024px + the content's own 16px px-4, solved so the
+ sidebar's right edge lands exactly on the content's left edge - see that
+ file's comment before reusing this elsewhere, since the constant is
+ coupled to page.tsx's own max-w-5xl/px-4 and needs to move with it); on
+ `hover:`, it goes wider still (`w-96`, a fixed target so the transition
+ actually animates - browsers don't tween to/from `max-content`/`w-max`)
+ to fit whatever's still truncated at the default's cap, gaining a
+ background/shadow only in that state since only then is it overlapping
+ real content rather than empty gutter.
## Color tokens
diff --git a/web2/src/app/api-tracker/api-tracker-client.tsx b/web2/src/app/api-tracker/api-tracker-client.tsx
deleted file mode 100644
index c94ad6b..0000000
--- a/web2/src/app/api-tracker/api-tracker-client.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef } from "react";
-import { GraphvizView } from "@/components/graphviz-view";
-import type { ApiMapNode } from "@/lib/yaml";
-import { buildApiMapIdMap } from "@/lib/api-map-tree";
-
-const HIGHLIGHT_STROKE = "#2563eb";
-const HIGHLIGHT_WIDTH = "3";
-
-function escapeHtml(str: string): string {
- return str
- .replace(/&/g, "&")
- .replace(//g, ">");
-}
-
-function escapeDotString(str: string): string {
- return str.replace(/"/g, '\\"');
-}
-
-function buildDot(nodes: ApiMapNode[], idOf: Map): string {
- const byPath = new Map(nodes.map((n) => [n.path, n]));
- // Parents referenced by path but with no API-map.yaml of their own get a
- // plain node using the first name they were introduced under.
- const phantomNames = new Map();
- for (const node of nodes) {
- for (const parent of node.parents) {
- if (!byPath.has(parent.path) && !phantomNames.has(parent.path)) {
- phantomNames.set(parent.path, parent.name);
- }
- }
- }
-
- let dot = "digraph G {\n rankdir=TB;\n node [shape=box, style=filled];\n";
-
- for (const node of nodes) {
- let subLabel: string;
- let fillColor: string;
- if (node.requirementsTotal === 0) {
- subLabel = "No requirements defined yet";
- fillColor = "#EEEEEE";
- } else if (node.requirementsDone === node.requirementsTotal) {
- subLabel = "Complete";
- fillColor = "#CCFFCC";
- } else if (node.requirementsDone === 0) {
- subLabel = `Next step: build requirements (0/${node.requirementsTotal})`;
- fillColor = "#FFCCCC";
- } else {
- subLabel = `${node.requirementsDone}/${node.requirementsTotal} requirements done`;
- fillColor = "#FFE8A3";
- }
-
- const label = escapeHtml(node.title);
- const dotId = escapeDotString(node.path);
- const svgId = idOf.get(node.path)!;
- dot += ` "${dotId}" [id="${svgId}", label=<${label}${subLabel} >, fillcolor="${fillColor}", URL="${node.url}"];\n`;
- }
-
- for (const [nodePath, name] of phantomNames) {
- const dotId = escapeDotString(nodePath);
- dot += ` "${dotId}" [label=<${escapeHtml(name)}>, fillcolor="#FFFFFF"];\n`;
- }
-
- for (const node of nodes) {
- for (const parent of node.parents) {
- dot += ` "${escapeDotString(parent.path)}" -> "${escapeDotString(node.path)}";\n`;
- }
- }
-
- dot += "}";
- return dot;
-}
-
-export function APITrackerClient({ nodes }: { nodes: ApiMapNode[] }) {
- const idOf = useMemo(() => buildApiMapIdMap(nodes), [nodes]);
- const getDot = useCallback(
- () => Promise.resolve(buildDot(nodes, idOf)),
- [nodes, idOf],
- );
-
- const highlightedRef = useRef(null);
-
- // The sidebar (ApiTrackerSidebar) is mounted outside this component's tree
- // - see the design-language skill - so selection is coordinated through
- // the URL hash rather than shared React state.
- useEffect(() => {
- function highlight(svgId: string): boolean {
- const shape = document
- .getElementById(svgId)
- ?.querySelector("polygon");
- if (!shape) return false;
- if (highlightedRef.current) {
- highlightedRef.current.style.stroke = "";
- highlightedRef.current.style.strokeWidth = "";
- }
- shape.style.stroke = HIGHLIGHT_STROKE;
- shape.style.strokeWidth = HIGHLIGHT_WIDTH;
- highlightedRef.current = shape;
- return true;
- }
-
- let observer: MutationObserver | null = null;
- let timeoutId: ReturnType | null = null;
-
- function applyFromHash() {
- const nodePath = decodeURIComponent(window.location.hash.replace(/^#/, ""));
- const svgId = nodePath ? idOf.get(nodePath) : undefined;
- if (!svgId) return;
- if (highlight(svgId)) return;
- // The graph renders asynchronously (Graphviz WASM), so the target node
- // may not exist yet - wait for it rather than polling on a guess.
- observer?.disconnect();
- observer = new MutationObserver(() => {
- if (highlight(svgId)) observer?.disconnect();
- });
- observer.observe(document.body, { childList: true, subtree: true });
- timeoutId = setTimeout(() => observer?.disconnect(), 10_000);
- }
-
- applyFromHash();
- window.addEventListener("hashchange", applyFromHash);
- return () => {
- window.removeEventListener("hashchange", applyFromHash);
- observer?.disconnect();
- if (timeoutId) clearTimeout(timeoutId);
- };
- }, [idOf]);
-
- return ;
-}
diff --git a/web2/src/app/api-tracker/page.tsx b/web2/src/app/api-tracker/page.tsx
index 251dd97..ce00e42 100644
--- a/web2/src/app/api-tracker/page.tsx
+++ b/web2/src/app/api-tracker/page.tsx
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { getApiMap } from "@/lib/yaml";
-import { APITrackerClient } from "./api-tracker-client";
+import { ApiFlowchart } from "@/components/api-tracker/api-flowchart";
export const metadata: Metadata = {
title: "API Tracker | Physlib",
description:
- "Visualize the API dependency graph for Physlib based on its API-map.yaml files.",
+ "Visualize the API dependency flowchart for Physlib based on its API-map.yaml files.",
};
export default async function APITrackerPage() {
@@ -15,14 +15,15 @@ export default async function APITrackerPage() {
API Tracker
- This graph visualizes the APIs defined across Physlib, built from each
- API's API-map.yaml file. Green nodes have all their
- requirements done, yellow nodes are in progress, and red nodes have no
- requirements done yet. White nodes are referenced as a parent API but
- don't have their own API-map.yaml yet. Click a name in the list to
- highlight it in the graph.
+ This flowchart visualizes the APIs defined across Physlib, built from
+ each API's API-map.yaml file, with each box's
+ parent shown above it. Green boxes have all their requirements done,
+ yellow boxes are in progress, and red boxes have no requirements done
+ yet. Dashed boxes are referenced as a parent API but don't have
+ their own API-map.yaml yet. Click a name in the sidebar or a box to
+ highlight it.
-
+
);
}
diff --git a/web2/src/app/globals.css b/web2/src/app/globals.css
index b42085e..32a46eb 100644
--- a/web2/src/app/globals.css
+++ b/web2/src/app/globals.css
@@ -156,10 +156,87 @@ body {
}
/* ─── TOC anchor offset (clears fixed navbar) ───────────────── */
-h2[id], h3[id] {
+h2[id], h3[id], .api-tree a[id] {
scroll-margin-top: 5rem;
}
+/*
+ * ─── API tracker flowchart connectors ─────────────────────────
+ * Classic pure-CSS org-chart technique: every non-root draws a vertical
+ * stem up to a shared horizontal bus (::before), and contributes half of
+ * that bus itself (::after) - the first child's half starts at its own
+ * center, the last child's half ends at its own center, and middle children
+ * draw the full width, so the segments chain into one continuous line
+ * spanning exactly from the first sibling's center to the last. A parent
+ * with children gets its own stem down to that bus, drawn on the that
+ * holds them so it's centered on the group rather than on any one sibling.
+ */
+.api-tree, .api-tree ul {
+ display: flex;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.api-tree {
+ padding: 2rem 1.5rem;
+}
+.api-tree li {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0 0.75rem;
+}
+.api-tree li > ul {
+ position: relative;
+ padding-top: 2.5rem;
+}
+/* Parent's own stem down to the bus connecting its children - top:0 here
+ sits at the 's own top edge (absolute positioning ignores the box's
+ own padding), spanning down across the whole reserved padding-top zone. */
+.api-tree li > ul::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 50%;
+ width: 1px;
+ height: 2.5rem;
+ background: var(--border);
+}
+/* Each child's stem up to that bus - top:-2.5rem because the itself has
+ no reserved space above it; the stem has to reach backward into its
+ parent 's padding-top zone rather than grow down into its own box. */
+.api-tree ul li::before {
+ content: "";
+ position: absolute;
+ top: -2.5rem;
+ left: 50%;
+ width: 1px;
+ height: 2.5rem;
+ background: var(--border);
+}
+/* This child's segment of the horizontal bus, at the same y as the stem's
+ top end. */
+.api-tree ul li::after {
+ content: "";
+ position: absolute;
+ top: -2.5rem;
+ left: 0;
+ width: 100%;
+ height: 1px;
+ background: var(--border);
+}
+.api-tree ul li:first-child::after {
+ left: 50%;
+ width: 50%;
+}
+.api-tree ul li:last-child::after {
+ width: 50%;
+}
+.api-tree ul li:only-child::after {
+ display: none;
+}
+
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
diff --git a/web2/src/components/api-tracker/api-flowchart.tsx b/web2/src/components/api-tracker/api-flowchart.tsx
new file mode 100644
index 0000000..edf696c
--- /dev/null
+++ b/web2/src/components/api-tracker/api-flowchart.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ApiMapNode } from "@/lib/yaml";
+import { buildApiMapForest, type ApiMapTreeEntry } from "@/lib/api-map-tree";
+
+function statusStyle(node: ApiMapNode): { box: string; label: string } {
+ if (node.requirementsTotal === 0) {
+ return { box: "bg-surface-secondary border-border", label: "No requirements yet" };
+ }
+ if (node.requirementsDone === node.requirementsTotal) {
+ return { box: "bg-success/15 border-success/40", label: "Complete" };
+ }
+ if (node.requirementsDone === 0) {
+ return {
+ box: "bg-danger/10 border-danger/30",
+ label: `0/${node.requirementsTotal} requirements done`,
+ };
+ }
+ return {
+ box: "bg-warning/15 border-warning/40",
+ label: `${node.requirementsDone}/${node.requirementsTotal} requirements done`,
+ };
+}
+
+function FlowNode({
+ entry,
+ activePath,
+}: {
+ entry: ApiMapTreeEntry;
+ activePath: string;
+}) {
+ const { path, title, node, children, primaryParentPath } = entry;
+ const isActive = path === activePath;
+
+ // Only one parent becomes this box's connector line (see api-map-tree.ts);
+ // any others are still real dependencies, so surface them as text instead
+ // of silently dropping them.
+ const otherParents = node ? node.parents.filter((p) => p.path !== primaryParentPath) : [];
+
+ const style = node
+ ? statusStyle(node)
+ : { box: "border-dashed border-border bg-surface-secondary/60", label: "No API-map.yaml yet" };
+
+ return (
+
+
+
+ {title}
+
+ {style.label}
+ {otherParents.length > 0 && (
+
+ Also needs: {otherParents.map((p) => p.name).join(", ")}
+
+ )}
+
+ {children.length > 0 && (
+
+ {children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function ApiFlowchart({ nodes }: { nodes: ApiMapNode[] }) {
+ const [activePath, setActivePath] = useState("");
+
+ useEffect(() => {
+ const onHashChange = () =>
+ setActivePath(decodeURIComponent(window.location.hash.replace(/^#/, "")));
+ onHashChange();
+ window.addEventListener("hashchange", onHashChange);
+ return () => window.removeEventListener("hashchange", onHashChange);
+ }, []);
+
+ const forest = buildApiMapForest(nodes);
+
+ return (
+
+
+ {forest.map((entry) => (
+
+ ))}
+
+
+ );
+}
diff --git a/web2/src/components/api-tracker/api-tracker-sidebar.tsx b/web2/src/components/api-tracker/api-tracker-sidebar.tsx
index 8229d11..6e2462c 100644
--- a/web2/src/components/api-tracker/api-tracker-sidebar.tsx
+++ b/web2/src/components/api-tracker/api-tracker-sidebar.tsx
@@ -19,22 +19,27 @@ function TreeList({
}) {
return (
- {entries.map(({ node, children }) => {
- const isActive = node.path === activePath;
+ {entries.map(({ path, title, node, children }) => {
+ const isActive = path === activePath;
+ const isPhantom = node === null;
return (
-
+
{isActive && (
)}
- {node.title}
+ {title}
{children.length > 0 && (
@@ -75,10 +80,20 @@ export function ApiTrackerSidebar() {
return (
{/*
- * Widens to fit its content on hover instead of truncating every long
- * title - w-52 -> w-96 is a fixed pixel target (not w-max) so the
- * width transition actually animates; browsers don't tween to/from
- * intrinsic-size keywords like max-content.
+ * itself stays a fixed w-52 (13rem) - that's what the flex
+ * layout reserves, so it doesn't push 's centered max-w-5xl
+ * content over. is the visible piece and is free to overflow
+ * past 's edge without affecting layout (same trick the hover
+ * state below already used) - by default it grows to fill the gutter
+ * up to where that centered content starts, instead of leaving it
+ * blank. That gap is `(mainWidth - 1024px)/2`, mainWidth is
+ * `100vw - asideWidth`, and the content itself adds another 16px of
+ * its own px-4 before text starts - solving default-width = gap for a
+ * self-consistent width gives calc(100vw - 992px). If page.tsx's
+ * max-w-5xl/px-4 ever changes, this 992 needs to move with it.
+ * Clamped so it never shrinks below the old fixed w-52, or grows
+ * past a sensible cap - hover still goes wider still (24rem), to fit
+ * whatever's still truncated at the clamp's max.
*
* dir="rtl" moves the vertical scrollbar to this edge - the one
* against the viewport - rather than the one against , which is
@@ -87,7 +102,7 @@ export function ApiTrackerSidebar() {
*/}
diff --git a/web2/src/lib/api-map-tree.ts b/web2/src/lib/api-map-tree.ts
index 15ba9aa..eff97ae 100644
--- a/web2/src/lib/api-map-tree.ts
+++ b/web2/src/lib/api-map-tree.ts
@@ -1,74 +1,127 @@
import type { ApiMapNode } from "@/lib/yaml";
-export type ApiMapTreeEntry = { node: ApiMapNode; children: ApiMapTreeEntry[] };
+/**
+ * One entry in the API tree. `node` is null for a "phantom" entry - a path
+ * named in some API's ParentAPIs that has no API-map.yaml of its own. Those
+ * still get a path/title and are still navigable (sidebar link, flowchart
+ * box), just with no requirements/progress data behind them.
+ */
+export type ApiMapTreeEntry = {
+ path: string;
+ title: string;
+ node: ApiMapNode | null;
+ /** The parent this entry is nested under here - null for a top-level entry.
+ * A real node can have other (non-primary) parents too; see `node.parents`
+ * for those. */
+ primaryParentPath: string | null;
+ children: ApiMapTreeEntry[];
+};
-/** Stable per-node id used to link a sidebar entry to its rendered SVG node. */
-export function buildApiMapIdMap(nodes: ApiMapNode[]): Map {
- const map = new Map();
- nodes.forEach((n, i) => map.set(n.path, `api-node-${i}`));
+type PrimaryParent = { path: string; name: string };
+
+/**
+ * Each real node's first parent - preferring one that also has its own
+ * API-map.yaml, but falling back to the first phantom parent when it has no
+ * real one. ParentAPIs is a DAG, not a tree; picking one edge per node is
+ * what makes a tree renderable at all.
+ */
+function buildPrimaryParentMap(nodes: ApiMapNode[]): Map {
+ const byPath = new Map(nodes.map((n) => [n.path, n]));
+ const map = new Map();
+ for (const node of nodes) {
+ const realParent = node.parents.find((p) => byPath.has(p.path));
+ const parent = realParent ?? node.parents[0];
+ if (parent) map.set(node.path, parent);
+ }
return map;
}
/**
- * Nests nodes under their first parent that also has its own API-map.yaml
- * (ParentAPIs is a DAG, not a tree - picking the first real parent avoids a
- * node appearing under more than one branch). Nodes with no real parent, or
- * only phantom parents, become top-level entries.
+ * Nests every real API under its primary parent (see above), and gives every
+ * phantom parent its own top-level entry so it's still visible and clickable
+ * even though it has no API-map.yaml behind it.
*/
export function buildApiMapForest(nodes: ApiMapNode[]): ApiMapTreeEntry[] {
const byPath = new Map(nodes.map((n) => [n.path, n]));
- const childrenOf = new Map();
- const primaryParentOf = new Map();
+ const primaryParentOf = buildPrimaryParentMap(nodes);
+ const childrenOf = new Map();
for (const node of nodes) {
- const primaryParent = node.parents.find((p) => byPath.has(p.path));
- if (primaryParent) {
- primaryParentOf.set(node.path, primaryParent.path);
- const siblings = childrenOf.get(primaryParent.path) ?? [];
+ const parent = primaryParentOf.get(node.path);
+ if (parent) {
+ const siblings = childrenOf.get(parent.path) ?? [];
siblings.push(node);
- childrenOf.set(primaryParent.path, siblings);
+ childrenOf.set(parent.path, siblings);
}
}
- // ParentAPIs can contain a cycle (e.g. two APIs each listing the other as
- // a parent - Space and Time do). Each node's primary-parent pointer forms
- // a functional graph (out-degree <= 1), so every chain either terminates
- // at a true root or loops back on itself; find each loop's single entry
- // point so *it* - not an arbitrary node inside or hanging off the loop -
- // becomes the top-level entry the rest nests under. Without this, a node
- // stuck in (or only reachable through) a cycle has no root to hang off of
- // and silently disappears from the sidebar.
+ // ParentAPIs can contain a cycle among *real* nodes (e.g. two APIs each
+ // listing the other as a parent - Space and Time do). Each real node's
+ // primary-parent pointer forms a functional graph (out-degree <= 1), so
+ // every chain either terminates (at a true root or a phantom) or loops
+ // back on itself; find each loop's single entry point so *it* - not an
+ // arbitrary node inside or hanging off the loop - becomes the top-level
+ // entry the rest nests under. Without this, a node stuck in a cycle has no
+ // root to hang off of and silently disappears from the tree.
const walkState = new Map();
const cycleEntries = new Set();
function findCycleEntries(path: string): void {
if (walkState.has(path)) return;
walkState.set(path, "visiting");
const parent = primaryParentOf.get(path);
- if (parent !== undefined) {
- if (walkState.get(parent) === "visiting") cycleEntries.add(parent);
- else findCycleEntries(parent);
+ if (parent && byPath.has(parent.path)) {
+ if (walkState.get(parent.path) === "visiting") cycleEntries.add(parent.path);
+ else findCycleEntries(parent.path);
}
walkState.set(path, "done");
}
for (const node of nodes) findCycleEntries(node.path);
- const byTitle = (a: ApiMapNode, b: ApiMapNode) => a.title.localeCompare(b.title);
+ const byTitle = (a: { title: string }, b: { title: string }) =>
+ a.title.localeCompare(b.title);
// Global backstop so a node is never rendered under more than one
// top-level entry; `pathVisited` below only guards a single branch against
// re-entering its own ancestors (the cycle itself).
const placed = new Set();
- function toEntry(node: ApiMapNode, pathVisited: Set): ApiMapTreeEntry {
- placed.add(node.path);
- const children = (childrenOf.get(node.path) ?? [])
+ function toEntry(
+ path: string,
+ title: string,
+ node: ApiMapNode | null,
+ pathVisited: Set,
+ ): ApiMapTreeEntry {
+ placed.add(path);
+ const children = (childrenOf.get(path) ?? [])
.filter((c) => !pathVisited.has(c.path) && !placed.has(c.path))
.sort(byTitle)
- .map((c) => toEntry(c, new Set(pathVisited).add(c.path)));
- return { node, children };
+ .map((c) => toEntry(c.path, c.title, c, new Set(pathVisited).add(c.path)));
+ return { path, title, node, primaryParentPath: primaryParentOf.get(path)?.path ?? null, children };
+ }
+
+ // A real node roots the forest itself when it has no parent at all, or
+ // when it's the entry point of a cycle. Anything else - including a node
+ // whose only parent is a phantom - gets placed as somebody's child below.
+ const realRoots = nodes.filter((n) => {
+ const parent = primaryParentOf.get(n.path);
+ return !parent || cycleEntries.has(n.path);
+ });
+
+ // Every phantom path that's actually somebody's primary parent becomes its
+ // own top-level entry, in first-referenced order.
+ const phantomRoots: PrimaryParent[] = [];
+ for (const node of nodes) {
+ const parent = primaryParentOf.get(node.path);
+ if (parent && !byPath.has(parent.path) && !phantomRoots.some((p) => p.path === parent.path)) {
+ phantomRoots.push(parent);
+ }
}
- const roots = nodes.filter(
- (n) => !primaryParentOf.has(n.path) || cycleEntries.has(n.path),
- );
- return roots.sort(byTitle).map((n) => toEntry(n, new Set([n.path])));
+ const forest = [
+ ...realRoots.map((n) => toEntry(n.path, n.title, n, new Set([n.path]))),
+ ...phantomRoots
+ .filter((p) => !placed.has(p.path))
+ .map((p) => toEntry(p.path, p.name, null, new Set([p.path]))),
+ ];
+
+ return forest.sort(byTitle);
}