Fix
This commit is contained in:
1 parent
844f86cda0
commit
77bf8d9f2e
109 files changed
+934
-3325
No files matched your search
+24
-332
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user