Compare commits

...

26 Commits

Author SHA1 Message Date
2b36799acc Merge branch 'rig-work' 2026-09-30 09:53:53 -03:00
85276d34c3 updated make docs 2026-09-30 09:53:32 -03:00
8185d16c07 Merge branch 'rig-work' 2026-09-28 10:04:31 -03:00
f00055033e proper docs and .version 2026-09-28 10:02:55 -03:00
d8e7de1c59 Merge branch 'rig-work' 2026-09-25 08:33:33 -03:00
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
2ef6139957 bring secrets per repo 2026-09-22 14:00:43 -03:00
55c0d73ffe update dataconvert and source 2026-09-22 11:57:31 -03:00
0b227cbc0e Merge branch 'rig-work' 2026-09-22 08:18:31 -03:00
60527645f2 distilled tools 2026-09-22 08:18:25 -03:00
8f7e409ec4 Merge branch 'rig-work' 2026-09-22 06:47:55 -03:00
f4b2c15a9f update distill 2026-09-22 06:22:04 -03:00
Mariano Gabriel
aeeb26f4e9 update distill example 2026-09-22 04:40:43 -03:00
185 changed files with 16977 additions and 1736 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
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"
}
}

9
rig/.gitignore vendored
View File

@@ -6,14 +6,7 @@ def
.env.local
ctrl/.env
# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited.
# The .svg IS committed — onboarding material should render in a repo browser.
arch/*.dot
# ctrl/Tiltfile.gen was here for a generator that no longer exists. ctrl/Tiltfile
# is now a real, committed file that derives its values when Tilt parses it, so
# there is nothing generated to ignore.
# binaries pulled by `make deps-bundle` for the air-gapped installer image
# the manifests the air-gapped installer image unpacks (docs/notes/Dockerfile.deps.md)
vendor
# Overlays that live with no version control of their own, or clones of their own

1
rig/.version Normal file
View File

@@ -0,0 +1 @@
0.1.2

View File

@@ -83,9 +83,8 @@ fetches with `curl`. A stock `debian:trixie-slim` has no curl — detection runs
fine, then the first download dies with `curl: command not found` and an exit
code of 127. That is the bootstrap paradox `ctrl/Dockerfile.deps` exists
to kill — the installer carries its own toolchain so the host needs only Docker —
but building the image and running it are two different things, and only the
build has a Makefile target today. **On a genuinely bare machine, run it by
hand:**
but `make deps image` only builds the image; running it is the step below.
**On a genuinely bare machine, run it by hand:**
```bash
make deps image # builds rig-deps:deps
@@ -207,13 +206,13 @@ never tracks it), or keep it anywhere else and name it by path.
## Run it
```bash
make kind-up # idempotent create, then selects the context
make tilt-up # context + your assigned port
make cluster up # idempotent create, then selects the context
make tilt # context + your assigned port
```
`tilt-up` passes `--context kind-<name>` every time, which is the point of going
`make tilt` passes `--context kind-<name>` every time, which is the point of going
through `make` at all: tilt cannot deploy into whichever cluster you last looked
at.
`make tilt-down` and `make kind-down` close the loop, and `make kind-reset` is
`make tilt down` and `make cluster down` close the loop, and `make cluster reset` is
delete-and-recreate for when a cluster wedges.

View File

@@ -1,200 +1,99 @@
# rig
A runnable local model of a large, regulated estate — legacy and new side by
side. Its job is onboarding and exploration, not a production replica: most
services are deliberately mocked, because what has to be faithful is the
topology, not the workloads.
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 **onboarding and exploration**, not a
production replica.
## Prerequisite
**Docker.** Nothing else — no curl, no jq, no python, no apt repositories.
### Starting from plain Windows
Everything here is bash and runs *inside* a Linux shell, so on a Windows machine
that means WSL. Nothing in rig installs WSL, and nothing will: `wsl --install`
enables Windows features and requires a reboot, which is not something a script
should do to a machine on your behalf — and there is no tested undo for it.
From an elevated PowerShell or Command Prompt, once:
Most services in it are **not real**. 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 GB; stubs of about 30 MB each fit
three times.
```powershell
wsl --install
```
Under the hood it is a kind cluster, a local image registry and a Tilt dev loop, each with its
own name and ports, so several environments run side by side. What runs on it is an
**overlay**: a folder outside rig that holds one use case.
Then reboot and open the Linux shell it installed.
## Using rig from a project
**If you cloned this on the Windows side, copy it into WSL before carrying on.**
WSL can reach the Windows drives at `/mnt/c`, and working from there mostly
functions — slowly — but file watching does not: that filesystem raises no
inotify events, so anything watching for edits silently stops seeing them.
You don't need to read rig to use it. Get a release (`rig-<version>.tar.gz`, or a checkout at
the tag `rig-<version>`), extract it anywhere, and point the project at it (`RIG_DIR`). Then run
`make check`: it says what the machine still lacks and which command comes next. `.version`
names the release; a project records the one it was last run against. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
```bash
cp -r /mnt/c/Users/<you>/rig ~/rig
cd ~/rig
```
## Prerequisite
`make deps` reports it if you are running from `/mnt/...`. Then carry on below.
**Docker.** Nothing else: no curl, jq, python or apt repositories.
If it fails, the usual causes give unhelpful messages:
On Windows, rig runs inside WSL. Run `wsl --install` once, from an elevated shell, then reboot.
Work inside the Linux filesystem, not `/mnt/c`: file watching doesn't work there, and
`make deps` warns about it. Git Bash, MSYS and Cygwin don't work (no `/proc`, no docker socket).
| symptom | cause |
| --- | --- |
| "the virtual machine could not be started" | virtualization disabled in BIOS/UEFI |
| the command is not recognised | Windows build too old — needs 2004 or later |
| the command is not recognised | Windows build too old (needs 2004 or later) |
| the install starts, then nothing works | a reboot is still pending |
Running the scripts from **Git Bash, MSYS or Cygwin does not work** — those look
close enough to a Linux shell to get started and then fail without `/proc` or a
docker socket. `ctrl/deps.sh` detects that and says so rather than letting you
find out the slow way.
## Read the docs first
```bash
make docs # serves on localhost, prints the URL
```
They run before anything is installed, which matters because they are the
instructions for everything else. No cluster and no toolchain required.
Why the code is the way it is — the reasoning, measurements and gotchas — lives
in [`docs/notes/`](docs/notes/), one file per script, so the code keeps short comments.
## Then
```bash
make check # is this machine ready? short; `make check all` for every detail
make deps # install the toolchain (add `core` on a managed machine)
make cluster up # cluster + registry + the profile's addons
```
On a machine with nothing but Docker, [BOOTSTRAP.md](BOOTSTRAP.md) runs the toolchain through
the installer container.
That is the whole setup. `make cluster up` also starts this environment's local
registry and wires it into the node, so an image built locally is pullable by the
cluster without going near docker.io. `make check` shows its port, among
everything else:
## What it does
```bash
make check # ... registry localhost:<port> (running)
docker build -t localhost:<port>/app:1 .
docker push localhost:<port>/app:1
kubectl --context kind-$(basename $PWD) run app --image=localhost:<port>/app:1
```
| command | does |
| --- | --- |
| `make docs` | serves the docs locally; works before anything is installed |
| `make check` | is this machine ready? memory, toolchain, cluster, the next command (`check all` for every detail) |
| `make deps` | installs the pinned toolchain (`deps core` on a managed machine) |
| `make cluster up` | the cluster, its registry and the configured addons (`down`, `reset`, `list`, `free`) |
| `make tilt` | the dev loop: rig's part, then the overlay's Tiltfile |
| `make selftest` | checks rig itself |
| `make help` | every target |
The port block is derived from the directory name, so two copies of rig never
collide — nothing to configure. `make check all` lists it; `bash ctrl/ports.sh persist`
pins it into `ctrl/.env` if you want it fixed:
Every verb is a `Makefile` target calling one script in `ctrl/`. The aliases (`kind-up`,
`tilt-up`, …) call the same scripts.
```bash
make cluster list # every cluster on this machine, with memory
make cluster free # stop the others if memory is tight
make cluster down # remove this cluster and its registry
```
## Overlays
**The verbs are yours to change.** `cluster` is the script — `ctrl/cluster.sh` —
and every spelling above is a `Makefile` target that calls it. `make kind-up` is
an alias for `make cluster up`, kept because the other projects on this machine
answer to that spelling and muscle memory spans repos rather than stopping at
one. Nothing outside the `Makefile` reads these names, so rename them, drop the
ones you never type, or add whatever your own projects already say: each alias
is two lines at the bottom of the file, calling the same script the canonical
target does.
**`make tilt` works on a fresh copy, unedited.** rig's `ctrl/Tiltfile` does
rig's part — identity, context guard, registry, the manifests — and then includes
the overlay's own Tiltfile. With no overlay named that is `examples/starter`, so
the dev loop comes up with its two examples running and nothing to configure.
It hardcodes nothing. It asks `ctrl/ports.sh active` for this environment's
cluster name, kube context, ports and paths — the same values every other rig
script resolves through `ctrl/lib/config.sh` — so a copied and renamed rig, or a
moved overlay, deploys into its own cluster with no edits.
`make help` lists every target. `make selftest` checks rig itself, the installer's
detection included; `make selftest install` runs the installer on clean containers
([`docs/notes/installer-testing.md`](docs/notes/installer-testing.md)).
On a machine where Docker really is the only thing installed, `make deps` has
nothing to download with — see [BOOTSTRAP.md](BOOTSTRAP.md), which runs the
toolchain through the installer container and carries on to scaffolding and running
a new project.
## What runs is an overlay
rig is the machine: toolchain, cluster, registry, port block, the dev loop's
plumbing. What runs on it is an **overlay** — one folder, outside rig's version
control, holding a use case: its settings (`rig.env`), its manifests
(`k8s/overlays/dev`), its images and Tiltfile, its addons, its kind config if it
needs its own. rig reads it and never writes into it. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
An overlay holds its settings (`rig.env`), its manifests (`k8s/overlays/dev`), its Tiltfile and
images, its addons, and its kind config if it needs one. rig reads it and never writes into it.
```bash
cp -r examples/starter local/myenv # local/ is gitignored
OVERLAY=local/myenv make cluster up # or OVERLAY=local/myenv in ctrl/.env
cp -r examples/starter local/myenv # local/ is ignored
OVERLAY=local/myenv make cluster up
OVERLAY=local/myenv make tilt
```
An overlay can also be a repo of its own, anywhere, or a project folder that
carries rig at `<project>/rig/` with a three-line forwarding Makefile.
With no overlay named, rig runs `examples/starter`. [`examples/data`](examples/data/) adds
postgres, redis and airflow as plain manifests. A project can carry rig at `./rig`, or keep it
anywhere and name it with `RIG_DIR`.
## One environment per folder
The cluster name, context, image tags and ports derive from the overlay folder's name (rig's
own, with no overlay), so environments never collide, and one's teardown never touches another.
Cluster name, kubectl context, image tags and the host port block all derive
from a folder name — the overlay's when one is named, else rig's own — so copies
never collide and neither one's teardown can touch the other. Two overlays run
side by side from one rig; two copies of rig do too.
## Configuration
## Profiles
Layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile < the overlay's
`rig.env` < `ctrl/.env` < the command line. A profile (`ctrl/env.d/`) is optional and says how
this machine reaches the world: `mirror` (a pull-through cache) and `offline` (air-gapped) ship
as examples. The cluster itself is one kind template: the overlay's, or rig's one-node default.
**rig needs no profile.** With none named it runs on built-in defaults: one node,
no addons, a local registry, the newest Kubernetes version it pins. A profile is
an optional file in `ctrl/env.d/`, named by `PROFILE`, that says how this machine
reaches the world. rig ships two as **examples**; copy one to use it (the copy is
gitignored):
rig's own addons make the cluster work: `metallb` (LoadBalancer addresses), `cert-manager` (a
local CA), `metrics-server` (`kubectl top`). A workload's database, cache or scheduler belongs to
its overlay. There is no ingress controller: services are reached through LoadBalancer
addresses, or through the overlay's own gateway on the environment's HTTP port.
| example | what it changes |
| --- | --- |
| `mirror.env.example` | images through a pull-through cache of an internal registry |
| `offline.env.example` | air-gapped: everything from a preloaded local registry |
## Where things are
```bash
cp ctrl/env.d/mirror.env.example ctrl/env.d/mirror.env
PROFILE=mirror make cluster up
```
The layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile
< the overlay's `rig.env` < `ctrl/.env` < your command line.
The **cluster itself** is one file: the overlay's `kind-config.yaml.tpl` if it has
one, else rig's `ctrl/k8s/kind-config.yaml.tpl` (one node). To change it — more
nodes, other port mappings, mounts — edit it and `make cluster reset`. The node
count is read back out of it, so there is nothing to drift.
## Addons
Each addon is its own idempotent script, and `ADDONS` names the ones to install,
in order. Adding one is adding a file — there is no dispatcher to edit. An
overlay's `addons/<name>.sh` is found before rig's own.
**There is no ingress controller, deliberately.** They pin a narrow window of
Kubernetes versions, so depending on one would constrain which k8s a rig can be
built with — and running a trailing-edge control plane is often the point.
Services are reached through MetalLB and `type: LoadBalancer`, which carries no
such constraint and is also what a real cluster does.
rig's own addons make the cluster work:
| Addon | Does |
| --- | --- |
| `metallb` | gives `type: LoadBalancer` an address it can actually reach |
| `cert-manager` | a local CA, so TLS works offline |
| `metrics-server` | makes `kubectl top` work on kind |
What a workload needs — a database, a cache, a scheduler — belongs to its
overlay. [`examples/data`](examples/data/) carries postgres, redis and airflow as
plain manifests (no helm: a chart repo is a network dependency), with passwords
generated on first install and kept across re-runs:
```bash
OVERLAY=examples/data make cluster up
Makefile the verbs
.version this release: patch and minor steps are edits here; milestones get a tag
ctrl/ one script per verb; lib/config.sh (settings and layers); Tiltfile (rig's
half of the dev loop); k8s/kind-config.yaml.tpl; addons/; env.d/; versions.env
docs/ index.html (make docs); notes/, one per script: why the code is as it is
examples/ starter (the default overlay), data (postgres, redis, airflow)
standalone/ single-file kits generated from ctrl/ (make standalone); never edited by hand
tests/ the installer's host fixtures
BOOTSTRAP.md from a bare machine with only Docker
STALE.md assumptions withdrawn, each checked by selftest
```

View File

@@ -1 +0,0 @@
0.1.0

View File

@@ -1,5 +1,5 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
# Cluster SHAPE: an optional profile in ctrl/env.d/. Architecture MODEL: arch/<name>.json.
# Cluster SHAPE: an optional profile in ctrl/env.d/.
# Notes: docs/notes/env.md
# A profile in ctrl/env.d/ to build. Empty means rig's built-in defaults, which

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Documentation: render the diagrams, and serve the pages from a throwaway nginx container.
# Usage: docs.sh serve | graphs
# Whose: DOCS_DIR, the docs/ of the folder `make docs` runs from (a project's launcher sets
# it); unset, rig's own.
# Notes: docs/notes/docs.md
set -euo pipefail
cd "$(dirname "$0")"
@@ -9,11 +11,13 @@ source ./lib/config.sh
load_config
REPO="$(cd .. && pwd)"
DOCS="${DOCS_DIR:-$REPO/docs}"
[ -d "$DOCS" ] && DOCS="$(cd "$DOCS" && pwd)"
DOCS_PORT="${DOCS_PORT:-$((HTTP_PORT + 4))}" # +4 sits inside this env's block
serve() {
if [ ! -f "$REPO/docs/index.html" ]; then
echo "no docs/index.html" >&2
if [ ! -f "$DOCS/index.html" ]; then
echo "no index.html in $DOCS" >&2
exit 1
fi
# One worker and no access log: a few static pages need no more, and the
@@ -32,13 +36,14 @@ http {
}
NGINX
chmod 644 "$DOCS_CONF"
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
echo "docs of $DOCS"
echo " 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:/usr/share/nginx/html:ro" \
-v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \
nginx:alpine
}
@@ -51,13 +56,13 @@ graphs() {
fi
shopt -s nullglob
local found=0 f out
for f in "$REPO"/docs/graphs/*.dot; do
for f in "$DOCS"/graphs/*.dot; do
out="${f%.dot}.svg"
echo " graphviz $(basename "$f") → $(basename "$out")"
dot -Tsvg "$f" -o "$out"
found=1
done
[ "$found" -eq 1 ] || echo " no .dot files in docs/graphs/"
[ "$found" -eq 1 ] || echo " no .dot files in $DOCS/graphs/"
}
case "${1:-serve}" in

View File

@@ -193,6 +193,13 @@ check "the caller beats rig.env" "from-caller" \
# Identity follows the overlay's folder, so one rig serves several without collisions.
check "identity follows the overlay's folder" "my-env kind-my-env" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $1, $2}')"
mkdir -p "$OVR/local/renamed"; printf 'CLUSTER=pinned\n' > "$OVR/local/renamed/rig.env"
check "rig.env's CLUSTER names it, whatever the folder" "pinned kind-pinned $(derive_port_base pinned)" \
"$(in_ov env OVERLAY=local/renamed bash ports.sh active | awk '{print $1, $2, $3}')"
check "the caller's CLUSTER beats rig.env's" "second kind-second" \
"$(in_ov env OVERLAY=local/renamed CLUSTER=second bash ports.sh active | awk '{print $1, $2}')"
check "make docs serves DOCS_DIR, the folder it runs from" "no index.html in $OVR/local/renamed" \
"$(in_ov env DOCS_DIR="$OVR/local/renamed" bash docs.sh serve 2>&1 >/dev/null | head -1)"
check "paths in rig.env are relative to the overlay" "$OVR/local/My_Env/k8s/prod" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $7}')"
check "a named overlay that does not exist is an error" "yes" \

View File

@@ -386,7 +386,8 @@ docker load &lt; rig.tgz &amp;&amp; make cluster up PROFILE=offline <span clas
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>
browser on Windows has no route to them: use the environment's HTTP port instead, where
an overlay's gateway listens (its kind config maps it to the host).</p>
<h3>Networking and memory</h3>
<p>The cluster uses kind's built-in networking, which does enforce standard
@@ -417,9 +418,9 @@ 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>
addons. A project can carry rig at <code>./rig</code>, or keep it anywhere and name
it with <code>RIG_DIR</code>. <code>.version</code> names the release; the project
records the one it was last run against. 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

View File

@@ -15,7 +15,7 @@ docker build -f ctrl/Dockerfile.deps --target deps -t <slug>-deps .
docker build -f ctrl/Dockerfile.deps --target deps-full -t <slug>-deps:full .
```
`deps-full` bakes every pinned binary in at build time, and the manifests rig's own addons apply (metallb, cert-manager, metrics-server). `docker save` it and you have the whole installer as one file to carry into an air-gapped network. There, put the manifests where the addons look for them:
`make deps image full` builds the second. `deps-full` bakes every pinned binary in at build time, and the manifests rig's own addons apply (metallb, cert-manager, metrics-server). `docker save` it and you have the whole installer as one file to carry into an air-gapped network. There, put the manifests where the addons look for them:
```
docker run --rm -v "$PWD/vendor:/out/vendor" rig-deps:full manifests --to /out/vendor/manifests

View File

@@ -31,7 +31,7 @@ Read positionally, so the order is a contract; ctrl/selftest.sh pins it. The two
`OVERLAY` and `CLUSTER` given as make arguments (`make tilt OVERLAY=local/x`) are handed to that `$(shell ...)` explicitly. Before make 4.4, `$(shell)` runs with make's own environment and does not see command-line variables, while the recipes do: tilt would then be told one context and the Tiltfile would guard on another. An overlay's forwarder avoids the question by putting `OVERLAY` in the environment.
This used to be sed over ctrl/.env plus a slug computed in the Makefile, which is a SECOND derivation of values lib/config.sh already owns, and the two could disagree about the port after `ports.sh persist`, or about the name for any directory whose sanitised form differs from its raw one. One source now; the Tiltfile reads the same line.
One source: lib/config.sh owns these values, and the Tiltfile reads the same line. A second derivation in the Makefile could disagree about the port after `ports.sh persist`, or about the name of any directory whose sanitised form differs from its raw one.
## CLUSTER / KCTX fallback
@@ -39,7 +39,7 @@ The fallback matters: ports.sh sources config.sh, and if a profile or .env is br
## ARGS as .PHONY
Words after the target become the script's subcommand; each gets a no-op rule so make does not treat them as goals. They are also marked PHONY, because some of those words name real directories. `cfg`, `ctrl`, `docs`, `gen` and `init` all exist at this level, and make considers a target that is an existing directory already built, so `make build ctrl` ran the build and then printed "make: 'ctrl' is up to date". The empty rule is not enough on its own; only .PHONY stops make consulting the filesystem.
Words after the target become the script's subcommand; each gets a no-op rule so make does not treat them as goals. They are also marked PHONY, because some of those words name real directories (`ctrl`, `docs`, `examples`), and make considers a target that is an existing directory already built: without it, `make docs ctrl` would print "make: 'ctrl' is up to date". The empty rule is not enough on its own; only .PHONY stops make consulting the filesystem.
## tilt: --port guard

View File

@@ -54,7 +54,7 @@ switch for the host part, so the standalone `rigdeps.sh detect` is short too.
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
its own knob (an airflow sized by the mode the overlay runs it in). 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.

View File

@@ -18,3 +18,12 @@ fails. The pages declare an empty icon, so the browser does not ask for a
Rendered SVGs are committed alongside their `.dot` sources for the same reason:
the pages have to read on a machine with no Graphviz installed.
## Whose docs
`make docs` serves the docs of the folder it runs from. In rig that is rig's own `docs/`. A
project's launcher sets `DOCS_DIR` to its own `docs/` (an absolute path), so `make docs` there
serves the project's pages through this same script, on the same terms: an `index.html` at
the top, the environment's block for the port (`HTTP_PORT + 4`), a container named after the
environment. rig's own docs stay one `make -C <rig> docs` away. The project keeps no copy of
this script.

View File

@@ -2,7 +2,7 @@
## ctrl/.env.example: header
Machine-local config. Copy to ctrl/.env (gitignored) and edit. The cluster SHAPE is an optional profile in ctrl/env.d/ — see the *.env.example there. The architecture MODEL lives in arch/<name>.json — not in .env either.
Machine-local config. Copy to ctrl/.env (gitignored) and edit. The cluster SHAPE is an optional profile in ctrl/env.d/ — see the *.env.example there.
## ctrl/.env.example: CLUSTER

View File

@@ -74,6 +74,36 @@ follow the overlay folder's name, sanitised the same way a rig folder's name is.
One rig can therefore serve several overlays, each in its own cluster, and a
project that carries rig at `./rig` does not name every cluster `rig`.
That is the default. The convention is to pin the name in the overlay's `rig.env`:
```
CLUSTER=<name>
```
The name only has to be unique on the machine: the port block derives from it, so two
overlays with the same name share one cluster and one set of ports. Pinned, it no longer
follows the folder, so moving or renaming folders changes nothing. A second copy of the
same project (another worktree, say) runs beside it by setting its own `CLUSTER=` in the
project's ignored `.env.local`: the caller beats `rig.env`. Ports the workload forwards
itself (a Tiltfile's `port_forwards`) are the overlay's, not the block's, and can still
collide.
A repository that runs on rig keeps it in a `devenv/` folder, with rig as the project's
sibling:
```
devenv/
rig/ rig
<project>/ the forwarder (RIG_DIR ?= $(HERE)/../rig), its knobs, its data
overlay/ what rig reads
```
The overlay folder is then called `overlay/` in every project, so its name says nothing
and `CLUSTER=` in its `rig.env` is required, not optional: left to the default, every such
project would be the cluster `overlay`, on the same ports. The project folder, the cluster
and the workload's namespace are three names: a folder may hold `_`, the other two are DNS
labels.
`ports.sh persist` refuses while an overlay is set: it writes to rig's
`ctrl/.env`, and a pin there would follow every overlay.
@@ -164,17 +194,23 @@ RIG_DIR ?= $(HERE)/rig
override RIG_DIR := $(abspath $(patsubst ~/%,$(HOME)/%,$(RIG_DIR)))
```
`make docs` should serve the project's pages, not rig's: the forwarder names them with
`DOCS_DIR := $(HERE)/docs` (see docs.md). rig's own stay `make -C <rig> docs`.
No search path: an unset `RIG_DIR` means `./rig`, and nothing else is tried. Moving rig
changes nothing in the cluster's name, context or ports; moving or renaming the overlay
folder does.
`VERSION`, at rig's top, names the rig. It travels with the folder, vendored, a
worktree or a plain copy, and needs no git. A project records the one it was last run
against and warns on another; it does not refuse:
`.version`, at rig's top, names the rig. It travels with the folder, vendored, a
worktree, a plain copy or a `git archive` release, and needs no git. Patch and minor
steps are edits to that file, with no tag; a `rig-<version>` tag marks a milestone only.
A project records the version it was last run against and warns on another; it does not
refuse. Releases before 0.1.1 carry `VERSION` instead, so the project reads that as a
fallback:
```make
RIG_REF := 0.1.0
RIG_AT := $(strip $(shell cat '$(RIG_DIR)/VERSION' 2>/dev/null))
RIG_REF := 0.1.1
RIG_AT := $(strip $(shell cat '$(RIG_DIR)/.version' 2>/dev/null || cat '$(RIG_DIR)/VERSION' 2>/dev/null))
ifneq ($(RIG_AT),$(RIG_REF))
$(warning rig at $(RIG_DIR) is $(or $(RIG_AT),unversioned); this overlay was last run against rig $(RIG_REF))
endif
@@ -183,18 +219,3 @@ endif
`rig.env` holds no secrets, by this contract. A repository whose `.gitignore` has a
broad `*.env` (a common secrets rule) would still hide it, so an overlay living in
such a repo re-includes it in its own `.gitignore`: `!rig.env`.
## Moving a copied rig to an overlay
A rig copied into a project and edited there splits cleanly:
| was, in the copy | goes to |
| --- | --- |
| `ctrl/k8s/base`, `ctrl/k8s/overlays` | `k8s/` |
| the workload parts of `ctrl/Tiltfile` | `Tiltfile` (paths now relative to the overlay) |
| `ctrl/env.d/<name>.env` | `rig.env` |
| edits to `ctrl/k8s/kind-config.yaml.tpl` | `kind-config.yaml.tpl` (`${HOST_WORKDIR}` → `${OVERLAY_DIR}`) |
| workload addons | `addons/` |
| Dockerfiles for the workload | beside the Tiltfile |
| `ctrl/.env` | `rig/ctrl/.env` (this machine's; drop a `MANIFESTS_DIR=ctrl/k8s/overlays/dev` line) |
| everything else of rig's | replaced by rig as it is |

View File

@@ -28,7 +28,7 @@ So the loop is generated FROM the list: add a key to `CONFIG_OVERRIDABLE` and th
## one derivation, not three
The Makefile used to compute the cluster name itself and sed `TILT_PORT` out of `ctrl/.env`: a second derivation of values `lib/config.sh` already owns, which could disagree with it after `ports.sh persist`. It now reads `ports.sh active`. Nothing structurally prevents the sed coming back, so the agreement is asserted against the real `make -n` output rather than against the source.
The Makefile reads `ports.sh active` rather than deriving the cluster name and `TILT_PORT` itself: a second derivation of values `lib/config.sh` already owns could disagree with it after `ports.sh persist`. Nothing structurally prevents one coming back, so the agreement is asserted against the real `make -n` output rather than against the source.
`--no-print-directory` and a grep, not `tail -1`: run from `make selftest` this is a RECURSIVE make, and the "Entering/Leaving directory" lines go to STDOUT. `tail -1` then reads "make[1]: Leaving directory ..." and both checks fail, but only when invoked through make, never when the script is run directly. A test that passes one way and fails the other is worse than no test.

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 · "
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():
say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
say(f" open {site_dir / 'index.html'}")
say(f" {path}")
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,12 +30,16 @@ 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)
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,6 +50,17 @@ def main(argv) -> int:
print(f"{runfile.path} — {len(selected)} of {len(runfile.books)} book(s)")
if runfile.reference:
print(f" reference {runfile.reference}")
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
@@ -53,6 +68,8 @@ def main(argv) -> int:
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

View File

@@ -0,0 +1,25 @@
"""What every command does the same way — written once."""
from pathlib import Path
ROOT = __package__.rsplit(".", 1)[0]
PROG = f"python3 -m {ROOT}"
class Abort(Exception):
"""An expected failure. Printed as one line, exit 1 — never a traceback."""
def kb(n: int) -> str:
return f"{n / 1024:.1f} KB"
def load_runfile(path: Path):
from .. import runfile, tokens
if not path.exists():
raise Abort(f"no run file at {path} — see soleprint/common/slice/ui.example.toml")
try:
return runfile.load(path, tokens.available_themes())
except runfile.ConfigError as e:
raise Abort(str(e)) from None

View File

@@ -0,0 +1,81 @@
"""
python3 -m common.slice list every export and part, and what it pulls in
python3 -m common.slice list --json the same, as data (the derived half of a manifest)
The point is the right-hand columns: what taking one piece costs. `Panel` is one
file and `vue`; `TimeSeriesRenderer` brings uplot; `maximize` brings `panel`.
"""
import argparse
import json
from ...theme import bake
from .. import graph
from ..slices import _needs
from .common import PROG, kb
def exports_table() -> list[dict]:
nodes = graph.load()
rows = []
for e in graph.barrel(nodes=nodes):
closure, _ = graph.select([e.name], [], graph.barrel(nodes=nodes), nodes)
rows.append({
"name": e.name,
"kind": "type" if e.is_type else "value",
"file": e.file,
"depends_on": sorted(closure - {e.file}),
"packages": graph.dependencies(closure, nodes),
"tokens": sorted(graph.tokens_read(closure, nodes)),
"bytes": sum(nodes[f].size for f in closure),
})
return rows
def parts_table() -> list[dict]:
parts = bake.load_parts()
rows = []
for name, (css, classes, detects, always) in sorted(parts.items()):
js = (bake.PARTS / f"{name}.js")
js_text = js.read_text() if js.exists() else ""
rows.append({
"name": name,
"always": always,
"depends_on": _needs(name, parts),
"exposes": {"classes": sorted(classes), "detect": sorted(detects)},
"tokens": sorted(set(bake.re.findall(r"var\(\s*(--[\w-]+)", css + js_text))),
"bytes": len(css.encode()) + len(js_text.encode()),
})
return rows
def main(argv, command=None) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} list")
p.add_argument("--json", action="store_true", help="Print the tables as JSON.")
args = p.parse_args(argv)
exports, parts = exports_table(), parts_table()
if args.json:
print(json.dumps({"vue": exports, "plain": parts}, indent=2))
return 0
print(f"target = \"vue\" — common/ui/src ({len(exports)} exports)\n")
print(f" {'export':<22} {'file':<34} {'pulls in':<30} {'packages':<22} size")
for r in exports:
if r["kind"] == "type":
continue
pulls = ", ".join(d.rsplit("/", 1)[-1] for d in r["depends_on"]) or "-"
pkgs = ", ".join(r["packages"]) or "-"
print(f" {r['name']:<22} {r['file']:<34} {pulls:<30} {pkgs:<22} {kb(r['bytes'])}")
types = [r["name"] for r in exports if r["kind"] == "type"]
print(f"\n types ride with their file: {', '.join(types)}")
print(f"\ntarget = \"plain\" — common/theme/parts ({len(parts)} parts)\n")
print(f" {'part':<10} {'needs':<10} {'found by':<40} size")
for r in parts:
found = "always, with any other part" if r["always"] else \
", ".join(["." + c for c in sorted(r["exposes"]["classes"],
key=lambda c: (not c.startswith(r["name"]), c))[:3]]
+ r["exposes"]["detect"])
print(f" {r['name']:<10} {', '.join(r['depends_on']) or '-':<10} {found[:40]:<40} {kb(r['bytes'])}")
return 0

View File

@@ -0,0 +1,71 @@
"""
python3 -m common.slice plexus every plexus -> gen/plexuses/<name>/
python3 -m common.slice plexus NAME [--dest PATH] one, by name
python3 -m common.slice plexus FILE [--only NAME] every [[plexus]] in a run file
… --delivery vendored --notice none|FILE renamed, scrubbed, notice stated
No instance to build first, nothing keyed on configuration elsewhere: a plexus
is its own directory, and this writes one self-contained file from it.
"""
import argparse
import re
from pathlib import Path
from .. import plexus as px
from .common import PROG, Abort, kb, load_runfile
EXTERNAL = re.compile(r"""(?:src|href)=["'](?!#|data:|javascript:)([^"']+)["']""")
def main(argv, command=None) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} plexus")
p.add_argument("target", nargs="?", help="A plexus name, or a run file. Omit for every plexus.")
p.add_argument("--only", action="append", default=[], metavar="NAME",
help="With a run file: just this plexus. Repeatable.")
p.add_argument("--dest", help="Where the .html goes (a file, or a directory for index.html).")
p.add_argument("--delivery", choices=("dependency", "vendored"), default="dependency")
p.add_argument("--notice", help='With --delivery vendored: "none", or a file to head the page.')
p.add_argument("--minify", action="store_true", help="Vue plexus: minify the inlined script.")
args = p.parse_args(argv)
try:
if args.target and args.target.endswith(".toml"):
rf = load_runfile(Path(args.target))
chosen = px.from_runfile(rf.plexuses, rf.path.resolve().parent)
if args.only:
chosen = [x for x in chosen if x.name in set(args.only)]
if not chosen:
raise Abort(f"{args.target} lists no [[plexus]] to build")
else:
names = [args.target] if args.target else px.discover()
chosen = [px.resolve(n, dest=args.dest if args.target else None,
delivery=args.delivery, notice=args.notice,
minify=args.minify) for n in names]
except px.PlexusError as e:
raise Abort(str(e)) from None
failed = 0
for x in chosen:
kind = "vue" if x.is_vue else "template"
try:
built = px.build(x)
path = px.write(built)
except px.PlexusError as e:
failed += 1
print(f" FAIL {x.name:<12} {e}")
for line, term, text in (built.hits if "built" in locals() else [])[:10]:
print(f" line {line} {term!r} {text[:90]}")
continue
external = sorted(set(EXTERNAL.findall(built.html)))
print(f" ok {x.name:<12} {kind:<8} {x.delivery:<10} {kb(len(built.html.encode())):>9} -> {path}")
for note in built.notes:
print(f" {note}")
if external:
print(f" WARNING references outside the file: {', '.join(external[:5])}")
print()
if failed:
print(f"{failed} of {len(chosen)} plexus(es) failed")
return 1
print(f"{len(chosen)} plexus(es) built")
return 0

View File

@@ -0,0 +1,63 @@
"""
python3 -m common.slice reference the ui-reference page of spr's /docs, from common/ui/src
python3 -m common.slice reference --check write nothing; exit 1 if stale, edited or unreconciled
python3 -m common.slice reference --spots every spot a note can go, one per line
python3 -m common.slice reference --scaffold an empty stub for every spot without a note
python3 -m common.slice reference --force overwrite a generated file that was edited by hand
The page is generated; the notes in common/ui/notes are not. See reference.py for
why the two are kept apart, and for the measure each build ends with. To read it,
`make docs` — the one command that launches spr's docs site.
"""
import argparse
from .. import reference as docs
from .common import PROG, Abort
def main(argv, command=None) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} reference")
p.add_argument("--check", action="store_true", help="Write nothing; exit 1 if anything is off.")
p.add_argument("--spots", action="store_true", help="List every spot a note can go.")
p.add_argument("--scaffold", action="store_true", help="Write a stub note for every empty spot.")
p.add_argument("--force", action="store_true", help="Overwrite a hand-edited generated file.")
args = p.parse_args(argv)
rel = docs.SITE.relative_to(docs.graph.COMMON.parent.parent)
notes_rel = docs.NOTES.relative_to(docs.graph.COMMON.parent.parent)
if args.spots or args.scaffold:
inv = docs.inventory()
if args.spots:
have = docs.load_notes()
for spot in docs.spots(inv):
mark = "written" if have.get(spot) else "stub" if spot in have else "-"
print(f"{spot:<22} {mark:<8} {notes_rel}/{spot}.md")
if args.scaffold:
made = docs.scaffold(inv)
print(f"{len(made)} stub(s) written" + (f": {', '.join(made)}" if made else ""))
return 0
res = docs.build(write=not args.check, force=args.force)
for name in res.edited:
print(f" EDITED {rel}/{name} — changed by hand since the last build"
+ ("; overwritten (--force)" if args.force and not args.check else "; left alone"))
for name in (res.written if not args.check else res.stale):
print(f" {'wrote ' if not args.check else 'STALE '} {rel}/{name}")
for name in res.unchanged:
print(f" ok {rel}/{name}")
for name in res.removed:
print(f" {'removed' if not args.check else 'GONE '} {rel}/{name} — no longer generated")
print()
for ok, msg in res.reconciled:
print(f" {'ok ' if ok else 'FAIL'} {msg}")
if res.edited and not args.force:
raise Abort(f"{len(res.edited)} generated file(s) edited by hand — move the text into "
f"{notes_rel}/, or rerun with --force to discard it")
if args.check and not res.ok:
raise Abort(f"the ui reference is not current — run: make reference (in common/ui)")
if not all(ok for ok, _ in res.reconciled):
raise Abort("the reference does not reconcile with what was read")
return 0

View File

@@ -0,0 +1,103 @@
"""
python3 -m common.slice scan <app-dir> [<app-dir> …]
python3 -m common.slice scan <app-dir> --copy <framework-dir>
python3 -m common.slice scan <app-dir> --json
What an app uses of the framework, what a slice of it would hold, and — when the
app keeps a copy of the framework — how that copy compares with spr. Reads only;
nothing is written anywhere.
"""
import argparse
import json
from pathlib import Path
from .. import graph
from .. import scan as scanning
from .common import PROG, Abort
def report(app: Path, aliases, copy: Path | None) -> dict:
nodes = graph.load()
copy = copy or scanning.find_copy(app)
usage = scanning.usage([app], aliases, exclude=[copy] if copy else [], nodes=nodes)
exports = graph.barrel(nodes=nodes)
files = sorted(usage.files - graph.GENERATED - {"base.css"})
closure, unknown = graph.select(sorted(usage.names), files, exports, nodes)
out = {
"app": str(app),
"imports": {"barrel": usage.barrel, "deep": usage.deep},
"names": sorted(usage.names),
"files": sorted(usage.files),
"unknown": sorted(set(unknown) | usage.unknown),
"closure": sorted(closure),
"packages": graph.dependencies(closure, nodes),
"own_tokens": len(usage.tokens),
"copy": None,
}
if copy:
state = scanning.compare(copy)
out["copy"] = {
"path": str(copy),
"stamp": state.stamp,
"stamp_commit_exists": state.stamp_known,
"rows": [{"file": r, "status": s, "detail": d} for r, s, d in state.rows],
}
return out
def show(r: dict) -> None:
print(r["app"])
print(f" imports {r['imports']['deep']} by deep path, {r['imports']['barrel']} through the barrel")
if r["names"]:
print(f" names {', '.join(r['names'])}")
if r["files"]:
print(f" files {', '.join(r['files'])}")
if r["unknown"]:
print(f" UNKNOWN {', '.join(r['unknown'])} (spr has no such export or file)")
pkgs = ", ".join(f"{k} {v}" for k, v in r["packages"].items()) or "none"
print(f" a slice {len(r['closure'])} file(s): {', '.join(r['closure']) or '-'}")
print(f" packages: {pkgs}; {r['own_tokens']} custom properties read by the app itself")
c = r["copy"]
if not c:
print(" copy none found (no `link:` in package.json, no @framework alias)")
return
stamp = c["stamp"]
if stamp:
known = "exists in spr" if c["stamp_commit_exists"] else \
"NOT in spr's history — compared with current spr instead"
print(f" copy {c['path']} (.spr: {stamp.get('mode', '?')} at {stamp.get('sha', '?')}, {known})")
else:
print(f" copy {c['path']} (no .spr stamp)")
rows = [x for x in c["rows"] if x["status"] != "same"]
same = len(c["rows"]) - len(rows)
for x in rows:
print(f" {x['status']:<10} {x['file']:<32} {x['detail']}")
print(f" {same} file(s) identical to spr")
def main(argv, command=None) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} scan")
p.add_argument("apps", type=Path, nargs="+", help="App directories to read.")
p.add_argument("--alias", action="append", default=None,
help="An import specifier that means the framework "
"(default: soleprint-ui, @framework). Repeatable.")
p.add_argument("--copy", type=Path, default=None,
help="The app's framework copy, when it cannot be found automatically.")
p.add_argument("--json", action="store_true", help="Print the report as JSON.")
args = p.parse_args(argv)
aliases = tuple(args.alias) if args.alias else scanning.DEFAULT_ALIASES
reports = []
for app in args.apps:
if not app.is_dir():
raise Abort(f"{app} is not a directory")
reports.append(report(app.resolve(), aliases, args.copy.resolve() if args.copy else None))
if args.json:
print(json.dumps(reports if len(reports) > 1 else reports[0], indent=2))
return 0
for i, r in enumerate(reports):
if i:
print()
show(r)
return 0

View File

@@ -0,0 +1,11 @@
""" python3 -m common.slice selftest — runs selftest.py in a fresh interpreter."""
import subprocess
import sys
from pathlib import Path
SOLEPRINT = Path(__file__).resolve().parents[3]
def main(argv, command=None) -> int:
return subprocess.call([sys.executable, "-m", "common.slice.selftest", *argv], cwd=SOLEPRINT)

View File

@@ -0,0 +1,106 @@
"""
python3 -m common.slice slice <run-file> [--only NAME] [--list] [--force]
python3 -m common.slice check <run-file> [--only NAME]
`slice` writes what is stale and leaves the rest alone; `check` says what
`slice` would do and exits 1 if that is anything at all. `--list` shows every
slice fully resolved — what it selects, what that pulls in, where it goes —
and writes nothing.
"""
import argparse
from pathlib import Path
from .. import slices
from .common import PROG, Abort, kb, load_runfile
def describe(built) -> list[str]:
e = built.entry
lines = [f" {e.line()}"]
if built.selected:
lines.append(f" selected {', '.join(built.selected)}")
lines.append(f" closure {', '.join(built.closure) or '(nothing)'}")
if e.target == "vue":
pkgs = ", ".join(f"{k} {v}" for k, v in built.deps.items()) or "none"
lines.append(f" packages {pkgs}")
source = "library" if e.theme in (None, "library") else f"theme {e.theme}"
lines.append(f" tokens {len(built.carried)} carried, from {source}"
+ (f"; fell back to the library for {', '.join(built.fell_back)}"
if built.fell_back else ""))
if built.missing:
lines.append(f" WARNING read but defined nowhere: {', '.join(built.missing)}")
size = sum(len(t.encode()) for t in built.files.values())
lines.append(f" files {len(built.files)}, {kb(size)}")
for problem in built.blocked:
lines.append(f" BLOCKED {problem}")
for rel, line, term, text in built.hits[:20]:
lines.append(f" {rel}:{line} {term!r} {text[:80]}")
if len(built.hits) > 20:
lines.append(f" … and {len(built.hits) - 20} more")
return lines
def main(argv, command="slice") -> int:
p = argparse.ArgumentParser(prog=f"{PROG} {command}")
p.add_argument("runfile", type=Path, help="The run file (see ui.example.toml).")
p.add_argument("--only", action="append", default=[], metavar="NAME",
help="Just this slice. Repeatable.")
if command == "slice":
p.add_argument("--list", action="store_true", help="Resolve and show; write nothing.")
p.add_argument("--check", action="store_true", help="Same as the `check` command.")
p.add_argument("--force", action="store_true",
help="Overwrite files edited by hand in dest. Never skips the scrub.")
args = p.parse_args(argv)
checking = command == "check" or getattr(args, "check", False)
listing = getattr(args, "list", False)
rf = load_runfile(args.runfile)
try:
chosen = rf.select(args.only)
except ValueError as e:
raise Abort(str(e)) from None
mode = " (check: nothing written)" if checking else " (list: nothing written)" if listing else ""
print(f"{rf.path} — {len(chosen)} slice(s){mode}")
failed = 0
for entry in chosen:
built = slices.build(entry)
steps = slices.plan(entry, built)
print()
print("\n".join(describe(built)))
counts = {k: len(steps.of(k)) for k in ("create", "update", "same", "delete", "conflict")}
print(" plan " + ", ".join(f"{k} {v}" for k, v in counts.items()))
for rel, action, detail in steps.actions:
if action != "same" and (listing or checking or action == "conflict"):
print(f" {action:<9} {rel}" + (f" — {detail}" if detail else ""))
if built.blocked:
failed += 1
continue
if listing:
continue
if checking:
if steps.stale:
failed += 1
print(" STALE run `slice` to bring it up to date")
else:
print(" ok up to date")
continue
try:
changed = slices.apply(entry, built, steps, args.force)
except ValueError as e:
failed += 1
print(f" REFUSED {e}")
continue
if changed:
for line in changed:
print(f" {line}")
else:
print(" ok up to date, nothing written")
print()
if failed:
print(f"{failed} of {len(chosen)} slice(s) did not hold")
return 1
print(f"{len(chosen)} slice(s) " + ("current" if checking else "resolved" if listing else "done"))
return 0

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