Compare commits
21 Commits
rig-0.1.0
...
9021cbe3c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9021cbe3c1 | |||
| 73998020b3 | |||
| b693e324ef | |||
| 12ddd01940 | |||
| 5ff513fcc5 | |||
| 4fe3718795 | |||
| 514efc5e52 | |||
| 8fad2223f3 | |||
| 45018e4743 | |||
| 907b616d1b | |||
| b3cf24e750 | |||
| 88adcd68bc | |||
| 54c577a700 | |||
| 0ce36b98e4 | |||
| 2ef6139957 | |||
| 55c0d73ffe | |||
| 0b227cbc0e | |||
| 60527645f2 | |||
| 8f7e409ec4 | |||
| f4b2c15a9f | |||
|
|
aeeb26f4e9 |
11
Makefile
11
Makefile
@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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
|
help: ## list targets
|
||||||
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
|
@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>]
|
stop: ## stop a running room [<room>]
|
||||||
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
||||||
|
|
||||||
dist: ## compile the plexus UIs to single files [<room>]
|
dist: ## compile the plexus UIs to single files [NAME|FILE]
|
||||||
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
|
bash ctrl/dist.sh $(ARGS)
|
||||||
|
|
||||||
# ── theme ──────────────────────────────────────────────────────────────────
|
# ── theme ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
|
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
|
||||||
bash ctrl/theme.sh $(or $(ARGS),bake)
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
|
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
|
# 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.
|
# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does.
|
||||||
ctrl/.secrets/
|
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 ────────────────────────────────────────────────────────────────
|
# ── 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)
|
bash ctrl/host.sh $(or $(ARGS),status)
|
||||||
|
|
||||||
registry: ## the image registry [status] (default status)
|
registry: ## the image registry [status] (default status)
|
||||||
|
|||||||
@@ -2,13 +2,28 @@
|
|||||||
# The remote box — the other half of the inventory.
|
# The remote box — the other half of the inventory.
|
||||||
#
|
#
|
||||||
# Usage:
|
# 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
|
# Always the ssh alias, never a hostname: there is no `Host mcrn.ar` block, so
|
||||||
# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare
|
# a bare hostname offers every agent key and trips MaxAuthTries.
|
||||||
# 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
|
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")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
source ./lib/config.sh
|
source ./lib/config.sh
|
||||||
@@ -31,6 +46,273 @@ announce_batch() {
|
|||||||
echo "learned rather than scrolled past."
|
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
|
guard_alias
|
||||||
case "${1:-status}" in
|
case "${1:-status}" in
|
||||||
status)
|
status)
|
||||||
@@ -59,5 +341,7 @@ for r in json.load(sys.stdin):
|
|||||||
echo "stacks joining the shared 'gateway' network — intended design, but"
|
echo "stacks joining the shared 'gateway' network — intended design, but"
|
||||||
echo "nothing in the tree lists it. The inventory produces that list."
|
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
|
esac
|
||||||
|
|||||||
@@ -145,3 +145,16 @@ service_upstream() {
|
|||||||
*) printf '%s' "$up" ;;
|
*) printf '%s' "$up" ;;
|
||||||
esac
|
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" \
|
check "the capture warning clears once keys are in" "0" \
|
||||||
"$(bash ./check.sh 2>/dev/null | grep -c 'public keys not captured')"
|
"$(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"
|
note "every STALE entry has a check here"
|
||||||
|
|
||||||
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no
|
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no
|
||||||
|
|||||||
@@ -196,17 +196,7 @@ PY
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# A .gitignore pattern containing a slash anchors to its own directory, so the
|
# assert_ignored lives in lib/estate.sh — host.sh's snapshots need it too.
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
keygen() {
|
keygen() {
|
||||||
local peer="${1:-}"
|
local peer="${1:-}"
|
||||||
|
|||||||
184
build.py
184
build.py
@@ -23,7 +23,6 @@ Generated structure for managed rooms:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
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")
|
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):
|
def build_soleprint(output_dir: Path, room: str):
|
||||||
"""Build soleprint folder with core + room config merged."""
|
"""Build soleprint folder with core + room config merged."""
|
||||||
soleprint = SPR_ROOT / "soleprint"
|
soleprint = SPR_ROOT / "soleprint"
|
||||||
@@ -738,11 +595,6 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
log.info("Composing cabinets...")
|
log.info("Composing cabinets...")
|
||||||
compose_cabinets(output_dir, room)
|
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
|
# Generate models
|
||||||
log.info("Generating models...")
|
log.info("Generating models...")
|
||||||
if not generate_models(output_dir, room):
|
if not generate_models(output_dir, room):
|
||||||
@@ -799,33 +651,6 @@ def build_models_only():
|
|||||||
sys.exit(1)
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
|
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("--cfg", "-c", type=str, help="Room config name")
|
||||||
parser.add_argument("--all", action="store_true", help="Build all rooms")
|
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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.plexuses:
|
if args.models:
|
||||||
build_plexuses_only(args.cfg or "standalone")
|
|
||||||
elif args.models:
|
|
||||||
build_models_only()
|
build_models_only()
|
||||||
elif args.all:
|
elif args.all:
|
||||||
build(SPR_ROOT / "gen" / "standalone", None)
|
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`.
|
# Compile the plexus UIs to distributable files — this repo's `vite build`.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./ctrl/dist.sh # standalone
|
# ./ctrl/dist.sh # every plexus -> gen/plexuses/<name>/index.html
|
||||||
# ./ctrl/dist.sh sample # a named room
|
# ./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
|
# 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
|
# 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
|
# from a double-click on a machine with no server, no node and no network — the
|
||||||
# state a regulated Windows box is usually in.
|
# state a regulated Windows box is usually in.
|
||||||
#
|
#
|
||||||
# `make build` runs this as one of its steps. This exists for the loop where the
|
# It needs nothing built first: a plexus is its own directory under
|
||||||
# UI is what you are working on: rebuilding a whole room to see a CSS change is
|
# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it
|
||||||
# a slow way to iterate.
|
# carries is cut to what the page reads. See soleprint/common/slice/plexus.py.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
CALLER_DIR="$PWD"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
PYTHON="${PYTHON:-python3}"
|
PYTHON="${PYTHON:-python3}"
|
||||||
ROOM="${1:-standalone}"
|
|
||||||
|
|
||||||
if [[ ! -d "cfg/$ROOM" ]]; then
|
args=()
|
||||||
echo "No such room: cfg/$ROOM" >&2
|
for a in "$@"; do
|
||||||
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
|
# A bare word that names an existing path is a path the caller typed.
|
||||||
exit 1
|
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
|
||||||
fi
|
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():
|
for line in (source / "pyproject.toml").read_text().splitlines():
|
||||||
if line.strip().startswith("version"):
|
if line.strip().startswith("version"):
|
||||||
return line.split('"')[1]
|
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
|
pass
|
||||||
return "?"
|
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.
|
builds for production, and that compile is the whole point of the format.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dist # compile the plexus UIs for standalone
|
make dist # every plexus
|
||||||
make dist sample # for a named room
|
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
|
carrying its theme, its data and its diagrams inline. No server, no node, no
|
||||||
network. Zip it, mail it, double-click it.
|
network. Zip it, mail it, double-click it.
|
||||||
|
|
||||||
`make build` runs the same step as part of a room build. `make dist` exists for
|
A plexus is built on its own: nothing else has to be built first, and no other
|
||||||
the loop where the UI is what you are working on — rebuilding a whole room to
|
configuration has to name it.
|
||||||
see a CSS change is a slow way to iterate.
|
|
||||||
|
|
||||||
## The constraint that shapes 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
|
are stacks. The test that matters is opening the output with the network off and
|
||||||
seeing zero failed requests — everything else is cosmetic.
|
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
|
`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a
|
||||||
same place as its sibling `data/*.json` files:
|
`plexus.json`. To build with other settings — another theme, a plexus kept
|
||||||
|
outside spr, a different destination — list it in a run file:
|
||||||
|
|
||||||
```json
|
```toml
|
||||||
{ "items": [ { "name": "bundle" } ] }
|
[[plexus]]
|
||||||
|
name = "bundle"
|
||||||
|
theme = "mcrn"
|
||||||
|
dest = "out/bundle.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
A room may override anything the plexus declares — most usefully the theme:
|
`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
|
||||||
```json
|
vendored slice. See [Export / Compile](#export).
|
||||||
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing one
|
## Writing one
|
||||||
|
|
||||||
@@ -57,17 +59,22 @@ soleprint/artery/plexuses/<name>/
|
|||||||
app/index.html the template
|
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 |
|
| 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 |
|
| `%%BUNDLE%%` | `plexus.json` as a JS object |
|
||||||
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
|
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
|
||||||
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
||||||
|
|
||||||
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
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 bundle plexus
|
||||||
|
|
||||||
The one that ships. It answers "what does a rig installation have at its
|
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.
|
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
|
## soleprint-modelgen
|
||||||
|
|
||||||
Model generator. Reads schema definitions and produces model files.
|
Model generator. Reads schema definitions and produces model files.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ make start # run it
|
|||||||
| Command | Runs | Does |
|
| Command | Runs | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
|
| `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 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 start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
|
||||||
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
|
| `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
|
4. **Compose cabinets.** The dependency containers the room declared in
|
||||||
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
||||||
[Cabinets](#station-cabinets).
|
[Cabinets](#station-cabinets).
|
||||||
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
|
5. **Generate models.** modelgen reads the room's `config.json` and writes
|
||||||
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
|
|
||||||
`models/pydantic/__init__.py`.
|
`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.
|
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
|
||||||
|
|
||||||
## What comes out
|
## What comes out
|
||||||
@@ -71,7 +68,6 @@ gen/standalone/
|
|||||||
cfg/config.json
|
cfg/config.json
|
||||||
data/*.json
|
data/*.json
|
||||||
models/pydantic/
|
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
|
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,
|
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.
|
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
|
## Deploying
|
||||||
|
|
||||||
```bash
|
```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"
|
"en": "Shared Components"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "ui-reference",
|
||||||
|
"title": {
|
||||||
|
"en": "↳ UI reference"
|
||||||
|
},
|
||||||
|
"sub": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "export",
|
"id": "export",
|
||||||
"title": {
|
"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": {
|
"soleprint-cli": {
|
||||||
"type": "npm",
|
"type": "python",
|
||||||
"path": "soleprint/common/ui"
|
"path": "soleprint/station/tools/common"
|
||||||
},
|
},
|
||||||
"soleprint-modelgen": {
|
"soleprint-modelgen": {
|
||||||
"type": "pip",
|
"type": "pip",
|
||||||
"path": "soleprint/station/tools/modelgen"
|
"path": "soleprint/station/tools/modelgen"
|
||||||
|
},
|
||||||
|
"soleprint-ui": {
|
||||||
|
"type": "npm",
|
||||||
|
"path": "soleprint/common/ui"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
0.1.0
|
|
||||||
@@ -155,31 +155,6 @@ $(wordlist 2,$(words $(GOALS)),$(GOALS)):
|
|||||||
The cluster is then named after `<project>`, exactly as a copied rig named
|
The cluster is then named after `<project>`, exactly as a copied rig named
|
||||||
`<project>` was, so moving a copied rig to this layout keeps its cluster and ports.
|
`<project>` was, so moving a copied rig to this layout keeps its cluster and ports.
|
||||||
|
|
||||||
rig can also live outside the project, which then holds only the overlay. The
|
|
||||||
forwarder names it with one variable, a machine path that is never committed (the
|
|
||||||
shell, or an ignored `.env.local`), defaulting to the vendored layout:
|
|
||||||
|
|
||||||
```make
|
|
||||||
RIG_DIR ?= $(HERE)/rig
|
|
||||||
override RIG_DIR := $(abspath $(patsubst ~/%,$(HOME)/%,$(RIG_DIR)))
|
|
||||||
```
|
|
||||||
|
|
||||||
No search path: an unset `RIG_DIR` means `./rig`, and nothing else is tried. Moving rig
|
|
||||||
changes nothing in the cluster's name, context or ports; moving or renaming the overlay
|
|
||||||
folder does.
|
|
||||||
|
|
||||||
`VERSION`, at rig's top, names the rig. It travels with the folder, vendored, a
|
|
||||||
worktree or a plain copy, and needs no git. A project records the one it was last run
|
|
||||||
against and warns on another; it does not refuse:
|
|
||||||
|
|
||||||
```make
|
|
||||||
RIG_REF := 0.1.0
|
|
||||||
RIG_AT := $(strip $(shell cat '$(RIG_DIR)/VERSION' 2>/dev/null))
|
|
||||||
ifneq ($(RIG_AT),$(RIG_REF))
|
|
||||||
$(warning rig at $(RIG_DIR) is $(or $(RIG_AT),unversioned); this overlay was last run against rig $(RIG_REF))
|
|
||||||
endif
|
|
||||||
```
|
|
||||||
|
|
||||||
`rig.env` holds no secrets, by this contract. A repository whose `.gitignore` has a
|
`rig.env` holds no secrets, by this contract. A repository whose `.gitignore` has a
|
||||||
broad `*.env` (a common secrets rule) would still hide it, so an overlay living in
|
broad `*.env` (a common secrets rule) would still hide it, so an overlay living in
|
||||||
such a repo re-includes it in its own `.gitignore`: `!rig.env`.
|
such a repo re-includes it in its own `.gitignore`: `!rig.env`.
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ A plexus is a full app — and, unlike everything else here, one that is
|
|||||||
vite builds for production.
|
vite builds for production.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dist # standalone
|
make dist # every plexus here
|
||||||
make dist sample # a named room
|
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
|
Output: `gen/plexuses/<name>/index.html` (or `--dest`), one self-contained file
|
||||||
theme, its data and its diagrams inlined.
|
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
|
## The constraint
|
||||||
|
|
||||||
@@ -39,15 +41,22 @@ failed requests. Everything else is cosmetic.
|
|||||||
|
|
||||||
| Placeholder | Becomes |
|
| 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 |
|
| `%%BUNDLE%%` | `plexus.json` as a JS object literal |
|
||||||
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
|
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
|
||||||
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
||||||
|
|
||||||
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
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
|
A run file's `[[plexus]]` table can override `theme`, `inline_themes`, `title`
|
||||||
override anything the manifest sets — most usefully `theme`.
|
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
|
## Why the diagram is inlined
|
||||||
|
|
||||||
@@ -61,5 +70,5 @@ diagram become something you could put in a document.
|
|||||||
|
|
||||||
## Adding one
|
## Adding one
|
||||||
|
|
||||||
Drop a directory in beside `bundle/`. Nothing dispatches on the name — the room's
|
Drop a directory in beside `bundle/`. Nothing dispatches on the name and nothing
|
||||||
`plexuses.json` is the only place it has to be mentioned.
|
lists it: `make dist` builds every directory that holds a `plexus.json`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<!--
|
<!--
|
||||||
Bundle plexus — the template. build.py inlines everything marked %%…%% and
|
Bundle plexus — the template. `make dist bundle` inlines everything marked
|
||||||
writes one self-contained file to gen/<room>/plexuses/bundle/index.html.
|
%%…%% 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
|
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
|
renders genuine output — showcase.py runs modelgen, datagen and shuntgen over
|
||||||
@@ -217,7 +217,7 @@ if (S.shunt) {
|
|||||||
|
|
||||||
if (S.cabinet) {
|
if (S.cabinet) {
|
||||||
out.push('<h2>Dependencies it declares <span class="n">— cabinets</span></h2>');
|
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 ' +
|
'</code> once. This is merged into its compose file on a laptop; the same name ' +
|
||||||
'installs as a rig addon in a cluster.</p>');
|
'installs as a rig addon in a cluster.</p>');
|
||||||
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");
|
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"title": "Soleprint Bundle",
|
"title": "Soleprint Bundle",
|
||||||
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
|
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
|
||||||
"theme": "lucid",
|
"theme": "lucid",
|
||||||
|
"inline_themes": ["lucid", "soleprint", "mcrn"],
|
||||||
"graph": "system_overview",
|
"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.",
|
"_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:
|
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"
|
path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
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 {
|
.panel-status {
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ textarea {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
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 {
|
.panel-status {
|
||||||
|
|||||||
1
soleprint/atlas2/docgen/.gitignore
vendored
1
soleprint/atlas2/docgen/.gitignore
vendored
@@ -3,3 +3,4 @@ out/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
.docgen/
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ SLUG ?=
|
|||||||
# en_US.UTF-8 from the environment and --reader rejects it.
|
# en_US.UTF-8 from the environment and --reader rejects it.
|
||||||
READER ?= python
|
READER ?= python
|
||||||
OVERLAY ?=
|
OVERLAY ?=
|
||||||
|
NOTES ?=
|
||||||
CONFIG ?= docgen.toml
|
CONFIG ?= docgen.toml
|
||||||
ONLY ?=
|
ONLY ?=
|
||||||
CHECK ?=
|
CHECK ?=
|
||||||
@@ -54,7 +55,7 @@ SLUG_ARG := $(if $(SLUG),--slug $(SLUG))
|
|||||||
OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
|
OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
|
||||||
ONLY_ARGS := $(foreach n,$(subst $(comma), ,$(ONLY)),--only $(n))
|
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
|
help: ## List every target
|
||||||
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
|
@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 " 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 " 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 " 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 " 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 " CHECK=1 with run: each book's own level after building it"
|
||||||
@echo
|
@echo
|
||||||
@@ -92,11 +94,14 @@ book: ## SRC (or SCHEMA/OPENAPI/HAR) -> one operation, measured at both ends
|
|||||||
$(if $(OPENAPI),--openapi "$(OPENAPI)") \
|
$(if $(OPENAPI),--openapi "$(OPENAPI)") \
|
||||||
$(if $(HAR),--har "$(HAR)") \
|
$(if $(HAR),--har "$(HAR)") \
|
||||||
-o "$(if $(BOOK),$(BOOK),$(OUT)/book)" \
|
-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)
|
@$(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>)
|
check: ## Prove docgen (or one book, with BOOK=<dir>)
|
||||||
@$(CLI) check $(if $(BOOK),"$(BOOK)")
|
@$(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)/index.md
|
||||||
@$(CLI) emit index $(OUT)/ir.json -o $(OUT)/sidebar.json
|
@$(CLI) emit index $(OUT)/ir.json -o $(OUT)/sidebar.json
|
||||||
|
|
||||||
site: view ## OUT/view.json -> a self-contained docs site in OUT/site
|
minisite: view ## OUT/view.json -> a self-contained page in OUT/minisite
|
||||||
@$(CLI) emit site $(OUT)/view.json -o $(OUT)/site $(STYLE_ARGS)
|
@$(CLI) emit minisite $(OUT)/view.json -o $(OUT)/minisite $(STYLE_ARGS)
|
||||||
@echo " open $(OUT)/site/index.html"
|
@echo " open $(OUT)/minisite/index.html"
|
||||||
|
|
||||||
minimap: ## OUT/ir.json -> OUT/minimap.svg — what is where, read from the colours
|
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)
|
@$(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 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 \
|
@$(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)"
|
|| 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; \
|
from $(PKG).style import Style; import pathlib; \
|
||||||
pathlib.Path('$(HERE)/docs/viewer.html').write_text( \
|
pathlib.Path('$(HERE)/docs/viewer.html').write_text( \
|
||||||
_fill(VIEWER.replace('__TITLE__', 'docgen docs'), _slots(Style.load('lucid'))))"
|
_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 sync # optional: .venv with every group, from uv.lock
|
||||||
make check # prove it, on a tree it builds itself
|
make check # prove it, on a tree it builds itself
|
||||||
make book SRC=/path/to/repo # one whole operation, measured at both ends
|
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
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
cli/ the command line — every command, and nothing else
|
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
|
extractors/ source -> IR, one per source type
|
||||||
ir/ the contract: schema.json, the dataclasses, the validator
|
ir/ the contract: schema.json, the dataclasses, the validator
|
||||||
ops/ IR -> a smaller IR
|
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`.
|
build's outputs and never touches a hand-written `checks.py`.
|
||||||
`docgen.example.toml` runs against the shipped fixtures.
|
`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
|
Or as three composable commands, which is what the Makefile is wrapping — run
|
||||||
from the directory above this one:
|
from the directory above this one:
|
||||||
|
|
||||||
@@ -240,8 +282,8 @@ about either.
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make check # docgen's own suite: 287 after make sync, 272 with nothing
|
make check # docgen's own suite: 350 after make sync, 335 with nothing
|
||||||
make check BOOK=out/book/x # one book's own level — generated and hand-written checks
|
make check BOOK=out/book/x # one book's own level — or a project's .docgen/<name>
|
||||||
make doctor # the machine; never fails
|
make doctor # the machine; never fails
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ artifacts useful, and that property is the whole reason the sequence is worth
|
|||||||
having.
|
having.
|
||||||
|
|
||||||
**Not a new pipeline.** Everything here composes functions that already existed
|
**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,
|
adds a ledger and two measurements. If it ever starts doing the work itself,
|
||||||
something has gone wrong.
|
something has gone wrong.
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .larder import Larder
|
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
|
# all have to agree on what they are called, and three string literals in three
|
||||||
# files is how they stop agreeing.
|
# files is how they stop agreeing.
|
||||||
FIRST = "larder"
|
FIRST = "larder"
|
||||||
@@ -258,13 +258,13 @@ class Book:
|
|||||||
|
|
||||||
# -- writing ----------------------------------------------------------
|
# -- 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.
|
"""Append the last step. Call once, after the web output is written.
|
||||||
|
|
||||||
Must be called BEFORE the notebook is built, because the notebook quotes
|
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 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
|
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
|
`self.notebook` afterwards, by name only — a notebook cannot report its
|
||||||
own byte count without changing it.
|
own byte count without changing it.
|
||||||
@@ -273,10 +273,10 @@ class Book:
|
|||||||
summary = " · ".join(f"{v} {k}" for k, v in
|
summary = " · ".join(f"{v} {k}" for k, v in
|
||||||
sorted(m["by_kind"].items(), key=lambda kv: -kv[1]))
|
sorted(m["by_kind"].items(), key=lambda kv: -kv[1]))
|
||||||
artifact, size = None, 0
|
artifact, size = None, 0
|
||||||
if site is not None:
|
if minisite is not None:
|
||||||
site = Path(site)
|
minisite = Path(minisite)
|
||||||
if site.exists():
|
if minisite.exists():
|
||||||
artifact, size = str(site.relative_to(self.out)), _tree_bytes(site)
|
artifact, size = str(minisite.relative_to(self.out)), _tree_bytes(minisite)
|
||||||
self.steps.append(Step(
|
self.steps.append(Step(
|
||||||
id=LAST,
|
id=LAST,
|
||||||
label="what came out",
|
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
|
python3 -m docgen book --root ../station -o out/book/station
|
||||||
|
|
||||||
Composes functions that already exist. Nothing here parses, lays out or styles
|
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
|
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.
|
ever starts doing the work, the seam has moved to the wrong place.
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ EXTRACTORS = {
|
|||||||
TAKES_EXCLUDE = {"python", "code"}
|
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."""
|
"""(IR dict, Larder) for one source type, dispatched through EXTRACTORS."""
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
@@ -91,6 +91,8 @@ def extract(kind: str, source, *, exclude=(), identity=None):
|
|||||||
kwargs = {"identity": identity or str(source)}
|
kwargs = {"identity": identity or str(source)}
|
||||||
if kind in TAKES_EXCLUDE:
|
if kind in TAKES_EXCLUDE:
|
||||||
kwargs["exclude"] = exclude
|
kwargs["exclude"] = exclude
|
||||||
|
if kind == "code" and suffixes:
|
||||||
|
kwargs["suffixes"] = suffixes
|
||||||
g = fn(source, **kwargs)
|
g = fn(source, **kwargs)
|
||||||
|
|
||||||
ir = g.to_dict()
|
ir = g.to_dict()
|
||||||
@@ -102,7 +104,7 @@ def extract(kind: str, source, *, exclude=(), identity=None):
|
|||||||
return ir, Larder.from_dict(measured)
|
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.
|
"""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
|
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",
|
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:
|
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"**{summary} · {m['edges']} edges · {m['external']} external**\n\n"
|
||||||
f"{len(m['artifacts'])} artifact(s), {m['bytes']:,} bytes.\n\n"
|
f"{len(m['artifacts'])} artifact(s), {m['bytes']:,} bytes.\n\n"
|
||||||
f"Reconciled against what came in:\n\n{reconciled}\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"):
|
if artifact.endswith(".json"):
|
||||||
return (
|
return (
|
||||||
f'data = load("{artifact}")\n'
|
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'
|
' if "nodes" in data:\n'
|
||||||
' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
|
' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
|
||||||
' else:\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
|
# 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
|
# `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.
|
# 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]:
|
def clear(out) -> list[str]:
|
||||||
"""Remove a previous build's outputs from `out`. Returns what was removed.
|
"""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
|
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
|
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.
|
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
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
|
def build_book(kind: str, source, out, *, slug: str | None = None, exclude=(),
|
||||||
theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
|
ops: dict | None = None, suffixes=None, identity: str | None = None,
|
||||||
"""The whole book, in one process. Returns it; `book.json` is written."""
|
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 ..ir import check
|
||||||
from ..ops import classify, overview
|
from ..ops import classify, overview
|
||||||
|
from ..ops.pages import for_view
|
||||||
from ..style import Style
|
from ..style import Style
|
||||||
|
from .document import apply_ops
|
||||||
|
|
||||||
out = Path(out)
|
out = Path(out)
|
||||||
slug = slug or Path(str(source)).name or "book"
|
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:
|
if not quiet:
|
||||||
print(text)
|
print(text)
|
||||||
|
|
||||||
ir, larder = extract(kind, source, exclude=exclude)
|
ir, larder = extract(kind, source, exclude=exclude, suffixes=suffixes, identity=identity)
|
||||||
if larder is None:
|
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 = Book(slug=slug, larder=larder, out=out)
|
||||||
book.ir = ir
|
book.ir = ir
|
||||||
say(f" larder {larder.line()}")
|
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")
|
p.write_text(json.dumps(ir, indent=2) + "\n")
|
||||||
book.step("ir", "the graph, extracted", p, note=f"{len(ir['nodes'])} nodes")
|
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 = steps_dir / "view.json"
|
||||||
p.write_text(json.dumps(view, indent=2) + "\n")
|
p.write_text(json.dumps(view, indent=2) + "\n")
|
||||||
verdict = classify(view)
|
verdict = classify(view)
|
||||||
book.step("view", "the default view for this source", p,
|
book.step("view", "the default view for this source", p,
|
||||||
note=f"{verdict['kind']} — {verdict['why']}")
|
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
|
# The whole view in one drawing — still worth having as a step on its own,
|
||||||
# `draw()` the `emit auto` and `emit site` commands use, so the three can
|
# and the same `draw()` the `emit auto` command uses.
|
||||||
# never disagree about what a graph wants.
|
|
||||||
from ..emitters.auto import draw
|
from ..emitters.auto import draw
|
||||||
|
|
||||||
drawn = draw(view, style_obj)
|
drawn = draw(view, style_obj)
|
||||||
graph_rel = None
|
|
||||||
labels = {"erd": "drawn as an ERD", "dot": f"drawn as a {verdict['kind']}",
|
labels = {"erd": "drawn as an ERD", "dot": f"drawn as a {verdict['kind']}",
|
||||||
"index": "not a diagram — written as a list"}
|
"index": "not a diagram — written as a list"}
|
||||||
if drawn["content"] is None:
|
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"])
|
p.write_bytes(drawn["content"])
|
||||||
else:
|
else:
|
||||||
p.write_text(drawn["content"])
|
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"])
|
book.step("graph", labels[drawn["emitter"]], p, note=drawn["why"])
|
||||||
|
|
||||||
from ..emitters.index import to_markdown, to_sidebar
|
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
|
except Exception as e: # noqa: BLE001
|
||||||
book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
|
book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
# -- the last step ----------------------------------------------------
|
return book
|
||||||
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"
|
|
||||||
|
|
||||||
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()
|
m = book.measure()
|
||||||
say(f" book {m['nodes']} nodes · {m['edges']} edges · "
|
if not quiet:
|
||||||
f"{m['external']} external · {len(m['artifacts'])} artifacts")
|
print(f" book {m['nodes']} nodes · {m['edges']} edges · "
|
||||||
for r in book.compare():
|
f"{m['external']} external · {len(m['artifacts'])} artifacts")
|
||||||
say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
|
for r in book.compare():
|
||||||
say(f" open {site_dir / 'index.html'}")
|
print(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
|
||||||
say(f" {path}")
|
print(f" open {out / 'minisite' / 'index.html'}")
|
||||||
|
print(f" {out / 'book.json'}")
|
||||||
return book
|
return book
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ class Loaded:
|
|||||||
class Report:
|
class Report:
|
||||||
"""rig's reporting shape: sections, one line per decision, nothing aborts."""
|
"""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.passed, self.failed, self.skipped = 0, [], 0
|
||||||
|
self.subject = subject
|
||||||
|
|
||||||
def note(self, text: str) -> None:
|
def note(self, text: str) -> None:
|
||||||
print(f"\n{text}")
|
print(f"\n{text}")
|
||||||
@@ -116,7 +117,7 @@ class Report:
|
|||||||
# it summarises — which is how this was found, piping to `tail`.
|
# it summarises — which is how this was found, piping to `tail`.
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
if not self.failed:
|
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 ""))
|
+ (f", {self.skipped} skipped" if self.skipped else ""))
|
||||||
return 0
|
return 0
|
||||||
print(f"FAILED — {len(self.failed)} of {self.passed + len(self.failed)}: "
|
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.check(f"its {ran} code cells run, not merely compile", None, failure)
|
||||||
|
|
||||||
r.note("the web output exists and shows both ends")
|
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():
|
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:
|
else:
|
||||||
html = index.read_text()
|
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
|
# definitely opens. A page that shows the result without showing what
|
||||||
# went in is the thing this whole change corrects.
|
# went in is the thing this whole change corrects.
|
||||||
r.check("the page says what came in", True, "what came in" in html)
|
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,
|
r.check("the page admits the book is incomplete", True,
|
||||||
"This book is incomplete" in html)
|
"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")
|
r.note("the book is reproducible")
|
||||||
# A timestamp would make two builds of an unchanged larder differ, which
|
# 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
|
# 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:
|
finally:
|
||||||
os.chdir(previous)
|
os.chdir(previous)
|
||||||
return ran, failure
|
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")
|
READERS = ("python", "code")
|
||||||
|
|
||||||
TOP_KEYS = {"reference", "defaults", "book"}
|
TOP_KEYS = {"reference", "defaults", "book"}
|
||||||
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader"}
|
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader", "notes"}
|
||||||
BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", *SOURCES}
|
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
|
# 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.
|
# as both — no separators, nothing that means something to a shell.
|
||||||
@@ -99,6 +99,11 @@ class Entry:
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
overlay: Path | None = None
|
overlay: Path | None = None
|
||||||
exclude: tuple = ()
|
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:
|
def line(self) -> str:
|
||||||
return f"{self.name:<18} {self.kind:<8} {self.source} -> {self.out}"
|
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
|
return inner == outer or outer in inner.parents
|
||||||
|
|
||||||
|
|
||||||
def load(path) -> RunFile:
|
def _read(path: Path) -> dict:
|
||||||
"""Read and resolve a run file. Raises ConfigError listing every problem."""
|
|
||||||
path = Path(path)
|
|
||||||
if tomllib is None:
|
if tomllib is None:
|
||||||
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
|
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
|
||||||
try:
|
try:
|
||||||
data = tomllib.loads(path.read_text())
|
return tomllib.loads(path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ConfigError(path, [f"cannot read: {e}"]) from None
|
raise ConfigError(path, [f"cannot read: {e}"]) from None
|
||||||
except tomllib.TOMLDecodeError as e:
|
except tomllib.TOMLDecodeError as e:
|
||||||
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
|
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
|
base = path.resolve().parent
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
|
|
||||||
@@ -246,10 +271,13 @@ def load(path) -> RunFile:
|
|||||||
if source_key == "root" and source.is_dir() and _within(out, source):
|
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})")
|
problems.append(f"{where}: out {out} is inside the tree it reads ({source})")
|
||||||
|
|
||||||
|
notes = _path(pick("notes", "notes"), base)
|
||||||
|
|
||||||
books.append(Entry(
|
books.append(Entry(
|
||||||
name=name, kind=kind, source=source, out=out,
|
name=name, kind=kind, source=source, out=out,
|
||||||
style=pick("style", "lucid"), theme=pick("theme"),
|
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 = {}, {}
|
seen_names, seen_outs = {}, {}
|
||||||
@@ -282,13 +310,30 @@ def apply_reference(runfile: RunFile) -> str | None:
|
|||||||
return str(runfile.reference)
|
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.
|
"""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]}
|
{"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
|
from .build import run as build
|
||||||
|
|
||||||
apply_reference(runfile)
|
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)
|
overlay = spec_mod.load(entry.overlay)
|
||||||
book = build(entry.kind, entry.source, entry.out, slug=entry.name,
|
book = build(entry.kind, entry.source, entry.out, slug=entry.name,
|
||||||
style=entry.style, theme=entry.theme, exclude=entry.exclude,
|
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"]]
|
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
|
except Exception as e: # noqa: BLE001 - one bad book must not cost the run
|
||||||
result["error"] = f"{type(e).__name__}: {e}"
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
@@ -311,17 +357,272 @@ def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if check:
|
if check:
|
||||||
import contextlib
|
result["checks_failed"] = _checks(entry.out, quiet)
|
||||||
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["ok"] = not result["lost"] and not result["checks_failed"]
|
result["ok"] = not result["lost"] and not result["checks_failed"]
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return results
|
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
|
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
|
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
|
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
|
book one operation, measured at both ends
|
||||||
run every book a run file lists
|
run every book a run file lists
|
||||||
check docgen's own suite, or one book's own level
|
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
|
extract source -> ir.json
|
||||||
validate check an ir.json at the boundary
|
validate check an ir.json at the boundary
|
||||||
view ir.json -> a narrower ir.json
|
view ir.json -> a narrower ir.json
|
||||||
@@ -36,6 +38,8 @@ COMMANDS = {
|
|||||||
"book": ("book", "one operation, measured at both ends"),
|
"book": ("book", "one operation, measured at both ends"),
|
||||||
"run": ("run", "every book a run file lists"),
|
"run": ("run", "every book a run file lists"),
|
||||||
"check": ("check", "docgen's own suite, or one book's own level"),
|
"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"),
|
"extract": ("extract", "source -> ir.json"),
|
||||||
"validate": ("validate", "check an ir.json at the boundary"),
|
"validate": ("validate", "check an ir.json at the boundary"),
|
||||||
"view": ("view", "ir.json -> a narrower ir.json"),
|
"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.")
|
help="A hand-written notebook overlay, re-applied on every build.")
|
||||||
p.add_argument("--exclude", action="append", default=[],
|
p.add_argument("--exclude", action="append", default=[],
|
||||||
help="Directory name to skip. Repeatable.")
|
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")
|
p.add_argument("--quiet", "-q", action="store_true")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ def main(argv) -> int:
|
|||||||
try:
|
try:
|
||||||
book = run(kind, source, args.output, slug=args.slug, style=args.style,
|
book = run(kind, source, args.output, slug=args.slug, style=args.style,
|
||||||
theme=args.theme, exclude=tuple(args.exclude), overlay=overlay,
|
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
|
except Exception as e: # noqa: BLE001 - the CLI reports, it does not traceback
|
||||||
raise Abort(f"{type(e).__name__}: {e}") from None
|
raise Abort(f"{type(e).__name__}: {e}") from None
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ def main(argv) -> int:
|
|||||||
# what a fresh one would do.
|
# what a fresh one would do.
|
||||||
return subprocess.call([sys.executable, str(HERE / "selftest.py")])
|
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:
|
try:
|
||||||
book = Loaded(args.book)
|
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 index <ir.json> [-o out.md|.json]
|
||||||
python3 -m docgen emit minimap <ir.json> [-o out.svg] [--scale 0.55]
|
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 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
|
python3 -m docgen emit explore <ir.json> -o DIR
|
||||||
|
|
||||||
IR -> an artifact. Every emitter validates its input first, so a broken document
|
IR -> an artifact. Every emitter validates its input first, so a broken document
|
||||||
@@ -182,8 +182,8 @@ def notebook(argv) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def site(argv) -> int:
|
def minisite(argv) -> int:
|
||||||
p = argparse.ArgumentParser(prog=f"{PROG} emit site")
|
p = argparse.ArgumentParser(prog=f"{PROG} emit minisite")
|
||||||
p.add_argument("ir", type=Path)
|
p.add_argument("ir", type=Path)
|
||||||
p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
|
p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
|
||||||
style_args(p)
|
style_args(p)
|
||||||
@@ -191,7 +191,7 @@ def site(argv) -> int:
|
|||||||
p.add_argument("--no-graph", action="store_true")
|
p.add_argument("--no-graph", action="store_true")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
from ..emitters.auto import draw
|
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)
|
data, style = read_ir(args.ir), load_style(args.style, args.theme)
|
||||||
args.output.mkdir(parents=True, exist_ok=True)
|
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(f" note: {drawn['verdict']['kind']} — {drawn['why']}", file=sys.stderr)
|
||||||
print(" no diagram on the page; the index is the artifact", 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):
|
for f in page_write(data, style, args.output, graph=graph_name, title=args.title):
|
||||||
print(f" site {f}")
|
print(f" minisite {f}")
|
||||||
if graph_name:
|
if graph_name:
|
||||||
print(f" site {args.output / graph_name}")
|
print(f" minisite {args.output / graph_name}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -240,13 +240,17 @@ def explore(argv) -> int:
|
|||||||
|
|
||||||
|
|
||||||
EMITTERS = {"auto": auto, "dot": dot, "erd": erd, "index": index, "minimap": minimap,
|
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:
|
def main(argv) -> int:
|
||||||
if not argv or argv[0] in ("-h", "--help"):
|
if not argv or argv[0] in ("-h", "--help"):
|
||||||
print(__doc__.strip("\n"))
|
print(__doc__.strip("\n"))
|
||||||
return 0 if argv else 2
|
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:
|
if argv[0] not in EMITTERS:
|
||||||
raise Abort(f"no emitter {argv[0]!r} — have: {', '.join(EMITTERS)}")
|
raise Abort(f"no emitter {argv[0]!r} — have: {', '.join(EMITTERS)}")
|
||||||
return EMITTERS[argv[0]](argv[1:])
|
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.")
|
help="Show each book's own output, not just the summary.")
|
||||||
args = p.parse_args(argv)
|
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():
|
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:
|
try:
|
||||||
runfile = load(args.config)
|
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:
|
except ConfigError as e:
|
||||||
raise Abort(str(e)) from None
|
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)")
|
print(f"{runfile.path} — {len(selected)} of {len(runfile.books)} book(s)")
|
||||||
if runfile.reference:
|
if runfile.reference:
|
||||||
print(f" reference {runfile.reference}")
|
print(f" reference {runfile.reference}")
|
||||||
for b in selected:
|
if isinstance(runfile, ProjectFile):
|
||||||
print(f" {b.line()}")
|
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
|
return 0
|
||||||
|
|
||||||
print(f"{runfile.path} — {len(selected)} book(s)")
|
print(f"{runfile.path} — {len(selected)} book(s)")
|
||||||
results = run(runfile, [b.name for b in selected], check=args.check,
|
results = run(runfile, [b.name for b in selected], check=args.check,
|
||||||
quiet=not args.verbose)
|
quiet=not args.verbose)
|
||||||
|
if not results:
|
||||||
|
raise Abort("nothing was built")
|
||||||
|
|
||||||
width = max(len(r["name"]) for r in results)
|
width = max(len(r["name"]) for r in results)
|
||||||
for r in results:
|
for r in results:
|
||||||
@@ -70,10 +87,12 @@ def main(argv) -> int:
|
|||||||
print(f" {status} {r['name']:<{width}} {detail}")
|
print(f" {status} {r['name']:<{width}} {detail}")
|
||||||
|
|
||||||
failed = [r for r in results if not r["ok"]]
|
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()
|
print()
|
||||||
if failed:
|
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"))
|
+ ("" if args.verbose else " — --verbose for each book's own output"))
|
||||||
return 1
|
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
|
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"?>
|
<?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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2824pt" viewBox="0 0 886 2824">
|
||||||
<rect width="886" height="2238" fill="#0a0a0a"/>
|
<rect width="886" height="2824" 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>
|
<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="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="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>
|
<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="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="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="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="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="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="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="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="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="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="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="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="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>
|
<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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="28" y="1481" 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="210" y="1481" 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="28" y="1648" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
|
||||||
<text x="702" y="1406" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</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="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
|
<text x="28" y="1846" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
|
||||||
<text x="292" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</text>
|
<text x="210" y="1846" 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="720" y="1846" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
|
||||||
<text x="28" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
|
<text x="28" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
|
||||||
<text x="210" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</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="392" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
|
<text x="556" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
|
||||||
<text x="492" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
|
<text x="738" y="2198" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
|
||||||
<text x="592" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
|
<text x="28" y="2478" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
|
||||||
<text x="692" y="1920" 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="28" y="1219" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</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="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="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="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="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="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="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="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="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="28" y="1510" 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="110" y="1510" 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="210" y="1624" 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="292" y="1556" 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="374" y="1546" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">run</text>
|
||||||
<text x="456" y="1297" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">common</text>
|
<text x="456" y="1541" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">view</text>
|
||||||
<text x="538" y="1297" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">run</text>
|
<text x="538" y="1540" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">scaffold</text>
|
||||||
<text x="620" y="1294" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
|
<text x="620" y="1536" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">common</text>
|
||||||
<text x="702" y="1282" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">check</text>
|
<text x="702" y="1534" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
|
||||||
<text x="784" y="1274" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
|
<text x="784" y="1525" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">check</text>
|
||||||
<text x="28" y="1668" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
|
<text x="28" y="1680" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">contract</text>
|
||||||
<text x="110" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
|
<text x="110" y="1679" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
|
||||||
<text x="192" y="1568" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
|
<text x="210" y="1822" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
|
||||||
<text x="274" y="1561" 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="356" y="1560" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
|
<text x="374" y="1817" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
|
||||||
<text x="438" y="1552" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
|
<text x="456" y="1802" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
|
||||||
<text x="520" y="1504" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</text>
|
<text x="538" y="1800" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
|
||||||
<text x="602" y="1454" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
|
<text x="620" y="1798" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minisite</text>
|
||||||
<text x="702" y="1562" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
|
<text x="702" y="1744" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</text>
|
||||||
<text x="784" y="1556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
|
<text x="784" y="1736" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">markdown</text>
|
||||||
<text x="28" y="1802" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
|
<text x="28" y="1920" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">paged</text>
|
||||||
<text x="110" y="1787" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
|
<text x="110" y="1892" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
|
||||||
<text x="192" y="1725" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
|
<text x="210" y="2175" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">config</text>
|
||||||
<text x="292" y="1897" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
|
<text x="292" y="2086" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">document</text>
|
||||||
<text x="374" y="1870" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">config</text>
|
<text x="374" y="2071" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
|
||||||
<text x="456" y="1844" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
|
<text x="456" y="2050" 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="538" y="1960" 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="620" y="1942" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notes</text>
|
||||||
<text x="720" y="1798" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
|
<text x="720" y="2002" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
|
||||||
<text x="28" y="2074" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
|
<text x="28" y="2348" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
|
||||||
<text x="110" y="2018" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
|
<text x="110" y="2308" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
|
||||||
<text x="210" y="2046" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
|
<text x="192" y="2294" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
|
||||||
<text x="292" y="2040" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
|
<text x="274" y="2232" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
|
||||||
<text x="392" y="1949" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
|
<text x="374" y="2331" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
|
||||||
<text x="492" y="2010" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
|
<text x="456" y="2305" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
|
||||||
<text x="592" y="2067" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
|
<text x="556" y="2352" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
|
||||||
<text x="692" y="2178" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
|
<text x="638" y="2296" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
|
||||||
<rect x="28" y="2217.5" width="9" height="9" rx="2" fill="#1a1a1a"/>
|
<text x="738" y="2456" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
|
||||||
<text x="41" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
|
<text x="28" y="2685" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pages</text>
|
||||||
<rect x="86" y="2217.5" width="9" height="9" rx="2" fill="#1d4ed8"/>
|
<text x="128" y="2604" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
|
||||||
<text x="99" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
|
<text x="210" y="2599" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
|
||||||
<rect x="138" y="2217.5" width="9" height="9" rx="2" fill="#d4a574"/>
|
<text x="310" y="2508" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
|
||||||
<text x="151" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
|
<text x="410" y="2764" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">onepage</text>
|
||||||
<rect x="214" y="2217.5" width="9" height="9" rx="2" fill="#15803d"/>
|
<text x="510" y="2568" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
|
||||||
<text x="227" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
|
<text x="610" y="2626" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</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>
|
<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>
|
</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="#what">What it is</a>
|
||||||
<a href="#idea">The idea</a>
|
<a href="#idea">The idea</a>
|
||||||
<a href="#quick">Five minutes</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>
|
<div class="group">Architecture</div>
|
||||||
<a href="#layers">The three concerns</a>
|
<a href="#layers">The three concerns</a>
|
||||||
<a href="#book">The book</a>
|
<a href="#book">The book</a>
|
||||||
<a href="#ir">The IR</a>
|
<a href="#ir">The IR</a>
|
||||||
<a href="#shape">Shape decides the drawing</a>
|
<a href="#shape">Shape decides the drawing</a>
|
||||||
|
<a href="#pages">Pages</a>
|
||||||
|
|
||||||
<div class="group">Reading</div>
|
<div class="group">Reading</div>
|
||||||
<a href="#extractors">Extractors</a>
|
<a href="#extractors">Extractors</a>
|
||||||
@@ -37,11 +40,13 @@
|
|||||||
<a href="#emitters">Emitters</a>
|
<a href="#emitters">Emitters</a>
|
||||||
<a href="#explore">Explore</a>
|
<a href="#explore">Explore</a>
|
||||||
<a href="#notebooks">Notebooks</a>
|
<a href="#notebooks">Notebooks</a>
|
||||||
|
<a href="#notes">Notes</a>
|
||||||
<a href="#style">Style & colour</a>
|
<a href="#style">Style & colour</a>
|
||||||
|
|
||||||
<div class="group">Reference</div>
|
<div class="group">Reference</div>
|
||||||
<a href="#standalone">Standalone</a>
|
<a href="#standalone">Standalone</a>
|
||||||
<a href="#runs">Run files</a>
|
<a href="#runs">Run files</a>
|
||||||
|
<a href="#projects">Project files</a>
|
||||||
<a href="#commands">Commands</a>
|
<a href="#commands">Commands</a>
|
||||||
<a href="#deps">Dependencies</a>
|
<a href="#deps">Dependencies</a>
|
||||||
<a href="#testing">Testing</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,
|
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
|
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
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="note">
|
<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
|
<pre><code> larder /path/to/repo — 45 files read, 2 failed, 12 packages
|
||||||
book 312 nodes · 244 edges · 26 external · 8 artifacts
|
book 312 nodes · 244 edges · 26 external · 8 artifacts
|
||||||
ok 45 file(s) read produced 45 module(s)
|
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>
|
<p>
|
||||||
Underneath it is three commands that compose, and each still works on its own.
|
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>
|
<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 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>
|
make self <span class="c"># docgen's book of soleprint, then check it</span></code></pre>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -154,6 +159,145 @@ make self <span class="c"># docgen's book of soleprint
|
|||||||
the outputs open over <code>file://</code>.
|
the outputs open over <code>file://</code>.
|
||||||
</p>
|
</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>
|
<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>
|
├── 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>
|
├── 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>
|
├── 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
|
<pre><code>make book SRC=../station BOOK=out/book/station
|
||||||
make check BOOK=out/book/station</code></pre>
|
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>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>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>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>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>
|
<tr><td><code>auto</code></td><td>whichever of the above fits</td><td>not having to choose</td></tr>
|
||||||
</table>
|
</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>
|
dark for everything that is not a declaration — imports, constants, prose.</figcaption>
|
||||||
</figure>
|
</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>
|
<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
|
<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>
|
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>
|
<h2 id="style">Style & colour</h2>
|
||||||
|
|
||||||
@@ -872,10 +1129,10 @@ make doctor
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr><th>context</th><th>checks</th><th>skipped</th></tr>
|
<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>in the repo, synced</td><td>350</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, 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>279</td><td>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>286</td>
|
<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>
|
<td>the in-place case — correctly, there is no repo above</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -957,7 +1214,7 @@ python3 -m docgen run docgen.toml --list <span class="c"># resolved paths
|
|||||||
<div class="note">
|
<div class="note">
|
||||||
<b>A rebuild replaces the previous build, and nothing else.</b> Before writing,
|
<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>,
|
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>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
|
<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.
|
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.
|
because those are data that tools write.
|
||||||
</p>
|
</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>
|
<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><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 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 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</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 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>
|
<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 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 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 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>
|
<tr><td><code>make self</code></td><td>docgen's book of soleprint, then check it</td></tr>
|
||||||
</table>
|
</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 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 <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 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 python --root SRC -o ir.json
|
||||||
python3 -m docgen extract code --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 erd ir.json -o schema.svg
|
||||||
python3 -m docgen emit minimap ir.json -o map.svg --scale 0.5
|
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 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>
|
python3 -m docgen emit explore ir.json -o explore/</code></pre>
|
||||||
|
|
||||||
<h3>Where the command line lives</h3>
|
<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>
|
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,
|
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
|
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
|
command and the book — which is now <code>emitters.auto.draw()</code>, returning
|
||||||
content and leaving the file name to the caller.
|
content and leaving the file name to the caller.
|
||||||
</p>
|
</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><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>
|
<tr><td><code>make doctor</code></td><td>the machine — what is installed</td>
|
||||||
<td>never; it reports</td></tr>
|
<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>
|
<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>
|
<td>exit 1</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>
|
<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
|
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.
|
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
|
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
|
## One function, three callers
|
||||||
|
|
||||||
This branch — erd, or index, or dot through Graphviz — used to be written out
|
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
|
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
|
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
|
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}
|
"why": why this emitter, or why nothing was drawn}
|
||||||
|
|
||||||
Never raises for a missing Graphviz. A layered graph on a machine without
|
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.
|
goes text-only — so it is reported in `why` with `content` None.
|
||||||
"""
|
"""
|
||||||
verdict = classify(ir)
|
verdict = classify(ir)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _style_words(rule: dict, *, filled: bool = True) -> str:
|
|||||||
return ",".join(words)
|
return ",".join(words)
|
||||||
|
|
||||||
|
|
||||||
def _node_attrs(node, rule: dict, style) -> dict:
|
def _node_attrs(node, rule: dict, style, links: dict | None = None) -> dict:
|
||||||
a = {
|
a = {
|
||||||
"label": node.get("label") or node["id"],
|
"label": node.get("label") or node["id"],
|
||||||
"shape": rule.get("shape", "box"),
|
"shape": rule.get("shape", "box"),
|
||||||
@@ -76,7 +76,16 @@ def _node_attrs(node, rule: dict, style) -> dict:
|
|||||||
"class": node["kind"],
|
"class": node["kind"],
|
||||||
}
|
}
|
||||||
attrs = node.get("attrs") or {}
|
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")
|
line = attrs.get("line")
|
||||||
a["href"] = f"{attrs['file']}#L{line}" if line else attrs["file"]
|
a["href"] = f"{attrs['file']}#L{line}" if line else attrs["file"]
|
||||||
a["tooltip"] = attrs.get("doc") or node["id"]
|
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,
|
def emit(ir: dict, style, *, max_depth: int | None = None,
|
||||||
rankdir: str | None = None) -> str:
|
rankdir: str | None = None, links: dict | None = None) -> str:
|
||||||
"""IR document (a dict) + Style -> DOT text."""
|
"""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"]}
|
nodes = {n["id"]: n for n in ir["nodes"]}
|
||||||
children: dict[str | None, list[str]] = {}
|
children: dict[str | None, list[str]] = {}
|
||||||
for n in ir["nodes"]:
|
for n in ir["nodes"]:
|
||||||
@@ -119,7 +134,7 @@ def emit(ir: dict, style, *, max_depth: int | None = None,
|
|||||||
pad = " " * (depth + 1)
|
pad = " " * (depth + 1)
|
||||||
|
|
||||||
if not kids or too_deep:
|
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
|
return
|
||||||
|
|
||||||
rule = style.group(node["kind"])
|
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} labeljust=l')
|
||||||
out.append(f'{pad} id="{_esc(node_id)}"')
|
out.append(f'{pad} id="{_esc(node_id)}"')
|
||||||
out.append(f'{pad} class="{node["kind"]}"')
|
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:
|
for kid in kids:
|
||||||
write(kid, depth + 1)
|
write(kid, depth + 1)
|
||||||
out.append(f"{pad}}}")
|
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)
|
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.
|
"""Place cards in √n columns, referenced tables first.
|
||||||
|
|
||||||
Sorting by "is the target of a foreign key" puts the tables everything
|
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"]))
|
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))
|
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
|
col_w = CARD_W + COL_GAP
|
||||||
cursor = [PAD] * cols
|
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
|
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.
|
"""IR (a db document) + Style -> SVG text.
|
||||||
|
|
||||||
`columns=False` draws the header of every card and none of its contents —
|
`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")]
|
edges = [e for e in ir["edges"] if e["kind"] in ("foreign_key", "references")]
|
||||||
by_id = {t["id"]: t for t in tables}
|
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
|
s = style.slot
|
||||||
width = max(x for x, _ in pos.values()) + CARD_W + PAD
|
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
|
Not invented here. Five demos under `semester/` already converged on the same
|
||||||
two files, and this generates that arrangement rather than a sixth variant:
|
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
|
## Colours
|
||||||
|
|
||||||
Baked from the same style slots as every diagram, so the page and the graph on
|
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.
|
diagram together; nothing has to be kept in sync by hand.
|
||||||
|
|
||||||
Self-contained and offline: no CDN, no build step, opens over `file://`.
|
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__;
|
--muted: __MUTED__;
|
||||||
--dim: __DIM__;
|
--dim: __DIM__;
|
||||||
--accent: __ACCENT__;
|
--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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -240,8 +247,7 @@ body {
|
|||||||
text-transform: uppercase; letter-spacing: .04em; }
|
text-transform: uppercase; letter-spacing: .04em; }
|
||||||
.lede { color: var(--dim); font-size: 12px; margin-bottom: 1.5rem; }
|
.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 { margin: 1rem 0 1.5rem; }
|
||||||
.figure a { display: block; border: 1px solid var(--border); border-radius: 8px;
|
.figure a { display: block; border: 1px solid var(--border); border-radius: 8px;
|
||||||
overflow: hidden; background: var(--surface); }
|
overflow: hidden; background: var(--surface); }
|
||||||
@@ -260,20 +266,58 @@ body {
|
|||||||
.ledger dd { margin: 0; color: var(--text); font-size: 12.5px; }
|
.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;
|
.ledger dd .sub { display: block; color: var(--muted); font-size: 11px; margin-top: .2rem;
|
||||||
overflow-wrap: anywhere; }
|
overflow-wrap: anywhere; }
|
||||||
.ledger .lost { color: var(--artery); }
|
.ledger .lost { color: var(--lost); }
|
||||||
.ledger .kept { color: var(--ok); }
|
.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; }
|
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 ul { margin: .4rem 0 0 1.1rem; color: var(--muted); }
|
||||||
.gap code { font-size: 11px; }
|
.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:
|
def _ledger(book: dict) -> str:
|
||||||
"""The two measures, and the gap between them if there is one.
|
"""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
|
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
|
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.
|
nobody reads does not count as having said it.
|
||||||
@@ -337,6 +381,8 @@ def _slots(style) -> dict:
|
|||||||
"__MUTED__": s("text-muted"),
|
"__MUTED__": s("text-muted"),
|
||||||
"__DIM__": s("text-dim"),
|
"__DIM__": s("text-dim"),
|
||||||
"__ACCENT__": s("accent"),
|
"__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',
|
"__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
|
`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
|
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.
|
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", {})
|
meta = ir.get("meta", {})
|
||||||
name = title or meta.get("root", "docs")
|
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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{escape(name)}</title>
|
<title>{escape(name)}</title>
|
||||||
<link rel="stylesheet" href="site.css">
|
<link rel="stylesheet" href="minisite.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -474,7 +520,7 @@ document.querySelectorAll('h2[id], h3[id]').forEach(function (h) {{ obs.observe(
|
|||||||
return {
|
return {
|
||||||
"index.html": index,
|
"index.html": index,
|
||||||
"viewer.html": _fill(VIEWER.replace("__TITLE__", escape(name)), values),
|
"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)),
|
CSS.replace("__SIDEBAR__", str(SIDEBAR_W)).replace("__CONTENT__", str(CONTENT_W)),
|
||||||
values,
|
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")
|
lines = text.split("\n")
|
||||||
source = [ln + "\n" for ln in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
|
source = [ln + "\n" for ln in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
|
||||||
cell = {
|
cell = {
|
||||||
@@ -49,6 +49,12 @@ def _cell(index: int, kind: str, text: str) -> dict:
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": source,
|
"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":
|
if kind == "code":
|
||||||
cell["execution_count"] = None
|
cell["execution_count"] = None
|
||||||
cell["outputs"] = []
|
cell["outputs"] = []
|
||||||
@@ -212,15 +218,50 @@ def _call_md(step: dict) -> str:
|
|||||||
return "\n".join(out)
|
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:
|
def build(spec: dict) -> dict:
|
||||||
"""A merged notebook spec -> the notebook, as a dict."""
|
"""A merged notebook spec -> the notebook, as a dict."""
|
||||||
blocks: list[tuple[str, str]] = []
|
blocks: list[tuple] = []
|
||||||
|
|
||||||
for step in spec["steps"]:
|
for step in spec["steps"]:
|
||||||
kind = step.get("kind", "md")
|
kind = step.get("kind", "md")
|
||||||
before = step.get("before")
|
before = step.get("before")
|
||||||
if 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":
|
if kind == "md":
|
||||||
text = []
|
text = []
|
||||||
@@ -259,7 +300,7 @@ def build(spec: dict) -> dict:
|
|||||||
blocks.append(("md", after))
|
blocks.append(("md", after))
|
||||||
|
|
||||||
return {
|
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,
|
"metadata": METADATA,
|
||||||
"nbformat": 4,
|
"nbformat": 4,
|
||||||
"nbformat_minor": 5,
|
"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"
|
SPEC_VERSION = "1"
|
||||||
|
|
||||||
# Step kinds. Small and closed, the same discipline the IR's `kind` follows.
|
# 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:
|
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=["*"])
|
ops_mod = __import__(f"{PKG}.ops", fromlist=["*"])
|
||||||
erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"])
|
erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"])
|
||||||
nb_mod = __import__(f"{PKG}.emitters.notebook", 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=["*"])
|
mm_mod = __import__(f"{PKG}.emitters.minimap", fromlist=["*"])
|
||||||
exp_mod = __import__(f"{PKG}.emitters.explore", fromlist=["*"])
|
exp_mod = __import__(f"{PKG}.emitters.explore", fromlist=["*"])
|
||||||
spec_mod = __import__(f"{PKG}.notebook", 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
|
# Not invented here: five demos under semester/ carry the same viewer, differing
|
||||||
# only in comments and one background colour. This generates that arrangement
|
# 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.
|
# 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")
|
files = site_mod.write(ops_mod.overview(ir), lucid, site_dir, graph="graph.svg", title="fx")
|
||||||
names = {f.name for f in files}
|
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()
|
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()
|
viewer = (site_dir / "viewer.html").read_text()
|
||||||
|
|
||||||
check(
|
check(
|
||||||
@@ -1316,8 +1316,8 @@ check(
|
|||||||
light = site_mod.emit(ops_mod.overview(ir), print_theme, graph="graph.svg")
|
light = site_mod.emit(ops_mod.overview(ir), print_theme, graph="graph.svg")
|
||||||
check(
|
check(
|
||||||
"a light theme gives a light page",
|
"a light theme gives a light page",
|
||||||
print_theme.slot("surface-0") in light["site.css"]
|
print_theme.slot("surface-0") in light["minisite.css"]
|
||||||
and lucid.slot("surface-0") not in light["site.css"],
|
and lucid.slot("surface-0") not in light["minisite.css"],
|
||||||
"the page and the diagram on it move together",
|
"the page and the diagram on it move together",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
@@ -1766,11 +1766,11 @@ def _books():
|
|||||||
ran, failure = checks_mod._run_cells(nb["cells"], out)
|
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 "")
|
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(
|
check(
|
||||||
"the web output shows both ends",
|
"the web output shows both ends",
|
||||||
"what came in" in html and "what came out" in html,
|
"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(
|
check(
|
||||||
"and admits when the book is incomplete",
|
"and admits when the book is incomplete",
|
||||||
@@ -1783,7 +1783,7 @@ def _books():
|
|||||||
again = Path(tmp.name) / "book-out-2"
|
again = Path(tmp.name) / "book-out-2"
|
||||||
build_mod.run("python", fixture, again, slug="fixture", quiet=True)
|
build_mod.run("python", fixture, again, slug="fixture", quiet=True)
|
||||||
differs = [
|
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()
|
if (out / name).read_bytes() != (again / name).read_bytes()
|
||||||
]
|
]
|
||||||
check("the same larder builds the same bytes", not differs, f"differ: {differs}")
|
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,
|
and "Traceback" not in bad.stderr and bad.stderr.count("\n") == 1,
|
||||||
f"exit {bad.returncode}: {bad.stderr[:120]!r}",
|
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 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.
|
# 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
|
# 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.
|
# `emitters.dot` is prose, and the first version of this check failed on it.
|
||||||
@@ -2266,7 +2269,7 @@ def _runfiles():
|
|||||||
check(
|
check(
|
||||||
"a rebuild removes the previous build's outputs",
|
"a rebuild removes the previous build's outputs",
|
||||||
again["app"]["ok"] and not (app / "steps" / "stale.svg").exists(),
|
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())
|
check("...and keeps the hand-written checks.py", (app / "checks.py").exists())
|
||||||
|
|
||||||
@@ -2319,6 +2322,481 @@ def _runfiles():
|
|||||||
_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")
|
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
|
||||||
if FAIL:
|
if FAIL:
|
||||||
print("\nfailed:")
|
print("\nfailed:")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Drive the generated viewer's logic under a stub DOM and assert the toggle.
|
// Drive the generated viewer's logic under a stub DOM and assert the toggle.
|
||||||
const fs = require('fs');
|
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 script = html.split('<script>')[1].split('</script>')[0];
|
||||||
|
|
||||||
const handlers = {};
|
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
|
||||||
25
soleprint/common/slice/cli/common.py
Normal file
25
soleprint/common/slice/cli/common.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""What every command does the same way — written once."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = __package__.rsplit(".", 1)[0]
|
||||||
|
PROG = f"python3 -m {ROOT}"
|
||||||
|
|
||||||
|
|
||||||
|
class Abort(Exception):
|
||||||
|
"""An expected failure. Printed as one line, exit 1 — never a traceback."""
|
||||||
|
|
||||||
|
|
||||||
|
def kb(n: int) -> str:
|
||||||
|
return f"{n / 1024:.1f} KB"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runfile(path: Path):
|
||||||
|
from .. import runfile, tokens
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
raise Abort(f"no run file at {path} — see soleprint/common/slice/ui.example.toml")
|
||||||
|
try:
|
||||||
|
return runfile.load(path, tokens.available_themes())
|
||||||
|
except runfile.ConfigError as e:
|
||||||
|
raise Abort(str(e)) from None
|
||||||
81
soleprint/common/slice/cli/list.py
Normal file
81
soleprint/common/slice/cli/list.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
python3 -m common.slice list every export and part, and what it pulls in
|
||||||
|
python3 -m common.slice list --json the same, as data (the derived half of a manifest)
|
||||||
|
|
||||||
|
The point is the right-hand columns: what taking one piece costs. `Panel` is one
|
||||||
|
file and `vue`; `TimeSeriesRenderer` brings uplot; `maximize` brings `panel`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ...theme import bake
|
||||||
|
from .. import graph
|
||||||
|
from ..slices import _needs
|
||||||
|
from .common import PROG, kb
|
||||||
|
|
||||||
|
|
||||||
|
def exports_table() -> list[dict]:
|
||||||
|
nodes = graph.load()
|
||||||
|
rows = []
|
||||||
|
for e in graph.barrel(nodes=nodes):
|
||||||
|
closure, _ = graph.select([e.name], [], graph.barrel(nodes=nodes), nodes)
|
||||||
|
rows.append({
|
||||||
|
"name": e.name,
|
||||||
|
"kind": "type" if e.is_type else "value",
|
||||||
|
"file": e.file,
|
||||||
|
"depends_on": sorted(closure - {e.file}),
|
||||||
|
"packages": graph.dependencies(closure, nodes),
|
||||||
|
"tokens": sorted(graph.tokens_read(closure, nodes)),
|
||||||
|
"bytes": sum(nodes[f].size for f in closure),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def parts_table() -> list[dict]:
|
||||||
|
parts = bake.load_parts()
|
||||||
|
rows = []
|
||||||
|
for name, (css, classes, detects, always) in sorted(parts.items()):
|
||||||
|
js = (bake.PARTS / f"{name}.js")
|
||||||
|
js_text = js.read_text() if js.exists() else ""
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"always": always,
|
||||||
|
"depends_on": _needs(name, parts),
|
||||||
|
"exposes": {"classes": sorted(classes), "detect": sorted(detects)},
|
||||||
|
"tokens": sorted(set(bake.re.findall(r"var\(\s*(--[\w-]+)", css + js_text))),
|
||||||
|
"bytes": len(css.encode()) + len(js_text.encode()),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=f"{PROG} list")
|
||||||
|
p.add_argument("--json", action="store_true", help="Print the tables as JSON.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
exports, parts = exports_table(), parts_table()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"vue": exports, "plain": parts}, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"target = \"vue\" — common/ui/src ({len(exports)} exports)\n")
|
||||||
|
print(f" {'export':<22} {'file':<34} {'pulls in':<30} {'packages':<22} size")
|
||||||
|
for r in exports:
|
||||||
|
if r["kind"] == "type":
|
||||||
|
continue
|
||||||
|
pulls = ", ".join(d.rsplit("/", 1)[-1] for d in r["depends_on"]) or "-"
|
||||||
|
pkgs = ", ".join(r["packages"]) or "-"
|
||||||
|
print(f" {r['name']:<22} {r['file']:<34} {pulls:<30} {pkgs:<22} {kb(r['bytes'])}")
|
||||||
|
types = [r["name"] for r in exports if r["kind"] == "type"]
|
||||||
|
print(f"\n types ride with their file: {', '.join(types)}")
|
||||||
|
|
||||||
|
print(f"\ntarget = \"plain\" — common/theme/parts ({len(parts)} parts)\n")
|
||||||
|
print(f" {'part':<10} {'needs':<10} {'found by':<40} size")
|
||||||
|
for r in parts:
|
||||||
|
found = "always, with any other part" if r["always"] else \
|
||||||
|
", ".join(["." + c for c in sorted(r["exposes"]["classes"],
|
||||||
|
key=lambda c: (not c.startswith(r["name"]), c))[:3]]
|
||||||
|
+ r["exposes"]["detect"])
|
||||||
|
print(f" {r['name']:<10} {', '.join(r['depends_on']) or '-':<10} {found[:40]:<40} {kb(r['bytes'])}")
|
||||||
|
return 0
|
||||||
71
soleprint/common/slice/cli/plexus.py
Normal file
71
soleprint/common/slice/cli/plexus.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
python3 -m common.slice plexus every plexus -> gen/plexuses/<name>/
|
||||||
|
python3 -m common.slice plexus NAME [--dest PATH] one, by name
|
||||||
|
python3 -m common.slice plexus FILE [--only NAME] every [[plexus]] in a run file
|
||||||
|
… --delivery vendored --notice none|FILE renamed, scrubbed, notice stated
|
||||||
|
|
||||||
|
No instance to build first, nothing keyed on configuration elsewhere: a plexus
|
||||||
|
is its own directory, and this writes one self-contained file from it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import plexus as px
|
||||||
|
from .common import PROG, Abort, kb, load_runfile
|
||||||
|
|
||||||
|
EXTERNAL = re.compile(r"""(?:src|href)=["'](?!#|data:|javascript:)([^"']+)["']""")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=f"{PROG} plexus")
|
||||||
|
p.add_argument("target", nargs="?", help="A plexus name, or a run file. Omit for every plexus.")
|
||||||
|
p.add_argument("--only", action="append", default=[], metavar="NAME",
|
||||||
|
help="With a run file: just this plexus. Repeatable.")
|
||||||
|
p.add_argument("--dest", help="Where the .html goes (a file, or a directory for index.html).")
|
||||||
|
p.add_argument("--delivery", choices=("dependency", "vendored"), default="dependency")
|
||||||
|
p.add_argument("--notice", help='With --delivery vendored: "none", or a file to head the page.')
|
||||||
|
p.add_argument("--minify", action="store_true", help="Vue plexus: minify the inlined script.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.target and args.target.endswith(".toml"):
|
||||||
|
rf = load_runfile(Path(args.target))
|
||||||
|
chosen = px.from_runfile(rf.plexuses, rf.path.resolve().parent)
|
||||||
|
if args.only:
|
||||||
|
chosen = [x for x in chosen if x.name in set(args.only)]
|
||||||
|
if not chosen:
|
||||||
|
raise Abort(f"{args.target} lists no [[plexus]] to build")
|
||||||
|
else:
|
||||||
|
names = [args.target] if args.target else px.discover()
|
||||||
|
chosen = [px.resolve(n, dest=args.dest if args.target else None,
|
||||||
|
delivery=args.delivery, notice=args.notice,
|
||||||
|
minify=args.minify) for n in names]
|
||||||
|
except px.PlexusError as e:
|
||||||
|
raise Abort(str(e)) from None
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
for x in chosen:
|
||||||
|
kind = "vue" if x.is_vue else "template"
|
||||||
|
try:
|
||||||
|
built = px.build(x)
|
||||||
|
path = px.write(built)
|
||||||
|
except px.PlexusError as e:
|
||||||
|
failed += 1
|
||||||
|
print(f" FAIL {x.name:<12} {e}")
|
||||||
|
for line, term, text in (built.hits if "built" in locals() else [])[:10]:
|
||||||
|
print(f" line {line} {term!r} {text[:90]}")
|
||||||
|
continue
|
||||||
|
external = sorted(set(EXTERNAL.findall(built.html)))
|
||||||
|
print(f" ok {x.name:<12} {kind:<8} {x.delivery:<10} {kb(len(built.html.encode())):>9} -> {path}")
|
||||||
|
for note in built.notes:
|
||||||
|
print(f" {note}")
|
||||||
|
if external:
|
||||||
|
print(f" WARNING references outside the file: {', '.join(external[:5])}")
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"{failed} of {len(chosen)} plexus(es) failed")
|
||||||
|
return 1
|
||||||
|
print(f"{len(chosen)} plexus(es) built")
|
||||||
|
return 0
|
||||||
63
soleprint/common/slice/cli/reference.py
Normal file
63
soleprint/common/slice/cli/reference.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
python3 -m common.slice reference the ui-reference page of spr's /docs, from common/ui/src
|
||||||
|
python3 -m common.slice reference --check write nothing; exit 1 if stale, edited or unreconciled
|
||||||
|
python3 -m common.slice reference --spots every spot a note can go, one per line
|
||||||
|
python3 -m common.slice reference --scaffold an empty stub for every spot without a note
|
||||||
|
python3 -m common.slice reference --force overwrite a generated file that was edited by hand
|
||||||
|
|
||||||
|
The page is generated; the notes in common/ui/notes are not. See reference.py for
|
||||||
|
why the two are kept apart, and for the measure each build ends with. To read it,
|
||||||
|
`make docs` — the one command that launches spr's docs site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from .. import reference as docs
|
||||||
|
from .common import PROG, Abort
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=f"{PROG} reference")
|
||||||
|
p.add_argument("--check", action="store_true", help="Write nothing; exit 1 if anything is off.")
|
||||||
|
p.add_argument("--spots", action="store_true", help="List every spot a note can go.")
|
||||||
|
p.add_argument("--scaffold", action="store_true", help="Write a stub note for every empty spot.")
|
||||||
|
p.add_argument("--force", action="store_true", help="Overwrite a hand-edited generated file.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
rel = docs.SITE.relative_to(docs.graph.COMMON.parent.parent)
|
||||||
|
notes_rel = docs.NOTES.relative_to(docs.graph.COMMON.parent.parent)
|
||||||
|
|
||||||
|
if args.spots or args.scaffold:
|
||||||
|
inv = docs.inventory()
|
||||||
|
if args.spots:
|
||||||
|
have = docs.load_notes()
|
||||||
|
for spot in docs.spots(inv):
|
||||||
|
mark = "written" if have.get(spot) else "stub" if spot in have else "-"
|
||||||
|
print(f"{spot:<22} {mark:<8} {notes_rel}/{spot}.md")
|
||||||
|
if args.scaffold:
|
||||||
|
made = docs.scaffold(inv)
|
||||||
|
print(f"{len(made)} stub(s) written" + (f": {', '.join(made)}" if made else ""))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
res = docs.build(write=not args.check, force=args.force)
|
||||||
|
for name in res.edited:
|
||||||
|
print(f" EDITED {rel}/{name} — changed by hand since the last build"
|
||||||
|
+ ("; overwritten (--force)" if args.force and not args.check else "; left alone"))
|
||||||
|
for name in (res.written if not args.check else res.stale):
|
||||||
|
print(f" {'wrote ' if not args.check else 'STALE '} {rel}/{name}")
|
||||||
|
for name in res.unchanged:
|
||||||
|
print(f" ok {rel}/{name}")
|
||||||
|
for name in res.removed:
|
||||||
|
print(f" {'removed' if not args.check else 'GONE '} {rel}/{name} — no longer generated")
|
||||||
|
print()
|
||||||
|
for ok, msg in res.reconciled:
|
||||||
|
print(f" {'ok ' if ok else 'FAIL'} {msg}")
|
||||||
|
|
||||||
|
if res.edited and not args.force:
|
||||||
|
raise Abort(f"{len(res.edited)} generated file(s) edited by hand — move the text into "
|
||||||
|
f"{notes_rel}/, or rerun with --force to discard it")
|
||||||
|
if args.check and not res.ok:
|
||||||
|
raise Abort(f"the ui reference is not current — run: make reference (in common/ui)")
|
||||||
|
if not all(ok for ok, _ in res.reconciled):
|
||||||
|
raise Abort("the reference does not reconcile with what was read")
|
||||||
|
return 0
|
||||||
103
soleprint/common/slice/cli/scan.py
Normal file
103
soleprint/common/slice/cli/scan.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
python3 -m common.slice scan <app-dir> [<app-dir> …]
|
||||||
|
python3 -m common.slice scan <app-dir> --copy <framework-dir>
|
||||||
|
python3 -m common.slice scan <app-dir> --json
|
||||||
|
|
||||||
|
What an app uses of the framework, what a slice of it would hold, and — when the
|
||||||
|
app keeps a copy of the framework — how that copy compares with spr. Reads only;
|
||||||
|
nothing is written anywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import graph
|
||||||
|
from .. import scan as scanning
|
||||||
|
from .common import PROG, Abort
|
||||||
|
|
||||||
|
|
||||||
|
def report(app: Path, aliases, copy: Path | None) -> dict:
|
||||||
|
nodes = graph.load()
|
||||||
|
copy = copy or scanning.find_copy(app)
|
||||||
|
usage = scanning.usage([app], aliases, exclude=[copy] if copy else [], nodes=nodes)
|
||||||
|
exports = graph.barrel(nodes=nodes)
|
||||||
|
files = sorted(usage.files - graph.GENERATED - {"base.css"})
|
||||||
|
closure, unknown = graph.select(sorted(usage.names), files, exports, nodes)
|
||||||
|
out = {
|
||||||
|
"app": str(app),
|
||||||
|
"imports": {"barrel": usage.barrel, "deep": usage.deep},
|
||||||
|
"names": sorted(usage.names),
|
||||||
|
"files": sorted(usage.files),
|
||||||
|
"unknown": sorted(set(unknown) | usage.unknown),
|
||||||
|
"closure": sorted(closure),
|
||||||
|
"packages": graph.dependencies(closure, nodes),
|
||||||
|
"own_tokens": len(usage.tokens),
|
||||||
|
"copy": None,
|
||||||
|
}
|
||||||
|
if copy:
|
||||||
|
state = scanning.compare(copy)
|
||||||
|
out["copy"] = {
|
||||||
|
"path": str(copy),
|
||||||
|
"stamp": state.stamp,
|
||||||
|
"stamp_commit_exists": state.stamp_known,
|
||||||
|
"rows": [{"file": r, "status": s, "detail": d} for r, s, d in state.rows],
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def show(r: dict) -> None:
|
||||||
|
print(r["app"])
|
||||||
|
print(f" imports {r['imports']['deep']} by deep path, {r['imports']['barrel']} through the barrel")
|
||||||
|
if r["names"]:
|
||||||
|
print(f" names {', '.join(r['names'])}")
|
||||||
|
if r["files"]:
|
||||||
|
print(f" files {', '.join(r['files'])}")
|
||||||
|
if r["unknown"]:
|
||||||
|
print(f" UNKNOWN {', '.join(r['unknown'])} (spr has no such export or file)")
|
||||||
|
pkgs = ", ".join(f"{k} {v}" for k, v in r["packages"].items()) or "none"
|
||||||
|
print(f" a slice {len(r['closure'])} file(s): {', '.join(r['closure']) or '-'}")
|
||||||
|
print(f" packages: {pkgs}; {r['own_tokens']} custom properties read by the app itself")
|
||||||
|
c = r["copy"]
|
||||||
|
if not c:
|
||||||
|
print(" copy none found (no `link:` in package.json, no @framework alias)")
|
||||||
|
return
|
||||||
|
stamp = c["stamp"]
|
||||||
|
if stamp:
|
||||||
|
known = "exists in spr" if c["stamp_commit_exists"] else \
|
||||||
|
"NOT in spr's history — compared with current spr instead"
|
||||||
|
print(f" copy {c['path']} (.spr: {stamp.get('mode', '?')} at {stamp.get('sha', '?')}, {known})")
|
||||||
|
else:
|
||||||
|
print(f" copy {c['path']} (no .spr stamp)")
|
||||||
|
rows = [x for x in c["rows"] if x["status"] != "same"]
|
||||||
|
same = len(c["rows"]) - len(rows)
|
||||||
|
for x in rows:
|
||||||
|
print(f" {x['status']:<10} {x['file']:<32} {x['detail']}")
|
||||||
|
print(f" {same} file(s) identical to spr")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=f"{PROG} scan")
|
||||||
|
p.add_argument("apps", type=Path, nargs="+", help="App directories to read.")
|
||||||
|
p.add_argument("--alias", action="append", default=None,
|
||||||
|
help="An import specifier that means the framework "
|
||||||
|
"(default: soleprint-ui, @framework). Repeatable.")
|
||||||
|
p.add_argument("--copy", type=Path, default=None,
|
||||||
|
help="The app's framework copy, when it cannot be found automatically.")
|
||||||
|
p.add_argument("--json", action="store_true", help="Print the report as JSON.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
aliases = tuple(args.alias) if args.alias else scanning.DEFAULT_ALIASES
|
||||||
|
reports = []
|
||||||
|
for app in args.apps:
|
||||||
|
if not app.is_dir():
|
||||||
|
raise Abort(f"{app} is not a directory")
|
||||||
|
reports.append(report(app.resolve(), aliases, args.copy.resolve() if args.copy else None))
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(reports if len(reports) > 1 else reports[0], indent=2))
|
||||||
|
return 0
|
||||||
|
for i, r in enumerate(reports):
|
||||||
|
if i:
|
||||||
|
print()
|
||||||
|
show(r)
|
||||||
|
return 0
|
||||||
11
soleprint/common/slice/cli/selftest.py
Normal file
11
soleprint/common/slice/cli/selftest.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
""" python3 -m common.slice selftest — runs selftest.py in a fresh interpreter."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SOLEPRINT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command=None) -> int:
|
||||||
|
return subprocess.call([sys.executable, "-m", "common.slice.selftest", *argv], cwd=SOLEPRINT)
|
||||||
106
soleprint/common/slice/cli/slice.py
Normal file
106
soleprint/common/slice/cli/slice.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
python3 -m common.slice slice <run-file> [--only NAME] [--list] [--force]
|
||||||
|
python3 -m common.slice check <run-file> [--only NAME]
|
||||||
|
|
||||||
|
`slice` writes what is stale and leaves the rest alone; `check` says what
|
||||||
|
`slice` would do and exits 1 if that is anything at all. `--list` shows every
|
||||||
|
slice fully resolved — what it selects, what that pulls in, where it goes —
|
||||||
|
and writes nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import slices
|
||||||
|
from .common import PROG, Abort, kb, load_runfile
|
||||||
|
|
||||||
|
|
||||||
|
def describe(built) -> list[str]:
|
||||||
|
e = built.entry
|
||||||
|
lines = [f" {e.line()}"]
|
||||||
|
if built.selected:
|
||||||
|
lines.append(f" selected {', '.join(built.selected)}")
|
||||||
|
lines.append(f" closure {', '.join(built.closure) or '(nothing)'}")
|
||||||
|
if e.target == "vue":
|
||||||
|
pkgs = ", ".join(f"{k} {v}" for k, v in built.deps.items()) or "none"
|
||||||
|
lines.append(f" packages {pkgs}")
|
||||||
|
source = "library" if e.theme in (None, "library") else f"theme {e.theme}"
|
||||||
|
lines.append(f" tokens {len(built.carried)} carried, from {source}"
|
||||||
|
+ (f"; fell back to the library for {', '.join(built.fell_back)}"
|
||||||
|
if built.fell_back else ""))
|
||||||
|
if built.missing:
|
||||||
|
lines.append(f" WARNING read but defined nowhere: {', '.join(built.missing)}")
|
||||||
|
size = sum(len(t.encode()) for t in built.files.values())
|
||||||
|
lines.append(f" files {len(built.files)}, {kb(size)}")
|
||||||
|
for problem in built.blocked:
|
||||||
|
lines.append(f" BLOCKED {problem}")
|
||||||
|
for rel, line, term, text in built.hits[:20]:
|
||||||
|
lines.append(f" {rel}:{line} {term!r} {text[:80]}")
|
||||||
|
if len(built.hits) > 20:
|
||||||
|
lines.append(f" … and {len(built.hits) - 20} more")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command="slice") -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=f"{PROG} {command}")
|
||||||
|
p.add_argument("runfile", type=Path, help="The run file (see ui.example.toml).")
|
||||||
|
p.add_argument("--only", action="append", default=[], metavar="NAME",
|
||||||
|
help="Just this slice. Repeatable.")
|
||||||
|
if command == "slice":
|
||||||
|
p.add_argument("--list", action="store_true", help="Resolve and show; write nothing.")
|
||||||
|
p.add_argument("--check", action="store_true", help="Same as the `check` command.")
|
||||||
|
p.add_argument("--force", action="store_true",
|
||||||
|
help="Overwrite files edited by hand in dest. Never skips the scrub.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
checking = command == "check" or getattr(args, "check", False)
|
||||||
|
listing = getattr(args, "list", False)
|
||||||
|
|
||||||
|
rf = load_runfile(args.runfile)
|
||||||
|
try:
|
||||||
|
chosen = rf.select(args.only)
|
||||||
|
except ValueError as e:
|
||||||
|
raise Abort(str(e)) from None
|
||||||
|
|
||||||
|
mode = " (check: nothing written)" if checking else " (list: nothing written)" if listing else ""
|
||||||
|
print(f"{rf.path} — {len(chosen)} slice(s){mode}")
|
||||||
|
failed = 0
|
||||||
|
for entry in chosen:
|
||||||
|
built = slices.build(entry)
|
||||||
|
steps = slices.plan(entry, built)
|
||||||
|
print()
|
||||||
|
print("\n".join(describe(built)))
|
||||||
|
counts = {k: len(steps.of(k)) for k in ("create", "update", "same", "delete", "conflict")}
|
||||||
|
print(" plan " + ", ".join(f"{k} {v}" for k, v in counts.items()))
|
||||||
|
for rel, action, detail in steps.actions:
|
||||||
|
if action != "same" and (listing or checking or action == "conflict"):
|
||||||
|
print(f" {action:<9} {rel}" + (f" — {detail}" if detail else ""))
|
||||||
|
if built.blocked:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
if listing:
|
||||||
|
continue
|
||||||
|
if checking:
|
||||||
|
if steps.stale:
|
||||||
|
failed += 1
|
||||||
|
print(" STALE run `slice` to bring it up to date")
|
||||||
|
else:
|
||||||
|
print(" ok up to date")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
changed = slices.apply(entry, built, steps, args.force)
|
||||||
|
except ValueError as e:
|
||||||
|
failed += 1
|
||||||
|
print(f" REFUSED {e}")
|
||||||
|
continue
|
||||||
|
if changed:
|
||||||
|
for line in changed:
|
||||||
|
print(f" {line}")
|
||||||
|
else:
|
||||||
|
print(" ok up to date, nothing written")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"{failed} of {len(chosen)} slice(s) did not hold")
|
||||||
|
return 1
|
||||||
|
print(f"{len(chosen)} slice(s) " + ("current" if checking else "resolved" if listing else "done"))
|
||||||
|
return 0
|
||||||
147
soleprint/common/slice/css.py
Normal file
147
soleprint/common/slice/css.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Top-level CSS rules, and which of them a page can use.
|
||||||
|
|
||||||
|
A plexus used to inline the whole theme contract and every theme — 20 KB of
|
||||||
|
rules for a page that uses a fraction of them, including component styling for
|
||||||
|
screens it does not have. This cuts that down, conservatively:
|
||||||
|
|
||||||
|
custom properties only the names the page reads, plus what they reach
|
||||||
|
a rule kept unless the page provably cannot use it: every
|
||||||
|
class and id in one of its selectors has to appear
|
||||||
|
somewhere in the page — markup *or* script, since a
|
||||||
|
page that builds its DOM in JS names its classes there
|
||||||
|
element selectors kept (h2, button, *): cheap, and a page has elements
|
||||||
|
@keyframes kept when a kept rule or the page names it
|
||||||
|
@media / @supports kept when anything inside is
|
||||||
|
|
||||||
|
When unsure it keeps. A rule too many costs bytes; a rule too few is a page that
|
||||||
|
renders wrong, and nothing would say so.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||||
|
DECL = re.compile(r"(--[\w-]+)\s*:\s*([^;]+);")
|
||||||
|
VAR = re.compile(r"var\(\s*(--[\w-]+)")
|
||||||
|
THEME_ATTR = re.compile(r"""\[data-theme=["']?[\w-]+["']?\]""")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rule:
|
||||||
|
prelude: str # selector list, or an at-rule's head
|
||||||
|
body: str # what is between the braces
|
||||||
|
|
||||||
|
@property
|
||||||
|
def at(self) -> str:
|
||||||
|
return self.prelude.split()[0] if self.prelude.startswith("@") else ""
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return f"{self.prelude} {{{self.body}}}"
|
||||||
|
|
||||||
|
|
||||||
|
def rules(css: str) -> list[Rule]:
|
||||||
|
"""Top-level rules, comments removed. Nested blocks stay inside their body."""
|
||||||
|
css = COMMENT.sub("", css)
|
||||||
|
out, i, n = [], 0, len(css)
|
||||||
|
while i < n:
|
||||||
|
start = css.find("{", i)
|
||||||
|
if start < 0:
|
||||||
|
break
|
||||||
|
prelude = css[i:start].strip()
|
||||||
|
depth, j = 1, start + 1
|
||||||
|
while j < n and depth:
|
||||||
|
if css[j] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif css[j] == "}":
|
||||||
|
depth -= 1
|
||||||
|
j += 1
|
||||||
|
out.append(Rule(prelude=prelude, body=css[start + 1:j - 1]))
|
||||||
|
i = j
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def only_properties(rule: Rule) -> bool:
|
||||||
|
"""A block that sets custom properties and nothing else."""
|
||||||
|
body = rule.body.strip()
|
||||||
|
return bool(body) and not DECL.sub("", body).strip(" \n\t;")
|
||||||
|
|
||||||
|
|
||||||
|
def declarations(rule: Rule) -> dict[str, str]:
|
||||||
|
return {name: value.strip() for name, value in DECL.findall(rule.body)}
|
||||||
|
|
||||||
|
|
||||||
|
def _split_selectors(prelude: str) -> list[str]:
|
||||||
|
parts, depth, cur = [], 0, ""
|
||||||
|
for ch in prelude:
|
||||||
|
if ch in "([":
|
||||||
|
depth += 1
|
||||||
|
elif ch in ")]":
|
||||||
|
depth -= 1
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
parts.append(cur)
|
||||||
|
cur = ""
|
||||||
|
else:
|
||||||
|
cur += ch
|
||||||
|
parts.append(cur)
|
||||||
|
return [p.strip() for p in parts if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _names(selector: str) -> list[str]:
|
||||||
|
"""Classes and ids a selector requires, outside :not(…) and attribute brackets."""
|
||||||
|
s = THEME_ATTR.sub("", selector)
|
||||||
|
s = re.sub(r":not\([^)]*\)", "", s)
|
||||||
|
s = re.sub(r"\[[^\]]*\]", "", s)
|
||||||
|
return re.findall(r"[.#](-?[A-Za-z_][\w-]*)", s)
|
||||||
|
|
||||||
|
|
||||||
|
def matches(prelude: str, page: str) -> bool:
|
||||||
|
for selector in _split_selectors(prelude):
|
||||||
|
names = _names(selector)
|
||||||
|
if all(re.search(rf"(?<![\w-]){re.escape(n)}(?![\w-])", page) for n in names):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def keyframes_name(rule: Rule) -> str:
|
||||||
|
parts = rule.prelude.split()
|
||||||
|
return parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def shake(css: str, page: str) -> tuple[list[Rule], set]:
|
||||||
|
"""Rules of `css` the page can use, and the custom properties they read.
|
||||||
|
|
||||||
|
Custom-property-only blocks are not decided here — the caller cuts those
|
||||||
|
by name — so they are returned as they are.
|
||||||
|
"""
|
||||||
|
kept, reads = [], set()
|
||||||
|
candidates = rules(css)
|
||||||
|
keyframes = [r for r in candidates if r.at == "@keyframes"]
|
||||||
|
for r in candidates:
|
||||||
|
if r.at == "@keyframes":
|
||||||
|
continue
|
||||||
|
if r.at in ("@media", "@supports"):
|
||||||
|
inner, inner_reads = shake(r.body, page)
|
||||||
|
if inner:
|
||||||
|
kept.append(Rule(prelude=r.prelude, body="\n" + "\n".join(x.text() for x in inner) + "\n"))
|
||||||
|
reads |= inner_reads
|
||||||
|
continue
|
||||||
|
if r.at:
|
||||||
|
kept.append(r) # @font-face and friends: rare, and never guessed at
|
||||||
|
continue
|
||||||
|
if only_properties(r) or matches(r.prelude, page):
|
||||||
|
kept.append(r)
|
||||||
|
reads |= set(VAR.findall(r.body))
|
||||||
|
text = page + "\n".join(r.body for r in kept)
|
||||||
|
for k in keyframes:
|
||||||
|
name = keyframes_name(k)
|
||||||
|
if name and re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", text):
|
||||||
|
kept.append(k)
|
||||||
|
reads |= set(VAR.findall(k.body))
|
||||||
|
return kept, reads
|
||||||
|
|
||||||
|
|
||||||
|
def pretty(rule: Rule) -> str:
|
||||||
|
"""One rule, re-indented: readable, never minified."""
|
||||||
|
body = "\n".join(f" {line.strip()}" for line in rule.body.strip().splitlines() if line.strip())
|
||||||
|
return f"{rule.prelude} {{\n{body}\n}}"
|
||||||
201
soleprint/common/slice/graph.py
Normal file
201
soleprint/common/slice/graph.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
The import graph of common/ui/src, and the closure of what a selection needs.
|
||||||
|
|
||||||
|
Read out of the source with patterns, not a TypeScript parser: the library is
|
||||||
|
thirty-odd small files, every import is on a line of its own, and a parser would
|
||||||
|
be a node dependency for a question grep answers. The selftest pins the edges it
|
||||||
|
must find, so a pattern that stops matching fails loudly.
|
||||||
|
|
||||||
|
Three kinds of edge matter, and all three are followed:
|
||||||
|
|
||||||
|
import { x } from './a' code, including `import type` — a type-only
|
||||||
|
import still has to resolve for vue-tsc
|
||||||
|
import './theme.css' side effects: stylesheets
|
||||||
|
export { default as X } from './b' the barrel's own re-exports
|
||||||
|
|
||||||
|
A bare specifier (`vue`, `uplot/dist/uPlot.min.css`) is a package, not a file.
|
||||||
|
It becomes a dependency of the slice, with the version common/ui/package.json
|
||||||
|
pins for it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
COMMON = HERE.parent
|
||||||
|
SRC = COMMON / "ui" / "src"
|
||||||
|
PACKAGE_JSON = COMMON / "ui" / "package.json"
|
||||||
|
|
||||||
|
# The files a slice regenerates rather than copies. The barrel is rebuilt to
|
||||||
|
# export only the selection; the token file is cut down to what is read.
|
||||||
|
GENERATED = {"index.ts", "theme.css", "tokens.css"}
|
||||||
|
|
||||||
|
# Source that never belongs in a delivery.
|
||||||
|
SKIP = {"__tests__"}
|
||||||
|
|
||||||
|
_STRING = r"""['"]([^'"]+)['"]"""
|
||||||
|
IMPORT_FROM = re.compile(r"(?:^|\n)\s*import\s+(?:type\s+)?[^'\";]*?\s*from\s*" + _STRING, re.S)
|
||||||
|
IMPORT_BARE = re.compile(r"(?:^|\n)\s*import\s*" + _STRING)
|
||||||
|
EXPORT_FROM = re.compile(
|
||||||
|
r"(?:^|\n)\s*export\s+(type\s+)?\{([^}]*)\}\s*from\s*" + _STRING, re.S)
|
||||||
|
DYNAMIC = re.compile(r"import\(\s*" + _STRING + r"\s*\)")
|
||||||
|
CSS_IMPORT = re.compile(r"@import\s+(?:url\()?\s*" + _STRING)
|
||||||
|
VAR = re.compile(r"var\(\s*(--[\w-]+)")
|
||||||
|
|
||||||
|
SCRIPT = re.compile(r"<script\b[^>]*>(.*?)</script>", re.S)
|
||||||
|
STYLE = re.compile(r"<style\b[^>]*>(.*?)</style>", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Node:
|
||||||
|
"""One file of the library."""
|
||||||
|
|
||||||
|
path: str # relative to SRC, posix
|
||||||
|
imports: set = field(default_factory=set) # other nodes, relative to SRC
|
||||||
|
packages: set = field(default_factory=set) # bare specifiers, as written
|
||||||
|
tokens: set = field(default_factory=set) # custom properties it reads
|
||||||
|
size: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Export:
|
||||||
|
"""One name the barrel exports."""
|
||||||
|
|
||||||
|
name: str # what a consumer imports
|
||||||
|
local: str # what the file exports it as (`default` for SFCs)
|
||||||
|
file: str # relative to SRC
|
||||||
|
is_type: bool
|
||||||
|
|
||||||
|
|
||||||
|
def code_of(path: Path, text: str) -> tuple[str, str]:
|
||||||
|
"""(script, style) text of a file. A .vue file is split into its blocks."""
|
||||||
|
if path.suffix == ".vue":
|
||||||
|
return ("\n".join(SCRIPT.findall(text)), "\n".join(STYLE.findall(text)))
|
||||||
|
if path.suffix == ".css":
|
||||||
|
return "", text
|
||||||
|
return text, ""
|
||||||
|
|
||||||
|
|
||||||
|
def package_of(specifier: str) -> str:
|
||||||
|
"""`@vue-flow/core/dist/style.css` -> `@vue-flow/core`; `uplot` -> `uplot`."""
|
||||||
|
parts = specifier.split("/")
|
||||||
|
return "/".join(parts[:2]) if specifier.startswith("@") else parts[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(importer: str, specifier: str, known: set) -> str | None:
|
||||||
|
"""A relative specifier -> the SRC-relative file it names, or None."""
|
||||||
|
base = (Path(importer).parent / specifier).as_posix()
|
||||||
|
base = str(Path(base)) # normalise ./ and ../ lexically
|
||||||
|
parts = []
|
||||||
|
for part in base.split("/"):
|
||||||
|
if part == "..":
|
||||||
|
if parts:
|
||||||
|
parts.pop()
|
||||||
|
elif part not in ("", "."):
|
||||||
|
parts.append(part)
|
||||||
|
base = "/".join(parts)
|
||||||
|
for candidate in (base, base + ".ts", base + ".vue", base + "/index.ts"):
|
||||||
|
if candidate in known:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load(src: Path = SRC) -> dict[str, Node]:
|
||||||
|
"""Every file under src, with its edges."""
|
||||||
|
files = {
|
||||||
|
p.relative_to(src).as_posix(): p
|
||||||
|
for p in sorted(src.rglob("*"))
|
||||||
|
if p.is_file() and p.suffix in (".ts", ".vue", ".css")
|
||||||
|
and not SKIP & set(p.relative_to(src).parts)
|
||||||
|
}
|
||||||
|
known = set(files)
|
||||||
|
nodes = {}
|
||||||
|
for rel, path in files.items():
|
||||||
|
text = path.read_text()
|
||||||
|
script, style = code_of(path, text)
|
||||||
|
node = Node(path=rel, size=len(text.encode()))
|
||||||
|
specifiers = (IMPORT_FROM.findall(script) + IMPORT_BARE.findall(script)
|
||||||
|
+ [m[2] for m in EXPORT_FROM.findall(script)]
|
||||||
|
+ DYNAMIC.findall(script) + CSS_IMPORT.findall(style))
|
||||||
|
for spec in specifiers:
|
||||||
|
if spec.startswith("."):
|
||||||
|
target = _resolve(rel, spec, known)
|
||||||
|
if target:
|
||||||
|
node.imports.add(target)
|
||||||
|
else:
|
||||||
|
node.packages.add(spec)
|
||||||
|
if path.suffix == ".vue":
|
||||||
|
# An SFC compiles to imports from `vue` even when its script names
|
||||||
|
# none (Panel.vue uses only compiler macros), so it always needs vue.
|
||||||
|
node.packages.add("vue")
|
||||||
|
node.tokens = set(VAR.findall(style if path.suffix == ".vue" else text))
|
||||||
|
nodes[rel] = node
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def barrel(src: Path = SRC, nodes: dict | None = None) -> list[Export]:
|
||||||
|
"""What index.ts exports, name by name, in its own order."""
|
||||||
|
nodes = nodes if nodes is not None else load(src)
|
||||||
|
text = (src / "index.ts").read_text()
|
||||||
|
out = []
|
||||||
|
for block_type, names, spec in EXPORT_FROM.findall(text):
|
||||||
|
target = _resolve("index.ts", spec, set(nodes))
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
for item in names.split(","):
|
||||||
|
item = item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
item_type = item.startswith("type ")
|
||||||
|
item = item[5:].strip() if item_type else item
|
||||||
|
local, _, alias = item.partition(" as ")
|
||||||
|
local, alias = local.strip(), alias.strip()
|
||||||
|
out.append(Export(name=alias or local, local=local, file=target,
|
||||||
|
is_type=bool(block_type) or item_type))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def closure(start: set, nodes: dict[str, Node]) -> set:
|
||||||
|
"""Every file reachable from `start` through relative imports."""
|
||||||
|
seen, stack = set(), list(start)
|
||||||
|
while stack:
|
||||||
|
rel = stack.pop()
|
||||||
|
if rel in seen or rel not in nodes:
|
||||||
|
continue
|
||||||
|
seen.add(rel)
|
||||||
|
stack.extend(nodes[rel].imports - seen)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def select(names: list[str], files: list[str], exports: list[Export],
|
||||||
|
nodes: dict[str, Node]) -> tuple[set, list[str]]:
|
||||||
|
"""Export names and/or SRC-relative files -> (closure, unknown names)."""
|
||||||
|
by_name = {e.name: e for e in exports}
|
||||||
|
start, unknown = set(files), []
|
||||||
|
for name in names:
|
||||||
|
if name in by_name:
|
||||||
|
start.add(by_name[name].file)
|
||||||
|
elif name in nodes:
|
||||||
|
start.add(name)
|
||||||
|
else:
|
||||||
|
unknown.append(name)
|
||||||
|
unknown += [f for f in files if f not in nodes]
|
||||||
|
# The library's own theme files are regenerated, never selected into a copy.
|
||||||
|
return closure(start - GENERATED, nodes) - GENERATED, unknown
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies(files: set, nodes: dict[str, Node],
|
||||||
|
package_json: Path = PACKAGE_JSON) -> dict[str, str]:
|
||||||
|
"""The packages a set of files imports, with the version common/ui pins."""
|
||||||
|
pins = {}
|
||||||
|
if package_json.exists():
|
||||||
|
data = json.loads(package_json.read_text())
|
||||||
|
pins = {**data.get("devDependencies", {}), **data.get("dependencies", {})}
|
||||||
|
wanted = sorted({package_of(s) for f in files for s in nodes[f].packages})
|
||||||
|
return {name: pins.get(name, "*") for name in wanted}
|
||||||
|
|
||||||
|
|
||||||
|
def tokens_read(files: set, nodes: dict[str, Node]) -> set:
|
||||||
|
return {t for f in files for t in nodes[f].tokens}
|
||||||
97
soleprint/common/slice/manifest.py
Normal file
97
soleprint/common/slice/manifest.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
The manifest a slice leaves beside its run file, and the `.spr` stamp.
|
||||||
|
|
||||||
|
The manifest is what makes a copy checkable. It records, for every file written,
|
||||||
|
the hash of what was written — so the next run can tell the two kinds of drift
|
||||||
|
apart:
|
||||||
|
|
||||||
|
source moved on the file in dest still matches the manifest, but spr
|
||||||
|
would now write something else: stale, rewrite it.
|
||||||
|
edited by hand the file in dest no longer matches the manifest: someone
|
||||||
|
changed the copy. Refused unless --force, and named, so a
|
||||||
|
real improvement can be brought back to spr instead of
|
||||||
|
being overwritten.
|
||||||
|
|
||||||
|
A first run with files already in dest has no manifest to trust, so any file
|
||||||
|
that differs from what would be written counts as edited. That is the case of
|
||||||
|
adopting an existing copy — mts's framework copy was ahead of spr, and a tool
|
||||||
|
that overwrote it would have destroyed the only copy of that work.
|
||||||
|
|
||||||
|
It carries no timestamp. The same inputs write the same bytes, so a check can
|
||||||
|
compare files rather than dates. The `.spr` stamp is the exception on purpose:
|
||||||
|
it keeps ctrl/spr.py's format (with `updated=`), and it is written only when
|
||||||
|
the slice actually changed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .graph import COMMON
|
||||||
|
|
||||||
|
REPO = COMMON.parent.parent # the spr checkout
|
||||||
|
STAMP = ".spr"
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def file_sha(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args) -> str:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["git", "-C", str(REPO), *args], capture_output=True, text=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
return out.stdout.strip() if out.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def source_state(paths) -> dict:
|
||||||
|
"""The spr commit a slice was cut from, and whether its inputs were dirty."""
|
||||||
|
rel = [str(Path(p).resolve().relative_to(REPO)) for p in paths if Path(p).exists()]
|
||||||
|
dirty = bool(git("status", "--porcelain", "--", *rel)) if rel else False
|
||||||
|
return {"commit": git("rev-parse", "HEAD") or "unknown", "dirty": dirty}
|
||||||
|
|
||||||
|
|
||||||
|
def commit_exists(sha: str) -> bool:
|
||||||
|
return bool(sha) and bool(git("cat-file", "-t", f"{sha}^{{commit}}"))
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: Path) -> dict | None:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def stamp(name: str, kind: str, version: str, source: Path) -> str:
|
||||||
|
"""ctrl/spr.py's stamp, so a dependency slice says where it came from."""
|
||||||
|
sha = git("rev-parse", "--short", "HEAD") or "unknown"
|
||||||
|
return (f"name={name}\nversion={version}\ntype={kind}\nsha={sha}\nmode=slice\n"
|
||||||
|
f"updated={datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}\n"
|
||||||
|
f"source={source}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def read_stamp(directory: Path) -> dict:
|
||||||
|
path = directory / STAMP
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
out = {}
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if sep:
|
||||||
|
out[key.strip()] = value.strip()
|
||||||
|
return out
|
||||||
127
soleprint/common/slice/names.py
Normal file
127
soleprint/common/slice/names.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
The mapping profile a vendored slice is written through, and the scrub after it.
|
||||||
|
|
||||||
|
Three passes, in this order, and the order matters:
|
||||||
|
|
||||||
|
1. **Identifiers**, everywhere — code and comments. `SprFeed` becomes whatever
|
||||||
|
the profile says in the page that calls it and in the file that defines it,
|
||||||
|
or the rename breaks the code. This goes first so that a longer identifier
|
||||||
|
(`soleprint-ui`) is replaced whole before a prose term inside it is seen.
|
||||||
|
2. **Prose terms**, only inside comments, case kept (vein -> connector, Vein ->
|
||||||
|
Connector). Never in code: a variable that happens to be called `vein` is
|
||||||
|
an identifier, and renaming it silently is how a working slice breaks.
|
||||||
|
3. **Scrub**: every `deny` term, case-insensitive, whole words only — so
|
||||||
|
`sprint` passes and `spr` fails — plus any camelCase identifier that still
|
||||||
|
starts with `spr`. A hit fails the slice; nothing is written.
|
||||||
|
|
||||||
|
The profile is names/corp.json, generic by design, extended by a gitignored
|
||||||
|
names.local.json beside it and by a run file's own `rename`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
PROFILE = HERE / "names" / "corp.json"
|
||||||
|
LOCAL = HERE / "names" / "names.local.json"
|
||||||
|
|
||||||
|
WORD = r"[A-Za-z0-9_$]"
|
||||||
|
# A camelCase identifier that still carries spr's prefix: sprThing, SprThing.
|
||||||
|
# Lower case after `spr` is an English word (sprint, spread), not a leak.
|
||||||
|
SPR_PREFIX = re.compile(r"(?<![A-Za-z0-9_$])[Ss]pr[A-Z]\w*")
|
||||||
|
|
||||||
|
COMMENTS = re.compile(
|
||||||
|
r"/\*.*?\*/" # block comments: CSS, JS, TS, .vue script/style
|
||||||
|
r"|<!--.*?-->" # HTML and .vue template comments
|
||||||
|
r"|(?<![:\w'\"/\\])//[^\n]*", # line comments, not the // inside a URL
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Profile:
|
||||||
|
identifiers: dict = field(default_factory=dict)
|
||||||
|
prose: dict = field(default_factory=dict)
|
||||||
|
deny: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def reverse(self) -> dict:
|
||||||
|
"""Renamed -> original, to read a vendored page back in spr's words."""
|
||||||
|
return {v: k for k, v in self.identifiers.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def load(renames: dict | None = None) -> Profile:
|
||||||
|
"""corp.json, then names.local.json, then the run file's own renames."""
|
||||||
|
data = json.loads(PROFILE.read_text())
|
||||||
|
profile = Profile(dict(data.get("identifiers", {})), dict(data.get("prose", {})),
|
||||||
|
list(data.get("deny", [])))
|
||||||
|
if LOCAL.exists():
|
||||||
|
local = json.loads(LOCAL.read_text())
|
||||||
|
profile.identifiers.update(local.get("identifiers", {}))
|
||||||
|
profile.prose.update(local.get("prose", {}))
|
||||||
|
profile.deny += [d for d in local.get("deny", []) if d not in profile.deny]
|
||||||
|
profile.identifiers.update(renames or {})
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded(term: str) -> re.Pattern:
|
||||||
|
edge = r"[A-Za-z0-9_$-]" if "-" in term else WORD
|
||||||
|
return re.compile(rf"(?<!{edge}){re.escape(term)}(?!{edge})")
|
||||||
|
|
||||||
|
|
||||||
|
def _cased(match: str, replacement: str) -> str:
|
||||||
|
if match.isupper() and len(match) > 1:
|
||||||
|
return replacement.upper()
|
||||||
|
if match[:1].isupper():
|
||||||
|
return replacement[:1].upper() + replacement[1:]
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
|
||||||
|
def rename(text: str, profile: Profile) -> str:
|
||||||
|
# Longest first, so `soleprint-ui` goes before any `soleprint` could.
|
||||||
|
for old in sorted(profile.identifiers, key=len, reverse=True):
|
||||||
|
text = _bounded(old).sub(profile.identifiers[old], text)
|
||||||
|
|
||||||
|
prose = sorted(profile.prose, key=len, reverse=True)
|
||||||
|
patterns = [(re.compile(rf"(?<!{WORD}){re.escape(t)}(?!{WORD})", re.I), profile.prose[t])
|
||||||
|
for t in prose]
|
||||||
|
|
||||||
|
def in_comment(m: re.Match) -> str:
|
||||||
|
chunk = m.group(0)
|
||||||
|
for pattern, replacement in patterns:
|
||||||
|
chunk = pattern.sub(lambda w: _cased(w.group(0), replacement), chunk)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
return COMMENTS.sub(in_comment, text)
|
||||||
|
|
||||||
|
|
||||||
|
def scrub(text: str, profile: Profile) -> list[tuple[int, str, str]]:
|
||||||
|
"""Every surviving term: (line number, term, the line)."""
|
||||||
|
hits = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
patterns = [(t, re.compile(rf"(?<![A-Za-z0-9_$]){re.escape(t)}(?![A-Za-z0-9_$])", re.I))
|
||||||
|
for t in profile.deny]
|
||||||
|
for n, line in enumerate(lines, 1):
|
||||||
|
for term, pattern in patterns:
|
||||||
|
if pattern.search(line):
|
||||||
|
hits.append((n, term, line.strip()))
|
||||||
|
for m in SPR_PREFIX.finditer(line):
|
||||||
|
hits.append((n, m.group(0), line.strip()))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def strip_header(text: str, suffix: str) -> str:
|
||||||
|
"""The file without its leading comment — the provenance a vendored copy drops.
|
||||||
|
|
||||||
|
Only the first comment, and only when it is the first thing in the file:
|
||||||
|
documentation further down is the code's, and stays (through `rename`).
|
||||||
|
"""
|
||||||
|
if suffix in (".css", ".ts", ".js"):
|
||||||
|
stripped = re.sub(r"\A\s*/\*.*?\*/[ \t]*\n?", "", text, count=1, flags=re.S)
|
||||||
|
if stripped == text:
|
||||||
|
stripped = re.sub(r"\A(?:\s*//[^\n]*\n)+", "", text, count=1)
|
||||||
|
return stripped.lstrip("\n")
|
||||||
|
if suffix in (".vue", ".html"):
|
||||||
|
return re.sub(r"\A\s*<!--.*?-->[ \t]*\n?", "", text, count=1, flags=re.S)
|
||||||
|
return text
|
||||||
25
soleprint/common/slice/names/corp.json
Normal file
25
soleprint/common/slice/names/corp.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"_comment": "The mapping profile a vendored slice is written through. Generic on purpose: nothing here names a client. Identifiers are renamed everywhere (code and comments), prose terms only inside comments, and after both the scrub must find none of `deny`. Extend or override per machine in names.local.json beside this file (gitignored), and per slice with the run file's `rename`. When spr's naming index lands, it becomes the source of these tables.",
|
||||||
|
"identifiers": {
|
||||||
|
"SprFeed": "LiveFeed",
|
||||||
|
"sprParams": "renderParams",
|
||||||
|
"soleprint-ui": "ui-kit"
|
||||||
|
},
|
||||||
|
"prose": {
|
||||||
|
"veins": "connectors",
|
||||||
|
"vein": "connector",
|
||||||
|
"shunts": "simulators",
|
||||||
|
"shunt": "simulator",
|
||||||
|
"cabinets": "backing services",
|
||||||
|
"cabinet": "backing service",
|
||||||
|
"plexuses": "single-file builds",
|
||||||
|
"plexus": "single-file build",
|
||||||
|
"soleprint": "the toolkit"
|
||||||
|
},
|
||||||
|
"deny": [
|
||||||
|
"soleprint", "spr", "vein", "veins", "shunt", "shunts", "plexus", "plexuses",
|
||||||
|
"larder", "artery", "mcrn", "mpr", "mts", "meetus", "doocus", "unt", "nvi", "amar",
|
||||||
|
"common/theme", "common/ui", "common/slice", "bake.py", "theme:here", "theme:parts",
|
||||||
|
"theme:baked-defaults"
|
||||||
|
]
|
||||||
|
}
|
||||||
414
soleprint/common/slice/plexus.py
Normal file
414
soleprint/common/slice/plexus.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"""
|
||||||
|
A plexus: one page that carries everything it needs, built with nothing behind it.
|
||||||
|
|
||||||
|
make dist every plexus under artery/plexuses -> gen/plexuses/
|
||||||
|
make dist bundle one, by name
|
||||||
|
make dist path/to/ui.toml every [[plexus]] a run file lists
|
||||||
|
|
||||||
|
A plexus is exported, not served. The output is one index.html with its theme,
|
||||||
|
its data and its diagram inlined, so it survives a locked-down machine, a zip
|
||||||
|
attachment and a double-click. It is the output-only / demo-only form of the UI:
|
||||||
|
the rendered page ships, the source that made it never does.
|
||||||
|
|
||||||
|
## What changed when it stopped belonging to a build
|
||||||
|
|
||||||
|
It used to be one step of building a whole instance, keyed on which plexuses an
|
||||||
|
instance's config asked for, and it inlined the entire theme contract and every
|
||||||
|
theme — 20 KB of rules for a page that reads a fraction of them. Now:
|
||||||
|
|
||||||
|
- it is built on its own, from its directory or from a run file;
|
||||||
|
- the theme it carries is cut to what the page reads (see css.py): the custom
|
||||||
|
properties it uses and what they reach, per theme it can switch to, and the
|
||||||
|
rules that can match something on the page;
|
||||||
|
- parts arrive when the template carries `<!-- theme:here -->`, as in bake;
|
||||||
|
- `delivery = "vendored"` renames through the mapping profile and scrubs the
|
||||||
|
result, exactly as a vendored slice is — and needs a `notice`, for the same
|
||||||
|
reason.
|
||||||
|
|
||||||
|
## Two kinds
|
||||||
|
|
||||||
|
A template plexus (`app/index.html` with `%%…%%` placeholders) is filled here.
|
||||||
|
A Vue plexus (`app/package.json`) is an app built on a slice: its `@ui` imports
|
||||||
|
are sliced from common/ui, `vite build` runs in a temporary directory, and the
|
||||||
|
emitted script and stylesheet are inlined into one file — no bundler plugin,
|
||||||
|
because the inlining is a few lines of text work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..theme import bake
|
||||||
|
from . import css, names
|
||||||
|
from .graph import COMMON
|
||||||
|
from .manifest import REPO
|
||||||
|
|
||||||
|
PLEXUSES = COMMON.parent / "artery" / "plexuses"
|
||||||
|
THEME = COMMON / "theme"
|
||||||
|
GRAPHS = REPO / "docs" / "graphs"
|
||||||
|
GEN = REPO / "gen" / "plexuses"
|
||||||
|
|
||||||
|
KEYS = {"name", "source", "dest", "theme", "inline_themes", "delivery", "notice",
|
||||||
|
"rename", "title", "description", "minify"}
|
||||||
|
|
||||||
|
|
||||||
|
class PlexusError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Plexus:
|
||||||
|
name: str
|
||||||
|
source: Path
|
||||||
|
dest: Path # the .html written
|
||||||
|
delivery: str = "dependency"
|
||||||
|
notice: str | Path | None = None
|
||||||
|
rename: dict = field(default_factory=dict)
|
||||||
|
overrides: dict = field(default_factory=dict) # manifest keys a run file replaces
|
||||||
|
minify: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vendored(self) -> bool:
|
||||||
|
return self.delivery == "vendored"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_vue(self) -> bool:
|
||||||
|
return (self.source / "app" / "package.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Built:
|
||||||
|
plexus: Plexus
|
||||||
|
html: str = ""
|
||||||
|
hits: list = field(default_factory=list)
|
||||||
|
notes: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def discover() -> list[str]:
|
||||||
|
return sorted(p.parent.name for p in PLEXUSES.glob("*/plexus.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(name: str, *, source=None, dest=None, delivery="dependency", notice=None,
|
||||||
|
rename=None, overrides=None, minify=False, base: Path | None = None) -> Plexus:
|
||||||
|
"""One plexus, every path absolute. Raises PlexusError saying what is wrong."""
|
||||||
|
base = base or Path.cwd()
|
||||||
|
src = Path(source) if source else PLEXUSES / name
|
||||||
|
src = src if src.is_absolute() else (base / src).resolve()
|
||||||
|
if not (src / "plexus.json").exists():
|
||||||
|
raise PlexusError(f"no plexus at {src} (have: {', '.join(discover()) or 'none'})")
|
||||||
|
if dest:
|
||||||
|
out = Path(dest) if Path(dest).is_absolute() else (base / dest).resolve()
|
||||||
|
if out.suffix != ".html":
|
||||||
|
out = out / "index.html"
|
||||||
|
else:
|
||||||
|
out = GEN / name / "index.html"
|
||||||
|
if delivery not in ("dependency", "vendored"):
|
||||||
|
raise PlexusError(f"{name}: delivery must be dependency or vendored")
|
||||||
|
if delivery == "vendored":
|
||||||
|
if notice is None:
|
||||||
|
raise PlexusError(f"{name}: a vendored plexus needs `notice` — \"none\", or a file "
|
||||||
|
f"whose text heads the page. The tool never picks the licensing.")
|
||||||
|
if notice != "none":
|
||||||
|
notice = Path(notice) if Path(notice).is_absolute() else (base / notice).resolve()
|
||||||
|
if not notice.is_file():
|
||||||
|
raise PlexusError(f"{name}: notice {notice} is not a file")
|
||||||
|
return Plexus(name=name, source=src, dest=out, delivery=delivery, notice=notice,
|
||||||
|
rename=dict(rename or {}), overrides=dict(overrides or {}), minify=minify)
|
||||||
|
|
||||||
|
|
||||||
|
def from_runfile(tables: list, base: Path) -> list[Plexus]:
|
||||||
|
"""The raw [[plexus]] tables of a run file, resolved; every problem at once."""
|
||||||
|
out, problems = [], []
|
||||||
|
for i, raw in enumerate(tables):
|
||||||
|
where = f"plexus[{i}]"
|
||||||
|
if not isinstance(raw, dict) or not isinstance(raw.get("name"), str):
|
||||||
|
problems.append(f"{where} needs a name")
|
||||||
|
continue
|
||||||
|
where = f"plexus {raw['name']!r}"
|
||||||
|
unknown = sorted(set(raw) - KEYS)
|
||||||
|
if unknown:
|
||||||
|
problems.append(f"{where} has unknown key(s) {', '.join(unknown)} — have: "
|
||||||
|
f"{', '.join(sorted(KEYS))}")
|
||||||
|
continue
|
||||||
|
overrides = {k: raw[k] for k in ("theme", "inline_themes", "title", "description") if k in raw}
|
||||||
|
try:
|
||||||
|
out.append(resolve(raw["name"], source=raw.get("source"), dest=raw.get("dest"),
|
||||||
|
delivery=raw.get("delivery", "dependency"), notice=raw.get("notice"),
|
||||||
|
rename=raw.get("rename"), overrides=overrides,
|
||||||
|
minify=bool(raw.get("minify", False)), base=base))
|
||||||
|
except PlexusError as e:
|
||||||
|
problems.append(str(e))
|
||||||
|
if problems:
|
||||||
|
raise PlexusError("; ".join(problems))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(p: Plexus) -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads((p.source / "plexus.json").read_text())
|
||||||
|
except ValueError as e:
|
||||||
|
raise PlexusError(f"{p.name}: plexus.json is not valid JSON: {e}") from None
|
||||||
|
data.update(p.overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _showcase(p: Plexus) -> dict | None:
|
||||||
|
"""A plexus may ship showcase.py exposing collect(): what the page shows,
|
||||||
|
produced at build time by the real tools, so it cannot drift from them."""
|
||||||
|
collector = p.source / "showcase.py"
|
||||||
|
if not collector.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(f"plexus_{p.name}_showcase", collector)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module.collect()
|
||||||
|
except Exception as e: # noqa: BLE001 - a demo panel must not cost the build
|
||||||
|
return {"_error": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(name: str, theme: str) -> str:
|
||||||
|
"""A rendered graph, inlined so the page's CSS can recolour it per theme."""
|
||||||
|
for candidate in (GRAPHS / f"{name}.{theme}.svg", GRAPHS / f"{name}.svg"):
|
||||||
|
if candidate.exists():
|
||||||
|
svg = candidate.read_text()
|
||||||
|
start = svg.find("<svg")
|
||||||
|
return svg[start:] if start >= 0 else svg
|
||||||
|
return "<p>diagram not rendered — run docs/graphs/render.sh</p>"
|
||||||
|
|
||||||
|
|
||||||
|
def theme_css(page: str, themes: list[str]) -> str:
|
||||||
|
"""The contract and the listed themes, cut to what `page` can use."""
|
||||||
|
missing = [t for t in themes if not (THEME / "themes" / f"{t}.css").exists()]
|
||||||
|
if missing:
|
||||||
|
raise PlexusError(f"no theme {', '.join(map(repr, missing))}")
|
||||||
|
contract = (THEME / "tokens.css").read_text()
|
||||||
|
sheets = {t: (THEME / "themes" / f"{t}.css").read_text() for t in themes}
|
||||||
|
|
||||||
|
kept_contract, reads = css.shake(contract, page)
|
||||||
|
kept_themes = {}
|
||||||
|
for t, text in sheets.items():
|
||||||
|
kept_themes[t], more = css.shake(text, page)
|
||||||
|
reads |= more
|
||||||
|
reads |= set(css.VAR.findall(page))
|
||||||
|
|
||||||
|
# Every declaration that could answer a name, from the contract and from
|
||||||
|
# each theme's own block — the closure runs over all of them.
|
||||||
|
root = {}
|
||||||
|
for r in kept_contract:
|
||||||
|
if r.prelude == ":root" and css.only_properties(r):
|
||||||
|
root.update(css.declarations(r))
|
||||||
|
per_theme = {}
|
||||||
|
for t, kept in kept_themes.items():
|
||||||
|
per_theme[t] = {}
|
||||||
|
for r in kept:
|
||||||
|
if r.prelude.strip() == f'[data-theme="{t}"]' and css.only_properties(r):
|
||||||
|
per_theme[t].update(css.declarations(r))
|
||||||
|
everything = {**root, **{k: v for d in per_theme.values() for k, v in d.items()}}
|
||||||
|
wanted, stack = set(), list(reads)
|
||||||
|
while stack:
|
||||||
|
n = stack.pop()
|
||||||
|
if n in wanted:
|
||||||
|
continue
|
||||||
|
wanted.add(n)
|
||||||
|
if n in everything:
|
||||||
|
stack.extend(css.VAR.findall(everything[n]))
|
||||||
|
|
||||||
|
out = [":root {"] + [f" {k}: {v};" for k, v in root.items() if k in wanted] + ["}"]
|
||||||
|
for t, decls in per_theme.items():
|
||||||
|
chosen = [f" {k}: {v};" for k, v in decls.items() if k in wanted]
|
||||||
|
if chosen:
|
||||||
|
out += [f'[data-theme="{t}"] {{'] + chosen + ["}"]
|
||||||
|
for r in kept_contract:
|
||||||
|
if not css.only_properties(r):
|
||||||
|
out.append(css.pretty(r))
|
||||||
|
for t, kept in kept_themes.items():
|
||||||
|
for r in kept:
|
||||||
|
if not (r.prelude.strip() == f'[data-theme="{t}"]' and css.only_properties(r)):
|
||||||
|
out.append(css.pretty(r))
|
||||||
|
return "\n".join(out) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(built: Built) -> None:
|
||||||
|
p = built.plexus
|
||||||
|
if not p.vendored:
|
||||||
|
return
|
||||||
|
profile = names.load(p.rename)
|
||||||
|
built.html = names.rename(built.html, profile)
|
||||||
|
if isinstance(p.notice, Path):
|
||||||
|
built.html = f"<!--\n{p.notice.read_text().rstrip()}\n-->\n" + built.html
|
||||||
|
built.hits = names.scrub(built.html, profile)
|
||||||
|
|
||||||
|
|
||||||
|
def build(p: Plexus) -> Built:
|
||||||
|
return build_vue(p) if p.is_vue else build_template(p)
|
||||||
|
|
||||||
|
|
||||||
|
def build_template(p: Plexus) -> Built:
|
||||||
|
built = Built(plexus=p)
|
||||||
|
manifest = _manifest(p)
|
||||||
|
template = p.source / "app" / "index.html"
|
||||||
|
if not template.exists():
|
||||||
|
raise PlexusError(f"{p.name}: no app/index.html")
|
||||||
|
theme = manifest.get("theme", "soleprint")
|
||||||
|
themes = manifest.get("inline_themes") or [theme]
|
||||||
|
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
|
||||||
|
showcase = _showcase(p)
|
||||||
|
if showcase is not None:
|
||||||
|
data["showcase"] = showcase
|
||||||
|
|
||||||
|
today = date.today().isoformat()
|
||||||
|
page = template.read_text()
|
||||||
|
for token, value in (
|
||||||
|
("%%TITLE%%", manifest.get("title", p.name)),
|
||||||
|
("%%DESCRIPTION%%", manifest.get("description", "")),
|
||||||
|
("%%DEFAULT_THEME%%", theme),
|
||||||
|
("%%BUILT%%", f"built {today}" + ("" if p.vendored else " by soleprint")),
|
||||||
|
("%%GRAPH%%", _graph(manifest.get("graph", "system_overview"), theme)),
|
||||||
|
("%%BUNDLE%%", json.dumps(data, indent=2)),
|
||||||
|
):
|
||||||
|
page = page.replace(token, value)
|
||||||
|
|
||||||
|
# Parts, when the template asks for them the way a page does.
|
||||||
|
if bake.ANCHOR in page:
|
||||||
|
parts = bake.load_parts()
|
||||||
|
wanted = bake.parts_used(page, parts)
|
||||||
|
if wanted:
|
||||||
|
block = bake.parts_block(wanted, parts, "")
|
||||||
|
page = page.replace(bake.ANCHOR, bake.ANCHOR + "\n" + block, 1)
|
||||||
|
|
||||||
|
built.html = page.replace("%%THEME_CSS%%", theme_css(page, themes))
|
||||||
|
|
||||||
|
# Anything else in app/ rides along, for a plexus that outgrows one file.
|
||||||
|
extras = [x.name for x in (p.source / "app").iterdir() if x.name != "index.html"]
|
||||||
|
if extras:
|
||||||
|
built.notes.append(f"also copied beside it: {', '.join(extras)}")
|
||||||
|
_finish(built)
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vue ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SCRIPT_TAG = re.compile(r'<script type="module"[^>]*\ssrc="([^"]+)"[^>]*></script>')
|
||||||
|
STYLE_TAG = re.compile(r'<link rel="stylesheet"[^>]*\shref="([^"]+)"[^>]*>')
|
||||||
|
|
||||||
|
VITE_CONFIG = """import vue from '@vitejs/plugin-vue'
|
||||||
|
export default {{
|
||||||
|
root: {root!r},
|
||||||
|
base: './',
|
||||||
|
plugins: [vue()],
|
||||||
|
cacheDir: {cache!r},
|
||||||
|
resolve: {{ alias: {{ '@ui': {ui!r} }} }},
|
||||||
|
build: {{
|
||||||
|
outDir: {out!r},
|
||||||
|
emptyOutDir: true,
|
||||||
|
assetsInlineLimit: 100000000,
|
||||||
|
cssCodeSplit: false,
|
||||||
|
modulePreload: false,
|
||||||
|
minify: {minify},
|
||||||
|
rollupOptions: {{ output: {{ inlineDynamicImports: true }} }},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list, cwd: Path) -> None:
|
||||||
|
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
tail = (result.stdout + result.stderr).strip().splitlines()[-12:]
|
||||||
|
raise PlexusError(f"`{' '.join(cmd)}` failed:\n " + "\n ".join(tail))
|
||||||
|
|
||||||
|
|
||||||
|
def build_vue(p: Plexus) -> Built:
|
||||||
|
from . import runfile, slices
|
||||||
|
|
||||||
|
built = Built(plexus=p)
|
||||||
|
manifest = _manifest(p)
|
||||||
|
app = p.source / "app"
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"plexus-{p.name}-") as t:
|
||||||
|
tmp = Path(t)
|
||||||
|
work = tmp / "app"
|
||||||
|
shutil.copytree(app, work, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
||||||
|
|
||||||
|
# The app's @ui imports, sliced from common/ui into the build — the
|
||||||
|
# same closure a consumer would receive, and nothing else.
|
||||||
|
# The slice follows the plexus's delivery: a vendored page must not
|
||||||
|
# carry the provenance headers a dependency slice keeps. Its licensing
|
||||||
|
# is the page's `notice`, stated once, so the inner slice says none.
|
||||||
|
entry = runfile.Slice(name=p.name, target="vue", delivery=p.delivery,
|
||||||
|
dest=work / "ui", manifest=tmp / "ui.slice.json",
|
||||||
|
layout="package", scan=(work / "src",), alias=("@ui",),
|
||||||
|
theme=manifest.get("theme"), rename=p.rename,
|
||||||
|
notice="none" if p.vendored else None)
|
||||||
|
sliced = slices.build(entry)
|
||||||
|
if sliced.blocked:
|
||||||
|
raise PlexusError(f"{p.name}: the slice would not build: {'; '.join(sliced.blocked)}")
|
||||||
|
slices.apply(entry, sliced, slices.plan(entry, sliced), force=True)
|
||||||
|
built.notes.append(f"slice: {', '.join(sliced.closure)} ({', '.join(sliced.deps)})")
|
||||||
|
|
||||||
|
# pnpm 10+ exits non-zero when a dependency's build script is not
|
||||||
|
# allowed, and every Vite build needs esbuild's. An app that says
|
||||||
|
# nothing gets the one allowance it cannot build without.
|
||||||
|
if not (work / "pnpm-workspace.yaml").exists():
|
||||||
|
(work / "pnpm-workspace.yaml").write_text("allowBuilds:\n esbuild: true\n")
|
||||||
|
installed = subprocess.run(["pnpm", "install", "--offline"], cwd=work,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if installed.returncode != 0:
|
||||||
|
_run(["pnpm", "install", "--prefer-offline"], work)
|
||||||
|
|
||||||
|
(work / "vite.plexus.config.mjs").write_text(VITE_CONFIG.format(
|
||||||
|
root=str(work), cache=str(tmp / ".vite"), ui=str(work / "ui" / "src"),
|
||||||
|
out=str(tmp / "dist"), minify="true" if p.minify else "false"))
|
||||||
|
_run([str(work / "node_modules" / ".bin" / "vite"), "build",
|
||||||
|
"--config", "vite.plexus.config.mjs", "--logLevel", "warn"], work)
|
||||||
|
|
||||||
|
dist = tmp / "dist"
|
||||||
|
page = (dist / "index.html").read_text()
|
||||||
|
|
||||||
|
def script(m):
|
||||||
|
code = (dist / m.group(1)).read_text().replace("</script", "<\\/script")
|
||||||
|
return f'<script type="module">\n{code}\n</script>'
|
||||||
|
|
||||||
|
def style(m):
|
||||||
|
return f"<style>\n{(dist / m.group(1)).read_text()}\n</style>"
|
||||||
|
|
||||||
|
page = SCRIPT_TAG.sub(script, page)
|
||||||
|
page = STYLE_TAG.sub(style, page)
|
||||||
|
left = re.findall(r'(?:src|href)="\./[^"]+"', page)
|
||||||
|
if left:
|
||||||
|
raise PlexusError(f"{p.name}: not inlined: {', '.join(left)}")
|
||||||
|
|
||||||
|
title = manifest.get("title", p.name)
|
||||||
|
page = re.sub(r"<title>.*?</title>", f"<title>{title}</title>", page, count=1, flags=re.S)
|
||||||
|
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
|
||||||
|
# The page's own data, for an app that wants it, under a name that says
|
||||||
|
# what it is to whoever opens the file.
|
||||||
|
page = page.replace("</head>", f"<script>window.PAGE_DATA = {json.dumps(data)}</script>\n</head>", 1)
|
||||||
|
built.html = page
|
||||||
|
_finish(built)
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def write(built: Built) -> Path:
|
||||||
|
p = built.plexus
|
||||||
|
if built.hits:
|
||||||
|
raise PlexusError(f"{p.name}: scrub found {len(built.hits)} spr word(s) in a vendored "
|
||||||
|
f"plexus — nothing written")
|
||||||
|
p.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.dest.write_text(built.html)
|
||||||
|
if not p.is_vue:
|
||||||
|
for extra in (p.source / "app").iterdir():
|
||||||
|
if extra.name == "index.html":
|
||||||
|
continue
|
||||||
|
target = p.dest.parent / extra.name
|
||||||
|
if extra.is_dir():
|
||||||
|
shutil.copytree(extra, target, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(extra, target)
|
||||||
|
return p.dest
|
||||||
454
soleprint/common/slice/reference.py
Normal file
454
soleprint/common/slice/reference.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
"""
|
||||||
|
The UI framework's reference, generated from its source into spr's docs site.
|
||||||
|
|
||||||
|
python3 -m common.slice reference (from soleprint/; or make reference in common/ui)
|
||||||
|
make docs serves it, with the rest of spr's docs
|
||||||
|
|
||||||
|
It is a page of the `/docs` minisite (`docs/data/en/ui-reference.md`), because in
|
||||||
|
soleprint `make docs` always means one thing: launch that site. A component does
|
||||||
|
not grow a second documentation tree of its own.
|
||||||
|
|
||||||
|
The same bargain docgen makes, done here without docgen — docgen is a copy-out
|
||||||
|
tool with its own release cycle, and a library's reference should not wait on it:
|
||||||
|
|
||||||
|
**The source is read, never described.** Every prop, model, emit, slot, file,
|
||||||
|
package and token in the reference comes from graph.py and surface.py reading
|
||||||
|
common/ui/src. A prop that is removed leaves the reference on the next build.
|
||||||
|
|
||||||
|
**One format in the middle.** `inventory.json` is the only thing extraction
|
||||||
|
produces; the reference is rendered from it and nothing else. It has docgen's
|
||||||
|
three keys (`meta`, `nodes`, `edges`), stable ids, a closed `kind` vocabulary per
|
||||||
|
node, and no visual information at all — so the web view that comes later, or
|
||||||
|
docgen itself, reads the same file instead of re-reading the source.
|
||||||
|
|
||||||
|
**Both ends are measured.** The larder says what came in — files seen, read, and
|
||||||
|
failed **by name**; the book says what came out, and is reconciled against it.
|
||||||
|
"33 files in, 34 exports documented, nothing lost" is a fact. A clean reference
|
||||||
|
over an incomplete read is a lie by omission.
|
||||||
|
|
||||||
|
**Generated base, hand-written notes.** Prose only a person can write — why a
|
||||||
|
thing exists, what replaced what, who actually uses it — lives in
|
||||||
|
`common/ui/notes/<spot>.md`, beside the library, and is placed above its spot on every build. A note whose
|
||||||
|
spot is gone is reported and fails the check; it is never dropped.
|
||||||
|
|
||||||
|
**A rebuild replaces exactly what the last one wrote.** `docs/.generated.json`
|
||||||
|
records every generated file and its hash. A generated file edited by hand is
|
||||||
|
refused, not overwritten, until `--force` — the same rule a slice's dest keeps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..theme import bake
|
||||||
|
from . import graph, surface, tokens
|
||||||
|
|
||||||
|
SITE = graph.COMMON.parent.parent / "docs" # spr's /docs minisite
|
||||||
|
PAGE = "data/en/ui-reference.md" # its id in topics.json: ui-reference
|
||||||
|
INVENTORY = "data/ui-inventory.json"
|
||||||
|
MANIFEST = "data/.ui-reference.generated.json"
|
||||||
|
NOTES = graph.COMMON / "ui" / "notes"
|
||||||
|
SCHEMA_VERSION = "1"
|
||||||
|
|
||||||
|
# The groups the library is laid out in, and what a file in each one is. A file
|
||||||
|
# at the top of src/ is the library's own plumbing: barrel and stylesheets.
|
||||||
|
KINDS = {
|
||||||
|
"components": "component",
|
||||||
|
"renderers": "renderer",
|
||||||
|
"datasources": "datasource",
|
||||||
|
"composables": "composable",
|
||||||
|
"plugins": "plugin",
|
||||||
|
}
|
||||||
|
ORDER = list(KINDS) + [""]
|
||||||
|
|
||||||
|
STUB = "<!-- note: {spot} — write below this line, or replace it; a stub renders nothing -->\n"
|
||||||
|
SPOTS_FIXED = ("_intro", "larder", "contents", "types", "tokens", "book", "_end")
|
||||||
|
|
||||||
|
|
||||||
|
# ── extraction → inventory ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _group(rel: str) -> str:
|
||||||
|
return rel.split("/", 1)[0] if "/" in rel else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _kind(rel: str) -> str:
|
||||||
|
if "/" not in rel:
|
||||||
|
return "stylesheet" if rel.endswith(".css") else "barrel"
|
||||||
|
return KINDS.get(_group(rel), "module")
|
||||||
|
|
||||||
|
|
||||||
|
def _bindings(nodes: dict) -> dict[str, list[str]]:
|
||||||
|
"""Library file -> the plain parts derived from it, read from each part's header.
|
||||||
|
|
||||||
|
A part says where it came from in its own header ("Derived from
|
||||||
|
common/ui/src/components/Panel.vue"), so the link is kept by the part,
|
||||||
|
not by a table here that would go stale.
|
||||||
|
"""
|
||||||
|
by_base = {}
|
||||||
|
for rel in nodes:
|
||||||
|
by_base.setdefault(rel.rsplit("/", 1)[-1], []).append(rel)
|
||||||
|
out: dict[str, set] = {}
|
||||||
|
for name in bake.load_parts():
|
||||||
|
text = ""
|
||||||
|
for ext in ("css", "js"):
|
||||||
|
p = bake.PARTS / f"{name}.{ext}"
|
||||||
|
if p.exists():
|
||||||
|
t = p.read_text()
|
||||||
|
text += t[: t.find("*/")] if "*/" in t else ""
|
||||||
|
found = set(re.findall(r"common/ui/src/([\w./-]+\.(?:vue|ts|css))", text))
|
||||||
|
m = re.search(r"Derived from ([^\n]*(?:\n \*[^\n]*){0,2})", text)
|
||||||
|
if m:
|
||||||
|
for base in re.findall(r"\b([A-Za-z]\w*\.(?:vue|ts|css))\b", m.group(1)):
|
||||||
|
if len(by_base.get(base, [])) == 1:
|
||||||
|
found.add(by_base[base][0])
|
||||||
|
for rel in found:
|
||||||
|
if rel in nodes:
|
||||||
|
out.setdefault(rel, set()).add(name)
|
||||||
|
return {k: sorted(v) for k, v in out.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def inventory(src: Path = graph.SRC) -> dict:
|
||||||
|
"""The one document everything else is rendered from."""
|
||||||
|
nodes = graph.load(src)
|
||||||
|
exports = graph.barrel(src, nodes)
|
||||||
|
surfaces = surface.load(src)
|
||||||
|
bindings = _bindings(nodes)
|
||||||
|
library = tokens.source(None)
|
||||||
|
theme = tokens.source("soleprint")
|
||||||
|
|
||||||
|
seen = sorted(p.relative_to(src).as_posix() for p in src.rglob("*")
|
||||||
|
if p.is_file() and not graph.SKIP & set(p.relative_to(src).parts))
|
||||||
|
failed = [{"name": rel, "error": f"no reader for {Path(rel).suffix or 'a file with no suffix'}"}
|
||||||
|
for rel in seen if rel not in nodes]
|
||||||
|
failed += [{"name": s.file, "error": s.error} for s in surfaces.values() if s.error]
|
||||||
|
|
||||||
|
out_nodes, edges = [], []
|
||||||
|
for rel, n in sorted(nodes.items()):
|
||||||
|
s = surfaces.get(rel)
|
||||||
|
attrs = {"path": rel, "bytes": n.size, "group": _group(rel)}
|
||||||
|
if s and s.summary:
|
||||||
|
attrs["summary"] = s.summary
|
||||||
|
if s and rel.endswith(".vue"):
|
||||||
|
attrs["props"] = [vars(p) for p in s.props]
|
||||||
|
attrs["models"] = [vars(m) for m in s.models]
|
||||||
|
attrs["emits"] = [vars(e) for e in s.emits]
|
||||||
|
attrs["slots"] = list(s.slots)
|
||||||
|
if s and s.declarations:
|
||||||
|
attrs["declarations"] = [{"kind": k, "name": nm, "doc": d} for k, nm, d in s.declarations]
|
||||||
|
if s and s.error:
|
||||||
|
attrs["error"] = s.error
|
||||||
|
out_nodes.append({"id": f"file:{rel}", "kind": _kind(rel), "label": rel.rsplit("/", 1)[-1],
|
||||||
|
"parent": None, "attrs": attrs})
|
||||||
|
for target in sorted(n.imports):
|
||||||
|
edges.append({"source": f"file:{rel}", "target": f"file:{target}", "kind": "imports", "attrs": {}})
|
||||||
|
for pkg in sorted({graph.package_of(p) for p in n.packages}):
|
||||||
|
edges.append({"source": f"file:{rel}", "target": f"package:{pkg}", "kind": "requires", "attrs": {}})
|
||||||
|
for tok in sorted(n.tokens):
|
||||||
|
edges.append({"source": f"file:{rel}", "target": f"token:{tok}", "kind": "reads", "attrs": {}})
|
||||||
|
|
||||||
|
for e in exports:
|
||||||
|
closure, _ = graph.select([e.name], [], exports, nodes)
|
||||||
|
out_nodes.append({"id": f"export:{e.name}", "kind": "type" if e.is_type else "export",
|
||||||
|
"label": e.name, "parent": f"file:{e.file}",
|
||||||
|
"attrs": {"file": e.file, "local": e.local,
|
||||||
|
"closure": sorted(closure),
|
||||||
|
"packages": graph.dependencies(closure, nodes),
|
||||||
|
"tokens": sorted(graph.tokens_read(closure, nodes)),
|
||||||
|
"bytes": sum(nodes[f].size for f in closure)}})
|
||||||
|
|
||||||
|
for pkg, version in sorted(graph.dependencies(set(nodes), nodes).items()):
|
||||||
|
out_nodes.append({"id": f"package:{pkg}", "kind": "package", "label": pkg,
|
||||||
|
"parent": None, "attrs": {"version": version}})
|
||||||
|
|
||||||
|
names = set(library.table) | set().union(*(n.tokens for n in nodes.values()))
|
||||||
|
for name in sorted(names):
|
||||||
|
lib, thm = tokens.resolved(library, name), tokens.resolved(theme, name)
|
||||||
|
out_nodes.append({"id": f"token:{name}", "kind": "token", "label": name, "parent": None,
|
||||||
|
"attrs": {"library": lib, "theme": thm}})
|
||||||
|
|
||||||
|
for rel, parts in sorted(bindings.items()):
|
||||||
|
for part in parts:
|
||||||
|
if not any(n["id"] == f"part:{part}" for n in out_nodes):
|
||||||
|
out_nodes.append({"id": f"part:{part}", "kind": "part", "label": part, "parent": None,
|
||||||
|
"attrs": {"path": f"common/theme/parts/{part}"}})
|
||||||
|
edges.append({"source": f"part:{part}", "target": f"file:{rel}", "kind": "binds", "attrs": {}})
|
||||||
|
|
||||||
|
larder = {"kind": "vue-library", "identity": "common/ui/src", "unit": "file",
|
||||||
|
"seen": len(seen), "failed": failed,
|
||||||
|
"extra": {"exports": sum(1 for e in exports if not e.is_type),
|
||||||
|
"types": sum(1 for e in exports if e.is_type)}}
|
||||||
|
return {"meta": {"source": "common/ui/src", "schema_version": SCHEMA_VERSION,
|
||||||
|
"generator": "common.slice docs", "larder": larder},
|
||||||
|
"nodes": out_nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
|
# ── the book measure: what came out, reconciled against what came in ──────────
|
||||||
|
|
||||||
|
def reconcile(inv: dict, notes: dict[str, str]) -> list[tuple[bool, str]]:
|
||||||
|
larder = inv["meta"]["larder"]
|
||||||
|
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
|
||||||
|
files = [n for n in inv["nodes"] if n["id"].startswith("file:")]
|
||||||
|
values = [n for n in inv["nodes"] if n["kind"] == "export"]
|
||||||
|
types = [n for n in inv["nodes"] if n["kind"] == "type"]
|
||||||
|
vue = [n for n in files if n["attrs"]["path"].endswith(".vue")]
|
||||||
|
unparsed = [n["attrs"]["path"] for n in vue if "error" in n["attrs"]]
|
||||||
|
orphans = sorted(set(notes) - set(spots(inv)))
|
||||||
|
return [
|
||||||
|
(read == len(files), f"{read} file(s) read produced {len(files)} file node(s)"),
|
||||||
|
(len(values) == larder["extra"]["exports"] and len(types) == larder["extra"]["types"],
|
||||||
|
f"{len(values)} export(s) and {len(types)} type(s) in the barrel, each with a node"),
|
||||||
|
(not unparsed, f"{len(vue) - len(unparsed)} of {len(vue)} component(s) have their surface read"
|
||||||
|
+ (f" — not: {', '.join(unparsed)}" if unparsed else "")),
|
||||||
|
(not orphans, "every note lands on a spot" if not orphans
|
||||||
|
else f"{len(orphans)} note(s) with no spot: {', '.join(orphans)}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── notes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def spots(inv: dict) -> list[str]:
|
||||||
|
"""Every place a note can go. Exports by name; the fixed sections by role."""
|
||||||
|
named = [n["label"] for n in inv["nodes"] if n["kind"] == "export"]
|
||||||
|
return list(SPOTS_FIXED[:3]) + named + list(SPOTS_FIXED[3:])
|
||||||
|
|
||||||
|
|
||||||
|
def load_notes(notes_dir: Path = NOTES) -> dict[str, str]:
|
||||||
|
"""spot -> the note's text. A stub (only the scaffold comment) counts as empty."""
|
||||||
|
out = {}
|
||||||
|
if notes_dir.is_dir():
|
||||||
|
for p in sorted(notes_dir.glob("*.md")):
|
||||||
|
body = re.sub(r"<!--.*?-->", "", p.read_text(), flags=re.S).strip()
|
||||||
|
out[p.stem] = body
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold(inv: dict, notes_dir: Path = NOTES) -> list[str]:
|
||||||
|
"""Write an empty stub for every spot that has no note. Never overwrites."""
|
||||||
|
notes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
made = []
|
||||||
|
for spot in spots(inv):
|
||||||
|
p = notes_dir / f"{spot}.md"
|
||||||
|
if not p.exists():
|
||||||
|
p.write_text(STUB.format(spot=spot))
|
||||||
|
made.append(spot)
|
||||||
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
# ── the reference ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _cell(text) -> str:
|
||||||
|
return str(text).replace("|", "\\|").replace("\n", " ") if text not in (None, "") else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _code(text) -> str:
|
||||||
|
return f"`{_cell(text)}`" if text not in (None, "") else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def render(inv: dict, notes: dict[str, str]) -> str:
|
||||||
|
by_id = {n["id"]: n for n in inv["nodes"]}
|
||||||
|
out_edges: dict[str, list] = {}
|
||||||
|
for e in inv["edges"]:
|
||||||
|
out_edges.setdefault(e["source"], []).append(e)
|
||||||
|
binds: dict[str, list] = {}
|
||||||
|
for e in inv["edges"]:
|
||||||
|
if e["kind"] == "binds":
|
||||||
|
binds.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
|
||||||
|
larder = inv["meta"]["larder"]
|
||||||
|
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
|
||||||
|
|
||||||
|
L = []
|
||||||
|
|
||||||
|
def note(spot):
|
||||||
|
if notes.get(spot):
|
||||||
|
L.extend([notes[spot], ""])
|
||||||
|
|
||||||
|
L += ["# common/ui — reference", "",
|
||||||
|
"> Generated from `common/ui/src` by `make reference` in common/ui "
|
||||||
|
"(`python3 -m common.slice reference`).",
|
||||||
|
"> Do not edit this page: the next build replaces it. Prose goes in "
|
||||||
|
"`soleprint/common/ui/notes/<spot>.md`,",
|
||||||
|
"> and `make reference-spots` lists every spot.", ""]
|
||||||
|
note("_intro")
|
||||||
|
|
||||||
|
L += ["## What came in", ""]
|
||||||
|
note("larder")
|
||||||
|
L += [f"`{larder['identity']}` — **{larder['seen']} file(s) seen, {read} read, "
|
||||||
|
f"{len(larder['failed'])} failed**; {larder['extra']['exports']} exports and "
|
||||||
|
f"{larder['extra']['types']} types in the barrel.", ""]
|
||||||
|
if larder["failed"]:
|
||||||
|
L += ["| file | why it is not (fully) read |", "| --- | --- |"]
|
||||||
|
L += [f"| `{f['name']}` | {_cell(f['error'])} |" for f in larder["failed"]]
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
exports = [n for n in inv["nodes"] if n["kind"] == "export"]
|
||||||
|
L += ["## Contents", ""]
|
||||||
|
note("contents")
|
||||||
|
L += ["What each export costs a consumer that takes it: the files it pulls in, the packages it",
|
||||||
|
"brings, and whether a plain-HTML part exists for it (`common/theme/parts`).", "",
|
||||||
|
"| export | kind | file | pulls in | packages | size | plain part |",
|
||||||
|
"| --- | --- | --- | --- | --- | --- | --- |"]
|
||||||
|
for n in exports:
|
||||||
|
a = n["attrs"]
|
||||||
|
f = by_id[f"file:{a['file']}"]
|
||||||
|
pulls = ", ".join(p.rsplit("/", 1)[-1] for p in a["closure"] if p != a["file"]) or "—"
|
||||||
|
L.append(f"| [`{n['label']}`](#{n['label'].lower()}) | {f['kind']} | `{a['file']}` | {pulls} | "
|
||||||
|
f"{', '.join(a['packages']) or '—'} | {a['bytes'] / 1024:.1f} KB | "
|
||||||
|
f"{', '.join(binds.get(f['id'], [])) or '—'} |")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
for group in ORDER:
|
||||||
|
members = [n for n in exports if _group(n["attrs"]["file"]) == group]
|
||||||
|
if not members:
|
||||||
|
continue
|
||||||
|
L += [f"## {group.capitalize() if group else 'Library'}", ""]
|
||||||
|
for n in members:
|
||||||
|
a = n["attrs"]
|
||||||
|
f = by_id[f"file:{a['file']}"]
|
||||||
|
fa = f["attrs"]
|
||||||
|
L += [f"### {n['label']}", ""]
|
||||||
|
if fa.get("summary"):
|
||||||
|
L += [fa["summary"], ""]
|
||||||
|
note(n["label"])
|
||||||
|
meta = [f"`{a['file']}` · {a['bytes'] / 1024:.1f} KB with its closure",
|
||||||
|
f"packages: {', '.join(f'`{p}` {v}' for p, v in a['packages'].items()) or 'none'}"]
|
||||||
|
if binds.get(f["id"]):
|
||||||
|
meta.append("plain part: " + ", ".join(f"`{p}`" for p in binds[f["id"]]))
|
||||||
|
L += [" · ".join(meta), ""]
|
||||||
|
if fa.get("error"):
|
||||||
|
L += [f"**Not fully read:** {fa['error']}", ""]
|
||||||
|
if fa.get("props"):
|
||||||
|
L += ["| prop | type | required | default | |", "| --- | --- | --- | --- | --- |"]
|
||||||
|
L += [f"| `{p['name']}` | {_code(p['type'])} | {'yes' if p['required'] else 'no'} | "
|
||||||
|
f"{_code(p['default'])} | {_cell(p['doc'])} |" for p in fa["props"]]
|
||||||
|
L.append("")
|
||||||
|
if fa.get("models"):
|
||||||
|
L += ["| v-model | type | default |", "| --- | --- | --- |"]
|
||||||
|
L += [f"| `v-model:{m['name']}` | {_code(m['type'])} | {_code(m['default'])} |"
|
||||||
|
for m in fa["models"]]
|
||||||
|
L.append("")
|
||||||
|
if fa.get("emits"):
|
||||||
|
L += ["| emits | payload | |", "| --- | --- | --- |"]
|
||||||
|
L += [f"| `{e['name']}` | {_code(e['type'])} | {_cell(e['doc'])} |" for e in fa["emits"]]
|
||||||
|
L.append("")
|
||||||
|
if fa.get("slots"):
|
||||||
|
L += ["slots: " + ", ".join(f"`#{s}`" for s in fa["slots"]), ""]
|
||||||
|
# A .ts export's own doc comment (a class or function's) is its
|
||||||
|
# description; an SFC's is the file summary, already printed above.
|
||||||
|
for d in (d for d in fa.get("declarations", []) if d["name"] == a["local"]):
|
||||||
|
if d["doc"] and d["doc"] != fa.get("summary"):
|
||||||
|
L += [d["doc"], ""]
|
||||||
|
if a["tokens"]:
|
||||||
|
L += ["reads: " + " ".join(f"`{t}`" for t in a["tokens"]), ""]
|
||||||
|
|
||||||
|
types = [n for n in inv["nodes"] if n["kind"] == "type"]
|
||||||
|
L += ["## Types", ""]
|
||||||
|
note("types")
|
||||||
|
L += ["Types ride with the file that declares them: taking the export takes its types.", "",
|
||||||
|
"| type | declared in | |", "| --- | --- | --- |"]
|
||||||
|
for n in types:
|
||||||
|
f = by_id[f"file:{n['attrs']['file']}"]
|
||||||
|
decl = next((d for d in f["attrs"].get("declarations", []) if d["name"] == n["attrs"]["local"]), None)
|
||||||
|
L.append(f"| `{n['label']}` | `{n['attrs']['file']}` | {_cell(decl['kind'] if decl else '')} |")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
toks = [n for n in inv["nodes"] if n["kind"] == "token"]
|
||||||
|
readers: dict[str, list] = {}
|
||||||
|
for e in inv["edges"]:
|
||||||
|
if e["kind"] == "reads":
|
||||||
|
readers.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
|
||||||
|
L += ["## Tokens", ""]
|
||||||
|
note("tokens")
|
||||||
|
L += ["Every custom property the library defines or reads. `library` is `src/tokens.css`, what",
|
||||||
|
"Vue consumers render today; `theme` is the contract plain pages use",
|
||||||
|
"(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a",
|
||||||
|
"visible change for a consumer that switches.", "",
|
||||||
|
"| token | library | theme | read by |", "| --- | --- | --- | --- |"]
|
||||||
|
for n in toks:
|
||||||
|
a = n["attrs"]
|
||||||
|
flag = " ⚠" if a["library"] and a["theme"] and a["library"] != a["theme"] else ""
|
||||||
|
who = ", ".join(sorted(set(readers.get(n["id"], [])))) or "*nobody*"
|
||||||
|
L.append(f"| `{n['label']}`{flag} | {_code(a['library'])} | {_code(a['theme'])} | {who} |")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
L += ["## What came out", ""]
|
||||||
|
note("book")
|
||||||
|
kinds: dict[str, int] = {}
|
||||||
|
for n in inv["nodes"]:
|
||||||
|
kinds[n["kind"]] = kinds.get(n["kind"], 0) + 1
|
||||||
|
ekinds: dict[str, int] = {}
|
||||||
|
for e in inv["edges"]:
|
||||||
|
ekinds[e["kind"]] = ekinds.get(e["kind"], 0) + 1
|
||||||
|
L += ["nodes: " + " · ".join(f"{v} {k}" for k, v in sorted(kinds.items())),
|
||||||
|
"", "edges: " + " · ".join(f"{v} {k}" for k, v in sorted(ekinds.items())), ""]
|
||||||
|
L += [f"- {'ok ' if ok else '**FAIL**'} {msg}" for ok, msg in reconcile(inv, notes)]
|
||||||
|
L.append("")
|
||||||
|
note("_end")
|
||||||
|
return "\n".join(L).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── writing, and checking without writing ─────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
written: list = field(default_factory=list)
|
||||||
|
unchanged: list = field(default_factory=list)
|
||||||
|
stale: list = field(default_factory=list)
|
||||||
|
edited: list = field(default_factory=list)
|
||||||
|
removed: list = field(default_factory=list)
|
||||||
|
reconciled: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return not self.stale and not self.edited and all(ok for ok, _ in self.reconciled)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def outputs(inv: dict, notes: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {INVENTORY: json.dumps(inv, indent=2, sort_keys=True) + "\n",
|
||||||
|
PAGE: render(inv, notes)}
|
||||||
|
|
||||||
|
|
||||||
|
def build(docs: Path = SITE, *, write: bool, force: bool = False, src: Path = graph.SRC,
|
||||||
|
notes_dir: Path = NOTES) -> Result:
|
||||||
|
inv = inventory(src)
|
||||||
|
notes = load_notes(notes_dir)
|
||||||
|
files = outputs(inv, notes)
|
||||||
|
res = Result(reconciled=reconcile(inv, notes))
|
||||||
|
manifest = docs / MANIFEST
|
||||||
|
previous = json.loads(manifest.read_text()).get("files", {}) if manifest.exists() else {}
|
||||||
|
|
||||||
|
for name, text in files.items():
|
||||||
|
path = docs / name
|
||||||
|
on_disk = path.read_text() if path.exists() else None
|
||||||
|
if on_disk is not None and name in previous and _sha(on_disk) != previous[name]:
|
||||||
|
# Somebody edited a generated file. That text would be lost on the
|
||||||
|
# next build, so it is named and refused rather than overwritten.
|
||||||
|
res.edited.append(name)
|
||||||
|
if not force:
|
||||||
|
continue
|
||||||
|
if on_disk == text:
|
||||||
|
res.unchanged.append(name)
|
||||||
|
continue
|
||||||
|
res.stale.append(name)
|
||||||
|
if write:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(text)
|
||||||
|
res.written.append(name)
|
||||||
|
|
||||||
|
gone = [n for n in previous if n not in files and (docs / n).exists()]
|
||||||
|
res.removed = gone
|
||||||
|
if write and (not res.edited or force):
|
||||||
|
for n in gone:
|
||||||
|
(docs / n).unlink()
|
||||||
|
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest.write_text(json.dumps({"generator": "common.slice reference",
|
||||||
|
"files": {n: _sha(t) for n, t in files.items()}},
|
||||||
|
indent=2, sort_keys=True) + "\n")
|
||||||
|
if res.written:
|
||||||
|
res.stale = []
|
||||||
|
return res
|
||||||
287
soleprint/common/slice/runfile.py
Normal file
287
soleprint/common/slice/runfile.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
A run file: every slice (and plexus) one consumer takes from spr, and how.
|
||||||
|
|
||||||
|
python3 -m common.slice slice ui.toml # write what is stale
|
||||||
|
python3 -m common.slice slice ui.toml --list # resolved; writes nothing
|
||||||
|
python3 -m common.slice check ui.toml # exit 1 if anything is stale
|
||||||
|
|
||||||
|
This module only loads and validates. Same three rules as docgen's and theme's
|
||||||
|
run files, so spr has one convention for "rebuild these, with these settings":
|
||||||
|
|
||||||
|
- **Paths are relative to the run file**, not to where the command runs.
|
||||||
|
- **An entry's value replaces the default**, for every key.
|
||||||
|
- **Unknown keys are refused**, and every problem is reported in one pass.
|
||||||
|
|
||||||
|
## The shape
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
delivery = "dependency"
|
||||||
|
|
||||||
|
[[slice]]
|
||||||
|
name = "app-ui"
|
||||||
|
target = "vue" # or "plain"
|
||||||
|
dest = "../framework" # where the files land
|
||||||
|
scan = ["src"] # read the consumer's own imports / markup
|
||||||
|
use = ["Panel"] # and/or name what to take
|
||||||
|
delivery = "vendored" # needs `notice`
|
||||||
|
notice = "none" # or a path: copied into dest as-is
|
||||||
|
|
||||||
|
## Where it lives
|
||||||
|
|
||||||
|
Beside the consumer, never in spr: it names the consumer's paths. For a vendored
|
||||||
|
slice keep it — and the manifest it writes — outside the consumer's repository,
|
||||||
|
with `dest` pointing in. `ui.example.toml` beside this file is the committed one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
||||||
|
tomllib = None
|
||||||
|
|
||||||
|
TOP_KEYS = {"defaults", "slice", "plexus"}
|
||||||
|
SLICE_KEYS = {"name", "target", "delivery", "layout", "dest", "use", "scan", "alias", "theme",
|
||||||
|
"tokens", "package", "notice", "header", "manifest", "rename"}
|
||||||
|
DEFAULT_KEYS = SLICE_KEYS - {"name", "dest", "manifest"}
|
||||||
|
TARGETS = ("vue", "plain")
|
||||||
|
DELIVERIES = ("dependency", "vendored")
|
||||||
|
LAYOUTS = ("package", "flat")
|
||||||
|
VUE_ONLY = {"layout", "alias", "package"}
|
||||||
|
|
||||||
|
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
|
TOKEN = re.compile(r"^--[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(ValueError):
|
||||||
|
"""A run file that cannot be run. Carries every problem, not the first."""
|
||||||
|
|
||||||
|
def __init__(self, path, problems: list[str]):
|
||||||
|
self.path, self.problems = path, problems
|
||||||
|
super().__init__(f"{path}: {len(problems)} problem(s)\n " + "\n ".join(problems))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Slice:
|
||||||
|
"""One slice, fully resolved — nothing relative, nothing defaulted later."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
target: str
|
||||||
|
delivery: str
|
||||||
|
dest: Path
|
||||||
|
manifest: Path
|
||||||
|
layout: str = "package"
|
||||||
|
use: tuple = ()
|
||||||
|
scan: tuple = ()
|
||||||
|
alias: tuple = ("soleprint-ui", "@framework")
|
||||||
|
theme: str | None = None
|
||||||
|
tokens: dict = field(default_factory=dict)
|
||||||
|
package: bool | str = False
|
||||||
|
notice: str | Path | None = None
|
||||||
|
header: str | None = None
|
||||||
|
rename: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vendored(self) -> bool:
|
||||||
|
return self.delivery == "vendored"
|
||||||
|
|
||||||
|
def line(self) -> str:
|
||||||
|
return f"{self.name:<16} {self.target:<5} {self.delivery:<10} -> {self.dest}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunFile:
|
||||||
|
path: Path
|
||||||
|
slices: list = field(default_factory=list)
|
||||||
|
plexuses: list = field(default_factory=list) # raw tables; plexus.py resolves them
|
||||||
|
|
||||||
|
def select(self, names) -> list[Slice]:
|
||||||
|
if not names:
|
||||||
|
return list(self.slices)
|
||||||
|
known = {s.name for s in self.slices}
|
||||||
|
unknown = [n for n in names if n not in known]
|
||||||
|
if unknown:
|
||||||
|
raise ConfigError(self.path, [f"no slice named {n!r} — have: "
|
||||||
|
f"{', '.join(sorted(known))}" for n in unknown])
|
||||||
|
return [s for s in self.slices if s.name in set(names)]
|
||||||
|
|
||||||
|
|
||||||
|
def _path(value, base: Path) -> Path:
|
||||||
|
p = Path(str(value)).expanduser()
|
||||||
|
return Path(os.path.normpath(p if p.is_absolute() else base / p))
|
||||||
|
|
||||||
|
|
||||||
|
def _strings(value) -> bool:
|
||||||
|
return isinstance(value, list) and all(isinstance(x, str) for x in value)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_of_strings(value) -> bool:
|
||||||
|
return isinstance(value, dict) and all(isinstance(k, str) and isinstance(v, str)
|
||||||
|
for k, v in value.items())
|
||||||
|
|
||||||
|
|
||||||
|
def load(path, themes: list[str]) -> RunFile:
|
||||||
|
"""Read and resolve a run file. Raises ConfigError listing every problem.
|
||||||
|
|
||||||
|
`themes` is passed in so this stays a reader of files with nothing to know
|
||||||
|
about where themes live.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if tomllib is None:
|
||||||
|
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
|
||||||
|
try:
|
||||||
|
data = tomllib.loads(path.read_text())
|
||||||
|
except OSError as e:
|
||||||
|
raise ConfigError(path, [f"cannot read: {e}"]) from None
|
||||||
|
except tomllib.TOMLDecodeError as e:
|
||||||
|
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
|
||||||
|
|
||||||
|
base = path.resolve().parent
|
||||||
|
problems: list[str] = []
|
||||||
|
for key in sorted(set(data) - TOP_KEYS):
|
||||||
|
problems.append(f"unknown top-level key {key!r} — have: {', '.join(sorted(TOP_KEYS))}")
|
||||||
|
|
||||||
|
defaults = data.get("defaults") or {}
|
||||||
|
if not isinstance(defaults, dict):
|
||||||
|
problems.append("[defaults] must be a table")
|
||||||
|
defaults = {}
|
||||||
|
for key in sorted(set(defaults) - DEFAULT_KEYS):
|
||||||
|
problems.append(f"[defaults] has unknown key {key!r} — have: "
|
||||||
|
f"{', '.join(sorted(DEFAULT_KEYS))}")
|
||||||
|
|
||||||
|
raw_slices = data.get("slice") or []
|
||||||
|
plexuses = data.get("plexus") or []
|
||||||
|
if not isinstance(raw_slices, list) or not isinstance(plexuses, list):
|
||||||
|
problems.append("[[slice]] and [[plexus]] must be arrays of tables")
|
||||||
|
raw_slices, plexuses = [], []
|
||||||
|
if not raw_slices and not plexuses:
|
||||||
|
problems.append("nothing to do — add a [[slice]] (or a [[plexus]]) table")
|
||||||
|
|
||||||
|
slices: list[Slice] = []
|
||||||
|
for i, raw in enumerate(raw_slices):
|
||||||
|
where = f"slice[{i}]"
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
problems.append(f"{where} must be a table")
|
||||||
|
continue
|
||||||
|
name = raw.get("name")
|
||||||
|
if isinstance(name, str):
|
||||||
|
where = f"slice {name!r}"
|
||||||
|
if not isinstance(name, str) or not NAME.match(name):
|
||||||
|
problems.append(f"{where} needs a name of letters, digits, '.', '_' or '-'")
|
||||||
|
continue
|
||||||
|
for key in sorted(set(raw) - SLICE_KEYS):
|
||||||
|
problems.append(f"{where} has unknown key {key!r} — have: {', '.join(sorted(SLICE_KEYS))}")
|
||||||
|
|
||||||
|
def pick(key, default=None):
|
||||||
|
return raw[key] if key in raw else defaults.get(key, default)
|
||||||
|
|
||||||
|
target = pick("target")
|
||||||
|
if target not in TARGETS:
|
||||||
|
problems.append(f"{where}: target must be one of {', '.join(TARGETS)}")
|
||||||
|
continue
|
||||||
|
delivery = pick("delivery", "dependency")
|
||||||
|
if delivery not in DELIVERIES:
|
||||||
|
problems.append(f"{where}: delivery must be one of {', '.join(DELIVERIES)}")
|
||||||
|
if target == "plain":
|
||||||
|
for key in sorted(VUE_ONLY & set(raw)):
|
||||||
|
problems.append(f"{where}: {key!r} applies to target = \"vue\" only")
|
||||||
|
|
||||||
|
if not isinstance(raw.get("dest"), str):
|
||||||
|
problems.append(f"{where} needs `dest`, the directory the slice is written to")
|
||||||
|
continue
|
||||||
|
dest = _path(raw["dest"], base)
|
||||||
|
|
||||||
|
layout = pick("layout", "flat" if delivery == "vendored" else "package")
|
||||||
|
if layout not in LAYOUTS:
|
||||||
|
problems.append(f"{where}: layout must be one of {', '.join(LAYOUTS)}")
|
||||||
|
|
||||||
|
use = pick("use", [])
|
||||||
|
if not _strings(use):
|
||||||
|
problems.append(f"{where}: use must be a list of names")
|
||||||
|
use = []
|
||||||
|
scan = pick("scan", [])
|
||||||
|
if not _strings(scan):
|
||||||
|
problems.append(f"{where}: scan must be a list of paths")
|
||||||
|
scan = []
|
||||||
|
scan_paths = []
|
||||||
|
for s in scan:
|
||||||
|
sp = _path(s, base)
|
||||||
|
if not sp.exists():
|
||||||
|
problems.append(f"{where}: scan path {sp} does not exist")
|
||||||
|
scan_paths.append(sp)
|
||||||
|
if not use and not scan:
|
||||||
|
problems.append(f"{where}: nothing selected — give `use`, `scan`, or both")
|
||||||
|
|
||||||
|
alias = pick("alias", ["soleprint-ui", "@framework"])
|
||||||
|
if not _strings(alias) or not alias:
|
||||||
|
problems.append(f"{where}: alias must be a non-empty list of import specifiers")
|
||||||
|
alias = ["soleprint-ui", "@framework"]
|
||||||
|
|
||||||
|
theme = pick("theme")
|
||||||
|
if theme is not None and (not isinstance(theme, str) or
|
||||||
|
(theme != "library" and theme not in themes)):
|
||||||
|
problems.append(f"{where}: theme must be \"library\" or one of: {', '.join(themes)}")
|
||||||
|
theme = None
|
||||||
|
if target == "plain" and theme in (None, "library"):
|
||||||
|
# Parts are written against the theme contract, never the library's file.
|
||||||
|
theme = "soleprint"
|
||||||
|
|
||||||
|
tokens = pick("tokens", {})
|
||||||
|
if not _table_of_strings(tokens) or not all(TOKEN.match(k) for k in tokens):
|
||||||
|
problems.append(f"{where}: tokens must map --names to values")
|
||||||
|
tokens = {}
|
||||||
|
|
||||||
|
package = pick("package", False)
|
||||||
|
if not isinstance(package, (bool, str)):
|
||||||
|
problems.append(f"{where}: package must be true, false, or a package name")
|
||||||
|
package = False
|
||||||
|
|
||||||
|
notice = pick("notice")
|
||||||
|
if delivery == "vendored":
|
||||||
|
if notice is None:
|
||||||
|
problems.append(f"{where}: a vendored slice needs `notice` — \"none\", or a file to "
|
||||||
|
f"copy in. The tool never picks the licensing for you.")
|
||||||
|
elif notice != "none":
|
||||||
|
np = _path(notice, base)
|
||||||
|
if not np.is_file():
|
||||||
|
problems.append(f"{where}: notice {np} is not a file")
|
||||||
|
notice = np
|
||||||
|
elif notice is not None and notice != "none":
|
||||||
|
notice = _path(notice, base)
|
||||||
|
|
||||||
|
header = pick("header")
|
||||||
|
if header is not None and not isinstance(header, str):
|
||||||
|
problems.append(f"{where}: header must be a string")
|
||||||
|
header = None
|
||||||
|
|
||||||
|
rename = pick("rename", {})
|
||||||
|
if not _table_of_strings(rename):
|
||||||
|
problems.append(f"{where}: rename must map identifiers to identifiers")
|
||||||
|
rename = {}
|
||||||
|
|
||||||
|
manifest = _path(raw["manifest"], base) if isinstance(raw.get("manifest"), str) \
|
||||||
|
else base / f"{name}.slice.json"
|
||||||
|
|
||||||
|
slices.append(Slice(name=name, target=target, delivery=delivery, dest=dest,
|
||||||
|
manifest=manifest, layout=layout, use=tuple(use),
|
||||||
|
scan=tuple(scan_paths), alias=tuple(alias), theme=theme,
|
||||||
|
tokens=dict(tokens), package=package, notice=notice,
|
||||||
|
header=header, rename=dict(rename)))
|
||||||
|
|
||||||
|
seen_names, seen_dests, seen_manifests = set(), {}, {}
|
||||||
|
for s in slices:
|
||||||
|
if s.name in seen_names:
|
||||||
|
problems.append(f"slice {s.name!r} is listed twice")
|
||||||
|
seen_names.add(s.name)
|
||||||
|
for key, seen, what in ((s.dest, seen_dests, "dest"), (s.manifest, seen_manifests, "manifest")):
|
||||||
|
k = key.resolve()
|
||||||
|
if k in seen:
|
||||||
|
problems.append(f"slices {seen[k]!r} and {s.name!r} share one {what}: {key}")
|
||||||
|
seen[k] = s.name
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
raise ConfigError(path, problems)
|
||||||
|
return RunFile(path=path, slices=slices, plexuses=list(plexuses))
|
||||||
190
soleprint/common/slice/scan.py
Normal file
190
soleprint/common/slice/scan.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
What a consumer uses of the framework, read from the consumer itself.
|
||||||
|
|
||||||
|
This is the check that used to be done by hand — grep an app for its imports,
|
||||||
|
diff its framework copy against spr — made repeatable, and made the input of a
|
||||||
|
slice. Two questions, answered separately:
|
||||||
|
|
||||||
|
**Usage.** Which exports, files and custom properties the consumer's own code
|
||||||
|
reads. Both import styles in use are understood:
|
||||||
|
|
||||||
|
import { Panel, type LogEntry } from 'soleprint-ui' barrel (mpr, nvi, unt)
|
||||||
|
import Panel from '@framework/components/Panel.vue' deep path (mts)
|
||||||
|
import '@framework/tokens.css' a stylesheet
|
||||||
|
|
||||||
|
Its own `var(--…)` reads count too: every consumer imports tokens.css directly
|
||||||
|
and styles its own components with it, so a slice that carried only what the
|
||||||
|
framework reads would leave the app's own CSS unstyled.
|
||||||
|
|
||||||
|
**Copy state.** When the consumer holds a copy of the framework (found through
|
||||||
|
its package.json `link:` or its Vite alias, or named with --copy), every file is
|
||||||
|
compared with current spr: same, differs, missing from the copy, or only in the
|
||||||
|
copy. A copy that is *ahead* shows up here first — which is how mts's maximize
|
||||||
|
was found before anything could overwrite it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import graph
|
||||||
|
from .manifest import commit_exists, git, read_stamp
|
||||||
|
|
||||||
|
DEFAULT_ALIASES = ("soleprint-ui", "@framework")
|
||||||
|
SKIP_DIRS = {"node_modules", "dist", ".git", "__pycache__", ".venv", "out", "gen"}
|
||||||
|
CODE = {".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs"}
|
||||||
|
STYLE = {".css"}
|
||||||
|
MARKUP = {".html", ".htm", ".jinja", ".j2"}
|
||||||
|
|
||||||
|
VAR = re.compile(r"var\(\s*(--[\w-]+)")
|
||||||
|
LINK = re.compile(r"^(?:link|file):(.+)$")
|
||||||
|
VITE_ALIAS = re.compile(r"""['"]([^'"]+)['"]\s*:\s*[^,\n]*?['"](\.{1,2}/[^'"]*)['"]""")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Usage:
|
||||||
|
names: set = field(default_factory=set) # barrel names imported
|
||||||
|
files: set = field(default_factory=set) # SRC-relative files deep-imported
|
||||||
|
tokens: set = field(default_factory=set) # custom properties the consumer reads
|
||||||
|
texts: list = field(default_factory=list) # (path, text) of every file read
|
||||||
|
barrel: int = 0 # imports through the barrel
|
||||||
|
deep: int = 0 # imports by deep path
|
||||||
|
unknown: set = field(default_factory=set) # deep paths spr does not have
|
||||||
|
|
||||||
|
def markup(self) -> list:
|
||||||
|
return [(p, t) for p, t in self.texts if p.suffix in MARKUP]
|
||||||
|
|
||||||
|
|
||||||
|
def _files(paths, exclude=()):
|
||||||
|
excluded = [Path(e).resolve() for e in exclude]
|
||||||
|
for base in paths:
|
||||||
|
base = Path(base)
|
||||||
|
candidates = [base] if base.is_file() else sorted(base.rglob("*"))
|
||||||
|
for p in candidates:
|
||||||
|
if not p.is_file() or SKIP_DIRS & set(p.parts):
|
||||||
|
continue
|
||||||
|
rp = p.resolve()
|
||||||
|
if any(rp == e or e in rp.parents for e in excluded):
|
||||||
|
continue
|
||||||
|
if p.suffix in CODE | STYLE | MARKUP:
|
||||||
|
yield p
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_target(rest: str, nodes: dict) -> str | None:
|
||||||
|
rest = rest.lstrip("/")
|
||||||
|
if rest.startswith("src/"):
|
||||||
|
rest = rest[4:]
|
||||||
|
for candidate in (rest, rest + ".ts", rest + ".vue", rest + "/index.ts"):
|
||||||
|
if candidate in nodes or candidate in graph.GENERATED | {"base.css"}:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def usage(paths, aliases=DEFAULT_ALIASES, exclude=(), nodes=None) -> Usage:
|
||||||
|
"""Read every file under `paths` for its use of the framework."""
|
||||||
|
nodes = nodes if nodes is not None else graph.load()
|
||||||
|
alias_group = "|".join(re.escape(a) for a in sorted(aliases, key=len, reverse=True))
|
||||||
|
named = re.compile(
|
||||||
|
r"import\s+(type\s+)?\{([^}]*)\}\s*from\s*['\"](" + alias_group + r")(/[^'\"]*)?['\"]", re.S)
|
||||||
|
any_import = re.compile(
|
||||||
|
r"(?:import|export)\s+(?:[^'\";]*?\s*from\s*)?['\"](" + alias_group + r")(/[^'\"]*)?['\"]",
|
||||||
|
re.S)
|
||||||
|
out = Usage()
|
||||||
|
for path in _files(paths, exclude):
|
||||||
|
text = path.read_text(errors="replace")
|
||||||
|
out.texts.append((path, text))
|
||||||
|
out.tokens |= set(VAR.findall(text))
|
||||||
|
if path.suffix not in CODE:
|
||||||
|
continue
|
||||||
|
for _, names, _, rest in named.findall(text):
|
||||||
|
if rest:
|
||||||
|
continue # `{ x } from '@framework/…'` — counted as deep below
|
||||||
|
for item in names.split(","):
|
||||||
|
item = item.strip()
|
||||||
|
if item.startswith("type "):
|
||||||
|
item = item[5:].strip()
|
||||||
|
if item:
|
||||||
|
out.names.add(item.split(" as ")[0].strip())
|
||||||
|
for _, rest in any_import.findall(text):
|
||||||
|
if not rest:
|
||||||
|
out.barrel += 1
|
||||||
|
continue
|
||||||
|
out.deep += 1
|
||||||
|
target = _deep_target(rest, nodes)
|
||||||
|
if target:
|
||||||
|
out.files.add(target)
|
||||||
|
else:
|
||||||
|
out.unknown.add(rest.lstrip("/"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_copy(app: Path) -> Path | None:
|
||||||
|
"""The framework copy an app resolves to: package.json `link:`, else a Vite alias."""
|
||||||
|
pkg = app / "package.json"
|
||||||
|
if pkg.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(pkg.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
deps = {**data.get("devDependencies", {}), **data.get("dependencies", {})}
|
||||||
|
for name in DEFAULT_ALIASES:
|
||||||
|
m = LINK.match(str(deps.get(name, "")))
|
||||||
|
if m:
|
||||||
|
return (app / m.group(1)).resolve()
|
||||||
|
for config in sorted(app.glob("vite.config.*")):
|
||||||
|
for alias, target in VITE_ALIAS.findall(config.read_text()):
|
||||||
|
if alias in DEFAULT_ALIASES:
|
||||||
|
found = (app / target).resolve()
|
||||||
|
return found.parent if found.name == "src" else found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CopyState:
|
||||||
|
root: Path
|
||||||
|
rows: list = field(default_factory=list) # (rel, status, detail)
|
||||||
|
stamp: dict = field(default_factory=dict)
|
||||||
|
stamp_known: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def compare(copy_root: Path, src: Path = graph.SRC) -> CopyState:
|
||||||
|
"""Every file of spr's src against the copy's src, both ways."""
|
||||||
|
copy_src = copy_root / "src" if (copy_root / "src").is_dir() else copy_root
|
||||||
|
state = CopyState(root=copy_root, stamp=read_stamp(copy_root))
|
||||||
|
state.stamp_known = commit_exists(state.stamp.get("sha", ""))
|
||||||
|
|
||||||
|
def listing(root):
|
||||||
|
return {p.relative_to(root).as_posix() for p in root.rglob("*")
|
||||||
|
if p.is_file() and not graph.SKIP & set(p.relative_to(root).parts)
|
||||||
|
and p.suffix in (".ts", ".vue", ".css")}
|
||||||
|
|
||||||
|
ours, theirs = listing(src), listing(copy_src)
|
||||||
|
for rel in sorted(ours | theirs):
|
||||||
|
if rel not in theirs:
|
||||||
|
state.rows.append((rel, "missing", "in spr, not in the copy"))
|
||||||
|
elif rel not in ours:
|
||||||
|
state.rows.append((rel, "copy-only", "in the copy, not in spr"))
|
||||||
|
else:
|
||||||
|
a = (src / rel).read_text().splitlines()
|
||||||
|
b = (copy_src / rel).read_text().splitlines()
|
||||||
|
if a == b:
|
||||||
|
state.rows.append((rel, "same", ""))
|
||||||
|
continue
|
||||||
|
added = removed = 0
|
||||||
|
for line in difflib.unified_diff(a, b, lineterm="", n=0):
|
||||||
|
if line.startswith("+") and not line.startswith("+++"):
|
||||||
|
added += 1
|
||||||
|
elif line.startswith("-") and not line.startswith("---"):
|
||||||
|
removed += 1
|
||||||
|
# Dates, not a verdict. A worktree's mtimes are checkout times, so
|
||||||
|
# spr's side is dated by its last commit; the copy has no history,
|
||||||
|
# so its mtime is all there is.
|
||||||
|
ours_date = git("log", "-1", "--format=%cs", "--", str((src / rel).resolve()))
|
||||||
|
theirs_date = datetime.fromtimestamp((copy_src / rel).stat().st_mtime).strftime("%Y-%m-%d")
|
||||||
|
state.rows.append((rel, "differs",
|
||||||
|
f"+{added} -{removed} in the copy · copy {theirs_date}, "
|
||||||
|
f"spr last commit {ours_date or '?'}"))
|
||||||
|
return state
|
||||||
348
soleprint/common/slice/selftest.py
Normal file
348
soleprint/common/slice/selftest.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""
|
||||||
|
Prove slices on spr's own tree, writing only into a temporary directory.
|
||||||
|
|
||||||
|
python3 -m common.slice.selftest (from soleprint/; or make ui selftest)
|
||||||
|
|
||||||
|
Nothing in spr is written, and nothing is installed. The negative cases carry the
|
||||||
|
same weight as the positive ones; the checks that are the *design* rather than a
|
||||||
|
regression are marked (design), and they are the ones to keep if anything is cut:
|
||||||
|
|
||||||
|
a slice holds the closure, not the library it is never a bundle
|
||||||
|
a vendored slice fails on any spr word no --force past the scrub
|
||||||
|
a hand edit in dest is refused, not overwritten drift is reported both ways
|
||||||
|
a vendored slice needs an explicit notice the tool never picks licensing
|
||||||
|
a surface that does not parse is a failure by name the reference never thins silently
|
||||||
|
a generated doc edited by hand is refused the notes, not the output, hold prose
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import css, graph, manifest, names, plexus, runfile, scan, slices, surface, tokens
|
||||||
|
from . import reference as docs
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
PASS, FAIL = [], []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, condition, detail=""):
|
||||||
|
(PASS if condition else FAIL).append(name)
|
||||||
|
print(f" {'ok ' if condition else 'FAIL'} {name}" + ("" if condition or not detail
|
||||||
|
else f"\n {detail}"))
|
||||||
|
|
||||||
|
|
||||||
|
def section(title):
|
||||||
|
print(f"\n{title}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_file(tmp: Path, body: str, name="ui.toml") -> Path:
|
||||||
|
path = tmp / name
|
||||||
|
path.write_text(body)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path):
|
||||||
|
return runfile.load(path, tokens.available_themes())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
nodes = graph.load()
|
||||||
|
exports = graph.barrel(nodes=nodes)
|
||||||
|
|
||||||
|
section("the graph")
|
||||||
|
check("Panel.vue imports no other library file", not nodes["components/Panel.vue"].imports)
|
||||||
|
check("an SFC always depends on vue, even with no import",
|
||||||
|
"vue" in nodes["components/Panel.vue"].packages)
|
||||||
|
check("useDataSource reaches DataSource",
|
||||||
|
"datasources/DataSource.ts" in nodes["composables/useDataSource.ts"].imports)
|
||||||
|
check("theme.css imports tokens.css and base.css",
|
||||||
|
nodes["theme.css"].imports == {"tokens.css", "base.css"})
|
||||||
|
check("a type-only import is an edge (plugins -> InteractionPlugin)",
|
||||||
|
"plugins/InteractionPlugin.ts" in nodes["plugins/BBoxDrawPlugin.ts"].imports)
|
||||||
|
check("the barrel's exports are all found", len(exports) >= 34, f"found {len(exports)}")
|
||||||
|
check("type exports are marked as types",
|
||||||
|
{e.name for e in exports if e.is_type} >= {"LogEntry", "ConfigField", "DataSourceStatus"})
|
||||||
|
|
||||||
|
section("closures")
|
||||||
|
c, unknown = graph.select(["Panel", "SplitPane"], [], exports, nodes)
|
||||||
|
check("(design) Panel + SplitPane is two files, not the library",
|
||||||
|
c == {"components/Panel.vue", "components/SplitPane.vue"}, sorted(c))
|
||||||
|
check("... and depends on vue alone", graph.dependencies(c, nodes) == {"vue": "^3.5"},
|
||||||
|
graph.dependencies(c, nodes))
|
||||||
|
c, _ = graph.select(["TimeSeriesRenderer"], [], exports, nodes)
|
||||||
|
check("TimeSeriesRenderer brings uplot, and only it", set(graph.dependencies(c, nodes)) == {"uplot", "vue"})
|
||||||
|
c, _ = graph.select(["GraphRenderer"], [], exports, nodes)
|
||||||
|
check("GraphRenderer brings @vue-flow/core", "@vue-flow/core" in graph.dependencies(c, nodes))
|
||||||
|
c, unknown = graph.select(["Panel", "NoSuchThing"], ["components/Nope.vue"], exports, nodes)
|
||||||
|
check("an unknown name or file is reported, not ignored",
|
||||||
|
set(unknown) == {"NoSuchThing", "components/Nope.vue"}, unknown)
|
||||||
|
c, _ = graph.select([], ["tokens.css", "components/Panel.vue"], exports, nodes)
|
||||||
|
check("the library's own token file is regenerated, never copied", "tokens.css" not in c)
|
||||||
|
|
||||||
|
section("tokens")
|
||||||
|
lib, theme = tokens.source(None), tokens.source("soleprint")
|
||||||
|
differ = {n for n in lib.table if tokens.resolved(lib, n) != tokens.resolved(theme, n)}
|
||||||
|
check("library and soleprint theme differ exactly where documented "
|
||||||
|
"(why Vue defaults to the library)", differ == {"--font-mono", "--font-ui", "--surface-3"},
|
||||||
|
sorted(differ))
|
||||||
|
block, missing = lib.block({"--panel-border"})
|
||||||
|
check("a chain travels whole: --panel-border brings --border",
|
||||||
|
"--border:" in block and "var(--border)" in block and not missing, block)
|
||||||
|
with_override = tokens.source(None, {"--border": "#ff0000"})
|
||||||
|
check("an override lands before aliases are read",
|
||||||
|
tokens.resolved(with_override, "--panel-border") == "1px solid #ff0000")
|
||||||
|
_, missing = lib.block({"--does-not-exist"})
|
||||||
|
check("a name nobody defines is reported", missing == ["--does-not-exist"])
|
||||||
|
check("the .waiting rule is found in the library file", ".waiting" in tokens.library_rules())
|
||||||
|
|
||||||
|
section("names and the scrub")
|
||||||
|
profile = names.load()
|
||||||
|
check("sprint is an English word, not a leak", not names.scrub("sprint planning", profile))
|
||||||
|
check("spr on its own is a leak", bool(names.scrub("built by spr", profile)))
|
||||||
|
check("a camelCase spr identifier is a leak", bool(names.scrub("sprHelper()", profile)))
|
||||||
|
renamed = names.rename("global.SprFeed = SprFeed // a vein page", profile)
|
||||||
|
check("identifiers rename in code, prose only in comments",
|
||||||
|
"LiveFeed" in renamed and "connector page" in renamed and "SprFeed" not in renamed, renamed)
|
||||||
|
renamed = names.rename("var vein = 1", profile)
|
||||||
|
check("a code identifier named like a prose term is left alone (and the scrub catches it)",
|
||||||
|
renamed == "var vein = 1" and names.scrub(renamed, profile))
|
||||||
|
check("a hyphenated identifier is replaced whole",
|
||||||
|
names.rename("'soleprint-ui'", profile) == "'ui-kit'")
|
||||||
|
check("the leading header is stripped, later comments kept",
|
||||||
|
names.strip_header("/* head */\na{}\n/* keep */\n", ".css") == "a{}\n/* keep */\n")
|
||||||
|
|
||||||
|
section("the run file")
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
tmp = Path(t)
|
||||||
|
try:
|
||||||
|
load(run_file(tmp, '[[slice]]\nname="x"\ntarget="vue"\ndelivery="vendored"\n'
|
||||||
|
'dest="d"\nuse=["Panel"]\n'))
|
||||||
|
check("(design) a vendored slice without notice is refused", False)
|
||||||
|
except runfile.ConfigError as e:
|
||||||
|
check("(design) a vendored slice without notice is refused",
|
||||||
|
any("notice" in p for p in e.problems), e.problems)
|
||||||
|
try:
|
||||||
|
load(run_file(tmp, '[[slice]]\nname="x"\ntarget="vue"\ndset="d"\nuse=["Panel"]\n'
|
||||||
|
'colour="red"\n[[slice]]\nname="y"\ntarget="plain"\ndest="e"\n'
|
||||||
|
'layout="flat"\n'))
|
||||||
|
check("every problem is reported in one pass", False)
|
||||||
|
except runfile.ConfigError as e:
|
||||||
|
check("every problem is reported in one pass", len(e.problems) >= 4, e.problems)
|
||||||
|
rf = load(run_file(tmp, '[[slice]]\nname="x"\ntarget="plain"\ndest="sub/out"\nuse=["panel"]\n'))
|
||||||
|
check("paths are relative to the run file", rf.slices[0].dest == tmp / "sub" / "out")
|
||||||
|
check("plain resolves through the theme contract by default", rf.slices[0].theme == "soleprint")
|
||||||
|
check("the manifest defaults beside the run file", rf.slices[0].manifest == tmp / "x.slice.json")
|
||||||
|
|
||||||
|
section("writing, and drift both ways")
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
tmp = Path(t)
|
||||||
|
rf = load(run_file(tmp, '[[slice]]\nname="app"\ntarget="vue"\ndest="fw"\n'
|
||||||
|
'use=["Panel","SplitPane"]\n'))
|
||||||
|
entry = rf.slices[0]
|
||||||
|
built = slices.build(entry)
|
||||||
|
steps = slices.plan(entry, built)
|
||||||
|
check("a first run creates every file", {a for _, a, _ in steps.actions} == {"create"})
|
||||||
|
slices.apply(entry, built, steps, force=False)
|
||||||
|
check("a dependency slice carries a .spr stamp in spr.py's format",
|
||||||
|
manifest.read_stamp(entry.dest).get("mode") == "slice")
|
||||||
|
check("the barrel exports only what the slice holds",
|
||||||
|
"LogRenderer" not in (entry.dest / "src/index.ts").read_text()
|
||||||
|
and "SplitPane" in (entry.dest / "src/index.ts").read_text())
|
||||||
|
check("a second run changes nothing", not slices.plan(entry, slices.build(entry)).stale)
|
||||||
|
|
||||||
|
panel = entry.dest / "src/components/Panel.vue"
|
||||||
|
panel.write_text(panel.read_text() + "\n<!-- local change -->\n")
|
||||||
|
steps = slices.plan(entry, slices.build(entry))
|
||||||
|
check("(design) a hand edit is a conflict",
|
||||||
|
[a for r, a, _ in steps.actions if r.endswith("Panel.vue")] == ["conflict"])
|
||||||
|
try:
|
||||||
|
slices.apply(entry, slices.build(entry), steps, force=False)
|
||||||
|
check("(design) ... and nothing is written without --force", False)
|
||||||
|
except ValueError:
|
||||||
|
check("(design) ... and nothing is written without --force",
|
||||||
|
"local change" in panel.read_text())
|
||||||
|
|
||||||
|
# Spr moving on: dest still holds what the manifest recorded, but the
|
||||||
|
# slice would now write something else. Simulated by recording the
|
||||||
|
# edited file as ours.
|
||||||
|
m = manifest.read(entry.manifest)
|
||||||
|
m["files"]["src/components/Panel.vue"]["sha256"] = manifest.file_sha(panel)
|
||||||
|
manifest.write(entry.manifest, m)
|
||||||
|
steps = slices.plan(entry, slices.build(entry))
|
||||||
|
check("a file the manifest trusts is an update, not a conflict",
|
||||||
|
[a for r, a, _ in steps.actions if r.endswith("Panel.vue")] == ["update"])
|
||||||
|
slices.apply(entry, slices.build(entry), steps, force=False)
|
||||||
|
|
||||||
|
rf = load(run_file(tmp, '[[slice]]\nname="app"\ntarget="vue"\ndest="fw"\nuse=["Panel"]\n'))
|
||||||
|
steps = slices.plan(rf.slices[0], slices.build(rf.slices[0]))
|
||||||
|
check("a file the slice no longer needs is deleted",
|
||||||
|
("src/components/SplitPane.vue", "delete", "no longer part of the slice") in steps.actions)
|
||||||
|
|
||||||
|
section("vendored")
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
tmp = Path(t)
|
||||||
|
(tmp / "NOTICE").write_text("Notice text chosen by the owner.\n")
|
||||||
|
rf = load(run_file(tmp, '[[slice]]\nname="v"\ntarget="vue"\ndelivery="vendored"\n'
|
||||||
|
'notice="NOTICE"\ndest="repo/ui"\nmanifest="private/v.json"\n'
|
||||||
|
'use=["Panel","SplitPane","SSEDataSource"]\n'))
|
||||||
|
entry = rf.slices[0]
|
||||||
|
built = slices.build(entry)
|
||||||
|
check("(design) a vendored slice of real pieces scrubs clean", not built.hits, built.hits[:3])
|
||||||
|
slices.apply(entry, built, slices.plan(entry, built), force=False)
|
||||||
|
written = sorted(p.relative_to(entry.dest).as_posix() for p in entry.dest.rglob("*") if p.is_file())
|
||||||
|
check("no stamp and no manifest land in the consumer's tree",
|
||||||
|
".spr" not in written and not any(w.endswith(".json") for w in written), written)
|
||||||
|
check("the manifest is written where the run file says", (tmp / "private/v.json").exists())
|
||||||
|
check("the notice is copied as given", (entry.dest / "NOTICE").read_text().startswith("Notice text"))
|
||||||
|
check("flat layout for a vendored slice", (entry.dest / "index.ts").exists())
|
||||||
|
leaks = [w for w in written for _ in names.scrub((entry.dest / w).read_text(), names.load())]
|
||||||
|
check("an independent scrub of the written files finds nothing", not leaks, leaks)
|
||||||
|
|
||||||
|
page = tmp / "page.html"
|
||||||
|
page.write_text('<div class="panel" data-maximize><div class="panel-header">'
|
||||||
|
'<span class="panel-title">x</span></div></div>\n'
|
||||||
|
'<script>var f = new LiveFeed({url:"/s"})</script>\n')
|
||||||
|
rf = load(run_file(tmp, '[[slice]]\nname="p"\ntarget="plain"\ndelivery="vendored"\n'
|
||||||
|
'notice="none"\ndest="plain"\nscan=["page.html"]\n', "plain.toml"))
|
||||||
|
built = slices.build(rf.slices[0])
|
||||||
|
check("maximize brings panel; base comes with any part",
|
||||||
|
{"base", "panel", "maximize"} <= set(built.closure), built.closure)
|
||||||
|
check("a vendored page calling the renamed global still selects feed", "feed" in built.closure,
|
||||||
|
built.closure)
|
||||||
|
check("the renamed global is what ships", "LiveFeed" in built.files.get("ui.js", "")
|
||||||
|
and "SprFeed" not in built.files.get("ui.js", ""))
|
||||||
|
check("the plain slice scrubs clean", not built.hits, built.hits[:3])
|
||||||
|
js = built.files.get("ui.js", "")
|
||||||
|
chunks = [c.strip() for c in re.split(r"(?m)^/\* [\w-]+ \*/$", js) if c.strip()]
|
||||||
|
check("parts joined into one ui.js are each terminated (an unterminated IIFE "
|
||||||
|
"turns the next one into a call)",
|
||||||
|
chunks and all(c.endswith(";") for c in chunks), [c[-30:] for c in chunks])
|
||||||
|
|
||||||
|
section("the plexus")
|
||||||
|
page = '<div class="card"><h2>x</h2></div><script>el.className = "dyn"</script>'
|
||||||
|
kept, _ = css.shake('.card{a:1} .gone{b:2} h2{c:3} .dyn{d:4} @keyframes spin{} '
|
||||||
|
'.card{animation: spin 1s}', page)
|
||||||
|
preludes = [r.prelude for r in kept]
|
||||||
|
check("a rule whose class never appears is dropped", ".gone" not in preludes, preludes)
|
||||||
|
check("a class named only in script still counts", ".dyn" in preludes, preludes)
|
||||||
|
check("element rules stay; referenced keyframes stay",
|
||||||
|
"h2" in preludes and "@keyframes spin" in preludes, preludes)
|
||||||
|
try:
|
||||||
|
plexus.resolve("bundle", delivery="vendored")
|
||||||
|
check("(design) a vendored plexus without notice is refused", False)
|
||||||
|
except plexus.PlexusError as e:
|
||||||
|
check("(design) a vendored plexus without notice is refused", "notice" in str(e))
|
||||||
|
b = plexus.resolve("bundle")
|
||||||
|
check("a plexus needs nothing built first: it lands in gen/plexuses/",
|
||||||
|
b.dest == plexus.GEN / "bundle" / "index.html")
|
||||||
|
built = plexus.build(b)
|
||||||
|
check("every placeholder is filled", not re.search(r"%%[A-Z_]+%%", built.html))
|
||||||
|
check("each theme it can switch to is carried, and nothing it cannot use",
|
||||||
|
all(f'[data-theme="{t}"]' in built.html for t in ("lucid", "soleprint", "mcrn"))
|
||||||
|
and "#spr-theme-toggle" not in built.html and ".prompt-list" not in built.html)
|
||||||
|
vendored = plexus.build(plexus.resolve("bundle", delivery="vendored", notice="none"))
|
||||||
|
check("(design) spr's own showcase cannot be vendored — its content is spr",
|
||||||
|
bool(vendored.hits))
|
||||||
|
check("no plexus code is left in build.py",
|
||||||
|
"plexus" not in (manifest.REPO / "build.py").read_text().lower())
|
||||||
|
|
||||||
|
section("consumers (read-only, skipped when absent)")
|
||||||
|
meetus = Path.home() / "wdir/semester/mts/ui/meetus-app"
|
||||||
|
if meetus.exists():
|
||||||
|
u = scan.usage([meetus / "src"], nodes=nodes)
|
||||||
|
check("meetus is read through deep paths",
|
||||||
|
u.files >= {"components/Panel.vue", "components/SplitPane.vue", "tokens.css"} and u.deep > 0)
|
||||||
|
copy = scan.find_copy(meetus)
|
||||||
|
check("its framework copy is found through the Vite alias", copy and copy.name == "framework")
|
||||||
|
else:
|
||||||
|
print(" skip meetus snapshot not on this machine")
|
||||||
|
|
||||||
|
section("the surface (what the ui reference is generated from)")
|
||||||
|
surf = surface.load()
|
||||||
|
panel = surf["components/Panel.vue"]
|
||||||
|
check("Panel's props, in order", [m.name for m in panel.props] == ["title", "status", "maximizable"],
|
||||||
|
[m.name for m in panel.props])
|
||||||
|
check("... with required-ness and withDefaults defaults",
|
||||||
|
panel.props[0].required and not panel.props[1].required and panel.props[2].default == "true")
|
||||||
|
check("... a doc comment lands on its prop", panel.props[1].doc.startswith("Omit to show"))
|
||||||
|
check("a defineModel is a v-model with its default",
|
||||||
|
[(m.name, m.type, m.default) for m in panel.models] == [("maximized", "boolean", "false")])
|
||||||
|
check("named and default slots are found", panel.slots == ["actions", "default", "overlay"], panel.slots)
|
||||||
|
check("quoted and colon-named emits parse (VideoPlayer)",
|
||||||
|
{"update:currentTime", "ended"} <= {e.name for e in surf["components/VideoPlayer.vue"].emits})
|
||||||
|
check("a file doc comment is its summary, a prop's is not",
|
||||||
|
surf["components/VideoPlayer.vue"].summary.startswith("Agnostic") and not panel.summary)
|
||||||
|
check("a .ts file's exports are declarations",
|
||||||
|
("abstract class", "DataSource") in [(k, n) for k, n, _ in surf["datasources/DataSource.ts"].declarations])
|
||||||
|
broken = [s.file for s in surf.values() if s.error]
|
||||||
|
check("(design) every component in the library parses", not broken, broken)
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
bad = Path(t) / "Broken.vue"
|
||||||
|
bad.write_text("<script setup lang=\"ts\">\ndefineProps<{ title: string\n</script>\n")
|
||||||
|
check("(design) a surface that does not parse is recorded by name, not dropped",
|
||||||
|
surface.read(bad, "Broken.vue").error is not None)
|
||||||
|
|
||||||
|
section("the ui reference")
|
||||||
|
inv = docs.inventory()
|
||||||
|
check("the inventory is deterministic: two builds, one document", inv == docs.inventory())
|
||||||
|
visual = {"color", "colour", "fill", "stroke", "shape", "x", "y", "width", "height", "position"}
|
||||||
|
keys = {k for n in inv["nodes"] for k in n["attrs"]}
|
||||||
|
check("no visual keys in the inventory — style belongs to whoever renders it",
|
||||||
|
not keys & visual, sorted(keys & visual))
|
||||||
|
ids = [n["id"] for n in inv["nodes"]]
|
||||||
|
check("node ids are unique", len(ids) == len(set(ids)))
|
||||||
|
check("every edge points at a node", all(e["source"] in ids and e["target"] in ids for e in inv["edges"]))
|
||||||
|
check("the reference reconciles with what was read, on spr's own tree",
|
||||||
|
all(ok for ok, _ in docs.reconcile(inv, {})), docs.reconcile(inv, {}))
|
||||||
|
check("a note for a spot that does not exist fails the reconcile",
|
||||||
|
not all(ok for ok, _ in docs.reconcile(inv, {"NoSuchExport": "text"})))
|
||||||
|
check("Panel's plain parts are found from the parts' own headers",
|
||||||
|
{e["source"] for e in inv["edges"] if e["kind"] == "binds" and e["target"] == "file:components/Panel.vue"}
|
||||||
|
== {"part:panel", "part:maximize"})
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
d = Path(t)
|
||||||
|
page = d / docs.PAGE
|
||||||
|
first = docs.build(d, write=True)
|
||||||
|
check("a first build writes the page and the inventory into the docs site",
|
||||||
|
set(first.written) == {docs.PAGE, docs.INVENTORY} and (d / docs.MANIFEST).exists())
|
||||||
|
check("a second build changes nothing", not docs.build(d, write=True).written)
|
||||||
|
check("--check on a current build passes", docs.build(d, write=False).ok)
|
||||||
|
page.write_text(page.read_text() + "\nhand edit\n")
|
||||||
|
edited = docs.build(d, write=True)
|
||||||
|
check("(design) a generated doc edited by hand is refused, not overwritten",
|
||||||
|
edited.edited == [docs.PAGE] and "hand edit" in page.read_text())
|
||||||
|
forced = docs.build(d, write=True, force=True)
|
||||||
|
check("... and --force discards it", "hand edit" not in page.read_text()
|
||||||
|
and docs.PAGE in forced.written)
|
||||||
|
check("the page is listed in the docs site's sidebar",
|
||||||
|
any(t.get("id") == "ui-reference" for t in json.loads(
|
||||||
|
(docs.SITE / "data" / "topics.json").read_text())))
|
||||||
|
|
||||||
|
section("the split")
|
||||||
|
offenders = []
|
||||||
|
for path in HERE.rglob("*.py"):
|
||||||
|
rel = path.relative_to(HERE)
|
||||||
|
if rel.parts[0] == "cli" or rel.as_posix() in ("__main__.py", "selftest.py"):
|
||||||
|
continue
|
||||||
|
for node in ast.walk(ast.parse(path.read_text())):
|
||||||
|
if isinstance(node, ast.Import) and any(a.name == "argparse" for a in node.names):
|
||||||
|
offenders.append(rel.as_posix())
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module == "argparse":
|
||||||
|
offenders.append(rel.as_posix())
|
||||||
|
check("nothing outside cli/ parses a command line", not offenders, offenders)
|
||||||
|
room = [p.relative_to(HERE).as_posix() for p in HERE.rglob("*")
|
||||||
|
if p.is_file() and p.suffix in (".py", ".json", ".toml")
|
||||||
|
and re.search(r"\broom", p.read_text(), re.I)
|
||||||
|
and p.name != "selftest.py"]
|
||||||
|
check("no file here uses the retired word", not room, room)
|
||||||
|
|
||||||
|
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
|
||||||
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
414
soleprint/common/slice/slices.py
Normal file
414
soleprint/common/slice/slices.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"""
|
||||||
|
Build a slice in memory, then write it — or say exactly why not.
|
||||||
|
|
||||||
|
Building never touches the disk. Writing is a separate step that compares what
|
||||||
|
would be written with what is there and with the manifest, and refuses as a
|
||||||
|
whole: a run that overwrote half a copy and then stopped on a hand edit would
|
||||||
|
leave the consumer worse off than either state.
|
||||||
|
|
||||||
|
Per file, one of:
|
||||||
|
|
||||||
|
create not in dest yet
|
||||||
|
same dest already holds exactly this
|
||||||
|
update dest holds what the last run wrote; spr has moved on
|
||||||
|
delete the last run wrote it and the slice no longer needs it
|
||||||
|
conflict dest differs from what the last run wrote — edited by hand, or
|
||||||
|
never written by this slice. Refused unless --force.
|
||||||
|
|
||||||
|
A vendored slice must also pass the scrub. That gate has no --force: a slice
|
||||||
|
that names spr is not a vendored slice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..theme import bake
|
||||||
|
from . import graph, names, scan, tokens
|
||||||
|
from .manifest import (REPO, STAMP, file_sha, read, sha256, source_state, stamp, write)
|
||||||
|
|
||||||
|
PARTS_DIR = graph.COMMON / "theme" / "parts"
|
||||||
|
UI_PACKAGE = graph.PACKAGE_JSON
|
||||||
|
|
||||||
|
# What a dependency slice says at the top of every file it generates. No path
|
||||||
|
# and no command: the same text is right in every consumer, on every machine.
|
||||||
|
PROVENANCE = ("Generated by spr (`make ui slice`) from {source}. "
|
||||||
|
"Edits here are overwritten; change spr, or keep a change with --force.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Built:
|
||||||
|
entry: object
|
||||||
|
files: dict = field(default_factory=dict) # dest-relative path -> text
|
||||||
|
origins: dict = field(default_factory=dict) # dest-relative path -> where it came from
|
||||||
|
selected: list = field(default_factory=list) # export names, or part names
|
||||||
|
closure: list = field(default_factory=list) # source files, or parts
|
||||||
|
deps: dict = field(default_factory=dict)
|
||||||
|
carried: list = field(default_factory=list) # custom properties in tokens
|
||||||
|
fell_back: list = field(default_factory=list)
|
||||||
|
missing: list = field(default_factory=list)
|
||||||
|
unknown: list = field(default_factory=list)
|
||||||
|
hits: list = field(default_factory=list) # (file, line, term, text)
|
||||||
|
notes: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked(self) -> list[str]:
|
||||||
|
why = []
|
||||||
|
if self.unknown:
|
||||||
|
why.append(f"unknown: {', '.join(sorted(self.unknown))}")
|
||||||
|
if self.hits:
|
||||||
|
why.append(f"scrub: {len(self.hits)} hit(s) in a vendored slice")
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
def _comment(text: str, suffix: str) -> str:
|
||||||
|
if suffix in (".ts", ".js"):
|
||||||
|
return "".join(f"// {line}".rstrip() + "\n" for line in text.splitlines())
|
||||||
|
if suffix in (".vue", ".html"):
|
||||||
|
return f"<!-- {text} -->\n"
|
||||||
|
return f"/* {text} */\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _header(entry, suffix: str, source: str) -> str:
|
||||||
|
if entry.header is not None:
|
||||||
|
return _comment(entry.header, suffix) if entry.header else ""
|
||||||
|
if entry.vendored:
|
||||||
|
return ""
|
||||||
|
return _comment(PROVENANCE.format(source=source), suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(built: Built, profile) -> None:
|
||||||
|
"""Vendored: rename through the profile, then scrub. Dependency: as is."""
|
||||||
|
if not built.entry.vendored:
|
||||||
|
return
|
||||||
|
for rel, text in list(built.files.items()):
|
||||||
|
if rel.endswith(".json") and rel != "package.json":
|
||||||
|
continue
|
||||||
|
text = names.rename(text, profile)
|
||||||
|
built.files[rel] = text
|
||||||
|
for line, term, content in names.scrub(text, profile):
|
||||||
|
built.hits.append((rel, line, term, content))
|
||||||
|
|
||||||
|
|
||||||
|
def _notice(entry, built: Built) -> None:
|
||||||
|
if entry.vendored and isinstance(entry.notice, Path):
|
||||||
|
built.files[entry.notice.name] = entry.notice.read_text()
|
||||||
|
built.origins[entry.notice.name] = str(entry.notice)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_file(entry, built: Built, wanted: set, defined: set, texts: list,
|
||||||
|
header: str) -> str:
|
||||||
|
src = tokens.source(None if entry.theme in (None, "library") else entry.theme, entry.tokens)
|
||||||
|
block, missing = src.block(wanted)
|
||||||
|
built.carried = sorted(src.closure(wanted) - set(missing))
|
||||||
|
built.fell_back = sorted(src.fell_back)
|
||||||
|
# A name the consumer declares itself is the consumer's, not a gap.
|
||||||
|
built.missing = sorted(set(missing) - defined)
|
||||||
|
rules = tokens.library_rules() if tokens.uses_waiting(texts) else ""
|
||||||
|
return header + block + (("\n" + rules) if rules else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _defined(texts) -> set:
|
||||||
|
"""Custom properties a consumer sets itself — in CSS, or from a Vue style
|
||||||
|
binding (`:style="{ '--toggle-color': c }"`), where the name is quoted."""
|
||||||
|
return {m for _, t in texts for m in re.findall(r"(--[\w-]+)['\"]?\s*:", t)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_vue(entry) -> Built:
|
||||||
|
built = Built(entry=entry)
|
||||||
|
profile = names.load(entry.rename)
|
||||||
|
nodes = graph.load()
|
||||||
|
exports = graph.barrel(nodes=nodes)
|
||||||
|
export_names = {e.name for e in exports}
|
||||||
|
|
||||||
|
use_names = [u for u in entry.use if u in export_names]
|
||||||
|
use_files = [u for u in entry.use if u not in export_names]
|
||||||
|
usage = scan.usage(entry.scan, entry.alias, exclude=[entry.dest], nodes=nodes) \
|
||||||
|
if entry.scan else scan.Usage()
|
||||||
|
|
||||||
|
wanted_names = sorted(set(use_names) | usage.names)
|
||||||
|
wanted_files = sorted((set(use_files) | usage.files) - graph.GENERATED - {"base.css"})
|
||||||
|
closure, unknown = graph.select(wanted_names, wanted_files, exports, nodes)
|
||||||
|
built.unknown = sorted(set(unknown) | usage.unknown)
|
||||||
|
built.selected = sorted(set(wanted_names) | set(wanted_files))
|
||||||
|
built.closure = sorted(closure)
|
||||||
|
built.deps = graph.dependencies(closure, nodes)
|
||||||
|
|
||||||
|
prefix = "src/" if entry.layout == "package" else ""
|
||||||
|
origin = "soleprint/common/ui/src"
|
||||||
|
|
||||||
|
for rel in sorted(closure):
|
||||||
|
text = (graph.SRC / rel).read_text()
|
||||||
|
if entry.vendored:
|
||||||
|
text = names.strip_header(text, Path(rel).suffix)
|
||||||
|
built.files[prefix + rel] = text
|
||||||
|
built.origins[prefix + rel] = f"{origin}/{rel}"
|
||||||
|
|
||||||
|
base = (graph.SRC / "base.css").read_text()
|
||||||
|
if entry.vendored:
|
||||||
|
base = names.strip_header(base, ".css")
|
||||||
|
built.files[prefix + "base.css"] = base
|
||||||
|
built.origins[prefix + "base.css"] = f"{origin}/base.css"
|
||||||
|
|
||||||
|
wanted_tokens = graph.tokens_read(closure | {"base.css"}, nodes) | usage.tokens
|
||||||
|
texts = [(graph.SRC / rel, (graph.SRC / rel).read_text()) for rel in closure] + usage.texts
|
||||||
|
built.files[prefix + "tokens.css"] = _token_file(
|
||||||
|
entry, built, wanted_tokens, _defined(usage.texts), [t for _, t in texts],
|
||||||
|
_header(entry, ".css", f"{origin}/tokens.css"))
|
||||||
|
built.origins[prefix + "tokens.css"] = "generated"
|
||||||
|
|
||||||
|
built.files[prefix + "theme.css"] = (_header(entry, ".css", f"{origin}/theme.css")
|
||||||
|
+ "@import './tokens.css';\n@import './base.css';\n")
|
||||||
|
built.origins[prefix + "theme.css"] = "generated"
|
||||||
|
|
||||||
|
built.files[prefix + "index.ts"] = _barrel(entry, exports, closure)
|
||||||
|
built.origins[prefix + "index.ts"] = "generated"
|
||||||
|
|
||||||
|
if entry.delivery == "dependency" or entry.package:
|
||||||
|
built.files["package.json"] = _package_json(entry, built.deps, profile)
|
||||||
|
built.origins["package.json"] = "generated"
|
||||||
|
|
||||||
|
_notice(entry, built)
|
||||||
|
_finish(built, profile)
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def _barrel(entry, exports, closure) -> str:
|
||||||
|
"""index.ts exporting what the full barrel exports from the files held — no more."""
|
||||||
|
header = _header(entry, ".ts", "soleprint/common/ui/src/index.ts")
|
||||||
|
lines = [header.rstrip("\n")] if header else []
|
||||||
|
lines += ["import './theme.css'", ""]
|
||||||
|
by_file: dict[str, list] = {}
|
||||||
|
for e in exports:
|
||||||
|
if e.file in closure:
|
||||||
|
by_file.setdefault(e.file, []).append(e)
|
||||||
|
for file, items in by_file.items():
|
||||||
|
spec = "./" + (file[:-3] if file.endswith(".ts") else file)
|
||||||
|
values = [e for e in items if not e.is_type]
|
||||||
|
types = [e for e in items if e.is_type]
|
||||||
|
if values:
|
||||||
|
names_ = ", ".join(e.local if e.local == e.name else f"{e.local} as {e.name}"
|
||||||
|
for e in values)
|
||||||
|
lines.append(f"export {{ {names_} }} from '{spec}'")
|
||||||
|
if types:
|
||||||
|
lines.append(f"export type {{ {', '.join(e.name for e in types)} }} from '{spec}'")
|
||||||
|
return "\n".join(lines).lstrip("\n") + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _package_json(entry, deps: dict, profile) -> str:
|
||||||
|
version = json.loads(UI_PACKAGE.read_text()).get("version", "0.0.0")
|
||||||
|
if isinstance(entry.package, str):
|
||||||
|
name = entry.package
|
||||||
|
elif entry.vendored:
|
||||||
|
name = profile.identifiers.get("soleprint-ui", entry.name)
|
||||||
|
else:
|
||||||
|
name = "soleprint-ui"
|
||||||
|
src = "./src/" if entry.layout == "package" else "./"
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"private": True,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts" if entry.layout == "package" else "index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": f"{src}index.ts",
|
||||||
|
"./tokens.css": f"{src}tokens.css",
|
||||||
|
"./theme.css": f"{src}theme.css",
|
||||||
|
"./base.css": f"{src}base.css",
|
||||||
|
**({"./src/*": "./src/*"} if entry.layout == "package" else {"./*": "./*"}),
|
||||||
|
},
|
||||||
|
"dependencies": deps,
|
||||||
|
}
|
||||||
|
return json.dumps(data, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _needs(name: str, parts: dict) -> list[str]:
|
||||||
|
header = bake.part_header(name, parts[name][0])
|
||||||
|
line = bake.field(header, "NEEDS")
|
||||||
|
found = [w for w in re.findall(r"[a-z][\w-]*", line) if w in parts and w != name]
|
||||||
|
return list(dict.fromkeys(found))
|
||||||
|
|
||||||
|
|
||||||
|
def build_plain(entry) -> Built:
|
||||||
|
built = Built(entry=entry)
|
||||||
|
profile = names.load(entry.rename)
|
||||||
|
parts = bake.load_parts()
|
||||||
|
reverse = profile.reverse() if entry.vendored else {}
|
||||||
|
|
||||||
|
wanted = set()
|
||||||
|
for u in entry.use:
|
||||||
|
(wanted.add if u in parts else built.unknown.append)(u)
|
||||||
|
usage = scan.usage(entry.scan, (), exclude=[entry.dest]) if entry.scan else scan.Usage()
|
||||||
|
for path, text in usage.texts:
|
||||||
|
# A vendored page calls the renamed globals; read it back in spr's words
|
||||||
|
# so bake's DETECT lines still find them.
|
||||||
|
for new, old in reverse.items():
|
||||||
|
text = re.sub(rf"(?<![\w$]){re.escape(new)}(?![\w$])", old, text)
|
||||||
|
wanted |= set(bake.parts_used(text, parts))
|
||||||
|
|
||||||
|
# What a part needs comes with it; the element layer comes with any part.
|
||||||
|
stack = list(wanted)
|
||||||
|
while stack:
|
||||||
|
for dep in _needs(stack.pop(), parts):
|
||||||
|
if dep not in wanted:
|
||||||
|
wanted.add(dep)
|
||||||
|
stack.append(dep)
|
||||||
|
if wanted:
|
||||||
|
wanted |= {n for n, p in parts.items() if p[3]}
|
||||||
|
|
||||||
|
order, placed = [], set()
|
||||||
|
|
||||||
|
def place(n):
|
||||||
|
if n in placed:
|
||||||
|
return
|
||||||
|
for d in _needs(n, parts):
|
||||||
|
place(d)
|
||||||
|
placed.add(n)
|
||||||
|
order.append(n)
|
||||||
|
|
||||||
|
for n in sorted(wanted, key=lambda n: (not parts[n][3], n)):
|
||||||
|
place(n)
|
||||||
|
built.selected = sorted(set(entry.use))
|
||||||
|
built.closure = order
|
||||||
|
|
||||||
|
def body(n, ext):
|
||||||
|
path = PARTS_DIR / f"{n}.{ext}"
|
||||||
|
text = path.read_text() if path.exists() else ""
|
||||||
|
stripped = re.sub(r"\A\s*/\*.*?\*/\s*", "", text, count=1, flags=re.S).rstrip()
|
||||||
|
if ext == "js" and stripped and not stripped.endswith(";"):
|
||||||
|
# Each part is an IIFE written without semicolons: fine alone in its
|
||||||
|
# own <script>, as bake places it. Joined into one file, `})(window)`
|
||||||
|
# followed by `(function …` parses as a call and the page dies — so
|
||||||
|
# every part is terminated before it is joined.
|
||||||
|
stripped += ";"
|
||||||
|
label = n if entry.vendored else f"part: {n} — common/theme/parts/{n}.{ext}"
|
||||||
|
return f"/* {label} */\n{stripped}\n" if stripped else ""
|
||||||
|
|
||||||
|
css = [body(n, "css") for n in order]
|
||||||
|
js = [body(n, "js") for n in order]
|
||||||
|
wanted_tokens = set()
|
||||||
|
for n in order:
|
||||||
|
wanted_tokens |= tokens.read(parts[n][0])
|
||||||
|
js_path = PARTS_DIR / f"{n}.js"
|
||||||
|
if js_path.exists():
|
||||||
|
wanted_tokens |= tokens.read(js_path.read_text())
|
||||||
|
wanted_tokens |= usage.tokens
|
||||||
|
|
||||||
|
origin = "soleprint/common/theme/parts"
|
||||||
|
token_css = _token_file(entry, built, wanted_tokens, _defined(usage.texts),
|
||||||
|
[t for _, t in usage.texts], "")
|
||||||
|
header = _header(entry, ".css", origin)
|
||||||
|
built.files["ui.css"] = header + token_css + "\n" + "\n".join(c for c in css if c)
|
||||||
|
built.origins["ui.css"] = origin
|
||||||
|
if any(js):
|
||||||
|
built.files["ui.js"] = _header(entry, ".js", origin) + "\n".join(j for j in js if j)
|
||||||
|
built.origins["ui.js"] = origin
|
||||||
|
|
||||||
|
_notice(entry, built)
|
||||||
|
_finish(built, profile)
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def build(entry) -> Built:
|
||||||
|
return build_vue(entry) if entry.target == "vue" else build_plain(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Plan:
|
||||||
|
actions: list = field(default_factory=list) # (rel, action, detail)
|
||||||
|
|
||||||
|
def of(self, *kinds) -> list:
|
||||||
|
return [a for a in self.actions if a[1] in kinds]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stale(self) -> bool:
|
||||||
|
return bool(self.of("create", "update", "delete", "conflict"))
|
||||||
|
|
||||||
|
|
||||||
|
def plan(entry, built: Built) -> Plan:
|
||||||
|
old = read(entry.manifest) or {}
|
||||||
|
recorded = {rel: meta.get("sha256") for rel, meta in old.get("files", {}).items()}
|
||||||
|
out = Plan()
|
||||||
|
for rel, text in built.files.items():
|
||||||
|
current = file_sha(entry.dest / rel)
|
||||||
|
if current is None:
|
||||||
|
out.actions.append((rel, "create", ""))
|
||||||
|
elif current == sha256(text):
|
||||||
|
out.actions.append((rel, "same", ""))
|
||||||
|
elif recorded.get(rel) == current:
|
||||||
|
out.actions.append((rel, "update", "spr moved on"))
|
||||||
|
else:
|
||||||
|
why = "edited by hand since the last run" if rel in recorded \
|
||||||
|
else "exists, and was not written by this slice"
|
||||||
|
out.actions.append((rel, "conflict", why))
|
||||||
|
for rel in sorted(set(recorded) - set(built.files)):
|
||||||
|
current = file_sha(entry.dest / rel)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
if current == recorded[rel]:
|
||||||
|
out.actions.append((rel, "delete", "no longer part of the slice"))
|
||||||
|
else:
|
||||||
|
out.actions.append((rel, "conflict", "no longer part of the slice, and edited by hand"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply(entry, built: Built, steps: Plan, force: bool) -> list[str]:
|
||||||
|
"""Write the slice. Returns what changed. Raises ValueError when refused."""
|
||||||
|
if built.blocked:
|
||||||
|
raise ValueError("; ".join(built.blocked))
|
||||||
|
conflicts = steps.of("conflict")
|
||||||
|
if conflicts and not force:
|
||||||
|
raise ValueError(f"{len(conflicts)} file(s) in dest differ from what this slice last "
|
||||||
|
f"wrote — nothing written. Bring a real change back to spr, or rerun "
|
||||||
|
f"with --force to overwrite: "
|
||||||
|
+ ", ".join(rel for rel, _, _ in conflicts))
|
||||||
|
changed = []
|
||||||
|
for rel, action, _ in steps.actions:
|
||||||
|
target = entry.dest / rel
|
||||||
|
if action in ("create", "update") or (action == "conflict" and rel in built.files):
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(built.files[rel])
|
||||||
|
changed.append(f"{'overwrite' if action == 'conflict' else action:<8} {rel}")
|
||||||
|
elif action == "delete" or (action == "conflict" and rel not in built.files):
|
||||||
|
target.unlink()
|
||||||
|
changed.append(f"delete {rel}")
|
||||||
|
|
||||||
|
old = read(entry.manifest) or {}
|
||||||
|
inputs = [graph.SRC, PARTS_DIR, tokens.CONTRACT, tokens.THEMES, Path(__file__).parent]
|
||||||
|
manifest = {
|
||||||
|
"slice": entry.name,
|
||||||
|
"target": entry.target,
|
||||||
|
"delivery": entry.delivery,
|
||||||
|
"layout": entry.layout if entry.target == "vue" else None,
|
||||||
|
# The source only moves when the files do, so a new spr commit that
|
||||||
|
# changes nothing here does not churn the consumer's repository.
|
||||||
|
"source": source_state(inputs) if changed or "source" not in old else old["source"],
|
||||||
|
"selected": built.selected,
|
||||||
|
"closure": built.closure,
|
||||||
|
"deps": built.deps,
|
||||||
|
"tokens": {"source": entry.theme or "library", "carried": built.carried,
|
||||||
|
"fell_back": built.fell_back, "missing": built.missing,
|
||||||
|
"overrides": entry.tokens},
|
||||||
|
"renames": names.load(entry.rename).identifiers if entry.vendored else {},
|
||||||
|
"notice": (entry.notice.name if isinstance(entry.notice, Path) else entry.notice),
|
||||||
|
"files": {rel: {"from": built.origins.get(rel, "generated"), "sha256": sha256(text)}
|
||||||
|
for rel, text in sorted(built.files.items())},
|
||||||
|
}
|
||||||
|
if manifest != old:
|
||||||
|
write(entry.manifest, manifest)
|
||||||
|
changed.append(f"manifest {entry.manifest}")
|
||||||
|
if entry.delivery == "dependency" and (changed or not (entry.dest / STAMP).exists()):
|
||||||
|
kind = "vue-slice" if entry.target == "vue" else "plain-slice"
|
||||||
|
version = json.loads(UI_PACKAGE.read_text()).get("version", "?")
|
||||||
|
source = graph.SRC if entry.target == "vue" else PARTS_DIR
|
||||||
|
(entry.dest / STAMP).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
(entry.dest / STAMP).write_text(stamp(entry.name, kind, version, source))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def repo_relative(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.resolve().relative_to(REPO).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
219
soleprint/common/slice/surface.py
Normal file
219
soleprint/common/slice/surface.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""
|
||||||
|
What each file of common/ui/src offers a consumer: props, models, emits, slots,
|
||||||
|
and the declarations a .ts file exports.
|
||||||
|
|
||||||
|
graph.py answers "what does this file need". This answers "what does it give",
|
||||||
|
and between them they are everything the reference in common/ui/docs says —
|
||||||
|
read out of the source, so the reference cannot describe a prop that is gone.
|
||||||
|
|
||||||
|
Same method as graph.py and for the same reason: patterns, not a TypeScript
|
||||||
|
parser. The library writes its surface one way — `defineProps<{…}>()`, optionally
|
||||||
|
inside `withDefaults(…, {…})`, `defineEmits<{…}>()`, `defineModel<T>('name', …)`,
|
||||||
|
`<slot name="…">` — and the selftest pins what each of those must yield. A file
|
||||||
|
whose macros are there but do not parse is **recorded as a failure, by name**,
|
||||||
|
never dropped: a component with its props silently missing reads as a component
|
||||||
|
with no props, and that is the lie a reference must not tell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import graph
|
||||||
|
|
||||||
|
DOC = re.compile(r"/\*\*(.*?)\*/", re.S)
|
||||||
|
SLOT = re.compile(r"<slot\b([^>]*)/?>")
|
||||||
|
SLOT_NAME = re.compile(r"""\bname\s*=\s*["']([^"']+)["']""")
|
||||||
|
MODEL = re.compile(
|
||||||
|
r"defineModel\s*<\s*(?P<type>[^>]+?)\s*>\s*\(\s*(?:['\"](?P<name>[\w:-]+)['\"])?"
|
||||||
|
r"\s*(?:,\s*\{(?P<opts>[^}]*)\})?\s*\)")
|
||||||
|
EXPORT_DECL = re.compile(
|
||||||
|
r"(?:(?P<doc>/\*\*(?:(?!\*/).)*\*/)\s*)?"
|
||||||
|
r"^export\s+(?P<abstract>abstract\s+)?(?P<kind>class|function|interface|type|const)\s+"
|
||||||
|
r"(?P<name>[A-Za-z_]\w*)", re.S | re.M)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Member:
|
||||||
|
"""One prop, emit or model."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
required: bool = True
|
||||||
|
default: str | None = None
|
||||||
|
doc: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Surface:
|
||||||
|
"""What one file offers."""
|
||||||
|
|
||||||
|
file: str
|
||||||
|
props: list = field(default_factory=list)
|
||||||
|
models: list = field(default_factory=list)
|
||||||
|
emits: list = field(default_factory=list)
|
||||||
|
slots: list = field(default_factory=list)
|
||||||
|
declarations: list = field(default_factory=list) # (kind, name, doc)
|
||||||
|
summary: str = "" # first doc paragraph, if any
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_text(raw: str) -> str:
|
||||||
|
lines = [ln.strip().lstrip("*").strip() for ln in raw.strip("/*").splitlines()]
|
||||||
|
return " ".join(ln for ln in lines if ln).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _balanced(text: str, start: int, open_ch="{", close_ch="}") -> int:
|
||||||
|
"""Index just past the bracket that closes the one at `start`, or -1."""
|
||||||
|
depth = 0
|
||||||
|
for i in range(start, len(text)):
|
||||||
|
c = text[i]
|
||||||
|
if c == open_ch:
|
||||||
|
depth += 1
|
||||||
|
elif c == close_ch:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i + 1
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _split_top(text: str, seps=",\n;") -> list[str]:
|
||||||
|
"""Split on separators that are not nested in brackets or quotes."""
|
||||||
|
out, depth, quote, cur = [], 0, None, []
|
||||||
|
for c in text:
|
||||||
|
if quote:
|
||||||
|
cur.append(c)
|
||||||
|
if c == quote:
|
||||||
|
quote = None
|
||||||
|
continue
|
||||||
|
if c in "'\"`":
|
||||||
|
quote = c
|
||||||
|
elif c in "([{<":
|
||||||
|
depth += 1
|
||||||
|
elif c in ")]}>":
|
||||||
|
depth -= 1
|
||||||
|
if c in seps and depth == 0:
|
||||||
|
out.append("".join(cur))
|
||||||
|
cur = []
|
||||||
|
else:
|
||||||
|
cur.append(c)
|
||||||
|
out.append("".join(cur))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _members(body: str) -> list[Member]:
|
||||||
|
"""The members of a type literal: `/** doc */ name?: type`, one per entry."""
|
||||||
|
out, pending_doc = [], ""
|
||||||
|
# Doc comments are pulled out first and attached to the member after them.
|
||||||
|
pieces = re.split(r"(/\*\*.*?\*/)", body, flags=re.S)
|
||||||
|
for piece in pieces:
|
||||||
|
if piece.startswith("/**"):
|
||||||
|
pending_doc = _doc_text(piece)
|
||||||
|
continue
|
||||||
|
for chunk in _split_top(piece):
|
||||||
|
chunk = re.sub(r"//[^\n]*", "", chunk).strip()
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
m = re.match(r"""^['"]?([\w:-]+)['"]?\s*(\?)?\s*:\s*(.+)$""", chunk, re.S)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
out.append(Member(name=m.group(1), type=" ".join(m.group(3).split()),
|
||||||
|
required=not m.group(2), doc=pending_doc))
|
||||||
|
pending_doc = ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults(text: str) -> dict[str, str]:
|
||||||
|
"""`{ a: 1, b: () => [] }` → {"a": "1", "b": "() => []"}, values as written."""
|
||||||
|
out = {}
|
||||||
|
for chunk in _split_top(text.strip()[1:-1], seps=",\n"):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
m = re.match(r"^['\"]?([\w-]+)['\"]?\s*:\s*(.+)$", chunk, re.S)
|
||||||
|
if m:
|
||||||
|
out[m.group(1)] = " ".join(m.group(2).split())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _macro_body(script: str, macro: str) -> tuple[str | None, int]:
|
||||||
|
"""The `{…}` type literal of `macro<{…}>()`, and where it ends."""
|
||||||
|
m = re.search(macro + r"\s*<\s*\{", script)
|
||||||
|
if not m:
|
||||||
|
return None, -1
|
||||||
|
start = m.end() - 1
|
||||||
|
end = _balanced(script, start)
|
||||||
|
if end < 0:
|
||||||
|
return None, -1
|
||||||
|
return script[start + 1:end - 1], end
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: Path, rel: str) -> Surface:
|
||||||
|
text = path.read_text()
|
||||||
|
s = Surface(file=rel)
|
||||||
|
script, _ = graph.code_of(path, text)
|
||||||
|
|
||||||
|
for m in EXPORT_DECL.finditer(script):
|
||||||
|
kind = ("abstract class" if m.group("abstract") else m.group("kind"))
|
||||||
|
s.declarations.append((kind, m.group("name"), _doc_text(m.group("doc") or "")))
|
||||||
|
|
||||||
|
# A file's summary is a doc comment with nothing but imports before it. The
|
||||||
|
# first doc comment is not enough: in Panel.vue it belongs to a prop.
|
||||||
|
lead = DOC.search(script)
|
||||||
|
if lead:
|
||||||
|
before = re.sub(r"^\s*import\b[^\n]*$", "", script[:lead.start()], flags=re.M)
|
||||||
|
if not before.strip():
|
||||||
|
first = _doc_text(lead.group(0))
|
||||||
|
s.summary = first.split(". ")[0].rstrip(".") + "."
|
||||||
|
|
||||||
|
if path.suffix != ".vue":
|
||||||
|
return s
|
||||||
|
|
||||||
|
if "defineProps" in script:
|
||||||
|
body, end = _macro_body(script, "defineProps")
|
||||||
|
if body is None:
|
||||||
|
s.error = "defineProps is present but its type literal did not parse"
|
||||||
|
else:
|
||||||
|
s.props = _members(body)
|
||||||
|
# withDefaults(defineProps<{…}>(), { … }) — the defaults follow `)`.
|
||||||
|
tail = script[end:]
|
||||||
|
dm = re.match(r"\s*>\s*\(\s*\)\s*,\s*\{", tail)
|
||||||
|
if dm and "withDefaults" in script[:end]:
|
||||||
|
d_start = end + dm.end() - 1
|
||||||
|
d_end = _balanced(script, d_start)
|
||||||
|
if d_end > 0:
|
||||||
|
defaults = _defaults(script[d_start:d_end])
|
||||||
|
for p in s.props:
|
||||||
|
if p.name in defaults:
|
||||||
|
p.default = defaults[p.name]
|
||||||
|
|
||||||
|
if "defineEmits" in script:
|
||||||
|
body, _ = _macro_body(script, "defineEmits")
|
||||||
|
if body is None:
|
||||||
|
s.error = (s.error + "; " if s.error else "") + \
|
||||||
|
"defineEmits is present but its type literal did not parse"
|
||||||
|
else:
|
||||||
|
s.emits = _members(body)
|
||||||
|
|
||||||
|
for m in MODEL.finditer(script):
|
||||||
|
opts = m.group("opts") or ""
|
||||||
|
dflt = re.search(r"default\s*:\s*([^,}]+)", opts)
|
||||||
|
s.models.append(Member(name=m.group("name") or "modelValue", type=m.group("type").strip(),
|
||||||
|
required=bool(re.search(r"required\s*:\s*true", opts)),
|
||||||
|
default=dflt.group(1).strip() if dflt else None))
|
||||||
|
|
||||||
|
template = re.sub(r"<script\b.*?</script>|<style\b.*?</style>", "", text, flags=re.S)
|
||||||
|
for m in SLOT.finditer(template):
|
||||||
|
name = SLOT_NAME.search(m.group(1))
|
||||||
|
slot = name.group(1) if name else "default"
|
||||||
|
if slot not in s.slots:
|
||||||
|
s.slots.append(slot)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def load(src: Path = graph.SRC) -> dict[str, Surface]:
|
||||||
|
"""Every .vue/.ts file's surface, keyed like graph.load()."""
|
||||||
|
out = {}
|
||||||
|
for p in sorted(src.rglob("*")):
|
||||||
|
rel = p.relative_to(src).as_posix()
|
||||||
|
if p.is_file() and p.suffix in (".vue", ".ts") and not graph.SKIP & set(p.relative_to(src).parts):
|
||||||
|
out[rel] = read(p, rel)
|
||||||
|
return out
|
||||||
162
soleprint/common/slice/tokens.py
Normal file
162
soleprint/common/slice/tokens.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
Where a slice's custom properties come from, and which of them it carries.
|
||||||
|
|
||||||
|
Two sources, and the default differs by target on purpose:
|
||||||
|
|
||||||
|
library common/ui/src/tokens.css — what every Vue consumer renders today.
|
||||||
|
The default for target = "vue", because swapping a consumer's copy
|
||||||
|
for a slice must not change a pixel.
|
||||||
|
<theme> common/theme/tokens.css + themes/<theme>.css — the theme contract
|
||||||
|
every plain page already uses. The default for target = "plain"
|
||||||
|
(as `soleprint`), and opt-in for Vue with `theme = "…"`.
|
||||||
|
|
||||||
|
The two agree on almost every name but not all: the contract derives
|
||||||
|
--surface-3 from --border, and its font stacks are chosen for a locked-down
|
||||||
|
Windows box. Resolving a Vue slice through a theme is therefore a visible
|
||||||
|
change, which is exactly why it has to be asked for.
|
||||||
|
|
||||||
|
A slice carries the declarations it reads **as written**, var() chains
|
||||||
|
included, plus everything those chains reach. Nothing is flattened to a literal:
|
||||||
|
a consumer that overrides --border still moves --panel-border with it, the same
|
||||||
|
as with the full file. A name the chosen source lacks falls back to the library
|
||||||
|
and is reported, because a fallback is a gap in the contract worth seeing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..theme import bake
|
||||||
|
from .graph import COMMON, SRC
|
||||||
|
|
||||||
|
LIBRARY = SRC / "tokens.css"
|
||||||
|
THEME = COMMON / "theme"
|
||||||
|
CONTRACT = THEME / "tokens.css"
|
||||||
|
THEMES = THEME / "themes"
|
||||||
|
|
||||||
|
COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||||
|
REFERENCE = re.compile(r"var\(\s*(--[\w-]+)")
|
||||||
|
ROOT_BLOCK = re.compile(r":root\s*\{.*?\n\}", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def available_themes() -> list[str]:
|
||||||
|
return sorted(p.stem for p in THEMES.glob("*.css"))
|
||||||
|
|
||||||
|
|
||||||
|
def _declarations(css: str, selector: str) -> dict[str, str]:
|
||||||
|
"""`--name: value;` pairs of one rule, comments removed first.
|
||||||
|
|
||||||
|
bake.declarations reads a rule the same way; comments go first here because
|
||||||
|
prose inside a block ("Pages set --system-accent; …") must never be read as
|
||||||
|
a declaration.
|
||||||
|
"""
|
||||||
|
return bake.declarations(COMMENT.sub("", css), selector)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Source:
|
||||||
|
"""A resolved token source: the table a slice cuts its tokens from."""
|
||||||
|
|
||||||
|
name: str # "library" or a theme name
|
||||||
|
table: dict[str, str] # name -> value as written
|
||||||
|
fallback: dict[str, str] = field(default_factory=dict)
|
||||||
|
rules: str = "" # library extras outside :root
|
||||||
|
fell_back: set = field(default_factory=set)
|
||||||
|
|
||||||
|
def closure(self, names: set) -> set:
|
||||||
|
"""`names` plus every name their values reach through var()."""
|
||||||
|
seen, stack = set(), list(names)
|
||||||
|
while stack:
|
||||||
|
name = stack.pop()
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
value = self.table.get(name, self.fallback.get(name))
|
||||||
|
if value:
|
||||||
|
stack.extend(set(REFERENCE.findall(value)) - seen)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def value(self, name: str) -> str | None:
|
||||||
|
if name in self.table:
|
||||||
|
return self.table[name]
|
||||||
|
if name in self.fallback:
|
||||||
|
self.fell_back.add(name)
|
||||||
|
return self.fallback[name]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def block(self, names: set, indent: str = " ") -> tuple[str, list[str]]:
|
||||||
|
"""The `:root { … }` a slice carries, and the names nobody defines.
|
||||||
|
|
||||||
|
Order follows the source file, so the result reads like the file it was
|
||||||
|
cut from rather than like a sorted dump.
|
||||||
|
"""
|
||||||
|
wanted = self.closure(names)
|
||||||
|
order = list(self.table) + [n for n in self.fallback if n not in self.table]
|
||||||
|
lines, missing = [":root {"], []
|
||||||
|
for name in order + sorted(wanted - set(order)):
|
||||||
|
if name not in wanted:
|
||||||
|
continue
|
||||||
|
value = self.value(name)
|
||||||
|
if value is None:
|
||||||
|
missing.append(name)
|
||||||
|
continue
|
||||||
|
lines.append(f"{indent}{name}: {value};")
|
||||||
|
wanted.discard(name)
|
||||||
|
lines.append("}")
|
||||||
|
return "\n".join(lines) + "\n", sorted(set(missing))
|
||||||
|
|
||||||
|
|
||||||
|
def library_rules() -> str:
|
||||||
|
"""What common/ui/src/tokens.css carries besides the :root block.
|
||||||
|
|
||||||
|
Today that is the `.waiting` glow and its keyframes: a UI affordance that
|
||||||
|
happens to live in the token file. It travels only to a consumer that uses
|
||||||
|
the class — mpr's detection app does, the others do not.
|
||||||
|
"""
|
||||||
|
text = LIBRARY.read_text()
|
||||||
|
text = ROOT_BLOCK.sub("", text, count=1)
|
||||||
|
text = re.sub(r"\A\s*/\*.*?\*/", "", text, count=1, flags=re.S) # the file's header
|
||||||
|
return text.strip() + "\n" if text.strip() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def source(theme: str | None, overrides: dict[str, str] | None = None) -> Source:
|
||||||
|
"""The table for `theme` (None = the library), with overrides applied.
|
||||||
|
|
||||||
|
Overrides go in before anything is read, so an alias that points at an
|
||||||
|
overridden name (`--amber: var(--accent)`) follows it.
|
||||||
|
"""
|
||||||
|
library = _declarations(LIBRARY.read_text(), ":root")
|
||||||
|
if theme is None or theme == "library":
|
||||||
|
table = dict(library)
|
||||||
|
table.update(overrides or {})
|
||||||
|
return Source(name="library", table=table, rules=library_rules())
|
||||||
|
|
||||||
|
sheet = THEMES / f"{theme}.css"
|
||||||
|
if not sheet.exists():
|
||||||
|
raise ValueError(f"no theme {theme!r} — have: {', '.join(available_themes())}")
|
||||||
|
table = _declarations(CONTRACT.read_text(), ":root")
|
||||||
|
table.update(_declarations(sheet.read_text(), f'[data-theme="{theme}"]'))
|
||||||
|
table.update(overrides or {})
|
||||||
|
return Source(name=theme, table=table, fallback=library)
|
||||||
|
|
||||||
|
|
||||||
|
def resolved(src: Source, name: str) -> str | None:
|
||||||
|
"""A token flattened to literals — for reports and comparisons only."""
|
||||||
|
table = {**src.fallback, **src.table}
|
||||||
|
return bake.resolve(name, table)
|
||||||
|
|
||||||
|
|
||||||
|
def read(text: str) -> set:
|
||||||
|
"""Custom properties a piece of text reads."""
|
||||||
|
return set(REFERENCE.findall(text))
|
||||||
|
|
||||||
|
|
||||||
|
def uses_waiting(texts) -> bool:
|
||||||
|
return any(re.search(r"\bwaiting\b", t) for t in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def relpath(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.relative_to(COMMON.parent).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
46
soleprint/common/slice/ui.example.toml
Normal file
46
soleprint/common/slice/ui.example.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# A run file: every slice one consumer takes from spr, and how.
|
||||||
|
#
|
||||||
|
# ./ctrl/ui.sh slice soleprint/common/slice/ui.example.toml
|
||||||
|
# ./ctrl/ui.sh slice <file> --list # resolved, nothing written
|
||||||
|
# ./ctrl/ui.sh check <file> # nothing written; exit 1 if stale
|
||||||
|
#
|
||||||
|
# Copy it beside the consumer it describes. A real one names that consumer's
|
||||||
|
# paths, so it never lives in spr — and for a vendored slice keep it (and the
|
||||||
|
# manifest it writes) outside the consumer's repository, with `dest` pointing in.
|
||||||
|
#
|
||||||
|
# Paths are relative to THIS FILE. An entry's value replaces the default for
|
||||||
|
# every key. Unknown keys are refused, every problem in one pass.
|
||||||
|
#
|
||||||
|
# This one runs against spr's own tree and writes under out/ (gitignored).
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
delivery = "dependency"
|
||||||
|
|
||||||
|
# Vue, named outright: what a small live page takes. Drop-in `package` layout —
|
||||||
|
# package.json + src/ — the shape an app links as `soleprint-ui`.
|
||||||
|
[[slice]]
|
||||||
|
name = "panels"
|
||||||
|
target = "vue"
|
||||||
|
dest = "out/panels"
|
||||||
|
manifest = "out/panels.slice.json"
|
||||||
|
use = ["Panel", "SplitPane", "LogRenderer"]
|
||||||
|
|
||||||
|
# The same pieces vendored: corp names, no headers, no stamp, scrubbed, and a
|
||||||
|
# `notice` stated on purpose (a path to a file, or "none").
|
||||||
|
[[slice]]
|
||||||
|
name = "panels-vendored"
|
||||||
|
target = "vue"
|
||||||
|
delivery = "vendored"
|
||||||
|
notice = "none"
|
||||||
|
dest = "out/panels-vendored"
|
||||||
|
manifest = "out/panels-vendored.slice.json"
|
||||||
|
use = ["Panel", "SplitPane"]
|
||||||
|
|
||||||
|
# Plain, read from a page's own markup: the jira vein's page uses panel and
|
||||||
|
# split, so that is what ui.css / ui.js carry — nothing else.
|
||||||
|
[[slice]]
|
||||||
|
name = "jira-page"
|
||||||
|
target = "plain"
|
||||||
|
dest = "out/jira-page"
|
||||||
|
manifest = "out/jira-page.slice.json"
|
||||||
|
scan = ["../../artery/veins/jira/ui/index.html"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user