Remove comments

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

No files matched your search

-24
View File
@@ -4,38 +4,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>World English Bible Updated</title>
<!-- Vendored rather than linked from a CDN: sw.js only caches same-origin
responses, so a CDN stylesheet would leave the app unstyled offline. -->
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/app.css">
<meta name="theme-color" content="#1a202c">
</head>
<body class="bg-body-tertiary">
<!-- Served by server.py in place of any address a browser navigates to while
no service worker is controlling this origin: a first visit, a cleared
cache, a reload that bypassed the worker.
The pages on disk are fragments and the shell is a template, and it is the
worker that puts them together. So this page has one job: register the
worker and reload once it is running, which lands on the real app at the
address that was asked for. It is only ever seen once per device.
There is no fallback for a device that cannot install one. There used to
be -- this page loaded the renderer itself and composed the page in the
browser -- and it existed for one case: the app served over plain http://
from a LAN address, which is not a secure context, so no worker can
register. This app is served over TLS, where that case cannot arise, and
the fallback was a second way of building every page that nothing
exercised. What is left when registration fails is a sentence saying so,
because there is nothing honest to show instead. -->
<main class="boot container py-5 text-center">
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
<!-- Empty until there is something to say. This page is on screen for a
moment on a first visit and then never again, and a line naming what it
is doing is a line nobody needs to read: the spinner already says wait.
It is here, and empty, because it is where fail() writes. -->
<p id="boot-status" class="text-body-secondary"></p>
<noscript><p class="alert alert-warning d-inline-block">This app builds its
pages on the device and needs JavaScript.</p></noscript>
-42
View File
@@ -1,20 +1,8 @@
/* The text layer. Bootstrap styles the chrome; this styles scripture.
Every selector here is a class render_books.py already emits, so restyling
the text never means regenerating books/. */
/* Serif everywhere, chrome included. Overriding Bootstrap's own variable
rather than setting font-family on elements means the navbar, modal and
buttons all follow, and a typeface is named in exactly one place. System
fonts on purpose: a webfont would be one more thing to keep cached for
offline, and one more request before the text can be read. */
:root {
--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;
@@ -25,15 +13,12 @@
user-select: none;
}
/* These are real buttons -- compile_bible.py writes role and tabindex on every
one of them -- so they need a visible focus ring. */
.v:focus-visible {
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;
@@ -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;
+4 -147
View File
@@ -1,187 +1,44 @@
// Page startup.
//
// Pages arrive whole -- the service worker composes every one of them -- so
// there is nothing here that builds a page.
// What is left is the three things a composed document cannot do for itself:
// put a swapped-in page where it belongs before it is painted, land the
// reference picker where the reader asked to go, and tell the chrome when a
// worker has appeared to answer it.
//
// Three things used to live here and no longer do, all for the same reason:
// they were reading the page to work out something that was already known
// somewhere better.
//
// The reference picker was built by reading the chapters and verses off the
// book on screen. It is markup and htmx now: sw.js renders it from
// bible/verses.json and answers api/reference, and the address-bar update that
// a change event used to run through here is a response header on the answer.
// See templates/reference.njk.
//
// The verse dialog was built by scraping the book name out of article
// [data-name] and the chapter out of the enclosing section, then filling a
// form and showing it. sw.js answers api/verse with the whole of the dialog,
// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap
// opens it -- which is htmx's own bootstrap-modal example, with the click
// delegated because the trigger is one of 38,000 generated verse numbers and
// cannot carry attributes of its own. See templates/verse.njk.
//
// The third was marking those numbers as controls: role, tabindex and an
// aria-label, set on every verse on load, after every swap and after every
// history restore -- a walk over a whole book, three times over, to arrive at
// the same three attributes every time. They are in the markup now, written
// by compile_bible.py, because nothing about them depends on anything only the
// browser knows. It costs the corpus a couple of megabytes and saves the pass.
(function () {
'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 () {});
}
+4 -69
View File
@@ -1,32 +1,11 @@
// The renderer: the templates in templates/, and the two things sw.js does
// with them -- compose a fragment into the shell, and stand a notice in for a
// page that could not be delivered.
//
// A file of its own rather than more of sw.js because it is the half with
// nothing to do with the network: no routes, no caches, no requests. It was
// loaded by boot.html as well once, which composed pages in the browser for a
// device that could not install a worker; nothing but the worker builds a page
// now.
//
// Loaded with importScripts, so it defines a global rather than exporting.
// Nunjucks must already be loaded.
(function (scope) {
'use strict';
// A fragment is a <title> and one root element. The root's data-template
// names the template that wraps it; compile_bible.py writes both.
var TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
var 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<string> is the only thing the two callers do
// differently: the worker reads through its cache, the page over the network.
scope.createRenderer = function (readText) {
// 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 </select> after
// it -- a picker with a chapter and no verses, on whichever navigation
// happened to be the first to need the template.
//
// Which is why it is all of them and not just the ones that get included:
// whether a template has been reached yet depends on which pages have
// been rendered, and in the worker on whether it has been restarted since.
// That is the whole shape of the bug -- intermittent, and never twice in a
// row. One pass over a few kilobytes already in the cache buys the
// question being settled.
//
// Failures resolve rather than reject: a template that cannot be read now
// will fail again in the render that actually needs it, where the error
// belongs to a request and can be answered with the bare fragment.
var loaded = null;
// 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 });
}
-37
View File
@@ -1,17 +1,3 @@
// A very small HTTP router, so the worker reads as a set of routes rather than
// a chain of ifs unpicking a URL. The shape is lifted from the one in
// htmx-serviceworker-todo, which is in turn a miniature of Hono or Express:
// register a method and a path pattern, get the matching handler back.
//
// Patterns are matched segment by segment against a path relative to the
// worker's own scope, so the app still works if it is ever served from a
// subdirectory. A segment written as :name matches anything and lands in
// params. Segment counts must be equal -- there are no wildcards, because
// nothing here needs one.
//
// A handler returning a string gets it as an HTML response; returning a
// Response passes it through untouched; returning nothing means "not mine
// after all", and the caller falls back to serving the URL as a file.
(function (scope) {
'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'
}
});
+7 -348
View File
@@ -1,80 +1,11 @@
// The app's renderer, and its backend. Hand-written: this file is the source,
// served as-is apart from the three lines server.py stamps (see below).
//
// It reads as a small server, because that is what it is. Requests come in
// through a router (js/router.js) and are answered by handlers; the handlers
// render the nunjucks templates in templates/ (js/render.js) and read and
// write the bookmark in IndexedDB. htmx on the page talks to it exactly as it
// would to an origin.
//
// GET / the book list
// GET /:page one book -- /GEN, /EXO
// POST /api/bookmark set the bookmark, and return the navbar slot
// GET /api/reference the navbar's chapter and verse picker
// GET /api/verse the dialog for one verse
// GET /api/toast one confirmation, by name
//
// Anything with a dot in its last segment is a file, not a route, and is
// served from the cache or the network without going near the router. Pages
// have no extension: what is on disk is bible/GEN.html, what the reader sees
// is /GEN, and server.py resolves one to the other. bible/index.html is the
// root itself -- there is no /index, the book list lives at /.
//
// The files on disk are fragments: a title element and one root element. The
// shell around them is templates/base.njk, applied per request, so changing the
// chrome means editing one template rather than regenerating every book. A
// handler returns the bare fragment to htmx and the composed document to a
// navigation -- one route, both callers, which is the whole reason the request
// object carries `navigate`.
//
// The composing is in js/render.js. Every page in the app comes through it:
// boot.html registers this worker and reloads into it, and from there nothing
// else ever builds one.
//
// Every same-origin GET is network-first: ask the network, fall back to the
// cache when it fails. Offline, fetch rejects immediately, so the fallback is
// effectively instant -- the only cost is one round trip while online, and you
// never see a stale page. Every response that arrives is written back to the
// cache, so a book that has been read once stays readable offline. Templates
// are the one exception, and readTemplate() says why.
//
// The whole corpus is kept offline, but not by making anyone wait for it and
// not by telling them about it. Install caches the shell alone -- a handful of
// small files -- and the app is usable the moment that is done. The books are
// then fetched a few at a time in the background while the reader reads, with
// nothing on screen to say so. Close the tab half way through and the next
// visit picks up where it left off: the cache is the record of what has been
// fetched, so there is no separate bookkeeping to lose.
// Imported scripts are stored with the registration, so these survive offline
// without being in PRECACHE. importScripts rather than the fetch-and-eval
// module loader the router came from: it is the one the platform supports in a
// worker, and it costs no waterfall.
importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js');
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 <div> into a <select>, which renders as nothing, and
// the select is then empty for good -- every change after that posts the
// same empty value and gets the same answer.
//
// An empty value is still an answerable question. It names no chapter, so it
// gets chapter 1, which is what everything here does with an anchor it
// cannot read.
const moved = params.has('at') ? params.get('at') : null;
// The address bar is the fallback, and only that: once a select has been
// used it is the one saying where to go, and HX-Replace-Url has not caught
// up yet.
const reference = await referenceOf(
bookOf(page), moved === null ? (page && page.hash.slice(1)) : moved);
// settled, because this picker is already standing where the anchor put it.
// The one that came with the page is not, and carries a load trigger to ask
// -- which this answer must not carry as well, or it would ask forever.
if (moved === null) {
return view.render('reference.njk', { reference: reference, settled: true });
}
// The options were rendered from this map, so failing to parse them back
// means the map moved under an open page: leave the picker alone rather
// than swapping in options for a book that is no longer on screen.
if (!reference) return new Response('', { status: 204 });
const target = reference.verse || reference.chapter;
@@ -544,18 +271,6 @@ router.get('/api/reference', async (call) => {
});
});
// The dialog for one verse.
//
// at=v1_3 which verse, by the id of the element that is it -- the same
// spelling the picker's options use, read back the same way.
//
// The book is not in the URL, for the reason it is not in the picker's: this
// is chrome, it outlives the page under it, and HX-Current-URL is the address
// the page is actually showing.
//
// 204 for anything it cannot place. htmx swaps nothing on a 204, and the
// dialog opens on the swap, so a bad anchor is a click that does nothing
// rather than an empty dialog asking to bookmark it.
router.get('/api/verse', async (call) => {
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
if (!verse) return new Response('', { status: 204 });
@@ -570,10 +285,6 @@ router.post('/api/bookmark', async (call) => {
if (!ref || !href) return new Response('', { status: 400 });
await withStore('readwrite',
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
// Only on the way out of a write that committed. base.njk closes the
// dialog on this, and there is nothing left in the dialog worth keeping
// open for: the confirmation rides out of band into the toast container,
// which is not inside it.
return new Response(await bookmarkFragment('bookmarked'), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
@@ -582,19 +293,10 @@ router.post('/api/bookmark', async (call) => {
}
});
} catch (err) {
// No trigger, so the dialog stays where it is. The toast says what
// happened whether or not it does, but a dialog that closed on a failed
// write would look exactly like one that closed on a successful one.
return bookmarkFragment('bookmark-refused');
}
});
// ------------------------------------------------------------------ toasts
// Everything the app says back to a reader who has just done something. Named
// rather than passed as text: the page asks for 'copied', not for a sentence,
// so the wording is here with the rest of the app's words and an attribute in
// a template never has to spell one out. bad is the ones that did not happen.
const TOASTS = {
'copied': { text: 'Link copied.' },
'copy-refused': { text: 'Copy the link above: this browser would not '
@@ -604,43 +306,22 @@ const TOASTS = {
+ 'unavailable.', bad: true }
};
// oob is how it travels: on its own to the toast container, which is what the
// copy button asks for, or out of band on an answer that is already going
// somewhere else, which is what a bookmark write sends.
function toastFragment(say, oob) {
const toast = TOASTS[say];
return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob });
}
// One toast, by name.
//
// say=copied which one, from the table above.
//
// 204 for a name that is not in it, on the same reasoning api/verse 204s for a
// verse that is not there: htmx swaps nothing, so a typo appends nothing,
// rather than an empty toast sliding up to say nothing at all.
router.get('/api/toast', (call) => {
const say = call.url.searchParams.get('say');
if (!TOASTS[say]) return new Response('', { status: 204 });
return toastFragment(say, false);
});
// ----------------------------------------------------------------- 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
let filling = null;
let complete = false;
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; });
}
@@ -658,8 +339,6 @@ async function fillLibrary() {
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() {
@@ -667,30 +346,18 @@ async function fillLibrary() {
const url = missing.pop();
try {
const res = await fromNetwork(pageRequest(url), DOWNLOAD_TIMEOUT_MS);
// A response that arrived but is not usable -- a page that has since
// been removed, say -- is skipped rather than retried. The connection
// is plainly fine, and stopping for it would strand everything after.
if (res.ok && res.type === 'basic') await cache.put(url, res);
} 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
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 can assume a mark -- bookmark.njk's empty branch is for the boot
// page, which has no worker to ask, not for a reader without one.
const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' };
function openDb() {
@@ -702,8 +369,6 @@ function openDb() {
});
}
// Resolved on the transaction rather than the request, so the value is only
// reported once the write it belongs to has actually committed.
async function withStore(mode, fn) {
const db = await openDb();
try {
@@ -718,18 +383,12 @@ async function withStore(mode, fn) {
}
}
// Storage being unavailable degrades to the default rather than a broken page,
// which is the same fallback as never having set one.
function getBookmark() {
return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
.catch(() => null)
.then((mark) => mark || DEFAULT_BOOKMARK);
}
// The navbar slot, and the toast that says what just happened to it. The
// toast rides out of band, so one response updates the slot and raises it
// without the page having to ask for the second one -- same template and same
// table api/toast answers from, so the two cannot drift apart.
async function bookmarkFragment(say) {
return await view.render('bookmark.njk', { mark: await getBookmark() })
+ '\n' + await toastFragment(say, true);
-118
View File
@@ -1,52 +1,14 @@
{# The shell every page is wrapped in. Rendered by sw.js, not by the build,
so editing this file changes every page without regenerating any of them.
Context: title, content (safe HTML fragment), mark (bookmark or null),
reference (the navbar picker's model, or null).
Bootstrap is the whole stylesheet. The fragments carry semantic USFM
classes and no Bootstrap ones, so the body text is styled by Reboot; the
chrome here is what uses the framework. #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
{# No viewport-fit=cover, deliberately. It extends the page into the notch
and the home indicator, which only makes sense paired with safe-area-inset
padding in the stylesheet -- and without that padding on iOS the first line
of scripture sits under the clock. Paired with the status bar style below:
change one and the other has to change too. #}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title | safe }}{% endblock %}</title>
{# Vendored rather than linked from a CDN: sw.js only caches same-origin
responses, so a CDN stylesheet would leave the app unstyled offline. #}
<link rel="stylesheet" href="css/bootstrap.min.css">
{# app.css comes second so its variable overrides win. It styles the
scripture; Bootstrap styles the chrome. #}
<link rel="stylesheet" href="css/app.css">
{# History entries are whole books, and htmx keeps its history cache in
localStorage -- a couple of them would blow the quota. Turned off, so every
back or forward is a cache miss, and refreshOnHistoryMiss decides what a
miss does.
False on purpose. True makes htmx call location.reload(true) -- a forced
reload, which is exactly the kind of navigation a browser takes past the
service worker, so it reaches server.py and comes back as boot.html: the
spinner flashes by and then reloads into the real page. False makes htmx
fetch the page over XHR instead, which the worker answers itself, from its
own cache when offline. No navigation, no boot page, nothing to flash.
That request carries HX-History-Restore-Request, and the worker answers it
with a whole composed document; htmx lifts the element marked
hx-history-elt out of the response and puts it back in the page. #}
<meta name="htmx-config"
content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'>
{# The bundle carries Popper, which the navbar collapse and the modal do not
need -- but a dropdown or tooltip added later would, and this is one file
in the precache either way. Deferred scripts run in order, so `bootstrap`
is defined by the time app.js runs. #}
<script src="js/bootstrap.bundle.min.js" defer></script>
{# htmx is not deferred: hx-boost has to be in place before the first click. #}
<script src="js/htmx.min.js"></script>
<script src="js/app.js" defer></script>
<link rel="manifest" href="manifest.webmanifest">
@@ -54,22 +16,9 @@
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
{# black, not black-translucent. Translucent makes iOS draw the status bar
over the web view and reserve no space for it, so the navbar starts at the
very top of the screen with the clock and battery on top of it. black keeps
the status bar opaque and the view below it, which is what this app wants:
there is no full-bleed artwork here to show through. #}
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Bible">
</head>
{# Boosted links swap only the fragment, so the navbar, the modal and the
scripts above survive navigation and never reload.
No show: modifier, deliberately. htmx adds show:top itself for a boosted
link, but skips it when the href carries a fragment identifier and scrolls
to that anchor after the swap instead. Spelling show:top out here overrides
that decision for every link, which leaves a cross-reference like ISA#v61_6
sitting at the top of Isaiah. #}
<body class="{% block body_class %}{% endblock %} bg-body-tertiary"
hx-boost="true" hx-target="#content" hx-swap="innerHTML">
@@ -94,31 +43,8 @@
</div>
</nav>
{# col-xl-8 keeps a line of verse near a readable measure instead of running
the full width of a desktop window. #}
<main class="container-xl my-4">
<div class="row justify-content-center">
{# The swap target. hx-target on <body> names this id, so every boosted
link lands here -- remove it and a click does nothing at all while a
full navigation still works.
It is also where a verse click becomes a request. htmx's bootstrap-modal
example puts hx-get on the element that opens the dialog; the element
here is a verse number in a generated book, and there are 38,000 of
them -- an attribute apiece would cost more than the whole shell, and a
listener apiece more than the reading. So one handler sits on the
container they all swap into, and names the verse it found. Delegated,
so it survives every swap: this element is never replaced, only filled.
hx-vals cannot do this. htmx evaluates it as Function("return (...)")()
with nothing passed in, so an expression there can read the document but
not the click -- and which verse was clicked is the one thing that has
to travel.
Enter and space go through the same door rather than a second one:
compile_bible.py writes role=button and tabindex on every verse, and a
button that answers the keyboard by being clicked is a button with one
behaviour instead of two. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt
hx-on:click="var verse = event.target.closest('.v[id]');
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
@@ -131,37 +57,8 @@
{% block content %}{% endblock %}
</div>
</div>
{% include "version.njk" %}
</main>
{# The verse dialog: empty in the page, filled per request. Bootstrap owns
showing it, dismissing it, the backdrop and Escape; sw.js owns everything
inside, and templates/verse.njk is what lands in the modal-content below.
Opened by the arrival of the content rather than by data-bs-toggle on
whatever was clicked. The example puts that attribute on its trigger, which
is not available here for the same reason hx-get is not -- see #content
above -- and the swap is the honest signal anyway: there is nothing worth
showing until it is here. A verse the corpus does not have is answered 204,
which swaps nothing, which opens nothing.
after-swap and not after-settle: settling is the 20ms attribute pass and
this dialog has nothing to settle. Only the modal-content swap can reach
here -- the bookmark write targets the navbar slot, and the toast it sends
back rides in out of band, which fires its own event and not this one.
verse-done is the dialog saying it has finished its job, and it arrives
from both of the things in it: sw.js sends it as a trigger header on a
bookmark write that committed, and the copy button dispatches it when the
clipboard has actually taken the link. Neither sends it when the action
failed -- the field in the dialog is the fallback for a copy that was
refused, and there is nothing to take down the dialog for when there is
nothing to celebrate.
Closed at once rather than after a pause. The pause was there to let the
status line inside be read before the dialog took it away; the line is a
toast now, outside and above all of this, so it outlives the dialog and
there is nothing left to wait for. #}
<div class="modal fade" id="verse-dialog" tabindex="-1"
aria-labelledby="verse-dialog-ref" aria-hidden="true"
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
@@ -171,21 +68,6 @@
</div>
</div>
{# Where confirmations land. Empty in the page and filled a toast at a time:
api/toast appends one when the copy button asks for it, and a bookmark
write sends one out of band on its own answer. templates/toast.njk is what
lands here, and each one shows and then removes itself, so this is empty
again a few seconds later.
Fixed, and outside #content, for the same reason the dialog is: it has to
survive the swap under it. It also has to survive the dialog -- a copy
confirmed by a toast inside the modal would go when the modal did.
Bottom right, and last in the document, so it covers the text rather than
moving it, and so nothing in the reading order comes after it. It sits
above the modal without being asked to: Bootstrap gives .toast a z-index of
1090 against the modal's 1055, and this container takes none of its own,
which is what leaves that comparison to the toast. #}
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
</body>
-16
View File
@@ -1,21 +1,5 @@
{# The navbar's bookmark slot. Rendered into the shell with whatever mark the
worker holds, and returned on its own by api/bookmark after a write. Both
paths must produce the same element for the swap to be seamless.
Always filled: getBookmark() answers with the default when nothing has been
set and when storage cannot be read at all, so there is no empty state to
render. There was one -- a hidden slot that fetched itself once a worker
turned up -- for pages boot.html composed before there was a worker to ask.
Nothing composes a page but the worker now, and the worker always knows. #}
<span class="bookmark navbar-text d-flex align-items-center gap-1" id="bookmark">
{# A word and a colon, not a badge: this is one line of text that happens to
end in a link, and a pill in front of it read as a control. Outside the
anchor so that only the reference is underlined -- Reboot underlines an
<a>, and the label is not the part you can go to. It takes the muted
navbar colour from .navbar-text on the parent without asking. #}
<span class="bookmark-label">Bookmark:</span>
{# 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>
</span>
-2
View File
@@ -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 %}
-6
View File
@@ -1,9 +1,3 @@
{# A page that could not be delivered, written in the same shape as a real
one: a title and one root element naming the template that wraps it.
Sent with 200 on purpose. htmx only swaps a 2xx body, and an error status
would leave the reader looking at the page they came from with nothing to
say what happened. #}
<title>{{ heading }}</title>
<div class="notice" data-template="page">
<h1>{{ heading }}</h1>
-1
View File
@@ -1,3 +1,2 @@
{# Fallback for a fragment that names no template of its own. #}
{% extends "base.njk" %}
{% block content %}{{ content | safe }}{% endblock %}
+2 -67
View File
@@ -1,77 +1,15 @@
{# The navbar's reference picker: which book you are in, and where in it to go.
Context: reference (or null), oob.
reference is built by sw.js from bible/verses.json, which compile_bible.py
writes from the same tree it renders the books from -- so an option can
never name an anchor that is not in the page it lands on.
It used to be built in the browser, by app.js, reading the chapters and
verses off the book already on screen. That was free, and it only worked
where there was a document to read: this shell is composed in a service
worker, which has no DOM, before any document exists. What the picker needs
is a fact on disk now, and everything below is markup and htmx.
Outside the collapse deliberately -- it is the main way of moving around a
book now that the chapter list is gone from the page, and a chapter picker
behind a hamburger is no use on a phone.
d-none for the empty state rather than the hidden attribute: Bootstrap
declares .d-flex after Reboot's [hidden] rule, both !important and at the
same specificity, so the utility would win.
Three things bring it back into step with the page, because the navbar is
never the swap target -- every boosted link lands in #content:
a navigation sw.js appends this template to the fragment with oob set,
the same way a bookmark write carries the modal's status
line, so one response moves both.
a restore htmx swaps the history element straight in and processes
no out-of-band elements at all, so the picker asks for
itself instead.
an anchor a link to GEN#v3_16 should open with the picker reading
3:16, and the fragment never reaches the worker: a browser
asks for /GEN and keeps the rest. So a picker that arrived
with the page asks once, on load, if there is an anchor to
ask about.
None of them carries the book or the anchor. The request does, in
HX-Current-URL, which htmx sets from location.href -- the address the page
is actually showing, fragment and all. Baking either into these attributes
is the one thing here that can go stale, because the picker outlives the
page it was rendered for.
settled is what stops the load trigger looping: the answer to it is a
picker already standing in the right place, and it says so by leaving the
trigger out of the copy it sends back.
!!location.hash, and not location.hash, because htmx keeps an event only
when the filter returns boolean true exactly -- it tests the result with
!== true -- and a hash is a string. Written without the coercion the
trigger is dead: every link carrying an anchor lands with the picker still
reading 1:1, which is where the worker rendered it, because a browser
keeps the fragment to itself and the request that would correct it is
never sent. #}
<div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3"
id="reference"
{%- if oob %} hx-swap-oob="true"{% endif %}
hx-get="api/reference"
{# htmx keeps an event only when the filter returns true exactly, so a
string is discarded. #}
hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}htmx:historyRestore from:body"
hx-target="this" hx-swap="outerHTML">
{%- if reference %}
<span class="reference-book text-light fw-semibold"
id="reference-book">{{ reference.book }}</span>
<label class="visually-hidden" for="reference-chapter">Chapter</label>
{# The value is the id of the element to land on, so nothing downstream has
to know how a verse anchor is spelled -- including the worker, which
reads the chapter back out of the id it sent.
Which is why both selects post under the same name. ch3 and v3_16 are
told apart by their shape, so a second name saying which select it came
from would only be repeating what the value already says.
hx-target is spelled out on both selects because the one on the div above
is "this", and an inherited "this" would resolve to the select. #}
<select class="reference-chapter form-select form-select-sm"
id="reference-chapter" name="at"
hx-get="api/reference" hx-target="#reference-verse"
@@ -83,9 +21,6 @@
</select>
<span class="reference-sep text-light">:</span>
<label class="visually-hidden" for="reference-verse">Verse</label>
{# Answers with its own options again rather than nothing, so the response
has something to hang the scroll on -- and so the option the reader chose
comes back marked selected instead of being reset by the swap. #}
<select class="reference-verse form-select form-select-sm"
id="reference-verse" name="at"
hx-get="api/reference" hx-target="this" hx-swap="innerHTML">
-29
View File
@@ -1,29 +1,3 @@
{# One confirmation: something the reader just did, said once and then gone.
Bootstrap's custom-content shape -- a flex row of body and close button,
with no toast-header above it. The header carries a title, an icon and a
timestamp, which is what a notification arriving from elsewhere needs; this
is the answer to a button pressed a second ago, and the sentence is the
whole of it.
Context: text, bad (it did not happen), oob.
Two ways in, the same element either way. api/toast renders it alone and
htmx appends it to the container; the bookmark write renders it with oob
set and it rides along on the answer that updates the navbar slot -- one
response, both halves of the page, the way the modal's status line used to.
The wrapper is how an out-of-band element with no id of its own says where
it goes: htmx appends the wrapper's children to the named container and
drops the wrapper itself.
It shows itself on arrival and removes itself once it has gone. hx-on::load
is htmx's own event, fired on content it has just inserted and processed,
so it covers both ways in -- and it is the same move base.njk makes to open
the dialog: the arrival of the content is the signal to show it. Bootstrap
owns the timer, the fade, the close button and Escape from there. Removing
the element on hidden.bs.toast is what keeps the container from filling up
with dead markup: a toast that has been read is not history, and nothing
here ever looks at it again. #}
{% if oob %}<div hx-swap-oob="beforeend:#toasts">{% endif %}
<div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}"
role="alert" aria-live="assertive" aria-atomic="true"
@@ -31,9 +5,6 @@
hx-on:hidden.bs.toast="this.remove();">
<div class="d-flex">
<div class="toast-body">{{ text }}</div>
{# me-2 m-auto, not ms-auto: the row is body then button, and the body is
what grows -- the button only has to sit centred against however many
lines it turns out to be. #}
<button type="button"
class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
-38
View File
@@ -1,24 +1,3 @@
{# The verse dialog's contents: what this verse is called, a link to it, and
the one thing you can do with it.
Rendered by sw.js for the verse that was clicked and swapped into the empty
modal-content in base.njk -- the shape htmx's bootstrap-modal example uses.
Bootstrap owns showing, dismissing, the backdrop and Escape; everything
here is what is inside.
Context: verse (ref, href, url).
All three are built in the worker, from the book in the address bar and the
anchor that was clicked. app.js used to read them off the page -- the book
out of article[data-name], the chapter out of the enclosing section -- which
put the reference in two places and left the spelling of an anchor known to
both the file that generates them and the file that scrapes them back.
The link is a field rather than a line of text because it is the fallback
for the button beside it: navigator.clipboard exists only in a secure
context, so over plain http:// from a LAN address there is nothing to write
with and a selected field is the whole of what is left. Readonly, so it
reads as something to take rather than something to fill in. #}
<div class="modal-header">
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"
@@ -30,27 +9,10 @@
type="text" readonly value="{{ verse.url }}">
</div>
<div class="modal-footer">
{# The form is the bookmark write and nothing else. ref and href are hidden
because they are this dialog's whole subject: the reader chose them by
clicking a verse, and there is nothing here to edit. #}
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="{{ verse.ref }}">
<input type="hidden" name="href" value="{{ verse.href }}">
{# Selected first, written second: the selection is what makes the field
useful when the write is refused, and it costs nothing when it is not.
The toast is only raised once the clipboard has actually taken the link,
which is why the promise is answered rather than assumed.
Both outcomes are a toast, and neither is written here: api/toast is
asked for one by name, so the wording lives in the worker with the rest
of the app's words rather than in the middle of an attribute. It is the
same request the dialog itself arrives on -- htmx, the worker, a
fragment -- with the container in base.njk as the target.
Only a copy that happened ends the dialog. A refused one leaves it
standing, because the field above it, selected, is the whole of what is
left to copy with. #}
<button class="btn btn-outline-secondary" type="button"
hx-on:click="var button = this;
var field = document.getElementById('verse-dialog-url');
-3
View File
@@ -1,6 +1,3 @@
{# The verse select's options. Rendered into the picker by reference.njk, and
returned on their own by api/reference after a chapter or verse change.
Both paths must produce the same options for the swap to be seamless. #}
{% for verse in reference.verses -%}
<option value="{{ verse.id }}"
{%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option>
+1 -20
View File
@@ -1,23 +1,4 @@
{# The footer under the book list: what this device is running.
Both digests are stamped into sw.js by server.py from the files on disk:
shell covers assets/, the hand-written chrome, and library covers bible/,
the generated pages. They are the same two values the worker names its
caches after, so a device showing a digest is a device serving that cache.
It says what the worker that composed this document was, not what is
controlling right now. An update installs, claims and starts answering
without this document being rebuilt, so the value only becomes current on
the next full load -- which is the reload you would do to check it anyway.
Rendered on every page and shown under the book list alone. Populating it
only for the index would look like the smaller change and would not work:
the shell is composed once per full navigation, and a boosted link to the
book list swaps #content without rebuilding anything around it. So the page
decides, not the composer -- app.js looks for the index in #content after
every swap. The body class this was rendered with cannot be asked, for the
same reason: it goes stale the moment a link is clicked. #}
<div class="app-version d-none text-center mt-5 mb-2" id="app-version">
<div class="app-version text-center mt-5 mb-2">
<p class="app-version-digests form-text m-0">
shell {{ version.shell }} &middot; library {{ version.library }}
</p>