270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""
|
||
The minisite: a document's index of books, and every book as pages you click through.
|
||
|
||
minisite/
|
||
├── index.html every book, with what came in and what came out
|
||
├── minisite.css viewer.html
|
||
└── <book>/
|
||
├── overview.html the book's top page
|
||
├── <page>.html one subsystem each; its boxes open the next level
|
||
├── <page>.svg the same drawing, for the full-screen viewer
|
||
└── reference.html every module with its docstring — where a leaf opens
|
||
|
||
## Inline, so the boxes are links
|
||
|
||
The single-page reference (`onepage.py`) put its diagram in an `<img>`, and an image swallows every
|
||
click. Here the SVG is inlined, so a box that stands for a subsystem opens that
|
||
subsystem's page, a box for a module opens its section of the reference page,
|
||
and a context box — something outside this subsystem — opens its own branch.
|
||
Breadcrumbs lead back up.
|
||
|
||
## Adjustable
|
||
|
||
A page is drawn to fit the content column, so it reads without zooming. The size
|
||
is still the reader's: `fit` scales to the column, `1:1` shows the drawing at its
|
||
natural size with scrollbars, and the choice is remembered. `full screen` opens
|
||
the pan-and-zoom viewer for anything still too dense.
|
||
|
||
This writes HTML and nothing else. What goes on each page, the links and the
|
||
notes are prepared by `book/document.py` and handed in as data — an emitter has
|
||
never heard of a book's extractor, and this one has not either.
|
||
"""
|
||
|
||
from html import escape
|
||
from pathlib import Path
|
||
|
||
from ..markdown import to_html
|
||
from ..paged import inline
|
||
from .onepage import CONTENT_W, CSS, SIDEBAR_W, VIEWER, _fill, _ledger, _slots
|
||
from .onepage import emit as reference_page
|
||
|
||
CONTENT_WIDTH = CONTENT_W
|
||
|
||
SIZE_SCRIPT = """<script>
|
||
// fit | 1:1 — a choice that sticks, because a reader who wants 1:1 on one page
|
||
// wants it on the next. No dependency, no build step.
|
||
(function () {
|
||
var key = 'docgen.size';
|
||
function set(mode) {
|
||
document.querySelectorAll('.fig').forEach(function (f) {
|
||
f.classList.toggle('actual', mode === 'actual');
|
||
});
|
||
document.querySelectorAll('.figbar button[data-size]').forEach(function (b) {
|
||
b.classList.toggle('on', b.dataset.size === mode);
|
||
});
|
||
try { localStorage.setItem(key, mode); } catch (e) {}
|
||
}
|
||
document.querySelectorAll('.figbar button[data-size]').forEach(function (b) {
|
||
b.addEventListener('click', function () { set(b.dataset.size); });
|
||
});
|
||
var saved = 'fit';
|
||
try { saved = localStorage.getItem(key) || 'fit'; } catch (e) {}
|
||
set(saved);
|
||
})();
|
||
</script>"""
|
||
|
||
|
||
def _note(note, cls="note") -> str:
|
||
if not note:
|
||
return ""
|
||
text, uris = note
|
||
return f'<div class="{cls}">' + to_html(
|
||
text, image=lambda alt, src: f'<img src="{escape(uris.get(src, src))}" alt="{escape(alt)}">'
|
||
) + "</div>"
|
||
|
||
|
||
def _missing(failed: dict | None) -> str:
|
||
"""Books that were meant to be here and are not. Above the ones that are,
|
||
because a reader who does not see this has no way to know the minisite is short."""
|
||
if not failed:
|
||
return ""
|
||
items = "".join(f"<li><code>{escape(name)}</code> — {escape(why)}</li>"
|
||
for name, why in sorted(failed.items()))
|
||
return ('<div class="gap"><b>This document is incomplete.</b> '
|
||
f"{len(failed)} book(s) could not be built, so they are not below.<ul>{items}</ul></div>")
|
||
|
||
|
||
def _tree(pages: list[dict], current: str | None) -> str:
|
||
by_parent: dict = {}
|
||
for p in pages:
|
||
by_parent.setdefault(p["parent"], []).append(p)
|
||
root = pages[0]["id"] if pages else None
|
||
|
||
def walk(page, depth):
|
||
active = ' class="active"' if page["slug"] == current else ""
|
||
link = f'<a href="{escape(page["slug"])}.html"{active}>{escape(page["title"])}</a>'
|
||
kids = by_parent.get(page["id"], [])
|
||
if not kids:
|
||
return f"<li>{link}</li>"
|
||
return f"<li>{link}<ul>" + "".join(walk(k, depth + 1) for k in kids) + "</ul></li>"
|
||
|
||
return "<ul>" + "".join(walk(p, 0) for p in pages if p["id"] == root) + "</ul>"
|
||
|
||
|
||
def _page(title: str, book: dict, page: dict, svg: str | None, pages: list[dict],
|
||
note, doc_title: str) -> str:
|
||
b = book["book"]
|
||
by_id = {p["id"]: p for p in pages}
|
||
trail, cur = [], page
|
||
while cur is not None:
|
||
trail.append(cur)
|
||
cur = by_id.get(cur["parent"]) if cur.get("parent") else None
|
||
crumbs = [f'<a href="../index.html">{escape(doc_title)}</a>']
|
||
for p in reversed(trail):
|
||
if p is page:
|
||
crumbs.append(escape(p["title"]))
|
||
else:
|
||
crumbs.append(f'<a href="{escape(p["slug"])}.html">{escape(p["title"])}</a>')
|
||
|
||
from ...book.document import caption # noqa: F401 - kept out of module import time
|
||
cap = caption(page)
|
||
warn = " warn" if page.get("too_wide") else ""
|
||
if svg is not None:
|
||
figure = (
|
||
'<div class="figbar"><button data-size="fit">fit</button>'
|
||
'<button data-size="actual">1:1</button>'
|
||
f'<a href="../viewer.html?src={escape(b.slug)}/{escape(page["slug"])}.svg">full screen</a>'
|
||
"</div>"
|
||
f'<div class="fig">{inline(svg)}</div>'
|
||
)
|
||
else:
|
||
figure = '<p class="caption warn">Not drawn — Graphviz is not installed here.</p>'
|
||
|
||
children = ""
|
||
if page.get("children"):
|
||
items = "".join(
|
||
f'<li><a href="{escape(by_id[c]["slug"])}.html">{escape(by_id[c]["title"])}</a></li>'
|
||
for c in page["children"] if c in by_id
|
||
)
|
||
children = f"<h3>Opens into</h3><ul class=\"children\">{items}</ul>"
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{escape(page["title"])} — {escape(b.slug)} — {escape(doc_title)}</title>
|
||
<link rel="stylesheet" href="../minisite.css">
|
||
</head>
|
||
<body>
|
||
<div class="layout">
|
||
<nav class="sidebar">
|
||
<div class="sidebar-header"><b>{escape(b.slug)}</b><small>{escape(doc_title)}</small></div>
|
||
{_tree(pages, page["slug"])}
|
||
<ul>
|
||
<li><a href="reference.html">reference</a></li>
|
||
<li><a href="explore/explore.html">explore</a></li>
|
||
<li><a href="../index.html">all books</a></li>
|
||
</ul>
|
||
</nav>
|
||
<main class="content">
|
||
<div class="crumbs">{" › ".join(crumbs)}</div>
|
||
<h1>{escape(page["title"])}</h1>
|
||
{_note(note)}
|
||
{figure}
|
||
<p class="caption{warn}">{escape(cap)}</p>
|
||
{children}
|
||
</main>
|
||
</div>
|
||
{SIZE_SCRIPT}
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def write(out_dir, style, *, title: str, books: list[dict], intro=None, end=None,
|
||
failed: dict | None = None) -> list[Path]:
|
||
"""Write the minisite. Returns every file written, for the output manifest.
|
||
|
||
`books` is a list of::
|
||
|
||
{"book": Book, "pages": [(page, svg)], "notes": {slug: (markdown, {ref: data uri})},
|
||
"intro": (markdown, {}) or None}
|
||
"""
|
||
import shutil
|
||
|
||
out_dir = Path(out_dir)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
values = _slots(style)
|
||
written: list[Path] = []
|
||
|
||
def put(path: Path, text: str):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(text)
|
||
written.append(path)
|
||
|
||
put(out_dir / "minisite.css",
|
||
_fill(CSS.replace("__SIDEBAR__", str(SIDEBAR_W)).replace("__CONTENT__", str(CONTENT_W)),
|
||
values))
|
||
put(out_dir / "viewer.html", _fill(VIEWER.replace("__TITLE__", escape(title)), values))
|
||
|
||
cards, nav = [], []
|
||
for entry in books:
|
||
b = entry["book"]
|
||
pages = [p for p, _ in entry["pages"]]
|
||
book_dir = out_dir / b.slug
|
||
for page, svg in entry["pages"]:
|
||
put(book_dir / f"{page['slug']}.html",
|
||
_page(title, entry, page, svg, pages, entry["notes"].get(page["slug"]), title))
|
||
if svg is not None:
|
||
put(book_dir / f"{page['slug']}.svg", svg)
|
||
|
||
# The reference page: the single-page `onepage` as it was, one per book, where
|
||
# a module box on any page opens its own section.
|
||
ref = reference_page(b.view, style, title=f"{b.slug} — reference")["index.html"]
|
||
ref = ref.replace('href="minisite.css"', 'href="../minisite.css"')
|
||
put(book_dir / "reference.html", ref)
|
||
|
||
explore = b.out / "explore"
|
||
if explore.is_dir():
|
||
for f in sorted(explore.rglob("*")):
|
||
if f.is_file():
|
||
target = book_dir / "explore" / f.relative_to(explore)
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copyfile(f, target)
|
||
written.append(target)
|
||
|
||
first = pages[0]["slug"] if pages else "reference"
|
||
too_wide = sum(1 for p in pages if p.get("too_wide"))
|
||
cards.append(
|
||
f'<div class="bookcard"><h2><a href="{escape(b.slug)}/{escape(first)}.html">'
|
||
f"{escape(b.slug)}</a></h2>"
|
||
+ _note(entry.get("intro"))
|
||
+ _ledger(b.to_dict())
|
||
+ '<div class="links">'
|
||
+ f'<a href="{escape(b.slug)}/{escape(first)}.html">{len(pages)} page(s)</a>'
|
||
+ f'<a href="{escape(b.slug)}/reference.html">reference</a>'
|
||
+ (f'<a href="{escape(b.slug)}/explore/explore.html">explore</a>' if explore.is_dir() else "")
|
||
+ (f'<span class="caption warn">{too_wide} too wide</span>' if too_wide else "")
|
||
+ "</div></div>"
|
||
)
|
||
nav.append(f'<li><a href="{escape(b.slug)}/{escape(first)}.html">{escape(b.slug)}</a></li>')
|
||
|
||
put(out_dir / "index.html", f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{escape(title)}</title>
|
||
<link rel="stylesheet" href="minisite.css">
|
||
</head>
|
||
<body>
|
||
<div class="layout">
|
||
<nav class="sidebar">
|
||
<div class="sidebar-header"><b>{escape(title)}</b><small>{len(books)} book(s)</small></div>
|
||
<ul>{"".join(nav)}</ul>
|
||
</nav>
|
||
<main class="content">
|
||
<h1>{escape(title)}</h1>
|
||
<p class="lede">Regenerated, not edited. Each book opens with what came in and what came out.</p>
|
||
{_note((intro, {}) if intro else None)}
|
||
{_missing(failed)}
|
||
<div class="books">{"".join(cards)}</div>
|
||
{_note((end, {}) if end else None)}
|
||
</main>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
""")
|
||
return written
|