From 057b4be7d72671aa79e06dd502f02513a338c4db Mon Sep 17 00:00:00 2001 From: Mark Wissink Date: Wed, 9 Sep 2026 12:09:44 -0400 Subject: [PATCH] Remove comments --- assets/boot.html | 24 --- assets/css/app.css | 42 ---- assets/js/app.js | 151 +------------- assets/js/render.js | 73 +------ assets/js/router.js | 37 ---- assets/sw.js | 355 +-------------------------------- assets/templates/base.njk | 118 ----------- assets/templates/bookmark.njk | 16 -- assets/templates/index.njk | 2 - assets/templates/notice.njk | 6 - assets/templates/page.njk | 1 - assets/templates/reference.njk | 69 +------ assets/templates/toast.njk | 29 --- assets/templates/verse.njk | 38 ---- assets/templates/verses.njk | 3 - assets/templates/version.njk | 21 +- server.py | 124 +----------- utils/compile_bible.py | 307 ++++------------------------ 18 files changed, 66 insertions(+), 1350 deletions(-) diff --git a/assets/boot.html b/assets/boot.html index 88dd206..69a4af4 100644 --- a/assets/boot.html +++ b/assets/boot.html @@ -4,38 +4,14 @@ World English Bible Updated - - -
-

diff --git a/assets/css/app.css b/assets/css/app.css index 9697104..4eb8d1c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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 , not a , 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

