Compare commits

...

14 Commits

Author SHA1 Message Date
9021cbe3c1 docgen ui combine 2026-09-24 00:58:19 -03:00
73998020b3 docgen graphgen ui and converging update 2026-09-24 00:58:19 -03:00
b693e324ef init docgen 2026-09-24 00:58:19 -03:00
12ddd01940 updates 38.1-164 2026-09-24 00:58:19 -03:00
5ff513fcc5 updates 38.1 90 2026-09-24 00:58:19 -03:00
4fe3718795 updated conventions 2026-09-24 00:58:19 -03:00
514efc5e52 handover agent doing docgen-graphgen 2026-09-24 00:43:18 -03:00
8fad2223f3 big update based on real wokr 2026-09-24 00:43:18 -03:00
45018e4743 berth updates 2026-09-24 00:43:18 -03:00
907b616d1b berth updates 2026-09-24 00:43:18 -03:00
b3cf24e750 ui exporting and docgen testing 2026-09-24 00:43:18 -03:00
88adcd68bc ui work 2026-09-24 00:43:18 -03:00
54c577a700 ui refactor phase 2 2026-09-24 00:43:18 -03:00
0ce36b98e4 ui work phase 1 2026-09-24 00:43:18 -03:00
172 changed files with 16876 additions and 2100 deletions

View File

@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop dist theme docs cluster deploy component
.PHONY: help build start stop dist theme ui docs cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
@@ -58,14 +58,19 @@ start: ## run a built room [<room>] [-d] [--build]
stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [NAME|FILE]
bash ctrl/dist.sh $(ARGS)
# ── theme ──────────────────────────────────────────────────────────────────
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
bash ctrl/theme.sh $(or $(ARGS),bake)
# ── ui ─────────────────────────────────────────────────────────────────────
ui: ## slices of the UI [list|scan DIR|slice FILE|check FILE|reference|selftest]
bash ctrl/ui.sh $(or $(ARGS),list)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)

5
berth/.gitignore vendored
View File

@@ -19,3 +19,8 @@ def/
# slash resolves against this file's own directory. `git check-ignore -v` is the
# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does.
ctrl/.secrets/
# Snapshots of a remote box's state (`host.sh snapshot --run`). Not secrets —
# the probe refuses to read environments, compose files or logs — but machine
# state from production, and never source. Anchored to ctrl/ for the reason above.
ctrl/.snapshots/

View File

@@ -50,7 +50,7 @@ certs: ## TLS [status|verify|renew|push] (default stat
# ── the box ────────────────────────────────────────────────────────────────
host: ## the remote box [status|ports|services] (default status)
host: ## the remote box [status|ports|services|snapshot|check] (default status)
bash ctrl/host.sh $(or $(ARGS),status)
registry: ## the image registry [status] (default status)

View File

@@ -2,13 +2,28 @@
# The remote box — the other half of the inventory.
#
# Usage:
# ./host.sh status|ports|services
# ./host.sh status|ports|services announce a batch; run nothing
# ./host.sh snapshot [--admin] print the probe that would run
# ./host.sh snapshot --run [--admin] run it, save it, then check it
# ./host.sh check [<snapshot-dir>] is the box healthy? offline, never ssh
#
# Announces what it would run over `ssh $HOST` and does not run it. Always the
# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare
# hostname offers every agent key and trips MaxAuthTries.
# Always the ssh alias, never a hostname: there is no `Host mcrn.ar` block, so
# a bare hostname offers every agent key and trips MaxAuthTries.
#
# snapshot + check is rig's `make check` asked of a REMOTE machine. rig's check
# reports and instructs and never fixes; so does this. rig's check already reads
# /proc/meminfo through an override path so its branches can be exercised
# "against another machine's real numbers" — this is that idea taken the rest
# of the way: collect the other machine's numbers ONCE, over one announced ssh
# session, then answer every question from the saved copy. The analysis can
# then be re-run, tested against fixtures, and compared across days, and it
# never needs the box to be reachable — which, on a box that is failing, it
# may not be.
set -euo pipefail
# Kept before the cd: a snapshot path given on the command line is relative to
# where the user stood, not to ctrl/.
CALLER_PWD="$PWD"
cd "$(dirname "$0")"
source ./lib/config.sh
@@ -31,6 +46,273 @@ announce_batch() {
echo "learned rather than scrolled past."
}
# Overridable so selftest can point check at an empty root. snapshot still
# refuses to write anywhere git does not confirm is ignored — a root outside
# the repo is refused, not honoured.
SNAP_ROOT="${SNAP_ROOT:-./.snapshots}"
snapshot() {
local run="" admin=""
for a in "$@"; do
case "$a" in
--run) run=1 ;;
--admin) admin=1 ;;
*) echo "snapshot: unknown argument '$a'" >&2; exit 1 ;;
esac
done
if [ -z "$run" ]; then
echo "would send, over 'ssh $HOST bash -s', exactly this file — ctrl/remote/probe.sh:"
echo
sed 's/^/ | /' ./remote/probe.sh
if [ -n "$admin" ]; then
echo
echo "and, over 'ssh $HOST_ADMIN bash -s', exactly this — ctrl/remote/probe-admin.sh:"
echo
sed 's/^/ | /' ./remote/probe-admin.sh
fi
echo
echo "read-only, and NOT run. What you read above is the file that is sent;"
echo "there is no second copy. To run it:"
echo " bash ctrl/host.sh snapshot --run${admin:+ --admin}"
return 0
fi
local dir; dir="$SNAP_ROOT/$ESTATE/$(date -u +%Y%m%dT%H%M%SZ)"
assert_ignored "$dir/raw" # before anything is written, not after
mkdir -p "$dir"
# Interrupted (Ctrl-C on a slow box), keep what arrived: split it and mark
# it partial. Unsplit, it was invisible to check — and newer than the last
# good snapshot, so it hid that one too.
trap 'echo " ! interrupted — keeping the partial snapshot" >&2;
echo "interrupted" > "$dir/PARTIAL"; split_sections "$dir/raw" "$dir";
[ -f "$dir/raw-admin" ] && split_sections "$dir/raw-admin" "$dir"; exit 130' INT
echo "ssh $HOST bash -s < ctrl/remote/probe.sh"
if ! ssh -o BatchMode=yes "$HOST" bash -s < ./remote/probe.sh > "$dir/raw" 2> "$dir/stderr"; then
echo " ! ssh $HOST exited non-zero — output so far is kept in $dir" >&2
sed 's/^/ /' "$dir/stderr" >&2
fi
split_sections "$dir/raw" "$dir"
if [ -n "$admin" ]; then
echo "ssh $HOST_ADMIN bash -s < ctrl/remote/probe-admin.sh"
ssh -o BatchMode=yes "$HOST_ADMIN" bash -s < ./remote/probe-admin.sh \
> "$dir/raw-admin" 2>> "$dir/stderr" || \
echo " ! ssh $HOST_ADMIN exited non-zero — see $dir/stderr" >&2
split_sections "$dir/raw-admin" "$dir"
fi
trap - INT
echo "saved: $dir"
echo
check "$dir"
}
# One file per `### name` section. An empty section still gets its file, so
# "the probe ran and found nothing" is distinguishable from "it never ran".
split_sections() {
awk -v d="$2" '/^### /{ f = d "/" $2; printf "" > f; next } f { print > f }' "$1"
}
# docker prints decimal units (kB, MB, GB). Approximate on purpose: these are
# for choosing between levers, not for accounting.
AWK_MB='function mb(s, v, u) {
v = s; u = s; sub(/[A-Za-z]+$/, "", v); sub(/^[0-9.]+/, "", u)
if (u == "B") return v / 1e6
if (u == "kB" || u == "KB") return v / 1e3
if (u == "MB") return v + 0
if (u == "GB") return v * 1e3
if (u == "TB") return v * 1e6
return 0
}'
check() {
local f="${1:-}"
if [ -z "$f" ]; then
# `|| true`: with no snapshots, ls fails, pipefail fails the pipeline and
# set -e exits silently — on exactly the path that must explain itself.
f=$(ls -1d "$SNAP_ROOT/$ESTATE"/*/meta 2>/dev/null | sort | tail -1) || true
f=${f%/meta}
fi
# Exit 1 here, unlike every other report in berth: with no snapshot there is
# no report, and a check that prints nothing alarming because it looked at
# nothing is the vacuous pass selftest exists to catch.
case "$f" in /*|"") ;; *) [ -d "$f" ] || f="$CALLER_PWD/$f" ;; esac
if [ -z "$f" ] || [ ! -f "$f/meta" ]; then
echo "no snapshot for estate '$ESTATE'. Take one — read-only, printed before it runs:"
echo " make host snapshot"
exit 1
fi
local taken box age_min
taken=$(sed -n 1p "$f/meta"); box=$(sed -n 2p "$f/meta")
age_min=$(( ( $(date -u +%s) - $(date -u -d "$taken" +%s 2>/dev/null || date -u +%s) ) / 60 ))
echo "snapshot $f"
echo " $box, taken $taken"
[ -f "$f/PARTIAL" ] && echo " ! PARTIAL — interrupted; sections after the cut are missing, not empty"
if [ "$age_min" -ge 60 ]; then
echo " ! $((age_min / 60))h old — this is how the box WAS. Re-snapshot before acting on it."
fi
# ── disk ────────────────────────────────────────────────────────────────
echo
echo "disk"
local rows; rows=$(awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); printf "%s\t%d\t%d\t%d\n", $6, p, $4, $2 }' "$f/df")
local full=0 mnt pct avail total
while IFS=$'\t' read -r mnt pct avail total; do
[ -z "$mnt" ] && continue
local mark=" "; [ "$pct" -ge 85 ] && mark=" !"
printf "%s %-22s %3d%% %7d MB free of %7d MB\n" "$mark" "$mnt" "$pct" "$avail" "$total"
[ "$pct" -ge 95 ] && full=1
done <<< "$rows"
awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); if (p + 0 >= 85) printf " ! inodes %s at %s%% — space can be free and writes still fail\n", $6, $5 }' "$f/df_inodes"
# The filesystem docker writes to: the longest mount point that prefixes
# /var/lib/docker. Usually /, but a separate /var would otherwise be missed.
local dmnt dpct davail dtotal
IFS=$'\t' read -r dmnt dpct davail dtotal < <(
awk -F'\t' '{ m = $1; if (index("/var/lib/docker", m) == 1 && (m == "/" || substr("/var/lib/docker", length(m) + 1, 1) == "/") && length(m) > best) { best = length(m); row = $0 } } END { print row }' <<< "$rows")
if [ "$full" = 1 ]; then
echo
echo " A full disk fails WRITES, not reads — so it rarely presents as 'disk':"
echo " pages render logged-out but 500 logged-in (sessions, DB writes)"
echo " databases crash-loop on start (they must write to start)"
echo " pushes and uploads die partway through"
fi
# ── memory — rig's check.sh mb_of, recomputed here (✖ B8), pointed at the box's /proc/meminfo ──
echo
echo "memory"
mb_of() {
awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } END { if (!found) printf "0" }' "$f/meminfo"
}
local mtotal mavail swap_used
mtotal=$(mb_of MemTotal); mavail=$(mb_of MemAvailable)
swap_used=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
printf " available %d MB of %d MB\n" "$mavail" "$mtotal"
[ "$mtotal" -gt 0 ] && [ $(( mavail * 100 / mtotal )) -lt 10 ] && \
echo " ! under 10% available — expect the OOM killer before anything says 'memory'"
[ "$swap_used" -gt 0 ] && printf " ! %d MB in swap\n" "$swap_used"
# ── containers ──────────────────────────────────────────────────────────
echo
echo "containers"
awk -F'\t' 'NF >= 3 { n++; if ($2 == "running") r++; else printf " ! %-26s %-11s %s\n", $1, $2, $3 }
END { printf " %d of %d running\n", r, n }' "$f/containers"
awk -F'\t' 'NF >= 3 && $3 + 0 > 0 { n = $2; sub(/^\//, "", n); printf "%d\t%s\n", $3, n }' "$f/container_config" \
| sort -rn | head -5 | awk -F'\t' '{ printf " restarted %4d× %s\n", $1, $2 }'
local unbounded
unbounded=$(awk -F'\t' 'NF >= 6 && $5 == "json-file" && ($6 == "" || $6 == "<no value>") { n = $2; sub(/^\//, "", n); printf "%s ", n }' "$f/container_config")
if [ -n "$unbounded" ]; then
echo " ! json-file logs with NO max-size — each grows until the disk is full:"
echo "$unbounded" | fold -s -w 70 | sed 's/^/ /'
fi
# ── what would free space, cheapest first ───────────────────────────────
echo
echo "space — what would free it, and what it costs (≈MB; printed, never run)"
local dfrows; dfrows=$(awk "$AWK_MB"'
NR > 1 && NF >= 5 {
line = $0; gsub(/\([0-9]+%\)/, "", line); n = split(line, a, " ")
t = a[1]; for (i = 2; i <= n - 4; i++) t = t " " a[i]
printf "%s\t%d\t%d\n", t, mb(a[n - 1]), mb(a[n])
}' "$f/docker_df")
rec() { awk -F'\t' -v t="$1" '$1 == t { print $3 }' <<< "$dfrows"; }
local r_cache r_img r_ctr r_vol j_mb logs_mb=""
r_cache=$(rec "Build Cache"); r_img=$(rec "Images"); r_ctr=$(rec "Containers"); r_vol=$(rec "Local Volumes")
j_mb=$(awk '/take up/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9.]+[KMGT]$/) { v = $i; u = substr(v, length(v)); sub(/[KMGT]$/, "", v); m = (u == "K" ? v / 1024 : u == "M" ? v : u == "G" ? v * 1024 : v * 1048576); printf "%d", m } }' "$f/journal" 2>/dev/null)
printf " %6s build cache docker builder prune free — nothing running uses it\n" "${r_cache:-?}"
printf " %6s unused images docker image prune -a next deploy re-pulls or rebuilds\n" "${r_img:-?}"
printf " %6s stopped ctrs docker container prune they cannot be 'start'ed again\n" "${r_ctr:-?}"
printf " %6s host journal sudo journalctl --vacuum-size=200M older host logs are gone\n" "${j_mb:-?}"
if [ -f "$f/admin_container_logs" ]; then
logs_mb=$(awk '$1 ~ /^[0-9]+$/ { s += $1 } END { print s + 0 }' "$f/admin_container_logs")
printf " %6s container logs sudo truncate -s 0 <log> RELIEF, not cure: they refill\n" "$logs_mb"
awk -F'\t' 'NR == FNR { id[substr($1, 1, 12)] = $2; next }
$1 ~ /^[0-9]+$/ && $1 >= 50 { n = $2; sub(/.*containers\//, "", n); k = substr(n, 1, 12); nm = (k in id ? id[k] : k); sub(/^\//, "", nm); printf " %6d %s\n", $1, nm }' \
"$f/container_config" "$f/admin_container_logs"
else
echo " ? container logs not sized: /var/lib/docker is root-only"
echo " — 'make host snapshot' with --admin sizes them"
fi
printf " %6s unused VOLUMES (no command) DATA. Unreferenced is not unwanted —\n" "${r_vol:-?}"
echo " a dangling volume can be the only copy. Decide per volume."
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
local blobs; blobs=$(awk -F'\t' '$1 == "blobs_mb" { print $2 }' "$f/registry_repos")
printf " %6s registry blobs registry garbage-collect a DECISION — see registry below\n" "${blobs:-?}"
fi
# rig's check tells apart two failures with opposite fixes ("stopping that
# is what helps — a bigger VM would not"). The disk has the same pair.
if [ -n "$dmnt" ] && [ "${dtotal:-0}" -gt 0 ]; then
local deficit safe
deficit=$(( (dtotal - davail) - dtotal * 80 / 100 ))
safe=$(( ${r_cache:-0} + ${r_img:-0} + ${r_ctr:-0} + ${j_mb:-0} + ${logs_mb:-0} ))
echo
if [ "$deficit" -le 0 ]; then
printf " %s is under 80%% — no deficit.\n" "$dmnt"
elif [ "$safe" -lt "$deficit" ] && [ -z "$logs_mb" ]; then
# Container logs are unsized without --admin. Counting that as 0 once
# printed "Pruning will NOT fix this" on a 15 MB margin — a verdict
# resting entirely on a number nobody measured.
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable, NOT counting container logs.\n" "$dmnt" "$deficit" "$safe"
echo " Undetermined. Logs were not sized, and they are the one lever that can"
echo " flip this verdict. Size them first: bash ctrl/host.sh snapshot --run --admin"
elif [ "$safe" -ge "$deficit" ]; then
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
echo " Pruning is enough. A bigger disk would not be needed yet —"
echo " and if it refills, the cause is unbounded logs or builds, not size."
else
printf " %s needs ≈%d MB to reach 80%%; only ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
echo " Pruning will NOT fix this. The rest is data — see the biggest directories."
echo " Grow the volume (EBS resizes online, then growpart + resize2fs), or move data off."
fi
fi
# What was measured, against what is used. The gap is not a finding about
# WHAT fills it — only that the unprivileged probe could not see it.
# Measured against used. Two estimates were wrong before this (2026-09-16):
# "used − docker − \$HOME" forgot the OS, and called 3 GB of /usr invisible.
# du / already walks the OS; only root-only trees are missing from it.
if [ -n "$dmnt" ] && [ -z "$logs_mb" ]; then
local seen gap
seen=$(awk -F'\t' '$2 == "/" { print $1 + 0 }' "$f/du_root" 2>/dev/null)
if [ -n "$seen" ]; then
gap=$(( (dtotal - davail) - seen ))
if [ "$gap" -gt 512 ]; then
echo
printf " ≈%d MB used on %s is root-only — du / could not read it.\n" "$gap" "$dmnt"
echo " Mostly /var/lib/docker, container logs included. --admin measures it."
fi
fi
fi
# ── where the space is ──────────────────────────────────────────────────
echo
echo "biggest directories (MB; unprivileged, so root-only trees are absent)"
awk -F'\t' 'NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_root" 2>/dev/null
if [ -f "$f/admin_docker_dirs" ]; then
echo " — /var/lib/docker (root):"
awk -F'\t' 'NR <= 4 && $1 ~ /^[0-9]+$/ { printf " %7d %s\n", $1, $2 }' "$f/admin_docker_dirs"
fi
echo " — under \$HOME:"
awk -F'\t' 'NR >= 2 && NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_home" 2>/dev/null
# ── registry ────────────────────────────────────────────────────────────
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
echo
echo "registry (answers /v2/ anonymously — so read this list before collecting garbage)"
awk -F'\t' '$1 != "blobs_mb" { printf " %s %3s tag(s) %s\n", $1, $2, $3 }' "$f/registry_repos"
echo " A repository nobody recognises here is a finding, not clutter: with"
echo " anonymous push, it may be someone else's image in your deploy path."
fi
echo
echo "reports, never fixes. Every command above is printed; none was run."
}
guard_alias
case "${1:-status}" in
status)
@@ -59,5 +341,7 @@ for r in json.load(sys.stdin):
echo "stacks joining the shared 'gateway' network — intended design, but"
echo "nothing in the tree lists it. The inventory produces that list."
;;
*) echo "usage: $0 [status|ports|services]" >&2; exit 1 ;;
snapshot) shift; snapshot "$@" ;;
check) shift; check "$@" ;;
*) echo "usage: $0 [status|ports|services|snapshot [--run] [--admin]|check [<dir>]]" >&2; exit 1 ;;
esac

View File

@@ -145,3 +145,16 @@ service_upstream() {
*) printf '%s' "$up" ;;
esac
}
# A .gitignore pattern containing a slash anchors to its own directory, so the
# only way to know a path is ignored is to ask git. Used before writing key
# material (vpn.sh) and machine snapshots (host.sh) — refuse, don't hope.
assert_ignored() {
local path="$1"
if ! git check-ignore -q "$path" 2>/dev/null; then
echo "REFUSING: '$path' is not gitignored." >&2
echo " Writing there would stage it on the next 'git add'." >&2
echo " Verify with: git check-ignore -v $path" >&2
exit 1
fi
}

View File

@@ -0,0 +1,17 @@
# Read-only probe of what only root can size (`ssh $HOST_ADMIN`, sudo).
#
# Separate from probe.sh on purpose: INDEX §7 — the admin alias only where sudo
# is genuinely required. It is here: /var/lib/docker is root-only, so without
# this a container's json log — the classic way a small disk fills — has no size.
# Sizes only. Nothing is read out of any file.
set -u
section() { printf '\n### %s\n' "$1"; }
section admin_docker_dirs
sudo -n timeout 60 du -xm --max-depth=1 /var/lib/docker 2>&1 | sort -rn | head -20
section admin_container_logs
sudo -n timeout 60 sh -c 'du -m /var/lib/docker/containers/*/*-json.log 2>/dev/null' | sort -rn | head -20
section admin_volumes
sudo -n timeout 60 du -xm --max-depth=1 /var/lib/docker/volumes 2>&1 | sort -rn | head -20

View File

@@ -0,0 +1,79 @@
# Read-only probe of the box, as the unprivileged user (`ssh $HOST`).
#
# host.sh sends THIS FILE, verbatim, over `ssh $HOST bash -s`, and prints this
# file, verbatim, when asked what it would run. The commands you read are the
# commands that run — there is no second copy to drift from.
#
# Never read here, because a snapshot is written to disk on another machine:
# - container environments (bare `docker inspect` prints Env — every
# inspect below names the fields it prints)
# - compose files and .env (inline secrets)
# - container logs (tokens and credentials leak into logs)
# No sudo. What needs root is probe-admin.sh, a separately announced batch.
set -u
section() { printf '\n### %s\n' "$1"; }
# Every step that walks a tree is bounded. `du /` takes ~20s on this box and
# prints nothing until it finishes (its output goes through sort), so a slow or
# sick disk looks exactly like a hang. A step that times out says so and the
# probe moves on: a partial answer beats none.
bounded() { local t="$1"; shift; timeout "$t" "$@"; rc=$?; [ $rc = 124 ] && echo "(timed out after ${t}s: $*)"; return 0; }
REAL='-x tmpfs -x devtmpfs -x overlay -x squashfs -x efivarfs'
section meta
date -u +%Y-%m-%dT%H:%M:%SZ
hostname
uname -r
uptime
section df
df -P -B1M $REAL 2>/dev/null
section df_inodes
df -P -i $REAL 2>/dev/null
section meminfo
cat /proc/meminfo
section containers
docker ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}' 2>&1
section container_config
ids=$(docker ps -aq 2>/dev/null)
[ -n "$ids" ] && docker inspect --format '{{.Id}} {{.Name}} {{.RestartCount}} {{.HostConfig.RestartPolicy.Name}} {{.HostConfig.LogConfig.Type}} {{index .HostConfig.LogConfig.Config "max-size"}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' $ids 2>&1
section docker_df
docker system df 2>&1
section docker_df_verbose
bounded 30 docker system df -v 2>&1
section daemon_json
cat /etc/docker/daemon.json 2>/dev/null || echo '(absent)'
section journal
journalctl --disk-usage 2>&1
section du_home
bounded 30 du -xm --max-depth=3 "$HOME" 2>/dev/null | sort -rn | head -40
section du_root
# Unprivileged, so /var/lib/docker is absent — probe-admin.sh sizes that. What it
# does see matters anyway: the OS under /usr, and /var/log (779 MB, 2026-09-16).
bounded 90 du -xm --max-depth=2 / 2>/dev/null | sort -rn | head -40
section registry_repos
# The registry answers /v2/ anonymously (curl -> 200, 2026-09-14), so what is
# stored in it is a question in its own right, not bookkeeping.
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' registry 2>/dev/null)
r="${wd:-$HOME/ppl/gateway}/registry-data/docker/registry/v2/repositories"
if [ -d "$r" ]; then
timeout 30 find "$r" -type d -name _manifests -prune -print 2>/dev/null | while read -r m; do
repo=${m%/_manifests}; repo=${repo#"$r"/}
newest=$(find "$m" -type f -printf '%TY-%Tm-%Td\n' 2>/dev/null | sort | tail -1)
tags=$(ls "$m/tags" 2>/dev/null | wc -l)
printf '%s\t%s\t%s\n' "${newest:-?}" "$tags" "$repo"
done | sort
printf 'blobs_mb\t%s\n' "$(du -sm "${r%/repositories}/blobs" 2>/dev/null | cut -f1)"
else
echo "(not found at $r)"
fi

View File

@@ -195,6 +195,57 @@ check "a roaming peer's port is not the overlay's port" "51820" \
check "the capture warning clears once keys are in" "0" \
"$(bash ./check.sh 2>/dev/null | grep -c 'public keys not captured')"
note "host snapshot + check — rig's check, asked of a remote box"
# Announce-first made mechanical: without --run nothing reaches ssh or disk.
before="$(ls -1 .snapshots 2>/dev/null | wc -l)"
out="$(bash ./host.sh snapshot 2>&1)"
check "snapshot without --run says it ran nothing" "1" "$(grep -c 'and NOT run' <<< "$out")"
check "snapshot without --run writes nothing" "$before" "$(ls -1 .snapshots 2>/dev/null | wc -l)"
# The printed probe IS the sent probe — no second copy to drift from.
check "what is announced is byte-for-byte what is sent" "same" \
"$(diff -q <(sed -n 's/^ | //p; s/^ |$//p' <<< "$out" | sed -n "1,$(wc -l < remote/probe.sh)p") remote/probe.sh >/dev/null && echo same || echo differs)"
check "snapshots are gitignored" "yes" \
"$(cd .. && git check-ignore -q ctrl/.snapshots/x/raw && echo yes || echo no)"
# A snapshot is written to another machine's disk. Environments, compose files
# and logs carry secrets, so the probes must never read them. Comments excluded:
# probe.sh's header names exactly these things in order to forbid them.
check "the probes never read env, compose, or logs" "0" \
"$(grep -hvE '^\s*#' remote/probe.sh remote/probe-admin.sh \
| grep -cE 'docker logs|docker inspect [^-]|docker inspect \$|\.env\b|compose\.ya?ml|printenv|/environ')"
hfix="$(mktemp -d)"
T="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\nfixture\n' "$T" > "$hfix/meta"
printf 'Filesystem 1048576-blocks Used Available Capacity Mounted\n/dev/x 15731 15714 0 100%% /\n' > "$hfix/df"
printf 'Filesystem Inodes IUsed IFree IUse%% Mounted\n/dev/x 100 10 90 10%% /\n' > "$hfix/df_inodes"
printf 'MemTotal: 2000000 kB\nMemAvailable: 1000000 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n' > "$hfix/meminfo"
printf 'mongo\trestarting\tRestarting (1) 2s ago\tmongo:6\n' > "$hfix/containers"
printf 'aaaaaaaaaaaa\t/mongo\t99\talways\tjson-file\t<no value>\t/x\n' > "$hfix/container_config"
printf 'TYPE TOTAL ACTIVE SIZE RECLAIMABLE\nImages 3 1 5GB 4GB (80%%)\nBuild Cache 1 0 1GB 1GB\n' > "$hfix/docker_df"
: > "$hfix/journal"; : > "$hfix/du_root"; : > "$hfix/du_home"
hout="$(bash ./host.sh check "$hfix" 2>&1)"
check "a full disk is flagged" "1" "$(grep -c '^ ! / ' <<< "$hout")"
check "a crash-looping container is flagged" "1" "$(grep -c '! mongo .*restarting' <<< "$hout")"
check "an unbounded json log is flagged" "1" "$(grep -c 'NO max-size' <<< "$hout")"
check "reclaimable ≥ deficit → pruning is enough" "1" "$(grep -c 'Pruning is enough' <<< "$hout")"
printf 'TYPE TOTAL ACTIVE SIZE RECLAIMABLE\nImages 3 3 5GB 0B (0%%)\n' > "$hfix/docker_df"
# Unknown is not zero. Without --admin, container logs are unsized, and they can
# flip the verdict — 2026-09-16 printed "will NOT fix" on a 15 MB margin that
# rested on counting them as 0. Unsized → undetermined, never a verdict.
check "unsized logs + a shortfall → undetermined, not a verdict" "1 0" \
"$(o="$(bash ./host.sh check "$hfix" 2>&1)"; echo "$(grep -c Undetermined <<< "$o") $(grep -c 'will NOT fix' <<< "$o")")"
printf '5000\t/var/lib/docker/containers/aaaaaaaaaaaa/aaaaaaaaaaaa-json.log\n' > "$hfix/admin_container_logs"
check "sized logs map back to container names" "1" \
"$(bash ./host.sh check "$hfix" 2>&1 | grep -cE '^ +5000 mongo$')"
: > "$hfix/admin_container_logs"
check "measured and still short → pruning will NOT fix it" "1" \
"$(bash ./host.sh check "$hfix" 2>&1 | grep -c 'Pruning will NOT fix')"
rm -rf "$hfix"
check "no snapshot is exit 1, not a silent pass" "1" \
"$(empty="$(mktemp -d)"; SNAP_ROOT="$empty" bash ./host.sh check >/dev/null 2>&1; echo $?; rmdir "$empty")"
note "every STALE entry has a check here"
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no

View File

@@ -196,17 +196,7 @@ PY
return 0
}
# A .gitignore pattern containing a slash anchors to its own directory, so the
# only way to know a path is ignored is to ask git.
assert_ignored() {
local path="$1"
if ! git check-ignore -q "$path" 2>/dev/null; then
echo "REFUSING: '$path' is not gitignored." >&2
echo " Writing key material there would stage it on the next 'git add'." >&2
echo " Verify with: git check-ignore -v $path" >&2
exit 1
fi
}
# assert_ignored lives in lib/estate.sh — host.sh's snapshots need it too.
keygen() {
local peer="${1:-}"

184
build.py
View File

@@ -23,7 +23,6 @@ Generated structure for managed rooms:
"""
import argparse
import importlib.util
import json
import logging
import shutil
@@ -559,148 +558,6 @@ def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def load_plexuses(room: str) -> list[dict]:
"""The plexuses a room asked for. Same shape as its sibling data/*.json."""
path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json"
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except ValueError as e:
log.warning(f" plexuses.json is not valid JSON, ignoring: {e}")
return []
entries = raw.get("items", raw) if isinstance(raw, dict) else raw
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def _theme_css(theme: str) -> str:
"""The token contract plus one theme, flattened for inlining.
Only the named theme ships alongside the others it can switch to, because
the export has to work with no server: there is no /theme.css to fetch.
"""
theme_dir = SPR_ROOT / "soleprint" / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Every theme, so the switcher in the page has something to switch to.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
return "\n".join(parts)
def _inline_svg(name: str, theme: str) -> str:
"""A rendered graph, stripped of its XML prolog so it can sit in HTML.
Inlined rather than <img>-linked so the page's CSS can recolour it when the
theme switches — graphviz writes class="node accent" into the SVG, and CSS
outranks the presentation attributes it bakes in.
"""
graphs = SPR_ROOT / "docs" / "graphs"
for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"):
if candidate.exists():
svg = candidate.read_text()
start = svg.find("<svg")
return svg[start:] if start >= 0 else svg
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
return "<p>diagram not rendered</p>"
def build_plexuses(output_dir: Path, room: str):
"""Export each plexus the room declared to a single self-contained file.
A plexus is exported, not served. The output is one index.html carrying its
theme, its data and its diagram, so it survives a locked-down machine, a zip
attachment and a double-click — which is the whole point of the format.
"""
requested = load_plexuses(room)
if not requested:
return
source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses"
built = []
for entry in requested:
name = entry["name"]
source = source_root / name
manifest_path = source / "plexus.json"
if not manifest_path.exists():
available = sorted(
p.name for p in source_root.iterdir() if p.is_dir()
) if source_root.exists() else []
log.warning(
f" no such plexus: {name} (available: {', '.join(available) or 'none'})"
)
continue
try:
manifest = json.loads(manifest_path.read_text())
except ValueError as e:
log.warning(f" plexus {name} has invalid plexus.json: {e}")
continue
# The room may override anything the plexus declares — theme first.
manifest.update({k: v for k, v in entry.items() if k != "name"})
template_path = source / "app" / "index.html"
if not template_path.exists():
log.warning(f" plexus {name} has no app/index.html")
continue
theme = manifest.get("theme", "soleprint")
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# A plexus may ship a showcase.py exposing collect(): anything it returns
# is merged into the page's data. The bundle uses it to run the real
# tools over the real fixtures at build time, so what the page shows
# cannot drift from what the tools do.
collector = source / "showcase.py"
if collector.exists():
try:
spec = importlib.util.spec_from_file_location(
f"plexus_{name}_showcase", collector
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data["showcase"] = module.collect()
except Exception as e:
log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})")
page = template_path.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"{room} · built by soleprint build.py"),
("%%THEME_CSS%%", _theme_css(theme)),
("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
target = output_dir / "plexuses" / name
ensure_dir(target)
(target / "index.html").write_text(page)
# Anything else in app/ rides along, for a plexus that outgrows one file.
for extra in (source / "app").iterdir():
if extra.name != "index.html":
copy_path(extra, target / extra.name, quiet=True)
built.append(f"{name} ({theme})")
if built:
log.info(f" plexuses: {', '.join(built)}")
def build_soleprint(output_dir: Path, room: str):
"""Build soleprint folder with core + room config merged."""
soleprint = SPR_ROOT / "soleprint"
@@ -738,11 +595,6 @@ def build_soleprint(output_dir: Path, room: str):
log.info("Composing cabinets...")
compose_cabinets(output_dir, room)
# Plexuses are exported rather than served, so this is a compile step like
# the cabinet merge above — not something run.py does at request time.
log.info("Exporting plexuses...")
build_plexuses(output_dir, room)
# Generate models
log.info("Generating models...")
if not generate_models(output_dir, room):
@@ -799,33 +651,6 @@ def build_models_only():
sys.exit(1)
def build_plexuses_only(room: str):
"""Compile just the plexus UIs, without rebuilding the room around them.
The equivalent of `vite build` for this repo: the iteration loop when you
are working on the UI itself is edit, compile, reopen the file — and a full
room build to see a CSS change is a slow way to do that.
"""
output_dir = SPR_ROOT / "gen" / room
if not output_dir.exists():
log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}")
sys.exit(1)
log.info(f"Compiling plexus UIs for {room}...")
build_plexuses(output_dir, room)
built = sorted((output_dir / "plexuses").glob("*/index.html"))
if not built:
log.warning(
f" nothing compiled — does cfg/{room}/data/plexuses.json list one?"
)
return
for page in built:
log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)")
log.info("\n✓ Open directly — no server needed:")
log.info(f" xdg-open {built[0]}")
def main():
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
@@ -833,17 +658,10 @@ def main():
parser.add_argument("--cfg", "-c", type=str, help="Room config name")
parser.add_argument("--all", action="store_true", help="Build all rooms")
parser.add_argument("--models", action="store_true", help="Only regenerate models")
parser.add_argument(
"--plexuses",
action="store_true",
help="Only compile the plexus UIs into an already-built room",
)
args = parser.parse_args()
if args.plexuses:
build_plexuses_only(args.cfg or "standalone")
elif args.models:
if args.models:
build_models_only()
elif args.all:
build(SPR_ROOT / "gen" / "standalone", None)

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,7 +0,0 @@
{
"items": [
{
"name": "bundle"
}
]
}

View File

@@ -2,30 +2,36 @@
# Compile the plexus UIs to distributable files — this repo's `vite build`.
#
# Usage:
# ./ctrl/dist.sh # standalone
# ./ctrl/dist.sh sample # a named room
# ./ctrl/dist.sh # every plexus -> gen/plexuses/<name>/index.html
# ./ctrl/dist.sh bundle # one, by name
# ./ctrl/dist.sh path/to/ui.toml # every [[plexus]] a run file lists
# ./ctrl/dist.sh bundle --dest /tmp/out --delivery vendored --notice none
#
# A plexus is a UI that gets EXPORTED, not served. The output is a single
# index.html carrying its theme, its data and its diagrams inline, so it opens
# from a double-click on a machine with no server, no node and no network — the
# state a regulated Windows box is usually in.
#
# `make build` runs this as one of its steps. This exists for the loop where the
# UI is what you are working on: rebuilding a whole room to see a CSS change is
# a slow way to iterate.
# It needs nothing built first: a plexus is its own directory under
# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it
# carries is cut to what the page reads. See soleprint/common/slice/plexus.py.
set -e
CALLER_DIR="$PWD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
args=()
for a in "$@"; do
# A bare word that names an existing path is a path the caller typed.
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")")
else
args+=("$a")
fi
done
exec "$PYTHON" build.py --plexuses --cfg "$ROOM"
cd "$ROOT_DIR/soleprint"
exec "$PYTHON" -m common.slice plexus "${args[@]}"

View File

@@ -73,7 +73,15 @@ def get_version(comp_type, source):
for line in (source / "pyproject.toml").read_text().splitlines():
if line.strip().startswith("version"):
return line.split('"')[1]
except FileNotFoundError:
elif comp_type == "python":
# A plain package: no package.json, no pyproject.toml, just
# __version__ in __init__.py. station/tools/common is one -- it is
# imported and vendored, never installed, so giving it packaging
# metadata to satisfy this function would be the tail wagging the dog.
for line in (source / "__init__.py").read_text().splitlines():
if line.strip().startswith("__version__"):
return line.split('"')[1] if '"' in line else line.split("'")[1]
except (FileNotFoundError, IndexError):
pass
return "?"

45
ctrl/ui.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Slices: the part of spr's UI one consumer uses, generated as readable source.
#
# Usage:
# ./ctrl/ui.sh list # what can be sliced, and what each piece pulls in
# ./ctrl/ui.sh scan APP_DIR... # what an app uses; its framework copy against spr
# ./ctrl/ui.sh slice FILE [--only N] [--list] [--force]
# ./ctrl/ui.sh check FILE [--only N] # writes nothing; exit 1 if anything is stale
# ./ctrl/ui.sh reference [--check|--spots|--scaffold] # the ui-reference page of /docs, from common/ui/src
# ./ctrl/ui.sh selftest
#
# FILE is a run file (see soleprint/common/slice/ui.example.toml). It lives beside
# the consumer it describes, never in spr, and is named relative to where you
# stand — resolved here before the cd below would change what it means.
#
# `delivery = "dependency"` is for own projects: spr's names stay and a .spr stamp
# says where the copy came from. `delivery = "vendored"` renames through a mapping
# profile, drops the headers, writes no stamp, scrubs every spr word, and will
# not run without an explicit `notice`.
#
# This is not ./ctrl/theme.sh: that bakes parts into single pages inside spr.
# This writes a slice into a consumer, and checks it for drift both ways.
set -e
CALLER_DIR="$PWD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
PYTHON="${PYTHON:-python3}"
# The first word is the command, never a path — `docs` is also a directory at the
# repo root, and resolving it turned the command into /…/docs.
args=("${1:-}")
shift || true
for a in "$@"; do
# A bare word that names an existing path is a path the caller typed.
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")")
else
args+=("$a")
fi
done
cd "$ROOT_DIR/soleprint"
exec "$PYTHON" -m common.slice "${args[@]}"

View File

@@ -0,0 +1,7 @@
{
"files": {
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
},
"generator": "common.slice reference"
}

View File

@@ -6,17 +6,17 @@ is **exported, not served**. It is compiled to a distributable file the way vite
builds for production, and that compile is the whole point of the format.
```bash
make dist # compile the plexus UIs for standalone
make dist sample # for a named room
make dist # every plexus
make dist bundle # one, by name
make dist path/to/ui.toml # the [[plexus]] tables of a run file
```
Output is one `index.html` per plexus under `gen/<room>/plexuses/<name>/`,
Output is one `index.html` per plexus under `gen/plexuses/<name>/` (or `--dest`),
carrying its theme, its data and its diagrams inline. No server, no node, no
network. Zip it, mail it, double-click it.
`make build` runs the same step as part of a room build. `make dist` exists for
the loop where the UI is what you are working on — rebuilding a whole room to
see a CSS change is a slow way to iterate.
A plexus is built on its own: nothing else has to be built first, and no other
configuration has to name it.
## The constraint that shapes it
@@ -34,20 +34,22 @@ So the data is a JS object, the theme is inlined at compile time, and the fonts
are stacks. The test that matters is opening the output with the network off and
seeing zero failed requests — everything else is cosmetic.
## Declaring one
## Choosing what gets built
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
same place as its sibling `data/*.json` files:
`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a
`plexus.json`. To build with other settings — another theme, a plexus kept
outside spr, a different destination — list it in a run file:
```json
{ "items": [ { "name": "bundle" } ] }
```toml
[[plexus]]
name = "bundle"
theme = "mcrn"
dest = "out/bundle.html"
```
A room may override anything the plexus declares — most usefully the theme:
```json
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
```
`delivery = "vendored"` renames the output through the mapping profile, refuses
to write if any spr word survives, and needs a `notice` — the same rules as a
vendored slice. See [Export / Compile](#export).
## Writing one
@@ -57,17 +59,22 @@ soleprint/artery/plexuses/<name>/
app/index.html the template
```
`build.py` fills these placeholders and writes one file:
`make dist` fills these placeholders and writes one file:
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to |
| `%%THEME_CSS%%` | only what the page can use: the custom properties it reads (and what they reach) per theme in `inline_themes`, and the rules that can match something on it |
| `%%BUNDLE%%` | `plexus.json` as a JS object |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
A plexus whose `app/` holds a `package.json` is built as a **Vue app** instead:
its `@ui` imports are sliced from `common/ui` (the same closure a consumer
would get), `vite build` runs in a temporary directory, and the emitted script
and stylesheet are inlined into one file. `live/` is the example.
## The bundle plexus
The one that ships. It answers "what does a rig installation have at its

View File

@@ -18,6 +18,21 @@ python ctrl/spr.py publish soleprint-ui <target>
The target project receives the built files and commits them directly. No npm dependency on spr.
To give a project only the parts it uses — readable source, not the whole
library — slice it instead:
```bash
make ui scan <app-dir> # what it uses, and how its copy compares with spr
./ctrl/ui.sh slice <app>/ui.toml # write the slice its run file describes
```
See [Export / Compile](#export) for run files, the two deliveries and drift.
**Reference:** [UI reference](#ui-reference) — every export's props, models, emits,
slots, closure, packages and tokens, generated from the source by `make reference` in
`soleprint/common/ui` (which also has `make check`: typecheck, tests, reference current,
parts in agreement). Hand-written notes live beside the library in `common/ui/notes/`.
## soleprint-modelgen
Model generator. Reads schema definitions and produces model files.

View File

@@ -19,7 +19,7 @@ make start # run it
| Command | Runs | Does |
| --- | --- | --- |
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
| `make dist [<room>]` | `ctrl/dist.sh` | compile just the plexus UIs to single files |
| `make dist [NAME\|FILE]` | `ctrl/dist.sh` | compile the plexus UIs to single files — nothing built first |
| `make docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams |
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
@@ -52,12 +52,9 @@ make component ARGS="publish soleprint-ui /tmp/out --dist"
4. **Compose cabinets.** The dependency containers the room declared in
`data/cabinets.json` are merged into its `docker-compose.yml`. See
[Cabinets](#station-cabinets).
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
compiled to a single self-contained `index.html` — theme, data and diagrams
inlined, so it opens with no server. See [Plexuses](#artery-plexuses).
6. **Generate models.** modelgen reads the room's `config.json` and writes
5. **Generate models.** modelgen reads the room's `config.json` and writes
`models/pydantic/__init__.py`.
7. **Render k8s** (optional). When the room's config enables it,
6. **Render k8s** (optional). When the room's config enables it,
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
## What comes out
@@ -71,7 +68,6 @@ gen/standalone/
cfg/config.json
data/*.json
models/pydantic/
plexuses/<name>/index.html # one file each, opens with no server
```
A **managed** room — one that wraps an existing application — is three folders
@@ -112,6 +108,38 @@ build it first: cd soleprint/common/ui && pnpm build
Each publish leaves a `.spr` stamp in the destination recording name, version,
type, source and mode, so a copy can say where it came from.
### Slices: only what a consumer uses
`publish` hands over the whole component. A **slice** hands over the part one
consumer actually uses, as readable source, computed rather than picked:
```bash
make ui # list: every export and part, and what it pulls in
make ui scan ../mpr/ui/detection-app # what an app uses; its framework copy against spr
./ctrl/ui.sh slice path/to/ui.toml # write the slices a run file lists
./ctrl/ui.sh check path/to/ui.toml # the same, writing nothing — exit 1 if stale
```
A run file names the consumer, where the slice goes, and whether it is read from
the consumer's own imports (`scan`) or named (`use`). `target = "vue"` takes files
from `common/ui/src` — `Panel` alone is one file and `vue`; `TimeSeriesRenderer`
brings uplot and nothing else does. `target = "plain"` takes parts from
`common/theme/parts` as one `ui.css` and one `ui.js`. See
`soleprint/common/slice/ui.example.toml`.
Two deliveries:
| `delivery` | for | what changes |
| --- | --- | --- |
| `dependency` | your own projects | nothing: spr's names stay, and a `.spr` stamp says where the copy came from |
| `vendored` | a repository that is not spr's | names go through a mapping profile, headers are stripped, no stamp is written, a scrub fails on any spr word, and `notice` must be stated |
Every run writes a manifest beside its run file recording the hash of each file
it wrote. That is what makes drift visible both ways: when spr moves on, `check`
says the slice is stale; when someone edits the copy, the next run names the file
and refuses to overwrite it without `--force` — so an improvement made in a
consumer can be brought back instead of lost.
## Deploying
```bash

View File

@@ -0,0 +1,485 @@
# common/ui — reference
> Generated from `common/ui/src` by `make reference` in common/ui (`python3 -m common.slice reference`).
> Do not edit this page: the next build replaces it. Prose goes in `soleprint/common/ui/notes/<spot>.md`,
> and `make reference-spots` lists every spot.
## What this is
A panel framework, not a dashboard: boxes with a title and a status light, splits that
resize, a feed that pushes typed events, and renderers for what arrives. It was built for
high-frequency operational views — a minimal Grafana — and every piece below exists
because an app needed it.
**The contract is the framework; Vue is one binding of it.** The contract is four things:
the token names, the element layer (`base.css`), the class names (`.panel`,
`.panel-header`, `.split-pane`, …) and the status words. Those are not whole yet: a feed
reports `idle`, `connecting`, `live`, `error`, while the panel's dot shows `idle`,
`live`, `processing`, `error` — so `connecting` has no dot, and a consumer has to map it
by hand. That is a gap in the contract, listed below. `common/theme/parts` is the second
binding: the same contract as plain CSS and JS, for pages that must open from a file with
no build. docgen's site is not a binding yet: it bakes its colours from its own style
slots, a third token vocabulary.
## The layers, as use shaped them
| layer | what | the evidence |
| --- | --- | --- |
| contract | tokens, element layer, class names, status words | every consumer reads `tokens.css`; four apps pasted their own `base.css` before it shipped here |
| chrome | `Panel` (+ maximize), `SplitPane`, `ParameterEditor`, `VideoPlayer` | `Panel` and `SplitPane` are the only exports all five consumers use; nested splits, not `LayoutGrid`, turned out to be the layout |
| feed | `DataSource`, `SSEDataSource` | one stream, typed events, a terminal `done` |
| renderers | `LogRenderer`, `TableRenderer`, `FrameRenderer`, `TimeSeriesRenderer`, `GraphRenderer` | opt-in; the only two external packages arrive through these |
| app state | not here | mpr keeps its own Pinia stores; `pinia` is imported nowhere in this library |
| delivery | slices, not this whole folder | see below |
## How to take it
Not as this folder. A consumer takes a **slice**: the closure of the exports it uses,
as readable source, with a barrel exporting only those and a `tokens.css` cut down to
what is read — `make ui scan <app>` computes it, `ctrl/ui.sh slice <run file>` writes it,
and `check` reports drift both ways. The "Contents" table is the price list.
## How this document is made
Everything outside these notes is generated from `src/` by `make reference` (in
`common/ui`), so a prop that is removed disappears from here on the next build. The notes
are the only hand-written part, one file per spot in `soleprint/common/ui/notes/`; a note
whose spot disappears fails `make check`. `make docs` — anywhere in soleprint — launches
this site.
## What came in
`common/ui/src` — **24 file(s) seen, 24 read, 0 failed**; 19 exports and 15 types in the barrel.
## Contents
**Who uses what**, measured 2026-09-22 with `make ui scan <app>` over the five apps that
consume this library. It is the one table here that is not generated — the apps live
outside this repository — so re-run the scan before trusting a row.
| export | mpr detection | mts meetus | mts doocus | unt | nvi |
| --- | --- | --- | --- | --- | --- |
| `Panel`, `SplitPane` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `LogRenderer` | ✓ | | | ✓ | |
| `VideoPlayer` | | ✓ | | | |
| `ParameterEditor`, `ResizeHandle`, all renderers but Log, `SSEDataSource`, `useRegistry`, `useEditorExecution` | ✓ | | | | |
| `LayoutGrid`, `StaticDataSource`, `useDataSource`, `BBoxDrawPlugin`, `CrosshairPlugin` | | | | | |
The last row has **no users at all**. Those exports stay in the library — they are
small and a slice never carries them unless asked — but nothing should be built on them
as if they were proven. `ResizeHandle` is imported by mpr and never rendered: it is what
`SplitPane` replaced.
What each export costs a consumer that takes it: the files it pulls in, the packages it
brings, and whether a plain-HTML part exists for it (`common/theme/parts`).
| export | kind | file | pulls in | packages | size | plain part |
| --- | --- | --- | --- | --- | --- | --- |
| [`DataSource`](#datasource) | datasource | `datasources/DataSource.ts` | — | vue | 1.3 KB | feed |
| [`SSEDataSource`](#ssedatasource) | datasource | `datasources/SSEDataSource.ts` | DataSource.ts | vue | 3.8 KB | feed |
| [`StaticDataSource`](#staticdatasource) | datasource | `datasources/StaticDataSource.ts` | DataSource.ts | vue | 2.4 KB | — |
| [`useDataSource`](#usedatasource) | composable | `composables/useDataSource.ts` | DataSource.ts | vue | 2.0 KB | — |
| [`useRegistry`](#useregistry) | composable | `composables/useRegistry.ts` | — | vue | 1.9 KB | — |
| [`useEditorExecution`](#useeditorexecution) | composable | `composables/useEditorExecution.ts` | — | vue | 1.4 KB | — |
| [`Panel`](#panel) | component | `components/Panel.vue` | — | vue | 4.3 KB | maximize, panel |
| [`LayoutGrid`](#layoutgrid) | component | `components/LayoutGrid.vue` | — | vue | 0.5 KB | — |
| [`ResizeHandle`](#resizehandle) | component | `components/ResizeHandle.vue` | — | vue | 1.3 KB | — |
| [`SplitPane`](#splitpane) | component | `components/SplitPane.vue` | — | vue | 3.7 KB | split |
| [`ParameterEditor`](#parametereditor) | component | `components/ParameterEditor.vue` | — | vue | 3.1 KB | params |
| [`VideoPlayer`](#videoplayer) | component | `components/VideoPlayer.vue` | — | vue | 4.1 KB | — |
| [`LogRenderer`](#logrenderer) | renderer | `renderers/LogRenderer.vue` | — | vue | 3.7 KB | — |
| [`TimeSeriesRenderer`](#timeseriesrenderer) | renderer | `renderers/TimeSeriesRenderer.vue` | — | uplot, vue | 4.3 KB | — |
| [`GraphRenderer`](#graphrenderer) | renderer | `renderers/GraphRenderer.vue` | — | @vue-flow/core, vue | 7.9 KB | — |
| [`FrameRenderer`](#framerenderer) | renderer | `renderers/FrameRenderer.vue` | — | vue | 4.9 KB | — |
| [`TableRenderer`](#tablerenderer) | renderer | `renderers/TableRenderer.vue` | — | vue | 2.4 KB | — |
| [`BBoxDrawPlugin`](#bboxdrawplugin) | plugin | `plugins/BBoxDrawPlugin.ts` | InteractionPlugin.ts | — | 3.0 KB | — |
| [`CrosshairPlugin`](#crosshairplugin) | plugin | `plugins/CrosshairPlugin.ts` | InteractionPlugin.ts | — | 2.5 KB | — |
## Components
### Panel
The one component everything uses — 13 instances in mpr, 7 across mts's two apps.
**Maximize came from mts.** mts's copy grew `v-model:maximized` and `maximizable` while
spr's stood still, and it was brought upstream on 2026-09-22 — the framework spec's own
rule ("a second application finds the holes") working as written. The reason it is the
panel's job and not the video's is in the source: a fullscreen `<video>` paints over the
panel's actions and overlays.
**`#actions` is where panel buttons go.** mts fills it in 6 of 7 panels; mpr fills it in
none and absolutely-positions buttons inside the body instead. The slot is the answer.
**`#overlay` is filled by no consumer**; it stays for the interaction plugins, which have
no consumer either.
`components/Panel.vue` · 4.3 KB with its closure · packages: `vue` ^3.5 · plain part: `maximize`, `panel`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `title` | `string` | yes | — | — |
| `status` | `'idle' \| 'live' \| 'processing' \| 'error'` | no | — | Omit to show no indicator at all — see the note on `.panel-status`. |
| `maximizable` | `boolean` | no | `true` | Set false for panels that make no sense filling the window. |
| v-model | type | default |
| --- | --- | --- |
| `v-model:maximized` | `boolean` | `false` |
slots: `#actions`, `#default`, `#overlay`
reads: `--font-size-sm` `--font-ui` `--panel-border` `--panel-header-height` `--panel-radius` `--space-1` `--space-2` `--space-3` `--status-error` `--status-idle` `--status-live` `--status-processing` `--surface-1` `--surface-2` `--text-primary` `--text-secondary`
### LayoutGrid
`components/LayoutGrid.vue` · 0.5 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `columns` | `number` | no | `2` | — |
| `rows` | `number` | no | `2` | — |
| `gap` | `string` | no | `'var(--space-2)'` | — |
slots: `#default`
### ResizeHandle
`components/ResizeHandle.vue` · 1.3 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `direction` | `'horizontal' \| 'vertical'` | yes | — | — |
| emits | payload | |
| --- | --- | --- |
| `resize` | `[delta: number]` | — |
reads: `--text-dim`
### SplitPane
The real layout primitive. Every app lays itself out with nested splits — mpr three
deep — and none uses `LayoutGrid`. `sizeMode="ratio"` is the common case (all of mts);
`"px"` pins a sidebar (mpr). `resizable: false` has never been passed by anyone: a split
that cannot move is a flex row.
`components/SplitPane.vue` · 3.7 KB with its closure · packages: `vue` ^3.5 · plain part: `split`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `direction` | `'horizontal' \| 'vertical'` | no | `'horizontal'` | Split direction |
| `initialSize` | `number` | no | `1` | Initial size of the sized pane (px or flex ratio) |
| `sizeMode` | `'px' \| 'ratio'` | no | `'ratio'` | Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio |
| `anchor` | `'first' \| 'second'` | no | `'first'` | Which pane is sized: 'first' or 'second'. Default: 'first'. |
| `min` | `number` | no | `0.1` | Min size (px in px-mode, ratio in ratio-mode) |
| `max` | `number` | no | `10` | Max size (px in px-mode, ratio in ratio-mode) |
| `resizable` | `boolean` | no | `true` | Whether the divider is draggable |
slots: `#first`, `#second`
reads: `--text-dim`
### ParameterEditor
Schema-driven: the same field list a pipeline stage declares renders its sliders. Two
gaps, both fixed in the plain binding (`params` part) and not yet here: a field with
`options` (an enum) renders **nothing**, because only `int`/`float` and `bool` are
filtered for; and the `reset` event is declared but never emitted — mpr wires a handler
to it that cannot fire.
`components/ParameterEditor.vue` · 3.1 KB with its closure · packages: `vue` ^3.5 · plain part: `params`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `fields` | `ConfigField[]` | yes | — | — |
| `values` | `Record<string, unknown>` | yes | — | — |
| emits | payload | |
| --- | --- | --- |
| `update` | `[name: string, value: unknown]` | — |
| `reset` | `[]` | — |
reads: `--space-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
### VideoPlayer
Agnostic seekable media player.
mts's, and only mts uses it. It came upstream with its `#overlay` slot on 2026-09-22.
It deliberately does no fullscreen of its own: put it in a `Panel` and maximize that.
`components/VideoPlayer.vue` · 4.1 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `src` | `string` | yes | — | Media source URL |
| `currentTime` | `number` | no | `0` | Playback position in seconds (v-model:currentTime) |
| `playing` | `boolean` | no | `false` | Whether the media is playing (v-model:playing) |
| emits | payload | |
| --- | --- | --- |
| `update:currentTime` | `[value: number]` | — |
| `update:playing` | `[value: boolean]` | — |
| `duration` | `[value: number]` | Media duration once metadata has loaded |
| `ended` | `[]` | Playback reached the end |
slots: `#overlay`
reads: `--surface-0`
## Renderers
### LogRenderer
Virtual scroll, and the only one in any of the apps: rows outside the viewport are not
rendered, and auto-scroll pauses while the reader scrolls up. mts's long lists and mpr's
React side render every row instead. `rowHeight` and `autoScroll` have never been passed
by a consumer; the defaults are what is proven.
`renderers/LogRenderer.vue` · 3.7 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `entries` | `LogEntry[]` | yes | — | — |
| `rowHeight` | `number` | no | `24` | — |
| `autoScroll` | `boolean` | no | `true` | — |
reads: `--font-mono` `--space-2` `--space-6` `--status-error` `--status-escalating` `--status-live` `--status-processing` `--text-dim`
### TimeSeriesRenderer
One of the only two exports that bring a package (`uplot`, with its stylesheet), and the
reason a chart cannot be a plain part: baking uplot into every standalone page is the
bundle this library refuses to be. A page that needs a real chart is a served page.
`renderers/TimeSeriesRenderer.vue` · 4.3 KB with its closure · packages: `uplot` ^1.6, `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `series` | `TimeSeriesSeries[]` | yes | — | Array of series configs (label + color) |
| `data` | `uPlot.AlignedData` | yes | — | Data: [timestamps[], series1[], series2[], ...] |
| `title` | `string` | no | — | Chart title (optional) |
| `stacked` | `boolean` | no | `false` | Stacked area mode |
reads: `--font-mono` `--surface-2` `--surface-3` `--text-primary` `--text-secondary`
### GraphRenderer
`renderers/GraphRenderer.vue` · 7.9 KB with its closure · packages: `@vue-flow/core` ^1.48.2, `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `nodes` | `GraphNode[]` | yes | — | — |
| `mode` | `GraphMode` | no | `'observe'` | Interaction mode — changes visual treatment and click behavior |
| `activeStage` | `string \| null` | no | `null` | Currently edited stage (highlighted in edit modes) |
| `regionStages` | `string[]` | no | — | Stages that have a region editor (bbox/polygon) |
| emits | payload | |
| --- | --- | --- |
| `open-region-editor` | `[stage: string]` | — |
| `open-stage-editor` | `[stage: string]` | — |
| `node-click` | `[stage: string]` | — |
reads: `--font-mono` `--font-size-sm` `--panel-radius` `--status-processing` `--text-secondary`
### FrameRenderer
**Known bug:** box colours by confidence are returned as `'var(--conf-high)'` strings and
handed to a canvas `strokeStyle`. Canvas does not resolve custom properties, so those
boxes draw in the context's previous colour. That is why the "Tokens" table shows the
`--conf-*` tokens as read by nobody: in effect, they are not.
`renderers/FrameRenderer.vue` · 4.9 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `imageSrc` | `string` | yes | — | Base64 JPEG image |
| `boxes` | `FrameBBox[]` | yes | — | Bounding boxes to overlay |
| `overlays` | `FrameOverlay[]` | no | — | Debug overlay layers (edge images, line visualizations, etc.) |
reads: `--text-dim`
### TableRenderer
`renderers/TableRenderer.vue` · 2.4 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `columns` | `TableColumn[]` | yes | — | — |
| `rows` | `Record<string, unknown>[]` | yes | — | — |
| `sortKey` | `string` | no | — | — |
| `sortDir` | `'asc' \| 'desc'` | no | — | — |
| emits | payload | |
| --- | --- | --- |
| `sort` | `[key: string]` | — |
reads: `--font-mono` `--font-size-sm` `--panel-border` `--space-1` `--space-2` `--space-3` `--space-6` `--surface-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
## Datasources
### DataSource
`datasources/DataSource.ts` · 1.3 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
Base class for all data sources. A DataSource connects to some event stream, exposes reactive state, and lets consumers subscribe to typed events. Panels read from these reactively — they never touch the transport layer directly.
### SSEDataSource
One stream, many typed events: panels subscribe with `on(type, handler)` to the event
types they draw, and the stream ends with a terminal `done`. `status` uses the same four
words as a panel's dot, so wiring one to the other needs no mapping.
Two things the code does not do, both learned from mpr. **It does not throttle**: at a few
hundred events a second, a handler that writes to the DOM per event stalls — the plain
binding's `feed` part adds `onFrame()` (one call per animation frame, newest payload) for
exactly this, and a Vue consumer has to coalesce the same way. **Nobody unsubscribes**:
`on()` returns an unsubscribe function and every call site in mpr drops it, which only
works because its panels live as long as the page.
`datasources/SSEDataSource.ts` · 3.8 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
DataSource backed by native EventSource (Server-Sent Events). Connects to a single SSE endpoint and demultiplexes events by type. Multiple panels can subscribe to different event types from the same source.
### StaticDataSource
`datasources/StaticDataSource.ts` · 2.4 KB with its closure · packages: `vue` ^3.5
DataSource that replays a fixture array of events. Used for development and testing without a running backend. Events are emitted in sequence with optional delays.
## Composables
### useDataSource
Composable that connects a component to a DataSource.
`composables/useDataSource.ts` · 2.0 KB with its closure · packages: `vue` ^3.5
Composable that connects a component to a DataSource. Connects on mount, disconnects on unmount. Returns reactive refs for data, status, and error.
### useRegistry
Generic registry composable — fetches typed data from a URL, caches it, exposes it reactively.
`composables/useRegistry.ts` · 1.9 KB with its closure · packages: `vue` ^3.5
### useEditorExecution
`composables/useEditorExecution.ts` · 1.4 KB with its closure · packages: `vue` ^3.5
Generic editor execution pattern — debounced apply with auto-apply toggle, loading/error/timing state tracking. The caller provides the actual execution function. This composable handles the orchestration: debounce, auto-apply, loading state, timing.
## Plugins
### BBoxDrawPlugin
BBoxDrawPlugin — draw bounding boxes on the frame viewer.
`plugins/BBoxDrawPlugin.ts` · 3.0 KB with its closure · packages: none
### CrosshairPlugin
CrosshairPlugin — synchronized vertical crosshair across time-series panels.
`plugins/CrosshairPlugin.ts` · 2.5 KB with its closure · packages: none
## Types
Types ride with the file that declares them: taking the export takes its types.
| type | declared in | |
| --- | --- | --- |
| `DataSourceStatus` | `datasources/DataSource.ts` | type |
| `EditorExecutionOptions` | `composables/useEditorExecution.ts` | interface |
| `ConfigField` | `components/ParameterEditor.vue` | interface |
| `FrameBBox` | `renderers/FrameRenderer.vue` | interface |
| `FrameOverlay` | `renderers/FrameRenderer.vue` | interface |
| `LogEntry` | `renderers/LogRenderer.vue` | interface |
| `GraphNode` | `renderers/GraphRenderer.vue` | interface |
| `GraphMode` | `renderers/GraphRenderer.vue` | type |
| `TableColumn` | `renderers/TableRenderer.vue` | interface |
| `TimeSeriesSeries` | `renderers/TimeSeriesRenderer.vue` | interface |
| `InteractionPlugin` | `plugins/InteractionPlugin.ts` | interface |
| `PluginContext` | `plugins/InteractionPlugin.ts` | interface |
| `BBoxResult` | `plugins/BBoxDrawPlugin.ts` | interface |
| `BBoxCallback` | `plugins/BBoxDrawPlugin.ts` | type |
| `CrosshairCallback` | `plugins/CrosshairPlugin.ts` | type |
## Tokens
**There are two definitions, and the ⚠ rows are where they disagree.** `src/tokens.css`
is the library's own, dark-only; the theme contract in `common/theme` derives the same
names from its own palette. They agree on every colour but `--surface-3` and on neither
font stack. A Vue slice uses the library's values by default so swapping a consumer's
copy for a slice changes no pixel; `theme = "soleprint"` opts into the contract, and
that is a visible change. Retiring the library's file in favour of the contract is the
open question — it changes what every current consumer renders.
"read by nobody" means no stylesheet in the library reads it. Apps read tokens directly
too — `make ui scan` reports between 14 and 23 per app — and a slice carries those.
Every custom property the library defines or reads. `library` is `src/tokens.css`, what
Vue consumers render today; `theme` is the contract plain pages use
(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a
visible change for a consumer that switches.
| token | library | theme | read by |
| --- | --- | --- | --- |
| `--border` | `#2e2e38` | `#2e2e38` | tokens.css |
| `--conf-high` | `#3ecf8e` | `#3ecf8e` | *nobody* |
| `--conf-low` | `#f06565` | `#f06565` | *nobody* |
| `--conf-mid` | `#f5a623` | `#f5a623` | *nobody* |
| `--font-mono` ⚠ | `'JetBrains Mono', 'Fira Code', monospace` | `"JetBrains Mono", "Cascadia Mono", Consolas, monospace` | GraphRenderer.vue, LogRenderer.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
| `--font-size-base` | `13px` | `13px` | base.css |
| `--font-size-lg` | `15px` | `15px` | *nobody* |
| `--font-size-sm` | `11px` | `11px` | GraphRenderer.vue, Panel.vue, TableRenderer.vue |
| `--font-ui` ⚠ | `'Inter', system-ui, sans-serif` | `Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif` | Panel.vue, base.css |
| `--panel-border` | `1px solid #2e2e38` | `1px solid #2e2e38` | Panel.vue, TableRenderer.vue, base.css |
| `--panel-header-height` | `36px` | `36px` | Panel.vue |
| `--panel-radius` | `6px` | `6px` | GraphRenderer.vue, Panel.vue, base.css |
| `--space-1` | `4px` | `4px` | Panel.vue, TableRenderer.vue, base.css |
| `--space-2` | `8px` | `8px` | LogRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue |
| `--space-3` | `12px` | `12px` | Panel.vue, TableRenderer.vue, base.css |
| `--space-4` | `16px` | `16px` | *nobody* |
| `--space-6` | `24px` | `24px` | LogRenderer.vue, TableRenderer.vue |
| `--space-8` | `32px` | `32px` | *nobody* |
| `--status-error` | `#f06565` | `#f06565` | LogRenderer.vue, Panel.vue |
| `--status-escalating` | `#f5a623` | `#f5a623` | LogRenderer.vue, tokens.css |
| `--status-idle` | `#555568` | `#555568` | Panel.vue |
| `--status-live` | `#3ecf8e` | `#3ecf8e` | LogRenderer.vue, Panel.vue, tokens.css |
| `--status-processing` | `#4f9cf9` | `#4f9cf9` | GraphRenderer.vue, LogRenderer.vue, Panel.vue, tokens.css |
| `--surface-0` | `#0d0d0f` | `#0d0d0f` | VideoPlayer.vue, base.css, theme.css |
| `--surface-1` | `#16161a` | `#16161a` | Panel.vue |
| `--surface-2` | `#1e1e24` | `#1e1e24` | Panel.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--surface-3` ⚠ | `#26262f` | `#2e2e38` | ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--text-dim` | `#555568` | `#555568` | FrameRenderer.vue, LogRenderer.vue, ParameterEditor.vue, ResizeHandle.vue, SplitPane.vue, TableRenderer.vue |
| `--text-primary` | `#e8e8f0` | `#e8e8f0` | Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--text-secondary` | `#8888a0` | `#8888a0` | GraphRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
## What came out
nodes: 1 barrel · 6 component · 3 composable · 3 datasource · 19 export · 3 package · 6 part · 3 plugin · 5 renderer · 3 stylesheet · 30 token · 15 type
edges: 7 binds · 28 imports · 70 reads · 17 requires
- ok 24 file(s) read produced 24 file node(s)
- ok 19 export(s) and 15 type(s) in the barrel, each with a node
- ok 11 of 11 component(s) have their surface read
- ok every note lands on a spot
## Where it stands
- **Done:** the reference is generated and checked; slices are cut per consumer with a
two-way drift check; mts's additions (maximize, the video overlay) are upstream.
- **Behind:** the copies in mpr, unt and nvi date from 2026-04 and lack maximize,
`VideoPlayer`, `base.css` and `theme.css`. Their `.spr` stamp names a commit that no
longer exists. Replacing each with a slice is the next step for each.
- **Open:** one token definition instead of two (three, counting docgen's style slots);
the `connecting` status the panel cannot show; `--accent`, which nvi and doocus read and
nothing defines; the `FrameRenderer` colour bug; `ParameterEditor`'s enum fields.
## Rebuilding this
`make reference` in `common/ui`. `make check` fails when this page is stale, when a
generated file was edited by hand, or when a note no longer has its spot. Extraction reads the
source with patterns, not a TypeScript parser; `common/slice/selftest.py` pins what the
patterns must find, so one that stops matching fails the suite rather than thinning this
page.

View File

@@ -167,6 +167,13 @@
"en": "Shared Components"
}
},
{
"id": "ui-reference",
"title": {
"en": "↳ UI reference"
},
"sub": true
},
{
"id": "export",
"title": {

2701
docs/data/ui-inventory.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,14 @@
{
"soleprint-ui": {
"type": "npm",
"path": "soleprint/common/ui"
"soleprint-cli": {
"type": "python",
"path": "soleprint/station/tools/common"
},
"soleprint-modelgen": {
"type": "pip",
"path": "soleprint/station/tools/modelgen"
},
"soleprint-ui": {
"type": "npm",
"path": "soleprint/common/ui"
}
}

View File

@@ -3,6 +3,7 @@
# The pool is derived from the kind Docker network at install time.
# Notes: docs/notes/addons.md
set -euo pipefail
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
cd "${RIG_CTRL:-$(dirname "$0")/..}"
source ./lib/config.sh
@@ -61,27 +62,7 @@ $K rollout status daemonset/speaker -n metallb-system --timeout=240s
# on a race that resolves itself in seconds.
echo " configuring the address pool"
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if $K apply -f - >/dev/null 2>&1 <<YAML
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default
YAML
if render_manifest "$ADDON_DIR/metallb.yaml" | $K apply -f - >/dev/null 2>&1
then
echo " pool ready: ${pool_start}-${pool_end}"
exit 0

View File

@@ -0,0 +1,19 @@
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Readiness check: is this machine ready to run rig? Reports and instructs; never fixes.
# Usage: check.sh [all | mem [status|push|all|backup|restore]] (all = every detail)
# Prints the memory budget and what to do next first, then the configuration as it is.
# Notes: docs/notes/check.md
set -euo pipefail
cd "$(dirname "$0")"
@@ -12,13 +13,11 @@ if [ "${1:-}" = mem ]; then
exec bash ./mem.sh "${@:-status}"
fi
# Compact by default: facts only with `all`; problems (!) always print.
# Facts only with `all`; problems (!) always print.
VERBOSE=""
if [ "${1:-}" = all ]; then VERBOSE=1; fi
fact() { if [ -n "$VERBOSE" ]; then echo "$@"; fi; }
bash ./deps.sh detect ${VERBOSE:+all}
source ./lib/config.sh
load_config
@@ -66,49 +65,64 @@ port_busy() {
grep -qi "^ *[0-9]*: [0-9A-F]*$hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null
}
echo
echo "rig"
echo " cluster ${CLUSTER} (${KUBECONTEXT}) profile ${PROFILE_NAME}, ${NODES} node(s), registry ${REGISTRY_MODE}"
if [ -n "${OVERLAY:-}" ]; then
echo " overlay $(basename "$(_abs_from_ctrl "$OVERLAY_DIR")") ($(_abs_from_ctrl "$OVERLAY_DIR"))"
elif [ -n "$OVERLAY_DIR" ]; then
fact " overlay none named — rig's own ${OVERLAY_DIR}"
fi
if [ -n "$VERBOSE" ] && [ -n "$OVERLAY_DIR" ]; then
ov=$(_from_ctrl "$OVERLAY_DIR") pieces=""
for piece in rig.env k8s/overlays/dev kind-config.yaml.tpl addons Tiltfile; do
[ -e "$ov/$piece" ] && pieces+="$piece "
done
echo " provides: ${pieces:-nothing rig reads}"
fi
fact " manifests ${MANIFESTS_DIR:-none}"
fact " kind config ${KIND_CONFIG}"
fact " ingress ${INGRESS_MODE}"
if [ ! -f ./.env ]; then
fact " .env none — built-in defaults (cp ctrl/.env.example ctrl/.env to set values)"
fi
if [ -n "${STALE_MANIFESTS_DIR:-}" ]; then
echo " ! .env MANIFESTS_DIR=${STALE_MANIFESTS_DIR} is the old default; rig's examples moved"
echo " to examples/ — delete that line from ctrl/.env (ignored until then)"
fi
# registry.sh points containerd at certs.d, which only works if the kind config says so,
# and a kind config is fixed at creation: a project's own file that drops it fails silently.
if [ "$REGISTRY_MODE" != none ] && ! grep -q 'config_path *= *"/etc/containerd/certs.d"' "$KIND_CONFIG"; then
echo " ! kind ${KIND_CONFIG} lacks the containerd config_path patch that registry mode"
echo " '${REGISTRY_MODE}' needs — copy it from ctrl/k8s/kind-config.yaml.tpl"
# Who listens on a port, as "pid name", when ss can tell (only for our own processes).
port_owner() {
command -v ss >/dev/null 2>&1 || return 0
ss -ltnpH "sport = :$1" 2>/dev/null \
| sed -n 's/.*users:(("\([^"]*\)",pid=\([0-9]*\).*/\2 \1/p' | head -1
}
# This environment's Tilt: a tilt started from this ctrl/ (the Makefile runs it there).
tilt_pid=""
if tilt_owner=$(port_owner "$TILT_PORT") && [ -n "$tilt_owner" ]; then
pid=${tilt_owner%% *}
if [ "${tilt_owner#* }" = tilt ] && [ "$(readlink "/proc/$pid/cwd" 2>/dev/null)" = "$(pwd -P)" ]; then
tilt_pid=$pid
fi
fi
# ── memory: does this cluster fit right now? Warns; never blocks. ──────────
# none | stopped | up | unknown (no docker)
cluster_state() {
docker info >/dev/null 2>&1 || { echo unknown; return; }
local f="label=io.x-k8s.kind.cluster=${CLUSTER}"
if [ -n "$(docker ps -q --filter "$f")" ]; then echo up
elif [ -n "$(docker ps -aq --filter "$f")" ]; then echo stopped
else echo none; fi
}
state=$(cluster_state)
# ── memory budget: what this environment needs, against what the machine has ──
# The workload's share comes from the overlay: MEMORY_BUDGET="name=MB ..." in its rig.env,
# and MEMORY_BUDGET_TIP, optionally, says how to switch its largest part off.
# rig only knows what a node costs. Warns; never blocks.
total_mb=$(mb_of MemTotal)
avail_mb=$(mb_of MemAvailable)
swap_used_mb=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
overcommit=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?')
need_mb=$(( NODES * NODE_MB ))
node_mb=$(( NODES * NODE_MB ))
budget="" work_mb=0 big_mb=0 big=""
for item in ${MEMORY_BUDGET:-}; do
name=${item%=*} mb=${item##*=}
if [ "$name" = "$item" ] || ! [[ "$mb" =~ ^[0-9]+$ ]]; then
echo " ! budget '$item' in MEMORY_BUDGET is not name=MB"
continue
fi
[ "$mb" -gt 0 ] || continue
budget+=$(printf ' %-18s %6d MB' "$name" "$mb")$'\n'
work_mb=$(( work_mb + mb ))
if [ "$mb" -gt "$big_mb" ]; then big_mb=$mb big=$name; fi
done
need_mb=$(( work_mb + node_mb ))
rows=$(container_mb)
# If our cluster is already up, its memory is already out of MemAvailable: need nothing more.
# If our cluster is already up, its memory is already out of MemAvailable.
ours_mb=$(awk -F'\t' -v c="$CLUSTER" '$2 == c { s += $1 } END { print s + 0 }' <<< "$rows")
still_mb=$(( ours_mb > 0 ? 0 : need_mb ))
if [ "$state" = up ]; then
still_mb=$(( need_mb > ours_mb ? need_mb - ours_mb : 0 ))
else
still_mb=$need_mb
fi
headroom=$(( avail_mb - still_mb ))
# The biggest things holding memory, other than this cluster: kind clusters summed, the rest by name.
@@ -117,82 +131,164 @@ others=$(awk -F'\t' -v c="$CLUSTER" '
$2 == "-" { k["container \x27" $3 "\x27"] += $1 }
END { for (n in k) printf "%d\t%s\n", k[n], n }' <<< "$rows" | sort -rn)
if [ "$still_mb" -eq 0 ] && [ "$headroom" -ge 512 ]; then
printf " memory up, holding %d MB — %d MB headroom for what you deploy\n" "$ours_mb" "$headroom"
elif [ "$still_mb" -eq 0 ]; then
printf " ! memory up, but only %d MB headroom for anything you deploy\n" "$headroom"
elif [ "$headroom" -ge 512 ]; then
printf " memory fits — ~%d MB for %s node(s), %d MB headroom\n" "$need_mb" "$NODES" "$headroom"
echo
echo "memory budget (${CLUSTER})"
printf '%s' "$budget"
if [ -z "$budget" ]; then
echo " workload ? (the overlay lists none: MEMORY_BUDGET in its rig.env)"
fi
printf ' %-18s %6d MB (kind control plane, kube-system; %s node(s))\n' "node overhead" "$node_mb" "$NODES"
echo " ---------"
printf ' %-18s %6d MB\n' "required" "$need_mb"
if [ "$state" = up ]; then
printf ' %-18s %6d MB (%s is up)\n' "held by it now" "$ours_mb" "$CLUSTER"
fi
printf ' %-18s %6d MB\n' "this machine has" "$total_mb"
printf ' %-18s %6d MB (right now)\n' "available" "$avail_mb"
echo
if [ "$headroom" -ge 512 ]; then
if [ "$state" = up ]; then
echo "fits: ${CLUSTER} holds ${ours_mb} MB of its ${need_mb} MB; ${avail_mb} MB still available — ${headroom} MB headroom."
else
echo "fits: ${need_mb} MB needed, ${avail_mb} MB available — ${headroom} MB headroom."
fi
elif [ "$headroom" -ge 0 ]; then
printf " ! memory fits, but only %d MB headroom (~%d MB for %s node(s))\n" "$headroom" "$need_mb" "$NODES"
echo "! tight: ${still_mb} MB still needed, ${avail_mb} MB available — only ${headroom} MB headroom."
else
printf " ! memory does not fit: ~%d MB needed, %d MB available\n" "$still_mb" "$avail_mb"
echo "! does not fit: ${still_mb} MB still needed, ${avail_mb} MB available."
# Two failures with opposite fixes, and telling them apart is the point.
if [ "$still_mb" -le "$total_mb" ]; then
echo " something else holds it (below) — stopping that helps, a bigger VM would not."
if [ "$need_mb" -le "$total_mb" ]; then
echo " Something else holds the memory (below): stopping it helps, a bigger machine would not."
if grep -q 'kind cluster' <<< "$others"; then
echo " 'make cluster free' stops the other kind clusters. It stops, never deletes."
echo " 'make cluster free' stops the other kind clusters. It stops, never deletes."
fi
else
echo " the machine itself is too small: ${total_mb} MB total."
echo " The machine itself is too small: ${total_mb} MB in all. Give it more memory"
echo " ('make check mem' has the WSL steps), or switch parts of the workload off."
fi
fi
if [ -n "$others" ] && { [ -n "$VERBOSE" ] || [ "$headroom" -lt 512 ]; }; then
echo " held elsewhere:"
head -6 <<< "$others" | awk -F'\t' '{ printf " %6d MB %s\n", $1, $2 }'
echo " held elsewhere:"
head -6 <<< "$others" | awk -F'\t' '{ printf " %6d MB %s\n", $1, $2 }'
n_others=$(wc -l <<< "$others")
if [ "$n_others" -gt 6 ]; then
echo " ... and $((n_others - 6)) more"
echo " ... and $((n_others - 6)) more"
fi
fi
# One part that outweighs the rest is the first thing to switch off on a smaller machine.
if [ "$work_mb" -gt 0 ] && [ $(( big_mb * 2 )) -gt "$work_mb" ] && [ "$big_mb" -ge 1024 ]; then
echo
echo "${big} is ${big_mb} of the ${work_mb} MB the workload needs — more than the rest"
echo "combined. If you are not working on it, ${MEMORY_BUDGET_TIP:-switching it off in the overlay} gets that back."
fi
if [ "$swap_used_mb" -gt 0 ]; then
fact " ${swap_used_mb} MB already in swap, which 'available' does not count: expect slow before failing"
fact " ${swap_used_mb} MB already in swap, which 'available' does not count: expect slow before failing"
fi
if [ "$overcommit" = "1" ]; then
fact " overcommit=1: allocations never fail, so read 'fits' as a ceiling (OOM killer settles up)"
fact " overcommit=1: allocations never fail, so read 'fits' as a ceiling (OOM killer settles up)"
fi
# ── where things stand, and the next step ──────────────────────────────────
echo
case "$state" in
none)
echo "${CLUSTER} does not exist yet. 'make cluster up' creates it${ADDONS:+ (with ${ADDONS})},"
echo "then 'make tilt' starts the dev loop." ;;
stopped)
echo "${CLUSTER} exists but is stopped (nothing is lost). Start it again with:"
echo " docker start \$(docker ps -aq -f label=io.x-k8s.kind.cluster=${CLUSTER})" ;;
up)
if [ -n "$tilt_pid" ]; then
echo "${CLUSTER} is up, and its Tilt is running (pid ${tilt_pid}): http://localhost:${TILT_PORT}"
else
echo "${CLUSTER} is up. 'make tilt' starts the dev loop, at http://localhost:${TILT_PORT}."
fi
echo "Through the gateway: http://localhost:${HTTP_PORT}" ;;
*)
echo "docker is not reachable, so the cluster's state is unknown (the machine block below says why)." ;;
esac
# ── the configuration, as it is ────────────────────────────────────────────
echo
bash ./deps.sh detect ${VERBOSE:+all}
echo
echo "config"
echo " cluster ${CLUSTER} ($([ "$state" = none ] && echo 'not created' || echo "$state"))"
echo " context ${KUBECONTEXT}"
if [ -n "${OVERLAY:-}" ]; then
echo " overlay $(_abs_from_ctrl "$OVERLAY_DIR")"
elif [ -n "$OVERLAY_DIR" ]; then
echo " overlay none named — rig's own ${OVERLAY_DIR}"
fi
if [ -n "$VERBOSE" ] && [ -n "$OVERLAY_DIR" ]; then
ov=$(_from_ctrl "$OVERLAY_DIR") pieces=""
for piece in rig.env k8s/overlays/dev kind-config.yaml.tpl addons Tiltfile; do
[ -e "$ov/$piece" ] && pieces+="$piece "
done
echo " provides: ${pieces:-nothing rig reads}"
fi
echo " profile ${PROFILE_NAME}"
echo " nodes ${NODES}"
echo " kind config ${KIND_CONFIG}"
echo " manifests ${MANIFESTS_DIR:-none}"
echo " ingress ${INGRESS_MODE}"
if [ ! -f ./.env ]; then
fact " .env none — built-in defaults (cp ctrl/.env.example ctrl/.env to set values)"
fi
if [ -n "${STALE_MANIFESTS_DIR:-}" ]; then
echo " ! .env MANIFESTS_DIR=${STALE_MANIFESTS_DIR} is the old default; rig's examples moved"
echo " to examples/ — delete that line from ctrl/.env (ignored until then)"
fi
# registry.sh points containerd at certs.d, which only works if the kind config says so,
# and a kind config is fixed at creation: a project's own file that drops it fails silently.
if [ "$REGISTRY_MODE" != none ] && ! grep -q 'config_path *= *"/etc/containerd/certs.d"' "$KIND_CONFIG"; then
echo " ! kind ${KIND_CONFIG} lacks the containerd config_path patch that registry mode"
echo " '${REGISTRY_MODE}' needs — copy it from ctrl/k8s/kind-config.yaml.tpl"
fi
# ── ports: checked before creation; docker reports a clash only halfway through. ──
# Ports held by our own cluster are not clashes. Second grep, not `tr -d ':->'` (a tr range).
ours=$(docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" \
--format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \
| grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
clash=0 list="" mine=0
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
# Ports held by our own cluster are not clashes: its node containers, and its registry,
# which is a container of its own. Second grep, not `tr -d ':->'` (a tr range).
ours=$({ docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" --format '{{.Ports}}'
docker ps --filter "name=^${CLUSTER}-registry\$" --format '{{.Ports}}'; } 2>/dev/null \
| tr ',' '\n' | grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
clash=0 list=""
for entry in "http:${HTTP_PORT}" "https:${HTTPS_PORT}" \
"tilt:${TILT_PORT}" "registry:${REGISTRY_PORT}"; do
name="${entry%%:*}"; p="${entry#*:}"
[ -n "$p" ] || continue
list+="$p "
list+="$name $p "
if ! port_busy "$p"; then
fact "$(printf " %-9s %-6s free" "$name" "$p")"
elif echo "$ours" | grep -qx "$p"; then
mine=1
fact "$(printf " %-9s %-6s in use by this environment's cluster" "$name" "$p")"
elif [ "$name" = tilt ] && [ -n "$tilt_pid" ]; then
fact "$(printf " %-9s %-6s in use by this environment's Tilt" "$name" "$p")"
else
printf " ! ports %s %s IN USE by something else\n" "$name" "$p"
clash=1
owner=$(port_owner "$p")
if [ "$name" = tilt ] && [ "${owner#* }" = tilt ]; then
printf " ! ports tilt %s is held by another Tilt (pid %s, in %s)\n" "$p" "${owner%% *}" \
"$(readlink "/proc/${owner%% *}/cwd" 2>/dev/null || echo '?')"
echo " stop it (kill ${owner%% *}) before 'make tilt'"
else
printf " ! ports %s %s IN USE by %s\n" "$name" "$p" "${owner:+pid ${owner}}${owner:-something else}"
clash=1
fi
fi
done
echo " ports ${list% }"
if [ "$clash" -eq 1 ]; then
echo " override it in ctrl/.env (e.g. HTTP_PORT=21080), or rename this directory"
elif [ "$mine" -eq 1 ]; then
echo " ports ${list% } held by this cluster"
else
echo " ports ${list% } free"
fi
if [ -n "${OVERLAY:-}" ]; then
fact " derived from the overlay's folder name"
else
fact " derived from the directory name; pin them: bash ctrl/ports.sh persist"
fi
# ── what `make cluster up` wires in beside the cluster ─────────────────────
REG_NAME="${CLUSTER}-registry"
if state=$(docker inspect -f '{{.State.Status}}' "$REG_NAME" 2>/dev/null); then
echo " registry localhost:${REGISTRY_PORT} ($state)"
if reg_state=$(docker inspect -f '{{.State.Status}}' "$REG_NAME" 2>/dev/null); then
echo " registry ${REGISTRY_MODE} localhost:${REGISTRY_PORT} (${reg_state})"
else
fact " registry no container yet — 'make cluster up' starts it"
echo " registry ${REGISTRY_MODE} (no container yet)"
fi
echo " addons ${ADDONS:-none}"
if [ -n "$VERBOSE" ]; then

View File

@@ -16,12 +16,30 @@ serve() {
echo "no docs/index.html" >&2
exit 1
fi
# One worker and no access log: a few static pages need no more, and the
# terminal then shows only real errors. The config is a temp file, gone on exit.
# Global, not local: the trap runs after serve() has returned.
DOCS_CONF=$(mktemp)
trap 'rm -f "$DOCS_CONF"' EXIT
cat > "$DOCS_CONF" <<'NGINX'
worker_processes 1;
error_log stderr error;
events { worker_connections 64; }
http {
include /etc/nginx/mime.types;
access_log off;
server { listen 80; root /usr/share/nginx/html; }
}
NGINX
chmod 644 "$DOCS_CONF"
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
docker run --rm \
--name "${CLUSTER}-docs" \
-e NGINX_ENTRYPOINT_QUIET_LOGS=1 \
-p "${DOCS_PORT}:80" \
-v "$REPO/docs:/usr/share/nginx/html:ro" \
-v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \
nginx:alpine
}

View File

@@ -208,6 +208,29 @@ render_kind_config() {
"$KIND_CONFIG"
}
# Render an addon's manifest (a .yaml beside its .sh) to stdout. Every ${NAME}, or
# ${NAME:-default}, is filled from the loaded config or the calling script's variables;
# a NAME with neither is an error, so a typo never becomes an empty image. $VAR and
# $(VAR) are left alone: they belong to the container. Notes: docs/notes/addons.md
render_manifest() {
local file="$1" text ref key def val
text=$(<"$file") || return 1
while IFS= read -r ref; do
key=${ref#\$\{}; key=${key%\}}; def=""
if [[ $key == *:-* ]]; then def=${key#*:-}; key=${key%%:-*}; fi
if [ -n "${!key:-}" ]; then
val=${!key}
elif [[ $ref == *:-* ]]; then
val=$def
else
echo "render_manifest: $file needs \${$key}, which nothing sets" >&2
return 1
fi
text=${text//"$ref"/"$val"}
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u)
printf '%s\n' "$text"
}
_config_restore() {
local line
while IFS= read -r line; do

View File

@@ -307,7 +307,7 @@ HOUSE_PAT="$(printf '%s' 'semes' 'ter|local' '\.ar\b')"
check "✖ S2 no example environment name from the copies era" "0" \
"$(cd .. && grep -rIlE "$COPIES_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
check "✖ S3 ctrl/addons makes the cluster work, nothing more" "cert-manager metallb metrics-server" \
"$(ls addons/ | sed 's/\.sh$//' | sort | xargs)"
"$(ls addons/*.sh | xargs -n1 basename | sed 's/\.sh$//' | sort | xargs)" # the scripts; their manifests sit beside them
check "✖ S3 versions.env pins no workload image" "0" \
"$(grep -cE '^(POSTGRES|REDIS|AIRFLOW)_IMAGE=' versions.env)"
check "✖ S4 no namespace named after the cluster" "0" "$(grep -c "CLUSTER + ':namespace'" Tiltfile)"
@@ -378,6 +378,16 @@ note "the examples are overlays that work as shipped"
bad=""
for f in ../examples/*/addons/*.sh; do [ -e "$f" ] && { bash -n "$f" 2>/dev/null || bad+="$f "; }; done
check "every example addon parses" "" "$bad"
# An addon's manifest may only name what the config or its own script sets, so a typo
# fails here rather than as an empty value on a cluster.
bad=""
for y in addons/*.yaml ../examples/*/addons/*.yaml; do
[ -e "$y" ] || continue
for n in $(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$y" | tr -d '${}' | sort -u); do
grep -qE "(^|[[:space:]])$n=" "${y%.yaml}.sh" versions.env ../examples/*/rig.env 2>/dev/null || bad+="$y:$n "
done
done
check "every addon manifest names only what is set" "" "$bad"
if command -v python3 >/dev/null; then
bad=""
for f in ../examples/*/dags/*.py; do

View File

@@ -1,56 +0,0 @@
// TODO: PLACEHOLDER — replace with the real estate topology.
//
// This is where the extracted platform diagrams land. The shape below is
// illustrative only: it shows how a mocked dependency, a real service and a
// remote system are meant to sit together, not what the system actually is.
//
// The intended end state is that this file stops being hand-written and is
// generated from the running cluster, so the diagram becomes a report of what
// exists rather than a drawing of what was once intended.
digraph estate {
rankdir=LR
bgcolor="#0a0e17"
fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
label="Estate topology — PLACEHOLDER"
labelloc=t
fontsize=16
fontcolor="#ffc107"
subgraph cluster_new {
label="New"
style=dashed
color="#1e2a4a"
fontcolor="#8892a8"
api [label="service under work\n(real: built and hot-reloaded)" fillcolor="#1a3a1a" fontcolor="#00c853"]
}
subgraph cluster_core {
label="Core (mocked)"
style=dashed
color="#1e2a4a"
fontcolor="#8892a8"
svc_a [label="upstream service\n(mock: canned responses)" fillcolor="#121829"]
db [label="datastore\n(mock)" fillcolor="#121829" shape=cylinder]
}
subgraph cluster_legacy {
label="Legacy estate (mocked)"
style=dashed
color="#1e2a4a"
fontcolor="#8892a8"
batch [label="batch drop\n(mock: writes files on a timer)" fillcolor="#121829"]
}
remote [label="external system\n(remote: ExternalName,\nreachable only from a VDI)" fillcolor="#3a1a1a" fontcolor="#ffc107" shape=octagon]
api -> svc_a [label="HTTP"]
api -> db [label="query"]
api -> batch [label="file handoff" style=dashed]
api -> remote [label="only when reachable" style=dashed color="#ffc107"]
}

View File

@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: estate Pages: 1 -->
<svg width="579pt" height="362pt"
viewBox="0.00 0.00 579.00 362.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 357.62)">
<title>estate</title>
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-357.62 575.41,-357.62 575.41,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="285.7" y="-334.42" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#ffc107">Estate topology — PLACEHOLDER</text>
<g id="clust1" class="cluster">
<title>cluster_new</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-130.12 8,-210.12 199,-210.12 199,-130.12 8,-130.12"/>
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-190.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">New</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_core</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="357.33,-172.12 357.33,-318.12 534.83,-318.12 534.83,-172.12 357.33,-172.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-298.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Core (mocked)</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_legacy</title>
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="341.83,-84.12 341.83,-164.12 551.33,-164.12 551.33,-84.12 341.83,-84.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.58" y="-144.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Legacy estate (mocked)</text>
</g>
<!-- api -->
<g id="node1" class="node">
<title>api</title>
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="191,-174.12 16,-174.12 16,-138.12 191,-138.12 191,-174.12"/>
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-159.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">service under work</text>
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-145.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">(real: built and hot&#45;reloaded)</text>
</g>
<!-- svc_a -->
<g id="node2" class="node">
<title>svc_a</title>
<polygon fill="#121829" stroke="#1e2a4a" points="526.83,-282.12 365.33,-282.12 365.33,-246.12 526.83,-246.12 526.83,-282.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-267.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">upstream service</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-253.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock: canned responses)</text>
</g>
<!-- api&#45;&gt;svc_a -->
<g id="edge1" class="edge">
<title>api&#45;&gt;svc_a</title>
<path fill="none" stroke="#4a5568" d="M149.07,-174.56C167.54,-182.07 189.23,-190.7 209,-198.12 258.25,-216.6 270.12,-222.85 320.75,-237.12 331.43,-240.13 342.71,-243 353.94,-245.67"/>
<polygon fill="#4a5568" stroke="#4a5568" points="353.12,-249.07 363.66,-247.92 354.71,-242.25 353.12,-249.07"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-234.24" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
</g>
<!-- db -->
<g id="node3" class="node">
<title>db</title>
<path fill="#121829" stroke="#1e2a4a" d="M480.7,-223.81C480.7,-226.22 465.18,-228.18 446.08,-228.18 426.97,-228.18 411.45,-226.22 411.45,-223.81 411.45,-223.81 411.45,-184.43 411.45,-184.43 411.45,-182.02 426.97,-180.06 446.08,-180.06 465.18,-180.06 480.7,-182.02 480.7,-184.43 480.7,-184.43 480.7,-223.81 480.7,-223.81"/>
<path fill="none" stroke="#1e2a4a" d="M480.7,-223.81C480.7,-221.39 465.18,-219.43 446.08,-219.43 426.97,-219.43 411.45,-221.39 411.45,-223.81"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-207.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">datastore</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-193.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock)</text>
</g>
<!-- api&#45;&gt;db -->
<g id="edge2" class="edge">
<title>api&#45;&gt;db</title>
<path fill="none" stroke="#4a5568" d="M191.32,-168.36C257.83,-177.73 346.91,-190.28 399.91,-197.75"/>
<polygon fill="#4a5568" stroke="#4a5568" points="399.4,-201.22 409.79,-199.15 400.38,-194.29 399.4,-201.22"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-185.69" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">query</text>
</g>
<!-- batch -->
<g id="node4" class="node">
<title>batch</title>
<polygon fill="#121829" stroke="#1e2a4a" points="537.33,-128.12 354.83,-128.12 354.83,-92.12 537.33,-92.12 537.33,-128.12"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-113.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">batch drop</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-99.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock: writes files on a timer)</text>
</g>
<!-- api&#45;&gt;batch -->
<g id="edge3" class="edge">
<title>api&#45;&gt;batch</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M191.32,-144.39C237.67,-138.13 294.98,-130.39 343.4,-123.85"/>
<polygon fill="#4a5568" stroke="#4a5568" points="343.61,-127.36 353.05,-122.55 342.67,-120.42 343.61,-127.36"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-143.94" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">file handoff</text>
</g>
<!-- remote -->
<g id="node5" class="node">
<title>remote</title>
<polygon fill="#3a1a1a" stroke="#1e2a4a" points="571.41,-21.74 571.41,-52.5 497.99,-74.24 394.17,-74.24 320.75,-52.5 320.75,-21.74 394.17,0 497.99,0 571.41,-21.74"/>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-46.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">external system</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-33.42" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">(remote: ExternalName,</text>
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-19.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">reachable only from a VDI)</text>
</g>
<!-- api&#45;&gt;remote -->
<g id="edge4" class="edge">
<title>api&#45;&gt;remote</title>
<path fill="none" stroke="#ffc107" stroke-dasharray="5,2" d="M149.55,-137.68C167.9,-130.34 189.37,-121.97 209,-114.87 254.45,-98.43 305.32,-81.49 348.17,-67.64"/>
<polygon fill="#ffc107" stroke="#ffc107" points="349,-71.05 357.44,-64.65 346.85,-64.39 349,-71.05"/>
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-117.57" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">only when reachable</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rig — local environment installer</title>
<style>
@@ -237,13 +238,9 @@
<div class="nav-backdrop"></div>
<nav>
<a href="#start">Start here</a>
<a href="#steps">The steps</a>
<a href="#install">Installation</a>
<a href="#environments">Environments</a>
<a href="#overlays">Overlays</a>
<a href="#profiles">Profiles</a>
<a href="#registry">Registry</a>
<a href="#architecture">Architecture</a>
<a href="#troubleshooting">Troubleshooting</a>
</nav>
@@ -251,114 +248,68 @@
<section class="section" id="start">
<h2>Start here</h2>
<p class="lede">A runnable local model of a large, regulated estate — legacy and new side by side.</p>
<div class="prose">
<p>rig builds a disposable Kubernetes environment on your machine so you can
explore how a system fits together without needing access to any of it. Its
job is <b>onboarding and exploration</b>, not a production replica.</p>
<p>rig builds a disposable Kubernetes environment on your machine, so you can
see how a system fits together without access to any of it. It is for
<b>onboarding and exploration</b>, not a production replica.</p>
<p>Most services in it are deliberately <b>not real</b>. What has to be faithful
is the topology — the names, the ports, the dependency order, who can reach whom,
and how it fails. The workloads themselves are noise. This is what makes the
whole estate fit on a laptop: a real 20-service platform will not fit even once
on 14&nbsp;GB, but mocks are about 30&nbsp;MB each, so three faithful copies do.</p>
<p>Most services in it are <b>not real</b>. What has to be faithful is the
topology: names, ports, dependency order, who can reach whom, and how it fails.
That is what makes a whole estate fit on a laptop. A real 20-service platform
does not fit once in 14&nbsp;GB; stubs of about 30&nbsp;MB each fit three times.</p>
<h3>The only prerequisite</h3>
<p><b>Docker.</b> No curl, no jq, no python, no apt repositories to configure.</p>
<pre><code><span class="c"># then, in the environment directory:</span>
make check <span class="c"># is this machine ready? reports, never fixes</span>
make deps <span class="c"># install the pinned toolchain</span>
<pre><code><span class="c"># in the environment directory:</span>
make check <span class="c"># is this machine ready? reports, never changes anything</span>
make deps <span class="c"># install the pinned toolchain; safe to re-run</span>
make cluster up <span class="c"># cluster + registry + addons; ports derive by themselves</span>
</code></pre>
<p>Read <code>make check</code> before <code>make deps</code>. It never changes
anything — it prints what it found and, at the end, the steps it cannot perform
for you.</p>
</div>
</section>
<section class="section" id="steps">
<h2>The steps</h2>
<p class="lede">Start to finish, in order, with what each one actually does.</p>
<div class="prose">
<h3>1 &middot; make check</h3>
<p>Asks whether this machine is ready. It <b>changes nothing</b> — it
reports what it found and, at the end, the things only a human can do
(anything needing <code>sudo</code>, or a Windows-side restart). Read it
before installing anything; it is faster than discovering the same problems
one failure at a time.</p>
<pre><code>make check</code></pre>
<h3>2 &middot; make deps</h3>
<p>Installs the pinned toolchain — only what is missing — and tells you
if its directory is not on PATH yet. Running it twice is safe.</p>
<pre><code>make deps</code></pre>
<h3>3 &middot; make cluster up</h3>
<p>Builds the cluster, starts its registry and installs the profile's
addons — there is nothing else to run first. It prints what the profile
locks in <i>before</i> spending the time, because the kind config is
fixed at creation and cannot be changed afterwards.</p>
<p>Re-running is safe and, more importantly, <b>convergent</b>: if a first
attempt was interrupted before the CNI was installed, running it again
finishes the job rather than reporting "already exists" and leaving every
node permanently NotReady.</p>
<pre><code>make cluster up <span class="c"># built-in defaults — no profile needed</span>
make cluster up PROFILE=mirror <span class="c"># after copying env.d/mirror.env.example: cached registry</span>
<p>Read <code>make check</code> before <code>make deps</code>: it lists what is
missing, including the steps only you can do, such as anything needing
<code>sudo</code> or a Windows-side restart.</p>
<h3>make cluster up</h3>
<p>It prints what the kind config locks in before spending the time, because
that config is fixed when the cluster is created. Re-running is safe and
continues where an interrupted run stopped, rather than reporting that the
cluster already exists and leaving every node <code>NotReady</code>.</p>
<pre><code>make cluster up <span class="c"># built-in defaults — no profile needed</span>
make cluster up PROFILE=mirror <span class="c"># images through an internal registry</span>
make cluster up OVERLAY=examples/data <span class="c"># an overlay: what runs, kept outside rig</span>
make cluster reset <span class="c"># destroy and rebuild — how an edited kind config takes effect</span>
make cluster reset <span class="c"># destroy and rebuild: how an edited kind config takes effect</span>
</code></pre>
<h3>4 &middot; make docs</h3>
<p>Serves this page from a throwaway container. Works with no cluster and
no toolchain, which is deliberate: these pages are the instructions for
building everything else, so they cannot depend on it.</p>
<pre><code>make docs</code></pre>
<h3>Checking on things</h3>
<dl>
<dt>make cluster list</dt><dd>Every cluster on the machine, its memory cost and its port block. The usual reason a new one will not start is an old one you forgot about; <code>make cluster free</code> frees them without deleting.</dd>
<dt>make check</dt><dd>Short: host, toolchain, and whether this cluster fits, its ports, registry and addons. Details only appear when something needs attention; <code>make check all</code> prints every one.</dd>
<dt>make check mem</dt><dd>Memory in depth: what caps it, how far it really climbs, and on WSL the <code>.wslconfig</code> backup and restore.</dd>
<dt>make cluster list</dt><dd>Every cluster on the machine, its memory cost and its port block. A new cluster usually fails to start because of an old one; <code>make cluster free</code> stops them without deleting.</dd>
<dt>make check</dt><dd>Host, toolchain, and whether this cluster fits, its ports, registry and addons. Only problems print; <code>make check all</code> prints everything.</dd>
<dt>make check mem</dt><dd>Memory in depth: what caps it, how far it climbs, and on WSL the <code>.wslconfig</code> backup and restore.</dd>
<dt>make docs</dt><dd>Serves these pages from a throwaway container. It needs no cluster and no toolchain, because these are the instructions for building both.</dd>
</dl>
<h3>Running more than one</h3>
<p>Name another overlay, or copy the directory and rename it. Cluster name,
context, image tags and the port block all follow the folder name — the
overlay's, or rig's — so the second environment collides with nothing and
neither one's teardown can reach the other.</p>
<pre><code>OVERLAY=local/platform-v2 make cluster up
cp -r rig ../platform-v3 &amp;&amp; cd ../platform-v3 &amp;&amp; make cluster up
</code></pre>
</div>
</section>
<section class="section" id="install">
<h2>Installation</h2>
<p class="lede">A container installs onto the host and then gets out of the way.</p>
<div class="graph-container">
<a href="viewer.html?src=graphs/01-install.svg"><img src="graphs/01-install.svg" alt="Installation flow"></a>
<a href="viewer.html?src=graphs/01-install.svg"><img src="graphs/01-install.svg" alt="How the installer works"></a>
</div>
<div class="prose">
<p>The installer is a container, not a shell script, for a specific reason: a
stock slim Debian has no <code>curl</code>, no <code>wget</code>, no
<code>jq</code>, no <code>python3</code> and <b>no CA bundle</b>. A shell
installer could not make a verified HTTPS request, let alone check one. The
container carries its own toolchain, so the host needs nothing but Docker.</p>
<p>The cluster never runs inside that container. Everything it installs —
kind, kubectl, tilt, jq — runs natively afterwards, so nothing pays a
container tax during daily work.</p>
<h3>Pinned and verified</h3>
<p>Every tool is a single binary fetched at a pinned version and checked
against a published SHA256. Node images are pinned <b>by digest</b>, so
upgrading kind cannot silently move your Kubernetes version.</p>
<h3>Not every machine should get cluster tooling</h3>
<p>A managed or corporate-issued machine — the kind that holds the access
you cannot get anywhere else — is not somewhere to install development
tools by default. So the toolchain comes in two tiers:</p>
<p>The installer is a container, not a shell script, because a stock slim Debian
has no curl, wget, jq, python3 or CA bundle: a shell installer could not make a
verified HTTPS request, let alone check one. The container carries its own
toolchain, so the host needs only Docker. Nothing runs inside it afterwards —
kind, kubectl, tilt and jq run natively.</p>
<p>Every tool is a single binary at a pinned version, checked against a published
SHA256. Node images are pinned <b>by digest</b>, so upgrading kind cannot move
your Kubernetes version.</p>
<h3>Two tiers</h3>
<p>A managed or corporate-issued machine is not somewhere to install development
tools by default, so installing cluster tooling is a decision rather than a side
effect:</p>
<table>
<tr><th>tier</th><th>installs</th><th>for</th></tr>
<tr><td><code>core</code></td><td>kubectl, jq</td><td>talk to a cluster someone else runs</td></tr>
@@ -367,11 +318,6 @@ cp -r rig ../platform-v3 &amp;&amp; cd ../platform-v3 &amp;&amp; make cluster up
<pre><code>make deps core <span class="c"># kubectl and jq only — nothing that creates a cluster</span>
make deps <span class="c"># dev, the default</span>
</code></pre>
<p>Testing <i>in situ</i> on a managed machine is still possible — install
the <code>dev</code> tier deliberately when you need it. The point is that
it should be a decision rather than a side effect of installing.</p>
<p>The documentation itself needs neither tier: <code>make docs</code>
wants only Docker.</p>
<h3>Air-gapped</h3>
<pre><code>make deps image full <span class="c"># bakes every binary into the image</span>
@@ -384,185 +330,129 @@ docker load &lt; rig.tgz &amp;&amp; make cluster up PROFILE=offline <span clas
<section class="section" id="environments">
<h2>Environments</h2>
<p class="lede">One folder is one environment — an overlay's, or rig's own. Copy it, rename it, run it.</p>
<div class="graph-container">
<a href="viewer.html?src=graphs/02-environment.svg"><img src="graphs/02-environment.svg" alt="Environment derivation"></a>
</div>
<div class="prose">
<p>Running several versions of a system at once means several clusters on one
machine, not several machines. Everything that could collide is derived from
the folder name (the overlay's when one is named):</p>
<p>One folder is one environment. Running several versions of a system at once
means several clusters on one machine, and everything that could collide is
derived from the folder name (the overlay's, when one is named):</p>
<dl>
<dt>cluster + context</dt><dd><code>platform-v2/</code> builds <code>platform-v2</code> on <code>kind-platform-v2</code>.</dd>
<dt>port block</dt><dd>Ten ports from a hash of the name, in the 20000+ range — clear of 80, 443, 3000, 5432, 8000 and 8080.</dd>
<dt>port block</dt><dd>Ten ports from a hash of the name, above 20000 — clear of 80, 443, 3000, 5432, 8000 and 8080.</dd>
<dt>registry + images</dt><dd>Named after the environment, so two copies never share one.</dd>
</dl>
<p>Two copies therefore never collide, and neither one's
<code>make cluster down</code> can touch the other. <code>make check</code>
shows the block; <code>bash ctrl/ports.sh persist</code> freezes it into
<code>ctrl/.env</code> if you want it fixed rather than derived.</p>
<p>So two copies never collide, and neither one's <code>make cluster down</code>
can touch the other. <code>make check</code> shows the block;
<code>bash ctrl/ports.sh persist</code> freezes it into <code>ctrl/.env</code>.</p>
<h3>Configuration layers</h3>
<p>Weakest first, later wins: built-in defaults → pinned versions → a
profile, if you name one → the overlay's <code>rig.env</code> →
<code>ctrl/.env</code> → the environment. So
<code>make cluster up PROFILE=&lt;name&gt;</code> always beats every file.</p>
<p>Weakest first, later wins: built-in defaults → pinned versions → a profile, if
you name one → the overlay's <code>rig.env</code> → <code>ctrl/.env</code> → the
caller's environment. So a value on the command line always beats every file.</p>
<h3>Profiles</h3>
<p>A profile says how this machine reaches the world. rig needs none.</p>
<table>
<tr><th>example</th><th>registry</th><th>for</th></tr>
<tr><td>none</td><td><code>local</code></td><td>the built-in defaults</td></tr>
<tr><td><code>mirror.env.example</code></td><td><code>mirror</code></td><td>images through an internal registry</td></tr>
<tr><td><code>offline.env.example</code></td><td><code>local</code></td><td>air-gapped</td></tr>
</table>
<h3>Registry modes</h3>
<table>
<tr><th>mode</th><th>what it does</th></tr>
<tr><td><code>none</code></td><td>images are built straight into the node</td></tr>
<tr><td><code>local</code></td><td>a registry container wired into the cluster</td></tr>
<tr><td><code>mirror</code></td><td>that container as a pull-through cache of the corporate registry</td></tr>
<tr><td><code>remote</code></td><td>no local container; pull direct with an <code>imagePullSecret</code></td></tr>
</table>
<p><code>mirror</code> is what a locked-down network looks like: images come from
the corporate registry, you do not hammer it, and you keep working when the
connection drops. It is also only reachable from where that registry is routable —
from a laptop, use <code>local</code> or <code>offline</code>.</p>
<p><b>Corporate CA trust is the usual failure.</b> It has to reach three places:
the host Docker daemon, every node's containerd (nodes do not inherit host trust),
and any in-cluster client. Set <code>REGISTRY_CA_FILE</code>;
<code>make check</code> reports which is still missing. Without it the error is
<code>x509: certificate signed by unknown authority</code>.</p>
<h3>LoadBalancer services</h3>
<p>Real manifests use <code>type: LoadBalancer</code>. On a bare local cluster
those Services sit at <code>EXTERNAL-IP &lt;pending&gt;</code> forever, with no
error: the deployment looks healthy and is unreachable. The <code>metallb</code>
addon fixes that, so upstream manifests work unchanged. Its address pool is
derived from the cluster's Docker network at install time, because Docker picks
that subnet and it differs between machines.</p>
<p>Those addresses are reachable from the Linux side, including inside WSL. A
browser on Windows has no route to them: use the ingress host ports instead.</p>
<h3>Networking and memory</h3>
<p>The cluster uses kind's built-in networking, which does enforce standard
NetworkPolicy — verified against a no-policy control. A pluggable CNI was tried
and removed: it added policy features nothing here needs, in exchange for a
slower boot and one more thing to get right at creation time.</p>
<p>Every cluster is a running container tree whether you use it or not.
<code>make cluster list</code> shows what each costs, and
<code>make cluster free</code> stops the others without deleting them.</p>
</div>
</section>
<section class="section" id="overlays">
<h2>Overlays</h2>
<p class="lede">What runs lives outside rig — rig reads it and never writes into it.</p>
<div class="prose">
<p>An overlay is one folder, kept outside rig's version control, holding a
use case. Every piece is optional:</p>
<p>An overlay is one folder, kept outside rig's version control, holding a use
case. rig reads it and never writes into it. Every piece is optional:</p>
<table>
<tr><th>in the overlay</th><th>what rig does with it</th></tr>
<tr><td><code>rig.env</code></td><td>a config layer: addons, namespaces, images — anything a profile could set</td></tr>
<tr><td><code>k8s/overlays/dev/</code></td><td>the manifests the dev loop applies</td></tr>
<tr><td><code>kind-config.yaml.tpl</code></td><td>the cluster's shape, when it needs its own (mounts, ports)</td></tr>
<tr><td><code>addons/&lt;name&gt;.sh</code></td><td>addons, found before rig's own</td></tr>
<tr><td><code>addons/&lt;name&gt;.sh</code></td><td>addons, found before rig's own; each applies its own <code>&lt;name&gt;.yaml</code></td></tr>
<tr><td><code>Tiltfile</code></td><td>the workload's half of the dev loop, included by rig's</td></tr>
</table>
<pre><code>cp -r examples/starter local/myenv <span class="c"># local/ is gitignored</span>
OVERLAY=local/myenv make cluster up
</code></pre>
<p>With none named, rig runs its own <code>examples/starter</code>.
<code>examples/data</code> carries postgres, redis and airflow as an
overlay's own addons. A project can also carry rig at
<code>&lt;project&gt;/rig/</code> and be the overlay itself, with a
three-line forwarding Makefile — see <code>docs/notes/overlay.md</code>.</p>
</div>
</section>
<section class="section" id="profiles">
<h2>Profiles</h2>
<p class="lede">How this machine reaches the world — optional; rig needs none.</p>
<div class="prose">
<table>
<tr><th>example</th><th>registry</th><th>for</th></tr>
<tr><td><i>none</i></td><td>local</td><td>the built-in defaults; no profile needed</td></tr>
<tr><td><code>mirror.env.example</code></td><td>mirror</td><td>images through an internal registry</td></tr>
<tr><td><code>offline.env.example</code></td><td>local</td><td>air-gapped</td></tr>
</table>
<div class="note"><p><b>The kind config cannot be re-applied.</b> Edit
<code>ctrl/k8s/kind-config.yaml.tpl</code> (or the overlay's own); it takes effect when the cluster is created. <code>cluster up</code> prints what it
locks in before spending the time, and <code>make cluster reset</code> is
the way out.</p></div>
<h3>LoadBalancer services</h3>
<p>Real manifests use <code>type: LoadBalancer</code>, because a real
cluster has one. On a bare local cluster those Services sit at
<code>EXTERNAL-IP &lt;pending&gt;</code> forever, with no error anywhere —
the deployment looks healthy and simply is not reachable.</p>
<p>The <code>metallb</code> addon fixes that, so the same manifests work
here as upstream and nothing has to be rewritten to NodePort. Its address
pool is derived from the cluster's Docker network at install time rather
than hardcoded, because Docker picks that subnet and it differs between
machines.</p>
<div class="note"><p><b>Where those addresses are reachable from.</b> The
pool lives on the Docker bridge, so LoadBalancer IPs work from the Linux
side — including from inside WSL. A browser on Windows has no route to
them. Use the ingress host ports for anything you need to open in a
browser.</p></div>
<h3>Networking</h3>
<p>The cluster uses kind's built-in networking, which <b>does</b> enforce
standard NetworkPolicy — verified against a no-policy control, not assumed.
The widely repeated claim that it accepts policies and silently ignores
them is out of date.</p>
<p>A pluggable CNI was tried and removed: it only added
GlobalNetworkPolicy, policy tiers and egress-CIDR rules, none of which are
needed yet, in exchange for a slower boot and one more thing that has to be
right at creation time. Worth revisiting only when a policy the built-in
cannot express actually comes up.</p>
<h3>Memory</h3>
<p>Every cluster is a running container tree whether you are using it or not.
<code>make cluster list</code> shows what exists and what it costs;
<code>make cluster free</code> stops the others without deleting them.</p>
</div>
</section>
<section class="section" id="registry">
<h2>Registry</h2>
<p class="lede">Local, cached, or straight to the corporate registry.</p>
<div class="prose">
<table>
<tr><th>mode</th><th>what it does</th></tr>
<tr><td><code>none</code></td><td>images are built straight into the node</td></tr>
<tr><td><code>local</code></td><td>a registry container wired into the cluster</td></tr>
<tr><td><code>mirror</code></td><td>that container as a <b>pull-through cache</b> of the corporate registry</td></tr>
<tr><td><code>remote</code></td><td>no local container; pull direct with an imagePullSecret</td></tr>
</table>
<p><code>mirror</code> is what a locked-down network actually looks like:
images originate from the corporate registry, you do not hammer it, and you
keep working when the connection drops.</p>
<div class="note"><p><b>The corporate CA will bite you.</b> A corporate
registry is usually behind an internal CA, and trust has to reach
<b>three</b> places: the host Docker daemon, every cluster node's containerd
(nodes do <i>not</i> inherit host trust), and any in-cluster client. Set
<code>REGISTRY_CA_FILE</code> and <code>make check</code> reports which is
still missing. The symptom otherwise is an opaque
<code>x509: certificate signed by unknown authority</code>.</p></div>
<p>Reachability also depends on where you are: if the registry is only
routable from a managed workspace, <code>mirror</code> and <code>remote</code>
will not resolve from a laptop at all. That is what <code>local</code> and
<code>offline</code> are for.</p>
</div>
</section>
<section class="section" id="architecture">
<h2>Architecture</h2>
<p class="lede">The estate being modelled.</p>
<div class="note"><p><b>TODO — placeholder.</b> The diagram below is
illustrative only: it shows how a mocked dependency, a service under active
work, and an unreachable remote system sit together. It is not the real
topology. Replace <code>docs/graphs/03-architecture.dot</code> with the
extracted platform diagrams, then run <code>make docs graphs</code>.</p></div>
<div class="graph-container">
<a href="viewer.html?src=graphs/03-architecture.svg"><img src="graphs/03-architecture.svg" alt="Estate topology (placeholder)"></a>
</div>
<div class="prose">
<p>Each component is one of three things, and switching between them should be
a one-line change rather than a rewrite:</p>
<code>examples/data</code> carries postgres, redis and airflow as an overlay's own
addons. A project can also carry rig at <code>&lt;project&gt;/rig/</code> and be
the overlay itself, with a three-line forwarding Makefile — see
<code>docs/notes/overlay.md</code>.</p>
<h3>What runs in it</h3>
<p>Each component is one of three things, and switching between them is a
one-line change:</p>
<dl>
<dt>real</dt><dd>Built from source and hot-reloaded. The thing you are actually working on — usually exactly one.</dd>
<dt>real</dt><dd>Built from source and hot-reloaded. What you are working on — usually exactly one.</dd>
<dt>mock</dt><dd>A generic stub with canned responses. Everything you do not care about today.</dd>
<dt>remote</dt><dd>No pod at all: a Service of type ExternalName pointing at the real system. In-cluster DNS resolves identically, so callers never change.</dd>
<dt>remote</dt><dd>No pod at all: a Service of type <code>ExternalName</code> pointing at the real system. In-cluster DNS resolves identically, so callers never change.</dd>
</dl>
<p>The intended end state is that this diagram is <b>generated from the
running cluster</b> rather than drawn by hand — so it becomes a report of
what exists instead of a picture of what was once intended.</p>
</div>
</section>
<section class="section" id="troubleshooting">
<h2>Troubleshooting</h2>
<p class="lede">The failures that are hard to diagnose from their symptoms.</p>
<div class="prose">
<h3>Tilt stops noticing file changes</h3>
<p>Almost always <code>inotify</code> limits, and it fails <i>silently</i> —
nothing errors, changes just stop being picked up. Defaults on WSL are far too
low. <code>make check</code> reports it and prints the fix.</p>
<p>Almost always inotify limits, and it fails silently: nothing errors, changes
just stop being picked up. Defaults on WSL are far too low.
<code>make check</code> reports it and prints the fix.</p>
<h3>Cluster creation dies halfway with a port error</h3>
<p>Docker reports <code>failed to bind host port … address already in use</code>
partway through creating the cluster. Run <code>make check</code> first — it
checks every port in this environment's block before anything is built.</p>
partway through creating the cluster. <code>make check</code> tests every port in
this environment's block before anything is built.</p>
<h3>Every node stays NotReady</h3>
<p>Usually a cluster created with the default CNI disabled but the real CNI
never installed — typically an interrupted first run. Just run
<code>make cluster up</code> again: it converges rather than exiting early, and
will finish the missing steps.</p>
<p>Usually a cluster created with the default CNI disabled and the real CNI never
installed, after an interrupted first run. Run <code>make cluster up</code> again:
it finishes the missing steps instead of stopping because the cluster exists.</p>
<h3>x509: certificate signed by unknown authority</h3>
<p>Corporate CA trust has not reached one of the three places it needs to be.
See <a href="#registry">Registry</a>.</p>
<p>Corporate CA trust has not reached one of the three places it needs to be. See
<a href="#environments">Environments</a>.</p>
<h3>kubectl says the context does not exist</h3>
<p>The cluster can exist while its context does not — a reset or a switched

View File

@@ -4,6 +4,12 @@
Each addon is its own idempotent script — adding one is adding a file, not editing a dispatcher. `ADDONS` names them, in install order; the overlay's `addons/<name>.sh` is found before rig's `ctrl/addons/<name>.sh`, and every one runs from rig's `ctrl/` with `RIG_CTRL` exported, wherever its file lives (see [overlay.md](overlay.md)).
## The manifest beside the script
An addon's Kubernetes objects are a plain `<name>.yaml` beside its `<name>.sh`, not a heredoc inside it: the YAML reads, diffs and lints as YAML, and the script keeps only what YAML cannot do (secrets made once, a database created, waiting for a rollout). The script renders it with `render_manifest "$ADDON_DIR/<name>.yaml"` (`lib/config.sh`) and pipes it to `kubectl apply`.
`render_manifest` fills `${NAME}` and `${NAME:-default}` from the loaded config or the script's own variables, and fails on a name nothing sets, so a typo is an error rather than an empty image. `$VAR` and `$(VAR)` are left alone: they are the container's, as in postgres' `pg_isready -U $POSTGRES_USER`. selftest checks that every name a manifest uses is set somewhere. `ADDON_DIR` is taken before the script's `cd` into rig's `ctrl/`: `ADDON_DIR="$(cd "$(dirname "$0")" && pwd)"`.
## What rig ships, and what it does not
rig's own addons make the *cluster* work, and are useless outside one: metallb, cert-manager, metrics-server. Things a workload happens to need — a database, a cache, a scheduler — are the workload's, and which workload needs which is not rig's business, so they live with the overlay. `examples/data/addons/` has postgres, redis and airflow as a worked example; an overlay that wants them copies them in.

View File

@@ -34,14 +34,37 @@ A port held by THIS environment's own cluster is not a clash; it is the thing wo
The ports are extracted with a second grep rather than `tr -d ':->'`: in tr, ':->' is the character RANGE ':' to '>', which does not contain '-', so the trailing dash survives and nothing ever matches.
## Compact by default
`make check` prints one line per question — host, toolchain, and for this rig: cluster, memory,
ports, registry, addons — and adds detail only where something needs attention (`!` lines, the
"held elsewhere" list when memory is tight, the clashing port). `make check all` prints every fact,
as the full report did before 2026-09-17. `deps.sh detect all` is the same switch for the host part,
so the standalone `rigdeps.sh detect` is short too. Changed because the long report buried the few
lines that mattered.
## Budget first, configuration after (2026-09-23)
`make check` answers the question people run it for first, in the shape the adapter rig had
(the user preferred it): a memory budget with one row per part, the node overhead, the total
against what the machine has and has available; then a one-line verdict (`fits`, `! tight`,
`! does not fit`, with the two opposite fixes told apart); then where things stand and the next
command — not created, stopped (with the `docker start` line), up (with the Tilt and gateway
URLs, and whether this environment's Tilt is already running).
Below that, the configuration as it is, raw: the host and toolchain (`deps.sh detect`), then
one `key value` line each for cluster, context, overlay, profile, nodes, kind config, manifests,
ingress, ports (named), registry, addons. `!` lines always print; `make check all` adds every
fact (per-port detail, addon list, swap and overcommit notes). `deps.sh detect all` is the same
switch for the host part, so the standalone `rigdeps.sh detect` is short too.
## MEMORY_BUDGET
rig knows what a node costs (NODE_MB), not what a workload does, so the parts come from the
overlay's rig.env: `MEMORY_BUDGET="name=MB ..."`, in the order listed; a 0 is left out, and an
entry that is not `name=MB` is a `!` line. rig.env is sourced, so an overlay can size a part by
its own knob (poc_rig's airflow follows its mode). When one part outweighs the rest together
(and is at least 1 GB), check names it as the first thing to switch off; `MEMORY_BUDGET_TIP`
says how, in the overlay's words. No budget: one `?` row says where to add it.
## Tilt: ours or another
A Tilt still running after `make cluster down` holds the Tilt port, and the next `make tilt`
fails with "address already in use" (2026-09-23, the Workspace). check reads the listener with
`ss -p` (our own processes only): a `tilt` whose working directory is this ctrl/ is this
environment's Tilt, reported as running with its URL; another `tilt` is a `!` line with its pid,
its folder and the `kill` that frees the port. Anything else is the ordinary port clash.
## overlay and kind config

View File

@@ -8,6 +8,12 @@ out `python -m http.server` too — a minimal Debian has no python3. What it doe
have, by definition, is Docker: the single prerequisite rig already demands. So a
throwaway nginx container serves a read-only bind mount.
It runs with a config of its own: one worker (nginx's `auto` starts one per core,
twelve on an ordinary laptop, for a handful of static pages), no access log and a
quiet entrypoint, so the terminal shows the URL and nothing else unless something
fails. The pages declare an empty icon, so the browser does not ask for a
`favicon.ico` that is not there.
## Committed SVGs
Rendered SVGs are committed alongside their `.dot` sources for the same reason:

View File

@@ -60,6 +60,9 @@ air-gapped install); an overlay says what runs. `ctrl/.env` is still this
machine's, and the caller still wins over everything.
`rig.env` may not set `PROFILE` or `OVERLAY`: both are chosen before it loads.
`MEMORY_BUDGET` (and optionally `MEMORY_BUDGET_TIP`) is how an overlay tells `make check` what its
workload costs: rig only knows the node. See check.md.
The paths it sets (`MANIFESTS_DIR`, `KIND_CONFIG`) are relative to the overlay.
`MANIFESTS_DIR=none` means rig applies no manifests and the overlay's Tiltfile
does, e.g. when kustomize needs flags.
@@ -100,7 +103,8 @@ load_config
```
`OVERLAY_DIR` is set, so an addon can find files beside it
(`$(_from_ctrl "$OVERLAY_DIR")/...`). rig's own `ctrl/addons/` holds only what
(`$(_from_ctrl "$OVERLAY_DIR")/...`). Its manifest is a `<name>.yaml` beside the script,
rendered with `render_manifest` (see [addons.md](addons.md)). rig's own `ctrl/addons/` holds only what
makes a cluster work (metallb, cert-manager, metrics-server);
`examples/data/addons/` shows workload ones.

View File

@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:,">
<title>Graph Viewer</title>
<style>
* { margin: 0; padding: 0; }

View File

@@ -5,6 +5,7 @@
# Requires the postgres addon; refuses to install without it.
# Notes: ../README.md
set -euo pipefail
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
source ./lib/config.sh
@@ -70,77 +71,7 @@ if [ -d "$dags" ]; then
fi
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: Service
metadata:
name: airflow
spec:
selector:
app: airflow
ports:
- port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: airflow
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: airflow
template:
metadata:
labels:
app: airflow
spec:
containers:
- name: airflow
image: ${AIRFLOW_IMAGE}
args: ["standalone"]
env:
- name: AIRFLOW__CORE__EXECUTOR
value: LocalExecutor
- name: AIRFLOW__CORE__LOAD_EXAMPLES
value: "false"
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
valueFrom:
secretKeyRef: {name: airflow-connections, key: SQL_ALCHEMY_CONN}
- name: AIRFLOW_CONN_APP_DB
valueFrom:
secretKeyRef: {name: airflow-connections, key: AIRFLOW_CONN_APP_DB}
- name: AIRFLOW__CORE__FERNET_KEY
valueFrom:
secretKeyRef: {name: airflow, key: FERNET_KEY}
- name: _AIRFLOW_WWW_USER_USERNAME
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_USER}
- name: _AIRFLOW_WWW_USER_PASSWORD
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
ports:
- containerPort: 8080
volumeMounts:
- name: dags
mountPath: /opt/airflow/dags
readinessProbe:
httpGet:
path: /health
port: 8080
# First boot runs the whole migration before it serves anything.
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 20
volumes:
- name: dags
configMap:
name: airflow-dags
optional: true
YAML
render_manifest "$ADDON_DIR/airflow.yaml" | $K apply -n "$NS" -f - >/dev/null
echo " waiting for airflow (the first boot migrates the database, so this is slow)..."
$K rollout status deployment/airflow -n "$NS" --timeout=600s

View File

@@ -0,0 +1,69 @@
apiVersion: v1
kind: Service
metadata:
name: airflow
spec:
selector:
app: airflow
ports:
- port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: airflow
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: airflow
template:
metadata:
labels:
app: airflow
spec:
containers:
- name: airflow
image: ${AIRFLOW_IMAGE}
args: ["standalone"]
env:
- name: AIRFLOW__CORE__EXECUTOR
value: LocalExecutor
- name: AIRFLOW__CORE__LOAD_EXAMPLES
value: "false"
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
valueFrom:
secretKeyRef: {name: airflow-connections, key: SQL_ALCHEMY_CONN}
- name: AIRFLOW_CONN_APP_DB
valueFrom:
secretKeyRef: {name: airflow-connections, key: AIRFLOW_CONN_APP_DB}
- name: AIRFLOW__CORE__FERNET_KEY
valueFrom:
secretKeyRef: {name: airflow, key: FERNET_KEY}
- name: _AIRFLOW_WWW_USER_USERNAME
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_USER}
- name: _AIRFLOW_WWW_USER_PASSWORD
valueFrom:
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
ports:
- containerPort: 8080
volumeMounts:
- name: dags
mountPath: /opt/airflow/dags
readinessProbe:
httpGet:
path: /health
port: 8080
# First boot runs the whole migration before it serves anything.
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 20
volumes:
- name: dags
configMap:
name: airflow-dags
optional: true

View File

@@ -2,6 +2,7 @@
# PostgreSQL for this overlay: plain manifests, one replica on a PVC, password generated once and kept.
# Notes: ../README.md
set -euo pipefail
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
source ./lib/config.sh
@@ -26,77 +27,7 @@ else
fi
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: ${POSTGRES_STORAGE:-2Gi}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
# One volume, one writer. Rolling would start a second pod against the same
# PVC before the first exits, and Postgres refuses to share a data directory.
strategy:
type: Recreate
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: ${POSTGRES_IMAGE}
envFrom:
- secretRef:
name: postgres
env:
# The image initialises into the volume root otherwise, and a
# lost+found from the PVC makes it refuse to initdb.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
initialDelaySeconds: 30
periodSeconds: 15
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
YAML
render_manifest "$ADDON_DIR/postgres.yaml" | $K apply -n "$NS" -f - >/dev/null
echo " waiting for postgres..."
$K rollout status deployment/postgres -n "$NS" --timeout=240s

View File

@@ -0,0 +1,69 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: ${POSTGRES_STORAGE:-2Gi}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
# One volume, one writer. Rolling would start a second pod against the same
# PVC before the first exits, and Postgres refuses to share a data directory.
strategy:
type: Recreate
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: ${POSTGRES_IMAGE}
envFrom:
- secretRef:
name: postgres
env:
# The image initialises into the volume root otherwise, and a
# lost+found from the PVC makes it refuse to initdb.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
initialDelaySeconds: 30
periodSeconds: 15
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data

View File

@@ -2,6 +2,7 @@
# Redis for this overlay: cache and broker (Celery), no persistence.
# Notes: ../README.md
set -euo pipefail
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
source ./lib/config.sh
@@ -13,43 +14,7 @@ NS="${DATA_NAMESPACE:-data}"
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
echo " applying manifests"
$K apply -n "$NS" -f - >/dev/null <<YAML
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: ${REDIS_IMAGE}
ports:
- containerPort: 6379
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 3
periodSeconds: 5
YAML
render_manifest "$ADDON_DIR/redis.yaml" | $K apply -n "$NS" -f - >/dev/null
echo " waiting for redis..."
$K rollout status deployment/redis -n "$NS" --timeout=180s

View File

@@ -0,0 +1,35 @@
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: ${REDIS_IMAGE}
ports:
- containerPort: 6379
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 3
periodSeconds: 5

View File

@@ -7,6 +7,9 @@
# LocalExecutor and needs no broker: add redis (before airflow) only to switch to Celery.
ADDONS="metallb postgres airflow"
# What each part costs in MB, for `make check`'s memory budget (rig adds the kind node).
MEMORY_BUDGET="airflow=1800 postgres=300"
# Namespace for the dependency containers (k8s/base/kustomization.yaml names it too).
DATA_NAMESPACE=data

View File

@@ -61,6 +61,32 @@ render_kind_config ()
fi;
sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" -e "s|\${OVERLAY_DIR}|${overlay_dir}|g" "$KIND_CONFIG"
}
render_manifest ()
{
local file="$1" text ref key def val;
text=$(< "$file") || return 1;
while IFS= read -r ref; do
key=${ref#\$\{};
key=${key%\}};
def="";
if [[ $key == *:-* ]]; then
def=${key#*:-};
key=${key%%:-*};
fi;
if [ -n "${!key:-}" ]; then
val=${!key};
else
if [[ $ref == *:-* ]]; then
val=$def;
else
echo "render_manifest: $file needs \${$key}, which nothing sets" 1>&2;
return 1;
fi;
fi;
text=${text//"$ref"/"$val"};
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u);
printf '%s\n' "$text"
}
# ── configuration, frozen for profile 'default' ──
load_config() {

View File

@@ -61,6 +61,32 @@ render_kind_config ()
fi;
sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" -e "s|\${OVERLAY_DIR}|${overlay_dir}|g" "$KIND_CONFIG"
}
render_manifest ()
{
local file="$1" text ref key def val;
text=$(< "$file") || return 1;
while IFS= read -r ref; do
key=${ref#\$\{};
key=${key%\}};
def="";
if [[ $key == *:-* ]]; then
def=${key#*:-};
key=${key%%:-*};
fi;
if [ -n "${!key:-}" ]; then
val=${!key};
else
if [[ $ref == *:-* ]]; then
val=$def;
else
echo "render_manifest: $file needs \${$key}, which nothing sets" 1>&2;
return 1;
fi;
fi;
text=${text//"$ref"/"$val"};
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u);
printf '%s\n' "$text"
}
# ── configuration, frozen for profile 'default' ──
load_config() {

View File

@@ -5,12 +5,14 @@ A plexus is a full app — and, unlike everything else here, one that is
vite builds for production.
```bash
make dist # standalone
make dist sample # a named room
make dist # every plexus here
make dist bundle # one, by name
make dist path/to/ui.toml # the [[plexus]] tables of a run file
```
Output: `gen/<room>/plexuses/<name>/index.html`, one self-contained file with its
theme, its data and its diagrams inlined.
Output: `gen/plexuses/<name>/index.html` (or `--dest`), one self-contained file
with its theme, its data and its diagrams inlined. Nothing has to be built first:
a plexus is its own directory, and `soleprint/common/slice/plexus.py` builds it.
## The constraint
@@ -39,15 +41,22 @@ failed requests. Everything else is cosmetic.
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has somewhere to go |
| `%%THEME_CSS%%` | the tokens and rules the page can use, for each theme in `inline_themes` (default: `theme` alone) |
| `%%BUNDLE%%` | `plexus.json` as a JS object literal |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
A room declares which ones it wants in `cfg/<room>/data/plexuses.json`, and may
override anything the manifest sets — most usefully `theme`.
A run file's `[[plexus]]` table can override `theme`, `inline_themes`, `title`
and `description`, point `source` at a plexus kept elsewhere, and set `dest`.
`delivery = "vendored"` renames the output through the mapping profile, fails on
any spr word, and needs a `notice` — the same rules as a vendored slice.
A plexus whose `app/` holds a `package.json` is a **Vue** plexus: its `@ui`
imports are sliced from `common/ui`, `vite build` runs in a temporary directory,
and the script and stylesheet are inlined into the one file. `live/` is the
example.
## Why the diagram is inlined
@@ -61,5 +70,5 @@ diagram become something you could put in a document.
## Adding one
Drop a directory in beside `bundle/`. Nothing dispatches on the name — the room's
`plexuses.json` is the only place it has to be mentioned.
Drop a directory in beside `bundle/`. Nothing dispatches on the name and nothing
lists it: `make dist` builds every directory that holds a `plexus.json`.

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<!--
Bundle plexus — the template. build.py inlines everything marked %%…%% and
writes one self-contained file to gen/<room>/plexuses/bundle/index.html.
Bundle plexus — the template. `make dist bundle` inlines everything marked
%%…%% and writes one self-contained file to gen/plexuses/bundle/index.html.
What this page is FOR: showing the three themes doing real work. Every panel
renders genuine output — showcase.py runs modelgen, datagen and shuntgen over
@@ -217,7 +217,7 @@ if (S.shunt) {
if (S.cabinet) {
out.push('<h2>Dependencies it declares <span class="n">— cabinets</span></h2>');
out.push('<p class="lede">A room asks for <code>' + esc(S.cabinet.name) +
out.push('<p class="lede">An environment asks for <code>' + esc(S.cabinet.name) +
'</code> once. This is merged into its compose file on a laptop; the same name ' +
'installs as a rig addon in a cluster.</p>');
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");

View File

@@ -3,6 +3,7 @@
"title": "Soleprint Bundle",
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
"theme": "lucid",
"inline_themes": ["lucid", "soleprint", "mcrn"],
"graph": "system_overview",
"_comment": "A plexus is exported, not served. build.py inlines the theme, the data and the diagram into one index.html so it survives a locked-down Windows box, a zip attachment and a double-click. Nothing here is fetched at runtime.",

View File

@@ -151,7 +151,7 @@ def _shunt(tmp: Path) -> dict | None:
def _cabinet() -> dict | None:
"""A cabinet's compose fragment, as it gets merged into a room."""
"""A cabinet's compose fragment, as it gets merged into a compose file."""
path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml"
if not path.exists():
return None

View File

@@ -0,0 +1,3 @@
node_modules/
dist/
ui/

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live panels</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{
"name": "live-panels",
"private": true,
"type": "module",
"dependencies": {
"vue": "^3.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5",
"vite": "^6"
}
}

View File

@@ -0,0 +1,4 @@
# pnpm 10+ refuses dependency build scripts unless they are allowed here.
# esbuild (vite's bundler) needs its postinstall to link a platform binary.
allowBuilds:
esbuild: true

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
/*
* A live page with no server: a local generator stands in for a stream, so
* the file behaves the same from a double-click as it would behind an API.
* Everything drawn comes from the slice: Panel, SplitPane, LogRenderer.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { LogRenderer, Panel, SplitPane, type LogEntry } from '@ui'
const STAGES = ['ingest', 'validate', 'transform', 'load']
const LEVELS = ['INFO', 'INFO', 'INFO', 'DEBUG', 'WARN', 'ERROR']
const MAX_LINES = 2000
const entries = ref<LogEntry[]>([])
const counts = ref<Record<string, number>>({})
const running = ref(true)
let timer: number | undefined
function tick(): void {
const level = LEVELS[Math.floor(Math.random() * LEVELS.length)]
const stage = STAGES[Math.floor(Math.random() * STAGES.length)]
const n = entries.value.length + 1
entries.value.push({ level, stage, msg: `record ${n} ${level === 'ERROR' ? 'rejected' : 'ok'}`,
ts: new Date().toISOString().slice(11, 23) })
if (entries.value.length > MAX_LINES) entries.value.splice(0, entries.value.length - MAX_LINES)
counts.value[level] = (counts.value[level] ?? 0) + 1
}
function toggle(): void {
running.value = !running.value
if (running.value) start()
else stop()
}
function start(): void { timer = window.setInterval(tick, 50) }
function stop(): void { window.clearInterval(timer) }
const status = computed(() => (running.value ? 'live' : 'idle'))
onMounted(start)
onUnmounted(stop)
</script>
<template>
<div class="page">
<SplitPane direction="horizontal" :initial-size="2">
<template #first>
<Panel title="Events" :status="status">
<template #actions>
<button @click="toggle">{{ running ? 'pause' : 'resume' }}</button>
</template>
<LogRenderer :entries="entries" />
</Panel>
</template>
<template #second>
<Panel title="Counts" :maximizable="false">
<dl class="counts">
<template v-for="level in ['INFO', 'DEBUG', 'WARN', 'ERROR']" :key="level">
<dt>{{ level }}</dt>
<dd>{{ counts[level] ?? 0 }}</dd>
</template>
</dl>
</Panel>
</template>
</SplitPane>
</div>
</template>
<style>
html, body, #app { height: 100%; margin: 0; }
.page { height: 100%; padding: var(--space-3); box-sizing: border-box; }
.counts { display: grid; grid-template-columns: auto 1fr; gap: var(--space-2) var(--space-4);
font-family: var(--font-mono); color: var(--text-primary); margin: 0; }
.counts dt { color: var(--text-secondary); }
.counts dd { margin: 0; text-align: right; }
</style>

View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@@ -0,0 +1,6 @@
{
"name": "live",
"title": "Live panels",
"description": "A panel layout fed by a local generator, built from a slice of the Vue library into one file that opens with no server.",
"_comment": "A Vue plexus: app/ is an ordinary Vite + Vue app whose `@ui` imports are sliced from common/ui at build time. `make dist live` builds it in a temporary directory and inlines the script and stylesheet, so the output is one file. Nothing here is fetched at runtime; the generator stands in for a stream."
}

View File

@@ -159,6 +159,19 @@
display: flex;
align-items: center;
gap: var(--space-2);
/* The header is a fixed height and the panel clips, so a crowded actions strip
silently loses whatever sits at its end. Labels give way instead: they
truncate, the controls never shrink. (As Panel.vue does.) */
min-width: 0;
}
.panel-actions > :is(button, input, select, label) {
flex-shrink: 0;
}
.panel-actions > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-status {

View File

@@ -165,6 +165,19 @@ textarea {
display: flex;
align-items: center;
gap: var(--space-2);
/* The header is a fixed height and the panel clips, so a crowded actions strip
silently loses whatever sits at its end. Labels give way instead: they
truncate, the controls never shrink. (As Panel.vue does.) */
min-width: 0;
}
.panel-actions > :is(button, input, select, label) {
flex-shrink: 0;
}
.panel-actions > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-status {

View File

@@ -3,3 +3,4 @@ out/
__pycache__/
*.pyc
.venv/
.docgen/

View File

@@ -43,6 +43,7 @@ SLUG ?=
# en_US.UTF-8 from the environment and --reader rejects it.
READER ?= python
OVERLAY ?=
NOTES ?=
CONFIG ?= docgen.toml
ONLY ?=
CHECK ?=
@@ -54,7 +55,7 @@ SLUG_ARG := $(if $(SLUG),--slug $(SLUG))
OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
ONLY_ARGS := $(foreach n,$(subst $(comma), ,$(ONLY)),--only $(n))
.PHONY: help sync lock book run check ir db code view graph index site minimap explore docs self doctor clean
.PHONY: help sync lock book run scaffold check ir db code view graph index minisite minimap explore docs self doctor clean
help: ## List every target
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
@@ -68,6 +69,7 @@ help: ## List every target
@echo " BOOK=/path where a book goes, and which book to check"
@echo " READER=python|code ast, or tree-sitter SLUG=name what to call the book"
@echo " OVERLAY=overlay.json hand-written notebook additions, re-applied every build"
@echo " NOTES=notes/ markdown filed by page: notes/<book>/<page>.md"
@echo " CONFIG=docgen.toml a run file ONLY=a,b just these books"
@echo " CHECK=1 with run: each book's own level after building it"
@echo
@@ -92,11 +94,14 @@ book: ## SRC (or SCHEMA/OPENAPI/HAR) -> one operation, measured at both ends
$(if $(OPENAPI),--openapi "$(OPENAPI)") \
$(if $(HAR),--har "$(HAR)") \
-o "$(if $(BOOK),$(BOOK),$(OUT)/book)" \
$(STYLE_ARGS) $(SLUG_ARG) $(OVER_ARG)
$(STYLE_ARGS) $(SLUG_ARG) $(OVER_ARG) $(if $(NOTES),--notes "$(NOTES)")
run: ## CONFIG (a run file) -> every book it lists; ONLY=a,b for some
run: ## CONFIG (a run file or a project file) -> every book it lists; ONLY=a,b for some
@$(CLI) run "$(CONFIG)" $(ONLY_ARGS) $(if $(CHECK),--check)
scaffold: ## CONFIG -> an empty note for every page and step, never overwriting
@$(CLI) scaffold "$(CONFIG)" $(ONLY_ARGS)
check: ## Prove docgen (or one book, with BOOK=<dir>)
@$(CLI) check $(if $(BOOK),"$(BOOK)")
@@ -125,9 +130,9 @@ index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json
@$(CLI) emit index $(OUT)/ir.json -o $(OUT)/index.md
@$(CLI) emit index $(OUT)/ir.json -o $(OUT)/sidebar.json
site: view ## OUT/view.json -> a self-contained docs site in OUT/site
@$(CLI) emit site $(OUT)/view.json -o $(OUT)/site $(STYLE_ARGS)
@echo " open $(OUT)/site/index.html"
minisite: view ## OUT/view.json -> a self-contained page in OUT/minisite
@$(CLI) emit minisite $(OUT)/view.json -o $(OUT)/minisite $(STYLE_ARGS)
@echo " open $(OUT)/minisite/index.html"
minimap: ## OUT/ir.json -> OUT/minimap.svg — what is where, read from the colours
@$(CLI) emit minimap $(OUT)/ir.json -o $(OUT)/minimap.svg $(STYLE_ARGS) --scale $(SCALE)
@@ -144,7 +149,7 @@ docs: ## Regenerate the figures in docs/ — docgen documented by docgen
@$(CLI) emit minimap /tmp/$(PKG)-docs.json -o $(HERE)/docs/img/minimap.svg --scale 0.5 --width 860
@$(CLI) emit erd $(OUT)/ir.json -o $(HERE)/docs/img/erd.svg 2>/dev/null \
|| echo " (erd figure kept — needs a schema IR at $(OUT)/ir.json to refresh)"
@PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).emitters.site import VIEWER, _slots, _fill; \
@PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).emitters.minisite import VIEWER, _slots, _fill; \
from $(PKG).style import Style; import pathlib; \
pathlib.Path('$(HERE)/docs/viewer.html').write_text( \
_fill(VIEWER.replace('__TITLE__', 'docgen docs'), _slots(Style.load('lucid'))))"

View File

@@ -17,17 +17,25 @@ extractors/ → graph IR (JSON) → emitters/
make sync # optional: .venv with every group, from uv.lock
make check # prove it, on a tree it builds itself
make book SRC=/path/to/repo # one whole operation, measured at both ends
make run CONFIG=docgen.toml # every book a run file lists
make run CONFIG=docgen.toml # every book a run file lists, or a project's document
make scaffold CONFIG=docgen.toml # an empty note for every page and step
python3 -m docgen contract # the instructions to hand a model
make help
```
The full documentation is `docs/index.html` — open it in a browser.
The full documentation is `docs/index.html` — open it in a browser. Start with
**Your first project** for a step-by-step walkthrough.
**For a model** writing `docgen.toml` and notes: `python3 -m docgen contract` prints
`docs/models.md` — every key, every rule, every error with its fix. Attach it (with
explode's contract, if replies go through explode). The suite checks it against the
loaders, so it cannot say something docgen does not accept.
## Layout
```
cli/ the command line — every command, and nothing else
book/ an operation: larder measure, steps, web output, run files
book/ an operation: larder measure, steps, pages, notes, documents, run files
extractors/ source -> IR, one per source type
ir/ the contract: schema.json, the dataclasses, the validator
ops/ IR -> a smaller IR
@@ -69,6 +77,40 @@ stops the others; unknown keys are refused; a rebuild replaces the previous
build's outputs and never touches a hand-written `checks.py`.
`docgen.example.toml` runs against the shipped fixtures.
## Project files, pages and notes
A **project file** is one document built from several sources — the shape a model
writes unprompted:
```toml
[project]
name = "orders-api"
[extract.openapi]
spec = "doc/contracts/openapi.yaml"
[extract.code]
root = "src"
ext = [".cs"]
[emit.notebook]
output = "../docs_out/orders_api_notebook"
```
Each `[extract.*]` is a book — a *part* of every output — and each `[emit.*]` an
output made of all of them. Keys docgen deliberately doesn't do (`[extract.db] url`,
shell/YAML/SQL `ext`, unknown readers) are refused with what to do instead.
A book's diagram is split into **pages** by structure — overview first, one page per
subsystem — measured until each fits its column. On the minisite the diagrams are inline,
so a box opens its page; in a notebook, a page index links every figure, and each
figure is an SVG cell attachment that renders without running anything.
Hand-written text goes in **notes**: `notes/<book>/<page>.md` lands above that page.
`make scaffold` writes an empty stub for every spot; a note whose page disappeared is
reported and fails the check level. `docgen.project.example.toml` builds the fixtures
into one document with a sample note.
Or as three composable commands, which is what the Makefile is wrapping — run
from the directory above this one:
@@ -240,8 +282,8 @@ about either.
## Testing
```bash
make check # docgen's own suite: 287 after make sync, 272 with nothing
make check BOOK=out/book/x # one book's own level — generated and hand-written checks
make check # docgen's own suite: 350 after make sync, 335 with nothing
make check BOOK=out/book/x # one book's own level — or a project's .docgen/<name>
make doctor # the machine; never fails
```

View File

@@ -30,7 +30,7 @@ artifacts useful, and that property is the whole reason the sequence is worth
having.
**Not a new pipeline.** Everything here composes functions that already existed
— `ops.overview`, `emitters.dot`, `emitters.site`, `notebook.spec`. The spine
— `ops.overview`, `emitters.dot`, `emitters.minisite`, `notebook.spec`. The spine
adds a ledger and two measurements. If it ever starts doing the work itself,
something has gone wrong.
@@ -48,7 +48,7 @@ from pathlib import Path
from .larder import Larder
# The two spine steps. Named here because the notebook, the site and the checks
# The two spine steps. Named here because the notebook, the minisite and the checks
# all have to agree on what they are called, and three string literals in three
# files is how they stop agreeing.
FIRST = "larder"
@@ -258,13 +258,13 @@ class Book:
# -- writing ----------------------------------------------------------
def close(self, *, site=None) -> Step:
def close(self, *, minisite=None) -> Step:
"""Append the last step. Call once, after the web output is written.
Must be called BEFORE the notebook is built, because the notebook quotes
the book measure and the measure is not complete until this step exists.
The site is this step's *artifact*. The notebook is not: it is the whole
The minisite is this step's *artifact*. The notebook is not: it is the whole
sequence's rendering rather than an item in it, and it is set on
`self.notebook` afterwards, by name only — a notebook cannot report its
own byte count without changing it.
@@ -273,10 +273,10 @@ class Book:
summary = " · ".join(f"{v} {k}" for k, v in
sorted(m["by_kind"].items(), key=lambda kv: -kv[1]))
artifact, size = None, 0
if site is not None:
site = Path(site)
if site.exists():
artifact, size = str(site.relative_to(self.out)), _tree_bytes(site)
if minisite is not None:
minisite = Path(minisite)
if minisite.exists():
artifact, size = str(minisite.relative_to(self.out)), _tree_bytes(minisite)
self.steps.append(Step(
id=LAST,
label="what came out",

View File

@@ -4,7 +4,7 @@ Run a book: larder, the steps, the web output, the book measure.
python3 -m docgen book --root ../station -o out/book/station
Composes functions that already exist. Nothing here parses, lays out or styles
anything — `ops.overview`, `emitters.auto.draw`, `emitters.site` and `notebook.spec`
anything — `ops.overview`, `emitters.auto.draw`, `emitters.minisite` and `notebook.spec`
do all of it, and this decides the order and writes the ledger. If this file
ever starts doing the work, the seam has moved to the wrong place.
@@ -73,7 +73,7 @@ EXTRACTORS = {
TAKES_EXCLUDE = {"python", "code"}
def extract(kind: str, source, *, exclude=(), identity=None):
def extract(kind: str, source, *, exclude=(), identity=None, suffixes=None):
"""(IR dict, Larder) for one source type, dispatched through EXTRACTORS."""
from importlib import import_module
@@ -91,6 +91,8 @@ def extract(kind: str, source, *, exclude=(), identity=None):
kwargs = {"identity": identity or str(source)}
if kind in TAKES_EXCLUDE:
kwargs["exclude"] = exclude
if kind == "code" and suffixes:
kwargs["suffixes"] = suffixes
g = fn(source, **kwargs)
ir = g.to_dict()
@@ -102,7 +104,7 @@ def extract(kind: str, source, *, exclude=(), identity=None):
return ir, Larder.from_dict(measured)
def spec_from(book: Book, ir: dict) -> dict:
def spec_from(book: Book, ir: dict, *, prelude_dir: str | None = None) -> dict:
"""The book's ledger -> a notebook spec, with the two measures at the ends.
Reuses `notebook.spec`'s step vocabulary rather than inventing one, so
@@ -128,7 +130,10 @@ def spec_from(book: Book, ir: dict) -> dict:
),
),
spec_mod._step("prelude", "code", title="Reading a step on its own",
code=PRELUDE),
# In its own book directory the notebook reads steps from
# where it is opened; elsewhere it points back at the book.
code=PRELUDE if prelude_dir is None else PRELUDE.replace(
"BOOK = Path.cwd()", f"BOOK = Path.cwd() / {prelude_dir!r}", 1)),
]
for s in book.steps:
@@ -165,7 +170,7 @@ def spec_from(book: Book, ir: dict) -> dict:
f"**{summary} · {m['edges']} edges · {m['external']} external**\n\n"
f"{len(m['artifacts'])} artifact(s), {m['bytes']:,} bytes.\n\n"
f"Reconciled against what came in:\n\n{reconciled}\n\n"
"The web output is `site/index.html`."
"The web output is `minisite/index.html`."
),
))
@@ -191,7 +196,9 @@ def _load_cell(step_id: str, artifact: str) -> str:
if artifact.endswith(".json"):
return (
f'data = load("{artifact}")\n'
'if data:\n'
'if isinstance(data, list):\n'
' print(f"{len(data)} item(s)")\n'
'elif data:\n'
' if "nodes" in data:\n'
' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
' else:\n'
@@ -222,14 +229,29 @@ def _load_cell(step_id: str, artifact: str) -> str:
# remove first. Named, never globbed: a book directory also holds the hand-written
# `checks.py` and `overlay.json`, and a misconfigured output path could point at
# somebody's source tree — so nothing outside this list is ever deleted.
GENERATED = ("book.json", "notebook.ipynb", "steps", "site", "explore")
# `site` is the minisite's old name: a book built before the rename still has one.
GENERATED = ("book.json", "notebook.ipynb", "steps", "minisite", "site", "explore",
"document.json")
# The steps a book runs, by id — fixed, so a note can be filed under one before
# the book has ever been built, and a skipped step does not make its note drift.
STEP_LABELS = (
("ir", "the graph, extracted"),
("view", "the default view for this source"),
("pages", "the diagram, split into pages that fit"),
("graph", "the whole view in one drawing"),
("index", "readable without a diagram"),
("sidebar", "navigation, for whatever renders it"),
("minimap", "what is where, read from the colours"),
("explore", "navigate on one side, explore on the other"),
)
def clear(out) -> list[str]:
"""Remove a previous build's outputs from `out`. Returns what was removed.
Without this, re-running a book leaves the last run's artifacts beside the new
ledger: a graph that is now `graph.md` keeps its old `graph.svg`, and the site
ledger: a graph that is now `graph.md` keeps its old `graph.svg`, and the minisite
goes on showing it. The ledger would not list the stale file, so nothing would
say it was stale — which is the failure the book measure exists to prevent.
@@ -253,12 +275,20 @@ def clear(out) -> list[str]:
return removed
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
"""The whole book, in one process. Returns it; `book.json` is written."""
def build_book(kind: str, source, out, *, slug: str | None = None, exclude=(),
ops: dict | None = None, suffixes=None, identity: str | None = None,
quiet: bool = False) -> Book:
"""One book's steps: larder, IR, view, pages, drawings. No minisite, no notebook.
Those are a document's outputs and are assembled by `book/document.py`, which
may put several books into one of each. The book is returned open — its last
step is added once the outputs it belongs to exist.
"""
from ..ir import check
from ..ops import classify, overview
from ..ops.pages import for_view
from ..style import Style
from .document import apply_ops
out = Path(out)
slug = slug or Path(str(source)).name or "book"
@@ -270,9 +300,9 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
if not quiet:
print(text)
ir, larder = extract(kind, source, exclude=exclude)
ir, larder = extract(kind, source, exclude=exclude, suffixes=suffixes, identity=identity)
if larder is None:
larder = Larder(kind=kind, identity=str(source), unit="document", seen=0)
larder = Larder(kind=kind, identity=identity or str(source), unit="document", seen=0)
book = Book(slug=slug, larder=larder, out=out)
book.ir = ir
say(f" larder {larder.line()}")
@@ -289,22 +319,31 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
p.write_text(json.dumps(ir, indent=2) + "\n")
book.step("ir", "the graph, extracted", p, note=f"{len(ir['nodes'])} nodes")
view = overview(ir)
view = apply_ops(overview(ir), ops or {})
book.view = view
p = steps_dir / "view.json"
p.write_text(json.dumps(view, indent=2) + "\n")
verdict = classify(view)
book.step("view", "the default view for this source", p,
note=f"{verdict['kind']} — {verdict['why']}")
style_obj = Style.load(style, theme=theme)
# Which pages exist — width-free, so it is decided once. Each output lays
# them out for its own column when it is assembled.
book.pages = for_view(ir, view)
p = steps_dir / "pages.json"
p.write_text(json.dumps(
[{k: v for k, v in page.items() if k != "ir"} for page in book.pages],
indent=2) + "\n")
book.step("pages", "the diagram, split into pages that fit", p,
note=f"{len(book.pages)} page(s)")
style_obj = Style.load("lucid")
# The drawing, chosen by structure rather than by the caller — the same
# `draw()` the `emit auto` and `emit site` commands use, so the three can
# never disagree about what a graph wants.
# The whole view in one drawing — still worth having as a step on its own,
# and the same `draw()` the `emit auto` command uses.
from ..emitters.auto import draw
drawn = draw(view, style_obj)
graph_rel = None
labels = {"erd": "drawn as an ERD", "dot": f"drawn as a {verdict['kind']}",
"index": "not a diagram — written as a list"}
if drawn["content"] is None:
@@ -315,8 +354,6 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
p.write_bytes(drawn["content"])
else:
p.write_text(drawn["content"])
if drawn["suffix"] == ".svg":
graph_rel = f"steps/{p.name}"
book.step("graph", labels[drawn["emitter"]], p, note=drawn["why"])
from ..emitters.index import to_markdown, to_sidebar
@@ -343,38 +380,41 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
except Exception as e: # noqa: BLE001
book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
# -- the last step ----------------------------------------------------
from ..emitters.site import write as site_write
site_dir = out / "site"
site_write(view, style_obj, site_dir,
graph=Path(graph_rel).name if graph_rel else None,
title=slug, book=book.to_dict())
if graph_rel and (out / graph_rel).exists():
(site_dir / Path(graph_rel).name).write_bytes((out / graph_rel).read_bytes())
# close() BEFORE the notebook spec is built, not after. The spec quotes the
# book measure, and the book measure only includes the site once the last
# step exists — build it the other way round and the notebook says 7
# artifacts while book.json says 8, which is exactly what it did.
book.close(site=site_dir)
from ..emitters.notebook import write as nb_write
from ..notebook import spec as spec_mod
spec, spec_problems = spec_mod.merge(spec_from(book, ir), overlay)
nb_write(spec, out / "notebook.ipynb")
# Recorded by name and not by size: a notebook cannot report its own byte
# count without changing it.
book.notebook = "notebook.ipynb"
return book
for pr in spec_problems:
say(f" overlay {pr}")
path = book.write()
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
theme: str | None = None, exclude=(), overlay=None, notes=None,
identity: str | None = None, quiet: bool = False) -> Book:
"""One book, as a one-book document whose outputs live in its own directory.
`book.json`, `steps/`, `minisite/` and `notebook.ipynb` land where they always did;
the minisite and notebook are now assembled the same way a many-book document's are.
"""
from .document import Document, Output, Part, build
out = Path(out)
slug = slug or Path(str(source)).name or "book"
doc = Document(
name=slug, title=slug, build=out, inline=True,
notes=Path(notes) if notes else None,
parts=[Part(name=slug, kind=kind, source=Path(source), out=out,
exclude=tuple(exclude), overlay=overlay, identity=identity)],
outputs=[Output("minisite", out / "minisite", style=style, theme=theme),
Output("notebook", out / "notebook.ipynb", style=style, theme=theme)],
)
build(doc, quiet=quiet)
book = doc.books[0]
for pr in doc.problems:
if not quiet:
print(f" note {pr}")
m = book.measure()
say(f" book {m['nodes']} nodes · {m['edges']} edges · "
f"{m['external']} external · {len(m['artifacts'])} artifacts")
for r in book.compare():
say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
say(f" open {site_dir / 'index.html'}")
say(f" {path}")
if not quiet:
print(f" book {m['nodes']} nodes · {m['edges']} edges · "
f"{m['external']} external · {len(m['artifacts'])} artifacts")
for r in book.compare():
print(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
print(f" open {out / 'minisite' / 'index.html'}")
print(f" {out / 'book.json'}")
return book

View File

@@ -90,8 +90,9 @@ class Loaded:
class Report:
"""rig's reporting shape: sections, one line per decision, nothing aborts."""
def __init__(self):
def __init__(self, subject: str = "book"):
self.passed, self.failed, self.skipped = 0, [], 0
self.subject = subject
def note(self, text: str) -> None:
print(f"\n{text}")
@@ -116,7 +117,7 @@ class Report:
# it summarises — which is how this was found, piping to `tail`.
sys.stdout.flush()
if not self.failed:
print(f"{self.passed} checks passed — this book still holds"
print(f"{self.passed} checks passed — this {self.subject} still holds"
+ (f", {self.skipped} skipped" if self.skipped else ""))
return 0
print(f"FAILED — {len(self.failed)} of {self.passed + len(self.failed)}: "
@@ -190,12 +191,12 @@ def generated(book: Loaded, r: Report) -> None:
r.check(f"its {ran} code cells run, not merely compile", None, failure)
r.note("the web output exists and shows both ends")
index = book.dir / "site" / "index.html"
index = book.dir / "minisite" / "index.html"
if not index.exists():
r.skip("site", "no site/index.html in this book")
r.skip("minisite", "no minisite/index.html in this book")
else:
html = index.read_text()
# The site is the last step precisely because it is the artifact somebody
# The minisite is the last step precisely because it is the artifact somebody
# definitely opens. A page that shows the result without showing what
# went in is the thing this whole change corrects.
r.check("the page says what came in", True, "what came in" in html)
@@ -204,6 +205,12 @@ def generated(book: Loaded, r: Report) -> None:
r.check("the page admits the book is incomplete", True,
"This book is incomplete" in html)
# A book built inline — its own minisite and notebook in its own directory —
# also carries the document ledger, and with it the notes it used.
doc_ledger = book.dir / "document.json"
if doc_ledger.is_file():
_notes_drift(json.loads(doc_ledger.read_text()), r)
r.note("the book is reproducible")
# A timestamp would make two builds of an unchanged larder differ, which
# destroys the only useful property a ledger has: that a diff means a real
@@ -272,3 +279,99 @@ def _run_cells(cells: list, cwd: Path) -> tuple[int, str | None]:
finally:
os.chdir(previous)
return ran, failure
# -- documents --------------------------------------------------------------------
def _notes_drift(ledger: dict, r: Report) -> None:
r.note("notes land where they are filed")
# A note filed under a page that no longer exists has nowhere to go. It is
# reported rather than dropped, because dropping it is how hand-written text
# goes stale without anyone noticing — and this is the check that fails on it.
drift = (ledger.get("notes") or {}).get("drift") or []
r.check("no note is filed under a page or step that is gone", [], drift)
def document(build_dir, r: Report) -> None:
"""A document's own assertions: its books, its notes, every output it wrote."""
import re
build_dir = Path(build_dir)
ledger = json.loads((build_dir / "document.json").read_text())
books = ledger.get("books") or []
r.note("the document is made of its books")
r.check("it has at least one book", True, len(books) > 0)
for b in books:
r.check(f"book {b['name']} has its ledger", True, (Path(b["dir"]) / "book.json").is_file())
_notes_drift(ledger, r)
total_pages = sum(len(b.get("pages") or []) for b in books)
for out in ledger.get("outputs") or []:
path = Path(out["path"])
if out["emitter"] == "notebook":
r.note(f"the notebook — {path.name}")
if not path.is_file():
r.check(f"{path} exists", True, False)
continue
nb = json.loads(path.read_text())
text = ["".join(c.get("source") or []) for c in nb.get("cells") or []]
# Both ends of every part: the rule holds per book, not only overall.
# Matched by book name, because the document's own closing cell is
# also headed "— what came out" and a bare count mistook it for a part.
def heading(name, end):
return sum(1 for t in text if f"## {name} — what came {end}" in t)
r.check("every book part opens with what came in", [],
[b["name"] for b in books if heading(b["name"], "in") != 1])
r.check("every book part closes with what came out", [],
[b["name"] for b in books if heading(b["name"], "out") != 1])
figures = [c for c in nb.get("cells") or [] if '<a name="page-' in "".join(c.get("source") or [])]
r.check("every page is a figure", total_pages, len(figures))
drawn = sum(1 for c in figures if (c.get("attachments") or {}))
if drawn < len(figures):
r.skip("figures carry their SVG", f"{len(figures) - drawn} not drawn — Graphviz absent?")
else:
r.check("every figure carries its SVG, so it renders without running", True, True)
elif out["emitter"] == "minisite":
r.note(f"the minisite — {path}")
index = path / "index.html"
r.check("index.html exists", True, index.is_file())
if not index.is_file():
continue
html = index.read_text()
# Counted on the ledger's own labels, not the phrase: the page's
# introduction says "what came in" too, and was counted as a book.
r.check("the index shows what came in, for every book", len(books),
html.count("<dt>what came in</dt>"))
r.check("the index shows what came out, for every book", len(books),
html.count("<dt>what came out</dt>"))
manifest = path / ".docgen-output.json"
written = json.loads(manifest.read_text()).get("written", []) if manifest.is_file() else []
r.check("the output lists what it wrote", True, bool(written))
r.check("every file it lists is there", [], [w for w in written if not (path / w).is_file()])
# Every link on every page resolves — the drill-down is only worth
# having if following it never lands on nothing.
broken = []
for page in sorted(path.glob("*/*.html")):
for href in re.findall(r'href="([^"#?]+)', page.read_text()):
if href.startswith(("http:", "https:", "mailto:")):
continue
if not (page.parent / href).exists():
broken.append(f"{page.relative_to(path)} -> {href}")
r.check("every link on every page opens something", [], broken[:10])
pages = sum(1 for b in books for p in (b.get("pages") or [])
if (path / b["name"] / f"{p['slug']}.html").is_file())
r.check("every page has its own HTML", total_pages, pages)
def document_failures(build_dir, *, quiet: bool = True) -> list[str]:
import contextlib
import io
report = Report("document")
sink = io.StringIO() if quiet else None
with contextlib.redirect_stdout(sink) if sink else contextlib.nullcontext():
document(build_dir, report)
return list(report.failed)

View File

@@ -71,8 +71,8 @@ SOURCES = {"root": "python", "schema": "db", "openapi": "openapi", "har": "usage
READERS = ("python", "code")
TOP_KEYS = {"reference", "defaults", "book"}
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader"}
BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", *SOURCES}
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader", "notes"}
BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", "notes", *SOURCES}
# A book name becomes a directory name and a slug, so it is held to what is safe
# as both — no separators, nothing that means something to a shell.
@@ -99,6 +99,11 @@ class Entry:
theme: str | None = None
overlay: Path | None = None
exclude: tuple = ()
notes: Path | None = None
# The source as the run file wrote it. `source` is resolved so it can be
# read; this is what the ledger shows, because an absolute path is a fact
# about this machine and the outputs travel.
identity: str | None = None
def line(self) -> str:
return f"{self.name:<18} {self.kind:<8} {self.source} -> {self.out}"
@@ -137,18 +142,38 @@ def _within(inner: Path, outer: Path) -> bool:
return inner == outer or outer in inner.parents
def load(path) -> RunFile:
"""Read and resolve a run file. Raises ConfigError listing every problem."""
path = Path(path)
def _read(path: Path) -> dict:
if tomllib is None:
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
try:
data = tomllib.loads(path.read_text())
return tomllib.loads(path.read_text())
except OSError as e:
raise ConfigError(path, [f"cannot read: {e}"]) from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
def load(path):
"""Read and resolve a run file of either shape. Raises ConfigError listing
every problem.
Returns a `RunFile` for a `[[book]]` list, or a `ProjectFile` for a project.
A file with both is refused: which one it meant is exactly what nobody should
have to guess.
"""
path = Path(path)
data = _read(path)
if "project" in data and "book" in data:
raise ConfigError(path, [
"this file has both [project] and [[book]] — use one shape per file: "
"[project] for one document built from several sources, [[book]] for a "
"list of independent books"])
if "project" in data:
return load_project(path, data)
return _load_books(path, data)
def _load_books(path: Path, data: dict) -> "RunFile":
base = path.resolve().parent
problems: list[str] = []
@@ -246,10 +271,13 @@ def load(path) -> RunFile:
if source_key == "root" and source.is_dir() and _within(out, source):
problems.append(f"{where}: out {out} is inside the tree it reads ({source})")
notes = _path(pick("notes", "notes"), base)
books.append(Entry(
name=name, kind=kind, source=source, out=out,
style=pick("style", "lucid"), theme=pick("theme"),
overlay=overlay, exclude=tuple(exclude),
overlay=overlay, exclude=tuple(exclude), notes=notes,
identity=str(raw[source_key]),
))
seen_names, seen_outs = {}, {}
@@ -282,13 +310,30 @@ def apply_reference(runfile: RunFile) -> str | None:
return str(runfile.reference)
def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True):
def _checks(directory, quiet: bool) -> list[str]:
import contextlib
import io
from . import checks as checks_mod
report = checks_mod.Report()
loaded = checks_mod.Loaded(directory)
sink = io.StringIO() if quiet else None
with contextlib.redirect_stdout(sink) if sink else contextlib.nullcontext():
checks_mod.generated(loaded, report)
checks_mod.custom(loaded, report)
return list(report.failed)
def run(runfile, names=None, *, check: bool = False, quiet: bool = True):
"""Build every selected book, in order. One failure never stops the rest.
Returns one result per book:
Returns one result per book, plus one per output for a project:
{"name", "out", "ok", "error", "lost": [claims], "checks_failed": [names]}
"""
from . import checks as checks_mod
if isinstance(runfile, ProjectFile):
return run_project(runfile, names, check=check, quiet=quiet)
from .build import run as build
apply_reference(runfile)
@@ -303,7 +348,8 @@ def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True
overlay = spec_mod.load(entry.overlay)
book = build(entry.kind, entry.source, entry.out, slug=entry.name,
style=entry.style, theme=entry.theme, exclude=entry.exclude,
overlay=overlay, quiet=quiet)
overlay=overlay, notes=entry.notes, identity=entry.identity,
quiet=quiet)
result["lost"] = [r["claim"] for r in book.compare() if not r["ok"]]
except Exception as e: # noqa: BLE001 - one bad book must not cost the run
result["error"] = f"{type(e).__name__}: {e}"
@@ -311,17 +357,272 @@ def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True
continue
if check:
import contextlib
import io
report = checks_mod.Report()
loaded = checks_mod.Loaded(entry.out)
sink = io.StringIO() if quiet else None
with contextlib.redirect_stdout(sink) if sink else contextlib.nullcontext():
checks_mod.generated(loaded, report)
checks_mod.custom(loaded, report)
result["checks_failed"] = list(report.failed)
result["checks_failed"] = _checks(entry.out, quiet)
result["ok"] = not result["lost"] and not result["checks_failed"]
results.append(result)
return results
# -- the project shape ----------------------------------------------------------
#
# One file per project, the shape a model writes unprompted:
#
# [project] name, title, notes, build, reference
# [extract.<kind>] one book per table; [[extract.<kind>]] for several
# [ops] applied to every book's view, before paging
# [emit.<output>] notebook, minisite — each assembles every book
#
# It compiles to the same Document a [[book]] entry does, so everything after
# loading is one code path.
PROJECT_KEYS = {"name", "title", "notes", "build", "reference"}
PROJECT_TOP = {"project", "extract", "ops", "emit"}
EXTRACT_KEYS = {
"python": {"name", "root", "exclude", "overlay"},
"code": {"name", "root", "ext", "exclude", "overlay"},
"db": {"name", "schema", "overlay"},
"openapi": {"name", "spec", "overlay"},
"usage": {"name", "har", "overlay"},
}
EXTRACT_SOURCE = {"python": "root", "code": "root", "db": "schema",
"openapi": "spec", "usage": "har"}
OPS_KEYS = {"drop_stdlib", "drop_builtins", "drop_external", "only", "drop"}
EMITTERS = {"notebook", "minisite"}
# Outputs that were renamed, with their new name. Refused with the rename rather
# than accepted quietly, so a run file says what it does.
RENAMED_EMITTERS = {"site": "minisite"}
EMIT_KEYS = {"output", "style", "theme", "width"}
# Keys a model reaches for that docgen deliberately does not do, with what to do
# instead. Refused with the reason rather than with a bare "unknown key", because
# the reason is the useful half of the refusal.
NOT_SUPPORTED = {
("db", "url"): (
"docgen reads a schema dump and never connects to a database — and a URL in "
"a committed file is a credential in a committed file. Write the schema with "
"`modelgen from-db`, then set `schema = \"path/to/schema.json\"`"
),
}
@dataclass
class ProjectFile:
path: Path
document: object # book.document.Document
reference: Path | None = None
@property
def books(self):
return self.document.parts
def load_project(path: Path, data: dict) -> ProjectFile:
from ..extractors.code import LANGUAGES
from .document import Document, Output, Part
base = path.resolve().parent
problems: list[str] = []
for key in sorted(set(data) - PROJECT_TOP):
problems.append(f"unknown top-level key {key!r} — a project file has: "
f"{', '.join(sorted(PROJECT_TOP))}")
project = data.get("project") or {}
for key in sorted(set(project) - PROJECT_KEYS):
problems.append(f"[project] has unknown key {key!r} — have: {', '.join(sorted(PROJECT_KEYS))}")
name = project.get("name")
if not isinstance(name, str) or not NAME.match(name):
problems.append("[project] needs a name of letters, digits, '.', '_' or '-'")
name = "project"
title = project.get("title") or name
notes = _path(project.get("notes", "notes"), base)
build_root = _path(project.get("build", ".docgen"), base)
reference = _path(project["reference"], base) if "reference" in project else None
if reference is not None and not reference.is_dir():
problems.append(f"[project] reference {reference} is not a directory")
# -- extract ----------------------------------------------------------------
extract = data.get("extract") or {}
if not isinstance(extract, dict) or not extract:
problems.append("no sources — add at least one [extract.<kind>] table "
f"({', '.join(sorted(EXTRACT_KEYS))})")
extract = {}
parts, names = [], set()
for kind, tables in extract.items():
if kind not in EXTRACT_KEYS:
problems.append(f"[extract.{kind}]: no reader named {kind!r} — have: "
f"{', '.join(sorted(EXTRACT_KEYS))}")
continue
tables = tables if isinstance(tables, list) else [tables]
for i, raw in enumerate(tables):
where = f"[extract.{kind}]" + (f"[{i}]" if len(tables) > 1 else "")
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
for key in sorted(set(raw) - EXTRACT_KEYS[kind]):
reason = NOT_SUPPORTED.get((kind, key))
problems.append(f"{where} {key}: {reason}" if reason else
f"{where} has unknown key {key!r} — have: "
f"{', '.join(sorted(EXTRACT_KEYS[kind]))}")
book = raw.get("name") or (kind if len(tables) == 1 else None)
if not isinstance(book, str) or not NAME.match(book):
problems.append(f"{where} needs a name when there are several "
f"[[extract.{kind}]] tables")
continue
if book in names:
problems.append(f"{where}: a book named {book!r} already exists — set `name`")
continue
names.add(book)
src_key = EXTRACT_SOURCE[kind]
if src_key not in raw:
problems.append(f"{where} needs `{src_key}`")
continue
source = _path(raw[src_key], base)
if src_key == "root":
if not source.is_dir():
problems.append(f"{where}: root {source} is not a directory")
elif not source.is_file():
problems.append(f"{where}: {src_key} {source} is not a file")
exclude = raw.get("exclude", [])
if not isinstance(exclude, list) or not all(isinstance(x, str) for x in exclude):
problems.append(f"{where}: exclude must be a list of directory names")
exclude = []
ext = raw.get("ext")
if ext is not None:
if not isinstance(ext, list) or not all(isinstance(x, str) for x in ext):
problems.append(f"{where}: ext must be a list like [\".cs\", \".ts\"]")
else:
missing = [x for x in ext if x not in LANGUAGES]
if missing:
problems.append(
f"{where}: no grammar for {', '.join(missing)} — code reads "
f"{', '.join(sorted(LANGUAGES))}. Shell, YAML and SQL have no "
"reader yet; leave them out rather than expect them in the book")
overlay = None
if "overlay" in raw:
overlay_path = _path(raw["overlay"], base)
if not overlay_path.is_file():
problems.append(f"{where}: overlay {overlay_path} is not a file")
else:
import json
overlay = json.loads(overlay_path.read_text())
out = build_root / name / "books" / book
if src_key == "root" and source.is_dir() and _within(out, source):
problems.append(f"{where}: build directory {out} is inside the tree it reads "
f"({source}) — set [project] build outside it")
parts.append(Part(name=book, kind=kind, source=source, out=out,
exclude=tuple(exclude), overlay=overlay,
suffixes=tuple(ext) if ext else None,
identity=str(raw[src_key])))
# -- ops --------------------------------------------------------------------
ops = data.get("ops") or {}
for key in sorted(set(ops) - OPS_KEYS):
problems.append(f"[ops] has unknown key {key!r} — have: {', '.join(sorted(OPS_KEYS))}")
for key in ("only", "drop"):
if key in ops and not (isinstance(ops[key], list)
and all(isinstance(x, str) for x in ops[key])):
problems.append(f"[ops] {key} must be a list of kinds")
for part in parts:
part.ops = {k: v for k, v in ops.items() if k in OPS_KEYS}
# -- emit -------------------------------------------------------------------
emit = data.get("emit") or {}
outputs = []
if not emit:
problems.append("no outputs — add [emit.notebook] or [emit.minisite] with an `output`")
for emitter, raw in emit.items():
where = f"[emit.{emitter}]"
if emitter in RENAMED_EMITTERS:
problems.append(f"{where} is now [emit.{RENAMED_EMITTERS[emitter]}] — rename the table")
continue
if emitter not in EMITTERS:
problems.append(f"{where}: no output named {emitter!r} — have: "
f"{', '.join(sorted(EMITTERS))}")
continue
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
for key in sorted(set(raw) - EMIT_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: {', '.join(sorted(EMIT_KEYS))}")
if "output" not in raw:
problems.append(f"{where} needs `output`")
continue
width = raw.get("width")
if width is not None and (not isinstance(width, int) or width < 200):
problems.append(f"{where}: width must be a number of pixels, 200 or more")
width = None
target = _path(raw["output"], base)
for part in parts:
if part.kind in ("python", "code") and part.source.is_dir() \
and _within(target, part.source):
problems.append(f"{where}: output {target} is inside the tree book "
f"{part.name!r} reads — the next run would read it")
outputs.append(Output(emitter=emitter, path=target, style=raw.get("style", "lucid"),
theme=raw.get("theme"), width=width))
if problems:
raise ConfigError(path, problems)
doc = Document(name=name, title=title, build=build_root / name, parts=parts,
outputs=outputs, notes=notes)
return ProjectFile(path=path, document=doc, reference=reference)
def select_parts(project: ProjectFile, names) -> list:
if not names:
return list(project.document.parts)
known = {p.name for p in project.document.parts}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(project.path, [
f"no book named {n!r} — have: {', '.join(sorted(known))}" for n in unknown])
return [p for p in project.document.parts if p.name in set(names)]
def run_project(project: ProjectFile, names=None, *, check: bool = False, quiet: bool = True):
"""Build a project's document: every book, then every output.
`--only` narrows the books that go into this build's outputs, which is useful
while writing notes for one of them — the outputs are then that subset, and
say so in their ledger.
"""
from dataclasses import replace
from .document import build
apply_reference(project)
doc = replace(project.document, parts=select_parts(project, names), books=[], problems=[])
results = []
try:
build(doc, quiet=quiet)
except Exception as e: # noqa: BLE001 - reported, not raised
return [{"name": doc.name, "out": str(doc.build), "ok": False,
"error": f"{type(e).__name__}: {e}", "lost": [], "checks_failed": []}]
for part in doc.parts:
if part.name in doc.failed:
results.append({"name": part.name, "out": str(part.out), "ok": False,
"error": doc.failed[part.name], "lost": [], "checks_failed": []})
for book in doc.books:
lost = [r["claim"] for r in book.compare() if not r["ok"]]
failed = _checks(book.out, quiet) if check else []
results.append({"name": book.slug, "out": str(book.out), "ok": not lost and not failed,
"error": None, "lost": lost, "checks_failed": failed})
if check and doc.books:
from . import checks as checks_mod
failed = checks_mod.document_failures(doc.build, quiet=quiet)
else:
failed = []
# A failed book is already its own row; the document row carries what else
# went wrong — notes that drifted, an overlay that no longer matches.
rest = [p for p in doc.problems if not p.startswith("book ") or "was not built" not in p]
results.append({"name": f"{doc.name} (document)", "out": str(doc.build),
"ok": not rest and not failed and bool(doc.books), "error": None,
"lost": rest, "checks_failed": failed})
return results

View File

@@ -0,0 +1,449 @@
"""
A document: several books, assembled into each output.
Document
├── books one per source — each keeps its own larder → steps → book
├── notes hand-written markdown, filed by page (book/notes.py)
└── outputs a notebook, a minisite, … — each one made of every book, as parts
A book is a templated part of the final output, not the output itself. A project
with an OpenAPI contract and a C# tree is one document of two books: one notebook
and one minisite, each with a part per book. A `[[book]]` run file is the same thing
with one book per document, which is what it always was.
## Built once, rendered per output
Extraction, the view and the page structure happen once per book. Rendering
does not, because what a page looks like depends on where it is going: a minisite
column is narrower than a notebook cell, a minisite's boxes are links and a
notebook's cannot be, and each output can have its own theme. So each output
lays the pages out for its own width and draws them itself.
## Both ends, at both levels
Every book part still opens with what came in and closes with what came out. A
document of several books also opens with every larder and closes with every
reconciliation, so the rule — any operation is measured at both ends — holds for
the whole as well as for each part.
"""
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from . import Book
from .notes import Notes, data_uri, images
from .larder import Larder
MANIFEST = ".docgen-output.json"
@dataclass
class Output:
emitter: str # "notebook" | "minisite"
path: Path
style: str = "lucid"
theme: str | None = None
width: int | None = None # px; each emitter has its own default
@dataclass
class Part:
"""One book to build: a source, and where its artifacts go."""
name: str
kind: str
source: Path
out: Path
exclude: tuple = ()
overlay: dict | None = None
ops: dict = field(default_factory=dict)
suffixes: tuple | None = None # code only: which extensions to read
identity: str | None = None # the source as written; the ledger shows this
@dataclass
class Document:
name: str
title: str
build: Path # where document.json goes
parts: list[Part]
outputs: list[Output]
notes: Path | None = None
inline: bool = False # a single [[book]]: outputs live inside its directory
books: list[Book] = field(default_factory=list)
problems: list[str] = field(default_factory=list)
failed: dict = field(default_factory=dict) # book name -> why it was not built
# -- views --------------------------------------------------------------------
OPS = ("drop_stdlib", "drop_builtins", "drop_external", "only", "drop")
def apply_ops(view: dict, ops: dict) -> dict:
"""A project's `[ops]`, on top of the default view. Unknown keys never get here."""
from ..ops import filter as F
if ops.get("drop_stdlib"):
view = F.drop_stdlib(view)
if ops.get("drop_builtins"):
view = F.drop_builtins(view)
if ops.get("drop_external"):
view = F.drop_external(view)
if ops.get("drop"):
view = F.drop_kinds(view, ops["drop"])
if ops.get("only"):
view = F.only_kinds(view, ops["only"])
return view
# -- output bookkeeping -------------------------------------------------------
def clear_output(directory: Path) -> list[str]:
"""Remove exactly what the last build wrote into an output directory.
A page that disappeared between builds would otherwise linger — still on disk,
still linked from nowhere, still found by search. The manifest lists every file
written; anything not in it, including a hand-written file someone dropped in,
is left alone.
"""
manifest = directory / MANIFEST
if not manifest.is_file():
return []
try:
written = json.loads(manifest.read_text()).get("written", [])
except (OSError, json.JSONDecodeError):
return []
removed = []
for rel in written:
target = (directory / rel)
if target.is_file() and directory in target.resolve().parents:
target.unlink()
removed.append(rel)
# Directories docgen made and has now emptied — never one with anything left.
for d in sorted({(directory / r).parent for r in written}, key=lambda p: -len(p.parts)):
if d != directory and d.is_dir() and not any(d.iterdir()):
d.rmdir()
manifest.unlink()
return removed
def write_manifest(directory: Path, paths: list[Path]) -> None:
rel = sorted({p.relative_to(directory).as_posix() for p in paths if p.is_file()})
(directory / MANIFEST).write_text(json.dumps({"written": rel}, indent=2) + "\n")
# -- pages, rendered ----------------------------------------------------------
def rendered_pages(book: Book, style, width: int, *, url=None):
"""[(page, svg or None)] laid out for `width`. `url(page)` makes boxes links."""
from ..emitters.paged import fits_for, links_for, render
from ..ops.pages import layout
fits, measured = fits_for(style, width)
laid = layout(book.pages, fits)
known = {n["id"] for n in book.ir["nodes"]}
out = []
for page in laid:
links = None
if url is not None:
links = links_for(laid, page, url)
# A box with no page of its own opens its section of the reference
# page, where its docstring and file are — when it is a real node.
for n in page["ir"]["nodes"]:
if n["id"] not in links and n["id"] in known \
and not (n.get("attrs") or {}).get("context"):
links[n["id"]] = f"reference.html#{n['id']}"
page["measured"] = measured
out.append((page, render(page, style, width, links=links)))
return out
def caption(page: dict) -> str:
nodes = page["ir"]["nodes"]
parts = [f"{len(nodes)} box{'es' if len(nodes) != 1 else ''}",
f"{len(page['ir']['edges'])} relationship{'s' if len(page['ir']['edges']) != 1 else ''}"]
context = sum(1 for n in nodes if (n.get("attrs") or {}).get("context"))
if context:
parts.append(f"{context} outside this subsystem")
if page.get("children"):
parts.append(f"opens into {len(page['children'])} page{'s' if len(page['children']) != 1 else ''}")
if (page.get("options") or {}).get("rankdir") == "LR":
parts.append("drawn left to right to fit the width")
if page.get("too_wide"):
parts.append("still wider than the page — this level has no smaller structure to split into")
return " · ".join(parts)
# -- the notebook ---------------------------------------------------------------
def _note_step(text: str, base: Path) -> tuple[str, dict]:
"""A note for a notebook cell: local images become cell attachments."""
attachments = {}
for ref, name, mime, data in images(text, base):
import base64
text = text.replace(f"]({ref})", f"](attachment:{name})")
attachments[name] = {mime: base64.b64encode(data).decode("ascii")}
return text, attachments
def _with_note(step: dict, notes: Notes, book: str | None, spot: str) -> dict:
text = notes.book(book, spot) if book else notes.document(spot)
if text is None:
return step
path = notes.path(book, spot)
text, attachments = _note_step(text, path.parent)
step = dict(step)
step["before"] = text + ("\n\n" + step["before"] if step.get("before") else "")
if attachments:
step["before_attachments"] = {**(step.get("before_attachments") or {}), **attachments}
return step
def notebook_spec(doc: Document, notes: Notes, output: Output, notebook_dir: Path) -> dict:
"""Every book's part, in order, with the document's own ends when there are several."""
from ..emitters.paged import NOTEBOOK_WIDTH
from ..notebook import spec as spec_mod
from ..ops.pages import slugify
from ..style import Style
from .build import spec_from
style = Style.load(output.style, theme=output.theme)
width = output.width or NOTEBOOK_WIDTH
several = len(doc.books) > 1
steps: list[dict] = []
missing = ""
if doc.failed:
missing = ("\n\n**This document is incomplete** — these books could not be built "
"and are not below:\n\n"
+ "\n".join(f"- `{n}` — {why}" for n, why in sorted(doc.failed.items())))
if several or doc.failed:
lines = "\n".join(f"- **{b.slug}** — {b.larder.line()}" for b in doc.books)
steps.append(spec_mod._step(
"document", "md", title=doc.title,
text=(f"Built from {len(doc.books)} book(s). Each part below opens with what "
f"came in and closes with what came out.\n\n{lines}{missing}")))
for i, book in enumerate(doc.books):
rel = os.path.relpath(book.out, notebook_dir)
base = spec_from(book, book.ir, prelude_dir=None if doc.inline else rel)
base_steps = base["steps"]
figures = []
index_lines = []
for page, svg in rendered_pages(book, style, width):
anchor = f"page-{slugify(book.slug)}-{page['slug']}"
index_lines.append(f"{' ' * page['depth']}- [{page['title']}](#{anchor})"
+ (" — *too wide*" if page.get("too_wide") else ""))
figures.append(spec_mod._step(
f"page-{page['slug']}", "figure", title=page["title"], anchor=anchor,
depth=page["depth"], svg=svg, name=f"{page['slug']}.svg",
caption=caption(page),
why="Graphviz is not installed" if svg is None else None))
index = spec_mod._step("pages", "md", title=f"{book.slug} — pages",
text="Each page is one subsystem, drawn small enough to "
"read. Follow a link to jump to it.\n\n"
+ "\n".join(index_lines))
ordered = []
for step in base_steps:
if step["id"] == "book":
ordered.extend(figures)
ordered.append(step)
if step["id"] == "larder":
ordered.append(index)
spec, problems = spec_mod.merge({"meta": {}, "steps": ordered},
doc.parts_built[i].overlay)
doc.problems += [f"{book.slug}: overlay: {p}" for p in problems]
# Notes are applied after the overlay, so an overlay that moves a step moves
# its note with it. A page's note is filed under the page's slug; every
# other step under its own id. `_with_note` prepends, so applying the book's
# `_intro` last puts it above the larder's own note.
part = []
for n, step in enumerate(spec["steps"]):
sid = step["id"]
spot = sid[len("page-"):] if step.get("kind") == "figure" else slugify(sid)
step = _with_note(step, notes, book.slug, spot)
if n == 0:
step = _with_note(step, notes, book.slug, "_intro")
if several:
step = dict(step, id=f"{book.slug}/{sid}")
part.append(step)
steps.extend(part)
if several:
lines = []
for b in doc.books:
m = b.measure()
lines.append(f"- **{b.slug}** — {m['nodes']} nodes · {m['edges']} edges · "
f"{m['external']} external")
lines += [f" - {'✓' if r['ok'] else '✗'} {r['claim']}" for r in b.compare()]
steps.append(spec_mod._step("document-end", "md", title=f"{doc.title} — what came out",
text="\n".join(lines)))
if steps:
steps[0] = _with_note(steps[0], notes, None, "_intro")
end = notes.document("_end")
if end is not None:
text, att = _note_step(end, notes.path(None, "_end").parent)
steps.append(spec_mod._step("_end", "md", text=text,
**({"before_attachments": att} if att else {})))
return {"version": spec_mod.SPEC_VERSION, "steps": steps}
# -- building -----------------------------------------------------------------
def build(doc: Document, *, quiet: bool = True) -> Document:
"""Build every book, then every output. Returns the document, ledgers written."""
from ..emitters.notebook import write as nb_write
from ..ops.pages import slugify
from ..style import Style
from .build import build_book
def say(text):
if not quiet:
print(text)
notes = Notes(doc.notes)
doc.books, doc.failed = [], {}
built_parts = []
for part in doc.parts:
# One book that cannot be built must not cost the others — the same rule a
# run of independent books follows. The outputs are assembled from what
# was built, and the ledger names what was not and why, so a partial
# document is visibly partial rather than quietly short.
try:
book = build_book(part.kind, part.source, part.out, slug=part.name,
exclude=part.exclude, ops=part.ops, suffixes=part.suffixes,
identity=part.identity, quiet=quiet)
except Exception as e: # noqa: BLE001 - recorded, not raised
reason = f"{type(e).__name__}: {str(e).splitlines()[0] if str(e) else ''}"
doc.failed[part.name] = reason
doc.problems.append(f"book {part.name!r} was not built — {reason}")
say(f" FAIL {part.name}: {reason}")
continue
doc.books.append(book)
built_parts.append(part)
doc.parts_built = built_parts
# Minisites first: in a single inline book the minisite is its last step's
# artifact, and the notebook quotes the book measure — which only counts the
# minisite once the last step exists.
sites = [o for o in doc.outputs if o.emitter == "minisite"]
notebooks = [o for o in doc.outputs if o.emitter == "notebook"]
written_outputs = []
for output in sites:
from ..emitters import minisite
site_dir = Path(output.path)
clear_output(site_dir)
style = Style.load(output.style, theme=output.theme)
width = output.width or minisite.CONTENT_WIDTH
books = []
for book in doc.books:
pages = rendered_pages(book, style, width, url=lambda p: f"{p['slug']}.html")
page_notes = {}
for page, _ in pages:
text = notes.book(book.slug, page["slug"])
if text is not None:
base = notes.path(book.slug, page["slug"]).parent
page_notes[page["slug"]] = (text, {ref: data_uri(mime, data)
for ref, _, mime, data in images(text, base)})
intro = notes.book(book.slug, "_intro")
books.append({"book": book, "pages": pages, "notes": page_notes,
"intro": (intro, {}) if intro else None})
doc_intro = notes.document("_intro")
doc_end = notes.document("_end")
files = minisite.write(site_dir, style, title=doc.title, books=books,
intro=doc_intro, end=doc_end, failed=doc.failed)
write_manifest(site_dir, files)
written_outputs.append({"emitter": "minisite", "path": str(site_dir), "width": width,
"files": len(files)})
say(f" minisite {site_dir / 'index.html'}")
for book in doc.books:
inside = [o for o in sites if doc.inline and Path(o.path).parent == book.out]
book.close(minisite=Path(inside[0].path) if inside else None)
for output in notebooks:
path = Path(output.path)
if path.suffix != ".ipynb":
clear_output(path)
path.mkdir(parents=True, exist_ok=True)
target = path / f"{slugify(doc.name)}.ipynb"
else:
path.parent.mkdir(parents=True, exist_ok=True)
target = path
spec = notebook_spec(doc, notes, output, target.parent)
nb_write(spec, target)
if path.suffix != ".ipynb":
write_manifest(path, [target])
if doc.inline and len(doc.books) == 1:
doc.books[0].notebook = os.path.relpath(target, doc.books[0].out)
written_outputs.append({"emitter": "notebook", "path": str(target),
"width": output.width or 960})
say(f" notebook {target}")
known = {b.slug: known_spots(b) for b in doc.books}
drift = notes.drift(known)
doc.problems += drift
for book in doc.books:
book.write()
ledger = {
"name": doc.name,
"title": doc.title,
"failed": doc.failed,
"books": [{"name": b.slug, "dir": str(b.out),
"pages": [{"id": p["id"], "slug": p["slug"], "parent": p["parent"]}
for p in b.pages]}
for b in doc.books],
"outputs": written_outputs,
"notes": {"root": str(doc.notes) if doc.notes else None,
"used": sorted(str(p.relative_to(doc.notes)) for p in notes.used) if doc.notes else [],
"drift": drift},
"problems": doc.problems,
}
doc.build.mkdir(parents=True, exist_ok=True)
(doc.build / "document.json").write_text(json.dumps(ledger, indent=2) + "\n")
return doc
def _spots(ir: dict, pages: list[dict]) -> list[tuple[str, str]]:
"""[(spot id, what it is)] for one book, in reading order."""
from ..notebook import spec as spec_mod
from ..ops.pages import slugify
from .build import STEP_LABELS
out = [("larder", "what came in"), ("pages", "the page index")]
out += [(p["slug"], f"page {p['title']}") for p in pages]
out += [(f"step-{sid}", f"step: {label}") for sid, label in STEP_LABELS]
# An API-shaped book also carries the endpoint walkthrough; each call is a
# spot of its own, under the slug of its id.
if any(n["kind"] in ("endpoint", "operation") for n in ir.get("nodes") or []):
out += [(slugify(s["id"]), f"{s['kind']}: {s.get('title', s['id'])}")
for s in spec_mod.from_ir(ir)["steps"] if s["id"] != "intro"]
out.append(("book", "what came out"))
return out
def known_spots(book: Book) -> set[str]:
"""Every id a note for this book may be filed under."""
return {spot for spot, _ in _spots(book.ir, book.pages)}
def plan_spots(part: Part) -> list[tuple[str, str]]:
"""The spots a book will have, without building it — extraction and pages only."""
from ..ops import overview
from ..ops.pages import for_view
from .build import extract
ir, _ = extract(part.kind, part.source, exclude=part.exclude, suffixes=part.suffixes)
view = apply_ops(overview(ir), part.ops)
return _spots(ir, for_view(ir, view))

View File

@@ -40,7 +40,9 @@ discipline as `VISUAL_KEYS`: the architectural rule is a check, not a convention
Paths are left as the caller gave them rather than resolved. An absolute path is
not a secret but it is machine-specific, and `meta.root` is already a bare name
for exactly that reason.
for exactly that reason. A run file resolves its paths to read them, and still
hands the larder the path as the file wrote it: the outputs travel, and the
machine they were built on is nobody's business.
"""
import re

View File

@@ -0,0 +1,161 @@
"""
Notes: hand-written markdown, filed by the page or step it belongs above.
notes/
├── _intro.md top of the document
├── _end.md bottom of the document
└── <book>/
├── _intro.md top of that book's part
├── larder.md above the "what came in" step
├── overview.md above the overview page
└── docgen.book.md above the page for `docgen.book`
## Why files named by id
Generation alone gives a document that is never stale and never says anything a
parser could not work out. Prose is the part only a person can add, and it has
to survive every rebuild — so it cannot live in the output. An overlay could
carry it, but as JSON strings with `\\n` escapes, addressed by ids you have to
guess. A folder of markdown files, one per spot, is something a person or a
model writes without reading a schema: to put text above a page, create the
file named after it.
`python3 -m docgen scaffold` writes an empty stub for every spot that has none,
so the ids are listed rather than guessed. A stub is a single HTML comment, and a
note that is nothing but that comment renders nothing — so scaffolding a
hundred spots changes the output not at all until one is filled in.
## Drift is reported, never dropped
When a page is renamed or removed its note has nowhere to go. It is not quietly
discarded — that is how hand-written text goes stale without anyone noticing —
it is reported, and the book's own check level fails on it. The same rule the
notebook overlay follows.
"""
import base64
import mimetypes
import re
from pathlib import Path
DOCUMENT_SPOTS = ("_intro", "_end")
BOOK_SPOTS = ("_intro",)
STUB = re.compile(r"\A\s*<!--\s*docgen note:.*?-->\s*", re.S)
IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
def body(text: str | None) -> str | None:
"""A note's content, without its stub comment. None when nothing is left."""
if text is None:
return None
text = STUB.sub("", text, count=1).strip()
return text or None
def stub(where: str) -> str:
return (f"<!-- docgen note: {where}. Write markdown below; this comment does "
f"not render, and an empty note adds nothing. -->\n")
class Notes:
"""The notes folder of one document. A missing folder is simply no notes."""
def __init__(self, root):
self.root = Path(root) if root else None
self.used: set[Path] = set()
def _read(self, path: Path) -> str | None:
if self.root is None or not path.is_file():
return None
text = body(path.read_text())
if text is not None:
self.used.add(path)
return text
def document(self, spot: str) -> str | None:
return self._read(self.root / f"{spot}.md") if self.root else None
def book(self, book: str, spot: str) -> str | None:
return self._read(self.root / book / f"{spot}.md") if self.root else None
def path(self, book: str | None, spot: str) -> Path | None:
if self.root is None:
return None
return (self.root / book / f"{spot}.md") if book else (self.root / f"{spot}.md")
def drift(self, known: dict[str, set[str]]) -> list[str]:
"""Notes filed under a spot that no longer exists, as readable problems.
`known` is {book name: every page slug and step id it has}.
"""
if self.root is None or not self.root.is_dir():
return []
problems = []
for path in sorted(self.root.rglob("*.md")):
rel = path.relative_to(self.root)
if len(rel.parts) == 1:
if rel.stem not in DOCUMENT_SPOTS:
problems.append(f"note {rel} is not a document spot "
f"({', '.join(s + '.md' for s in DOCUMENT_SPOTS)})")
continue
book = rel.parts[0]
if book not in known:
problems.append(f"note {rel} is filed under book {book!r}, which this "
f"document does not have — have: {', '.join(sorted(known))}")
continue
spot = Path(*rel.parts[1:]).with_suffix("").as_posix()
if spot not in known[book] and spot not in BOOK_SPOTS:
if body(path.read_text()) is None:
continue # an empty stub for a vanished page says nothing
problems.append(f"note {rel} belongs above {spot!r}, which book "
f"{book!r} no longer has — rename or remove it")
return problems
def scaffold(self, spots: dict[str, list[tuple[str, str]]]) -> list[Path]:
"""Write a stub for every spot without a note. Never overwrites.
`spots` is {book name: [(spot id, what it is)]}. Returns the files written.
"""
if self.root is None:
return []
written = []
for spot in DOCUMENT_SPOTS:
path = self.root / f"{spot}.md"
where = "top of the document" if spot == "_intro" else "end of the document"
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(stub(where))
written.append(path)
for book, entries in spots.items():
for spot, where in [("_intro", f"top of book {book}")] + list(entries):
path = self.root / book / f"{spot}.md"
if path.exists():
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(stub(f"{book} › {where}"))
written.append(path)
return written
def images(text: str, base: Path) -> list[tuple[str, str, str, bytes]]:
"""Local images a note links to: [(markdown ref, name, mime type, bytes)].
Resolved against the note's own folder. Remote and missing images are left as
written — a link that does not resolve here is the author's to see, not ours
to delete.
"""
out = []
for m in IMAGE.finditer(text):
ref = m.group(2)
if re.match(r"^[a-z]+:", ref):
continue
path = (base / ref).resolve()
if not path.is_file():
continue
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
out.append((ref, path.name, mime, path.read_bytes()))
return out
def data_uri(mime: str, data: bytes) -> str:
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"

View File

@@ -6,6 +6,8 @@ The command line — every command docgen has, and nothing else.
book one operation, measured at both ends
run every book a run file lists
check docgen's own suite, or one book's own level
scaffold empty notes for every page and step, never overwriting
contract the instructions to hand a model that writes docgen.toml
extract source -> ir.json
validate check an ir.json at the boundary
view ir.json -> a narrower ir.json
@@ -36,6 +38,8 @@ COMMANDS = {
"book": ("book", "one operation, measured at both ends"),
"run": ("run", "every book a run file lists"),
"check": ("check", "docgen's own suite, or one book's own level"),
"scaffold": ("scaffold", "empty notes for every page and step, never overwriting"),
"contract": ("contract", "the instructions to hand a model that writes docgen.toml"),
"extract": ("extract", "source -> ir.json"),
"validate": ("validate", "check an ir.json at the boundary"),
"view": ("view", "ir.json -> a narrower ir.json"),

View File

@@ -37,6 +37,9 @@ def main(argv) -> int:
help="A hand-written notebook overlay, re-applied on every build.")
p.add_argument("--exclude", action="append", default=[],
help="Directory name to skip. Repeatable.")
p.add_argument("--notes", type=Path,
help="A notes folder: <slug>/<page>.md lands above that page. "
"See `scaffold`.")
p.add_argument("--quiet", "-q", action="store_true")
args = p.parse_args(argv)
@@ -66,7 +69,7 @@ def main(argv) -> int:
try:
book = run(kind, source, args.output, slug=args.slug, style=args.style,
theme=args.theme, exclude=tuple(args.exclude), overlay=overlay,
quiet=args.quiet)
notes=args.notes, quiet=args.quiet)
except Exception as e: # noqa: BLE001 - the CLI reports, it does not traceback
raise Abort(f"{type(e).__name__}: {e}") from None

View File

@@ -34,7 +34,14 @@ def main(argv) -> int:
# what a fresh one would do.
return subprocess.call([sys.executable, str(HERE / "selftest.py")])
from ..book.checks import Loaded, Report, custom, generated
from ..book.checks import Loaded, Report, custom, document, generated
if not (args.book / "book.json").is_file() and (args.book / "document.json").is_file():
# A project's build directory: the document's own level.
print(f"{args.book} — document")
r = Report("document")
document(args.book, r)
return r.total()
try:
book = Loaded(args.book)

View File

@@ -0,0 +1,33 @@
"""
python3 -m docgen contract > docgen-contract.md
Print the instructions to hand to a model that writes `docgen.toml` and notes —
`docs/models.md`, verbatim.
One copy of the wording, printed rather than remembered, the same arrangement as
`explode.sh --contract`: what a model is asked to write and what docgen accepts
cannot drift apart, because `selftest.py` checks the file against the loaders —
every key named, every example loading. Attach it; do not paste it into a
message body, where markdown gets a say first.
With `explode`, attach both: explode's contract says how to format the reply,
this one says what goes in the files.
"""
import argparse
import sys
from pathlib import Path
from .common import PROG, Abort
CONTRACT = Path(__file__).resolve().parent.parent / "docs" / "models.md"
def main(argv) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} contract",
description="Print the instructions for a model.")
p.parse_args(argv)
if not CONTRACT.is_file():
raise Abort(f"{CONTRACT} is missing — is the docgen folder intact?")
sys.stdout.write(CONTRACT.read_text())
return 0

View File

@@ -5,7 +5,7 @@
python3 -m docgen emit index <ir.json> [-o out.md|.json]
python3 -m docgen emit minimap <ir.json> [-o out.svg] [--scale 0.55]
python3 -m docgen emit notebook <ir.json> [-o out.ipynb] [--overlay f.json]
python3 -m docgen emit site <ir.json> -o DIR
python3 -m docgen emit minisite <ir.json> -o DIR
python3 -m docgen emit explore <ir.json> -o DIR
IR -> an artifact. Every emitter validates its input first, so a broken document
@@ -182,8 +182,8 @@ def notebook(argv) -> int:
return 0
def site(argv) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} emit site")
def minisite(argv) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} emit minisite")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
style_args(p)
@@ -191,7 +191,7 @@ def site(argv) -> int:
p.add_argument("--no-graph", action="store_true")
args = p.parse_args(argv)
from ..emitters.auto import draw
from ..emitters.site import write as site_write
from ..emitters.minisite.onepage import write as page_write
data, style = read_ir(args.ir), load_style(args.style, args.theme)
args.output.mkdir(parents=True, exist_ok=True)
@@ -208,10 +208,10 @@ def site(argv) -> int:
print(f" note: {drawn['verdict']['kind']} — {drawn['why']}", file=sys.stderr)
print(" no diagram on the page; the index is the artifact", file=sys.stderr)
for f in site_write(data, style, args.output, graph=graph_name, title=args.title):
print(f" site {f}")
for f in page_write(data, style, args.output, graph=graph_name, title=args.title):
print(f" minisite {f}")
if graph_name:
print(f" site {args.output / graph_name}")
print(f" minisite {args.output / graph_name}")
return 0
@@ -240,13 +240,17 @@ def explore(argv) -> int:
EMITTERS = {"auto": auto, "dot": dot, "erd": erd, "index": index, "minimap": minimap,
"notebook": notebook, "site": site, "explore": explore}
"notebook": notebook, "minisite": minisite, "explore": explore}
# Renamed emitters, so the old name says what to type instead.
RENAMED = {"site": "minisite"}
def main(argv) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__.strip("\n"))
return 0 if argv else 2
if argv[0] in RENAMED:
raise Abort(f"emit {argv[0]} is now emit {RENAMED[argv[0]]}")
if argv[0] not in EMITTERS:
raise Abort(f"no emitter {argv[0]!r} — have: {', '.join(EMITTERS)}")
return EMITTERS[argv[0]](argv[1:])

View File

@@ -30,13 +30,17 @@ def main(argv) -> int:
help="Show each book's own output, not just the summary.")
args = p.parse_args(argv)
from ..book.config import ConfigError, load, run
from ..book.config import ConfigError, ProjectFile, load, run, select_parts
if not args.config.exists():
raise Abort(f"no run file at {args.config} — see docgen.example.toml")
raise Abort(f"no run file at {args.config} — see docgen.example.toml "
"or docgen.project.example.toml")
try:
runfile = load(args.config)
selected = runfile.select(args.only)
if isinstance(runfile, ProjectFile):
selected = select_parts(runfile, args.only)
else:
selected = runfile.select(args.only)
except ConfigError as e:
raise Abort(str(e)) from None
@@ -46,13 +50,26 @@ def main(argv) -> int:
print(f"{runfile.path} — {len(selected)} of {len(runfile.books)} book(s)")
if runfile.reference:
print(f" reference {runfile.reference}")
for b in selected:
print(f" {b.line()}")
if isinstance(runfile, ProjectFile):
doc = runfile.document
print(f" project {doc.name} — {doc.title}")
print(f" build {doc.build}")
print(f" notes {doc.notes}")
for part in selected:
print(f" book {part.name:<16} {part.kind:<8} {part.source}")
for o in doc.outputs:
print(f" output {o.emitter:<16} {o.path}"
+ (f" (width {o.width}px)" if o.width else ""))
else:
for b in selected:
print(f" {b.line()}")
return 0
print(f"{runfile.path} — {len(selected)} book(s)")
results = run(runfile, [b.name for b in selected], check=args.check,
quiet=not args.verbose)
if not results:
raise Abort("nothing was built")
width = max(len(r["name"]) for r in results)
for r in results:
@@ -70,10 +87,12 @@ def main(argv) -> int:
print(f" {status} {r['name']:<{width}} {detail}")
failed = [r for r in results if not r["ok"]]
documents = sum(1 for r in results if r["name"].endswith("(document)"))
what = f"{len(results) - documents} book(s)" + (f" in {documents} document" if documents else "")
print()
if failed:
print(f"{len(failed)} of {len(results)} book(s) did not hold"
print(f"{len(failed)} of {len(results)} did not hold ({what})"
+ ("" if args.verbose else " — --verbose for each book's own output"))
return 1
print(f"{len(results)} book(s) built" + (" and checked" if args.check else ""))
print(f"{what} built" + (" and checked" if args.check else ""))
return 0

View File

@@ -0,0 +1,87 @@
"""
python3 -m docgen scaffold <docgen.toml> [--only NAME ...]
python3 -m docgen scaffold <docgen.toml> --list # print the spots, write nothing
Write an empty note for every spot a document has — the document's top and end,
each book's top, what came in, the page index, every page, every step, every
API call, what came out — so the ids are listed rather than guessed.
Never overwrites: a spot that already has a note is left alone, so running it
again after the sources change only adds the spots that are new. A stub is a
single HTML comment and renders nothing until somebody writes below it.
Extraction and page structure only — nothing is drawn and no output is written.
"""
import argparse
from pathlib import Path
from .common import PROG, Abort
def main(argv) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} scaffold")
p.add_argument("config", type=Path, nargs="?", default=Path("docgen.toml"))
p.add_argument("--only", action="append", default=[], metavar="NAME",
help="Just these books. Repeatable.")
p.add_argument("--list", action="store_true",
help="Print every spot a note can be filed under, and write nothing — "
"the list to hand a model instead of letting it guess page ids.")
args = p.parse_args(argv)
from ..book.config import ConfigError, ProjectFile, apply_reference, load, select_parts
from ..book.document import Part, plan_spots
from ..book.notes import Notes
if not args.config.exists():
raise Abort(f"no run file at {args.config}")
try:
loaded = load(args.config)
apply_reference(loaded)
if isinstance(loaded, ProjectFile):
groups = {loaded.document.notes: select_parts(loaded, args.only)}
else:
groups = {}
for e in loaded.select(args.only):
part = Part(name=e.name, kind=e.kind, source=e.source, out=e.out,
exclude=e.exclude)
groups.setdefault(e.notes, []).append(part)
except ConfigError as e:
raise Abort(str(e)) from None
total = 0
for root, parts in groups.items():
spots = {}
for part in parts:
try:
spots[part.name] = plan_spots(part)
except Exception as e: # noqa: BLE001 - one bad source must not cost the rest
print(f" skip {part.name}: {type(e).__name__}: {str(e).splitlines()[0]}")
if args.list:
# Relative to the run file's folder, which is how a note's path is
# written in a reply — not as an absolute path on this machine.
base = args.config.resolve().parent
try:
shown = root.relative_to(base)
except ValueError:
shown = root
rows = [(f"{shown}/_intro.md", "top of the document"),
(f"{shown}/_end.md", "end of the document")]
for book, entries in spots.items():
rows.append((f"{shown}/{book}/_intro.md", f"top of book {book}"))
for spot, where in entries:
has = " (has a note)" if Notes(root).book(book, spot) else ""
rows.append((f"{shown}/{book}/{spot}.md", where + has))
width = max(len(r[0]) for r in rows)
for path, where in rows:
print(f"{path:<{width}} {where}")
continue
written = Notes(root).scaffold(spots)
total += len(written)
existing = sum(len(v) for v in spots.values()) + 2 + len(spots) - len(written)
print(f" notes {root} — {len(written)} new stub(s), {existing} already there")
for path in written[:12]:
print(f" {path.relative_to(root)}")
if len(written) > 12:
print(f" … and {len(written) - 12} more")
return 0

View File

@@ -0,0 +1,53 @@
# A project file: one document built from several sources.
#
# make run CONFIG=docgen.project.example.toml CHECK=1
# make scaffold CONFIG=docgen.project.example.toml # a stub note for every spot
# python3 -m docgen run docgen.project.example.toml --list
#
# Every [extract.*] table is a book — a part of each output. Every [emit.*] table
# is an output made of all of them. Paths are relative to THIS FILE. Unknown keys
# are refused with a reason, never ignored.
#
# The other shape, a list of independent books, is docgen.example.toml.
[project]
name = "docgen-fixtures"
title = "docgen's own fixtures"
notes = "fixtures/notes" # notes/<book>/<page>.md lands above that page
build = ".docgen" # book artifacts; gitignore it
# reference = "../../" # the repo holding station/tools/modelgen, for OpenAPI
# A tree of Python. Several trees: [[extract.python]] with a `name` each.
[extract.python]
name = "extractors"
root = "extractors"
# A database schema, as `modelgen from-db` writes it. docgen reads the dump and
# never connects: `url = ...` is refused, since a URL in a committed file is a
# credential in a committed file.
[extract.db]
name = "schema"
schema = "fixtures/shop.json"
# An OpenAPI document. Needs pyyaml and the reference repo's modelgen; without
# them this book fails with a one-line reason and the others still build.
[extract.openapi]
name = "api"
spec = "fixtures/orders.yaml"
# Also available:
# [extract.code] root = "src", ext = [".cs", ".ts"] (tree-sitter)
# [extract.usage] har = "session.har"
[ops]
drop_stdlib = true
drop_builtins = true
[emit.notebook]
output = "out/project/notebook" # a directory: <name>.ipynb goes inside
theme = "lucid"
width = 960 # px — pages are split until each fits
[emit.minisite]
output = "out/project/minisite"
theme = "dark"

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2238pt" viewBox="0 0 886 2238">
<rect width="886" height="2238" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="1164.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 2328 lines</title></rect>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2824pt" viewBox="0 0 886 2824">
<rect width="886" height="2824" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="1403.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 2806 lines</title></rect>
<rect x="28.0" y="94.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.own_py_files" data-kind="function" class="blk"><title>own_py_files — function, 3 lines</title></rect>
<rect x="28.0" y="96.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.check" data-kind="function" class="blk"><title>check — function, 5 lines</title></rect>
<rect x="28.0" y="99.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._err" data-kind="function" class="blk"><title>_err — function, 7 lines</title></rect>
@@ -11,10 +11,16 @@
<rect x="28.0" y="578.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._entry" data-kind="function" class="blk"><title>_entry — function, 7 lines</title></rect>
<rect x="28.0" y="831.5" width="74.0" height="139.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._books" data-kind="function" class="blk"><title>_books — function, 279 lines</title></rect>
<rect x="28.0" y="976.0" width="74.0" height="76.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._standalone" data-kind="function" class="blk"><title>_standalone — function, 153 lines</title></rect>
<rect x="28.0" y="1057.5" width="74.0" height="44.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._layout" data-kind="function" class="blk"><title>_layout — function, 89 lines</title></rect>
<rect x="28.0" y="1106.5" width="74.0" height="97.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._runfiles" data-kind="function" class="blk"><title>_runfiles — function, 195 lines</title></rect>
<rect x="35.0" y="1112.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._runfiles.write" data-kind="function" class="blk"><title>write — function, 4 lines</title></rect>
<rect x="35.0" y="1115.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._runfiles.problems" data-kind="function" class="blk"><title>problems — function, 6 lines</title></rect>
<rect x="28.0" y="1057.5" width="74.0" height="46.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._layout" data-kind="function" class="blk"><title>_layout — function, 92 lines</title></rect>
<rect x="28.0" y="1108.0" width="74.0" height="97.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._runfiles" data-kind="function" class="blk"><title>_runfiles — function, 195 lines</title></rect>
<rect x="35.0" y="1114.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._runfiles.write" data-kind="function" class="blk"><title>write — function, 4 lines</title></rect>
<rect x="35.0" y="1116.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._runfiles.problems" data-kind="function" class="blk"><title>problems — function, 6 lines</title></rect>
<rect x="28.0" y="1210.5" width="74.0" height="57.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._pages" data-kind="function" class="blk"><title>_pages — function, 114 lines</title></rect>
<rect x="28.0" y="1272.0" width="74.0" height="80.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._documents" data-kind="function" class="blk"><title>_documents — function, 161 lines</title></rect>
<rect x="28.0" y="1357.0" width="74.0" height="43.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._projects" data-kind="function" class="blk"><title>_projects — function, 87 lines</title></rect>
<rect x="35.0" y="1361.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._projects.load" data-kind="function" class="blk"><title>load — function, 7 lines</title></rect>
<rect x="28.0" y="1405.5" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._contract" data-kind="function" class="blk"><title>_contract — function, 75 lines</title></rect>
<rect x="35.0" y="1415.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._contract.named" data-kind="function" class="blk"><title>named — function, 2 lines</title></rect>
<rect x="110.0" y="46.0" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book — 316 lines</title></rect>
<rect x="110.0" y="75.0" width="74.0" height="10.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.Step" data-kind="class" class="blk"><title>Step — class, 20 lines</title></rect>
<rect x="117.0" y="80.0" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Step.to_dict" data-kind="function" class="blk"><title>to_dict — function, 10 lines</title></rect>
@@ -54,353 +60,452 @@
<rect x="274.0" y="82.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.describe" data-kind="function" class="blk"><title>describe — function, 10 lines</title></rect>
<rect x="274.0" y="88.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.on_path" data-kind="function" class="blk"><title>on_path — function, 13 lines</title></rect>
<rect x="274.0" y="95.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.missing" data-kind="function" class="blk"><title>missing — function, 8 lines</title></rect>
<rect x="356.0" y="46.0" width="74.0" height="38.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli — 76 lines</title></rect>
<rect x="356.0" y="68.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.usage" data-kind="function" class="blk"><title>usage — function, 7 lines</title></rect>
<rect x="356.0" y="73.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="356.0" y="46.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli — 80 lines</title></rect>
<rect x="356.0" y="70.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.usage" data-kind="function" class="blk"><title>usage — function, 7 lines</title></rect>
<rect x="356.0" y="75.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="438.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops — 28 lines</title></rect>
<rect x="520.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook — 15 lines</title></rect>
<rect x="602.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters — 12 lines</title></rect>
<rect x="684.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab — 11 lines</title></rect>
<rect x="766.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.__main__ — 9 lines</title></rect>
<rect x="28.0" y="1248.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir — 7 lines</title></rect>
<rect x="110.0" y="1248.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors — 2 lines</title></rect>
<rect x="210.0" y="1248.0" width="74.0" height="126.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.emit — 253 lines</title></rect>
<rect x="210.0" y="1260.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit._parser" data-kind="function" class="blk"><title>_parser — function, 7 lines</title></rect>
<rect x="210.0" y="1264.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit._size" data-kind="function" class="blk"><title>_size — function, 3 lines</title></rect>
<rect x="210.0" y="1267.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.auto" data-kind="function" class="blk"><title>auto — function, 16 lines</title></rect>
<rect x="210.0" y="1276.0" width="74.0" height="19.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.dot" data-kind="function" class="blk"><title>dot — function, 38 lines</title></rect>
<rect x="210.0" y="1296.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.erd" data-kind="function" class="blk"><title>erd — function, 11 lines</title></rect>
<rect x="210.0" y="1302.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.index" data-kind="function" class="blk"><title>index — function, 14 lines</title></rect>
<rect x="210.0" y="1310.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.minimap" data-kind="function" class="blk"><title>minimap — function, 14 lines</title></rect>
<rect x="210.0" y="1318.5" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.notebook" data-kind="function" class="blk"><title>notebook — function, 41 lines</title></rect>
<rect x="210.0" y="1340.0" width="74.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.site" data-kind="function" class="blk"><title>site — function, 31 lines</title></rect>
<rect x="210.0" y="1356.5" width="74.0" height="11.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.explore" data-kind="function" class="blk"><title>explore — function, 22 lines</title></rect>
<rect x="210.0" y="1370.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.main" data-kind="function" class="blk"><title>main — function, 7 lines</title></rect>
<rect x="292.0" y="1248.0" width="74.0" height="59.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.extract — 119 lines</title></rect>
<rect x="292.0" y="1257.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract._parser" data-kind="function" class="blk"><title>_parser — function, 5 lines</title></rect>
<rect x="292.0" y="1261.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract._counts" data-kind="function" class="blk"><title>_counts — function, 3 lines</title></rect>
<rect x="292.0" y="1263.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.python" data-kind="function" class="blk"><title>python — function, 15 lines</title></rect>
<rect x="292.0" y="1272.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.code" data-kind="function" class="blk"><title>code — function, 14 lines</title></rect>
<rect x="292.0" y="1280.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.db" data-kind="function" class="blk"><title>db — function, 11 lines</title></rect>
<rect x="292.0" y="1286.5" width="74.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.openapi" data-kind="function" class="blk"><title>openapi — function, 12 lines</title></rect>
<rect x="292.0" y="1293.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.usage" data-kind="function" class="blk"><title>usage — function, 15 lines</title></rect>
<rect x="292.0" y="1303.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.main" data-kind="function" class="blk"><title>main — function, 7 lines</title></rect>
<rect x="374.0" y="1248.0" width="74.0" height="45.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.view — 90 lines</title></rect>
<rect x="374.0" y="1255.0" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.view.main" data-kind="function" class="blk"><title>main — function, 75 lines</title></rect>
<rect x="456.0" y="1248.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.common — 80 lines</title></rect>
<rect x="456.0" y="1258.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.cli.common.Abort" data-kind="class" class="blk"><title>Abort — class, 2 lines</title></rect>
<rect x="456.0" y="1260.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.read_json" data-kind="function" class="blk"><title>read_json — function, 5 lines</title></rect>
<rect x="456.0" y="1263.5" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.read_ir" data-kind="function" class="blk"><title>read_ir — function, 11 lines</title></rect>
<rect x="456.0" y="1270.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.load_style" data-kind="function" class="blk"><title>load_style — function, 7 lines</title></rect>
<rect x="456.0" y="1274.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.style_args" data-kind="function" class="blk"><title>style_args — function, 3 lines</title></rect>
<rect x="456.0" y="1277.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.emit" data-kind="function" class="blk"><title>emit — function, 16 lines</title></rect>
<rect x="456.0" y="1286.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.dump_ir" data-kind="function" class="blk"><title>dump_ir — function, 3 lines</title></rect>
<rect x="538.0" y="1248.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.run — 80 lines</title></rect>
<rect x="538.0" y="1258.0" width="74.0" height="29.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.run.main" data-kind="function" class="blk"><title>main — function, 59 lines</title></rect>
<rect x="620.0" y="1248.0" width="74.0" height="37.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.book — 74 lines</title></rect>
<rect x="620.0" y="1258.5" width="74.0" height="26.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.book.main" data-kind="function" class="blk"><title>main — function, 52 lines</title></rect>
<rect x="702.0" y="1248.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.check — 51 lines</title></rect>
<rect x="702.0" y="1258.0" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.check.main" data-kind="function" class="blk"><title>main — function, 30 lines</title></rect>
<rect x="784.0" y="1248.0" width="74.0" height="16.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.validate — 33 lines</title></rect>
<rect x="784.0" y="1255.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.validate.main" data-kind="function" class="blk"><title>main — function, 18 lines</title></rect>
<rect x="28.0" y="1412.5" width="74.0" height="247.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.site — 494 lines</title></rect>
<rect x="28.0" y="1548.5" width="74.0" height="27.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._ledger" data-kind="function" class="blk"><title>_ledger — function, 54 lines</title></rect>
<rect x="28.0" y="1576.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._slots" data-kind="function" class="blk"><title>_slots — function, 13 lines</title></rect>
<rect x="28.0" y="1584.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._fill" data-kind="function" class="blk"><title>_fill — function, 4 lines</title></rect>
<rect x="28.0" y="1587.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sidebar" data-kind="function" class="blk"><title>_sidebar — function, 17 lines</title></rect>
<rect x="28.0" y="1596.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sections" data-kind="function" class="blk"><title>_sections — function, 15 lines</title></rect>
<rect x="28.0" y="1605.0" width="74.0" height="48.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.emit" data-kind="function" class="blk"><title>emit — function, 96 lines</title></rect>
<rect x="28.0" y="1654.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.write" data-kind="function" class="blk"><title>write — function, 10 lines</title></rect>
<rect x="110.0" y="1412.5" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.explore — 316 lines</title></rect>
<rect x="110.0" y="1432.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._is_schema" data-kind="function" class="blk"><title>_is_schema — function, 4 lines</title></rect>
<rect x="110.0" y="1435.0" width="74.0" height="24.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._neighbourhood_svgs" data-kind="function" class="blk"><title>_neighbourhood_svgs — function, 48 lines</title></rect>
<rect x="117.0" y="1441.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="117.0" y="1445.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one#L66" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="110.0" y="1460.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._facts" data-kind="function" class="blk"><title>_facts — function, 27 lines</title></rect>
<rect x="110.0" y="1474.5" width="74.0" height="86.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.emit" data-kind="function" class="blk"><title>emit — function, 172 lines</title></rect>
<rect x="110.0" y="1561.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.write" data-kind="function" class="blk"><title>write — function, 17 lines</title></rect>
<rect x="192.0" y="1412.5" width="74.0" height="146.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.dot — 292 lines</title></rect>
<rect x="192.0" y="1431.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.RenderError" data-kind="class" class="blk"><title>RenderError — class, 2 lines</title></rect>
<rect x="192.0" y="1433.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._esc" data-kind="function" class="blk"><title>_esc — function, 2 lines</title></rect>
<rect x="192.0" y="1435.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._attrs" data-kind="function" class="blk"><title>_attrs — function, 3 lines</title></rect>
<rect x="192.0" y="1437.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._style_words" data-kind="function" class="blk"><title>_style_words — function, 9 lines</title></rect>
<rect x="192.0" y="1443.0" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._node_attrs" data-kind="function" class="blk"><title>_node_attrs — function, 24 lines</title></rect>
<rect x="192.0" y="1456.0" width="74.0" height="52.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.emit" data-kind="function" class="blk"><title>emit — function, 105 lines</title></rect>
<rect x="199.0" y="1469.5" width="60.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot.emit.write" data-kind="function" class="blk"><title>write — function, 29 lines</title></rect>
<rect x="192.0" y="1509.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._within" data-kind="function" class="blk"><title>_within — function, 8 lines</title></rect>
<rect x="192.0" y="1514.5" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._endpoints" data-kind="function" class="blk"><title>_endpoints — function, 41 lines</title></rect>
<rect x="199.0" y="1519.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.walk" data-kind="function" class="blk"><title>walk — function, 4 lines</title></rect>
<rect x="199.0" y="1523.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.collapsed" data-kind="function" class="blk"><title>collapsed — function, 2 lines</title></rect>
<rect x="199.0" y="1525.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.first_leaf" data-kind="function" class="blk"><title>first_leaf — function, 4 lines</title></rect>
<rect x="192.0" y="1536.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._safe" data-kind="function" class="blk"><title>_safe — function, 2 lines</title></rect>
<rect x="192.0" y="1538.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._q" data-kind="function" class="blk"><title>_q — function, 2 lines</title></rect>
<rect x="192.0" y="1541.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.have_graphviz" data-kind="function" class="blk"><title>have_graphviz — function, 2 lines</title></rect>
<rect x="192.0" y="1543.5" width="74.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.render" data-kind="function" class="blk"><title>render — function, 29 lines</title></rect>
<rect x="274.0" y="1412.5" width="74.0" height="139.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.notebook — 279 lines</title></rect>
<rect x="274.0" y="1433.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._cell" data-kind="function" class="blk"><title>_cell — function, 13 lines</title></rect>
<rect x="274.0" y="1441.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._example" data-kind="function" class="blk"><title>_example — function, 26 lines</title></rect>
<rect x="274.0" y="1474.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._params_cell" data-kind="function" class="blk"><title>_params_cell — function, 18 lines</title></rect>
<rect x="274.0" y="1484.5" width="74.0" height="20.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_cell" data-kind="function" class="blk"><title>_call_cell — function, 40 lines</title></rect>
<rect x="274.0" y="1505.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_md" data-kind="function" class="blk"><title>_call_md — function, 26 lines</title></rect>
<rect x="274.0" y="1519.5" width="74.0" height="26.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.build" data-kind="function" class="blk"><title>build — function, 52 lines</title></rect>
<rect x="274.0" y="1546.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.emit" data-kind="function" class="blk"><title>emit — function, 3 lines</title></rect>
<rect x="274.0" y="1549.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.write" data-kind="function" class="blk"><title>write — function, 5 lines</title></rect>
<rect x="356.0" y="1412.5" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.minimap — 278 lines</title></rect>
<rect x="356.0" y="1444.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._files" data-kind="function" class="blk"><title>_files — function, 57 lines</title></rect>
<rect x="363.0" y="1448.0" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.declared" data-kind="function" class="blk"><title>declared — function, 18 lines</title></rect>
<rect x="363.0" y="1457.5" width="60.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.build" data-kind="function" class="blk"><title>build — function, 11 lines</title></rect>
<rect x="356.0" y="1473.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._bands" data-kind="function" class="blk"><title>_bands — function, 7 lines</title></rect>
<rect x="356.0" y="1478.0" width="74.0" height="9.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._blocks" data-kind="function" class="blk"><title>_blocks — function, 19 lines</title></rect>
<rect x="356.0" y="1488.5" width="74.0" height="60.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.emit" data-kind="function" class="blk"><title>emit — function, 120 lines</title></rect>
<rect x="356.0" y="1549.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.marks_to_labels" data-kind="function" class="blk"><title>marks_to_labels — function, 3 lines</title></rect>
<rect x="438.0" y="1412.5" width="74.0" height="130.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.erd — 260 lines</title></rect>
<rect x="438.0" y="1439.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._truncate" data-kind="function" class="blk"><title>_truncate — function, 3 lines</title></rect>
<rect x="438.0" y="1442.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._tables" data-kind="function" class="blk"><title>_tables — function, 20 lines</title></rect>
<rect x="438.0" y="1453.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._card_height" data-kind="function" class="blk"><title>_card_height — function, 3 lines</title></rect>
<rect x="438.0" y="1455.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.layout" data-kind="function" class="blk"><title>layout — function, 20 lines</title></rect>
<rect x="438.0" y="1466.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._field_y" data-kind="function" class="blk"><title>_field_y — function, 3 lines</title></rect>
<rect x="438.0" y="1469.0" width="74.0" height="73.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.emit" data-kind="function" class="blk"><title>emit — function, 146 lines</title></rect>
<rect x="520.0" y="1412.5" width="74.0" height="82.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.index — 164 lines</title></rect>
<rect x="520.0" y="1429.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._tree" data-kind="function" class="blk"><title>_tree — function, 8 lines</title></rect>
<rect x="520.0" y="1434.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._anchor" data-kind="function" class="blk"><title>_anchor — function, 5 lines</title></rect>
<rect x="520.0" y="1438.0" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_markdown" data-kind="function" class="blk"><title>to_markdown — function, 84 lines</title></rect>
<rect x="527.0" y="1449.5" width="60.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_markdown.walk" data-kind="function" class="blk"><title>walk — function, 27 lines</title></rect>
<rect x="520.0" y="1481.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_sidebar" data-kind="function" class="blk"><title>to_sidebar — function, 26 lines</title></rect>
<rect x="527.0" y="1484.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_sidebar.build" data-kind="function" class="blk"><title>build — function, 13 lines</title></rect>
<rect x="602.0" y="1412.5" width="74.0" height="32.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.auto — 64 lines</title></rect>
<rect x="602.0" y="1426.0" width="74.0" height="18.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.auto.draw" data-kind="function" class="blk"><title>draw — function, 36 lines</title></rect>
<rect x="702.0" y="1412.5" width="74.0" height="141.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code — 282 lines</title></rect>
<rect x="702.0" y="1460.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.code.MissingParser" data-kind="class" class="blk"><title>MissingParser — class, 2 lines</title></rect>
<rect x="702.0" y="1462.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._parser" data-kind="function" class="blk"><title>_parser — function, 21 lines</title></rect>
<rect x="702.0" y="1473.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._name" data-kind="function" class="blk"><title>_name — function, 10 lines</title></rect>
<rect x="702.0" y="1479.5" width="74.0" height="17.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._walk" data-kind="function" class="blk"><title>_walk — function, 34 lines</title></rect>
<rect x="702.0" y="1497.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract_file" data-kind="function" class="blk"><title>extract_file — function, 27 lines</title></rect>
<rect x="702.0" y="1512.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._count_errors" data-kind="function" class="blk"><title>_count_errors — function, 5 lines</title></rect>
<rect x="702.0" y="1515.5" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract" data-kind="function" class="blk"><title>extract — function, 75 lines</title></rect>
<rect x="784.0" y="1412.5" width="74.0" height="134.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage — 269 lines</title></rect>
<rect x="784.0" y="1444.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._template" data-kind="function" class="blk"><title>_template — function, 27 lines</title></rect>
<rect x="784.0" y="1459.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._body" data-kind="function" class="blk"><title>_body — function, 10 lines</title></rect>
<rect x="784.0" y="1465.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._shape" data-kind="function" class="blk"><title>_shape — function, 15 lines</title></rect>
<rect x="784.0" y="1473.5" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._graphql" data-kind="function" class="blk"><title>_graphql — function, 11 lines</title></rect>
<rect x="784.0" y="1480.0" width="74.0" height="66.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage.extract" data-kind="function" class="blk"><title>extract — function, 133 lines</title></rect>
<rect x="28.0" y="1697.5" width="74.0" height="95.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi — 191 lines</title></rect>
<rect x="28.0" y="1713.0" width="74.0" height="14.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._modelgen" data-kind="function" class="blk"><title>_modelgen — function, 28 lines</title></rect>
<rect x="28.0" y="1728.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._type_name" data-kind="function" class="blk"><title>_type_name — function, 6 lines</title></rect>
<rect x="28.0" y="1732.0" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._refs" data-kind="function" class="blk"><title>_refs — function, 43 lines</title></rect>
<rect x="28.0" y="1754.5" width="74.0" height="38.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi.extract" data-kind="function" class="blk"><title>extract — function, 76 lines</title></rect>
<rect x="110.0" y="1697.5" width="74.0" height="80.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db — 161 lines</title></rect>
<rect x="110.0" y="1717.0" width="74.0" height="41.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.from_schema_dict" data-kind="function" class="blk"><title>from_schema_dict — function, 82 lines</title></rect>
<rect x="110.0" y="1759.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._relation" data-kind="function" class="blk"><title>_relation — function, 10 lines</title></rect>
<rect x="110.0" y="1765.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._plain_type" data-kind="function" class="blk"><title>_plain_type — function, 6 lines</title></rect>
<rect x="110.0" y="1769.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._dedupe" data-kind="function" class="blk"><title>_dedupe — function, 9 lines</title></rect>
<rect x="110.0" y="1774.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.extract" data-kind="function" class="blk"><title>extract — function, 6 lines</title></rect>
<rect x="192.0" y="1697.5" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python — 37 lines</title></rect>
<rect x="192.0" y="1707.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.extract" data-kind="function" class="blk"><title>extract — function, 14 lines</title></rect>
<rect x="292.0" y="1697.5" width="74.0" height="190.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.build — 381 lines</title></rect>
<rect x="292.0" y="1735.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.extract" data-kind="function" class="blk"><title>extract — function, 27 lines</title></rect>
<rect x="292.0" y="1749.5" width="74.0" height="34.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.spec_from" data-kind="function" class="blk"><title>spec_from — function, 68 lines</title></rect>
<rect x="292.0" y="1784.5" width="74.0" height="22.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build._load_cell" data-kind="function" class="blk"><title>_load_cell — function, 44 lines</title></rect>
<rect x="292.0" y="1811.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.clear" data-kind="function" class="blk"><title>clear — function, 26 lines</title></rect>
<rect x="292.0" y="1825.0" width="74.0" height="62.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.run" data-kind="function" class="blk"><title>run — function, 125 lines</title></rect>
<rect x="299.0" y="1831.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.build.run.say" data-kind="function" class="blk"><title>say — function, 3 lines</title></rect>
<rect x="374.0" y="1697.5" width="74.0" height="164.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.config — 328 lines</title></rect>
<rect x="374.0" y="1738.0" width="74.0" height="3.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.ConfigError" data-kind="class" class="blk"><title>ConfigError — class, 6 lines</title></rect>
<rect x="381.0" y="1739.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.ConfigError.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="374.0" y="1742.5" width="74.0" height="7.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.Entry" data-kind="class" class="blk"><title>Entry — class, 14 lines</title></rect>
<rect x="381.0" y="1748.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.Entry.line" data-kind="function" class="blk"><title>line — function, 2 lines</title></rect>
<rect x="374.0" y="1751.0" width="74.0" height="8.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.RunFile" data-kind="class" class="blk"><title>RunFile — class, 17 lines</title></rect>
<rect x="381.0" y="1753.5" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.RunFile.select" data-kind="function" class="blk"><title>select — function, 12 lines</title></rect>
<rect x="374.0" y="1760.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._path" data-kind="function" class="blk"><title>_path — function, 6 lines</title></rect>
<rect x="374.0" y="1764.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._within" data-kind="function" class="blk"><title>_within — function, 3 lines</title></rect>
<rect x="374.0" y="1767.0" width="74.0" height="65.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.load" data-kind="function" class="blk"><title>load — function, 130 lines</title></rect>
<rect x="381.0" y="1799.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.load.pick" data-kind="function" class="blk"><title>pick — function, 3 lines</title></rect>
<rect x="374.0" y="1833.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.apply_reference" data-kind="function" class="blk"><title>apply_reference — function, 11 lines</title></rect>
<rect x="374.0" y="1839.5" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.run" data-kind="function" class="blk"><title>run — function, 43 lines</title></rect>
<rect x="456.0" y="1697.5" width="74.0" height="137.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.checks — 275 lines</title></rect>
<rect x="456.0" y="1728.0" width="74.0" height="13.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Loaded" data-kind="class" class="blk"><title>Loaded — class, 26 lines</title></rect>
<rect x="463.0" y="1729.5" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.__init__" data-kind="function" class="blk"><title>__init__ — function, 15 lines</title></rect>
<rect x="463.0" y="1738.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.larder" data-kind="function" class="blk"><title>larder — function, 2 lines</title></rect>
<rect x="463.0" y="1740.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.measure" data-kind="function" class="blk"><title>measure — function, 2 lines</title></rect>
<rect x="456.0" y="1742.0" width="74.0" height="18.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Report" data-kind="class" class="blk"><title>Report — class, 37 lines</title></rect>
<rect x="463.0" y="1743.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.__init__" data-kind="function" class="blk"><title>__init__ — function, 2 lines</title></rect>
<rect x="463.0" y="1745.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.note" data-kind="function" class="blk"><title>note — function, 2 lines</title></rect>
<rect x="463.0" y="1746.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.check" data-kind="function" class="blk"><title>check — function, 8 lines</title></rect>
<rect x="463.0" y="1751.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="463.0" y="1753.0" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.total" data-kind="function" class="blk"><title>total — function, 15 lines</title></rect>
<rect x="456.0" y="1761.5" width="74.0" height="41.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.generated" data-kind="function" class="blk"><title>generated — function, 83 lines</title></rect>
<rect x="456.0" y="1804.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.custom" data-kind="function" class="blk"><title>custom — function, 26 lines</title></rect>
<rect x="456.0" y="1818.0" width="74.0" height="16.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks._run_cells" data-kind="function" class="blk"><title>_run_cells — function, 33 lines</title></rect>
<rect x="538.0" y="1697.5" width="74.0" height="98.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.larder — 196 lines</title></rect>
<rect x="538.0" y="1729.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder._count" data-kind="function" class="blk"><title>_count — function, 13 lines</title></rect>
<rect x="538.0" y="1736.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.redact" data-kind="function" class="blk"><title>redact — function, 32 lines</title></rect>
<rect x="538.0" y="1754.0" width="74.0" height="38.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.larder.Larder" data-kind="class" class="blk"><title>Larder — class, 77 lines</title></rect>
<rect x="545.0" y="1761.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 7 lines</title></rect>
<rect x="545.0" y="1765.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.read" data-kind="function" class="blk"><title>read — function, 3 lines</title></rect>
<rect x="545.0" y="1767.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.fail" data-kind="function" class="blk"><title>fail — function, 3 lines</title></rect>
<rect x="545.0" y="1769.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.to_dict" data-kind="function" class="blk"><title>to_dict — function, 13 lines</title></rect>
<rect x="545.0" y="1777.0" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.from_dict" data-kind="function" class="blk"><title>from_dict — function, 10 lines</title></rect>
<rect x="545.0" y="1782.5" width="60.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.line" data-kind="function" class="blk"><title>line — function, 20 lines</title></rect>
<rect x="538.0" y="1793.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.of" data-kind="function" class="blk"><title>of — function, 3 lines</title></rect>
<rect x="638.0" y="1697.5" width="74.0" height="118.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.collect — 236 lines</title></rect>
<rect x="638.0" y="1714.0" width="74.0" height="5.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Definition" data-kind="class" class="blk"><title>Definition — class, 10 lines</title></rect>
<rect x="638.0" y="1720.5" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Module" data-kind="class" class="blk"><title>Module — class, 12 lines</title></rect>
<rect x="638.0" y="1727.5" width="74.0" height="33.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._Collector" data-kind="class" class="blk"><title>_Collector — class, 67 lines</title></rect>
<rect x="645.0" y="1729.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="645.0" y="1732.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector._define" data-kind="function" class="blk"><title>_define — function, 17 lines</title></rect>
<rect x="645.0" y="1741.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ClassDef" data-kind="function" class="blk"><title>visit_ClassDef — function, 5 lines</title></rect>
<rect x="645.0" y="1744.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_FunctionDef" data-kind="function" class="blk"><title>visit_FunctionDef — function, 5 lines</title></rect>
<rect x="645.0" y="1749.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_Import" data-kind="function" class="blk"><title>visit_Import — function, 8 lines</title></rect>
<rect x="645.0" y="1754.0" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ImportFrom" data-kind="function" class="blk"><title>visit_ImportFrom — function, 14 lines</title></rect>
<rect x="638.0" y="1762.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._first_line" data-kind="function" class="blk"><title>_first_line — function, 5 lines</title></rect>
<rect x="638.0" y="1765.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._name_of" data-kind="function" class="blk"><title>_name_of — function, 15 lines</title></rect>
<rect x="638.0" y="1774.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._resolve_relative" data-kind="function" class="blk"><title>_resolve_relative — function, 16 lines</title></rect>
<rect x="638.0" y="1783.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.module_name" data-kind="function" class="blk"><title>module_name — function, 26 lines</title></rect>
<rect x="638.0" y="1797.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect_file" data-kind="function" class="blk"><title>collect_file — function, 21 lines</title></rect>
<rect x="638.0" y="1808.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect" data-kind="function" class="blk"><title>collect — function, 13 lines</title></rect>
<rect x="720.0" y="1697.5" width="74.0" height="92.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.resolve — 184 lines</title></rect>
<rect x="720.0" y="1711.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._id_for" data-kind="function" class="blk"><title>_id_for — function, 2 lines</title></rect>
<rect x="720.0" y="1713.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._resolve" data-kind="function" class="blk"><title>_resolve — function, 32 lines</title></rect>
<rect x="720.0" y="1730.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.larder_of" data-kind="function" class="blk"><title>larder_of — function, 17 lines</title></rect>
<rect x="720.0" y="1740.0" width="74.0" height="49.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.to_ir" data-kind="function" class="blk"><title>to_ir — function, 98 lines</title></rect>
<rect x="727.0" y="1769.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.resolve.to_ir._point_at" data-kind="function" class="blk"><title>_point_at — function, 9 lines</title></rect>
<rect x="28.0" y="1926.0" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.validate — 278 lines</title></rect>
<rect x="28.0" y="1947.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.validate.IRError" data-kind="class" class="blk"><title>IRError — class, 2 lines</title></rect>
<rect x="28.0" y="1949.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._schema" data-kind="function" class="blk"><title>_schema — function, 2 lines</title></rect>
<rect x="28.0" y="1951.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._props" data-kind="function" class="blk"><title>_props — function, 5 lines</title></rect>
<rect x="28.0" y="1954.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._fields" data-kind="function" class="blk"><title>_fields — function, 4 lines</title></rect>
<rect x="28.0" y="1957.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check" data-kind="function" class="blk"><title>check — function, 108 lines</title></rect>
<rect x="28.0" y="2019.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._check_larder" data-kind="function" class="blk"><title>_check_larder — function, 58 lines</title></rect>
<rect x="28.0" y="2049.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.validate" data-kind="function" class="blk"><title>validate — function, 6 lines</title></rect>
<rect x="28.0" y="2053.0" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check_model_matches_schema" data-kind="function" class="blk"><title>check_model_matches_schema — function, 23 lines</title></rect>
<rect x="110.0" y="1926.0" width="74.0" height="82.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.model — 165 lines</title></rect>
<rect x="110.0" y="1942.5" width="74.0" height="19.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Meta" data-kind="class" class="blk"><title>Meta — class, 38 lines</title></rect>
<rect x="117.0" y="1952.5" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Meta.to_dict" data-kind="function" class="blk"><title>to_dict — function, 18 lines</title></rect>
<rect x="110.0" y="1963.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Node" data-kind="class" class="blk"><title>Node — class, 21 lines</title></rect>
<rect x="117.0" y="1967.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 3 lines</title></rect>
<rect x="117.0" y="1969.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.to_dict" data-kind="function" class="blk"><title>to_dict — function, 8 lines</title></rect>
<rect x="110.0" y="1975.0" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Edge" data-kind="class" class="blk"><title>Edge — class, 15 lines</title></rect>
<rect x="117.0" y="1979.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Edge.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="110.0" y="1984.0" width="74.0" height="24.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Graph" data-kind="class" class="blk"><title>Graph — class, 48 lines</title></rect>
<rect x="117.0" y="1988.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.node" data-kind="function" class="blk"><title>node — function, 4 lines</title></rect>
<rect x="117.0" y="1991.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.edge" data-kind="function" class="blk"><title>edge — function, 4 lines</title></rect>
<rect x="117.0" y="1993.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.has" data-kind="function" class="blk"><title>has — function, 2 lines</title></rect>
<rect x="117.0" y="1996.0" width="60.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.to_dict" data-kind="function" class="blk"><title>to_dict — function, 16 lines</title></rect>
<rect x="117.0" y="2005.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.from_dict" data-kind="function" class="blk"><title>from_dict — function, 6 lines</title></rect>
<rect x="210.0" y="1926.0" width="74.0" height="111.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.extract — 222 lines</title></rect>
<rect x="210.0" y="1956.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._values_from" data-kind="function" class="blk"><title>_values_from — function, 17 lines</title></rect>
<rect x="210.0" y="1966.0" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._normalise" data-kind="function" class="blk"><title>_normalise — function, 45 lines</title></rect>
<rect x="210.0" y="1989.5" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._svg_files" data-kind="function" class="blk"><title>_svg_files — function, 25 lines</title></rect>
<rect x="210.0" y="2003.0" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.harvest" data-kind="function" class="blk"><title>harvest — function, 43 lines</title></rect>
<rect x="210.0" y="2025.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="210.0" y="2030.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.summarise" data-kind="function" class="blk"><title>summarise — function, 13 lines</title></rect>
<rect x="292.0" y="1926.0" width="74.0" height="105.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.tokens — 211 lines</title></rect>
<rect x="292.0" y="1954.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._top" data-kind="function" class="blk"><title>_top — function, 2 lines</title></rect>
<rect x="292.0" y="1956.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._mode" data-kind="function" class="blk"><title>_mode — function, 3 lines</title></rect>
<rect x="292.0" y="1958.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._luminance" data-kind="function" class="blk"><title>_luminance — function, 6 lines</title></rect>
<rect x="292.0" y="1962.5" width="74.0" height="50.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.derive" data-kind="function" class="blk"><title>derive — function, 101 lines</title></rect>
<rect x="299.0" y="1984.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.tokens.derive.accent" data-kind="function" class="blk"><title>accent — function, 2 lines</title></rect>
<rect x="292.0" y="2014.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.geometry" data-kind="function" class="blk"><title>geometry — function, 14 lines</title></rect>
<rect x="292.0" y="2022.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="292.0" y="2026.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.from_folder" data-kind="function" class="blk"><title>from_folder — function, 9 lines</title></rect>
<rect x="392.0" y="1926.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="492.0" y="1926.0" width="74.0" height="75.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab.pg_probe — 150 lines</title></rect>
<rect x="492.0" y="1961.5" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.probe" data-kind="function" class="blk"><title>probe — function, 30 lines</title></rect>
<rect x="492.0" y="1983.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe._simplify" data-kind="function" class="blk"><title>_simplify — function, 3 lines</title></rect>
<rect x="492.0" y="1986.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="592.0" y="1926.0" width="74.0" height="132.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook.spec — 264 lines</title></rect>
<rect x="592.0" y="1954.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec._step" data-kind="function" class="blk"><title>_step — function, 4 lines</title></rect>
<rect x="592.0" y="1957.0" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.from_ir" data-kind="function" class="blk"><title>from_ir — function, 108 lines</title></rect>
<rect x="599.0" y="1964.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.notebook.spec.from_ir._order" data-kind="function" class="blk"><title>_order — function, 5 lines</title></rect>
<rect x="592.0" y="2012.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.scaffold" data-kind="function" class="blk"><title>scaffold — function, 20 lines</title></rect>
<rect x="592.0" y="2023.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.merge" data-kind="function" class="blk"><title>merge — function, 58 lines</title></rect>
<rect x="592.0" y="2053.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.load" data-kind="function" class="blk"><title>load — function, 2 lines</title></rect>
<rect x="592.0" y="2055.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<rect x="692.0" y="1926.0" width="74.0" height="242.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.filter — 485 lines</title></rect>
<rect x="692.0" y="1944.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter._rebuild" data-kind="function" class="blk"><title>_rebuild — function, 57 lines</title></rect>
<rect x="699.0" y="1948.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.surviving_parent" data-kind="function" class="blk"><title>surviving_parent — function, 5 lines</title></rect>
<rect x="699.0" y="1955.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.lift" data-kind="function" class="blk"><title>lift — function, 6 lines</title></rect>
<rect x="692.0" y="1973.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_kinds" data-kind="function" class="blk"><title>drop_kinds — function, 14 lines</title></rect>
<rect x="692.0" y="1981.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.only_kinds" data-kind="function" class="blk"><title>only_kinds — function, 14 lines</title></rect>
<rect x="692.0" y="1989.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_stdlib" data-kind="function" class="blk"><title>drop_stdlib — function, 13 lines</title></rect>
<rect x="692.0" y="1997.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_external" data-kind="function" class="blk"><title>drop_external — function, 3 lines</title></rect>
<rect x="692.0" y="1999.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.subtree" data-kind="function" class="blk"><title>subtree — function, 13 lines</title></rect>
<rect x="692.0" y="2007.0" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.neighbourhood" data-kind="function" class="blk"><title>neighbourhood — function, 45 lines</title></rect>
<rect x="692.0" y="2030.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.collapse_to_depth" data-kind="function" class="blk"><title>collapse_to_depth — function, 18 lines</title></rect>
<rect x="699.0" y="2035.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.collapse_to_depth.level" data-kind="function" class="blk"><title>level — function, 6 lines</title></rect>
<rect x="692.0" y="2040.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_builtins" data-kind="function" class="blk"><title>drop_builtins — function, 14 lines</title></rect>
<rect x="692.0" y="2048.5" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.overview" data-kind="function" class="blk"><title>overview — function, 35 lines</title></rect>
<rect x="692.0" y="2067.0" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.shape" data-kind="function" class="blk"><title>shape — function, 72 lines</title></rect>
<rect x="699.0" y="2091.0" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.shape.rank_of" data-kind="function" class="blk"><title>rank_of — function, 9 lines</title></rect>
<rect x="692.0" y="2104.0" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.split" data-kind="function" class="blk"><title>split — function, 24 lines</title></rect>
<rect x="692.0" y="2117.0" width="74.0" height="51.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.classify" data-kind="function" class="blk"><title>classify — function, 102 lines</title></rect>
<rect x="28.0" y="1487.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir — 7 lines</title></rect>
<rect x="110.0" y="1487.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors — 2 lines</title></rect>
<rect x="210.0" y="1487.0" width="74.0" height="128.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.emit — 257 lines</title></rect>
<rect x="210.0" y="1499.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit._parser" data-kind="function" class="blk"><title>_parser — function, 7 lines</title></rect>
<rect x="210.0" y="1503.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit._size" data-kind="function" class="blk"><title>_size — function, 3 lines</title></rect>
<rect x="210.0" y="1506.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.auto" data-kind="function" class="blk"><title>auto — function, 16 lines</title></rect>
<rect x="210.0" y="1515.0" width="74.0" height="19.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.dot" data-kind="function" class="blk"><title>dot — function, 38 lines</title></rect>
<rect x="210.0" y="1535.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.erd" data-kind="function" class="blk"><title>erd — function, 11 lines</title></rect>
<rect x="210.0" y="1541.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.index" data-kind="function" class="blk"><title>index — function, 14 lines</title></rect>
<rect x="210.0" y="1549.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.minimap" data-kind="function" class="blk"><title>minimap — function, 14 lines</title></rect>
<rect x="210.0" y="1557.5" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.notebook" data-kind="function" class="blk"><title>notebook — function, 41 lines</title></rect>
<rect x="210.0" y="1579.0" width="74.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.minisite" data-kind="function" class="blk"><title>minisite — function, 31 lines</title></rect>
<rect x="210.0" y="1595.5" width="74.0" height="11.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.explore" data-kind="function" class="blk"><title>explore — function, 22 lines</title></rect>
<rect x="210.0" y="1610.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.main" data-kind="function" class="blk"><title>main — function, 9 lines</title></rect>
<rect x="292.0" y="1487.0" width="74.0" height="59.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.extract — 119 lines</title></rect>
<rect x="292.0" y="1496.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract._parser" data-kind="function" class="blk"><title>_parser — function, 5 lines</title></rect>
<rect x="292.0" y="1500.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract._counts" data-kind="function" class="blk"><title>_counts — function, 3 lines</title></rect>
<rect x="292.0" y="1502.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.python" data-kind="function" class="blk"><title>python — function, 15 lines</title></rect>
<rect x="292.0" y="1511.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.code" data-kind="function" class="blk"><title>code — function, 14 lines</title></rect>
<rect x="292.0" y="1519.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.db" data-kind="function" class="blk"><title>db — function, 11 lines</title></rect>
<rect x="292.0" y="1525.5" width="74.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.openapi" data-kind="function" class="blk"><title>openapi — function, 12 lines</title></rect>
<rect x="292.0" y="1532.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.usage" data-kind="function" class="blk"><title>usage — function, 15 lines</title></rect>
<rect x="292.0" y="1542.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.main" data-kind="function" class="blk"><title>main — function, 7 lines</title></rect>
<rect x="374.0" y="1487.0" width="74.0" height="49.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.run — 99 lines</title></rect>
<rect x="374.0" y="1497.0" width="74.0" height="39.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.run.main" data-kind="function" class="blk"><title>main — function, 78 lines</title></rect>
<rect x="456.0" y="1487.0" width="74.0" height="45.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.view — 90 lines</title></rect>
<rect x="456.0" y="1494.0" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.view.main" data-kind="function" class="blk"><title>main — function, 75 lines</title></rect>
<rect x="538.0" y="1487.0" width="74.0" height="44.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.scaffold — 88 lines</title></rect>
<rect x="538.0" y="1497.5" width="74.0" height="33.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.scaffold.main" data-kind="function" class="blk"><title>main — function, 66 lines</title></rect>
<rect x="620.0" y="1487.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.common — 80 lines</title></rect>
<rect x="620.0" y="1497.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.cli.common.Abort" data-kind="class" class="blk"><title>Abort — class, 2 lines</title></rect>
<rect x="620.0" y="1499.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.read_json" data-kind="function" class="blk"><title>read_json — function, 5 lines</title></rect>
<rect x="620.0" y="1502.5" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.read_ir" data-kind="function" class="blk"><title>read_ir — function, 11 lines</title></rect>
<rect x="620.0" y="1509.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.load_style" data-kind="function" class="blk"><title>load_style — function, 7 lines</title></rect>
<rect x="620.0" y="1513.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.style_args" data-kind="function" class="blk"><title>style_args — function, 3 lines</title></rect>
<rect x="620.0" y="1516.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.emit" data-kind="function" class="blk"><title>emit — function, 16 lines</title></rect>
<rect x="620.0" y="1525.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.dump_ir" data-kind="function" class="blk"><title>dump_ir — function, 3 lines</title></rect>
<rect x="702.0" y="1487.0" width="74.0" height="38.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.book — 77 lines</title></rect>
<rect x="702.0" y="1497.5" width="74.0" height="27.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.book.main" data-kind="function" class="blk"><title>main — function, 55 lines</title></rect>
<rect x="784.0" y="1487.0" width="74.0" height="29.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.check — 58 lines</title></rect>
<rect x="784.0" y="1497.0" width="74.0" height="18.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.check.main" data-kind="function" class="blk"><title>main — function, 37 lines</title></rect>
<rect x="28.0" y="1653.5" width="74.0" height="17.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.contract — 34 lines</title></rect>
<rect x="28.0" y="1666.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.contract.main" data-kind="function" class="blk"><title>main — function, 8 lines</title></rect>
<rect x="110.0" y="1653.5" width="74.0" height="16.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.validate — 33 lines</title></rect>
<rect x="110.0" y="1660.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.validate.main" data-kind="function" class="blk"><title>main — function, 18 lines</title></rect>
<rect x="210.0" y="1653.5" width="74.0" height="160.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.notebook — 320 lines</title></rect>
<rect x="210.0" y="1674.5" width="74.0" height="9.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._cell" data-kind="function" class="blk"><title>_cell — function, 19 lines</title></rect>
<rect x="210.0" y="1685.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._example" data-kind="function" class="blk"><title>_example — function, 26 lines</title></rect>
<rect x="210.0" y="1718.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._params_cell" data-kind="function" class="blk"><title>_params_cell — function, 18 lines</title></rect>
<rect x="210.0" y="1728.5" width="74.0" height="20.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_cell" data-kind="function" class="blk"><title>_call_cell — function, 40 lines</title></rect>
<rect x="210.0" y="1749.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_md" data-kind="function" class="blk"><title>_call_md — function, 26 lines</title></rect>
<rect x="210.0" y="1763.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._figure_md" data-kind="function" class="blk"><title>_figure_md — function, 21 lines</title></rect>
<rect x="210.0" y="1775.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._figure_attachments" data-kind="function" class="blk"><title>_figure_attachments — function, 7 lines</title></rect>
<rect x="210.0" y="1779.5" width="74.0" height="27.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.build" data-kind="function" class="blk"><title>build — function, 55 lines</title></rect>
<rect x="210.0" y="1808.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.emit" data-kind="function" class="blk"><title>emit — function, 3 lines</title></rect>
<rect x="210.0" y="1810.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.write" data-kind="function" class="blk"><title>write — function, 5 lines</title></rect>
<rect x="292.0" y="1653.5" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.explore — 316 lines</title></rect>
<rect x="292.0" y="1673.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._is_schema" data-kind="function" class="blk"><title>_is_schema — function, 4 lines</title></rect>
<rect x="292.0" y="1676.0" width="74.0" height="24.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._neighbourhood_svgs" data-kind="function" class="blk"><title>_neighbourhood_svgs — function, 48 lines</title></rect>
<rect x="299.0" y="1682.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="299.0" y="1686.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one#L66" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="292.0" y="1701.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._facts" data-kind="function" class="blk"><title>_facts — function, 27 lines</title></rect>
<rect x="292.0" y="1715.5" width="74.0" height="86.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.emit" data-kind="function" class="blk"><title>emit — function, 172 lines</title></rect>
<rect x="292.0" y="1802.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.write" data-kind="function" class="blk"><title>write — function, 17 lines</title></rect>
<rect x="374.0" y="1653.5" width="74.0" height="154.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.dot — 309 lines</title></rect>
<rect x="374.0" y="1672.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.RenderError" data-kind="class" class="blk"><title>RenderError — class, 2 lines</title></rect>
<rect x="374.0" y="1674.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._esc" data-kind="function" class="blk"><title>_esc — function, 2 lines</title></rect>
<rect x="374.0" y="1676.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._attrs" data-kind="function" class="blk"><title>_attrs — function, 3 lines</title></rect>
<rect x="374.0" y="1678.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._style_words" data-kind="function" class="blk"><title>_style_words — function, 9 lines</title></rect>
<rect x="374.0" y="1684.0" width="74.0" height="16.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._node_attrs" data-kind="function" class="blk"><title>_node_attrs — function, 33 lines</title></rect>
<rect x="374.0" y="1701.5" width="74.0" height="56.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.emit" data-kind="function" class="blk"><title>emit — function, 113 lines</title></rect>
<rect x="381.0" y="1718.0" width="60.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot.emit.write" data-kind="function" class="blk"><title>write — function, 31 lines</title></rect>
<rect x="374.0" y="1759.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._within" data-kind="function" class="blk"><title>_within — function, 8 lines</title></rect>
<rect x="374.0" y="1764.0" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._endpoints" data-kind="function" class="blk"><title>_endpoints — function, 41 lines</title></rect>
<rect x="381.0" y="1769.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.walk" data-kind="function" class="blk"><title>walk — function, 4 lines</title></rect>
<rect x="381.0" y="1773.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.collapsed" data-kind="function" class="blk"><title>collapsed — function, 2 lines</title></rect>
<rect x="381.0" y="1774.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.first_leaf" data-kind="function" class="blk"><title>first_leaf — function, 4 lines</title></rect>
<rect x="374.0" y="1785.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._safe" data-kind="function" class="blk"><title>_safe — function, 2 lines</title></rect>
<rect x="374.0" y="1787.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._q" data-kind="function" class="blk"><title>_q — function, 2 lines</title></rect>
<rect x="374.0" y="1791.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.have_graphviz" data-kind="function" class="blk"><title>have_graphviz — function, 2 lines</title></rect>
<rect x="374.0" y="1793.0" width="74.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.render" data-kind="function" class="blk"><title>render — function, 29 lines</title></rect>
<rect x="456.0" y="1653.5" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.minimap — 278 lines</title></rect>
<rect x="456.0" y="1685.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._files" data-kind="function" class="blk"><title>_files — function, 57 lines</title></rect>
<rect x="463.0" y="1689.0" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.declared" data-kind="function" class="blk"><title>declared — function, 18 lines</title></rect>
<rect x="463.0" y="1698.5" width="60.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.build" data-kind="function" class="blk"><title>build — function, 11 lines</title></rect>
<rect x="456.0" y="1714.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._bands" data-kind="function" class="blk"><title>_bands — function, 7 lines</title></rect>
<rect x="456.0" y="1719.0" width="74.0" height="9.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._blocks" data-kind="function" class="blk"><title>_blocks — function, 19 lines</title></rect>
<rect x="456.0" y="1729.5" width="74.0" height="60.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.emit" data-kind="function" class="blk"><title>emit — function, 120 lines</title></rect>
<rect x="456.0" y="1790.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.marks_to_labels" data-kind="function" class="blk"><title>marks_to_labels — function, 3 lines</title></rect>
<rect x="538.0" y="1653.5" width="74.0" height="138.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.erd — 276 lines</title></rect>
<rect x="538.0" y="1680.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._truncate" data-kind="function" class="blk"><title>_truncate — function, 3 lines</title></rect>
<rect x="538.0" y="1683.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._tables" data-kind="function" class="blk"><title>_tables — function, 20 lines</title></rect>
<rect x="538.0" y="1694.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._card_height" data-kind="function" class="blk"><title>_card_height — function, 3 lines</title></rect>
<rect x="538.0" y="1696.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.max_columns" data-kind="function" class="blk"><title>max_columns — function, 9 lines</title></rect>
<rect x="538.0" y="1702.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.layout" data-kind="function" class="blk"><title>layout — function, 25 lines</title></rect>
<rect x="538.0" y="1715.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._field_y" data-kind="function" class="blk"><title>_field_y — function, 3 lines</title></rect>
<rect x="538.0" y="1718.0" width="74.0" height="73.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.emit" data-kind="function" class="blk"><title>emit — function, 146 lines</title></rect>
<rect x="620.0" y="1653.5" width="74.0" height="135.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.minisite — 270 lines</title></rect>
<rect x="620.0" y="1686.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite._note" data-kind="function" class="blk"><title>_note — function, 7 lines</title></rect>
<rect x="620.0" y="1691.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite._missing" data-kind="function" class="blk"><title>_missing — function, 9 lines</title></rect>
<rect x="620.0" y="1696.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite._tree" data-kind="function" class="blk"><title>_tree — function, 15 lines</title></rect>
<rect x="627.0" y="1699.5" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minisite._tree.walk" data-kind="function" class="blk"><title>walk — function, 7 lines</title></rect>
<rect x="620.0" y="1705.0" width="74.0" height="34.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite._page" data-kind="function" class="blk"><title>_page — function, 69 lines</title></rect>
<rect x="620.0" y="1740.5" width="74.0" height="47.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.write" data-kind="function" class="blk"><title>write — function, 95 lines</title></rect>
<rect x="627.0" y="1748.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minisite.write.put" data-kind="function" class="blk"><title>put — function, 4 lines</title></rect>
<rect x="702.0" y="1653.5" width="74.0" height="82.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.index — 164 lines</title></rect>
<rect x="702.0" y="1670.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._tree" data-kind="function" class="blk"><title>_tree — function, 8 lines</title></rect>
<rect x="702.0" y="1675.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._anchor" data-kind="function" class="blk"><title>_anchor — function, 5 lines</title></rect>
<rect x="702.0" y="1679.0" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_markdown" data-kind="function" class="blk"><title>to_markdown — function, 84 lines</title></rect>
<rect x="709.0" y="1690.5" width="60.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_markdown.walk" data-kind="function" class="blk"><title>walk — function, 27 lines</title></rect>
<rect x="702.0" y="1722.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_sidebar" data-kind="function" class="blk"><title>to_sidebar — function, 26 lines</title></rect>
<rect x="709.0" y="1725.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_sidebar.build" data-kind="function" class="blk"><title>build — function, 13 lines</title></rect>
<rect x="784.0" y="1653.5" width="74.0" height="73.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.markdown — 146 lines</title></rect>
<rect x="784.0" y="1664.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.markdown._url" data-kind="function" class="blk"><title>_url — function, 7 lines</title></rect>
<rect x="784.0" y="1673.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.markdown._inline" data-kind="function" class="blk"><title>_inline — function, 13 lines</title></rect>
<rect x="791.0" y="1675.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.markdown._inline.keep" data-kind="function" class="blk"><title>keep — function, 3 lines</title></rect>
<rect x="784.0" y="1680.5" width="74.0" height="45.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.markdown.to_html" data-kind="function" class="blk"><title>to_html — function, 91 lines</title></rect>
<rect x="791.0" y="1684.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.markdown.to_html.para" data-kind="function" class="blk"><title>para — function, 3 lines</title></rect>
<rect x="791.0" y="1716.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.markdown.to_html.cells" data-kind="function" class="blk"><title>cells — function, 2 lines</title></rect>
<rect x="28.0" y="1851.5" width="74.0" height="59.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.paged — 119 lines</title></rect>
<rect x="28.0" y="1867.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.paged.svg_width_px" data-kind="function" class="blk"><title>svg_width_px — function, 6 lines</title></rect>
<rect x="28.0" y="1871.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.paged._estimate_px" data-kind="function" class="blk"><title>_estimate_px — function, 10 lines</title></rect>
<rect x="28.0" y="1877.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.paged.fits_for" data-kind="function" class="blk"><title>fits_for — function, 17 lines</title></rect>
<rect x="35.0" y="1882.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.paged.fits_for.fits" data-kind="function" class="blk"><title>fits — function, 6 lines</title></rect>
<rect x="28.0" y="1887.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.paged.links_for" data-kind="function" class="blk"><title>links_for — function, 15 lines</title></rect>
<rect x="28.0" y="1895.5" width="74.0" height="9.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.paged.render" data-kind="function" class="blk"><title>render — function, 19 lines</title></rect>
<rect x="28.0" y="1906.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.paged.inline" data-kind="function" class="blk"><title>inline — function, 9 lines</title></rect>
<rect x="110.0" y="1851.5" width="74.0" height="32.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.auto — 64 lines</title></rect>
<rect x="110.0" y="1865.0" width="74.0" height="18.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.auto.draw" data-kind="function" class="blk"><title>draw — function, 36 lines</title></rect>
<rect x="210.0" y="1851.5" width="74.0" height="314.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.config — 629 lines</title></rect>
<rect x="210.0" y="1892.0" width="74.0" height="3.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.ConfigError" data-kind="class" class="blk"><title>ConfigError — class, 6 lines</title></rect>
<rect x="217.0" y="1893.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.ConfigError.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="210.0" y="1896.5" width="74.0" height="9.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.Entry" data-kind="class" class="blk"><title>Entry — class, 19 lines</title></rect>
<rect x="217.0" y="1905.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.Entry.line" data-kind="function" class="blk"><title>line — function, 2 lines</title></rect>
<rect x="210.0" y="1907.5" width="74.0" height="8.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.RunFile" data-kind="class" class="blk"><title>RunFile — class, 17 lines</title></rect>
<rect x="217.0" y="1910.0" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.RunFile.select" data-kind="function" class="blk"><title>select — function, 12 lines</title></rect>
<rect x="210.0" y="1917.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._path" data-kind="function" class="blk"><title>_path — function, 6 lines</title></rect>
<rect x="210.0" y="1921.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._within" data-kind="function" class="blk"><title>_within — function, 3 lines</title></rect>
<rect x="210.0" y="1923.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._read" data-kind="function" class="blk"><title>_read — function, 9 lines</title></rect>
<rect x="210.0" y="1929.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.load" data-kind="function" class="blk"><title>load — function, 18 lines</title></rect>
<rect x="210.0" y="1939.0" width="74.0" height="61.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._load_books" data-kind="function" class="blk"><title>_load_books — function, 122 lines</title></rect>
<rect x="217.0" y="1965.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config._load_books.pick" data-kind="function" class="blk"><title>pick — function, 3 lines</title></rect>
<rect x="210.0" y="2001.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.apply_reference" data-kind="function" class="blk"><title>apply_reference — function, 11 lines</title></rect>
<rect x="210.0" y="2007.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._checks" data-kind="function" class="blk"><title>_checks — function, 13 lines</title></rect>
<rect x="210.0" y="2015.0" width="74.0" height="18.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.run" data-kind="function" class="blk"><title>run — function, 37 lines</title></rect>
<rect x="210.0" y="2056.0" width="74.0" height="4.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.ProjectFile" data-kind="class" class="blk"><title>ProjectFile — class, 8 lines</title></rect>
<rect x="217.0" y="2059.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.ProjectFile.books" data-kind="function" class="blk"><title>books — function, 2 lines</title></rect>
<rect x="210.0" y="2061.0" width="74.0" height="77.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.load_project" data-kind="function" class="blk"><title>load_project — function, 155 lines</title></rect>
<rect x="210.0" y="2139.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.select_parts" data-kind="function" class="blk"><title>select_parts — function, 9 lines</title></rect>
<rect x="210.0" y="2145.0" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.run_project" data-kind="function" class="blk"><title>run_project — function, 41 lines</title></rect>
<rect x="292.0" y="1851.5" width="74.0" height="225.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.document — 450 lines</title></rect>
<rect x="292.0" y="1872.5" width="74.0" height="3.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.document.Output" data-kind="class" class="blk"><title>Output — class, 6 lines</title></rect>
<rect x="292.0" y="1877.0" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.document.Part" data-kind="class" class="blk"><title>Part — class, 12 lines</title></rect>
<rect x="292.0" y="1884.5" width="74.0" height="5.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.document.Document" data-kind="class" class="blk"><title>Document — class, 11 lines</title></rect>
<rect x="292.0" y="1893.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.apply_ops" data-kind="function" class="blk"><title>apply_ops — function, 15 lines</title></rect>
<rect x="292.0" y="1903.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.clear_output" data-kind="function" class="blk"><title>clear_output — function, 27 lines</title></rect>
<rect x="292.0" y="1917.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.write_manifest" data-kind="function" class="blk"><title>write_manifest — function, 3 lines</title></rect>
<rect x="292.0" y="1921.0" width="74.0" height="11.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.rendered_pages" data-kind="function" class="blk"><title>rendered_pages — function, 22 lines</title></rect>
<rect x="292.0" y="1933.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.caption" data-kind="function" class="blk"><title>caption — function, 14 lines</title></rect>
<rect x="292.0" y="1942.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document._note_step" data-kind="function" class="blk"><title>_note_step — function, 8 lines</title></rect>
<rect x="292.0" y="1947.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document._with_note" data-kind="function" class="blk"><title>_with_note — function, 11 lines</title></rect>
<rect x="292.0" y="1953.5" width="74.0" height="45.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.notebook_spec" data-kind="function" class="blk"><title>notebook_spec — function, 91 lines</title></rect>
<rect x="292.0" y="2001.0" width="74.0" height="58.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.build" data-kind="function" class="blk"><title>build — function, 116 lines</title></rect>
<rect x="299.0" y="2004.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.document.build.say" data-kind="function" class="blk"><title>say — function, 3 lines</title></rect>
<rect x="292.0" y="2060.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document._spots" data-kind="function" class="blk"><title>_spots — function, 16 lines</title></rect>
<rect x="292.0" y="2069.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.known_spots" data-kind="function" class="blk"><title>known_spots — function, 3 lines</title></rect>
<rect x="292.0" y="2071.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.document.plan_spots" data-kind="function" class="blk"><title>plan_spots — function, 9 lines</title></rect>
<rect x="374.0" y="1851.5" width="74.0" height="210.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.build — 421 lines</title></rect>
<rect x="374.0" y="1889.0" width="74.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.extract" data-kind="function" class="blk"><title>extract — function, 29 lines</title></rect>
<rect x="374.0" y="1904.5" width="74.0" height="35.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.spec_from" data-kind="function" class="blk"><title>spec_from — function, 71 lines</title></rect>
<rect x="374.0" y="1941.0" width="74.0" height="23.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build._load_cell" data-kind="function" class="blk"><title>_load_cell — function, 46 lines</title></rect>
<rect x="374.0" y="1976.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.clear" data-kind="function" class="blk"><title>clear — function, 26 lines</title></rect>
<rect x="374.0" y="1990.0" width="74.0" height="53.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.build_book" data-kind="function" class="blk"><title>build_book — function, 106 lines</title></rect>
<rect x="381.0" y="2000.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.build.build_book.say" data-kind="function" class="blk"><title>say — function, 3 lines</title></rect>
<rect x="374.0" y="2044.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.run" data-kind="function" class="blk"><title>run — function, 35 lines</title></rect>
<rect x="456.0" y="1851.5" width="74.0" height="189.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.checks — 378 lines</title></rect>
<rect x="456.0" y="1882.0" width="74.0" height="13.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Loaded" data-kind="class" class="blk"><title>Loaded — class, 26 lines</title></rect>
<rect x="463.0" y="1883.5" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.__init__" data-kind="function" class="blk"><title>__init__ — function, 15 lines</title></rect>
<rect x="463.0" y="1892.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.larder" data-kind="function" class="blk"><title>larder — function, 2 lines</title></rect>
<rect x="463.0" y="1894.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.measure" data-kind="function" class="blk"><title>measure — function, 2 lines</title></rect>
<rect x="456.0" y="1896.0" width="74.0" height="19.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Report" data-kind="class" class="blk"><title>Report — class, 38 lines</title></rect>
<rect x="463.0" y="1897.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="463.0" y="1899.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.note" data-kind="function" class="blk"><title>note — function, 2 lines</title></rect>
<rect x="463.0" y="1901.0" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.check" data-kind="function" class="blk"><title>check — function, 8 lines</title></rect>
<rect x="463.0" y="1905.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="463.0" y="1907.5" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.total" data-kind="function" class="blk"><title>total — function, 15 lines</title></rect>
<rect x="456.0" y="1916.0" width="74.0" height="44.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.generated" data-kind="function" class="blk"><title>generated — function, 89 lines</title></rect>
<rect x="456.0" y="1961.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.custom" data-kind="function" class="blk"><title>custom — function, 26 lines</title></rect>
<rect x="456.0" y="1975.5" width="74.0" height="16.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks._run_cells" data-kind="function" class="blk"><title>_run_cells — function, 33 lines</title></rect>
<rect x="456.0" y="1994.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks._notes_drift" data-kind="function" class="blk"><title>_notes_drift — function, 7 lines</title></rect>
<rect x="456.0" y="1998.5" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.document" data-kind="function" class="blk"><title>document — function, 72 lines</title></rect>
<rect x="463.0" y="2012.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.document.heading" data-kind="function" class="blk"><title>heading — function, 2 lines</title></rect>
<rect x="456.0" y="2035.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.document_failures" data-kind="function" class="blk"><title>document_failures — function, 9 lines</title></rect>
<rect x="538.0" y="1851.5" width="74.0" height="99.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.larder — 198 lines</title></rect>
<rect x="538.0" y="1884.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder._count" data-kind="function" class="blk"><title>_count — function, 13 lines</title></rect>
<rect x="538.0" y="1891.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.redact" data-kind="function" class="blk"><title>redact — function, 32 lines</title></rect>
<rect x="538.0" y="1909.0" width="74.0" height="38.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.larder.Larder" data-kind="class" class="blk"><title>Larder — class, 77 lines</title></rect>
<rect x="545.0" y="1916.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 7 lines</title></rect>
<rect x="545.0" y="1920.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.read" data-kind="function" class="blk"><title>read — function, 3 lines</title></rect>
<rect x="545.0" y="1922.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.fail" data-kind="function" class="blk"><title>fail — function, 3 lines</title></rect>
<rect x="545.0" y="1924.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.to_dict" data-kind="function" class="blk"><title>to_dict — function, 13 lines</title></rect>
<rect x="545.0" y="1932.0" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.from_dict" data-kind="function" class="blk"><title>from_dict — function, 10 lines</title></rect>
<rect x="545.0" y="1937.5" width="60.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.line" data-kind="function" class="blk"><title>line — function, 20 lines</title></rect>
<rect x="538.0" y="1948.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.of" data-kind="function" class="blk"><title>of — function, 3 lines</title></rect>
<rect x="620.0" y="1851.5" width="74.0" height="81.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.notes — 162 lines</title></rect>
<rect x="620.0" y="1874.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.notes.body" data-kind="function" class="blk"><title>body — function, 6 lines</title></rect>
<rect x="620.0" y="1878.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.notes.stub" data-kind="function" class="blk"><title>stub — function, 3 lines</title></rect>
<rect x="620.0" y="1881.0" width="74.0" height="39.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.notes.Notes" data-kind="class" class="blk"><title>Notes — class, 78 lines</title></rect>
<rect x="627.0" y="1882.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="627.0" y="1884.5" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes._read" data-kind="function" class="blk"><title>_read — function, 7 lines</title></rect>
<rect x="627.0" y="1888.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes.document" data-kind="function" class="blk"><title>document — function, 2 lines</title></rect>
<rect x="627.0" y="1890.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes.book" data-kind="function" class="blk"><title>book — function, 2 lines</title></rect>
<rect x="627.0" y="1891.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes.path" data-kind="function" class="blk"><title>path — function, 4 lines</title></rect>
<rect x="627.0" y="1894.0" width="60.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes.drift" data-kind="function" class="blk"><title>drift — function, 27 lines</title></rect>
<rect x="627.0" y="1908.0" width="60.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.notes.Notes.scaffold" data-kind="function" class="blk"><title>scaffold — function, 24 lines</title></rect>
<rect x="620.0" y="1921.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.notes.images" data-kind="function" class="blk"><title>images — function, 18 lines</title></rect>
<rect x="620.0" y="1931.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.notes.data_uri" data-kind="function" class="blk"><title>data_uri — function, 2 lines</title></rect>
<rect x="720.0" y="1851.5" width="74.0" height="141.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code — 282 lines</title></rect>
<rect x="720.0" y="1899.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.code.MissingParser" data-kind="class" class="blk"><title>MissingParser — class, 2 lines</title></rect>
<rect x="720.0" y="1901.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._parser" data-kind="function" class="blk"><title>_parser — function, 21 lines</title></rect>
<rect x="720.0" y="1912.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._name" data-kind="function" class="blk"><title>_name — function, 10 lines</title></rect>
<rect x="720.0" y="1918.5" width="74.0" height="17.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._walk" data-kind="function" class="blk"><title>_walk — function, 34 lines</title></rect>
<rect x="720.0" y="1936.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract_file" data-kind="function" class="blk"><title>extract_file — function, 27 lines</title></rect>
<rect x="720.0" y="1951.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._count_errors" data-kind="function" class="blk"><title>_count_errors — function, 5 lines</title></rect>
<rect x="720.0" y="1954.5" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract" data-kind="function" class="blk"><title>extract — function, 75 lines</title></rect>
<rect x="28.0" y="2204.0" width="74.0" height="134.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage — 269 lines</title></rect>
<rect x="28.0" y="2236.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._template" data-kind="function" class="blk"><title>_template — function, 27 lines</title></rect>
<rect x="28.0" y="2250.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._body" data-kind="function" class="blk"><title>_body — function, 10 lines</title></rect>
<rect x="28.0" y="2256.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._shape" data-kind="function" class="blk"><title>_shape — function, 15 lines</title></rect>
<rect x="28.0" y="2265.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._graphql" data-kind="function" class="blk"><title>_graphql — function, 11 lines</title></rect>
<rect x="28.0" y="2271.5" width="74.0" height="66.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage.extract" data-kind="function" class="blk"><title>extract — function, 133 lines</title></rect>
<rect x="110.0" y="2204.0" width="74.0" height="95.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi — 191 lines</title></rect>
<rect x="110.0" y="2219.5" width="74.0" height="14.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._modelgen" data-kind="function" class="blk"><title>_modelgen — function, 28 lines</title></rect>
<rect x="110.0" y="2234.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._type_name" data-kind="function" class="blk"><title>_type_name — function, 6 lines</title></rect>
<rect x="110.0" y="2238.5" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._refs" data-kind="function" class="blk"><title>_refs — function, 43 lines</title></rect>
<rect x="110.0" y="2261.0" width="74.0" height="38.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi.extract" data-kind="function" class="blk"><title>extract — function, 76 lines</title></rect>
<rect x="192.0" y="2204.0" width="74.0" height="80.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db — 161 lines</title></rect>
<rect x="192.0" y="2223.5" width="74.0" height="41.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.from_schema_dict" data-kind="function" class="blk"><title>from_schema_dict — function, 82 lines</title></rect>
<rect x="192.0" y="2265.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._relation" data-kind="function" class="blk"><title>_relation — function, 10 lines</title></rect>
<rect x="192.0" y="2271.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._plain_type" data-kind="function" class="blk"><title>_plain_type — function, 6 lines</title></rect>
<rect x="192.0" y="2275.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._dedupe" data-kind="function" class="blk"><title>_dedupe — function, 9 lines</title></rect>
<rect x="192.0" y="2281.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.extract" data-kind="function" class="blk"><title>extract — function, 6 lines</title></rect>
<rect x="274.0" y="2204.0" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python — 37 lines</title></rect>
<rect x="274.0" y="2213.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.extract" data-kind="function" class="blk"><title>extract — function, 14 lines</title></rect>
<rect x="374.0" y="2204.0" width="74.0" height="118.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.collect — 236 lines</title></rect>
<rect x="374.0" y="2220.5" width="74.0" height="5.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Definition" data-kind="class" class="blk"><title>Definition — class, 10 lines</title></rect>
<rect x="374.0" y="2227.0" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Module" data-kind="class" class="blk"><title>Module — class, 12 lines</title></rect>
<rect x="374.0" y="2234.0" width="74.0" height="33.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._Collector" data-kind="class" class="blk"><title>_Collector — class, 67 lines</title></rect>
<rect x="381.0" y="2235.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="381.0" y="2238.5" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector._define" data-kind="function" class="blk"><title>_define — function, 17 lines</title></rect>
<rect x="381.0" y="2247.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ClassDef" data-kind="function" class="blk"><title>visit_ClassDef — function, 5 lines</title></rect>
<rect x="381.0" y="2250.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_FunctionDef" data-kind="function" class="blk"><title>visit_FunctionDef — function, 5 lines</title></rect>
<rect x="381.0" y="2256.0" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_Import" data-kind="function" class="blk"><title>visit_Import — function, 8 lines</title></rect>
<rect x="381.0" y="2260.5" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ImportFrom" data-kind="function" class="blk"><title>visit_ImportFrom — function, 14 lines</title></rect>
<rect x="374.0" y="2268.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._first_line" data-kind="function" class="blk"><title>_first_line — function, 5 lines</title></rect>
<rect x="374.0" y="2272.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._name_of" data-kind="function" class="blk"><title>_name_of — function, 15 lines</title></rect>
<rect x="374.0" y="2280.5" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._resolve_relative" data-kind="function" class="blk"><title>_resolve_relative — function, 16 lines</title></rect>
<rect x="374.0" y="2289.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.module_name" data-kind="function" class="blk"><title>module_name — function, 26 lines</title></rect>
<rect x="374.0" y="2303.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect_file" data-kind="function" class="blk"><title>collect_file — function, 21 lines</title></rect>
<rect x="374.0" y="2315.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect" data-kind="function" class="blk"><title>collect — function, 13 lines</title></rect>
<rect x="456.0" y="2204.0" width="74.0" height="92.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.resolve — 184 lines</title></rect>
<rect x="456.0" y="2218.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._id_for" data-kind="function" class="blk"><title>_id_for — function, 2 lines</title></rect>
<rect x="456.0" y="2220.0" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._resolve" data-kind="function" class="blk"><title>_resolve — function, 32 lines</title></rect>
<rect x="456.0" y="2237.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.larder_of" data-kind="function" class="blk"><title>larder_of — function, 17 lines</title></rect>
<rect x="456.0" y="2246.5" width="74.0" height="49.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.to_ir" data-kind="function" class="blk"><title>to_ir — function, 98 lines</title></rect>
<rect x="463.0" y="2276.0" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.resolve.to_ir._point_at" data-kind="function" class="blk"><title>_point_at — function, 9 lines</title></rect>
<rect x="556.0" y="2204.0" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.validate — 278 lines</title></rect>
<rect x="556.0" y="2225.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.validate.IRError" data-kind="class" class="blk"><title>IRError — class, 2 lines</title></rect>
<rect x="556.0" y="2227.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._schema" data-kind="function" class="blk"><title>_schema — function, 2 lines</title></rect>
<rect x="556.0" y="2229.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._props" data-kind="function" class="blk"><title>_props — function, 5 lines</title></rect>
<rect x="556.0" y="2232.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._fields" data-kind="function" class="blk"><title>_fields — function, 4 lines</title></rect>
<rect x="556.0" y="2235.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check" data-kind="function" class="blk"><title>check — function, 108 lines</title></rect>
<rect x="556.0" y="2297.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._check_larder" data-kind="function" class="blk"><title>_check_larder — function, 58 lines</title></rect>
<rect x="556.0" y="2327.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.validate" data-kind="function" class="blk"><title>validate — function, 6 lines</title></rect>
<rect x="556.0" y="2331.0" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check_model_matches_schema" data-kind="function" class="blk"><title>check_model_matches_schema — function, 23 lines</title></rect>
<rect x="638.0" y="2204.0" width="74.0" height="82.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.model — 165 lines</title></rect>
<rect x="638.0" y="2220.5" width="74.0" height="19.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Meta" data-kind="class" class="blk"><title>Meta — class, 38 lines</title></rect>
<rect x="645.0" y="2230.5" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Meta.to_dict" data-kind="function" class="blk"><title>to_dict — function, 18 lines</title></rect>
<rect x="638.0" y="2241.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Node" data-kind="class" class="blk"><title>Node — class, 21 lines</title></rect>
<rect x="645.0" y="2245.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 3 lines</title></rect>
<rect x="645.0" y="2247.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.to_dict" data-kind="function" class="blk"><title>to_dict — function, 8 lines</title></rect>
<rect x="638.0" y="2253.0" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Edge" data-kind="class" class="blk"><title>Edge — class, 15 lines</title></rect>
<rect x="645.0" y="2257.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Edge.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="638.0" y="2262.0" width="74.0" height="24.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Graph" data-kind="class" class="blk"><title>Graph — class, 48 lines</title></rect>
<rect x="645.0" y="2266.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.node" data-kind="function" class="blk"><title>node — function, 4 lines</title></rect>
<rect x="645.0" y="2269.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.edge" data-kind="function" class="blk"><title>edge — function, 4 lines</title></rect>
<rect x="645.0" y="2271.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.has" data-kind="function" class="blk"><title>has — function, 2 lines</title></rect>
<rect x="645.0" y="2274.0" width="60.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.to_dict" data-kind="function" class="blk"><title>to_dict — function, 16 lines</title></rect>
<rect x="645.0" y="2283.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.from_dict" data-kind="function" class="blk"><title>from_dict — function, 6 lines</title></rect>
<rect x="738.0" y="2204.0" width="74.0" height="242.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.filter — 485 lines</title></rect>
<rect x="738.0" y="2222.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter._rebuild" data-kind="function" class="blk"><title>_rebuild — function, 57 lines</title></rect>
<rect x="745.0" y="2226.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.surviving_parent" data-kind="function" class="blk"><title>surviving_parent — function, 5 lines</title></rect>
<rect x="745.0" y="2233.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.lift" data-kind="function" class="blk"><title>lift — function, 6 lines</title></rect>
<rect x="738.0" y="2251.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_kinds" data-kind="function" class="blk"><title>drop_kinds — function, 14 lines</title></rect>
<rect x="738.0" y="2259.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.only_kinds" data-kind="function" class="blk"><title>only_kinds — function, 14 lines</title></rect>
<rect x="738.0" y="2267.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_stdlib" data-kind="function" class="blk"><title>drop_stdlib — function, 13 lines</title></rect>
<rect x="738.0" y="2275.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_external" data-kind="function" class="blk"><title>drop_external — function, 3 lines</title></rect>
<rect x="738.0" y="2277.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.subtree" data-kind="function" class="blk"><title>subtree — function, 13 lines</title></rect>
<rect x="738.0" y="2285.0" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.neighbourhood" data-kind="function" class="blk"><title>neighbourhood — function, 45 lines</title></rect>
<rect x="738.0" y="2308.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.collapse_to_depth" data-kind="function" class="blk"><title>collapse_to_depth — function, 18 lines</title></rect>
<rect x="745.0" y="2313.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.collapse_to_depth.level" data-kind="function" class="blk"><title>level — function, 6 lines</title></rect>
<rect x="738.0" y="2318.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_builtins" data-kind="function" class="blk"><title>drop_builtins — function, 14 lines</title></rect>
<rect x="738.0" y="2326.5" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.overview" data-kind="function" class="blk"><title>overview — function, 35 lines</title></rect>
<rect x="738.0" y="2345.0" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.shape" data-kind="function" class="blk"><title>shape — function, 72 lines</title></rect>
<rect x="745.0" y="2369.0" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.shape.rank_of" data-kind="function" class="blk"><title>rank_of — function, 9 lines</title></rect>
<rect x="738.0" y="2382.0" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.split" data-kind="function" class="blk"><title>split — function, 24 lines</title></rect>
<rect x="738.0" y="2395.0" width="74.0" height="51.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.classify" data-kind="function" class="blk"><title>classify — function, 102 lines</title></rect>
<rect x="28.0" y="2484.5" width="74.0" height="191.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.pages — 383 lines</title></rect>
<rect x="28.0" y="2515.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.slugify" data-kind="function" class="blk"><title>slugify — function, 8 lines</title></rect>
<rect x="28.0" y="2520.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages._key" data-kind="function" class="blk"><title>_key — function, 8 lines</title></rect>
<rect x="28.0" y="2525.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages._under" data-kind="function" class="blk"><title>_under — function, 5 lines</title></rect>
<rect x="28.0" y="2528.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages._representative" data-kind="function" class="blk"><title>_representative — function, 13 lines</title></rect>
<rect x="28.0" y="2536.0" width="74.0" height="33.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.page_ir" data-kind="function" class="blk"><title>page_ir — function, 67 lines</title></rect>
<rect x="28.0" y="2570.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.layout" data-kind="function" class="blk"><title>layout — function, 20 lines</title></rect>
<rect x="28.0" y="2581.5" width="74.0" height="30.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.paginate" data-kind="function" class="blk"><title>paginate — function, 61 lines</title></rect>
<rect x="35.0" y="2590.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.pages.paginate.unique" data-kind="function" class="blk"><title>unique — function, 6 lines</title></rect>
<rect x="35.0" y="2593.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.pages.paginate.opens" data-kind="function" class="blk"><title>opens — function, 3 lines</title></rect>
<rect x="35.0" y="2595.5" width="60.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.pages.paginate.visit" data-kind="function" class="blk"><title>visit — function, 30 lines</title></rect>
<rect x="28.0" y="2613.0" width="74.0" height="38.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.paginate_tables" data-kind="function" class="blk"><title>paginate_tables — function, 76 lines</title></rect>
<rect x="28.0" y="2652.0" width="74.0" height="14.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.coverage" data-kind="function" class="blk"><title>coverage — function, 28 lines</title></rect>
<rect x="28.0" y="2667.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.pages.for_view" data-kind="function" class="blk"><title>for_view — function, 17 lines</title></rect>
<rect x="128.0" y="2484.5" width="74.0" height="111.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.extract — 222 lines</title></rect>
<rect x="128.0" y="2515.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._values_from" data-kind="function" class="blk"><title>_values_from — function, 17 lines</title></rect>
<rect x="128.0" y="2524.5" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._normalise" data-kind="function" class="blk"><title>_normalise — function, 45 lines</title></rect>
<rect x="128.0" y="2548.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._svg_files" data-kind="function" class="blk"><title>_svg_files — function, 25 lines</title></rect>
<rect x="128.0" y="2561.5" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.harvest" data-kind="function" class="blk"><title>harvest — function, 43 lines</title></rect>
<rect x="128.0" y="2584.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="128.0" y="2588.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.summarise" data-kind="function" class="blk"><title>summarise — function, 13 lines</title></rect>
<rect x="210.0" y="2484.5" width="74.0" height="105.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.tokens — 211 lines</title></rect>
<rect x="210.0" y="2512.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._top" data-kind="function" class="blk"><title>_top — function, 2 lines</title></rect>
<rect x="210.0" y="2514.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._mode" data-kind="function" class="blk"><title>_mode — function, 3 lines</title></rect>
<rect x="210.0" y="2517.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._luminance" data-kind="function" class="blk"><title>_luminance — function, 6 lines</title></rect>
<rect x="210.0" y="2521.0" width="74.0" height="50.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.derive" data-kind="function" class="blk"><title>derive — function, 101 lines</title></rect>
<rect x="217.0" y="2543.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.tokens.derive.accent" data-kind="function" class="blk"><title>accent — function, 2 lines</title></rect>
<rect x="210.0" y="2572.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.geometry" data-kind="function" class="blk"><title>geometry — function, 14 lines</title></rect>
<rect x="210.0" y="2580.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="210.0" y="2585.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.from_folder" data-kind="function" class="blk"><title>from_folder — function, 9 lines</title></rect>
<rect x="310.0" y="2484.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="410.0" y="2484.5" width="74.0" height="270.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.minisite.onepage — 540 lines</title></rect>
<rect x="410.0" y="2642.5" width="74.0" height="27.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage._ledger" data-kind="function" class="blk"><title>_ledger — function, 54 lines</title></rect>
<rect x="410.0" y="2670.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage._slots" data-kind="function" class="blk"><title>_slots — function, 15 lines</title></rect>
<rect x="410.0" y="2679.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage._fill" data-kind="function" class="blk"><title>_fill — function, 4 lines</title></rect>
<rect x="410.0" y="2682.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage._sidebar" data-kind="function" class="blk"><title>_sidebar — function, 17 lines</title></rect>
<rect x="410.0" y="2691.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage._sections" data-kind="function" class="blk"><title>_sections — function, 15 lines</title></rect>
<rect x="410.0" y="2700.0" width="74.0" height="48.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage.emit" data-kind="function" class="blk"><title>emit — function, 96 lines</title></rect>
<rect x="410.0" y="2749.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minisite.onepage.write" data-kind="function" class="blk"><title>write — function, 10 lines</title></rect>
<rect x="510.0" y="2484.5" width="74.0" height="75.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab.pg_probe — 150 lines</title></rect>
<rect x="510.0" y="2520.0" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.probe" data-kind="function" class="blk"><title>probe — function, 30 lines</title></rect>
<rect x="510.0" y="2542.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe._simplify" data-kind="function" class="blk"><title>_simplify — function, 3 lines</title></rect>
<rect x="510.0" y="2544.5" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="610.0" y="2484.5" width="74.0" height="132.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook.spec — 264 lines</title></rect>
<rect x="610.0" y="2512.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec._step" data-kind="function" class="blk"><title>_step — function, 4 lines</title></rect>
<rect x="610.0" y="2515.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.from_ir" data-kind="function" class="blk"><title>from_ir — function, 108 lines</title></rect>
<rect x="617.0" y="2522.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.notebook.spec.from_ir._order" data-kind="function" class="blk"><title>_order — function, 5 lines</title></rect>
<rect x="610.0" y="2570.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.scaffold" data-kind="function" class="blk"><title>scaffold — function, 20 lines</title></rect>
<rect x="610.0" y="2581.5" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.merge" data-kind="function" class="blk"><title>merge — function, 58 lines</title></rect>
<rect x="610.0" y="2611.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.load" data-kind="function" class="blk"><title>load — function, 2 lines</title></rect>
<rect x="610.0" y="2613.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<text x="28" y="40" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="28" y="1242" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="210" y="1242" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
<text x="28" y="1406" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="702" y="1406" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="28" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="292" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</text>
<text x="638" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="28" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="210" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
<text x="392" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="492" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="592" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="692" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="28" y="1219" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="28" y="1481" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="210" y="1481" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
<text x="28" y="1648" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
<text x="210" y="1648" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="28" y="1846" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="210" y="1846" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</text>
<text x="720" y="1846" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="28" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="374" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="556" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="738" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="28" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="128" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
<text x="310" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="410" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters.minisite</text>
<text x="510" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="610" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="28" y="1458" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="110" y="213" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="192" y="152" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
<text x="274" y="109" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">reference</text>
<text x="356" y="93" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli</text>
<text x="356" y="95" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli</text>
<text x="438" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
<text x="520" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="602" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
<text x="684" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
<text x="766" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="28" y="1271" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="110" y="1271" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="210" y="1384" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emit</text>
<text x="292" y="1316" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="374" y="1302" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">view</text>
<text x="456" y="1297" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">common</text>
<text x="538" y="1297" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">run</text>
<text x="620" y="1294" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="702" y="1282" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">check</text>
<text x="784" y="1274" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="28" y="1668" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
<text x="110" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
<text x="192" y="1568" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
<text x="274" y="1561" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="356" y="1560" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
<text x="438" y="1552" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
<text x="520" y="1504" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</text>
<text x="602" y="1454" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
<text x="702" y="1562" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="784" y="1556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="28" y="1802" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="110" y="1787" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="192" y="1725" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="292" y="1897" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
<text x="374" y="1870" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">config</text>
<text x="456" y="1844" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
<text x="538" y="1804" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">larder</text>
<text x="638" y="1824" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="720" y="1798" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="28" y="2074" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="110" y="2018" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="210" y="2046" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="292" y="2040" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
<text x="392" y="1949" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="492" y="2010" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="592" y="2067" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<text x="692" y="2178" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<rect x="28" y="2217.5" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="2217.5" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="2217.5" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="2217.5" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="2225.5" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">47 files · 10,350 lines · 1px ≈ 2.0 lines</text>
<text x="28" y="1510" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="110" y="1510" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="210" y="1624" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emit</text>
<text x="292" y="1556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="374" y="1546" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">run</text>
<text x="456" y="1541" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">view</text>
<text x="538" y="1540" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">scaffold</text>
<text x="620" y="1536" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">common</text>
<text x="702" y="1534" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="784" y="1525" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">check</text>
<text x="28" y="1680" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">contract</text>
<text x="110" y="1679" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="210" y="1822" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="292" y="1820" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
<text x="374" y="1817" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
<text x="456" y="1802" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
<text x="538" y="1800" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
<text x="620" y="1798" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minisite</text>
<text x="702" y="1744" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</text>
<text x="784" y="1736" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">markdown</text>
<text x="28" y="1920" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">paged</text>
<text x="110" y="1892" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
<text x="210" y="2175" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">config</text>
<text x="292" y="2086" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">document</text>
<text x="374" y="2071" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
<text x="456" y="2050" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
<text x="538" y="1960" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">larder</text>
<text x="620" y="1942" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notes</text>
<text x="720" y="2002" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="28" y="2348" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="110" y="2308" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="192" y="2294" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="274" y="2232" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="374" y="2331" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="456" y="2305" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="556" y="2352" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="638" y="2296" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="738" y="2456" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<text x="28" y="2685" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pages</text>
<text x="128" y="2604" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="210" y="2599" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
<text x="310" y="2508" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="410" y="2764" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">onepage</text>
<text x="510" y="2568" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="610" y="2626" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<rect x="28" y="2803.5" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="2811.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="2803.5" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="2811.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="2803.5" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="2811.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="2803.5" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="2811.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="2811.5" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">55 files · 13,083 lines · 1px ≈ 2.0 lines</text>
</svg>

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -19,12 +19,15 @@
<a href="#what">What it is</a>
<a href="#idea">The idea</a>
<a href="#quick">Five minutes</a>
<a href="#first">Your first project</a>
<a href="#models">Working with a model</a>
<div class="group">Architecture</div>
<a href="#layers">The three concerns</a>
<a href="#book">The book</a>
<a href="#ir">The IR</a>
<a href="#shape">Shape decides the drawing</a>
<a href="#pages">Pages</a>
<div class="group">Reading</div>
<a href="#extractors">Extractors</a>
@@ -37,11 +40,13 @@
<a href="#emitters">Emitters</a>
<a href="#explore">Explore</a>
<a href="#notebooks">Notebooks</a>
<a href="#notes">Notes</a>
<a href="#style">Style &amp; colour</a>
<div class="group">Reference</div>
<a href="#standalone">Standalone</a>
<a href="#runs">Run files</a>
<a href="#projects">Project files</a>
<a href="#commands">Commands</a>
<a href="#deps">Dependencies</a>
<a href="#testing">Testing</a>
@@ -73,7 +78,7 @@
docgen is the one answer. It reads the source rather than being told about it,
so a diagram cannot drift from the thing it describes, and it separates
<em>what a graph is</em> from <em>how it looks</em> so one extraction feeds
a diagram, an index, a notebook and a browsable site without being redone.
a diagram, an index, a notebook and a browsable minisite without being redone.
</p>
<div class="note">
@@ -126,7 +131,7 @@ make check BOOK=out/book/mine</code></pre>
<pre><code> larder /path/to/repo — 45 files read, 2 failed, 12 packages
book 312 nodes · 244 edges · 26 external · 8 artifacts
ok 45 file(s) read produced 45 module(s)
open out/book/mine/site/index.html</code></pre>
open out/book/mine/minisite/index.html</code></pre>
<p>
Underneath it is three commands that compose, and each still works on its own.
@@ -146,7 +151,7 @@ python3 -m docgen emit auto view.json -o out/</code></pre>
<pre><code>make ir SRC=/path/to/repo OUT=out <span class="c"># extract</span>
make explore OUT=out <span class="c"># the two-pane navigator</span>
make site OUT=out <span class="c"># a docs site with a sidebar</span>
make minisite OUT=out <span class="c"># a one-page site with a sidebar</span>
make self <span class="c"># docgen's book of soleprint, then check it</span></code></pre>
<p>
@@ -154,6 +159,145 @@ make self <span class="c"># docgen's book of soleprint
the outputs open over <code>file://</code>.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="first">Your first project</h2>
<p>
From nothing to a notebook and a minisite for a project of yours, with your own text
in the right places. docgen lives in its own folder, and the project only gets one
config file and, later, a folder of notes.
</p>
<h3>1. Put docgen somewhere and check the machine</h3>
<pre><code>cp -r docgen ~/tools/docgen
cd ~/tools/docgen
make doctor <span class="c"># what is installed; never fails</span>
make sync <span class="c"># optional: C#/TypeScript, OpenAPI, all from uv.lock</span></code></pre>
<p>
From anywhere else, docgen runs as <code>python3 -m docgen</code> with its parent
folder on the path:
</p>
<pre><code>export PYTHONPATH=~/tools <span class="c"># the folder that holds docgen/</span>
python3 -m docgen --help</code></pre>
<h3>2. Write <code>docgen.toml</code> at the project's root</h3>
<pre><code>[project]
name = "orders-service"
[extract.code]
root = "src"
ext = [".cs"]
[emit.notebook]
output = "../docs_out/orders_service_notebook"
[emit.minisite]
output = "../docs_out/orders_service_minisite"</code></pre>
<p>
Paths are relative to this file. Keep the outputs outside the source tree. See
<a href="#projects">project files</a> for every key, and add
<code>.docgen/</code> to the project's <code>.gitignore</code>.
</p>
<h3>3. Check what it will do, then build</h3>
<pre><code>cd ~/work/orders-service
python3 -m docgen run docgen.toml --list <span class="c"># resolved paths; builds nothing</span>
python3 -m docgen run docgen.toml --check <span class="c"># build, then check every book</span></code></pre>
<pre><code>docgen.toml — 1 book(s)
ok code …/.docgen/orders-service/books/code
ok orders-service (document) …/.docgen/orders-service
1 book(s) in 1 document built and checked</code></pre>
<p>
A file docgen can't use is refused before anything is built, with every problem
listed at once and what to do about each. Open
<code>../docs_out/orders_service_site/index.html</code>: each book shows what
came in and what came out, and its pages click through from the overview.
</p>
<h3>4. Add your own text</h3>
<pre><code>python3 -m docgen scaffold docgen.toml --list <span class="c"># every spot a note can go</span>
python3 -m docgen scaffold docgen.toml <span class="c"># or: write an empty stub for each</span></code></pre>
<p>
Pick a spot and write markdown into <code>notes/&lt;book&gt;/&lt;spot&gt;.md</code>,
for example <code>notes/code/overview.md</code> above the overview page. Rebuild
and it's there, in the notebook and on the minisite. Stubs you leave empty add
nothing.
</p>
<div class="note">
<b>If a note stops appearing</b>, the page it was filed under was probably
renamed, for instance a package that moved. The rebuild says so and
<code>--check</code> fails with the note's name. Rename the file to the new spot
(<code>scaffold --list</code> shows them) or delete it.
</div>
<h3>5. Rebuild whenever the code moves</h3>
<p>
<code>python3 -m docgen run docgen.toml --check</code> again. A rebuild replaces
exactly what the last one wrote and never touches your notes or anything you
added to the output folders. <code>--only code</code> rebuilds one book while
you're writing its notes.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="models">Working with a model</h2>
<p>
A model can write the config and the notes. It shouldn't write anything else,
since every other file is generated. docgen ships the instructions to give it:
</p>
<pre><code>python3 -m docgen contract &gt; docgen-contract.md</code></pre>
<p>
That file is <code>docs/models.md</code>, printed verbatim. It lists every key
docgen accepts and the extensions it reads, has rules like "never a credential"
and "don't guess page ids", and ends with a table that maps each error docgen
can produce to the fix. docgen's own suite checks it against the code, so it
can't quietly go out of date: a key the loader accepts but the doc doesn't
name, or an example in it that doesn't load, fails <code>make check</code>.
</p>
<h3>The loop</h3>
<ol>
<li><b>Give it the project and the contract.</b> A digest of the project
(<code>distill</code>), plus <code>docgen-contract.md</code> as an
attachment rather than pasted. If replies go through <code>explode</code>,
attach its contract too: explode's says how to format the reply, docgen's
says what goes in the files.</li>
<li><b>Ask for <code>docgen.toml</code> first.</b> Unpack it, then run
<code>run docgen.toml --list</code>. If it's refused, paste the errors back
verbatim. Each one is in the contract's repair table.</li>
<li><b>Build once, then list the spots.</b> Run
<code>scaffold docgen.toml --list</code> and paste the output back. Page
names depend on the real directories, and a model that guesses them writes
notes that land nowhere.</li>
<li><b>Ask for notes for those spots.</b> Unpack, rebuild with
<code>--check</code>, and paste back any note reported as filed under a page
that doesn't exist.</li>
</ol>
<div class="note warn">
<b>What a model tends to write that docgen refuses.</b> In a dry run, a model
asked for a live database URL (with its password), a <code>seed</code> reader,
and shell, YAML and SQL files as code. All three are refused with a reason, and
the URL is never echoed in the error. The contract tells the model up front, but
the refusal is what makes it safe.
</div>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="layers">The three concerns</h2>
@@ -330,7 +474,7 @@ make self <span class="c"># docgen's book of soleprint
├── notebook.ipynb <span class="c">the sequence; first and last cells are the measures</span>
├── overlay.json <span class="c">hand-written, optional, re-applied every build</span>
├── checks.py <span class="c">this book's own assertions — optional</span>
└── site/ <span class="c">the web output, both measures at the top</span></code></pre>
└── minisite/ <span class="c">the web output, both measures at the top</span></code></pre>
<pre><code>make book SRC=../station BOOK=out/book/station
make check BOOK=out/book/station</code></pre>
@@ -582,7 +726,7 @@ python3 -m docgen view ir.json --shape <span class="c"># what will this
<tr><td><code>erd</code></td><td>SVG, written directly</td><td>a schema, as cards</td></tr>
<tr><td><code>minimap</code></td><td>SVG, written directly</td><td>what is where, at a glance</td></tr>
<tr><td><code>notebook</code></td><td><code>.ipynb</code></td><td>a runnable walkthrough</td></tr>
<tr><td><code>site</code></td><td>a static docs site</td><td>reading</td></tr>
<tr><td><code>minisite</code></td><td>a static docs site</td><td>reading</td></tr>
<tr><td><code>explore</code></td><td>a two-pane navigator</td><td>finding your way</td></tr>
<tr><td><code>auto</code></td><td>whichever of the above fits</td><td>not having to choose</td></tr>
</table>
@@ -641,6 +785,69 @@ python3 -m docgen view ir.json --shape <span class="c"># what will this
dark for everything that is not a declaration — imports, constants, prose.</figcaption>
</figure>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="pages">Pages</h2>
<p>
A notebook doesn't scroll a wide image sideways: it scales it down to the cell
width until the labels can't be read. Zooming and panning is a workaround for a
picture that holds too much. So a book's diagram is split into <b>pages</b>,
each drawn small enough to fit the column it's going into.
</p>
<pre><code>overview one box per top-level group, relationships lifted
└── station one box per child of station
└── station.tools one box per child of station.tools, and so on</code></pre>
<p>
Pages follow structure, never scissors. Slicing one large image cuts boxes and
edges at the seams, and a slice means nothing on its own. A page is a subsystem:
its boxes are the scope's children, and anything outside the scope that they
touch appears as a <em>context</em> box, collapsed to the branch it sits on. A
page still says what its subsystem reaches without drawing the rest of the tree.
</p>
<h3>Fitted by measuring, not estimating</h3>
<p>
Whether a page fits depends on its labels, fonts and spacing, so an estimate from
node counts is wrong the moment the names are long. Each page is rendered and its
width read back. If a page is too wide top-to-bottom, it's drawn left-to-right,
since height is free in a notebook and width isn't. If it still doesn't fit
(a level of many peers with no smaller structure to split into), the page is
kept and flagged <em>too wide</em> in its caption and in the index. Never cut,
never dropped.
</p>
<p>
Measured on soleprint, whose module graph rendered at 14:1 as one picture: 69
pages, and at the minisite's 800px column the widest flagged page is 988px.
Schemas page differently: cards in columns capped to the width, grouped by
tables joined by foreign keys, so a schema grows downward instead of sideways.
</p>
<div class="note">
<b>Which pages exist doesn't depend on width</b>, only how each is laid out
does. A notebook column and a minisite column get the same pages, which is what lets
a <a href="#notes">note</a> filed under a page land in the same place in both.
</div>
<h3>Clicking through, on the minisite</h3>
<p>
Every page is its own HTML file with the diagram <b>inline</b>, not in an
<code>&lt;img&gt;</code>, which swallows clicks. A box standing for a subsystem
opens that subsystem's page. A module box opens its section of the book's
reference page. A context box opens its own branch. Breadcrumbs lead back up.
The size is adjustable: <b>fit</b> scales to the column, <b>1:1</b> shows the
drawing at natural size with scrollbars, the choice is remembered, and
<b>full screen</b> opens the pan-and-zoom viewer.
</p>
<p>
A notebook can't follow a link inside an image, so it gets a <b>page index</b>
instead: every page, indented by depth, each a link to its figure.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="explore">Explore</h2>
@@ -747,6 +954,56 @@ python3 -m docgen view ir.json --shape <span class="c"># what will this
<pre><code>python3 -m docgen emit notebook ir.json --scaffold overlay.json
python3 -m docgen emit notebook ir.json --overlay overlay.json -o walkthrough.ipynb</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="notes">Notes</h2>
<p>
Generation alone gives a document that's never stale and never says anything a
parser couldn't work out. Prose is the part only a person adds, and it has to
survive every rebuild, so it can't live in the output. It lives in a
<b>notes folder</b>, one markdown file per spot, named after the page or step it
belongs above:
</p>
<pre><code>notes/
├── _intro.md top of the document
├── _end.md bottom of the document
└── &lt;book&gt;/
├── _intro.md top of that book's part
├── larder.md above "what came in"
├── overview.md above the overview page
└── station.tools.md above the page for station.tools</code></pre>
<p>
To put text above a page, create the file named after it. No schema, no JSON,
no ids to look up. That's something a person or a model can do without reading
anything first. Local images in a note are embedded: as a cell attachment in the
notebook, as a data URI on the minisite.
</p>
<pre><code>make scaffold CONFIG=docgen.toml <span class="c"># an empty stub for every spot, never overwriting</span></code></pre>
<p>
<code>scaffold</code> lists the spots rather than making you guess them. A stub
is a single HTML comment and renders nothing, so scaffolding a hundred spots
changes the output not at all until one is filled in. Run it again after the
sources change and it only adds the new spots.
</p>
<div class="note warn">
<b>A note whose page is gone is reported, not dropped.</b> Renaming a package
renames its page, and the note filed under the old name has nowhere to land.
Discarding it quietly is how hand-written text goes stale without anyone
noticing, so it appears in the document ledger and fails the check level. It's
the same rule the notebook overlay follows.
</div>
<p>
The <a href="#notebooks">overlay</a> stays for what notes can't do: drop a
step, reorder, replace generated code. Notes are applied after the overlay, so a
step that moves takes its note with it.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="style">Style &amp; colour</h2>
@@ -872,10 +1129,10 @@ make doctor
<table>
<tr><th>context</th><th>checks</th><th>skipped</th></tr>
<tr><td>in the repo, synced</td><td>287</td><td>—</td></tr>
<tr><td>copied out, bare <code>python3</code></td><td>270</td><td>tree-sitter, OpenAPI, the in-place case</td></tr>
<tr><td>copied out, synced</td><td>279</td><td>OpenAPI, the in-place case</td></tr>
<tr><td>copied out, synced, <code>DOCGEN_REFERENCE</code> set</td><td>286</td>
<tr><td>in the repo, synced</td><td>350</td><td>—</td></tr>
<tr><td>copied out, bare <code>python3</code></td><td>333</td><td>tree-sitter, OpenAPI, the in-place case</td></tr>
<tr><td>copied out, synced</td><td>342</td><td>OpenAPI, the in-place case</td></tr>
<tr><td>copied out, synced, <code>DOCGEN_REFERENCE</code> set</td><td>349</td>
<td>the in-place case — correctly, there is no repo above</td></tr>
</table>
@@ -957,7 +1214,7 @@ python3 -m docgen run docgen.toml --list <span class="c"># resolved paths
<div class="note">
<b>A rebuild replaces the previous build, and nothing else.</b> Before writing,
a book removes exactly what a build writes — <code>book.json</code>,
<code>notebook.ipynb</code>, <code>steps/</code>, <code>site/</code>,
<code>notebook.ipynb</code>, <code>steps/</code>, <code>minisite/</code>,
<code>explore/</code> — and only from a directory that already holds a
<code>book.json</code>. Without it a graph that is now <code>graph.md</code> kept
its old <code>graph.svg</code>, unlisted in the ledger and still on the page.
@@ -973,6 +1230,80 @@ python3 -m docgen run docgen.toml --list <span class="c"># resolved paths
because those are data that tools write.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="projects">Project files</h2>
<p>
A run file lists independent books. A <b>project file</b> describes one
<em>document</em> built from several sources: an API contract and the code
behind it, a schema and the service that uses it. It's the shape a model wrote
unprompted in a dry run, so docgen reads it as written:
</p>
<pre><code><span class="c"># &lt;project&gt;/docgen.toml</span>
[project]
name = "orders-api"
title = "Orders API"
notes = "notes" <span class="c"># default</span>
build = ".docgen" <span class="c"># book artifacts, default; gitignore it</span>
[extract.openapi]
spec = "doc/contracts/openapi.yaml"
[extract.code]
root = "src"
ext = [".cs"]
[ops]
drop_stdlib = true
drop_builtins = true
[emit.notebook]
output = "../docs_out/orders_api_notebook"
theme = "lucid"
[emit.minisite]
output = "../docs_out/orders_api_minisite"
width = 800</code></pre>
<p>
Each <code>[extract.*]</code> table is a <b>book</b>, and a book is a part of
every output rather than an output of its own. Each <code>[emit.*]</code> table
is an output made of all of them: one notebook and one minisite, each with a part
per book. Several sources of one kind use <code>[[extract.code]]</code> with a
<code>name</code> each. <code>[ops]</code> applies to every book's view before
paging.
</p>
<p>
Every part still opens with what came in and closes with what came out, and the
document opens with every larder and closes with every reconciliation. A book
that can't be built doesn't cost the others: the outputs are assembled from
what was built, and both say <em>this document is incomplete</em> and name what's
missing.
</p>
<h3>Refused, with what to do instead</h3>
<table>
<tr><th>written</th><th>why not</th></tr>
<tr><td><code>[extract.db] url = …</code></td>
<td>docgen reads a schema dump and never connects, and a URL in a committed
file is a credential in a committed file. Write the schema with
<code>modelgen from-db</code>, then <code>schema = …</code>. The URL is never
repeated in the error.</td></tr>
<tr><td><code>ext = [".sh", ".yaml", ".sql"]</code></td>
<td>No grammar for those yet. The error names the extensions that are read.</td></tr>
<tr><td><code>[extract.seed]</code></td>
<td>No such reader. The error names the ones that exist.</td></tr>
<tr><td><code>[project]</code> and <code>[[book]]</code> in one file</td>
<td>Which one it meant is exactly what nobody should have to guess.</td></tr>
</table>
<p>
<code>docgen.project.example.toml</code> ships beside the run file example and
builds docgen's own fixtures into one document, with a sample note.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="commands">Commands</h2>
@@ -981,7 +1312,9 @@ python3 -m docgen run docgen.toml --list <span class="c"># resolved paths
<tr><th>target</th><th>does</th></tr>
<tr><td><code>make sync</code></td><td><code>.venv</code> with every optional group, from <code>uv.lock</code></td></tr>
<tr><td><code>make book SRC=…</code></td><td><b>one whole operation</b>, measured at both ends</td></tr>
<tr><td><code>make run CONFIG=…</code></td><td>every book a <a href="#runs">run file</a> lists</td></tr>
<tr><td><code>make run CONFIG=…</code></td><td>every book a <a href="#runs">run file</a> lists, or a <a href="#projects">project</a>'s document</td></tr>
<tr><td><code>make scaffold CONFIG=…</code></td><td>an empty <a href="#notes">note</a> for every page and step</td></tr>
<tr><td><code>python3 -m docgen contract</code></td><td>the <a href="#models">instructions for a model</a> that writes the config and notes</td></tr>
<tr><td><code>make check</code></td><td>docgen's own suite, offline, nothing installed</td></tr>
<tr><td><code>make check BOOK=…</code></td><td>one book's own level — generated and custom</td></tr>
<tr><td><code>make doctor</code></td><td>what this machine has and what it is missing</td></tr>
@@ -993,7 +1326,7 @@ python3 -m docgen run docgen.toml --list <span class="c"># resolved paths
<tr><td><code>make index</code></td><td>markdown index and sidebar JSON</td></tr>
<tr><td><code>make minimap</code></td><td>what is where, read from the colours</td></tr>
<tr><td><code>make explore</code></td><td>the two-pane navigator</td></tr>
<tr><td><code>make site</code></td><td>a self-contained docs site</td></tr>
<tr><td><code>make minisite</code></td><td>a self-contained one-page site</td></tr>
<tr><td><code>make self</code></td><td>docgen's book of soleprint, then check it</td></tr>
</table>
@@ -1028,6 +1361,10 @@ python3 -m docgen run docgen.toml --list <span class="c"># resolved paths
python3 -m docgen run docgen.toml <span class="c"># every book a run file lists</span>
python3 -m docgen check <span class="c"># docgen's own suite</span>
python3 -m docgen check out/book/slug <span class="c"># that book's level</span>
python3 -m docgen check .docgen/&lt;project&gt; <span class="c"># a project document's level</span>
python3 -m docgen scaffold docgen.toml <span class="c"># stub notes for every spot</span>
python3 -m docgen scaffold docgen.toml --list <span class="c"># the spots, written nowhere</span>
python3 -m docgen contract <span class="c"># the instructions for a model</span>
python3 -m docgen extract python --root SRC -o ir.json
python3 -m docgen extract code --root SRC -o ir.json
@@ -1044,7 +1381,7 @@ python3 -m docgen emit dot view.json -o graph.svg --theme lucid
python3 -m docgen emit erd ir.json -o schema.svg
python3 -m docgen emit minimap ir.json -o map.svg --scale 0.5
python3 -m docgen emit notebook ir.json -o book.ipynb --overlay overlay.json
python3 -m docgen emit site view.json -o site/
python3 -m docgen emit minisite view.json -o minisite/
python3 -m docgen emit explore ir.json -o explore/</code></pre>
<h3>Where the command line lives</h3>
@@ -1070,7 +1407,7 @@ python3 -m docgen emit explore ir.json -o explore/</code></pre>
A command reports an expected failure by raising, and <code>cli.main</code>
turns it into the only exit path: one <code>Error:</code> line on stderr, exit 1,
never a traceback. Doing that split also surfaced a third copy of "which drawing
does this graph want" — in the <code>auto</code> command, the <code>site</code>
does this graph want" — in the <code>auto</code> command, the <code>minisite</code>
command and the book — which is now <code>emitters.auto.draw()</code>, returning
content and leaving the file name to the caller.
</p>
@@ -1136,14 +1473,14 @@ make lock <span class="c"># after editing pyproject
<tr><th>command</th><th>asks about</th><th>fails?</th></tr>
<tr><td><code>make doctor</code></td><td>the machine — what is installed</td>
<td>never; it reports</td></tr>
<tr><td><code>make check</code></td><td>docgen — 287 checks</td>
<tr><td><code>make check</code></td><td>docgen — 350 checks</td>
<td>exit 1</td></tr>
<tr><td><code>make check BOOK=&lt;dir&gt;</code></td><td>that one book</td>
<tr><td><code>make check BOOK=&lt;dir&gt;</code></td><td>that one book, or a project's document</td>
<td>exit 1</td></tr>
</table>
<p>
All 287 after <code>make sync</code>, 272 with nothing optional installed — the
All 350 after <code>make sync</code>, 335 with nothing optional installed — the
suite skips rather than fails when tree-sitter, lxml or the OpenAPI reader is
absent. See <a href="#standalone">standalone</a> for the counts outside the repo.
Among them, two that keep this page's structure true: nothing outside

View File

@@ -0,0 +1,261 @@
# docgen — instructions for a model
You are helping document a software project with **docgen**. docgen reads source
artifacts (a code tree, a database schema dump, an OpenAPI document, a HAR recording)
and generates a notebook and a website from them. You do not generate those outputs.
You write the two kinds of file a person would otherwise write by hand:
1. **`docgen.toml`**: which sources to read and which outputs to build.
2. **Notes**: short markdown files that docgen places above a specific page or step.
Everything else is generated, and anything you write into a generated file is
overwritten on the next build.
If your replies are unpacked by `explode`, its contract decides the reply format. This
file decides only what goes in the files.
---
## Rules
Follow all of these. A file that breaks one is refused when it is loaded, and the error
is pasted back to you.
1. **Use only the keys listed below, spelled exactly as listed.** Unknown keys are
refused, never ignored. Do not invent keys, readers or outputs.
2. **Never put a database URL, password, token or any credential in any file.** docgen
does not connect to databases. It reads a schema file written by another tool.
3. **Every path is relative to the `docgen.toml` file itself.**
4. **Outputs must be outside every source tree** (`output = "../docs_out/..."` is the
usual choice). A build written inside a tree it reads is refused.
5. **Only list file extensions docgen can read** (see `ext` below). If the project's
interesting files are shell scripts, YAML or SQL, leave them out and say so in a
note. docgen does not read them yet.
6. **Do not guess page ids for notes.** Write a note only for a spot you were given,
either by the output of `docgen scaffold --list` or by the table in "Notes" below. A
note for a spot that does not exist fails the build's checks.
7. **Notes say what the code cannot.** Why something exists, what it replaced, how it is
meant to be used, what to watch out for. Do not restate what the diagram already shows
(names, counts, which module imports which).
8. If something you need to write a correct file is missing (the project's directory
layout, where the OpenAPI file is), ask for it instead of guessing.
---
## `docgen.toml` for one project
One file at the project root. It describes one **document** built from several sources.
Each `[extract.*]` table is a **book**, one part of the document. Each `[emit.*]` table
is an output that contains every book.
```toml
[project]
name = "orders-service"
title = "Orders Service"
[extract.openapi]
spec = "doc/contracts/openapi.yaml"
[extract.code]
root = "src"
ext = [".cs"]
[ops]
drop_stdlib = true
drop_builtins = true
[emit.notebook]
output = "../docs_out/orders_service_notebook"
theme = "lucid"
[emit.minisite]
output = "../docs_out/orders_service_minisite"
```
### `[project]`
| key | required | value |
|---|---|---|
| `name` | yes | letters, digits, `.`, `_`, `-`; starts with a letter or digit |
| `title` | no | any text; defaults to `name` |
| `notes` | no | folder of notes; default `"notes"` |
| `build` | no | where intermediate files go; default `".docgen"` (should be gitignored) |
| `reference` | no | path to the spr repository; needed only to read OpenAPI |
### `[extract.<reader>]`: the sources
| reader | required key | optional keys | reads |
|---|---|---|---|
| `python` | `root` (a directory) | `name`, `exclude`, `overlay` | Python, with imports and inheritance |
| `code` | `root` (a directory) | `name`, `ext`, `exclude`, `overlay` | C#, TypeScript, Python structure (no edges) |
| `db` | `schema` (a file) | `name`, `overlay` | a schema JSON dump, as `modelgen from-db` writes |
| `openapi` | `spec` (a file) | `name`, `overlay` | an OpenAPI document |
| `usage` | `har` (a file) | `name`, `overlay` | a HAR recording of real API traffic |
- `ext`: a list chosen from `".cs"`, `".ts"`, `".tsx"`, `".mts"`, `".py"`. Nothing else.
- `exclude`: a list of directory names to skip, e.g. `["tests", "migrations"]`.
- `name`: the book's name. It defaults to the reader's name. Use `[[extract.<reader>]]`
(double brackets) with a different `name` in each table to read two trees of the same
kind:
```toml
[project]
name = "storefront"
[[extract.code]]
name = "backend"
root = "src"
ext = [".cs"]
[[extract.code]]
name = "frontend"
root = "web/src"
ext = [".ts", ".tsx"]
[emit.minisite]
output = "../docs_out/storefront_minisite"
```
### `[ops]`: applied to every book before drawing
| key | value |
|---|---|
| `drop_stdlib` | `true` removes standard-library imports |
| `drop_builtins` | `true` removes built-in names |
| `drop_external` | `true` removes every name the source does not define |
| `only` | a list of kinds to keep |
| `drop` | a list of kinds to remove |
Kinds: `module`, `class`, `interface`, `function`, `external`, `table`, `column`,
`endpoint`, `operation`.
### `[emit.<output>]`: the outputs
The outputs are `notebook` and `minisite`, nothing else. `site` is the old name of
`minisite` and is refused.
| key | required | value |
|---|---|---|
| `output` | yes | a directory. A notebook output may also be a path ending in `.ipynb` |
| `theme` | no | `"dark"` (default) or `"lucid"` (light, for print) |
| `style` | no | `"lucid"` (default) |
| `width` | no | pixels a diagram page must fit; default 960 for a notebook, 800 for a minisite |
---
## `docgen.toml` for many independent books
A different shape, for a list of unrelated books with one output folder each. Use it
only when asked. A file may not contain both `[project]` and `[[book]]`.
```toml
[defaults]
out = "../docs_out"
exclude = ["tests"]
[[book]]
name = "billing"
root = "src"
[[book]]
name = "schema"
schema = "schema/schema.json"
```
| where | keys |
|---|---|
| top level | `reference`, `defaults`, `book` |
| `[defaults]` | `out`, `style`, `theme`, `exclude`, `reader`, `notes` |
| `[[book]]` | `name`, exactly one of `root` / `schema` / `openapi` / `har`, and optionally `reader` (`"python"` or `"code"`, only with `root`), `out`, `style`, `theme`, `exclude`, `overlay`, `notes` |
A book's own value replaces the default for that key. Values are never merged.
---
## Notes
A note is a markdown file. docgen puts its content directly above the spot it is named
after, in both the notebook and the minisite. Its path is:
```
<notes>/_intro.md top of the whole document
<notes>/_end.md end of the whole document
<notes>/<book>/_intro.md top of that book's part
<notes>/<book>/<spot>.md above that spot in the book
```
`<book>` is the book's `name` (the reader's name if none was given).
### Spots every book has
| spot | appears above |
|---|---|
| `larder` | "what came in": what was read |
| `pages` | the index of diagram pages |
| `overview` | the first diagram page |
| `book` | "what came out": the closing summary |
| `step-ir`, `step-view`, `step-pages`, `step-graph`, `step-index`, `step-sidebar`, `step-minimap`, `step-explore` | each intermediate step |
### Spots that depend on the source
- **Pages of a code tree**: named by a package's or directory's dotted path from the
root, for example `billing.invoices`. Not every directory gets a page: a level that
holds a single group is merged into it, and the top page is always `overview`.
- **Pages of a schema**: `overview`, then `tables-01`, `tables-02`, …
- **An API** (`openapi`, `usage`): `setup`, `client`, `shapes`, and one spot per call:
the method and path with every character outside letters, digits, `.`, `_` and `-`
replaced by `-`. So `GET /orders/{orderId}/lines` becomes `GET-orders-orderId-lines`.
Page ids for a code tree depend on the actual directories. **Do not derive them yourself
unless you have the full directory listing.** Ask for the output of:
```
python3 -m docgen scaffold docgen.toml --list
```
It prints every spot that exists, one per line.
### Writing a note
- Plain markdown: headings, paragraphs, lists, **bold**, *italic*, `code`, fenced code
blocks, links, tables, quotes.
- No raw HTML. It is shown as text.
- Images: a path relative to the note file, e.g. `![flow](../img/flow.svg)`. They are
embedded.
- Keep it short: a few paragraphs. Start with `##` headings, because the page already
has its own title.
- A note that contains only the stub comment docgen writes renders nothing. Write below
the comment, or replace the file's contents.
Example: `notes/code/billing.invoices.md`:
```markdown
## Why invoices are immutable
An invoice is never edited after it is issued. Corrections are a new credit note that
references it. This is a legal requirement, not a design preference, so do not add
update endpoints here.
Start reading at `InvoiceIssuer`. It is the only place an invoice is created.
```
---
## When the build refuses your file
The person running docgen pastes the errors back. Each one says what to change:
| error contains | fix |
|---|---|
| `has unknown key 'x'` | remove the key or use a listed one; check the spelling |
| `docgen reads a schema dump and never connects` | remove `url`; use `schema = "path/to/schema.json"` |
| `no grammar for .sh` | remove those extensions from `ext` |
| `no reader named 'x'` | use one of `python`, `code`, `db`, `openapi`, `usage` |
| `no output named 'x'` | use `notebook` or `minisite` |
| `is now [emit.…] — rename the table` | rename `[emit.site]` to `[emit.minisite]`; nothing else changes |
| `is inside the tree it reads` | move `output` or `build` outside the source directory |
| `needs a name when there are several` | give each `[[extract.x]]` table a different `name` |
| `must name exactly one source` | a `[[book]]` needs exactly one of `root`, `schema`, `openapi`, `har` |
| `is not a directory` / `is not a file` | the path is wrong; ask for the real layout |
| `note … belongs above 'x', which book 'y' no longer has` | that spot does not exist: rename the note to a listed spot, or delete it |
| `both [project] and [[book]]` | use one shape per file |

View File

@@ -11,7 +11,7 @@ because that *is* the right artifact for a flat list of peers.
## One function, three callers
This branch — erd, or index, or dot through Graphviz — used to be written out
three times: in the `auto` command, in the `site` command choosing its picture,
three times: in the `auto` command, in the `minisite` command choosing its picture,
and in the book choosing its graph step. Three copies of "which drawing does this
graph want" is three chances for them to disagree about the answer, so it lives
here and returns content rather than writing it. Where the file goes, and what
@@ -37,7 +37,7 @@ def draw(ir: dict, style, *, force: str | None = None) -> dict:
"why": why this emitter, or why nothing was drawn}
Never raises for a missing Graphviz. A layered graph on a machine without
`dot` is a normal state — the book records the step as skipped and the site
`dot` is a normal state — the book records the step as skipped and the minisite
goes text-only — so it is reported in `why` with `content` None.
"""
verdict = classify(ir)

View File

@@ -59,7 +59,7 @@ def _style_words(rule: dict, *, filled: bool = True) -> str:
return ",".join(words)
def _node_attrs(node, rule: dict, style) -> dict:
def _node_attrs(node, rule: dict, style, links: dict | None = None) -> dict:
a = {
"label": node.get("label") or node["id"],
"shape": rule.get("shape", "box"),
@@ -76,7 +76,16 @@ def _node_attrs(node, rule: dict, style) -> dict:
"class": node["kind"],
}
attrs = node.get("attrs") or {}
if attrs.get("file"):
if links and node["id"] in links:
# A link the caller supplied wins over the source anchor: on a paginated
# minisite a box is a way into its own page, which is worth more than the
# file it came from.
a["href"] = links[node["id"]]
a["tooltip"] = attrs.get("doc") or f"open {node['id']}"
elif attrs.get("file") and links is None:
# Only when the output is not managing links itself. A relative source
# path is a dead link on a web page, so an output that passes `links` —
# even an empty one — owns every href in the drawing.
line = attrs.get("line")
a["href"] = f"{attrs['file']}#L{line}" if line else attrs["file"]
a["tooltip"] = attrs.get("doc") or node["id"]
@@ -86,8 +95,14 @@ def _node_attrs(node, rule: dict, style) -> dict:
def emit(ir: dict, style, *, max_depth: int | None = None,
rankdir: str | None = None) -> str:
"""IR document (a dict) + Style -> DOT text."""
rankdir: str | None = None, links: dict | None = None) -> str:
"""IR document (a dict) + Style -> DOT text.
`links` maps node ids to URLs and becomes each box's `href` in the SVG.
Passed in rather than read from the IR, because where a box leads is a fact
about the output — a minisite page, a notebook anchor — and the IR is the same
for every output. Same rule that keeps colour out of it.
"""
nodes = {n["id"]: n for n in ir["nodes"]}
children: dict[str | None, list[str]] = {}
for n in ir["nodes"]:
@@ -119,7 +134,7 @@ def emit(ir: dict, style, *, max_depth: int | None = None,
pad = " " * (depth + 1)
if not kids or too_deep:
out.append(f"{pad}{_q(node_id)}{_attrs(_node_attrs(node, style.node(node['kind']), style))}")
out.append(f"{pad}{_q(node_id)}{_attrs(_node_attrs(node, style.node(node['kind']), style, links))}")
return
rule = style.group(node["kind"])
@@ -138,6 +153,8 @@ def emit(ir: dict, style, *, max_depth: int | None = None,
out.append(f'{pad} labeljust=l')
out.append(f'{pad} id="{_esc(node_id)}"')
out.append(f'{pad} class="{node["kind"]}"')
if links and node_id in links:
out.append(f'{pad} href="{_esc(links[node_id])}"')
for kid in kids:
write(kid, depth + 1)
out.append(f"{pad}}}")

View File

@@ -84,7 +84,19 @@ def _card_height(table: dict, columns: bool = True) -> int:
return header + (len(table["fields"]) * FIELD_H if columns else 0)
def layout(tables: list[dict], edges: list[dict], columns: bool = True) -> dict[str, tuple[int, int]]:
def max_columns(width_px: int) -> int:
"""How many card columns fit a width budget given in CSS pixels.
The SVG is written in points (`width="Npt"`), and a browser draws a point as
4/3 of a pixel — so the budget is converted before dividing, or a page sized
for 960px renders at 1280px and the notebook shrinks it after all.
"""
usable = width_px * 0.75 - 2 * PAD + COL_GAP
return max(1, int(usable // (CARD_W + COL_GAP)))
def layout(tables: list[dict], edges: list[dict], columns: bool = True,
max_cols: int | None = None) -> dict[str, tuple[int, int]]:
"""Place cards in √n columns, referenced tables first.
Sorting by "is the target of a foreign key" puts the tables everything
@@ -95,6 +107,10 @@ def layout(tables: list[dict], edges: list[dict], columns: bool = True) -> dict[
ordered = sorted(tables, key=lambda t: (t["id"] not in referenced, t["id"]))
cols = max(2, int((len(ordered) * 1.2) ** 0.5 + 0.999))
if max_cols is not None:
# Capped, not chosen: the page grows downward instead, which a notebook
# scrolls and does not shrink.
cols = max(1, min(cols, max_cols))
col_w = CARD_W + COL_GAP
cursor = [PAD] * cols
@@ -111,7 +127,7 @@ def _field_y(table: dict, index: int, top: int) -> float:
return top + header + (max(index, 0) + 0.5) * FIELD_H
def emit(ir: dict, style, *, columns: bool = True) -> str:
def emit(ir: dict, style, *, columns: bool = True, width: int | None = None) -> str:
"""IR (a db document) + Style -> SVG text.
`columns=False` draws the header of every card and none of its contents —
@@ -127,7 +143,7 @@ def emit(ir: dict, style, *, columns: bool = True) -> str:
)
edges = [e for e in ir["edges"] if e["kind"] in ("foreign_key", "references")]
by_id = {t["id"]: t for t in tables}
pos = layout(tables, edges, columns)
pos = layout(tables, edges, columns, max_cols=max_columns(width) if width else None)
s = style.slot
width = max(x for x, _ in pos.values()) + CARD_W + PAD

View File

@@ -0,0 +1,145 @@
"""
Markdown -> HTML, for notes on the minisite. Stdlib only, and deliberately small.
A notebook renders markdown itself, so notes go into it as written. The minisite has
no renderer, and adding a dependency to turn a few paragraphs into HTML would
break the rule that the structural path is the stdlib. So this covers what notes
are actually written with:
# headings paragraphs - bullet / 1. numbered lists
**bold** *italic* `code` ```fenced code```
[links](url) ![images](src) | pipe | tables |
> quotes ---
Anything it does not recognise is **escaped, not passed through** — a note is
text, and raw HTML in it is shown rather than executed.
"""
import re
from html import escape
def _url(raw: str) -> str:
"""An attribute-safe URL. The text around it is already escaped without
quotes, so a `"` here would close the attribute; and a `javascript:` link is
refused outright — a note is text, not a place to run code."""
if re.match(r"^\s*(javascript|vbscript|data):", raw, re.I):
return "#"
return raw.replace('"', "&quot;")
INLINE = [
(re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)"), lambda m, img: img(m.group(1), m.group(2))),
(re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)"),
lambda m, img: f'<a href="{_url(m.group(2))}">{m.group(1)}</a>'),
(re.compile(r"\*\*(.+?)\*\*"), lambda m, img: f"<strong>{m.group(1)}</strong>"),
(re.compile(r"(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])"), lambda m, img: f"<em>{m.group(1)}</em>"),
]
def _inline(text: str, image) -> str:
# Code spans first and protected, so nothing inside them is formatted.
spans: list[str] = []
def keep(m):
spans.append(f"<code>{escape(m.group(1))}</code>")
return f"\x00{len(spans) - 1}\x00"
text = re.sub(r"`([^`]+)`", keep, text)
text = escape(text, quote=False)
for pattern, render in INLINE:
text = pattern.sub(lambda m: render(m, image), text)
return re.sub(r"\x00(\d+)\x00", lambda m: spans[int(m.group(1))], text)
def to_html(text: str, *, image=None) -> str:
"""Render a note. `image(alt, src) -> html` lets the caller embed local images."""
image = image or (lambda alt, src: f'<img src="{_url(src)}" alt="{alt.replace(chr(34), "&quot;")}">')
lines = text.replace("\r\n", "\n").split("\n")
out: list[str] = []
i = 0
def para(buf):
if buf:
out.append(f"<p>{_inline(' '.join(s.strip() for s in buf), image)}</p>")
buf: list[str] = []
while i < len(lines):
line = lines[i]
stripped = line.strip()
if stripped.startswith("```"):
para(buf); buf = []
code = []
i += 1
while i < len(lines) and not lines[i].strip().startswith("```"):
code.append(lines[i])
i += 1
out.append(f"<pre><code>{escape(chr(10).join(code))}</code></pre>")
i += 1
continue
if not stripped:
para(buf); buf = []
i += 1
continue
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if m:
para(buf); buf = []
level = len(m.group(1))
out.append(f"<h{level}>{_inline(m.group(2), image)}</h{level}>")
i += 1
continue
if re.match(r"^(-{3,}|\*{3,})$", stripped):
para(buf); buf = []
out.append("<hr>")
i += 1
continue
if stripped.startswith(">"):
para(buf); buf = []
quote = []
while i < len(lines) and lines[i].strip().startswith(">"):
quote.append(lines[i].strip()[1:].strip())
i += 1
out.append(f"<blockquote>{to_html(chr(10).join(quote), image=image)}</blockquote>")
continue
if re.match(r"^([-*+]|\d+[.)])\s+", stripped):
para(buf); buf = []
ordered = bool(re.match(r"^\d", stripped))
items = []
while i < len(lines) and re.match(r"^\s*([-*+]|\d+[.)])\s+", lines[i]):
items.append(re.sub(r"^\s*([-*+]|\d+[.)])\s+", "", lines[i]))
i += 1
tag = "ol" if ordered else "ul"
out.append(f"<{tag}>" + "".join(f"<li>{_inline(it, image)}</li>" for it in items)
+ f"</{tag}>")
continue
if stripped.startswith("|") and i + 1 < len(lines) \
and re.match(r"^\|?\s*:?-{3,}", lines[i + 1].strip()):
para(buf); buf = []
def cells(row):
return [c.strip() for c in row.strip().strip("|").split("|")]
head = cells(lines[i])
i += 2
rows = []
while i < len(lines) and lines[i].strip().startswith("|"):
rows.append(cells(lines[i]))
i += 1
html = "<table><tr>" + "".join(f"<th>{_inline(h, image)}</th>" for h in head) + "</tr>"
for r in rows:
html += "<tr>" + "".join(f"<td>{_inline(c, image)}</td>" for c in r) + "</tr>"
out.append(html + "</table>")
continue
buf.append(line)
i += 1
para(buf)
return "\n".join(out)

View File

@@ -0,0 +1,269 @@
"""
The minisite: a document's index of books, and every book as pages you click through.
minisite/
├── index.html every book, with what came in and what came out
├── minisite.css viewer.html
└── <book>/
├── overview.html the book's top page
├── <page>.html one subsystem each; its boxes open the next level
├── <page>.svg the same drawing, for the full-screen viewer
└── reference.html every module with its docstring — where a leaf opens
## Inline, so the boxes are links
The single-page reference (`onepage.py`) put its diagram in an `<img>`, and an image swallows every
click. Here the SVG is inlined, so a box that stands for a subsystem opens that
subsystem's page, a box for a module opens its section of the reference page,
and a context box — something outside this subsystem — opens its own branch.
Breadcrumbs lead back up.
## Adjustable
A page is drawn to fit the content column, so it reads without zooming. The size
is still the reader's: `fit` scales to the column, `1:1` shows the drawing at its
natural size with scrollbars, and the choice is remembered. `full screen` opens
the pan-and-zoom viewer for anything still too dense.
This writes HTML and nothing else. What goes on each page, the links and the
notes are prepared by `book/document.py` and handed in as data — an emitter has
never heard of a book's extractor, and this one has not either.
"""
from html import escape
from pathlib import Path
from ..markdown import to_html
from ..paged import inline
from .onepage import CONTENT_W, CSS, SIDEBAR_W, VIEWER, _fill, _ledger, _slots
from .onepage import emit as reference_page
CONTENT_WIDTH = CONTENT_W
SIZE_SCRIPT = """<script>
// fit | 1:1 — a choice that sticks, because a reader who wants 1:1 on one page
// wants it on the next. No dependency, no build step.
(function () {
var key = 'docgen.size';
function set(mode) {
document.querySelectorAll('.fig').forEach(function (f) {
f.classList.toggle('actual', mode === 'actual');
});
document.querySelectorAll('.figbar button[data-size]').forEach(function (b) {
b.classList.toggle('on', b.dataset.size === mode);
});
try { localStorage.setItem(key, mode); } catch (e) {}
}
document.querySelectorAll('.figbar button[data-size]').forEach(function (b) {
b.addEventListener('click', function () { set(b.dataset.size); });
});
var saved = 'fit';
try { saved = localStorage.getItem(key) || 'fit'; } catch (e) {}
set(saved);
})();
</script>"""
def _note(note, cls="note") -> str:
if not note:
return ""
text, uris = note
return f'<div class="{cls}">' + to_html(
text, image=lambda alt, src: f'<img src="{escape(uris.get(src, src))}" alt="{escape(alt)}">'
) + "</div>"
def _missing(failed: dict | None) -> str:
"""Books that were meant to be here and are not. Above the ones that are,
because a reader who does not see this has no way to know the minisite is short."""
if not failed:
return ""
items = "".join(f"<li><code>{escape(name)}</code> — {escape(why)}</li>"
for name, why in sorted(failed.items()))
return ('<div class="gap"><b>This document is incomplete.</b> '
f"{len(failed)} book(s) could not be built, so they are not below.<ul>{items}</ul></div>")
def _tree(pages: list[dict], current: str | None) -> str:
by_parent: dict = {}
for p in pages:
by_parent.setdefault(p["parent"], []).append(p)
root = pages[0]["id"] if pages else None
def walk(page, depth):
active = ' class="active"' if page["slug"] == current else ""
link = f'<a href="{escape(page["slug"])}.html"{active}>{escape(page["title"])}</a>'
kids = by_parent.get(page["id"], [])
if not kids:
return f"<li>{link}</li>"
return f"<li>{link}<ul>" + "".join(walk(k, depth + 1) for k in kids) + "</ul></li>"
return "<ul>" + "".join(walk(p, 0) for p in pages if p["id"] == root) + "</ul>"
def _page(title: str, book: dict, page: dict, svg: str | None, pages: list[dict],
note, doc_title: str) -> str:
b = book["book"]
by_id = {p["id"]: p for p in pages}
trail, cur = [], page
while cur is not None:
trail.append(cur)
cur = by_id.get(cur["parent"]) if cur.get("parent") else None
crumbs = [f'<a href="../index.html">{escape(doc_title)}</a>']
for p in reversed(trail):
if p is page:
crumbs.append(escape(p["title"]))
else:
crumbs.append(f'<a href="{escape(p["slug"])}.html">{escape(p["title"])}</a>')
from ...book.document import caption # noqa: F401 - kept out of module import time
cap = caption(page)
warn = " warn" if page.get("too_wide") else ""
if svg is not None:
figure = (
'<div class="figbar"><button data-size="fit">fit</button>'
'<button data-size="actual">1:1</button>'
f'<a href="../viewer.html?src={escape(b.slug)}/{escape(page["slug"])}.svg">full screen</a>'
"</div>"
f'<div class="fig">{inline(svg)}</div>'
)
else:
figure = '<p class="caption warn">Not drawn — Graphviz is not installed here.</p>'
children = ""
if page.get("children"):
items = "".join(
f'<li><a href="{escape(by_id[c]["slug"])}.html">{escape(by_id[c]["title"])}</a></li>'
for c in page["children"] if c in by_id
)
children = f"<h3>Opens into</h3><ul class=\"children\">{items}</ul>"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(page["title"])} — {escape(b.slug)} — {escape(doc_title)}</title>
<link rel="stylesheet" href="../minisite.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-header"><b>{escape(b.slug)}</b><small>{escape(doc_title)}</small></div>
{_tree(pages, page["slug"])}
<ul>
<li><a href="reference.html">reference</a></li>
<li><a href="explore/explore.html">explore</a></li>
<li><a href="../index.html">all books</a></li>
</ul>
</nav>
<main class="content">
<div class="crumbs">{" › ".join(crumbs)}</div>
<h1>{escape(page["title"])}</h1>
{_note(note)}
{figure}
<p class="caption{warn}">{escape(cap)}</p>
{children}
</main>
</div>
{SIZE_SCRIPT}
</body>
</html>
"""
def write(out_dir, style, *, title: str, books: list[dict], intro=None, end=None,
failed: dict | None = None) -> list[Path]:
"""Write the minisite. Returns every file written, for the output manifest.
`books` is a list of::
{"book": Book, "pages": [(page, svg)], "notes": {slug: (markdown, {ref: data uri})},
"intro": (markdown, {}) or None}
"""
import shutil
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
values = _slots(style)
written: list[Path] = []
def put(path: Path, text: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
written.append(path)
put(out_dir / "minisite.css",
_fill(CSS.replace("__SIDEBAR__", str(SIDEBAR_W)).replace("__CONTENT__", str(CONTENT_W)),
values))
put(out_dir / "viewer.html", _fill(VIEWER.replace("__TITLE__", escape(title)), values))
cards, nav = [], []
for entry in books:
b = entry["book"]
pages = [p for p, _ in entry["pages"]]
book_dir = out_dir / b.slug
for page, svg in entry["pages"]:
put(book_dir / f"{page['slug']}.html",
_page(title, entry, page, svg, pages, entry["notes"].get(page["slug"]), title))
if svg is not None:
put(book_dir / f"{page['slug']}.svg", svg)
# The reference page: the single-page `onepage` as it was, one per book, where
# a module box on any page opens its own section.
ref = reference_page(b.view, style, title=f"{b.slug} — reference")["index.html"]
ref = ref.replace('href="minisite.css"', 'href="../minisite.css"')
put(book_dir / "reference.html", ref)
explore = b.out / "explore"
if explore.is_dir():
for f in sorted(explore.rglob("*")):
if f.is_file():
target = book_dir / "explore" / f.relative_to(explore)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(f, target)
written.append(target)
first = pages[0]["slug"] if pages else "reference"
too_wide = sum(1 for p in pages if p.get("too_wide"))
cards.append(
f'<div class="bookcard"><h2><a href="{escape(b.slug)}/{escape(first)}.html">'
f"{escape(b.slug)}</a></h2>"
+ _note(entry.get("intro"))
+ _ledger(b.to_dict())
+ '<div class="links">'
+ f'<a href="{escape(b.slug)}/{escape(first)}.html">{len(pages)} page(s)</a>'
+ f'<a href="{escape(b.slug)}/reference.html">reference</a>'
+ (f'<a href="{escape(b.slug)}/explore/explore.html">explore</a>' if explore.is_dir() else "")
+ (f'<span class="caption warn">{too_wide} too wide</span>' if too_wide else "")
+ "</div></div>"
)
nav.append(f'<li><a href="{escape(b.slug)}/{escape(first)}.html">{escape(b.slug)}</a></li>')
put(out_dir / "index.html", f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(title)}</title>
<link rel="stylesheet" href="minisite.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-header"><b>{escape(title)}</b><small>{len(books)} book(s)</small></div>
<ul>{"".join(nav)}</ul>
</nav>
<main class="content">
<h1>{escape(title)}</h1>
<p class="lede">Regenerated, not edited. Each book opens with what came in and what came out.</p>
{_note((intro, {}) if intro else None)}
{_missing(failed)}
<div class="books">{"".join(cards)}</div>
{_note((end, {}) if end else None)}
</main>
</div>
</body>
</html>
""")
return written

View File

@@ -1,5 +1,7 @@
"""
IR -> a self-contained documentation site: sidebar, content, graph viewer.
IR -> one self-contained page: sidebar, content, graph viewer.
The minisite's per-book reference page, and `emit minisite` on a bare IR.
Not invented here. Five demos under `semester/` already converged on the same
two files, and this generates that arrangement rather than a sixth variant:
@@ -29,7 +31,7 @@ are looking at. A click that moved the mouse is a drag and does not toggle.
## Colours
Baked from the same style slots as every diagram, so the page and the graph on
it match by construction. `--theme lucid` produces a light site and a light
it match by construction. `--theme lucid` produces a light page and a light
diagram together; nothing has to be kept in sync by hand.
Self-contained and offline: no CDN, no build step, opens over `file://`.
@@ -177,6 +179,11 @@ CSS = """/* Generated by docgen. The layout five demos converged on: a sticky si
--muted: __MUTED__;
--dim: __DIM__;
--accent: __ACCENT__;
/* What was lost: the ledger's "incomplete" panel. Declared here because it was not,
and an undefined custom property falls back silently — the warning rendered
in the ordinary text colour, which is the one place it must not. */
--lost: __LOST__;
--ok: __OK__;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -240,8 +247,7 @@ body {
text-transform: uppercase; letter-spacing: .04em; }
.lede { color: var(--dim); font-size: 12px; margin-bottom: 1.5rem; }
/* The convention both spr/docs and sms/docs arrived at independently:
inline and scaled to the column, click for the full thing. */
/* Inline and scaled to the column, click for the full thing. */
.figure { margin: 1rem 0 1.5rem; }
.figure a { display: block; border: 1px solid var(--border); border-radius: 8px;
overflow: hidden; background: var(--surface); }
@@ -260,20 +266,58 @@ body {
.ledger dd { margin: 0; color: var(--text); font-size: 12.5px; }
.ledger dd .sub { display: block; color: var(--muted); font-size: 11px; margin-top: .2rem;
overflow-wrap: anywhere; }
.ledger .lost { color: var(--artery); }
.ledger .lost { color: var(--lost); }
.ledger .kept { color: var(--ok); }
.gap { border: 1px solid var(--artery); border-left-width: 3px; border-radius: 6px;
.gap { border: 1px solid var(--lost); border-left-width: 3px; border-radius: 6px;
background: var(--surface); padding: .7rem .9rem; margin: 0 0 1.5rem; font-size: 12px; }
.gap b { color: var(--artery); }
.gap b { color: var(--lost); }
.gap ul { margin: .4rem 0 0 1.1rem; color: var(--muted); }
.gap code { font-size: 11px; }
/* -- pages ------------------------------------------------------------------
A page is one subsystem drawn small enough to read. The diagram is inline SVG,
not an <img>, so its boxes are links: a group opens its own page, a module
opens its section of the reference page. */
.crumbs { color: var(--dim); font-size: 11px; margin-bottom: .6rem; }
.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--accent); }
.note { margin: 0 0 1.2rem; }
.note h1, .note h2, .note h3 { color: var(--text); margin: 1rem 0 .4rem; font-size: 14px; }
.note p, .note li { color: var(--muted); margin-bottom: .5rem; }
.note ul, .note ol { margin: 0 0 .8rem 1.2rem; }
.note pre { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: .6rem .8rem; overflow-x: auto; margin: .6rem 0; }
.note code { font-size: 11px; }
.note table { border-collapse: collapse; margin: .6rem 0; }
.note th, .note td { border-bottom: 1px solid var(--border); padding: 4px 12px 4px 0; text-align: left; }
.note blockquote { border-left: 2px solid var(--border); padding-left: .8rem; color: var(--dim); }
.note img { max-width: 100%; }
.figbar { display: flex; gap: 6px; align-items: center; margin-bottom: .4rem; font-size: 11px; color: var(--dim); }
.figbar button, .figbar a {
background: var(--surface); color: var(--muted); border: 1px solid var(--border);
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; text-decoration: none; }
.figbar button.on { color: var(--accent); border-color: var(--accent); }
.fig { border: 1px solid var(--border); border-radius: 8px; background: var(--surface);
padding: 8px; overflow: hidden; }
.fig svg { display: block; max-width: 100%; height: auto; margin: 0 auto; }
.fig.actual { overflow: auto; }
.fig.actual svg { max-width: none; }
.fig a:hover polygon, .fig a:hover path { stroke: var(--accent); }
.caption { color: var(--dim); font-size: 11px; margin: .4rem 0 1.2rem; }
.caption.warn { color: var(--lost); }
.children { columns: 2; margin: .4rem 0 1.4rem; list-style: none; }
.children li { margin-bottom: 2px; }
.books { display: grid; gap: 1rem; margin: 1rem 0 2rem; }
.bookcard { border: 1px solid var(--border); border-radius: 8px; padding: .9rem 1rem; }
.bookcard h2 { font-size: 15px; margin-bottom: .5rem; }
.bookcard .links a { margin-right: 1rem; font-size: 12px; }
"""
def _ledger(book: dict) -> str:
"""The two measures, and the gap between them if there is one.
This is the whole reason the site is the book's last step rather than just
This is the whole reason the minisite is the book's last step rather than just
another emitter: it is the one artifact somebody definitely opens, so it is
where "45 of 47 files" has to appear. A diagram cannot say it, and a log
nobody reads does not count as having said it.
@@ -337,6 +381,8 @@ def _slots(style) -> dict:
"__MUTED__": s("text-muted"),
"__DIM__": s("text-dim"),
"__ACCENT__": s("accent"),
"__LOST__": s("error") or s("artery"),
"__OK__": s("ok") or s("atlas"),
"__FONT__": '"Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif',
}
@@ -390,9 +436,9 @@ def emit(ir: dict, style, *, graph: str | None = None, title: str = "",
`book` is a book ledger (`book/__init__.py`). When present the page opens
with both measures — what went in, what came out — because a page that
shows only the result is the thing the measure exists to correct.
Optional, so the site emitter still works on a bare IR.
Optional, so the page still works on a bare IR.
"""
from .index import to_sidebar
from ..index import to_sidebar
meta = ir.get("meta", {})
name = title or meta.get("root", "docs")
@@ -432,7 +478,7 @@ def emit(ir: dict, style, *, graph: str | None = None, title: str = "",
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(name)}</title>
<link rel="stylesheet" href="site.css">
<link rel="stylesheet" href="minisite.css">
</head>
<body>
<div class="layout">
@@ -474,7 +520,7 @@ document.querySelectorAll('h2[id], h3[id]').forEach(function (h) {{ obs.observe(
return {
"index.html": index,
"viewer.html": _fill(VIEWER.replace("__TITLE__", escape(name)), values),
"site.css": _fill(
"minisite.css": _fill(
CSS.replace("__SIDEBAR__", str(SIDEBAR_W)).replace("__CONTENT__", str(CONTENT_W)),
values,
),

View File

@@ -40,7 +40,7 @@ METADATA = {
}
def _cell(index: int, kind: str, text: str) -> dict:
def _cell(index: int, kind: str, text: str, attachments: dict | None = None) -> dict:
lines = text.split("\n")
source = [ln + "\n" for ln in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
cell = {
@@ -49,6 +49,12 @@ def _cell(index: int, kind: str, text: str) -> dict:
"metadata": {},
"source": source,
}
if attachments and kind == "md":
# nbformat 4 lets a markdown cell carry its own images, referenced as
# `attachment:<name>`. That is how a figure renders without the notebook
# being run: nothing is an output, so "run all" cannot clear it, and the
# same SVG gives the same bytes every build.
cell["attachments"] = {name: dict(bundle) for name, bundle in sorted(attachments.items())}
if kind == "code":
cell["execution_count"] = None
cell["outputs"] = []
@@ -212,15 +218,50 @@ def _call_md(step: dict) -> str:
return "\n".join(out)
def _figure_md(step: dict) -> str:
"""A page of a paginated diagram, as markdown.
The anchor is an explicit `<a name>` rather than the heading's own: JupyterLab
and VS Code derive heading anchors differently, and JupyterLab's sanitiser
keeps `name` where it drops `id`. The page index links to these.
"""
parts = []
if step.get("anchor"):
parts.append(f'<a name="{step["anchor"]}"></a>')
parts.append("")
if step.get("title"):
parts.append(f'{"#" * min(6, 2 + int(step.get("depth", 0)))} {step["title"]}')
parts.append("")
if step.get("svg"):
parts.append(f'![{step.get("title", "figure")}](attachment:{step["name"]})')
else:
parts.append(f'*Not drawn here — {step.get("why", "no renderer")}.*')
if step.get("caption"):
parts += ["", f'*{step["caption"]}*']
return "\n".join(parts)
def _figure_attachments(step: dict) -> dict | None:
if not step.get("svg"):
return None
import base64
data = step["svg"].encode("utf-8") if isinstance(step["svg"], str) else step["svg"]
return {step["name"]: {"image/svg+xml": base64.b64encode(data).decode("ascii")}}
def build(spec: dict) -> dict:
"""A merged notebook spec -> the notebook, as a dict."""
blocks: list[tuple[str, str]] = []
blocks: list[tuple] = []
for step in spec["steps"]:
kind = step.get("kind", "md")
before = step.get("before")
if before:
blocks.append(("md", before))
blocks.append(("md", before, step.get("before_attachments")))
if kind == "figure":
blocks.append(("md", _figure_md(step), _figure_attachments(step)))
if kind == "md":
text = []
@@ -259,7 +300,7 @@ def build(spec: dict) -> dict:
blocks.append(("md", after))
return {
"cells": [_cell(i, k, t) for i, (k, t) in enumerate(blocks)],
"cells": [_cell(i, *block) for i, block in enumerate(blocks)],
"metadata": METADATA,
"nbformat": 4,
"nbformat_minor": 5,

View File

@@ -0,0 +1,118 @@
"""
Draw pages: measure each one against a width, then render it for an output.
`ops/pages.py` decides which pages exist and never renders anything — `ops/`
imports no emitter. This is the other half: the `fits` test it needs, and the
drawing of a laid-out page, with the links an output wants.
## Measured, not estimated
A layered drawing's width is its widest level, but *how* wide depends on the
labels, the font and the spacing the style sets. A count-based estimate was off
by enough to matter, so when Graphviz is present a page is rendered and its
`width="Npt"` read back. Without Graphviz the estimate is all there is, and the
page records `measured: False` so nobody mistakes one for the other.
## Points and pixels
Graphviz and the ERD emitter both write sizes in points, and a browser draws a
point as 4/3 of a CSS pixel. Budgets here are pixels, because the column they
have to fit — a notebook cell, a minisite's content column — is sized in pixels.
"""
import re
PT_TO_PX = 4 / 3
# Budgets for the outputs docgen writes. A notebook cell in JupyterLab or VS Code
# is a little under a thousand pixels at a normal window size; the minisite's
# content column is set by `minisite.CONTENT_W`.
NOTEBOOK_WIDTH = 960
def svg_width_px(svg: str) -> float | None:
m = re.search(r'<svg[^>]*\swidth="([\d.]+)(pt|px)?"', svg)
if not m:
return None
value = float(m.group(1))
return value * PT_TO_PX if (m.group(2) or "pt") == "pt" else value
def _estimate_px(ir: dict, options: dict) -> float:
"""Width without a renderer: widest level of boxes, or levels side by side."""
from ..ops import shape
sh = shape(ir)
labels = [len(n.get("label") or n["id"]) for n in ir["nodes"]] or [8]
box = 7 * (sum(labels) / len(labels)) + 40
if options.get("rankdir") == "LR":
return sh["levels"] * (box + 58) + 30
return sh["widest_level"] * (box + 36) + 30
def fits_for(style, width: int):
"""A `fits(page_ir, options) -> bool` for `ops.pages.layout`, at `width` px.
Also returns whether it measured or estimated, so the ledger can say which.
"""
from .dot import emit, have_graphviz, render
measured = have_graphviz()
def fits(ir: dict, options: dict) -> bool:
if not measured:
return _estimate_px(ir, options) <= width
svg = render(emit(ir, style, rankdir=options.get("rankdir"))).decode("utf-8")
px = svg_width_px(svg)
return px is not None and px <= width
return fits, measured
def links_for(pages: list[dict], page: dict, url) -> dict:
"""{box id on this page: url of the page that box opens}.
`url(target_page)` builds the address, so the minisite and anything else that
can follow a link decide their own scheme. Context boxes link too, when their
branch has a page of its own — that is how a reader crosses to a sibling.
"""
by_scope = {p.get("scope") or p["id"]: p for p in pages}
by_id = {p["id"]: p for p in pages}
out = {}
for n in page["ir"]["nodes"]:
target = by_id.get(n["id"]) if page.get("tables") else by_scope.get(n["id"])
if target is not None and target is not page:
out[n["id"]] = url(target)
return out
def render(page: dict, style, width: int, *, links: dict | None = None) -> str | None:
"""A laid-out page as SVG text, or None when it cannot be drawn here.
Schema pages go through the ERD emitter with its columns capped to `width`;
everything else, schema overviews included, through Graphviz with the page's
layout options and the caller's links.
"""
if page.get("tables") and page["id"] != "overview":
from .erd import emit as erd
return erd(page["ir"], style, width=width)
from .dot import emit, have_graphviz, render as dot_render
if not have_graphviz():
return None
svg = dot_render(emit(page["ir"], style, rankdir=(page.get("options") or {}).get("rankdir"),
links=links)).decode("utf-8")
return svg
def inline(svg: str) -> str:
"""SVG text ready to sit inside HTML: no XML declaration, no DOCTYPE.
Inline rather than `<img>`, because an image swallows every click and the
whole point of a page's boxes is that they open other pages.
"""
svg = re.sub(r"<\?xml[^>]*\?>\s*", "", svg)
svg = re.sub(r"<!DOCTYPE[^>]*>\s*", "", svg)
return re.sub(r"<!--.*?-->\s*", "", svg, flags=re.S).strip()

View File

@@ -0,0 +1,3 @@
<!-- docgen note: top of the document. Write markdown below; this comment does not render, and an empty note adds nothing. -->
The fixtures docgen tests itself against: a Python tree, a schema and an API
contract, built into one document to show the parts fitting together.

View File

@@ -0,0 +1,5 @@
<!-- docgen note: schema › page Overview. Write markdown below; this comment does not render, and an empty note adds nothing. -->
## Where to start
`orders` is the centre: every line belongs to one, and every order to one user.
Open the page to see the keys.

View File

@@ -51,7 +51,7 @@ from pathlib import Path
SPEC_VERSION = "1"
# Step kinds. Small and closed, the same discipline the IR's `kind` follows.
KINDS = ("md", "code", "params", "call")
KINDS = ("md", "code", "params", "call", "figure")
def _step(id: str, kind: str, **fields) -> dict:

View File

@@ -0,0 +1,382 @@
"""
Pages: a graph too big for one picture, as a set of pictures that each fit.
pages = paginate(view) # the structure: which pages exist
laid = layout(pages, fits) # per output: fits(page_ir, options) -> bool
A notebook scales an image down to its cell width, so a wide graph does not
scroll — it shrinks until its labels are unreadable. The only fix that holds is
to draw less per picture. This decides *what* goes on each picture; the caller
decides whether it fits, by rendering it and measuring, because a width
estimated from node counts is wrong the moment the labels are long.
**Which pages exist does not depend on width** — only how each one is laid out
does. So the two are separate calls: the page ids a note is filed under are the
same for a notebook column and a minisite column, and listing them needs no
renderer at all.
## By structure, never by slicing
Tiling one large image cuts boxes and edges at the seams, and a tile means
nothing on its own. A page here is a subsystem:
overview one box per top-level group, relationships lifted
└── station one box per child of `station`
└── station.tools one box per child of `station.tools`, and so on
Clicking a box — on the minisite — or following the index — in a notebook — moves
down a level. Every page is a real graph: the boxes are the scope's children,
and anything outside the scope that they touch appears as a **context** box,
collapsed to the branch it sits on, so a page still says what its subsystem
reaches without drawing the rest of the tree.
## Groups come from the id, not from `parent`
A directory without `__init__.py` is not a Python package, so its modules have
no `parent` — soleprint has 173 such roots — but their dotted ids still carry
the directory. `split()` groups on id segments for the same reason. A group with
no node of its own (such a directory) is synthesised on the page as a box of
the kind most of its contents are, marked `attrs.group`.
## When a level cannot be narrowed
A scope whose children genuinely are many peers has no smaller structure to
split into. It is redrawn left-to-right — height is free in a notebook, width is
not — and if that still does not fit, the page is kept and flagged `too_wide`.
Never cut, never dropped: a flagged page is honest, a missing one is not.
## Schemas page differently
A schema is peers with references, drawn as cards in columns whose count the
emitter caps to the width, so width is never the problem — count is. Tables are
paged by connected component (tables joined by foreign keys stay together), and
tables with no keys at all are chunked alphabetically.
"""
import re
OVERVIEW = "overview"
MAX_TABLES = 20 # past this a schema page stops being scannable
def slugify(page_id: str) -> str:
"""A filename- and anchor-safe name for a page id.
Ids are not safe as they stand: an endpoint id is `GET /orders/{id}`, and a
tree-sitter id can carry `#L12`.
"""
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", page_id).strip("-.")
return slug or "page"
def _key(node_id: str, scope: str) -> str:
"""The box at `scope` that `node_id` falls into: scope + one more segment."""
if scope == "":
return node_id.split(".", 1)[0]
if node_id == scope:
return scope
rest = node_id[len(scope) + 1:]
return f"{scope}.{rest.split('.', 1)[0]}"
def _under(node: dict, scope: str) -> bool:
if node["kind"] == "external":
return False # externals are context everywhere, never members
nid = node["id"]
return scope == "" or nid == scope or nid.startswith(scope + ".")
def _representative(node_id: str, scope: str) -> str:
"""Where an outside node shows on this page: the branch it diverges on.
The common prefix of the scope and the node, plus one more segment of the
node's id. That box is never an ancestor of the scope (it is on the node's
branch, past the point they part), so it never swallows the page's own
members — and two outside nodes on the same branch share one box.
"""
s, n = scope.split(".") if scope else [], node_id.split(".")
common = 0
while common < len(s) and common < len(n) and s[common] == n[common]:
common += 1
return ".".join(n[: common + 1])
def page_ir(view: dict, scope: str) -> tuple[dict, dict]:
"""The graph for one page, and {member key: [node ids it stands for]}.
Built directly rather than through `_rebuild`, because a synthesised group
box and a context box have no node in the source graph to keep.
"""
by_id = {n["id"]: n for n in view["nodes"]}
members: dict[str, list] = {}
for n in view["nodes"]:
if _under(n, scope):
members.setdefault(_key(n["id"], scope), []).append(n["id"])
where = {nid: key for key, ids in members.items() for nid in ids}
context: dict[str, list] = {}
for e in view["edges"]:
s_in, t_in = e["source"] in where, e["target"] in where
if s_in == t_in:
continue # internal to the page (handled below) or unrelated
outside = e["target"] if s_in else e["source"]
rep = _representative(outside, scope)
if rep in members:
continue # cannot happen for a well-formed tree; stay safe
context.setdefault(rep, []).append(outside)
where.setdefault(outside, rep)
nodes = []
for key, ids in sorted(members.items()) + sorted(context.items()):
is_context = key in context and key not in members
own = by_id.get(key)
ids = sorted(set(ids))
if own is not None and (ids == [key] or not is_context):
node = {"id": key, "kind": own["kind"], "label": own.get("label") or key,
"parent": None, "attrs": dict(own.get("attrs") or {})}
else:
kinds = [by_id[i]["kind"] for i in ids if i in by_id]
kind = max(set(kinds), key=lambda k: (kinds.count(k), k)) if kinds else "module"
node = {"id": key, "kind": kind, "label": key.rsplit(".", 1)[-1],
"parent": None, "attrs": {}}
# Semantic, not visual: which boxes are subsystems to open, and which are
# the outside world seen from here. How that looks is style's decision.
grouped = [i for i in ids if i != key]
if grouped or (own is None):
node["attrs"]["group"] = True
node["attrs"]["count"] = len(ids)
if is_context:
node["attrs"]["context"] = True
nodes.append(node)
merged: dict[tuple, dict] = {}
for e in view["edges"]:
src, dst = where.get(e["source"]), where.get(e["target"])
if src is None or dst is None or src == dst:
continue
if src in context and dst in context:
continue # two outside boxes: not this page's relationship
k = (src, dst, e["kind"])
if k in merged:
merged[k]["attrs"]["weight"] = merged[k]["attrs"].get("weight", 1) + 1
continue
attrs = dict(e.get("attrs") or {})
if src != e["source"] or dst != e["target"]:
attrs = {"weight": 1}
merged[k] = {"source": src, "target": dst, "kind": e["kind"], "attrs": attrs}
ir = {"meta": dict(view["meta"]), "nodes": nodes,
"edges": sorted(merged.values(), key=lambda e: (e["source"], e["target"], e["kind"]))}
return ir, members
def layout(pages: list[dict], fits) -> list[dict]:
"""The same pages, each with the layout that fits: top-to-bottom, then
left-to-right, then flagged `too_wide`. Returns new page dicts.
Schema pages are left alone — the ERD emitter caps its own columns to the
width, so they always fit — except the overview, which is a graph.
"""
out = []
for page in pages:
page = dict(page)
if page.get("tables") and page["id"] != OVERVIEW:
page["options"], page["too_wide"] = {}, False
elif fits(page["ir"], {}):
page["options"], page["too_wide"] = {}, False
elif fits(page["ir"], {"rankdir": "LR"}):
page["options"], page["too_wide"] = {"rankdir": "LR"}, False
else:
page["options"], page["too_wide"] = {"rankdir": "LR"}, True
out.append(page)
return out
def paginate(view: dict, *, max_depth: int = 6) -> list[dict]:
"""Pages for a graph, overview first, each drawn as a subsystem.
Returns a list of::
{"id", "slug", "title", "scope", "parent", "depth", "ir",
"children": [page ids], "options": {}, "too_wide": False}
`options` and `too_wide` are placeholders until `layout()` fills them.
in reading order — a page is followed by its own children, depth first — so
a notebook can emit them in sequence and a minisite can build its tree from
`parent` and `children`.
"""
pages: list[dict] = []
slugs: set[str] = set()
def unique(slug: str) -> str:
base, i = slug, 2
while slug in slugs:
slug, i = f"{base}-{i}", i + 1
slugs.add(slug)
return slug
def opens(key: str, scope: str, ids: list) -> bool:
# A box opens into a page when it stands for more than itself.
return key != scope and any(i != key for i in ids)
def visit(scope: str, parent: str | None, depth: int, page_id: str | None = None):
ir, members = page_ir(view, scope)
if not ir["nodes"]:
return
# A page whose only member is one group says nothing but "open this" —
# docgen's own overview was one box reading `docgen`. Descend instead,
# keeping the id the caller expects, so the overview is still `overview`.
groups = [k for k, ids in members.items() if opens(k, scope, ids)]
if len(members) == 1 and len(groups) == 1:
return visit(groups[0], parent, depth, page_id or (scope or OVERVIEW))
page_id = page_id or scope or OVERVIEW
page = {
"id": page_id,
"slug": unique(slugify(page_id)),
"title": scope if scope and page_id == scope else (scope or "Overview"),
"scope": scope,
"parent": parent,
"depth": depth,
"ir": ir,
"options": {},
"children": [],
"too_wide": False,
}
pages.append(page)
if depth >= max_depth:
return
for key in sorted(members):
if opens(key, scope, members[key]):
page["children"].append(key)
visit(key, page_id, depth + 1)
visit("", None, 0)
return pages
def paginate_tables(view: dict, *, max_tables: int = MAX_TABLES) -> list[dict]:
"""Pages for a schema: connected components, packed, then chunked.
Each page is the tables plus their columns, so the ERD emitter draws cards.
The overview has one box per page, which is what a reader opens from.
"""
tables = sorted(n["id"] for n in view["nodes"] if n["kind"] == "table")
links: dict[str, set] = {t: set() for t in tables}
for e in view["edges"]:
if e["source"] in links and e["target"] in links:
links[e["source"]].add(e["target"])
links[e["target"]].add(e["source"])
seen, components = set(), []
for t in tables:
if t in seen:
continue
stack, comp = [t], []
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
comp.append(cur)
stack.extend(links[cur] - seen)
components.append(sorted(comp))
connected = sorted((c for c in components if len(c) > 1), key=lambda c: (-len(c), c[0]))
lonely = sorted(c[0] for c in components if len(c) == 1)
groups: list[list[str]] = []
for comp in connected:
# A component larger than a page is still split — by name, since a
# foreign-key cluster has no finer structure to split on — and the cut
# keys show as edges to the other page's tables in neither, which the
# overview's lifted edges make up for.
for i in range(0, len(comp), max_tables):
groups.append(comp[i:i + max_tables])
for i in range(0, len(lonely), max_tables):
groups.append(lonely[i:i + max_tables])
by_id = {n["id"]: n for n in view["nodes"]}
pages: list[dict] = []
table_page: dict[str, str] = {}
for i, group in enumerate(groups, start=1):
pid = group[0] if len(group) == 1 else f"{group[0]}…{group[-1]}"
slug = f"tables-{i:02d}"
for t in group:
table_page[t] = slug
keep = set(group) | {n["id"] for n in view["nodes"] if n.get("parent") in set(group)}
ir = {"meta": dict(view["meta"]),
"nodes": [dict(by_id[n]) for n in sorted(keep)],
"edges": [dict(e) for e in view["edges"]
if e["source"] in keep and e["target"] in keep]}
pages.append({"id": slug, "slug": slug, "title": pid, "scope": slug,
"parent": OVERVIEW, "depth": 1, "ir": ir, "options": {},
"children": [], "too_wide": False, "tables": True})
over_nodes = [{"id": p["id"], "kind": "table", "label": p["title"], "parent": None,
"attrs": {"group": True, "count": sum(
1 for n in p["ir"]["nodes"] if n["kind"] == "table")}}
for p in pages]
merged: dict[tuple, dict] = {}
for e in view["edges"]:
a, b = table_page.get(e["source"]), table_page.get(e["target"])
if a and b and a != b:
k = (a, b, e["kind"])
merged.setdefault(k, {"source": a, "target": b, "kind": e["kind"],
"attrs": {"weight": 0}})
merged[k]["attrs"]["weight"] += 1
overview = {"id": OVERVIEW, "slug": OVERVIEW, "title": "Overview", "scope": "",
"tables": True, "parent": None,
"depth": 0, "ir": {"meta": dict(view["meta"]), "nodes": over_nodes,
"edges": list(merged.values())},
"options": {}, "children": [p["id"] for p in pages], "too_wide": False}
return [overview] + pages
def coverage(view: dict, pages: list[dict]) -> list[str]:
"""Node ids of the view that no page accounts for. Empty means nothing lost.
A node is accounted for when a page shows it — as itself, or inside a box
that opens onto a page that does. Externals count when their branch appears
as context somewhere.
"""
shown = set()
for p in pages:
shown |= {n["id"] for n in p["ir"]["nodes"]}
# Schema pages draw tables and their columns; an API's endpoints are the
# notebook's walkthrough, not a diagram, so they are not a page's to account for.
tables_only = bool(pages) and pages[0].get("tables")
missing = []
for n in view["nodes"]:
if tables_only and n["kind"] not in ("table", "column"):
continue
nid = n["id"]
if nid in shown:
continue
parts = nid.split(".")
if n["kind"] == "external" and any(".".join(parts[:i]) in shown
for i in range(1, len(parts) + 1)):
continue
if n.get("parent") in shown and n["kind"] == "column":
continue
missing.append(nid)
return sorted(missing)
def for_view(ir: dict, view: dict, *, max_depth: int = 6) -> list[dict]:
"""The pages a book gets, whatever its source.
A schema pages by table — from the full IR, since a card needs its columns and
the default view drops them — and anything else pages by structure from the
view. The same decision `classify` makes about which emitter draws it.
"""
from .filter import classify
if classify(view)["emitter"] == "erd":
tables = {"meta": ir["meta"],
"nodes": [n for n in ir["nodes"] if n["kind"] in ("table", "column")],
"edges": [e for e in ir["edges"]
if e["kind"] in ("foreign_key", "references")]}
if any(n["kind"] == "table" for n in tables["nodes"]):
return paginate_tables(tables)
return paginate(view, max_depth=max_depth)

View File

@@ -46,7 +46,7 @@ index_mod = __import__(f"{PKG}.emitters.index", fromlist=["*"])
ops_mod = __import__(f"{PKG}.ops", fromlist=["*"])
erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"])
nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"])
site_mod = __import__(f"{PKG}.emitters.site", fromlist=["*"])
site_mod = __import__(f"{PKG}.emitters.minisite.onepage", fromlist=["*"])
mm_mod = __import__(f"{PKG}.emitters.minimap", fromlist=["*"])
exp_mod = __import__(f"{PKG}.emitters.explore", fromlist=["*"])
spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"])
@@ -1284,18 +1284,18 @@ check("every verdict carries options", "options" in ops_mod.classify(ir))
# --------------------------------------------------------------------------
print("\n11. the docs site")
print("\n11. the minisite's one page")
# Not invented here: five demos under semester/ carry the same viewer, differing
# only in comments and one background colour. This generates that arrangement
# rather than a sixth copy — and adds the 1:1 toggle none of them has.
site_dir = ROOT.parent / "site"
site_dir = ROOT.parent / "minisite"
files = site_mod.write(ops_mod.overview(ir), lucid, site_dir, graph="graph.svg", title="fx")
names = {f.name for f in files}
check("it writes the three files", names == {"index.html", "viewer.html", "site.css"}, str(names))
check("it writes the three files", names == {"index.html", "viewer.html", "minisite.css"}, str(names))
index_html = (site_dir / "index.html").read_text()
css = (site_dir / "site.css").read_text()
css = (site_dir / "minisite.css").read_text()
viewer = (site_dir / "viewer.html").read_text()
check(
@@ -1316,8 +1316,8 @@ check(
light = site_mod.emit(ops_mod.overview(ir), print_theme, graph="graph.svg")
check(
"a light theme gives a light page",
print_theme.slot("surface-0") in light["site.css"]
and lucid.slot("surface-0") not in light["site.css"],
print_theme.slot("surface-0") in light["minisite.css"]
and lucid.slot("surface-0") not in light["minisite.css"],
"the page and the diagram on it move together",
)
check(
@@ -1766,11 +1766,11 @@ def _books():
ran, failure = checks_mod._run_cells(nb["cells"], out)
check(f"its {ran} code cells run from the book directory", failure is None, failure or "")
html = (out / "site" / "index.html").read_text()
html = (out / "minisite" / "index.html").read_text()
check(
"the web output shows both ends",
"what came in" in html and "what came out" in html,
"the site is the last step because it is the artifact somebody opens",
"the minisite is the last step because it is the artifact somebody opens",
)
check(
"and admits when the book is incomplete",
@@ -1783,7 +1783,7 @@ def _books():
again = Path(tmp.name) / "book-out-2"
build_mod.run("python", fixture, again, slug="fixture", quiet=True)
differs = [
name for name in ("book.json", "notebook.ipynb", "steps/ir.json", "site/index.html")
name for name in ("book.json", "notebook.ipynb", "steps/ir.json", "minisite/index.html")
if (out / name).read_bytes() != (again / name).read_bytes()
]
check("the same larder builds the same bytes", not differs, f"differ: {differs}")
@@ -2094,9 +2094,12 @@ def _layout():
and "Traceback" not in bad.stderr and bad.stderr.count("\n") == 1,
f"exit {bad.returncode}: {bad.stderr[:120]!r}",
)
old = run("emit", "site")
check("`emit site` names its new name, `emit minisite`",
old.returncode == 1 and "is now emit minisite" in old.stderr, old.stderr[:120])
# The structure-picks-the-drawing branch used to be written three times —
# the `auto` command, the `site` command, the book. Now it is `draw()`, and
# the `auto` command, the `minisite` command, the book. Now it is `draw()`, and
# the book reaching past it to the concrete emitters would be the first copy.
# Parsed, not grepped, for the reason section 2 gives: the docstring naming
# `emitters.dot` is prose, and the first version of this check failed on it.
@@ -2266,7 +2269,7 @@ def _runfiles():
check(
"a rebuild removes the previous build's outputs",
again["app"]["ok"] and not (app / "steps" / "stale.svg").exists(),
"a stale graph.svg would go on showing in the site, unlisted in the ledger",
"a stale graph.svg would go on showing in the minisite, unlisted in the ledger",
)
check("...and keeps the hand-written checks.py", (app / "checks.py").exists())
@@ -2319,6 +2322,481 @@ def _runfiles():
_runfiles()
# --------------------------------------------------------------------------
print("\n19. pages — a graph too big for one picture, as pictures that each fit")
def _pages():
pages_mod = __import__(f"{PKG}.ops.pages", fromlist=["*"])
paged = __import__(f"{PKG}.emitters.paged", fromlist=["*"])
dot = __import__(f"{PKG}.emitters.dot", fromlist=["*"])
from_ops = __import__(f"{PKG}.ops", fromlist=["*"])
# ops/ decides what goes on a page and must never draw one. Paging is exactly
# where the temptation lives — measuring a width wants a renderer — so the
# measure is passed in as `fits`, and this holds the line.
reaching = []
for path in sorted((HERE / "ops").glob("*.py")):
for node in ast.walk(ast.parse(path.read_text())):
if isinstance(node, ast.ImportFrom) and node.module and node.level \
and node.module.split(".")[0] in ("emitters", "book", "cli", "extractors"):
reaching.append(f"{path.name}: {node.module}")
check("ops/ imports no emitter, book, command line or extractor", not reaching, reaching)
view = from_ops.overview(py_ex.extract(HERE).to_dict())
structure = pages_mod.paginate(view)
check(
"the first page is the overview",
structure[0]["id"] == pages_mod.OVERVIEW,
f"got {structure[0]['id']}",
)
check(
"every page is a valid IR on its own",
not [p["id"] for p in structure if check_ir(p["ir"])],
)
# The claim pagination is allowed to make: drawn smaller, never drawn less.
lost = pages_mod.coverage(view, structure)
check("pagination loses no node — each is on a page or inside a box that opens one",
not lost, f"{lost[:5]}")
ids = {p["id"] for p in structure}
check(
"every box that opens a page opens one that exists",
not [c for p in structure for c in p["children"] if c not in ids],
)
check(
"slugs are unique and safe as file names",
len({p["slug"] for p in structure}) == len(structure)
and all(re.fullmatch(r"[A-Za-z0-9._-]+", p["slug"]) for p in structure),
)
check(
"an endpoint id becomes a safe slug",
pages_mod.slugify("GET /orders/{orderId}") == "GET-orders-orderId",
pages_mod.slugify("GET /orders/{orderId}"),
)
# docgen's own overview used to be a single box reading `docgen`: a page
# whose only content is "open this". A lone group is descended into instead.
check(
"a page is never just one box that opens another page",
not [p["id"] for p in structure
if len([n for n in p["ir"]["nodes"] if not (n.get("attrs") or {}).get("context")]) == 1
and p["children"]],
)
# Which pages exist must not depend on width — notes are filed by page id,
# and a note has to land in the same place in a notebook and on a minisite.
check(
"which pages exist does not depend on width",
[p["id"] for p in pages_mod.layout(structure, lambda ir, o: False)]
== [p["id"] for p in pages_mod.layout(structure, lambda ir, o: True)],
)
narrow = pages_mod.layout(structure, lambda ir, o: False)
check(
"a page that fits no way is kept and flagged, never dropped",
len(narrow) == len(structure) and all(p["too_wide"] for p in narrow),
)
# -- measured against a real width --------------------------------------
if not dot.have_graphviz():
skip("pages fit their width", "Graphviz is not installed")
else:
lucid_style = Style.load("lucid")
fits, measured = paged.fits_for(lucid_style, 800)
laid = pages_mod.layout(structure, fits)
over = []
for p in laid:
px = paged.svg_width_px(paged.render(p, lucid_style, 800))
if px > 800 and not p["too_wide"]:
over.append(f"{p['id']} {px:.0f}px")
check("no page is wider than its budget unless it says so", not over, "; ".join(over))
# Links are the output's, never the IR's: a minisite passes them, and then a
# relative source path — a dead link on a web page — must not survive.
page = next(p for p in laid if p["children"])
links = paged.links_for(laid, page, lambda t: f"{t['slug']}.html")
svg = paged.render(page, lucid_style, 800, links=links)
check(
"a box that opens a page links to it",
all(f'href="{url}"' in svg for url in links.values()) and links,
f"{len(links)} links",
)
check(
"an output that manages links drops the source-file hrefs",
".py" not in "".join(re.findall(r'href="([^"]+)"', svg)),
)
# Schemas page by table, and a card grows downward rather than sideways.
shop = db_ex.extract(HERE / "fixtures" / "shop.json").to_dict()
tables = pages_mod.for_view(shop, from_ops.overview(shop))
check(
"a schema pages by connected tables, cards with their columns",
tables[0]["id"] == "overview" and tables[1].get("tables")
and any(n["kind"] == "column" for n in tables[1]["ir"]["nodes"]),
)
many = {"models": {f"t{i:02d}": {"fields": {"id": {"type": "int", "pk": True}}}
for i in range(12)}}
wide_ir = db_ex.from_schema_dict(many, root="x").to_dict()
svg = erd_mod.emit(wide_ir, lucid, width=800)
check(
"an ERD capped to a width stays inside it",
paged.svg_width_px(svg) <= 800,
f"{paged.svg_width_px(svg):.0f}px",
)
_pages()
# --------------------------------------------------------------------------
print("\n20. notes, figures and pages — the hand-written part, and where it lands")
def _documents():
doc_mod = __import__(f"{PKG}.book.document", fromlist=["*"])
notes_mod = __import__(f"{PKG}.book.notes", fromlist=["*"])
build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
checks_mod = __import__(f"{PKG}.book.checks", fromlist=["*"])
md = __import__(f"{PKG}.emitters.markdown", fromlist=["*"])
dot = __import__(f"{PKG}.emitters.dot", fromlist=["*"])
import io as _io3
import contextlib as _ctx3
base = Path(tmp.name) / "documents"
src = base / "src" / "shop"
for sub in ("orders", "billing"):
(src / sub).mkdir(parents=True, exist_ok=True)
(src / sub / "__init__.py").write_text(f'"""{sub}."""\n')
(src / sub / "models.py").write_text(f'"""{sub} models."""\nfrom shop.{sub} import x\n')
(src / "__init__.py").write_text('"""shop."""\n')
notes = base / "notes"
# -- notes ---------------------------------------------------------------
stubbed = notes_mod.Notes(notes).scaffold({"app": [("overview", "page Overview")]})
check("scaffold writes a stub per spot", (notes / "app" / "overview.md") in stubbed)
check("a stub renders nothing", notes_mod.body((notes / "app" / "overview.md").read_text()) is None)
(notes / "app" / "overview.md").write_text(
(notes / "app" / "overview.md").read_text() + "## Written by hand\n\nKept.\n")
again = notes_mod.Notes(notes).scaffold({"app": [("overview", "page Overview")]})
check(
"scaffold never overwrites a note",
(notes / "app" / "overview.md") not in again
and "Written by hand" in (notes / "app" / "overview.md").read_text(),
)
(notes / "img").mkdir(exist_ok=True)
(notes / "img" / "dot.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg"/>')
(notes / "app" / "overview.md").write_text(
(notes / "app" / "overview.md").read_text() + "\n![a dot](../img/dot.svg)\n")
(notes / "app" / "no-such-page.md").write_text("Filed under a page that is gone.\n")
# A note is text: raw HTML is shown, not run, and a link cannot break out of
# its attribute or run script.
html = md.to_html('<script>x</script> [a](b"onclick=1) [c](javascript:alert(1))')
check(
"a note's HTML is escaped and its links are attribute-safe",
# The quote stays inside the attribute as &quot; — asserted on the exact
# attribute, since stripping the entity first (as this check once did)
# makes the attribute's own closing quote look like a break-out.
"<script>" not in html and 'href="b&quot;onclick=1"' in html
and not re.search(r'"\s*onclick=', html) and 'href="#"' in html,
html,
)
# -- a one-book document, built inline -------------------------------------
out = base / "book"
book = build_mod.run("python", src, out, slug="app", notes=notes, quiet=True)
ledger = json.loads((out / "document.json").read_text())
check(
"a note filed under a vanished page is reported, not dropped",
any("no-such-page" in d for d in ledger["notes"]["drift"]),
ledger["notes"]["drift"],
)
r = checks_mod.Report()
with _ctx3.redirect_stdout(_io3.StringIO()):
checks_mod.generated(checks_mod.Loaded(out), r)
check("...and fails the book's own checks", "no note is filed under a page or step that is gone" in r.failed)
nb = json.loads((out / "notebook.ipynb").read_text())
cells = ["".join(c["source"]) for c in nb["cells"]]
note_at = next(i for i, t in enumerate(cells) if "Written by hand" in t)
check(
"a note lands directly above its page",
'<a name="page-app-overview">' in cells[note_at + 1],
cells[note_at + 1][:60],
)
check(
"a note's local image travels with it, as an attachment",
"attachment:dot.svg" in cells[note_at] and nb["cells"][note_at].get("attachments"),
)
figures = [c for c in nb["cells"] if '<a name="page-' in "".join(c["source"])]
check(
"every page is a figure in the notebook",
len(figures) == len(book.pages), f"{len(figures)} figures, {len(book.pages)} pages",
)
if dot.have_graphviz():
check(
"a figure carries its SVG, so it renders without running anything",
all("image/svg+xml" in next(iter((c.get("attachments") or {"": {}}).values()))
for c in figures),
)
else:
skip("figures carry SVG", "Graphviz is not installed")
check("the page index links every page", all(
f"(#page-app-{p['slug']})" in "".join(cells) for p in book.pages))
ran, failure = checks_mod._run_cells(nb["cells"], out)
check(f"the notebook still runs ({ran} code cells)", failure is None, failure or "")
# Two builds, same bytes — attachments included.
first = (out / "notebook.ipynb").read_bytes()
build_mod.run("python", src, out, slug="app", notes=notes, quiet=True)
check("the same sources and notes build the same notebook", first == (out / "notebook.ipynb").read_bytes())
# -- the minisite: drill-down ----------------------------------------------
site = out / "minisite"
page_files = sorted((site / "app").glob("*.html"))
broken = []
for f in page_files:
for href in re.findall(r'href="([^"#?]+)', f.read_text()):
if not href.startswith(("http:", "https:")) and not (f.parent / href).exists():
broken.append(f"{f.name} -> {href}")
check("every link on every minisite page opens something", not broken, broken[:5])
overview_html = (site / "app" / "overview.html").read_text()
check(
"a page's diagram is inline, so its boxes can be clicked",
"<svg" in overview_html and "<?xml" not in overview_html and "<img" not in overview_html.split('class="fig"')[1][:200]
if dot.have_graphviz() else True,
)
child = next((p for p in book.pages if p["parent"]), None)
if child is not None:
child_html = (site / "app" / f"{child['slug']}.html").read_text()
check(
"a page links back up through its breadcrumbs",
'href="overview.html"' in child_html.split('class="crumbs"')[1].split("</div>")[0],
)
check("a page has a fit / 1:1 control", 'data-size="actual"' in overview_html)
check("the minisite renders the note above the diagram",
overview_html.index("Written by hand") < overview_html.index('class="figbar"'))
# Manifests: a rebuild removes what the last one wrote, and nothing else.
project_out = base / "project-minisite"
doc = doc_mod.Document(
name="p", title="P", build=base / "p-build", notes=None,
parts=[doc_mod.Part(name="app", kind="python", source=src, out=base / "p-build" / "app")],
outputs=[doc_mod.Output("minisite", project_out)])
doc_mod.build(doc)
(project_out / "app" / "stale.html").write_text("left by a previous build")
manifest = json.loads((project_out / ".docgen-output.json").read_text())
manifest["written"].append("app/stale.html")
(project_out / ".docgen-output.json").write_text(json.dumps(manifest))
(project_out / "hand-written.md").write_text("mine")
doc_mod.build(doc)
check("a rebuild removes a page the last build wrote and this one did not",
not (project_out / "app" / "stale.html").exists())
check("...and never a file it did not write", (project_out / "hand-written.md").exists())
# One book that cannot be built must not cost the document — and the document
# has to say it is short, where a reader will see it, not only in a terminal.
(base / "broken.har").write_text("not a recording")
partial_site = base / "partial-minisite"
partial = doc_mod.Document(
name="partial", title="Partial", build=base / "partial-build", notes=None,
parts=[doc_mod.Part(name="bad", kind="usage", source=base / "broken.har",
out=base / "partial-build" / "bad"),
doc_mod.Part(name="app", kind="python", source=src,
out=base / "partial-build" / "app")],
outputs=[doc_mod.Output("minisite", partial_site),
doc_mod.Output("notebook", base / "partial.ipynb")])
doc_mod.build(partial)
check("a book that fails does not stop the others",
[b.slug for b in partial.books] == ["app"] and "bad" in partial.failed)
check("the minisite says the document is incomplete",
"This document is incomplete" in (partial_site / "index.html").read_text())
check("...and so does the notebook",
"This document is incomplete" in (base / "partial.ipynb").read_text())
_documents()
# --------------------------------------------------------------------------
print("\n21. project files — one document, several sources, the shape a model writes")
def _projects():
cfg = __import__(f"{PKG}.book.config", fromlist=["*"])
base = Path(tmp.name) / "projects"
(base / "src").mkdir(parents=True, exist_ok=True)
(base / "src" / "a.py").write_text('"""A."""\n')
(base / "shop.json").write_text((HERE / "fixtures" / "shop.json").read_text())
def load(text, name="docgen.toml"):
path = base / name
path.write_text(text)
try:
return cfg.load(path), []
except cfg.ConfigError as e:
return None, e.problems
project, problems = load(
'[project]\nname = "p"\n\n[extract.python]\nroot = "src"\n\n'
'[extract.db]\nschema = "shop.json"\n\n[ops]\ndrop_stdlib = true\n\n'
'[emit.notebook]\noutput = "out/nb"\n\n[emit.minisite]\noutput = "out/minisite"\nwidth = 700\n')
check("a project file loads", isinstance(project, cfg.ProjectFile), problems)
if project is None:
return
doc = project.document
check(
"each [extract.*] is a book, each [emit.*] an output of all of them",
[p.name for p in doc.parts] == ["python", "db"]
and sorted(o.emitter for o in doc.outputs) == ["minisite", "notebook"],
)
check("[ops] applies to every book", all(p.ops.get("drop_stdlib") for p in doc.parts))
check("an output's width is kept", next(o for o in doc.outputs if o.emitter == "minisite").width == 700)
# The outputs are what travels: a minisite or a notebook handed to someone
# else says nothing about the machine that built it, and nothing of the
# toolkit behind it. Its own source's names are fair game — that is the
# content — so the source here holds none of the words scanned for.
doc_mod = __import__(f"{PKG}.book.document", fromlist=["*"])
with _ctx.redirect_stdout(_io.StringIO()):
doc_mod.build(doc)
check("the ledger names a source the way the run file wrote it",
[b.larder.identity for b in doc.books] == ["src", "shop.json"],
[b.larder.identity for b in doc.books])
leaks = []
toolkit = re.compile(r"\b(soleprint|spr|artery|veins?|shunts?|plexus(es)?)\b", re.I)
for out_dir in (base / "out" / "minisite", base / "out" / "nb"):
for f in sorted(out_dir.rglob("*")):
if not f.is_file() or f.suffix not in (".html", ".css", ".js", ".svg", ".ipynb", ".json"):
continue
text = f.read_text()
if str(base) in text or str(Path.home()) in text:
leaks.append(f"{f.relative_to(base)}: an absolute path")
leaks += [f"{f.relative_to(base)}: {m.group(0)}" for m in toolkit.finditer(text)][:2]
check("no output carries an absolute path or the toolkit's own words", not leaks, leaks[:6])
books_file, _ = load('[[book]]\nname = "a"\nroot = "src"\n', "books.toml")
check("a [[book]] list still loads as it did", isinstance(books_file, cfg.RunFile))
_, both = load('[project]\nname = "p"\n[[book]]\nname = "a"\nroot = "src"\n', "both.toml")
check("a file with both shapes is refused", any("both [project] and [[book]]" in p for p in both), both)
# The keys a model reached for in a real dry run, each refused with what to
# do instead — and the database URL never repeated back, since it is a
# credential.
_, refused = load(
'[project]\nname = "p"\n\n'
'[extract.db]\nurl = "postgresql://user:hunter2@localhost:5432/app"\n\n'
'[extract.code]\nroot = "src"\next = [".sh", ".yaml", ".sql"]\n\n'
'[extract.seed]\nroot = "src"\n\n[emit.minisite]\noutput = "out"\n', "refused.toml")
text = "\n".join(refused)
check("a database url is refused, pointing at modelgen from-db",
"never connects" in text and "modelgen from-db" in text, refused)
check("...and the credential is not repeated in the error", "hunter2" not in text)
check("an extension with no grammar is refused, naming what exists",
"no grammar for .sh, .yaml, .sql" in text and ".cs" in text)
check("an unknown reader is refused, naming the readers", "no reader named 'seed'" in text)
_, inside = load('[project]\nname = "p"\n[extract.python]\nroot = "src"\n'
'[emit.minisite]\noutput = "src/minisite"\n', "inside.toml")
check("an output inside a tree it reads is refused", any("inside the tree" in p for p in inside), inside)
_, renamed = load('[project]\nname = "p"\n[extract.python]\nroot = "src"\n'
'[emit.site]\noutput = "out/site"\n', "renamed.toml")
check("the old [emit.site] is refused with its new name, not as unknown",
any("is now [emit.minisite]" in p for p in renamed)
and not any("no output named" in p for p in renamed), renamed)
example = cfg.load(HERE / "docgen.project.example.toml")
check("the shipped project example loads",
[p.name for p in example.document.parts] == ["extractors", "schema", "api"])
_projects()
# --------------------------------------------------------------------------
print("\n22. the instructions for a model say what docgen accepts")
def _contract():
"""docs/models.md is what a model is told. It is only useful while true.
A model follows it literally: a key it names that the loader refuses is a
refused file, and a key the loader accepts that it never mentions is a
capability no model will use. So it is checked against the loaders
themselves, the way explode's contract is one copy printed by the script
that parses the reply.
"""
cfg = __import__(f"{PKG}.book.config", fromlist=["*"])
code_ex = __import__(f"{PKG}.extractors.code", fromlist=["*"])
build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
doc_path = HERE / "docs" / "models.md"
check("docs/models.md exists", doc_path.is_file())
if not doc_path.is_file():
return
text = doc_path.read_text()
def named(word):
return f"`{word}`" in text or f'`"{word}"`' in text
keys = (cfg.TOP_KEYS | cfg.DEFAULT_KEYS | cfg.BOOK_KEYS | cfg.PROJECT_KEYS
| cfg.OPS_KEYS | cfg.EMIT_KEYS | set().union(*cfg.EXTRACT_KEYS.values()))
check("every key a loader accepts is named", not sorted(k for k in keys if not named(k)),
sorted(k for k in keys if not named(k)))
check("every reader is named", all(named(r) for r in cfg.EXTRACT_KEYS))
check("every output is named", all(named(e) for e in cfg.EMITTERS))
# Read off the one line that lists them, not the whole file: a bare scan for
# quoted dot-words also caught the default build folder, `".docgen"`.
ext_line = next((ln for ln in text.splitlines() if ln.startswith("- `ext`:")), "")
listed = set(re.findall(r'"(\.[a-z]+)"', ext_line))
check("the ext list names exactly the extensions code reads",
listed == set(code_ex.LANGUAGES), f"doc {sorted(listed)} vs code {sorted(code_ex.LANGUAGES)}")
check("every step spot is named",
all(named(f"step-{sid}") for sid, _ in build_mod.STEP_LABELS))
# Every example a model is shown must be one that loads. The shipped
# examples use a fixed set of paths, created here as stand-ins.
proj = Path(tmp.name) / "contract-project"
for d in ("src", "web/src"):
(proj / d).mkdir(parents=True, exist_ok=True)
for f in ("doc/contracts/openapi.yaml", "schema/schema.json", "recordings/session.har"):
(proj / f).parent.mkdir(parents=True, exist_ok=True)
(proj / f).write_text("{}")
blocks = re.findall(r"```toml\n(.*?)```", text, re.S)
failures = []
for i, block in enumerate(blocks):
path = proj / f"example-{i}.toml"
path.write_text(block)
try:
cfg.load(path)
except cfg.ConfigError as e:
failures.append(f"example {i}: {e.problems[:2]}")
check(f"every toml example in it loads ({len(blocks)})", blocks and not failures, failures)
# The error table is what a model uses to repair a refused file, so every
# message it quotes has to be one the code can actually produce.
source = "".join((HERE / "book" / f).read_text() for f in ("config.py", "notes.py"))
table = text.split("## When the build refuses your file")[-1]
# Every backticked message in the first column — a cell can hold two.
quoted = [m for row in table.splitlines() if row.startswith("| `")
for m in re.findall(r"`([^`]+)`", row.split("|")[1])]
unknown = []
for message in quoted:
fragments = [f.strip() for f in re.split(r"'[^']*'|…|\.sh", message) if f.strip()]
if not all(frag in source for frag in fragments):
unknown.append(message)
check(f"every error in its repair table is one the loaders produce ({len(quoted)})",
quoted and not unknown, unknown)
env = dict(__import__("os").environ, PYTHONPATH=str(HERE.parent))
printed = _sub.run([sys.executable, "-m", PKG, "contract"], capture_output=True,
text=True, env=env)
check("`contract` prints it verbatim", printed.returncode == 0 and printed.stdout == text)
_contract()
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
if FAIL:
print("\nfailed:")

View File

@@ -1,6 +1,6 @@
// Drive the generated viewer's logic under a stub DOM and assert the toggle.
const fs = require('fs');
const html = fs.readFileSync(process.argv[2] || '/tmp/site/viewer.html', 'utf8');
const html = fs.readFileSync(process.argv[2] || '/tmp/minisite/viewer.html', 'utf8');
const script = html.split('<script>')[1].split('</script>')[0];
const handlers = {};

7
soleprint/common/slice/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Everything a run writes, and a real run file: it names a consumer's paths and
# belongs beside that consumer. ui.example.toml is the committed one.
out/
ui.toml
*.slice.json
names/names.local.json
__pycache__/

View File

@@ -0,0 +1,40 @@
"""
Slices: the part of spr's UI one consumer uses, generated as readable source.
python3 -m common.slice <command> (from soleprint/; or ./ctrl/ui.sh, make ui)
A **slice** is the closure of what a consumer actually uses, cut out of spr and
written into that consumer:
target = "vue" from common/ui/src: the .vue/.ts files the used exports
need, a barrel that exports only those, and tokens.css
with only the variables they (and the consumer) read.
target = "plain" from common/theme/parts: ui.css + ui.js carrying only the
parts the consumer's markup uses, with resolved tokens.
It is never a bundle. The first attempt at reuse was one artifact for everyone,
and it failed the way bundles do: every consumer carried every part. A slice is
the per-part owned copy that common/theme/bake.py already makes for pages,
scaled up to a whole consumer. The selection is computed from the import graph
and from the consumer's own imports and markup, not typed by hand, and a check
reports drift in both directions: spr moved on, or the copy was edited.
## Two deliveries, in the words of the shipping model
delivery = "dependency" own projects. spr's names stay; a `.spr` stamp in
the ctrl/spr.py format says where the copy came from.
delivery = "vendored" a slice renamed to corp terms through a mapping
profile (names/corp.json), headers stripped, no
stamp, and a scrub that fails on any word that
would identify spr. A `notice` must be stated —
this tool never guesses the licensing.
Both write a manifest beside the run file: selection, source commit and the hash
of every file written. For vendored slices that file stays with the run file in
a private place, and only the neutral files land in the consumer's repo.
## What lives where
Library modules here hold no command-line code; `cli/` holds all of it (the
same split docgen uses). The generator itself never ships — only its output.
"""

View File

@@ -0,0 +1,8 @@
""" python3 -m common.slice <command> [args] — see cli/__init__.py for the commands."""
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,62 @@
"""
The command line — every command slices have, and nothing else.
python3 -m common.slice <command> [args] (from soleprint/)
./ctrl/ui.sh <command> [args] (from anywhere)
make ui <command>
list what can be sliced: every export and part, with what it pulls in
scan what a consumer uses, and how its framework copy compares with spr
slice write every slice a run file lists (only what is stale)
check the same, writing nothing: exit 1 if anything is stale
plexus build one self-contained page from a plexus (make dist)
reference the UI reference page of spr's /docs, generated from common/ui/src
selftest this package's own suite
Library modules beside this package hold no command-line code; selftest asserts
that nothing outside cli/ imports argparse, the same rule docgen keeps.
"""
import sys
# command -> (module in this package, one-line description)
COMMANDS = {
"list": ("list", "what can be sliced, and what each piece pulls in"),
"scan": ("scan", "what a consumer uses, and its copy against spr"),
"slice": ("slice", "write every slice a run file lists"),
"check": ("slice", "the same, writing nothing — exit 1 if stale"),
"plexus": ("plexus", "build a plexus: one self-contained page"),
"reference": ("reference", "the UI reference page of spr's docs, from common/ui/src"),
"selftest": ("selftest", "this package's own suite"),
}
def usage() -> str:
from .common import PROG
lines = [f"usage: {PROG} <command> [args]", ""]
lines += [f" {name:<9} {desc}" for name, (_, desc) in COMMANDS.items()]
lines += ["", f" {PROG} <command> --help for a command's options"]
return "\n".join(lines)
def main(argv=None) -> int:
from importlib import import_module
from .common import Abort
argv = sys.argv[1:] if argv is None else list(argv)
if not argv or argv[0] in ("-h", "--help", "help"):
print(usage())
return 0 if argv else 2
name, rest = argv[0], argv[1:]
if name not in COMMANDS:
print(f"Error: no command {name!r}\n\n{usage()}", file=sys.stderr)
return 2
module = import_module(f".{COMMANDS[name][0]}", package=__name__)
try:
return module.main(rest, command=name)
except Abort as e:
# The only exit path for an expected failure: one line, stderr, exit 1.
print(f"Error: {e}", file=sys.stderr)
return 1

Some files were not shown because too many files have changed in this diff Show More