This commit is contained in:
mark committed 2026-09-08 08:02:21 -04:00
1 parent f041daeda3
commit 9407c26d2d
6 files changed
+301 -33

No files matched your search

+155 -3
View File
@@ -116,6 +116,8 @@
var verse = event.target.closest('.v');
if (verse && verse.id) { event.preventDefault(); openFor(verse); return; }
if (event.target.closest('[data-action="copy"]')) copyLink(currentUrl);
var reset = event.target.closest('#app-reset');
if (reset) resetApp(reset);
});
document.addEventListener('keydown', function (event) {
@@ -134,7 +136,9 @@
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
if (target.id === 'bookmark') return setTimeout(hideModal, 900);
if (target.id === 'content') land();
// 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(); }
});
// A history restore does not go through the request pipeline -- htmx swaps
@@ -145,6 +149,7 @@
document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
land();
showVersion();
});
// -- startup ------------------------------------------------------------
@@ -162,8 +167,28 @@
// :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) {
el.scrollIntoView({ block: 'start', behavior: 'instant' });
try {
el.scrollIntoView({ block: 'start', behavior: 'instant' });
} catch (err) {
el.scrollIntoView({ block: 'start', behavior: 'auto' });
}
}
// The same, for the top of the page and the same reason.
function jumpToTop() {
try {
window.scrollTo({ top: 0, behavior: 'instant' });
} catch (err) {
window.scrollTo(0, 0);
}
}
// A worker-composed page is an ordinary document and the browser scrolls to
@@ -192,7 +217,133 @@
function land() {
var el = hashTarget();
if (el) jumpTo(el);
else window.scrollTo({ top: 0, behavior: 'instant' });
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.
//
// Only the digest line is missing on a page boot.html composed -- there was
// no worker to name a version -- so the element itself is always here.
function showVersion() {
var el = document.getElementById('app-version');
if (!el) return;
el.classList.toggle('d-none', !document.querySelector('#content .index'));
}
// -- reset --------------------------------------------------------------
// Everything this app has put on the device, cleared in the order that makes
// the next load a first visit.
//
// The worker goes first, so that nothing is left answering out of a cache
// while the caches themselves are being deleted. Unregistering does not stop
// the copy already controlling this page -- an active worker keeps its
// clients until they unload -- which is what the reload at the end is for:
// the registration is gone by then, the navigation reaches server.py, and
// boot.html registers a new worker against whatever is on disk now.
//
// Then the caches, both of them, by whatever names they carry: the point of
// this button is the case where the names are not the ones this build would
// have chosen. Then the bookmark, which is the only thing here a reader
// would miss -- hence the question first.
//
// One after another rather than all at once, so that the order above is the
// order it happens in, and so that a step cannot be reading storage another
// is halfway through deleting.
//
// It still races the worker's own background download, which writes a book
// into the cache on any request that arrives while this is running -- there
// is no way to call that off from here. The reload is what settles it: the
// worker is gone by then, and the one that installs in its place opens
// caches under its own names regardless of what survived.
//
// Each step is allowed to fail on its own. A browser with no caches API, or
// storage the user has locked down, should still get everything else
// cleared and still reload.
var RESET_QUESTION =
'Clear this app from the device? The offline copy of the books and the ' +
'bookmark go with it, and the app reloads as a first visit.';
function settle(promise) {
return promise.catch(function () {});
}
function forgetWorker() {
if (!('serviceWorker' in navigator)) return Promise.resolve();
return navigator.serviceWorker.getRegistrations().then(function (regs) {
return Promise.all(regs.map(function (reg) { return reg.unregister(); }));
});
}
function forgetCaches() {
if (!window.caches) return Promise.resolve();
return caches.keys().then(function (keys) {
return Promise.all(keys.map(function (key) { return caches.delete(key); }));
});
}
// Resolved on blocked as well as on done. Blocked means another tab still
// has the database open, and there is nothing useful to do about it from
// here: this tab is about to reload, and the delete completes when the last
// one lets go.
function forgetBookmark() {
return new Promise(function (resolve) {
if (!window.indexedDB) return resolve();
var req = indexedDB.deleteDatabase('bible');
req.onsuccess = req.onerror = req.onblocked = function () { resolve(); };
});
}
function resetApp(button) {
if (!window.confirm(RESET_QUESTION)) return;
button.disabled = true;
button.textContent = 'Clearing…';
settle(forgetWorker())
.then(function () { return settle(forgetCaches()); })
.then(function () { return settle(forgetBookmark()); })
.then(function () { location.reload(); });
}
// api/bookmark has no origin server -- it is answered by the worker -- so
@@ -210,6 +361,7 @@
document.addEventListener('DOMContentLoaded', function () {
markVerses();
scrollToHash();
showVersion();
// Registered from the page it is going to serve, not from a page of its
// own: there is nothing to wait for and nothing to reload. This load is
+70 -7
View File
@@ -24,6 +24,26 @@
// 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',
'book.njk',
'index.njk',
'bookmark.njk',
'reference.njk',
'verses.njk',
'version.njk',
'status.njk',
'notice.njk'
];
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) {
@@ -42,10 +62,50 @@
// 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;
function loadAll() {
if (!loaded) {
loaded = Promise.all(NAMES.map(function (name) {
return new Promise(function (resolve) {
env.getTemplate(name, true, null, false, function () { resolve(); });
});
}));
}
return loaded;
}
function render(name, context) {
return new Promise(function (resolve, reject) {
env.render(name, context, function (err, out) {
return err ? reject(err) : resolve(out);
return loadAll().then(function () {
return new Promise(function (resolve, reject) {
env.render(name, context, function (err, out) {
return err ? reject(err) : resolve(out);
});
});
});
}
@@ -61,9 +121,11 @@
// base.njk is for.
//
// chrome is what the shell needs and the fragment cannot carry: the
// bookmark, and the navbar picker's model. Both are null from boot.html,
// which has no worker to ask for either, and both render as a hidden
// slot that fills itself if one ever appears.
// bookmark, the navbar picker's model, and the two versions the worker
// was built with. All three are null from boot.html, which has no worker
// to ask for any of them; the first two render as a hidden slot that
// fills itself if one ever appears, and the third renders as nothing,
// because no worker is the whole of what there is to report.
compose: function (fragment, chrome) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, '');
@@ -72,7 +134,8 @@
title: title,
content: content,
mark: (chrome && chrome.mark) || null,
reference: (chrome && chrome.reference) || null
reference: (chrome && chrome.reference) || null,
version: (chrome && chrome.version) || null
});
},