init docgen

This commit is contained in:
2026-09-22 04:54:49 -03:00
parent 12ddd01940
commit b693e324ef
31 changed files with 4521 additions and 816 deletions

View File

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

View File

@@ -43,6 +43,7 @@ SLUG ?=
# en_US.UTF-8 from the environment and --reader rejects it.
READER ?= python
OVERLAY ?=
NOTES ?=
CONFIG ?= docgen.toml
ONLY ?=
CHECK ?=
@@ -54,7 +55,7 @@ SLUG_ARG := $(if $(SLUG),--slug $(SLUG))
OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
ONLY_ARGS := $(foreach n,$(subst $(comma), ,$(ONLY)),--only $(n))
.PHONY: help sync lock book run check ir db code view graph index site minimap explore docs self doctor clean
.PHONY: help sync lock book run scaffold check ir db code view graph index site minimap explore docs self doctor clean
help: ## List every target
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
@@ -68,6 +69,7 @@ help: ## List every target
@echo " BOOK=/path where a book goes, and which book to check"
@echo " READER=python|code ast, or tree-sitter SLUG=name what to call the book"
@echo " OVERLAY=overlay.json hand-written notebook additions, re-applied every build"
@echo " NOTES=notes/ markdown filed by page: notes/<book>/<page>.md"
@echo " CONFIG=docgen.toml a run file ONLY=a,b just these books"
@echo " CHECK=1 with run: each book's own level after building it"
@echo
@@ -92,11 +94,14 @@ book: ## SRC (or SCHEMA/OPENAPI/HAR) -> one operation, measured at both ends
$(if $(OPENAPI),--openapi "$(OPENAPI)") \
$(if $(HAR),--har "$(HAR)") \
-o "$(if $(BOOK),$(BOOK),$(OUT)/book)" \
$(STYLE_ARGS) $(SLUG_ARG) $(OVER_ARG)
$(STYLE_ARGS) $(SLUG_ARG) $(OVER_ARG) $(if $(NOTES),--notes "$(NOTES)")
run: ## CONFIG (a run file) -> every book it lists; ONLY=a,b for some
run: ## CONFIG (a run file or a project file) -> every book it lists; ONLY=a,b for some
@$(CLI) run "$(CONFIG)" $(ONLY_ARGS) $(if $(CHECK),--check)
scaffold: ## CONFIG -> an empty note for every page and step, never overwriting
@$(CLI) scaffold "$(CONFIG)" $(ONLY_ARGS)
check: ## Prove docgen (or one book, with BOOK=<dir>)
@$(CLI) check $(if $(BOOK),"$(BOOK)")

View File

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

View File

