This commit is contained in:
mark committed 2026-09-04 08:08:32 -04:00
1 parent 77bf8d9f2e
commit ea193e1264
53 files changed
+545 -347

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
/* 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 {
--bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
"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 {
font-size: .7em;
font-weight: 700;
vertical-align: super;
line-height: 0;
margin-right: .15em;
color: var(--bs-secondary-color);
user-select: none;
}
/* app.js turns these into real buttons, so they need a visible focus ring. */
.v:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
border-radius: 2px;
}
/* Chapter numbers are <h2>, which at heading size shouts over the text. */
.chapter-number {
font-size: 2rem;
font-weight: 400;
color: var(--bs-secondary-color);
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 .line {
margin: 0;
padding-left: 1.5em;
text-indent: -1.5em;
}
.poetry .line[data-indent="2"] { padding-left: 3em; }
.poetry .line[data-indent="3"] { padding-left: 4.5em; }
/* Descriptive titles: "A Psalm by David". */
.d {
font-style: italic;
color: var(--bs-secondary-color);
}
/* The notes are apparatus, not scripture, and should not read as it. */
.footnotes {
font-size: .875rem;
color: var(--bs-secondary-color);
}
.footnotes-title { font-size: 1rem; }
.marker { font-weight: 700; }
+52 -12
View File
@@ -1,7 +1,14 @@
// Verse modal wiring. The modal markup lives in base.njk and the bookmark
// lives in the service worker, so all this file does is decide which verse
// was clicked, fill the form, and show it -- Bootstrap handles opening,
// dismissing and focus, and htmx carries the save.
// Page startup and verse modal wiring.
//
// shell.html is static and ships with an empty #content, so the first thing
// every load does is fetch the fragment its URL names and swap it in. That is
// the same request a boosted link makes, so there is one path into #content
// and nothing special about a first visit.
//
// The rest is the modal: the markup lives in shell.html and the bookmark lives
// in the service worker, so all this file does is decide which verse was
// clicked, fill the form, and show it -- Bootstrap handles opening, dismissing
// and focus, and htmx carries the save.
(function () {
'use strict';
@@ -65,7 +72,7 @@
if (!el || !window.bootstrap) return;
var info = verseInfo(span);
currentUrl = info.url;
el.querySelector('.verse-dialog-ref').textContent = info.ref;
document.getElementById('verse-dialog-ref').textContent = info.ref;
el.querySelector('input[name="ref"]').value = info.ref;
el.querySelector('input[name="href"]').value = info.href;
status('');
@@ -121,13 +128,46 @@
}
});
markVerses(document);
// -- startup ------------------------------------------------------------
// The shell already carries the bookmark, so this is only for the case where
// the worker started controlling the page after it was rendered.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(function () {
document.body.dispatchEvent(new CustomEvent('bookmark-load'));
}).catch(function () {});
function fragmentUrl() {
return (location.pathname.split('/').pop() || 'index.html') + location.search;
}
// The content arrives after the document has been parsed, so the browser has
// long given up on the fragment identifier by the time the verse exists.
// Doing it here is what makes a link to GEN.html#v1_1 land on the verse.
function scrollToHash() {
if (!location.hash) return;
var el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (el) el.scrollIntoView();
}
// api/bookmark has no origin server -- it is answered by the worker -- so
// there is nothing to ask until the worker is controlling this page.
// controller, not ready: ready can resolve a tick before clients.claim()
// takes effect, and a request sent in that gap reaches the network and 404s.
function whenControlled(fn) {
if (!('serviceWorker' in navigator)) return;
if (navigator.serviceWorker.controller) return fn();
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
}
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
htmx.ajax('GET', fragmentUrl(), { target: '#content', swap: 'innerHTML' })
.then(scrollToHash);
// Registered from the page it is going to serve, not from a page of its
// own: there is nothing to wait for and nothing to reload. This load is
// already the real app -- the worker installs alongside it, and takes over
// for the bookmark, the offline download and every request from then on.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
whenControlled(function () {
document.body.dispatchEvent(new CustomEvent('worker-ready'));
});
});
})();
-3
View File
@@ -1,3 +0,0 @@
{% extends "base.njk" %}
{% block body_class %}reading{% endblock %}
{% block content %}{{ content | safe }}{% endblock %}
-26
View File
@@ -1,26 +0,0 @@
{# Returned on its own by GET/POST/DELETE api/bookmark, and included in the
shell so a normal navigation paints the bookmark without a second request.
Both paths must produce the same element for the swap to be seamless.
outerHTML is the swap, so this element re-declares its own htmx attributes
every time: the replacement is what receives the next event. hx-target is
inherited, and the shell points it at #content, so "this" is not the
default here -- it has to be said.
Empty state is d-none, not the hidden attribute: Bootstrap declares
.d-flex after Reboot's [hidden] rule, both !important and at the same
specificity, so the utility would win. #}
<span class="bookmark navbar-text {% if mark %}d-flex{% else %}d-none{% endif %} align-items-center gap-2"
id="bookmark"
hx-get="api/bookmark" hx-trigger="bookmark-load from:body"
hx-target="this" hx-swap="outerHTML">
{%- if mark %}
<span class="bookmark-label badge text-bg-secondary">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 }}"
hx-boost="false">{{ mark.ref }}</a>
<button type="button" class="bookmark-clear btn btn-sm btn-outline-light py-0"
hx-delete="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">Clear</button>
{%- endif %}
</span>
-60
View File
@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>World English Bible Updated</title>
<link rel="stylesheet" href="bootstrap.min.css">
<meta name="theme-color" content="#1a202c">
</head>
<body class="bg-body-tertiary">
<!-- Pages are fragments now: the shell is assembled by the service worker, so
a first visit has nothing to render until the worker is installed and
controlling. This page installs it and reloads into the real app. -->
<main class="boot container py-5 text-center">
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
<p id="boot-status" class="text-body-secondary">Preparing the offline copy…</p>
<noscript><p class="alert alert-warning">This app assembles its pages in the browser and needs JavaScript.</p></noscript>
</main>
<script>
(function () {
var status = document.getElementById('boot-status');
function fail(text) { status.textContent = text; }
if (!('serviceWorker' in navigator)) {
fail('This browser has no service worker support, which this app needs.');
return;
}
// Guards against a reload loop if the worker activates but never claims:
// better to sit on this page with a message than to spin.
var tries = 0;
try { tries = parseInt(sessionStorage.getItem('boot:tries') || '0', 10); }
catch (e) { tries = 0; }
if (tries >= 2) {
fail('The offline copy could not start. Try clearing this site’s data.');
return;
}
try { sessionStorage.setItem('boot:tries', String(tries + 1)); } catch (e) {}
function go() {
try { sessionStorage.removeItem('boot:tries'); } catch (e) {}
location.reload();
}
navigator.serviceWorker.register('sw.js').then(function () {
// controller is what actually matters: ready can resolve a tick before
// clients.claim() takes effect, and reloading then lands here again.
if (navigator.serviceWorker.controller) return go();
navigator.serviceWorker.addEventListener('controllerchange', go);
setTimeout(function () {
if (navigator.serviceWorker.controller) go();
else fail('The offline copy is taking longer than expected. Reload to retry.');
}, 10000);
}, function (err) {
fail('Could not install the offline copy: ' + err.message);
});
})();
</script>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
{% extends "base.njk" %}
{% 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 %}
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="index.html">All books</a>
</li>
{% endblock %}
{% block content %}{{ content | safe }}{% endblock %}
-2
View File
@@ -1,2 +0,0 @@
/*! Browser bundle of nunjucks 3.2.4 */
!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.nunjucks=n():t.nunjucks=n()}("undefined"!=typeof self?self:this,function(){return function(t){var n={};function i(r){if(n[r])return n[r].exports;var e=n[r]={i:r,l:!1,exports:{}};return t[r].call(e.exports,e,e.exports,i),e.l=!0,e.exports}return i.m=t,i.c=n,i.d=function(t,n,r){i.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},i.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(n,"a",n),n},i.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},i.p="",i(i.s=11)}([function(t,n,i){"use strict";var r=Array.prototype,e=Object.prototype,s={"&":"&amp;",'"':"&quot;","'":"&#39;","<":"&lt;",">":"&gt;","\\":"&#92;"},o=/[&"'<>\\]/g;function u(t,n){return e.hasOwnProperty.call(t,n)}function h(t){return s[t]}function f(t,n,i){var r,e,s;if(t instanceof Error&&(t=(e=t).name+": "+e.message),Object.setPrototypeOf?Object.setPrototypeOf(r=Error(t),f.prototype):Object.defineProperty(r=this,"message",{enumerable:!1,writable:!0,value:t}),Object.defineProperty(r,"name",{value:"Template render error"}),Error.captureStackTrace&&Error.captureStackTrace(r,this.constructor),e){var o=Object.getOwnPropertyDescriptor(e,"stack");(s=o&&(o.get||function(){return o.value}))||(s=function(){return e.stack})}else{var u=Error(t).stack;s=function(){return u}}return Object.defineProperty(r,"stack",{get:function(){return s.call(r)}}),Object.defineProperty(r,"cause",{value:e}),r.lineno=n,r.colno=i,r.firstUpdate=!0,r.Update=function(t){var n="("+(t||"unknown path")+")";return this.firstUpdate&&(this.lineno&&this.colno?n+=" [Line "+this.lineno+", Column "+this.colno+"]":this.lineno&&(n+=" [Line "+this.lineno+"]")),n+="\n ",this.firstUpdate&&(n+=" "),this.message=n+(this.message||""),this.firstUpdate=!1,this},r}function c(t){return"[object Function]"===e.toString.call(t)}function a(t){return"[object Array]"===e.toString.call(t)}function l(t){return"[object String]"===e.toString.call(t)}function v(t){return"[object Object]"===e.toString.call(t)}function p(t){var n,i=(n=t)?"string"==typeof n?n.split("."):[n]:[];return function(t){for(var n=t,r=0;r<i.length;r++){var e=i[r];if(!u(n,e))return;n=n[e]}return n}}function d(t){return Array.prototype.slice.call(t)}function m(t,n,i){return Array.prototype.indexOf.call(t||[],n,i)}function w(t){var n=[];for(var i in t)u(t,i)&&n.push(i);return n}(n=t.exports={}).hasOwnProp=u,n.t=function(t,i,r){if(r.Update||(r=new n.TemplateError(r)),r.Update(t),!i){var e=r;(r=Error(e.message)).name=e.name}return r},Object.setPrototypeOf?Object.setPrototypeOf(f.prototype,Error.prototype):f.prototype=Object.create(Error.prototype,{constructor:{value:f}}),n.TemplateError=f,n.escape=function(t){return t.replace(o,h)},n.isFunction=c,n.isArray=a,n.isString=l,n.isObject=v,n.getAttrGetter=p,n.groupBy=function(t,n,i){for(var r={},e=c(n)?n:p(n),s=0;s<t.length;s++){var o=t[s],u=e(o,s);if(void 0===u&&!0===i)throw new TypeError('groupby: attribute "'+n+'" resolved to undefined');(r[u]||(r[u]=[])).push(o)}return r},n.toArray=d,n.without=function(t){var n=[];if(!t)return n;for(var i=t.length,r=d(arguments).slice(1),e=-1;++e<i;)-1===m(r,t[e])&&n.push(t[e]);return n},n.repeat=function(t,n){for(var i="",r=0;r<n;r++)i+=t;return i},n.each=function(t,n,i){if(null!=t)if(r.forEach&&t.forEach===r.forEach)t.forEach(n,i);else if(t.length===+t.length)for(var e=0,s=t.length;e<s;e++)n.call(i,t[e],e,t)},n.map=function(t,n){var i=[];if(null==t)return i;if(r.map&&t.map===r.map)return t.map(n);for(var e=0;e<t.length;e++)i[i.length]=n(t[e],e);return t.length===+t.length&&(i.length=t.length),i},n.asyncIter=function(t,n,i){var r=-1;!function e(){++r<t.length?n(t[r],r,e,i):i()}()},n.asyncFor=function(t,n,i){var r=w(t||{}),e=r.length,s=-1;!function o(){var u=r[++s];s<e?n(u,t[u],s,e,o):i()}()},n.indexOf=m,n.keys=w,n.r=function(t){return w(t).map(function(n){return[n,t[n]]})},n.u=function(t){return w(t).map(function(n){return t[n]})},n.h=n.extend=function(t,n){return t=t||{},w(n).forEach(function(i){t[i]=n[i]}),t},n.inOperator=function(t,n){if(a(n)||l(n))return-1!==n.indexOf(t);if(v(n))return t in n;throw Error('Cannot use "in" operator to search for "'+t+'" in unexpected types.')}},function(t,n,i){"use strict";function r(t,n){for(var i=0;i<n.length;i++){var r=n[i];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,(e=r.key,void 0,"symbol"==typeof(s=function(t,n){if("object"!=typeof t||null===t)return t;var i=t[Symbol.toPrimitive];if(void 0!==i){var r=i.call(t,n||"default");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}(e,"string"))?s:s+""),r)}var e,s}function e(t,n,i){return n&&r(t.prototype,n),i&&r(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t}function s(t,n){t.prototype=OLine truncated
-3
View File
@@ -1,3 +0,0 @@
{# Fallback for a fragment that names no template of its own. #}
{% extends "base.njk" %}
{% block content %}{{ content | safe }}{% endblock %}
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>World English Bible Updated</title>
<!-- Vendored, not from a CDN: the worker only caches same-origin responses.
app.css comes second so its variable overrides win. -->
<link rel="stylesheet" href="bootstrap.min.css">
<link rel="stylesheet" href="app.css">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<meta name="theme-color" content="#1a202c">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Bible">
<!-- A history entry is a whole book and htmx keeps its cache in localStorage,
so the cache is off and back/forward is an ordinary page load instead. -->
<meta name="htmx-config" content='{"historyCacheSize":0,"refreshOnHistoryMiss":true}'>
<!-- htmx is not deferred: hx-boost must be in place before the first click.
Deferred scripts run in order, so bootstrap is defined before app.js. -->
<script src="htmx.min.js"></script>
<script src="bootstrap.bundle.min.js" defer></script>
<script src="app.js" defer></script>
</head>
<!-- This one file is every page. The server answers any navigation with it,
and app.js puts the fragment the URL names into #content. -->
<body hx-boost="true" hx-target="#content" hx-swap="innerHTML">
<nav class="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
<div class="container">
<a class="navbar-brand" href="index.html">Bible</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#site-nav" aria-controls="site-nav"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="site-nav">
<!-- Empty until the worker answers api/bookmark. -->
<span id="bookmark" class="d-none"
hx-get="api/bookmark" hx-trigger="worker-ready from:body"
hx-target="this" hx-swap="outerHTML"></span>
</div>
</div>
</nav>
<main id="content" class="container my-4"></main>
<!-- Opened by app.js when a verse number is clicked. -->
<div class="modal fade" id="verse-dialog" tabindex="-1"
aria-labelledby="verse-dialog-ref" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="verse-dialog-ref"></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="verse-dialog-status" class="form-text m-0"
role="status" aria-live="polite"></p>
</div>
<div class="modal-footer">
<form class="d-flex gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="">
<input type="hidden" name="href" value="">
<button class="btn btn-outline-secondary" type="button"
data-action="copy">Copy link</button>
<button class="btn btn-primary" type="submit">Set bookmark</button>
</form>
</div>
</div>
</div>
</div>
<noscript><p class="container my-4">This app builds its pages in the browser
and needs JavaScript.</p></noscript>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
{# The modal's status line. One template for both paths: the shell renders it
empty, and the bookmark API re-renders it with oob set so a single response
updates the header slot and this line together. #}
<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"
role="status" aria-live="polite"{% if oob %} hx-swap-oob="true"{% endif %}>{{ text }}</p>
+221 -132
View File
@@ -1,52 +1,93 @@
// The app's renderer, and its backend. Hand-written: this file is the source,
// served as-is apart from the two lines server.py stamps (see below).
// The app's offline copy, and its backend. Hand-written: this file is the
// source, served as-is apart from the three lines server.py stamps (see below).
//
// The files on disk are fragments: a
// <title> and one root element. The shell around them lives in base.njk and
// is applied here, per request, with nunjucks -- so changing the chrome means
// editing one template rather than rebuilding every book.
// The files on disk are fragments: a title element and one root element. The shell
// around it is shell.html, a static document with an empty #content. This
// worker does not assemble anything -- it answers every navigation with that
// one file, and app.js asks for the fragment the URL names. So there is a
// single way a page is built, online or off, first visit or hundredth.
//
// It is also the app's backend. The bookmark lives in IndexedDB here, not in
// localStorage on the page, and is read and written over ./api/bookmark by
// htmx exactly as if it were a server.
//
// STRATEGY decides what a same-origin GET does:
// 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.
//
// network-first ask the network, fall back to the cache when it fails.
// Offline, fetch rejects immediately, so the fallback is
// effectively instant -- the only cost is one round trip
// while online, and you never see a stale page.
// cache-first answer from the cache the moment there is a hit. Fastest
// possible load, but a rebuild needs two reloads to appear.
//
// Either way every response that arrives is written back to the cache, so the
// offline copy stays current, and any change on disk changes VERSION, dropping
// the caches belonging to older versions.
// 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 this survives offline
// without being in ASSETS.
importScripts('./nunjucks.min.js');
const STRATEGY = 'network-first';
const NETWORK_TIMEOUT_MS = 3000;
// VERSION and ASSETS are stamped in by server.py from the files actually on
// disk, so a template edit reaches installed devices with no build step: the
// worker's bytes change, the browser sees a new worker, and the old cache is
// dropped on activate. The values below are the standalone fallback.
// 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;
// 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;
// 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/, so
// editing the shell drops and refetches a few hundred kilobytes. The other
// covers the generated pages in books/, which change only when
// render_books.py runs -- so a change of chrome no longer costs every device
// the whole corpus again.
const VERSION = 'dev';
const CACHE = 'bible-' + VERSION;
const ASSETS = ["./"];
const LIBRARY_VERSION = 'dev';
const LIBRARY = [];
const SHELL_CACHE = 'bible-shell-' + VERSION;
const LIBRARY_CACHE = 'bible-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));
// The shell and what it needs to paint. index.html 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.
const SHELL = './shell.html';
const PRECACHE = [
SHELL,
'./index.html',
'./app.js',
'./app.css',
'./htmx.min.js',
'./bootstrap.min.css',
'./bootstrap.bundle.min.js',
'./manifest.webmanifest'
];
function cacheFor(req) {
return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE);
}
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(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;
await Promise.all(ASSETS.map((url) =>
await Promise.all(PRECACHE.map((url) =>
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
if (failed) console.warn('precache: ' + failed + ' of ' + ASSETS.length + ' failed');
if (failed) console.warn('precache: ' + failed + ' of ' + PRECACHE.length + ' failed');
await self.skipWaiting();
})());
});
@@ -55,7 +96,7 @@ self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter((k) => k.startsWith('bible-') && k !== CACHE)
.filter((k) => k.startsWith('bible-') && k !== SHELL_CACHE && k !== LIBRARY_CACHE)
.map((k) => caches.delete(k)));
await self.clients.claim();
})());
@@ -66,15 +107,25 @@ self.addEventListener('fetch', (event) => {
const url = new URL(req.url);
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);
// Checked before the method guard: the bookmark API is the one thing here
// that answers POST and DELETE.
// that answers POST.
if (isBookmarkApi(url)) return event.respondWith(bookmarkApi(event));
if (req.method !== 'GET') return;
// A top-level navigation is the only request that gets the shell wrapped
// around it. htmx's own requests are not navigations, so they receive the
// bare fragment and swap it into the shell that is already on screen.
if (req.mode === 'navigate') return event.respondWith(compose(event));
// Every navigation gets the shell, whatever path it asked for. One URL to
// cache, and it answers for a book that has never been opened as readily as
// for one that has -- what is missing is then the fragment, which is a
// message inside the app rather than a browser error page.
if (req.mode === 'navigate') return event.respondWith(answer(SHELL, event));
if (isPage(url)) return event.respondWith(page(req, event));
event.respondWith(answer(req, event));
});
@@ -83,9 +134,9 @@ self.addEventListener('fetch', (event) => {
// Aborted rather than raced, so a hung request is actually torn down instead
// of leaking while the cache answers.
async function fromNetwork(req) {
async function fromNetwork(req, timeout) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
try {
return await fetch(req, { signal: controller.signal });
} finally {
@@ -97,27 +148,19 @@ function cached(req) {
return caches.match(req, { ignoreSearch: true });
}
// Everything the worker reads goes through here, templates included, so a
// .njk file is cached and served on exactly the same terms as a book.
//
// 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 bootstrap page.
// internal fetch from a browser one and hand back the file itself rather than
// the shell.
async function answer(input, event) {
const req = typeof input === 'string' ? new Request(input) : input;
if (STRATEGY === 'cache-first') {
const hit = await cached(req);
if (hit) return hit;
}
try {
const res = await fromNetwork(req);
if (res && res.ok && res.type === 'basic') {
const copy = res.clone();
// Not awaited: the response should not wait on the cache write.
const write = caches.open(CACHE).then((c) => c.put(req, copy));
const write = cacheFor(req).then((c) => c.put(req, copy));
if (event) event.waitUntil(write);
else write.catch(() => {});
}
@@ -129,45 +172,105 @@ async function answer(input, event) {
}
}
async function readText(input, event) {
const res = await answer(input, event);
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
return res.text();
}
function htmlResponse(body, status) {
return new Response(body, {
status: status || 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
// Composed on the fly from parts that are themselves cached; storing
// the result too would pin a copy of the old shell.
// Built here from live state rather than fetched; storing it would pin
// a stale copy over the real thing.
'Cache-Control': 'no-store'
}
});
}
// --------------------------------------------------------------- templates
// ------------------------------------------------------------------- pages
// Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist in
// a service worker. This one reads through answer(), so templates are offline
// on the same terms as everything else.
function CacheLoader() {}
CacheLoader.prototype.async = true;
CacheLoader.prototype.getSource = function (name, callback) {
readText('./' + name).then(
(src) => callback(null, { src: src, path: name, noCache: false }),
(err) => callback(err));
};
function isPage(url) {
return url.pathname.endsWith('.html');
}
// Compiled templates are held in memory for the worker's lifetime. A rebuild
// changes VERSION, which starts a new worker, so this never goes stale.
const env = new nunjucks.Environment(new CacheLoader(), { autoescape: true });
// A page that cannot be delivered becomes a fragment saying so, in the same
// shape as a real one: a title and one root element. htmx only swaps a 2xx
// body, so this is deliberately a 200 -- an error status would leave the
// reader looking at the page they came from with no explanation.
function notice(heading, text) {
return htmlResponse(
'<title>' + heading + '</title>\n' +
'<div class="notice">\n' +
' <h1>' + heading + '</h1>\n' +
' <p>' + text + '</p>\n' +
' <p><a href="index.html">All books</a></p>\n' +
'</div>');
}
function render(name, context) {
return new Promise((resolve, reject) => {
env.render(name, context, (err, out) => (err ? reject(err) : resolve(out)));
});
async function page(req, event) {
let res;
try {
res = await answer(req, event);
} catch (err) {
return notice('Not available offline',
'This page has not been opened on this device, so there is no copy ' +
'stored here. Reconnect and try again, or open one you have read before.');
}
if (res.ok) return res;
return notice('Not found', 'There is no page at this address.');
}
// ----------------------------------------------------------------- library
// 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) {
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) {
filling = fillLibrary().catch(() => {}).finally(() => { filling = null; });
}
if (filling) event.waitUntil(filling);
}
async function fillLibrary() {
const cache = await caches.open(LIBRARY_CACHE);
const have = new Set((await cache.keys()).map((req) => req.url));
const missing = [];
LIBRARY_URLS.forEach((url) => { if (!have.has(url)) missing.push(url); });
if (!missing.length) { complete = true; return; }
let stopped = false;
const runners = [];
for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run());
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;
async function run() {
while (missing.length && !stopped) {
const url = missing.pop();
try {
const res = await fromNetwork(new Request(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);
} 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;
}
}
}
}
// ---------------------------------------------------------------- bookmark
@@ -176,6 +279,11 @@ const DB_NAME = 'bible';
const DB_STORE = 'kv';
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 -- the navbar slot and the API -- can assume a mark.
const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN.html#v1_1' };
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
@@ -201,25 +309,51 @@ async function withStore(mode, fn) {
}
}
// Storage being unavailable degrades to "no bookmark" rather than a broken
// page, the same way the old localStorage code did.
// Storage being unavailable degrades to the default rather than a broken page,
// which is the same fallback as never having set one.
function getBookmark() {
return withStore('readonly', (s) => s.get(BOOKMARK_KEY)).catch(() => null);
return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
.catch(() => null)
.then((mark) => mark || DEFAULT_BOOKMARK);
}
function isBookmarkApi(url) {
return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark');
}
function esc(text) {
return String(text).replace(/[&<>"]/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
// Replaces the empty slot in shell.html, and then replaces itself on every
// later read or write. The swap is outerHTML, so this markup has to re-declare
// its own htmx attributes: the replacement is what receives the next event.
// hx-target is inherited, and the shell points it at #content, so "this" is
// not the default here -- it has to be said.
function bookmarkHtml(mark) {
return '<span class="navbar-text d-flex align-items-center gap-2"' +
' id="bookmark" hx-get="api/bookmark"' +
' hx-trigger="worker-ready from:body"' +
' hx-target="this" hx-swap="outerHTML">' +
// Not boosted: a full load lands on the shell with the fragment
// identifier intact, which is what app.js scrolls to.
'<a class="link-light text-truncate"' +
' href="' + esc(mark.href) + '" hx-boost="false">' +
esc(mark.ref) + '</a></span>';
}
// Out of band, so one response updates both the navbar slot and the modal's
// status line without the modal having to ask separately.
function statusHtml(text) {
return '<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"' +
' role="status" aria-live="polite" hx-swap-oob="true">' +
esc(text) + '</p>';
}
async function bookmarkFragment(status) {
const mark = await getBookmark();
let html = await render('bookmark.njk', { mark: mark || null });
if (status) {
// Out of band, so one response updates both the navbar slot and the
// modal's status line without the modal having to ask separately. Same
// template the shell uses, so the two cannot drift apart.
html += '\n' + await render('status.njk', { text: status, oob: true });
}
let html = bookmarkHtml(await getBookmark());
if (status) html += '\n' + statusHtml(status);
return html;
}
@@ -236,9 +370,6 @@ async function bookmarkApi(event) {
await withStore('readwrite',
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
status = 'Bookmark set.';
} else if (req.method === 'DELETE') {
await withStore('readwrite', (s) => s.delete(BOOKMARK_KEY));
status = 'Bookmark cleared.';
} else if (req.method !== 'GET') {
return htmlResponse('', 405);
}
@@ -247,45 +378,3 @@ async function bookmarkApi(event) {
return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.'));
}
}
// ------------------------------------------------------------- composition
const TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
const TEMPLATE_RE = /\sdata-template="([\w-]+)"/;
function fragmentPath(url) {
const name = url.pathname.split('/').pop();
return './' + (name || 'index.html');
}
async function compose(event) {
const url = new URL(event.request.url);
let path = fragmentPath(url);
// The bootstrap page is a whole document already; wrapping it in the shell
// would nest one inside the other.
if (path === './boot.html') return answer(event.request, event);
let fragment;
try {
fragment = await readText(path, event);
} catch (err) {
// Offline and never cached. The index is always precached, so falling
// back to it keeps the app usable instead of showing a network error.
if (path === './index.html') throw err;
path = './index.html';
fragment = await readText(path, event);
}
// Already escaped by the renderer, hence the |safe on it in base.njk.
const title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
const body = fragment.replace(TITLE_RE, '');
const template = (TEMPLATE_RE.exec(body) || ['', 'page'])[1] + '.njk';
return htmlResponse(await render(template, {
title: title,
content: body,
mark: (await getBookmark()) || null
}));
}
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Amos — World English Bible Updated</title>
<article class="book" data-template="book" id="AMO" data-book="AMO" data-name="Amos" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Amos</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Amos</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Daniel (Greek) — World English Bible Updated</title>
<article class="book" data-template="book" id="DAG" data-book="DAG" data-name="Daniel (Greek)" data-section="deuterocanon">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Daniel</span><span class="mt2">with Greek Portions</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Daniel</span> <span class="mt2">with Greek Portions</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Daniel — World English Bible Updated</title>
<article class="book" data-template="book" id="DAN" data-book="DAN" data-name="Daniel" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Daniel</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Daniel</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Deuteronomy — World English Bible Updated</title>
<article class="book" data-template="book" id="DEU" data-book="DEU" data-name="Deuteronomy" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Fifth Book of Moses,</span><span class="mt3">Commonly Called</span><span class="mt">Deuteronomy</span></h1>
<h1><span class="mt2">The Fifth Book of Moses,</span> <span class="mt3">Commonly Called</span> <span class="mt">Deuteronomy</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Ecclesiastes — World English Bible Updated</title>
<article class="book" data-template="book" id="ECC" data-book="ECC" data-name="Ecclesiastes" data-section="ot">
<header class="book-header">
<h1><span class="mt">Ecclesiastes</span><span class="mt2">or, The Preacher</span></h1>
<h1><span class="mt">Ecclesiastes</span> <span class="mt2">or, The Preacher</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Esther (Greek) — World English Bible Updated</title>
<article class="book" data-template="book" id="ESG" data-book="ESG" data-name="Esther (Greek)" data-section="deuterocanon">
<header class="book-header">
<h1><span class="mt">Esther</span><span class="mt2">translated from the Greek Septuagint</span></h1>
<h1><span class="mt">Esther</span> <span class="mt2">translated from the Greek Septuagint</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Esther — World English Bible Updated</title>
<article class="book" data-template="book" id="EST" data-book="EST" data-name="Esther" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Esther</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Esther</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Exodus — World English Bible Updated</title>
<article class="book" data-template="book" id="EXO" data-book="EXO" data-name="Exodus" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Second Book of Moses,</span><span class="mt3">Commonly Called</span><span class="mt">Exodus</span></h1>
<h1><span class="mt2">The Second Book of Moses,</span> <span class="mt3">Commonly Called</span> <span class="mt">Exodus</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Ezekiel — World English Bible Updated</title>
<article class="book" data-template="book" id="EZK" data-book="EZK" data-name="Ezekiel" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Ezekiel</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Ezekiel</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Ezra — World English Bible Updated</title>
<article class="book" data-template="book" id="EZR" data-book="EZR" data-name="Ezra" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Ezra</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Ezra</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Genesis — World English Bible Updated</title>
<article class="book" data-template="book" id="GEN" data-book="GEN" data-name="Genesis" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The First Book of Moses,</span><span class="mt3">Commonly Called</span><span class="mt">Genesis</span></h1>
<h1><span class="mt2">The First Book of Moses,</span> <span class="mt3">Commonly Called</span> <span class="mt">Genesis</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Habakkuk — World English Bible Updated</title>
<article class="book" data-template="book" id="HAB" data-book="HAB" data-name="Habakkuk" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Habakkuk</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Habakkuk</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Haggai — World English Bible Updated</title>
<article class="book" data-template="book" id="HAG" data-book="HAG" data-name="Haggai" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Haggai</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Haggai</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Hosea — World English Bible Updated</title>
<article class="book" data-template="book" id="HOS" data-book="HOS" data-name="Hosea" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Hosea</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Hosea</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Isaiah — World English Bible Updated</title>
<article class="book" data-template="book" id="ISA" data-book="ISA" data-name="Isaiah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of the Prophet</span><span class="mt">Isaiah</span></h1>
<h1><span class="mt2">The Book of the Prophet</span> <span class="mt">Isaiah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Judges — World English Bible Updated</title>
<article class="book" data-template="book" id="JDG" data-book="JDG" data-name="Judges" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Judges</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Judges</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Jeremiah — World English Bible Updated</title>
<article class="book" data-template="book" id="JER" data-book="JER" data-name="Jeremiah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Jeremiah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Jeremiah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>John — World English Bible Updated</title>
<article class="book" data-template="book" id="JHN" data-book="JHN" data-name="John" data-section="nt">
<header class="book-header">
<h1><span class="mt2">The Good News According to</span><span class="mt">John</span></h1>
<h1><span class="mt2">The Good News According to</span> <span class="mt">John</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Job — World English Bible Updated</title>
<article class="book" data-template="book" id="JOB" data-book="JOB" data-name="Job" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Job</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Job</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Joel — World English Bible Updated</title>
<article class="book" data-template="book" id="JOL" data-book="JOL" data-name="Joel" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Joel</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Joel</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Jonah — World English Bible Updated</title>
<article class="book" data-template="book" id="JON" data-book="JON" data-name="Jonah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Jonah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Jonah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Joshua — World English Bible Updated</title>
<article class="book" data-template="book" id="JOS" data-book="JOS" data-name="Joshua" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Joshua</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Joshua</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Lamentations — World English Bible Updated</title>
<article class="book" data-template="book" id="LAM" data-book="LAM" data-name="Lamentations" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The</span><span class="mt">Lamentations</span><span class="mt2">of Jeremiah</span></h1>
<h1><span class="mt2">The</span> <span class="mt">Lamentations</span> <span class="mt2">of Jeremiah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Leviticus — World English Bible Updated</title>
<article class="book" data-template="book" id="LEV" data-book="LEV" data-name="Leviticus" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Third Book of Moses,</span><span class="mt3">Commonly Called</span><span class="mt">Leviticus</span></h1>
<h1><span class="mt2">The Third Book of Moses,</span> <span class="mt3">Commonly Called</span> <span class="mt">Leviticus</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Luke — World English Bible Updated</title>
<article class="book" data-template="book" id="LUK" data-book="LUK" data-name="Luke" data-section="nt">
<header class="book-header">
<h1><span class="mt2">The Good News According to</span><span class="mt">Luke</span></h1>
<h1><span class="mt2">The Good News According to</span> <span class="mt">Luke</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Malachi — World English Bible Updated</title>
<article class="book" data-template="book" id="MAL" data-book="MAL" data-name="Malachi" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Malachi</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Malachi</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Prayer of Manasses — World English Bible Updated</title>
<article class="book" data-template="book" id="MAN" data-book="MAN" data-name="Prayer of Manasses" data-section="deuterocanon">
<header class="book-header">
<h1><span class="mt3">The</span><span class="mt">Prayer of Manasses</span><span class="mt2">King of Judah,</span><span class="mt3">When He Was Held Captive in Babylon</span></h1>
<h1><span class="mt3">The</span> <span class="mt">Prayer of Manasses</span> <span class="mt2">King of Judah,</span> <span class="mt3">When He Was Held Captive in Babylon</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Matthew — World English Bible Updated</title>
<article class="book" data-template="book" id="MAT" data-book="MAT" data-name="Matthew" data-section="nt">
<header class="book-header">
<h1><span class="mt2">The Good News According to</span><span class="mt">Matthew</span></h1>
<h1><span class="mt2">The Good News According to</span> <span class="mt">Matthew</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Micah — World English Bible Updated</title>
<article class="book" data-template="book" id="MIC" data-book="MIC" data-name="Micah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Micah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Micah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Mark — World English Bible Updated</title>
<article class="book" data-template="book" id="MRK" data-book="MRK" data-name="Mark" data-section="nt">
<header class="book-header">
<h1><span class="mt2">The Good News According to</span><span class="mt">Mark</span></h1>
<h1><span class="mt2">The Good News According to</span> <span class="mt">Mark</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Nahum — World English Bible Updated</title>
<article class="book" data-template="book" id="NAM" data-book="NAM" data-name="Nahum" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Nahum</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Nahum</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Nehemiah — World English Bible Updated</title>
<article class="book" data-template="book" id="NEH" data-book="NEH" data-name="Nehemiah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Nehemiah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Nehemiah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Numbers — World English Bible Updated</title>
<article class="book" data-template="book" id="NUM" data-book="NUM" data-name="Numbers" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Fourth Book of Moses,</span><span class="mt3">Commonly Called</span><span class="mt">Numbers</span></h1>
<h1><span class="mt2">The Fourth Book of Moses,</span> <span class="mt3">Commonly Called</span> <span class="mt">Numbers</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Obadiah — World English Bible Updated</title>
<article class="book" data-template="book" id="OBA" data-book="OBA" data-name="Obadiah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Obadiah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Obadiah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Romans — World English Bible Updated</title>
<article class="book" data-template="book" id="ROM" data-book="ROM" data-name="Romans" data-section="nt">
<header class="book-header">
<h1><span class="mt2">Paul’s Letter to the</span><span class="mt">Romans</span></h1>
<h1><span class="mt2">Paul’s Letter to the</span> <span class="mt">Romans</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Ruth — World English Bible Updated</title>
<article class="book" data-template="book" id="RUT" data-book="RUT" data-name="Ruth" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Ruth</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Ruth</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Sirach — World English Bible Updated</title>
<article class="book" data-template="book" id="SIR" data-book="SIR" data-name="Sirach" data-section="deuterocanon">
<header class="book-header">
<h1><span class="mt">The Wisdom of Jesus the Son of Sirach,</span><span class="mt2">or</span><span class="mt">Ecclesiaticus</span></h1>
<h1><span class="mt">The Wisdom of Jesus the Son of Sirach,</span> <span class="mt2">or</span> <span class="mt">Ecclesiaticus</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Zechariah — World English Bible Updated</title>
<article class="book" data-template="book" id="ZEC" data-book="ZEC" data-name="Zechariah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Zechariah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Zechariah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
+1 -1
View File
@@ -1,7 +1,7 @@
<title>Zephaniah — World English Bible Updated</title>
<article class="book" data-template="book" id="ZEP" data-book="ZEP" data-name="Zephaniah" data-section="ot">
<header class="book-header">
<h1><span class="mt2">The Book of</span><span class="mt">Zephaniah</span></h1>
<h1><span class="mt2">The Book of</span> <span class="mt">Zephaniah</span></h1>
<nav class="chapter-nav" aria-label="Chapters" hx-boost="false">
<ul>
<li><a href="#ch1">1</a></li>
-1
View File
@@ -1,6 +1,5 @@
<title>World English Bible Updated</title>
<div class="index" data-template="index">
<h1>World English Bible Updated</h1>
<section class="book-group" data-section="ot">
<h2>Old Testament</h2>
<ul>
+17 -11
View File
@@ -3,11 +3,11 @@
Reads the JSON produced by extract_bible.py and writes books/GEN.html and so
on, plus an index. These are fragments, not documents: a <title> and one root
element carrying data-template. The surrounding shell is assembled at request
time by the service worker from the nunjucks templates in assets/, so chrome
changes never require running this. The markup carries structure and no
presentation: every USFM distinction survives as a class or data attribute, so
the whole corpus can be restyled from the templates without regenerating it.
element. The document around them is assets/shell.html, which is static and the
same for every page, 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 regenerating it.
This is the only build step left, and books/ is all it owns: everything
hand-written lives in assets/ and is served straight from there. Run it when
@@ -176,8 +176,8 @@ def render_book(book, translation, out_dir):
code, name = book["code"], book["name"]
out = []
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.
# A fragment: the title the shell will use, then one root element. The
# class on it is what identifies the kind of page to CSS.
out.append("<title>%s</title>\n" % esc(title))
out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
'data-name="%s" data-section="%s">\n'
@@ -187,8 +187,15 @@ def render_book(book, translation, out_dir):
out.append(' <header class="book-header">\n')
out.append(" <h1>")
if book["titles"]:
for part in book["titles"]:
out.append('<span class="%s">%s</span>' % (part["style"], esc(part["text"])))
# 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>'
% (part["style"], esc(part["text"]))
for part in book["titles"]))
else:
out.append(esc(name))
out.append("</h1>\n")
@@ -226,8 +233,7 @@ SECTION_LABELS = [
def render_index(books, translation, out_dir):
out = ["<title>%s</title>\n" % esc(translation["name"]),
'<div class="index" data-template="index">\n <h1>%s</h1>\n'
% esc(translation["name"])]
'<div class="index" data-template="index">\n']
for key, label in SECTION_LABELS:
group = [b for b in books if b["section"] == key]
if not group:
+62 -43
View File
@@ -9,27 +9,30 @@ 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; the service worker
assembles the shell around them. A browser asking for one directly, before any
worker is installed, would get a bare fragment with no chrome, so navigations
are answered with boot.html, which installs the worker and reloads into the
real app. Requests the worker itself makes are ordinary fetches rather than
navigations, so they pass straight through and get the fragment.
The first is the shell. The pages here are fragments; shell.html is the one
document that wraps them, and app.js fills it in from the URL. So every
navigation is answered with shell.html, whatever path it asked for -- exactly
what the service worker does once it is installed, which is why a first visit
needs no special handling and no reload. Requests the worker and htmx make are
ordinary fetches rather than navigations, so they pass straight through and get
the fragment.
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. See stamp_worker().
Doing it here rather than in a build step means editing the shell 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 pages that register it are served stale, a new version can never
worker or the shell 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. That is not optional for
the offline app: browsers only register a service worker in a secure context,
so a device loading this over plain http:// from a LAN address installs the
page but never gets an offline copy.
Expects to sit behind something that terminates TLS. Without it the app still
works -- the shell and the fragments are plain files -- but there is no offline
copy: browsers only register a service worker in a secure context, and the
bookmark lives in that worker, so over plain http:// from a LAN address the app
reads online-only and the bookmark slot stays empty.
"""
from __future__ import annotations
@@ -47,17 +50,18 @@ 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_EXTS = {".html", ".njk", ".webmanifest"}
NO_CACHE_EXTS = {".html", ".webmanifest"}
# Served in place of any page a browser navigates to directly.
BOOT_PAGE = "/boot.html"
# Served in place of any page a browser navigates to.
SHELL_PAGE = "/shell.html"
WORKER = "sw.js"
# The two lines stamp_worker() rewrites. They are real statements rather than
# 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';"
ASSETS_LINE = 'const ASSETS = ["./"];'
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
LIBRARY_LINE = "const LIBRARY = [];"
def walk(roots):
@@ -75,33 +79,49 @@ def walk(roots):
return found
def stamp_worker(roots):
"""assets/sw.js with VERSION and ASSETS filled in from what is on disk.
def digest(files):
"""A short fingerprint of a set of files, by name, size and mtime.
The version is a digest of every file's name, size and mtime rather than
its contents: it has to change whenever anything changes, and it is
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 precache, which is harmless.
changing it costs one needless refetch, which is harmless.
"""
files = walk(roots)
digest = hashlib.sha256()
assets = []
out = hashlib.sha256()
for rel in sorted(files):
if rel == WORKER:
continue # the browser revalidates the worker itself
st = os.stat(files[rel])
digest.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
assets.append("./" + rel)
assets.insert(0, "./") # a bare-root launch is its own cache entry
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
return out.hexdigest()[:12]
src = files.get(WORKER)
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 rest is what render_books.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.
"""
chrome = walk(roots[:1])
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
if src is None:
return None
with open(src, encoding="utf-8") as fh:
body = fh.read()
for line, value in ((VERSION_LINE, "const VERSION = '%s';" % digest.hexdigest()[:12]),
(ASSETS_LINE, "const ASSETS = %s;" % json.dumps(assets, indent=2))):
for line, value in (
(VERSION_LINE, "const VERSION = '%s';" % digest(chrome)),
(LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)),
(LIBRARY_LINE, "const LIBRARY = %s;"
% json.dumps(sorted("./" + rel for rel in library), indent=2)),
):
if line not in body:
raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line))
body = body.replace(line, value, 1)
@@ -112,7 +132,6 @@ class Handler(SimpleHTTPRequestHandler):
extensions_map = {
**SimpleHTTPRequestHandler.extensions_map,
".html": "text/html; charset=utf-8",
".njk": "text/plain; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
@@ -139,13 +158,13 @@ class Handler(SimpleHTTPRequestHandler):
return super().translate_path(path)
def send_head(self):
# Sec-Fetch-Mode is the one signal that separates a browser typing in
# a URL from the worker fetching a fragment: a request the worker
# constructs is never in navigate mode. Browsers without the header
# also have no service worker, and get the fragment -- there is
# nothing better to give them.
if self.headers.get("Sec-Fetch-Mode") == "navigate" and self.path != BOOT_PAGE:
self.path = BOOT_PAGE
# 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. Browsers without the header also have no
# service worker, and get the fragment -- there is nothing better to
# give them.
if self.headers.get("Sec-Fetch-Mode") == "navigate":
self.path = SHELL_PAGE
if posixpath.basename(self.path.split("?", 1)[0]) == WORKER:
return self.send_worker()