Remove comments
This commit is contained in:
1 parent
872175153a
commit
057b4be7d7
18 files changed
+66
-1350
No files matched your search
@@ -4,38 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>World English Bible Updated</title>
|
||||
<!-- Vendored rather than linked from a CDN: sw.js only caches same-origin
|
||||
responses, so a CDN stylesheet would leave the app unstyled offline. -->
|
||||
<link rel="stylesheet" href="css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
</head>
|
||||
<body class="bg-body-tertiary">
|
||||
|
||||
<!-- Served by server.py in place of any address a browser navigates to while
|
||||
no service worker is controlling this origin: a first visit, a cleared
|
||||
cache, a reload that bypassed the worker.
|
||||
|
||||
The pages on disk are fragments and the shell is a template, and it is the
|
||||
worker that puts them together. So this page has one job: register the
|
||||
worker and reload once it is running, which lands on the real app at the
|
||||
address that was asked for. It is only ever seen once per device.
|
||||
|
||||
There is no fallback for a device that cannot install one. There used to
|
||||
be -- this page loaded the renderer itself and composed the page in the
|
||||
browser -- and it existed for one case: the app served over plain http://
|
||||
from a LAN address, which is not a secure context, so no worker can
|
||||
register. This app is served over TLS, where that case cannot arise, and
|
||||
the fallback was a second way of building every page that nothing
|
||||
exercised. What is left when registration fails is a sentence saying so,
|
||||
because there is nothing honest to show instead. -->
|
||||
|
||||
<main class="boot container py-5 text-center">
|
||||
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
|
||||
<!-- Empty until there is something to say. This page is on screen for a
|
||||
moment on a first visit and then never again, and a line naming what it
|
||||
is doing is a line nobody needs to read: the spinner already says wait.
|
||||
It is here, and empty, because it is where fail() writes. -->
|
||||
<p id="boot-status" class="text-body-secondary"></p>
|
||||
<noscript><p class="alert alert-warning d-inline-block">This app builds its
|
||||
pages on the device and needs JavaScript.</p></noscript>
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
/* The text layer. Bootstrap styles the chrome; this styles scripture.
|
||||
Every selector here is a class render_books.py already emits, so restyling
|
||||
the text never means regenerating books/. */
|
||||
|
||||
/* Serif everywhere, chrome included. Overriding Bootstrap's own variable
|
||||
rather than setting font-family on elements means the navbar, modal and
|
||||
buttons all follow, and a typeface is named in exactly one place. System
|
||||
fonts on purpose: a webfont would be one more thing to keep cached for
|
||||
offline, and one more request before the text can be read. */
|
||||
:root {
|
||||
--bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
|
||||
"Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* Verse numbers. The markup is a <span>, not a <sup>, so the raise happens
|
||||
here -- and line-height:0 keeps the raised number from opening up the line
|
||||
it sits on. Not selectable, so copying a passage copies the words. */
|
||||
.v {
|
||||
font-size: .7em;
|
||||
font-weight: 700;
|
||||
@@ -25,15 +13,12 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* These are real buttons -- compile_bible.py writes role and tabindex on every
|
||||
one of them -- so they need a visible focus ring. */
|
||||
.v:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Chapter numbers are <h2>, which at heading size shouts over the text. */
|
||||
.chapter-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
@@ -41,10 +26,6 @@
|
||||
margin: 2rem 0 .75rem;
|
||||
}
|
||||
|
||||
/* Poetry lines are paragraphs, so left alone each one carries a paragraph's
|
||||
bottom margin and a psalm falls apart into separate blocks. Close the gap,
|
||||
indent by the level the generator recorded, and hang the indent so a line
|
||||
too long for the screen still reads as one line. */
|
||||
.poetry { margin-bottom: 1rem; }
|
||||
.poetry .line {
|
||||
margin: 0;
|
||||
@@ -54,13 +35,11 @@
|
||||
.poetry .line[data-indent="2"] { padding-left: 3em; }
|
||||
.poetry .line[data-indent="3"] { padding-left: 4.5em; }
|
||||
|
||||
/* Descriptive titles: "A Psalm by David". */
|
||||
.d {
|
||||
font-style: italic;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
/* The notes are apparatus, not scripture, and should not read as it. */
|
||||
.footnotes {
|
||||
font-size: .875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
@@ -68,38 +47,17 @@
|
||||
.footnotes-title { font-size: 1rem; }
|
||||
.marker { font-weight: 700; }
|
||||
|
||||
/* The callers are * † ‡ § and ✡, and the star is the one character in the
|
||||
whole corpus a browser may render as an emoji. render_books.py already
|
||||
appends U+FE0E to ask for the text glyph; this asks a second way, for
|
||||
renderers that ignore the variation selector. Newer property, so where it
|
||||
is not supported the selector is simply dropped and the variation selector
|
||||
is still doing the work. */
|
||||
.noteref,
|
||||
.marker { font-variant-emoji: text; }
|
||||
|
||||
/* The navbar is sticky, so an element scrolled to the top of the viewport
|
||||
lands underneath it. These two are everything an anchor in this app points
|
||||
at -- chapter headings and verse numbers -- so the offset lives on them
|
||||
rather than on a scroll container. Roughly the navbar's height. */
|
||||
.chapter,
|
||||
.v { scroll-margin-top: 4.5rem; }
|
||||
|
||||
/* The navbar's reference picker. form-select is width:100% by default, which
|
||||
in a flex row means both selects try to take the whole navbar; and the
|
||||
default right padding leaves the caret sitting on top of a three-digit
|
||||
chapter number. */
|
||||
.reference .form-select {
|
||||
width: auto;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
/* The book list is a list of links, not a table of facts. Reboot's bullets
|
||||
and the chapter count after each title are both noise between the reader
|
||||
and the book they came for.
|
||||
|
||||
Hidden here rather than dropped from render_books.py: the count is real
|
||||
information the generator knows, and whether to show it is a presentation
|
||||
decision. It stays in the markup for anything that wants it later. */
|
||||
.index ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
|
||||
+4
-147
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
// A very small HTTP router, so the worker reads as a set of routes rather than
|
||||
// a chain of ifs unpicking a URL. The shape is lifted from the one in
|
||||
// htmx-serviceworker-todo, which is in turn a miniature of Hono or Express:
|
||||
// register a method and a path pattern, get the matching handler back.
|
||||
//
|
||||
// Patterns are matched segment by segment against a path relative to the
|
||||
// worker's own scope, so the app still works if it is ever served from a
|
||||
// subdirectory. A segment written as :name matches anything and lands in
|
||||
// params. Segment counts must be equal -- there are no wildcards, because
|
||||
// nothing here needs one.
|
||||
//
|
||||
// A handler returning a string gets it as an HTML response; returning a
|
||||
// Response passes it through untouched; returning nothing means "not mine
|
||||
// after all", and the caller falls back to serving the URL as a file.
|
||||
(function (scope) {
|
||||
'use strict';
|
||||
|
||||
@@ -21,32 +7,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Everything a handler is given: the event it must attach cache writes to,
|
||||
// the URL, the path parameters, and the two questions every handler here
|
||||
// asks -- who is calling, and are they replacing the whole document.
|
||||
function Call(event, url, params) {
|
||||
this.event = event;
|
||||
this.request = event.request;
|
||||
this.url = url;
|
||||
this.params = params;
|
||||
// htmx stamps every request it makes; a navigation never carries it.
|
||||
this.htmx = this.request.headers.get('HX-Request') === 'true';
|
||||
this.navigate = this.request.mode === 'navigate';
|
||||
// Going back or forward. htmx asks for the page again and expects a whole
|
||||
// document back, the same as a navigation -- it takes the history element
|
||||
// out of the response itself.
|
||||
this.restore =
|
||||
this.request.headers.get('HX-History-Restore-Request') === 'true';
|
||||
// The address the page making this request is showing. htmx stamps it on
|
||||
// everything it sends; a navigation carries nothing, because there is no
|
||||
// page yet. It is how a handler answering the chrome -- which htmx never
|
||||
// swaps, and which therefore outlives the page it was rendered for -- can
|
||||
// still find out which page that is.
|
||||
this.current = this.request.headers.get('HX-Current-URL');
|
||||
}
|
||||
|
||||
// That address as a URL, resolved against this request's own so a relative
|
||||
// one still works. Null when there was no page.
|
||||
Call.prototype.currentUrl = function () {
|
||||
return this.current ? new URL(this.current, this.url) : null;
|
||||
};
|
||||
@@ -55,8 +27,6 @@
|
||||
return this.request.formData();
|
||||
};
|
||||
|
||||
// base is the worker's scope path, e.g. "/" -- the prefix every pattern is
|
||||
// written without.
|
||||
function Router(base) {
|
||||
this.base = base;
|
||||
this.routes = [];
|
||||
@@ -75,9 +45,6 @@
|
||||
return this.route('POST', pattern, handler);
|
||||
};
|
||||
|
||||
// The path a pattern is matched against: absolute, but with the scope
|
||||
// removed, so "/GEN" is "/GEN" whether the app sits at the origin root or
|
||||
// under /bible/.
|
||||
Router.prototype.relative = function (url) {
|
||||
var path = url.pathname;
|
||||
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
|
||||
@@ -99,8 +66,6 @@
|
||||
return null;
|
||||
};
|
||||
|
||||
// Returns a promise of a Response, or null when nothing matched -- null
|
||||
// being the signal to the caller that this URL is a file, not a route.
|
||||
Router.prototype.handle = function (event, url) {
|
||||
var found = this.find(event.request.method, this.relative(url));
|
||||
if (!found) return null;
|
||||
@@ -113,8 +78,6 @@
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// Built here from live state -- the bookmark, the templates, the
|
||||
// cache -- so storing it would pin a stale copy over the real thing.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
|
||||
+7
-348
@@ -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);
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
{# The shell every page is wrapped in. Rendered by sw.js, not by the build,
|
||||
so editing this file changes every page without regenerating any of them.
|
||||
|
||||
Context: title, content (safe HTML fragment), mark (bookmark or null),
|
||||
reference (the navbar picker's model, or null).
|
||||
|
||||
Bootstrap is the whole stylesheet. The fragments carry semantic USFM
|
||||
classes and no Bootstrap ones, so the body text is styled by Reboot; the
|
||||
chrome here is what uses the framework. #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{# No viewport-fit=cover, deliberately. It extends the page into the notch
|
||||
and the home indicator, which only makes sense paired with safe-area-inset
|
||||
padding in the stylesheet -- and without that padding on iOS the first line
|
||||
of scripture sits under the clock. Paired with the status bar style below:
|
||||
change one and the other has to change too. #}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ title | safe }}{% endblock %}</title>
|
||||
{# Vendored rather than linked from a CDN: sw.js only caches same-origin
|
||||
responses, so a CDN stylesheet would leave the app unstyled offline. #}
|
||||
<link rel="stylesheet" href="css/bootstrap.min.css">
|
||||
{# app.css comes second so its variable overrides win. It styles the
|
||||
scripture; Bootstrap styles the chrome. #}
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
{# History entries are whole books, and htmx keeps its history cache in
|
||||
localStorage -- a couple of them would blow the quota. Turned off, so every
|
||||
back or forward is a cache miss, and refreshOnHistoryMiss decides what a
|
||||
miss does.
|
||||
|
||||
False on purpose. True makes htmx call location.reload(true) -- a forced
|
||||
reload, which is exactly the kind of navigation a browser takes past the
|
||||
service worker, so it reaches server.py and comes back as boot.html: the
|
||||
spinner flashes by and then reloads into the real page. False makes htmx
|
||||
fetch the page over XHR instead, which the worker answers itself, from its
|
||||
own cache when offline. No navigation, no boot page, nothing to flash.
|
||||
|
||||
That request carries HX-History-Restore-Request, and the worker answers it
|
||||
with a whole composed document; htmx lifts the element marked
|
||||
hx-history-elt out of the response and puts it back in the page. #}
|
||||
<meta name="htmx-config"
|
||||
content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'>
|
||||
{# The bundle carries Popper, which the navbar collapse and the modal do not
|
||||
need -- but a dropdown or tooltip added later would, and this is one file
|
||||
in the precache either way. Deferred scripts run in order, so `bootstrap`
|
||||
is defined by the time app.js runs. #}
|
||||
<script src="js/bootstrap.bundle.min.js" defer></script>
|
||||
{# htmx is not deferred: hx-boost has to be in place before the first click. #}
|
||||
<script src="js/htmx.min.js"></script>
|
||||
<script src="js/app.js" defer></script>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
@@ -54,22 +16,9 @@
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
{# black, not black-translucent. Translucent makes iOS draw the status bar
|
||||
over the web view and reserve no space for it, so the navbar starts at the
|
||||
very top of the screen with the clock and battery on top of it. black keeps
|
||||
the status bar opaque and the view below it, which is what this app wants:
|
||||
there is no full-bleed artwork here to show through. #}
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Bible">
|
||||
</head>
|
||||
{# Boosted links swap only the fragment, so the navbar, the modal and the
|
||||
scripts above survive navigation and never reload.
|
||||
|
||||
No show: modifier, deliberately. htmx adds show:top itself for a boosted
|
||||
link, but skips it when the href carries a fragment identifier and scrolls
|
||||
to that anchor after the swap instead. Spelling show:top out here overrides
|
||||
that decision for every link, which leaves a cross-reference like ISA#v61_6
|
||||
sitting at the top of Isaiah. #}
|
||||
<body class="{% block body_class %}{% endblock %} bg-body-tertiary"
|
||||
hx-boost="true" hx-target="#content" hx-swap="innerHTML">
|
||||
|
||||
@@ -94,31 +43,8 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{# col-xl-8 keeps a line of verse near a readable measure instead of running
|
||||
the full width of a desktop window. #}
|
||||
<main class="container-xl my-4">
|
||||
<div class="row justify-content-center">
|
||||
{# The swap target. hx-target on <body> names this id, so every boosted
|
||||
link lands here -- remove it and a click does nothing at all while a
|
||||
full navigation still works.
|
||||
|
||||
It is also where a verse click becomes a request. htmx's bootstrap-modal
|
||||
example puts hx-get on the element that opens the dialog; the element
|
||||
here is a verse number in a generated book, and there are 38,000 of
|
||||
them -- an attribute apiece would cost more than the whole shell, and a
|
||||
listener apiece more than the reading. So one handler sits on the
|
||||
container they all swap into, and names the verse it found. Delegated,
|
||||
so it survives every swap: this element is never replaced, only filled.
|
||||
|
||||
hx-vals cannot do this. htmx evaluates it as Function("return (...)")()
|
||||
with nothing passed in, so an expression there can read the document but
|
||||
not the click -- and which verse was clicked is the one thing that has
|
||||
to travel.
|
||||
|
||||
Enter and space go through the same door rather than a second one:
|
||||
compile_bible.py writes role=button and tabindex on every verse, and a
|
||||
button that answers the keyboard by being clicked is a button with one
|
||||
behaviour instead of two. #}
|
||||
<div id="content" class="col-12 col-xl-8" hx-history-elt
|
||||
hx-on:click="var verse = event.target.closest('.v[id]');
|
||||
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
|
||||
@@ -131,37 +57,8 @@
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "version.njk" %}
|
||||
</main>
|
||||
|
||||
{# The verse dialog: empty in the page, filled per request. Bootstrap owns
|
||||
showing it, dismissing it, the backdrop and Escape; sw.js owns everything
|
||||
inside, and templates/verse.njk is what lands in the modal-content below.
|
||||
|
||||
Opened by the arrival of the content rather than by data-bs-toggle on
|
||||
whatever was clicked. The example puts that attribute on its trigger, which
|
||||
is not available here for the same reason hx-get is not -- see #content
|
||||
above -- and the swap is the honest signal anyway: there is nothing worth
|
||||
showing until it is here. A verse the corpus does not have is answered 204,
|
||||
which swaps nothing, which opens nothing.
|
||||
|
||||
after-swap and not after-settle: settling is the 20ms attribute pass and
|
||||
this dialog has nothing to settle. Only the modal-content swap can reach
|
||||
here -- the bookmark write targets the navbar slot, and the toast it sends
|
||||
back rides in out of band, which fires its own event and not this one.
|
||||
|
||||
verse-done is the dialog saying it has finished its job, and it arrives
|
||||
from both of the things in it: sw.js sends it as a trigger header on a
|
||||
bookmark write that committed, and the copy button dispatches it when the
|
||||
clipboard has actually taken the link. Neither sends it when the action
|
||||
failed -- the field in the dialog is the fallback for a copy that was
|
||||
refused, and there is nothing to take down the dialog for when there is
|
||||
nothing to celebrate.
|
||||
|
||||
Closed at once rather than after a pause. The pause was there to let the
|
||||
status line inside be read before the dialog took it away; the line is a
|
||||
toast now, outside and above all of this, so it outlives the dialog and
|
||||
there is nothing left to wait for. #}
|
||||
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
||||
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
||||
@@ -171,21 +68,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Where confirmations land. Empty in the page and filled a toast at a time:
|
||||
api/toast appends one when the copy button asks for it, and a bookmark
|
||||
write sends one out of band on its own answer. templates/toast.njk is what
|
||||
lands here, and each one shows and then removes itself, so this is empty
|
||||
again a few seconds later.
|
||||
|
||||
Fixed, and outside #content, for the same reason the dialog is: it has to
|
||||
survive the swap under it. It also has to survive the dialog -- a copy
|
||||
confirmed by a toast inside the modal would go when the modal did.
|
||||
|
||||
Bottom right, and last in the document, so it covers the text rather than
|
||||
moving it, and so nothing in the reading order comes after it. It sits
|
||||
above the modal without being asked to: Bootstrap gives .toast a z-index of
|
||||
1090 against the modal's 1055, and this container takes none of its own,
|
||||
which is what leaves that comparison to the toast. #}
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
{# The navbar's bookmark slot. Rendered into the shell with whatever mark the
|
||||
worker holds, and returned on its own by api/bookmark after a write. Both
|
||||
paths must produce the same element for the swap to be seamless.
|
||||
|
||||
Always filled: getBookmark() answers with the default when nothing has been
|
||||
set and when storage cannot be read at all, so there is no empty state to
|
||||
render. There was one -- a hidden slot that fetched itself once a worker
|
||||
turned up -- for pages boot.html composed before there was a worker to ask.
|
||||
Nothing composes a page but the worker now, and the worker always knows. #}
|
||||
<span class="bookmark navbar-text d-flex align-items-center gap-1" id="bookmark">
|
||||
{# A word and a colon, not a badge: this is one line of text that happens to
|
||||
end in a link, and a pill in front of it read as a control. Outside the
|
||||
anchor so that only the reference is underlined -- Reboot underlines an
|
||||
<a>, and the label is not the part you can go to. It takes the muted
|
||||
navbar colour from .navbar-text on the parent without asking. #}
|
||||
<span class="bookmark-label">Bookmark:</span>
|
||||
{# Not boosted: a real navigation lets the browser scroll to the verse
|
||||
anchor, which a swap into #content would not do. #}
|
||||
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
|
||||
hx-boost="false">{{ mark.ref }}</a>
|
||||
</span>
|
||||
@@ -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 %}
|
||||
@@ -1,9 +1,3 @@
|
||||
{# A page that could not be delivered, written in the same shape as a real
|
||||
one: a title and one root element naming the template that wraps it.
|
||||
|
||||
Sent with 200 on purpose. htmx only swaps a 2xx body, and an error status
|
||||
would leave the reader looking at the page they came from with nothing to
|
||||
say what happened. #}
|
||||
<title>{{ heading }}</title>
|
||||
<div class="notice" data-template="page">
|
||||
<h1>{{ heading }}</h1>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
{# Fallback for a fragment that names no template of its own. #}
|
||||
{% extends "base.njk" %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -1,77 +1,15 @@
|
||||
{# The navbar's reference picker: which book you are in, and where in it to go.
|
||||
|
||||
Context: reference (or null), oob.
|
||||
|
||||
reference is built by sw.js from bible/verses.json, which compile_bible.py
|
||||
writes from the same tree it renders the books from -- so an option can
|
||||
never name an anchor that is not in the page it lands on.
|
||||
|
||||
It used to be built in the browser, by app.js, reading the chapters and
|
||||
verses off the book already on screen. That was free, and it only worked
|
||||
where there was a document to read: this shell is composed in a service
|
||||
worker, which has no DOM, before any document exists. What the picker needs
|
||||
is a fact on disk now, and everything below is markup and htmx.
|
||||
|
||||
Outside the collapse deliberately -- it is the main way of moving around a
|
||||
book now that the chapter list is gone from the page, and a chapter picker
|
||||
behind a hamburger is no use on a phone.
|
||||
|
||||
d-none for the empty state rather than the hidden attribute: Bootstrap
|
||||
declares .d-flex after Reboot's [hidden] rule, both !important and at the
|
||||
same specificity, so the utility would win.
|
||||
|
||||
Three things bring it back into step with the page, because the navbar is
|
||||
never the swap target -- every boosted link lands in #content:
|
||||
|
||||
a navigation sw.js appends this template to the fragment with oob set,
|
||||
the same way a bookmark write carries the modal's status
|
||||
line, so one response moves both.
|
||||
a restore htmx swaps the history element straight in and processes
|
||||
no out-of-band elements at all, so the picker asks for
|
||||
itself instead.
|
||||
an anchor a link to GEN#v3_16 should open with the picker reading
|
||||
3:16, and the fragment never reaches the worker: a browser
|
||||
asks for /GEN and keeps the rest. So a picker that arrived
|
||||
with the page asks once, on load, if there is an anchor to
|
||||
ask about.
|
||||
|
||||
None of them carries the book or the anchor. The request does, in
|
||||
HX-Current-URL, which htmx sets from location.href -- the address the page
|
||||
is actually showing, fragment and all. Baking either into these attributes
|
||||
is the one thing here that can go stale, because the picker outlives the
|
||||
page it was rendered for.
|
||||
|
||||
settled is what stops the load trigger looping: the answer to it is a
|
||||
picker already standing in the right place, and it says so by leaving the
|
||||
trigger out of the copy it sends back.
|
||||
|
||||
!!location.hash, and not location.hash, because htmx keeps an event only
|
||||
when the filter returns boolean true exactly -- it tests the result with
|
||||
!== true -- and a hash is a string. Written without the coercion the
|
||||
trigger is dead: every link carrying an anchor lands with the picker still
|
||||
reading 1:1, which is where the worker rendered it, because a browser
|
||||
keeps the fragment to itself and the request that would correct it is
|
||||
never sent. #}
|
||||
<div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3"
|
||||
id="reference"
|
||||
{%- if oob %} hx-swap-oob="true"{% endif %}
|
||||
hx-get="api/reference"
|
||||
{# htmx keeps an event only when the filter returns true exactly, so a
|
||||
string is discarded. #}
|
||||
hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}htmx:historyRestore from:body"
|
||||
hx-target="this" hx-swap="outerHTML">
|
||||
{%- if reference %}
|
||||
<span class="reference-book text-light fw-semibold"
|
||||
id="reference-book">{{ reference.book }}</span>
|
||||
<label class="visually-hidden" for="reference-chapter">Chapter</label>
|
||||
{# The value is the id of the element to land on, so nothing downstream has
|
||||
to know how a verse anchor is spelled -- including the worker, which
|
||||
reads the chapter back out of the id it sent.
|
||||
|
||||
Which is why both selects post under the same name. ch3 and v3_16 are
|
||||
told apart by their shape, so a second name saying which select it came
|
||||
from would only be repeating what the value already says.
|
||||
|
||||
hx-target is spelled out on both selects because the one on the div above
|
||||
is "this", and an inherited "this" would resolve to the select. #}
|
||||
<select class="reference-chapter form-select form-select-sm"
|
||||
id="reference-chapter" name="at"
|
||||
hx-get="api/reference" hx-target="#reference-verse"
|
||||
@@ -83,9 +21,6 @@
|
||||
</select>
|
||||
<span class="reference-sep text-light">:</span>
|
||||
<label class="visually-hidden" for="reference-verse">Verse</label>
|
||||
{# Answers with its own options again rather than nothing, so the response
|
||||
has something to hang the scroll on -- and so the option the reader chose
|
||||
comes back marked selected instead of being reset by the swap. #}
|
||||
<select class="reference-verse form-select form-select-sm"
|
||||
id="reference-verse" name="at"
|
||||
hx-get="api/reference" hx-target="this" hx-swap="innerHTML">
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
{# One confirmation: something the reader just did, said once and then gone.
|
||||
|
||||
Bootstrap's custom-content shape -- a flex row of body and close button,
|
||||
with no toast-header above it. The header carries a title, an icon and a
|
||||
timestamp, which is what a notification arriving from elsewhere needs; this
|
||||
is the answer to a button pressed a second ago, and the sentence is the
|
||||
whole of it.
|
||||
|
||||
Context: text, bad (it did not happen), oob.
|
||||
|
||||
Two ways in, the same element either way. api/toast renders it alone and
|
||||
htmx appends it to the container; the bookmark write renders it with oob
|
||||
set and it rides along on the answer that updates the navbar slot -- one
|
||||
response, both halves of the page, the way the modal's status line used to.
|
||||
The wrapper is how an out-of-band element with no id of its own says where
|
||||
it goes: htmx appends the wrapper's children to the named container and
|
||||
drops the wrapper itself.
|
||||
|
||||
It shows itself on arrival and removes itself once it has gone. hx-on::load
|
||||
is htmx's own event, fired on content it has just inserted and processed,
|
||||
so it covers both ways in -- and it is the same move base.njk makes to open
|
||||
the dialog: the arrival of the content is the signal to show it. Bootstrap
|
||||
owns the timer, the fade, the close button and Escape from there. Removing
|
||||
the element on hidden.bs.toast is what keeps the container from filling up
|
||||
with dead markup: a toast that has been read is not history, and nothing
|
||||
here ever looks at it again. #}
|
||||
{% if oob %}<div hx-swap-oob="beforeend:#toasts">{% endif %}
|
||||
<div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}"
|
||||
role="alert" aria-live="assertive" aria-atomic="true"
|
||||
@@ -31,9 +5,6 @@
|
||||
hx-on:hidden.bs.toast="this.remove();">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">{{ text }}</div>
|
||||
{# me-2 m-auto, not ms-auto: the row is body then button, and the body is
|
||||
what grows -- the button only has to sit centred against however many
|
||||
lines it turns out to be. #}
|
||||
<button type="button"
|
||||
class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto"
|
||||
data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
{# The verse dialog's contents: what this verse is called, a link to it, and
|
||||
the one thing you can do with it.
|
||||
|
||||
Rendered by sw.js for the verse that was clicked and swapped into the empty
|
||||
modal-content in base.njk -- the shape htmx's bootstrap-modal example uses.
|
||||
Bootstrap owns showing, dismissing, the backdrop and Escape; everything
|
||||
here is what is inside.
|
||||
|
||||
Context: verse (ref, href, url).
|
||||
|
||||
All three are built in the worker, from the book in the address bar and the
|
||||
anchor that was clicked. app.js used to read them off the page -- the book
|
||||
out of article[data-name], the chapter out of the enclosing section -- which
|
||||
put the reference in two places and left the spelling of an anchor known to
|
||||
both the file that generates them and the file that scrapes them back.
|
||||
|
||||
The link is a field rather than a line of text because it is the fallback
|
||||
for the button beside it: navigator.clipboard exists only in a secure
|
||||
context, so over plain http:// from a LAN address there is nothing to write
|
||||
with and a selected field is the whole of what is left. Readonly, so it
|
||||
reads as something to take rather than something to fill in. #}
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||
@@ -30,27 +9,10 @@
|
||||
type="text" readonly value="{{ verse.url }}">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{# The form is the bookmark write and nothing else. ref and href are hidden
|
||||
because they are this dialog's whole subject: the reader chose them by
|
||||
clicking a verse, and there is nothing here to edit. #}
|
||||
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
|
||||
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
|
||||
<input type="hidden" name="ref" value="{{ verse.ref }}">
|
||||
<input type="hidden" name="href" value="{{ verse.href }}">
|
||||
{# Selected first, written second: the selection is what makes the field
|
||||
useful when the write is refused, and it costs nothing when it is not.
|
||||
The toast is only raised once the clipboard has actually taken the link,
|
||||
which is why the promise is answered rather than assumed.
|
||||
|
||||
Both outcomes are a toast, and neither is written here: api/toast is
|
||||
asked for one by name, so the wording lives in the worker with the rest
|
||||
of the app's words rather than in the middle of an attribute. It is the
|
||||
same request the dialog itself arrives on -- htmx, the worker, a
|
||||
fragment -- with the container in base.njk as the target.
|
||||
|
||||
Only a copy that happened ends the dialog. A refused one leaves it
|
||||
standing, because the field above it, selected, is the whole of what is
|
||||
left to copy with. #}
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
hx-on:click="var button = this;
|
||||
var field = document.getElementById('verse-dialog-url');
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
{# The verse select's options. Rendered into the picker by reference.njk, and
|
||||
returned on their own by api/reference after a chapter or verse change.
|
||||
Both paths must produce the same options for the swap to be seamless. #}
|
||||
{% for verse in reference.verses -%}
|
||||
<option value="{{ verse.id }}"
|
||||
{%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option>
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
{# The footer under the book list: what this device is running.
|
||||
|
||||
Both digests are stamped into sw.js by server.py from the files on disk:
|
||||
shell covers assets/, the hand-written chrome, and library covers bible/,
|
||||
the generated pages. They are the same two values the worker names its
|
||||
caches after, so a device showing a digest is a device serving that cache.
|
||||
|
||||
It says what the worker that composed this document was, not what is
|
||||
controlling right now. An update installs, claims and starts answering
|
||||
without this document being rebuilt, so the value only becomes current on
|
||||
the next full load -- which is the reload you would do to check it anyway.
|
||||
|
||||
Rendered on every page and shown under the book list alone. Populating it
|
||||
only for the index would look like the smaller change and would not work:
|
||||
the shell is composed once per full navigation, and a boosted link to the
|
||||
book list swaps #content without rebuilding anything around it. So the page
|
||||
decides, not the composer -- app.js looks for the index in #content after
|
||||
every swap. The body class this was rendered with cannot be asked, for the
|
||||
same reason: it goes stale the moment a link is clicked. #}
|
||||
<div class="app-version d-none text-center mt-5 mb-2" id="app-version">
|
||||
<div class="app-version text-center mt-5 mb-2">
|
||||
<p class="app-version-digests form-text m-0">
|
||||
shell {{ version.shell }} · library {{ version.library }}
|
||||
</p>
|
||||
|
||||
Reference in new issue
Block a user