Files
soleprint/soleprint/atlas2/docgen/emitters/site.py
2026-09-14 06:13:22 -03:00

494 lines
20 KiB
Python

"""
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:
<a href="viewer.html?src=X.svg"><img src="X.svg" title="Click to expand"></a>
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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: __BG__; overflow: hidden; width: 100vw; height: 100vh;
font-family: __FONT__; }
#container { width: 100vw; height: 100vh; overflow: hidden; cursor: grab; }
#container.dragging { cursor: grabbing; }
img { transform-origin: 0 0; user-select: none; -webkit-user-drag: none; }
#hud {
position: fixed; bottom: 14px; left: 14px; display: flex; gap: 8px;
align-items: center; font-size: 11px; color: __MUTED__;
background: __SURFACE__; border: 1px solid __BORDER__;
border-radius: 6px; padding: 5px 9px; user-select: none;
}
#hud b { color: __TEXT__; font-weight: 600; font-variant-numeric: tabular-nums; }
#hud span { opacity: .7; }
a.back { position: fixed; top: 14px; left: 14px; font-size: 11px;
color: __MUTED__; text-decoration: none; background: __SURFACE__;
border: 1px solid __BORDER__; border-radius: 6px; padding: 5px 9px; }
a.back:hover { color: __TEXT__; }
</style>
</head>
<body>
<div id="container"><img id="img" alt=""></div>
<a class="back" href="index.html">&larr; docs</a>
<div id="hud"><b id="pct">100%</b><span id="mode">fit</span><span>&middot; click 1:1 &middot; drag &middot; wheel</span></div>
<script>
var src = new URLSearchParams(location.search).get('src');
var img = document.getElementById('img');
var container = document.getElementById('container');
var pct = document.getElementById('pct');
var modeEl = document.getElementById('mode');
if (src) { img.src = src; document.title = src + ' — __TITLE__'; }
var scale = 1, x = 0, y = 0, fitScale = 1, mode = 'fit';
var dragging = false, moved = false, startX, startY, startPanX, startPanY;
function apply() {
img.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')';
pct.textContent = Math.round(scale * 100) + '%';
modeEl.textContent = mode;
}
function fit() {
var sw = window.innerWidth / img.naturalWidth;
var sh = window.innerHeight / img.naturalHeight;
fitScale = Math.min(sw, sh) * 0.95;
scale = fitScale;
x = (window.innerWidth - img.naturalWidth * scale) / 2;
y = (window.innerHeight - img.naturalHeight * scale) / 2;
mode = 'fit';
apply();
}
// Zoom about a point in the viewport, so what is under the cursor stays there.
function zoomAt(px, py, factor) {
x = px - (px - x) * factor;
y = py - (py - y) * factor;
scale *= factor;
mode = Math.abs(scale - fitScale) < 0.001 ? 'fit'
: (Math.abs(scale - 1) < 0.001 ? '1:1' : 'free');
apply();
}
img.onload = fit;
window.addEventListener('resize', function () { if (mode === 'fit') fit(); });
container.addEventListener('wheel', function (e) {
e.preventDefault();
var rect = container.getBoundingClientRect();
zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.12 : 0.89);
}, { passive: false });
container.addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
dragging = true; moved = false;
startX = e.clientX; startY = e.clientY; startPanX = x; startPanY = y;
container.classList.add('dragging');
e.preventDefault();
});
window.addEventListener('mousemove', function (e) {
if (!dragging) return;
if (Math.abs(e.clientX - startX) > 3 || Math.abs(e.clientY - startY) > 3) moved = true;
x = startPanX + (e.clientX - startX);
y = startPanY + (e.clientY - startY);
apply();
});
window.addEventListener('mouseup', function (e) {
if (!dragging) return;
dragging = false;
container.classList.remove('dragging');
// A click that moved the mouse was a drag, and must not also toggle.
if (moved) return;
if (mode === '1:1') { fit(); return; }
// Toggle to actual size about the point clicked, so the thing you aimed at
// is the thing you end up looking at.
var rect = container.getBoundingClientRect();
zoomAt(e.clientX - rect.left, e.clientY - rect.top, 1 / scale);
mode = '1:1';
apply();
});
container.addEventListener('dblclick', fit);
window.addEventListener('keydown', function (e) {
if (e.key === '0' || e.key === 'f') fit();
if (e.key === '1') { var r = container.getBoundingClientRect();
zoomAt(r.width / 2, r.height / 2, 1 / scale); mode = '1:1'; apply(); }
if (e.key === 'Escape') location.href = 'index.html';
});
</script>
</body>
</html>
"""
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 <summary> 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; }
/* Both ends of the book, side by side and above the diagram. Above, because a
reader who scrolls past the picture has already formed an impression, and
"2 files could not be read" has to arrive before that and not after. */
.ledger { display: flex; gap: 1px; background: var(--border); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 0 0 1.5rem; }
.ledger > div { flex: 1 1 0; background: var(--surface); padding: .7rem .9rem; min-width: 0; }
.ledger dt { color: var(--dim); font-size: 9.5px; text-transform: uppercase;
letter-spacing: .07em; margin-bottom: .3rem; }
.ledger dd { margin: 0; color: var(--text); font-size: 12.5px; }
.ledger dd .sub { display: block; color: var(--muted); font-size: 11px; margin-top: .2rem;
overflow-wrap: anywhere; }
.ledger .lost { color: var(--artery); }
.ledger .kept { color: var(--ok); }
.gap { border: 1px solid var(--artery); border-left-width: 3px; border-radius: 6px;
background: var(--surface); padding: .7rem .9rem; margin: 0 0 1.5rem; font-size: 12px; }
.gap b { color: var(--artery); }
.gap ul { margin: .4rem 0 0 1.1rem; color: var(--muted); }
.gap code { font-size: 11px; }
"""
def _ledger(book: dict) -> str:
"""The two measures, and the gap between them if there is one.
This is the whole reason the site is the book's last step rather than just
another emitter: it is the one artifact somebody definitely opens, so it is
where "45 of 47 files" has to appear. A diagram cannot say it, and a log
nobody reads does not count as having said it.
"""
larder = book.get("larder") or {}
measure = book.get("book") or {}
failed = larder.get("failed") or []
kinds = measure.get("by_kind") or {}
out_summary = " · ".join(f"{v} {k}" for k, v in
sorted(kinds.items(), key=lambda kv: -kv[1])[:4])
unit = larder.get("unit", "unit")
read, seen = larder.get("read", 0), larder.get("seen", 0)
plural = unit if read == 1 else (unit[:-1] + "ies" if unit.endswith("y") else unit + "s")
in_line = f"{read} {plural} read"
if failed:
in_line += f' <span class="lost"{len(failed)} of {seen} could not be</span>'
panel = (
'<div class="ledger">'
f'<div><dt>what came in</dt><dd>{in_line}'
f'<span class="sub">{escape(larder.get("identity", "?"))}</span></dd></div>'
f'<div><dt>what came out</dt><dd>{escape(out_summary) or "nothing"}'
f'<span class="sub">{measure.get("edges", 0)} edges · '
f'{measure.get("external", 0)} external · '
# "step artifacts", not "artifacts": this page is written before the
# book's last step closes, so it cannot count itself. Saying `step`
# makes the number true rather than one short of book.json's.
f'{len(measure.get("artifacts") or [])} step artifacts</span></dd></div>'
"</div>"
)
# A reconciliation that failed is not a footnote. It means the document
# below is incomplete in a way the document below cannot show.
lost = [r for r in (book.get("reconciled") or []) if not r.get("ok")]
if lost or failed:
items = "".join(f"<li>{escape(r['claim'])}{escape(r['why'])}</li>" for r in lost)
items += "".join(
f"<li><code>{escape(f['name'])}</code> — {escape(f['error'])}</li>"
for f in failed[:12]
)
if len(failed) > 12:
items += f"<li>and {len(failed) - 12} more</li>"
panel += (
'<div class="gap"><b>This book is incomplete.</b> '
"What is drawn below is everything that could be read, which is not "
f"everything there is.<ul>{items}</ul></div>"
)
return panel
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 = ["<ul>"]
for item in items:
label = escape(item.get("label", item["id"]))
kind = escape(item.get("kind", ""))
anchor = escape(item["id"])
link = f'<a href="#{anchor}" data-id="{anchor}">{label}<span class="k">{kind}</span></a>'
kids = item.get("children") or []
if kids:
out.append(
f"<li><details{' open' if depth == 0 else ''}>"
f"<summary>{link}</summary>{_sidebar(kids, depth + 1)}</details></li>"
)
else:
out.append(f"<li>{link}</li>")
out.append("</ul>")
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'<span class="k"> {escape(item.get("kind", ""))}</span></{tag}>')
if item.get("doc"):
out.append(f"<p>{escape(item['doc'])}</p>")
if item.get("href"):
out.append(f'<p><code>{escape(item["href"])}</code></p>')
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 = "",
book: dict | None = None) -> dict:
"""IR + Style -> {filename: text}. Write them next to each other.
`book` is a book ledger (`book/__init__.py`). When present the page opens
with both measures — what went in, what came out — because a page that
shows only the result is the thing the measure exists to correct.
Optional, so the site emitter still works on a bare IR.
"""
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]))
ledger = _ledger(book) if book else ""
figure = ""
if graph:
figure = (
'<figure class="figure">'
f'<a href="viewer.html?src={escape(graph)}" title="Click to open — then click again for 1:1">'
f'<img src="{escape(graph)}" alt="{escape(name)}"></a>'
"<figcaption>Click to open the viewer · click again for actual size</figcaption>"
"</figure>"
)
external = side.get("external") or []
ext_html = ""
if external:
rows = "".join(f"<tr><td><code>{escape(e)}</code></td></tr>" for e in external[:40])
ext_html = (
'<h2 id="__external">Depends on, outside this tree</h2>'
"<p>Names that could not be resolved here — the dependency surface.</p>"
f"<table>{rows}</table>"
)
index = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(name)}</title>
<link rel="stylesheet" href="site.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-header"><b>{escape(name)}</b><small>{escape(summary)}</small></div>
{_sidebar(side["items"])}
</nav>
<main class="content">
<h1>{escape(name)}</h1>
<p class="lede">Generated from <code>{escape(meta.get("source", "?"))}</code> ·
{escape(summary)}. Regenerated, not edited.</p>
{ledger}
{figure}
{_sections(side["items"])}
{ext_html}
</main>
</div>
<script>
// Highlight the section being read. No dependency, no build step.
var links = [].slice.call(document.querySelectorAll('.sidebar a[data-id]'));
var byId = {{}};
links.forEach(function (a) {{ byId[a.dataset.id] = a; }});
var obs = new IntersectionObserver(function (entries) {{
entries.forEach(function (en) {{
var a = byId[en.target.id];
if (!a) return;
if (en.isIntersecting) {{
links.forEach(function (l) {{ l.classList.remove('active'); }});
a.classList.add('active');
}}
}});
}}, {{ rootMargin: '-10% 0px -80% 0px' }});
document.querySelectorAll('h2[id], h3[id]').forEach(function (h) {{ obs.observe(h); }});
</script>
</body>
</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 = "",
book: dict | None = None) -> 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, book=book).items():
path = out_dir / name
path.write_text(text)
written.append(path)
return written