site emitter

This commit is contained in:
2026-09-12 07:08:00 -03:00
parent 7cb892ccfe
commit 358b98f826
6 changed files with 619 additions and 4 deletions

View File

@@ -31,7 +31,7 @@ DEPTH ?= 2
THEME_ARG := $(if $(THEME),--theme $(THEME)) 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 help: ## List every target
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it" @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) \ @$(RUN) $(PKG).emitters auto $(OUT)/view.json -o $(OUT) \
--style $(STYLE) $(THEME_ARG) --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 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)/index.md
@$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/sidebar.json @$(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 ir SRC=$(PARENT)/.. OUT=$(OUT)
@$(MAKE) --no-print-directory index OUT=$(OUT) @$(MAKE) --no-print-directory index OUT=$(OUT)
@$(MAKE) --no-print-directory graph OUT=$(OUT) @$(MAKE) --no-print-directory graph OUT=$(OUT)
@$(MAKE) --no-print-directory site OUT=$(OUT)
@echo @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 doctor: ## Report whether this machine can run it
@printf 'python : '; $(PY) --version 2>&1 || echo MISSING @printf 'python : '; $(PY) --version 2>&1 || echo MISSING

View File

@@ -6,7 +6,7 @@ import sys
def main(argv=None): def main(argv=None):
argv = sys.argv[1:] if argv is None else argv argv = sys.argv[1:] if argv is None else argv
if not argv: if not argv:
print("usage: python3 -m docgen.emitters <auto|dot|index|erd|notebook> <ir.json> [-o OUT] [--style NAME] [--theme NAME]", print("usage: python3 -m docgen.emitters <auto|dot|index|erd|notebook|site> <ir.json> [-o OUT] [--style NAME] [--theme NAME]",
file=sys.stderr) file=sys.stderr)
return 2 return 2
name, rest = argv[0], argv[1:] name, rest = argv[0], argv[1:]
@@ -20,8 +20,10 @@ def main(argv=None):
from .auto import main as run from .auto import main as run
elif name == "notebook": elif name == "notebook":
from .cli_notebook import main as run from .cli_notebook import main as run
elif name == "site":
from .cli_site import main as run
else: 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 1
return run(rest) return run(rest)

View File

@@ -0,0 +1,73 @@
""" python3 -m docgen.emitters site <ir.json> -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

View File

@@ -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:
<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; }
"""
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 = "") -> 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 = (
'<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>
{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 = "") -> 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

View File

@@ -46,6 +46,7 @@ index_mod = __import__(f"{PKG}.emitters.index", fromlist=["*"])
ops_mod = __import__(f"{PKG}.ops", fromlist=["*"]) ops_mod = __import__(f"{PKG}.ops", fromlist=["*"])
erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"]) erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"])
nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"]) nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"])
site_mod = __import__(f"{PKG}.emitters.site", fromlist=["*"])
spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"]) spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"])
db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"]) db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"])
usage_ex = __import__(f"{PKG}.extractors.usage", 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)) 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 "<img src=" in index_html,
"spr/docs/docs.js:229 and sms/docs/index.html:311 arrived at this independently",
)
check("the sidebar nests", "<details" in index_html and "<summary>" in index_html)
check(
"every sidebar link is styled",
".sidebar a {" in css,
"selecting `li > a` misses links inside a <summary>, 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() tmp.cleanup()
print() print()

View File

@@ -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('<script>')[1].split('</script>')[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);