From 358b98f82605c7793637ed32f8ce38db9ad42c13 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Sat, 12 Sep 2026 07:08:00 -0300 Subject: [PATCH] site emitter --- soleprint/atlas2/docgen/Makefile | 10 +- soleprint/atlas2/docgen/emitters/__main__.py | 6 +- soleprint/atlas2/docgen/emitters/cli_site.py | 73 ++++ soleprint/atlas2/docgen/emitters/site.py | 407 +++++++++++++++++++ soleprint/atlas2/docgen/selftest.py | 68 ++++ soleprint/atlas2/docgen/viewer_test.js | 59 +++ 6 files changed, 619 insertions(+), 4 deletions(-) create mode 100644 soleprint/atlas2/docgen/emitters/cli_site.py create mode 100644 soleprint/atlas2/docgen/emitters/site.py create mode 100644 soleprint/atlas2/docgen/viewer_test.js diff --git a/soleprint/atlas2/docgen/Makefile b/soleprint/atlas2/docgen/Makefile index d407ec1..c1d76ed 100644 --- a/soleprint/atlas2/docgen/Makefile +++ b/soleprint/atlas2/docgen/Makefile @@ -31,7 +31,7 @@ DEPTH ?= 2 THEME_ARG := $(if $(THEME),--theme $(THEME)) -.PHONY: help check ir db graph index view self doctor clean +.PHONY: help check ir db graph index site view self doctor clean help: ## List every target @echo "docgen — static analysis of a tree, and the artifacts that fall out of it" @@ -65,6 +65,11 @@ graph: view ## OUT/view.json -> whatever its structure asks for @$(RUN) $(PKG).emitters auto $(OUT)/view.json -o $(OUT) \ --style $(STYLE) $(THEME_ARG) +site: view ## OUT/view.json -> a self-contained docs site in OUT/site + @$(RUN) $(PKG).emitters site $(OUT)/view.json -o $(OUT)/site \ + --style $(STYLE) $(THEME_ARG) + @echo " open $(OUT)/site/index.html" + index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json @$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/index.md @$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/sidebar.json @@ -73,8 +78,9 @@ self: ## Run the whole pipeline over soleprint itself — the honest end-to-end @$(MAKE) --no-print-directory ir SRC=$(PARENT)/.. OUT=$(OUT) @$(MAKE) --no-print-directory index OUT=$(OUT) @$(MAKE) --no-print-directory graph OUT=$(OUT) + @$(MAKE) --no-print-directory site OUT=$(OUT) @echo - @echo " Read $(OUT)/index.md — it should read like the system." + @echo " Read $(OUT)/index.md, or open $(OUT)/site/index.html" doctor: ## Report whether this machine can run it @printf 'python : '; $(PY) --version 2>&1 || echo MISSING diff --git a/soleprint/atlas2/docgen/emitters/__main__.py b/soleprint/atlas2/docgen/emitters/__main__.py index a03d2fe..35fd2dc 100644 --- a/soleprint/atlas2/docgen/emitters/__main__.py +++ b/soleprint/atlas2/docgen/emitters/__main__.py @@ -6,7 +6,7 @@ import sys def main(argv=None): argv = sys.argv[1:] if argv is None else argv if not argv: - print("usage: python3 -m docgen.emitters [-o OUT] [--style NAME] [--theme NAME]", + print("usage: python3 -m docgen.emitters [-o OUT] [--style NAME] [--theme NAME]", file=sys.stderr) return 2 name, rest = argv[0], argv[1:] @@ -20,8 +20,10 @@ def main(argv=None): from .auto import main as run elif name == "notebook": from .cli_notebook import main as run + elif name == "site": + from .cli_site import main as run else: - print(f"Error: no emitter {name!r} — have: auto, dot, index, erd, notebook", file=sys.stderr) + print(f"Error: no emitter {name!r} — have: auto, dot, index, erd, notebook, site", file=sys.stderr) return 1 return run(rest) diff --git a/soleprint/atlas2/docgen/emitters/cli_site.py b/soleprint/atlas2/docgen/emitters/cli_site.py new file mode 100644 index 0000000..131b5e5 --- /dev/null +++ b/soleprint/atlas2/docgen/emitters/cli_site.py @@ -0,0 +1,73 @@ +""" python3 -m docgen.emitters site -o DIR [--theme lucid] + +Writes index.html, viewer.html, site.css and the graph — self-contained, offline. +""" + +import argparse +import json +import sys +from pathlib import Path + +from ..ir import check +from ..ops import classify +from ..style import Style, StyleError +from .site import write + + +def main(argv=None): + p = argparse.ArgumentParser(prog="python3 -m docgen.emitters site") + p.add_argument("ir", type=Path) + p.add_argument("--output", "-o", type=Path, required=True, help="Directory.") + p.add_argument("--style", default="lucid") + p.add_argument("--theme", default=None) + p.add_argument("--title", default="") + p.add_argument("--no-graph", action="store_true") + args = p.parse_args(argv) + + try: + data = json.loads(args.ir.read_text()) + except (OSError, json.JSONDecodeError) as e: + print(f"Error: could not read {args.ir}: {e}", file=sys.stderr) + return 1 + problems = check(data) + if problems: + print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr) + for pr in problems[:5]: + print(f" {pr}", file=sys.stderr) + return 1 + + try: + style = Style.load(args.style, theme=args.theme) + except StyleError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + args.output.mkdir(parents=True, exist_ok=True) + graph_name = None + if not args.no_graph: + # Whatever the structure asks for, so the page carries the right picture. + verdict = classify(data) + if verdict["emitter"] == "erd": + from .erd import emit as draw + (args.output / "graph.svg").write_text(draw(data, style)) + graph_name = "graph.svg" + elif verdict["emitter"] == "dot": + from .dot import emit as dot_emit, have_graphviz, render + if have_graphviz(): + opts = verdict.get("options") or {} + (args.output / "graph.svg").write_bytes( + render(dot_emit(data, style, rankdir=opts.get("rankdir"))) + ) + graph_name = "graph.svg" + else: + print(" note: graphviz absent — the site is text only", file=sys.stderr) + else: + print(f" note: {verdict['kind']} — {verdict['why']}", file=sys.stderr) + print(" no diagram on the page; the index is the artifact", file=sys.stderr) + + files = write(data, style, args.output, graph=graph_name, title=args.title) + for f in files: + print(f" site {f}") + if graph_name: + print(f" site {args.output / graph_name}") + return 0 diff --git a/soleprint/atlas2/docgen/emitters/site.py b/soleprint/atlas2/docgen/emitters/site.py new file mode 100644 index 0000000..bd4fd3f --- /dev/null +++ b/soleprint/atlas2/docgen/emitters/site.py @@ -0,0 +1,407 @@ +""" +IR -> a self-contained documentation site: sidebar, content, graph viewer. + +Not invented here. Five demos under `semester/` already converged on the same +two files, and this generates that arrangement rather than a sixth variant: + + docs/index.html a 220px sticky sidebar beside a max-800px content column + docs/viewer.html `?src=` → fit to window, wheel-zoom at cursor, drag to pan + +`sms`, `mpr`, `cht`, `unt` and `eth` each carry a copy of that viewer. They are +**the same 97 lines**, differing only in comments and one background colour — +which is the same eight-ways-to-do-one-thing this whole tool exists to end. + +The handoff between them is already a convention, in both `spr/docs/docs.js:229` +and `sms/docs/index.html:311`, arrived at independently: + + + +Inline and scaled to the column; click for the full thing. + +## What is added + +**A 1:1 toggle.** The copied viewer fits on load and resets to fit on +double-click, and has no way to say "actual size" — which is the one thing you +want the moment a diagram has small text in it. A click toggles fit ↔ 100%, +with the current scale shown in the corner so it is never ambiguous which you +are looking at. A click that moved the mouse is a drag and does not toggle. + +## Colours + +Baked from the same style slots as every diagram, so the page and the graph on +it match by construction. `--theme lucid` produces a light site and a light +diagram together; nothing has to be kept in sync by hand. + +Self-contained and offline: no CDN, no build step, opens over `file://`. +""" + +import json +from html import escape +from pathlib import Path + +SIDEBAR_W = 220 +CONTENT_W = 800 + +# The viewer, with the toggle the copied ones lack. Kept as one string because +# it is one file and its whole value is that there is exactly one of it. +VIEWER = """ + + + + +__TITLE__ + + + +
+← docs +
100%fit· click 1:1 · drag · wheel
+ + + +""" + +CSS = """/* Generated by docgen. The layout five demos converged on: a sticky sidebar + beside a bounded content column. Colours are baked from the style's theme, so + the page and the diagrams on it are one visual language. */ +:root { + --bg: __BG__; + --surface: __SURFACE__; + --surface-2: __SURFACE2__; + --border: __BORDER__; + --text: __TEXT__; + --muted: __MUTED__; + --dim: __DIM__; + --accent: __ACCENT__; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + background: var(--bg); color: var(--text); + font-family: __FONT__; font-size: 13px; line-height: 1.65; +} +.layout { display: flex; min-height: 100vh; } + +.sidebar { + width: __SIDEBAR__px; flex-shrink: 0; background: var(--surface); + border-right: 1px solid var(--border); + position: sticky; top: 0; height: 100vh; overflow-y: auto; + padding: 1.25rem 0; scrollbar-width: none; +} +.sidebar::-webkit-scrollbar { display: none; } +.sidebar-header { padding: 0 1rem 1rem; border-bottom: 1px solid var(--border); } +.sidebar-header b { color: var(--text); font-size: 13px; } +.sidebar-header small { display: block; color: var(--dim); font-size: 10px; margin-top: 2px; } +.sidebar ul { list-style: none; } +/* Every link, however deep — a link inside a is still a link, and + selecting `li > a` quietly missed all of them. */ +.sidebar a { + display: block; padding: 3px 1rem; color: var(--muted); + text-decoration: none; font-size: 12px; border-left: 2px solid transparent; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.sidebar a:hover { color: var(--text); background: var(--surface-2); } +.sidebar a.active { color: var(--accent); border-left-color: var(--accent); } +.sidebar .k { color: var(--dim); font-size: 9px; text-transform: uppercase; + letter-spacing: .04em; margin-left: .4em; font-weight: 400; } +/* Indent by nesting depth rather than by element, so it keeps working however + deep the tree goes. */ +.sidebar ul ul a { padding-left: 1.8rem; } +.sidebar ul ul ul a { padding-left: 2.6rem; } +.sidebar ul ul ul ul a { padding-left: 3.4rem; } +.sidebar details > summary { + cursor: pointer; list-style: none; display: flex; align-items: center; +} +.sidebar details > summary::-webkit-details-marker { display: none; } +.sidebar details > summary::before { + content: "▸"; color: var(--dim); flex: 0 0 auto; + margin-left: .55rem; font-size: 9px; transition: transform .12s; +} +.sidebar details[open] > summary::before { transform: rotate(90deg); } +.sidebar details > summary > a { flex: 1 1 auto; padding-left: .45rem; } +.sidebar details > summary:hover::before { color: var(--text); } + +.content { flex: 1; min-width: 0; max-width: __CONTENT__px; padding: 2rem 3rem; } +.content h1 { font-size: 22px; margin-bottom: .25rem; } +.content h2 { font-size: 15px; margin: 2rem 0 .5rem; padding-top: 1rem; + border-top: 1px solid var(--border); } +.content h3 { font-size: 13px; margin: 1.25rem 0 .35rem; color: var(--muted); } +.content p { margin-bottom: .75rem; color: var(--muted); } +.content code { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 11px; background: var(--surface); padding: 1px 5px; + border-radius: 3px; color: var(--text); } +.content table { border-collapse: collapse; margin: .75rem 0; font-size: 12px; } +.content th, .content td { text-align: left; padding: 4px 14px 4px 0; + border-bottom: 1px solid var(--border); color: var(--muted); } +.content th { color: var(--dim); font-weight: 600; font-size: 10px; + text-transform: uppercase; letter-spacing: .04em; } +.lede { color: var(--dim); font-size: 12px; margin-bottom: 1.5rem; } + +/* The convention both spr/docs and sms/docs arrived at independently: + inline and scaled to the column, click for the full thing. */ +.figure { margin: 1rem 0 1.5rem; } +.figure a { display: block; border: 1px solid var(--border); border-radius: 8px; + overflow: hidden; background: var(--surface); } +.figure a:hover { border-color: var(--accent); } +.figure img { display: block; width: 100%; height: auto; } +.figure figcaption { color: var(--dim); font-size: 10px; margin-top: .4rem; } +""" + + +def _slots(style) -> dict: + s = style.slot + return { + "__BG__": s("surface-0"), + "__SURFACE__": s("surface-1") or s("surface-2"), + "__SURFACE2__": s("surface-2"), + "__BORDER__": s("border"), + "__TEXT__": s("text"), + "__MUTED__": s("text-muted"), + "__DIM__": s("text-dim"), + "__ACCENT__": s("accent"), + "__FONT__": '"Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif', + } + + +def _fill(template: str, values: dict) -> str: + for key, value in values.items(): + template = template.replace(key, str(value)) + return template + + +def _sidebar(items: list, depth: int = 0) -> str: + out = ["
    "] + for item in items: + label = escape(item.get("label", item["id"])) + kind = escape(item.get("kind", "")) + anchor = escape(item["id"]) + link = f'{label}{kind}' + kids = item.get("children") or [] + if kids: + out.append( + f"
  • " + f"{link}{_sidebar(kids, depth + 1)}
  • " + ) + else: + out.append(f"
  • {link}
  • ") + out.append("
") + return "".join(out) + + +def _sections(items: list, depth: int = 0) -> str: + out = [] + for item in items: + tag = "h2" if depth == 0 else "h3" + attrs = item.get("attrs") or {} + out.append(f'<{tag} id="{escape(item["id"])}">{escape(item.get("label", ""))}' + f' {escape(item.get("kind", ""))}') + if item.get("doc"): + out.append(f"

{escape(item['doc'])}

") + if item.get("href"): + out.append(f'

{escape(item["href"])}

') + kids = item.get("children") or [] + if kids: + out.append(_sections(kids, depth + 1)) + return "".join(out) + + +def emit(ir: dict, style, *, graph: str | None = None, title: str = "") -> dict: + """IR + Style -> {filename: text}. Write them next to each other.""" + from .index import to_sidebar + + meta = ir.get("meta", {}) + name = title or meta.get("root", "docs") + side = to_sidebar(ir) + values = _slots(style) + + counts: dict[str, int] = {} + for n in ir["nodes"]: + counts[n["kind"]] = counts.get(n["kind"], 0) + 1 + summary = " · ".join(f"{v} {k}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1])) + + figure = "" + if graph: + figure = ( + '
' + f'' + f'{escape(name)}' + "
Click to open the viewer · click again for actual size
" + "
" + ) + + external = side.get("external") or [] + ext_html = "" + if external: + rows = "".join(f"{escape(e)}" for e in external[:40]) + ext_html = ( + '

Depends on, outside this tree

' + "

Names that could not be resolved here — the dependency surface.

" + f"{rows}
" + ) + + index = f""" + + + + +{escape(name)} + + + +
+ +
+

{escape(name)}

+

Generated from {escape(meta.get("source", "?"))} · + {escape(summary)}. Regenerated, not edited.

+ {figure} + {_sections(side["items"])} + {ext_html} +
+
+ + + +""" + + return { + "index.html": index, + "viewer.html": _fill(VIEWER.replace("__TITLE__", escape(name)), values), + "site.css": _fill( + CSS.replace("__SIDEBAR__", str(SIDEBAR_W)).replace("__CONTENT__", str(CONTENT_W)), + values, + ), + } + + +def write(ir: dict, style, out_dir, *, graph: str | None = None, title: str = "") -> list[Path]: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + written = [] + for name, text in emit(ir, style, graph=graph, title=title).items(): + path = out_dir / name + path.write_text(text) + written.append(path) + return written diff --git a/soleprint/atlas2/docgen/selftest.py b/soleprint/atlas2/docgen/selftest.py index 0725431..019fc24 100644 --- a/soleprint/atlas2/docgen/selftest.py +++ b/soleprint/atlas2/docgen/selftest.py @@ -46,6 +46,7 @@ index_mod = __import__(f"{PKG}.emitters.index", fromlist=["*"]) ops_mod = __import__(f"{PKG}.ops", fromlist=["*"]) erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"]) nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"]) +site_mod = __import__(f"{PKG}.emitters.site", fromlist=["*"]) spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"]) db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"]) usage_ex = __import__(f"{PKG}.extractors.usage", fromlist=["*"]) @@ -1040,6 +1041,73 @@ check( check("every verdict carries options", "options" in ops_mod.classify(ir)) +# -------------------------------------------------------------------------- +print("\n11. the docs site") + +# Not invented here: five demos under semester/ carry the same viewer, differing +# only in comments and one background colour. This generates that arrangement +# rather than a sixth copy — and adds the 1:1 toggle none of them has. +site_dir = ROOT.parent / "site" +files = site_mod.write(ops_mod.overview(ir), lucid, site_dir, graph="graph.svg", title="fx") +names = {f.name for f in files} +check("it writes the three files", names == {"index.html", "viewer.html", "site.css"}, str(names)) + +index_html = (site_dir / "index.html").read_text() +css = (site_dir / "site.css").read_text() +viewer = (site_dir / "viewer.html").read_text() + +check( + "the graph links to the viewer, the way both demos already do it", + 'href="viewer.html?src=graph.svg"' in index_html and "" in index_html) +check( + "every sidebar link is styled", + ".sidebar a {" in css, + "selecting `li > a` misses links inside a , which is most of them", +) +check( + "colours are baked from the theme, not hardcoded", + lucid.slot("surface-0") in css and lucid.slot("accent") in css, +) +light = site_mod.emit(ops_mod.overview(ir), print_theme, graph="graph.svg") +check( + "a light theme gives a light page", + print_theme.slot("surface-0") in light["site.css"] + and lucid.slot("surface-0") not in light["site.css"], + "the page and the diagram on it move together", +) +check( + "it is self-contained and offline", + "http://" not in index_html and "https://" not in index_html + and "cdn" not in index_html.lower(), +) +check("the viewer carries a 1:1 toggle", "1:1" in viewer and "fitScale" in viewer) + +# The viewer is JavaScript, so it is tested as JavaScript. +import shutil as _shutil +import subprocess as _sub + +if not _shutil.which("node"): + skip("viewer behaviour", "node not installed") +else: + harness = HERE / "viewer_test.js" + if not harness.exists(): + skip("viewer behaviour", "viewer_test.js missing") + else: + proc = _sub.run(["node", str(harness), str(site_dir / "viewer.html")], + capture_output=True, text=True) + for line in proc.stdout.strip().splitlines(): + name = line.strip()[5:] + (PASS if line.strip().startswith("ok") else FAIL).append(f"viewer: {name}") + print(f" {line.strip()[:4]} viewer: {name}") + if proc.returncode and not proc.stdout.strip(): + check("the viewer harness runs", False, proc.stderr.strip()[:200]) + +_shutil.rmtree(site_dir, ignore_errors=True) + + # -------------------------------------------------------------------------- tmp.cleanup() print() diff --git a/soleprint/atlas2/docgen/viewer_test.js b/soleprint/atlas2/docgen/viewer_test.js new file mode 100644 index 0000000..4ed571c --- /dev/null +++ b/soleprint/atlas2/docgen/viewer_test.js @@ -0,0 +1,59 @@ +// Drive the generated viewer's logic under a stub DOM and assert the toggle. +const fs = require('fs'); +const html = fs.readFileSync(process.argv[2] || '/tmp/site/viewer.html', 'utf8'); +const script = html.split('')[0]; + +const handlers = {}; +const listen = (t, f) => { (handlers[t] = handlers[t] || []).push(f); }; +function node(extra) { + return Object.assign({ + style: {}, classList: { add() {}, remove() {} }, + addEventListener: listen, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 1000, height: 800 }), + }, extra || {}); +} + +const imgStub = node({ naturalWidth: 2000, naturalHeight: 1000, src: '' }); +const pctStub = node({ textContent: '' }); +const modeStub = node({ textContent: '' }); +const ids = { img: imgStub, container: node(), pct: pctStub, mode: modeStub }; + +global.document = { getElementById: (i) => ids[i], title: '' }; +global.window = { innerWidth: 1000, innerHeight: 800, addEventListener: listen }; +global.location = { search: '?src=graph.svg', href: '' }; + +new Function(script)(); +imgStub.onload(); + +const fire = (t, e) => (handlers[t] || []).forEach((f) => f(e)); +let ok = true; +const check = (name, cond) => { + console.log(` ${cond ? 'ok ' : 'FAIL'} ${name}`); + if (!cond) ok = false; +}; +const click = (x, y) => { + fire('mousedown', { button: 0, clientX: x, clientY: y, preventDefault() {} }); + fire('mouseup', { button: 0, clientX: x, clientY: y }); +}; + +check('fits on load, below 100%', modeStub.textContent === 'fit' && parseInt(pctStub.textContent) < 100); +const fitPct = pctStub.textContent; + +click(500, 400); +check('a click toggles to 1:1', modeStub.textContent === '1:1' && pctStub.textContent === '100%'); + +click(500, 400); +check('clicking again returns to fit', modeStub.textContent === 'fit' && pctStub.textContent === fitPct); + +fire('mousedown', { button: 0, clientX: 100, clientY: 100, preventDefault() {} }); +fire('mousemove', { clientX: 260, clientY: 180 }); +fire('mouseup', { button: 0, clientX: 260, clientY: 180 }); +check('a drag pans and does NOT toggle', modeStub.textContent === 'fit'); + +fire('wheel', { deltaY: -1, clientX: 500, clientY: 400, preventDefault() {} }); +check('the wheel zooms in past fit', parseInt(pctStub.textContent) > parseInt(fitPct)); + +fire('dblclick', {}); +check('double-click resets to fit', modeStub.textContent === 'fit'); + +process.exit(ok ? 0 : 1);