Compare commits

..

21 Commits

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

View File

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

5
berth/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

184
build.py
View File

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

View File

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

View File

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

View File

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

View File

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

45
ctrl/ui.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1 +0,0 @@
0.1.0

View File

@@ -155,31 +155,6 @@ $(wordlist 2,$(words $(GOALS)),$(GOALS)):
The cluster is then named after `<project>`, exactly as a copied rig named
`<project>` was, so moving a copied rig to this layout keeps its cluster and ports.
rig can also live outside the project, which then holds only the overlay. The
forwarder names it with one variable, a machine path that is never committed (the
shell, or an ignored `.env.local`), defaulting to the vendored layout:
```make
RIG_DIR ?= $(HERE)/rig
override RIG_DIR := $(abspath $(patsubst ~/%,$(HOME)/%,$(RIG_DIR)))
```
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:
```make
RIG_REF := 0.1.0
RIG_AT := $(strip $(shell 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
```
`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`.

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
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
for pr in spec_problems:
say(f" overlay {pr}")
path = book.write()
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

View File

@@ -0,0 +1,147 @@
"""
Top-level CSS rules, and which of them a page can use.
A plexus used to inline the whole theme contract and every theme — 20 KB of
rules for a page that uses a fraction of them, including component styling for
screens it does not have. This cuts that down, conservatively:
custom properties only the names the page reads, plus what they reach
a rule kept unless the page provably cannot use it: every
class and id in one of its selectors has to appear
somewhere in the page — markup *or* script, since a
page that builds its DOM in JS names its classes there
element selectors kept (h2, button, *): cheap, and a page has elements
@keyframes kept when a kept rule or the page names it
@media / @supports kept when anything inside is
When unsure it keeps. A rule too many costs bytes; a rule too few is a page that
renders wrong, and nothing would say so.
"""
import re
from dataclasses import dataclass
COMMENT = re.compile(r"/\*.*?\*/", re.S)
DECL = re.compile(r"(--[\w-]+)\s*:\s*([^;]+);")
VAR = re.compile(r"var\(\s*(--[\w-]+)")
THEME_ATTR = re.compile(r"""\[data-theme=["']?[\w-]+["']?\]""")
@dataclass
class Rule:
prelude: str # selector list, or an at-rule's head
body: str # what is between the braces
@property
def at(self) -> str:
return self.prelude.split()[0] if self.prelude.startswith("@") else ""
def text(self) -> str:
return f"{self.prelude} {{{self.body}}}"
def rules(css: str) -> list[Rule]:
"""Top-level rules, comments removed. Nested blocks stay inside their body."""
css = COMMENT.sub("", css)
out, i, n = [], 0, len(css)
while i < n:
start = css.find("{", i)
if start < 0:
break
prelude = css[i:start].strip()
depth, j = 1, start + 1
while j < n and depth:
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
j += 1
out.append(Rule(prelude=prelude, body=css[start + 1:j - 1]))
i = j
return out
def only_properties(rule: Rule) -> bool:
"""A block that sets custom properties and nothing else."""
body = rule.body.strip()
return bool(body) and not DECL.sub("", body).strip(" \n\t;")
def declarations(rule: Rule) -> dict[str, str]:
return {name: value.strip() for name, value in DECL.findall(rule.body)}
def _split_selectors(prelude: str) -> list[str]:
parts, depth, cur = [], 0, ""
for ch in prelude:
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
if ch == "," and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
parts.append(cur)
return [p.strip() for p in parts if p.strip()]
def _names(selector: str) -> list[str]:
"""Classes and ids a selector requires, outside :not(…) and attribute brackets."""
s = THEME_ATTR.sub("", selector)
s = re.sub(r":not\([^)]*\)", "", s)
s = re.sub(r"\[[^\]]*\]", "", s)
return re.findall(r"[.#](-?[A-Za-z_][\w-]*)", s)
def matches(prelude: str, page: str) -> bool:
for selector in _split_selectors(prelude):
names = _names(selector)
if all(re.search(rf"(?<![\w-]){re.escape(n)}(?![\w-])", page) for n in names):
return True
return False
def keyframes_name(rule: Rule) -> str:
parts = rule.prelude.split()
return parts[1] if len(parts) > 1 else ""
def shake(css: str, page: str) -> tuple[list[Rule], set]:
"""Rules of `css` the page can use, and the custom properties they read.
Custom-property-only blocks are not decided here — the caller cuts those
by name — so they are returned as they are.
"""
kept, reads = [], set()
candidates = rules(css)
keyframes = [r for r in candidates if r.at == "@keyframes"]
for r in candidates:
if r.at == "@keyframes":
continue
if r.at in ("@media", "@supports"):
inner, inner_reads = shake(r.body, page)
if inner:
kept.append(Rule(prelude=r.prelude, body="\n" + "\n".join(x.text() for x in inner) + "\n"))
reads |= inner_reads
continue
if r.at:
kept.append(r) # @font-face and friends: rare, and never guessed at
continue
if only_properties(r) or matches(r.prelude, page):
kept.append(r)
reads |= set(VAR.findall(r.body))
text = page + "\n".join(r.body for r in kept)
for k in keyframes:
name = keyframes_name(k)
if name and re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", text):
kept.append(k)
reads |= set(VAR.findall(k.body))
return kept, reads
def pretty(rule: Rule) -> str:
"""One rule, re-indented: readable, never minified."""
body = "\n".join(f" {line.strip()}" for line in rule.body.strip().splitlines() if line.strip())
return f"{rule.prelude} {{\n{body}\n}}"

View File

@@ -0,0 +1,201 @@
"""
The import graph of common/ui/src, and the closure of what a selection needs.
Read out of the source with patterns, not a TypeScript parser: the library is
thirty-odd small files, every import is on a line of its own, and a parser would
be a node dependency for a question grep answers. The selftest pins the edges it
must find, so a pattern that stops matching fails loudly.
Three kinds of edge matter, and all three are followed:
import { x } from './a' code, including `import type` — a type-only
import still has to resolve for vue-tsc
import './theme.css' side effects: stylesheets
export { default as X } from './b' the barrel's own re-exports
A bare specifier (`vue`, `uplot/dist/uPlot.min.css`) is a package, not a file.
It becomes a dependency of the slice, with the version common/ui/package.json
pins for it.
"""
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
HERE = Path(__file__).resolve().parent
COMMON = HERE.parent
SRC = COMMON / "ui" / "src"
PACKAGE_JSON = COMMON / "ui" / "package.json"
# The files a slice regenerates rather than copies. The barrel is rebuilt to
# export only the selection; the token file is cut down to what is read.
GENERATED = {"index.ts", "theme.css", "tokens.css"}
# Source that never belongs in a delivery.
SKIP = {"__tests__"}
_STRING = r"""['"]([^'"]+)['"]"""
IMPORT_FROM = re.compile(r"(?:^|\n)\s*import\s+(?:type\s+)?[^'\";]*?\s*from\s*" + _STRING, re.S)
IMPORT_BARE = re.compile(r"(?:^|\n)\s*import\s*" + _STRING)
EXPORT_FROM = re.compile(
r"(?:^|\n)\s*export\s+(type\s+)?\{([^}]*)\}\s*from\s*" + _STRING, re.S)
DYNAMIC = re.compile(r"import\(\s*" + _STRING + r"\s*\)")
CSS_IMPORT = re.compile(r"@import\s+(?:url\()?\s*" + _STRING)
VAR = re.compile(r"var\(\s*(--[\w-]+)")
SCRIPT = re.compile(r"<script\b[^>]*>(.*?)</script>", re.S)
STYLE = re.compile(r"<style\b[^>]*>(.*?)</style>", re.S)
@dataclass
class Node:
"""One file of the library."""
path: str # relative to SRC, posix
imports: set = field(default_factory=set) # other nodes, relative to SRC
packages: set = field(default_factory=set) # bare specifiers, as written
tokens: set = field(default_factory=set) # custom properties it reads
size: int = 0
@dataclass
class Export:
"""One name the barrel exports."""
name: str # what a consumer imports
local: str # what the file exports it as (`default` for SFCs)
file: str # relative to SRC
is_type: bool
def code_of(path: Path, text: str) -> tuple[str, str]:
"""(script, style) text of a file. A .vue file is split into its blocks."""
if path.suffix == ".vue":
return ("\n".join(SCRIPT.findall(text)), "\n".join(STYLE.findall(text)))
if path.suffix == ".css":
return "", text
return text, ""
def package_of(specifier: str) -> str:
"""`@vue-flow/core/dist/style.css` -> `@vue-flow/core`; `uplot` -> `uplot`."""
parts = specifier.split("/")
return "/".join(parts[:2]) if specifier.startswith("@") else parts[0]
def _resolve(importer: str, specifier: str, known: set) -> str | None:
"""A relative specifier -> the SRC-relative file it names, or None."""
base = (Path(importer).parent / specifier).as_posix()
base = str(Path(base)) # normalise ./ and ../ lexically
parts = []
for part in base.split("/"):
if part == "..":
if parts:
parts.pop()
elif part not in ("", "."):
parts.append(part)
base = "/".join(parts)
for candidate in (base, base + ".ts", base + ".vue", base + "/index.ts"):
if candidate in known:
return candidate
return None
def load(src: Path = SRC) -> dict[str, Node]:
"""Every file under src, with its edges."""
files = {
p.relative_to(src).as_posix(): p
for p in sorted(src.rglob("*"))
if p.is_file() and p.suffix in (".ts", ".vue", ".css")
and not SKIP & set(p.relative_to(src).parts)
}
known = set(files)
nodes = {}
for rel, path in files.items():
text = path.read_text()
script, style = code_of(path, text)
node = Node(path=rel, size=len(text.encode()))
specifiers = (IMPORT_FROM.findall(script) + IMPORT_BARE.findall(script)
+ [m[2] for m in EXPORT_FROM.findall(script)]
+ DYNAMIC.findall(script) + CSS_IMPORT.findall(style))
for spec in specifiers:
if spec.startswith("."):
target = _resolve(rel, spec, known)
if target:
node.imports.add(target)
else:
node.packages.add(spec)
if path.suffix == ".vue":
# An SFC compiles to imports from `vue` even when its script names
# none (Panel.vue uses only compiler macros), so it always needs vue.
node.packages.add("vue")
node.tokens = set(VAR.findall(style if path.suffix == ".vue" else text))
nodes[rel] = node
return nodes
def barrel(src: Path = SRC, nodes: dict | None = None) -> list[Export]:
"""What index.ts exports, name by name, in its own order."""
nodes = nodes if nodes is not None else load(src)
text = (src / "index.ts").read_text()
out = []
for block_type, names, spec in EXPORT_FROM.findall(text):
target = _resolve("index.ts", spec, set(nodes))
if target is None:
continue
for item in names.split(","):
item = item.strip()
if not item:
continue
item_type = item.startswith("type ")
item = item[5:].strip() if item_type else item
local, _, alias = item.partition(" as ")
local, alias = local.strip(), alias.strip()
out.append(Export(name=alias or local, local=local, file=target,
is_type=bool(block_type) or item_type))
return out
def closure(start: set, nodes: dict[str, Node]) -> set:
"""Every file reachable from `start` through relative imports."""
seen, stack = set(), list(start)
while stack:
rel = stack.pop()
if rel in seen or rel not in nodes:
continue
seen.add(rel)
stack.extend(nodes[rel].imports - seen)
return seen
def select(names: list[str], files: list[str], exports: list[Export],
nodes: dict[str, Node]) -> tuple[set, list[str]]:
"""Export names and/or SRC-relative files -> (closure, unknown names)."""
by_name = {e.name: e for e in exports}
start, unknown = set(files), []
for name in names:
if name in by_name:
start.add(by_name[name].file)
elif name in nodes:
start.add(name)
else:
unknown.append(name)
unknown += [f for f in files if f not in nodes]
# The library's own theme files are regenerated, never selected into a copy.
return closure(start - GENERATED, nodes) - GENERATED, unknown
def dependencies(files: set, nodes: dict[str, Node],
package_json: Path = PACKAGE_JSON) -> dict[str, str]:
"""The packages a set of files imports, with the version common/ui pins."""
pins = {}
if package_json.exists():
data = json.loads(package_json.read_text())
pins = {**data.get("devDependencies", {}), **data.get("dependencies", {})}
wanted = sorted({package_of(s) for f in files for s in nodes[f].packages})
return {name: pins.get(name, "*") for name in wanted}
def tokens_read(files: set, nodes: dict[str, Node]) -> set:
return {t for f in files for t in nodes[f].tokens}

View File

