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

+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'
}
});