From b693e324ef7f9afc6e36218471d7cd542f148da0 Mon Sep 17 00:00:00 2001
From: buenosairesam
Date: Tue, 22 Sep 2026 04:54:49 -0300
Subject: [PATCH] init docgen
---
soleprint/atlas2/docgen/.gitignore | 1 +
soleprint/atlas2/docgen/Makefile | 11 +-
soleprint/atlas2/docgen/README.md | 52 +-
soleprint/atlas2/docgen/book/build.py | 133 ++-
soleprint/atlas2/docgen/book/checks.py | 107 +-
soleprint/atlas2/docgen/book/config.py | 330 +++++-
soleprint/atlas2/docgen/book/document.py | 448 ++++++++
soleprint/atlas2/docgen/book/notes.py | 161 +++
soleprint/atlas2/docgen/cli/__init__.py | 4 +
soleprint/atlas2/docgen/cli/book.py | 5 +-
soleprint/atlas2/docgen/cli/check.py | 9 +-
soleprint/atlas2/docgen/cli/contract.py | 33 +
soleprint/atlas2/docgen/cli/run.py | 33 +-
soleprint/atlas2/docgen/cli/scaffold.py | 87 ++
.../atlas2/docgen/docgen.project.example.toml | 53 +
.../atlas2/docgen/docs/img/architecture.svg | 966 +++++++++++-------
soleprint/atlas2/docgen/docs/img/minimap.svg | 778 ++++++++------
soleprint/atlas2/docgen/docs/index.html | 353 ++++++-
soleprint/atlas2/docgen/docs/models.md | 259 +++++
soleprint/atlas2/docgen/emitters/docsite.py | 269 +++++
soleprint/atlas2/docgen/emitters/dot.py | 27 +-
soleprint/atlas2/docgen/emitters/erd.py | 22 +-
soleprint/atlas2/docgen/emitters/markdown.py | 145 +++
soleprint/atlas2/docgen/emitters/notebook.py | 49 +-
soleprint/atlas2/docgen/emitters/paged.py | 118 +++
soleprint/atlas2/docgen/emitters/site.py | 45 +
.../atlas2/docgen/fixtures/notes/_intro.md | 3 +
.../docgen/fixtures/notes/schema/overview.md | 5 +
soleprint/atlas2/docgen/notebook/spec.py | 2 +-
soleprint/atlas2/docgen/ops/pages.py | 382 +++++++
soleprint/atlas2/docgen/selftest.py | 447 ++++++++
31 files changed, 4521 insertions(+), 816 deletions(-)
create mode 100644 soleprint/atlas2/docgen/book/document.py
create mode 100644 soleprint/atlas2/docgen/book/notes.py
create mode 100644 soleprint/atlas2/docgen/cli/contract.py
create mode 100644 soleprint/atlas2/docgen/cli/scaffold.py
create mode 100644 soleprint/atlas2/docgen/docgen.project.example.toml
create mode 100644 soleprint/atlas2/docgen/docs/models.md
create mode 100644 soleprint/atlas2/docgen/emitters/docsite.py
create mode 100644 soleprint/atlas2/docgen/emitters/markdown.py
create mode 100644 soleprint/atlas2/docgen/emitters/paged.py
create mode 100644 soleprint/atlas2/docgen/fixtures/notes/_intro.md
create mode 100644 soleprint/atlas2/docgen/fixtures/notes/schema/overview.md
create mode 100644 soleprint/atlas2/docgen/ops/pages.py
diff --git a/soleprint/atlas2/docgen/.gitignore b/soleprint/atlas2/docgen/.gitignore
index f58baf9..b055b03 100644
--- a/soleprint/atlas2/docgen/.gitignore
+++ b/soleprint/atlas2/docgen/.gitignore
@@ -3,3 +3,4 @@ out/
__pycache__/
*.pyc
.venv/
+.docgen/
diff --git a/soleprint/atlas2/docgen/Makefile b/soleprint/atlas2/docgen/Makefile
index ac3585d..d963bac 100644
--- a/soleprint/atlas2/docgen/Makefile
+++ b/soleprint/atlas2/docgen/Makefile
@@ -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//.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=)
@$(CLI) check $(if $(BOOK),"$(BOOK)")
diff --git a/soleprint/atlas2/docgen/README.md b/soleprint/atlas2/docgen/README.md
index c07f87d..b02ddf8 100644
--- a/soleprint/atlas2/docgen/README.md
+++ b/soleprint/atlas2/docgen/README.md
@@ -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//.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/
make doctor # the machine; never fails
```
diff --git a/soleprint/atlas2/docgen/book/build.py b/soleprint/atlas2/docgen/book/build.py
index 6df0abd..0122b96 100644
--- a/soleprint/atlas2/docgen/book/build.py
+++ b/soleprint/atlas2/docgen/book/build.py
@@ -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
diff --git a/soleprint/atlas2/docgen/book/checks.py b/soleprint/atlas2/docgen/book/checks.py
index 8c69b78..3c56c5c 100644
--- a/soleprint/atlas2/docgen/book/checks.py
+++ b/soleprint/atlas2/docgen/book/checks.py
@@ -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 'what came in"))
+ r.check("the index shows what came out, for every book", len(books),
+ html.count("what came out "))
+ 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)
diff --git a/soleprint/atlas2/docgen/book/config.py b/soleprint/atlas2/docgen/book/config.py
index e6391a6..bff8674 100644
--- a/soleprint/atlas2/docgen/book/config.py
+++ b/soleprint/atlas2/docgen/book/config.py
@@ -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.] one book per table; [[extract.]] for several
+# [ops] applied to every book's view, before paging
+# [emit.] 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.] 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
diff --git a/soleprint/atlas2/docgen/book/document.py b/soleprint/atlas2/docgen/book/document.py
new file mode 100644
index 0000000..4de04b5
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/document.py
@@ -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))
diff --git a/soleprint/atlas2/docgen/book/notes.py b/soleprint/atlas2/docgen/book/notes.py
new file mode 100644
index 0000000..b509d3c
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/notes.py
@@ -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
+ └── /
+ ├── _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*", 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"\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')}"
diff --git a/soleprint/atlas2/docgen/cli/__init__.py b/soleprint/atlas2/docgen/cli/__init__.py
index 44f1a18..e7188ec 100644
--- a/soleprint/atlas2/docgen/cli/__init__.py
+++ b/soleprint/atlas2/docgen/cli/__init__.py
@@ -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"),
diff --git a/soleprint/atlas2/docgen/cli/book.py b/soleprint/atlas2/docgen/cli/book.py
index d78ca21..f1db3af 100644
--- a/soleprint/atlas2/docgen/cli/book.py
+++ b/soleprint/atlas2/docgen/cli/book.py
@@ -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: /.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
diff --git a/soleprint/atlas2/docgen/cli/check.py b/soleprint/atlas2/docgen/cli/check.py
index cdd981b..606adda 100644
--- a/soleprint/atlas2/docgen/cli/check.py
+++ b/soleprint/atlas2/docgen/cli/check.py
@@ -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)
diff --git a/soleprint/atlas2/docgen/cli/contract.py b/soleprint/atlas2/docgen/cli/contract.py
new file mode 100644
index 0000000..2205c4a
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/contract.py
@@ -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
diff --git a/soleprint/atlas2/docgen/cli/run.py b/soleprint/atlas2/docgen/cli/run.py
index 5c9775b..e0321f5 100644
--- a/soleprint/atlas2/docgen/cli/run.py
+++ b/soleprint/atlas2/docgen/cli/run.py
@@ -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
diff --git a/soleprint/atlas2/docgen/cli/scaffold.py b/soleprint/atlas2/docgen/cli/scaffold.py
new file mode 100644
index 0000000..2dfee75
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/scaffold.py
@@ -0,0 +1,87 @@
+"""
+ python3 -m docgen scaffold [--only NAME ...]
+ python3 -m docgen scaffold --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
diff --git a/soleprint/atlas2/docgen/docgen.project.example.toml b/soleprint/atlas2/docgen/docgen.project.example.toml
new file mode 100644
index 0000000..fa574f9
--- /dev/null
+++ b/soleprint/atlas2/docgen/docgen.project.example.toml
@@ -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//.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: .ipynb goes inside
+theme = "lucid"
+width = 960 # px — pages are split until each fits
+
+[emit.site]
+output = "out/project/site"
+theme = "dark"
diff --git a/soleprint/atlas2/docgen/docs/img/architecture.svg b/soleprint/atlas2/docgen/docs/img/architecture.svg
index 9df61e1..001a373 100644
--- a/soleprint/atlas2/docgen/docs/img/architecture.svg
+++ b/soleprint/atlas2/docgen/docs/img/architecture.svg
@@ -4,72 +4,72 @@
-
-
+
+
ir
-
+
cluster_docgen
-
-docgen
+
+docgen
cluster_docgen_book
-
-book
+
+book
cluster_docgen_cli
-
-cli
+
+cli
cluster_docgen_emitters
-
-emitters
+
+emitters
cluster_docgen_ir
-
-ir
+
+ir
cluster_docgen_lab
-
-lab
+
+lab
cluster_docgen_notebook
-
-notebook
+
+notebook
cluster_docgen_ops
-
-ops
+
+ops
cluster_docgen_style
-
-style
+
+style
docgen.__main__
-
-__main__
+
+__main__
@@ -77,776 +77,1004 @@
docgen.cli.book
-
-book
+
+book
docgen.__main__->docgen.cli.book
-
-
+
+
docgen.book.build
-
-build
+
+build
+
+
+docgen.book.document
+
+
+document
+
+
+
+
+
+docgen.book.build->docgen.book.document
+
+
+
docgen.book.larder
-
-larder
+
+larder
docgen.book.build->docgen.book.larder
-
-
+
+
-
+
docgen.book.build->docgen.book.larder
-
-
+
+
docgen.emitters.auto
-
-auto
+
+auto
-
+
docgen.book.build->docgen.emitters.auto
-
-
+
+
docgen.emitters.explore
-
-explore
+
+explore
-
+
docgen.book.build->docgen.emitters.explore
-
-
+
+
docgen.emitters.index
-
-index
+
+index
-
+
docgen.book.build->docgen.emitters.index
-
-
+
+
docgen.emitters.minimap
-
-minimap
+
+minimap
-
-docgen.book.build->docgen.emitters.minimap
-
-
-
-
-
-docgen.emitters.notebook
-
-
-notebook
-
-
-
-
-docgen.book.build->docgen.emitters.notebook
-
-
-
-
-
-docgen.emitters.site
-
-
-site
-
-
-
-
-
-docgen.book.build->docgen.emitters.site
-
-
+docgen.book.build->docgen.emitters.minimap
+
+
docgen.ir.model
-
-model
+
+model
-
+
docgen.book.build->docgen.ir.model
-
-
+
+
docgen.notebook.spec
-
-spec
+
+spec
-
+
docgen.book.build->docgen.notebook.spec
-
-
+
+
docgen.ops.filter
-
-filter
+
+filter
-
+
docgen.book.build->docgen.ops.filter
-
-
+
+
+
+
+
+docgen.ops.pages
+
+
+pages
+
+
+
+
+
+docgen.book.build->docgen.ops.pages
+
+
docgen.book.build->docgen.style.extract
-
-
+
+
docgen.book.checks
-
-checks
+
+checks
docgen.book.checks->docgen.book.build
-
-
+
+
docgen.book.config
-
-config
+
+config
docgen.book.config->docgen.__main__
-
-
+
+
docgen.book.config->docgen.book.build
-
-
+
+
docgen.book.config->docgen.book.build
-
-
+
+
-
+
+docgen.book.config->docgen.book.document
+
+
+
+
+
+
+
+docgen.book.config->docgen.extractors.code
+
+
+
+
+
docgen.book.config->docgen.notebook.spec
-
-
+
+
+
+
+
+docgen.book.document->docgen.book.build
+
+
+
+
+
+docgen.book.document->docgen.book.build
+
+
+
+
+
+docgen.book.document->docgen.book.larder
+
+
+
+
+
+docgen.book.notes
+
+
+notes
+
+
+
+
+
+docgen.book.document->docgen.book.notes
+
+
+
+
+
+docgen.book.document->docgen.emitters.auto
+
+
+
+
+
+docgen.emitters.notebook
+
+
+notebook
+
+
+
+
+
+docgen.book.document->docgen.emitters.notebook
+
+
+
+
+
+docgen.emitters.paged
+
+
+paged
+
+
+
+
+
+docgen.book.document->docgen.emitters.paged
+
+
+
+
+
+docgen.book.document->docgen.notebook.spec
+
+
+
+
+
+docgen.book.document->docgen.ops.filter
+
+
+
+
+
+docgen.book.document->docgen.ops.pages
+
+
+
+
+
+docgen.book.document->docgen.style.extract
+
+
-
+
docgen.cli.book->docgen.book.build
-
-
+
+
docgen.cli.common
-
-common
+
+common
-
+
docgen.cli.book->docgen.cli.common
-
-
+
+
-
+
docgen.cli.book->docgen.cli.common
-
-
+
+
-
+
docgen.cli.book->docgen.notebook.spec
-
-
+
+
docgen.cli.check
-
-check
+
+check
-
+
docgen.cli.check->docgen.book.checks
-
-
+
+
-
+
docgen.cli.check->docgen.cli.common
-
-
+
+
-
+
docgen.cli.common->docgen.ir.model
-
-
+
+
-
+
docgen.cli.common->docgen.style.extract
-
-
+
+
+
+
+
+docgen.cli.contract
+
+
+contract
+
+
+
+
+
+docgen.cli.contract->docgen.cli.common
+
+
docgen.cli.emit
-
-emit
+
+emit
-
+
docgen.cli.emit->docgen.cli.common
-
-
+
+
-
+
docgen.cli.emit->docgen.emitters.auto
-
-
+
+
docgen.emitters.dot
-
-dot
+
+dot
-
+
docgen.cli.emit->docgen.emitters.dot
-
-
+
+
docgen.emitters.erd
-
-erd
+
+erd
-
+
docgen.cli.emit->docgen.emitters.erd
-
-
+
+
-
+
docgen.cli.emit->docgen.emitters.explore
-
-
+
+
-
+
docgen.cli.emit->docgen.emitters.index
-
-
+
+
-
+
docgen.cli.emit->docgen.emitters.minimap
-
-
+
+
-
+
docgen.cli.emit->docgen.emitters.notebook
-
-
+
+
+
+
+
+docgen.emitters.site
+
+
+site
+
+
-
+
docgen.cli.emit->docgen.emitters.site
-
-
+
+
-
+
docgen.cli.emit->docgen.notebook.spec
-
-
+
+
-
+
docgen.cli.emit->docgen.ops.filter
-
-
+
+
-
+
docgen.cli.extract->docgen.cli.common
-
-
-
-
-
-
+
docgen.cli.extract->docgen.extractors.code
-
-
+
+
-
+
docgen.cli.extract->docgen.extractors.db
-
-
+
+
-
+
docgen.cli.extract->docgen.extractors.openapi
-
-
+
+
-
+
docgen.cli.extract->docgen.extractors.python.collect
-
-
+
+
-
+
docgen.cli.extract->docgen.extractors.usage
-
-
+
+
docgen.cli.run
-
-run
+
+run
-
+
docgen.cli.run->docgen.book.config
-
-
+
+
-
+
docgen.cli.run->docgen.cli.common
-
-
+
+
+
+
+
+docgen.cli.scaffold
+
+
+scaffold
+
+
+
+
+
+docgen.cli.scaffold->docgen.book.config
+
+
+
+
+
+docgen.cli.scaffold->docgen.book.document
+
+
+
+
+
+docgen.cli.scaffold->docgen.book.notes
+
+
+
+
+
+docgen.cli.scaffold->docgen.cli.common
+
+
docgen.cli.validate
-
-validate
+
+validate
-
+
docgen.cli.validate->docgen.cli.common
-
-
+
+
docgen.ir.validate
-
-validate
+
+validate
-
+
docgen.cli.validate->docgen.ir.validate
-
-
+
+
docgen.cli.view
-
-view
+
+view
-
+
docgen.cli.view->docgen.cli.common
-
-
+
+
-
+
docgen.cli.view->docgen.ir.model
-
-
+
+
-
+
docgen.cli.view->docgen.ops.filter
-
-
+
+
-
+
docgen.emitters.auto->docgen.emitters.dot
-
-
+
+
-
+
docgen.emitters.auto->docgen.emitters.erd
-
-
+
+
-
+
docgen.emitters.auto->docgen.emitters.index
-
-
+
+
-
+
docgen.emitters.auto->docgen.ops.filter
-
-
+
+
+
+
+
+docgen.emitters.docsite
+
+
+docsite
+
+
+
+
+
+docgen.emitters.docsite->docgen.book.document
+
+
+
+
+
+docgen.emitters.markdown
+
+
+markdown
+
+
+
+
+
+docgen.emitters.docsite->docgen.emitters.markdown
+
+
+
+
+
+docgen.emitters.docsite->docgen.emitters.paged
+
+
+
+
+
+docgen.emitters.docsite->docgen.emitters.site
+
+
-
+
docgen.emitters.explore->docgen.emitters.dot
-
-
+
+
-
+
docgen.emitters.explore->docgen.emitters.erd
-
-
+
+
-
+
docgen.emitters.explore->docgen.emitters.minimap
-
-
+
+
-
+
docgen.emitters.explore->docgen.ops.filter
-
-
+
+
+
+
+
+docgen.emitters.paged->docgen.emitters.dot
+
+
+
+
+
+docgen.emitters.paged->docgen.emitters.erd
+
+
+
+
+
+docgen.emitters.paged->docgen.ops.filter
+
+
-
+
docgen.emitters.site->docgen.emitters.index
-
-
+
+
-
+
docgen.extractors.code->docgen.book.larder
-
-
+
+
-
+
docgen.extractors.code->docgen.ir.model
-
-
+
+
tree_sitter
-
-tree_sitter
+
+tree_sitter
-
+
docgen.extractors.code->tree_sitter
-
-
+
+
-
+
docgen.extractors.db->docgen.book.larder
-
-
+
+
-
+
docgen.extractors.db->docgen.ir.model
-
-
+
+
-
+
docgen.extractors.openapi->docgen.__main__
-
-
+
+
-
+
docgen.extractors.openapi->docgen.book.larder
-
-
+
+
-
+
docgen.extractors.openapi->docgen.ir.model
-
-
+
+
-
+
docgen.extractors.openapi->modelgen.loader.extract.openapi
-
-
+
+
yaml
-
-yaml
+
+yaml
-
+
docgen.extractors.openapi->yaml
-
-
+
+
-
+
docgen.extractors.python.collect->docgen.extractors.python.resolve
-
-
+
+
-
+
docgen.extractors.python.resolve->docgen.book.larder
-
-
+
+
-
+
docgen.extractors.python.resolve->docgen.extractors.python.collect
-
-
+
+
-
+
docgen.extractors.python.resolve->docgen.ir.model
-
-
+
+
-
+
docgen.extractors.usage->docgen.book.larder
-
-
+
+
-
+
docgen.extractors.usage->docgen.ir.model
-
-
+
+
-
+
docgen.ir.model->docgen.ir.validate
-
-
+
+
-
+
docgen.ir.validate->docgen.ir.model
-
-
+
+
docgen.lab.pg_probe
-
-pg_probe
+
+pg_probe
+
+
+docgen.ops.pages->docgen.ops.filter
+
+
+
docgen.reference
-
-reference
+
+reference
@@ -854,49 +1082,49 @@
docgen.selftest
-
-selftest
+
+selftest
lxml
-
-lxml
+
+lxml
-
+
docgen.selftest->lxml
-
-
+
+
docgen.style.tokens
-
-tokens
+
+tokens
-
+
docgen.style.extract->docgen.style.tokens
-
-
+
+
-
+
docgen.style.extract->lxml
-
-
+
+
-
+
docgen.style.tokens->docgen.style.extract
-
-
+
+
diff --git a/soleprint/atlas2/docgen/docs/img/minimap.svg b/soleprint/atlas2/docgen/docs/img/minimap.svg
index dcdef6d..c132b2f 100644
--- a/soleprint/atlas2/docgen/docs/img/minimap.svg
+++ b/soleprint/atlas2/docgen/docs/img/minimap.svg
@@ -1,7 +1,7 @@
-
-
-docgen.selftest — 2328 lines
+
+
+docgen.selftest — 2775 lines
own_py_files — function, 3 lines
check — function, 5 lines
_err — function, 7 lines
@@ -15,6 +15,12 @@
_runfiles — function, 195 lines
write — function, 4 lines
problems — function, 6 lines
+_pages — function, 114 lines
+_documents — function, 161 lines
+_projects — function, 59 lines
+load — function, 7 lines
+_contract — function, 75 lines
+named — function, 2 lines
docgen.book — 316 lines
Step — class, 20 lines
to_dict — function, 10 lines
@@ -54,353 +60,449 @@
describe — function, 10 lines
on_path — function, 13 lines
missing — function, 8 lines
-docgen.cli — 76 lines
-usage — function, 7 lines
-main — function, 21 lines
+docgen.cli — 80 lines
+usage — function, 7 lines
+main — function, 21 lines
docgen.ops — 28 lines
docgen.notebook — 15 lines
docgen.emitters — 12 lines
docgen.lab — 11 lines
docgen.__main__ — 9 lines
-docgen.ir — 7 lines
-docgen.extractors — 2 lines
-docgen.cli.emit — 253 lines
-_parser — function, 7 lines
-_size — function, 3 lines
-auto — function, 16 lines
-dot — function, 38 lines
-erd — function, 11 lines
-index — function, 14 lines
-minimap — function, 14 lines
-notebook — function, 41 lines
-site — function, 31 lines
-explore — function, 22 lines
-main — function, 7 lines
-docgen.cli.extract — 119 lines
-_parser — function, 5 lines
-_counts — function, 3 lines
-python — function, 15 lines
-code — function, 14 lines
-db — function, 11 lines
-openapi — function, 12 lines
-usage — function, 15 lines
-main — function, 7 lines
-docgen.cli.view — 90 lines
-main — function, 75 lines
-docgen.cli.common — 80 lines
-Abort — class, 2 lines
-read_json — function, 5 lines
-read_ir — function, 11 lines
-load_style — function, 7 lines
-style_args — function, 3 lines
-emit — function, 16 lines
-dump_ir — function, 3 lines
-docgen.cli.run — 80 lines
-main — function, 59 lines
-docgen.cli.book — 74 lines
-main — function, 52 lines
-docgen.cli.check — 51 lines
-main — function, 30 lines
-docgen.cli.validate — 33 lines
-main — function, 18 lines
-docgen.emitters.site — 494 lines
-_ledger — function, 54 lines
-_slots — function, 13 lines
-_fill — function, 4 lines
-_sidebar — function, 17 lines
-_sections — function, 15 lines
-emit — function, 96 lines
-write — function, 10 lines
-docgen.emitters.explore — 316 lines
-_is_schema — function, 4 lines
-_neighbourhood_svgs — function, 48 lines
-render_one — function, 2 lines
-render_one — function, 2 lines
-_facts — function, 27 lines
-emit — function, 172 lines
-write — function, 17 lines
-docgen.emitters.dot — 292 lines
-RenderError — class, 2 lines
-_esc — function, 2 lines
-_attrs — function, 3 lines
-_style_words — function, 9 lines
-_node_attrs — function, 24 lines
-emit — function, 105 lines
-write — function, 29 lines
-_within — function, 8 lines
-_endpoints — function, 41 lines
-walk — function, 4 lines
-collapsed — function, 2 lines
-first_leaf — function, 4 lines
-_safe — function, 2 lines
-_q — function, 2 lines
-have_graphviz — function, 2 lines
-render — function, 29 lines
-docgen.emitters.notebook — 279 lines
-_cell — function, 13 lines
-_example — function, 26 lines
-_params_cell — function, 18 lines
-_call_cell — function, 40 lines
-_call_md — function, 26 lines
-build — function, 52 lines
-emit — function, 3 lines
-write — function, 5 lines
-docgen.emitters.minimap — 278 lines
-_files — function, 57 lines
-declared — function, 18 lines
-build — function, 11 lines
-_bands — function, 7 lines
-_blocks — function, 19 lines
-emit — function, 120 lines
-marks_to_labels — function, 3 lines
-docgen.emitters.erd — 260 lines
-_truncate — function, 3 lines
-_tables — function, 20 lines
-_card_height — function, 3 lines
-layout — function, 20 lines
-_field_y — function, 3 lines
-emit — function, 146 lines
-docgen.emitters.index — 164 lines
-_tree — function, 8 lines
-_anchor — function, 5 lines
-to_markdown — function, 84 lines
-walk — function, 27 lines
-to_sidebar — function, 26 lines
-build — function, 13 lines
-docgen.emitters.auto — 64 lines
-draw — function, 36 lines
-docgen.extractors.code — 282 lines
-MissingParser — class, 2 lines
-_parser — function, 21 lines
-_name — function, 10 lines
-_walk — function, 34 lines
-extract_file — function, 27 lines
-_count_errors — function, 5 lines
-extract — function, 75 lines
-docgen.extractors.usage — 269 lines
-_template — function, 27 lines
-_body — function, 10 lines
-_shape — function, 15 lines
-_graphql — function, 11 lines
-extract — function, 133 lines
-docgen.extractors.openapi — 191 lines
-_modelgen — function, 28 lines
-_type_name — function, 6 lines
-_refs — function, 43 lines
-extract — function, 76 lines
-docgen.extractors.db — 161 lines
-from_schema_dict — function, 82 lines
-_relation — function, 10 lines
-_plain_type — function, 6 lines
-_dedupe — function, 9 lines
-extract — function, 6 lines
-docgen.extractors.python — 37 lines
-extract — function, 14 lines
-docgen.book.build — 381 lines
-extract — function, 27 lines
-spec_from — function, 68 lines
-_load_cell — function, 44 lines
-clear — function, 26 lines
-run — function, 125 lines
-say — function, 3 lines
-docgen.book.config — 328 lines
-ConfigError — class, 6 lines
-__init__ — function, 3 lines
-Entry — class, 14 lines
-line — function, 2 lines
-RunFile — class, 17 lines
-select — function, 12 lines
-_path — function, 6 lines
-_within — function, 3 lines
-load — function, 130 lines
-pick — function, 3 lines
-apply_reference — function, 11 lines
-run — function, 43 lines
-docgen.book.checks — 275 lines
-Loaded — class, 26 lines
-__init__ — function, 15 lines
-larder — function, 2 lines
-measure — function, 2 lines
-Report — class, 37 lines
-__init__ — function, 2 lines
-note — function, 2 lines
-check — function, 8 lines
-skip — function, 3 lines
-total — function, 15 lines
-generated — function, 83 lines
-custom — function, 26 lines
-_run_cells — function, 33 lines
-docgen.book.larder — 196 lines
-_count — function, 13 lines
-redact — function, 32 lines
-Larder — class, 77 lines
-__post_init__ — function, 7 lines
-read — function, 3 lines
-fail — function, 3 lines
-to_dict — function, 13 lines
-from_dict — function, 10 lines
-line — function, 20 lines
-of — function, 3 lines
-docgen.extractors.python.collect — 236 lines
-Definition — class, 10 lines
-Module — class, 12 lines
-_Collector — class, 67 lines
-__init__ — function, 3 lines
-_define — function, 17 lines
-visit_ClassDef — function, 5 lines
-visit_FunctionDef — function, 5 lines
-visit_Import — function, 8 lines
-visit_ImportFrom — function, 14 lines
-_first_line — function, 5 lines
-_name_of — function, 15 lines
-_resolve_relative — function, 16 lines
-module_name — function, 26 lines
-collect_file — function, 21 lines
-collect — function, 13 lines
-docgen.extractors.python.resolve — 184 lines
-_id_for — function, 2 lines
-_resolve — function, 32 lines
-larder_of — function, 17 lines
-to_ir — function, 98 lines
-_point_at — function, 9 lines
-docgen.ir.validate — 278 lines
-IRError — class, 2 lines
-_schema — function, 2 lines
-_props — function, 5 lines
-_fields — function, 4 lines
-check — function, 108 lines
-_check_larder — function, 58 lines
-validate — function, 6 lines
-check_model_matches_schema — function, 23 lines
-docgen.ir.model — 165 lines
-Meta — class, 38 lines
-to_dict — function, 18 lines
-Node — class, 21 lines
-__post_init__ — function, 3 lines
-to_dict — function, 8 lines
-Edge — class, 15 lines
-to_dict — function, 7 lines
-Graph — class, 48 lines
-node — function, 4 lines
-edge — function, 4 lines
-has — function, 2 lines
-to_dict — function, 16 lines
-from_dict — function, 6 lines
-docgen.style.extract — 222 lines
-_values_from — function, 17 lines
-_normalise — function, 45 lines
-_svg_files — function, 25 lines
-harvest — function, 43 lines
-write — function, 7 lines
-summarise — function, 13 lines
-docgen.style.tokens — 211 lines
-_top — function, 2 lines
-_mode — function, 3 lines
-_luminance — function, 6 lines
-derive — function, 101 lines
-accent — function, 2 lines
-geometry — function, 14 lines
-write — function, 7 lines
-from_folder — function, 9 lines
-docgen — 2 lines
-docgen.lab.pg_probe — 150 lines
-probe — function, 30 lines
-_simplify — function, 3 lines
-main — function, 25 lines
-docgen.notebook.spec — 264 lines
-_step — function, 4 lines
-from_ir — function, 108 lines
-_order — function, 5 lines
-scaffold — function, 20 lines
-merge — function, 58 lines
-load — function, 2 lines
-dump — function, 5 lines
-docgen.ops.filter — 485 lines
-_rebuild — function, 57 lines
-surviving_parent — function, 5 lines
-lift — function, 6 lines
-drop_kinds — function, 14 lines
-only_kinds — function, 14 lines
-drop_stdlib — function, 13 lines
-drop_external — function, 3 lines
-subtree — function, 13 lines
-neighbourhood — function, 45 lines
-collapse_to_depth — function, 18 lines
-level — function, 6 lines
-drop_builtins — function, 14 lines
-overview — function, 35 lines
-shape — function, 72 lines
-rank_of — function, 9 lines
-split — function, 24 lines
-classify — function, 102 lines
+docgen.ir — 7 lines
+docgen.extractors — 2 lines
+docgen.emitters.site — 539 lines
+_ledger — function, 54 lines
+_slots — function, 15 lines
+_fill — function, 4 lines
+_sidebar — function, 17 lines
+_sections — function, 15 lines
+emit — function, 96 lines
+write — function, 10 lines
+docgen.emitters.notebook — 320 lines
+_cell — function, 19 lines
+_example — function, 26 lines
+_params_cell — function, 18 lines
+_call_cell — function, 40 lines
+_call_md — function, 26 lines
+_figure_md — function, 21 lines
+_figure_attachments — function, 7 lines
+build — function, 55 lines
+emit — function, 3 lines
+write — function, 5 lines
+docgen.emitters.explore — 316 lines
+_is_schema — function, 4 lines
+_neighbourhood_svgs — function, 48 lines
+render_one — function, 2 lines
+render_one — function, 2 lines
+_facts — function, 27 lines
+emit — function, 172 lines
+write — function, 17 lines
+docgen.emitters.dot — 309 lines
+RenderError — class, 2 lines
+_esc — function, 2 lines
+_attrs — function, 3 lines
+_style_words — function, 9 lines
+_node_attrs — function, 33 lines
+emit — function, 113 lines
+write — function, 31 lines
+_within — function, 8 lines
+_endpoints — function, 41 lines
+walk — function, 4 lines
+collapsed — function, 2 lines
+first_leaf — function, 4 lines
+_safe — function, 2 lines
+_q — function, 2 lines
+have_graphviz — function, 2 lines
+render — function, 29 lines
+docgen.emitters.minimap — 278 lines
+_files — function, 57 lines
+declared — function, 18 lines
+build — function, 11 lines
+_bands — function, 7 lines
+_blocks — function, 19 lines
+emit — function, 120 lines
+marks_to_labels — function, 3 lines
+docgen.emitters.erd — 276 lines
+_truncate — function, 3 lines
+_tables — function, 20 lines
+_card_height — function, 3 lines
+max_columns — function, 9 lines
+layout — function, 25 lines
+_field_y — function, 3 lines
+emit — function, 146 lines
+docgen.emitters.docsite — 270 lines
+_note — function, 7 lines
+_missing — function, 9 lines
+_tree — function, 15 lines
+walk — function, 7 lines
+_page — function, 69 lines
+write — function, 95 lines
+put — function, 4 lines
+docgen.emitters.index — 164 lines
+_tree — function, 8 lines
+_anchor — function, 5 lines
+to_markdown — function, 84 lines
+walk — function, 27 lines
+to_sidebar — function, 26 lines
+build — function, 13 lines
+docgen.emitters.markdown — 146 lines
+_url — function, 7 lines
+_inline — function, 13 lines
+keep — function, 3 lines
+to_html — function, 91 lines
+para — function, 3 lines
+cells — function, 2 lines
+docgen.emitters.paged — 119 lines
+svg_width_px — function, 6 lines
+_estimate_px — function, 10 lines
+fits_for — function, 17 lines
+fits — function, 6 lines
+links_for — function, 15 lines
+render — function, 19 lines
+inline — function, 9 lines
+docgen.emitters.auto — 64 lines
+draw — function, 36 lines
+docgen.cli.emit — 253 lines
+_parser — function, 7 lines
+_size — function, 3 lines
+auto — function, 16 lines
+dot — function, 38 lines
+erd — function, 11 lines
+index — function, 14 lines
+minimap — function, 14 lines
+notebook — function, 41 lines
+site — function, 31 lines
+explore — function, 22 lines
+main — function, 7 lines
+docgen.cli.extract — 119 lines
+_parser — function, 5 lines
+_counts — function, 3 lines
+python — function, 15 lines
+code — function, 14 lines
+db — function, 11 lines
+openapi — function, 12 lines
+usage — function, 15 lines
+main — function, 7 lines
+docgen.cli.run — 99 lines
+main — function, 78 lines
+docgen.cli.view — 90 lines
+main — function, 75 lines
+docgen.cli.scaffold — 88 lines
+main — function, 66 lines
+docgen.cli.common — 80 lines
+Abort — class, 2 lines
+read_json — function, 5 lines
+read_ir — function, 11 lines
+load_style — function, 7 lines
+style_args — function, 3 lines
+emit — function, 16 lines
+dump_ir — function, 3 lines
+docgen.cli.book — 77 lines
+main — function, 55 lines
+docgen.cli.check — 58 lines
+main — function, 37 lines
+docgen.cli.contract — 34 lines
+main — function, 8 lines
+docgen.cli.validate — 33 lines
+main — function, 18 lines
+docgen.book.config — 616 lines
+ConfigError — class, 6 lines
+__init__ — function, 3 lines
+Entry — class, 15 lines
+line — function, 2 lines
+RunFile — class, 17 lines
+select — function, 12 lines
+_path — function, 6 lines
+_within — function, 3 lines
+_read — function, 9 lines
+load — function, 18 lines
+_load_books — function, 121 lines
+pick — function, 3 lines
+apply_reference — function, 11 lines
+_checks — function, 13 lines
+run — function, 36 lines
+ProjectFile — class, 8 lines
+books — function, 2 lines
+load_project — function, 151 lines
+select_parts — function, 9 lines
+run_project — function, 41 lines
+docgen.book.document — 449 lines
+Output — class, 6 lines
+Part — class, 11 lines
+Document — class, 11 lines
+apply_ops — function, 15 lines
+clear_output — function, 27 lines
+write_manifest — function, 3 lines
+rendered_pages — function, 22 lines
+caption — function, 14 lines
+_note_step — function, 8 lines
+_with_note — function, 11 lines
+notebook_spec — function, 91 lines
+build — function, 116 lines
+say — function, 3 lines
+_spots — function, 16 lines
+known_spots — function, 3 lines
+plan_spots — function, 9 lines
+docgen.book.build — 418 lines
+extract — function, 29 lines
+spec_from — function, 71 lines
+_load_cell — function, 46 lines
+clear — function, 26 lines
+build_book — function, 105 lines
+say — function, 3 lines
+run — function, 35 lines
+docgen.book.checks — 378 lines
+Loaded — class, 26 lines
+__init__ — function, 15 lines
+larder — function, 2 lines
+measure — function, 2 lines
+Report — class, 38 lines
+__init__ — function, 3 lines
+note — function, 2 lines
+check — function, 8 lines
+skip — function, 3 lines
+total — function, 15 lines
+generated — function, 89 lines
+custom — function, 26 lines
+_run_cells — function, 33 lines
+_notes_drift — function, 7 lines
+document — function, 72 lines
+heading — function, 2 lines
+document_failures — function, 9 lines
+docgen.book.larder — 196 lines
+_count — function, 13 lines
+redact — function, 32 lines
+Larder — class, 77 lines
+__post_init__ — function, 7 lines
+read — function, 3 lines
+fail — function, 3 lines
+to_dict — function, 13 lines
+from_dict — function, 10 lines
+line — function, 20 lines
+of — function, 3 lines
+docgen.book.notes — 162 lines
+body — function, 6 lines
+stub — function, 3 lines
+Notes — class, 78 lines
+__init__ — function, 3 lines
+_read — function, 7 lines
+document — function, 2 lines
+book — function, 2 lines
+path — function, 4 lines
+drift — function, 27 lines
+scaffold — function, 24 lines
+images — function, 18 lines
+data_uri — function, 2 lines
+docgen.extractors.code — 282 lines
+MissingParser — class, 2 lines
+_parser — function, 21 lines
+_name — function, 10 lines
+_walk — function, 34 lines
+extract_file — function, 27 lines
+_count_errors — function, 5 lines
+extract — function, 75 lines
+docgen.extractors.usage — 269 lines
+_template — function, 27 lines
+_body — function, 10 lines
+_shape — function, 15 lines
+_graphql — function, 11 lines
+extract — function, 133 lines
+docgen.extractors.openapi — 191 lines
+_modelgen — function, 28 lines
+_type_name — function, 6 lines
+_refs — function, 43 lines
+extract — function, 76 lines
+docgen.extractors.db — 161 lines
+from_schema_dict — function, 82 lines
+_relation — function, 10 lines
+_plain_type — function, 6 lines
+_dedupe — function, 9 lines
+extract — function, 6 lines
+docgen.extractors.python — 37 lines
+extract — function, 14 lines
+docgen.extractors.python.collect — 236 lines
+Definition — class, 10 lines
+Module — class, 12 lines
+_Collector — class, 67 lines
+__init__ — function, 3 lines
+_define — function, 17 lines
+visit_ClassDef — function, 5 lines
+visit_FunctionDef — function, 5 lines
+visit_Import — function, 8 lines
+visit_ImportFrom — function, 14 lines
+_first_line — function, 5 lines
+_name_of — function, 15 lines
+_resolve_relative — function, 16 lines
+module_name — function, 26 lines
+collect_file — function, 21 lines
+collect — function, 13 lines
+docgen.extractors.python.resolve — 184 lines
+_id_for — function, 2 lines
+_resolve — function, 32 lines
+larder_of — function, 17 lines
+to_ir — function, 98 lines
+_point_at — function, 9 lines
+docgen.ir.validate — 278 lines
+IRError — class, 2 lines
+_schema — function, 2 lines
+_props — function, 5 lines
+_fields — function, 4 lines
+check — function, 108 lines
+_check_larder — function, 58 lines
+validate — function, 6 lines
+check_model_matches_schema — function, 23 lines
+docgen.ir.model — 165 lines
+Meta — class, 38 lines
+to_dict — function, 18 lines
+Node — class, 21 lines
+__post_init__ — function, 3 lines
+to_dict — function, 8 lines
+Edge — class, 15 lines
+to_dict — function, 7 lines
+Graph — class, 48 lines
+node — function, 4 lines
+edge — function, 4 lines
+has — function, 2 lines
+to_dict — function, 16 lines
+from_dict — function, 6 lines
+docgen.ops.filter — 485 lines
+_rebuild — function, 57 lines
+surviving_parent — function, 5 lines
+lift — function, 6 lines
+drop_kinds — function, 14 lines
+only_kinds — function, 14 lines
+drop_stdlib — function, 13 lines
+drop_external — function, 3 lines
+subtree — function, 13 lines
+neighbourhood — function, 45 lines
+collapse_to_depth — function, 18 lines
+level — function, 6 lines
+drop_builtins — function, 14 lines
+overview — function, 35 lines
+shape — function, 72 lines
+rank_of — function, 9 lines
+split — function, 24 lines
+classify — function, 102 lines
+docgen.ops.pages — 383 lines
+slugify — function, 8 lines
+_key — function, 8 lines
+_under — function, 5 lines
+_representative — function, 13 lines
+page_ir — function, 67 lines
+layout — function, 20 lines
+paginate — function, 61 lines
+unique — function, 6 lines
+opens — function, 3 lines
+visit — function, 30 lines
+paginate_tables — function, 76 lines
+coverage — function, 28 lines
+for_view — function, 17 lines
+docgen.style.extract — 222 lines
+_values_from — function, 17 lines
+_normalise — function, 45 lines
+_svg_files — function, 25 lines
+harvest — function, 43 lines
+write — function, 7 lines
+summarise — function, 13 lines
+docgen.style.tokens — 211 lines
+_top — function, 2 lines
+_mode — function, 3 lines
+_luminance — function, 6 lines
+derive — function, 101 lines
+accent — function, 2 lines
+geometry — function, 14 lines
+write — function, 7 lines
+from_folder — function, 9 lines
+docgen — 2 lines
+docgen.lab.pg_probe — 150 lines
+probe — function, 30 lines
+_simplify — function, 3 lines
+main — function, 25 lines
+docgen.notebook.spec — 264 lines
+_step — function, 4 lines
+from_ir — function, 108 lines
+_order — function, 5 lines
+scaffold — function, 20 lines
+merge — function, 58 lines
+load — function, 2 lines
+dump — function, 5 lines
docgen
-docgen
-docgen.cli
-docgen.emitters
-docgen.extractors
-docgen.extractors
-docgen.book
-docgen.extractors.python
-docgen.ir
-docgen.style
-(root)
-docgen.lab
-docgen.notebook
-docgen.ops
-selftest
+docgen
+docgen.emitters
+docgen.emitters
+docgen.cli
+docgen.cli
+docgen.book
+docgen.extractors
+docgen.extractors.python
+docgen.ir
+docgen.ops
+docgen.style
+(root)
+docgen.lab
+docgen.notebook
+selftest
book
style
reference
-cli
+cli
ops
notebook
emitters
lab
__main__
-ir
-extractors
-emit
-extract
-view
-common
-run
-book
-check
-validate
-site
-explore
-dot
-notebook
-minimap
-erd
-index
-auto
-code
-usage
-openapi
-db
-python
-build
-config
-checks
-larder
-collect
-resolve
-validate
-model
-extract
-tokens
-docgen
-pg_probe
-spec
-filter
-
-module
-
-class
-
-interface
-
-function
-47 files · 10,350 lines · 1px ≈ 2.0 lines
+ir
+extractors
+site
+notebook
+explore
+dot
+minimap
+erd
+docsite
+index
+markdown
+paged
+auto
+emit
+extract
+run
+view
+scaffold
+common
+book
+check
+contract
+validate
+config
+document
+build
+checks
+larder
+notes
+code
+usage
+openapi
+db
+python
+collect
+resolve
+validate
+model
+filter
+pages
+extract
+tokens
+docgen
+pg_probe
+spec
+
+module
+
+class
+
+interface
+
+function
+55 files · 13,028 lines · 1px ≈ 2.0 lines
diff --git a/soleprint/atlas2/docgen/docs/index.html b/soleprint/atlas2/docgen/docs/index.html
index 4df16ab..9931fb6 100644
--- a/soleprint/atlas2/docgen/docs/index.html
+++ b/soleprint/atlas2/docgen/docs/index.html
@@ -19,12 +19,15 @@
What it is
The idea
Five minutes
+ Your first project
+ Working with a model
Architecture
The three concerns
The book
The IR
Shape decides the drawing
+ Pages
Reading
Extractors
@@ -37,11 +40,13 @@
Emitters
Explore
Notebooks
+ Notes
Style & colour
Reference
Standalone
Run files
+ Project files
Commands
Dependencies
Testing
@@ -154,6 +159,145 @@ make self # docgen's book of soleprint
the outputs open over file://.
+
+Your first project
+
+
+ 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.
+
+
+1. Put docgen somewhere and check the machine
+
+cp -r docgen ~/tools/docgen
+cd ~/tools/docgen
+make doctor # what is installed; never fails
+make sync # optional: C#/TypeScript, OpenAPI, all from uv.lock
+
+
+ From anywhere else, docgen runs as python3 -m docgen with its parent
+ folder on the path:
+
+
+export PYTHONPATH=~/tools # the folder that holds docgen/
+python3 -m docgen --help
+
+2. Write docgen.toml at the project's root
+
+[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"
+
+
+ Paths are relative to this file. Keep the outputs outside the source tree. See
+ project files for every key, and add
+ .docgen/ to the project's .gitignore.
+
+
+3. Check what it will do, then build
+
+cd ~/work/orders-service
+python3 -m docgen run docgen.toml --list # resolved paths; builds nothing
+python3 -m docgen run docgen.toml --check # build, then check every book
+
+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
+
+
+ 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
+ ../docs_out/orders_service_site/index.html: each book shows what
+ came in and what came out, and its pages click through from the overview.
+
+
+4. Add your own text
+
+python3 -m docgen scaffold docgen.toml --list # every spot a note can go
+python3 -m docgen scaffold docgen.toml # or: write an empty stub for each
+
+
+ Pick a spot and write markdown into notes/<book>/<spot>.md,
+ for example notes/code/overview.md above the overview page. Rebuild
+ and it's there, in the notebook and on the site. Stubs you leave empty add
+ nothing.
+
+
+
+ If a note stops appearing , the page it was filed under was probably
+ renamed, for instance a package that moved. The rebuild says so and
+ --check fails with the note's name. Rename the file to the new spot
+ (scaffold --list shows them) or delete it.
+
+
+5. Rebuild whenever the code moves
+
+
+ python3 -m docgen run docgen.toml --check again. A rebuild replaces
+ exactly what the last one wrote and never touches your notes or anything you
+ added to the output folders. --only code rebuilds one book while
+ you're writing its notes.
+
+
+
+Working with a model
+
+
+ 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:
+
+
+python3 -m docgen contract > docgen-contract.md
+
+
+ That file is docs/models.md, 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 make check.
+
+
+The loop
+
+
+ Give it the project and the contract. A digest of the project
+ (distill), plus docgen-contract.md as an
+ attachment rather than pasted. If replies go through explode,
+ attach its contract too: explode's says how to format the reply, docgen's
+ says what goes in the files.
+ Ask for docgen.toml first. Unpack it, then run
+ run docgen.toml --list. If it's refused, paste the errors back
+ verbatim. Each one is in the contract's repair table.
+ Build once, then list the spots. Run
+ scaffold docgen.toml --list and paste the output back. Page
+ names depend on the real directories, and a model that guesses them writes
+ notes that land nowhere.
+ Ask for notes for those spots. Unpack, rebuild with
+ --check, and paste back any note reported as filed under a page
+ that doesn't exist.
+
+
+
+ What a model tends to write that docgen refuses. In a dry run, a model
+ asked for a live database URL (with its password), a seed 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.
+
+
The three concerns
@@ -641,6 +785,69 @@ python3 -m docgen view ir.json --shape # what will this
dark for everything that is not a declaration — imports, constants, prose.
+
+Pages
+
+
+ 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 pages ,
+ each drawn small enough to fit the column it's going into.
+
+
+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
+
+
+ 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 context box, collapsed to the branch it sits on. A
+ page still says what its subsystem reaches without drawing the rest of the tree.
+
+
+Fitted by measuring, not estimating
+
+
+ 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 too wide in its caption and in the index. Never cut,
+ never dropped.
+
+
+
+ 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.
+
+
+
+
Which pages exist doesn't depend on width , only how each is laid out
+ does. A notebook column and a site column get the same pages, which is what lets
+ a
note filed under a page land in the same place in both.
+
+
+Clicking through, on the site
+
+
+ Every page is its own HTML file with the diagram inline , not in an
+ <img>, 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: fit scales to the column, 1:1 shows the
+ drawing at natural size with scrollbars, the choice is remembered, and
+ full screen opens the pan-and-zoom viewer.
+
+
+ A notebook can't follow a link inside an image, so it gets a page index
+ instead: every page, indented by depth, each a link to its figure.
+
+
Explore
@@ -747,6 +954,56 @@ python3 -m docgen view ir.json --shape # what will this
python3 -m docgen emit notebook ir.json --scaffold overlay.json
python3 -m docgen emit notebook ir.json --overlay overlay.json -o walkthrough.ipynb
+
+Notes
+
+
+ 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
+ notes folder , one markdown file per spot, named after 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 "what came in"
+ ├── overview.md above the overview page
+ └── station.tools.md above the page for station.tools
+
+
+ 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.
+
+
+make scaffold CONFIG=docgen.toml # an empty stub for every spot, never overwriting
+
+
+ scaffold 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.
+
+
+
+ A note whose page is gone is reported, not dropped. 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.
+
+
+
+ The overlay 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.
+
+
Style & colour
@@ -872,10 +1129,10 @@ make doctor
context checks skipped
- in the repo, synced 287 —
- copied out, bare python3 270 tree-sitter, OpenAPI, the in-place case
- copied out, synced 279 OpenAPI, the in-place case
- copied out, synced, DOCGEN_REFERENCE set 286
+ in the repo, synced 346 —
+ copied out, bare python3 329 tree-sitter, OpenAPI, the in-place case
+ copied out, synced 338 OpenAPI, the in-place case
+ copied out, synced, DOCGEN_REFERENCE set 345
the in-place case — correctly, there is no repo above
@@ -973,6 +1230,80 @@ python3 -m docgen run docgen.toml --list # resolved paths
because those are data that tools write.
+
+Project files
+
+
+ A run file lists independent books. A project file describes one
+ document 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:
+
+
+# <project>/docgen.toml
+[project]
+name = "orders-api"
+title = "Orders API"
+notes = "notes" # default
+build = ".docgen" # book artifacts, default; gitignore it
+
+[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
+
+
+ Each [extract.*] table is a book , and a book is a part of
+ every output rather than an output of its own. Each [emit.*] 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 [[extract.code]] with a
+ name each. [ops] applies to every book's view before
+ paging.
+
+
+ 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 this document is incomplete and name what's
+ missing.
+
+
+Refused, with what to do instead
+
+
+ written why not
+ [extract.db] url = …
+ 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
+ modelgen from-db, then schema = …. The URL is never
+ repeated in the error.
+ ext = [".sh", ".yaml", ".sql"]
+ No grammar for those yet. The error names the extensions that are read.
+ [extract.seed]
+ No such reader. The error names the ones that exist.
+ [project] and [[book]] in one file
+ Which one it meant is exactly what nobody should have to guess.
+
+
+
+ docgen.project.example.toml ships beside the run file example and
+ builds docgen's own fixtures into one document, with a sample note.
+
+
Commands
@@ -981,7 +1312,9 @@ python3 -m docgen run docgen.toml --list # resolved paths
target does
make sync.venv with every optional group, from uv.lock
make book SRC=…one whole operation , measured at both ends
- make run CONFIG=…every book a run file lists
+ make run CONFIG=…every book a run file lists, or a project 's document
+ make scaffold CONFIG=…an empty note for every page and step
+ python3 -m docgen contractthe instructions for a model that writes the config and notes
make checkdocgen's own suite, offline, nothing installed
make check BOOK=…one book's own level — generated and custom
make doctorwhat this machine has and what it is missing
@@ -1028,6 +1361,10 @@ python3 -m docgen run docgen.toml --list # resolved paths
python3 -m docgen run docgen.toml # every book a run file lists
python3 -m docgen check # docgen's own suite
python3 -m docgen check out/book/slug # that book's level
+python3 -m docgen check .docgen/<project> # a project document's level
+python3 -m docgen scaffold docgen.toml # stub notes for every spot
+python3 -m docgen scaffold docgen.toml --list # the spots, written nowhere
+python3 -m docgen contract # the instructions for a model
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 # after editing pyproject
command asks about fails?
make doctorthe machine — what is installed
never; it reports
- make checkdocgen — 287 checks
+ make checkdocgen — 346 checks
exit 1
- make check BOOK=<dir>that one book
+ make check BOOK=<dir>that one book, or a project's document
exit 1
- All 287 after make sync, 272 with nothing optional installed — the
+ All 346 after make sync, 331 with nothing optional installed — the
suite skips rather than fails when tree-sitter, lxml or the OpenAPI reader is
absent. See standalone for the counts outside the repo.
Among them, two that keep this page's structure true: nothing outside
diff --git a/soleprint/atlas2/docgen/docs/models.md b/soleprint/atlas2/docgen/docs/models.md
new file mode 100644
index 0000000..d92e257
--- /dev/null
+++ b/soleprint/atlas2/docgen/docs/models.md
@@ -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.]`: 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.]]`
+ (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.]`: 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:
+
+```
+/_intro.md top of the whole document
+/_end.md end of the whole document
+//_intro.md top of that book's part
+//.md above that spot in the book
+```
+
+`` is the book's `name` (the reader's name if none was given).
+
+### Spots every book has
+
+| spot | appears above |
+|---|---|
+| `larder` | "what came in": what was read |
+| `pages` | the index of diagram pages |
+| `overview` | the first diagram page |
+| `book` | "what came out": the closing summary |
+| `step-ir`, `step-view`, `step-pages`, `step-graph`, `step-index`, `step-sidebar`, `step-minimap`, `step-explore` | each intermediate step |
+
+### Spots that depend on the source
+
+- **Pages of a code tree**: named by a package's or directory's dotted path from the
+ root, for example `billing.invoices`. Not every directory gets a page: a level that
+ holds a single group is merged into it, and the top page is always `overview`.
+- **Pages of a schema**: `overview`, then `tables-01`, `tables-02`, …
+- **An API** (`openapi`, `usage`): `setup`, `client`, `shapes`, and one spot per call:
+ the method and path with every character outside letters, digits, `.`, `_` and `-`
+ replaced by `-`. So `GET /orders/{orderId}/lines` becomes `GET-orders-orderId-lines`.
+
+Page ids for a code tree depend on the actual directories. **Do not derive them yourself
+unless you have the full directory listing.** Ask for the output of:
+
+```
+python3 -m docgen scaffold docgen.toml --list
+```
+
+It prints every spot that exists, one per line.
+
+### Writing a note
+
+- Plain markdown: headings, paragraphs, lists, **bold**, *italic*, `code`, fenced code
+ blocks, links, tables, quotes.
+- No raw HTML. It is shown as text.
+- Images: a path relative to the note file, e.g. ``. They are
+ embedded.
+- Keep it short: a few paragraphs. Start with `##` headings, because the page already
+ has its own title.
+- A note that contains only the stub comment docgen writes renders nothing. Write below
+ the comment, or replace the file's contents.
+
+Example: `notes/code/billing.invoices.md`:
+
+```markdown
+## Why invoices are immutable
+
+An invoice is never edited after it is issued. Corrections are a new credit note that
+references it. This is a legal requirement, not a design preference, so do not add
+update endpoints here.
+
+Start reading at `InvoiceIssuer`. It is the only place an invoice is created.
+```
+
+---
+
+## When the build refuses your file
+
+The person running docgen pastes the errors back. Each one says what to change:
+
+| error contains | fix |
+|---|---|
+| `has unknown key 'x'` | remove the key or use a listed one; check the spelling |
+| `docgen reads a schema dump and never connects` | remove `url`; use `schema = "path/to/schema.json"` |
+| `no grammar for .sh` | remove those extensions from `ext` |
+| `no reader named 'x'` | use one of `python`, `code`, `db`, `openapi`, `usage` |
+| `no output named 'x'` | use `notebook` or `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 |
diff --git a/soleprint/atlas2/docgen/emitters/docsite.py b/soleprint/atlas2/docgen/emitters/docsite.py
new file mode 100644
index 0000000..cc902dc
--- /dev/null
+++ b/soleprint/atlas2/docgen/emitters/docsite.py
@@ -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
+ └── /
+ ├── overview.html the book's top page
+ ├── .html one subsystem each; its boxes open the next level
+ ├── .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 ` `, 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 = """"""
+
+
+def _note(note, cls="note") -> str:
+ if not note:
+ return ""
+ text, uris = note
+ return f'' + to_html(
+ text, image=lambda alt, src: f'
'
+ ) + "
"
+
+
+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"{escape(name)} — {escape(why)} "
+ for name, why in sorted(failed.items()))
+ return ('This document is incomplete. '
+ f"{len(failed)} book(s) could not be built, so they are not below.
")
+
+
+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'{escape(page["title"])} '
+ kids = by_parent.get(page["id"], [])
+ if not kids:
+ return f"{link} "
+ return f"{link}" + "".join(walk(k, depth + 1) for k in kids) + " "
+
+ return "" + "".join(walk(p, 0) for p in pages if p["id"] == root) + " "
+
+
+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'{escape(doc_title)} ']
+ for p in reversed(trail):
+ if p is page:
+ crumbs.append(escape(p["title"]))
+ else:
+ crumbs.append(f'{escape(p["title"])} ')
+
+ 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 = (
+ '"
+ f'{inline(svg)}
'
+ )
+ else:
+ figure = 'Not drawn — Graphviz is not installed here.
'
+
+ children = ""
+ if page.get("children"):
+ items = "".join(
+ f'{escape(by_id[c]["title"])} '
+ for c in page["children"] if c in by_id
+ )
+ children = f"Opens into "
+
+ return f"""
+
+
+
+
+{escape(page["title"])} — {escape(b.slug)} — {escape(doc_title)}
+
+
+
+
+
+
+ {" › ".join(crumbs)}
+ {escape(page["title"])}
+ {_note(note)}
+ {figure}
+ {escape(cap)}
+ {children}
+
+
+{SIZE_SCRIPT}
+
+
+"""
+
+
+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'"
+ + _note(entry.get("intro"))
+ + _ledger(b.to_dict())
+ + '
"
+ )
+ nav.append(f'{escape(b.slug)} ')
+
+ put(out_dir / "index.html", f"""
+
+
+
+
+{escape(title)}
+
+
+
+
+
+
+ {escape(title)}
+ Regenerated, not edited. Each book opens with what came in and what came out.
+ {_note((intro, {}) if intro else None)}
+ {_missing(failed)}
+ {"".join(cards)}
+ {_note((end, {}) if end else None)}
+
+
+
+
+""")
+ return written
diff --git a/soleprint/atlas2/docgen/emitters/dot.py b/soleprint/atlas2/docgen/emitters/dot.py
index 0e4f2c7..bc4915b 100644
--- a/soleprint/atlas2/docgen/emitters/dot.py
+++ b/soleprint/atlas2/docgen/emitters/dot.py
@@ -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}}}")
diff --git a/soleprint/atlas2/docgen/emitters/erd.py b/soleprint/atlas2/docgen/emitters/erd.py
index d736c1f..86450d3 100644
--- a/soleprint/atlas2/docgen/emitters/erd.py
+++ b/soleprint/atlas2/docgen/emitters/erd.py
@@ -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
diff --git a/soleprint/atlas2/docgen/emitters/markdown.py b/soleprint/atlas2/docgen/emitters/markdown.py
new file mode 100644
index 0000000..767cd78
--- /dev/null
+++ b/soleprint/atlas2/docgen/emitters/markdown.py
@@ -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)  | pipe | tables |
+ > quotes ---
+
+Anything it does not recognise is **escaped, not passed through** — a note is
+text, and raw HTML in it is shown rather than executed.
+"""
+
+import re
+from html import escape
+
+
+def _url(raw: str) -> str:
+ """An attribute-safe URL. The text around it is already escaped without
+ quotes, so a `"` here would close the attribute; and a `javascript:` link is
+ refused outright — a note is text, not a place to run code."""
+ if re.match(r"^\s*(javascript|vbscript|data):", raw, re.I):
+ return "#"
+ return raw.replace('"', """)
+
+
+INLINE = [
+ (re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)"), lambda m, img: img(m.group(1), m.group(2))),
+ (re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)"),
+ lambda m, img: f'{m.group(1)} '),
+ (re.compile(r"\*\*(.+?)\*\*"), lambda m, img: f"{m.group(1)} "),
+ (re.compile(r"(?{m.group(1)}"),
+]
+
+
+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"{escape(m.group(1))}")
+ 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' ')
+ lines = text.replace("\r\n", "\n").split("\n")
+ out: list[str] = []
+ i = 0
+
+ def para(buf):
+ if buf:
+ out.append(f"{_inline(' '.join(s.strip() for s in buf), image)}
")
+
+ 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"{escape(chr(10).join(code))} ")
+ 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"{_inline(m.group(2), image)} ")
+ i += 1
+ continue
+
+ if re.match(r"^(-{3,}|\*{3,})$", stripped):
+ para(buf); buf = []
+ out.append(" ")
+ 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"{to_html(chr(10).join(quote), image=image)} ")
+ 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"{_inline(it, image)} " 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 = "" + "".join(f"{_inline(h, image)} " for h in head) + " "
+ for r in rows:
+ html += "" + "".join(f"{_inline(c, image)} " for c in r) + " "
+ out.append(html + "
")
+ continue
+
+ buf.append(line)
+ i += 1
+
+ para(buf)
+ return "\n".join(out)
diff --git a/soleprint/atlas2/docgen/emitters/notebook.py b/soleprint/atlas2/docgen/emitters/notebook.py
index 32df47f..964a9e4 100644
--- a/soleprint/atlas2/docgen/emitters/notebook.py
+++ b/soleprint/atlas2/docgen/emitters/notebook.py
@@ -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:`. 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 `` 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' ')
+ parts.append("")
+ if step.get("title"):
+ parts.append(f'{"#" * min(6, 2 + int(step.get("depth", 0)))} {step["title"]}')
+ parts.append("")
+ if step.get("svg"):
+ parts.append(f'')
+ else:
+ parts.append(f'*Not drawn here — {step.get("why", "no renderer")}.*')
+ if step.get("caption"):
+ parts += ["", f'*{step["caption"]}*']
+ return "\n".join(parts)
+
+
+def _figure_attachments(step: dict) -> dict | None:
+ if not step.get("svg"):
+ return None
+ import base64
+
+ data = step["svg"].encode("utf-8") if isinstance(step["svg"], str) else step["svg"]
+ return {step["name"]: {"image/svg+xml": base64.b64encode(data).decode("ascii")}}
+
+
def build(spec: dict) -> dict:
"""A merged notebook spec -> the notebook, as a dict."""
- blocks: list[tuple[str, str]] = []
+ blocks: list[tuple] = []
for step in spec["steps"]:
kind = step.get("kind", "md")
before = step.get("before")
if before:
- blocks.append(("md", before))
+ blocks.append(("md", before, step.get("before_attachments")))
+
+ if kind == "figure":
+ blocks.append(("md", _figure_md(step), _figure_attachments(step)))
if kind == "md":
text = []
@@ -259,7 +300,7 @@ def build(spec: dict) -> dict:
blocks.append(("md", after))
return {
- "cells": [_cell(i, k, t) for i, (k, t) in enumerate(blocks)],
+ "cells": [_cell(i, *block) for i, block in enumerate(blocks)],
"metadata": METADATA,
"nbformat": 4,
"nbformat_minor": 5,
diff --git a/soleprint/atlas2/docgen/emitters/paged.py b/soleprint/atlas2/docgen/emitters/paged.py
new file mode 100644
index 0000000..92ba6f1
--- /dev/null
+++ b/soleprint/atlas2/docgen/emitters/paged.py
@@ -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']*\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 ` `, 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"]*>\s*", "", svg)
+ return re.sub(r"\s*", "", svg, flags=re.S).strip()
diff --git a/soleprint/atlas2/docgen/emitters/site.py b/soleprint/atlas2/docgen/emitters/site.py
index 7057d9c..e48c933 100644
--- a/soleprint/atlas2/docgen/emitters/site.py
+++ b/soleprint/atlas2/docgen/emitters/site.py
@@ -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 , 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',
}
diff --git a/soleprint/atlas2/docgen/fixtures/notes/_intro.md b/soleprint/atlas2/docgen/fixtures/notes/_intro.md
new file mode 100644
index 0000000..b92345c
--- /dev/null
+++ b/soleprint/atlas2/docgen/fixtures/notes/_intro.md
@@ -0,0 +1,3 @@
+
+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.
diff --git a/soleprint/atlas2/docgen/fixtures/notes/schema/overview.md b/soleprint/atlas2/docgen/fixtures/notes/schema/overview.md
new file mode 100644
index 0000000..bd8e658
--- /dev/null
+++ b/soleprint/atlas2/docgen/fixtures/notes/schema/overview.md
@@ -0,0 +1,5 @@
+
+## 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.
diff --git a/soleprint/atlas2/docgen/notebook/spec.py b/soleprint/atlas2/docgen/notebook/spec.py
index cb8c7e6..76b5116 100644
--- a/soleprint/atlas2/docgen/notebook/spec.py
+++ b/soleprint/atlas2/docgen/notebook/spec.py
@@ -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:
diff --git a/soleprint/atlas2/docgen/ops/pages.py b/soleprint/atlas2/docgen/ops/pages.py
new file mode 100644
index 0000000..22dc851
--- /dev/null
+++ b/soleprint/atlas2/docgen/ops/pages.py
@@ -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)
diff --git a/soleprint/atlas2/docgen/selftest.py b/soleprint/atlas2/docgen/selftest.py
index 4d3ab2e..9575d56 100644
--- a/soleprint/atlas2/docgen/selftest.py
+++ b/soleprint/atlas2/docgen/selftest.py
@@ -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(' ')
+ (notes / "app" / "overview.md").write_text(
+ (notes / "app" / "overview.md").read_text() + "\n\n")
+ (notes / "app" / "no-such-page.md").write_text("Filed under a page that is gone.\n")
+
+ # A note is text: raw HTML is shown, not run, and a link cannot break out of
+ # its attribute or run script.
+ html = md.to_html(' [a](b"onclick=1) [c](javascript:alert(1))')
+ check(
+ "a note's HTML is escaped and its links are attribute-safe",
+ # The quote stays inside the attribute as " — asserted on the exact
+ # attribute, since stripping the entity first (as this check once did)
+ # makes the attribute's own closing quote look like a break-out.
+ "