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

No files matched your search

+154 -2
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) {
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
+67 -4
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,12 +62,52 @@
// 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 loadAll().then(function () {
return new Promise(function (resolve, reject) {
env.render(name, context, function (err, out) {
return err ? reject(err) : resolve(out);
});
});
});
}
return {
@@ -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
});
},
+26 -21
View File
@@ -83,6 +83,11 @@ 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.
@@ -103,17 +108,12 @@ const PRECACHE = [
'./css/bootstrap.min.css',
'./js/app.js',
'./js/htmx.min.js',
'./js/bootstrap.bundle.min.js',
'./templates/base.njk',
'./templates/page.njk',
'./templates/book.njk',
'./templates/index.njk',
'./templates/bookmark.njk',
'./templates/reference.njk',
'./templates/verses.njk',
'./templates/status.njk',
'./templates/notice.njk'
];
'./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) {
return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE);
@@ -409,7 +409,8 @@ async function servePage(path, code, call) {
}
try {
return await view.compose(fragment, { mark: await getBookmark(),
reference: reference });
reference: reference,
version: VERSIONS });
} 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
@@ -457,17 +458,21 @@ router.get('/api/bookmark', () => bookmarkFragment(''));
// under a page boot.html composed once a worker turns up, for nothing extra:
// those requests carry the address bar too.
//
// What the response headers are doing is the whole of what app.js used to do
// on a change event, and htmx has a name for each part of it:
// Two response headers carry what a change means beyond the options:
//
// settle:0 swap the options now. The default waits 20ms, and the point
// of this request is a scroll the reader is waiting on.
// show:#id:top scroll the named anchor to the top -- htmx's scrollBehavior
// is "instant", which is what this app wants everywhere.
// Reboot sets scroll-behavior: smooth, so saying nothing here
// would animate.
// 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();
@@ -505,7 +510,7 @@ router.get('/api/reference', async (call) => {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'HX-Reswap': 'innerHTML settle:0 show:#' + target + ':top',
'HX-Reswap': 'innerHTML settle:0',
'HX-Replace-Url': page.pathname + page.search + '#' + target
}
});
+1
View File
@@ -105,6 +105,7 @@
{% block content %}{% endblock %}
</div>
</div>
{% include "version.njk" %}
</main>
{# Chrome, so it is written once here instead of built in JavaScript on every
+10 -2
View File
@@ -44,12 +44,20 @@
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. #}
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"
hx-trigger="{% if reference and not settled %}load[location.hash], {% endif %}worker-ready from:body, htmx:historyRestore from:body"
hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}worker-ready from:body, htmx:historyRestore from:body"
hx-target="this" hx-swap="outerHTML">
{%- if reference %}
<span class="reference-book text-light fw-semibold"
+39
View File
@@ -0,0 +1,39 @@
{# The footer under the book list: what this device is running, and the one
button that makes it forget.
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.
version is null in one case: boot.html composed this page, so there was no
worker to ask. The line is then absent, which is the honest answer -- no
worker means no version, and the bookmark slot in the navbar is empty for
the same reason. The button stays, because a device with no worker and a
full cache is exactly the state worth clearing.
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.
The button is a plain <button> outside any form, which is the whole of why
hx-boost on <body> leaves it alone: htmx boosts links, forms, and submits
belonging to one. resetApp() in js/app.js is what it runs. #}
<div class="app-version d-none text-center mt-5 mb-2" id="app-version">
{%- if version %}
<p class="app-version-digests form-text mt-0 mb-2">
shell {{ version.shell }} &middot; library {{ version.library }}
</p>
{%- endif %}
<button type="button" class="app-reset btn btn-outline-secondary btn-sm"
id="app-reset">Reset app</button>
</div>