, which at heading size shouts over the text. */ .chapter-number { font-size: 2rem; font-weight: 400; @@ -41,10 +26,6 @@ margin: 2rem 0 .75rem; } -/* Poetry lines are paragraphs, so left alone each one carries a paragraph's - bottom margin and a psalm falls apart into separate blocks. Close the gap, - indent by the level the generator recorded, and hang the indent so a line - too long for the screen still reads as one line. */ .poetry { margin-bottom: 1rem; } .poetry .line { margin: 0; @@ -54,13 +35,11 @@ .poetry .line[data-indent="2"] { padding-left: 3em; } .poetry .line[data-indent="3"] { padding-left: 4.5em; } -/* Descriptive titles: "A Psalm by David". */ .d { font-style: italic; color: var(--bs-secondary-color); } -/* The notes are apparatus, not scripture, and should not read as it. */ .footnotes { font-size: .875rem; color: var(--bs-secondary-color); @@ -68,38 +47,17 @@ .footnotes-title { font-size: 1rem; } .marker { font-weight: 700; } -/* The callers are * † ‡ § and ✡, and the star is the one character in the - whole corpus a browser may render as an emoji. render_books.py already - appends U+FE0E to ask for the text glyph; this asks a second way, for - renderers that ignore the variation selector. Newer property, so where it - is not supported the selector is simply dropped and the variation selector - is still doing the work. */ .noteref, .marker { font-variant-emoji: text; } -/* The navbar is sticky, so an element scrolled to the top of the viewport - lands underneath it. These two are everything an anchor in this app points - at -- chapter headings and verse numbers -- so the offset lives on them - rather than on a scroll container. Roughly the navbar's height. */ .chapter, .v { scroll-margin-top: 4.5rem; } -/* The navbar's reference picker. form-select is width:100% by default, which - in a flex row means both selects try to take the whole navbar; and the - default right padding leaves the caret sitting on top of a three-digit - chapter number. */ .reference .form-select { width: auto; padding-right: 2rem; } -/* The book list is a list of links, not a table of facts. Reboot's bullets - and the chapter count after each title are both noise between the reader - and the book they came for. - - Hidden here rather than dropped from render_books.py: the count is real - information the generator knows, and whether to show it is a presentation - decision. It stays in the markup for anything that wants it later. */ .index ul { list-style: none; padding-left: 0; diff --git a/assets/js/app.js b/assets/js/app.js index 7341f81..a6a70ec 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,187 +1,44 @@ -// Page startup. -// -// Pages arrive whole -- the service worker composes every one of them -- so -// there is nothing here that builds a page. -// What is left is the three things a composed document cannot do for itself: -// put a swapped-in page where it belongs before it is painted, land the -// reference picker where the reader asked to go, and tell the chrome when a -// worker has appeared to answer it. -// -// Three things used to live here and no longer do, all for the same reason: -// they were reading the page to work out something that was already known -// somewhere better. -// -// The reference picker was built by reading the chapters and verses off the -// book on screen. It is markup and htmx now: sw.js renders it from -// bible/verses.json and answers api/reference, and the address-bar update that -// a change event used to run through here is a response header on the answer. -// See templates/reference.njk. -// -// The verse dialog was built by scraping the book name out of article -// [data-name] and the chapter out of the enclosing section, then filling a -// form and showing it. sw.js answers api/verse with the whole of the dialog, -// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap -// opens it -- which is htmx's own bootstrap-modal example, with the click -// delegated because the trigger is one of 38,000 generated verse numbers and -// cannot carry attributes of its own. See templates/verse.njk. -// -// The third was marking those numbers as controls: role, tabindex and an -// aria-label, set on every verse on load, after every swap and after every -// history restore -- a walk over a whole book, three times over, to arrive at -// the same three attributes every time. They are in the markup now, written -// by compile_bible.py, because nothing about them depends on anything only the -// browser knows. It costs the corpus a couple of megabytes and saves the pass. (function () { 'use strict'; - // -- events ------------------------------------------------------------- - document.body.addEventListener('htmx:afterSwap', function (event) { var target = event.detail.target; if (!target) return; - // Both selects answer into the verse list, so this is either of them. - if (target.id === 'reference-verse') return landOnReference(); - if (target.id === 'content') { land(); showVersion(); } + if (target.id === 'reference-verse') return scrollToHash(); + if (target.id === 'content') return land(); }); - // A history restore does not go through the request pipeline -- htmx swaps - // the element straight in and fires this instead -- so the work afterSwap - // does has to be repeated here or a page reached by the back button arrives - // scrolled to wherever the last one was. The picker listens for the same - // event itself, from the navbar, because a restore reaches nothing outside - // #content. document.body.addEventListener('htmx:historyRestore', function () { land(); - showVersion(); }); - // -- startup ------------------------------------------------------------ - function hashTarget() { if (!location.hash) return null; return document.getElementById(decodeURIComponent(location.hash.slice(1))); } - // Every scroll here is a correction, not a gesture: the page is meant to - // arrive already in position. So they all say instant. - // - // Saying nothing would not mean instant. The default is "auto", which means - // "whatever scroll-behavior computes to" -- and Bootstrap's Reboot sets - // :root { scroll-behavior: smooth }, so the default animates. Bootstrap - // wraps that in prefers-reduced-motion, which is the right call for the - // chapter nav's own anchors; these are not that. - // - // Asked for twice, because "instant" is a later addition to an old enum and - // a browser that does not know the value rejects the whole call with a - // TypeError rather than ignoring the word -- so on an older WebKit this did - // not scroll slowly, it did not scroll at all. "auto" is what the enum has - // always had; it animates here, which is worse than instant and far better - // than staying put. function jumpTo(el) { - try { - el.scrollIntoView({ block: 'start', behavior: 'instant' }); - } catch (err) { - el.scrollIntoView({ block: 'start', behavior: 'auto' }); - } + el.scrollIntoView({ block: 'start', behavior: 'instant' }); } - // The same, for the top of the page and the same reason. function jumpToTop() { - try { - window.scrollTo({ top: 0, behavior: 'instant' }); - } catch (err) { - window.scrollTo(0, 0); - } + window.scrollTo({ top: 0, behavior: 'instant' }); } - // The browser scrolls to the anchor itself on a document it navigated to, - // so this is for the one it does not: a reload that landed on boot.html and - // was reloaded again into the real page. Costs nothing when the browser has - // already been there. function scrollToHash() { var el = hashTarget(); if (el) jumpTo(el); } - // Where a swapped-in page should sit before it is ever seen. - // - // htmx does this itself -- scrolling to the anchor a boosted href named, or - // to the top of the target when it named none -- but from the settle - // callback, which is a setTimeout of defaultSettleDelay, 20ms. The browser - // paints the new content at the old scroll offset first, so the jump is - // visible every time. Here it runs in the same task as the swap, before any - // paint, so the page simply appears where it belongs. htmx's own scroll - // still follows and finds nothing left to do. - // - // htmx has already pushed the new URL by this point -- history is updated - // before the swap, not after -- so location.hash is the anchor that was - // asked for. function land() { var el = hashTarget(); if (el) jumpTo(el); else jumpToTop(); } - // Where the reference picker lands, which is the one scroll in the app that - // is a gesture: the reader asked for chapter 3 and is waiting to be taken - // there. - // - // It used to ride along on the answer, as show: on HX-Reswap, and be htmx's - // scroll rather than this file's. Two reasons it is here now. The smaller - // one is that htmx's own scrollIntoView says "instant" with nothing around - // it, so the enum above takes the whole scroll down with it. The larger one - // is the phone: a select on iOS is a picker drawn over the page, the change - // event arrives while it is still up, and dismissing it puts the page back - // where it stood when it opened -- undoing anything scrolled in between. - // The options were swapping correctly and the book never moved. - // - // So: once now, for a pointer, and once more after the picker has had time - // to finish, but only if the page did not stay where it was put. That test - // is also what keeps the second attempt from yanking back a reader who has - // started scrolling on their own. - // - // The anchor comes from the address bar. The worker sends HX-Replace-Url - // with the answer and htmx updates history before it swaps, so by the time - // this runs location.hash is already naming the chapter or verse chosen. - var PICKER_SETTLE_MS = 350; - - function landOnReference() { - var el = hashTarget(); - if (!el) return; - jumpTo(el); - var landed = window.pageYOffset; - setTimeout(function () { - var again = hashTarget(); - if (again && Math.abs(window.pageYOffset - landed) > 2) jumpTo(again); - }, PICKER_SETTLE_MS); - } - - // The version readout, shown under the book list and nowhere else. - // - // It is chrome: it sits outside #content, so it outlives every swap -- and - // so does the body class it was rendered with, which is exactly why that - // cannot be what decides. What is in #content can, because that is the one - // thing a swap changes: the book list is the fragment rooted at .index. - // - // Toggling the class rather than the style because Bootstrap declares - // .d-none !important, which an inline display would lose to. - function showVersion() { - var el = document.getElementById('app-version'); - if (!el) return; - el.classList.toggle('d-none', !document.querySelector('#content .index')); - } - - // Deferred scripts run before DOMContentLoaded fires, so this always lands, - // and it lands after htmx has processed the shell. document.addEventListener('DOMContentLoaded', function () { scrollToHash(); - showVersion(); - // Registered from the page it is going to serve, not from a page of its - // own: this load is already the real app, composed by the worker that is - // controlling it. What this catches is the copy on disk having changed - // since -- the browser refetches sw.js, sees different bytes, and installs - // the new one alongside, which takes over from the next navigation on. if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').catch(function () {}); } diff --git a/assets/js/render.js b/assets/js/render.js index 44052cf..166b6fc 100644 --- a/assets/js/render.js +++ b/assets/js/render.js @@ -1,32 +1,11 @@ -// The renderer: the templates in templates/, and the two things sw.js does -// with them -- compose a fragment into the shell, and stand a notice in for a -// page that could not be delivered. -// -// A file of its own rather than more of sw.js because it is the half with -// nothing to do with the network: no routes, no caches, no requests. It was -// loaded by boot.html as well once, which composed pages in the browser for a -// device that could not install a worker; nothing but the worker builds a page -// now. -// -// Loaded with importScripts, so it defines a global rather than exporting. -// Nunjucks must already be loaded. (function (scope) { 'use strict'; - // A fragment is a and one root element. The root's data-template - // names the template that wraps it; compile_bible.py writes both. var TITLE_RE = /<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 }); } diff --git a/assets/js/router.js b/assets/js/router.js index 0797b5c..19af433 100644 --- a/assets/js/router.js +++ b/assets/js/router.js @@ -1,17 +1,3 @@ -// A very small HTTP router, so the worker reads as a set of routes rather than -// a chain of ifs unpicking a URL. The shape is lifted from the one in -// htmx-serviceworker-todo, which is in turn a miniature of Hono or Express: -// register a method and a path pattern, get the matching handler back. -// -// Patterns are matched segment by segment against a path relative to the -// worker's own scope, so the app still works if it is ever served from a -// subdirectory. A segment written as :name matches anything and lands in -// params. Segment counts must be equal -- there are no wildcards, because -// nothing here needs one. -// -// A handler returning a string gets it as an HTML response; returning a -// Response passes it through untouched; returning nothing means "not mine -// after all", and the caller falls back to serving the URL as a file. (function (scope) { 'use strict'; @@ -21,32 +7,18 @@ }); } - // Everything a handler is given: the event it must attach cache writes to, - // the URL, the path parameters, and the two questions every handler here - // asks -- who is calling, and are they replacing the whole document. function Call(event, url, params) { this.event = event; this.request = event.request; this.url = url; this.params = params; - // htmx stamps every request it makes; a navigation never carries it. this.htmx = this.request.headers.get('HX-Request') === 'true'; this.navigate = this.request.mode === 'navigate'; - // Going back or forward. htmx asks for the page again and expects a whole - // document back, the same as a navigation -- it takes the history element - // out of the response itself. this.restore = this.request.headers.get('HX-History-Restore-Request') === 'true'; - // The address the page making this request is showing. htmx stamps it on - // everything it sends; a navigation carries nothing, because there is no - // page yet. It is how a handler answering the chrome -- which htmx never - // swaps, and which therefore outlives the page it was rendered for -- can - // still find out which page that is. this.current = this.request.headers.get('HX-Current-URL'); } - // That address as a URL, resolved against this request's own so a relative - // one still works. Null when there was no page. Call.prototype.currentUrl = function () { return this.current ? new URL(this.current, this.url) : null; }; @@ -55,8 +27,6 @@ return this.request.formData(); }; - // base is the worker's scope path, e.g. "/" -- the prefix every pattern is - // written without. function Router(base) { this.base = base; this.routes = []; @@ -75,9 +45,6 @@ return this.route('POST', pattern, handler); }; - // The path a pattern is matched against: absolute, but with the scope - // removed, so "/GEN" is "/GEN" whether the app sits at the origin root or - // under /bible/. Router.prototype.relative = function (url) { var path = url.pathname; return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path; @@ -99,8 +66,6 @@ return null; }; - // Returns a promise of a Response, or null when nothing matched -- null - // being the signal to the caller that this URL is a file, not a route. Router.prototype.handle = function (event, url) { var found = this.find(event.request.method, this.relative(url)); if (!found) return null; @@ -113,8 +78,6 @@ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', - // Built here from live state -- the bookmark, the templates, the - // cache -- so storing it would pin a stale copy over the real thing. 'Cache-Control': 'no-store' } }); diff --git a/assets/sw.js b/assets/sw.js index 5816799..ad0e13a 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -1,80 +1,11 @@ -// The app's renderer, and its backend. Hand-written: this file is the source, -// served as-is apart from the three lines server.py stamps (see below). -// -// It reads as a small server, because that is what it is. Requests come in -// through a router (js/router.js) and are answered by handlers; the handlers -// render the nunjucks templates in templates/ (js/render.js) and read and -// write the bookmark in IndexedDB. htmx on the page talks to it exactly as it -// would to an origin. -// -// GET / the book list -// GET /:page one book -- /GEN, /EXO -// POST /api/bookmark set the bookmark, and return the navbar slot -// GET /api/reference the navbar's chapter and verse picker -// GET /api/verse the dialog for one verse -// GET /api/toast one confirmation, by name -// -// Anything with a dot in its last segment is a file, not a route, and is -// served from the cache or the network without going near the router. Pages -// have no extension: what is on disk is bible/GEN.html, what the reader sees -// is /GEN, and server.py resolves one to the other. bible/index.html is the -// root itself -- there is no /index, the book list lives at /. -// -// The files on disk are fragments: a title element and one root element. The -// shell around them is templates/base.njk, applied per request, so changing the -// chrome means editing one template rather than regenerating every book. A -// handler returns the bare fragment to htmx and the composed document to a -// navigation -- one route, both callers, which is the whole reason the request -// object carries `navigate`. -// -// The composing is in js/render.js. Every page in the app comes through it: -// boot.html registers this worker and reloads into it, and from there nothing -// else ever builds one. -// -// Every same-origin GET is network-first: ask the network, fall back to the -// cache when it fails. Offline, fetch rejects immediately, so the fallback is -// effectively instant -- the only cost is one round trip while online, and you -// never see a stale page. Every response that arrives is written back to the -// cache, so a book that has been read once stays readable offline. Templates -// are the one exception, and readTemplate() says why. -// -// The whole corpus is kept offline, but not by making anyone wait for it and -// not by telling them about it. Install caches the shell alone -- a handful of -// small files -- and the app is usable the moment that is done. The books are -// then fetched a few at a time in the background while the reader reads, with -// nothing on screen to say so. Close the tab half way through and the next -// visit picks up where it left off: the cache is the record of what has been -// fetched, so there is no separate bookkeeping to lose. - -// Imported scripts are stored with the registration, so these survive offline -// without being in PRECACHE. importScripts rather than the fetch-and-eval -// module loader the router came from: it is the one the platform supports in a -// worker, and it costs no waterfall. importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js'); const NETWORK_TIMEOUT_MS = 3000; -// The background download gets a longer leash than a page the reader is -// waiting on: nothing is blocked on it, and giving up early on a slow link -// only means fetching the same book again later. const DOWNLOAD_TIMEOUT_MS = 30000; -// Few enough to leave the connection room for the page being read, more than -// one so a slow response does not stall the queue. The old code asked for -// every file at once, which is what made a weak connection collapse. const DOWNLOAD_AT_ONCE = 4; -// All three are stamped in by server.py from the files actually on disk, so an -// edit reaches installed devices with no build step: the worker's bytes -// change, the browser sees a new worker, and stale caches go on activate. The -// values below are the standalone fallback. -// -// The two versions are deliberately separate, over the two directories the -// server puts together. VERSION covers the hand-written chrome in assets/, -// templates included, so editing the shell drops and refetches a few hundred -// kilobytes. The other covers the generated pages in bible/, which change only -// when compile_bible.py runs -- so a change of chrome no longer costs every -// device the whole corpus again. const VERSION = 'dev'; const LIBRARY_VERSION = 'dev'; const LIBRARY = []; @@ -82,23 +13,10 @@ const LIBRARY = []; const SHELL_CACHE = 'bible-shell-' + VERSION; const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION; -// The same two values, handed to the shell so a device can be asked what it is -// running. A phone that has kept an old worker is the hardest thing here to -// see from the outside, and these are what say so. See templates/version.njk. const VERSIONS = { shell: VERSION, library: LIBRARY_VERSION }; -// Resolved once, and the set is the test for which cache a response belongs -// in. Membership, not a path pattern: the split is whichever directory the -// file came from, which is a fact only the server has. const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href)); -// The shell and what it needs to paint. "./" -- the book list -- is in here as -// well as in the library: it is the one page the app falls back to, so it -// should not have to wait for the download to reach it. -// -// boot.html is here for the one case it can still be reached with a worker -// installed: a reload that bypasses this file. The three scripts in js/ that -// this file imports are not, because importScripts already keeps them. const PRECACHE = [ './boot.html', './', @@ -108,10 +26,6 @@ const PRECACHE = [ './js/app.js', './js/htmx.min.js', './js/bootstrap.bundle.min.js' -// The templates come from render.js, which is the file that knows what one is -// -- and which loads exactly this list into nunjucks before it renders -// anything. Two lists would be one list and a way for the renderer to be -// asked for a template that was never cached. ].concat(TEMPLATE_URLS); function cacheFor(req) { @@ -121,8 +35,6 @@ function cacheFor(req) { self.addEventListener('install', (event) => { event.waitUntil((async () => { const cache = await caches.open(SHELL_CACHE); - // Added one at a time rather than addAll(): addAll is atomic, so a single - // failed asset would leave the app with no offline copy at all. let failed = 0; await Promise.all(PRECACHE.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; }))); @@ -141,9 +53,6 @@ self.addEventListener('activate', (event) => { })()); }); -// A dot in the last segment means a file that exists on disk: a script, a -// stylesheet, an icon, a template. Page URLs never have one, so this is the -// whole boundary between what the router owns and what is simply served. function isFile(url) { return url.pathname.split('/').pop().indexOf('.') !== -1; } @@ -153,17 +62,10 @@ self.addEventListener('fetch', (event) => { const url = new URL(req.url); if (url.origin !== location.origin) return; - // The background download rides along on whatever the page is already - // asking for. It gets no event of its own: activate cannot be used, because - // waitUntil there holds back activation and every fetch with it, and a - // worker is only kept alive while an event of its own is pending. So each - // request the reader makes is what carries the download forward. keepFilling(event); if (!isFile(url)) { const routed = router.handle(event, url); - // A route that declines -- by returning nothing -- leaves the URL to be - // served as a file, the same as one that never matched. if (routed) { return event.respondWith(routed.then((res) => res || answer(req, event))); } @@ -173,10 +75,6 @@ self.addEventListener('fetch', (event) => { event.respondWith(answer(req, event)); }); -// ---------------------------------------------------------------- fetching - -// Aborted rather than raced, so a hung request is actually torn down instead -// of leaking while the cache answers. async function fromNetwork(req, timeout) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS); @@ -191,10 +89,6 @@ function cached(req) { return caches.match(req, { ignoreSearch: true }); } -// A string argument becomes a fresh Request, which matters beyond -// convenience: it is not a navigation, so the origin server can tell an -// internal fetch from a browser one and hand back the fragment rather than -// the boot page. async function answer(input, event) { const req = typeof input === 'string' ? new Request(input) : input; @@ -202,7 +96,6 @@ async function answer(input, event) { const res = await fromNetwork(req); if (res && res.ok && res.type === 'basic') { const copy = res.clone(); - // Not awaited: the response should not wait on the cache write. const write = cacheFor(req).then((c) => c.put(req, copy)); if (event) event.waitUntil(write); else write.catch(() => {}); @@ -215,35 +108,16 @@ async function answer(input, event) { } } -// Pages are always asked for with revalidation forced. -// -// server.py marks them no-cache, which asks the browser for the same thing -- -// but this worker's own fetch goes through the browser's HTTP cache too, so a -// page stored under an earlier and wronger Cache-Control keeps being served -// from it until that entry expires, however many times the library version -// changes underneath. Saying it on the request is what gets past one that -// already is. Costs a conditional GET, which the response header was asking -// for anyway. function pageRequest(url) { return new Request(url, { cache: 'no-cache' }); } -// Used for verses.json as well, which is generated beside the pages and -// versions with them, and would go stale the same way for the same reason. - async function readText(input, event) { const res = await answer(input, event); if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input)); return res.text(); } -// --------------------------------------------------------------- templates - -// Templates are read cache-first, alone among everything here. They are -// precached on install, and editing one changes VERSION, which installs a new -// worker with a new cache -- so a hit cannot be stale. Going to the network -// for them would put six round trips in front of the first page every time the -// browser has reaped this worker, which it does constantly. async function readTemplate(path) { const hit = await cached(path); return hit ? hit.text() : readText(path); @@ -251,31 +125,12 @@ async function readTemplate(path) { const view = createRenderer(readTemplate); -// ------------------------------------------------------------------ routes - -// Patterns are relative to the worker's scope, so this is "/" and "/GEN" even -// if the app is ever served from a subdirectory. const router = new Router(new URL('./', location.href).pathname); -// ---------------------------------------------------------------- reference - -// bible/verses.json: every book's chapters, and every chapter's verse labels. -// compile_bible.py writes it from the same tree it renders the books from, so -// every option built here names an anchor that is in the page it lands on. -// -// A chapter whose verses run 1..n is stored as the number n; only the eight in -// the corpus with a gap or a range -- Sirach 26 ends "19-27", 28, 29 -- spell -// their labels out. 6 KB for 38,000 verses rather than 180. const VERSES_URL = './verses.json'; let verses = null; -// Held for the life of this worker, and only for that. Regenerating the corpus -// changes LIBRARY_VERSION, which is a new worker with a new cache, so a copy -// held here cannot outlive the pages it describes. -// -// It lives in the library cache, so a device that has never been online long -// enough to fetch it has no picker rather than no page -- see referenceOf. function readVerses() { if (!verses) { verses = readText(pageRequest(VERSES_URL)) @@ -285,10 +140,6 @@ function readVerses() { return verses; } -// "ch3" or "v3_16" -- the id of the element an option lands on, which is also -// where the chapter it belongs to is written down. Options are built here, so -// this is only ever reading back what this file sent; a URL is still a URL, -// and anything else is null. function parseAnchor(id) { const chapter = /^ch(\d+)$/.exec(id || ''); if (chapter) return { chapter: +chapter[1], verse: null }; @@ -305,40 +156,17 @@ function verseLabels(chapters, number) { return out; } -// The picker's model for one book, or null if that is not a book with verses -// in it -- the book list, a notice, and the two front-matter books that have -// no verse anchors to offer. -// -// anchor says which chapter to show the verses of, and which verse to mark -// selected. Anything this cannot read -- nothing at all, an empty select, a -// footnote id, a chapter past the end of the book -- is chapter 1, and chapter -// 1 is filled in as if it had been picked. There is no state where a chapter -// is only sort of chosen: a select showing an option is a select that chose -// it, and the verses beside it have to be that chapter's. -// -// Every book that gets a picker has verses in its first chapter -- the two -// that do not, the front matter and the glossary, are turned away above -- so -// falling back to 1 always fills the verse list rather than emptying it. -// -// The verse is left unselected when the anchor names only a chapter. That is -// not the same as unfilled: the options are chapter 1's, and the select shows -// the first of them. It says where you are going, not where you are. function referenceFor(map, code, anchor) { const chapters = map && Object.prototype.hasOwnProperty.call(map, code) ? map[code] : null; if (!chapters || !chapters.length) return null; - // The front matter and the glossary are books with chapters and no verses. - // A picker over them would be a chapter select beside an empty one, so they - // get none, the same as the book list. if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) { return null; } const at = parseAnchor(anchor) || { chapter: 1, verse: null }; const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1; - // Only when the anchor got as far as naming this chapter. A fallback to 1 - // has not chosen a verse, it has just run out of anchor. const verse = at.chapter === number ? at.verse : null; return { @@ -351,16 +179,10 @@ function referenceFor(map, code, anchor) { }; } -// The book a page URL is showing: "GEN" for /GEN, "" for the book list. function bookOf(url) { return url ? router.relative(url).slice(1) : ''; } -// Never throws. The map is fetched over the network first and answered from -// the library cache when that fails, so the one case this swallows is a device -// that has gone offline before the background download reached verses.json: -// the pages it has read still read, and the picker is hidden until a later -// navigation finds the map. The same degradation the bookmark makes. async function referenceOf(code, anchor) { if (!code) return null; try { @@ -370,23 +192,6 @@ async function referenceOf(code, anchor) { } } -// ------------------------------------------------------------------- verse - -// The verse dialog's model: what to call this verse, and the two addresses for -// it. Null for anything that is not a verse of the book on screen. -// -// Checked against verses.json rather than taken at its word. at arrives from -// the page, and a dialog offering to bookmark an anchor the corpus does not -// have is a dialog offering a bookmark that lands nowhere -- so it is read -// back through the same map the options were built from, and the label comes -// out of that map rather than out of the id. Which is what makes a verse -// written "19-27" read as 26:19-27 and not as whatever splitting an id on an -// underscore would produce. -// -// href is relative, so a bookmark survives a change of host or port; url is -// absolute, because the only thing anyone does with it is paste it somewhere -// else. Both are spelled from the address bar, which is the only record of -// which book is on screen. async function verseOf(page, at) { const code = bookOf(page); const anchor = parseAnchor(at); @@ -409,9 +214,6 @@ const OFFLINE_NOTICE = ['Not available offline', 'here. Reconnect and try again, or open one you have read before.']; const MISSING_NOTICE = ['Not found', 'There is no page at this address.']; -// The fragment a page URL names, or one explaining why there is none. A page -// asks the origin for its own address -- /GEN fetches ./GEN -- and server.py -// is what knows the file behind it is bible/GEN.html. async function fragmentFor(path, event) { let res; try { @@ -422,115 +224,40 @@ async function fragmentFor(path, event) { return res.ok ? res.text() : view.notice(...MISSING_NOTICE); } -// One handler for every caller. htmx swapping a link into a shell already on -// screen wants the bare fragment; a navigation has no shell yet, and a history -// restore is rebuilding one, so both get the fragment composed into a document. -// -// code is the book the path names, or "" for the book list -- the one thing -// the picker needs and the path already knows. async function servePage(path, code, call) { - const fragment = await fragmentFor(path, call.event); + let fragment = await fragmentFor(path, call.event); + if (!code) { + fragment += '\n' + await view.render('version.njk', { version: VERSIONS }); + } const reference = await referenceOf(code, null); if (!call.navigate && !call.restore) { - // The picker is in the navbar and every boosted link targets #content, so - // a swap never reaches it. It rides along out of band instead, the way a - // bookmark write carries its toast: one response, both halves of the - // page. Sent even when it is null -- that is the hidden - // state, and arriving at the book list has to put the picker away. return fragment + '\n' + await view.render('reference.njk', { reference: reference, oob: true }); } try { return await view.compose(fragment, { mark: await getBookmark(), - reference: reference, - version: VERSIONS }); + reference: reference }); } catch (err) { - // A template that will not render is a bug, not a condition to design for, - // but it should not take the text down with it: the bare fragment has no - // chrome and no styling, and it is still the page that was asked for. console.error('compose failed: ' + err.message); return fragment; } } -// "/" before "/:page", because both are two segments and the first match wins. router.get('/', (call) => servePage('./', '', call)); router.get('/:page', (call) => servePage('./' + call.params.page, call.params.page, call)); -// The picker, and the two moves it makes. -// -// no parameters the whole picker, standing where the anchor in the address -// bar puts it. Asked for by the picker itself: on load, and -// after a history restore -- which swaps the history element -// straight in and processes no out-of-band elements. -// at=ch3 the verses of that chapter, and land on the chapter. -// at=v3_16 the same verses, that one selected, and land on the verse. -// -// One parameter for both selects: at is the id of the element to land on, and -// ch3 and v3_16 are told apart by their shape. Which select it came from is -// not a second fact. -// -// Neither the book nor the anchor is in the URL, deliberately. Both come from -// HX-Current-URL, which htmx sets from location.href on every request it -// makes: the whole address, fragment included. -// -// For the book, because the picker sits in the navbar and outlives the page -// under it -- an address baked into its attributes is the one thing here that -// can go stale. For the anchor, because this is the only way a fragment -// reaches this file at all. A browser opening /GEN#v3_16 asks for /GEN and -// keeps the rest to itself, so the shell is composed without it and the picker -// is rendered at 1:1 like any other. The page then asks this route for itself, -// and that request is the one carrying the anchor. -// -// Which also puts the picker where it belongs after a back or forward, for -// nothing extra: those requests carry the address bar too. -// -// Two response headers carry what a change means beyond the options: -// -// settle:0 swap the options now. The default waits 20ms, and this -// answer is one the reader is waiting on. -// HX-Replace-Url put the anchor in the address bar without stacking a -// history entry per option spun past. -// -// The scroll used to be a third, show:#id:top, and is app.js's again. Not -// because the header was wrong but because the moment was: on iOS the change -// event arrives while the native picker is still drawn over the page, and -// dismissing it restores the scroll offset it opened at -- so a scroll made -// inside the swap is undone before it is seen. The page has to be the one -// doing it, because only the page is still there afterwards to try again. -// See landOnReference() in js/app.js. The address bar above is how it knows -// where to go: htmx updates history before it swaps. router.get('/api/reference', async (call) => { const params = call.url.searchParams; const page = call.currentUrl(); - // Set when a select was moved -- by whether the parameter is there, not by - // whether it says anything. A select posts an empty value when it has no - // options, and reading that as "no select moved" answers it with a whole - // picker: htmx swaps a <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); diff --git a/assets/templates/base.njk b/assets/templates/base.njk index f93bc6d..5a83a49 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -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 %} -{# Vendored rather than linked from a CDN: sw.js only caches same-origin - responses, so a CDN stylesheet would leave the app unstyled offline. #} -{# app.css comes second so its variable overrides win. It styles the - scripture; Bootstrap styles the chrome. #} -{# 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. #} -{# 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. #} -{# htmx is not deferred: hx-boost has to be in place before the first click. #} @@ -54,22 +16,9 @@ -{# 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. #} -{# 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. #} @@ -94,31 +43,8 @@ -{# col-xl-8 keeps a line of verse near a readable measure instead of running - the full width of a desktop window. #}
- {# The swap target. hx-target on names this id, so every boosted - link lands here -- remove it and a click does nothing at all while a - full navigation still works. - - It is also where a verse click becomes a request. htmx's bootstrap-modal - example puts hx-get on the element that opens the dialog; the element - here is a verse number in a generated book, and there are 38,000 of - them -- an attribute apiece would cost more than the whole shell, and a - listener apiece more than the reading. So one handler sits on the - container they all swap into, and names the verse it found. Delegated, - so it survives every swap: this element is never replaced, only filled. - - hx-vals cannot do this. htmx evaluates it as Function("return (...)")() - with nothing passed in, so an expression there can read the document but - not the click -- and which verse was clicked is the one thing that has - to travel. - - Enter and space go through the same door rather than a second one: - compile_bible.py writes role=button and tabindex on every verse, and a - button that answers the keyboard by being clicked is a button with one - behaviour instead of two. #}
-{# The verse dialog: empty in the page, filled per request. Bootstrap owns - showing it, dismissing it, the backdrop and Escape; sw.js owns everything - inside, and templates/verse.njk is what lands in the modal-content below. - - Opened by the arrival of the content rather than by data-bs-toggle on - whatever was clicked. The example puts that attribute on its trigger, which - is not available here for the same reason hx-get is not -- see #content - above -- and the swap is the honest signal anyway: there is nothing worth - showing until it is here. A verse the corpus does not have is answered 204, - which swaps nothing, which opens nothing. - - after-swap and not after-settle: settling is the 20ms attribute pass and - this dialog has nothing to settle. Only the modal-content swap can reach - here -- the bookmark write targets the navbar slot, and the toast it sends - back rides in out of band, which fires its own event and not this one. - - verse-done is the dialog saying it has finished its job, and it arrives - from both of the things in it: sw.js sends it as a trigger header on a - bookmark write that committed, and the copy button dispatches it when the - clipboard has actually taken the link. Neither sends it when the action - failed -- the field in the dialog is the fallback for a copy that was - refused, and there is nothing to take down the dialog for when there is - nothing to celebrate. - - Closed at once rather than after a pause. The pause was there to let the - status line inside be read before the dialog took it away; the line is a - toast now, outside and above all of this, so it outlives the dialog and - there is nothing left to wait for. #} -{# Where confirmations land. Empty in the page and filled a toast at a time: - api/toast appends one when the copy button asks for it, and a bookmark - write sends one out of band on its own answer. templates/toast.njk is what - lands here, and each one shows and then removes itself, so this is empty - again a few seconds later. - - Fixed, and outside #content, for the same reason the dialog is: it has to - survive the swap under it. It also has to survive the dialog -- a copy - confirmed by a toast inside the modal would go when the modal did. - - Bottom right, and last in the document, so it covers the text rather than - moving it, and so nothing in the reading order comes after it. It sits - above the modal without being asked to: Bootstrap gives .toast a z-index of - 1090 against the modal's 1055, and this container takes none of its own, - which is what leaves that comparison to the toast. #}
diff --git a/assets/templates/bookmark.njk b/assets/templates/bookmark.njk index 7357c26..7acc242 100644 --- a/assets/templates/bookmark.njk +++ b/assets/templates/bookmark.njk @@ -1,21 +1,5 @@ -{# The navbar's bookmark slot. Rendered into the shell with whatever mark the - worker holds, and returned on its own by api/bookmark after a write. Both - paths must produce the same element for the swap to be seamless. - - Always filled: getBookmark() answers with the default when nothing has been - set and when storage cannot be read at all, so there is no empty state to - render. There was one -- a hidden slot that fetched itself once a worker - turned up -- for pages boot.html composed before there was a worker to ask. - Nothing composes a page but the worker now, and the worker always knows. #} - {# 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 - , 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. #} Bookmark: - {# Not boosted: a real navigation lets the browser scroll to the verse - anchor, which a swap into #content would not do. #} {{ mark.ref }} diff --git a/assets/templates/index.njk b/assets/templates/index.njk index 8bf664b..ba070b3 100644 --- a/assets/templates/index.njk +++ b/assets/templates/index.njk @@ -1,7 +1,5 @@ {% extends "base.njk" %} {% block body_class %}browsing{% endblock %} -{# Already on the index, so the nav entry marks the current page instead of - offering a link to it. #} {% block navbar_nav %} {% endblock %} {% block content %}{{ content | safe }}{% endblock %} diff --git a/assets/templates/notice.njk b/assets/templates/notice.njk index 2aa9b50..3a979f5 100644 --- a/assets/templates/notice.njk +++ b/assets/templates/notice.njk @@ -1,9 +1,3 @@ -{# A page that could not be delivered, written in the same shape as a real - one: a title and one root element naming the template that wraps it. - - Sent with 200 on purpose. htmx only swaps a 2xx body, and an error status - would leave the reader looking at the page they came from with nothing to - say what happened. #} {{ heading }}

{{ heading }}

diff --git a/assets/templates/page.njk b/assets/templates/page.njk index 694ddb1..38cce34 100644 --- a/assets/templates/page.njk +++ b/assets/templates/page.njk @@ -1,3 +1,2 @@ -{# Fallback for a fragment that names no template of its own. #} {% extends "base.njk" %} {% block content %}{{ content | safe }}{% endblock %} diff --git a/assets/templates/reference.njk b/assets/templates/reference.njk index 9d18eab..ce47f77 100644 --- a/assets/templates/reference.njk +++ b/assets/templates/reference.njk @@ -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. #}