Move verse selector to htmx

This commit is contained in:
mark committed 2026-09-07 08:05:45 -04:00
1 parent dd0021ce15
commit ba3b588ac5
9 files changed
+390 -131

No files matched your search

+16 -97
View File
@@ -2,15 +2,21 @@
//
// Pages arrive whole -- composed by the service worker, or by boot.html on the
// visit before there is one -- so there is nothing here that builds a page.
// What is left is the three things a composed document cannot do for itself:
// mark up the verse numbers as controls, run the modal, and tell the bookmark
// slot when a worker has appeared to answer it.
// What is left is the four things a composed document cannot do for itself:
// mark up the verse numbers as controls, run the modal, put a swapped-in page
// where it belongs before it is painted, and tell the chrome when a worker has
// appeared to answer it.
//
// The reference picker used to be here as well, built by reading the chapters
// and verses off the book on the page. It is markup and htmx now: sw.js
// renders it from bible/verses.json and answers api/reference, and the scroll
// and the address-bar update that a change event used to run through here are
// response headers on the answer. See templates/reference.njk.
//
// The modal's markup is in templates/base.njk and the bookmark is in the
// worker, so
// all this file does for it is decide which verse was clicked, fill the form,
// and show it -- Bootstrap handles opening, dismissing and focus, and htmx
// carries the save.
// worker, so all this file does for it is decide which verse was clicked, fill
// the form, and show it -- Bootstrap handles opening, dismissing and focus,
// and htmx carries the save.
(function () {
'use strict';
@@ -128,102 +134,16 @@
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
if (target.id === 'bookmark') return setTimeout(hideModal, 900);
if (target.id === 'content') { fillReference(); land(); }
});
// -- reference picker ---------------------------------------------------
// The "GEN 1:1" in the navbar. Built from the book on the page rather than
// from a table shipped beside it: the chapters are the sections and a
// chapter's verses are the anchors inside it, so an option can never name a
// target that is not there to scroll to, and there is no second file to keep
// in step, cache, or version.
//
// It is not kept in step with the scroll position, on purpose. It says where
// you are going, not where you are, and it starts at 1:1 in every book.
function byId(id) {
return document.getElementById(id);
}
function fillOptions(select, items) {
select.textContent = '';
for (var i = 0; i < items.length; i++) {
var option = document.createElement('option');
// The value is the id of the element to land on, so nothing downstream
// has to know how a verse anchor is spelled.
option.value = items[i].value;
option.textContent = items[i].label;
select.appendChild(option);
}
}
function fillVerses(chapterId) {
var section = byId(chapterId);
var verses = section ? section.querySelectorAll('.v') : [];
var items = [];
for (var i = 0; i < verses.length; i++) {
items.push({ value: verses[i].id,
label: (verses[i].textContent || '').trim() });
}
fillOptions(byId('reference-verse'), items);
}
function fillReference() {
var picker = byId('reference');
if (!picker) return;
var book = document.querySelector('#content article.book');
picker.classList.toggle('d-none', !book);
picker.classList.toggle('d-flex', !!book);
if (!book) return;
byId('reference-book').textContent = book.getAttribute('data-book') || '';
var chapters = book.querySelectorAll('.chapter[data-chapter]');
var items = [];
for (var i = 0; i < chapters.length; i++) {
items.push({ value: chapters[i].id,
label: chapters[i].getAttribute('data-chapter') });
}
fillOptions(byId('reference-chapter'), items);
if (items.length) fillVerses(items[0].value);
}
// The URL is the whole mechanism: name the anchor, then land on it. Nothing
// is stored anywhere else, so a reload or a shared link ends up in the same
// place.
//
// replaceState rather than assigning location.hash: spinning through a
// select should not stack one history entry per option, and assigning the
// hash hands the scroll to the browser, which Bootstrap has set to smooth.
function goTo(id) {
var target = byId(id);
if (!target) return;
history.replaceState(null, '', '#' + id);
jumpTo(target);
}
// Delegated at the document, so it survives every swap and binds once.
// Changing the chapter rebuilds the verses and lands on the chapter heading,
// which is what 1 is directly below; changing the verse lands on the verse.
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.id) return;
if (target.id === 'reference-chapter') {
fillVerses(target.value);
goTo(target.value);
} else if (target.id === 'reference-verse') {
goTo(target.value);
}
if (target.id === 'content') land();
});
// A history restore does not go through the request pipeline -- htmx swaps
// the element straight in and fires this instead -- so the work afterSwap
// does has to be repeated here or a page reached by the back button arrives
// with dead verse numbers.
// with dead verse numbers. The picker listens for the same event itself,
// from the navbar, because a restore reaches nothing outside #content.
document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
fillReference();
land();
});
@@ -289,7 +209,6 @@
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
markVerses();
fillReference();
scrollToHash();
// Registered from the page it is going to serve, not from a page of its
+8 -2
View File
@@ -59,14 +59,20 @@
// content is already escaped -- compile_bible.py escapes as it writes and
// notice.njk comes out of nunjucks -- which is what the |safe on it in
// base.njk is for.
compose: function (fragment, mark) {
//
// chrome is what the shell needs and the fragment cannot carry: the
// bookmark, and the navbar picker's model. Both are null from boot.html,
// which has no worker to ask for either, and both render as a hidden
// slot that fills itself if one ever appears.
compose: function (fragment, chrome) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, '');
var name = (TEMPLATE_RE.exec(content) || ['', 'page'])[1] + '.njk';
return render(name, {
title: title,
content: content,
mark: mark || null
mark: (chrome && chrome.mark) || null,
reference: (chrome && chrome.reference) || null
});
},
+12
View File
@@ -37,8 +37,20 @@
// out of the response itself.
this.restore =
this.request.headers.get('HX-History-Restore-Request') === 'true';
// The address the page making this request is showing. htmx stamps it on
// everything it sends; a navigation carries nothing, because there is no
// page yet. It is how a handler answering the chrome -- which htmx never
// swaps, and which therefore outlives the page it was rendered for -- can
// still find out which page that is.
this.current = this.request.headers.get('HX-Current-URL');
}
// That address as a URL, resolved against this request's own so a relative
// one still works. Null when there was no page.
Call.prototype.currentUrl = function () {
return this.current ? new URL(this.current, this.url) : null;
};
Call.prototype.form = function () {
return this.request.formData();
};
+175 -5
View File
@@ -11,6 +11,7 @@
// GET /:page one book -- /GEN, /EXO
// GET /api/bookmark the navbar slot
// POST /api/bookmark set the bookmark, and return the slot
// GET /api/reference the navbar's chapter and verse picker
//
// Anything with a dot in its last segment is a file, not a route, and is
// served from the cache or the network without going near the router. Pages
@@ -108,6 +109,8 @@ const PRECACHE = [
'./templates/book.njk',
'./templates/index.njk',
'./templates/bookmark.njk',
'./templates/reference.njk',
'./templates/verses.njk',
'./templates/status.njk',
'./templates/notice.njk'
];
@@ -226,6 +229,9 @@ function pageRequest(url) {
return new Request(url, { cache: 'no-cache' });
}
// Used for verses.json as well, which is generated beside the pages and
// versions with them, and would go stale the same way for the same reason.
async function readText(input, event) {
const res = await answer(input, event);
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
@@ -252,6 +258,106 @@ const view = createRenderer(readTemplate);
// if the app is ever served from a subdirectory.
const router = new Router(new URL('./', location.href).pathname);
// ---------------------------------------------------------------- reference
// bible/verses.json: every book's chapters, and every chapter's verse labels.
// compile_bible.py writes it from the same tree it renders the books from, so
// every option built here names an anchor that is in the page it lands on.
//
// A chapter whose verses run 1..n is stored as the number n; only the eight in
// the corpus with a gap or a range -- Sirach 26 ends "19-27", 28, 29 -- spell
// their labels out. 6 KB for 38,000 verses rather than 180.
const VERSES_URL = './verses.json';
let verses = null;
// Held for the life of this worker, and only for that. Regenerating the corpus
// changes LIBRARY_VERSION, which is a new worker with a new cache, so a copy
// held here cannot outlive the pages it describes.
//
// It lives in the library cache, so a device that has never been online long
// enough to fetch it has no picker rather than no page -- see referenceOf.
function readVerses() {
if (!verses) {
verses = readText(pageRequest(VERSES_URL))
.then(JSON.parse)
.catch((err) => { verses = null; throw err; });
}
return verses;
}
// "ch3" or "v3_16" -- the id of the element an option lands on, which is also
// where the chapter it belongs to is written down. Options are built here, so
// this is only ever reading back what this file sent; a URL is still a URL,
// and anything else is null.
function parseAnchor(id) {
const chapter = /^ch(\d+)$/.exec(id || '');
if (chapter) return { chapter: +chapter[1], verse: null };
const verse = /^v(\d+)_[0-9-]+$/.exec(id || '');
if (verse) return { chapter: +verse[1], verse: id };
return null;
}
function verseLabels(chapters, number) {
const entry = chapters[number - 1];
if (typeof entry !== 'number') return entry || [];
const out = [];
for (let i = 1; i <= entry; i++) out.push(String(i));
return out;
}
// The picker's model for one book, or null if that is not a book with verses
// in it -- the book list, a notice, and the two front-matter books that have
// no verse anchors to offer.
//
// anchor says which chapter to show the verses of, and which verse to mark
// selected. Null starts at 1:1, which is where every book starts: this says
// where you are going, not where you are, and the address bar is the only
// record of the latter.
function referenceFor(map, code, anchor) {
const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
? map[code] : null;
if (!chapters || !chapters.length) return null;
// The front matter and the glossary are books with chapters and no verses.
// A picker over them would be a chapter select beside an empty one, so they
// get none, the same as the book list.
if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) {
return null;
}
const at = parseAnchor(anchor) || { chapter: 1, verse: null };
const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1;
return {
book: code,
chapter: 'ch' + number,
chapters: chapters.map((_, i) => ({ id: 'ch' + (i + 1), label: String(i + 1) })),
verse: at.verse,
verses: verseLabels(chapters, number)
.map((label) => ({ id: 'v' + number + '_' + label, label: label }))
};
}
// The book a page URL is showing: "GEN" for /GEN, "" for the book list.
function bookOf(url) {
return url ? router.relative(url).slice(1) : '';
}
// Never throws. The map is fetched over the network first and answered from
// the library cache when that fails, so the one case this swallows is a device
// that has gone offline before the background download reached verses.json:
// the pages it has read still read, and the picker is hidden until a later
// navigation finds the map. The same degradation the bookmark makes.
async function referenceOf(code, anchor) {
if (!code) return null;
try {
return referenceFor(await readVerses(), code, anchor);
} catch (err) {
return null;
}
}
const OFFLINE_NOTICE = ['Not available offline',
'This page has not been opened on this device, so there is no copy stored ' +
'here. Reconnect and try again, or open one you have read before.'];
@@ -273,11 +379,24 @@ async function fragmentFor(path, event) {
// One handler for every caller. htmx swapping a link into a shell already on
// screen wants the bare fragment; a navigation has no shell yet, and a history
// restore is rebuilding one, so both get the fragment composed into a document.
async function servePage(path, call) {
//
// code is the book the path names, or "" for the book list -- the one thing
// the picker needs and the path already knows.
async function servePage(path, code, call) {
const fragment = await fragmentFor(path, call.event);
if (!call.navigate && !call.restore) return fragment;
const reference = await referenceOf(code, null);
if (!call.navigate && !call.restore) {
// The picker is in the navbar and every boosted link targets #content, so
// a swap never reaches it. It rides along out of band instead, the way a
// bookmark write carries the modal's status line: one response, both
// halves of the page. Sent even when it is null -- that is the hidden
// state, and arriving at the book list has to put the picker away.
return fragment + '\n'
+ await view.render('reference.njk', { reference: reference, oob: true });
}
try {
return await view.compose(fragment, await getBookmark());
return await view.compose(fragment, { mark: await getBookmark(),
reference: reference });
} catch (err) {
// A template that will not render is a bug, not a condition to design for,
// but it should not take the text down with it: the bare fragment has no
@@ -288,11 +407,62 @@ async function servePage(path, call) {
}
// "/" before "/:page", because both are two segments and the first match wins.
router.get('/', (call) => servePage('./', call));
router.get('/:page', (call) => servePage('./' + call.params.page, call));
router.get('/', (call) => servePage('./', '', call));
router.get('/:page', (call) =>
servePage('./' + call.params.page, call.params.page, call));
router.get('/api/bookmark', () => bookmarkFragment(''));
// The picker, and the two moves it makes.
//
// no parameters the whole picker, for whichever book is on screen. Asked
// for by the picker itself, after a history restore -- which
// swaps the history element straight in and processes no
// out-of-band elements -- and when a worker first appears
// under a page boot.html composed.
// chapter=ch3 the verses of that chapter, and land on the chapter.
// verse=v3_16 the same verses, that one selected, and land on the verse.
//
// The book is not in the URL, deliberately: it comes from HX-Current-URL,
// which is the address the page is showing now rather than the address it was
// showing when the picker was last rendered. The picker sits in the navbar and
// outlives the page under it, so anything baked into its attributes is the one
// thing here that can go stale.
//
// What the response headers are doing is the whole of what app.js used to do
// on a change event, and htmx has a name for each part of it:
//
// settle:0 swap the options now. The default waits 20ms, and the point
// of this request is a scroll the reader is waiting on.
// show:#id:top scroll the named anchor to the top -- htmx's scrollBehavior
// is "instant", which is what this app wants everywhere.
// Reboot sets scroll-behavior: smooth, so saying nothing here
// would animate.
// HX-Replace-Url put the anchor in the address bar without stacking a
// history entry per option spun past.
router.get('/api/reference', async (call) => {
const params = call.url.searchParams;
const anchor = params.get('verse') || params.get('chapter');
const page = call.currentUrl();
const reference = await referenceOf(bookOf(page), anchor);
if (!anchor) return view.render('reference.njk', { reference: reference });
// The options were rendered from this map, so failing to parse them back
// means the map moved under an open page: leave the picker alone rather
// than swapping in options for a book that is no longer on screen.
if (!reference) return new Response('', { status: 204 });
const target = reference.verse || reference.chapter;
return new Response(await view.render('verses.njk', { reference: reference }), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'HX-Reswap': 'innerHTML settle:0 show:#' + target + ':top',
'HX-Replace-Url': page.pathname + page.search + '#' + target
}
});
});
router.post('/api/bookmark', async (call) => {
try {
const form = await call.form();
+4 -25
View File
@@ -1,7 +1,8 @@
{# The shell every page is wrapped in. Rendered by sw.js, not by the build,
so editing this file changes every page without regenerating any of them.
Context: title, content (safe HTML fragment), mark (bookmark or null).
Context: title, content (safe HTML fragment), mark (bookmark or null),
reference (the navbar picker's model, or null).
Bootstrap is the whole stylesheet. The fragments carry semantic USFM
classes and no Bootstrap ones, so the body text is styled by Reboot; the
@@ -76,29 +77,7 @@
data-bs-theme="dark">
<div class="container-xl">
<a class="navbar-brand" href="./">Bible</a>
{# The reference picker: which book you are in, and where in it to go.
Outside the collapse deliberately -- it is the main way of moving around
a book now that the chapter list is gone from the page, and a chapter
picker behind a hamburger is no use on a phone.
Empty here and filled by app.js from the book on the page. The verse
list has to be rebuilt in the browser every time the chapter changes, so
there is nothing for a template to usefully render first.
d-none for the empty state rather than the hidden attribute: Bootstrap
declares .d-flex after Reboot's [hidden] rule, both !important and at
the same specificity, so the utility would win. #}
<div class="reference navbar-text d-none align-items-center gap-1 ms-3"
id="reference">
<span class="reference-book text-light fw-semibold" id="reference-book"></span>
<label class="visually-hidden" for="reference-chapter">Chapter</label>
<select class="reference-chapter form-select form-select-sm"
id="reference-chapter"></select>
<span class="reference-sep text-light">:</span>
<label class="visually-hidden" for="reference-verse">Verse</label>
<select class="reference-verse form-select form-select-sm"
id="reference-verse"></select>
</div>
{% include "reference.njk" %}
<button class="navbar-toggler" type="button"
data-bs-toggle="collapse" data-bs-target="#site-nav"
aria-controls="site-nav" aria-expanded="false"
@@ -129,7 +108,7 @@
</main>
{# Chrome, so it is written once here instead of built in JavaScript on every
page. app.js only fills in the reference and shows it. #}
page. app.js only fills in the verse it was opened for and shows it. #}
<div class="modal fade" id="verse-dialog" tabindex="-1"
aria-labelledby="verse-dialog-ref" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
+74
View File
@@ -0,0 +1,74 @@
{# The navbar's reference picker: which book you are in, and where in it to go.
Context: reference (or null), oob.
reference is built by sw.js from bible/verses.json, which compile_bible.py
writes from the same tree it renders the books from -- so an option can
never name an anchor that is not in the page it lands on.
It used to be built in the browser, by app.js, reading the chapters and
verses off the book already on screen. That was free, and it only worked
where there was a document to read: this shell is composed in a service
worker, which has no DOM, before any document exists. What the picker needs
is a fact on disk now, and everything below is markup and htmx.
Outside the collapse deliberately -- it is the main way of moving around a
book now that the chapter list is gone from the page, and a chapter picker
behind a hamburger is no use on a phone.
d-none for the empty state rather than the hidden attribute: Bootstrap
declares .d-flex after Reboot's [hidden] rule, both !important and at the
same specificity, so the utility would win.
Two things bring it back into step with the page, because the navbar is
never the swap target -- every boosted link lands in #content:
a navigation sw.js appends this template to the fragment with oob set,
the same way a bookmark write carries the modal's status
line, so one response moves both.
a restore htmx swaps the history element straight in and processes
no out-of-band elements at all, so the picker asks for
itself instead. worker-ready is in the same trigger for
the empty state below.
Neither carries the book: the request does, in HX-Current-URL, which is the
address the page is actually showing. An address baked into this attribute
is the one thing here that can go stale, because the picker outlives the
page it was rendered for. #}
<div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3"
id="reference"
{%- if oob %} hx-swap-oob="true"{% endif %}
hx-get="api/reference"
hx-trigger="worker-ready from:body, htmx:historyRestore from:body"
hx-target="this" hx-swap="outerHTML">
{%- if reference %}
<span class="reference-book text-light fw-semibold"
id="reference-book">{{ reference.book }}</span>
<label class="visually-hidden" for="reference-chapter">Chapter</label>
{# The value is the id of the element to land on, so nothing downstream has
to know how a verse anchor is spelled -- including the worker, which
reads the chapter back out of the id it sent.
hx-target is spelled out on both selects because the one on the div above
is "this", and an inherited "this" would resolve to the select. #}
<select class="reference-chapter form-select form-select-sm"
id="reference-chapter" name="chapter"
hx-get="api/reference" hx-target="#reference-verse"
hx-swap="innerHTML">
{%- for chapter in reference.chapters %}
<option value="{{ chapter.id }}"
{%- if chapter.id == reference.chapter %} selected{% endif %}>{{ chapter.label }}</option>
{%- endfor %}
</select>
<span class="reference-sep text-light">:</span>
<label class="visually-hidden" for="reference-verse">Verse</label>
{# Answers with its own options again rather than nothing, so the response
has something to hang the scroll on -- and so the option the reader chose
comes back marked selected instead of being reset by the swap. #}
<select class="reference-verse form-select form-select-sm"
id="reference-verse" name="verse"
hx-get="api/reference" hx-target="this" hx-swap="innerHTML">
{% include "verses.njk" %}
</select>
{%- endif %}
</div>
+7
View File
@@ -0,0 +1,7 @@
{# The verse select's options. Rendered into the picker by reference.njk, and
returned on their own by api/reference after a chapter or verse change.
Both paths must produce the same options for the swap to be seamless. #}
{% for verse in reference.verses -%}
<option value="{{ verse.id }}"
{%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option>
{% endfor -%}
+4 -2
View File
@@ -53,8 +53,10 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
# Revalidate every time: these are what carry an update to an installed app.
NO_CACHE_NAMES = {"sw.js"}
# .njk is in here because the worker reads templates at runtime: a stale one
# would outlive the version that replaced it.
NO_CACHE_EXTS = {".html", ".njk", ".webmanifest"}
# would outlive the version that replaced it. .json is verses.json, which
# compile_bible.py generates beside the pages and which describes them, so it
# goes stale exactly when they do.
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
# Served in place of any page a browser navigates to.
BOOT_PAGE = "/boot.html"
+90
View File
@@ -45,6 +45,7 @@ from __future__ import annotations
import argparse
import html
import json
import os
import re
import sys
@@ -777,6 +778,91 @@ def render_index(books, translation, out_dir):
return path
# ==========================================================================
# WRITING -- the reference map
# ==========================================================================
# bible/verses.json: every book's chapters, and every chapter's verse labels.
#
# The navbar's reference picker is built from this, by the service worker. It
# used to be built in the browser by reading the book already on the page --
# the chapters were the sections and a chapter's verses were the anchors inside
# it -- which was free but only worked where there was a document to read. A
# worker has no DOM, and the picker now lives in a shell the worker composes
# before any document exists, so what the picker needs has to be a fact on
# disk rather than something recovered from markup.
#
# Written here, from the same tree the fragments are rendered from, so the two
# cannot disagree: every option this file produces names an anchor render_book
# wrote, because both come from the same verse nodes.
#
# The encoding is compact on purpose. A chapter whose verses run 1..n is
# written as the number n; only the eight in the corpus with a gap or a range
# -- Sirach 26 ends "19-27", 28, 29 -- spell their labels out:
#
# {"GEN": [31, 25, 24, ...], "SIR": [..., ["1", ..., "19-27", "28", "29"]]}
#
# That is 6 KB for 38,000 verses against 180 KB of spelled-out labels, which is
# the difference between a file the worker can parse every time it wakes and
# one it would have to find somewhere to keep.
#
# Chapter numbers are not in the file at all: they are the array index plus
# one. render_verses checks that, rather than assuming it.
VERSES_FILE = "verses.json"
def chapter_verses(chapter):
"""The verse labels of one chapter, in document order.
Blocks only. Footnotes hang inside the chapter section too, but a note's
content never carries a verse node -- and if one ever did, it would be a
citation rather than a place in the text.
"""
labels = []
def walk(nodes):
for node in nodes:
if node["t"] == "verse":
labels.append(node["label"])
elif "c" in node:
walk(node["c"])
for block in chapter["blocks"]:
walk(block.get("content", []))
return labels
def render_verses(books, out_dir):
"""Write the reference map. Returns its path and the verses in it.
See the note above for the encoding.
"""
out = {}
total = 0
for book in books:
numbers = [c["number"] for c in book["chapters"]]
if numbers != list(range(1, len(numbers) + 1)):
# The map addresses chapters by position, so a book that skips or
# repeats one would silently mislabel every option after the gap.
# Better to stop the build than to ship a picker that lies.
raise RuntimeError("%s: chapters are not 1..%d (%s)"
% (book["code"], len(numbers), numbers))
chapters = []
for chapter in book["chapters"]:
labels = chapter_verses(chapter)
plain = [str(i + 1) for i in range(len(labels))]
chapters.append(len(labels) if labels == plain else labels)
total += len(labels)
out[book["code"]] = chapters
path = os.path.join(out_dir, VERSES_FILE)
with open(path, "w", encoding="utf-8") as fh:
json.dump(out, fh, separators=(",", ":"), ensure_ascii=False)
fh.write("\n")
return path, total
# ==========================================================================
# Entry point
# ==========================================================================
@@ -797,6 +883,7 @@ def main(argv=None):
for book in data["books"]:
total += os.path.getsize(render_book(book, data["translation"], args.out))
render_index(data["books"], data["translation"], args.out)
verses, count = render_verses(data["books"], args.out)
chapters = sum(len(b["chapters"]) for b in data["books"])
notes = sum(len(c["notes"]) for b in data["books"] for c in b["chapters"])
@@ -807,6 +894,9 @@ def main(argv=None):
print("skipped %d (contents pages)" % len(skipped), file=sys.stderr)
print("html %.1f MB total, %.0f KB average"
% (total / 1e6, total / len(data["books"]) / 1e3), file=sys.stderr)
print("verses %d in %s (%.1f KB)"
% (count, os.path.basename(verses), os.path.getsize(verses) / 1e3),
file=sys.stderr)
print("wrote %s/" % args.out.rstrip("/"), file=sys.stderr)
return 0