, which at heading size shouts over the text. */
.chapter-number {
font-size: 2rem;
font-weight: 400;
@@ -41,10 +26,6 @@
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;
@@ -54,13 +35,11 @@
.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);
@@ -68,38 +47,17 @@
.footnotes-title { font-size: 1rem; }
.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,
.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,
.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 {
width: auto;
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 {
list-style: none;
padding-left: 0;
diff --git a/assets/js/app.js b/assets/js/app.js
index 7341f81..a6a70ec 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -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 () {
'use strict';
- // -- events -------------------------------------------------------------
-
document.body.addEventListener('htmx:afterSwap', function (event) {
var target = event.detail.target;
if (!target) return;
- // Both selects answer into the verse list, so this is either of them.
- if (target.id === 'reference-verse') return landOnReference();
- if (target.id === 'content') { land(); showVersion(); }
+ if (target.id === 'reference-verse') return scrollToHash();
+ if (target.id === 'content') return land();
});
- // A history restore does not go through the request pipeline -- htmx swaps
- // the element straight in and fires this instead -- so the work afterSwap
- // does has to be repeated here or a page reached by the back button arrives
- // 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 () {
land();
- showVersion();
});
- // -- startup ------------------------------------------------------------
-
function hashTarget() {
if (!location.hash) return null;
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) {
- try {
- el.scrollIntoView({ block: 'start', behavior: 'instant' });
- } catch (err) {
- el.scrollIntoView({ block: 'start', behavior: 'auto' });
- }
+ el.scrollIntoView({ block: 'start', behavior: 'instant' });
}
- // The same, for the top of the page and the same reason.
function jumpToTop() {
- try {
- window.scrollTo({ top: 0, behavior: 'instant' });
- } catch (err) {
- window.scrollTo(0, 0);
- }
+ window.scrollTo({ top: 0, behavior: 'instant' });
}
- // 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() {
var el = hashTarget();
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() {
var el = hashTarget();
if (el) jumpTo(el);
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 () {
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) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
diff --git a/assets/js/render.js b/assets/js/render.js
index 44052cf..166b6fc 100644
--- a/assets/js/render.js
+++ b/assets/js/render.js
@@ -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) {
'use strict';
- // A fragment is a and one root element. The root's data-template
- // names the template that wraps it; compile_bible.py writes both.
var TITLE_RE = /([\s\S]*?)<\/title>/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/';
- // 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 = [
'base.njk',
'page.njk',
@@ -43,11 +22,7 @@
scope.TEMPLATE_URLS = NAMES.map(function (name) { return TEMPLATES + name; });
- // readText(path) -> Promise is the only thing the two callers do
- // differently: the worker reads through its cache, the page over the network.
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() {}
Loader.prototype.async = true;
Loader.prototype.getSource = function (name, callback) {
@@ -56,38 +31,13 @@
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 });
- // 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 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;
+ // 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() {
if (!loaded) {
loaded = Promise.all(NAMES.map(function (name) {
@@ -112,18 +62,6 @@
return {
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) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, '');
@@ -132,13 +70,10 @@
title: title,
content: content,
mark: chrome.mark,
- reference: chrome.reference,
- version: chrome.version
+ reference: chrome.reference
});
},
- // 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) {
return render('notice.njk', { heading: heading, text: text });
}
diff --git a/assets/js/router.js b/assets/js/router.js
index 0797b5c..19af433 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -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) {
'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) {
this.event = event;
this.request = event.request;
this.url = url;
this.params = params;
- // htmx stamps every request it makes; a navigation never carries it.
this.htmx = this.request.headers.get('HX-Request') === 'true';
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.request.headers.get('HX-History-Restore-Request') === 'true';
- // The address the page making this request is showing. htmx stamps it on
- // everything it sends; a navigation carries nothing, because there is no
- // page yet. It is how a handler answering the chrome -- which htmx never
- // swaps, and which therefore outlives the page it was rendered for -- can
- // still find out which page that is.
this.current = this.request.headers.get('HX-Current-URL');
}
- // That address as a URL, resolved against this request's own so a relative
- // one still works. Null when there was no page.
Call.prototype.currentUrl = function () {
return this.current ? new URL(this.current, this.url) : null;
};
@@ -55,8 +27,6 @@
return this.request.formData();
};
- // base is the worker's scope path, e.g. "/" -- the prefix every pattern is
- // written without.
function Router(base) {
this.base = base;
this.routes = [];
@@ -75,9 +45,6 @@
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) {
var path = url.pathname;
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
@@ -99,8 +66,6 @@
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) {
var found = this.find(event.request.method, this.relative(url));
if (!found) return null;
@@ -113,8 +78,6 @@
status: 200,
headers: {
'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'
}
});
diff --git a/assets/sw.js b/assets/sw.js
index 5816799..ad0e13a 100644
--- a/assets/sw.js
+++ b/assets/sw.js
@@ -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');
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;
-// 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/,
-// 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 LIBRARY_VERSION = 'dev';
const LIBRARY = [];
@@ -82,23 +13,10 @@ const LIBRARY = [];
const SHELL_CACHE = 'bible-shell-' + 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 };
-// 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. "./" -- 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 = [
'./boot.html',
'./',
@@ -108,10 +26,6 @@ const PRECACHE = [
'./js/app.js',
'./js/htmx.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);
function cacheFor(req) {
@@ -121,8 +35,6 @@ function cacheFor(req) {
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
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(PRECACHE.map((url) =>
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) {
return url.pathname.split('/').pop().indexOf('.') !== -1;
}
@@ -153,17 +62,10 @@ 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);
if (!isFile(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) {
return event.respondWith(routed.then((res) => res || answer(req, event)));
}
@@ -173,10 +75,6 @@ self.addEventListener('fetch', (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) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
@@ -191,10 +89,6 @@ function cached(req) {
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) {
const req = typeof input === 'string' ? new Request(input) : input;
@@ -202,7 +96,6 @@ async function answer(input, event) {
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 = cacheFor(req).then((c) => c.put(req, copy));
if (event) event.waitUntil(write);
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) {
return new Request(url, { cache: 'no-cache' });
}
-// Used for verses.json as well, which is generated beside the pages and
-// versions with them, and would go stale the same way for the same reason.
-
async function readText(input, event) {
const res = await answer(input, event);
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
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) {
const hit = await cached(path);
return hit ? hit.text() : readText(path);
@@ -251,31 +125,12 @@ async function readTemplate(path) {
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);
-// ---------------------------------------------------------------- reference
-
-// bible/verses.json: every book's chapters, and every chapter's verse labels.
-// compile_bible.py writes it from the same tree it renders the books from, so
-// every option built here names an anchor that is in the page it lands on.
-//
-// A chapter whose verses run 1..n is stored as the number n; only the eight in
-// the corpus with a gap or a range -- Sirach 26 ends "19-27", 28, 29 -- spell
-// their labels out. 6 KB for 38,000 verses rather than 180.
const VERSES_URL = './verses.json';
let verses = null;
-// Held for the life of this worker, and only for that. Regenerating the corpus
-// changes LIBRARY_VERSION, which is a new worker with a new cache, so a copy
-// held here cannot outlive the pages it describes.
-//
-// It lives in the library cache, so a device that has never been online long
-// enough to fetch it has no picker rather than no page -- see referenceOf.
function readVerses() {
if (!verses) {
verses = readText(pageRequest(VERSES_URL))
@@ -285,10 +140,6 @@ function readVerses() {
return verses;
}
-// "ch3" or "v3_16" -- the id of the element an option lands on, which is also
-// where the chapter it belongs to is written down. Options are built here, so
-// this is only ever reading back what this file sent; a URL is still a URL,
-// and anything else is null.
function parseAnchor(id) {
const chapter = /^ch(\d+)$/.exec(id || '');
if (chapter) return { chapter: +chapter[1], verse: null };
@@ -305,40 +156,17 @@ function verseLabels(chapters, number) {
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) {
const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
? map[code] : null;
if (!chapters || !chapters.length) return null;
- // The front matter and the glossary are books with chapters and no verses.
- // A picker over them would be a chapter select beside an empty one, so they
- // get none, the same as the book list.
if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) {
return null;
}
const at = parseAnchor(anchor) || { chapter: 1, verse: null };
const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1;
- // 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;
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) {
return url ? router.relative(url).slice(1) : '';
}
-// Never throws. The map is fetched over the network first and answered from
-// the library cache when that fails, so the one case this swallows is a device
-// that has gone offline before the background download reached verses.json:
-// the pages it has read still read, and the picker is hidden until a later
-// navigation finds the map. The same degradation the bookmark makes.
async function referenceOf(code, anchor) {
if (!code) return null;
try {
@@ -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) {
const code = bookOf(page);
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.'];
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) {
let res;
try {
@@ -422,115 +224,40 @@ async function fragmentFor(path, event) {
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) {
- 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);
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'
+ await view.render('reference.njk', { reference: reference, oob: true });
}
try {
return await view.compose(fragment, { mark: await getBookmark(),
- reference: reference,
- version: VERSIONS });
+ reference: reference });
} catch (err) {
- // A template that will not render is a bug, not a condition to design for,
- // but it should not take the text down with it: the bare fragment has no
- // chrome and no styling, and it is still the page that was asked for.
console.error('compose failed: ' + err.message);
return fragment;
}
}
-// "/" before "/:page", because both are two segments and the first match wins.
router.get('/', (call) => servePage('./', '', call));
router.get('/:page', (call) =>
servePage('./' + call.params.page, call.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) => {
const params = call.url.searchParams;
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 into a
-{# col-xl-8 keeps a line of verse near a readable measure instead of running
- the full width of a desktop window. #}
- {# The swap target. hx-target on 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. #}
-{# 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. #}
-{# 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. #}
diff --git a/assets/templates/bookmark.njk b/assets/templates/bookmark.njk
index 7357c26..7acc242 100644
--- a/assets/templates/bookmark.njk
+++ b/assets/templates/bookmark.njk
@@ -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. #}
diff --git a/assets/templates/index.njk b/assets/templates/index.njk
index 8bf664b..ba070b3 100644
--- a/assets/templates/index.njk
+++ b/assets/templates/index.njk
@@ -1,7 +1,5 @@
{% 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 %}
{% endblock %}
{% block content %}{{ content | safe }}{% endblock %}
diff --git a/assets/templates/notice.njk b/assets/templates/notice.njk
index 2aa9b50..3a979f5 100644
--- a/assets/templates/notice.njk
+++ b/assets/templates/notice.njk
@@ -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. #}
{{ heading }}
into a
-{# col-xl-8 keeps a line of verse near a readable measure instead of running
- the full width of a desktop window. #}
- {# The swap target. hx-target on 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. #}
-{# 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. #}
-{# 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. #}
diff --git a/assets/templates/bookmark.njk b/assets/templates/bookmark.njk
index 7357c26..7acc242 100644
--- a/assets/templates/bookmark.njk
+++ b/assets/templates/bookmark.njk
@@ -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. #}
diff --git a/assets/templates/index.njk b/assets/templates/index.njk
index 8bf664b..ba070b3 100644
--- a/assets/templates/index.njk
+++ b/assets/templates/index.njk
@@ -1,7 +1,5 @@
{% 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 %}
{% endblock %}
{% block content %}{{ content | safe }}{% endblock %}
diff --git a/assets/templates/notice.njk b/assets/templates/notice.njk
index 2aa9b50..3a979f5 100644
--- a/assets/templates/notice.njk
+++ b/assets/templates/notice.njk
@@ -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. #}
{{ heading }}