This commit is contained in:
mark committed 2026-09-03 17:24:22 -04:00
1 parent 844f86cda0
commit 77bf8d9f2e
109 files changed
+934 -3325

No files matched your search

+24 -332
View File
@@ -1,10 +1,17 @@
#!/usr/bin/env python3
"""Render bible.json into one semantic HTML page per book.
"""Render bible.json into one semantic HTML fragment per book.
Reads the JSON produced by extract_bible.py and writes books/GEN.html and so
on, plus an index. The markup carries structure and no presentation: every
USFM distinction survives as a class or data attribute, so the whole corpus
can be restyled from bible.css without regenerating anything.
on, plus an index. These are fragments, not documents: a <title> and one root
element carrying data-template. The surrounding shell is assembled at request
time by the service worker from the nunjucks templates in assets/, so chrome
changes never require running this. The markup carries structure and no
presentation: every USFM distinction survives as a class or data attribute, so
the whole corpus can be restyled from the templates without regenerating it.
This is the only build step left, and books/ is all it owns: everything
hand-written lives in assets/ and is served straight from there. Run it when
bible.json changes, which is to say almost never.
Grouping worth knowing about: consecutive poetry lines are wrapped in a
<div class="poetry"> that a blank-line block closes, so stanzas are real
@@ -14,14 +21,10 @@ elements rather than spacing; consecutive list items become one <ul>.
from __future__ import annotations
import argparse
import hashlib
import html
import json
import os
import shutil
import struct
import sys
import zlib
# Inline node type -> (tag, class). ``cite`` and ``dfn`` carry real meaning
# here: a book name cited in a note, and a glossary term at its definition.
@@ -48,47 +51,6 @@ BLOCK_TAGS = {
}
APP_NAME = "World English Bible"
APP_SHORT = "WEB Bible"
THEME = "#1a202c"
# iOS only honours the apple-prefixed meta tags; the unprefixed ones are the
# standard. Both are emitted so the app installs correctly on either.
HEAD = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>%(title)s</title>
<link rel="stylesheet" href="bible.css">
<script src="app.js" defer></script>
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="%(theme)s">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="%(short)s">
</head>
<body>
"""
# Registered on load so it never competes with first paint.
SW_SCRIPT = """<script>
if ('serviceWorker' in navigator) {
addEventListener('load', function () {
navigator.serviceWorker.register('sw.js').catch(function (e) {
console.warn('service worker registration failed:', e);
});
});
}
</script>
"""
def head_html(title):
return HEAD % {"title": esc(title), "theme": THEME, "short": esc(APP_SHORT)}
def esc(text):
return html.escape(text, quote=False)
@@ -214,9 +176,12 @@ def render_book(book, translation, out_dir):
code, name = book["code"], book["name"]
out = []
title = "%s — %s" % (name, translation["name"])
out.append(head_html(title))
out.append('<article class="book" id="%s" data-book="%s" data-name="%s" '
'data-section="%s">\n' % (code, code, esc(name), book["section"]))
# A fragment: the title the shell will use, then one root element whose
# data-template names the template that wraps it.
out.append("<title>%s</title>\n" % esc(title))
out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
'data-name="%s" data-section="%s">\n'
% (code, code, esc(name), book["section"]))
# Header: the full multi-line title if the source had one.
out.append(' <header class="book-header">\n')
@@ -227,7 +192,8 @@ def render_book(book, translation, out_dir):
else:
out.append(esc(name))
out.append("</h1>\n")
out.append(' <nav class="chapter-nav" aria-label="Chapters">\n <ul>\n')
out.append(' <nav class="chapter-nav" aria-label="Chapters" '
'hx-boost="false">\n <ul>\n')
for chapter in book["chapters"]:
out.append(' <li><a href="#ch%d">%d</a></li>\n'
% (chapter["number"], chapter["number"]))
@@ -242,7 +208,7 @@ def render_book(book, translation, out_dir):
out.append(' <footer class="colophon"><p>%s. %s.</p></footer>\n'
% (esc(translation["name"]), esc(translation["license"])))
out.append("</article>\n" + SW_SCRIPT + "</body>\n</html>\n")
out.append("</article>\n")
path = os.path.join(out_dir, "%s.html" % code)
with open(path, "w", encoding="utf-8") as fh:
@@ -259,9 +225,9 @@ SECTION_LABELS = [
def render_index(books, translation, out_dir):
out = [head_html(translation["name"]),
'<main class="index">\n <h1>%s</h1>\n' % esc(translation["name"]),
' <p class="bookmark" id="bookmark" hidden></p>\n']
out = ["<title>%s</title>\n" % esc(translation["name"]),
'<div class="index" data-template="index">\n <h1>%s</h1>\n'
% esc(translation["name"])]
for key, label in SECTION_LABELS:
group = [b for b in books if b["section"] == key]
if not group:
@@ -275,271 +241,13 @@ def render_index(books, translation, out_dir):
out.append(" </ul>\n </section>\n")
out.append(' <footer class="colophon"><p>%s. %s.</p></footer>\n'
% (esc(translation["name"]), esc(translation["license"])))
out.append("</main>\n" + SW_SCRIPT + "</body>\n</html>\n")
out.append("</div>\n")
path = os.path.join(out_dir, "index.html")
with open(path, "w", encoding="utf-8") as fh:
fh.write("".join(out))
return path
# Emitted classes, listed so the stylesheet has a checklist to work from.
CSS_STUB = """/* Stylesheet for the rendered books. Everything below is a class the
renderer emits; none of it is styled yet.
Structure book, book-header, chapter-nav, home, chapters, chapter,
chapter-number, poetry, list, footnotes, colophon, index,
book-group, count
Paragraphs p m nb pi mi pc ip (.line marks a poetry line;
Poetry q q2 q3 qs data-indent="1|2|3" gives depth)
Headings s ms is (rendered as <h3>)
Other d (psalm/acrostic title), sp (speaker), li ili (list items),
mt mt2 mt3 (book title parts)
Inline v (verse number), wj (words of Jesus), k (<dfn>), bk (<cite>),
ref, noteref
Notes note, marker, backref, ft, fq, fqa, fl, fine, xt
(li.note carries data-kind="footnote|xref")
Bookmark bookmark, bookmark-label, bookmark-link (index page),
verse-dialog, verse-dialog-ref, verse-dialog-note,
verse-dialog-actions, verse-dialog-status, verse-dialog-url
*/
/* Affordance, not design: verse numbers open the bookmark dialog, so they
need to look clickable. Everything else is left to you. */
.v { cursor: pointer; }
"""
# --------------------------------------------------------------------------
# Progressive web app assets
# --------------------------------------------------------------------------
# The site has to install to an iPhone home screen and open with no network,
# so everything it needs is precached by a service worker at install time.
def write_png(path, size, rows):
"""Minimal RGB PNG writer, so the toolchain stays dependency-free."""
raw = b"".join(b"\x00" + bytes(row) for row in rows)
def chunk(kind, data):
return (struct.pack(">I", len(data)) + kind + data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xffffffff))
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 9))
png += chunk(b"IEND", b"")
with open(path, "wb") as fh:
fh.write(png)
def _in_rrect(x, y, x0, y0, x1, y1, r):
"""Point-in-rounded-rectangle: clamp to the inner rect, measure back."""
cx = min(max(x, x0 + r), x1 - r)
cy = min(max(y, y0 + r), y1 - r)
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r
BG = (26, 32, 44) # slate, matches THEME
PAGE = (247, 250, 252)
SPINE = (99, 179, 237)
INK = (160, 174, 192)
def icon_rows(size):
"""An open page with a coloured spine. Content sits inside the maskable
safe zone, so one drawing serves both `any` and `maskable` purposes."""
s = float(size)
px0, py0, px1, py1 = 0.24 * s, 0.20 * s, 0.76 * s, 0.80 * s
radius = 0.045 * s
spine_x1 = px0 + 0.075 * s
lines = [(0.30, 0.375), (0.435, 0.51), (0.57, 0.645), (0.705, 0.74)]
ss = 2 # 2x supersampling to soften the curves
rows = []
for py in range(size):
row = bytearray()
for pxi in range(size):
r = g = b = 0
for oy in range(ss):
for ox in range(ss):
x = pxi + (ox + 0.5) / ss
y = py + (oy + 0.5) / ss
col = BG
if _in_rrect(x, y, px0, py0, px1, py1, radius):
col = PAGE
if x <= spine_x1:
col = SPINE
else:
for top, bot in lines:
if top * s <= y <= bot * s and x <= 0.68 * s:
col = INK
break
r += col[0]; g += col[1]; b += col[2]
n = ss * ss
row += bytes((r // n, g // n, b // n))
rows.append(row)
return rows
def copy_assets(out_dir, src="assets"):
"""Hand-written static files (app.js) live in assets/ and are copied in,
so they stay editable as source rather than as Python strings."""
if not os.path.isdir(src):
return
for name in sorted(os.listdir(src)):
path = os.path.join(src, name)
if os.path.isfile(path):
shutil.copyfile(path, os.path.join(out_dir, name))
def write_icons(out_dir):
icon_dir = os.path.join(out_dir, "icons")
os.makedirs(icon_dir, exist_ok=True)
for name, size in (("icon-192.png", 192), ("icon-512.png", 512),
("apple-touch-icon.png", 180)):
write_png(os.path.join(icon_dir, name), size, icon_rows(size))
def write_manifest(out_dir, translation):
manifest = {
"name": APP_NAME,
"short_name": APP_SHORT,
"description": "%s. %s." % (translation["name"], translation["license"]),
"start_url": "./index.html",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": THEME,
"theme_color": THEME,
"icons": [
{"src": "icons/icon-192.png", "sizes": "192x192",
"type": "image/png", "purpose": "any maskable"},
{"src": "icons/icon-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "any maskable"},
],
}
path = os.path.join(out_dir, "manifest.webmanifest")
with open(path, "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2)
SW_TEMPLATE = """// Generated by render_books.py -- do not edit by hand.
//
// STRATEGY decides what a same-origin GET does:
//
// network-first ask the network, fall back to the cache when it fails.
// Offline, fetch rejects immediately, so the fallback is
// effectively instant -- the only cost is one round trip
// while online, and you never see a stale page.
// cache-first answer from the cache the moment there is a hit. Fastest
// possible load, but a rebuild needs two reloads to appear.
//
// Either way every response that arrives is written back to the cache, so the
// offline copy stays current, and a new build changes VERSION, dropping the
// caches belonging to older builds.
const STRATEGY = '%(strategy)s';
const NETWORK_TIMEOUT_MS = 3000;
const VERSION = '%(version)s';
const CACHE = 'bible-' + VERSION;
const ASSETS = %(assets)s;
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
// Added one at a time rather than addAll(): addAll is atomic, so a single
// failed asset would leave the app with no offline copy at all.
let failed = 0;
await Promise.all(ASSETS.map((url) =>
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
if (failed) console.warn('precache: ' + failed + ' of ' + ASSETS.length + ' failed');
await self.skipWaiting();
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter((k) => k.startsWith('bible-') && k !== CACHE)
.map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
if (new URL(req.url).origin !== location.origin) return;
event.respondWith(respond(event));
});
// Aborted rather than raced, so a hung request is actually torn down instead
// of leaking while the cache answers.
async function fromNetwork(req) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
try {
return await fetch(req, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function cached(req) {
return caches.match(req, { ignoreSearch: true });
}
async function respond(event) {
const req = event.request;
if (STRATEGY === 'cache-first') {
const hit = await cached(req);
if (hit) return hit;
}
try {
const res = await fromNetwork(req);
if (res && res.ok && res.type === 'basic') {
const copy = res.clone();
// Not awaited: the response should not wait on the cache write.
event.waitUntil(caches.open(CACHE).then((c) => c.put(req, copy)));
}
return res;
} catch (err) {
const hit = await cached(req);
if (hit) return hit;
// Offline and never cached. Navigations get the index rather than the
// browser's error page; anything else genuinely fails.
if (req.mode === 'navigate') {
const index = await cached('./index.html');
if (index) return index;
}
throw err;
}
}
"""
def write_service_worker(out_dir, strategy="network-first"):
"""Precache every file actually produced, versioned by their contents."""
digest = hashlib.sha256()
assets = []
for root, _dirs, names in os.walk(out_dir):
for name in sorted(names):
path = os.path.join(root, name)
rel = os.path.relpath(path, out_dir).replace(os.sep, "/")
if rel == "sw.js":
continue # the browser revalidates the worker itself
assets.append("./" + rel)
with open(path, "rb") as fh:
digest.update(rel.encode() + fh.read())
assets.sort()
assets.insert(0, "./") # a bare-root launch is its own cache entry
body = SW_TEMPLATE % {
"version": digest.hexdigest()[:12],
"assets": json.dumps(assets, indent=2),
"strategy": strategy,
}
with open(os.path.join(out_dir, "sw.js"), "w", encoding="utf-8") as fh:
fh.write(body)
return assets
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
@@ -548,10 +256,6 @@ def main(argv=None):
help="JSON from extract_bible.py (default: bible.json)")
ap.add_argument("-o", "--out", default="books",
help="output directory (default: books)")
ap.add_argument("--cache-first", action="store_true",
help="service worker answers from cache before trying the "
"network: fastest loads, but a rebuild takes two "
"reloads to show up (default: network-first)")
args = ap.parse_args(argv)
with open(args.input, encoding="utf-8") as fh:
@@ -564,19 +268,7 @@ def main(argv=None):
total += os.path.getsize(path)
render_index(data["books"], data["translation"], args.out)
css = os.path.join(args.out, "bible.css")
if not os.path.exists(css):
with open(css, "w", encoding="utf-8") as fh:
fh.write(CSS_STUB)
copy_assets(args.out)
write_icons(args.out)
write_manifest(args.out, data["translation"])
strategy = "cache-first" if args.cache_first else "network-first"
assets = write_service_worker(args.out, strategy)
print("books %d" % len(data["books"]), file=sys.stderr)
print("precache %d assets (%s)" % (len(assets), strategy), file=sys.stderr)
print("html %.1f MB total, %.0f KB average"
% (total / 1e6, total / len(data["books"]) / 1e3), file=sys.stderr)
print("wrote %s/" % args.out, file=sys.stderr)