@@ -73,7 +73,7 @@ EXTRACTORS = {
TAKES_EXCLUDE = {"python", "code"}
def extract(kind: str, source, *, exclude=(), identity=None):
def extract(kind: str, source, *, exclude=(), identity=None, suffixes=None):
"""(IR dict, Larder) for one source type, dispatched through EXTRACTORS."""
from importlib import import_module
@@ -91,6 +91,8 @@ def extract(kind: str, source, *, exclude=(), identity=None):
kwargs = {"identity": identity or str(source)}
if kind in TAKES_EXCLUDE:
kwargs["exclude"] = exclude
if kind == "code" and suffixes:
kwargs["suffixes"] = suffixes
g = fn(source, **kwargs)
ir = g.to_dict()
@@ -102,7 +104,7 @@ def extract(kind: str, source, *, exclude=(), identity=None):
return ir, Larder.from_dict(measured)
def spec_from(book: Book, ir: dict) -> dict:
def spec_from(book: Book, ir: dict, *, prelude_dir: str | None = None) -> dict:
"""The book's ledger -> a notebook spec, with the two measures at the ends.
Reuses `notebook.spec`'s step vocabulary rather than inventing one, so
@@ -128,7 +130,10 @@ def spec_from(book: Book, ir: dict) -> dict:
),
),
spec_mod._step("prelude", "code", title="Reading a step on its own",
code=PRELUDE),
# In its own book directory the notebook reads steps from
# where it is opened; elsewhere it points back at the book.
code=PRELUDE if prelude_dir is None else PRELUDE.replace(
"BOOK = Path.cwd()", f"BOOK = Path.cwd() / {prelude_dir!r}", 1)),
]
for s in book.steps:
@@ -191,7 +196,9 @@ def _load_cell(step_id: str, artifact: str) -> str:
if artifact.endswith(".json"):
return (
f'data = load("{artifact}")\n'
'if data:\n'
'if isinstance(data, list):\n'
' print(f"{len(data)} item(s)")\n'
'elif data:\n'
' if "nodes" in data:\n'
' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
' else:\n'
@@ -222,7 +229,20 @@ def _load_cell(step_id: str, artifact: str) -> str:
# remove first. Named, never globbed: a book directory also holds the hand-written
# `checks.py` and `overlay.json`, and a misconfigured output path could point at
# somebody's source tree — so nothing outside this list is ever deleted.
GENERATED = ("book.json", "notebook.ipynb", "steps", "site", "explore")
GENERATED = ("book.json", "notebook.ipynb", "steps", "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]:
@@ -253,12 +273,19 @@ def clear(out) -> list[str]:
return removed
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
"""The whole book, in one process. Returns it; `book.json` is written."""
def build_book(kind: str, source, out, *, slug: str | None = None, exclude=(),
ops: dict | None = None, suffixes=None, quiet: bool = False) -> Book:
"""One book's steps: larder, IR, view, pages, drawings. No site, no notebook.
Those are a document's outputs and are assembled by `book/document.py`, which
may put several books into one of each. The book is returned open — its last
step is added once the outputs it belongs to exist.
"""
from ..ir import check
from ..ops import classify, overview
from ..ops.pages import for_view
from ..style import Style
from .document import apply_ops
out = Path(out)
slug = slug or Path(str(source)).name or "book"
@@ -270,7 +297,7 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
if not quiet:
print(text)
ir, larder = extract(kind, source, exclude=exclude)
ir, larder = extract(kind, source, exclude=exclude, suffixes=suffixes)
if larder is None:
larder = Larder(kind=kind, identity=str(source), unit="document", seen=0)
book = Book(slug=slug, larder=larder, out=out)
@@ -289,22 +316,31 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
p.write_text(json.dumps(ir, indent=2) + "\n")
book.step("ir", "the graph, extracted", p, note=f"{len(ir['nodes'])} nodes")
view = overview(ir)
view = apply_ops(overview(ir), ops or {})
book.view = view
p = steps_dir / "view.json"
p.write_text(json.dumps(view, indent=2) + "\n")
verdict = classify(view)
book.step("view", "the default view for this source", p,
note=f"{verdict['kind']} — {verdict['why']}")
style_obj = Style.load(style, theme=theme)
# Which pages exist — width-free, so it is decided once. Each output lays
# them out for its own column when it is assembled.
book.pages = for_view(ir, view)
p = steps_dir / "pages.json"
p.write_text(json.dumps(
[{k: v for k, v in page.items() if k != "ir"} for page in book.pages],
indent=2) + "\n")
book.step("pages", "the diagram, split into pages that fit", p,
note=f"{len(book.pages)} page(s)")
style_obj = Style.load("lucid")
# The drawing, chosen by structure rather than by the caller — the same
# `draw()` the `emit auto` and `emit site` commands use, so the three can
# never disagree about what a graph wants.
# The whole view in one drawing — still worth having as a step on its own,
# and the same `draw()` the `emit auto` command uses.
from ..emitters.auto import draw
drawn = draw(view, style_obj)
graph_rel = None
labels = {"erd": "drawn as an ERD", "dot": f"drawn as a {verdict['kind']}",
"index": "not a diagram — written as a list"}
if drawn["content"] is None:
@@ -315,8 +351,6 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
p.write_bytes(drawn["content"])
else:
p.write_text(drawn["content"])
if drawn["suffix"] == ".svg":
graph_rel = f"steps/{p.name}"
book.step("graph", labels[drawn["emitter"]], p, note=drawn["why"])
from ..emitters.index import to_markdown, to_sidebar
@@ -343,38 +377,41 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
except Exception as e: # noqa: BLE001
book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
# -- the last step ----------------------------------------------------
from ..emitters.site import write as site_write
site_dir = out / "site"
site_write(view, style_obj, site_dir,
graph=Path(graph_rel).name if graph_rel else None,
title=slug, book=book.to_dict())
if graph_rel and (out / graph_rel).exists():
(site_dir / Path(graph_rel).name).write_bytes((out / graph_rel).read_bytes())
# close() BEFORE the notebook spec is built, not after. The spec quotes the
# book measure, and the book measure only includes the site once the last
# step exists — build it the other way round and the notebook says 7
# artifacts while book.json says 8, which is exactly what it did.
book.close(site=site_dir)
from ..emitters.notebook import write as nb_write
from ..notebook import spec as spec_mod
spec, spec_problems = spec_mod.merge(spec_from(book, ir), overlay)
nb_write(spec, out / "notebook.ipynb")
# Recorded by name and not by size: a notebook cannot report its own byte
# count without changing it.
book.notebook = "notebook.ipynb"
return book
for pr in spec_problems:
say(f" overlay {pr}")
path = book.write()
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
theme: str | None = None, exclude=(), overlay=None, notes=None,
quiet: bool = False) -> Book:
"""One book, as a one-book document whose outputs live in its own directory.
`book.json`, `steps/`, `site/` and `notebook.ipynb` land where they always did;
the site 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)],
outputs=[Output("site", out / "site", style=style, theme=theme),
Output("notebook", out / "notebook.ipynb", style=style, theme=theme)],
)
build(doc, quiet=quiet)
book = doc.books[0]
for pr in doc.problems:
if not quiet:
print(f" note {pr}")
m = book.measure()
say(f" book {m['nodes']} nodes · {m['edges']} edges · "
f"{m['external']} external · {len(m['artifacts'])} artifacts")
for r in book.compare():
say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
say(f" open {site_dir / 'index.html'}")
say(f" {path}")
if not quiet:
print(f" book {m['nodes']} nodes · {m['edges']} edges · "
f"{m['external']} external · {len(m['artifacts'])} artifacts")
for r in book.compare():
print(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
print(f" open {out / 'site' / 'index.html'}")
print(f" {out / 'book.json'}")
return book

View File

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

View File

@@ -71,8 +71,8 @@ SOURCES = {"root": "python", "schema": "db", "openapi": "openapi", "har": "usage
READERS = ("python", "code")
TOP_KEYS = {"reference", "defaults", "book"}
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader"}
BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", *SOURCES}
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader", "notes"}
BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", "notes", *SOURCES}
# A book name becomes a directory name and a slug, so it is held to what is safe
# as both — no separators, nothing that means something to a shell.
@@ -99,6 +99,7 @@ class Entry:
theme: str | None = None
overlay: Path | None = None
exclude: tuple = ()
notes: Path | None = None
def line(self) -> str:
return f"{self.name:<18} {self.kind:<8} {self.source} -> {self.out}"
@@ -137,18 +138,38 @@ def _within(inner: Path, outer: Path) -> bool:
return inner == outer or outer in inner.parents
def load(path) -> RunFile:
"""Read and resolve a run file. Raises ConfigError listing every problem."""
path = Path(path)
def _read(path: Path) -> dict:
if tomllib is None:
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
try:
data = tomllib.loads(path.read_text())
return tomllib.loads(path.read_text())
except OSError as e:
raise ConfigError(path, [f"cannot read: {e}"]) from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
def load(path):
"""Read and resolve a run file of either shape. Raises ConfigError listing
every problem.
Returns a `RunFile` for a `[[book]]` list, or a `ProjectFile` for a project.
A file with both is refused: which one it meant is exactly what nobody should
have to guess.
"""
path = Path(path)
data = _read(path)
if "project" in data and "book" in data:
raise ConfigError(path, [
"this file has both [project] and [[book]] — use one shape per file: "
"[project] for one document built from several sources, [[book]] for a "
"list of independent books"])
if "project" in data:
return load_project(path, data)
return _load_books(path, data)
def _load_books(path: Path, data: dict) -> "RunFile":
base = path.resolve().parent
problems: list[str] = []
@@ -246,10 +267,12 @@ def load(path) -> RunFile:
if source_key == "root" and source.is_dir() and _within(out, source):
problems.append(f"{where}: out {out} is inside the tree it reads ({source})")
notes = _path(pick("notes", "notes"), base)
books.append(Entry(
name=name, kind=kind, source=source, out=out,
style=pick("style", "lucid"), theme=pick("theme"),
overlay=overlay, exclude=tuple(exclude),
overlay=overlay, exclude=tuple(exclude), notes=notes,
))
seen_names, seen_outs = {}, {}
@@ -282,13 +305,30 @@ def apply_reference(runfile: RunFile) -> str | None:
return str(runfile.reference)
def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True):
def _checks(directory, quiet: bool) -> list[str]:
import contextlib
import io
from . import checks as checks_mod
report = checks_mod.Report()
loaded = checks_mod.Loaded(directory)
sink = io.StringIO() if quiet else None
with contextlib.redirect_stdout(sink) if sink else contextlib.nullcontext():
checks_mod.generated(loaded, report)
checks_mod.custom(loaded, report)
return list(report.failed)
def run(runfile, names=None, *, check: bool = False, quiet: bool = True):
"""Build every selected book, in order. One failure never stops the rest.
Returns one result per book:
Returns one result per book, plus one per output for a project:
{"name", "out", "ok", "error", "lost": [claims], "checks_failed": [names]}
"""
from . import checks as checks_mod
if isinstance(runfile, ProjectFile):
return run_project(runfile, names, check=check, quiet=quiet)
from .build import run as build
apply_reference(runfile)
@@ -303,7 +343,7 @@ def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True
overlay = spec_mod.load(entry.overlay)
book = build(entry.kind, entry.source, entry.out, slug=entry.name,
style=entry.style, theme=entry.theme, exclude=entry.exclude,
overlay=overlay, quiet=quiet)
overlay=overlay, notes=entry.notes, quiet=quiet)
result["lost"] = [r["claim"] for r in book.compare() if not r["ok"]]
except Exception as e: # noqa: BLE001 - one bad book must not cost the run
result["error"] = f"{type(e).__name__}: {e}"
@@ -311,17 +351,265 @@ def run(runfile: RunFile, names=None, *, check: bool = False, quiet: bool = True
continue
if check:
import contextlib
import io
report = checks_mod.Report()
loaded = checks_mod.Loaded(entry.out)
sink = io.StringIO() if quiet else None
with contextlib.redirect_stdout(sink) if sink else contextlib.nullcontext():
checks_mod.generated(loaded, report)
checks_mod.custom(loaded, report)
result["checks_failed"] = list(report.failed)
result["checks_failed"] = _checks(entry.out, quiet)
result["ok"] = not result["lost"] and not result["checks_failed"]
results.append(result)
return results
# -- the project shape ----------------------------------------------------------
#
# One file per project, the shape a model writes unprompted:
#
# [project] name, title, notes, build, reference
# [extract.<kind>] one book per table; [[extract.<kind>]] for several
# [ops] applied to every book's view, before paging
# [emit.<output>] notebook, site — 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", "site"}
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))
# -- 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.site] with an `output`")
for emitter, raw in emit.items():
where = f"[emit.{emitter}]"
if emitter not in EMITTERS:
problems.append(f"{where}: no output named {emitter!r} — have: "
f"{', '.join(sorted(EMITTERS))}")
continue
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
for key in sorted(set(raw) - EMIT_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: {', '.join(sorted(EMIT_KEYS))}")
if "output" not in raw:
problems.append(f"{where} needs `output`")
continue
width = raw.get("width")
if width is not None and (not isinstance(width, int) or width < 200):
problems.append(f"{where}: width must be a number of pixels, 200 or more")
width = None
target = _path(raw["output"], base)
for part in parts:
if part.kind in ("python", "code") and part.source.is_dir() \
and _within(target, part.source):
problems.append(f"{where}: output {target} is inside the tree book "
f"{part.name!r} reads — the next run would read it")
outputs.append(Output(emitter=emitter, path=target, style=raw.get("style", "lucid"),
theme=raw.get("theme"), width=width))
if problems:
raise ConfigError(path, problems)
doc = Document(name=name, title=title, build=build_root / name, parts=parts,
outputs=outputs, notes=notes)
return ProjectFile(path=path, document=doc, reference=reference)
def select_parts(project: ProjectFile, names) -> list:
if not names:
return list(project.document.parts)
known = {p.name for p in project.document.parts}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(project.path, [
f"no book named {n!r} — have: {', '.join(sorted(known))}" for n in unknown])
return [p for p in project.document.parts if p.name in set(names)]
def run_project(project: ProjectFile, names=None, *, check: bool = False, quiet: bool = True):
"""Build a project's document: every book, then every output.
`--only` narrows the books that go into this build's outputs, which is useful
while writing notes for one of them — the outputs are then that subset, and
say so in their ledger.
"""
from dataclasses import replace
from .document import build
apply_reference(project)
doc = replace(project.document, parts=select_parts(project, names), books=[], problems=[])
results = []
try:
build(doc, quiet=quiet)
except Exception as e: # noqa: BLE001 - reported, not raised
return [{"name": doc.name, "out": str(doc.build), "ok": False,
"error": f"{type(e).__name__}: {e}", "lost": [], "checks_failed": []}]
for part in doc.parts:
if part.name in doc.failed:
results.append({"name": part.name, "out": str(part.out), "ok": False,
"error": doc.failed[part.name], "lost": [], "checks_failed": []})
for book in doc.books:
lost = [r["claim"] for r in book.compare() if not r["ok"]]
failed = _checks(book.out, quiet) if check else []
results.append({"name": book.slug, "out": str(book.out), "ok": not lost and not failed,
"error": None, "lost": lost, "checks_failed": failed})
if check and doc.books:
from . import checks as checks_mod
failed = checks_mod.document_failures(doc.build, quiet=quiet)
else:
failed = []
# A failed book is already its own row; the document row carries what else
# went wrong — notes that drifted, an overlay that no longer matches.
rest = [p for p in doc.problems if not p.startswith("book ") or "was not built" not in p]
results.append({"name": f"{doc.name} (document)", "out": str(doc.build),
"ok": not rest and not failed and bool(doc.books), "error": None,
"lost": rest, "checks_failed": failed})
return results

View File

@@ -0,0 +1,448 @@
"""
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 site, … — 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 site, 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 site
column is narrower than a notebook cell, a site'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" | "site"
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
@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,
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
# Sites first: in a single inline book the site is its last step's artifact,
# and the notebook quotes the book measure — which only counts the site once
# the last step exists.
sites = [o for o in doc.outputs if o.emitter == "site"]
notebooks = [o for o in doc.outputs if o.emitter == "notebook"]
written_outputs = []
for output in sites:
from ..emitters import docsite
site_dir = Path(output.path)
clear_output(site_dir)
style = Style.load(output.style, theme=output.theme)
width = output.width or docsite.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 = docsite.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": "site", "path": str(site_dir), "width": width,
"files": len(files)})
say(f" site {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(site=Path(inside[0].path) if inside else None)
for output in notebooks:
path = Path(output.path)
if path.suffix != ".ipynb":
clear_output(path)
path.mkdir(parents=True, exist_ok=True)
target = path / f"{slugify(doc.name)}.ipynb"
else:
path.parent.mkdir(parents=True, exist_ok=True)
target = path
spec = notebook_spec(doc, notes, output, target.parent)
nb_write(spec, target)
if path.suffix != ".ipynb":
write_manifest(path, [target])
if doc.inline and len(doc.books) == 1:
doc.books[0].notebook = os.path.relpath(target, doc.books[0].out)
written_outputs.append({"emitter": "notebook", "path": str(target),
"width": output.width or 960})
say(f" notebook {target}")
known = {b.slug: known_spots(b) for b in doc.books}
drift = notes.drift(known)
doc.problems += drift
for book in doc.books:
book.write()
ledger = {
"name": doc.name,
"title": doc.title,
"failed": doc.failed,
"books": [{"name": b.slug, "dir": str(b.out),
"pages": [{"id": p["id"], "slug": p["slug"], "parent": p["parent"]}
for p in b.pages]}
for b in doc.books],
"outputs": written_outputs,
"notes": {"root": str(doc.notes) if doc.notes else None,
"used": sorted(str(p.relative_to(doc.notes)) for p in notes.used) if doc.notes else [],
"drift": drift},
"problems": doc.problems,
}
doc.build.mkdir(parents=True, exist_ok=True)
(doc.build / "document.json").write_text(json.dumps(ledger, indent=2) + "\n")
return doc
def _spots(ir: dict, pages: list[dict]) -> list[tuple[str, str]]:
"""[(spot id, what it is)] for one book, in reading order."""
from ..notebook import spec as spec_mod
from ..ops.pages import slugify
from .build import STEP_LABELS
out = [("larder", "what came in"), ("pages", "the page index")]
out += [(p["slug"], f"page {p['title']}") for p in pages]
out += [(f"step-{sid}", f"step: {label}") for sid, label in STEP_LABELS]
# An API-shaped book also carries the endpoint walkthrough; each call is a
# spot of its own, under the slug of its id.
if any(n["kind"] in ("endpoint", "operation") for n in ir.get("nodes") or []):
out += [(slugify(s["id"]), f"{s['kind']}: {s.get('title', s['id'])}")
for s in spec_mod.from_ir(ir)["steps"] if s["id"] != "intro"]
out.append(("book", "what came out"))
return out
def known_spots(book: Book) -> set[str]:
"""Every id a note for this book may be filed under."""
return {spot for spot, _ in _spots(book.ir, book.pages)}
def plan_spots(part: Part) -> list[tuple[str, str]]:
"""The spots a book will have, without building it — extraction and pages only."""
from ..ops import overview
from ..ops.pages import for_view
from .build import extract
ir, _ = extract(part.kind, part.source, exclude=part.exclude, suffixes=part.suffixes)
view = apply_ops(overview(ir), part.ops)
return _spots(ir, for_view(ir, view))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2238pt" viewBox="0 0 886 2238">
<rect width="886" height="2238" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="1164.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 2328 lines</title></rect>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2781pt" viewBox="0 0 886 2781">
<rect width="886" height="2781" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="1387.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 2775 lines</title></rect>
<rect x="28.0" y="94.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.own_py_files" data-kind="function" class="blk"><title>own_py_files — function, 3 lines</title></rect>
<rect x="28.0" y="96.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.check" data-kind="function" class="blk"><title>check — function, 5 lines</title></rect>
<rect x="28.0" y="99.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._err" data-kind="function" class="blk"><title>_err — function, 7 lines</title></rect>
@@ -15,6 +15,12 @@
<rect x="28.0" y="1106.5" width="74.0" height="97.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._runfiles" data-kind="function" class="blk"><title>_runfiles — function, 195 lines</title></rect>
<rect x="35.0" y="1112.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._runfiles.write" data-kind="function" class="blk"><title>write — function, 4 lines</title></rect>
<rect x="35.0" y="1115.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._runfiles.problems" data-kind="function" class="blk"><title>problems — function, 6 lines</title></rect>
<rect x="28.0" y="1209.0" 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="1270.5" 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="1355.5" width="74.0" height="29.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._projects" data-kind="function" class="blk"><title>_projects — function, 59 lines</title></rect>
<rect x="35.0" y="1359.5" 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="1390.0" 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="1399.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.selftest._contract.named" data-kind="function" class="blk"><title>named — function, 2 lines</title></rect>
<rect x="110.0" y="46.0" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book — 316 lines</title></rect>
<rect x="110.0" y="75.0" width="74.0" height="10.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.Step" data-kind="class" class="blk"><title>Step — class, 20 lines</title></rect>
<rect x="117.0" y="80.0" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Step.to_dict" data-kind="function" class="blk"><title>to_dict — function, 10 lines</title></rect>
@@ -54,353 +60,449 @@
<rect x="274.0" y="82.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.describe" data-kind="function" class="blk"><title>describe — function, 10 lines</title></rect>
<rect x="274.0" y="88.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.on_path" data-kind="function" class="blk"><title>on_path — function, 13 lines</title></rect>
<rect x="274.0" y="95.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.missing" data-kind="function" class="blk"><title>missing — function, 8 lines</title></rect>
<rect x="356.0" y="46.0" width="74.0" height="38.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli — 76 lines</title></rect>
<rect x="356.0" y="68.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.usage" data-kind="function" class="blk"><title>usage — function, 7 lines</title></rect>
<rect x="356.0" y="73.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="356.0" y="46.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli — 80 lines</title></rect>
<rect x="356.0" y="70.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.usage" data-kind="function" class="blk"><title>usage — function, 7 lines</title></rect>
<rect x="356.0" y="75.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="438.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops — 28 lines</title></rect>
<rect x="520.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook — 15 lines</title></rect>
<rect x="602.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters — 12 lines</title></rect>
<rect x="684.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab — 11 lines</title></rect>
<rect x="766.0" y="46.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.__main__ — 9 lines</title></rect>
<rect x="28.0" y="1248.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir — 7 lines</title></rect>
<rect x="110.0" y="1248.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors — 2 lines</title></rect>
<rect x="210.0" y="1248.0" width="74.0" height="126.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.emit — 253 lines</title></rect>
<rect x="210.0" y="1260.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit._parser" data-kind="function" class="blk"><title>_parser — function, 7 lines</title></rect>
<rect x="210.0" y="1264.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit._size" data-kind="function" class="blk"><title>_size — function, 3 lines</title></rect>
<rect x="210.0" y="1267.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.auto" data-kind="function" class="blk"><title>auto — function, 16 lines</title></rect>
<rect x="210.0" y="1276.0" width="74.0" height="19.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.dot" data-kind="function" class="blk"><title>dot — function, 38 lines</title></rect>
<rect x="210.0" y="1296.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.erd" data-kind="function" class="blk"><title>erd — function, 11 lines</title></rect>
<rect x="210.0" y="1302.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.index" data-kind="function" class="blk"><title>index — function, 14 lines</title></rect>
<rect x="210.0" y="1310.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.minimap" data-kind="function" class="blk"><title>minimap — function, 14 lines</title></rect>
<rect x="210.0" y="1318.5" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.notebook" data-kind="function" class="blk"><title>notebook — function, 41 lines</title></rect>
<rect x="210.0" y="1340.0" width="74.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.site" data-kind="function" class="blk"><title>site — function, 31 lines</title></rect>
<rect x="210.0" y="1356.5" width="74.0" height="11.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.explore" data-kind="function" class="blk"><title>explore — function, 22 lines</title></rect>
<rect x="210.0" y="1370.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.emit.main" data-kind="function" class="blk"><title>main — function, 7 lines</title></rect>
<rect x="292.0" y="1248.0" width="74.0" height="59.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.extract — 119 lines</title></rect>
<rect x="292.0" y="1257.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract._parser" data-kind="function" class="blk"><title>_parser — function, 5 lines</title></rect>
<rect x="292.0" y="1261.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract._counts" data-kind="function" class="blk"><title>_counts — function, 3 lines</title></rect>
<rect x="292.0" y="1263.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.python" data-kind="function" class="blk"><title>python — function, 15 lines</title></rect>
<rect x="292.0" y="1272.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.code" data-kind="function" class="blk"><title>code — function, 14 lines</title></rect>
<rect x="292.0" y="1280.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.db" data-kind="function" class="blk"><title>db — function, 11 lines</title></rect>
<rect x="292.0" y="1286.5" width="74.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.openapi" data-kind="function" class="blk"><title>openapi — function, 12 lines</title></rect>
<rect x="292.0" y="1293.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.usage" data-kind="function" class="blk"><title>usage — function, 15 lines</title></rect>
<rect x="292.0" y="1303.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.extract.main" data-kind="function" class="blk"><title>main — function, 7 lines</title></rect>
<rect x="374.0" y="1248.0" width="74.0" height="45.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.view — 90 lines</title></rect>
<rect x="374.0" y="1255.0" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.view.main" data-kind="function" class="blk"><title>main — function, 75 lines</title></rect>
<rect x="456.0" y="1248.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.common — 80 lines</title></rect>
<rect x="456.0" y="1258.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.cli.common.Abort" data-kind="class" class="blk"><title>Abort — class, 2 lines</title></rect>
<rect x="456.0" y="1260.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.read_json" data-kind="function" class="blk"><title>read_json — function, 5 lines</title></rect>
<rect x="456.0" y="1263.5" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.read_ir" data-kind="function" class="blk"><title>read_ir — function, 11 lines</title></rect>
<rect x="456.0" y="1270.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.load_style" data-kind="function" class="blk"><title>load_style — function, 7 lines</title></rect>
<rect x="456.0" y="1274.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.style_args" data-kind="function" class="blk"><title>style_args — function, 3 lines</title></rect>
<rect x="456.0" y="1277.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.emit" data-kind="function" class="blk"><title>emit — function, 16 lines</title></rect>
<rect x="456.0" y="1286.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.common.dump_ir" data-kind="function" class="blk"><title>dump_ir — function, 3 lines</title></rect>
<rect x="538.0" y="1248.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.run — 80 lines</title></rect>
<rect x="538.0" y="1258.0" width="74.0" height="29.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.run.main" data-kind="function" class="blk"><title>main — function, 59 lines</title></rect>
<rect x="620.0" y="1248.0" width="74.0" height="37.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.book — 74 lines</title></rect>
<rect x="620.0" y="1258.5" width="74.0" height="26.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.book.main" data-kind="function" class="blk"><title>main — function, 52 lines</title></rect>
<rect x="702.0" y="1248.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.check — 51 lines</title></rect>
<rect x="702.0" y="1258.0" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.check.main" data-kind="function" class="blk"><title>main — function, 30 lines</title></rect>
<rect x="784.0" y="1248.0" width="74.0" height="16.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.cli.validate — 33 lines</title></rect>
<rect x="784.0" y="1255.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.cli.validate.main" data-kind="function" class="blk"><title>main — function, 18 lines</title></rect>
<rect x="28.0" y="1412.5" width="74.0" height="247.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.site — 494 lines</title></rect>
<rect x="28.0" y="1548.5" width="74.0" height="27.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._ledger" data-kind="function" class="blk"><title>_ledger — function, 54 lines</title></rect>
<rect x="28.0" y="1576.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._slots" data-kind="function" class="blk"><title>_slots — function, 13 lines</title></rect>
<rect x="28.0" y="1584.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._fill" data-kind="function" class="blk"><title>_fill — function, 4 lines</title></rect>
<rect x="28.0" y="1587.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sidebar" data-kind="function" class="blk"><title>_sidebar — function, 17 lines</title></rect>
<rect x="28.0" y="1596.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sections" data-kind="function" class="blk"><title>_sections — function, 15 lines</title></rect>
<rect x="28.0" y="1605.0" width="74.0" height="48.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.emit" data-kind="function" class="blk"><title>emit — function, 96 lines</title></rect>
<rect x="28.0" y="1654.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.write" data-kind="function" class="blk"><title>write — function, 10 lines</title></rect>
<rect x="110.0" y="1412.5" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.explore — 316 lines</title></rect>
<rect x="110.0" y="1432.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._is_schema" data-kind="function" class="blk"><title>_is_schema — function, 4 lines</title></rect>
<rect x="110.0" y="1435.0" width="74.0" height="24.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._neighbourhood_svgs" data-kind="function" class="blk"><title>_neighbourhood_svgs — function, 48 lines</title></rect>
<rect x="117.0" y="1441.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="117.0" y="1445.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one#L66" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="110.0" y="1460.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._facts" data-kind="function" class="blk"><title>_facts — function, 27 lines</title></rect>
<rect x="110.0" y="1474.5" width="74.0" height="86.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.emit" data-kind="function" class="blk"><title>emit — function, 172 lines</title></rect>
<rect x="110.0" y="1561.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.write" data-kind="function" class="blk"><title>write — function, 17 lines</title></rect>
<rect x="192.0" y="1412.5" width="74.0" height="146.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.dot — 292 lines</title></rect>
<rect x="192.0" y="1431.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.RenderError" data-kind="class" class="blk"><title>RenderError — class, 2 lines</title></rect>
<rect x="192.0" y="1433.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._esc" data-kind="function" class="blk"><title>_esc — function, 2 lines</title></rect>
<rect x="192.0" y="1435.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._attrs" data-kind="function" class="blk"><title>_attrs — function, 3 lines</title></rect>
<rect x="192.0" y="1437.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._style_words" data-kind="function" class="blk"><title>_style_words — function, 9 lines</title></rect>
<rect x="192.0" y="1443.0" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._node_attrs" data-kind="function" class="blk"><title>_node_attrs — function, 24 lines</title></rect>
<rect x="192.0" y="1456.0" width="74.0" height="52.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.emit" data-kind="function" class="blk"><title>emit — function, 105 lines</title></rect>
<rect x="199.0" y="1469.5" width="60.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot.emit.write" data-kind="function" class="blk"><title>write — function, 29 lines</title></rect>
<rect x="192.0" y="1509.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._within" data-kind="function" class="blk"><title>_within — function, 8 lines</title></rect>
<rect x="192.0" y="1514.5" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._endpoints" data-kind="function" class="blk"><title>_endpoints — function, 41 lines</title></rect>
<rect x="199.0" y="1519.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.walk" data-kind="function" class="blk"><title>walk — function, 4 lines</title></rect>
<rect x="199.0" y="1523.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.collapsed" data-kind="function" class="blk"><title>collapsed — function, 2 lines</title></rect>
<rect x="199.0" y="1525.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.first_leaf" data-kind="function" class="blk"><title>first_leaf — function, 4 lines</title></rect>
<rect x="192.0" y="1536.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._safe" data-kind="function" class="blk"><title>_safe — function, 2 lines</title></rect>
<rect x="192.0" y="1538.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._q" data-kind="function" class="blk"><title>_q — function, 2 lines</title></rect>
<rect x="192.0" y="1541.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.have_graphviz" data-kind="function" class="blk"><title>have_graphviz — function, 2 lines</title></rect>
<rect x="192.0" y="1543.5" width="74.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.render" data-kind="function" class="blk"><title>render — function, 29 lines</title></rect>
<rect x="274.0" y="1412.5" width="74.0" height="139.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.notebook — 279 lines</title></rect>
<rect x="274.0" y="1433.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._cell" data-kind="function" class="blk"><title>_cell — function, 13 lines</title></rect>
<rect x="274.0" y="1441.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._example" data-kind="function" class="blk"><title>_example — function, 26 lines</title></rect>
<rect x="274.0" y="1474.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._params_cell" data-kind="function" class="blk"><title>_params_cell — function, 18 lines</title></rect>
<rect x="274.0" y="1484.5" width="74.0" height="20.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_cell" data-kind="function" class="blk"><title>_call_cell — function, 40 lines</title></rect>
<rect x="274.0" y="1505.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_md" data-kind="function" class="blk"><title>_call_md — function, 26 lines</title></rect>
<rect x="274.0" y="1519.5" width="74.0" height="26.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.build" data-kind="function" class="blk"><title>build — function, 52 lines</title></rect>
<rect x="274.0" y="1546.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.emit" data-kind="function" class="blk"><title>emit — function, 3 lines</title></rect>
<rect x="274.0" y="1549.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.write" data-kind="function" class="blk"><title>write — function, 5 lines</title></rect>
<rect x="356.0" y="1412.5" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.minimap — 278 lines</title></rect>
<rect x="356.0" y="1444.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._files" data-kind="function" class="blk"><title>_files — function, 57 lines</title></rect>
<rect x="363.0" y="1448.0" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.declared" data-kind="function" class="blk"><title>declared — function, 18 lines</title></rect>
<rect x="363.0" y="1457.5" width="60.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.build" data-kind="function" class="blk"><title>build — function, 11 lines</title></rect>
<rect x="356.0" y="1473.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._bands" data-kind="function" class="blk"><title>_bands — function, 7 lines</title></rect>
<rect x="356.0" y="1478.0" width="74.0" height="9.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._blocks" data-kind="function" class="blk"><title>_blocks — function, 19 lines</title></rect>
<rect x="356.0" y="1488.5" width="74.0" height="60.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.emit" data-kind="function" class="blk"><title>emit — function, 120 lines</title></rect>
<rect x="356.0" y="1549.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.marks_to_labels" data-kind="function" class="blk"><title>marks_to_labels — function, 3 lines</title></rect>
<rect x="438.0" y="1412.5" width="74.0" height="130.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.erd — 260 lines</title></rect>
<rect x="438.0" y="1439.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._truncate" data-kind="function" class="blk"><title>_truncate — function, 3 lines</title></rect>
<rect x="438.0" y="1442.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._tables" data-kind="function" class="blk"><title>_tables — function, 20 lines</title></rect>
<rect x="438.0" y="1453.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._card_height" data-kind="function" class="blk"><title>_card_height — function, 3 lines</title></rect>
<rect x="438.0" y="1455.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.layout" data-kind="function" class="blk"><title>layout — function, 20 lines</title></rect>
<rect x="438.0" y="1466.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._field_y" data-kind="function" class="blk"><title>_field_y — function, 3 lines</title></rect>
<rect x="438.0" y="1469.0" width="74.0" height="73.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.emit" data-kind="function" class="blk"><title>emit — function, 146 lines</title></rect>
<rect x="520.0" y="1412.5" width="74.0" height="82.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.index — 164 lines</title></rect>
<rect x="520.0" y="1429.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._tree" data-kind="function" class="blk"><title>_tree — function, 8 lines</title></rect>
<rect x="520.0" y="1434.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._anchor" data-kind="function" class="blk"><title>_anchor — function, 5 lines</title></rect>
<rect x="520.0" y="1438.0" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_markdown" data-kind="function" class="blk"><title>to_markdown — function, 84 lines</title></rect>
<rect x="527.0" y="1449.5" width="60.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_markdown.walk" data-kind="function" class="blk"><title>walk — function, 27 lines</title></rect>
<rect x="520.0" y="1481.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_sidebar" data-kind="function" class="blk"><title>to_sidebar — function, 26 lines</title></rect>
<rect x="527.0" y="1484.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_sidebar.build" data-kind="function" class="blk"><title>build — function, 13 lines</title></rect>
<rect x="602.0" y="1412.5" width="74.0" height="32.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.auto — 64 lines</title></rect>
<rect x="602.0" y="1426.0" width="74.0" height="18.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.auto.draw" data-kind="function" class="blk"><title>draw — function, 36 lines</title></rect>
<rect x="702.0" y="1412.5" width="74.0" height="141.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code — 282 lines</title></rect>
<rect x="702.0" y="1460.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.code.MissingParser" data-kind="class" class="blk"><title>MissingParser — class, 2 lines</title></rect>
<rect x="702.0" y="1462.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._parser" data-kind="function" class="blk"><title>_parser — function, 21 lines</title></rect>
<rect x="702.0" y="1473.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._name" data-kind="function" class="blk"><title>_name — function, 10 lines</title></rect>
<rect x="702.0" y="1479.5" width="74.0" height="17.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._walk" data-kind="function" class="blk"><title>_walk — function, 34 lines</title></rect>
<rect x="702.0" y="1497.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract_file" data-kind="function" class="blk"><title>extract_file — function, 27 lines</title></rect>
<rect x="702.0" y="1512.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._count_errors" data-kind="function" class="blk"><title>_count_errors — function, 5 lines</title></rect>
<rect x="702.0" y="1515.5" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract" data-kind="function" class="blk"><title>extract — function, 75 lines</title></rect>
<rect x="784.0" y="1412.5" width="74.0" height="134.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage — 269 lines</title></rect>
<rect x="784.0" y="1444.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._template" data-kind="function" class="blk"><title>_template — function, 27 lines</title></rect>
<rect x="784.0" y="1459.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._body" data-kind="function" class="blk"><title>_body — function, 10 lines</title></rect>
<rect x="784.0" y="1465.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._shape" data-kind="function" class="blk"><title>_shape — function, 15 lines</title></rect>
<rect x="784.0" y="1473.5" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._graphql" data-kind="function" class="blk"><title>_graphql — function, 11 lines</title></rect>
<rect x="784.0" y="1480.0" width="74.0" height="66.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage.extract" data-kind="function" class="blk"><title>extract — function, 133 lines</title></rect>
<rect x="28.0" y="1697.5" width="74.0" height="95.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi — 191 lines</title></rect>
<rect x="28.0" y="1713.0" width="74.0" height="14.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._modelgen" data-kind="function" class="blk"><title>_modelgen — function, 28 lines</title></rect>
<rect x="28.0" y="1728.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._type_name" data-kind="function" class="blk"><title>_type_name — function, 6 lines</title></rect>
<rect x="28.0" y="1732.0" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._refs" data-kind="function" class="blk"><title>_refs — function, 43 lines</title></rect>
<rect x="28.0" y="1754.5" width="74.0" height="38.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi.extract" data-kind="function" class="blk"><title>extract — function, 76 lines</title></rect>
<rect x="110.0" y="1697.5" width="74.0" height="80.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db — 161 lines</title></rect>
<rect x="110.0" y="1717.0" width="74.0" height="41.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.from_schema_dict" data-kind="function" class="blk"><title>from_schema_dict — function, 82 lines</title></rect>
<rect x="110.0" y="1759.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._relation" data-kind="function" class="blk"><title>_relation — function, 10 lines</title></rect>
<rect x="110.0" y="1765.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._plain_type" data-kind="function" class="blk"><title>_plain_type — function, 6 lines</title></rect>
<rect x="110.0" y="1769.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._dedupe" data-kind="function" class="blk"><title>_dedupe — function, 9 lines</title></rect>
<rect x="110.0" y="1774.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.extract" data-kind="function" class="blk"><title>extract — function, 6 lines</title></rect>
<rect x="192.0" y="1697.5" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python — 37 lines</title></rect>
<rect x="192.0" y="1707.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.extract" data-kind="function" class="blk"><title>extract — function, 14 lines</title></rect>
<rect x="292.0" y="1697.5" width="74.0" height="190.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.build — 381 lines</title></rect>
<rect x="292.0" y="1735.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.extract" data-kind="function" class="blk"><title>extract — function, 27 lines</title></rect>
<rect x="292.0" y="1749.5" width="74.0" height="34.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.spec_from" data-kind="function" class="blk"><title>spec_from — function, 68 lines</title></rect>
<rect x="292.0" y="1784.5" width="74.0" height="22.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build._load_cell" data-kind="function" class="blk"><title>_load_cell — function, 44 lines</title></rect>
<rect x="292.0" y="1811.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.clear" data-kind="function" class="blk"><title>clear — function, 26 lines</title></rect>
<rect x="292.0" y="1825.0" width="74.0" height="62.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.run" data-kind="function" class="blk"><title>run — function, 125 lines</title></rect>
<rect x="299.0" y="1831.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.build.run.say" data-kind="function" class="blk"><title>say — function, 3 lines</title></rect>
<rect x="374.0" y="1697.5" width="74.0" height="164.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.config — 328 lines</title></rect>
<rect x="374.0" y="1738.0" width="74.0" height="3.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.ConfigError" data-kind="class" class="blk"><title>ConfigError — class, 6 lines</title></rect>
<rect x="381.0" y="1739.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.ConfigError.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="374.0" y="1742.5" width="74.0" height="7.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.Entry" data-kind="class" class="blk"><title>Entry — class, 14 lines</title></rect>
<rect x="381.0" y="1748.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.Entry.line" data-kind="function" class="blk"><title>line — function, 2 lines</title></rect>
<rect x="374.0" y="1751.0" width="74.0" height="8.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.RunFile" data-kind="class" class="blk"><title>RunFile — class, 17 lines</title></rect>
<rect x="381.0" y="1753.5" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.RunFile.select" data-kind="function" class="blk"><title>select — function, 12 lines</title></rect>
<rect x="374.0" y="1760.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._path" data-kind="function" class="blk"><title>_path — function, 6 lines</title></rect>
<rect x="374.0" y="1764.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config._within" data-kind="function" class="blk"><title>_within — function, 3 lines</title></rect>
<rect x="374.0" y="1767.0" width="74.0" height="65.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.load" data-kind="function" class="blk"><title>load — function, 130 lines</title></rect>
<rect x="381.0" y="1799.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.config.load.pick" data-kind="function" class="blk"><title>pick — function, 3 lines</title></rect>
<rect x="374.0" y="1833.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.apply_reference" data-kind="function" class="blk"><title>apply_reference — function, 11 lines</title></rect>
<rect x="374.0" y="1839.5" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.run" data-kind="function" class="blk"><title>run — function, 43 lines</title></rect>
<rect x="456.0" y="1697.5" width="74.0" height="137.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.checks — 275 lines</title></rect>
<rect x="456.0" y="1728.0" width="74.0" height="13.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Loaded" data-kind="class" class="blk"><title>Loaded — class, 26 lines</title></rect>
<rect x="463.0" y="1729.5" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.__init__" data-kind="function" class="blk"><title>__init__ — function, 15 lines</title></rect>
<rect x="463.0" y="1738.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.larder" data-kind="function" class="blk"><title>larder — function, 2 lines</title></rect>
<rect x="463.0" y="1740.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.measure" data-kind="function" class="blk"><title>measure — function, 2 lines</title></rect>
<rect x="456.0" y="1742.0" width="74.0" height="18.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Report" data-kind="class" class="blk"><title>Report — class, 37 lines</title></rect>
<rect x="463.0" y="1743.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.__init__" data-kind="function" class="blk"><title>__init__ — function, 2 lines</title></rect>
<rect x="463.0" y="1745.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.note" data-kind="function" class="blk"><title>note — function, 2 lines</title></rect>
<rect x="463.0" y="1746.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.check" data-kind="function" class="blk"><title>check — function, 8 lines</title></rect>
<rect x="463.0" y="1751.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="463.0" y="1753.0" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.total" data-kind="function" class="blk"><title>total — function, 15 lines</title></rect>
<rect x="456.0" y="1761.5" width="74.0" height="41.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.generated" data-kind="function" class="blk"><title>generated — function, 83 lines</title></rect>
<rect x="456.0" y="1804.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.custom" data-kind="function" class="blk"><title>custom — function, 26 lines</title></rect>
<rect x="456.0" y="1818.0" width="74.0" height="16.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks._run_cells" data-kind="function" class="blk"><title>_run_cells — function, 33 lines</title></rect>
<rect x="538.0" y="1697.5" width="74.0" height="98.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.larder — 196 lines</title></rect>
<rect x="538.0" y="1729.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder._count" data-kind="function" class="blk"><title>_count — function, 13 lines</title></rect>
<rect x="538.0" y="1736.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.redact" data-kind="function" class="blk"><title>redact — function, 32 lines</title></rect>
<rect x="538.0" y="1754.0" width="74.0" height="38.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.larder.Larder" data-kind="class" class="blk"><title>Larder — class, 77 lines</title></rect>
<rect x="545.0" y="1761.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 7 lines</title></rect>
<rect x="545.0" y="1765.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.read" data-kind="function" class="blk"><title>read — function, 3 lines</title></rect>
<rect x="545.0" y="1767.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.fail" data-kind="function" class="blk"><title>fail — function, 3 lines</title></rect>
<rect x="545.0" y="1769.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.to_dict" data-kind="function" class="blk"><title>to_dict — function, 13 lines</title></rect>
<rect x="545.0" y="1777.0" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.from_dict" data-kind="function" class="blk"><title>from_dict — function, 10 lines</title></rect>
<rect x="545.0" y="1782.5" width="60.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.line" data-kind="function" class="blk"><title>line — function, 20 lines</title></rect>
<rect x="538.0" y="1793.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.of" data-kind="function" class="blk"><title>of — function, 3 lines</title></rect>
<rect x="638.0" y="1697.5" width="74.0" height="118.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.collect — 236 lines</title></rect>
<rect x="638.0" y="1714.0" width="74.0" height="5.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Definition" data-kind="class" class="blk"><title>Definition — class, 10 lines</title></rect>
<rect x="638.0" y="1720.5" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Module" data-kind="class" class="blk"><title>Module — class, 12 lines</title></rect>
<rect x="638.0" y="1727.5" width="74.0" height="33.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._Collector" data-kind="class" class="blk"><title>_Collector — class, 67 lines</title></rect>
<rect x="645.0" y="1729.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="645.0" y="1732.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector._define" data-kind="function" class="blk"><title>_define — function, 17 lines</title></rect>
<rect x="645.0" y="1741.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ClassDef" data-kind="function" class="blk"><title>visit_ClassDef — function, 5 lines</title></rect>
<rect x="645.0" y="1744.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_FunctionDef" data-kind="function" class="blk"><title>visit_FunctionDef — function, 5 lines</title></rect>
<rect x="645.0" y="1749.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_Import" data-kind="function" class="blk"><title>visit_Import — function, 8 lines</title></rect>
<rect x="645.0" y="1754.0" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ImportFrom" data-kind="function" class="blk"><title>visit_ImportFrom — function, 14 lines</title></rect>
<rect x="638.0" y="1762.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._first_line" data-kind="function" class="blk"><title>_first_line — function, 5 lines</title></rect>
<rect x="638.0" y="1765.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._name_of" data-kind="function" class="blk"><title>_name_of — function, 15 lines</title></rect>
<rect x="638.0" y="1774.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._resolve_relative" data-kind="function" class="blk"><title>_resolve_relative — function, 16 lines</title></rect>
<rect x="638.0" y="1783.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.module_name" data-kind="function" class="blk"><title>module_name — function, 26 lines</title></rect>
<rect x="638.0" y="1797.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect_file" data-kind="function" class="blk"><title>collect_file — function, 21 lines</title></rect>
<rect x="638.0" y="1808.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect" data-kind="function" class="blk"><title>collect — function, 13 lines</title></rect>
<rect x="720.0" y="1697.5" width="74.0" height="92.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.resolve — 184 lines</title></rect>
<rect x="720.0" y="1711.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._id_for" data-kind="function" class="blk"><title>_id_for — function, 2 lines</title></rect>
<rect x="720.0" y="1713.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._resolve" data-kind="function" class="blk"><title>_resolve — function, 32 lines</title></rect>
<rect x="720.0" y="1730.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.larder_of" data-kind="function" class="blk"><title>larder_of — function, 17 lines</title></rect>
<rect x="720.0" y="1740.0" width="74.0" height="49.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.to_ir" data-kind="function" class="blk"><title>to_ir — function, 98 lines</title></rect>
<rect x="727.0" y="1769.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.resolve.to_ir._point_at" data-kind="function" class="blk"><title>_point_at — function, 9 lines</title></rect>
<rect x="28.0" y="1926.0" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.validate — 278 lines</title></rect>
<rect x="28.0" y="1947.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.validate.IRError" data-kind="class" class="blk"><title>IRError — class, 2 lines</title></rect>
<rect x="28.0" y="1949.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._schema" data-kind="function" class="blk"><title>_schema — function, 2 lines</title></rect>
<rect x="28.0" y="1951.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._props" data-kind="function" class="blk"><title>_props — function, 5 lines</title></rect>
<rect x="28.0" y="1954.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._fields" data-kind="function" class="blk"><title>_fields — function, 4 lines</title></rect>
<rect x="28.0" y="1957.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check" data-kind="function" class="blk"><title>check — function, 108 lines</title></rect>
<rect x="28.0" y="2019.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._check_larder" data-kind="function" class="blk"><title>_check_larder — function, 58 lines</title></rect>
<rect x="28.0" y="2049.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.validate" data-kind="function" class="blk"><title>validate — function, 6 lines</title></rect>
<rect x="28.0" y="2053.0" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check_model_matches_schema" data-kind="function" class="blk"><title>check_model_matches_schema — function, 23 lines</title></rect>
<rect x="110.0" y="1926.0" width="74.0" height="82.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.model — 165 lines</title></rect>
<rect x="110.0" y="1942.5" width="74.0" height="19.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Meta" data-kind="class" class="blk"><title>Meta — class, 38 lines</title></rect>
<rect x="117.0" y="1952.5" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Meta.to_dict" data-kind="function" class="blk"><title>to_dict — function, 18 lines</title></rect>
<rect x="110.0" y="1963.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Node" data-kind="class" class="blk"><title>Node — class, 21 lines</title></rect>
<rect x="117.0" y="1967.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 3 lines</title></rect>
<rect x="117.0" y="1969.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.to_dict" data-kind="function" class="blk"><title>to_dict — function, 8 lines</title></rect>
<rect x="110.0" y="1975.0" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Edge" data-kind="class" class="blk"><title>Edge — class, 15 lines</title></rect>
<rect x="117.0" y="1979.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Edge.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="110.0" y="1984.0" width="74.0" height="24.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Graph" data-kind="class" class="blk"><title>Graph — class, 48 lines</title></rect>
<rect x="117.0" y="1988.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.node" data-kind="function" class="blk"><title>node — function, 4 lines</title></rect>
<rect x="117.0" y="1991.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.edge" data-kind="function" class="blk"><title>edge — function, 4 lines</title></rect>
<rect x="117.0" y="1993.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.has" data-kind="function" class="blk"><title>has — function, 2 lines</title></rect>
<rect x="117.0" y="1996.0" width="60.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.to_dict" data-kind="function" class="blk"><title>to_dict — function, 16 lines</title></rect>
<rect x="117.0" y="2005.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.from_dict" data-kind="function" class="blk"><title>from_dict — function, 6 lines</title></rect>
<rect x="210.0" y="1926.0" width="74.0" height="111.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.extract — 222 lines</title></rect>
<rect x="210.0" y="1956.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._values_from" data-kind="function" class="blk"><title>_values_from — function, 17 lines</title></rect>
<rect x="210.0" y="1966.0" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._normalise" data-kind="function" class="blk"><title>_normalise — function, 45 lines</title></rect>
<rect x="210.0" y="1989.5" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._svg_files" data-kind="function" class="blk"><title>_svg_files — function, 25 lines</title></rect>
<rect x="210.0" y="2003.0" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.harvest" data-kind="function" class="blk"><title>harvest — function, 43 lines</title></rect>
<rect x="210.0" y="2025.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="210.0" y="2030.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.summarise" data-kind="function" class="blk"><title>summarise — function, 13 lines</title></rect>
<rect x="292.0" y="1926.0" width="74.0" height="105.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.tokens — 211 lines</title></rect>
<rect x="292.0" y="1954.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._top" data-kind="function" class="blk"><title>_top — function, 2 lines</title></rect>
<rect x="292.0" y="1956.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._mode" data-kind="function" class="blk"><title>_mode — function, 3 lines</title></rect>
<rect x="292.0" y="1958.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._luminance" data-kind="function" class="blk"><title>_luminance — function, 6 lines</title></rect>
<rect x="292.0" y="1962.5" width="74.0" height="50.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.derive" data-kind="function" class="blk"><title>derive — function, 101 lines</title></rect>
<rect x="299.0" y="1984.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.tokens.derive.accent" data-kind="function" class="blk"><title>accent — function, 2 lines</title></rect>
<rect x="292.0" y="2014.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.geometry" data-kind="function" class="blk"><title>geometry — function, 14 lines</title></rect>
<rect x="292.0" y="2022.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="292.0" y="2026.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.from_folder" data-kind="function" class="blk"><title>from_folder — function, 9 lines</title></rect>
<rect x="392.0" y="1926.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="492.0" y="1926.0" width="74.0" height="75.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab.pg_probe — 150 lines</title></rect>
<rect x="492.0" y="1961.5" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.probe" data-kind="function" class="blk"><title>probe — function, 30 lines</title></rect>
<rect x="492.0" y="1983.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe._simplify" data-kind="function" class="blk"><title>_simplify — function, 3 lines</title></rect>
<rect x="492.0" y="1986.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="592.0" y="1926.0" width="74.0" height="132.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook.spec — 264 lines</title></rect>
<rect x="592.0" y="1954.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec._step" data-kind="function" class="blk"><title>_step — function, 4 lines</title></rect>
<rect x="592.0" y="1957.0" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.from_ir" data-kind="function" class="blk"><title>from_ir — function, 108 lines</title></rect>
<rect x="599.0" y="1964.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.notebook.spec.from_ir._order" data-kind="function" class="blk"><title>_order — function, 5 lines</title></rect>
<rect x="592.0" y="2012.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.scaffold" data-kind="function" class="blk"><title>scaffold — function, 20 lines</title></rect>
<rect x="592.0" y="2023.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.merge" data-kind="function" class="blk"><title>merge — function, 58 lines</title></rect>
<rect x="592.0" y="2053.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.load" data-kind="function" class="blk"><title>load — function, 2 lines</title></rect>
<rect x="592.0" y="2055.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<rect x="692.0" y="1926.0" width="74.0" height="242.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.filter — 485 lines</title></rect>
<rect x="692.0" y="1944.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter._rebuild" data-kind="function" class="blk"><title>_rebuild — function, 57 lines</title></rect>
<rect x="699.0" y="1948.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.surviving_parent" data-kind="function" class="blk"><title>surviving_parent — function, 5 lines</title></rect>
<rect x="699.0" y="1955.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.lift" data-kind="function" class="blk"><title>lift — function, 6 lines</title></rect>
<rect x="692.0" y="1973.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_kinds" data-kind="function" class="blk"><title>drop_kinds — function, 14 lines</title></rect>
<rect x="692.0" y="1981.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.only_kinds" data-kind="function" class="blk"><title>only_kinds — function, 14 lines</title></rect>
<rect x="692.0" y="1989.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_stdlib" data-kind="function" class="blk"><title>drop_stdlib — function, 13 lines</title></rect>
<rect x="692.0" y="1997.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_external" data-kind="function" class="blk"><title>drop_external — function, 3 lines</title></rect>
<rect x="692.0" y="1999.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.subtree" data-kind="function" class="blk"><title>subtree — function, 13 lines</title></rect>
<rect x="692.0" y="2007.0" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.neighbourhood" data-kind="function" class="blk"><title>neighbourhood — function, 45 lines</title></rect>
<rect x="692.0" y="2030.5" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.collapse_to_depth" data-kind="function" class="blk"><title>collapse_to_depth — function, 18 lines</title></rect>
<rect x="699.0" y="2035.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.collapse_to_depth.level" data-kind="function" class="blk"><title>level — function, 6 lines</title></rect>
<rect x="692.0" y="2040.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_builtins" data-kind="function" class="blk"><title>drop_builtins — function, 14 lines</title></rect>
<rect x="692.0" y="2048.5" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.overview" data-kind="function" class="blk"><title>overview — function, 35 lines</title></rect>
<rect x="692.0" y="2067.0" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.shape" data-kind="function" class="blk"><title>shape — function, 72 lines</title></rect>
<rect x="699.0" y="2091.0" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.shape.rank_of" data-kind="function" class="blk"><title>rank_of — function, 9 lines</title></rect>
<rect x="692.0" y="2104.0" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.split" data-kind="function" class="blk"><title>split — function, 24 lines</title></rect>
<rect x="692.0" y="2117.0" width="74.0" height="51.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.classify" data-kind="function" class="blk"><title>classify — function, 102 lines</title></rect>
<rect x="28.0" y="1471.5" 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="1471.5" 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="1471.5" width="74.0" height="269.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.site — 539 lines</title></rect>
<rect x="210.0" y="1629.0" 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="210.0" y="1657.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._slots" data-kind="function" class="blk"><title>_slots — function, 15 lines</title></rect>
<rect x="210.0" y="1665.5" 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="210.0" y="1668.5" 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="1678.0" 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="1686.5" 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="1735.5" 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="292.0" y="1471.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="292.0" y="1492.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="292.0" y="1503.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="292.0" y="1536.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="292.0" y="1546.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="292.0" y="1567.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="292.0" y="1581.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="292.0" y="1593.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="292.0" y="1597.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="292.0" y="1626.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="292.0" y="1628.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="374.0" y="1471.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="374.0" y="1491.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="374.0" y="1494.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="381.0" y="1500.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="381.0" y="1504.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="374.0" y="1519.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="374.0" y="1533.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="374.0" y="1620.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="456.0" y="1471.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="456.0" y="1490.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="456.0" y="1492.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="456.0" y="1494.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="456.0" y="1496.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="456.0" y="1502.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="456.0" y="1519.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="463.0" y="1536.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="456.0" y="1577.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="456.0" y="1582.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="463.0" y="1587.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="463.0" y="1591.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="463.0" y="1592.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="456.0" y="1603.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="456.0" y="1605.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="456.0" y="1609.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="456.0" y="1611.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="538.0" y="1471.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="538.0" y="1503.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="545.0" y="1507.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="545.0" y="1516.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="538.0" y="1532.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="538.0" y="1537.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="538.0" y="1547.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="1608.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="620.0" y="1471.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="620.0" y="1498.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="620.0" y="1501.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="620.0" y="1512.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="620.0" y="1514.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="620.0" y="1520.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="620.0" y="1533.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="620.0" y="1536.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="702.0" y="1471.5" width="74.0" height="135.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.docsite — 270 lines</title></rect>
<rect x="702.0" y="1504.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.docsite._note" data-kind="function" class="blk"><title>_note — function, 7 lines</title></rect>
<rect x="702.0" y="1509.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.docsite._missing" data-kind="function" class="blk"><title>_missing — function, 9 lines</title></rect>
<rect x="702.0" y="1514.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.docsite._tree" data-kind="function" class="blk"><title>_tree — function, 15 lines</title></rect>
<rect x="709.0" y="1517.5" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.docsite._tree.walk" data-kind="function" class="blk"><title>walk — function, 7 lines</title></rect>
<rect x="702.0" y="1523.0" width="74.0" height="34.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.docsite._page" data-kind="function" class="blk"><title>_page — function, 69 lines</title></rect>
<rect x="702.0" y="1558.5" width="74.0" height="47.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.docsite.write" data-kind="function" class="blk"><title>write — function, 95 lines</title></rect>
<rect x="709.0" y="1566.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.docsite.write.put" data-kind="function" class="blk"><title>put — function, 4 lines</title></rect>
<rect x="784.0" y="1471.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="784.0" y="1488.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="784.0" y="1493.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="784.0" y="1497.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="791.0" y="1508.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="784.0" y="1540.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="791.0" y="1543.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="28.0" y="1779.0" 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="28.0" y="1789.5" 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="28.0" y="1798.5" 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="35.0" y="1800.5" 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="28.0" y="1806.0" 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="35.0" y="1809.5" 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="35.0" y="1841.5" 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="110.0" y="1779.0" 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="110.0" y="1795.0" 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="110.0" y="1799.0" 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="110.0" y="1805.0" 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="117.0" y="1809.5" 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="110.0" y="1814.5" 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="110.0" y="1823.0" 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="1833.5" 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="192.0" y="1779.0" 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="192.0" y="1792.5" 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="292.0" y="1779.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="292.0" y="1791.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="292.0" y="1795.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="292.0" y="1798.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="292.0" y="1807.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="292.0" y="1827.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="292.0" y="1833.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="292.0" y="1841.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="292.0" y="1849.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="292.0" y="1871.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="292.0" y="1887.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="292.0" y="1901.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="374.0" y="1779.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="374.0" y="1788.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="374.0" y="1792.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="374.0" y="1794.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="374.0" y="1803.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="374.0" y="1811.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="374.0" y="1817.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="374.0" y="1824.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="374.0" y="1834.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="456.0" y="1779.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="456.0" y="1789.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="538.0" y="1779.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="538.0" y="1786.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="620.0" y="1779.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="620.0" y="1789.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="702.0" y="1779.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="702.0" y="1789.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="702.0" y="1791.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="702.0" y="1794.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="702.0" y="1801.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="702.0" y="1805.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="702.0" y="1808.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="702.0" y="1817.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="784.0" y="1779.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="784.0" y="1789.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="28.0" y="1943.5" 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="28.0" y="1953.5" 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="110.0" y="1943.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="110.0" y="1956.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="192.0" y="1943.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="192.0" y="1950.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="292.0" y="1943.5" width="74.0" height="308.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.config — 616 lines</title></rect>
<rect x="292.0" y="1984.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="299.0" y="1985.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="292.0" y="1988.5" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.config.Entry" data-kind="class" class="blk"><title>Entry — class, 15 lines</title></rect>
<rect x="299.0" y="1995.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="1997.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="299.0" y="2000.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="2007.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="2011.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="2013.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="2019.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="292.0" y="2029.0" width="74.0" height="60.5" 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, 121 lines</title></rect>
<rect x="299.0" y="2055.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="292.0" y="2090.5" 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="2097.0" 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="292.0" y="2104.5" width="74.0" height="18.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.config.run" data-kind="function" class="blk"><title>run — function, 36 lines</title></rect>
<rect x="292.0" y="2143.5" 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="299.0" y="2146.5" 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="292.0" y="2148.5" width="74.0" height="75.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, 151 lines</title></rect>
<rect x="292.0" y="2225.0" 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="292.0" y="2230.5" 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="1943.5" width="74.0" height="224.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.document — 449 lines</title></rect>
<rect x="374.0" y="1964.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="1969.0" width="74.0" height="5.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.document.Part" data-kind="class" class="blk"><title>Part — class, 11 lines</title></rect>
<rect x="374.0" y="1976.0" 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="374.0" y="1985.0" 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="374.0" y="1994.5" 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="374.0" y="2009.0" 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="374.0" y="2012.5" 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="374.0" y="2024.5" 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="374.0" y="2033.5" 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="374.0" y="2038.5" 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="374.0" y="2045.0" 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="374.0" y="2092.5" 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="381.0" y="2096.0" 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="374.0" y="2151.5" 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="374.0" y="2160.5" 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="374.0" y="2163.0" 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="1943.5" width="74.0" height="209.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.build — 418 lines</title></rect>
<rect x="456.0" y="1981.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="456.0" y="1996.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="456.0" y="2033.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="456.0" y="2067.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="456.0" y="2081.0" width="74.0" height="52.5" 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, 105 lines</title></rect>
<rect x="463.0" y="2091.0" 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="456.0" y="2134.5" 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="538.0" y="1943.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="538.0" y="1974.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="1975.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="545.0" y="1984.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="545.0" y="1986.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="538.0" y="1988.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="545.0" y="1989.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="545.0" y="1991.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="545.0" y="1993.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="545.0" y="1997.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="545.0" y="1999.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="538.0" y="2008.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="538.0" y="2053.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="538.0" y="2067.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="538.0" y="2086.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="538.0" y="2090.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="545.0" y="2104.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="538.0" y="2127.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="620.0" y="1943.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="620.0" y="1975.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="620.0" y="1982.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="620.0" y="2000.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="627.0" y="2007.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="627.0" y="2011.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="627.0" y="2013.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="627.0" y="2015.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="627.0" y="2023.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="627.0" y="2028.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="620.0" y="2039.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="702.0" y="1943.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="702.0" y="1966.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="702.0" y="1970.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="702.0" y="1973.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="709.0" y="1974.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="709.0" y="1976.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="709.0" y="1980.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="709.0" y="1982.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="709.0" y="1983.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="709.0" y="1986.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="709.0" y="2000.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="702.0" y="2013.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="702.0" y="2023.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="28.0" y="2289.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="28.0" y="2337.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="28.0" y="2339.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="28.0" y="2350.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="28.0" y="2356.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="28.0" y="2374.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="28.0" y="2389.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="28.0" y="2392.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="110.0" y="2289.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="110.0" y="2321.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="110.0" y="2336.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="110.0" y="2342.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="110.0" y="2350.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="110.0" y="2357.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="192.0" y="2289.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="192.0" y="2305.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="192.0" y="2320.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="192.0" y="2324.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="192.0" y="2346.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="274.0" y="2289.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="274.0" y="2309.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="274.0" y="2351.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="274.0" y="2357.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="274.0" y="2361.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="274.0" y="2366.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="356.0" y="2289.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="356.0" y="2299.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="456.0" y="2289.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="456.0" y="2306.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="2312.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="456.0" y="2319.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="2321.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="2324.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="2333.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="2336.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="463.0" y="2341.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="463.0" y="2346.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="2354.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="2357.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="2366.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="456.0" y="2375.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="2389.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="456.0" y="2400.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="2289.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="2303.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="2305.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="538.0" y="2322.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="538.0" y="2332.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="2361.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="638.0" y="2289.5" 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="638.0" y="2310.5" 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="638.0" y="2312.5" 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="638.0" y="2314.5" 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="638.0" y="2318.0" 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="638.0" y="2321.0" 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="638.0" y="2382.5" 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="638.0" y="2412.5" 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="638.0" y="2416.5" 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="720.0" y="2289.5" 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="720.0" y="2306.0" 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="727.0" y="2316.0" 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="720.0" y="2326.5" 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="727.0" y="2331.0" 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="727.0" y="2333.0" 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="720.0" y="2338.5" 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="727.0" y="2342.5" 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="2347.5" 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="727.0" y="2352.0" 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="727.0" y="2354.5" 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="727.0" y="2357.0" 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="727.0" y="2359.5" 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="727.0" y="2368.5" 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="28.0" y="2468.5" 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="28.0" y="2486.5" 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="35.0" y="2491.0" 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="35.0" y="2498.0" 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="28.0" y="2516.0" 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="28.0" y="2524.0" 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="28.0" y="2532.0" 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="28.0" y="2539.5" 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="28.0" y="2542.0" 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="28.0" y="2549.5" 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="28.0" y="2573.0" 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="35.0" y="2578.0" 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="28.0" y="2583.0" 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="28.0" y="2591.0" 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="28.0" y="2609.5" 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="35.0" y="2633.5" 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="28.0" y="2646.5" 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="28.0" y="2659.5" 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="110.0" y="2468.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="110.0" y="2499.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="110.0" y="2504.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="110.0" y="2509.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="110.0" y="2512.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="110.0" y="2520.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="110.0" y="2554.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="110.0" y="2565.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="117.0" y="2574.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="117.0" y="2577.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="117.0" y="2579.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="110.0" y="2597.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="110.0" y="2636.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="110.0" y="2651.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="210.0" y="2468.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="210.0" y="2499.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="210.0" y="2508.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="210.0" y="2532.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="210.0" y="2545.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="210.0" y="2568.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="210.0" y="2572.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="292.0" y="2468.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="292.0" y="2496.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="292.0" y="2498.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="292.0" y="2501.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="292.0" y="2505.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="299.0" y="2527.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="292.0" y="2556.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="292.0" y="2564.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="292.0" y="2569.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="392.0" y="2468.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="492.0" y="2468.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="492.0" y="2504.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="492.0" y="2526.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="492.0" y="2528.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="592.0" y="2468.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="592.0" y="2496.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="592.0" y="2499.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="599.0" y="2506.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="592.0" y="2554.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="592.0" y="2565.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="592.0" y="2595.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="592.0" y="2597.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<text x="28" y="40" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="28" y="1242" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="210" y="1242" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
<text x="28" y="1406" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="702" y="1406" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="28" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="292" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</text>
<text x="638" y="1692" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="28" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="210" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
<text x="392" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="492" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="592" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="692" y="1920" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="28" y="1219" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="28" y="1466" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="210" y="1466" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="28" y="1773" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="292" y="1773" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
<text x="28" y="1938" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.cli</text>
<text x="292" y="1938" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</text>
<text x="28" y="2284" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="456" y="2284" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="638" y="2284" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="28" y="2462" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="210" y="2462" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
<text x="392" y="2462" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="492" y="2462" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="592" y="2462" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="28" y="1442" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="110" y="213" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="192" y="152" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
<text x="274" y="109" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">reference</text>
<text x="356" y="93" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli</text>
<text x="356" y="95" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli</text>
<text x="438" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
<text x="520" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="602" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
<text x="684" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
<text x="766" y="69" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="28" y="1271" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="110" y="1271" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="210" y="1384" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emit</text>
<text x="292" y="1316" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="374" y="1302" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">view</text>
<text x="456" y="1297" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">common</text>
<text x="538" y="1297" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">run</text>
<text x="620" y="1294" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="702" y="1282" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">check</text>
<text x="784" y="1274" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="28" y="1668" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
<text x="110" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
<text x="192" y="1568" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
<text x="274" y="1561" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="356" y="1560" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
<text x="438" y="1552" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
<text x="520" y="1504" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</text>
<text x="602" y="1454" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
<text x="702" y="1562" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="784" y="1556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="28" y="1802" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="110" y="1787" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="192" y="1725" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="292" y="1897" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
<text x="374" y="1870" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">config</text>
<text x="456" y="1844" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
<text x="538" y="1804" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">larder</text>
<text x="638" y="1824" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="720" y="1798" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="28" y="2074" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="110" y="2018" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="210" y="2046" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="292" y="2040" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
<text x="392" y="1949" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="492" y="2010" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="592" y="2067" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<text x="692" y="2178" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<rect x="28" y="2217.5" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="2217.5" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="2217.5" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="2217.5" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="2225.5" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="2225.5" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">47 files · 10,350 lines · 1px ≈ 2.0 lines</text>
<text x="28" y="1494" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="110" y="1494" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="210" y="1750" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
<text x="292" y="1640" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="374" y="1638" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
<text x="456" y="1635" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
<text x="538" y="1620" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
<text x="620" y="1618" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
<text x="702" y="1616" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docsite</text>
<text x="784" y="1562" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</text>
<text x="28" y="1861" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">markdown</text>
<text x="110" y="1848" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">paged</text>
<text x="192" y="1820" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
<text x="292" y="1914" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emit</text>
<text x="374" y="1848" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="456" y="1838" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">run</text>
<text x="538" y="1833" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">view</text>
<text x="620" y="1832" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">scaffold</text>
<text x="702" y="1828" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">common</text>
<text x="784" y="1826" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="28" y="1982" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">check</text>
<text x="110" y="1970" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">contract</text>
<text x="192" y="1969" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="292" y="2260" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">config</text>
<text x="374" y="2177" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">document</text>
<text x="456" y="2162" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
<text x="538" y="2142" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
<text x="620" y="2050" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">larder</text>
<text x="702" y="2034" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notes</text>
<text x="28" y="2440" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="110" y="2433" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="192" y="2394" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="274" y="2379" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="356" y="2317" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="456" y="2416" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="538" y="2390" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="638" y="2438" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="720" y="2381" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="28" y="2720" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<text x="110" y="2669" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pages</text>
<text x="210" y="2588" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="292" y="2583" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
<text x="392" y="2492" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="492" y="2552" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="592" y="2610" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<rect x="28" y="2760.0" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="2768.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="2760.0" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="2768.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="2760.0" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="2768.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="2760.0" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="2768.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="2768.0" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">55 files · 13,028 lines · 1px ≈ 2.0 lines</text>
</svg>

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

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

View File

@@ -0,0 +1,259 @@
# 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.site]
output = "../docs_out/orders_service_site"
```
### `[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.site]
output = "../docs_out/storefront_site"
```
### `[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 `site`, nothing else.
| 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 site |
---
## `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 site. Its path is:
```
<notes>/_intro.md top of the whole document
<notes>/_end.md end of the whole document
<notes>/<book>/_intro.md top of that book's part
<notes>/<book>/<spot>.md above that spot in the book
```
`<book>` is the book's `name` (the reader's name if none was given).
### Spots every book has
| spot | appears above |
|---|---|
| `larder` | "what came in": what was read |
| `pages` | the index of diagram pages |
| `overview` | the first diagram page |
| `book` | "what came out": the closing summary |
| `step-ir`, `step-view`, `step-pages`, `step-graph`, `step-index`, `step-sidebar`, `step-minimap`, `step-explore` | each intermediate step |
### Spots that depend on the source
- **Pages of a code tree**: named by a package's or directory's dotted path from the
root, for example `billing.invoices`. Not every directory gets a page: a level that
holds a single group is merged into it, and the top page is always `overview`.
- **Pages of a schema**: `overview`, then `tables-01`, `tables-02`, …
- **An API** (`openapi`, `usage`): `setup`, `client`, `shapes`, and one spot per call:
the method and path with every character outside letters, digits, `.`, `_` and `-`
replaced by `-`. So `GET /orders/{orderId}/lines` becomes `GET-orders-orderId-lines`.
Page ids for a code tree depend on the actual directories. **Do not derive them yourself
unless you have the full directory listing.** Ask for the output of:
```
python3 -m docgen scaffold docgen.toml --list
```
It prints every spot that exists, one per line.
### Writing a note
- Plain markdown: headings, paragraphs, lists, **bold**, *italic*, `code`, fenced code
blocks, links, tables, quotes.
- No raw HTML. It is shown as text.
- Images: a path relative to the note file, e.g. `![flow](../img/flow.svg)`. They are
embedded.
- Keep it short: a few paragraphs. Start with `##` headings, because the page already
has its own title.
- A note that contains only the stub comment docgen writes renders nothing. Write below
the comment, or replace the file's contents.
Example: `notes/code/billing.invoices.md`:
```markdown
## Why invoices are immutable
An invoice is never edited after it is issued. Corrections are a new credit note that
references it. This is a legal requirement, not a design preference, so do not add
update endpoints here.
Start reading at `InvoiceIssuer`. It is the only place an invoice is created.
```
---
## When the build refuses your file
The person running docgen pastes the errors back. Each one says what to change:
| error contains | fix |
|---|---|
| `has unknown key 'x'` | remove the key or use a listed one; check the spelling |
| `docgen reads a schema dump and never connects` | remove `url`; use `schema = "path/to/schema.json"` |
| `no grammar for .sh` | remove those extensions from `ext` |
| `no reader named 'x'` | use one of `python`, `code`, `db`, `openapi`, `usage` |
| `no output named 'x'` | use `notebook` or `site` |
| `is inside the tree it reads` | move `output` or `build` outside the source directory |
| `needs a name when there are several` | give each `[[extract.x]]` table a different `name` |
| `must name exactly one source` | a `[[book]]` needs exactly one of `root`, `schema`, `openapi`, `har` |
| `is not a directory` / `is not a file` | the path is wrong; ask for the real layout |
| `note … belongs above 'x', which book 'y' no longer has` | that spot does not exist: rename the note to a listed spot, or delete it |
| `both [project] and [[book]]` | use one shape per file |

View File

@@ -0,0 +1,269 @@
"""
A document's site: an index of books, and every book as pages you click through.
site/
├── index.html every book, with what came in and what came out
├── site.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 site 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 .site import CONTENT_W, CSS, SIDEBAR_W, VIEWER, _fill, _ledger, _slots
from .site 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 site 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="../site.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 site. 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 / "site.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 site 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="site.css"', 'href="../site.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="site.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-header"><b>{escape(title)}</b><small>{len(books)} book(s)</small></div>
<ul>{"".join(nav)}</ul>
</nav>
<main class="content">
<h1>{escape(title)}</h1>
<p class="lede">Regenerated, not edited. Each book opens with what came in and what came out.</p>
{_note((intro, {}) if intro else None)}
{_missing(failed)}
<div class="books">{"".join(cards)}</div>
{_note((end, {}) if end else None)}
</main>
</div>
</body>
</html>
""")
return written

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
"""
Draw pages: measure each one against a width, then render it for an output.
`ops/pages.py` decides which pages exist and never renders anything — `ops/`
imports no emitter. This is the other half: the `fits` test it needs, and the
drawing of a laid-out page, with the links an output wants.
## Measured, not estimated
A layered drawing's width is its widest level, but *how* wide depends on the
labels, the font and the spacing the style sets. A count-based estimate was off
by enough to matter, so when Graphviz is present a page is rendered and its
`width="Npt"` read back. Without Graphviz the estimate is all there is, and the
page records `measured: False` so nobody mistakes one for the other.
## Points and pixels
Graphviz and the ERD emitter both write sizes in points, and a browser draws a
point as 4/3 of a CSS pixel. Budgets here are pixels, because the column they
have to fit — a notebook cell, a site'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 site's content
# column is set by `site.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 site and anything else that
can follow a link decide their own scheme. Context boxes link too, when their
branch has a page of its own — that is how a reader crosses to a sibling.
"""
by_scope = {p.get("scope") or p["id"]: p for p in pages}
by_id = {p["id"]: p for p in pages}
out = {}
for n in page["ir"]["nodes"]:
target = by_id.get(n["id"]) if page.get("tables") else by_scope.get(n["id"])
if target is not None and target is not page:
out[n["id"]] = url(target)
return out
def render(page: dict, style, width: int, *, links: dict | None = None) -> str | None:
"""A laid-out page as SVG text, or None when it cannot be drawn here.
Schema pages go through the ERD emitter with its columns capped to `width`;
everything else, schema overviews included, through Graphviz with the page's
layout options and the caller's links.
"""
if page.get("tables") and page["id"] != "overview":
from .erd import emit as erd
return erd(page["ir"], style, width=width)
from .dot import emit, have_graphviz, render as dot_render
if not have_graphviz():
return None
svg = dot_render(emit(page["ir"], style, rankdir=(page.get("options") or {}).get("rankdir"),
links=links)).decode("utf-8")
return svg
def inline(svg: str) -> str:
"""SVG text ready to sit inside HTML: no XML declaration, no DOCTYPE.
Inline rather than `<img>`, because an image swallows every click and the
whole point of a page's boxes is that they open other pages.
"""
svg = re.sub(r"<\?xml[^>]*\?>\s*", "", svg)
svg = re.sub(r"<!DOCTYPE[^>]*>\s*", "", svg)
return re.sub(r"<!--.*?-->\s*", "", svg, flags=re.S).strip()

View File

@@ -177,6 +177,11 @@ CSS = """/* Generated by docgen. The layout five demos converged on: a sticky si
--muted: __MUTED__;
--dim: __DIM__;
--accent: __ACCENT__;
/* Used by 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. */
--artery: __ARTERY__;
--ok: __OK__;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -267,6 +272,44 @@ body {
.gap b { color: var(--artery); }
.gap ul { margin: .4rem 0 0 1.1rem; color: var(--muted); }
.gap code { font-size: 11px; }
/* -- pages ------------------------------------------------------------------
A page is one subsystem drawn small enough to read. The diagram is inline SVG,
not an <img>, so its boxes are links: a group opens its own page, a module
opens its section of the reference page. */
.crumbs { color: var(--dim); font-size: 11px; margin-bottom: .6rem; }
.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--accent); }
.note { margin: 0 0 1.2rem; }
.note h1, .note h2, .note h3 { color: var(--text); margin: 1rem 0 .4rem; font-size: 14px; }
.note p, .note li { color: var(--muted); margin-bottom: .5rem; }
.note ul, .note ol { margin: 0 0 .8rem 1.2rem; }
.note pre { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: .6rem .8rem; overflow-x: auto; margin: .6rem 0; }
.note code { font-size: 11px; }
.note table { border-collapse: collapse; margin: .6rem 0; }
.note th, .note td { border-bottom: 1px solid var(--border); padding: 4px 12px 4px 0; text-align: left; }
.note blockquote { border-left: 2px solid var(--border); padding-left: .8rem; color: var(--dim); }
.note img { max-width: 100%; }
.figbar { display: flex; gap: 6px; align-items: center; margin-bottom: .4rem; font-size: 11px; color: var(--dim); }
.figbar button, .figbar a {
background: var(--surface); color: var(--muted); border: 1px solid var(--border);
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; text-decoration: none; }
.figbar button.on { color: var(--accent); border-color: var(--accent); }
.fig { border: 1px solid var(--border); border-radius: 8px; background: var(--surface);
padding: 8px; overflow: hidden; }
.fig svg { display: block; max-width: 100%; height: auto; margin: 0 auto; }
.fig.actual { overflow: auto; }
.fig.actual svg { max-width: none; }
.fig a:hover polygon, .fig a:hover path { stroke: var(--accent); }
.caption { color: var(--dim); font-size: 11px; margin: .4rem 0 1.2rem; }
.caption.warn { color: var(--artery); }
.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; }
"""
@@ -337,6 +380,8 @@ def _slots(style) -> dict:
"__MUTED__": s("text-muted"),
"__DIM__": s("text-dim"),
"__ACCENT__": s("accent"),
"__ARTERY__": s("error") or s("artery"),
"__OK__": s("ok") or s("atlas"),
"__FONT__": '"Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif',
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,382 @@
"""
Pages: a graph too big for one picture, as a set of pictures that each fit.
pages = paginate(view) # the structure: which pages exist
laid = layout(pages, fits) # per output: fits(page_ir, options) -> bool
A notebook scales an image down to its cell width, so a wide graph does not
scroll — it shrinks until its labels are unreadable. The only fix that holds is
to draw less per picture. This decides *what* goes on each picture; the caller
decides whether it fits, by rendering it and measuring, because a width
estimated from node counts is wrong the moment the labels are long.
**Which pages exist does not depend on width** — only how each one is laid out
does. So the two are separate calls: the page ids a note is filed under are the
same for a notebook column and a site 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 site — 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 site can build its tree from
`parent` and `children`.
"""
pages: list[dict] = []
slugs: set[str] = set()
def unique(slug: str) -> str:
base, i = slug, 2
while slug in slugs:
slug, i = f"{base}-{i}", i + 1
slugs.add(slug)
return slug
def opens(key: str, scope: str, ids: list) -> bool:
# A box opens into a page when it stands for more than itself.
return key != scope and any(i != key for i in ids)
def visit(scope: str, parent: str | None, depth: int, page_id: str | None = None):
ir, members = page_ir(view, scope)
if not ir["nodes"]:
return
# A page whose only member is one group says nothing but "open this" —
# docgen's own overview was one box reading `docgen`. Descend instead,
# keeping the id the caller expects, so the overview is still `overview`.
groups = [k for k, ids in members.items() if opens(k, scope, ids)]
if len(members) == 1 and len(groups) == 1:
return visit(groups[0], parent, depth, page_id or (scope or OVERVIEW))
page_id = page_id or scope or OVERVIEW
page = {
"id": page_id,
"slug": unique(slugify(page_id)),
"title": scope if scope and page_id == scope else (scope or "Overview"),
"scope": scope,
"parent": parent,
"depth": depth,
"ir": ir,
"options": {},
"children": [],
"too_wide": False,
}
pages.append(page)
if depth >= max_depth:
return
for key in sorted(members):
if opens(key, scope, members[key]):
page["children"].append(key)
visit(key, page_id, depth + 1)
visit("", None, 0)
return pages
def paginate_tables(view: dict, *, max_tables: int = MAX_TABLES) -> list[dict]:
"""Pages for a schema: connected components, packed, then chunked.
Each page is the tables plus their columns, so the ERD emitter draws cards.
The overview has one box per page, which is what a reader opens from.
"""
tables = sorted(n["id"] for n in view["nodes"] if n["kind"] == "table")
links: dict[str, set] = {t: set() for t in tables}
for e in view["edges"]:
if e["source"] in links and e["target"] in links:
links[e["source"]].add(e["target"])
links[e["target"]].add(e["source"])
seen, components = set(), []
for t in tables:
if t in seen:
continue
stack, comp = [t], []
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
comp.append(cur)
stack.extend(links[cur] - seen)
components.append(sorted(comp))
connected = sorted((c for c in components if len(c) > 1), key=lambda c: (-len(c), c[0]))
lonely = sorted(c[0] for c in components if len(c) == 1)
groups: list[list[str]] = []
for comp in connected:
# A component larger than a page is still split — by name, since a
# foreign-key cluster has no finer structure to split on — and the cut
# keys show as edges to the other page's tables in neither, which the
# overview's lifted edges make up for.
for i in range(0, len(comp), max_tables):
groups.append(comp[i:i + max_tables])
for i in range(0, len(lonely), max_tables):
groups.append(lonely[i:i + max_tables])
by_id = {n["id"]: n for n in view["nodes"]}
pages: list[dict] = []
table_page: dict[str, str] = {}
for i, group in enumerate(groups, start=1):
pid = group[0] if len(group) == 1 else f"{group[0]}…{group[-1]}"
slug = f"tables-{i:02d}"
for t in group:
table_page[t] = slug
keep = set(group) | {n["id"] for n in view["nodes"] if n.get("parent") in set(group)}
ir = {"meta": dict(view["meta"]),
"nodes": [dict(by_id[n]) for n in sorted(keep)],
"edges": [dict(e) for e in view["edges"]
if e["source"] in keep and e["target"] in keep]}
pages.append({"id": slug, "slug": slug, "title": pid, "scope": slug,
"parent": OVERVIEW, "depth": 1, "ir": ir, "options": {},
"children": [], "too_wide": False, "tables": True})
over_nodes = [{"id": p["id"], "kind": "table", "label": p["title"], "parent": None,
"attrs": {"group": True, "count": sum(
1 for n in p["ir"]["nodes"] if n["kind"] == "table")}}
for p in pages]
merged: dict[tuple, dict] = {}
for e in view["edges"]:
a, b = table_page.get(e["source"]), table_page.get(e["target"])
if a and b and a != b:
k = (a, b, e["kind"])
merged.setdefault(k, {"source": a, "target": b, "kind": e["kind"],
"attrs": {"weight": 0}})
merged[k]["attrs"]["weight"] += 1
overview = {"id": OVERVIEW, "slug": OVERVIEW, "title": "Overview", "scope": "",
"tables": True, "parent": None,
"depth": 0, "ir": {"meta": dict(view["meta"]), "nodes": over_nodes,
"edges": list(merged.values())},
"options": {}, "children": [p["id"] for p in pages], "too_wide": False}
return [overview] + pages
def coverage(view: dict, pages: list[dict]) -> list[str]:
"""Node ids of the view that no page accounts for. Empty means nothing lost.
A node is accounted for when a page shows it — as itself, or inside a box
that opens onto a page that does. Externals count when their branch appears
as context somewhere.
"""
shown = set()
for p in pages:
shown |= {n["id"] for n in p["ir"]["nodes"]}
# Schema pages draw tables and their columns; an API's endpoints are the
# notebook's walkthrough, not a diagram, so they are not a page's to account for.
tables_only = bool(pages) and pages[0].get("tables")
missing = []
for n in view["nodes"]:
if tables_only and n["kind"] not in ("table", "column"):
continue
nid = n["id"]
if nid in shown:
continue
parts = nid.split(".")
if n["kind"] == "external" and any(".".join(parts[:i]) in shown
for i in range(1, len(parts) + 1)):
continue
if n.get("parent") in shown and n["kind"] == "column":
continue
missing.append(nid)
return sorted(missing)
def for_view(ir: dict, view: dict, *, max_depth: int = 6) -> list[dict]:
"""The pages a book gets, whatever its source.
A schema pages by table — from the full IR, since a card needs its columns and
the default view drops them — and anything else pages by structure from the
view. The same decision `classify` makes about which emitter draws it.
"""
from .filter import classify
if classify(view)["emitter"] == "erd":
tables = {"meta": ir["meta"],
"nodes": [n for n in ir["nodes"] if n["kind"] in ("table", "column")],
"edges": [e for e in ir["edges"]
if e["kind"] in ("foreign_key", "references")]}
if any(n["kind"] == "table" for n in tables["nodes"]):
return paginate_tables(tables)
return paginate(view, max_depth=max_depth)

View File

@@ -2319,6 +2319,453 @@ def _runfiles():
_runfiles()
# --------------------------------------------------------------------------
print("\n19. pages — a graph too big for one picture, as pictures that each fit")
def _pages():
pages_mod = __import__(f"{PKG}.ops.pages", fromlist=["*"])
paged = __import__(f"{PKG}.emitters.paged", fromlist=["*"])
dot = __import__(f"{PKG}.emitters.dot", fromlist=["*"])
from_ops = __import__(f"{PKG}.ops", fromlist=["*"])
# ops/ decides what goes on a page and must never draw one. Paging is exactly
# where the temptation lives — measuring a width wants a renderer — so the
# measure is passed in as `fits`, and this holds the line.
reaching = []
for path in sorted((HERE / "ops").glob("*.py")):
for node in ast.walk(ast.parse(path.read_text())):
if isinstance(node, ast.ImportFrom) and node.module and node.level \
and node.module.split(".")[0] in ("emitters", "book", "cli", "extractors"):
reaching.append(f"{path.name}: {node.module}")
check("ops/ imports no emitter, book, command line or extractor", not reaching, reaching)
view = from_ops.overview(py_ex.extract(HERE).to_dict())
structure = pages_mod.paginate(view)
check(
"the first page is the overview",
structure[0]["id"] == pages_mod.OVERVIEW,
f"got {structure[0]['id']}",
)
check(
"every page is a valid IR on its own",
not [p["id"] for p in structure if check_ir(p["ir"])],
)
# The claim pagination is allowed to make: drawn smaller, never drawn less.
lost = pages_mod.coverage(view, structure)
check("pagination loses no node — each is on a page or inside a box that opens one",
not lost, f"{lost[:5]}")
ids = {p["id"] for p in structure}
check(
"every box that opens a page opens one that exists",
not [c for p in structure for c in p["children"] if c not in ids],
)
check(
"slugs are unique and safe as file names",
len({p["slug"] for p in structure}) == len(structure)
and all(re.fullmatch(r"[A-Za-z0-9._-]+", p["slug"]) for p in structure),
)
check(
"an endpoint id becomes a safe slug",
pages_mod.slugify("GET /orders/{orderId}") == "GET-orders-orderId",
pages_mod.slugify("GET /orders/{orderId}"),
)
# docgen's own overview used to be a single box reading `docgen`: a page
# whose only content is "open this". A lone group is descended into instead.
check(
"a page is never just one box that opens another page",
not [p["id"] for p in structure
if len([n for n in p["ir"]["nodes"] if not (n.get("attrs") or {}).get("context")]) == 1
and p["children"]],
)
# Which pages exist must not depend on width — notes are filed by page id,
# and a note has to land in the same place in a notebook and on a site.
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 site passes them, and then a
# relative source path — a dead link on a web page — must not survive.
page = next(p for p in laid if p["children"])
links = paged.links_for(laid, page, lambda t: f"{t['slug']}.html")
svg = paged.render(page, lucid_style, 800, links=links)
check(
"a box that opens a page links to it",
all(f'href="{url}"' in svg for url in links.values()) and links,
f"{len(links)} links",
)
check(
"an output that manages links drops the source-file hrefs",
".py" not in "".join(re.findall(r'href="([^"]+)"', svg)),
)
# Schemas page by table, and a card grows downward rather than sideways.
shop = db_ex.extract(HERE / "fixtures" / "shop.json").to_dict()
tables = pages_mod.for_view(shop, from_ops.overview(shop))
check(
"a schema pages by connected tables, cards with their columns",
tables[0]["id"] == "overview" and tables[1].get("tables")
and any(n["kind"] == "column" for n in tables[1]["ir"]["nodes"]),
)
many = {"models": {f"t{i:02d}": {"fields": {"id": {"type": "int", "pk": True}}}
for i in range(12)}}
wide_ir = db_ex.from_schema_dict(many, root="x").to_dict()
svg = erd_mod.emit(wide_ir, lucid, width=800)
check(
"an ERD capped to a width stays inside it",
paged.svg_width_px(svg) <= 800,
f"{paged.svg_width_px(svg):.0f}px",
)
_pages()
# --------------------------------------------------------------------------
print("\n20. notes, figures and pages — the hand-written part, and where it lands")
def _documents():
doc_mod = __import__(f"{PKG}.book.document", fromlist=["*"])
notes_mod = __import__(f"{PKG}.book.notes", fromlist=["*"])
build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
checks_mod = __import__(f"{PKG}.book.checks", fromlist=["*"])
md = __import__(f"{PKG}.emitters.markdown", fromlist=["*"])
dot = __import__(f"{PKG}.emitters.dot", fromlist=["*"])
import io as _io3
import contextlib as _ctx3
base = Path(tmp.name) / "documents"
src = base / "src" / "shop"
for sub in ("orders", "billing"):
(src / sub).mkdir(parents=True, exist_ok=True)
(src / sub / "__init__.py").write_text(f'"""{sub}."""\n')
(src / sub / "models.py").write_text(f'"""{sub} models."""\nfrom shop.{sub} import x\n')
(src / "__init__.py").write_text('"""shop."""\n')
notes = base / "notes"
# -- notes ---------------------------------------------------------------
stubbed = notes_mod.Notes(notes).scaffold({"app": [("overview", "page Overview")]})
check("scaffold writes a stub per spot", (notes / "app" / "overview.md") in stubbed)
check("a stub renders nothing", notes_mod.body((notes / "app" / "overview.md").read_text()) is None)
(notes / "app" / "overview.md").write_text(
(notes / "app" / "overview.md").read_text() + "## Written by hand\n\nKept.\n")
again = notes_mod.Notes(notes).scaffold({"app": [("overview", "page Overview")]})
check(
"scaffold never overwrites a note",
(notes / "app" / "overview.md") not in again
and "Written by hand" in (notes / "app" / "overview.md").read_text(),
)
(notes / "img").mkdir(exist_ok=True)
(notes / "img" / "dot.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg"/>')
(notes / "app" / "overview.md").write_text(
(notes / "app" / "overview.md").read_text() + "\n![a dot](../img/dot.svg)\n")
(notes / "app" / "no-such-page.md").write_text("Filed under a page that is gone.\n")
# A note is text: raw HTML is shown, not run, and a link cannot break out of
# its attribute or run script.
html = md.to_html('<script>x</script> [a](b"onclick=1) [c](javascript:alert(1))')
check(
"a note's HTML is escaped and its links are attribute-safe",
# The quote stays inside the attribute as &quot; — asserted on the exact
# attribute, since stripping the entity first (as this check once did)
# makes the attribute's own closing quote look like a break-out.
"<script>" not in html and 'href="b&quot;onclick=1"' in html
and not re.search(r'"\s*onclick=', html) and 'href="#"' in html,
html,
)
# -- a one-book document, built inline -------------------------------------
out = base / "book"
book = build_mod.run("python", src, out, slug="app", notes=notes, quiet=True)
ledger = json.loads((out / "document.json").read_text())
check(
"a note filed under a vanished page is reported, not dropped",
any("no-such-page" in d for d in ledger["notes"]["drift"]),
ledger["notes"]["drift"],
)
r = checks_mod.Report()
with _ctx3.redirect_stdout(_io3.StringIO()):
checks_mod.generated(checks_mod.Loaded(out), r)
check("...and fails the book's own checks", "no note is filed under a page or step that is gone" in r.failed)
nb = json.loads((out / "notebook.ipynb").read_text())
cells = ["".join(c["source"]) for c in nb["cells"]]
note_at = next(i for i, t in enumerate(cells) if "Written by hand" in t)
check(
"a note lands directly above its page",
'<a name="page-app-overview">' in cells[note_at + 1],
cells[note_at + 1][:60],
)
check(
"a note's local image travels with it, as an attachment",
"attachment:dot.svg" in cells[note_at] and nb["cells"][note_at].get("attachments"),
)
figures = [c for c in nb["cells"] if '<a name="page-' in "".join(c["source"])]
check(
"every page is a figure in the notebook",
len(figures) == len(book.pages), f"{len(figures)} figures, {len(book.pages)} pages",
)
if dot.have_graphviz():
check(
"a figure carries its SVG, so it renders without running anything",
all("image/svg+xml" in next(iter((c.get("attachments") or {"": {}}).values()))
for c in figures),
)
else:
skip("figures carry SVG", "Graphviz is not installed")
check("the page index links every page", all(
f"(#page-app-{p['slug']})" in "".join(cells) for p in book.pages))
ran, failure = checks_mod._run_cells(nb["cells"], out)
check(f"the notebook still runs ({ran} code cells)", failure is None, failure or "")
# Two builds, same bytes — attachments included.
first = (out / "notebook.ipynb").read_bytes()
build_mod.run("python", src, out, slug="app", notes=notes, quiet=True)
check("the same sources and notes build the same notebook", first == (out / "notebook.ipynb").read_bytes())
# -- the site: drill-down --------------------------------------------------
site = out / "site"
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 site 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 site 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-site"
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("site", 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-site"
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("site", 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 site 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.site]\noutput = "out/site"\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) == ["notebook", "site"],
)
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 == "site").width == 700)
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.site]\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.site]\noutput = "src/site"\n', "inside.toml")
check("an output inside a tree it reads is refused", any("inside the tree" in p for p in inside), inside)
example = cfg.load(HERE / "docgen.project.example.toml")
check("the shipped project example loads",
[p.name for p in example.document.parts] == ["extractors", "schema", "api"])
_projects()
# --------------------------------------------------------------------------
print("\n22. the instructions for a model say what docgen accepts")
def _contract():
"""docs/models.md is what a model is told. It is only useful while true.
A model follows it literally: a key it names that the loader refuses is a
refused file, and a key the loader accepts that it never mentions is a
capability no model will use. So it is checked against the loaders
themselves, the way explode's contract is one copy printed by the script
that parses the reply.
"""
cfg = __import__(f"{PKG}.book.config", fromlist=["*"])
code_ex = __import__(f"{PKG}.extractors.code", fromlist=["*"])
build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
doc_path = HERE / "docs" / "models.md"
check("docs/models.md exists", doc_path.is_file())
if not doc_path.is_file():
return
text = doc_path.read_text()
def named(word):
return f"`{word}`" in text or f'`"{word}"`' in text
keys = (cfg.TOP_KEYS | cfg.DEFAULT_KEYS | cfg.BOOK_KEYS | cfg.PROJECT_KEYS
| cfg.OPS_KEYS | cfg.EMIT_KEYS | set().union(*cfg.EXTRACT_KEYS.values()))
check("every key a loader accepts is named", not sorted(k for k in keys if not named(k)),
sorted(k for k in keys if not named(k)))
check("every reader is named", all(named(r) for r in cfg.EXTRACT_KEYS))
check("every output is named", all(named(e) for e in cfg.EMITTERS))
# Read off the one line that lists them, not the whole file: a bare scan for
# quoted dot-words also caught the default build folder, `".docgen"`.
ext_line = next((ln for ln in text.splitlines() if ln.startswith("- `ext`:")), "")
listed = set(re.findall(r'"(\.[a-z]+)"', ext_line))
check("the ext list names exactly the extensions code reads",
listed == set(code_ex.LANGUAGES), f"doc {sorted(listed)} vs code {sorted(code_ex.LANGUAGES)}")
check("every step spot is named",
all(named(f"step-{sid}") for sid, _ in build_mod.STEP_LABELS))
# Every example a model is shown must be one that loads. The shipped
# examples use a fixed set of paths, created here as stand-ins.
proj = Path(tmp.name) / "contract-project"
for d in ("src", "web/src"):
(proj / d).mkdir(parents=True, exist_ok=True)
for f in ("doc/contracts/openapi.yaml", "schema/schema.json", "recordings/session.har"):
(proj / f).parent.mkdir(parents=True, exist_ok=True)
(proj / f).write_text("{}")
blocks = re.findall(r"```toml\n(.*?)```", text, re.S)
failures = []
for i, block in enumerate(blocks):
path = proj / f"example-{i}.toml"
path.write_text(block)
try:
cfg.load(path)
except cfg.ConfigError as e:
failures.append(f"example {i}: {e.problems[:2]}")
check(f"every toml example in it loads ({len(blocks)})", blocks and not failures, failures)
# The error table is what a model uses to repair a refused file, so every
# message it quotes has to be one the code can actually produce.
source = "".join((HERE / "book" / f).read_text() for f in ("config.py", "notes.py"))
table = text.split("## When the build refuses your file")[-1]
# Every backticked message in the first column — a cell can hold two.
quoted = [m for row in table.splitlines() if row.startswith("| `")
for m in re.findall(r"`([^`]+)`", row.split("|")[1])]
unknown = []
for message in quoted:
fragments = [f.strip() for f in re.split(r"'[^']*'|…|\.sh", message) if f.strip()]
if not all(frag in source for frag in fragments):
unknown.append(message)
check(f"every error in its repair table is one the loaders produce ({len(quoted)})",
quoted and not unknown, unknown)
env = dict(__import__("os").environ, PYTHONPATH=str(HERE.parent))
printed = _sub.run([sys.executable, "-m", PKG, "contract"], capture_output=True,
text=True, env=env)
check("`contract` prints it verbatim", printed.returncode == 0 and printed.stdout == text)
_contract()
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
if FAIL:
print("\nfailed:")