Files
soleprint/soleprint/atlas2/docgen/emitters/minisite/__init__.py

270 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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