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 charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>World English Bible Updated</title>
|
<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/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="css/app.css">
|
<link rel="stylesheet" href="css/app.css">
|
||||||
<meta name="theme-color" content="#1a202c">
|
<meta name="theme-color" content="#1a202c">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-body-tertiary">
|
<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">
|
<main class="boot container py-5 text-center">
|
||||||
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
|
<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>
|
<p id="boot-status" class="text-body-secondary"></p>
|
||||||
<noscript><p class="alert alert-warning d-inline-block">This app builds its
|
<noscript><p class="alert alert-warning d-inline-block">This app builds its
|
||||||
pages on the device and needs JavaScript.</p></noscript>
|
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 {
|
:root {
|
||||||
--bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
|
--bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
|
||||||
"Times New Roman", serif;
|
"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 {
|
.v {
|
||||||
font-size: .7em;
|
font-size: .7em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -25,15 +13,12 @@
|
|||||||
user-select: none;
|
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 {
|
.v:focus-visible {
|
||||||
outline: 2px solid var(--bs-primary);
|
outline: 2px solid var(--bs-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chapter numbers are <h2>, which at heading size shouts over the text. */
|
|
||||||
.chapter-number {
|
.chapter-number {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -41,10 +26,6 @@
|
|||||||
margin: 2rem 0 .75rem;
|
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 { margin-bottom: 1rem; }
|
||||||
.poetry .line {
|
.poetry .line {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -54,13 +35,11 @@
|
|||||||
.poetry .line[data-indent="2"] { padding-left: 3em; }
|
.poetry .line[data-indent="2"] { padding-left: 3em; }
|
||||||
.poetry .line[data-indent="3"] { padding-left: 4.5em; }
|
.poetry .line[data-indent="3"] { padding-left: 4.5em; }
|
||||||
|
|
||||||
/* Descriptive titles: "A Psalm by David". */
|
|
||||||
.d {
|
.d {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The notes are apparatus, not scripture, and should not read as it. */
|
|
||||||
.footnotes {
|
.footnotes {
|
||||||
font-size: .875rem;
|
font-size: .875rem;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
@@ -68,38 +47,17 @@
|
|||||||
.footnotes-title { font-size: 1rem; }
|
.footnotes-title { font-size: 1rem; }
|
||||||
.marker { font-weight: 700; }
|
.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,
|
.noteref,
|
||||||
.marker { font-variant-emoji: text; }
|
.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,
|
.chapter,
|
||||||
.v { scroll-margin-top: 4.5rem; }
|
.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 {
|
.reference .form-select {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding-right: 2rem;
|
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 {
|
.index ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding-left: 0;
|
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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// -- events -------------------------------------------------------------
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||||
var target = event.detail.target;
|
var target = event.detail.target;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
// Both selects answer into the verse list, so this is either of them.
|
if (target.id === 'reference-verse') return scrollToHash();
|
||||||
if (target.id === 'reference-verse') return landOnReference();
|
if (target.id === 'content') return land();
|
||||||
if (target.id === 'content') { land(); showVersion(); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 () {
|
document.body.addEventListener('htmx:historyRestore', function () {
|
||||||
land();
|
land();
|
||||||
showVersion();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- startup ------------------------------------------------------------
|
|
||||||
|
|
||||||
function hashTarget() {
|
function hashTarget() {
|
||||||
if (!location.hash) return null;
|
if (!location.hash) return null;
|
||||||
return document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
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) {
|
function jumpTo(el) {
|
||||||
try {
|
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||||
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
|
||||||
} catch (err) {
|
|
||||||
el.scrollIntoView({ block: 'start', behavior: 'auto' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same, for the top of the page and the same reason.
|
|
||||||
function jumpToTop() {
|
function jumpToTop() {
|
||||||
try {
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
} catch (err) {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
function scrollToHash() {
|
||||||
var el = hashTarget();
|
var el = hashTarget();
|
||||||
if (el) jumpTo(el);
|
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() {
|
function land() {
|
||||||
var el = hashTarget();
|
var el = hashTarget();
|
||||||
if (el) jumpTo(el);
|
if (el) jumpTo(el);
|
||||||
else jumpToTop();
|
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 () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
scrollToHash();
|
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) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('sw.js').catch(function () {});
|
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) {
|
(function (scope) {
|
||||||
'use strict';
|
'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 TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
|
||||||
var TEMPLATE_RE = /data-template="([a-z0-9_-]+)"/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/';
|
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 = [
|
var NAMES = [
|
||||||
'base.njk',
|
'base.njk',
|
||||||
'page.njk',
|
'page.njk',
|
||||||
@@ -43,11 +22,7 @@
|
|||||||
|
|
||||||
scope.TEMPLATE_URLS = NAMES.map(function (name) { return TEMPLATES + name; });
|
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) {
|
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() {}
|
function Loader() {}
|
||||||
Loader.prototype.async = true;
|
Loader.prototype.async = true;
|
||||||
Loader.prototype.getSource = function (name, callback) {
|
Loader.prototype.getSource = function (name, callback) {
|
||||||
@@ -56,38 +31,13 @@
|
|||||||
function (err) { callback(err); });
|
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 });
|
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;
|
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() {
|
function loadAll() {
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
loaded = Promise.all(NAMES.map(function (name) {
|
loaded = Promise.all(NAMES.map(function (name) {
|
||||||
@@ -112,18 +62,6 @@
|
|||||||
return {
|
return {
|
||||||
render: render,
|
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) {
|
compose: function (fragment, chrome) {
|
||||||
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
|
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
|
||||||
var content = fragment.replace(TITLE_RE, '');
|
var content = fragment.replace(TITLE_RE, '');
|
||||||
@@ -132,13 +70,10 @@
|
|||||||
title: title,
|
title: title,
|
||||||
content: content,
|
content: content,
|
||||||
mark: chrome.mark,
|
mark: chrome.mark,
|
||||||
reference: chrome.reference,
|
reference: chrome.reference
|
||||||
version: chrome.version
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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) {
|
notice: function (heading, text) {
|
||||||
return render('notice.njk', { heading: heading, text: 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) {
|
(function (scope) {
|
||||||
'use strict';
|
'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) {
|
function Call(event, url, params) {
|
||||||
this.event = event;
|
this.event = event;
|
||||||
this.request = event.request;
|
this.request = event.request;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.params = params;
|
this.params = params;
|
||||||
// htmx stamps every request it makes; a navigation never carries it.
|
|
||||||
this.htmx = this.request.headers.get('HX-Request') === 'true';
|
this.htmx = this.request.headers.get('HX-Request') === 'true';
|
||||||
this.navigate = this.request.mode === 'navigate';
|
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.restore =
|
||||||
this.request.headers.get('HX-History-Restore-Request') === 'true';
|
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');
|
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 () {
|
Call.prototype.currentUrl = function () {
|
||||||
return this.current ? new URL(this.current, this.url) : null;
|
return this.current ? new URL(this.current, this.url) : null;
|
||||||
};
|
};
|
||||||
@@ -55,8 +27,6 @@
|
|||||||
return this.request.formData();
|
return this.request.formData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// base is the worker's scope path, e.g. "/" -- the prefix every pattern is
|
|
||||||
// written without.
|
|
||||||
function Router(base) {
|
function Router(base) {
|
||||||
this.base = base;
|
this.base = base;
|
||||||
this.routes = [];
|
this.routes = [];
|
||||||
@@ -75,9 +45,6 @@
|
|||||||
return this.route('POST', pattern, handler);
|
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) {
|
Router.prototype.relative = function (url) {
|
||||||
var path = url.pathname;
|
var path = url.pathname;
|
||||||
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
|
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
|
||||||
@@ -99,8 +66,6 @@
|
|||||||
return null;
|
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) {
|
Router.prototype.handle = function (event, url) {
|
||||||
var found = this.find(event.request.method, this.relative(url));
|
var found = this.find(event.request.method, this.relative(url));
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
@@ -113,8 +78,6 @@
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'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'
|
'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');
|
importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js');
|
||||||
|
|
||||||
const NETWORK_TIMEOUT_MS = 3000;
|
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;
|
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;
|
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 VERSION = 'dev';
|
||||||
const LIBRARY_VERSION = 'dev';
|
const LIBRARY_VERSION = 'dev';
|
||||||
const LIBRARY = [];
|
const LIBRARY = [];
|
||||||
@@ -82,23 +13,10 @@ const LIBRARY = [];
|
|||||||
const SHELL_CACHE = 'bible-shell-' + VERSION;
|
const SHELL_CACHE = 'bible-shell-' + VERSION;
|
||||||
const LIBRARY_CACHE = 'bible-library-' + LIBRARY_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 };
|
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));
|
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 = [
|
const PRECACHE = [
|
||||||
'./boot.html',
|
'./boot.html',
|
||||||
'./',
|
'./',
|
||||||
@@ -108,10 +26,6 @@ const PRECACHE = [
|
|||||||
'./js/app.js',
|
'./js/app.js',
|
||||||
'./js/htmx.min.js',
|
'./js/htmx.min.js',
|
||||||
'./js/bootstrap.bundle.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);
|
].concat(TEMPLATE_URLS);
|
||||||
|
|
||||||
function cacheFor(req) {
|
function cacheFor(req) {
|
||||||
@@ -121,8 +35,6 @@ function cacheFor(req) {
|
|||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
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;
|
let failed = 0;
|
||||||
await Promise.all(PRECACHE.map((url) =>
|
await Promise.all(PRECACHE.map((url) =>
|
||||||
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
|
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) {
|
function isFile(url) {
|
||||||
return url.pathname.split('/').pop().indexOf('.') !== -1;
|
return url.pathname.split('/').pop().indexOf('.') !== -1;
|
||||||
}
|
}
|
||||||
@@ -153,17 +62,10 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (url.origin !== location.origin) return;
|
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);
|
keepFilling(event);
|
||||||
|
|
||||||
if (!isFile(url)) {
|
if (!isFile(url)) {
|
||||||
const routed = router.handle(event, 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) {
|
if (routed) {
|
||||||
return event.respondWith(routed.then((res) => res || answer(req, event)));
|
return event.respondWith(routed.then((res) => res || answer(req, event)));
|
||||||
}
|
}
|
||||||
@@ -173,10 +75,6 @@ self.addEventListener('fetch', (event) => {
|
|||||||
event.respondWith(answer(req, 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) {
|
async function fromNetwork(req, timeout) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
|
||||||
@@ -191,10 +89,6 @@ function cached(req) {
|
|||||||
return caches.match(req, { ignoreSearch: true });
|
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) {
|
async function answer(input, event) {
|
||||||
const req = typeof input === 'string' ? new Request(input) : input;
|
const req = typeof input === 'string' ? new Request(input) : input;
|
||||||
|
|
||||||
@@ -202,7 +96,6 @@ async function answer(input, event) {
|
|||||||
const res = await fromNetwork(req);
|
const res = await fromNetwork(req);
|
||||||
if (res && res.ok && res.type === 'basic') {
|
if (res && res.ok && res.type === 'basic') {
|
||||||
const copy = res.clone();
|
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));
|
const write = cacheFor(req).then((c) => c.put(req, copy));
|
||||||
if (event) event.waitUntil(write);
|
if (event) event.waitUntil(write);
|
||||||
else write.catch(() => {});
|
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) {
|
function pageRequest(url) {
|
||||||
return new Request(url, { cache: 'no-cache' });
|
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) {
|
async function readText(input, event) {
|
||||||
const res = await answer(input, event);
|
const res = await answer(input, event);
|
||||||
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
|
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
|
||||||
return res.text();
|
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) {
|
async function readTemplate(path) {
|
||||||
const hit = await cached(path);
|
const hit = await cached(path);
|
||||||
return hit ? hit.text() : readText(path);
|
return hit ? hit.text() : readText(path);
|
||||||
@@ -251,31 +125,12 @@ async function readTemplate(path) {
|
|||||||
|
|
||||||
const view = createRenderer(readTemplate);
|
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);
|
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';
|
const VERSES_URL = './verses.json';
|
||||||
|
|
||||||
let verses = null;
|
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() {
|
function readVerses() {
|
||||||
if (!verses) {
|
if (!verses) {
|
||||||
verses = readText(pageRequest(VERSES_URL))
|
verses = readText(pageRequest(VERSES_URL))
|
||||||
@@ -285,10 +140,6 @@ function readVerses() {
|
|||||||
return verses;
|
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) {
|
function parseAnchor(id) {
|
||||||
const chapter = /^ch(\d+)$/.exec(id || '');
|
const chapter = /^ch(\d+)$/.exec(id || '');
|
||||||
if (chapter) return { chapter: +chapter[1], verse: null };
|
if (chapter) return { chapter: +chapter[1], verse: null };
|
||||||
@@ -305,40 +156,17 @@ function verseLabels(chapters, number) {
|
|||||||
return out;
|
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) {
|
function referenceFor(map, code, anchor) {
|
||||||
const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
|
const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
|
||||||
? map[code] : null;
|
? map[code] : null;
|
||||||
if (!chapters || !chapters.length) return 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))) {
|
if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const at = parseAnchor(anchor) || { chapter: 1, verse: null };
|
const at = parseAnchor(anchor) || { chapter: 1, verse: null };
|
||||||
const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1;
|
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;
|
const verse = at.chapter === number ? at.verse : null;
|
||||||
|
|
||||||
return {
|
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) {
|
function bookOf(url) {
|
||||||
return url ? router.relative(url).slice(1) : '';
|
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) {
|
async function referenceOf(code, anchor) {
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
try {
|
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) {
|
async function verseOf(page, at) {
|
||||||
const code = bookOf(page);
|
const code = bookOf(page);
|
||||||
const anchor = parseAnchor(at);
|
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.'];
|
'here. Reconnect and try again, or open one you have read before.'];
|
||||||
const MISSING_NOTICE = ['Not found', 'There is no page at this address.'];
|
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) {
|
async function fragmentFor(path, event) {
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
@@ -422,115 +224,40 @@ async function fragmentFor(path, event) {
|
|||||||
return res.ok ? res.text() : view.notice(...MISSING_NOTICE);
|
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) {
|
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);
|
const reference = await referenceOf(code, null);
|
||||||
if (!call.navigate && !call.restore) {
|
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'
|
return fragment + '\n'
|
||||||
+ await view.render('reference.njk', { reference: reference, oob: true });
|
+ await view.render('reference.njk', { reference: reference, oob: true });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await view.compose(fragment, { mark: await getBookmark(),
|
return await view.compose(fragment, { mark: await getBookmark(),
|
||||||
reference: reference,
|
reference: reference });
|
||||||
version: VERSIONS });
|
|
||||||
} catch (err) {
|
} 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);
|
console.error('compose failed: ' + err.message);
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "/" before "/:page", because both are two segments and the first match wins.
|
|
||||||
router.get('/', (call) => servePage('./', '', call));
|
router.get('/', (call) => servePage('./', '', call));
|
||||||
router.get('/:page', (call) =>
|
router.get('/:page', (call) =>
|
||||||
servePage('./' + call.params.page, call.params.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) => {
|
router.get('/api/reference', async (call) => {
|
||||||
const params = call.url.searchParams;
|
const params = call.url.searchParams;
|
||||||
const page = call.currentUrl();
|
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;
|
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(
|
const reference = await referenceOf(
|
||||||
bookOf(page), moved === null ? (page && page.hash.slice(1)) : moved);
|
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) {
|
if (moved === null) {
|
||||||
return view.render('reference.njk', { reference: reference, settled: true });
|
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 });
|
if (!reference) return new Response('', { status: 204 });
|
||||||
|
|
||||||
const target = reference.verse || reference.chapter;
|
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) => {
|
router.get('/api/verse', async (call) => {
|
||||||
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
|
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
|
||||||
if (!verse) return new Response('', { status: 204 });
|
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 });
|
if (!ref || !href) return new Response('', { status: 400 });
|
||||||
await withStore('readwrite',
|
await withStore('readwrite',
|
||||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
(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'), {
|
return new Response(await bookmarkFragment('bookmarked'), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
@@ -582,19 +293,10 @@ router.post('/api/bookmark', async (call) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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');
|
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 = {
|
const TOASTS = {
|
||||||
'copied': { text: 'Link copied.' },
|
'copied': { text: 'Link copied.' },
|
||||||
'copy-refused': { text: 'Copy the link above: this browser would not '
|
'copy-refused': { text: 'Copy the link above: this browser would not '
|
||||||
@@ -604,43 +306,22 @@ const TOASTS = {
|
|||||||
+ 'unavailable.', bad: true }
|
+ '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) {
|
function toastFragment(say, oob) {
|
||||||
const toast = TOASTS[say];
|
const toast = TOASTS[say];
|
||||||
return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob });
|
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) => {
|
router.get('/api/toast', (call) => {
|
||||||
const say = call.url.searchParams.get('say');
|
const say = call.url.searchParams.get('say');
|
||||||
if (!TOASTS[say]) return new Response('', { status: 204 });
|
if (!TOASTS[say]) return new Response('', { status: 204 });
|
||||||
return toastFragment(say, false);
|
return toastFragment(say, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------------------- library
|
let filling = null;
|
||||||
|
let complete = false;
|
||||||
// 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
|
|
||||||
|
|
||||||
function keepFilling(event) {
|
function keepFilling(event) {
|
||||||
if (complete) return;
|
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) {
|
if (!filling && self.navigator.onLine !== false) {
|
||||||
filling = fillLibrary().catch(() => {}).finally(() => { filling = null; });
|
filling = fillLibrary().catch(() => {}).finally(() => { filling = null; });
|
||||||
}
|
}
|
||||||
@@ -658,8 +339,6 @@ async function fillLibrary() {
|
|||||||
const runners = [];
|
const runners = [];
|
||||||
for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run());
|
for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run());
|
||||||
await Promise.all(runners);
|
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;
|
if (!stopped) complete = true;
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@@ -667,30 +346,18 @@ async function fillLibrary() {
|
|||||||
const url = missing.pop();
|
const url = missing.pop();
|
||||||
try {
|
try {
|
||||||
const res = await fromNetwork(pageRequest(url), DOWNLOAD_TIMEOUT_MS);
|
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);
|
if (res.ok && res.type === 'basic') await cache.put(url, res);
|
||||||
} catch (err) {
|
} 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;
|
stopped = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- bookmark
|
|
||||||
|
|
||||||
const DB_NAME = 'bible';
|
const DB_NAME = 'bible';
|
||||||
const DB_STORE = 'kv';
|
const DB_STORE = 'kv';
|
||||||
const BOOKMARK_KEY = 'bookmark';
|
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' };
|
const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' };
|
||||||
|
|
||||||
function openDb() {
|
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) {
|
async function withStore(mode, fn) {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
try {
|
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() {
|
function getBookmark() {
|
||||||
return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
|
return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
.then((mark) => mark || DEFAULT_BOOKMARK);
|
.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) {
|
async function bookmarkFragment(say) {
|
||||||
return await view.render('bookmark.njk', { mark: await getBookmark() })
|
return await view.render('bookmark.njk', { mark: await getBookmark() })
|
||||||
+ '\n' + await toastFragment(say, true);
|
+ '\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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}{{ title | safe }}{% endblock %}</title>
|
<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">
|
<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">
|
<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"
|
<meta name="htmx-config"
|
||||||
content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'>
|
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>
|
<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/htmx.min.js"></script>
|
||||||
<script src="js/app.js" defer></script>
|
<script src="js/app.js" defer></script>
|
||||||
<link rel="manifest" href="manifest.webmanifest">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
@@ -54,22 +16,9 @@
|
|||||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-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-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="Bible">
|
<meta name="apple-mobile-web-app-title" content="Bible">
|
||||||
</head>
|
</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"
|
<body class="{% block body_class %}{% endblock %} bg-body-tertiary"
|
||||||
hx-boost="true" hx-target="#content" hx-swap="innerHTML">
|
hx-boost="true" hx-target="#content" hx-swap="innerHTML">
|
||||||
|
|
||||||
@@ -94,31 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</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">
|
<main class="container-xl my-4">
|
||||||
<div class="row justify-content-center">
|
<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
|
<div id="content" class="col-12 col-xl-8" hx-history-elt
|
||||||
hx-on:click="var verse = event.target.closest('.v[id]');
|
hx-on:click="var verse = event.target.closest('.v[id]');
|
||||||
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
|
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
|
||||||
@@ -131,37 +57,8 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include "version.njk" %}
|
|
||||||
</main>
|
</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"
|
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||||
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
||||||
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
||||||
@@ -171,21 +68,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
|
||||||
|
|
||||||
</body>
|
</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">
|
<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>
|
<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 }}"
|
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
|
||||||
hx-boost="false">{{ mark.ref }}</a>
|
hx-boost="false">{{ mark.ref }}</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
{% extends "base.njk" %}
|
{% extends "base.njk" %}
|
||||||
{% block body_class %}browsing{% endblock %}
|
{% 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 %}
|
{% block navbar_nav %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}{{ content | safe }}{% 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>
|
<title>{{ heading }}</title>
|
||||||
<div class="notice" data-template="page">
|
<div class="notice" data-template="page">
|
||||||
<h1>{{ heading }}</h1>
|
<h1>{{ heading }}</h1>
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
{# Fallback for a fragment that names no template of its own. #}
|
|
||||||
{% extends "base.njk" %}
|
{% extends "base.njk" %}
|
||||||
{% block content %}{{ content | safe }}{% endblock %}
|
{% 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"
|
<div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3"
|
||||||
id="reference"
|
id="reference"
|
||||||
{%- if oob %} hx-swap-oob="true"{% endif %}
|
{%- if oob %} hx-swap-oob="true"{% endif %}
|
||||||
hx-get="api/reference"
|
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-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}htmx:historyRestore from:body"
|
||||||
hx-target="this" hx-swap="outerHTML">
|
hx-target="this" hx-swap="outerHTML">
|
||||||
{%- if reference %}
|
{%- if reference %}
|
||||||
<span class="reference-book text-light fw-semibold"
|
<span class="reference-book text-light fw-semibold"
|
||||||
id="reference-book">{{ reference.book }}</span>
|
id="reference-book">{{ reference.book }}</span>
|
||||||
<label class="visually-hidden" for="reference-chapter">Chapter</label>
|
<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"
|
<select class="reference-chapter form-select form-select-sm"
|
||||||
id="reference-chapter" name="at"
|
id="reference-chapter" name="at"
|
||||||
hx-get="api/reference" hx-target="#reference-verse"
|
hx-get="api/reference" hx-target="#reference-verse"
|
||||||
@@ -83,9 +21,6 @@
|
|||||||
</select>
|
</select>
|
||||||
<span class="reference-sep text-light">:</span>
|
<span class="reference-sep text-light">:</span>
|
||||||
<label class="visually-hidden" for="reference-verse">Verse</label>
|
<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"
|
<select class="reference-verse form-select form-select-sm"
|
||||||
id="reference-verse" name="at"
|
id="reference-verse" name="at"
|
||||||
hx-get="api/reference" hx-target="this" hx-swap="innerHTML">
|
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 %}
|
{% if oob %}<div hx-swap-oob="beforeend:#toasts">{% endif %}
|
||||||
<div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}"
|
<div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}"
|
||||||
role="alert" aria-live="assertive" aria-atomic="true"
|
role="alert" aria-live="assertive" aria-atomic="true"
|
||||||
@@ -31,9 +5,6 @@
|
|||||||
hx-on:hidden.bs.toast="this.remove();">
|
hx-on:hidden.bs.toast="this.remove();">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="toast-body">{{ text }}</div>
|
<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"
|
<button type="button"
|
||||||
class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto"
|
class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto"
|
||||||
data-bs-dismiss="toast" aria-label="Close"></button>
|
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">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
|
<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"
|
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||||
@@ -30,27 +9,10 @@
|
|||||||
type="text" readonly value="{{ verse.url }}">
|
type="text" readonly value="{{ verse.url }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<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"
|
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
|
||||||
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
|
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
|
||||||
<input type="hidden" name="ref" value="{{ verse.ref }}">
|
<input type="hidden" name="ref" value="{{ verse.ref }}">
|
||||||
<input type="hidden" name="href" value="{{ verse.href }}">
|
<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"
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
hx-on:click="var button = this;
|
hx-on:click="var button = this;
|
||||||
var field = document.getElementById('verse-dialog-url');
|
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 -%}
|
{% for verse in reference.verses -%}
|
||||||
<option value="{{ verse.id }}"
|
<option value="{{ verse.id }}"
|
||||||
{%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option>
|
{%- 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.
|
<div class="app-version text-center mt-5 mb-2">
|
||||||
|
|
||||||
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">
|
|
||||||
<p class="app-version-digests form-text m-0">
|
<p class="app-version-digests form-text m-0">
|
||||||
shell {{ version.shell }} · library {{ version.library }}
|
shell {{ version.shell }} · library {{ version.library }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,42 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
"""Static file server for the Bible site."""
|
||||||
"""Static file server for the Bible site.
|
|
||||||
|
|
||||||
Stdlib only, so the container needs nothing but a Python base image.
|
|
||||||
|
|
||||||
It serves two directories as one origin: assets/, which is hand-written, and
|
|
||||||
bible/, which utils/compile_bible.py generates from source/. Nothing is copied
|
|
||||||
between them -- every file exists once, in the directory that owns it.
|
|
||||||
|
|
||||||
Beyond serving files it has three jobs.
|
|
||||||
|
|
||||||
The first is the boot page. The pages here are fragments and the shell around
|
|
||||||
them is a template, so a browser asking for one directly would get a bare
|
|
||||||
fragment with no chrome. Navigations are answered with boot.html instead, which
|
|
||||||
registers the service worker and reloads once it is running -- landing on the
|
|
||||||
real app at the address that was asked for. Requests the worker and htmx make
|
|
||||||
are ordinary fetches rather than navigations, so they pass straight through and
|
|
||||||
get the fragment. Once a worker is installed it intercepts the navigation
|
|
||||||
first and composes it there, and this page is not reached at all: it is seen
|
|
||||||
once per device.
|
|
||||||
|
|
||||||
The second is stamping the service worker. A browser only notices a new worker
|
|
||||||
when the bytes of sw.js change, so something has to record what is on disk.
|
|
||||||
Doing it here rather than in a build step means editing a template is the whole
|
|
||||||
job: save the file, reload, and the version the browser fetches already
|
|
||||||
differs. It also stamps in the list of pages the worker downloads to keep the
|
|
||||||
whole corpus available offline. See stamp_worker().
|
|
||||||
|
|
||||||
The third is cache headers: the worker owns the offline copy, and if the
|
|
||||||
worker or the boot page that registers it are served stale, a new version can
|
|
||||||
never reach a device that already installed the old one.
|
|
||||||
|
|
||||||
Expects to sit behind something that terminates TLS, and does not work without
|
|
||||||
it. Every page is composed by the service worker, browsers only register one in
|
|
||||||
a secure context, and boot.html has no fallback that builds a page without one
|
|
||||||
-- so over plain http:// from a LAN address the boot page says it could not
|
|
||||||
install and stops there. localhost is a secure context, which is why running
|
|
||||||
this directly for development still works.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -51,31 +13,18 @@ import sys
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
# Revalidate every time: these are what carry an update to an installed app.
|
|
||||||
NO_CACHE_NAMES = {"sw.js"}
|
NO_CACHE_NAMES = {"sw.js"}
|
||||||
# .njk is in here because the worker reads templates at runtime: a stale one
|
|
||||||
# would outlive the version that replaced it. .json is verses.json, which
|
|
||||||
# compile_bible.py generates beside the pages and which describes them, so it
|
|
||||||
# goes stale exactly when they do.
|
|
||||||
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
|
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
|
||||||
|
|
||||||
# Served in place of any page a browser navigates to.
|
|
||||||
BOOT_PAGE = "/boot.html"
|
BOOT_PAGE = "/boot.html"
|
||||||
|
|
||||||
WORKER = "sw.js"
|
WORKER = "sw.js"
|
||||||
|
|
||||||
# The three lines stamp_worker() rewrites. They are real statements rather than
|
|
||||||
# placeholders so that assets/sw.js stays valid JavaScript on its own.
|
|
||||||
VERSION_LINE = "const VERSION = 'dev';"
|
VERSION_LINE = "const VERSION = 'dev';"
|
||||||
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
|
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
|
||||||
LIBRARY_LINE = "const LIBRARY = [];"
|
LIBRARY_LINE = "const LIBRARY = [];"
|
||||||
|
|
||||||
|
|
||||||
def walk(roots):
|
def walk(roots):
|
||||||
"""Every served file, as url-relative paths mapped to their disk path.
|
|
||||||
|
|
||||||
Earlier roots win, which is what makes the two directories one origin.
|
|
||||||
"""
|
|
||||||
found = {}
|
found = {}
|
||||||
for root in roots:
|
for root in roots:
|
||||||
for dirpath, _dirs, names in os.walk(root):
|
for dirpath, _dirs, names in os.walk(root):
|
||||||
@@ -85,54 +34,23 @@ def walk(roots):
|
|||||||
found.setdefault(rel, path)
|
found.setdefault(rel, path)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def digest(files):
|
def digest(files):
|
||||||
"""A short fingerprint of a set of files, by name, size and mtime.
|
|
||||||
|
|
||||||
Not by contents: it has to change whenever anything changes, and it is
|
|
||||||
recomputed on every request for the worker, so reading 9 MB of book
|
|
||||||
fragments to hash them would be a poor trade. Touching a file without
|
|
||||||
changing it costs one needless refetch, which is harmless.
|
|
||||||
"""
|
|
||||||
out = hashlib.sha256()
|
out = hashlib.sha256()
|
||||||
for rel in sorted(files):
|
for rel in sorted(files):
|
||||||
st = os.stat(files[rel])
|
st = os.stat(files[rel])
|
||||||
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
|
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
|
||||||
return out.hexdigest()[:12]
|
return out.hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
def page_url(rel):
|
def page_url(rel):
|
||||||
"""The address a served file answers to.
|
|
||||||
|
|
||||||
bible/GEN.html is ./GEN, and bible/index.html is the root itself -- there
|
|
||||||
is no /index, the book list lives at /.
|
|
||||||
"""
|
|
||||||
if rel == "index.html":
|
if rel == "index.html":
|
||||||
return "./"
|
return "./"
|
||||||
return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel)
|
return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel)
|
||||||
|
|
||||||
|
|
||||||
def stamp_worker(roots):
|
def stamp_worker(roots):
|
||||||
"""assets/sw.js with its two versions and its file list filled in.
|
|
||||||
|
|
||||||
The split is by root, which is the same split the project already makes:
|
|
||||||
the first root is the hand-written chrome -- the worker, the boot page, and
|
|
||||||
the css/, js/ and templates/ directories -- and the rest is what
|
|
||||||
compile_bible.py generates. They get separate versions because they change on completely
|
|
||||||
different schedules -- editing the shell should not cost every installed
|
|
||||||
device the whole corpus again, which one shared version would.
|
|
||||||
|
|
||||||
LIBRARY is the list the worker downloads in the background. Stamped rather
|
|
||||||
than written by hand for the same reason the versions are: what is on disk
|
|
||||||
is the only honest source, and there is no build step to ask. It is written
|
|
||||||
in the URL space, not the disk one -- bible/GEN.html is "./GEN" and the
|
|
||||||
book list is "./" -- so that the worker caches a page under the address the
|
|
||||||
reader actually asks for.
|
|
||||||
"""
|
|
||||||
chrome = walk(roots[:1])
|
chrome = walk(roots[:1])
|
||||||
library = {rel: path for rel, path in walk(roots).items() if rel not in chrome}
|
library = {rel: path for rel, path in walk(roots).items() if rel not in chrome}
|
||||||
|
|
||||||
src = chrome.pop(WORKER, None) # the browser revalidates the worker itself
|
src = chrome.pop(WORKER, None)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
with open(src, encoding="utf-8") as fh:
|
with open(src, encoding="utf-8") as fh:
|
||||||
@@ -149,7 +67,6 @@ def stamp_worker(roots):
|
|||||||
body = body.replace(line, value, 1)
|
body = body.replace(line, value, 1)
|
||||||
return body.encode("utf-8")
|
return body.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
extensions_map = {
|
extensions_map = {
|
||||||
**SimpleHTTPRequestHandler.extensions_map,
|
**SimpleHTTPRequestHandler.extensions_map,
|
||||||
@@ -157,7 +74,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
".css": "text/css; charset=utf-8",
|
".css": "text/css; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
".json": "application/json; charset=utf-8",
|
".json": "application/json; charset=utf-8",
|
||||||
# Only ever fetched as text, by the renderer.
|
|
||||||
".njk": "text/plain; charset=utf-8",
|
".njk": "text/plain; charset=utf-8",
|
||||||
".webmanifest": "application/manifest+json; charset=utf-8",
|
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
@@ -168,20 +84,11 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
roots = ()
|
roots = ()
|
||||||
|
|
||||||
def translate_path(self, path):
|
def translate_path(self, path):
|
||||||
"""Resolve against each root in turn, first hit wins.
|
|
||||||
|
|
||||||
The parent does the sanitising -- it is what keeps ".." from escaping
|
|
||||||
the root -- so this only ever swaps which root it resolves against.
|
|
||||||
"""
|
|
||||||
for root in self.roots:
|
for root in self.roots:
|
||||||
self.directory = root
|
self.directory = root
|
||||||
candidate = super().translate_path(path)
|
candidate = super().translate_path(path)
|
||||||
if os.path.isfile(candidate):
|
if os.path.isfile(candidate):
|
||||||
return candidate
|
return candidate
|
||||||
# Pages are addressed without an extension -- /GEN, not /GEN.html.
|
|
||||||
# The worker's router owns a clean URL space and the file on disk
|
|
||||||
# stays an ordinary fragment; this is the one line that ties the
|
|
||||||
# two together.
|
|
||||||
if os.path.isfile(candidate + ".html"):
|
if os.path.isfile(candidate + ".html"):
|
||||||
return candidate + ".html"
|
return candidate + ".html"
|
||||||
if os.path.isfile(os.path.join(candidate, "index.html")):
|
if os.path.isfile(os.path.join(candidate, "index.html")):
|
||||||
@@ -190,12 +97,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
return super().translate_path(path)
|
return super().translate_path(path)
|
||||||
|
|
||||||
def send_head(self):
|
def send_head(self):
|
||||||
# Sec-Fetch-Mode is the one signal that separates a browser opening a
|
|
||||||
# URL from the worker or htmx fetching a fragment: neither of those is
|
|
||||||
# ever in navigate mode. A browser too old to send it gets the bare
|
|
||||||
# fragment, unstyled and without chrome -- readable, but not the app.
|
|
||||||
# Guessing from Accept and HX-Request instead would misroute one of
|
|
||||||
# htmx's own requests the first time it guessed wrong, which is worse.
|
|
||||||
if self.headers.get("Sec-Fetch-Mode") == "navigate":
|
if self.headers.get("Sec-Fetch-Mode") == "navigate":
|
||||||
self.path = BOOT_PAGE
|
self.path = BOOT_PAGE
|
||||||
|
|
||||||
@@ -206,8 +107,8 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
def send_worker(self):
|
def send_worker(self):
|
||||||
try:
|
try:
|
||||||
body = stamp_worker(self.roots)
|
body = stamp_worker(self.roots)
|
||||||
except Exception as err: # a broken stamp is a 500, not a
|
except Exception as err:
|
||||||
self.log_message("stamp failed: %s", err) # silently stale worker
|
self.log_message("stamp failed: %s", err)
|
||||||
self.send_error(500, "service worker unavailable")
|
self.send_error(500, "service worker unavailable")
|
||||||
return None
|
return None
|
||||||
if body is None:
|
if body is None:
|
||||||
@@ -223,24 +124,13 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
path = self.path.split("?", 1)[0]
|
path = self.path.split("?", 1)[0]
|
||||||
name = posixpath.basename(path)
|
name = posixpath.basename(path)
|
||||||
ext = posixpath.splitext(name)[1].lower()
|
ext = posixpath.splitext(name)[1].lower()
|
||||||
# A dot in the last segment means a file; anything else is a page. The
|
# Pages have no extension, so testing the extension alone treats them
|
||||||
# same test the worker's isFile() makes, and it has to stay the same
|
# as files and caches them for a week.
|
||||||
# one. Matching on the extension alone was right while pages were
|
|
||||||
# /GEN.html; the moment the URL space went extensionless it silently
|
|
||||||
# handed every book a week of browser caching that no rebuild could
|
|
||||||
# get past -- the worker's own fetch reads the browser cache too, so
|
|
||||||
# even a fresh library cache was being filled from a stale one.
|
|
||||||
if name in NO_CACHE_NAMES or ext in NO_CACHE_EXTS or "." not in name:
|
if name in NO_CACHE_NAMES or ext in NO_CACHE_EXTS or "." not in name:
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Cache-Control", "no-cache")
|
||||||
else:
|
else:
|
||||||
self.send_header("Cache-Control", "public, max-age=604800")
|
self.send_header("Cache-Control", "public, max-age=604800")
|
||||||
# One URL, two bodies: a navigation to /GEN gets the boot page and a
|
|
||||||
# fetch of it gets the fragment. Without this the browser cache keeps
|
|
||||||
# one entry for both, and the worker asking for the fragment it is
|
|
||||||
# about to compose is handed back the boot page instead -- which is a
|
|
||||||
# document nested inside a document, or a spinner where the text goes.
|
|
||||||
self.send_header("Vary", "Sec-Fetch-Mode")
|
self.send_header("Vary", "Sec-Fetch-Mode")
|
||||||
# Lets the worker claim the whole origin even if it moves off root.
|
|
||||||
self.send_header("Service-Worker-Allowed", "/")
|
self.send_header("Service-Worker-Allowed", "/")
|
||||||
self.send_header("X-Content-Type-Options", "nosniff")
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
super().end_headers()
|
super().end_headers()
|
||||||
@@ -249,7 +139,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args))
|
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
@@ -285,6 +174,5 @@ def main(argv=None):
|
|||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
+42
-265
@@ -1,44 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
"""Compile the eBible.org HTML export into the fragments the app serves."""
|
||||||
"""Compile the eBible.org HTML export into the fragments the app serves.
|
|
||||||
|
|
||||||
One step, source to output. It reads a directory of eBible .htm files -- a flat
|
|
||||||
set of per-chapter pages, ``JHN03.htm``, plus a site index -- and writes
|
|
||||||
bible/GEN.html and so on, plus bible/index.html.
|
|
||||||
|
|
||||||
There used to be two scripts and a 15 MB bible.json between them. The JSON was
|
|
||||||
worth having when the corpus was going to be queried; nothing queries it, so
|
|
||||||
it was a second representation to keep in step for no one's benefit. The parse
|
|
||||||
tree now lives for as long as one process run.
|
|
||||||
|
|
||||||
The two halves are still clearly separated below, because they are genuinely
|
|
||||||
different jobs and the seam is where a second output format would attach:
|
|
||||||
|
|
||||||
READING eBible's generated markup is a flat sequence of
|
|
||||||
``<div class='...'>`` blocks whose class is the USFM paragraph
|
|
||||||
marker (``p``, ``q2``, ``s``, ...), with verse numbers, footnote
|
|
||||||
markers and character styles as inline spans. This half turns
|
|
||||||
that into a tree of blocks and inline nodes. Navigation and the
|
|
||||||
copyright footer are dropped; footnotes are kept.
|
|
||||||
|
|
||||||
WRITING That tree becomes one semantic fragment per book: a <title> and
|
|
||||||
one root element carrying data-template, not a document. The
|
|
||||||
shell around it is assembled from the nunjucks templates in
|
|
||||||
assets/templates/ by the service worker, so chrome changes never
|
|
||||||
require running this. The markup carries structure and no presentation: every
|
|
||||||
USFM distinction survives as a class or data attribute, so the
|
|
||||||
whole corpus can be restyled from the stylesheet without being
|
|
||||||
regenerated.
|
|
||||||
|
|
||||||
This is the only build step in the project, and bible/ is all it owns.
|
|
||||||
Its output is not committed: bible/ is in .gitignore, so a deploy runs this.
|
|
||||||
Everything hand-written lives in assets/ and is served straight from there.
|
|
||||||
|
|
||||||
Grouping worth knowing about: consecutive poetry lines are wrapped in a
|
|
||||||
<div class="poetry"> that a blank-line block closes, so stanzas are real
|
|
||||||
elements rather than spacing; consecutive list items become one <ul>.
|
|
||||||
|
|
||||||
python3 utils/compile_bible.py source -o bible
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -50,70 +10,54 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# READING -- eBible .htm into a tree of blocks and inline nodes
|
|
||||||
# ==========================================================================
|
|
||||||
|
|
||||||
# Chapter files are a three-character book code plus a zero-padded chapter
|
|
||||||
# number (three digits for Psalms).
|
|
||||||
CHAPTER_RE = re.compile(r"^([0-9A-Z]{3})(\d{2,3})\.htm$")
|
CHAPTER_RE = re.compile(r"^([0-9A-Z]{3})(\d{2,3})\.htm$")
|
||||||
|
|
||||||
# Links between chapters: "JOB05.htm#V13", "JHN03.htm", or a bare "#V7".
|
|
||||||
HREF_RE = re.compile(r"^(?:([0-9A-Z]{3})(\d{2,3})\.htm)?(?:#V(\d+))?$")
|
HREF_RE = re.compile(r"^(?:([0-9A-Z]{3})(\d{2,3})\.htm)?(?:#V(\d+))?$")
|
||||||
|
|
||||||
# Block-level div classes -> semantic block type.
|
|
||||||
BLOCK_TYPES = {
|
BLOCK_TYPES = {
|
||||||
"p": "paragraph", # normal prose paragraph
|
"p": "paragraph",
|
||||||
"m": "paragraph", # continuation, no first-line indent
|
"m": "paragraph",
|
||||||
"nb": "paragraph", # no break with previous
|
"nb": "paragraph",
|
||||||
"pi": "paragraph", # indented
|
"pi": "paragraph",
|
||||||
"mi": "paragraph", # indented, no first-line indent
|
"mi": "paragraph",
|
||||||
"pc": "paragraph", # centered
|
"pc": "paragraph",
|
||||||
"ip": "paragraph", # introduction paragraph
|
"ip": "paragraph",
|
||||||
"q": "line", # poetry line
|
"q": "line",
|
||||||
"q2": "line",
|
"q2": "line",
|
||||||
"q3": "line",
|
"q3": "line",
|
||||||
"qs": "line", # Selah
|
"qs": "line",
|
||||||
"b": "break", # blank line between stanzas
|
"b": "break",
|
||||||
"d": "title", # descriptive title (psalm superscription, acrostic letter)
|
"d": "title",
|
||||||
"s": "heading", # section heading
|
"s": "heading",
|
||||||
"ms": "heading", # major section heading ("BOOK 3")
|
"ms": "heading",
|
||||||
"is": "heading", # introduction heading
|
"is": "heading",
|
||||||
"sp": "speaker", # speaker label (Song of Solomon)
|
"sp": "speaker",
|
||||||
"li": "item", # list item
|
"li": "item",
|
||||||
"ili": "item", # introduction list item (glossary entries)
|
"ili": "item",
|
||||||
"mt": "booktitle",
|
"mt": "booktitle",
|
||||||
"mt2": "booktitle",
|
"mt2": "booktitle",
|
||||||
"mt3": "booktitle",
|
"mt3": "booktitle",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Nesting depth for poetry lines and indented paragraphs.
|
|
||||||
INDENT = {"q": 1, "q2": 2, "q3": 3, "qs": 3, "pi": 1, "mi": 1, "li": 1, "ili": 1}
|
INDENT = {"q": 1, "q2": 2, "q3": 3, "qs": 3, "pi": 1, "mi": 1, "li": 1, "ili": 1}
|
||||||
|
|
||||||
# Heading / title rank.
|
|
||||||
LEVEL = {"mt": 1, "mt2": 2, "mt3": 3, "ms": 1, "s": 2, "is": 2}
|
LEVEL = {"mt": 1, "mt2": 2, "mt3": 3, "ms": 1, "s": 2, "is": 2}
|
||||||
|
|
||||||
# Character-style spans -> inline node type. ``None`` means unwrap the span and
|
|
||||||
# keep its children inline (``ft`` is just "footnote body text").
|
|
||||||
INLINE = {
|
INLINE = {
|
||||||
"wj": "wj", # words of Jesus
|
"wj": "wj",
|
||||||
"k": "keyword", # glossary keyword
|
"k": "keyword",
|
||||||
"bk": "bookref", # book name cited in a note
|
"bk": "bookref",
|
||||||
"fq": "fq", # the quoted words the footnote is about
|
"fq": "fq",
|
||||||
"fqa": "fqa", # an alternative rendering
|
"fqa": "fqa",
|
||||||
"fl": "fl", # footnote label
|
"fl": "fl",
|
||||||
"fine": "fine",
|
"fine": "fine",
|
||||||
"xt": "xt", # cross-reference target
|
"xt": "xt",
|
||||||
"ft": None,
|
"ft": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Divs whose entire subtree is navigation or licensing, not content.
|
|
||||||
DROP_CLASSES = {"copyright", "tnav", "vnav", "popup", "toc", "toc1", "toc2"}
|
DROP_CLASSES = {"copyright", "tnav", "vnav", "popup", "toc", "toc1", "toc2"}
|
||||||
|
|
||||||
|
|
||||||
def parse_href(href):
|
def parse_href(href):
|
||||||
"""Turn an intra-corpus link into a structured reference, or None."""
|
|
||||||
m = HREF_RE.match(href or "")
|
m = HREF_RE.match(href or "")
|
||||||
if not m or not any(m.groups()):
|
if not m or not any(m.groups()):
|
||||||
return None
|
return None
|
||||||
@@ -126,18 +70,8 @@ def parse_href(href):
|
|||||||
ref["verse"] = int(verse)
|
ref["verse"] = int(verse)
|
||||||
return ref or None
|
return ref or None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Whitespace
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def normalize(nodes):
|
def normalize(nodes):
|
||||||
"""Collapse whitespace across the whole inline tree, in document order.
|
state = {"prev_space": True}
|
||||||
|
|
||||||
Runs of whitespace become a single space and spaces are not duplicated
|
|
||||||
across element boundaries, so "word</span> <span>next" keeps exactly one.
|
|
||||||
"""
|
|
||||||
state = {"prev_space": True} # True at the start suppresses a leading space
|
|
||||||
|
|
||||||
def walk(lst):
|
def walk(lst):
|
||||||
out = []
|
out = []
|
||||||
@@ -162,15 +96,7 @@ def normalize(nodes):
|
|||||||
rstrip_last(result)
|
rstrip_last(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def rstrip_last(nodes):
|
def rstrip_last(nodes):
|
||||||
"""Drop the trailing space on a tree's final text node.
|
|
||||||
|
|
||||||
Only a genuinely trailing node is stripped. A space before a block's
|
|
||||||
closing verse or note marker separates it from the preceding word and
|
|
||||||
has to survive, so the walk stops at the last node rather than looking
|
|
||||||
past markers for something to strip.
|
|
||||||
"""
|
|
||||||
if not nodes:
|
if not nodes:
|
||||||
return False
|
return False
|
||||||
node = nodes[-1]
|
node = nodes[-1]
|
||||||
@@ -184,9 +110,7 @@ def rstrip_last(nodes):
|
|||||||
return rstrip_last(node["c"])
|
return rstrip_last(node["c"])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def plain_text(nodes, skip=("noteref",)):
|
def plain_text(nodes, skip=("noteref",)):
|
||||||
"""Flatten an inline tree to readable text, dropping note markers."""
|
|
||||||
parts = []
|
parts = []
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
if node["t"] in skip:
|
if node["t"] in skip:
|
||||||
@@ -199,34 +123,20 @@ def plain_text(nodes, skip=("noteref",)):
|
|||||||
parts.append(plain_text(node["c"], skip))
|
parts.append(plain_text(node["c"], skip))
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Chapter parser
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ChapterParser(HTMLParser):
|
class ChapterParser(HTMLParser):
|
||||||
"""Parse one chapter file into blocks + notes.
|
|
||||||
|
|
||||||
Content is appended to ``self.sink``, the inline-node list currently being
|
|
||||||
filled. A stack of frames records what to restore (and what to finish) as
|
|
||||||
each element closes; ``sink is None`` discards text, which is how
|
|
||||||
navigation and the copyright footer are dropped.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(convert_charrefs=True)
|
super().__init__(convert_charrefs=True)
|
||||||
self.blocks = []
|
self.blocks = []
|
||||||
self.notes = []
|
self.notes = []
|
||||||
self.titles = [] # mt/mt2/mt3, hoisted to the book
|
self.titles = []
|
||||||
self.chapter_label = None
|
self.chapter_label = None
|
||||||
self.is_toc = False # a chapter-0 navigation page
|
self.is_toc = False
|
||||||
|
|
||||||
self.in_main = False
|
self.in_main = False
|
||||||
self.stack = []
|
self.stack = []
|
||||||
self.sink = None
|
self.sink = None
|
||||||
|
|
||||||
# -- frame helpers ----------------------------------------------------
|
|
||||||
|
|
||||||
def _push(self, tag, sink, on_close=None, **extra):
|
def _push(self, tag, sink, on_close=None, **extra):
|
||||||
frame = {"tag": tag, "prev": self.sink, "close": on_close}
|
frame = {"tag": tag, "prev": self.sink, "close": on_close}
|
||||||
frame.update(extra)
|
frame.update(extra)
|
||||||
@@ -238,8 +148,6 @@ class ChapterParser(HTMLParser):
|
|||||||
if self.sink is not None:
|
if self.sink is not None:
|
||||||
self.sink.append(node)
|
self.sink.append(node)
|
||||||
|
|
||||||
# -- HTMLParser hooks -------------------------------------------------
|
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
attrs = dict(attrs)
|
attrs = dict(attrs)
|
||||||
classes = (attrs.get("class") or "").split()
|
classes = (attrs.get("class") or "").split()
|
||||||
@@ -251,7 +159,6 @@ class ChapterParser(HTMLParser):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self.in_main:
|
if not self.in_main:
|
||||||
# Everything before <div class="main"> is <head> and top nav.
|
|
||||||
if tag == "div" and "main" in classes:
|
if tag == "div" and "main" in classes:
|
||||||
self.in_main = True
|
self.in_main = True
|
||||||
self._push(tag, None)
|
self._push(tag, None)
|
||||||
@@ -295,14 +202,11 @@ class ChapterParser(HTMLParser):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if tag == "span" and cls == "notemark":
|
if tag == "span" and cls == "notemark":
|
||||||
# The note's own marker inside the footnote list.
|
|
||||||
buf = []
|
buf = []
|
||||||
self._push(tag, buf, self._close_note_marker, nodes=buf)
|
self._push(tag, buf, self._close_note_marker, nodes=buf)
|
||||||
return
|
return
|
||||||
|
|
||||||
if tag == "a" and cls == "notemark":
|
if tag == "a" and cls == "notemark":
|
||||||
# An in-text marker linking down to the note; its nested
|
|
||||||
# <span class="popup"> duplicates the note body and is dropped.
|
|
||||||
node = {"t": "noteref", "id": (attrs.get("href") or "").lstrip("#")}
|
node = {"t": "noteref", "id": (attrs.get("href") or "").lstrip("#")}
|
||||||
self._emit(node)
|
self._emit(node)
|
||||||
buf = []
|
buf = []
|
||||||
@@ -317,7 +221,7 @@ class ChapterParser(HTMLParser):
|
|||||||
if tag == "span" and cls in INLINE:
|
if tag == "span" and cls in INLINE:
|
||||||
kind = INLINE[cls]
|
kind = INLINE[cls]
|
||||||
if kind is None:
|
if kind is None:
|
||||||
self._push(tag, self.sink) # unwrap
|
self._push(tag, self.sink)
|
||||||
else:
|
else:
|
||||||
node = {"t": kind, "c": []}
|
node = {"t": kind, "c": []}
|
||||||
self._emit(node)
|
self._emit(node)
|
||||||
@@ -335,7 +239,6 @@ class ChapterParser(HTMLParser):
|
|||||||
self._push(tag, self.sink)
|
self._push(tag, self.sink)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Anything else (nested spans, stray markup) is transparent.
|
|
||||||
self._push(tag, self.sink)
|
self._push(tag, self.sink)
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
def handle_endtag(self, tag):
|
||||||
@@ -345,21 +248,19 @@ class ChapterParser(HTMLParser):
|
|||||||
if self.stack[i]["tag"] == tag:
|
if self.stack[i]["tag"] == tag:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
return # unmatched close tag; ignore
|
return
|
||||||
while len(self.stack) > i:
|
while len(self.stack) > i:
|
||||||
frame = self.stack.pop()
|
frame = self.stack.pop()
|
||||||
self.sink = frame["prev"]
|
self.sink = frame["prev"]
|
||||||
if frame["close"]:
|
if frame["close"]:
|
||||||
frame["close"](frame)
|
frame["close"](frame)
|
||||||
if tag == "div" and not self.stack:
|
if tag == "div" and not self.stack:
|
||||||
self.in_main = False # <div class="main"> closed
|
self.in_main = False
|
||||||
|
|
||||||
def handle_data(self, data):
|
def handle_data(self, data):
|
||||||
if self.sink is not None and data:
|
if self.sink is not None and data:
|
||||||
self.sink.append({"t": "text", "v": data})
|
self.sink.append({"t": "text", "v": data})
|
||||||
|
|
||||||
# -- frame close handlers ---------------------------------------------
|
|
||||||
|
|
||||||
def _close_chapter_label(self, frame):
|
def _close_chapter_label(self, frame):
|
||||||
self.chapter_label = plain_text(frame["nodes"]).strip()
|
self.chapter_label = plain_text(frame["nodes"]).strip()
|
||||||
|
|
||||||
@@ -417,20 +318,13 @@ class ChapterParser(HTMLParser):
|
|||||||
f["note"]["verse"] = int(m.group(1))
|
f["note"]["verse"] = int(m.group(1))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Corpus
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class IndexParser(HTMLParser):
|
class IndexParser(HTMLParser):
|
||||||
"""Read book order, display names and canon section from index.htm."""
|
|
||||||
|
|
||||||
# Link class on the site index marks which part of the canon a book is in.
|
|
||||||
SECTIONS = {"oo": "ot", "nn": "nt", "aa": "deuterocanon", "xx": "other"}
|
SECTIONS = {"oo": "ot", "nn": "nt", "aa": "deuterocanon", "xx": "other"}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(convert_charrefs=True)
|
super().__init__(convert_charrefs=True)
|
||||||
self.books = [] # [{code, name, section, order}]
|
self.books = []
|
||||||
self.title = None
|
self.title = None
|
||||||
self._link = None
|
self._link = None
|
||||||
self._in_title = False
|
self._in_title = False
|
||||||
@@ -464,7 +358,6 @@ class IndexParser(HTMLParser):
|
|||||||
elif self._link is not None:
|
elif self._link is not None:
|
||||||
self._link["name"] += data
|
self._link["name"] += data
|
||||||
|
|
||||||
|
|
||||||
def parse_chapter(path):
|
def parse_chapter(path):
|
||||||
parser = ChapterParser()
|
parser = ChapterParser()
|
||||||
with open(path, encoding="utf-8-sig") as fh:
|
with open(path, encoding="utf-8-sig") as fh:
|
||||||
@@ -472,7 +365,6 @@ def parse_chapter(path):
|
|||||||
parser.close()
|
parser.close()
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def extract(source_dir):
|
def extract(source_dir):
|
||||||
index_path = os.path.join(source_dir, "index.htm")
|
index_path = os.path.join(source_dir, "index.htm")
|
||||||
index = IndexParser()
|
index = IndexParser()
|
||||||
@@ -481,7 +373,6 @@ def extract(source_dir):
|
|||||||
|
|
||||||
meta = {b["code"]: b for b in index.books}
|
meta = {b["code"]: b for b in index.books}
|
||||||
|
|
||||||
# Group chapter files by book code.
|
|
||||||
by_book = {}
|
by_book = {}
|
||||||
for name in sorted(os.listdir(source_dir)):
|
for name in sorted(os.listdir(source_dir)):
|
||||||
m = CHAPTER_RE.match(name)
|
m = CHAPTER_RE.match(name)
|
||||||
@@ -503,7 +394,6 @@ def extract(source_dir):
|
|||||||
for number, name in sorted(by_book[code]):
|
for number, name in sorted(by_book[code]):
|
||||||
parsed = parse_chapter(os.path.join(source_dir, name))
|
parsed = parse_chapter(os.path.join(source_dir, name))
|
||||||
if parsed.is_toc or not parsed.blocks:
|
if parsed.is_toc or not parsed.blocks:
|
||||||
# Chapter-0 files are per-book tables of contents, not text.
|
|
||||||
skipped.append(name)
|
skipped.append(name)
|
||||||
continue
|
continue
|
||||||
if parsed.titles and not book["titles"]:
|
if parsed.titles and not book["titles"]:
|
||||||
@@ -528,26 +418,17 @@ def extract(source_dir):
|
|||||||
"books": books,
|
"books": books,
|
||||||
}, skipped
|
}, skipped
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# WRITING -- that tree into one HTML fragment per book
|
|
||||||
# ==========================================================================
|
|
||||||
|
|
||||||
# Inline node type -> (tag, class). ``cite`` and ``dfn`` carry real meaning
|
|
||||||
# here: a book name cited in a note, and a glossary term at its definition.
|
|
||||||
INLINE_TAGS = {
|
INLINE_TAGS = {
|
||||||
"wj": ("span", "wj"), # words of Jesus
|
"wj": ("span", "wj"),
|
||||||
"keyword": ("dfn", "k"), # glossary headword
|
"keyword": ("dfn", "k"),
|
||||||
"bookref": ("cite", "bk"), # book title cited in a note
|
"bookref": ("cite", "bk"),
|
||||||
"fq": ("span", "fq"), # the words a footnote is about
|
"fq": ("span", "fq"),
|
||||||
"fqa": ("span", "fqa"), # an alternative rendering
|
"fqa": ("span", "fqa"),
|
||||||
"fl": ("span", "fl"), # footnote label
|
"fl": ("span", "fl"),
|
||||||
"fine": ("span", "fine"),
|
"fine": ("span", "fine"),
|
||||||
"xt": ("span", "xt"), # cross-reference target
|
"xt": ("span", "xt"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Block type -> tag. Headings sit at h3 so the outline never skips a level:
|
|
||||||
# h1 book, h2 chapter, h3 section.
|
|
||||||
BLOCK_TAGS = {
|
BLOCK_TAGS = {
|
||||||
"paragraph": "p",
|
"paragraph": "p",
|
||||||
"line": "p",
|
"line": "p",
|
||||||
@@ -557,19 +438,13 @@ BLOCK_TAGS = {
|
|||||||
"item": "li",
|
"item": "li",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def esc(text):
|
def esc(text):
|
||||||
return html.escape(text, quote=False)
|
return html.escape(text, quote=False)
|
||||||
|
|
||||||
|
|
||||||
def verse_id(chapter, label):
|
def verse_id(chapter, label):
|
||||||
"""Page-unique verse anchor, e.g. v23_1 (or v11_15-16 for a range)."""
|
|
||||||
return "v%d_%s" % (chapter, label)
|
return "v%d_%s" % (chapter, label)
|
||||||
|
|
||||||
|
|
||||||
def ref_href(node, book, chapter):
|
def ref_href(node, book, chapter):
|
||||||
"""Link an intra-corpus reference; whole books are single pages."""
|
|
||||||
target_book = node.get("book") or book
|
target_book = node.get("book") or book
|
||||||
target_ch = node.get("chapter", chapter)
|
target_ch = node.get("chapter", chapter)
|
||||||
verse = node.get("verse")
|
verse = node.get("verse")
|
||||||
@@ -578,26 +453,12 @@ def ref_href(node, book, chapter):
|
|||||||
return "%s#%s" % (base, verse_id(target_ch, verse))
|
return "%s#%s" % (base, verse_id(target_ch, verse))
|
||||||
return "%s#ch%d" % (base, target_ch)
|
return "%s#ch%d" % (base, target_ch)
|
||||||
|
|
||||||
|
|
||||||
def render_inline(nodes, book, chapter, out):
|
def render_inline(nodes, book, chapter, out):
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
kind = node["t"]
|
kind = node["t"]
|
||||||
if kind == "text":
|
if kind == "text":
|
||||||
out.append(esc(node["v"]))
|
out.append(esc(node["v"]))
|
||||||
elif kind == "verse":
|
elif kind == "verse":
|
||||||
# Trailing space keeps the number off the text before any CSS
|
|
||||||
# lands; style .v with padding and it stays correct either way.
|
|
||||||
#
|
|
||||||
# A control, said here rather than by a pass over the page once it
|
|
||||||
# has landed. role is what announces it and what earns Reboot's
|
|
||||||
# [role=button] pointer cursor, tabindex is what puts it in the tab
|
|
||||||
# order, and the label is what a screen reader says in place of a
|
|
||||||
# bare number. All three are true of every verse in the corpus and
|
|
||||||
# none of them depends on anything only the browser knows, so
|
|
||||||
# there is nothing here for a script to work out: app.js used to
|
|
||||||
# set them on load, after every htmx swap and after every history
|
|
||||||
# restore, walking a whole book each time. The click itself never
|
|
||||||
# needed that pass -- it is delegated at #content in base.njk.
|
|
||||||
label = esc(node["label"])
|
label = esc(node["label"])
|
||||||
out.append('<span class="v" id="%s" role="button" tabindex="0" '
|
out.append('<span class="v" id="%s" role="button" tabindex="0" '
|
||||||
'aria-label="Verse %s, options">%s</span> '
|
'aria-label="Verse %s, options">%s</span> '
|
||||||
@@ -618,10 +479,9 @@ def render_inline(nodes, book, chapter, out):
|
|||||||
out.append('<%s class="%s">' % (tag, cls))
|
out.append('<%s class="%s">' % (tag, cls))
|
||||||
render_inline(node.get("c", []), book, chapter, out)
|
render_inline(node.get("c", []), book, chapter, out)
|
||||||
out.append("</%s>" % tag)
|
out.append("</%s>" % tag)
|
||||||
else: # unknown node type: keep the words, drop the wrapper
|
else:
|
||||||
render_inline(node.get("c", []), book, chapter, out)
|
render_inline(node.get("c", []), book, chapter, out)
|
||||||
|
|
||||||
|
|
||||||
def render_block(block, book, chapter, out):
|
def render_block(block, book, chapter, out):
|
||||||
btype = block["type"]
|
btype = block["type"]
|
||||||
tag = BLOCK_TAGS.get(btype, "p")
|
tag = BLOCK_TAGS.get(btype, "p")
|
||||||
@@ -635,23 +495,13 @@ def render_block(block, book, chapter, out):
|
|||||||
render_inline(block.get("content", []), book, chapter, out)
|
render_inline(block.get("content", []), book, chapter, out)
|
||||||
out.append("</%s>\n" % tag)
|
out.append("</%s>\n" % tag)
|
||||||
|
|
||||||
|
|
||||||
# Footnote callers arrive from the source as * † ‡ § ✡. The star is the only
|
|
||||||
# one of them a browser may decide is an emoji: no serif font carries a glyph
|
|
||||||
# for U+2721, so the fallback chain reaches the colour emoji font and drops a
|
|
||||||
# purple tile into the middle of a verse. U+FE0E is the standardised way to ask
|
|
||||||
# for the text glyph of the same character -- the symbol, not the emoji. There
|
|
||||||
# is no separate text codepoint to switch to; this is the only lever.
|
|
||||||
TEXT_PRESENTATION = "\ufe0e"
|
TEXT_PRESENTATION = "\ufe0e"
|
||||||
EMOJI_CAPABLE = "\u2721"
|
EMOJI_CAPABLE = "\u2721"
|
||||||
|
|
||||||
|
|
||||||
def caller(marker):
|
def caller(marker):
|
||||||
"""A footnote marker, asked to render as text rather than as an emoji."""
|
|
||||||
return "".join(c + TEXT_PRESENTATION if c in EMOJI_CAPABLE else c
|
return "".join(c + TEXT_PRESENTATION if c in EMOJI_CAPABLE else c
|
||||||
for c in (marker or "*"))
|
for c in (marker or "*"))
|
||||||
|
|
||||||
|
|
||||||
def render_notes(notes, chapter, book, out):
|
def render_notes(notes, chapter, book, out):
|
||||||
if not notes:
|
if not notes:
|
||||||
return
|
return
|
||||||
@@ -671,14 +521,13 @@ def render_notes(notes, chapter, book, out):
|
|||||||
out.append("</span></li>\n")
|
out.append("</span></li>\n")
|
||||||
out.append(" </ol>\n </section>\n")
|
out.append(" </ol>\n </section>\n")
|
||||||
|
|
||||||
|
|
||||||
def render_chapter(chapter, book, out):
|
def render_chapter(chapter, book, out):
|
||||||
num = chapter["number"]
|
num = chapter["number"]
|
||||||
out.append(' <section class="chapter" id="ch%d" data-chapter="%d">\n'
|
out.append(' <section class="chapter" id="ch%d" data-chapter="%d">\n'
|
||||||
% (num, num))
|
% (num, num))
|
||||||
out.append(' <h2 class="chapter-number">%d</h2>\n' % num)
|
out.append(' <h2 class="chapter-number">%d</h2>\n' % num)
|
||||||
|
|
||||||
group = None # "poetry" | "list" | None
|
group = None
|
||||||
|
|
||||||
def close_group():
|
def close_group():
|
||||||
nonlocal group
|
nonlocal group
|
||||||
@@ -691,7 +540,7 @@ def render_chapter(chapter, book, out):
|
|||||||
for block in chapter["blocks"]:
|
for block in chapter["blocks"]:
|
||||||
btype = block["type"]
|
btype = block["type"]
|
||||||
if btype == "break":
|
if btype == "break":
|
||||||
close_group() # a blank line ends the stanza
|
close_group()
|
||||||
continue
|
continue
|
||||||
want = {"line": "poetry", "item": "list"}.get(btype)
|
want = {"line": "poetry", "item": "list"}.get(btype)
|
||||||
if want != group:
|
if want != group:
|
||||||
@@ -707,41 +556,24 @@ def render_chapter(chapter, book, out):
|
|||||||
render_notes(chapter["notes"], num, book, out)
|
render_notes(chapter["notes"], num, book, out)
|
||||||
out.append(" </section>\n")
|
out.append(" </section>\n")
|
||||||
|
|
||||||
|
|
||||||
def render_book(book, translation, out_dir):
|
def render_book(book, translation, out_dir):
|
||||||
code, name = book["code"], book["name"]
|
code, name = book["code"], book["name"]
|
||||||
out = []
|
out = []
|
||||||
title = "%s — %s" % (name, translation["name"])
|
title = "%s — %s" % (name, translation["name"])
|
||||||
# A fragment: the title the shell will use, then one root element whose
|
|
||||||
# data-template names the template that wraps it.
|
|
||||||
out.append("<title>%s</title>\n" % esc(title))
|
out.append("<title>%s</title>\n" % esc(title))
|
||||||
out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
|
out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
|
||||||
'data-name="%s" data-section="%s">\n'
|
'data-name="%s" data-section="%s">\n'
|
||||||
% (code, code, esc(name), book["section"]))
|
% (code, code, esc(name), book["section"]))
|
||||||
|
|
||||||
# Header: the full multi-line title if the source had one.
|
|
||||||
out.append(' <header class="book-header">\n')
|
out.append(' <header class="book-header">\n')
|
||||||
out.append(" <h1>")
|
out.append(" <h1>")
|
||||||
if book["titles"]:
|
if book["titles"]:
|
||||||
# Separated by a space, not run together. The spans record what USFM
|
|
||||||
# called them -- mt2 and mt3 were lines above and below the main title
|
|
||||||
# -- but they are set as one line here, and without the space between
|
|
||||||
# them Genesis reads "The First Book of Moses,Commonly CalledGenesis".
|
|
||||||
# The markup still says what each part is, so stacking them again is a
|
|
||||||
# stylesheet away.
|
|
||||||
out.append(" ".join('<span class="%s">%s</span>'
|
out.append(" ".join('<span class="%s">%s</span>'
|
||||||
% (part["style"], esc(part["text"]))
|
% (part["style"], esc(part["text"]))
|
||||||
for part in book["titles"]))
|
for part in book["titles"]))
|
||||||
else:
|
else:
|
||||||
out.append(esc(name))
|
out.append(esc(name))
|
||||||
out.append("</h1>\n")
|
out.append("</h1>\n")
|
||||||
# The title and nothing else. Both of the things that used to sit under it
|
|
||||||
# are in the navbar now, where they are in reach from anywhere in the book
|
|
||||||
# rather than only from the top of it: the chapter list became the
|
|
||||||
# reference picker, which reads its chapters and verses straight off this
|
|
||||||
# markup, and "All books" is what the navbar brand already does. In Psalms
|
|
||||||
# the chapter list alone was a hundred and fifty links, carried in the
|
|
||||||
# fragment and in every device's offline copy.
|
|
||||||
out.append(" </header>\n")
|
out.append(" </header>\n")
|
||||||
|
|
||||||
out.append(' <div class="chapters">\n')
|
out.append(' <div class="chapters">\n')
|
||||||
@@ -758,7 +590,6 @@ def render_book(book, translation, out_dir):
|
|||||||
fh.write("".join(out))
|
fh.write("".join(out))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
SECTION_LABELS = [
|
SECTION_LABELS = [
|
||||||
("ot", "Old Testament"),
|
("ot", "Old Testament"),
|
||||||
("nt", "New Testament"),
|
("nt", "New Testament"),
|
||||||
@@ -766,7 +597,6 @@ SECTION_LABELS = [
|
|||||||
("other", "Other"),
|
("other", "Other"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def render_index(books, translation, out_dir):
|
def render_index(books, translation, out_dir):
|
||||||
out = ["<title>%s</title>\n" % esc(translation["name"]),
|
out = ["<title>%s</title>\n" % esc(translation["name"]),
|
||||||
'<div class="index" data-template="index">\n']
|
'<div class="index" data-template="index">\n']
|
||||||
@@ -789,48 +619,9 @@ def render_index(books, translation, out_dir):
|
|||||||
fh.write("".join(out))
|
fh.write("".join(out))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# WRITING -- the reference map
|
|
||||||
# ==========================================================================
|
|
||||||
|
|
||||||
# bible/verses.json: every book's chapters, and every chapter's verse labels.
|
|
||||||
#
|
|
||||||
# The navbar's reference picker is built from this, by the service worker. It
|
|
||||||
# used to be built in the browser by reading the book already on the page --
|
|
||||||
# the chapters were the sections and a chapter's verses were the anchors inside
|
|
||||||
# it -- which was free but only worked where there was a document to read. A
|
|
||||||
# worker has no DOM, and the picker now lives in a shell the worker composes
|
|
||||||
# before any document exists, so what the picker needs has to be a fact on
|
|
||||||
# disk rather than something recovered from markup.
|
|
||||||
#
|
|
||||||
# Written here, from the same tree the fragments are rendered from, so the two
|
|
||||||
# cannot disagree: every option this file produces names an anchor render_book
|
|
||||||
# wrote, because both come from the same verse nodes.
|
|
||||||
#
|
|
||||||
# The encoding is compact on purpose. A chapter whose verses run 1..n is
|
|
||||||
# written 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:
|
|
||||||
#
|
|
||||||
# {"GEN": [31, 25, 24, ...], "SIR": [..., ["1", ..., "19-27", "28", "29"]]}
|
|
||||||
#
|
|
||||||
# That is 6 KB for 38,000 verses against 180 KB of spelled-out labels, which is
|
|
||||||
# the difference between a file the worker can parse every time it wakes and
|
|
||||||
# one it would have to find somewhere to keep.
|
|
||||||
#
|
|
||||||
# Chapter numbers are not in the file at all: they are the array index plus
|
|
||||||
# one. render_verses checks that, rather than assuming it.
|
|
||||||
|
|
||||||
VERSES_FILE = "verses.json"
|
VERSES_FILE = "verses.json"
|
||||||
|
|
||||||
|
|
||||||
def chapter_verses(chapter):
|
def chapter_verses(chapter):
|
||||||
"""The verse labels of one chapter, in document order.
|
|
||||||
|
|
||||||
Blocks only. Footnotes hang inside the chapter section too, but a note's
|
|
||||||
content never carries a verse node -- and if one ever did, it would be a
|
|
||||||
citation rather than a place in the text.
|
|
||||||
"""
|
|
||||||
labels = []
|
labels = []
|
||||||
|
|
||||||
def walk(nodes):
|
def walk(nodes):
|
||||||
@@ -844,20 +635,12 @@ def chapter_verses(chapter):
|
|||||||
walk(block.get("content", []))
|
walk(block.get("content", []))
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
def render_verses(books, out_dir):
|
def render_verses(books, out_dir):
|
||||||
"""Write the reference map. Returns its path and the verses in it.
|
|
||||||
|
|
||||||
See the note above for the encoding.
|
|
||||||
"""
|
|
||||||
out = {}
|
out = {}
|
||||||
total = 0
|
total = 0
|
||||||
for book in books:
|
for book in books:
|
||||||
numbers = [c["number"] for c in book["chapters"]]
|
numbers = [c["number"] for c in book["chapters"]]
|
||||||
if numbers != list(range(1, len(numbers) + 1)):
|
if numbers != list(range(1, len(numbers) + 1)):
|
||||||
# The map addresses chapters by position, so a book that skips or
|
|
||||||
# repeats one would silently mislabel every option after the gap.
|
|
||||||
# Better to stop the build than to ship a picker that lies.
|
|
||||||
raise RuntimeError("%s: chapters are not 1..%d (%s)"
|
raise RuntimeError("%s: chapters are not 1..%d (%s)"
|
||||||
% (book["code"], len(numbers), numbers))
|
% (book["code"], len(numbers), numbers))
|
||||||
chapters = []
|
chapters = []
|
||||||
@@ -874,11 +657,6 @@ def render_verses(books, out_dir):
|
|||||||
fh.write("\n")
|
fh.write("\n")
|
||||||
return path, total
|
return path, total
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# Entry point
|
|
||||||
# ==========================================================================
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
@@ -912,6 +690,5 @@ def main(argv=None):
|
|||||||
print("wrote %s/" % args.out.rstrip("/"), file=sys.stderr)
|
print("wrote %s/" % args.out.rstrip("/"), file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user