site emitter
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
73
soleprint/atlas2/docgen/emitters/cli_site.py
Normal file
73
soleprint/atlas2/docgen/emitters/cli_site.py
Normal 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
|
||||||
407
soleprint/atlas2/docgen/emitters/site.py
Normal file
407
soleprint/atlas2/docgen/emitters/site.py
Normal 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">← 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; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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()
|
||||||
|
|||||||
59
soleprint/atlas2/docgen/viewer_test.js
Normal file
59
soleprint/atlas2/docgen/viewer_test.js
Normal 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);
|
||||||
Reference in New Issue
Block a user