494 lines
20 KiB
Python
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">← docs</a>
|
|
<div id="hud"><b id="pct">100%</b><span id="mode">fit</span><span>· click 1:1 · drag · 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
|