Compare commits
14 Commits
2ef6139957
...
9021cbe3c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9021cbe3c1 | |||
| 73998020b3 | |||
| b693e324ef | |||
| 12ddd01940 | |||
| 5ff513fcc5 | |||
| 4fe3718795 | |||
| 514efc5e52 | |||
| 8fad2223f3 | |||
| 45018e4743 | |||
| 907b616d1b | |||
| b3cf24e750 | |||
| 88adcd68bc | |||
| 54c577a700 | |||
| 0ce36b98e4 |
11
Makefile
11
Makefile
@@ -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
5
berth/.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
17
berth/ctrl/remote/probe-admin.sh
Normal file
17
berth/ctrl/remote/probe-admin.sh
Normal 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
|
||||
79
berth/ctrl/remote/probe.sh
Normal file
79
berth/ctrl/remote/probe.sh
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
184
build.py
@@ -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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "bundle"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
ctrl/dist.sh
32
ctrl/dist.sh
@@ -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[@]}"
|
||||
|
||||
10
ctrl/spr.py
10
ctrl/spr.py
@@ -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
45
ctrl/ui.sh
Executable 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[@]}"
|
||||
7
docs/data/.ui-reference.generated.json
Normal file
7
docs/data/.ui-reference.generated.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": {
|
||||
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
|
||||
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
|
||||
},
|
||||
"generator": "common.slice reference"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
485
docs/data/en/ui-reference.md
Normal file
485
docs/data/en/ui-reference.md
Normal 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.
|
||||
@@ -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
2701
docs/data/ui-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# The pool is derived from the kind Docker network at install time.
|
||||
# Notes: docs/notes/addons.md
|
||||
set -euo pipefail
|
||||
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
|
||||
cd "${RIG_CTRL:-$(dirname "$0")/..}"
|
||||
|
||||
source ./lib/config.sh
|
||||
@@ -61,27 +62,7 @@ $K rollout status daemonset/speaker -n metallb-system --timeout=240s
|
||||
# on a race that resolves itself in seconds.
|
||||
echo " configuring the address pool"
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if $K apply -f - >/dev/null 2>&1 <<YAML
|
||||
apiVersion: metallb.io/v1beta1
|
||||
kind: IPAddressPool
|
||||
metadata:
|
||||
name: default
|
||||
namespace: metallb-system
|
||||
spec:
|
||||
addresses:
|
||||
- ${pool_start}-${pool_end}
|
||||
---
|
||||
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
|
||||
# is what makes this work on a laptop.
|
||||
apiVersion: metallb.io/v1beta1
|
||||
kind: L2Advertisement
|
||||
metadata:
|
||||
name: default
|
||||
namespace: metallb-system
|
||||
spec:
|
||||
ipAddressPools:
|
||||
- default
|
||||
YAML
|
||||
if render_manifest "$ADDON_DIR/metallb.yaml" | $K apply -f - >/dev/null 2>&1
|
||||
then
|
||||
echo " pool ready: ${pool_start}-${pool_end}"
|
||||
exit 0
|
||||
|
||||
19
rig/ctrl/addons/metallb.yaml
Normal file
19
rig/ctrl/addons/metallb.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
apiVersion: metallb.io/v1beta1
|
||||
kind: IPAddressPool
|
||||
metadata:
|
||||
name: default
|
||||
namespace: metallb-system
|
||||
spec:
|
||||
addresses:
|
||||
- ${pool_start}-${pool_end}
|
||||
---
|
||||
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
|
||||
# is what makes this work on a laptop.
|
||||
apiVersion: metallb.io/v1beta1
|
||||
kind: L2Advertisement
|
||||
metadata:
|
||||
name: default
|
||||
namespace: metallb-system
|
||||
spec:
|
||||
ipAddressPools:
|
||||
- default
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Readiness check: is this machine ready to run rig? Reports and instructs; never fixes.
|
||||
# Usage: check.sh [all | mem [status|push|all|backup|restore]] (all = every detail)
|
||||
# Prints the memory budget and what to do next first, then the configuration as it is.
|
||||
# Notes: docs/notes/check.md
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
@@ -12,13 +13,11 @@ if [ "${1:-}" = mem ]; then
|
||||
exec bash ./mem.sh "${@:-status}"
|
||||
fi
|
||||
|
||||
# Compact by default: facts only with `all`; problems (!) always print.
|
||||
# Facts only with `all`; problems (!) always print.
|
||||
VERBOSE=""
|
||||
if [ "${1:-}" = all ]; then VERBOSE=1; fi
|
||||
fact() { if [ -n "$VERBOSE" ]; then echo "$@"; fi; }
|
||||
|
||||
bash ./deps.sh detect ${VERBOSE:+all}
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
@@ -66,49 +65,64 @@ port_busy() {
|
||||
grep -qi "^ *[0-9]*: [0-9A-F]*$hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null
|
||||
}
|
||||
|
||||
echo
|
||||
echo "rig"
|
||||
echo " cluster ${CLUSTER} (${KUBECONTEXT}) profile ${PROFILE_NAME}, ${NODES} node(s), registry ${REGISTRY_MODE}"
|
||||
if [ -n "${OVERLAY:-}" ]; then
|
||||
echo " overlay $(basename "$(_abs_from_ctrl "$OVERLAY_DIR")") ($(_abs_from_ctrl "$OVERLAY_DIR"))"
|
||||
elif [ -n "$OVERLAY_DIR" ]; then
|
||||
fact " overlay none named — rig's own ${OVERLAY_DIR}"
|
||||
fi
|
||||
if [ -n "$VERBOSE" ] && [ -n "$OVERLAY_DIR" ]; then
|
||||
ov=$(_from_ctrl "$OVERLAY_DIR") pieces=""
|
||||
for piece in rig.env k8s/overlays/dev kind-config.yaml.tpl addons Tiltfile; do
|
||||
[ -e "$ov/$piece" ] && pieces+="$piece "
|
||||
done
|
||||
echo " provides: ${pieces:-nothing rig reads}"
|
||||
fi
|
||||
fact " manifests ${MANIFESTS_DIR:-none}"
|
||||
fact " kind config ${KIND_CONFIG}"
|
||||
fact " ingress ${INGRESS_MODE}"
|
||||
if [ ! -f ./.env ]; then
|
||||
fact " .env none — built-in defaults (cp ctrl/.env.example ctrl/.env to set values)"
|
||||
fi
|
||||
if [ -n "${STALE_MANIFESTS_DIR:-}" ]; then
|
||||
echo " ! .env MANIFESTS_DIR=${STALE_MANIFESTS_DIR} is the old default; rig's examples moved"
|
||||
echo " to examples/ — delete that line from ctrl/.env (ignored until then)"
|
||||
fi
|
||||
# registry.sh points containerd at certs.d, which only works if the kind config says so,
|
||||
# and a kind config is fixed at creation: a project's own file that drops it fails silently.
|
||||
if [ "$REGISTRY_MODE" != none ] && ! grep -q 'config_path *= *"/etc/containerd/certs.d"' "$KIND_CONFIG"; then
|
||||
echo " ! kind ${KIND_CONFIG} lacks the containerd config_path patch that registry mode"
|
||||
echo " '${REGISTRY_MODE}' needs — copy it from ctrl/k8s/kind-config.yaml.tpl"
|
||||
# Who listens on a port, as "pid name", when ss can tell (only for our own processes).
|
||||
port_owner() {
|
||||
command -v ss >/dev/null 2>&1 || return 0
|
||||
ss -ltnpH "sport = :$1" 2>/dev/null \
|
||||
| sed -n 's/.*users:(("\([^"]*\)",pid=\([0-9]*\).*/\2 \1/p' | head -1
|
||||
}
|
||||
|
||||
# This environment's Tilt: a tilt started from this ctrl/ (the Makefile runs it there).
|
||||
tilt_pid=""
|
||||
if tilt_owner=$(port_owner "$TILT_PORT") && [ -n "$tilt_owner" ]; then
|
||||
pid=${tilt_owner%% *}
|
||||
if [ "${tilt_owner#* }" = tilt ] && [ "$(readlink "/proc/$pid/cwd" 2>/dev/null)" = "$(pwd -P)" ]; then
|
||||
tilt_pid=$pid
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── memory: does this cluster fit right now? Warns; never blocks. ──────────
|
||||
# none | stopped | up | unknown (no docker)
|
||||
cluster_state() {
|
||||
docker info >/dev/null 2>&1 || { echo unknown; return; }
|
||||
local f="label=io.x-k8s.kind.cluster=${CLUSTER}"
|
||||
if [ -n "$(docker ps -q --filter "$f")" ]; then echo up
|
||||
elif [ -n "$(docker ps -aq --filter "$f")" ]; then echo stopped
|
||||
else echo none; fi
|
||||
}
|
||||
state=$(cluster_state)
|
||||
|
||||
# ── memory budget: what this environment needs, against what the machine has ──
|
||||
# The workload's share comes from the overlay: MEMORY_BUDGET="name=MB ..." in its rig.env,
|
||||
# and MEMORY_BUDGET_TIP, optionally, says how to switch its largest part off.
|
||||
# rig only knows what a node costs. Warns; never blocks.
|
||||
total_mb=$(mb_of MemTotal)
|
||||
avail_mb=$(mb_of MemAvailable)
|
||||
swap_used_mb=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
|
||||
overcommit=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?')
|
||||
need_mb=$(( NODES * NODE_MB ))
|
||||
node_mb=$(( NODES * NODE_MB ))
|
||||
|
||||
budget="" work_mb=0 big_mb=0 big=""
|
||||
for item in ${MEMORY_BUDGET:-}; do
|
||||
name=${item%=*} mb=${item##*=}
|
||||
if [ "$name" = "$item" ] || ! [[ "$mb" =~ ^[0-9]+$ ]]; then
|
||||
echo " ! budget '$item' in MEMORY_BUDGET is not name=MB"
|
||||
continue
|
||||
fi
|
||||
[ "$mb" -gt 0 ] || continue
|
||||
budget+=$(printf ' %-18s %6d MB' "$name" "$mb")$'\n'
|
||||
work_mb=$(( work_mb + mb ))
|
||||
if [ "$mb" -gt "$big_mb" ]; then big_mb=$mb big=$name; fi
|
||||
done
|
||||
need_mb=$(( work_mb + node_mb ))
|
||||
|
||||
rows=$(container_mb)
|
||||
# If our cluster is already up, its memory is already out of MemAvailable: need nothing more.
|
||||
# If our cluster is already up, its memory is already out of MemAvailable.
|
||||
ours_mb=$(awk -F'\t' -v c="$CLUSTER" '$2 == c { s += $1 } END { print s + 0 }' <<< "$rows")
|
||||
still_mb=$(( ours_mb > 0 ? 0 : need_mb ))
|
||||
if [ "$state" = up ]; then
|
||||
still_mb=$(( need_mb > ours_mb ? need_mb - ours_mb : 0 ))
|
||||
else
|
||||
still_mb=$need_mb
|
||||
fi
|
||||
headroom=$(( avail_mb - still_mb ))
|
||||
|
||||
# The biggest things holding memory, other than this cluster: kind clusters summed, the rest by name.
|
||||
@@ -117,82 +131,164 @@ others=$(awk -F'\t' -v c="$CLUSTER" '
|
||||
$2 == "-" { k["container \x27" $3 "\x27"] += $1 }
|
||||
END { for (n in k) printf "%d\t%s\n", k[n], n }' <<< "$rows" | sort -rn)
|
||||
|
||||
if [ "$still_mb" -eq 0 ] && [ "$headroom" -ge 512 ]; then
|
||||
printf " memory up, holding %d MB — %d MB headroom for what you deploy\n" "$ours_mb" "$headroom"
|
||||
elif [ "$still_mb" -eq 0 ]; then
|
||||
printf " ! memory up, but only %d MB headroom for anything you deploy\n" "$headroom"
|
||||
elif [ "$headroom" -ge 512 ]; then
|
||||
printf " memory fits — ~%d MB for %s node(s), %d MB headroom\n" "$need_mb" "$NODES" "$headroom"
|
||||
echo
|
||||
echo "memory budget (${CLUSTER})"
|
||||
printf '%s' "$budget"
|
||||
if [ -z "$budget" ]; then
|
||||
echo " workload ? (the overlay lists none: MEMORY_BUDGET in its rig.env)"
|
||||
fi
|
||||
printf ' %-18s %6d MB (kind control plane, kube-system; %s node(s))\n' "node overhead" "$node_mb" "$NODES"
|
||||
echo " ---------"
|
||||
printf ' %-18s %6d MB\n' "required" "$need_mb"
|
||||
if [ "$state" = up ]; then
|
||||
printf ' %-18s %6d MB (%s is up)\n' "held by it now" "$ours_mb" "$CLUSTER"
|
||||
fi
|
||||
printf ' %-18s %6d MB\n' "this machine has" "$total_mb"
|
||||
printf ' %-18s %6d MB (right now)\n' "available" "$avail_mb"
|
||||
echo
|
||||
|
||||
if [ "$headroom" -ge 512 ]; then
|
||||
if [ "$state" = up ]; then
|
||||
echo "fits: ${CLUSTER} holds ${ours_mb} MB of its ${need_mb} MB; ${avail_mb} MB still available — ${headroom} MB headroom."
|
||||
else
|
||||
echo "fits: ${need_mb} MB needed, ${avail_mb} MB available — ${headroom} MB headroom."
|
||||
fi
|
||||
elif [ "$headroom" -ge 0 ]; then
|
||||
printf " ! memory fits, but only %d MB headroom (~%d MB for %s node(s))\n" "$headroom" "$need_mb" "$NODES"
|
||||
echo "! tight: ${still_mb} MB still needed, ${avail_mb} MB available — only ${headroom} MB headroom."
|
||||
else
|
||||
printf " ! memory does not fit: ~%d MB needed, %d MB available\n" "$still_mb" "$avail_mb"
|
||||
echo "! does not fit: ${still_mb} MB still needed, ${avail_mb} MB available."
|
||||
# Two failures with opposite fixes, and telling them apart is the point.
|
||||
if [ "$still_mb" -le "$total_mb" ]; then
|
||||
echo " something else holds it (below) — stopping that helps, a bigger VM would not."
|
||||
if [ "$need_mb" -le "$total_mb" ]; then
|
||||
echo " Something else holds the memory (below): stopping it helps, a bigger machine would not."
|
||||
if grep -q 'kind cluster' <<< "$others"; then
|
||||
echo " 'make cluster free' stops the other kind clusters. It stops, never deletes."
|
||||
echo " 'make cluster free' stops the other kind clusters. It stops, never deletes."
|
||||
fi
|
||||
else
|
||||
echo " the machine itself is too small: ${total_mb} MB total."
|
||||
echo " The machine itself is too small: ${total_mb} MB in all. Give it more memory"
|
||||
echo " ('make check mem' has the WSL steps), or switch parts of the workload off."
|
||||
fi
|
||||
fi
|
||||
if [ -n "$others" ] && { [ -n "$VERBOSE" ] || [ "$headroom" -lt 512 ]; }; then
|
||||
echo " held elsewhere:"
|
||||
head -6 <<< "$others" | awk -F'\t' '{ printf " %6d MB %s\n", $1, $2 }'
|
||||
echo " held elsewhere:"
|
||||
head -6 <<< "$others" | awk -F'\t' '{ printf " %6d MB %s\n", $1, $2 }'
|
||||
n_others=$(wc -l <<< "$others")
|
||||
if [ "$n_others" -gt 6 ]; then
|
||||
echo " ... and $((n_others - 6)) more"
|
||||
echo " ... and $((n_others - 6)) more"
|
||||
fi
|
||||
fi
|
||||
# One part that outweighs the rest is the first thing to switch off on a smaller machine.
|
||||
if [ "$work_mb" -gt 0 ] && [ $(( big_mb * 2 )) -gt "$work_mb" ] && [ "$big_mb" -ge 1024 ]; then
|
||||
echo
|
||||
echo "${big} is ${big_mb} of the ${work_mb} MB the workload needs — more than the rest"
|
||||
echo "combined. If you are not working on it, ${MEMORY_BUDGET_TIP:-switching it off in the overlay} gets that back."
|
||||
fi
|
||||
if [ "$swap_used_mb" -gt 0 ]; then
|
||||
fact " ${swap_used_mb} MB already in swap, which 'available' does not count: expect slow before failing"
|
||||
fact " ${swap_used_mb} MB already in swap, which 'available' does not count: expect slow before failing"
|
||||
fi
|
||||
if [ "$overcommit" = "1" ]; then
|
||||
fact " overcommit=1: allocations never fail, so read 'fits' as a ceiling (OOM killer settles up)"
|
||||
fact " overcommit=1: allocations never fail, so read 'fits' as a ceiling (OOM killer settles up)"
|
||||
fi
|
||||
|
||||
# ── where things stand, and the next step ──────────────────────────────────
|
||||
echo
|
||||
case "$state" in
|
||||
none)
|
||||
echo "${CLUSTER} does not exist yet. 'make cluster up' creates it${ADDONS:+ (with ${ADDONS})},"
|
||||
echo "then 'make tilt' starts the dev loop." ;;
|
||||
stopped)
|
||||
echo "${CLUSTER} exists but is stopped (nothing is lost). Start it again with:"
|
||||
echo " docker start \$(docker ps -aq -f label=io.x-k8s.kind.cluster=${CLUSTER})" ;;
|
||||
up)
|
||||
if [ -n "$tilt_pid" ]; then
|
||||
echo "${CLUSTER} is up, and its Tilt is running (pid ${tilt_pid}): http://localhost:${TILT_PORT}"
|
||||
else
|
||||
echo "${CLUSTER} is up. 'make tilt' starts the dev loop, at http://localhost:${TILT_PORT}."
|
||||
fi
|
||||
echo "Through the gateway: http://localhost:${HTTP_PORT}" ;;
|
||||
*)
|
||||
echo "docker is not reachable, so the cluster's state is unknown (the machine block below says why)." ;;
|
||||
esac
|
||||
|
||||
# ── the configuration, as it is ────────────────────────────────────────────
|
||||
echo
|
||||
bash ./deps.sh detect ${VERBOSE:+all}
|
||||
|
||||
echo
|
||||
echo "config"
|
||||
echo " cluster ${CLUSTER} ($([ "$state" = none ] && echo 'not created' || echo "$state"))"
|
||||
echo " context ${KUBECONTEXT}"
|
||||
if [ -n "${OVERLAY:-}" ]; then
|
||||
echo " overlay $(_abs_from_ctrl "$OVERLAY_DIR")"
|
||||
elif [ -n "$OVERLAY_DIR" ]; then
|
||||
echo " overlay none named — rig's own ${OVERLAY_DIR}"
|
||||
fi
|
||||
if [ -n "$VERBOSE" ] && [ -n "$OVERLAY_DIR" ]; then
|
||||
ov=$(_from_ctrl "$OVERLAY_DIR") pieces=""
|
||||
for piece in rig.env k8s/overlays/dev kind-config.yaml.tpl addons Tiltfile; do
|
||||
[ -e "$ov/$piece" ] && pieces+="$piece "
|
||||
done
|
||||
echo " provides: ${pieces:-nothing rig reads}"
|
||||
fi
|
||||
echo " profile ${PROFILE_NAME}"
|
||||
echo " nodes ${NODES}"
|
||||
echo " kind config ${KIND_CONFIG}"
|
||||
echo " manifests ${MANIFESTS_DIR:-none}"
|
||||
echo " ingress ${INGRESS_MODE}"
|
||||
if [ ! -f ./.env ]; then
|
||||
fact " .env none — built-in defaults (cp ctrl/.env.example ctrl/.env to set values)"
|
||||
fi
|
||||
if [ -n "${STALE_MANIFESTS_DIR:-}" ]; then
|
||||
echo " ! .env MANIFESTS_DIR=${STALE_MANIFESTS_DIR} is the old default; rig's examples moved"
|
||||
echo " to examples/ — delete that line from ctrl/.env (ignored until then)"
|
||||
fi
|
||||
# registry.sh points containerd at certs.d, which only works if the kind config says so,
|
||||
# and a kind config is fixed at creation: a project's own file that drops it fails silently.
|
||||
if [ "$REGISTRY_MODE" != none ] && ! grep -q 'config_path *= *"/etc/containerd/certs.d"' "$KIND_CONFIG"; then
|
||||
echo " ! kind ${KIND_CONFIG} lacks the containerd config_path patch that registry mode"
|
||||
echo " '${REGISTRY_MODE}' needs — copy it from ctrl/k8s/kind-config.yaml.tpl"
|
||||
fi
|
||||
|
||||
# ── ports: checked before creation; docker reports a clash only halfway through. ──
|
||||
# Ports held by our own cluster are not clashes. Second grep, not `tr -d ':->'` (a tr range).
|
||||
ours=$(docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" \
|
||||
--format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \
|
||||
| grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
|
||||
|
||||
clash=0 list="" mine=0
|
||||
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
|
||||
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
|
||||
# Ports held by our own cluster are not clashes: its node containers, and its registry,
|
||||
# which is a container of its own. Second grep, not `tr -d ':->'` (a tr range).
|
||||
ours=$({ docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" --format '{{.Ports}}'
|
||||
docker ps --filter "name=^${CLUSTER}-registry\$" --format '{{.Ports}}'; } 2>/dev/null \
|
||||
| tr ',' '\n' | grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
|
||||
|
||||
clash=0 list=""
|
||||
for entry in "http:${HTTP_PORT}" "https:${HTTPS_PORT}" \
|
||||
"tilt:${TILT_PORT}" "registry:${REGISTRY_PORT}"; do
|
||||
name="${entry%%:*}"; p="${entry#*:}"
|
||||
[ -n "$p" ] || continue
|
||||
list+="$p "
|
||||
list+="$name $p "
|
||||
if ! port_busy "$p"; then
|
||||
fact "$(printf " %-9s %-6s free" "$name" "$p")"
|
||||
elif echo "$ours" | grep -qx "$p"; then
|
||||
mine=1
|
||||
fact "$(printf " %-9s %-6s in use by this environment's cluster" "$name" "$p")"
|
||||
elif [ "$name" = tilt ] && [ -n "$tilt_pid" ]; then
|
||||
fact "$(printf " %-9s %-6s in use by this environment's Tilt" "$name" "$p")"
|
||||
else
|
||||
printf " ! ports %s %s IN USE by something else\n" "$name" "$p"
|
||||
clash=1
|
||||
owner=$(port_owner "$p")
|
||||
if [ "$name" = tilt ] && [ "${owner#* }" = tilt ]; then
|
||||
printf " ! ports tilt %s is held by another Tilt (pid %s, in %s)\n" "$p" "${owner%% *}" \
|
||||
"$(readlink "/proc/${owner%% *}/cwd" 2>/dev/null || echo '?')"
|
||||
echo " stop it (kill ${owner%% *}) before 'make tilt'"
|
||||
else
|
||||
printf " ! ports %s %s IN USE by %s\n" "$name" "$p" "${owner:+pid ${owner}}${owner:-something else}"
|
||||
clash=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo " ports ${list% }"
|
||||
if [ "$clash" -eq 1 ]; then
|
||||
echo " override it in ctrl/.env (e.g. HTTP_PORT=21080), or rename this directory"
|
||||
elif [ "$mine" -eq 1 ]; then
|
||||
echo " ports ${list% } held by this cluster"
|
||||
else
|
||||
echo " ports ${list% } free"
|
||||
fi
|
||||
if [ -n "${OVERLAY:-}" ]; then
|
||||
fact " derived from the overlay's folder name"
|
||||
else
|
||||
fact " derived from the directory name; pin them: bash ctrl/ports.sh persist"
|
||||
fi
|
||||
|
||||
# ── what `make cluster up` wires in beside the cluster ─────────────────────
|
||||
REG_NAME="${CLUSTER}-registry"
|
||||
if state=$(docker inspect -f '{{.State.Status}}' "$REG_NAME" 2>/dev/null); then
|
||||
echo " registry localhost:${REGISTRY_PORT} ($state)"
|
||||
if reg_state=$(docker inspect -f '{{.State.Status}}' "$REG_NAME" 2>/dev/null); then
|
||||
echo " registry ${REGISTRY_MODE} localhost:${REGISTRY_PORT} (${reg_state})"
|
||||
else
|
||||
fact " registry no container yet — 'make cluster up' starts it"
|
||||
echo " registry ${REGISTRY_MODE} (no container yet)"
|
||||
fi
|
||||
echo " addons ${ADDONS:-none}"
|
||||
if [ -n "$VERBOSE" ]; then
|
||||
|
||||
@@ -16,12 +16,30 @@ serve() {
|
||||
echo "no docs/index.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
# One worker and no access log: a few static pages need no more, and the
|
||||
# terminal then shows only real errors. The config is a temp file, gone on exit.
|
||||
# Global, not local: the trap runs after serve() has returned.
|
||||
DOCS_CONF=$(mktemp)
|
||||
trap 'rm -f "$DOCS_CONF"' EXIT
|
||||
cat > "$DOCS_CONF" <<'NGINX'
|
||||
worker_processes 1;
|
||||
error_log stderr error;
|
||||
events { worker_connections 64; }
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
access_log off;
|
||||
server { listen 80; root /usr/share/nginx/html; }
|
||||
}
|
||||
NGINX
|
||||
chmod 644 "$DOCS_CONF"
|
||||
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
|
||||
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
|
||||
docker run --rm \
|
||||
--name "${CLUSTER}-docs" \
|
||||
-e NGINX_ENTRYPOINT_QUIET_LOGS=1 \
|
||||
-p "${DOCS_PORT}:80" \
|
||||
-v "$REPO/docs:/usr/share/nginx/html:ro" \
|
||||
-v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \
|
||||
nginx:alpine
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,29 @@ render_kind_config() {
|
||||
"$KIND_CONFIG"
|
||||
}
|
||||
|
||||
# Render an addon's manifest (a .yaml beside its .sh) to stdout. Every ${NAME}, or
|
||||
# ${NAME:-default}, is filled from the loaded config or the calling script's variables;
|
||||
# a NAME with neither is an error, so a typo never becomes an empty image. $VAR and
|
||||
# $(VAR) are left alone: they belong to the container. Notes: docs/notes/addons.md
|
||||
render_manifest() {
|
||||
local file="$1" text ref key def val
|
||||
text=$(<"$file") || return 1
|
||||
while IFS= read -r ref; do
|
||||
key=${ref#\$\{}; key=${key%\}}; def=""
|
||||
if [[ $key == *:-* ]]; then def=${key#*:-}; key=${key%%:-*}; fi
|
||||
if [ -n "${!key:-}" ]; then
|
||||
val=${!key}
|
||||
elif [[ $ref == *:-* ]]; then
|
||||
val=$def
|
||||
else
|
||||
echo "render_manifest: $file needs \${$key}, which nothing sets" >&2
|
||||
return 1
|
||||
fi
|
||||
text=${text//"$ref"/"$val"}
|
||||
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u)
|
||||
printf '%s\n' "$text"
|
||||
}
|
||||
|
||||
_config_restore() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
|
||||
@@ -307,7 +307,7 @@ HOUSE_PAT="$(printf '%s' 'semes' 'ter|local' '\.ar\b')"
|
||||
check "✖ S2 no example environment name from the copies era" "0" \
|
||||
"$(cd .. && grep -rIlE "$COPIES_PAT" . --exclude-dir=def --exclude-dir=local --exclude=STALE.md 2>/dev/null | wc -l)"
|
||||
check "✖ S3 ctrl/addons makes the cluster work, nothing more" "cert-manager metallb metrics-server" \
|
||||
"$(ls addons/ | sed 's/\.sh$//' | sort | xargs)"
|
||||
"$(ls addons/*.sh | xargs -n1 basename | sed 's/\.sh$//' | sort | xargs)" # the scripts; their manifests sit beside them
|
||||
check "✖ S3 versions.env pins no workload image" "0" \
|
||||
"$(grep -cE '^(POSTGRES|REDIS|AIRFLOW)_IMAGE=' versions.env)"
|
||||
check "✖ S4 no namespace named after the cluster" "0" "$(grep -c "CLUSTER + ':namespace'" Tiltfile)"
|
||||
@@ -378,6 +378,16 @@ note "the examples are overlays that work as shipped"
|
||||
bad=""
|
||||
for f in ../examples/*/addons/*.sh; do [ -e "$f" ] && { bash -n "$f" 2>/dev/null || bad+="$f "; }; done
|
||||
check "every example addon parses" "" "$bad"
|
||||
# An addon's manifest may only name what the config or its own script sets, so a typo
|
||||
# fails here rather than as an empty value on a cluster.
|
||||
bad=""
|
||||
for y in addons/*.yaml ../examples/*/addons/*.yaml; do
|
||||
[ -e "$y" ] || continue
|
||||
for n in $(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$y" | tr -d '${}' | sort -u); do
|
||||
grep -qE "(^|[[:space:]])$n=" "${y%.yaml}.sh" versions.env ../examples/*/rig.env 2>/dev/null || bad+="$y:$n "
|
||||
done
|
||||
done
|
||||
check "every addon manifest names only what is set" "" "$bad"
|
||||
if command -v python3 >/dev/null; then
|
||||
bad=""
|
||||
for f in ../examples/*/dags/*.py; do
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// TODO: PLACEHOLDER — replace with the real estate topology.
|
||||
//
|
||||
// This is where the extracted platform diagrams land. The shape below is
|
||||
// illustrative only: it shows how a mocked dependency, a real service and a
|
||||
// remote system are meant to sit together, not what the system actually is.
|
||||
//
|
||||
// The intended end state is that this file stops being hand-written and is
|
||||
// generated from the running cluster, so the diagram becomes a report of what
|
||||
// exists rather than a drawing of what was once intended.
|
||||
digraph estate {
|
||||
rankdir=LR
|
||||
bgcolor="#0a0e17"
|
||||
fontname="Helvetica"
|
||||
node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=box]
|
||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||
|
||||
label="Estate topology — PLACEHOLDER"
|
||||
labelloc=t
|
||||
fontsize=16
|
||||
fontcolor="#ffc107"
|
||||
|
||||
subgraph cluster_new {
|
||||
label="New"
|
||||
style=dashed
|
||||
color="#1e2a4a"
|
||||
fontcolor="#8892a8"
|
||||
|
||||
api [label="service under work\n(real: built and hot-reloaded)" fillcolor="#1a3a1a" fontcolor="#00c853"]
|
||||
}
|
||||
|
||||
subgraph cluster_core {
|
||||
label="Core (mocked)"
|
||||
style=dashed
|
||||
color="#1e2a4a"
|
||||
fontcolor="#8892a8"
|
||||
|
||||
svc_a [label="upstream service\n(mock: canned responses)" fillcolor="#121829"]
|
||||
db [label="datastore\n(mock)" fillcolor="#121829" shape=cylinder]
|
||||
}
|
||||
|
||||
subgraph cluster_legacy {
|
||||
label="Legacy estate (mocked)"
|
||||
style=dashed
|
||||
color="#1e2a4a"
|
||||
fontcolor="#8892a8"
|
||||
|
||||
batch [label="batch drop\n(mock: writes files on a timer)" fillcolor="#121829"]
|
||||
}
|
||||
|
||||
remote [label="external system\n(remote: ExternalName,\nreachable only from a VDI)" fillcolor="#3a1a1a" fontcolor="#ffc107" shape=octagon]
|
||||
|
||||
api -> svc_a [label="HTTP"]
|
||||
api -> db [label="query"]
|
||||
api -> batch [label="file handoff" style=dashed]
|
||||
api -> remote [label="only when reachable" style=dashed color="#ffc107"]
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 14.1.2 (0)
|
||||
-->
|
||||
<!-- Title: estate Pages: 1 -->
|
||||
<svg width="579pt" height="362pt"
|
||||
viewBox="0.00 0.00 579.00 362.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 357.62)">
|
||||
<title>estate</title>
|
||||
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-357.62 575.41,-357.62 575.41,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="285.7" y="-334.42" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#ffc107">Estate topology — PLACEHOLDER</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_new</title>
|
||||
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-130.12 8,-210.12 199,-210.12 199,-130.12 8,-130.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-190.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">New</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_core</title>
|
||||
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="357.33,-172.12 357.33,-318.12 534.83,-318.12 534.83,-172.12 357.33,-172.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-298.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Core (mocked)</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<title>cluster_legacy</title>
|
||||
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="341.83,-84.12 341.83,-164.12 551.33,-164.12 551.33,-84.12 341.83,-84.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.58" y="-144.92" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Legacy estate (mocked)</text>
|
||||
</g>
|
||||
<!-- api -->
|
||||
<g id="node1" class="node">
|
||||
<title>api</title>
|
||||
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="191,-174.12 16,-174.12 16,-138.12 191,-138.12 191,-174.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-159.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">service under work</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="103.5" y="-145.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">(real: built and hot-reloaded)</text>
|
||||
</g>
|
||||
<!-- svc_a -->
|
||||
<g id="node2" class="node">
|
||||
<title>svc_a</title>
|
||||
<polygon fill="#121829" stroke="#1e2a4a" points="526.83,-282.12 365.33,-282.12 365.33,-246.12 526.83,-246.12 526.83,-282.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-267.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">upstream service</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-253.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock: canned responses)</text>
|
||||
</g>
|
||||
<!-- api->svc_a -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>api->svc_a</title>
|
||||
<path fill="none" stroke="#4a5568" d="M149.07,-174.56C167.54,-182.07 189.23,-190.7 209,-198.12 258.25,-216.6 270.12,-222.85 320.75,-237.12 331.43,-240.13 342.71,-243 353.94,-245.67"/>
|
||||
<polygon fill="#4a5568" stroke="#4a5568" points="353.12,-249.07 363.66,-247.92 354.71,-242.25 353.12,-249.07"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-234.24" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
|
||||
</g>
|
||||
<!-- db -->
|
||||
<g id="node3" class="node">
|
||||
<title>db</title>
|
||||
<path fill="#121829" stroke="#1e2a4a" d="M480.7,-223.81C480.7,-226.22 465.18,-228.18 446.08,-228.18 426.97,-228.18 411.45,-226.22 411.45,-223.81 411.45,-223.81 411.45,-184.43 411.45,-184.43 411.45,-182.02 426.97,-180.06 446.08,-180.06 465.18,-180.06 480.7,-182.02 480.7,-184.43 480.7,-184.43 480.7,-223.81 480.7,-223.81"/>
|
||||
<path fill="none" stroke="#1e2a4a" d="M480.7,-223.81C480.7,-221.39 465.18,-219.43 446.08,-219.43 426.97,-219.43 411.45,-221.39 411.45,-223.81"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-207.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">datastore</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-193.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock)</text>
|
||||
</g>
|
||||
<!-- api->db -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>api->db</title>
|
||||
<path fill="none" stroke="#4a5568" d="M191.32,-168.36C257.83,-177.73 346.91,-190.28 399.91,-197.75"/>
|
||||
<polygon fill="#4a5568" stroke="#4a5568" points="399.4,-201.22 409.79,-199.15 400.38,-194.29 399.4,-201.22"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-185.69" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">query</text>
|
||||
</g>
|
||||
<!-- batch -->
|
||||
<g id="node4" class="node">
|
||||
<title>batch</title>
|
||||
<polygon fill="#121829" stroke="#1e2a4a" points="537.33,-128.12 354.83,-128.12 354.83,-92.12 537.33,-92.12 537.33,-128.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-113.17" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">batch drop</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-99.67" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(mock: writes files on a timer)</text>
|
||||
</g>
|
||||
<!-- api->batch -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>api->batch</title>
|
||||
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M191.32,-144.39C237.67,-138.13 294.98,-130.39 343.4,-123.85"/>
|
||||
<polygon fill="#4a5568" stroke="#4a5568" points="343.61,-127.36 353.05,-122.55 342.67,-120.42 343.61,-127.36"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-143.94" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">file handoff</text>
|
||||
</g>
|
||||
<!-- remote -->
|
||||
<g id="node5" class="node">
|
||||
<title>remote</title>
|
||||
<polygon fill="#3a1a1a" stroke="#1e2a4a" points="571.41,-21.74 571.41,-52.5 497.99,-74.24 394.17,-74.24 320.75,-52.5 320.75,-21.74 394.17,0 497.99,0 571.41,-21.74"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-46.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">external system</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-33.42" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">(remote: ExternalName,</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="446.08" y="-19.92" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#ffc107">reachable only from a VDI)</text>
|
||||
</g>
|
||||
<!-- api->remote -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>api->remote</title>
|
||||
<path fill="none" stroke="#ffc107" stroke-dasharray="5,2" d="M149.55,-137.68C167.9,-130.34 189.37,-121.97 209,-114.87 254.45,-98.43 305.32,-81.49 348.17,-67.64"/>
|
||||
<polygon fill="#ffc107" stroke="#ffc107" points="349,-71.05 357.44,-64.65 346.85,-64.39 349,-71.05"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="255.88" y="-117.57" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">only when reachable</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>rig — local environment installer</title>
|
||||
<style>
|
||||
@@ -237,13 +238,9 @@
|
||||
<div class="nav-backdrop"></div>
|
||||
<nav>
|
||||
<a href="#start">Start here</a>
|
||||
<a href="#steps">The steps</a>
|
||||
<a href="#install">Installation</a>
|
||||
<a href="#environments">Environments</a>
|
||||
<a href="#overlays">Overlays</a>
|
||||
<a href="#profiles">Profiles</a>
|
||||
<a href="#registry">Registry</a>
|
||||
<a href="#architecture">Architecture</a>
|
||||
<a href="#troubleshooting">Troubleshooting</a>
|
||||
</nav>
|
||||
|
||||
@@ -251,114 +248,68 @@
|
||||
|
||||
<section class="section" id="start">
|
||||
<h2>Start here</h2>
|
||||
<p class="lede">A runnable local model of a large, regulated estate — legacy and new side by side.</p>
|
||||
<div class="prose">
|
||||
<p>rig builds a disposable Kubernetes environment on your machine so you can
|
||||
explore how a system fits together without needing access to any of it. Its
|
||||
job is <b>onboarding and exploration</b>, not a production replica.</p>
|
||||
<p>rig builds a disposable Kubernetes environment on your machine, so you can
|
||||
see how a system fits together without access to any of it. It is for
|
||||
<b>onboarding and exploration</b>, not a production replica.</p>
|
||||
|
||||
<p>Most services in it are deliberately <b>not real</b>. What has to be faithful
|
||||
is the topology — the names, the ports, the dependency order, who can reach whom,
|
||||
and how it fails. The workloads themselves are noise. This is what makes the
|
||||
whole estate fit on a laptop: a real 20-service platform will not fit even once
|
||||
on 14 GB, but mocks are about 30 MB each, so three faithful copies do.</p>
|
||||
<p>Most services in it are <b>not real</b>. What has to be faithful is the
|
||||
topology: names, ports, dependency order, who can reach whom, and how it fails.
|
||||
That is what makes a whole estate fit on a laptop. A real 20-service platform
|
||||
does not fit once in 14 GB; stubs of about 30 MB each fit three times.</p>
|
||||
|
||||
<h3>The only prerequisite</h3>
|
||||
<p><b>Docker.</b> No curl, no jq, no python, no apt repositories to configure.</p>
|
||||
<pre><code><span class="c"># then, in the environment directory:</span>
|
||||
make check <span class="c"># is this machine ready? reports, never fixes</span>
|
||||
make deps <span class="c"># install the pinned toolchain</span>
|
||||
<pre><code><span class="c"># in the environment directory:</span>
|
||||
make check <span class="c"># is this machine ready? reports, never changes anything</span>
|
||||
make deps <span class="c"># install the pinned toolchain; safe to re-run</span>
|
||||
make cluster up <span class="c"># cluster + registry + addons; ports derive by themselves</span>
|
||||
</code></pre>
|
||||
<p>Read <code>make check</code> before <code>make deps</code>. It never changes
|
||||
anything — it prints what it found and, at the end, the steps it cannot perform
|
||||
for you.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="steps">
|
||||
<h2>The steps</h2>
|
||||
<p class="lede">Start to finish, in order, with what each one actually does.</p>
|
||||
<div class="prose">
|
||||
|
||||
<h3>1 · make check</h3>
|
||||
<p>Asks whether this machine is ready. It <b>changes nothing</b> — it
|
||||
reports what it found and, at the end, the things only a human can do
|
||||
(anything needing <code>sudo</code>, or a Windows-side restart). Read it
|
||||
before installing anything; it is faster than discovering the same problems
|
||||
one failure at a time.</p>
|
||||
<pre><code>make check</code></pre>
|
||||
|
||||
<h3>2 · make deps</h3>
|
||||
<p>Installs the pinned toolchain — only what is missing — and tells you
|
||||
if its directory is not on PATH yet. Running it twice is safe.</p>
|
||||
<pre><code>make deps</code></pre>
|
||||
|
||||
<h3>3 · make cluster up</h3>
|
||||
<p>Builds the cluster, starts its registry and installs the profile's
|
||||
addons — there is nothing else to run first. It prints what the profile
|
||||
locks in <i>before</i> spending the time, because the kind config is
|
||||
fixed at creation and cannot be changed afterwards.</p>
|
||||
<p>Re-running is safe and, more importantly, <b>convergent</b>: if a first
|
||||
attempt was interrupted before the CNI was installed, running it again
|
||||
finishes the job rather than reporting "already exists" and leaving every
|
||||
node permanently NotReady.</p>
|
||||
<pre><code>make cluster up <span class="c"># built-in defaults — no profile needed</span>
|
||||
make cluster up PROFILE=mirror <span class="c"># after copying env.d/mirror.env.example: cached registry</span>
|
||||
<p>Read <code>make check</code> before <code>make deps</code>: it lists what is
|
||||
missing, including the steps only you can do, such as anything needing
|
||||
<code>sudo</code> or a Windows-side restart.</p>
|
||||
|
||||
<h3>make cluster up</h3>
|
||||
<p>It prints what the kind config locks in before spending the time, because
|
||||
that config is fixed when the cluster is created. Re-running is safe and
|
||||
continues where an interrupted run stopped, rather than reporting that the
|
||||
cluster already exists and leaving every node <code>NotReady</code>.</p>
|
||||
<pre><code>make cluster up <span class="c"># built-in defaults — no profile needed</span>
|
||||
make cluster up PROFILE=mirror <span class="c"># images through an internal registry</span>
|
||||
make cluster up OVERLAY=examples/data <span class="c"># an overlay: what runs, kept outside rig</span>
|
||||
make cluster reset <span class="c"># destroy and rebuild — how an edited kind config takes effect</span>
|
||||
make cluster reset <span class="c"># destroy and rebuild: how an edited kind config takes effect</span>
|
||||
</code></pre>
|
||||
|
||||
<h3>4 · make docs</h3>
|
||||
<p>Serves this page from a throwaway container. Works with no cluster and
|
||||
no toolchain, which is deliberate: these pages are the instructions for
|
||||
building everything else, so they cannot depend on it.</p>
|
||||
<pre><code>make docs</code></pre>
|
||||
|
||||
<h3>Checking on things</h3>
|
||||
<dl>
|
||||
<dt>make cluster list</dt><dd>Every cluster on the machine, its memory cost and its port block. The usual reason a new one will not start is an old one you forgot about; <code>make cluster free</code> frees them without deleting.</dd>
|
||||
<dt>make check</dt><dd>Short: host, toolchain, and whether this cluster fits, its ports, registry and addons. Details only appear when something needs attention; <code>make check all</code> prints every one.</dd>
|
||||
<dt>make check mem</dt><dd>Memory in depth: what caps it, how far it really climbs, and on WSL the <code>.wslconfig</code> backup and restore.</dd>
|
||||
<dt>make cluster list</dt><dd>Every cluster on the machine, its memory cost and its port block. A new cluster usually fails to start because of an old one; <code>make cluster free</code> stops them without deleting.</dd>
|
||||
<dt>make check</dt><dd>Host, toolchain, and whether this cluster fits, its ports, registry and addons. Only problems print; <code>make check all</code> prints everything.</dd>
|
||||
<dt>make check mem</dt><dd>Memory in depth: what caps it, how far it climbs, and on WSL the <code>.wslconfig</code> backup and restore.</dd>
|
||||
<dt>make docs</dt><dd>Serves these pages from a throwaway container. It needs no cluster and no toolchain, because these are the instructions for building both.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Running more than one</h3>
|
||||
<p>Name another overlay, or copy the directory and rename it. Cluster name,
|
||||
context, image tags and the port block all follow the folder name — the
|
||||
overlay's, or rig's — so the second environment collides with nothing and
|
||||
neither one's teardown can reach the other.</p>
|
||||
<pre><code>OVERLAY=local/platform-v2 make cluster up
|
||||
cp -r rig ../platform-v3 && cd ../platform-v3 && make cluster up
|
||||
</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="install">
|
||||
<h2>Installation</h2>
|
||||
<p class="lede">A container installs onto the host and then gets out of the way.</p>
|
||||
<div class="graph-container">
|
||||
<a href="viewer.html?src=graphs/01-install.svg"><img src="graphs/01-install.svg" alt="Installation flow"></a>
|
||||
<a href="viewer.html?src=graphs/01-install.svg"><img src="graphs/01-install.svg" alt="How the installer works"></a>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>The installer is a container, not a shell script, for a specific reason: a
|
||||
stock slim Debian has no <code>curl</code>, no <code>wget</code>, no
|
||||
<code>jq</code>, no <code>python3</code> and <b>no CA bundle</b>. A shell
|
||||
installer could not make a verified HTTPS request, let alone check one. The
|
||||
container carries its own toolchain, so the host needs nothing but Docker.</p>
|
||||
|
||||
<p>The cluster never runs inside that container. Everything it installs —
|
||||
kind, kubectl, tilt, jq — runs natively afterwards, so nothing pays a
|
||||
container tax during daily work.</p>
|
||||
|
||||
<h3>Pinned and verified</h3>
|
||||
<p>Every tool is a single binary fetched at a pinned version and checked
|
||||
against a published SHA256. Node images are pinned <b>by digest</b>, so
|
||||
upgrading kind cannot silently move your Kubernetes version.</p>
|
||||
|
||||
<h3>Not every machine should get cluster tooling</h3>
|
||||
<p>A managed or corporate-issued machine — the kind that holds the access
|
||||
you cannot get anywhere else — is not somewhere to install development
|
||||
tools by default. So the toolchain comes in two tiers:</p>
|
||||
<p>The installer is a container, not a shell script, because a stock slim Debian
|
||||
has no curl, wget, jq, python3 or CA bundle: a shell installer could not make a
|
||||
verified HTTPS request, let alone check one. The container carries its own
|
||||
toolchain, so the host needs only Docker. Nothing runs inside it afterwards —
|
||||
kind, kubectl, tilt and jq run natively.</p>
|
||||
|
||||
<p>Every tool is a single binary at a pinned version, checked against a published
|
||||
SHA256. Node images are pinned <b>by digest</b>, so upgrading kind cannot move
|
||||
your Kubernetes version.</p>
|
||||
|
||||
<h3>Two tiers</h3>
|
||||
<p>A managed or corporate-issued machine is not somewhere to install development
|
||||
tools by default, so installing cluster tooling is a decision rather than a side
|
||||
effect:</p>
|
||||
<table>
|
||||
<tr><th>tier</th><th>installs</th><th>for</th></tr>
|
||||
<tr><td><code>core</code></td><td>kubectl, jq</td><td>talk to a cluster someone else runs</td></tr>
|
||||
@@ -367,11 +318,6 @@ cp -r rig ../platform-v3 && cd ../platform-v3 && make cluster up
|
||||
<pre><code>make deps core <span class="c"># kubectl and jq only — nothing that creates a cluster</span>
|
||||
make deps <span class="c"># dev, the default</span>
|
||||
</code></pre>
|
||||
<p>Testing <i>in situ</i> on a managed machine is still possible — install
|
||||
the <code>dev</code> tier deliberately when you need it. The point is that
|
||||
it should be a decision rather than a side effect of installing.</p>
|
||||
<p>The documentation itself needs neither tier: <code>make docs</code>
|
||||
wants only Docker.</p>
|
||||
|
||||
<h3>Air-gapped</h3>
|
||||
<pre><code>make deps image full <span class="c"># bakes every binary into the image</span>
|
||||
@@ -384,185 +330,129 @@ docker load < rig.tgz && make cluster up PROFILE=offline <span clas
|
||||
|
||||
<section class="section" id="environments">
|
||||
<h2>Environments</h2>
|
||||
<p class="lede">One folder is one environment — an overlay's, or rig's own. Copy it, rename it, run it.</p>
|
||||
<div class="graph-container">
|
||||
<a href="viewer.html?src=graphs/02-environment.svg"><img src="graphs/02-environment.svg" alt="Environment derivation"></a>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>Running several versions of a system at once means several clusters on one
|
||||
machine, not several machines. Everything that could collide is derived from
|
||||
the folder name (the overlay's when one is named):</p>
|
||||
<p>One folder is one environment. Running several versions of a system at once
|
||||
means several clusters on one machine, and everything that could collide is
|
||||
derived from the folder name (the overlay's, when one is named):</p>
|
||||
<dl>
|
||||
<dt>cluster + context</dt><dd><code>platform-v2/</code> builds <code>platform-v2</code> on <code>kind-platform-v2</code>.</dd>
|
||||
<dt>port block</dt><dd>Ten ports from a hash of the name, in the 20000+ range — clear of 80, 443, 3000, 5432, 8000 and 8080.</dd>
|
||||
<dt>port block</dt><dd>Ten ports from a hash of the name, above 20000 — clear of 80, 443, 3000, 5432, 8000 and 8080.</dd>
|
||||
<dt>registry + images</dt><dd>Named after the environment, so two copies never share one.</dd>
|
||||
</dl>
|
||||
<p>Two copies therefore never collide, and neither one's
|
||||
<code>make cluster down</code> can touch the other. <code>make check</code>
|
||||
shows the block; <code>bash ctrl/ports.sh persist</code> freezes it into
|
||||
<code>ctrl/.env</code> if you want it fixed rather than derived.</p>
|
||||
<p>So two copies never collide, and neither one's <code>make cluster down</code>
|
||||
can touch the other. <code>make check</code> shows the block;
|
||||
<code>bash ctrl/ports.sh persist</code> freezes it into <code>ctrl/.env</code>.</p>
|
||||
|
||||
<h3>Configuration layers</h3>
|
||||
<p>Weakest first, later wins: built-in defaults → pinned versions → a
|
||||
profile, if you name one → the overlay's <code>rig.env</code> →
|
||||
<code>ctrl/.env</code> → the environment. So
|
||||
<code>make cluster up PROFILE=<name></code> always beats every file.</p>
|
||||
<p>Weakest first, later wins: built-in defaults → pinned versions → a profile, if
|
||||
you name one → the overlay's <code>rig.env</code> → <code>ctrl/.env</code> → the
|
||||
caller's environment. So a value on the command line always beats every file.</p>
|
||||
|
||||
<h3>Profiles</h3>
|
||||
<p>A profile says how this machine reaches the world. rig needs none.</p>
|
||||
<table>
|
||||
<tr><th>example</th><th>registry</th><th>for</th></tr>
|
||||
<tr><td>none</td><td><code>local</code></td><td>the built-in defaults</td></tr>
|
||||
<tr><td><code>mirror.env.example</code></td><td><code>mirror</code></td><td>images through an internal registry</td></tr>
|
||||
<tr><td><code>offline.env.example</code></td><td><code>local</code></td><td>air-gapped</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Registry modes</h3>
|
||||
<table>
|
||||
<tr><th>mode</th><th>what it does</th></tr>
|
||||
<tr><td><code>none</code></td><td>images are built straight into the node</td></tr>
|
||||
<tr><td><code>local</code></td><td>a registry container wired into the cluster</td></tr>
|
||||
<tr><td><code>mirror</code></td><td>that container as a pull-through cache of the corporate registry</td></tr>
|
||||
<tr><td><code>remote</code></td><td>no local container; pull direct with an <code>imagePullSecret</code></td></tr>
|
||||
</table>
|
||||
<p><code>mirror</code> is what a locked-down network looks like: images come from
|
||||
the corporate registry, you do not hammer it, and you keep working when the
|
||||
connection drops. It is also only reachable from where that registry is routable —
|
||||
from a laptop, use <code>local</code> or <code>offline</code>.</p>
|
||||
<p><b>Corporate CA trust is the usual failure.</b> It has to reach three places:
|
||||
the host Docker daemon, every node's containerd (nodes do not inherit host trust),
|
||||
and any in-cluster client. Set <code>REGISTRY_CA_FILE</code>;
|
||||
<code>make check</code> reports which is still missing. Without it the error is
|
||||
<code>x509: certificate signed by unknown authority</code>.</p>
|
||||
|
||||
<h3>LoadBalancer services</h3>
|
||||
<p>Real manifests use <code>type: LoadBalancer</code>. On a bare local cluster
|
||||
those Services sit at <code>EXTERNAL-IP <pending></code> forever, with no
|
||||
error: the deployment looks healthy and is unreachable. The <code>metallb</code>
|
||||
addon fixes that, so upstream manifests work unchanged. Its address pool is
|
||||
derived from the cluster's Docker network at install time, because Docker picks
|
||||
that subnet and it differs between machines.</p>
|
||||
<p>Those addresses are reachable from the Linux side, including inside WSL. A
|
||||
browser on Windows has no route to them: use the ingress host ports instead.</p>
|
||||
|
||||
<h3>Networking and memory</h3>
|
||||
<p>The cluster uses kind's built-in networking, which does enforce standard
|
||||
NetworkPolicy — verified against a no-policy control. A pluggable CNI was tried
|
||||
and removed: it added policy features nothing here needs, in exchange for a
|
||||
slower boot and one more thing to get right at creation time.</p>
|
||||
<p>Every cluster is a running container tree whether you use it or not.
|
||||
<code>make cluster list</code> shows what each costs, and
|
||||
<code>make cluster free</code> stops the others without deleting them.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="overlays">
|
||||
<h2>Overlays</h2>
|
||||
<p class="lede">What runs lives outside rig — rig reads it and never writes into it.</p>
|
||||
<div class="prose">
|
||||
<p>An overlay is one folder, kept outside rig's version control, holding a
|
||||
use case. Every piece is optional:</p>
|
||||
<p>An overlay is one folder, kept outside rig's version control, holding a use
|
||||
case. rig reads it and never writes into it. Every piece is optional:</p>
|
||||
<table>
|
||||
<tr><th>in the overlay</th><th>what rig does with it</th></tr>
|
||||
<tr><td><code>rig.env</code></td><td>a config layer: addons, namespaces, images — anything a profile could set</td></tr>
|
||||
<tr><td><code>k8s/overlays/dev/</code></td><td>the manifests the dev loop applies</td></tr>
|
||||
<tr><td><code>kind-config.yaml.tpl</code></td><td>the cluster's shape, when it needs its own (mounts, ports)</td></tr>
|
||||
<tr><td><code>addons/<name>.sh</code></td><td>addons, found before rig's own</td></tr>
|
||||
<tr><td><code>addons/<name>.sh</code></td><td>addons, found before rig's own; each applies its own <code><name>.yaml</code></td></tr>
|
||||
<tr><td><code>Tiltfile</code></td><td>the workload's half of the dev loop, included by rig's</td></tr>
|
||||
</table>
|
||||
<pre><code>cp -r examples/starter local/myenv <span class="c"># local/ is gitignored</span>
|
||||
OVERLAY=local/myenv make cluster up
|
||||
</code></pre>
|
||||
<p>With none named, rig runs its own <code>examples/starter</code>.
|
||||
<code>examples/data</code> carries postgres, redis and airflow as an
|
||||
overlay's own addons. A project can also carry rig at
|
||||
<code><project>/rig/</code> and be the overlay itself, with a
|
||||
three-line forwarding Makefile — see <code>docs/notes/overlay.md</code>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="profiles">
|
||||
<h2>Profiles</h2>
|
||||
<p class="lede">How this machine reaches the world — optional; rig needs none.</p>
|
||||
<div class="prose">
|
||||
<table>
|
||||
<tr><th>example</th><th>registry</th><th>for</th></tr>
|
||||
<tr><td><i>none</i></td><td>local</td><td>the built-in defaults; no profile needed</td></tr>
|
||||
<tr><td><code>mirror.env.example</code></td><td>mirror</td><td>images through an internal registry</td></tr>
|
||||
<tr><td><code>offline.env.example</code></td><td>local</td><td>air-gapped</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="note"><p><b>The kind config cannot be re-applied.</b> Edit
|
||||
<code>ctrl/k8s/kind-config.yaml.tpl</code> (or the overlay's own); it takes effect when the cluster is created. <code>cluster up</code> prints what it
|
||||
locks in before spending the time, and <code>make cluster reset</code> is
|
||||
the way out.</p></div>
|
||||
|
||||
<h3>LoadBalancer services</h3>
|
||||
<p>Real manifests use <code>type: LoadBalancer</code>, because a real
|
||||
cluster has one. On a bare local cluster those Services sit at
|
||||
<code>EXTERNAL-IP <pending></code> forever, with no error anywhere —
|
||||
the deployment looks healthy and simply is not reachable.</p>
|
||||
<p>The <code>metallb</code> addon fixes that, so the same manifests work
|
||||
here as upstream and nothing has to be rewritten to NodePort. Its address
|
||||
pool is derived from the cluster's Docker network at install time rather
|
||||
than hardcoded, because Docker picks that subnet and it differs between
|
||||
machines.</p>
|
||||
<div class="note"><p><b>Where those addresses are reachable from.</b> The
|
||||
pool lives on the Docker bridge, so LoadBalancer IPs work from the Linux
|
||||
side — including from inside WSL. A browser on Windows has no route to
|
||||
them. Use the ingress host ports for anything you need to open in a
|
||||
browser.</p></div>
|
||||
|
||||
<h3>Networking</h3>
|
||||
<p>The cluster uses kind's built-in networking, which <b>does</b> enforce
|
||||
standard NetworkPolicy — verified against a no-policy control, not assumed.
|
||||
The widely repeated claim that it accepts policies and silently ignores
|
||||
them is out of date.</p>
|
||||
<p>A pluggable CNI was tried and removed: it only added
|
||||
GlobalNetworkPolicy, policy tiers and egress-CIDR rules, none of which are
|
||||
needed yet, in exchange for a slower boot and one more thing that has to be
|
||||
right at creation time. Worth revisiting only when a policy the built-in
|
||||
cannot express actually comes up.</p>
|
||||
|
||||
<h3>Memory</h3>
|
||||
<p>Every cluster is a running container tree whether you are using it or not.
|
||||
<code>make cluster list</code> shows what exists and what it costs;
|
||||
<code>make cluster free</code> stops the others without deleting them.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="registry">
|
||||
<h2>Registry</h2>
|
||||
<p class="lede">Local, cached, or straight to the corporate registry.</p>
|
||||
<div class="prose">
|
||||
<table>
|
||||
<tr><th>mode</th><th>what it does</th></tr>
|
||||
<tr><td><code>none</code></td><td>images are built straight into the node</td></tr>
|
||||
<tr><td><code>local</code></td><td>a registry container wired into the cluster</td></tr>
|
||||
<tr><td><code>mirror</code></td><td>that container as a <b>pull-through cache</b> of the corporate registry</td></tr>
|
||||
<tr><td><code>remote</code></td><td>no local container; pull direct with an imagePullSecret</td></tr>
|
||||
</table>
|
||||
<p><code>mirror</code> is what a locked-down network actually looks like:
|
||||
images originate from the corporate registry, you do not hammer it, and you
|
||||
keep working when the connection drops.</p>
|
||||
|
||||
<div class="note"><p><b>The corporate CA will bite you.</b> A corporate
|
||||
registry is usually behind an internal CA, and trust has to reach
|
||||
<b>three</b> places: the host Docker daemon, every cluster node's containerd
|
||||
(nodes do <i>not</i> inherit host trust), and any in-cluster client. Set
|
||||
<code>REGISTRY_CA_FILE</code> and <code>make check</code> reports which is
|
||||
still missing. The symptom otherwise is an opaque
|
||||
<code>x509: certificate signed by unknown authority</code>.</p></div>
|
||||
|
||||
<p>Reachability also depends on where you are: if the registry is only
|
||||
routable from a managed workspace, <code>mirror</code> and <code>remote</code>
|
||||
will not resolve from a laptop at all. That is what <code>local</code> and
|
||||
<code>offline</code> are for.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="architecture">
|
||||
<h2>Architecture</h2>
|
||||
<p class="lede">The estate being modelled.</p>
|
||||
<div class="note"><p><b>TODO — placeholder.</b> The diagram below is
|
||||
illustrative only: it shows how a mocked dependency, a service under active
|
||||
work, and an unreachable remote system sit together. It is not the real
|
||||
topology. Replace <code>docs/graphs/03-architecture.dot</code> with the
|
||||
extracted platform diagrams, then run <code>make docs graphs</code>.</p></div>
|
||||
<div class="graph-container">
|
||||
<a href="viewer.html?src=graphs/03-architecture.svg"><img src="graphs/03-architecture.svg" alt="Estate topology (placeholder)"></a>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p>Each component is one of three things, and switching between them should be
|
||||
a one-line change rather than a rewrite:</p>
|
||||
<code>examples/data</code> carries postgres, redis and airflow as an overlay's own
|
||||
addons. A project can also carry rig at <code><project>/rig/</code> and be
|
||||
the overlay itself, with a three-line forwarding Makefile — see
|
||||
<code>docs/notes/overlay.md</code>.</p>
|
||||
|
||||
<h3>What runs in it</h3>
|
||||
<p>Each component is one of three things, and switching between them is a
|
||||
one-line change:</p>
|
||||
<dl>
|
||||
<dt>real</dt><dd>Built from source and hot-reloaded. The thing you are actually working on — usually exactly one.</dd>
|
||||
<dt>real</dt><dd>Built from source and hot-reloaded. What you are working on — usually exactly one.</dd>
|
||||
<dt>mock</dt><dd>A generic stub with canned responses. Everything you do not care about today.</dd>
|
||||
<dt>remote</dt><dd>No pod at all: a Service of type ExternalName pointing at the real system. In-cluster DNS resolves identically, so callers never change.</dd>
|
||||
<dt>remote</dt><dd>No pod at all: a Service of type <code>ExternalName</code> pointing at the real system. In-cluster DNS resolves identically, so callers never change.</dd>
|
||||
</dl>
|
||||
<p>The intended end state is that this diagram is <b>generated from the
|
||||
running cluster</b> rather than drawn by hand — so it becomes a report of
|
||||
what exists instead of a picture of what was once intended.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="troubleshooting">
|
||||
<h2>Troubleshooting</h2>
|
||||
<p class="lede">The failures that are hard to diagnose from their symptoms.</p>
|
||||
<div class="prose">
|
||||
<h3>Tilt stops noticing file changes</h3>
|
||||
<p>Almost always <code>inotify</code> limits, and it fails <i>silently</i> —
|
||||
nothing errors, changes just stop being picked up. Defaults on WSL are far too
|
||||
low. <code>make check</code> reports it and prints the fix.</p>
|
||||
<p>Almost always inotify limits, and it fails silently: nothing errors, changes
|
||||
just stop being picked up. Defaults on WSL are far too low.
|
||||
<code>make check</code> reports it and prints the fix.</p>
|
||||
|
||||
<h3>Cluster creation dies halfway with a port error</h3>
|
||||
<p>Docker reports <code>failed to bind host port … address already in use</code>
|
||||
partway through creating the cluster. Run <code>make check</code> first — it
|
||||
checks every port in this environment's block before anything is built.</p>
|
||||
partway through creating the cluster. <code>make check</code> tests every port in
|
||||
this environment's block before anything is built.</p>
|
||||
|
||||
<h3>Every node stays NotReady</h3>
|
||||
<p>Usually a cluster created with the default CNI disabled but the real CNI
|
||||
never installed — typically an interrupted first run. Just run
|
||||
<code>make cluster up</code> again: it converges rather than exiting early, and
|
||||
will finish the missing steps.</p>
|
||||
<p>Usually a cluster created with the default CNI disabled and the real CNI never
|
||||
installed, after an interrupted first run. Run <code>make cluster up</code> again:
|
||||
it finishes the missing steps instead of stopping because the cluster exists.</p>
|
||||
|
||||
<h3>x509: certificate signed by unknown authority</h3>
|
||||
<p>Corporate CA trust has not reached one of the three places it needs to be.
|
||||
See <a href="#registry">Registry</a>.</p>
|
||||
<p>Corporate CA trust has not reached one of the three places it needs to be. See
|
||||
<a href="#environments">Environments</a>.</p>
|
||||
|
||||
<h3>kubectl says the context does not exist</h3>
|
||||
<p>The cluster can exist while its context does not — a reset or a switched
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
Each addon is its own idempotent script — adding one is adding a file, not editing a dispatcher. `ADDONS` names them, in install order; the overlay's `addons/<name>.sh` is found before rig's `ctrl/addons/<name>.sh`, and every one runs from rig's `ctrl/` with `RIG_CTRL` exported, wherever its file lives (see [overlay.md](overlay.md)).
|
||||
|
||||
## The manifest beside the script
|
||||
|
||||
An addon's Kubernetes objects are a plain `<name>.yaml` beside its `<name>.sh`, not a heredoc inside it: the YAML reads, diffs and lints as YAML, and the script keeps only what YAML cannot do (secrets made once, a database created, waiting for a rollout). The script renders it with `render_manifest "$ADDON_DIR/<name>.yaml"` (`lib/config.sh`) and pipes it to `kubectl apply`.
|
||||
|
||||
`render_manifest` fills `${NAME}` and `${NAME:-default}` from the loaded config or the script's own variables, and fails on a name nothing sets, so a typo is an error rather than an empty image. `$VAR` and `$(VAR)` are left alone: they are the container's, as in postgres' `pg_isready -U $POSTGRES_USER`. selftest checks that every name a manifest uses is set somewhere. `ADDON_DIR` is taken before the script's `cd` into rig's `ctrl/`: `ADDON_DIR="$(cd "$(dirname "$0")" && pwd)"`.
|
||||
|
||||
## What rig ships, and what it does not
|
||||
|
||||
rig's own addons make the *cluster* work, and are useless outside one: metallb, cert-manager, metrics-server. Things a workload happens to need — a database, a cache, a scheduler — are the workload's, and which workload needs which is not rig's business, so they live with the overlay. `examples/data/addons/` has postgres, redis and airflow as a worked example; an overlay that wants them copies them in.
|
||||
|
||||
@@ -34,14 +34,37 @@ A port held by THIS environment's own cluster is not a clash; it is the thing wo
|
||||
|
||||
The ports are extracted with a second grep rather than `tr -d ':->'`: in tr, ':->' is the character RANGE ':' to '>', which does not contain '-', so the trailing dash survives and nothing ever matches.
|
||||
|
||||
## Compact by default
|
||||
|
||||
`make check` prints one line per question — host, toolchain, and for this rig: cluster, memory,
|
||||
ports, registry, addons — and adds detail only where something needs attention (`!` lines, the
|
||||
"held elsewhere" list when memory is tight, the clashing port). `make check all` prints every fact,
|
||||
as the full report did before 2026-09-17. `deps.sh detect all` is the same switch for the host part,
|
||||
so the standalone `rigdeps.sh detect` is short too. Changed because the long report buried the few
|
||||
lines that mattered.
|
||||
## Budget first, configuration after (2026-09-23)
|
||||
|
||||
`make check` answers the question people run it for first, in the shape the adapter rig had
|
||||
(the user preferred it): a memory budget with one row per part, the node overhead, the total
|
||||
against what the machine has and has available; then a one-line verdict (`fits`, `! tight`,
|
||||
`! does not fit`, with the two opposite fixes told apart); then where things stand and the next
|
||||
command — not created, stopped (with the `docker start` line), up (with the Tilt and gateway
|
||||
URLs, and whether this environment's Tilt is already running).
|
||||
|
||||
Below that, the configuration as it is, raw: the host and toolchain (`deps.sh detect`), then
|
||||
one `key value` line each for cluster, context, overlay, profile, nodes, kind config, manifests,
|
||||
ingress, ports (named), registry, addons. `!` lines always print; `make check all` adds every
|
||||
fact (per-port detail, addon list, swap and overcommit notes). `deps.sh detect all` is the same
|
||||
switch for the host part, so the standalone `rigdeps.sh detect` is short too.
|
||||
|
||||
## MEMORY_BUDGET
|
||||
|
||||
rig knows what a node costs (NODE_MB), not what a workload does, so the parts come from the
|
||||
overlay's rig.env: `MEMORY_BUDGET="name=MB ..."`, in the order listed; a 0 is left out, and an
|
||||
entry that is not `name=MB` is a `!` line. rig.env is sourced, so an overlay can size a part by
|
||||
its own knob (poc_rig's airflow follows its mode). When one part outweighs the rest together
|
||||
(and is at least 1 GB), check names it as the first thing to switch off; `MEMORY_BUDGET_TIP`
|
||||
says how, in the overlay's words. No budget: one `?` row says where to add it.
|
||||
|
||||
## Tilt: ours or another
|
||||
|
||||
A Tilt still running after `make cluster down` holds the Tilt port, and the next `make tilt`
|
||||
fails with "address already in use" (2026-09-23, the Workspace). check reads the listener with
|
||||
`ss -p` (our own processes only): a `tilt` whose working directory is this ctrl/ is this
|
||||
environment's Tilt, reported as running with its URL; another `tilt` is a `!` line with its pid,
|
||||
its folder and the `kill` that frees the port. Anything else is the ordinary port clash.
|
||||
|
||||
## overlay and kind config
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ out `python -m http.server` too — a minimal Debian has no python3. What it doe
|
||||
have, by definition, is Docker: the single prerequisite rig already demands. So a
|
||||
throwaway nginx container serves a read-only bind mount.
|
||||
|
||||
It runs with a config of its own: one worker (nginx's `auto` starts one per core,
|
||||
twelve on an ordinary laptop, for a handful of static pages), no access log and a
|
||||
quiet entrypoint, so the terminal shows the URL and nothing else unless something
|
||||
fails. The pages declare an empty icon, so the browser does not ask for a
|
||||
`favicon.ico` that is not there.
|
||||
|
||||
## Committed SVGs
|
||||
|
||||
Rendered SVGs are committed alongside their `.dot` sources for the same reason:
|
||||
|
||||
@@ -60,6 +60,9 @@ air-gapped install); an overlay says what runs. `ctrl/.env` is still this
|
||||
machine's, and the caller still wins over everything.
|
||||
|
||||
`rig.env` may not set `PROFILE` or `OVERLAY`: both are chosen before it loads.
|
||||
|
||||
`MEMORY_BUDGET` (and optionally `MEMORY_BUDGET_TIP`) is how an overlay tells `make check` what its
|
||||
workload costs: rig only knows the node. See check.md.
|
||||
The paths it sets (`MANIFESTS_DIR`, `KIND_CONFIG`) are relative to the overlay.
|
||||
`MANIFESTS_DIR=none` means rig applies no manifests and the overlay's Tiltfile
|
||||
does, e.g. when kustomize needs flags.
|
||||
@@ -100,7 +103,8 @@ load_config
|
||||
```
|
||||
|
||||
`OVERLAY_DIR` is set, so an addon can find files beside it
|
||||
(`$(_from_ctrl "$OVERLAY_DIR")/...`). rig's own `ctrl/addons/` holds only what
|
||||
(`$(_from_ctrl "$OVERLAY_DIR")/...`). Its manifest is a `<name>.yaml` beside the script,
|
||||
rendered with `render_manifest` (see [addons.md](addons.md)). rig's own `ctrl/addons/` holds only what
|
||||
makes a cluster work (metallb, cert-manager, metrics-server);
|
||||
`examples/data/addons/` shows workload ones.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>Graph Viewer</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Requires the postgres addon; refuses to install without it.
|
||||
# Notes: ../README.md
|
||||
set -euo pipefail
|
||||
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
|
||||
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
|
||||
|
||||
source ./lib/config.sh
|
||||
@@ -70,77 +71,7 @@ if [ -d "$dags" ]; then
|
||||
fi
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
selector:
|
||||
app: airflow
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airflow
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airflow
|
||||
spec:
|
||||
containers:
|
||||
- name: airflow
|
||||
image: ${AIRFLOW_IMAGE}
|
||||
args: ["standalone"]
|
||||
env:
|
||||
- name: AIRFLOW__CORE__EXECUTOR
|
||||
value: LocalExecutor
|
||||
- name: AIRFLOW__CORE__LOAD_EXAMPLES
|
||||
value: "false"
|
||||
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow-connections, key: SQL_ALCHEMY_CONN}
|
||||
- name: AIRFLOW_CONN_APP_DB
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow-connections, key: AIRFLOW_CONN_APP_DB}
|
||||
- name: AIRFLOW__CORE__FERNET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: FERNET_KEY}
|
||||
- name: _AIRFLOW_WWW_USER_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_USER}
|
||||
- name: _AIRFLOW_WWW_USER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: dags
|
||||
mountPath: /opt/airflow/dags
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
# First boot runs the whole migration before it serves anything.
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 20
|
||||
volumes:
|
||||
- name: dags
|
||||
configMap:
|
||||
name: airflow-dags
|
||||
optional: true
|
||||
YAML
|
||||
render_manifest "$ADDON_DIR/airflow.yaml" | $K apply -n "$NS" -f - >/dev/null
|
||||
|
||||
echo " waiting for airflow (the first boot migrates the database, so this is slow)..."
|
||||
$K rollout status deployment/airflow -n "$NS" --timeout=600s
|
||||
|
||||
69
rig/examples/data/addons/airflow.yaml
Normal file
69
rig/examples/data/addons/airflow.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
selector:
|
||||
app: airflow
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airflow
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airflow
|
||||
spec:
|
||||
containers:
|
||||
- name: airflow
|
||||
image: ${AIRFLOW_IMAGE}
|
||||
args: ["standalone"]
|
||||
env:
|
||||
- name: AIRFLOW__CORE__EXECUTOR
|
||||
value: LocalExecutor
|
||||
- name: AIRFLOW__CORE__LOAD_EXAMPLES
|
||||
value: "false"
|
||||
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow-connections, key: SQL_ALCHEMY_CONN}
|
||||
- name: AIRFLOW_CONN_APP_DB
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow-connections, key: AIRFLOW_CONN_APP_DB}
|
||||
- name: AIRFLOW__CORE__FERNET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: FERNET_KEY}
|
||||
- name: _AIRFLOW_WWW_USER_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_USER}
|
||||
- name: _AIRFLOW_WWW_USER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: dags
|
||||
mountPath: /opt/airflow/dags
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
# First boot runs the whole migration before it serves anything.
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 20
|
||||
volumes:
|
||||
- name: dags
|
||||
configMap:
|
||||
name: airflow-dags
|
||||
optional: true
|
||||
@@ -2,6 +2,7 @@
|
||||
# PostgreSQL for this overlay: plain manifests, one replica on a PVC, password generated once and kept.
|
||||
# Notes: ../README.md
|
||||
set -euo pipefail
|
||||
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
|
||||
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
|
||||
|
||||
source ./lib/config.sh
|
||||
@@ -26,77 +27,7 @@ else
|
||||
fi
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: ${POSTGRES_STORAGE:-2Gi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
# One volume, one writer. Rolling would start a second pod against the same
|
||||
# PVC before the first exits, and Postgres refuses to share a data directory.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: ${POSTGRES_IMAGE}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: postgres
|
||||
env:
|
||||
# The image initialises into the volume root otherwise, and a
|
||||
# lost+found from the PVC makes it refuse to initdb.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-data
|
||||
YAML
|
||||
render_manifest "$ADDON_DIR/postgres.yaml" | $K apply -n "$NS" -f - >/dev/null
|
||||
|
||||
echo " waiting for postgres..."
|
||||
$K rollout status deployment/postgres -n "$NS" --timeout=240s
|
||||
|
||||
69
rig/examples/data/addons/postgres.yaml
Normal file
69
rig/examples/data/addons/postgres.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: ${POSTGRES_STORAGE:-2Gi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
# One volume, one writer. Rolling would start a second pod against the same
|
||||
# PVC before the first exits, and Postgres refuses to share a data directory.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: ${POSTGRES_IMAGE}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: postgres
|
||||
env:
|
||||
# The image initialises into the volume root otherwise, and a
|
||||
# lost+found from the PVC makes it refuse to initdb.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-data
|
||||
@@ -2,6 +2,7 @@
|
||||
# Redis for this overlay: cache and broker (Celery), no persistence.
|
||||
# Notes: ../README.md
|
||||
set -euo pipefail
|
||||
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
|
||||
cd "${RIG_CTRL:?run it through rig: bash ctrl/addons.sh install}"
|
||||
|
||||
source ./lib/config.sh
|
||||
@@ -13,43 +14,7 @@ NS="${DATA_NAMESPACE:-data}"
|
||||
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: ${REDIS_IMAGE}
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
YAML
|
||||
render_manifest "$ADDON_DIR/redis.yaml" | $K apply -n "$NS" -f - >/dev/null
|
||||
|
||||
echo " waiting for redis..."
|
||||
$K rollout status deployment/redis -n "$NS" --timeout=180s
|
||||
|
||||
35
rig/examples/data/addons/redis.yaml
Normal file
35
rig/examples/data/addons/redis.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: ${REDIS_IMAGE}
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
@@ -7,6 +7,9 @@
|
||||
# LocalExecutor and needs no broker: add redis (before airflow) only to switch to Celery.
|
||||
ADDONS="metallb postgres airflow"
|
||||
|
||||
# What each part costs in MB, for `make check`'s memory budget (rig adds the kind node).
|
||||
MEMORY_BUDGET="airflow=1800 postgres=300"
|
||||
|
||||
# Namespace for the dependency containers (k8s/base/kustomization.yaml names it too).
|
||||
DATA_NAMESPACE=data
|
||||
|
||||
|
||||
@@ -61,6 +61,32 @@ render_kind_config ()
|
||||
fi;
|
||||
sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" -e "s|\${OVERLAY_DIR}|${overlay_dir}|g" "$KIND_CONFIG"
|
||||
}
|
||||
render_manifest ()
|
||||
{
|
||||
local file="$1" text ref key def val;
|
||||
text=$(< "$file") || return 1;
|
||||
while IFS= read -r ref; do
|
||||
key=${ref#\$\{};
|
||||
key=${key%\}};
|
||||
def="";
|
||||
if [[ $key == *:-* ]]; then
|
||||
def=${key#*:-};
|
||||
key=${key%%:-*};
|
||||
fi;
|
||||
if [ -n "${!key:-}" ]; then
|
||||
val=${!key};
|
||||
else
|
||||
if [[ $ref == *:-* ]]; then
|
||||
val=$def;
|
||||
else
|
||||
echo "render_manifest: $file needs \${$key}, which nothing sets" 1>&2;
|
||||
return 1;
|
||||
fi;
|
||||
fi;
|
||||
text=${text//"$ref"/"$val"};
|
||||
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u);
|
||||
printf '%s\n' "$text"
|
||||
}
|
||||
|
||||
# ── configuration, frozen for profile 'default' ──
|
||||
load_config() {
|
||||
|
||||
@@ -61,6 +61,32 @@ render_kind_config ()
|
||||
fi;
|
||||
sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" -e "s|\${OVERLAY_DIR}|${overlay_dir}|g" "$KIND_CONFIG"
|
||||
}
|
||||
render_manifest ()
|
||||
{
|
||||
local file="$1" text ref key def val;
|
||||
text=$(< "$file") || return 1;
|
||||
while IFS= read -r ref; do
|
||||
key=${ref#\$\{};
|
||||
key=${key%\}};
|
||||
def="";
|
||||
if [[ $key == *:-* ]]; then
|
||||
def=${key#*:-};
|
||||
key=${key%%:-*};
|
||||
fi;
|
||||
if [ -n "${!key:-}" ]; then
|
||||
val=${!key};
|
||||
else
|
||||
if [[ $ref == *:-* ]]; then
|
||||
val=$def;
|
||||
else
|
||||
echo "render_manifest: $file needs \${$key}, which nothing sets" 1>&2;
|
||||
return 1;
|
||||
fi;
|
||||
fi;
|
||||
text=${text//"$ref"/"$val"};
|
||||
done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}' "$file" | sort -u);
|
||||
printf '%s\n' "$text"
|
||||
}
|
||||
|
||||
# ── configuration, frozen for profile 'default' ──
|
||||
load_config() {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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.",
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
3
soleprint/artery/plexuses/live/app/.gitignore
vendored
Normal file
3
soleprint/artery/plexuses/live/app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
ui/
|
||||
12
soleprint/artery/plexuses/live/app/index.html
Normal file
12
soleprint/artery/plexuses/live/app/index.html
Normal 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>
|
||||
12
soleprint/artery/plexuses/live/app/package.json
Normal file
12
soleprint/artery/plexuses/live/app/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "live-panels",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"vue": "^3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5",
|
||||
"vite": "^6"
|
||||
}
|
||||
}
|
||||
4
soleprint/artery/plexuses/live/app/pnpm-workspace.yaml
Normal file
4
soleprint/artery/plexuses/live/app/pnpm-workspace.yaml
Normal 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
|
||||
74
soleprint/artery/plexuses/live/app/src/App.vue
Normal file
74
soleprint/artery/plexuses/live/app/src/App.vue
Normal 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>
|
||||
6
soleprint/artery/plexuses/live/app/src/env.d.ts
vendored
Normal file
6
soleprint/artery/plexuses/live/app/src/env.d.ts
vendored
Normal 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
|
||||
}
|
||||
4
soleprint/artery/plexuses/live/app/src/main.ts
Normal file
4
soleprint/artery/plexuses/live/app/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
6
soleprint/artery/plexuses/live/plexus.json
Normal file
6
soleprint/artery/plexuses/live/plexus.json
Normal 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."
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1
soleprint/atlas2/docgen/.gitignore
vendored
1
soleprint/atlas2/docgen/.gitignore
vendored
@@ -3,3 +3,4 @@ out/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.docgen/
|
||||
|
||||
@@ -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'))))"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,7 +4,7 @@ Run a book: larder, the steps, the web output, the book measure.
|
||||
python3 -m docgen book --root ../station -o out/book/station
|
||||
|
||||
Composes functions that already exist. Nothing here parses, lays out or styles
|
||||
anything — `ops.overview`, `emitters.auto.draw`, `emitters.site` and `notebook.spec`
|
||||
anything — `ops.overview`, `emitters.auto.draw`, `emitters.minisite` and `notebook.spec`
|
||||
do all of it, and this decides the order and writes the ledger. If this file
|
||||
ever starts doing the work, the seam has moved to the wrong place.
|
||||
|
||||
@@ -73,7 +73,7 @@ EXTRACTORS = {
|
||||
TAKES_EXCLUDE = {"python", "code"}
|
||||
|
||||
|
||||
def extract(kind: str, source, *, exclude=(), identity=None):
|
||||
def extract(kind: str, source, *, exclude=(), identity=None, suffixes=None):
|
||||
"""(IR dict, Larder) for one source type, dispatched through EXTRACTORS."""
|
||||
from importlib import import_module
|
||||
|
||||
@@ -91,6 +91,8 @@ def extract(kind: str, source, *, exclude=(), identity=None):
|
||||
kwargs = {"identity": identity or str(source)}
|
||||
if kind in TAKES_EXCLUDE:
|
||||
kwargs["exclude"] = exclude
|
||||
if kind == "code" and suffixes:
|
||||
kwargs["suffixes"] = suffixes
|
||||
g = fn(source, **kwargs)
|
||||
|
||||
ir = g.to_dict()
|
||||
@@ -102,7 +104,7 @@ def extract(kind: str, source, *, exclude=(), identity=None):
|
||||
return ir, Larder.from_dict(measured)
|
||||
|
||||
|
||||
def spec_from(book: Book, ir: dict) -> dict:
|
||||
def spec_from(book: Book, ir: dict, *, prelude_dir: str | None = None) -> dict:
|
||||
"""The book's ledger -> a notebook spec, with the two measures at the ends.
|
||||
|
||||
Reuses `notebook.spec`'s step vocabulary rather than inventing one, so
|
||||
@@ -128,7 +130,10 @@ def spec_from(book: Book, ir: dict) -> dict:
|
||||
),
|
||||
),
|
||||
spec_mod._step("prelude", "code", title="Reading a step on its own",
|
||||
code=PRELUDE),
|
||||
# In its own book directory the notebook reads steps from
|
||||
# where it is opened; elsewhere it points back at the book.
|
||||
code=PRELUDE if prelude_dir is None else PRELUDE.replace(
|
||||
"BOOK = Path.cwd()", f"BOOK = Path.cwd() / {prelude_dir!r}", 1)),
|
||||
]
|
||||
|
||||
for s in book.steps:
|
||||
@@ -165,7 +170,7 @@ def spec_from(book: Book, ir: dict) -> dict:
|
||||
f"**{summary} · {m['edges']} edges · {m['external']} external**\n\n"
|
||||
f"{len(m['artifacts'])} artifact(s), {m['bytes']:,} bytes.\n\n"
|
||||
f"Reconciled against what came in:\n\n{reconciled}\n\n"
|
||||
"The web output is `site/index.html`."
|
||||
"The web output is `minisite/index.html`."
|
||||
),
|
||||
))
|
||||
|
||||
@@ -191,7 +196,9 @@ def _load_cell(step_id: str, artifact: str) -> str:
|
||||
if artifact.endswith(".json"):
|
||||
return (
|
||||
f'data = load("{artifact}")\n'
|
||||
'if data:\n'
|
||||
'if isinstance(data, list):\n'
|
||||
' print(f"{len(data)} item(s)")\n'
|
||||
'elif data:\n'
|
||||
' if "nodes" in data:\n'
|
||||
' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
|
||||
' else:\n'
|
||||
@@ -222,14 +229,29 @@ def _load_cell(step_id: str, artifact: str) -> str:
|
||||
# remove first. Named, never globbed: a book directory also holds the hand-written
|
||||
# `checks.py` and `overlay.json`, and a misconfigured output path could point at
|
||||
# somebody's source tree — so nothing outside this list is ever deleted.
|
||||
GENERATED = ("book.json", "notebook.ipynb", "steps", "site", "explore")
|
||||
# `site` is the minisite's old name: a book built before the rename still has one.
|
||||
GENERATED = ("book.json", "notebook.ipynb", "steps", "minisite", "site", "explore",
|
||||
"document.json")
|
||||
|
||||
# The steps a book runs, by id — fixed, so a note can be filed under one before
|
||||
# the book has ever been built, and a skipped step does not make its note drift.
|
||||
STEP_LABELS = (
|
||||
("ir", "the graph, extracted"),
|
||||
("view", "the default view for this source"),
|
||||
("pages", "the diagram, split into pages that fit"),
|
||||
("graph", "the whole view in one drawing"),
|
||||
("index", "readable without a diagram"),
|
||||
("sidebar", "navigation, for whatever renders it"),
|
||||
("minimap", "what is where, read from the colours"),
|
||||
("explore", "navigate on one side, explore on the other"),
|
||||
)
|
||||
|
||||
|
||||
def clear(out) -> list[str]:
|
||||
"""Remove a previous build's outputs from `out`. Returns what was removed.
|
||||
|
||||
Without this, re-running a book leaves the last run's artifacts beside the new
|
||||
ledger: a graph that is now `graph.md` keeps its old `graph.svg`, and the site
|
||||
ledger: a graph that is now `graph.md` keeps its old `graph.svg`, and the minisite
|
||||
goes on showing it. The ledger would not list the stale file, so nothing would
|
||||
say it was stale — which is the failure the book measure exists to prevent.
|
||||
|
||||
@@ -253,12 +275,20 @@ def clear(out) -> list[str]:
|
||||
return removed
|
||||
|
||||
|
||||
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
|
||||
theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
|
||||
"""The whole book, in one process. Returns it; `book.json` is written."""
|
||||
def build_book(kind: str, source, out, *, slug: str | None = None, exclude=(),
|
||||
ops: dict | None = None, suffixes=None, identity: str | None = None,
|
||||
quiet: bool = False) -> Book:
|
||||
"""One book's steps: larder, IR, view, pages, drawings. No minisite, no notebook.
|
||||
|
||||
Those are a document's outputs and are assembled by `book/document.py`, which
|
||||
may put several books into one of each. The book is returned open — its last
|
||||
step is added once the outputs it belongs to exist.
|
||||
"""
|
||||
from ..ir import check
|
||||
from ..ops import classify, overview
|
||||
from ..ops.pages import for_view
|
||||
from ..style import Style
|
||||
from .document import apply_ops
|
||||
|
||||
out = Path(out)
|
||||
slug = slug or Path(str(source)).name or "book"
|
||||
@@ -270,9 +300,9 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
|
||||
if not quiet:
|
||||
print(text)
|
||||
|
||||
ir, larder = extract(kind, source, exclude=exclude)
|
||||
ir, larder = extract(kind, source, exclude=exclude, suffixes=suffixes, identity=identity)
|
||||
if larder is None:
|
||||
larder = Larder(kind=kind, identity=str(source), unit="document", seen=0)
|
||||
larder = Larder(kind=kind, identity=identity or str(source), unit="document", seen=0)
|
||||
book = Book(slug=slug, larder=larder, out=out)
|
||||
book.ir = ir
|
||||
say(f" larder {larder.line()}")
|
||||
@@ -289,22 +319,31 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
|
||||
p.write_text(json.dumps(ir, indent=2) + "\n")
|
||||
book.step("ir", "the graph, extracted", p, note=f"{len(ir['nodes'])} nodes")
|
||||
|
||||
view = overview(ir)
|
||||
view = apply_ops(overview(ir), ops or {})
|
||||
book.view = view
|
||||
p = steps_dir / "view.json"
|
||||
p.write_text(json.dumps(view, indent=2) + "\n")
|
||||
verdict = classify(view)
|
||||
book.step("view", "the default view for this source", p,
|
||||
note=f"{verdict['kind']} — {verdict['why']}")
|
||||
|
||||
style_obj = Style.load(style, theme=theme)
|
||||
# Which pages exist — width-free, so it is decided once. Each output lays
|
||||
# them out for its own column when it is assembled.
|
||||
book.pages = for_view(ir, view)
|
||||
p = steps_dir / "pages.json"
|
||||
p.write_text(json.dumps(
|
||||
[{k: v for k, v in page.items() if k != "ir"} for page in book.pages],
|
||||
indent=2) + "\n")
|
||||
book.step("pages", "the diagram, split into pages that fit", p,
|
||||
note=f"{len(book.pages)} page(s)")
|
||||
|
||||
style_obj = Style.load("lucid")
|
||||
|
||||
# The drawing, chosen by structure rather than by the caller — the same
|
||||
# `draw()` the `emit auto` and `emit site` commands use, so the three can
|
||||
# never disagree about what a graph wants.
|
||||
# The whole view in one drawing — still worth having as a step on its own,
|
||||
# and the same `draw()` the `emit auto` command uses.
|
||||
from ..emitters.auto import draw
|
||||
|
||||
drawn = draw(view, style_obj)
|
||||
graph_rel = None
|
||||
labels = {"erd": "drawn as an ERD", "dot": f"drawn as a {verdict['kind']}",
|
||||
"index": "not a diagram — written as a list"}
|
||||
if drawn["content"] is None:
|
||||
@@ -315,8 +354,6 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
|
||||
p.write_bytes(drawn["content"])
|
||||
else:
|
||||
p.write_text(drawn["content"])
|
||||
if drawn["suffix"] == ".svg":
|
||||
graph_rel = f"steps/{p.name}"
|
||||
book.step("graph", labels[drawn["emitter"]], p, note=drawn["why"])
|
||||
|
||||
from ..emitters.index import to_markdown, to_sidebar
|
||||
@@ -343,38 +380,41 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
|
||||
except Exception as e: # noqa: BLE001
|
||||
book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
|
||||
|
||||
# -- the last step ----------------------------------------------------
|
||||
from ..emitters.site import write as site_write
|
||||
site_dir = out / "site"
|
||||
site_write(view, style_obj, site_dir,
|
||||
graph=Path(graph_rel).name if graph_rel else None,
|
||||
title=slug, book=book.to_dict())
|
||||
if graph_rel and (out / graph_rel).exists():
|
||||
(site_dir / Path(graph_rel).name).write_bytes((out / graph_rel).read_bytes())
|
||||
|
||||
# close() BEFORE the notebook spec is built, not after. The spec quotes the
|
||||
# book measure, and the book measure only includes the site once the last
|
||||
# step exists — build it the other way round and the notebook says 7
|
||||
# artifacts while book.json says 8, which is exactly what it did.
|
||||
book.close(site=site_dir)
|
||||
|
||||
from ..emitters.notebook import write as nb_write
|
||||
from ..notebook import spec as spec_mod
|
||||
spec, spec_problems = spec_mod.merge(spec_from(book, ir), overlay)
|
||||
nb_write(spec, out / "notebook.ipynb")
|
||||
# Recorded by name and not by size: a notebook cannot report its own byte
|
||||
# count without changing it.
|
||||
book.notebook = "notebook.ipynb"
|
||||
return book
|
||||
|
||||
for pr in spec_problems:
|
||||
say(f" overlay {pr}")
|
||||
path = book.write()
|
||||
|
||||
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
|
||||
theme: str | None = None, exclude=(), overlay=None, notes=None,
|
||||
identity: str | None = None, quiet: bool = False) -> Book:
|
||||
"""One book, as a one-book document whose outputs live in its own directory.
|
||||
|
||||
`book.json`, `steps/`, `minisite/` and `notebook.ipynb` land where they always did;
|
||||
the minisite and notebook are now assembled the same way a many-book document's are.
|
||||
"""
|
||||
from .document import Document, Output, Part, build
|
||||
|
||||
out = Path(out)
|
||||
slug = slug or Path(str(source)).name or "book"
|
||||
doc = Document(
|
||||
name=slug, title=slug, build=out, inline=True,
|
||||
notes=Path(notes) if notes else None,
|
||||
parts=[Part(name=slug, kind=kind, source=Path(source), out=out,
|
||||
exclude=tuple(exclude), overlay=overlay, identity=identity)],
|
||||
outputs=[Output("minisite", out / "minisite", style=style, theme=theme),
|
||||
Output("notebook", out / "notebook.ipynb", style=style, theme=theme)],
|
||||
)
|
||||
build(doc, quiet=quiet)
|
||||
book = doc.books[0]
|
||||
|
||||
for pr in doc.problems:
|
||||
if not quiet:
|
||||
print(f" note {pr}")
|
||||
m = book.measure()
|
||||
say(f" book {m['nodes']} nodes · {m['edges']} edges · "
|
||||
f"{m['external']} external · {len(m['artifacts'])} artifacts")
|
||||
for r in book.compare():
|
||||
say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
|
||||
say(f" open {site_dir / 'index.html'}")
|
||||
say(f" {path}")
|
||||
if not quiet:
|
||||
print(f" book {m['nodes']} nodes · {m['edges']} edges · "
|
||||
f"{m['external']} external · {len(m['artifacts'])} artifacts")
|
||||
for r in book.compare():
|
||||
print(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
|
||||
print(f" open {out / 'minisite' / 'index.html'}")
|
||||
print(f" {out / 'book.json'}")
|
||||
return book
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
449
soleprint/atlas2/docgen/book/document.py
Normal file
449
soleprint/atlas2/docgen/book/document.py
Normal 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))
|
||||
@@ -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
|
||||
|
||||
161
soleprint/atlas2/docgen/book/notes.py
Normal file
161
soleprint/atlas2/docgen/book/notes.py
Normal 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')}"
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
soleprint/atlas2/docgen/cli/contract.py
Normal file
33
soleprint/atlas2/docgen/cli/contract.py
Normal 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
|
||||
@@ -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:])
|
||||
|
||||
@@ -30,13 +30,17 @@ def main(argv) -> int:
|
||||
help="Show each book's own output, not just the summary.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
from ..book.config import ConfigError, load, run
|
||||
from ..book.config import ConfigError, ProjectFile, load, run, select_parts
|
||||
|
||||
if not args.config.exists():
|
||||
raise Abort(f"no run file at {args.config} — see docgen.example.toml")
|
||||
raise Abort(f"no run file at {args.config} — see docgen.example.toml "
|
||||
"or docgen.project.example.toml")
|
||||
try:
|
||||
runfile = load(args.config)
|
||||
selected = runfile.select(args.only)
|
||||
if isinstance(runfile, ProjectFile):
|
||||
selected = select_parts(runfile, args.only)
|
||||
else:
|
||||
selected = runfile.select(args.only)
|
||||
except ConfigError as e:
|
||||
raise Abort(str(e)) from None
|
||||
|
||||
@@ -46,13 +50,26 @@ def main(argv) -> int:
|
||||
print(f"{runfile.path} — {len(selected)} of {len(runfile.books)} book(s)")
|
||||
if runfile.reference:
|
||||
print(f" reference {runfile.reference}")
|
||||
for b in selected:
|
||||
print(f" {b.line()}")
|
||||
if isinstance(runfile, ProjectFile):
|
||||
doc = runfile.document
|
||||
print(f" project {doc.name} — {doc.title}")
|
||||
print(f" build {doc.build}")
|
||||
print(f" notes {doc.notes}")
|
||||
for part in selected:
|
||||
print(f" book {part.name:<16} {part.kind:<8} {part.source}")
|
||||
for o in doc.outputs:
|
||||
print(f" output {o.emitter:<16} {o.path}"
|
||||
+ (f" (width {o.width}px)" if o.width else ""))
|
||||
else:
|
||||
for b in selected:
|
||||
print(f" {b.line()}")
|
||||
return 0
|
||||
|
||||
print(f"{runfile.path} — {len(selected)} book(s)")
|
||||
results = run(runfile, [b.name for b in selected], check=args.check,
|
||||
quiet=not args.verbose)
|
||||
if not results:
|
||||
raise Abort("nothing was built")
|
||||
|
||||
width = max(len(r["name"]) for r in results)
|
||||
for r in results:
|
||||
@@ -70,10 +87,12 @@ def main(argv) -> int:
|
||||
print(f" {status} {r['name']:<{width}} {detail}")
|
||||
|
||||
failed = [r for r in results if not r["ok"]]
|
||||
documents = sum(1 for r in results if r["name"].endswith("(document)"))
|
||||
what = f"{len(results) - documents} book(s)" + (f" in {documents} document" if documents else "")
|
||||
print()
|
||||
if failed:
|
||||
print(f"{len(failed)} of {len(results)} book(s) did not hold"
|
||||
print(f"{len(failed)} of {len(results)} did not hold ({what})"
|
||||
+ ("" if args.verbose else " — --verbose for each book's own output"))
|
||||
return 1
|
||||
print(f"{len(results)} book(s) built" + (" and checked" if args.check else ""))
|
||||
print(f"{what} built" + (" and checked" if args.check else ""))
|
||||
return 0
|
||||
|
||||
87
soleprint/atlas2/docgen/cli/scaffold.py
Normal file
87
soleprint/atlas2/docgen/cli/scaffold.py
Normal 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
|
||||
53
soleprint/atlas2/docgen/docgen.project.example.toml
Normal file
53
soleprint/atlas2/docgen/docgen.project.example.toml
Normal 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 |
@@ -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 |
@@ -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 & 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/<book>/<spot>.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 > 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><img></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
|
||||
└── <book>/
|
||||
├── _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 & 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"># <project>/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/<project> <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=<dir></code></td><td>that one book</td>
|
||||
<tr><td><code>make check BOOK=<dir></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
|
||||
|
||||
261
soleprint/atlas2/docgen/docs/models.md
Normal file
261
soleprint/atlas2/docgen/docs/models.md
Normal 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. ``. 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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}}")
|
||||
|
||||
@@ -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
|
||||
|
||||
145
soleprint/atlas2/docgen/emitters/markdown.py
Normal file
145
soleprint/atlas2/docgen/emitters/markdown.py
Normal 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)  | 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('"', """)
|
||||
|
||||
|
||||
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), """)}">')
|
||||
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)
|
||||
269
soleprint/atlas2/docgen/emitters/minisite/__init__.py
Normal file
269
soleprint/atlas2/docgen/emitters/minisite/__init__.py
Normal 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
|
||||
@@ -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,
|
||||
),
|
||||
@@ -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'')
|
||||
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,
|
||||
|
||||
118
soleprint/atlas2/docgen/emitters/paged.py
Normal file
118
soleprint/atlas2/docgen/emitters/paged.py
Normal 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()
|
||||
3
soleprint/atlas2/docgen/fixtures/notes/_intro.md
Normal file
3
soleprint/atlas2/docgen/fixtures/notes/_intro.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
382
soleprint/atlas2/docgen/ops/pages.py
Normal file
382
soleprint/atlas2/docgen/ops/pages.py
Normal 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)
|
||||
@@ -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\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 " — 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"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:")
|
||||
|
||||
@@ -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
7
soleprint/common/slice/.gitignore
vendored
Normal 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__/
|
||||
40
soleprint/common/slice/__init__.py
Normal file
40
soleprint/common/slice/__init__.py
Normal 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.
|
||||
"""
|
||||
8
soleprint/common/slice/__main__.py
Normal file
8
soleprint/common/slice/__main__.py
Normal 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())
|
||||
62
soleprint/common/slice/cli/__init__.py
Normal file
62
soleprint/common/slice/cli/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
The command line — every command slices have, and nothing else.
|
||||
|
||||
python3 -m common.slice <command> [args] (from soleprint/)
|
||||
./ctrl/ui.sh <command> [args] (from anywhere)
|
||||
make ui <command>
|
||||
|
||||
list what can be sliced: every export and part, with what it pulls in
|
||||
scan what a consumer uses, and how its framework copy compares with spr
|
||||
slice write every slice a run file lists (only what is stale)
|
||||
check the same, writing nothing: exit 1 if anything is stale
|
||||
plexus build one self-contained page from a plexus (make dist)
|
||||
reference the UI reference page of spr's /docs, generated from common/ui/src
|
||||
selftest this package's own suite
|
||||
|
||||
Library modules beside this package hold no command-line code; selftest asserts
|
||||
that nothing outside cli/ imports argparse, the same rule docgen keeps.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# command -> (module in this package, one-line description)
|
||||
COMMANDS = {
|
||||
"list": ("list", "what can be sliced, and what each piece pulls in"),
|
||||
"scan": ("scan", "what a consumer uses, and its copy against spr"),
|
||||
"slice": ("slice", "write every slice a run file lists"),
|
||||
"check": ("slice", "the same, writing nothing — exit 1 if stale"),
|
||||
"plexus": ("plexus", "build a plexus: one self-contained page"),
|
||||
"reference": ("reference", "the UI reference page of spr's docs, from common/ui/src"),
|
||||
"selftest": ("selftest", "this package's own suite"),
|
||||
}
|
||||
|
||||
|
||||
def usage() -> str:
|
||||
from .common import PROG
|
||||
|
||||
lines = [f"usage: {PROG} <command> [args]", ""]
|
||||
lines += [f" {name:<9} {desc}" for name, (_, desc) in COMMANDS.items()]
|
||||
lines += ["", f" {PROG} <command> --help for a command's options"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
from importlib import import_module
|
||||
|
||||
from .common import Abort
|
||||
|
||||
argv = sys.argv[1:] if argv is None else list(argv)
|
||||
if not argv or argv[0] in ("-h", "--help", "help"):
|
||||
print(usage())
|
||||
return 0 if argv else 2
|
||||
name, rest = argv[0], argv[1:]
|
||||
if name not in COMMANDS:
|
||||
print(f"Error: no command {name!r}\n\n{usage()}", file=sys.stderr)
|
||||
return 2
|
||||
module = import_module(f".{COMMANDS[name][0]}", package=__name__)
|
||||
try:
|
||||
return module.main(rest, command=name)
|
||||
except Abort as e:
|
||||
# The only exit path for an expected failure: one line, stderr, exit 1.
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user