Remove comments

This commit is contained in:
mark committed 2026-09-09 12:09:44 -04:00
1 parent 872175153a
commit 057b4be7d7
18 files changed
+66 -1350

No files matched your search

-24
View File
@@ -4,38 +4,14 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>World English Bible Updated</title> <title>World English Bible Updated</title>
<!-- Vendored rather than linked from a CDN: sw.js only caches same-origin
responses, so a CDN stylesheet would leave the app unstyled offline. -->
<link rel="stylesheet" href="css/bootstrap.min.css"> <link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/app.css">
<meta name="theme-color" content="#1a202c"> <meta name="theme-color" content="#1a202c">
</head> </head>
<body class="bg-body-tertiary"> <body class="bg-body-tertiary">
<!-- Served by server.py in place of any address a browser navigates to while
no service worker is controlling this origin: a first visit, a cleared
cache, a reload that bypassed the worker.
The pages on disk are fragments and the shell is a template, and it is the
worker that puts them together. So this page has one job: register the
worker and reload once it is running, which lands on the real app at the
address that was asked for. It is only ever seen once per device.
There is no fallback for a device that cannot install one. There used to
be -- this page loaded the renderer itself and composed the page in the
browser -- and it existed for one case: the app served over plain http://
from a LAN address, which is not a secure context, so no worker can
register. This app is served over TLS, where that case cannot arise, and
the fallback was a second way of building every page that nothing
exercised. What is left when registration fails is a sentence saying so,
because there is nothing honest to show instead. -->
<main class="boot container py-5 text-center"> <main class="boot container py-5 text-center">
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div> <div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
<!-- Empty until there is something to say. This page is on screen for a
moment on a first visit and then never again, and a line naming what it
is doing is a line nobody needs to read: the spinner already says wait.
It is here, and empty, because it is where fail() writes. -->
<p id="boot-status" class="text-body-secondary"></p> <p id="boot-status" class="text-body-secondary"></p>
<noscript><p class="alert alert-warning d-inline-block">This app builds its <noscript><p class="alert alert-warning d-inline-block">This app builds its
pages on the device and needs JavaScript.</p></noscript> pages on the device and needs JavaScript.</p></noscript>
-42
View File
@@ -1,20 +1,8 @@
/* The text layer. Bootstrap styles the chrome; this styles scripture.
Every selector here is a class render_books.py already emits, so restyling
the text never means regenerating books/. */
/* Serif everywhere, chrome included. Overriding Bootstrap's own variable
rather than setting font-family on elements means the navbar, modal and
buttons all follow, and a typeface is named in exactly one place. System
fonts on purpose: a webfont would be one more thing to keep cached for
offline, and one more request before the text can be read. */
:root { :root {
--bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype", --bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
"Times New Roman", serif; "Times New Roman", serif;
} }
/* Verse numbers. The markup is a <span>, not a <sup>, so the raise happens
here -- and line-height:0 keeps the raised number from opening up the line
it sits on. Not selectable, so copying a passage copies the words. */
.v { .v {
font-size: .7em; font-size: .7em;
font-weight: 700; font-weight: 700;
@@ -25,15 +13,12 @@
user-select: none; user-select: none;
} }
/* These are real buttons -- compile_bible.py writes role and tabindex on every
one of them -- so they need a visible focus ring. */
.v:focus-visible { .v:focus-visible {
outline: 2px solid var(--bs-primary); outline: 2px solid var(--bs-primary);
outline-offset: 2px; outline-offset: 2px;
border-radius: 2px; border-radius: 2px;
} }
/* Chapter numbers are <h2>, which at heading size shouts over the text. */
.chapter-number { .chapter-number {
font-size: 2rem; font-size: 2rem;
font-weight: 400; font-weight: 400;
@@ -41,10 +26,6 @@
margin: 2rem 0 .75rem; margin: 2rem 0 .75rem;
} }
/* Poetry lines are paragraphs, so left alone each one carries a paragraph's
bottom margin and a psalm falls apart into separate blocks. Close the gap,
indent by the level the generator recorded, and hang the indent so a line
too long for the screen still reads as one line. */
.poetry { margin-bottom: 1rem; } .poetry { margin-bottom: 1rem; }
.poetry .line { .poetry .line {
margin: 0; margin: 0;
@@ -54,13 +35,11 @@
.poetry .line[data-indent="2"] { padding-left: 3em; } .poetry .line[data-indent="2"] { padding-left: 3em; }
.poetry .line[data-indent="3"] { padding-left: 4.5em; } .poetry .line[data-indent="3"] { padding-left: 4.5em; }
/* Descriptive titles: "A Psalm by David". */
.d { .d {
font-style: italic; font-style: italic;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
} }
/* The notes are apparatus, not scripture, and should not read as it. */
.footnotes { .footnotes {
font-size: .875rem; font-size: .875rem;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
@@ -68,38 +47,17 @@
.footnotes-title { font-size: 1rem; } .footnotes-title { font-size: 1rem; }
.marker { font-weight: 700; } .marker { font-weight: 700; }
/* The callers are * † ‡ § and ✡, and the star is the one character in the
whole corpus a browser may render as an emoji. render_books.py already
appends U+FE0E to ask for the text glyph; this asks a second way, for
renderers that ignore the variation selector. Newer property, so where it
is not supported the selector is simply dropped and the variation selector
is still doing the work. */
.noteref, .noteref,
.marker { font-variant-emoji: text; } .marker { font-variant-emoji: text; }
/* The navbar is sticky, so an element scrolled to the top of the viewport
lands underneath it. These two are everything an anchor in this app points
at -- chapter headings and verse numbers -- so the offset lives on them
rather than on a scroll container. Roughly the navbar's height. */
.chapter, .chapter,
.v { scroll-margin-top: 4.5rem; } .v { scroll-margin-top: 4.5rem; }
/* The navbar's reference picker. form-select is width:100% by default, which
in a flex row means both selects try to take the whole navbar; and the
default right padding leaves the caret sitting on top of a three-digit
chapter number. */
.reference .form-select { .reference .form-select {
width: auto; width: auto;
padding-right: 2rem; padding-right: 2rem;
} }
/* The book list is a list of links, not a table of facts. Reboot's bullets
and the chapter count after each title are both noise between the reader
and the book they came for.
Hidden here rather than dropped from render_books.py: the count is real
information the generator knows, and whether to show it is a presentation
decision. It stays in the markup for anything that wants it later. */
.index ul { .index ul {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;
+4 -147
View File
@@ -1,187 +1,44 @@
// Page startup.
//
// Pages arrive whole -- the service worker composes every one of them -- so
// there is nothing here that builds a page.
// What is left is the three things a composed document cannot do for itself:
// put a swapped-in page where it belongs before it is painted, land the
// reference picker where the reader asked to go, and tell the chrome when a
// worker has appeared to answer it.
//
// Three things used to live here and no longer do, all for the same reason:
// they were reading the page to work out something that was already known
// somewhere better.
//
// The reference picker was built by reading the chapters and verses off the
// book on screen. It is markup and htmx now: sw.js renders it from
// bible/verses.json and answers api/reference, and the address-bar update that
// a change event used to run through here is a response header on the answer.
// See templates/reference.njk.
//
// The verse dialog was built by scraping the book name out of article
// [data-name] and the chapter out of the enclosing section, then filling a
// form and showing it. sw.js answers api/verse with the whole of the dialog,
// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap
// opens it -- which is htmx's own bootstrap-modal example, with the click
// delegated because the trigger is one of 38,000 generated verse numbers and
// cannot carry attributes of its own. See templates/verse.njk.
//
// The third was marking those numbers as controls: role, tabindex and an
// aria-label, set on every verse on load, after every swap and after every
// history restore -- a walk over a whole book, three times over, to arrive at
// the same three attributes every time. They are in the markup now, written
// by compile_bible.py, because nothing about them depends on anything only the
// browser knows. It costs the corpus a couple of megabytes and saves the pass.
(function () { (function () {
'use strict'; 'use strict';
// -- events -------------------------------------------------------------
document.body.addEventListener('htmx:afterSwap', function (event) { document.body.addEventListener('htmx:afterSwap', function (event) {
var target = event.detail.target; var target = event.detail.target;
if (!target) return; if (!target) return;
// Both selects answer into the verse list, so this is either of them. if (target.id === 'reference-verse') return scrollToHash();
if (target.id === 'reference-verse') return landOnReference(); if (target.id === 'content') return land();
if (target.id === 'content') { land(); showVersion(); }
}); });
// 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
// scrolled to wherever the last one was. The picker listens for the same
// event itself, from the navbar, because a restore reaches nothing outside
// #content.
document.body.addEventListener('htmx:historyRestore', function () { document.body.addEventListener('htmx:historyRestore', function () {
land(); land();
showVersion();
}); });
// -- startup ------------------------------------------------------------
function hashTarget() { function hashTarget() {
if (!location.hash) return null; if (!location.hash) return null;
return document.getElementById(decodeURIComponent(location.hash.slice(1))); return document.getElementById(decodeURIComponent(location.hash.slice(1)));
} }
// Every scroll here is a correction, not a gesture: the page is meant to
// arrive already in position. So they all say instant.
//
// Saying nothing would not mean instant. The default is "auto", which means
// "whatever scroll-behavior computes to" -- and Bootstrap's Reboot sets
// :root { scroll-behavior: smooth }, so the default animates. Bootstrap
// wraps that in prefers-reduced-motion, which is the right call for the
// chapter nav's own anchors; these are not that.
//
// Asked for twice, because "instant" is a later addition to an old enum and
// a browser that does not know the value rejects the whole call with a
// TypeError rather than ignoring the word -- so on an older WebKit this did
// not scroll slowly, it did not scroll at all. "auto" is what the enum has
// always had; it animates here, which is worse than instant and far better
// than staying put.
function jumpTo(el) { function jumpTo(el) {
try { el.scrollIntoView({ block: 'start', behavior: 'instant' });
el.scrollIntoView({ block: 'start', behavior: 'instant' });
} catch (err) {
el.scrollIntoView({ block: 'start', behavior: 'auto' });
}
} }
// The same, for the top of the page and the same reason.
function jumpToTop() { function jumpToTop() {
try { window.scrollTo({ top: 0, behavior: 'instant' });
window.scrollTo({ top: 0, behavior: 'instant' });
} catch (err) {
window.scrollTo(0, 0);
}
} }
// The browser scrolls to the anchor itself on a document it navigated to,
// so this is for the one it does not: a reload that landed on boot.html and
// was reloaded again into the real page. Costs nothing when the browser has
// already been there.
function scrollToHash() { function scrollToHash() {
var el = hashTarget(); var el = hashTarget();
if (el) jumpTo(el); if (el) jumpTo(el);
} }
// Where a swapped-in page should sit before it is ever seen.
//
// htmx does this itself -- scrolling to the anchor a boosted href named, or
// to the top of the target when it named none -- but from the settle
// callback, which is a setTimeout of defaultSettleDelay, 20ms. The browser
// paints the new content at the old scroll offset first, so the jump is
// visible every time. Here it runs in the same task as the swap, before any
// paint, so the page simply appears where it belongs. htmx's own scroll
// still follows and finds nothing left to do.
//
// htmx has already pushed the new URL by this point -- history is updated
// before the swap, not after -- so location.hash is the anchor that was
// asked for.
function land() { function land() {
var el = hashTarget(); var el = hashTarget();
if (el) jumpTo(el); if (el) jumpTo(el);
else jumpToTop(); else jumpToTop();
} }
// Where the reference picker lands, which is the one scroll in the app that
// is a gesture: the reader asked for chapter 3 and is waiting to be taken
// there.
//
// It used to ride along on the answer, as show: on HX-Reswap, and be htmx's
// scroll rather than this file's. Two reasons it is here now. The smaller
// one is that htmx's own scrollIntoView says "instant" with nothing around
// it, so the enum above takes the whole scroll down with it. The larger one
// is the phone: a select on iOS is a picker drawn over the page, the change
// event arrives while it is still up, and dismissing it puts the page back
// where it stood when it opened -- undoing anything scrolled in between.
// The options were swapping correctly and the book never moved.
//
// So: once now, for a pointer, and once more after the picker has had time
// to finish, but only if the page did not stay where it was put. That test
// is also what keeps the second attempt from yanking back a reader who has
// started scrolling on their own.
//
// The anchor comes from the address bar. The worker sends HX-Replace-Url
// with the answer and htmx updates history before it swaps, so by the time
// this runs location.hash is already naming the chapter or verse chosen.
var PICKER_SETTLE_MS = 350;
function landOnReference() {
var el = hashTarget();
if (!el) return;
jumpTo(el);
var landed = window.pageYOffset;
setTimeout(function () {
var again = hashTarget();
if (again && Math.abs(window.pageYOffset - landed) > 2) jumpTo(again);
}, PICKER_SETTLE_MS);
}
// The version readout, shown under the book list and nowhere else.
//
// It is chrome: it sits outside #content, so it outlives every swap -- and
// so does the body class it was rendered with, which is exactly why that
// cannot be what decides. What is in #content can, because that is the one
// thing a swap changes: the book list is the fragment rooted at .index.
//
// Toggling the class rather than the style because Bootstrap declares
// .d-none !important, which an inline display would lose to.
function showVersion() {
var el = document.getElementById('app-version');
if (!el) return;
el.classList.toggle('d-none', !document.querySelector('#content .index'));
}
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
scrollToHash(); scrollToHash();
showVersion();
// Registered from the page it is going to serve, not from a page of its
// own: this load is already the real app, composed by the worker that is
// controlling it. What this catches is the copy on disk having changed
// since -- the browser refetches sw.js, sees different bytes, and installs
// the new one alongside, which takes over from the next navigation on.
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {}); navigator.serviceWorker.register('sw.js').catch(function () {});
} }
+4 -69
View File
@@ -1,32 +1,11 @@
// The renderer: the templates in templates/, and the two things sw.js does
// with them -- compose a fragment into the shell, and stand a notice in for a
// page that could not be delivered.
//
// A file of its own rather than more of sw.js because it is the half with
// nothing to do with the network: no routes, no caches, no requests. It was
// loaded by boot.html as well once, which composed pages in the browser for a
// device that could not install a worker; nothing but the worker builds a page
// now.
//
// Loaded with importScripts, so it defines a global rather than exporting.
// Nunjucks must already be loaded.
(function (scope) { (function (scope) {
'use strict'; 'use strict';
// A fragment is a <title> and one root element. The root's data-template
// names the template that wraps it; compile_bible.py writes both.
var TITLE_RE = /<title>([\s\S]*?)<\/title>/i; var TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
var TEMPLATE_RE = /data-template="([a-z0-9_-]+)"/i; var TEMPLATE_RE = /data-template="([a-z0-9_-]+)"/i;
// Templates live in one directory and are named by file within it, so a
// template that extends or includes another names only the file.
var TEMPLATES = './templates/'; var TEMPLATES = './templates/';
// All of them. Named here rather than in either caller because this file is
// the one that knows what a template is: sw.js precaches them from this list
// and both renderers load them from it, so there is one place to add the
// next one. A template missing from here is a template nunjucks would have
// to fetch mid-render, which is the one thing loadAll() exists to prevent.
var NAMES = [ var NAMES = [
'base.njk', 'base.njk',
'page.njk', 'page.njk',
@@ -43,11 +22,7 @@
scope.TEMPLATE_URLS = NAMES.map(function (name) { return TEMPLATES + name; }); scope.TEMPLATE_URLS = NAMES.map(function (name) { return TEMPLATES + name; });
// readText(path) -> Promise<string> is the only thing the two callers do
// differently: the worker reads through its cache, the page over the network.
scope.createRenderer = function (readText) { scope.createRenderer = function (readText) {
// Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist
// in a service worker. This one is readText and nothing else.
function Loader() {} function Loader() {}
Loader.prototype.async = true; Loader.prototype.async = true;
Loader.prototype.getSource = function (name, callback) { Loader.prototype.getSource = function (name, callback) {
@@ -56,38 +31,13 @@
function (err) { callback(err); }); function (err) { callback(err); });
}; };
// Compiled templates are held for the lifetime of whatever loaded this. In
// the worker an edit to any of them changes VERSION, which starts a new
// worker, so they never go stale.
var env = new nunjucks.Environment(new Loader(), { autoescape: true }); var env = new nunjucks.Environment(new Loader(), { autoescape: true });
// Every template, in nunjucks' hands before the first render, because an
// include that has to wait for one does not come out whole.
//
// getTemplate answers from the loader's cache by calling straight back,
// and only reaches this loader -- and this loader's promise -- for a
// template it has never seen. Nunjucks compiles {% include %} into a
// waterfall, and the rest of the block it sits in is the waterfall's
// callback while the code after that block runs on regardless. Answered
// in the same tick the two happen in order and the output is whole;
// answered a tick later the render is finished and handed back first,
// and what comes back is missing the include and everything between it
// and the end of its block. The verse list, and then the </select> after
// it -- a picker with a chapter and no verses, on whichever navigation
// happened to be the first to need the template.
//
// Which is why it is all of them and not just the ones that get included:
// whether a template has been reached yet depends on which pages have
// been rendered, and in the worker on whether it has been restarted since.
// That is the whole shape of the bug -- intermittent, and never twice in a
// row. One pass over a few kilobytes already in the cache buys the
// question being settled.
//
// Failures resolve rather than reject: a template that cannot be read now
// will fail again in the render that actually needs it, where the error
// belongs to a request and can be answered with the bare fragment.
var loaded = null; var loaded = null;
// Nunjucks resolves {% include %} asynchronously. A template that is not
// compiled yet when the render reaches it is skipped, taking the rest of
// the block it sits in with it, and the render still succeeds.
function loadAll() { function loadAll() {
if (!loaded) { if (!loaded) {
loaded = Promise.all(NAMES.map(function (name) { loaded = Promise.all(NAMES.map(function (name) {
@@ -112,18 +62,6 @@
return { return {
render: render, render: render,
// A fragment becomes a whole document. page.njk is the fallback for one
// that names no template of its own.
//
// 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.
//
// chrome is what the shell needs and the fragment cannot carry: the
// bookmark, the navbar picker's model, and the two versions the worker
// was built with. reference is null on the book list, which is the
// picker's hidden state; the other two are always there, because the
// worker composing this is the thing that knows them.
compose: function (fragment, chrome) { compose: function (fragment, chrome) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim(); var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, ''); var content = fragment.replace(TITLE_RE, '');
@@ -132,13 +70,10 @@
title: title, title: title,
content: content, content: content,
mark: chrome.mark, mark: chrome.mark,
reference: chrome.reference, reference: chrome.reference
version: chrome.version
}); });
}, },
// A page that could not be delivered, in the shape of a real fragment so
// that it composes into a document and swaps into #content like one.
notice: function (heading, text) { notice: function (heading, text) {
return render('notice.njk', { heading: heading, text: text }); return render('notice.njk', { heading: heading, text: text });
} }
-37
View File
@@ -1,17 +1,3 @@
// A very small HTTP router, so the worker reads as a set of routes rather than
// a chain of ifs unpicking a URL. The shape is lifted from the one in
// htmx-serviceworker-todo, which is in turn a miniature of Hono or Express:
// register a method and a path pattern, get the matching handler back.
//
// Patterns are matched segment by segment against a path relative to the
// worker's own scope, so the app still works if it is ever served from a
// subdirectory. A segment written as :name matches anything and lands in
// params. Segment counts must be equal -- there are no wildcards, because
// nothing here needs one.
//
// A handler returning a string gets it as an HTML response; returning a
// Response passes it through untouched; returning nothing means "not mine
// after all", and the caller falls back to serving the URL as a file.
(function (scope) { (function (scope) {
'use strict'; 'use strict';
@@ -21,32 +7,18 @@
}); });
} }
// Everything a handler is given: the event it must attach cache writes to,
// the URL, the path parameters, and the two questions every handler here
// asks -- who is calling, and are they replacing the whole document.
function Call(event, url, params) { function Call(event, url, params) {
this.event = event; this.event = event;
this.request = event.request; this.request = event.request;
this.url = url; this.url = url;
this.params = params; this.params = params;
// htmx stamps every request it makes; a navigation never carries it.
this.htmx = this.request.headers.get('HX-Request') === 'true'; this.htmx = this.request.headers.get('HX-Request') === 'true';
this.navigate = this.request.mode === 'navigate'; this.navigate = this.request.mode === 'navigate';
// Going back or forward. htmx asks for the page again and expects a whole
// document back, the same as a navigation -- it takes the history element
// out of the response itself.
this.restore = this.restore =
this.request.headers.get('HX-History-Restore-Request') === 'true'; 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'); 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 () { Call.prototype.currentUrl = function () {
return this.current ? new URL(this.current, this.url) : null; return this.current ? new URL(this.current, this.url) : null;
}; };
@@ -55,8 +27,6 @@
return this.request.formData(); return this.request.formData();
}; };
// base is the worker's scope path, e.g. "/" -- the prefix every pattern is
// written without.
function Router(base) { function Router(base) {
this.base = base; this.base = base;
this.routes = []; this.routes = [];
@@ -75,9 +45,6 @@
return this.route('POST', pattern, handler); return this.route('POST', pattern, handler);
}; };
// The path a pattern is matched against: absolute, but with the scope
// removed, so "/GEN" is "/GEN" whether the app sits at the origin root or
// under /bible/.
Router.prototype.relative = function (url) { Router.prototype.relative = function (url) {
var path = url.pathname; var path = url.pathname;
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path; return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
@@ -99,8 +66,6 @@
return null; return null;
}; };
// Returns a promise of a Response, or null when nothing matched -- null
// being the signal to the caller that this URL is a file, not a route.
Router.prototype.handle = function (event, url) { Router.prototype.handle = function (event, url) {
var found = this.find(event.request.method, this.relative(url)); var found = this.find(event.request.method, this.relative(url));
if (!found) return null; if (!found) return null;
@@ -113,8 +78,6 @@
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'text/html; charset=utf-8', 'Content-Type': 'text/html; charset=utf-8',
// Built here from live state -- the bookmark, the templates, the
// cache -- so storing it would pin a stale copy over the real thing.
'Cache-Control': 'no-store' 'Cache-Control': 'no-store'
} }
}); });
+7 -348
View File
@@ -1,80 +1,11 @@
// The app's renderer, and its backend. Hand-written: this file is the source,
// served as-is apart from the three lines server.py stamps (see below).
//
// It reads as a small server, because that is what it is. Requests come in
// through a router (js/router.js) and are answered by handlers; the handlers
// render the nunjucks templates in templates/ (js/render.js) and read and
// write the bookmark in IndexedDB. htmx on the page talks to it exactly as it
// would to an origin.
//
// GET / the book list
// GET /:page one book -- /GEN, /EXO
// POST /api/bookmark set the bookmark, and return the navbar slot
// GET /api/reference the navbar's chapter and verse picker
// GET /api/verse the dialog for one verse
// GET /api/toast one confirmation, by name
//
// 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
// have no extension: what is on disk is bible/GEN.html, what the reader sees
// is /GEN, and server.py resolves one to the other. bible/index.html is the
// root itself -- there is no /index, the book list lives at /.
//
// The files on disk are fragments: a title element and one root element. The
// shell around them is templates/base.njk, applied per request, so changing the
// chrome means editing one template rather than regenerating every book. A
// handler returns the bare fragment to htmx and the composed document to a
// navigation -- one route, both callers, which is the whole reason the request
// object carries `navigate`.
//
// The composing is in js/render.js. Every page in the app comes through it:
// boot.html registers this worker and reloads into it, and from there nothing
// else ever builds one.
//
// Every same-origin GET is 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. Every response that arrives is written back to the
// cache, so a book that has been read once stays readable offline. Templates
// are the one exception, and readTemplate() says why.
//
// The whole corpus is kept offline, but not by making anyone wait for it and
// not by telling them about it. Install caches the shell alone -- a handful of
// small files -- and the app is usable the moment that is done. The books are
// then fetched a few at a time in the background while the reader reads, with
// nothing on screen to say so. Close the tab half way through and the next
// visit picks up where it left off: the cache is the record of what has been
// fetched, so there is no separate bookkeeping to lose.
// Imported scripts are stored with the registration, so these survive offline
// without being in PRECACHE. importScripts rather than the fetch-and-eval
// module loader the router came from: it is the one the platform supports in a
// worker, and it costs no waterfall.
importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js'); importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js');
const NETWORK_TIMEOUT_MS = 3000; const NETWORK_TIMEOUT_MS = 3000;
// The background download gets a longer leash than a page the reader is
// waiting on: nothing is blocked on it, and giving up early on a slow link
// only means fetching the same book again later.
const DOWNLOAD_TIMEOUT_MS = 30000; const DOWNLOAD_TIMEOUT_MS = 30000;
// Few enough to leave the connection room for the page being read, more than
// one so a slow response does not stall the queue. The old code asked for
// every file at once, which is what made a weak connection collapse.
const DOWNLOAD_AT_ONCE = 4; const DOWNLOAD_AT_ONCE = 4;
// All three are stamped in by server.py from the files actually on disk, so an
// edit reaches installed devices with no build step: the worker's bytes
// change, the browser sees a new worker, and stale caches go on activate. The
// values below are the standalone fallback.
//
// The two versions are deliberately separate, over the two directories the
// server puts together. VERSION covers the hand-written chrome in assets/,
// templates included, so editing the shell drops and refetches a few hundred
// kilobytes. The other covers the generated pages in bible/, which change only
// when compile_bible.py runs -- so a change of chrome no longer costs every
// device the whole corpus again.
const VERSION = 'dev'; const VERSION = 'dev';
const LIBRARY_VERSION = 'dev'; const LIBRARY_VERSION = 'dev';
const LIBRARY = []; const LIBRARY = [];
@@ -82,23 +13,10 @@ const LIBRARY = [];
const SHELL_CACHE = 'bible-shell-' + VERSION; const SHELL_CACHE = 'bible-shell-' + VERSION;
const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION; const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION;
// The same two values, handed to the shell so a device can be asked what it is
// running. A phone that has kept an old worker is the hardest thing here to
// see from the outside, and these are what say so. See templates/version.njk.
const VERSIONS = { shell: VERSION, library: LIBRARY_VERSION }; const VERSIONS = { shell: VERSION, library: LIBRARY_VERSION };
// Resolved once, and the set is the test for which cache a response belongs
// in. Membership, not a path pattern: the split is whichever directory the
// file came from, which is a fact only the server has.
const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href)); const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href));
// The shell and what it needs to paint. "./" -- the book list -- is in here as
// well as in the library: it is the one page the app falls back to, so it
// should not have to wait for the download to reach it.
//
// boot.html is here for the one case it can still be reached with a worker
// installed: a reload that bypasses this file. The three scripts in js/ that
// this file imports are not, because importScripts already keeps them.
const PRECACHE = [ const PRECACHE = [
'./boot.html', './boot.html',
'./', './',
@@ -108,10 +26,6 @@ const PRECACHE = [
'./js/app.js', './js/app.js',
'./js/htmx.min.js', './js/htmx.min.js',
'./js/bootstrap.bundle.min.js' './js/bootstrap.bundle.min.js'
// The templates come from render.js, which is the file that knows what one is
// -- and which loads exactly this list into nunjucks before it renders
// anything. Two lists would be one list and a way for the renderer to be
// asked for a template that was never cached.
].concat(TEMPLATE_URLS); ].concat(TEMPLATE_URLS);
function cacheFor(req) { function cacheFor(req) {
@@ -121,8 +35,6 @@ function cacheFor(req) {
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil((async () => { event.waitUntil((async () => {
const cache = await caches.open(SHELL_CACHE); const cache = await caches.open(SHELL_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; let failed = 0;
await Promise.all(PRECACHE.map((url) => await Promise.all(PRECACHE.map((url) =>
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; }))); cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
@@ -141,9 +53,6 @@ self.addEventListener('activate', (event) => {
})()); })());
}); });
// A dot in the last segment means a file that exists on disk: a script, a
// stylesheet, an icon, a template. Page URLs never have one, so this is the
// whole boundary between what the router owns and what is simply served.
function isFile(url) { function isFile(url) {
return url.pathname.split('/').pop().indexOf('.') !== -1; return url.pathname.split('/').pop().indexOf('.') !== -1;
} }
@@ -153,17 +62,10 @@ self.addEventListener('fetch', (event) => {
const url = new URL(req.url); const url = new URL(req.url);
if (url.origin !== location.origin) return; if (url.origin !== location.origin) return;
// The background download rides along on whatever the page is already
// asking for. It gets no event of its own: activate cannot be used, because
// waitUntil there holds back activation and every fetch with it, and a
// worker is only kept alive while an event of its own is pending. So each
// request the reader makes is what carries the download forward.
keepFilling(event); keepFilling(event);
if (!isFile(url)) { if (!isFile(url)) {
const routed = router.handle(event, url); const routed = router.handle(event, url);
// A route that declines -- by returning nothing -- leaves the URL to be
// served as a file, the same as one that never matched.
if (routed) { if (routed) {
return event.respondWith(routed.then((res) => res || answer(req, event))); return event.respondWith(routed.then((res) => res || answer(req, event)));
} }
@@ -173,10 +75,6 @@ self.addEventListener('fetch', (event) => {
event.respondWith(answer(req, event)); event.respondWith(answer(req, event));
}); });
// ---------------------------------------------------------------- fetching
// Aborted rather than raced, so a hung request is actually torn down instead
// of leaking while the cache answers.
async function fromNetwork(req, timeout) { async function fromNetwork(req, timeout) {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
@@ -191,10 +89,6 @@ function cached(req) {
return caches.match(req, { ignoreSearch: true }); return caches.match(req, { ignoreSearch: true });
} }
// A string argument becomes a fresh Request, which matters beyond
// convenience: it is not a navigation, so the origin server can tell an
// internal fetch from a browser one and hand back the fragment rather than
// the boot page.
async function answer(input, event) { async function answer(input, event) {
const req = typeof input === 'string' ? new Request(input) : input; const req = typeof input === 'string' ? new Request(input) : input;
@@ -202,7 +96,6 @@ async function answer(input, event) {
const res = await fromNetwork(req); const res = await fromNetwork(req);
if (res && res.ok && res.type === 'basic') { if (res && res.ok && res.type === 'basic') {
const copy = res.clone(); const copy = res.clone();
// Not awaited: the response should not wait on the cache write.
const write = cacheFor(req).then((c) => c.put(req, copy)); const write = cacheFor(req).then((c) => c.put(req, copy));
if (event) event.waitUntil(write); if (event) event.waitUntil(write);
else write.catch(() => {}); else write.catch(() => {});
@@ -215,35 +108,16 @@ async function answer(input, event) {
} }
} }
// Pages are always asked for with revalidation forced.
//
// server.py marks them no-cache, which asks the browser for the same thing --
// but this worker's own fetch goes through the browser's HTTP cache too, so a
// page stored under an earlier and wronger Cache-Control keeps being served
// from it until that entry expires, however many times the library version
// changes underneath. Saying it on the request is what gets past one that
// already is. Costs a conditional GET, which the response header was asking
// for anyway.
function pageRequest(url) { function pageRequest(url) {
return new Request(url, { cache: 'no-cache' }); 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) { async function readText(input, event) {
const res = await answer(input, event); const res = await answer(input, event);
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input)); if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
return res.text(); return res.text();
} }
// --------------------------------------------------------------- templates
// Templates are read cache-first, alone among everything here. They are
// precached on install, and editing one changes VERSION, which installs a new
// worker with a new cache -- so a hit cannot be stale. Going to the network
// for them would put six round trips in front of the first page every time the
// browser has reaped this worker, which it does constantly.
async function readTemplate(path) { async function readTemplate(path) {
const hit = await cached(path); const hit = await cached(path);
return hit ? hit.text() : readText(path); return hit ? hit.text() : readText(path);
@@ -251,31 +125,12 @@ async function readTemplate(path) {
const view = createRenderer(readTemplate); const view = createRenderer(readTemplate);
// ------------------------------------------------------------------ routes
// Patterns are relative to the worker's scope, so this is "/" and "/GEN" even
// if the app is ever served from a subdirectory.
const router = new Router(new URL('./', location.href).pathname); 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'; const VERSES_URL = './verses.json';
let verses = null; 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() { function readVerses() {
if (!verses) { if (!verses) {
verses = readText(pageRequest(VERSES_URL)) verses = readText(pageRequest(VERSES_URL))
@@ -285,10 +140,6 @@ function readVerses() {
return verses; 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) { function parseAnchor(id) {
const chapter = /^ch(\d+)$/.exec(id || ''); const chapter = /^ch(\d+)$/.exec(id || '');
if (chapter) return { chapter: +chapter[1], verse: null }; if (chapter) return { chapter: +chapter[1], verse: null };
@@ -305,40 +156,17 @@ function verseLabels(chapters, number) {
return out; 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. Anything this cannot read -- nothing at all, an empty select, a
// footnote id, a chapter past the end of the book -- is chapter 1, and chapter
// 1 is filled in as if it had been picked. There is no state where a chapter
// is only sort of chosen: a select showing an option is a select that chose
// it, and the verses beside it have to be that chapter's.
//
// Every book that gets a picker has verses in its first chapter -- the two
// that do not, the front matter and the glossary, are turned away above -- so
// falling back to 1 always fills the verse list rather than emptying it.
//
// The verse is left unselected when the anchor names only a chapter. That is
// not the same as unfilled: the options are chapter 1's, and the select shows
// the first of them. It says where you are going, not where you are.
function referenceFor(map, code, anchor) { function referenceFor(map, code, anchor) {
const chapters = map && Object.prototype.hasOwnProperty.call(map, code) const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
? map[code] : null; ? map[code] : null;
if (!chapters || !chapters.length) return 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))) { if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) {
return null; return null;
} }
const at = parseAnchor(anchor) || { chapter: 1, verse: null }; const at = parseAnchor(anchor) || { chapter: 1, verse: null };
const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1; const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1;
// Only when the anchor got as far as naming this chapter. A fallback to 1
// has not chosen a verse, it has just run out of anchor.
const verse = at.chapter === number ? at.verse : null; const verse = at.chapter === number ? at.verse : null;
return { return {
@@ -351,16 +179,10 @@ function referenceFor(map, code, anchor) {
}; };
} }
// The book a page URL is showing: "GEN" for /GEN, "" for the book list.
function bookOf(url) { function bookOf(url) {
return url ? router.relative(url).slice(1) : ''; 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) { async function referenceOf(code, anchor) {
if (!code) return null; if (!code) return null;
try { try {
@@ -370,23 +192,6 @@ async function referenceOf(code, anchor) {
} }
} }
// ------------------------------------------------------------------- verse
// The verse dialog's model: what to call this verse, and the two addresses for
// it. Null for anything that is not a verse of the book on screen.
//
// Checked against verses.json rather than taken at its word. at arrives from
// the page, and a dialog offering to bookmark an anchor the corpus does not
// have is a dialog offering a bookmark that lands nowhere -- so it is read
// back through the same map the options were built from, and the label comes
// out of that map rather than out of the id. Which is what makes a verse
// written "19-27" read as 26:19-27 and not as whatever splitting an id on an
// underscore would produce.
//
// href is relative, so a bookmark survives a change of host or port; url is
// absolute, because the only thing anyone does with it is paste it somewhere
// else. Both are spelled from the address bar, which is the only record of
// which book is on screen.
async function verseOf(page, at) { async function verseOf(page, at) {
const code = bookOf(page); const code = bookOf(page);
const anchor = parseAnchor(at); const anchor = parseAnchor(at);
@@ -409,9 +214,6 @@ const OFFLINE_NOTICE = ['Not available offline',
'here. Reconnect and try again, or open one you have read before.']; 'here. Reconnect and try again, or open one you have read before.'];
const MISSING_NOTICE = ['Not found', 'There is no page at this address.']; const MISSING_NOTICE = ['Not found', 'There is no page at this address.'];
// The fragment a page URL names, or one explaining why there is none. A page
// asks the origin for its own address -- /GEN fetches ./GEN -- and server.py
// is what knows the file behind it is bible/GEN.html.
async function fragmentFor(path, event) { async function fragmentFor(path, event) {
let res; let res;
try { try {
@@ -422,115 +224,40 @@ async function fragmentFor(path, event) {
return res.ok ? res.text() : view.notice(...MISSING_NOTICE); return res.ok ? res.text() : view.notice(...MISSING_NOTICE);
} }
// 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.
//
// 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) { async function servePage(path, code, call) {
const fragment = await fragmentFor(path, call.event); let fragment = await fragmentFor(path, call.event);
if (!code) {
fragment += '\n' + await view.render('version.njk', { version: VERSIONS });
}
const reference = await referenceOf(code, null); const reference = await referenceOf(code, null);
if (!call.navigate && !call.restore) { 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 its toast: 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' return fragment + '\n'
+ await view.render('reference.njk', { reference: reference, oob: true }); + await view.render('reference.njk', { reference: reference, oob: true });
} }
try { try {
return await view.compose(fragment, { mark: await getBookmark(), return await view.compose(fragment, { mark: await getBookmark(),
reference: reference, reference: reference });
version: VERSIONS });
} catch (err) { } 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
// chrome and no styling, and it is still the page that was asked for.
console.error('compose failed: ' + err.message); console.error('compose failed: ' + err.message);
return fragment; return fragment;
} }
} }
// "/" before "/:page", because both are two segments and the first match wins.
router.get('/', (call) => servePage('./', '', call)); router.get('/', (call) => servePage('./', '', call));
router.get('/:page', (call) => router.get('/:page', (call) =>
servePage('./' + call.params.page, call.params.page, call)); servePage('./' + call.params.page, call.params.page, call));
// The picker, and the two moves it makes.
//
// no parameters the whole picker, standing where the anchor in the address
// bar puts it. Asked for by the picker itself: on load, and
// after a history restore -- which swaps the history element
// straight in and processes no out-of-band elements.
// at=ch3 the verses of that chapter, and land on the chapter.
// at=v3_16 the same verses, that one selected, and land on the verse.
//
// One parameter for both selects: at is the id of the element to land on, and
// ch3 and v3_16 are told apart by their shape. Which select it came from is
// not a second fact.
//
// Neither the book nor the anchor is in the URL, deliberately. Both come from
// HX-Current-URL, which htmx sets from location.href on every request it
// makes: the whole address, fragment included.
//
// For the book, because the picker sits in the navbar and outlives the page
// under it -- an address baked into its attributes is the one thing here that
// can go stale. For the anchor, because this is the only way a fragment
// reaches this file at all. A browser opening /GEN#v3_16 asks for /GEN and
// keeps the rest to itself, so the shell is composed without it and the picker
// is rendered at 1:1 like any other. The page then asks this route for itself,
// and that request is the one carrying the anchor.
//
// Which also puts the picker where it belongs after a back or forward, for
// nothing extra: those requests carry the address bar too.
//
// Two response headers carry what a change means beyond the options:
//
// settle:0 swap the options now. The default waits 20ms, and this
// answer is one the reader is waiting on.
// HX-Replace-Url put the anchor in the address bar without stacking a
// history entry per option spun past.
//
// The scroll used to be a third, show:#id:top, and is app.js's again. Not
// because the header was wrong but because the moment was: on iOS the change
// event arrives while the native picker is still drawn over the page, and
// dismissing it restores the scroll offset it opened at -- so a scroll made
// inside the swap is undone before it is seen. The page has to be the one
// doing it, because only the page is still there afterwards to try again.
// See landOnReference() in js/app.js. The address bar above is how it knows
// where to go: htmx updates history before it swaps.
router.get('/api/reference', async (call) => { router.get('/api/reference', async (call) => {
const params = call.url.searchParams; const params = call.url.searchParams;
const page = call.currentUrl(); const page = call.currentUrl();
// Set when a select was moved -- by whether the parameter is there, not by
// whether it says anything. A select posts an empty value when it has no
// options, and reading that as "no select moved" answers it with a whole
// picker: htmx swaps a <div> into a <select>, which renders as nothing, and
// the select is then empty for good -- every change after that posts the
// same empty value and gets the same answer.
//
// An empty value is still an answerable question. It names no chapter, so it
// gets chapter 1, which is what everything here does with an anchor it
// cannot read.
const moved = params.has('at') ? params.get('at') : null; const moved = params.has('at') ? params.get('at') : null;
// The address bar is the fallback, and only that: once a select has been
// used it is the one saying where to go, and HX-Replace-Url has not caught
// up yet.
const reference = await referenceOf( const reference = await referenceOf(
bookOf(page), moved === null ? (page && page.hash.slice(1)) : moved); bookOf(page), moved === null ? (page && page.hash.slice(1)) : moved);
// settled, because this picker is already standing where the anchor put it.
// The one that came with the page is not, and carries a load trigger to ask
// -- which this answer must not carry as well, or it would ask forever.
if (moved === null) { if (moved === null) {
return view.render('reference.njk', { reference: reference, settled: true }); return view.render('reference.njk', { reference: reference, settled: true });
} }
// 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 }); if (!reference) return new Response('', { status: 204 });
const target = reference.verse || reference.chapter; const target = reference.verse || reference.chapter;
@@ -544,18 +271,6 @@ router.get('/api/reference', async (call) => {
}); });
}); });
// The dialog for one verse.
//
// at=v1_3 which verse, by the id of the element that is it -- the same
// spelling the picker's options use, read back the same way.
//
// The book is not in the URL, for the reason it is not in the picker's: this
// is chrome, it outlives the page under it, and HX-Current-URL is the address
// the page is actually showing.
//
// 204 for anything it cannot place. htmx swaps nothing on a 204, and the
// dialog opens on the swap, so a bad anchor is a click that does nothing
// rather than an empty dialog asking to bookmark it.
router.get('/api/verse', async (call) => { router.get('/api/verse', async (call) => {
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at')); const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
if (!verse) return new Response('', { status: 204 }); if (!verse) return new Response('', { status: 204 });
@@ -570,10 +285,6 @@ router.post('/api/bookmark', async (call) => {
if (!ref || !href) return new Response('', { status: 400 }); if (!ref || !href) return new Response('', { status: 400 });
await withStore('readwrite', await withStore('readwrite',
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY)); (s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
// Only on the way out of a write that committed. base.njk closes the
// dialog on this, and there is nothing left in the dialog worth keeping
// open for: the confirmation rides out of band into the toast container,
// which is not inside it.
return new Response(await bookmarkFragment('bookmarked'), { return new Response(await bookmarkFragment('bookmarked'), {
headers: { headers: {
'Content-Type': 'text/html; charset=utf-8', 'Content-Type': 'text/html; charset=utf-8',
@@ -582,19 +293,10 @@ router.post('/api/bookmark', async (call) => {
} }
}); });
} catch (err) { } catch (err) {
// No trigger, so the dialog stays where it is. The toast says what
// happened whether or not it does, but a dialog that closed on a failed
// write would look exactly like one that closed on a successful one.
return bookmarkFragment('bookmark-refused'); return bookmarkFragment('bookmark-refused');
} }
}); });
// ------------------------------------------------------------------ toasts
// Everything the app says back to a reader who has just done something. Named
// rather than passed as text: the page asks for 'copied', not for a sentence,
// so the wording is here with the rest of the app's words and an attribute in
// a template never has to spell one out. bad is the ones that did not happen.
const TOASTS = { const TOASTS = {
'copied': { text: 'Link copied.' }, 'copied': { text: 'Link copied.' },
'copy-refused': { text: 'Copy the link above: this browser would not ' 'copy-refused': { text: 'Copy the link above: this browser would not '
@@ -604,43 +306,22 @@ const TOASTS = {
+ 'unavailable.', bad: true } + 'unavailable.', bad: true }
}; };
// oob is how it travels: on its own to the toast container, which is what the
// copy button asks for, or out of band on an answer that is already going
// somewhere else, which is what a bookmark write sends.
function toastFragment(say, oob) { function toastFragment(say, oob) {
const toast = TOASTS[say]; const toast = TOASTS[say];
return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob }); return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob });
} }
// One toast, by name.
//
// say=copied which one, from the table above.
//
// 204 for a name that is not in it, on the same reasoning api/verse 204s for a
// verse that is not there: htmx swaps nothing, so a typo appends nothing,
// rather than an empty toast sliding up to say nothing at all.
router.get('/api/toast', (call) => { router.get('/api/toast', (call) => {
const say = call.url.searchParams.get('say'); const say = call.url.searchParams.get('say');
if (!TOASTS[say]) return new Response('', { status: 204 }); if (!TOASTS[say]) return new Response('', { status: 204 });
return toastFragment(say, false); return toastFragment(say, false);
}); });
// ----------------------------------------------------------------- library let filling = null;
let complete = false;
// The background download of the whole corpus.
//
// There is no progress to keep: the cache is the record. Each run asks it what
// is already there, which is what makes this survive the worker being shut
// down mid-run, resume on the next visit, and stay honest when a page arrives
// some other way -- reading a book puts it in the same cache.
let filling = null; // the run in flight, so two do not start at once
let complete = false; // a run got through the whole list; stop looking
function keepFilling(event) { function keepFilling(event) {
if (complete) return; if (complete) return;
// navigator.onLine is only ever trustworthy when it says false, which is the
// direction that matters: it stops a doomed run being started, and retried,
// on every request while the device is plainly offline.
if (!filling && self.navigator.onLine !== false) { if (!filling && self.navigator.onLine !== false) {
filling = fillLibrary().catch(() => {}).finally(() => { filling = null; }); filling = fillLibrary().catch(() => {}).finally(() => { filling = null; });
} }
@@ -658,8 +339,6 @@ async function fillLibrary() {
const runners = []; const runners = [];
for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run()); for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run());
await Promise.all(runners); await Promise.all(runners);
// Only after a run that was never cut short: anything else leaves pages
// outstanding, and the next request should look again.
if (!stopped) complete = true; if (!stopped) complete = true;
async function run() { async function run() {
@@ -667,30 +346,18 @@ async function fillLibrary() {
const url = missing.pop(); const url = missing.pop();
try { try {
const res = await fromNetwork(pageRequest(url), DOWNLOAD_TIMEOUT_MS); const res = await fromNetwork(pageRequest(url), DOWNLOAD_TIMEOUT_MS);
// A response that arrived but is not usable -- a page that has since
// been removed, say -- is skipped rather than retried. The connection
// is plainly fine, and stopping for it would strand everything after.
if (res.ok && res.type === 'basic') await cache.put(url, res); if (res.ok && res.type === 'basic') await cache.put(url, res);
} catch (err) { } catch (err) {
// Stop the whole run rather than grinding through a hundred certain
// failures. The next request starts a fresh one, so reconnecting is
// all it takes to carry on.
stopped = true; stopped = true;
} }
} }
} }
} }
// ---------------------------------------------------------------- bookmark
const DB_NAME = 'bible'; const DB_NAME = 'bible';
const DB_STORE = 'kv'; const DB_STORE = 'kv';
const BOOKMARK_KEY = 'bookmark'; const BOOKMARK_KEY = 'bookmark';
// There is no way to clear a bookmark, so there is no empty state: a reader
// who has never set one is treated as being at the beginning. Everything
// downstream can assume a mark -- bookmark.njk's empty branch is for the boot
// page, which has no worker to ask, not for a reader without one.
const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' }; const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' };
function openDb() { function openDb() {
@@ -702,8 +369,6 @@ function openDb() {
}); });
} }
// Resolved on the transaction rather than the request, so the value is only
// reported once the write it belongs to has actually committed.
async function withStore(mode, fn) { async function withStore(mode, fn) {
const db = await openDb(); const db = await openDb();
try { try {
@@ -718,18 +383,12 @@ async function withStore(mode, fn) {
} }
} }
// Storage being unavailable degrades to the default rather than a broken page,
// which is the same fallback as never having set one.
function getBookmark() { function getBookmark() {
return withStore('readonly', (s) => s.get(BOOKMARK_KEY)) return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
.catch(() => null) .catch(() => null)
.then((mark) => mark || DEFAULT_BOOKMARK); .then((mark) => mark || DEFAULT_BOOKMARK);
} }
// The navbar slot, and the toast that says what just happened to it. The
// toast rides out of band, so one response updates the slot and raises it
// without the page having to ask for the second one -- same template and same
// table api/toast answers from, so the two cannot drift apart.
async function bookmarkFragment(say) { async function bookmarkFragment(say) {
return await view.render('bookmark.njk', { mark: await getBookmark() }) return await view.render('bookmark.njk', { mark: await getBookmark() })
+ '\n' + await toastFragment(say, true); + '\n' + await toastFragment(say, true);
-118
View File
@@ -1,52 +1,14 @@
{# 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),
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
chrome here is what uses the framework. #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
{# No viewport-fit=cover, deliberately. It extends the page into the notch
and the home indicator, which only makes sense paired with safe-area-inset
padding in the stylesheet -- and without that padding on iOS the first line
of scripture sits under the clock. Paired with the status bar style below:
change one and the other has to change too. #}
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title | safe }}{% endblock %}</title> <title>{% block title %}{{ title | safe }}{% endblock %}</title>
{# Vendored rather than linked from a CDN: sw.js only caches same-origin
responses, so a CDN stylesheet would leave the app unstyled offline. #}
<link rel="stylesheet" href="css/bootstrap.min.css"> <link rel="stylesheet" href="css/bootstrap.min.css">
{# app.css comes second so its variable overrides win. It styles the
scripture; Bootstrap styles the chrome. #}
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/app.css">
{# History entries are whole books, and htmx keeps its history cache in
localStorage -- a couple of them would blow the quota. Turned off, so every
back or forward is a cache miss, and refreshOnHistoryMiss decides what a
miss does.
False on purpose. True makes htmx call location.reload(true) -- a forced
reload, which is exactly the kind of navigation a browser takes past the
service worker, so it reaches server.py and comes back as boot.html: the
spinner flashes by and then reloads into the real page. False makes htmx
fetch the page over XHR instead, which the worker answers itself, from its
own cache when offline. No navigation, no boot page, nothing to flash.
That request carries HX-History-Restore-Request, and the worker answers it
with a whole composed document; htmx lifts the element marked
hx-history-elt out of the response and puts it back in the page. #}
<meta name="htmx-config" <meta name="htmx-config"
content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'> content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'>
{# The bundle carries Popper, which the navbar collapse and the modal do not
need -- but a dropdown or tooltip added later would, and this is one file
in the precache either way. Deferred scripts run in order, so `bootstrap`
is defined by the time app.js runs. #}
<script src="js/bootstrap.bundle.min.js" defer></script> <script src="js/bootstrap.bundle.min.js" defer></script>
{# htmx is not deferred: hx-boost has to be in place before the first click. #}
<script src="js/htmx.min.js"></script> <script src="js/htmx.min.js"></script>
<script src="js/app.js" defer></script> <script src="js/app.js" defer></script>
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
@@ -54,22 +16,9 @@
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
{# black, not black-translucent. Translucent makes iOS draw the status bar
over the web view and reserve no space for it, so the navbar starts at the
very top of the screen with the clock and battery on top of it. black keeps
the status bar opaque and the view below it, which is what this app wants:
there is no full-bleed artwork here to show through. #}
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Bible"> <meta name="apple-mobile-web-app-title" content="Bible">
</head> </head>
{# Boosted links swap only the fragment, so the navbar, the modal and the
scripts above survive navigation and never reload.
No show: modifier, deliberately. htmx adds show:top itself for a boosted
link, but skips it when the href carries a fragment identifier and scrolls
to that anchor after the swap instead. Spelling show:top out here overrides
that decision for every link, which leaves a cross-reference like ISA#v61_6
sitting at the top of Isaiah. #}
<body class="{% block body_class %}{% endblock %} bg-body-tertiary" <body class="{% block body_class %}{% endblock %} bg-body-tertiary"
hx-boost="true" hx-target="#content" hx-swap="innerHTML"> hx-boost="true" hx-target="#content" hx-swap="innerHTML">
@@ -94,31 +43,8 @@
</div> </div>
</nav> </nav>
{# col-xl-8 keeps a line of verse near a readable measure instead of running
the full width of a desktop window. #}
<main class="container-xl my-4"> <main class="container-xl my-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
{# The swap target. hx-target on <body> names this id, so every boosted
link lands here -- remove it and a click does nothing at all while a
full navigation still works.
It is also where a verse click becomes a request. htmx's bootstrap-modal
example puts hx-get on the element that opens the dialog; the element
here is a verse number in a generated book, and there are 38,000 of
them -- an attribute apiece would cost more than the whole shell, and a
listener apiece more than the reading. So one handler sits on the
container they all swap into, and names the verse it found. Delegated,
so it survives every swap: this element is never replaced, only filled.
hx-vals cannot do this. htmx evaluates it as Function("return (...)")()
with nothing passed in, so an expression there can read the document but
not the click -- and which verse was clicked is the one thing that has
to travel.
Enter and space go through the same door rather than a second one:
compile_bible.py writes role=button and tabindex on every verse, and a
button that answers the keyboard by being clicked is a button with one
behaviour instead of two. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt <div id="content" class="col-12 col-xl-8" hx-history-elt
hx-on:click="var verse = event.target.closest('.v[id]'); hx-on:click="var verse = event.target.closest('.v[id]');
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id, if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
@@ -131,37 +57,8 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>
{% include "version.njk" %}
</main> </main>
{# The verse dialog: empty in the page, filled per request. Bootstrap owns
showing it, dismissing it, the backdrop and Escape; sw.js owns everything
inside, and templates/verse.njk is what lands in the modal-content below.
Opened by the arrival of the content rather than by data-bs-toggle on
whatever was clicked. The example puts that attribute on its trigger, which
is not available here for the same reason hx-get is not -- see #content
above -- and the swap is the honest signal anyway: there is nothing worth
showing until it is here. A verse the corpus does not have is answered 204,
which swaps nothing, which opens nothing.
after-swap and not after-settle: settling is the 20ms attribute pass and
this dialog has nothing to settle. Only the modal-content swap can reach
here -- the bookmark write targets the navbar slot, and the toast it sends
back rides in out of band, which fires its own event and not this one.
verse-done is the dialog saying it has finished its job, and it arrives
from both of the things in it: sw.js sends it as a trigger header on a
bookmark write that committed, and the copy button dispatches it when the
clipboard has actually taken the link. Neither sends it when the action
failed -- the field in the dialog is the fallback for a copy that was
refused, and there is nothing to take down the dialog for when there is
nothing to celebrate.
Closed at once rather than after a pause. The pause was there to let the
status line inside be read before the dialog took it away; the line is a
toast now, outside and above all of this, so it outlives the dialog and
there is nothing left to wait for. #}
<div class="modal fade" id="verse-dialog" tabindex="-1" <div class="modal fade" id="verse-dialog" tabindex="-1"
aria-labelledby="verse-dialog-ref" aria-hidden="true" aria-labelledby="verse-dialog-ref" aria-hidden="true"
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();" hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
@@ -171,21 +68,6 @@
</div> </div>
</div> </div>
{# Where confirmations land. Empty in the page and filled a toast at a time:
api/toast appends one when the copy button asks for it, and a bookmark
write sends one out of band on its own answer. templates/toast.njk is what
lands here, and each one shows and then removes itself, so this is empty
again a few seconds later.
Fixed, and outside #content, for the same reason the dialog is: it has to
survive the swap under it. It also has to survive the dialog -- a copy
confirmed by a toast inside the modal would go when the modal did.
Bottom right, and last in the document, so it covers the text rather than
moving it, and so nothing in the reading order comes after it. It sits
above the modal without being asked to: Bootstrap gives .toast a z-index of
1090 against the modal's 1055, and this container takes none of its own,
which is what leaves that comparison to the toast. #}
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div> <div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
</body> </body>
-16
View File
@@ -1,21 +1,5 @@
{# The navbar's bookmark slot. Rendered into the shell with whatever mark the
worker holds, and returned on its own by api/bookmark after a write. Both
paths must produce the same element for the swap to be seamless.
Always filled: getBookmark() answers with the default when nothing has been
set and when storage cannot be read at all, so there is no empty state to
render. There was one -- a hidden slot that fetched itself once a worker
turned up -- for pages boot.html composed before there was a worker to ask.
Nothing composes a page but the worker now, and the worker always knows. #}
<span class="bookmark navbar-text d-flex align-items-center gap-1" id="bookmark"> <span class="bookmark navbar-text d-flex align-items-center gap-1" id="bookmark">
{# A word and a colon, not a badge: this is one line of text that happens to
end in a link, and a pill in front of it read as a control. Outside the
anchor so that only the reference is underlined -- Reboot underlines an
<a>, and the label is not the part you can go to. It takes the muted
navbar colour from .navbar-text on the parent without asking. #}
<span class="bookmark-label">Bookmark:</span> <span class="bookmark-label">Bookmark:</span>
{# Not boosted: a real navigation lets the browser scroll to the verse
anchor, which a swap into #content would not do. #}
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}" <a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
hx-boost="false">{{ mark.ref }}</a> hx-boost="false">{{ mark.ref }}</a>
</span> </span>
-2
View File
@@ -1,7 +1,5 @@
{% extends "base.njk" %} {% extends "base.njk" %}
{% block body_class %}browsing{% endblock %} {% block body_class %}browsing{% endblock %}
{# Already on the index, so the nav entry marks the current page instead of
offering a link to it. #}
{% block navbar_nav %} {% block navbar_nav %}
{% endblock %} {% endblock %}
{% block content %}{{ content | safe }}{% endblock %} {% block content %}{{ content | safe }}{% endblock %}
-6
View File
@@ -1,9 +1,3 @@
{# A page that could not be delivered, written in the same shape as a real
one: a title and one root element naming the template that wraps it.
Sent with 200 on purpose. htmx only swaps a 2xx body, and an error status
would leave the reader looking at the page they came from with nothing to
say what happened. #}
<title>{{ heading }}</title> <title>{{ heading }}</title>
<div class="notice" data-template="page"> <div class="notice" data-template="page">
<h1>{{ heading }}</h1> <h1>{{ heading }}</h1>
-1
View File
@@ -1,3 +1,2 @@
{# Fallback for a fragment that names no template of its own. #}
{% extends "base.njk" %} {% extends "base.njk" %}
{% block content %}{{ content | safe }}{% endblock %} {% block content %}{{ content | safe }}{% endblock %}
+2 -67
View File
@@ -1,77 +1,15 @@
{# 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.
Three 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.
an anchor a link to GEN#v3_16 should open with the picker reading
3:16, and the fragment never reaches the worker: a browser
asks for /GEN and keeps the rest. So a picker that arrived
with the page asks once, on load, if there is an anchor to
ask about.
None of them carries the book or the anchor. The request does, in
HX-Current-URL, which htmx sets from location.href -- the address the page
is actually showing, fragment and all. Baking either into these attributes
is the one thing here that can go stale, because the picker outlives the
page it was rendered for.
settled is what stops the load trigger looping: the answer to it is a
picker already standing in the right place, and it says so by leaving the
trigger out of the copy it sends back.
!!location.hash, and not location.hash, because htmx keeps an event only
when the filter returns boolean true exactly -- it tests the result with
!== true -- and a hash is a string. Written without the coercion the
trigger is dead: every link carrying an anchor lands with the picker still
reading 1:1, which is where the worker rendered it, because a browser
keeps the fragment to itself and the request that would correct it is
never sent. #}
<div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3" <div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3"
id="reference" id="reference"
{%- if oob %} hx-swap-oob="true"{% endif %} {%- if oob %} hx-swap-oob="true"{% endif %}
hx-get="api/reference" hx-get="api/reference"
{# htmx keeps an event only when the filter returns true exactly, so a
string is discarded. #}
hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}htmx:historyRestore from:body" hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}htmx:historyRestore from:body"
hx-target="this" hx-swap="outerHTML"> hx-target="this" hx-swap="outerHTML">
{%- if reference %} {%- if reference %}
<span class="reference-book text-light fw-semibold" <span class="reference-book text-light fw-semibold"
id="reference-book">{{ reference.book }}</span> id="reference-book">{{ reference.book }}</span>
<label class="visually-hidden" for="reference-chapter">Chapter</label> <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.
Which is why both selects post under the same name. ch3 and v3_16 are
told apart by their shape, so a second name saying which select it came
from would only be repeating what the value already says.
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" <select class="reference-chapter form-select form-select-sm"
id="reference-chapter" name="at" id="reference-chapter" name="at"
hx-get="api/reference" hx-target="#reference-verse" hx-get="api/reference" hx-target="#reference-verse"
@@ -83,9 +21,6 @@
</select> </select>
<span class="reference-sep text-light">:</span> <span class="reference-sep text-light">:</span>
<label class="visually-hidden" for="reference-verse">Verse</label> <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" <select class="reference-verse form-select form-select-sm"
id="reference-verse" name="at" id="reference-verse" name="at"
hx-get="api/reference" hx-target="this" hx-swap="innerHTML"> hx-get="api/reference" hx-target="this" hx-swap="innerHTML">
-29
View File
@@ -1,29 +1,3 @@
{# One confirmation: something the reader just did, said once and then gone.
Bootstrap's custom-content shape -- a flex row of body and close button,
with no toast-header above it. The header carries a title, an icon and a
timestamp, which is what a notification arriving from elsewhere needs; this
is the answer to a button pressed a second ago, and the sentence is the
whole of it.
Context: text, bad (it did not happen), oob.
Two ways in, the same element either way. api/toast renders it alone and
htmx appends it to the container; the bookmark write renders it with oob
set and it rides along on the answer that updates the navbar slot -- one
response, both halves of the page, the way the modal's status line used to.
The wrapper is how an out-of-band element with no id of its own says where
it goes: htmx appends the wrapper's children to the named container and
drops the wrapper itself.
It shows itself on arrival and removes itself once it has gone. hx-on::load
is htmx's own event, fired on content it has just inserted and processed,
so it covers both ways in -- and it is the same move base.njk makes to open
the dialog: the arrival of the content is the signal to show it. Bootstrap
owns the timer, the fade, the close button and Escape from there. Removing
the element on hidden.bs.toast is what keeps the container from filling up
with dead markup: a toast that has been read is not history, and nothing
here ever looks at it again. #}
{% if oob %}<div hx-swap-oob="beforeend:#toasts">{% endif %} {% if oob %}<div hx-swap-oob="beforeend:#toasts">{% endif %}
<div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}" <div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}"
role="alert" aria-live="assertive" aria-atomic="true" role="alert" aria-live="assertive" aria-atomic="true"
@@ -31,9 +5,6 @@
hx-on:hidden.bs.toast="this.remove();"> hx-on:hidden.bs.toast="this.remove();">
<div class="d-flex"> <div class="d-flex">
<div class="toast-body">{{ text }}</div> <div class="toast-body">{{ text }}</div>
{# me-2 m-auto, not ms-auto: the row is body then button, and the body is
what grows -- the button only has to sit centred against however many
lines it turns out to be. #}
<button type="button" <button type="button"
class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto" class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button> data-bs-dismiss="toast" aria-label="Close"></button>
-38
View File
@@ -1,24 +1,3 @@
{# The verse dialog's contents: what this verse is called, a link to it, and
the one thing you can do with it.
Rendered by sw.js for the verse that was clicked and swapped into the empty
modal-content in base.njk -- the shape htmx's bootstrap-modal example uses.
Bootstrap owns showing, dismissing, the backdrop and Escape; everything
here is what is inside.
Context: verse (ref, href, url).
All three are built in the worker, from the book in the address bar and the
anchor that was clicked. app.js used to read them off the page -- the book
out of article[data-name], the chapter out of the enclosing section -- which
put the reference in two places and left the spelling of an anchor known to
both the file that generates them and the file that scrapes them back.
The link is a field rather than a line of text because it is the fallback
for the button beside it: navigator.clipboard exists only in a secure
context, so over plain http:// from a LAN address there is nothing to write
with and a selected field is the whole of what is left. Readonly, so it
reads as something to take rather than something to fill in. #}
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2> <h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" <button type="button" class="btn-close" data-bs-dismiss="modal"
@@ -30,27 +9,10 @@
type="text" readonly value="{{ verse.url }}"> type="text" readonly value="{{ verse.url }}">
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
{# The form is the bookmark write and nothing else. ref and href are hidden
because they are this dialog's whole subject: the reader chose them by
clicking a verse, and there is nothing here to edit. #}
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0" <form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML"> hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="{{ verse.ref }}"> <input type="hidden" name="ref" value="{{ verse.ref }}">
<input type="hidden" name="href" value="{{ verse.href }}"> <input type="hidden" name="href" value="{{ verse.href }}">
{# Selected first, written second: the selection is what makes the field
useful when the write is refused, and it costs nothing when it is not.
The toast is only raised once the clipboard has actually taken the link,
which is why the promise is answered rather than assumed.
Both outcomes are a toast, and neither is written here: api/toast is
asked for one by name, so the wording lives in the worker with the rest
of the app's words rather than in the middle of an attribute. It is the
same request the dialog itself arrives on -- htmx, the worker, a
fragment -- with the container in base.njk as the target.
Only a copy that happened ends the dialog. A refused one leaves it
standing, because the field above it, selected, is the whole of what is
left to copy with. #}
<button class="btn btn-outline-secondary" type="button" <button class="btn btn-outline-secondary" type="button"
hx-on:click="var button = this; hx-on:click="var button = this;
var field = document.getElementById('verse-dialog-url'); var field = document.getElementById('verse-dialog-url');
-3
View File
@@ -1,6 +1,3 @@
{# 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 -%} {% for verse in reference.verses -%}
<option value="{{ verse.id }}" <option value="{{ verse.id }}"
{%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option> {%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option>
+1 -20
View File
@@ -1,23 +1,4 @@
{# The footer under the book list: what this device is running. <div class="app-version text-center mt-5 mb-2">
Both digests are stamped into sw.js by server.py from the files on disk:
shell covers assets/, the hand-written chrome, and library covers bible/,
the generated pages. They are the same two values the worker names its
caches after, so a device showing a digest is a device serving that cache.
It says what the worker that composed this document was, not what is
controlling right now. An update installs, claims and starts answering
without this document being rebuilt, so the value only becomes current on
the next full load -- which is the reload you would do to check it anyway.
Rendered on every page and shown under the book list alone. Populating it
only for the index would look like the smaller change and would not work:
the shell is composed once per full navigation, and a boosted link to the
book list swaps #content without rebuilding anything around it. So the page
decides, not the composer -- app.js looks for the index in #content after
every swap. The body class this was rendered with cannot be asked, for the
same reason: it goes stale the moment a link is clicked. #}
<div class="app-version d-none text-center mt-5 mb-2" id="app-version">
<p class="app-version-digests form-text m-0"> <p class="app-version-digests form-text m-0">
shell {{ version.shell }} &middot; library {{ version.library }} shell {{ version.shell }} &middot; library {{ version.library }}
</p> </p>
+6 -118
View File
@@ -1,42 +1,4 @@
#!/usr/bin/env python3 """Static file server for the Bible site."""
"""Static file server for the Bible site.
Stdlib only, so the container needs nothing but a Python base image.
It serves two directories as one origin: assets/, which is hand-written, and
bible/, which utils/compile_bible.py generates from source/. Nothing is copied
between them -- every file exists once, in the directory that owns it.
Beyond serving files it has three jobs.
The first is the boot page. The pages here are fragments and the shell around
them is a template, so a browser asking for one directly would get a bare
fragment with no chrome. Navigations are answered with boot.html instead, which
registers the service worker and reloads once it is running -- landing on the
real app at the address that was asked for. Requests the worker and htmx make
are ordinary fetches rather than navigations, so they pass straight through and
get the fragment. Once a worker is installed it intercepts the navigation
first and composes it there, and this page is not reached at all: it is seen
once per device.
The second is stamping the service worker. A browser only notices a new worker
when the bytes of sw.js change, so something has to record what is on disk.
Doing it here rather than in a build step means editing a template is the whole
job: save the file, reload, and the version the browser fetches already
differs. It also stamps in the list of pages the worker downloads to keep the
whole corpus available offline. See stamp_worker().
The third is cache headers: the worker owns the offline copy, and if the
worker or the boot page that registers it are served stale, a new version can
never reach a device that already installed the old one.
Expects to sit behind something that terminates TLS, and does not work without
it. Every page is composed by the service worker, browsers only register one in
a secure context, and boot.html has no fallback that builds a page without one
-- so over plain http:// from a LAN address the boot page says it could not
install and stops there. localhost is a secure context, which is why running
this directly for development still works.
"""
from __future__ import annotations from __future__ import annotations
@@ -51,31 +13,18 @@ import sys
from functools import partial from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
# Revalidate every time: these are what carry an update to an installed app.
NO_CACHE_NAMES = {"sw.js"} 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. .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"} NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
# Served in place of any page a browser navigates to.
BOOT_PAGE = "/boot.html" BOOT_PAGE = "/boot.html"
WORKER = "sw.js" WORKER = "sw.js"
# The three lines stamp_worker() rewrites. They are real statements rather than
# placeholders so that assets/sw.js stays valid JavaScript on its own.
VERSION_LINE = "const VERSION = 'dev';" VERSION_LINE = "const VERSION = 'dev';"
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';" LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
LIBRARY_LINE = "const LIBRARY = [];" LIBRARY_LINE = "const LIBRARY = [];"
def walk(roots): def walk(roots):
"""Every served file, as url-relative paths mapped to their disk path.
Earlier roots win, which is what makes the two directories one origin.
"""
found = {} found = {}
for root in roots: for root in roots:
for dirpath, _dirs, names in os.walk(root): for dirpath, _dirs, names in os.walk(root):
@@ -85,54 +34,23 @@ def walk(roots):
found.setdefault(rel, path) found.setdefault(rel, path)
return found return found
def digest(files): def digest(files):
"""A short fingerprint of a set of files, by name, size and mtime.
Not by contents: it has to change whenever anything changes, and it is
recomputed on every request for the worker, so reading 9 MB of book
fragments to hash them would be a poor trade. Touching a file without
changing it costs one needless refetch, which is harmless.
"""
out = hashlib.sha256() out = hashlib.sha256()
for rel in sorted(files): for rel in sorted(files):
st = os.stat(files[rel]) st = os.stat(files[rel])
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode()) out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
return out.hexdigest()[:12] return out.hexdigest()[:12]
def page_url(rel): def page_url(rel):
"""The address a served file answers to.
bible/GEN.html is ./GEN, and bible/index.html is the root itself -- there
is no /index, the book list lives at /.
"""
if rel == "index.html": if rel == "index.html":
return "./" return "./"
return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel) return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel)
def stamp_worker(roots): def stamp_worker(roots):
"""assets/sw.js with its two versions and its file list filled in.
The split is by root, which is the same split the project already makes:
the first root is the hand-written chrome -- the worker, the boot page, and
the css/, js/ and templates/ directories -- and the rest is what
compile_bible.py generates. They get separate versions because they change on completely
different schedules -- editing the shell should not cost every installed
device the whole corpus again, which one shared version would.
LIBRARY is the list the worker downloads in the background. Stamped rather
than written by hand for the same reason the versions are: what is on disk
is the only honest source, and there is no build step to ask. It is written
in the URL space, not the disk one -- bible/GEN.html is "./GEN" and the
book list is "./" -- so that the worker caches a page under the address the
reader actually asks for.
"""
chrome = walk(roots[:1]) chrome = walk(roots[:1])
library = {rel: path for rel, path in walk(roots).items() if rel not in chrome} library = {rel: path for rel, path in walk(roots).items() if rel not in chrome}
src = chrome.pop(WORKER, None) # the browser revalidates the worker itself src = chrome.pop(WORKER, None)
if src is None: if src is None:
return None return None
with open(src, encoding="utf-8") as fh: with open(src, encoding="utf-8") as fh:
@@ -149,7 +67,6 @@ def stamp_worker(roots):
body = body.replace(line, value, 1) body = body.replace(line, value, 1)
return body.encode("utf-8") return body.encode("utf-8")
class Handler(SimpleHTTPRequestHandler): class Handler(SimpleHTTPRequestHandler):
extensions_map = { extensions_map = {
**SimpleHTTPRequestHandler.extensions_map, **SimpleHTTPRequestHandler.extensions_map,
@@ -157,7 +74,6 @@ class Handler(SimpleHTTPRequestHandler):
".css": "text/css; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8", ".json": "application/json; charset=utf-8",
# Only ever fetched as text, by the renderer.
".njk": "text/plain; charset=utf-8", ".njk": "text/plain; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8", ".webmanifest": "application/manifest+json; charset=utf-8",
".png": "image/png", ".png": "image/png",
@@ -168,20 +84,11 @@ class Handler(SimpleHTTPRequestHandler):
roots = () roots = ()
def translate_path(self, path): def translate_path(self, path):
"""Resolve against each root in turn, first hit wins.
The parent does the sanitising -- it is what keeps ".." from escaping
the root -- so this only ever swaps which root it resolves against.
"""
for root in self.roots: for root in self.roots:
self.directory = root self.directory = root
candidate = super().translate_path(path) candidate = super().translate_path(path)
if os.path.isfile(candidate): if os.path.isfile(candidate):
return candidate return candidate
# Pages are addressed without an extension -- /GEN, not /GEN.html.
# The worker's router owns a clean URL space and the file on disk
# stays an ordinary fragment; this is the one line that ties the
# two together.
if os.path.isfile(candidate + ".html"): if os.path.isfile(candidate + ".html"):
return candidate + ".html" return candidate + ".html"
if os.path.isfile(os.path.join(candidate, "index.html")): if os.path.isfile(os.path.join(candidate, "index.html")):
@@ -190,12 +97,6 @@ class Handler(SimpleHTTPRequestHandler):
return super().translate_path(path) return super().translate_path(path)
def send_head(self): def send_head(self):
# Sec-Fetch-Mode is the one signal that separates a browser opening a
# URL from the worker or htmx fetching a fragment: neither of those is
# ever in navigate mode. A browser too old to send it gets the bare
# fragment, unstyled and without chrome -- readable, but not the app.
# Guessing from Accept and HX-Request instead would misroute one of
# htmx's own requests the first time it guessed wrong, which is worse.
if self.headers.get("Sec-Fetch-Mode") == "navigate": if self.headers.get("Sec-Fetch-Mode") == "navigate":
self.path = BOOT_PAGE self.path = BOOT_PAGE
@@ -206,8 +107,8 @@ class Handler(SimpleHTTPRequestHandler):
def send_worker(self): def send_worker(self):
try: try:
body = stamp_worker(self.roots) body = stamp_worker(self.roots)
except Exception as err: # a broken stamp is a 500, not a except Exception as err:
self.log_message("stamp failed: %s", err) # silently stale worker self.log_message("stamp failed: %s", err)
self.send_error(500, "service worker unavailable") self.send_error(500, "service worker unavailable")
return None return None
if body is None: if body is None:
@@ -223,24 +124,13 @@ class Handler(SimpleHTTPRequestHandler):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
name = posixpath.basename(path) name = posixpath.basename(path)
ext = posixpath.splitext(name)[1].lower() ext = posixpath.splitext(name)[1].lower()
# A dot in the last segment means a file; anything else is a page. The # Pages have no extension, so testing the extension alone treats them
# same test the worker's isFile() makes, and it has to stay the same # as files and caches them for a week.
# one. Matching on the extension alone was right while pages were
# /GEN.html; the moment the URL space went extensionless it silently
# handed every book a week of browser caching that no rebuild could
# get past -- the worker's own fetch reads the browser cache too, so
# even a fresh library cache was being filled from a stale one.
if name in NO_CACHE_NAMES or ext in NO_CACHE_EXTS or "." not in name: if name in NO_CACHE_NAMES or ext in NO_CACHE_EXTS or "." not in name:
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
else: else:
self.send_header("Cache-Control", "public, max-age=604800") self.send_header("Cache-Control", "public, max-age=604800")
# One URL, two bodies: a navigation to /GEN gets the boot page and a
# fetch of it gets the fragment. Without this the browser cache keeps
# one entry for both, and the worker asking for the fragment it is
# about to compose is handed back the boot page instead -- which is a
# document nested inside a document, or a spinner where the text goes.
self.send_header("Vary", "Sec-Fetch-Mode") self.send_header("Vary", "Sec-Fetch-Mode")
# Lets the worker claim the whole origin even if it moves off root.
self.send_header("Service-Worker-Allowed", "/") self.send_header("Service-Worker-Allowed", "/")
self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Content-Type-Options", "nosniff")
super().end_headers() super().end_headers()
@@ -249,7 +139,6 @@ class Handler(SimpleHTTPRequestHandler):
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args)) sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args))
sys.stdout.flush() sys.stdout.flush()
def main(argv=None): def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -285,6 +174,5 @@ def main(argv=None):
httpd.server_close() httpd.server_close()
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+42 -265
View File
@@ -1,44 +1,4 @@
#!/usr/bin/env python3 """Compile the eBible.org HTML export into the fragments the app serves."""
"""Compile the eBible.org HTML export into the fragments the app serves.
One step, source to output. It reads a directory of eBible .htm files -- a flat
set of per-chapter pages, ``JHN03.htm``, plus a site index -- and writes
bible/GEN.html and so on, plus bible/index.html.
There used to be two scripts and a 15 MB bible.json between them. The JSON was
worth having when the corpus was going to be queried; nothing queries it, so
it was a second representation to keep in step for no one's benefit. The parse
tree now lives for as long as one process run.
The two halves are still clearly separated below, because they are genuinely
different jobs and the seam is where a second output format would attach:
READING eBible's generated markup is a flat sequence of
``<div class='...'>`` blocks whose class is the USFM paragraph
marker (``p``, ``q2``, ``s``, ...), with verse numbers, footnote
markers and character styles as inline spans. This half turns
that into a tree of blocks and inline nodes. Navigation and the
copyright footer are dropped; footnotes are kept.
WRITING That tree becomes one semantic fragment per book: a <title> and
one root element carrying data-template, not a document. The
shell around it is assembled from the nunjucks templates in
assets/templates/ by the service worker, 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 stylesheet without being
regenerated.
This is the only build step in the project, and bible/ is all it owns.
Its output is not committed: bible/ is in .gitignore, so a deploy runs this.
Everything hand-written lives in assets/ and is served straight from there.
Grouping worth knowing about: consecutive poetry lines are wrapped in a
<div class="poetry"> that a blank-line block closes, so stanzas are real
elements rather than spacing; consecutive list items become one <ul>.
python3 utils/compile_bible.py source -o bible
"""
from __future__ import annotations from __future__ import annotations
@@ -50,70 +10,54 @@ import re
import sys import sys
from html.parser import HTMLParser from html.parser import HTMLParser
# ==========================================================================
# READING -- eBible .htm into a tree of blocks and inline nodes
# ==========================================================================
# Chapter files are a three-character book code plus a zero-padded chapter
# number (three digits for Psalms).
CHAPTER_RE = re.compile(r"^([0-9A-Z]{3})(\d{2,3})\.htm$") CHAPTER_RE = re.compile(r"^([0-9A-Z]{3})(\d{2,3})\.htm$")
# Links between chapters: "JOB05.htm#V13", "JHN03.htm", or a bare "#V7".
HREF_RE = re.compile(r"^(?:([0-9A-Z]{3})(\d{2,3})\.htm)?(?:#V(\d+))?$") HREF_RE = re.compile(r"^(?:([0-9A-Z]{3})(\d{2,3})\.htm)?(?:#V(\d+))?$")
# Block-level div classes -> semantic block type.
BLOCK_TYPES = { BLOCK_TYPES = {
"p": "paragraph", # normal prose paragraph "p": "paragraph",
"m": "paragraph", # continuation, no first-line indent "m": "paragraph",
"nb": "paragraph", # no break with previous "nb": "paragraph",
"pi": "paragraph", # indented "pi": "paragraph",
"mi": "paragraph", # indented, no first-line indent "mi": "paragraph",
"pc": "paragraph", # centered "pc": "paragraph",
"ip": "paragraph", # introduction paragraph "ip": "paragraph",
"q": "line", # poetry line "q": "line",
"q2": "line", "q2": "line",
"q3": "line", "q3": "line",
"qs": "line", # Selah "qs": "line",
"b": "break", # blank line between stanzas "b": "break",
"d": "title", # descriptive title (psalm superscription, acrostic letter) "d": "title",
"s": "heading", # section heading "s": "heading",
"ms": "heading", # major section heading ("BOOK 3") "ms": "heading",
"is": "heading", # introduction heading "is": "heading",
"sp": "speaker", # speaker label (Song of Solomon) "sp": "speaker",
"li": "item", # list item "li": "item",
"ili": "item", # introduction list item (glossary entries) "ili": "item",
"mt": "booktitle", "mt": "booktitle",
"mt2": "booktitle", "mt2": "booktitle",
"mt3": "booktitle", "mt3": "booktitle",
} }
# Nesting depth for poetry lines and indented paragraphs.
INDENT = {"q": 1, "q2": 2, "q3": 3, "qs": 3, "pi": 1, "mi": 1, "li": 1, "ili": 1} INDENT = {"q": 1, "q2": 2, "q3": 3, "qs": 3, "pi": 1, "mi": 1, "li": 1, "ili": 1}
# Heading / title rank.
LEVEL = {"mt": 1, "mt2": 2, "mt3": 3, "ms": 1, "s": 2, "is": 2} LEVEL = {"mt": 1, "mt2": 2, "mt3": 3, "ms": 1, "s": 2, "is": 2}
# Character-style spans -> inline node type. ``None`` means unwrap the span and
# keep its children inline (``ft`` is just "footnote body text").
INLINE = { INLINE = {
"wj": "wj", # words of Jesus "wj": "wj",
"k": "keyword", # glossary keyword "k": "keyword",
"bk": "bookref", # book name cited in a note "bk": "bookref",
"fq": "fq", # the quoted words the footnote is about "fq": "fq",
"fqa": "fqa", # an alternative rendering "fqa": "fqa",
"fl": "fl", # footnote label "fl": "fl",
"fine": "fine", "fine": "fine",
"xt": "xt", # cross-reference target "xt": "xt",
"ft": None, "ft": None,
} }
# Divs whose entire subtree is navigation or licensing, not content.
DROP_CLASSES = {"copyright", "tnav", "vnav", "popup", "toc", "toc1", "toc2"} DROP_CLASSES = {"copyright", "tnav", "vnav", "popup", "toc", "toc1", "toc2"}
def parse_href(href): def parse_href(href):
"""Turn an intra-corpus link into a structured reference, or None."""
m = HREF_RE.match(href or "") m = HREF_RE.match(href or "")
if not m or not any(m.groups()): if not m or not any(m.groups()):
return None return None
@@ -126,18 +70,8 @@ def parse_href(href):
ref["verse"] = int(verse) ref["verse"] = int(verse)
return ref or None return ref or None
# --------------------------------------------------------------------------
# Whitespace
# --------------------------------------------------------------------------
def normalize(nodes): def normalize(nodes):
"""Collapse whitespace across the whole inline tree, in document order. state = {"prev_space": True}
Runs of whitespace become a single space and spaces are not duplicated
across element boundaries, so "word</span> <span>next" keeps exactly one.
"""
state = {"prev_space": True} # True at the start suppresses a leading space
def walk(lst): def walk(lst):
out = [] out = []
@@ -162,15 +96,7 @@ def normalize(nodes):
rstrip_last(result) rstrip_last(result)
return result return result
def rstrip_last(nodes): def rstrip_last(nodes):
"""Drop the trailing space on a tree's final text node.
Only a genuinely trailing node is stripped. A space before a block's
closing verse or note marker separates it from the preceding word and
has to survive, so the walk stops at the last node rather than looking
past markers for something to strip.
"""
if not nodes: if not nodes:
return False return False
node = nodes[-1] node = nodes[-1]
@@ -184,9 +110,7 @@ def rstrip_last(nodes):
return rstrip_last(node["c"]) return rstrip_last(node["c"])
return False return False
def plain_text(nodes, skip=("noteref",)): def plain_text(nodes, skip=("noteref",)):
"""Flatten an inline tree to readable text, dropping note markers."""
parts = [] parts = []
for node in nodes: for node in nodes:
if node["t"] in skip: if node["t"] in skip:
@@ -199,34 +123,20 @@ def plain_text(nodes, skip=("noteref",)):
parts.append(plain_text(node["c"], skip)) parts.append(plain_text(node["c"], skip))
return "".join(parts) return "".join(parts)
# --------------------------------------------------------------------------
# Chapter parser
# --------------------------------------------------------------------------
class ChapterParser(HTMLParser): class ChapterParser(HTMLParser):
"""Parse one chapter file into blocks + notes.
Content is appended to ``self.sink``, the inline-node list currently being
filled. A stack of frames records what to restore (and what to finish) as
each element closes; ``sink is None`` discards text, which is how
navigation and the copyright footer are dropped.
"""
def __init__(self): def __init__(self):
super().__init__(convert_charrefs=True) super().__init__(convert_charrefs=True)
self.blocks = [] self.blocks = []
self.notes = [] self.notes = []
self.titles = [] # mt/mt2/mt3, hoisted to the book self.titles = []
self.chapter_label = None self.chapter_label = None
self.is_toc = False # a chapter-0 navigation page self.is_toc = False
self.in_main = False self.in_main = False
self.stack = [] self.stack = []
self.sink = None self.sink = None
# -- frame helpers ----------------------------------------------------
def _push(self, tag, sink, on_close=None, **extra): def _push(self, tag, sink, on_close=None, **extra):
frame = {"tag": tag, "prev": self.sink, "close": on_close} frame = {"tag": tag, "prev": self.sink, "close": on_close}
frame.update(extra) frame.update(extra)
@@ -238,8 +148,6 @@ class ChapterParser(HTMLParser):
if self.sink is not None: if self.sink is not None:
self.sink.append(node) self.sink.append(node)
# -- HTMLParser hooks -------------------------------------------------
def handle_starttag(self, tag, attrs): def handle_starttag(self, tag, attrs):
attrs = dict(attrs) attrs = dict(attrs)
classes = (attrs.get("class") or "").split() classes = (attrs.get("class") or "").split()
@@ -251,7 +159,6 @@ class ChapterParser(HTMLParser):
return return
if not self.in_main: if not self.in_main:
# Everything before <div class="main"> is <head> and top nav.
if tag == "div" and "main" in classes: if tag == "div" and "main" in classes:
self.in_main = True self.in_main = True
self._push(tag, None) self._push(tag, None)
@@ -295,14 +202,11 @@ class ChapterParser(HTMLParser):
return return
if tag == "span" and cls == "notemark": if tag == "span" and cls == "notemark":
# The note's own marker inside the footnote list.
buf = [] buf = []
self._push(tag, buf, self._close_note_marker, nodes=buf) self._push(tag, buf, self._close_note_marker, nodes=buf)
return return
if tag == "a" and cls == "notemark": if tag == "a" and cls == "notemark":
# An in-text marker linking down to the note; its nested
# <span class="popup"> duplicates the note body and is dropped.
node = {"t": "noteref", "id": (attrs.get("href") or "").lstrip("#")} node = {"t": "noteref", "id": (attrs.get("href") or "").lstrip("#")}
self._emit(node) self._emit(node)
buf = [] buf = []
@@ -317,7 +221,7 @@ class ChapterParser(HTMLParser):
if tag == "span" and cls in INLINE: if tag == "span" and cls in INLINE:
kind = INLINE[cls] kind = INLINE[cls]
if kind is None: if kind is None:
self._push(tag, self.sink) # unwrap self._push(tag, self.sink)
else: else:
node = {"t": kind, "c": []} node = {"t": kind, "c": []}
self._emit(node) self._emit(node)
@@ -335,7 +239,6 @@ class ChapterParser(HTMLParser):
self._push(tag, self.sink) self._push(tag, self.sink)
return return
# Anything else (nested spans, stray markup) is transparent.
self._push(tag, self.sink) self._push(tag, self.sink)
def handle_endtag(self, tag): def handle_endtag(self, tag):
@@ -345,21 +248,19 @@ class ChapterParser(HTMLParser):
if self.stack[i]["tag"] == tag: if self.stack[i]["tag"] == tag:
break break
else: else:
return # unmatched close tag; ignore return
while len(self.stack) > i: while len(self.stack) > i:
frame = self.stack.pop() frame = self.stack.pop()
self.sink = frame["prev"] self.sink = frame["prev"]
if frame["close"]: if frame["close"]:
frame["close"](frame) frame["close"](frame)
if tag == "div" and not self.stack: if tag == "div" and not self.stack:
self.in_main = False # <div class="main"> closed self.in_main = False
def handle_data(self, data): def handle_data(self, data):
if self.sink is not None and data: if self.sink is not None and data:
self.sink.append({"t": "text", "v": data}) self.sink.append({"t": "text", "v": data})
# -- frame close handlers ---------------------------------------------
def _close_chapter_label(self, frame): def _close_chapter_label(self, frame):
self.chapter_label = plain_text(frame["nodes"]).strip() self.chapter_label = plain_text(frame["nodes"]).strip()
@@ -417,20 +318,13 @@ class ChapterParser(HTMLParser):
f["note"]["verse"] = int(m.group(1)) f["note"]["verse"] = int(m.group(1))
return return
# --------------------------------------------------------------------------
# Corpus
# --------------------------------------------------------------------------
class IndexParser(HTMLParser): class IndexParser(HTMLParser):
"""Read book order, display names and canon section from index.htm."""
# Link class on the site index marks which part of the canon a book is in.
SECTIONS = {"oo": "ot", "nn": "nt", "aa": "deuterocanon", "xx": "other"} SECTIONS = {"oo": "ot", "nn": "nt", "aa": "deuterocanon", "xx": "other"}
def __init__(self): def __init__(self):
super().__init__(convert_charrefs=True) super().__init__(convert_charrefs=True)
self.books = [] # [{code, name, section, order}] self.books = []
self.title = None self.title = None
self._link = None self._link = None
self._in_title = False self._in_title = False
@@ -464,7 +358,6 @@ class IndexParser(HTMLParser):
elif self._link is not None: elif self._link is not None:
self._link["name"] += data self._link["name"] += data
def parse_chapter(path): def parse_chapter(path):
parser = ChapterParser() parser = ChapterParser()
with open(path, encoding="utf-8-sig") as fh: with open(path, encoding="utf-8-sig") as fh:
@@ -472,7 +365,6 @@ def parse_chapter(path):
parser.close() parser.close()
return parser return parser
def extract(source_dir): def extract(source_dir):
index_path = os.path.join(source_dir, "index.htm") index_path = os.path.join(source_dir, "index.htm")
index = IndexParser() index = IndexParser()
@@ -481,7 +373,6 @@ def extract(source_dir):
meta = {b["code"]: b for b in index.books} meta = {b["code"]: b for b in index.books}
# Group chapter files by book code.
by_book = {} by_book = {}
for name in sorted(os.listdir(source_dir)): for name in sorted(os.listdir(source_dir)):
m = CHAPTER_RE.match(name) m = CHAPTER_RE.match(name)
@@ -503,7 +394,6 @@ def extract(source_dir):
for number, name in sorted(by_book[code]): for number, name in sorted(by_book[code]):
parsed = parse_chapter(os.path.join(source_dir, name)) parsed = parse_chapter(os.path.join(source_dir, name))
if parsed.is_toc or not parsed.blocks: if parsed.is_toc or not parsed.blocks:
# Chapter-0 files are per-book tables of contents, not text.
skipped.append(name) skipped.append(name)
continue continue
if parsed.titles and not book["titles"]: if parsed.titles and not book["titles"]:
@@ -528,26 +418,17 @@ def extract(source_dir):
"books": books, "books": books,
}, skipped }, skipped
# ==========================================================================
# WRITING -- that tree into one HTML fragment per book
# ==========================================================================
# 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.
INLINE_TAGS = { INLINE_TAGS = {
"wj": ("span", "wj"), # words of Jesus "wj": ("span", "wj"),
"keyword": ("dfn", "k"), # glossary headword "keyword": ("dfn", "k"),
"bookref": ("cite", "bk"), # book title cited in a note "bookref": ("cite", "bk"),
"fq": ("span", "fq"), # the words a footnote is about "fq": ("span", "fq"),
"fqa": ("span", "fqa"), # an alternative rendering "fqa": ("span", "fqa"),
"fl": ("span", "fl"), # footnote label "fl": ("span", "fl"),
"fine": ("span", "fine"), "fine": ("span", "fine"),
"xt": ("span", "xt"), # cross-reference target "xt": ("span", "xt"),
} }
# Block type -> tag. Headings sit at h3 so the outline never skips a level:
# h1 book, h2 chapter, h3 section.
BLOCK_TAGS = { BLOCK_TAGS = {
"paragraph": "p", "paragraph": "p",
"line": "p", "line": "p",
@@ -557,19 +438,13 @@ BLOCK_TAGS = {
"item": "li", "item": "li",
} }
def esc(text): def esc(text):
return html.escape(text, quote=False) return html.escape(text, quote=False)
def verse_id(chapter, label): def verse_id(chapter, label):
"""Page-unique verse anchor, e.g. v23_1 (or v11_15-16 for a range)."""
return "v%d_%s" % (chapter, label) return "v%d_%s" % (chapter, label)
def ref_href(node, book, chapter): def ref_href(node, book, chapter):
"""Link an intra-corpus reference; whole books are single pages."""
target_book = node.get("book") or book target_book = node.get("book") or book
target_ch = node.get("chapter", chapter) target_ch = node.get("chapter", chapter)
verse = node.get("verse") verse = node.get("verse")
@@ -578,26 +453,12 @@ def ref_href(node, book, chapter):
return "%s#%s" % (base, verse_id(target_ch, verse)) return "%s#%s" % (base, verse_id(target_ch, verse))
return "%s#ch%d" % (base, target_ch) return "%s#ch%d" % (base, target_ch)
def render_inline(nodes, book, chapter, out): def render_inline(nodes, book, chapter, out):
for node in nodes: for node in nodes:
kind = node["t"] kind = node["t"]
if kind == "text": if kind == "text":
out.append(esc(node["v"])) out.append(esc(node["v"]))
elif kind == "verse": elif kind == "verse":
# Trailing space keeps the number off the text before any CSS
# lands; style .v with padding and it stays correct either way.
#
# A control, said here rather than by a pass over the page once it
# has landed. role is what announces it and what earns Reboot's
# [role=button] pointer cursor, tabindex is what puts it in the tab
# order, and the label is what a screen reader says in place of a
# bare number. All three are true of every verse in the corpus and
# none of them depends on anything only the browser knows, so
# there is nothing here for a script to work out: app.js used to
# set them on load, after every htmx swap and after every history
# restore, walking a whole book each time. The click itself never
# needed that pass -- it is delegated at #content in base.njk.
label = esc(node["label"]) label = esc(node["label"])
out.append('<span class="v" id="%s" role="button" tabindex="0" ' out.append('<span class="v" id="%s" role="button" tabindex="0" '
'aria-label="Verse %s, options">%s</span> ' 'aria-label="Verse %s, options">%s</span> '
@@ -618,10 +479,9 @@ def render_inline(nodes, book, chapter, out):
out.append('<%s class="%s">' % (tag, cls)) out.append('<%s class="%s">' % (tag, cls))
render_inline(node.get("c", []), book, chapter, out) render_inline(node.get("c", []), book, chapter, out)
out.append("</%s>" % tag) out.append("</%s>" % tag)
else: # unknown node type: keep the words, drop the wrapper else:
render_inline(node.get("c", []), book, chapter, out) render_inline(node.get("c", []), book, chapter, out)
def render_block(block, book, chapter, out): def render_block(block, book, chapter, out):
btype = block["type"] btype = block["type"]
tag = BLOCK_TAGS.get(btype, "p") tag = BLOCK_TAGS.get(btype, "p")
@@ -635,23 +495,13 @@ def render_block(block, book, chapter, out):
render_inline(block.get("content", []), book, chapter, out) render_inline(block.get("content", []), book, chapter, out)
out.append("</%s>\n" % tag) out.append("</%s>\n" % tag)
# Footnote callers arrive from the source as * † ‡ § ✡. The star is the only
# one of them a browser may decide is an emoji: no serif font carries a glyph
# for U+2721, so the fallback chain reaches the colour emoji font and drops a
# purple tile into the middle of a verse. U+FE0E is the standardised way to ask
# for the text glyph of the same character -- the symbol, not the emoji. There
# is no separate text codepoint to switch to; this is the only lever.
TEXT_PRESENTATION = "\ufe0e" TEXT_PRESENTATION = "\ufe0e"
EMOJI_CAPABLE = "\u2721" EMOJI_CAPABLE = "\u2721"
def caller(marker): def caller(marker):
"""A footnote marker, asked to render as text rather than as an emoji."""
return "".join(c + TEXT_PRESENTATION if c in EMOJI_CAPABLE else c return "".join(c + TEXT_PRESENTATION if c in EMOJI_CAPABLE else c
for c in (marker or "*")) for c in (marker or "*"))
def render_notes(notes, chapter, book, out): def render_notes(notes, chapter, book, out):
if not notes: if not notes:
return return
@@ -671,14 +521,13 @@ def render_notes(notes, chapter, book, out):
out.append("</span></li>\n") out.append("</span></li>\n")
out.append(" </ol>\n </section>\n") out.append(" </ol>\n </section>\n")
def render_chapter(chapter, book, out): def render_chapter(chapter, book, out):
num = chapter["number"] num = chapter["number"]
out.append(' <section class="chapter" id="ch%d" data-chapter="%d">\n' out.append(' <section class="chapter" id="ch%d" data-chapter="%d">\n'
% (num, num)) % (num, num))
out.append(' <h2 class="chapter-number">%d</h2>\n' % num) out.append(' <h2 class="chapter-number">%d</h2>\n' % num)
group = None # "poetry" | "list" | None group = None
def close_group(): def close_group():
nonlocal group nonlocal group
@@ -691,7 +540,7 @@ def render_chapter(chapter, book, out):
for block in chapter["blocks"]: for block in chapter["blocks"]:
btype = block["type"] btype = block["type"]
if btype == "break": if btype == "break":
close_group() # a blank line ends the stanza close_group()
continue continue
want = {"line": "poetry", "item": "list"}.get(btype) want = {"line": "poetry", "item": "list"}.get(btype)
if want != group: if want != group:
@@ -707,41 +556,24 @@ def render_chapter(chapter, book, out):
render_notes(chapter["notes"], num, book, out) render_notes(chapter["notes"], num, book, out)
out.append(" </section>\n") out.append(" </section>\n")
def render_book(book, translation, out_dir): def render_book(book, translation, out_dir):
code, name = book["code"], book["name"] code, name = book["code"], book["name"]
out = [] out = []
title = "%s — %s" % (name, translation["name"]) title = "%s — %s" % (name, translation["name"])
# 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("<title>%s</title>\n" % esc(title))
out.append('<article class="book" data-template="book" id="%s" data-book="%s" ' out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
'data-name="%s" data-section="%s">\n' 'data-name="%s" data-section="%s">\n'
% (code, code, esc(name), book["section"])) % (code, code, esc(name), book["section"]))
# Header: the full multi-line title if the source had one.
out.append(' <header class="book-header">\n') out.append(' <header class="book-header">\n')
out.append(" <h1>") out.append(" <h1>")
if book["titles"]: if book["titles"]:
# Separated by a space, not run together. The spans record what USFM
# called them -- mt2 and mt3 were lines above and below the main title
# -- but they are set as one line here, and without the space between
# them Genesis reads "The First Book of Moses,Commonly CalledGenesis".
# The markup still says what each part is, so stacking them again is a
# stylesheet away.
out.append(" ".join('<span class="%s">%s</span>' out.append(" ".join('<span class="%s">%s</span>'
% (part["style"], esc(part["text"])) % (part["style"], esc(part["text"]))
for part in book["titles"])) for part in book["titles"]))
else: else:
out.append(esc(name)) out.append(esc(name))
out.append("</h1>\n") out.append("</h1>\n")
# The title and nothing else. Both of the things that used to sit under it
# are in the navbar now, where they are in reach from anywhere in the book
# rather than only from the top of it: the chapter list became the
# reference picker, which reads its chapters and verses straight off this
# markup, and "All books" is what the navbar brand already does. In Psalms
# the chapter list alone was a hundred and fifty links, carried in the
# fragment and in every device's offline copy.
out.append(" </header>\n") out.append(" </header>\n")
out.append(' <div class="chapters">\n') out.append(' <div class="chapters">\n')
@@ -758,7 +590,6 @@ def render_book(book, translation, out_dir):
fh.write("".join(out)) fh.write("".join(out))
return path return path
SECTION_LABELS = [ SECTION_LABELS = [
("ot", "Old Testament"), ("ot", "Old Testament"),
("nt", "New Testament"), ("nt", "New Testament"),
@@ -766,7 +597,6 @@ SECTION_LABELS = [
("other", "Other"), ("other", "Other"),
] ]
def render_index(books, translation, out_dir): def render_index(books, translation, out_dir):
out = ["<title>%s</title>\n" % esc(translation["name"]), out = ["<title>%s</title>\n" % esc(translation["name"]),
'<div class="index" data-template="index">\n'] '<div class="index" data-template="index">\n']
@@ -789,48 +619,9 @@ def render_index(books, translation, out_dir):
fh.write("".join(out)) fh.write("".join(out))
return path 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" VERSES_FILE = "verses.json"
def chapter_verses(chapter): 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 = [] labels = []
def walk(nodes): def walk(nodes):
@@ -844,20 +635,12 @@ def chapter_verses(chapter):
walk(block.get("content", [])) walk(block.get("content", []))
return labels return labels
def render_verses(books, out_dir): 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 = {} out = {}
total = 0 total = 0
for book in books: for book in books:
numbers = [c["number"] for c in book["chapters"]] numbers = [c["number"] for c in book["chapters"]]
if numbers != list(range(1, len(numbers) + 1)): 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)" raise RuntimeError("%s: chapters are not 1..%d (%s)"
% (book["code"], len(numbers), numbers)) % (book["code"], len(numbers), numbers))
chapters = [] chapters = []
@@ -874,11 +657,6 @@ def render_verses(books, out_dir):
fh.write("\n") fh.write("\n")
return path, total return path, total
# ==========================================================================
# Entry point
# ==========================================================================
def main(argv=None): def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -912,6 +690,5 @@ def main(argv=None):
print("wrote %s/" % args.out.rstrip("/"), file=sys.stderr) print("wrote %s/" % args.out.rstrip("/"), file=sys.stderr)
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())