@@ -0,0 +1,97 @@
"""
The manifest a slice leaves beside its run file, and the `.spr` stamp.
The manifest is what makes a copy checkable. It records, for every file written,
the hash of what was written — so the next run can tell the two kinds of drift
apart:
source moved on the file in dest still matches the manifest, but spr
would now write something else: stale, rewrite it.
edited by hand the file in dest no longer matches the manifest: someone
changed the copy. Refused unless --force, and named, so a
real improvement can be brought back to spr instead of
being overwritten.
A first run with files already in dest has no manifest to trust, so any file
that differs from what would be written counts as edited. That is the case of
adopting an existing copy — mts's framework copy was ahead of spr, and a tool
that overwrote it would have destroyed the only copy of that work.
It carries no timestamp. The same inputs write the same bytes, so a check can
compare files rather than dates. The `.spr` stamp is the exception on purpose:
it keeps ctrl/spr.py's format (with `updated=`), and it is written only when
the slice actually changed.
"""
import hashlib
import json
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from .graph import COMMON
REPO = COMMON.parent.parent # the spr checkout
STAMP = ".spr"
def sha256(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def file_sha(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def git(*args) -> str:
try:
out = subprocess.run(["git", "-C", str(REPO), *args], capture_output=True, text=True)
except FileNotFoundError:
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def source_state(paths) -> dict:
"""The spr commit a slice was cut from, and whether its inputs were dirty."""
rel = [str(Path(p).resolve().relative_to(REPO)) for p in paths if Path(p).exists()]
dirty = bool(git("status", "--porcelain", "--", *rel)) if rel else False
return {"commit": git("rev-parse", "HEAD") or "unknown", "dirty": dirty}
def commit_exists(sha: str) -> bool:
return bool(sha) and bool(git("cat-file", "-t", f"{sha}^{{commit}}"))
def read(path: Path) -> dict | None:
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return None
def write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n")
def stamp(name: str, kind: str, version: str, source: Path) -> str:
"""ctrl/spr.py's stamp, so a dependency slice says where it came from."""
sha = git("rev-parse", "--short", "HEAD") or "unknown"
return (f"name={name}\nversion={version}\ntype={kind}\nsha={sha}\nmode=slice\n"
f"updated={datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}\n"
f"source={source}\n")
def read_stamp(directory: Path) -> dict:
path = directory / STAMP
if not path.exists():
return {}
out = {}
for line in path.read_text().splitlines():
key, sep, value = line.partition("=")
if sep:
out[key.strip()] = value.strip()
return out

View File

@@ -0,0 +1,127 @@
"""
The mapping profile a vendored slice is written through, and the scrub after it.
Three passes, in this order, and the order matters:
1. **Identifiers**, everywhere — code and comments. `SprFeed` becomes whatever
the profile says in the page that calls it and in the file that defines it,
or the rename breaks the code. This goes first so that a longer identifier
(`soleprint-ui`) is replaced whole before a prose term inside it is seen.
2. **Prose terms**, only inside comments, case kept (vein -> connector, Vein ->
Connector). Never in code: a variable that happens to be called `vein` is
an identifier, and renaming it silently is how a working slice breaks.
3. **Scrub**: every `deny` term, case-insensitive, whole words only — so
`sprint` passes and `spr` fails — plus any camelCase identifier that still
starts with `spr`. A hit fails the slice; nothing is written.
The profile is names/corp.json, generic by design, extended by a gitignored
names.local.json beside it and by a run file's own `rename`.
"""
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
HERE = Path(__file__).resolve().parent
PROFILE = HERE / "names" / "corp.json"
LOCAL = HERE / "names" / "names.local.json"
WORD = r"[A-Za-z0-9_$]"
# A camelCase identifier that still carries spr's prefix: sprThing, SprThing.
# Lower case after `spr` is an English word (sprint, spread), not a leak.
SPR_PREFIX = re.compile(r"(?<![A-Za-z0-9_$])[Ss]pr[A-Z]\w*")
COMMENTS = re.compile(
r"/\*.*?\*/" # block comments: CSS, JS, TS, .vue script/style
r"|<!--.*?-->" # HTML and .vue template comments
r"|(?<![:\w'\"/\\])//[^\n]*", # line comments, not the // inside a URL
re.S,
)
@dataclass
class Profile:
identifiers: dict = field(default_factory=dict)
prose: dict = field(default_factory=dict)
deny: list = field(default_factory=list)
def reverse(self) -> dict:
"""Renamed -> original, to read a vendored page back in spr's words."""
return {v: k for k, v in self.identifiers.items()}
def load(renames: dict | None = None) -> Profile:
"""corp.json, then names.local.json, then the run file's own renames."""
data = json.loads(PROFILE.read_text())
profile = Profile(dict(data.get("identifiers", {})), dict(data.get("prose", {})),
list(data.get("deny", [])))
if LOCAL.exists():
local = json.loads(LOCAL.read_text())
profile.identifiers.update(local.get("identifiers", {}))
profile.prose.update(local.get("prose", {}))
profile.deny += [d for d in local.get("deny", []) if d not in profile.deny]
profile.identifiers.update(renames or {})
return profile
def _bounded(term: str) -> re.Pattern:
edge = r"[A-Za-z0-9_$-]" if "-" in term else WORD
return re.compile(rf"(?<!{edge}){re.escape(term)}(?!{edge})")
def _cased(match: str, replacement: str) -> str:
if match.isupper() and len(match) > 1:
return replacement.upper()
if match[:1].isupper():
return replacement[:1].upper() + replacement[1:]
return replacement
def rename(text: str, profile: Profile) -> str:
# Longest first, so `soleprint-ui` goes before any `soleprint` could.
for old in sorted(profile.identifiers, key=len, reverse=True):
text = _bounded(old).sub(profile.identifiers[old], text)
prose = sorted(profile.prose, key=len, reverse=True)
patterns = [(re.compile(rf"(?<!{WORD}){re.escape(t)}(?!{WORD})", re.I), profile.prose[t])
for t in prose]
def in_comment(m: re.Match) -> str:
chunk = m.group(0)
for pattern, replacement in patterns:
chunk = pattern.sub(lambda w: _cased(w.group(0), replacement), chunk)
return chunk
return COMMENTS.sub(in_comment, text)
def scrub(text: str, profile: Profile) -> list[tuple[int, str, str]]:
"""Every surviving term: (line number, term, the line)."""
hits = []
lines = text.splitlines()
patterns = [(t, re.compile(rf"(?<![A-Za-z0-9_$]){re.escape(t)}(?![A-Za-z0-9_$])", re.I))
for t in profile.deny]
for n, line in enumerate(lines, 1):
for term, pattern in patterns:
if pattern.search(line):
hits.append((n, term, line.strip()))
for m in SPR_PREFIX.finditer(line):
hits.append((n, m.group(0), line.strip()))
return hits
def strip_header(text: str, suffix: str) -> str:
"""The file without its leading comment — the provenance a vendored copy drops.
Only the first comment, and only when it is the first thing in the file:
documentation further down is the code's, and stays (through `rename`).
"""
if suffix in (".css", ".ts", ".js"):
stripped = re.sub(r"\A\s*/\*.*?\*/[ \t]*\n?", "", text, count=1, flags=re.S)
if stripped == text:
stripped = re.sub(r"\A(?:\s*//[^\n]*\n)+", "", text, count=1)
return stripped.lstrip("\n")
if suffix in (".vue", ".html"):
return re.sub(r"\A\s*<!--.*?-->[ \t]*\n?", "", text, count=1, flags=re.S)
return text

View File

@@ -0,0 +1,25 @@
{
"_comment": "The mapping profile a vendored slice is written through. Generic on purpose: nothing here names a client. Identifiers are renamed everywhere (code and comments), prose terms only inside comments, and after both the scrub must find none of `deny`. Extend or override per machine in names.local.json beside this file (gitignored), and per slice with the run file's `rename`. When spr's naming index lands, it becomes the source of these tables.",
"identifiers": {
"SprFeed": "LiveFeed",
"sprParams": "renderParams",
"soleprint-ui": "ui-kit"
},
"prose": {
"veins": "connectors",
"vein": "connector",
"shunts": "simulators",
"shunt": "simulator",
"cabinets": "backing services",
"cabinet": "backing service",
"plexuses": "single-file builds",
"plexus": "single-file build",
"soleprint": "the toolkit"
},
"deny": [
"soleprint", "spr", "vein", "veins", "shunt", "shunts", "plexus", "plexuses",
"larder", "artery", "mcrn", "mpr", "mts", "meetus", "doocus", "unt", "nvi", "amar",
"common/theme", "common/ui", "common/slice", "bake.py", "theme:here", "theme:parts",
"theme:baked-defaults"
]
}

View File

@@ -0,0 +1,414 @@
"""
A plexus: one page that carries everything it needs, built with nothing behind it.
make dist every plexus under artery/plexuses -> gen/plexuses/
make dist bundle one, by name
make dist path/to/ui.toml every [[plexus]] a run file lists
A plexus is exported, not served. The output is one index.html with its theme,
its data and its diagram inlined, so it survives a locked-down machine, a zip
attachment and a double-click. It is the output-only / demo-only form of the UI:
the rendered page ships, the source that made it never does.
## What changed when it stopped belonging to a build
It used to be one step of building a whole instance, keyed on which plexuses an
instance's config asked for, and it inlined the entire theme contract and every
theme — 20 KB of rules for a page that reads a fraction of them. Now:
- it is built on its own, from its directory or from a run file;
- the theme it carries is cut to what the page reads (see css.py): the custom
properties it uses and what they reach, per theme it can switch to, and the
rules that can match something on the page;
- parts arrive when the template carries `<!-- theme:here -->`, as in bake;
- `delivery = "vendored"` renames through the mapping profile and scrubs the
result, exactly as a vendored slice is — and needs a `notice`, for the same
reason.
## Two kinds
A template plexus (`app/index.html` with `%%…%%` placeholders) is filled here.
A Vue plexus (`app/package.json`) is an app built on a slice: its `@ui` imports
are sliced from common/ui, `vite build` runs in a temporary directory, and the
emitted script and stylesheet are inlined into one file — no bundler plugin,
because the inlining is a few lines of text work.
"""
import importlib.util
import json
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from ..theme import bake
from . import css, names
from .graph import COMMON
from .manifest import REPO
PLEXUSES = COMMON.parent / "artery" / "plexuses"
THEME = COMMON / "theme"
GRAPHS = REPO / "docs" / "graphs"
GEN = REPO / "gen" / "plexuses"
KEYS = {"name", "source", "dest", "theme", "inline_themes", "delivery", "notice",
"rename", "title", "description", "minify"}
class PlexusError(ValueError):
pass
@dataclass
class Plexus:
name: str
source: Path
dest: Path # the .html written
delivery: str = "dependency"
notice: str | Path | None = None
rename: dict = field(default_factory=dict)
overrides: dict = field(default_factory=dict) # manifest keys a run file replaces
minify: bool = False
@property
def vendored(self) -> bool:
return self.delivery == "vendored"
@property
def is_vue(self) -> bool:
return (self.source / "app" / "package.json").exists()
@dataclass
class Built:
plexus: Plexus
html: str = ""
hits: list = field(default_factory=list)
notes: list = field(default_factory=list)
def discover() -> list[str]:
return sorted(p.parent.name for p in PLEXUSES.glob("*/plexus.json"))
def resolve(name: str, *, source=None, dest=None, delivery="dependency", notice=None,
rename=None, overrides=None, minify=False, base: Path | None = None) -> Plexus:
"""One plexus, every path absolute. Raises PlexusError saying what is wrong."""
base = base or Path.cwd()
src = Path(source) if source else PLEXUSES / name
src = src if src.is_absolute() else (base / src).resolve()
if not (src / "plexus.json").exists():
raise PlexusError(f"no plexus at {src} (have: {', '.join(discover()) or 'none'})")
if dest:
out = Path(dest) if Path(dest).is_absolute() else (base / dest).resolve()
if out.suffix != ".html":
out = out / "index.html"
else:
out = GEN / name / "index.html"
if delivery not in ("dependency", "vendored"):
raise PlexusError(f"{name}: delivery must be dependency or vendored")
if delivery == "vendored":
if notice is None:
raise PlexusError(f"{name}: a vendored plexus needs `notice` — \"none\", or a file "
f"whose text heads the page. The tool never picks the licensing.")
if notice != "none":
notice = Path(notice) if Path(notice).is_absolute() else (base / notice).resolve()
if not notice.is_file():
raise PlexusError(f"{name}: notice {notice} is not a file")
return Plexus(name=name, source=src, dest=out, delivery=delivery, notice=notice,
rename=dict(rename or {}), overrides=dict(overrides or {}), minify=minify)
def from_runfile(tables: list, base: Path) -> list[Plexus]:
"""The raw [[plexus]] tables of a run file, resolved; every problem at once."""
out, problems = [], []
for i, raw in enumerate(tables):
where = f"plexus[{i}]"
if not isinstance(raw, dict) or not isinstance(raw.get("name"), str):
problems.append(f"{where} needs a name")
continue
where = f"plexus {raw['name']!r}"
unknown = sorted(set(raw) - KEYS)
if unknown:
problems.append(f"{where} has unknown key(s) {', '.join(unknown)} — have: "
f"{', '.join(sorted(KEYS))}")
continue
overrides = {k: raw[k] for k in ("theme", "inline_themes", "title", "description") if k in raw}
try:
out.append(resolve(raw["name"], source=raw.get("source"), dest=raw.get("dest"),
delivery=raw.get("delivery", "dependency"), notice=raw.get("notice"),
rename=raw.get("rename"), overrides=overrides,
minify=bool(raw.get("minify", False)), base=base))
except PlexusError as e:
problems.append(str(e))
if problems:
raise PlexusError("; ".join(problems))
return out
def _manifest(p: Plexus) -> dict:
try:
data = json.loads((p.source / "plexus.json").read_text())
except ValueError as e:
raise PlexusError(f"{p.name}: plexus.json is not valid JSON: {e}") from None
data.update(p.overrides)
return data
def _showcase(p: Plexus) -> dict | None:
"""A plexus may ship showcase.py exposing collect(): what the page shows,
produced at build time by the real tools, so it cannot drift from them."""
collector = p.source / "showcase.py"
if not collector.exists():
return None
try:
spec = importlib.util.spec_from_file_location(f"plexus_{p.name}_showcase", collector)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.collect()
except Exception as e: # noqa: BLE001 - a demo panel must not cost the build
return {"_error": f"{type(e).__name__}: {e}"}
def _graph(name: str, theme: str) -> str:
"""A rendered graph, inlined so the page's CSS can recolour it per theme."""
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
return "<p>diagram not rendered — run docs/graphs/render.sh</p>"
def theme_css(page: str, themes: list[str]) -> str:
"""The contract and the listed themes, cut to what `page` can use."""
missing = [t for t in themes if not (THEME / "themes" / f"{t}.css").exists()]
if missing:
raise PlexusError(f"no theme {', '.join(map(repr, missing))}")
contract = (THEME / "tokens.css").read_text()
sheets = {t: (THEME / "themes" / f"{t}.css").read_text() for t in themes}
kept_contract, reads = css.shake(contract, page)
kept_themes = {}
for t, text in sheets.items():
kept_themes[t], more = css.shake(text, page)
reads |= more
reads |= set(css.VAR.findall(page))
# Every declaration that could answer a name, from the contract and from
# each theme's own block — the closure runs over all of them.
root = {}
for r in kept_contract:
if r.prelude == ":root" and css.only_properties(r):
root.update(css.declarations(r))
per_theme = {}
for t, kept in kept_themes.items():
per_theme[t] = {}
for r in kept:
if r.prelude.strip() == f'[data-theme="{t}"]' and css.only_properties(r):
per_theme[t].update(css.declarations(r))
everything = {**root, **{k: v for d in per_theme.values() for k, v in d.items()}}
wanted, stack = set(), list(reads)
while stack:
n = stack.pop()
if n in wanted:
continue
wanted.add(n)
if n in everything:
stack.extend(css.VAR.findall(everything[n]))
out = [":root {"] + [f" {k}: {v};" for k, v in root.items() if k in wanted] + ["}"]
for t, decls in per_theme.items():
chosen = [f" {k}: {v};" for k, v in decls.items() if k in wanted]
if chosen:
out += [f'[data-theme="{t}"] {{'] + chosen + ["}"]
for r in kept_contract:
if not css.only_properties(r):
out.append(css.pretty(r))
for t, kept in kept_themes.items():
for r in kept:
if not (r.prelude.strip() == f'[data-theme="{t}"]' and css.only_properties(r)):
out.append(css.pretty(r))
return "\n".join(out) + "\n"
def _finish(built: Built) -> None:
p = built.plexus
if not p.vendored:
return
profile = names.load(p.rename)
built.html = names.rename(built.html, profile)
if isinstance(p.notice, Path):
built.html = f"<!--\n{p.notice.read_text().rstrip()}\n-->\n" + built.html
built.hits = names.scrub(built.html, profile)
def build(p: Plexus) -> Built:
return build_vue(p) if p.is_vue else build_template(p)
def build_template(p: Plexus) -> Built:
built = Built(plexus=p)
manifest = _manifest(p)
template = p.source / "app" / "index.html"
if not template.exists():
raise PlexusError(f"{p.name}: no app/index.html")
theme = manifest.get("theme", "soleprint")
themes = manifest.get("inline_themes") or [theme]
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
showcase = _showcase(p)
if showcase is not None:
data["showcase"] = showcase
today = date.today().isoformat()
page = template.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", p.name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"built {today}" + ("" if p.vendored else " by soleprint")),
("%%GRAPH%%", _graph(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
# Parts, when the template asks for them the way a page does.
if bake.ANCHOR in page:
parts = bake.load_parts()
wanted = bake.parts_used(page, parts)
if wanted:
block = bake.parts_block(wanted, parts, "")
page = page.replace(bake.ANCHOR, bake.ANCHOR + "\n" + block, 1)
built.html = page.replace("%%THEME_CSS%%", theme_css(page, themes))
# Anything else in app/ rides along, for a plexus that outgrows one file.
extras = [x.name for x in (p.source / "app").iterdir() if x.name != "index.html"]
if extras:
built.notes.append(f"also copied beside it: {', '.join(extras)}")
_finish(built)
return built
# ── Vue ──────────────────────────────────────────────────────────────────────
SCRIPT_TAG = re.compile(r'<script type="module"[^>]*\ssrc="([^"]+)"[^>]*></script>')
STYLE_TAG = re.compile(r'<link rel="stylesheet"[^>]*\shref="([^"]+)"[^>]*>')
VITE_CONFIG = """import vue from '@vitejs/plugin-vue'
export default {{
root: {root!r},
base: './',
plugins: [vue()],
cacheDir: {cache!r},
resolve: {{ alias: {{ '@ui': {ui!r} }} }},
build: {{
outDir: {out!r},
emptyOutDir: true,
assetsInlineLimit: 100000000,
cssCodeSplit: false,
modulePreload: false,
minify: {minify},
rollupOptions: {{ output: {{ inlineDynamicImports: true }} }},
}},
}}
"""
def _run(cmd: list, cwd: Path) -> None:
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if result.returncode != 0:
tail = (result.stdout + result.stderr).strip().splitlines()[-12:]
raise PlexusError(f"`{' '.join(cmd)}` failed:\n " + "\n ".join(tail))
def build_vue(p: Plexus) -> Built:
from . import runfile, slices
built = Built(plexus=p)
manifest = _manifest(p)
app = p.source / "app"
with tempfile.TemporaryDirectory(prefix=f"plexus-{p.name}-") as t:
tmp = Path(t)
work = tmp / "app"
shutil.copytree(app, work, ignore=shutil.ignore_patterns("node_modules", "dist"))
# The app's @ui imports, sliced from common/ui into the build — the
# same closure a consumer would receive, and nothing else.
# The slice follows the plexus's delivery: a vendored page must not
# carry the provenance headers a dependency slice keeps. Its licensing
# is the page's `notice`, stated once, so the inner slice says none.
entry = runfile.Slice(name=p.name, target="vue", delivery=p.delivery,
dest=work / "ui", manifest=tmp / "ui.slice.json",
layout="package", scan=(work / "src",), alias=("@ui",),
theme=manifest.get("theme"), rename=p.rename,
notice="none" if p.vendored else None)
sliced = slices.build(entry)
if sliced.blocked:
raise PlexusError(f"{p.name}: the slice would not build: {'; '.join(sliced.blocked)}")
slices.apply(entry, sliced, slices.plan(entry, sliced), force=True)
built.notes.append(f"slice: {', '.join(sliced.closure)} ({', '.join(sliced.deps)})")
# pnpm 10+ exits non-zero when a dependency's build script is not
# allowed, and every Vite build needs esbuild's. An app that says
# nothing gets the one allowance it cannot build without.
if not (work / "pnpm-workspace.yaml").exists():
(work / "pnpm-workspace.yaml").write_text("allowBuilds:\n esbuild: true\n")
installed = subprocess.run(["pnpm", "install", "--offline"], cwd=work,
capture_output=True, text=True)
if installed.returncode != 0:
_run(["pnpm", "install", "--prefer-offline"], work)
(work / "vite.plexus.config.mjs").write_text(VITE_CONFIG.format(
root=str(work), cache=str(tmp / ".vite"), ui=str(work / "ui" / "src"),
out=str(tmp / "dist"), minify="true" if p.minify else "false"))
_run([str(work / "node_modules" / ".bin" / "vite"), "build",
"--config", "vite.plexus.config.mjs", "--logLevel", "warn"], work)
dist = tmp / "dist"
page = (dist / "index.html").read_text()
def script(m):
code = (dist / m.group(1)).read_text().replace("</script", "<\\/script")
return f'<script type="module">\n{code}\n</script>'
def style(m):
return f"<style>\n{(dist / m.group(1)).read_text()}\n</style>"
page = SCRIPT_TAG.sub(script, page)
page = STYLE_TAG.sub(style, page)
left = re.findall(r'(?:src|href)="\./[^"]+"', page)
if left:
raise PlexusError(f"{p.name}: not inlined: {', '.join(left)}")
title = manifest.get("title", p.name)
page = re.sub(r"<title>.*?</title>", f"<title>{title}</title>", page, count=1, flags=re.S)
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# The page's own data, for an app that wants it, under a name that says
# what it is to whoever opens the file.
page = page.replace("</head>", f"<script>window.PAGE_DATA = {json.dumps(data)}</script>\n</head>", 1)
built.html = page
_finish(built)
return built
def write(built: Built) -> Path:
p = built.plexus
if built.hits:
raise PlexusError(f"{p.name}: scrub found {len(built.hits)} spr word(s) in a vendored "
f"plexus — nothing written")
p.dest.parent.mkdir(parents=True, exist_ok=True)
p.dest.write_text(built.html)
if not p.is_vue:
for extra in (p.source / "app").iterdir():
if extra.name == "index.html":
continue
target = p.dest.parent / extra.name
if extra.is_dir():
shutil.copytree(extra, target, dirs_exist_ok=True)
else:
shutil.copy2(extra, target)
return p.dest

View File

@@ -0,0 +1,454 @@
"""
The UI framework's reference, generated from its source into spr's docs site.
python3 -m common.slice reference (from soleprint/; or make reference in common/ui)
make docs serves it, with the rest of spr's docs
It is a page of the `/docs` minisite (`docs/data/en/ui-reference.md`), because in
soleprint `make docs` always means one thing: launch that site. A component does
not grow a second documentation tree of its own.
The same bargain docgen makes, done here without docgen — docgen is a copy-out
tool with its own release cycle, and a library's reference should not wait on it:
**The source is read, never described.** Every prop, model, emit, slot, file,
package and token in the reference comes from graph.py and surface.py reading
common/ui/src. A prop that is removed leaves the reference on the next build.
**One format in the middle.** `inventory.json` is the only thing extraction
produces; the reference is rendered from it and nothing else. It has docgen's
three keys (`meta`, `nodes`, `edges`), stable ids, a closed `kind` vocabulary per
node, and no visual information at all — so the web view that comes later, or
docgen itself, reads the same file instead of re-reading the source.
**Both ends are measured.** The larder says what came in — files seen, read, and
failed **by name**; the book says what came out, and is reconciled against it.
"33 files in, 34 exports documented, nothing lost" is a fact. A clean reference
over an incomplete read is a lie by omission.
**Generated base, hand-written notes.** Prose only a person can write — why a
thing exists, what replaced what, who actually uses it — lives in
`common/ui/notes/<spot>.md`, beside the library, and is placed above its spot on every build. A note whose
spot is gone is reported and fails the check; it is never dropped.
**A rebuild replaces exactly what the last one wrote.** `docs/.generated.json`
records every generated file and its hash. A generated file edited by hand is
refused, not overwritten, until `--force` — the same rule a slice's dest keeps.
"""
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from ..theme import bake
from . import graph, surface, tokens
SITE = graph.COMMON.parent.parent / "docs" # spr's /docs minisite
PAGE = "data/en/ui-reference.md" # its id in topics.json: ui-reference
INVENTORY = "data/ui-inventory.json"
MANIFEST = "data/.ui-reference.generated.json"
NOTES = graph.COMMON / "ui" / "notes"
SCHEMA_VERSION = "1"
# The groups the library is laid out in, and what a file in each one is. A file
# at the top of src/ is the library's own plumbing: barrel and stylesheets.
KINDS = {
"components": "component",
"renderers": "renderer",
"datasources": "datasource",
"composables": "composable",
"plugins": "plugin",
}
ORDER = list(KINDS) + [""]
STUB = "<!-- note: {spot} — write below this line, or replace it; a stub renders nothing -->\n"
SPOTS_FIXED = ("_intro", "larder", "contents", "types", "tokens", "book", "_end")
# ── extraction → inventory ────────────────────────────────────────────────────
def _group(rel: str) -> str:
return rel.split("/", 1)[0] if "/" in rel else ""
def _kind(rel: str) -> str:
if "/" not in rel:
return "stylesheet" if rel.endswith(".css") else "barrel"
return KINDS.get(_group(rel), "module")
def _bindings(nodes: dict) -> dict[str, list[str]]:
"""Library file -> the plain parts derived from it, read from each part's header.
A part says where it came from in its own header ("Derived from
common/ui/src/components/Panel.vue"), so the link is kept by the part,
not by a table here that would go stale.
"""
by_base = {}
for rel in nodes:
by_base.setdefault(rel.rsplit("/", 1)[-1], []).append(rel)
out: dict[str, set] = {}
for name in bake.load_parts():
text = ""
for ext in ("css", "js"):
p = bake.PARTS / f"{name}.{ext}"
if p.exists():
t = p.read_text()
text += t[: t.find("*/")] if "*/" in t else ""
found = set(re.findall(r"common/ui/src/([\w./-]+\.(?:vue|ts|css))", text))
m = re.search(r"Derived from ([^\n]*(?:\n \*[^\n]*){0,2})", text)
if m:
for base in re.findall(r"\b([A-Za-z]\w*\.(?:vue|ts|css))\b", m.group(1)):
if len(by_base.get(base, [])) == 1:
found.add(by_base[base][0])
for rel in found:
if rel in nodes:
out.setdefault(rel, set()).add(name)
return {k: sorted(v) for k, v in out.items()}
def inventory(src: Path = graph.SRC) -> dict:
"""The one document everything else is rendered from."""
nodes = graph.load(src)
exports = graph.barrel(src, nodes)
surfaces = surface.load(src)
bindings = _bindings(nodes)
library = tokens.source(None)
theme = tokens.source("soleprint")
seen = sorted(p.relative_to(src).as_posix() for p in src.rglob("*")
if p.is_file() and not graph.SKIP & set(p.relative_to(src).parts))
failed = [{"name": rel, "error": f"no reader for {Path(rel).suffix or 'a file with no suffix'}"}
for rel in seen if rel not in nodes]
failed += [{"name": s.file, "error": s.error} for s in surfaces.values() if s.error]
out_nodes, edges = [], []
for rel, n in sorted(nodes.items()):
s = surfaces.get(rel)
attrs = {"path": rel, "bytes": n.size, "group": _group(rel)}
if s and s.summary:
attrs["summary"] = s.summary
if s and rel.endswith(".vue"):
attrs["props"] = [vars(p) for p in s.props]
attrs["models"] = [vars(m) for m in s.models]
attrs["emits"] = [vars(e) for e in s.emits]
attrs["slots"] = list(s.slots)
if s and s.declarations:
attrs["declarations"] = [{"kind": k, "name": nm, "doc": d} for k, nm, d in s.declarations]
if s and s.error:
attrs["error"] = s.error
out_nodes.append({"id": f"file:{rel}", "kind": _kind(rel), "label": rel.rsplit("/", 1)[-1],
"parent": None, "attrs": attrs})
for target in sorted(n.imports):
edges.append({"source": f"file:{rel}", "target": f"file:{target}", "kind": "imports", "attrs": {}})
for pkg in sorted({graph.package_of(p) for p in n.packages}):
edges.append({"source": f"file:{rel}", "target": f"package:{pkg}", "kind": "requires", "attrs": {}})
for tok in sorted(n.tokens):
edges.append({"source": f"file:{rel}", "target": f"token:{tok}", "kind": "reads", "attrs": {}})
for e in exports:
closure, _ = graph.select([e.name], [], exports, nodes)
out_nodes.append({"id": f"export:{e.name}", "kind": "type" if e.is_type else "export",
"label": e.name, "parent": f"file:{e.file}",
"attrs": {"file": e.file, "local": e.local,
"closure": sorted(closure),
"packages": graph.dependencies(closure, nodes),
"tokens": sorted(graph.tokens_read(closure, nodes)),
"bytes": sum(nodes[f].size for f in closure)}})
for pkg, version in sorted(graph.dependencies(set(nodes), nodes).items()):
out_nodes.append({"id": f"package:{pkg}", "kind": "package", "label": pkg,
"parent": None, "attrs": {"version": version}})
names = set(library.table) | set().union(*(n.tokens for n in nodes.values()))
for name in sorted(names):
lib, thm = tokens.resolved(library, name), tokens.resolved(theme, name)
out_nodes.append({"id": f"token:{name}", "kind": "token", "label": name, "parent": None,
"attrs": {"library": lib, "theme": thm}})
for rel, parts in sorted(bindings.items()):
for part in parts:
if not any(n["id"] == f"part:{part}" for n in out_nodes):
out_nodes.append({"id": f"part:{part}", "kind": "part", "label": part, "parent": None,
"attrs": {"path": f"common/theme/parts/{part}"}})
edges.append({"source": f"part:{part}", "target": f"file:{rel}", "kind": "binds", "attrs": {}})
larder = {"kind": "vue-library", "identity": "common/ui/src", "unit": "file",
"seen": len(seen), "failed": failed,
"extra": {"exports": sum(1 for e in exports if not e.is_type),
"types": sum(1 for e in exports if e.is_type)}}
return {"meta": {"source": "common/ui/src", "schema_version": SCHEMA_VERSION,
"generator": "common.slice docs", "larder": larder},
"nodes": out_nodes, "edges": edges}
# ── the book measure: what came out, reconciled against what came in ──────────
def reconcile(inv: dict, notes: dict[str, str]) -> list[tuple[bool, str]]:
larder = inv["meta"]["larder"]
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
files = [n for n in inv["nodes"] if n["id"].startswith("file:")]
values = [n for n in inv["nodes"] if n["kind"] == "export"]
types = [n for n in inv["nodes"] if n["kind"] == "type"]
vue = [n for n in files if n["attrs"]["path"].endswith(".vue")]
unparsed = [n["attrs"]["path"] for n in vue if "error" in n["attrs"]]
orphans = sorted(set(notes) - set(spots(inv)))
return [
(read == len(files), f"{read} file(s) read produced {len(files)} file node(s)"),
(len(values) == larder["extra"]["exports"] and len(types) == larder["extra"]["types"],
f"{len(values)} export(s) and {len(types)} type(s) in the barrel, each with a node"),
(not unparsed, f"{len(vue) - len(unparsed)} of {len(vue)} component(s) have their surface read"
+ (f" — not: {', '.join(unparsed)}" if unparsed else "")),
(not orphans, "every note lands on a spot" if not orphans
else f"{len(orphans)} note(s) with no spot: {', '.join(orphans)}"),
]
# ── notes ─────────────────────────────────────────────────────────────────────
def spots(inv: dict) -> list[str]:
"""Every place a note can go. Exports by name; the fixed sections by role."""
named = [n["label"] for n in inv["nodes"] if n["kind"] == "export"]
return list(SPOTS_FIXED[:3]) + named + list(SPOTS_FIXED[3:])
def load_notes(notes_dir: Path = NOTES) -> dict[str, str]:
"""spot -> the note's text. A stub (only the scaffold comment) counts as empty."""
out = {}
if notes_dir.is_dir():
for p in sorted(notes_dir.glob("*.md")):
body = re.sub(r"<!--.*?-->", "", p.read_text(), flags=re.S).strip()
out[p.stem] = body
return out
def scaffold(inv: dict, notes_dir: Path = NOTES) -> list[str]:
"""Write an empty stub for every spot that has no note. Never overwrites."""
notes_dir.mkdir(parents=True, exist_ok=True)
made = []
for spot in spots(inv):
p = notes_dir / f"{spot}.md"
if not p.exists():
p.write_text(STUB.format(spot=spot))
made.append(spot)
return made
# ── the reference ─────────────────────────────────────────────────────────────
def _cell(text) -> str:
return str(text).replace("|", "\\|").replace("\n", " ") if text not in (None, "") else "—"
def _code(text) -> str:
return f"`{_cell(text)}`" if text not in (None, "") else "—"
def render(inv: dict, notes: dict[str, str]) -> str:
by_id = {n["id"]: n for n in inv["nodes"]}
out_edges: dict[str, list] = {}
for e in inv["edges"]:
out_edges.setdefault(e["source"], []).append(e)
binds: dict[str, list] = {}
for e in inv["edges"]:
if e["kind"] == "binds":
binds.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
larder = inv["meta"]["larder"]
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
L = []
def note(spot):
if notes.get(spot):
L.extend([notes[spot], ""])
L += ["# 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.", ""]
note("_intro")
L += ["## What came in", ""]
note("larder")
L += [f"`{larder['identity']}` — **{larder['seen']} file(s) seen, {read} read, "
f"{len(larder['failed'])} failed**; {larder['extra']['exports']} exports and "
f"{larder['extra']['types']} types in the barrel.", ""]
if larder["failed"]:
L += ["| file | why it is not (fully) read |", "| --- | --- |"]
L += [f"| `{f['name']}` | {_cell(f['error'])} |" for f in larder["failed"]]
L.append("")
exports = [n for n in inv["nodes"] if n["kind"] == "export"]
L += ["## Contents", ""]
note("contents")
L += ["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 |",
"| --- | --- | --- | --- | --- | --- | --- |"]
for n in exports:
a = n["attrs"]
f = by_id[f"file:{a['file']}"]
pulls = ", ".join(p.rsplit("/", 1)[-1] for p in a["closure"] if p != a["file"]) or "—"
L.append(f"| [`{n['label']}`](#{n['label'].lower()}) | {f['kind']} | `{a['file']}` | {pulls} | "
f"{', '.join(a['packages']) or '—'} | {a['bytes'] / 1024:.1f} KB | "
f"{', '.join(binds.get(f['id'], [])) or '—'} |")
L.append("")
for group in ORDER:
members = [n for n in exports if _group(n["attrs"]["file"]) == group]
if not members:
continue
L += [f"## {group.capitalize() if group else 'Library'}", ""]
for n in members:
a = n["attrs"]
f = by_id[f"file:{a['file']}"]
fa = f["attrs"]
L += [f"### {n['label']}", ""]
if fa.get("summary"):
L += [fa["summary"], ""]
note(n["label"])
meta = [f"`{a['file']}` · {a['bytes'] / 1024:.1f} KB with its closure",
f"packages: {', '.join(f'`{p}` {v}' for p, v in a['packages'].items()) or 'none'}"]
if binds.get(f["id"]):
meta.append("plain part: " + ", ".join(f"`{p}`" for p in binds[f["id"]]))
L += [" · ".join(meta), ""]
if fa.get("error"):
L += [f"**Not fully read:** {fa['error']}", ""]
if fa.get("props"):
L += ["| prop | type | required | default | |", "| --- | --- | --- | --- | --- |"]
L += [f"| `{p['name']}` | {_code(p['type'])} | {'yes' if p['required'] else 'no'} | "
f"{_code(p['default'])} | {_cell(p['doc'])} |" for p in fa["props"]]
L.append("")
if fa.get("models"):
L += ["| v-model | type | default |", "| --- | --- | --- |"]
L += [f"| `v-model:{m['name']}` | {_code(m['type'])} | {_code(m['default'])} |"
for m in fa["models"]]
L.append("")
if fa.get("emits"):
L += ["| emits | payload | |", "| --- | --- | --- |"]
L += [f"| `{e['name']}` | {_code(e['type'])} | {_cell(e['doc'])} |" for e in fa["emits"]]
L.append("")
if fa.get("slots"):
L += ["slots: " + ", ".join(f"`#{s}`" for s in fa["slots"]), ""]
# A .ts export's own doc comment (a class or function's) is its
# description; an SFC's is the file summary, already printed above.
for d in (d for d in fa.get("declarations", []) if d["name"] == a["local"]):
if d["doc"] and d["doc"] != fa.get("summary"):
L += [d["doc"], ""]
if a["tokens"]:
L += ["reads: " + " ".join(f"`{t}`" for t in a["tokens"]), ""]
types = [n for n in inv["nodes"] if n["kind"] == "type"]
L += ["## Types", ""]
note("types")
L += ["Types ride with the file that declares them: taking the export takes its types.", "",
"| type | declared in | |", "| --- | --- | --- |"]
for n in types:
f = by_id[f"file:{n['attrs']['file']}"]
decl = next((d for d in f["attrs"].get("declarations", []) if d["name"] == n["attrs"]["local"]), None)
L.append(f"| `{n['label']}` | `{n['attrs']['file']}` | {_cell(decl['kind'] if decl else '')} |")
L.append("")
toks = [n for n in inv["nodes"] if n["kind"] == "token"]
readers: dict[str, list] = {}
for e in inv["edges"]:
if e["kind"] == "reads":
readers.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
L += ["## Tokens", ""]
note("tokens")
L += ["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 |", "| --- | --- | --- | --- |"]
for n in toks:
a = n["attrs"]
flag = " ⚠" if a["library"] and a["theme"] and a["library"] != a["theme"] else ""
who = ", ".join(sorted(set(readers.get(n["id"], [])))) or "*nobody*"
L.append(f"| `{n['label']}`{flag} | {_code(a['library'])} | {_code(a['theme'])} | {who} |")
L.append("")
L += ["## What came out", ""]
note("book")
kinds: dict[str, int] = {}
for n in inv["nodes"]:
kinds[n["kind"]] = kinds.get(n["kind"], 0) + 1
ekinds: dict[str, int] = {}
for e in inv["edges"]:
ekinds[e["kind"]] = ekinds.get(e["kind"], 0) + 1
L += ["nodes: " + " · ".join(f"{v} {k}" for k, v in sorted(kinds.items())),
"", "edges: " + " · ".join(f"{v} {k}" for k, v in sorted(ekinds.items())), ""]
L += [f"- {'ok ' if ok else '**FAIL**'} {msg}" for ok, msg in reconcile(inv, notes)]
L.append("")
note("_end")
return "\n".join(L).rstrip() + "\n"
# ── writing, and checking without writing ─────────────────────────────────────
@dataclass
class Result:
written: list = field(default_factory=list)
unchanged: list = field(default_factory=list)
stale: list = field(default_factory=list)
edited: list = field(default_factory=list)
removed: list = field(default_factory=list)
reconciled: list = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.stale and not self.edited and all(ok for ok, _ in self.reconciled)
def _sha(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def outputs(inv: dict, notes: dict[str, str]) -> dict[str, str]:
return {INVENTORY: json.dumps(inv, indent=2, sort_keys=True) + "\n",
PAGE: render(inv, notes)}
def build(docs: Path = SITE, *, write: bool, force: bool = False, src: Path = graph.SRC,
notes_dir: Path = NOTES) -> Result:
inv = inventory(src)
notes = load_notes(notes_dir)
files = outputs(inv, notes)
res = Result(reconciled=reconcile(inv, notes))
manifest = docs / MANIFEST
previous = json.loads(manifest.read_text()).get("files", {}) if manifest.exists() else {}
for name, text in files.items():
path = docs / name
on_disk = path.read_text() if path.exists() else None
if on_disk is not None and name in previous and _sha(on_disk) != previous[name]:
# Somebody edited a generated file. That text would be lost on the
# next build, so it is named and refused rather than overwritten.
res.edited.append(name)
if not force:
continue
if on_disk == text:
res.unchanged.append(name)
continue
res.stale.append(name)
if write:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
res.written.append(name)
gone = [n for n in previous if n not in files and (docs / n).exists()]
res.removed = gone
if write and (not res.edited or force):
for n in gone:
(docs / n).unlink()
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text(json.dumps({"generator": "common.slice reference",
"files": {n: _sha(t) for n, t in files.items()}},
indent=2, sort_keys=True) + "\n")
if res.written:
res.stale = []
return res

View File

@@ -0,0 +1,287 @@
"""
A run file: every slice (and plexus) one consumer takes from spr, and how.
python3 -m common.slice slice ui.toml # write what is stale
python3 -m common.slice slice ui.toml --list # resolved; writes nothing
python3 -m common.slice check ui.toml # exit 1 if anything is stale
This module only loads and validates. Same three rules as docgen's and theme's
run files, so spr has one convention for "rebuild these, with these settings":
- **Paths are relative to the run file**, not to where the command runs.
- **An entry's value replaces the default**, for every key.
- **Unknown keys are refused**, and every problem is reported in one pass.
## The shape
[defaults]
delivery = "dependency"
[[slice]]
name = "app-ui"
target = "vue" # or "plain"
dest = "../framework" # where the files land
scan = ["src"] # read the consumer's own imports / markup
use = ["Panel"] # and/or name what to take
delivery = "vendored" # needs `notice`
notice = "none" # or a path: copied into dest as-is
## Where it lives
Beside the consumer, never in spr: it names the consumer's paths. For a vendored
slice keep it — and the manifest it writes — outside the consumer's repository,
with `dest` pointing in. `ui.example.toml` beside this file is the committed one.
"""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
tomllib = None
TOP_KEYS = {"defaults", "slice", "plexus"}
SLICE_KEYS = {"name", "target", "delivery", "layout", "dest", "use", "scan", "alias", "theme",
"tokens", "package", "notice", "header", "manifest", "rename"}
DEFAULT_KEYS = SLICE_KEYS - {"name", "dest", "manifest"}
TARGETS = ("vue", "plain")
DELIVERIES = ("dependency", "vendored")
LAYOUTS = ("package", "flat")
VUE_ONLY = {"layout", "alias", "package"}
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
TOKEN = re.compile(r"^--[A-Za-z0-9_-]+$")
class ConfigError(ValueError):
"""A run file that cannot be run. Carries every problem, not the first."""
def __init__(self, path, problems: list[str]):
self.path, self.problems = path, problems
super().__init__(f"{path}: {len(problems)} problem(s)\n " + "\n ".join(problems))
@dataclass
class Slice:
"""One slice, fully resolved — nothing relative, nothing defaulted later."""
name: str
target: str
delivery: str
dest: Path
manifest: Path
layout: str = "package"
use: tuple = ()
scan: tuple = ()
alias: tuple = ("soleprint-ui", "@framework")
theme: str | None = None
tokens: dict = field(default_factory=dict)
package: bool | str = False
notice: str | Path | None = None
header: str | None = None
rename: dict = field(default_factory=dict)
@property
def vendored(self) -> bool:
return self.delivery == "vendored"
def line(self) -> str:
return f"{self.name:<16} {self.target:<5} {self.delivery:<10} -> {self.dest}"
@dataclass
class RunFile:
path: Path
slices: list = field(default_factory=list)
plexuses: list = field(default_factory=list) # raw tables; plexus.py resolves them
def select(self, names) -> list[Slice]:
if not names:
return list(self.slices)
known = {s.name for s in self.slices}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(self.path, [f"no slice named {n!r} — have: "
f"{', '.join(sorted(known))}" for n in unknown])
return [s for s in self.slices if s.name in set(names)]
def _path(value, base: Path) -> Path:
p = Path(str(value)).expanduser()
return Path(os.path.normpath(p if p.is_absolute() else base / p))
def _strings(value) -> bool:
return isinstance(value, list) and all(isinstance(x, str) for x in value)
def _table_of_strings(value) -> bool:
return isinstance(value, dict) and all(isinstance(k, str) and isinstance(v, str)
for k, v in value.items())
def load(path, themes: list[str]) -> RunFile:
"""Read and resolve a run file. Raises ConfigError listing every problem.
`themes` is passed in so this stays a reader of files with nothing to know
about where themes live.
"""
path = Path(path)
if tomllib is None:
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
try:
data = 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
base = path.resolve().parent
problems: list[str] = []
for key in sorted(set(data) - TOP_KEYS):
problems.append(f"unknown top-level key {key!r} — have: {', '.join(sorted(TOP_KEYS))}")
defaults = data.get("defaults") or {}
if not isinstance(defaults, dict):
problems.append("[defaults] must be a table")
defaults = {}
for key in sorted(set(defaults) - DEFAULT_KEYS):
problems.append(f"[defaults] has unknown key {key!r} — have: "
f"{', '.join(sorted(DEFAULT_KEYS))}")
raw_slices = data.get("slice") or []
plexuses = data.get("plexus") or []
if not isinstance(raw_slices, list) or not isinstance(plexuses, list):
problems.append("[[slice]] and [[plexus]] must be arrays of tables")
raw_slices, plexuses = [], []
if not raw_slices and not plexuses:
problems.append("nothing to do — add a [[slice]] (or a [[plexus]]) table")
slices: list[Slice] = []
for i, raw in enumerate(raw_slices):
where = f"slice[{i}]"
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
name = raw.get("name")
if isinstance(name, str):
where = f"slice {name!r}"
if not isinstance(name, str) or not NAME.match(name):
problems.append(f"{where} needs a name of letters, digits, '.', '_' or '-'")
continue
for key in sorted(set(raw) - SLICE_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: {', '.join(sorted(SLICE_KEYS))}")
def pick(key, default=None):
return raw[key] if key in raw else defaults.get(key, default)
target = pick("target")
if target not in TARGETS:
problems.append(f"{where}: target must be one of {', '.join(TARGETS)}")
continue
delivery = pick("delivery", "dependency")
if delivery not in DELIVERIES:
problems.append(f"{where}: delivery must be one of {', '.join(DELIVERIES)}")
if target == "plain":
for key in sorted(VUE_ONLY & set(raw)):
problems.append(f"{where}: {key!r} applies to target = \"vue\" only")
if not isinstance(raw.get("dest"), str):
problems.append(f"{where} needs `dest`, the directory the slice is written to")
continue
dest = _path(raw["dest"], base)
layout = pick("layout", "flat" if delivery == "vendored" else "package")
if layout not in LAYOUTS:
problems.append(f"{where}: layout must be one of {', '.join(LAYOUTS)}")
use = pick("use", [])
if not _strings(use):
problems.append(f"{where}: use must be a list of names")
use = []
scan = pick("scan", [])
if not _strings(scan):
problems.append(f"{where}: scan must be a list of paths")
scan = []
scan_paths = []
for s in scan:
sp = _path(s, base)
if not sp.exists():
problems.append(f"{where}: scan path {sp} does not exist")
scan_paths.append(sp)
if not use and not scan:
problems.append(f"{where}: nothing selected — give `use`, `scan`, or both")
alias = pick("alias", ["soleprint-ui", "@framework"])
if not _strings(alias) or not alias:
problems.append(f"{where}: alias must be a non-empty list of import specifiers")
alias = ["soleprint-ui", "@framework"]
theme = pick("theme")
if theme is not None and (not isinstance(theme, str) or
(theme != "library" and theme not in themes)):
problems.append(f"{where}: theme must be \"library\" or one of: {', '.join(themes)}")
theme = None
if target == "plain" and theme in (None, "library"):
# Parts are written against the theme contract, never the library's file.
theme = "soleprint"
tokens = pick("tokens", {})
if not _table_of_strings(tokens) or not all(TOKEN.match(k) for k in tokens):
problems.append(f"{where}: tokens must map --names to values")
tokens = {}
package = pick("package", False)
if not isinstance(package, (bool, str)):
problems.append(f"{where}: package must be true, false, or a package name")
package = False
notice = pick("notice")
if delivery == "vendored":
if notice is None:
problems.append(f"{where}: a vendored slice needs `notice` — \"none\", or a file to "
f"copy in. The tool never picks the licensing for you.")
elif notice != "none":
np = _path(notice, base)
if not np.is_file():
problems.append(f"{where}: notice {np} is not a file")
notice = np
elif notice is not None and notice != "none":
notice = _path(notice, base)
header = pick("header")
if header is not None and not isinstance(header, str):
problems.append(f"{where}: header must be a string")
header = None
rename = pick("rename", {})
if not _table_of_strings(rename):
problems.append(f"{where}: rename must map identifiers to identifiers")
rename = {}
manifest = _path(raw["manifest"], base) if isinstance(raw.get("manifest"), str) \
else base / f"{name}.slice.json"
slices.append(Slice(name=name, target=target, delivery=delivery, dest=dest,
manifest=manifest, layout=layout, use=tuple(use),
scan=tuple(scan_paths), alias=tuple(alias), theme=theme,
tokens=dict(tokens), package=package, notice=notice,
header=header, rename=dict(rename)))
seen_names, seen_dests, seen_manifests = set(), {}, {}
for s in slices:
if s.name in seen_names:
problems.append(f"slice {s.name!r} is listed twice")
seen_names.add(s.name)
for key, seen, what in ((s.dest, seen_dests, "dest"), (s.manifest, seen_manifests, "manifest")):
k = key.resolve()
if k in seen:
problems.append(f"slices {seen[k]!r} and {s.name!r} share one {what}: {key}")
seen[k] = s.name
if problems:
raise ConfigError(path, problems)
return RunFile(path=path, slices=slices, plexuses=list(plexuses))

View File

@@ -0,0 +1,190 @@
"""
What a consumer uses of the framework, read from the consumer itself.
This is the check that used to be done by hand — grep an app for its imports,
diff its framework copy against spr — made repeatable, and made the input of a
slice. Two questions, answered separately:
**Usage.** Which exports, files and custom properties the consumer's own code
reads. Both import styles in use are understood:
import { Panel, type LogEntry } from 'soleprint-ui' barrel (mpr, nvi, unt)
import Panel from '@framework/components/Panel.vue' deep path (mts)
import '@framework/tokens.css' a stylesheet
Its own `var(--…)` reads count too: every consumer imports tokens.css directly
and styles its own components with it, so a slice that carried only what the
framework reads would leave the app's own CSS unstyled.
**Copy state.** When the consumer holds a copy of the framework (found through
its package.json `link:` or its Vite alias, or named with --copy), every file is
compared with current spr: same, differs, missing from the copy, or only in the
copy. A copy that is *ahead* shows up here first — which is how mts's maximize
was found before anything could overwrite it.
"""
import difflib
import json
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from . import graph
from .manifest import commit_exists, git, read_stamp
DEFAULT_ALIASES = ("soleprint-ui", "@framework")
SKIP_DIRS = {"node_modules", "dist", ".git", "__pycache__", ".venv", "out", "gen"}
CODE = {".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs"}
STYLE = {".css"}
MARKUP = {".html", ".htm", ".jinja", ".j2"}
VAR = re.compile(r"var\(\s*(--[\w-]+)")
LINK = re.compile(r"^(?:link|file):(.+)$")
VITE_ALIAS = re.compile(r"""['"]([^'"]+)['"]\s*:\s*[^,\n]*?['"](\.{1,2}/[^'"]*)['"]""")
@dataclass
class Usage:
names: set = field(default_factory=set) # barrel names imported
files: set = field(default_factory=set) # SRC-relative files deep-imported
tokens: set = field(default_factory=set) # custom properties the consumer reads
texts: list = field(default_factory=list) # (path, text) of every file read
barrel: int = 0 # imports through the barrel
deep: int = 0 # imports by deep path
unknown: set = field(default_factory=set) # deep paths spr does not have
def markup(self) -> list:
return [(p, t) for p, t in self.texts if p.suffix in MARKUP]
def _files(paths, exclude=()):
excluded = [Path(e).resolve() for e in exclude]
for base in paths:
base = Path(base)
candidates = [base] if base.is_file() else sorted(base.rglob("*"))
for p in candidates:
if not p.is_file() or SKIP_DIRS & set(p.parts):
continue
rp = p.resolve()
if any(rp == e or e in rp.parents for e in excluded):
continue
if p.suffix in CODE | STYLE | MARKUP:
yield p
def _deep_target(rest: str, nodes: dict) -> str | None:
rest = rest.lstrip("/")
if rest.startswith("src/"):
rest = rest[4:]
for candidate in (rest, rest + ".ts", rest + ".vue", rest + "/index.ts"):
if candidate in nodes or candidate in graph.GENERATED | {"base.css"}:
return candidate
return None
def usage(paths, aliases=DEFAULT_ALIASES, exclude=(), nodes=None) -> Usage:
"""Read every file under `paths` for its use of the framework."""
nodes = nodes if nodes is not None else graph.load()
alias_group = "|".join(re.escape(a) for a in sorted(aliases, key=len, reverse=True))
named = re.compile(
r"import\s+(type\s+)?\{([^}]*)\}\s*from\s*['\"](" + alias_group + r")(/[^'\"]*)?['\"]", re.S)
any_import = re.compile(
r"(?:import|export)\s+(?:[^'\";]*?\s*from\s*)?['\"](" + alias_group + r")(/[^'\"]*)?['\"]",
re.S)
out = Usage()
for path in _files(paths, exclude):
text = path.read_text(errors="replace")
out.texts.append((path, text))
out.tokens |= set(VAR.findall(text))
if path.suffix not in CODE:
continue
for _, names, _, rest in named.findall(text):
if rest:
continue # `{ x } from '@framework/…'` — counted as deep below
for item in names.split(","):
item = item.strip()
if item.startswith("type "):
item = item[5:].strip()
if item:
out.names.add(item.split(" as ")[0].strip())
for _, rest in any_import.findall(text):
if not rest:
out.barrel += 1
continue
out.deep += 1
target = _deep_target(rest, nodes)
if target:
out.files.add(target)
else:
out.unknown.add(rest.lstrip("/"))
return out
def find_copy(app: Path) -> Path | None:
"""The framework copy an app resolves to: package.json `link:`, else a Vite alias."""
pkg = app / "package.json"
if pkg.exists():
try:
data = json.loads(pkg.read_text())
except json.JSONDecodeError:
data = {}
deps = {**data.get("devDependencies", {}), **data.get("dependencies", {})}
for name in DEFAULT_ALIASES:
m = LINK.match(str(deps.get(name, "")))
if m:
return (app / m.group(1)).resolve()
for config in sorted(app.glob("vite.config.*")):
for alias, target in VITE_ALIAS.findall(config.read_text()):
if alias in DEFAULT_ALIASES:
found = (app / target).resolve()
return found.parent if found.name == "src" else found
return None
@dataclass
class CopyState:
root: Path
rows: list = field(default_factory=list) # (rel, status, detail)
stamp: dict = field(default_factory=dict)
stamp_known: bool = False
def compare(copy_root: Path, src: Path = graph.SRC) -> CopyState:
"""Every file of spr's src against the copy's src, both ways."""
copy_src = copy_root / "src" if (copy_root / "src").is_dir() else copy_root
state = CopyState(root=copy_root, stamp=read_stamp(copy_root))
state.stamp_known = commit_exists(state.stamp.get("sha", ""))
def listing(root):
return {p.relative_to(root).as_posix() for p in root.rglob("*")
if p.is_file() and not graph.SKIP & set(p.relative_to(root).parts)
and p.suffix in (".ts", ".vue", ".css")}
ours, theirs = listing(src), listing(copy_src)
for rel in sorted(ours | theirs):
if rel not in theirs:
state.rows.append((rel, "missing", "in spr, not in the copy"))
elif rel not in ours:
state.rows.append((rel, "copy-only", "in the copy, not in spr"))
else:
a = (src / rel).read_text().splitlines()
b = (copy_src / rel).read_text().splitlines()
if a == b:
state.rows.append((rel, "same", ""))
continue
added = removed = 0
for line in difflib.unified_diff(a, b, lineterm="", n=0):
if line.startswith("+") and not line.startswith("+++"):
added += 1
elif line.startswith("-") and not line.startswith("---"):
removed += 1
# Dates, not a verdict. A worktree's mtimes are checkout times, so
# spr's side is dated by its last commit; the copy has no history,
# so its mtime is all there is.
ours_date = git("log", "-1", "--format=%cs", "--", str((src / rel).resolve()))
theirs_date = datetime.fromtimestamp((copy_src / rel).stat().st_mtime).strftime("%Y-%m-%d")
state.rows.append((rel, "differs",
f"+{added} -{removed} in the copy · copy {theirs_date}, "
f"spr last commit {ours_date or '?'}"))
return state

View File

@@ -0,0 +1,348 @@
"""
Prove slices on spr's own tree, writing only into a temporary directory.
python3 -m common.slice.selftest (from soleprint/; or make ui selftest)
Nothing in spr is written, and nothing is installed. The negative cases carry the
same weight as the positive ones; the checks that are the *design* rather than a
regression are marked (design), and they are the ones to keep if anything is cut:
a slice holds the closure, not the library it is never a bundle
a vendored slice fails on any spr word no --force past the scrub
a hand edit in dest is refused, not overwritten drift is reported both ways
a vendored slice needs an explicit notice the tool never picks licensing
a surface that does not parse is a failure by name the reference never thins silently
a generated doc edited by hand is refused the notes, not the output, hold prose
"""
import ast
import json
import re
import sys
import tempfile
from pathlib import Path
from . import css, graph, manifest, names, plexus, runfile, scan, slices, surface, tokens
from . import reference as docs
HERE = Path(__file__).resolve().parent
PASS, FAIL = [], []
def check(name, condition, detail=""):
(PASS if condition else FAIL).append(name)
print(f" {'ok ' if condition else 'FAIL'} {name}" + ("" if condition or not detail
else f"\n {detail}"))
def section(title):
print(f"\n{title}")
def run_file(tmp: Path, body: str, name="ui.toml") -> Path:
path = tmp / name
path.write_text(body)
return path
def load(path: Path):
return runfile.load(path, tokens.available_themes())
def main() -> int:
nodes = graph.load()
exports = graph.barrel(nodes=nodes)
section("the graph")
check("Panel.vue imports no other library file", not nodes["components/Panel.vue"].imports)
check("an SFC always depends on vue, even with no import",
"vue" in nodes["components/Panel.vue"].packages)
check("useDataSource reaches DataSource",
"datasources/DataSource.ts" in nodes["composables/useDataSource.ts"].imports)
check("theme.css imports tokens.css and base.css",
nodes["theme.css"].imports == {"tokens.css", "base.css"})
check("a type-only import is an edge (plugins -> InteractionPlugin)",
"plugins/InteractionPlugin.ts" in nodes["plugins/BBoxDrawPlugin.ts"].imports)
check("the barrel's exports are all found", len(exports) >= 34, f"found {len(exports)}")
check("type exports are marked as types",
{e.name for e in exports if e.is_type} >= {"LogEntry", "ConfigField", "DataSourceStatus"})
section("closures")
c, unknown = graph.select(["Panel", "SplitPane"], [], exports, nodes)
check("(design) Panel + SplitPane is two files, not the library",
c == {"components/Panel.vue", "components/SplitPane.vue"}, sorted(c))
check("... and depends on vue alone", graph.dependencies(c, nodes) == {"vue": "^3.5"},
graph.dependencies(c, nodes))
c, _ = graph.select(["TimeSeriesRenderer"], [], exports, nodes)
check("TimeSeriesRenderer brings uplot, and only it", set(graph.dependencies(c, nodes)) == {"uplot", "vue"})
c, _ = graph.select(["GraphRenderer"], [], exports, nodes)
check("GraphRenderer brings @vue-flow/core", "@vue-flow/core" in graph.dependencies(c, nodes))
c, unknown = graph.select(["Panel", "NoSuchThing"], ["components/Nope.vue"], exports, nodes)
check("an unknown name or file is reported, not ignored",
set(unknown) == {"NoSuchThing", "components/Nope.vue"}, unknown)
c, _ = graph.select([], ["tokens.css", "components/Panel.vue"], exports, nodes)
check("the library's own token file is regenerated, never copied", "tokens.css" not in c)
section("tokens")
lib, theme = tokens.source(None), tokens.source("soleprint")
differ = {n for n in lib.table if tokens.resolved(lib, n) != tokens.resolved(theme, n)}
check("library and soleprint theme differ exactly where documented "
"(why Vue defaults to the library)", differ == {"--font-mono", "--font-ui", "--surface-3"},
sorted(differ))
block, missing = lib.block({"--panel-border"})
check("a chain travels whole: --panel-border brings --border",
"--border:" in block and "var(--border)" in block and not missing, block)
with_override = tokens.source(None, {"--border": "#ff0000"})
check("an override lands before aliases are read",
tokens.resolved(with_override, "--panel-border") == "1px solid #ff0000")
_, missing = lib.block({"--does-not-exist"})
check("a name nobody defines is reported", missing == ["--does-not-exist"])
check("the .waiting rule is found in the library file", ".waiting" in tokens.library_rules())
section("names and the scrub")
profile = names.load()
check("sprint is an English word, not a leak", not names.scrub("sprint planning", profile))
check("spr on its own is a leak", bool(names.scrub("built by spr", profile)))
check("a camelCase spr identifier is a leak", bool(names.scrub("sprHelper()", profile)))
renamed = names.rename("global.SprFeed = SprFeed // a vein page", profile)
check("identifiers rename in code, prose only in comments",
"LiveFeed" in renamed and "connector page" in renamed and "SprFeed" not in renamed, renamed)
renamed = names.rename("var vein = 1", profile)
check("a code identifier named like a prose term is left alone (and the scrub catches it)",
renamed == "var vein = 1" and names.scrub(renamed, profile))
check("a hyphenated identifier is replaced whole",
names.rename("'soleprint-ui'", profile) == "'ui-kit'")
check("the leading header is stripped, later comments kept",
names.strip_header("/* head */\na{}\n/* keep */\n", ".css") == "a{}\n/* keep */\n")
section("the run file")
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
try:
load(run_file(tmp, '[[slice]]\nname="x"\ntarget="vue"\ndelivery="vendored"\n'
'dest="d"\nuse=["Panel"]\n'))
check("(design) a vendored slice without notice is refused", False)
except runfile.ConfigError as e:
check("(design) a vendored slice without notice is refused",
any("notice" in p for p in e.problems), e.problems)
try:
load(run_file(tmp, '[[slice]]\nname="x"\ntarget="vue"\ndset="d"\nuse=["Panel"]\n'
'colour="red"\n[[slice]]\nname="y"\ntarget="plain"\ndest="e"\n'
'layout="flat"\n'))
check("every problem is reported in one pass", False)
except runfile.ConfigError as e:
check("every problem is reported in one pass", len(e.problems) >= 4, e.problems)
rf = load(run_file(tmp, '[[slice]]\nname="x"\ntarget="plain"\ndest="sub/out"\nuse=["panel"]\n'))
check("paths are relative to the run file", rf.slices[0].dest == tmp / "sub" / "out")
check("plain resolves through the theme contract by default", rf.slices[0].theme == "soleprint")
check("the manifest defaults beside the run file", rf.slices[0].manifest == tmp / "x.slice.json")
section("writing, and drift both ways")
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
rf = load(run_file(tmp, '[[slice]]\nname="app"\ntarget="vue"\ndest="fw"\n'
'use=["Panel","SplitPane"]\n'))
entry = rf.slices[0]
built = slices.build(entry)
steps = slices.plan(entry, built)
check("a first run creates every file", {a for _, a, _ in steps.actions} == {"create"})
slices.apply(entry, built, steps, force=False)
check("a dependency slice carries a .spr stamp in spr.py's format",
manifest.read_stamp(entry.dest).get("mode") == "slice")
check("the barrel exports only what the slice holds",
"LogRenderer" not in (entry.dest / "src/index.ts").read_text()
and "SplitPane" in (entry.dest / "src/index.ts").read_text())
check("a second run changes nothing", not slices.plan(entry, slices.build(entry)).stale)
panel = entry.dest / "src/components/Panel.vue"
panel.write_text(panel.read_text() + "\n<!-- local change -->\n")
steps = slices.plan(entry, slices.build(entry))
check("(design) a hand edit is a conflict",
[a for r, a, _ in steps.actions if r.endswith("Panel.vue")] == ["conflict"])
try:
slices.apply(entry, slices.build(entry), steps, force=False)
check("(design) ... and nothing is written without --force", False)
except ValueError:
check("(design) ... and nothing is written without --force",
"local change" in panel.read_text())
# Spr moving on: dest still holds what the manifest recorded, but the
# slice would now write something else. Simulated by recording the
# edited file as ours.
m = manifest.read(entry.manifest)
m["files"]["src/components/Panel.vue"]["sha256"] = manifest.file_sha(panel)
manifest.write(entry.manifest, m)
steps = slices.plan(entry, slices.build(entry))
check("a file the manifest trusts is an update, not a conflict",
[a for r, a, _ in steps.actions if r.endswith("Panel.vue")] == ["update"])
slices.apply(entry, slices.build(entry), steps, force=False)
rf = load(run_file(tmp, '[[slice]]\nname="app"\ntarget="vue"\ndest="fw"\nuse=["Panel"]\n'))
steps = slices.plan(rf.slices[0], slices.build(rf.slices[0]))
check("a file the slice no longer needs is deleted",
("src/components/SplitPane.vue", "delete", "no longer part of the slice") in steps.actions)
section("vendored")
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
(tmp / "NOTICE").write_text("Notice text chosen by the owner.\n")
rf = load(run_file(tmp, '[[slice]]\nname="v"\ntarget="vue"\ndelivery="vendored"\n'
'notice="NOTICE"\ndest="repo/ui"\nmanifest="private/v.json"\n'
'use=["Panel","SplitPane","SSEDataSource"]\n'))
entry = rf.slices[0]
built = slices.build(entry)
check("(design) a vendored slice of real pieces scrubs clean", not built.hits, built.hits[:3])
slices.apply(entry, built, slices.plan(entry, built), force=False)
written = sorted(p.relative_to(entry.dest).as_posix() for p in entry.dest.rglob("*") if p.is_file())
check("no stamp and no manifest land in the consumer's tree",
".spr" not in written and not any(w.endswith(".json") for w in written), written)
check("the manifest is written where the run file says", (tmp / "private/v.json").exists())
check("the notice is copied as given", (entry.dest / "NOTICE").read_text().startswith("Notice text"))
check("flat layout for a vendored slice", (entry.dest / "index.ts").exists())
leaks = [w for w in written for _ in names.scrub((entry.dest / w).read_text(), names.load())]
check("an independent scrub of the written files finds nothing", not leaks, leaks)
page = tmp / "page.html"
page.write_text('<div class="panel" data-maximize><div class="panel-header">'
'<span class="panel-title">x</span></div></div>\n'
'<script>var f = new LiveFeed({url:"/s"})</script>\n')
rf = load(run_file(tmp, '[[slice]]\nname="p"\ntarget="plain"\ndelivery="vendored"\n'
'notice="none"\ndest="plain"\nscan=["page.html"]\n', "plain.toml"))
built = slices.build(rf.slices[0])
check("maximize brings panel; base comes with any part",
{"base", "panel", "maximize"} <= set(built.closure), built.closure)
check("a vendored page calling the renamed global still selects feed", "feed" in built.closure,
built.closure)
check("the renamed global is what ships", "LiveFeed" in built.files.get("ui.js", "")
and "SprFeed" not in built.files.get("ui.js", ""))
check("the plain slice scrubs clean", not built.hits, built.hits[:3])
js = built.files.get("ui.js", "")
chunks = [c.strip() for c in re.split(r"(?m)^/\* [\w-]+ \*/$", js) if c.strip()]
check("parts joined into one ui.js are each terminated (an unterminated IIFE "
"turns the next one into a call)",
chunks and all(c.endswith(";") for c in chunks), [c[-30:] for c in chunks])
section("the plexus")
page = '<div class="card"><h2>x</h2></div><script>el.className = "dyn"</script>'
kept, _ = css.shake('.card{a:1} .gone{b:2} h2{c:3} .dyn{d:4} @keyframes spin{} '
'.card{animation: spin 1s}', page)
preludes = [r.prelude for r in kept]
check("a rule whose class never appears is dropped", ".gone" not in preludes, preludes)
check("a class named only in script still counts", ".dyn" in preludes, preludes)
check("element rules stay; referenced keyframes stay",
"h2" in preludes and "@keyframes spin" in preludes, preludes)
try:
plexus.resolve("bundle", delivery="vendored")
check("(design) a vendored plexus without notice is refused", False)
except plexus.PlexusError as e:
check("(design) a vendored plexus without notice is refused", "notice" in str(e))
b = plexus.resolve("bundle")
check("a plexus needs nothing built first: it lands in gen/plexuses/",
b.dest == plexus.GEN / "bundle" / "index.html")
built = plexus.build(b)
check("every placeholder is filled", not re.search(r"%%[A-Z_]+%%", built.html))
check("each theme it can switch to is carried, and nothing it cannot use",
all(f'[data-theme="{t}"]' in built.html for t in ("lucid", "soleprint", "mcrn"))
and "#spr-theme-toggle" not in built.html and ".prompt-list" not in built.html)
vendored = plexus.build(plexus.resolve("bundle", delivery="vendored", notice="none"))
check("(design) spr's own showcase cannot be vendored — its content is spr",
bool(vendored.hits))
check("no plexus code is left in build.py",
"plexus" not in (manifest.REPO / "build.py").read_text().lower())
section("consumers (read-only, skipped when absent)")
meetus = Path.home() / "wdir/semester/mts/ui/meetus-app"
if meetus.exists():
u = scan.usage([meetus / "src"], nodes=nodes)
check("meetus is read through deep paths",
u.files >= {"components/Panel.vue", "components/SplitPane.vue", "tokens.css"} and u.deep > 0)
copy = scan.find_copy(meetus)
check("its framework copy is found through the Vite alias", copy and copy.name == "framework")
else:
print(" skip meetus snapshot not on this machine")
section("the surface (what the ui reference is generated from)")
surf = surface.load()
panel = surf["components/Panel.vue"]
check("Panel's props, in order", [m.name for m in panel.props] == ["title", "status", "maximizable"],
[m.name for m in panel.props])
check("... with required-ness and withDefaults defaults",
panel.props[0].required and not panel.props[1].required and panel.props[2].default == "true")
check("... a doc comment lands on its prop", panel.props[1].doc.startswith("Omit to show"))
check("a defineModel is a v-model with its default",
[(m.name, m.type, m.default) for m in panel.models] == [("maximized", "boolean", "false")])
check("named and default slots are found", panel.slots == ["actions", "default", "overlay"], panel.slots)
check("quoted and colon-named emits parse (VideoPlayer)",
{"update:currentTime", "ended"} <= {e.name for e in surf["components/VideoPlayer.vue"].emits})
check("a file doc comment is its summary, a prop's is not",
surf["components/VideoPlayer.vue"].summary.startswith("Agnostic") and not panel.summary)
check("a .ts file's exports are declarations",
("abstract class", "DataSource") in [(k, n) for k, n, _ in surf["datasources/DataSource.ts"].declarations])
broken = [s.file for s in surf.values() if s.error]
check("(design) every component in the library parses", not broken, broken)
with tempfile.TemporaryDirectory() as t:
bad = Path(t) / "Broken.vue"
bad.write_text("<script setup lang=\"ts\">\ndefineProps<{ title: string\n</script>\n")
check("(design) a surface that does not parse is recorded by name, not dropped",
surface.read(bad, "Broken.vue").error is not None)
section("the ui reference")
inv = docs.inventory()
check("the inventory is deterministic: two builds, one document", inv == docs.inventory())
visual = {"color", "colour", "fill", "stroke", "shape", "x", "y", "width", "height", "position"}
keys = {k for n in inv["nodes"] for k in n["attrs"]}
check("no visual keys in the inventory — style belongs to whoever renders it",
not keys & visual, sorted(keys & visual))
ids = [n["id"] for n in inv["nodes"]]
check("node ids are unique", len(ids) == len(set(ids)))
check("every edge points at a node", all(e["source"] in ids and e["target"] in ids for e in inv["edges"]))
check("the reference reconciles with what was read, on spr's own tree",
all(ok for ok, _ in docs.reconcile(inv, {})), docs.reconcile(inv, {}))
check("a note for a spot that does not exist fails the reconcile",
not all(ok for ok, _ in docs.reconcile(inv, {"NoSuchExport": "text"})))
check("Panel's plain parts are found from the parts' own headers",
{e["source"] for e in inv["edges"] if e["kind"] == "binds" and e["target"] == "file:components/Panel.vue"}
== {"part:panel", "part:maximize"})
with tempfile.TemporaryDirectory() as t:
d = Path(t)
page = d / docs.PAGE
first = docs.build(d, write=True)
check("a first build writes the page and the inventory into the docs site",
set(first.written) == {docs.PAGE, docs.INVENTORY} and (d / docs.MANIFEST).exists())
check("a second build changes nothing", not docs.build(d, write=True).written)
check("--check on a current build passes", docs.build(d, write=False).ok)
page.write_text(page.read_text() + "\nhand edit\n")
edited = docs.build(d, write=True)
check("(design) a generated doc edited by hand is refused, not overwritten",
edited.edited == [docs.PAGE] and "hand edit" in page.read_text())
forced = docs.build(d, write=True, force=True)
check("... and --force discards it", "hand edit" not in page.read_text()
and docs.PAGE in forced.written)
check("the page is listed in the docs site's sidebar",
any(t.get("id") == "ui-reference" for t in json.loads(
(docs.SITE / "data" / "topics.json").read_text())))
section("the split")
offenders = []
for path in HERE.rglob("*.py"):
rel = path.relative_to(HERE)
if rel.parts[0] == "cli" or rel.as_posix() in ("__main__.py", "selftest.py"):
continue
for node in ast.walk(ast.parse(path.read_text())):
if isinstance(node, ast.Import) and any(a.name == "argparse" for a in node.names):
offenders.append(rel.as_posix())
if isinstance(node, ast.ImportFrom) and node.module == "argparse":
offenders.append(rel.as_posix())
check("nothing outside cli/ parses a command line", not offenders, offenders)
room = [p.relative_to(HERE).as_posix() for p in HERE.rglob("*")
if p.is_file() and p.suffix in (".py", ".json", ".toml")
and re.search(r"\broom", p.read_text(), re.I)
and p.name != "selftest.py"]
check("no file here uses the retired word", not room, room)
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,414 @@
"""
Build a slice in memory, then write it — or say exactly why not.
Building never touches the disk. Writing is a separate step that compares what
would be written with what is there and with the manifest, and refuses as a
whole: a run that overwrote half a copy and then stopped on a hand edit would
leave the consumer worse off than either state.
Per file, one of:
create not in dest yet
same dest already holds exactly this
update dest holds what the last run wrote; spr has moved on
delete the last run wrote it and the slice no longer needs it
conflict dest differs from what the last run wrote — edited by hand, or
never written by this slice. Refused unless --force.
A vendored slice must also pass the scrub. That gate has no --force: a slice
that names spr is not a vendored slice.
"""
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from ..theme import bake
from . import graph, names, scan, tokens
from .manifest import (REPO, STAMP, file_sha, read, sha256, source_state, stamp, write)
PARTS_DIR = graph.COMMON / "theme" / "parts"
UI_PACKAGE = graph.PACKAGE_JSON
# What a dependency slice says at the top of every file it generates. No path
# and no command: the same text is right in every consumer, on every machine.
PROVENANCE = ("Generated by spr (`make ui slice`) from {source}. "
"Edits here are overwritten; change spr, or keep a change with --force.")
@dataclass
class Built:
entry: object
files: dict = field(default_factory=dict) # dest-relative path -> text
origins: dict = field(default_factory=dict) # dest-relative path -> where it came from
selected: list = field(default_factory=list) # export names, or part names
closure: list = field(default_factory=list) # source files, or parts
deps: dict = field(default_factory=dict)
carried: list = field(default_factory=list) # custom properties in tokens
fell_back: list = field(default_factory=list)
missing: list = field(default_factory=list)
unknown: list = field(default_factory=list)
hits: list = field(default_factory=list) # (file, line, term, text)
notes: list = field(default_factory=list)
@property
def blocked(self) -> list[str]:
why = []
if self.unknown:
why.append(f"unknown: {', '.join(sorted(self.unknown))}")
if self.hits:
why.append(f"scrub: {len(self.hits)} hit(s) in a vendored slice")
return why
def _comment(text: str, suffix: str) -> str:
if suffix in (".ts", ".js"):
return "".join(f"// {line}".rstrip() + "\n" for line in text.splitlines())
if suffix in (".vue", ".html"):
return f"<!-- {text} -->\n"
return f"/* {text} */\n"
def _header(entry, suffix: str, source: str) -> str:
if entry.header is not None:
return _comment(entry.header, suffix) if entry.header else ""
if entry.vendored:
return ""
return _comment(PROVENANCE.format(source=source), suffix)
def _finish(built: Built, profile) -> None:
"""Vendored: rename through the profile, then scrub. Dependency: as is."""
if not built.entry.vendored:
return
for rel, text in list(built.files.items()):
if rel.endswith(".json") and rel != "package.json":
continue
text = names.rename(text, profile)
built.files[rel] = text
for line, term, content in names.scrub(text, profile):
built.hits.append((rel, line, term, content))
def _notice(entry, built: Built) -> None:
if entry.vendored and isinstance(entry.notice, Path):
built.files[entry.notice.name] = entry.notice.read_text()
built.origins[entry.notice.name] = str(entry.notice)
def _token_file(entry, built: Built, wanted: set, defined: set, texts: list,
header: str) -> str:
src = tokens.source(None if entry.theme in (None, "library") else entry.theme, entry.tokens)
block, missing = src.block(wanted)
built.carried = sorted(src.closure(wanted) - set(missing))
built.fell_back = sorted(src.fell_back)
# A name the consumer declares itself is the consumer's, not a gap.
built.missing = sorted(set(missing) - defined)
rules = tokens.library_rules() if tokens.uses_waiting(texts) else ""
return header + block + (("\n" + rules) if rules else "")
def _defined(texts) -> set:
"""Custom properties a consumer sets itself — in CSS, or from a Vue style
binding (`:style="{ '--toggle-color': c }"`), where the name is quoted."""
return {m for _, t in texts for m in re.findall(r"(--[\w-]+)['\"]?\s*:", t)}
def build_vue(entry) -> Built:
built = Built(entry=entry)
profile = names.load(entry.rename)
nodes = graph.load()
exports = graph.barrel(nodes=nodes)
export_names = {e.name for e in exports}
use_names = [u for u in entry.use if u in export_names]
use_files = [u for u in entry.use if u not in export_names]
usage = scan.usage(entry.scan, entry.alias, exclude=[entry.dest], nodes=nodes) \
if entry.scan else scan.Usage()
wanted_names = sorted(set(use_names) | usage.names)
wanted_files = sorted((set(use_files) | usage.files) - graph.GENERATED - {"base.css"})
closure, unknown = graph.select(wanted_names, wanted_files, exports, nodes)
built.unknown = sorted(set(unknown) | usage.unknown)
built.selected = sorted(set(wanted_names) | set(wanted_files))
built.closure = sorted(closure)
built.deps = graph.dependencies(closure, nodes)
prefix = "src/" if entry.layout == "package" else ""
origin = "soleprint/common/ui/src"
for rel in sorted(closure):
text = (graph.SRC / rel).read_text()
if entry.vendored:
text = names.strip_header(text, Path(rel).suffix)
built.files[prefix + rel] = text
built.origins[prefix + rel] = f"{origin}/{rel}"
base = (graph.SRC / "base.css").read_text()
if entry.vendored:
base = names.strip_header(base, ".css")
built.files[prefix + "base.css"] = base
built.origins[prefix + "base.css"] = f"{origin}/base.css"
wanted_tokens = graph.tokens_read(closure | {"base.css"}, nodes) | usage.tokens
texts = [(graph.SRC / rel, (graph.SRC / rel).read_text()) for rel in closure] + usage.texts
built.files[prefix + "tokens.css"] = _token_file(
entry, built, wanted_tokens, _defined(usage.texts), [t for _, t in texts],
_header(entry, ".css", f"{origin}/tokens.css"))
built.origins[prefix + "tokens.css"] = "generated"
built.files[prefix + "theme.css"] = (_header(entry, ".css", f"{origin}/theme.css")
+ "@import './tokens.css';\n@import './base.css';\n")
built.origins[prefix + "theme.css"] = "generated"
built.files[prefix + "index.ts"] = _barrel(entry, exports, closure)
built.origins[prefix + "index.ts"] = "generated"
if entry.delivery == "dependency" or entry.package:
built.files["package.json"] = _package_json(entry, built.deps, profile)
built.origins["package.json"] = "generated"
_notice(entry, built)
_finish(built, profile)
return built
def _barrel(entry, exports, closure) -> str:
"""index.ts exporting what the full barrel exports from the files held — no more."""
header = _header(entry, ".ts", "soleprint/common/ui/src/index.ts")
lines = [header.rstrip("\n")] if header else []
lines += ["import './theme.css'", ""]
by_file: dict[str, list] = {}
for e in exports:
if e.file in closure:
by_file.setdefault(e.file, []).append(e)
for file, items in by_file.items():
spec = "./" + (file[:-3] if file.endswith(".ts") else file)
values = [e for e in items if not e.is_type]
types = [e for e in items if e.is_type]
if values:
names_ = ", ".join(e.local if e.local == e.name else f"{e.local} as {e.name}"
for e in values)
lines.append(f"export {{ {names_} }} from '{spec}'")
if types:
lines.append(f"export type {{ {', '.join(e.name for e in types)} }} from '{spec}'")
return "\n".join(lines).lstrip("\n") + "\n"
def _package_json(entry, deps: dict, profile) -> str:
version = json.loads(UI_PACKAGE.read_text()).get("version", "0.0.0")
if isinstance(entry.package, str):
name = entry.package
elif entry.vendored:
name = profile.identifiers.get("soleprint-ui", entry.name)
else:
name = "soleprint-ui"
src = "./src/" if entry.layout == "package" else "./"
data = {
"name": name,
"version": version,
"private": True,
"type": "module",
"main": "src/index.ts" if entry.layout == "package" else "index.ts",
"exports": {
".": f"{src}index.ts",
"./tokens.css": f"{src}tokens.css",
"./theme.css": f"{src}theme.css",
"./base.css": f"{src}base.css",
**({"./src/*": "./src/*"} if entry.layout == "package" else {"./*": "./*"}),
},
"dependencies": deps,
}
return json.dumps(data, indent=2) + "\n"
def _needs(name: str, parts: dict) -> list[str]:
header = bake.part_header(name, parts[name][0])
line = bake.field(header, "NEEDS")
found = [w for w in re.findall(r"[a-z][\w-]*", line) if w in parts and w != name]
return list(dict.fromkeys(found))
def build_plain(entry) -> Built:
built = Built(entry=entry)
profile = names.load(entry.rename)
parts = bake.load_parts()
reverse = profile.reverse() if entry.vendored else {}
wanted = set()
for u in entry.use:
(wanted.add if u in parts else built.unknown.append)(u)
usage = scan.usage(entry.scan, (), exclude=[entry.dest]) if entry.scan else scan.Usage()
for path, text in usage.texts:
# A vendored page calls the renamed globals; read it back in spr's words
# so bake's DETECT lines still find them.
for new, old in reverse.items():
text = re.sub(rf"(?<![\w$]){re.escape(new)}(?![\w$])", old, text)
wanted |= set(bake.parts_used(text, parts))
# What a part needs comes with it; the element layer comes with any part.
stack = list(wanted)
while stack:
for dep in _needs(stack.pop(), parts):
if dep not in wanted:
wanted.add(dep)
stack.append(dep)
if wanted:
wanted |= {n for n, p in parts.items() if p[3]}
order, placed = [], set()
def place(n):
if n in placed:
return
for d in _needs(n, parts):
place(d)
placed.add(n)
order.append(n)
for n in sorted(wanted, key=lambda n: (not parts[n][3], n)):
place(n)
built.selected = sorted(set(entry.use))
built.closure = order
def body(n, ext):
path = PARTS_DIR / f"{n}.{ext}"
text = path.read_text() if path.exists() else ""
stripped = re.sub(r"\A\s*/\*.*?\*/\s*", "", text, count=1, flags=re.S).rstrip()
if ext == "js" and stripped and not stripped.endswith(";"):
# Each part is an IIFE written without semicolons: fine alone in its
# own <script>, as bake places it. Joined into one file, `})(window)`
# followed by `(function …` parses as a call and the page dies — so
# every part is terminated before it is joined.
stripped += ";"
label = n if entry.vendored else f"part: {n} — common/theme/parts/{n}.{ext}"
return f"/* {label} */\n{stripped}\n" if stripped else ""
css = [body(n, "css") for n in order]
js = [body(n, "js") for n in order]
wanted_tokens = set()
for n in order:
wanted_tokens |= tokens.read(parts[n][0])
js_path = PARTS_DIR / f"{n}.js"
if js_path.exists():
wanted_tokens |= tokens.read(js_path.read_text())
wanted_tokens |= usage.tokens
origin = "soleprint/common/theme/parts"
token_css = _token_file(entry, built, wanted_tokens, _defined(usage.texts),
[t for _, t in usage.texts], "")
header = _header(entry, ".css", origin)
built.files["ui.css"] = header + token_css + "\n" + "\n".join(c for c in css if c)
built.origins["ui.css"] = origin
if any(js):
built.files["ui.js"] = _header(entry, ".js", origin) + "\n".join(j for j in js if j)
built.origins["ui.js"] = origin
_notice(entry, built)
_finish(built, profile)
return built
def build(entry) -> Built:
return build_vue(entry) if entry.target == "vue" else build_plain(entry)
@dataclass
class Plan:
actions: list = field(default_factory=list) # (rel, action, detail)
def of(self, *kinds) -> list:
return [a for a in self.actions if a[1] in kinds]
@property
def stale(self) -> bool:
return bool(self.of("create", "update", "delete", "conflict"))
def plan(entry, built: Built) -> Plan:
old = read(entry.manifest) or {}
recorded = {rel: meta.get("sha256") for rel, meta in old.get("files", {}).items()}
out = Plan()
for rel, text in built.files.items():
current = file_sha(entry.dest / rel)
if current is None:
out.actions.append((rel, "create", ""))
elif current == sha256(text):
out.actions.append((rel, "same", ""))
elif recorded.get(rel) == current:
out.actions.append((rel, "update", "spr moved on"))
else:
why = "edited by hand since the last run" if rel in recorded \
else "exists, and was not written by this slice"
out.actions.append((rel, "conflict", why))
for rel in sorted(set(recorded) - set(built.files)):
current = file_sha(entry.dest / rel)
if current is None:
continue
if current == recorded[rel]:
out.actions.append((rel, "delete", "no longer part of the slice"))
else:
out.actions.append((rel, "conflict", "no longer part of the slice, and edited by hand"))
return out
def apply(entry, built: Built, steps: Plan, force: bool) -> list[str]:
"""Write the slice. Returns what changed. Raises ValueError when refused."""
if built.blocked:
raise ValueError("; ".join(built.blocked))
conflicts = steps.of("conflict")
if conflicts and not force:
raise ValueError(f"{len(conflicts)} file(s) in dest differ from what this slice last "
f"wrote — nothing written. Bring a real change back to spr, or rerun "
f"with --force to overwrite: "
+ ", ".join(rel for rel, _, _ in conflicts))
changed = []
for rel, action, _ in steps.actions:
target = entry.dest / rel
if action in ("create", "update") or (action == "conflict" and rel in built.files):
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(built.files[rel])
changed.append(f"{'overwrite' if action == 'conflict' else action:<8} {rel}")
elif action == "delete" or (action == "conflict" and rel not in built.files):
target.unlink()
changed.append(f"delete {rel}")
old = read(entry.manifest) or {}
inputs = [graph.SRC, PARTS_DIR, tokens.CONTRACT, tokens.THEMES, Path(__file__).parent]
manifest = {
"slice": entry.name,
"target": entry.target,
"delivery": entry.delivery,
"layout": entry.layout if entry.target == "vue" else None,
# The source only moves when the files do, so a new spr commit that
# changes nothing here does not churn the consumer's repository.
"source": source_state(inputs) if changed or "source" not in old else old["source"],
"selected": built.selected,
"closure": built.closure,
"deps": built.deps,
"tokens": {"source": entry.theme or "library", "carried": built.carried,
"fell_back": built.fell_back, "missing": built.missing,
"overrides": entry.tokens},
"renames": names.load(entry.rename).identifiers if entry.vendored else {},
"notice": (entry.notice.name if isinstance(entry.notice, Path) else entry.notice),
"files": {rel: {"from": built.origins.get(rel, "generated"), "sha256": sha256(text)}
for rel, text in sorted(built.files.items())},
}
if manifest != old:
write(entry.manifest, manifest)
changed.append(f"manifest {entry.manifest}")
if entry.delivery == "dependency" and (changed or not (entry.dest / STAMP).exists()):
kind = "vue-slice" if entry.target == "vue" else "plain-slice"
version = json.loads(UI_PACKAGE.read_text()).get("version", "?")
source = graph.SRC if entry.target == "vue" else PARTS_DIR
(entry.dest / STAMP).parent.mkdir(parents=True, exist_ok=True)
(entry.dest / STAMP).write_text(stamp(entry.name, kind, version, source))
return changed
def repo_relative(path: Path) -> str:
try:
return path.resolve().relative_to(REPO).as_posix()
except ValueError:
return str(path)

View File

@@ -0,0 +1,219 @@
"""
What each file of common/ui/src offers a consumer: props, models, emits, slots,
and the declarations a .ts file exports.
graph.py answers "what does this file need". This answers "what does it give",
and between them they are everything the reference in common/ui/docs says —
read out of the source, so the reference cannot describe a prop that is gone.
Same method as graph.py and for the same reason: patterns, not a TypeScript
parser. The library writes its surface one way — `defineProps<{…}>()`, optionally
inside `withDefaults(…, {…})`, `defineEmits<{…}>()`, `defineModel<T>('name', …)`,
`<slot name="…">` — and the selftest pins what each of those must yield. A file
whose macros are there but do not parse is **recorded as a failure, by name**,
never dropped: a component with its props silently missing reads as a component
with no props, and that is the lie a reference must not tell.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from . import graph
DOC = re.compile(r"/\*\*(.*?)\*/", re.S)
SLOT = re.compile(r"<slot\b([^>]*)/?>")
SLOT_NAME = re.compile(r"""\bname\s*=\s*["']([^"']+)["']""")
MODEL = re.compile(
r"defineModel\s*<\s*(?P<type>[^>]+?)\s*>\s*\(\s*(?:['\"](?P<name>[\w:-]+)['\"])?"
r"\s*(?:,\s*\{(?P<opts>[^}]*)\})?\s*\)")
EXPORT_DECL = re.compile(
r"(?:(?P<doc>/\*\*(?:(?!\*/).)*\*/)\s*)?"
r"^export\s+(?P<abstract>abstract\s+)?(?P<kind>class|function|interface|type|const)\s+"
r"(?P<name>[A-Za-z_]\w*)", re.S | re.M)
@dataclass
class Member:
"""One prop, emit or model."""
name: str
type: str
required: bool = True
default: str | None = None
doc: str = ""
@dataclass
class Surface:
"""What one file offers."""
file: str
props: list = field(default_factory=list)
models: list = field(default_factory=list)
emits: list = field(default_factory=list)
slots: list = field(default_factory=list)
declarations: list = field(default_factory=list) # (kind, name, doc)
summary: str = "" # first doc paragraph, if any
error: str | None = None
def _doc_text(raw: str) -> str:
lines = [ln.strip().lstrip("*").strip() for ln in raw.strip("/*").splitlines()]
return " ".join(ln for ln in lines if ln).strip()
def _balanced(text: str, start: int, open_ch="{", close_ch="}") -> int:
"""Index just past the bracket that closes the one at `start`, or -1."""
depth = 0
for i in range(start, len(text)):
c = text[i]
if c == open_ch:
depth += 1
elif c == close_ch:
depth -= 1
if depth == 0:
return i + 1
return -1
def _split_top(text: str, seps=",\n;") -> list[str]:
"""Split on separators that are not nested in brackets or quotes."""
out, depth, quote, cur = [], 0, None, []
for c in text:
if quote:
cur.append(c)
if c == quote:
quote = None
continue
if c in "'\"`":
quote = c
elif c in "([{<":
depth += 1
elif c in ")]}>":
depth -= 1
if c in seps and depth == 0:
out.append("".join(cur))
cur = []
else:
cur.append(c)
out.append("".join(cur))
return out
def _members(body: str) -> list[Member]:
"""The members of a type literal: `/** doc */ name?: type`, one per entry."""
out, pending_doc = [], ""
# Doc comments are pulled out first and attached to the member after them.
pieces = re.split(r"(/\*\*.*?\*/)", body, flags=re.S)
for piece in pieces:
if piece.startswith("/**"):
pending_doc = _doc_text(piece)
continue
for chunk in _split_top(piece):
chunk = re.sub(r"//[^\n]*", "", chunk).strip()
if not chunk:
continue
m = re.match(r"""^['"]?([\w:-]+)['"]?\s*(\?)?\s*:\s*(.+)$""", chunk, re.S)
if not m:
continue
out.append(Member(name=m.group(1), type=" ".join(m.group(3).split()),
required=not m.group(2), doc=pending_doc))
pending_doc = ""
return out
def _defaults(text: str) -> dict[str, str]:
"""`{ a: 1, b: () => [] }` → {"a": "1", "b": "() => []"}, values as written."""
out = {}
for chunk in _split_top(text.strip()[1:-1], seps=",\n"):
chunk = chunk.strip()
m = re.match(r"^['\"]?([\w-]+)['\"]?\s*:\s*(.+)$", chunk, re.S)
if m:
out[m.group(1)] = " ".join(m.group(2).split())
return out
def _macro_body(script: str, macro: str) -> tuple[str | None, int]:
"""The `{…}` type literal of `macro<{…}>()`, and where it ends."""
m = re.search(macro + r"\s*<\s*\{", script)
if not m:
return None, -1
start = m.end() - 1
end = _balanced(script, start)
if end < 0:
return None, -1
return script[start + 1:end - 1], end
def read(path: Path, rel: str) -> Surface:
text = path.read_text()
s = Surface(file=rel)
script, _ = graph.code_of(path, text)
for m in EXPORT_DECL.finditer(script):
kind = ("abstract class" if m.group("abstract") else m.group("kind"))
s.declarations.append((kind, m.group("name"), _doc_text(m.group("doc") or "")))
# A file's summary is a doc comment with nothing but imports before it. The
# first doc comment is not enough: in Panel.vue it belongs to a prop.
lead = DOC.search(script)
if lead:
before = re.sub(r"^\s*import\b[^\n]*$", "", script[:lead.start()], flags=re.M)
if not before.strip():
first = _doc_text(lead.group(0))
s.summary = first.split(". ")[0].rstrip(".") + "."
if path.suffix != ".vue":
return s
if "defineProps" in script:
body, end = _macro_body(script, "defineProps")
if body is None:
s.error = "defineProps is present but its type literal did not parse"
else:
s.props = _members(body)
# withDefaults(defineProps<{…}>(), { … }) — the defaults follow `)`.
tail = script[end:]
dm = re.match(r"\s*>\s*\(\s*\)\s*,\s*\{", tail)
if dm and "withDefaults" in script[:end]:
d_start = end + dm.end() - 1
d_end = _balanced(script, d_start)
if d_end > 0:
defaults = _defaults(script[d_start:d_end])
for p in s.props:
if p.name in defaults:
p.default = defaults[p.name]
if "defineEmits" in script:
body, _ = _macro_body(script, "defineEmits")
if body is None:
s.error = (s.error + "; " if s.error else "") + \
"defineEmits is present but its type literal did not parse"
else:
s.emits = _members(body)
for m in MODEL.finditer(script):
opts = m.group("opts") or ""
dflt = re.search(r"default\s*:\s*([^,}]+)", opts)
s.models.append(Member(name=m.group("name") or "modelValue", type=m.group("type").strip(),
required=bool(re.search(r"required\s*:\s*true", opts)),
default=dflt.group(1).strip() if dflt else None))
template = re.sub(r"<script\b.*?</script>|<style\b.*?</style>", "", text, flags=re.S)
for m in SLOT.finditer(template):
name = SLOT_NAME.search(m.group(1))
slot = name.group(1) if name else "default"
if slot not in s.slots:
s.slots.append(slot)
return s
def load(src: Path = graph.SRC) -> dict[str, Surface]:
"""Every .vue/.ts file's surface, keyed like graph.load()."""
out = {}
for p in sorted(src.rglob("*")):
rel = p.relative_to(src).as_posix()
if p.is_file() and p.suffix in (".vue", ".ts") and not graph.SKIP & set(p.relative_to(src).parts):
out[rel] = read(p, rel)
return out

View File

@@ -0,0 +1,162 @@
"""
Where a slice's custom properties come from, and which of them it carries.
Two sources, and the default differs by target on purpose:
library common/ui/src/tokens.css — what every Vue consumer renders today.
The default for target = "vue", because swapping a consumer's copy
for a slice must not change a pixel.
<theme> common/theme/tokens.css + themes/<theme>.css — the theme contract
every plain page already uses. The default for target = "plain"
(as `soleprint`), and opt-in for Vue with `theme = "…"`.
The two agree on almost every name but not all: the contract derives
--surface-3 from --border, and its font stacks are chosen for a locked-down
Windows box. Resolving a Vue slice through a theme is therefore a visible
change, which is exactly why it has to be asked for.
A slice carries the declarations it reads **as written**, var() chains
included, plus everything those chains reach. Nothing is flattened to a literal:
a consumer that overrides --border still moves --panel-border with it, the same
as with the full file. A name the chosen source lacks falls back to the library
and is reported, because a fallback is a gap in the contract worth seeing.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from ..theme import bake
from .graph import COMMON, SRC
LIBRARY = SRC / "tokens.css"
THEME = COMMON / "theme"
CONTRACT = THEME / "tokens.css"
THEMES = THEME / "themes"
COMMENT = re.compile(r"/\*.*?\*/", re.S)
REFERENCE = re.compile(r"var\(\s*(--[\w-]+)")
ROOT_BLOCK = re.compile(r":root\s*\{.*?\n\}", re.S)
def available_themes() -> list[str]:
return sorted(p.stem for p in THEMES.glob("*.css"))
def _declarations(css: str, selector: str) -> dict[str, str]:
"""`--name: value;` pairs of one rule, comments removed first.
bake.declarations reads a rule the same way; comments go first here because
prose inside a block ("Pages set --system-accent; …") must never be read as
a declaration.
"""
return bake.declarations(COMMENT.sub("", css), selector)
@dataclass
class Source:
"""A resolved token source: the table a slice cuts its tokens from."""
name: str # "library" or a theme name
table: dict[str, str] # name -> value as written
fallback: dict[str, str] = field(default_factory=dict)
rules: str = "" # library extras outside :root
fell_back: set = field(default_factory=set)
def closure(self, names: set) -> set:
"""`names` plus every name their values reach through var()."""
seen, stack = set(), list(names)
while stack:
name = stack.pop()
if name in seen:
continue
seen.add(name)
value = self.table.get(name, self.fallback.get(name))
if value:
stack.extend(set(REFERENCE.findall(value)) - seen)
return seen
def value(self, name: str) -> str | None:
if name in self.table:
return self.table[name]
if name in self.fallback:
self.fell_back.add(name)
return self.fallback[name]
return None
def block(self, names: set, indent: str = " ") -> tuple[str, list[str]]:
"""The `:root { … }` a slice carries, and the names nobody defines.
Order follows the source file, so the result reads like the file it was
cut from rather than like a sorted dump.
"""
wanted = self.closure(names)
order = list(self.table) + [n for n in self.fallback if n not in self.table]
lines, missing = [":root {"], []
for name in order + sorted(wanted - set(order)):
if name not in wanted:
continue
value = self.value(name)
if value is None:
missing.append(name)
continue
lines.append(f"{indent}{name}: {value};")
wanted.discard(name)
lines.append("}")
return "\n".join(lines) + "\n", sorted(set(missing))
def library_rules() -> str:
"""What common/ui/src/tokens.css carries besides the :root block.
Today that is the `.waiting` glow and its keyframes: a UI affordance that
happens to live in the token file. It travels only to a consumer that uses
the class — mpr's detection app does, the others do not.
"""
text = LIBRARY.read_text()
text = ROOT_BLOCK.sub("", text, count=1)
text = re.sub(r"\A\s*/\*.*?\*/", "", text, count=1, flags=re.S) # the file's header
return text.strip() + "\n" if text.strip() else ""
def source(theme: str | None, overrides: dict[str, str] | None = None) -> Source:
"""The table for `theme` (None = the library), with overrides applied.
Overrides go in before anything is read, so an alias that points at an
overridden name (`--amber: var(--accent)`) follows it.
"""
library = _declarations(LIBRARY.read_text(), ":root")
if theme is None or theme == "library":
table = dict(library)
table.update(overrides or {})
return Source(name="library", table=table, rules=library_rules())
sheet = THEMES / f"{theme}.css"
if not sheet.exists():
raise ValueError(f"no theme {theme!r} — have: {', '.join(available_themes())}")
table = _declarations(CONTRACT.read_text(), ":root")
table.update(_declarations(sheet.read_text(), f'[data-theme="{theme}"]'))
table.update(overrides or {})
return Source(name=theme, table=table, fallback=library)
def resolved(src: Source, name: str) -> str | None:
"""A token flattened to literals — for reports and comparisons only."""
table = {**src.fallback, **src.table}
return bake.resolve(name, table)
def read(text: str) -> set:
"""Custom properties a piece of text reads."""
return set(REFERENCE.findall(text))
def uses_waiting(texts) -> bool:
return any(re.search(r"\bwaiting\b", t) for t in texts)
def relpath(path: Path) -> str:
try:
return path.relative_to(COMMON.parent).as_posix()
except ValueError:
return str(path)

View File

@@ -0,0 +1,46 @@
# A run file: every slice one consumer takes from spr, and how.
#
# ./ctrl/ui.sh slice soleprint/common/slice/ui.example.toml
# ./ctrl/ui.sh slice <file> --list # resolved, nothing written
# ./ctrl/ui.sh check <file> # nothing written; exit 1 if stale
#
# Copy it beside the consumer it describes. A real one names that consumer's
# paths, so it never lives in spr — and for a vendored slice keep it (and the
# manifest it writes) outside the consumer's repository, with `dest` pointing in.
#
# Paths are relative to THIS FILE. An entry's value replaces the default for
# every key. Unknown keys are refused, every problem in one pass.
#
# This one runs against spr's own tree and writes under out/ (gitignored).
[defaults]
delivery = "dependency"
# Vue, named outright: what a small live page takes. Drop-in `package` layout —
# package.json + src/ — the shape an app links as `soleprint-ui`.
[[slice]]
name = "panels"
target = "vue"
dest = "out/panels"
manifest = "out/panels.slice.json"
use = ["Panel", "SplitPane", "LogRenderer"]
# The same pieces vendored: corp names, no headers, no stamp, scrubbed, and a
# `notice` stated on purpose (a path to a file, or "none").
[[slice]]
name = "panels-vendored"
target = "vue"
delivery = "vendored"
notice = "none"
dest = "out/panels-vendored"
manifest = "out/panels-vendored.slice.json"
use = ["Panel", "SplitPane"]
# Plain, read from a page's own markup: the jira vein's page uses panel and
# split, so that is what ui.css / ui.js carry — nothing else.
[[slice]]
name = "jira-page"
target = "plain"
dest = "out/jira-page"
manifest = "out/jira-page.slice.json"
scan = ["../../artery/veins/jira/ui/index.html"]

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