Decent
This commit is contained in:
1 parent
77bf8d9f2e
commit
ea193e1264
14 files changed
+506
-308
No files matched your search
@@ -0,0 +1,68 @@
|
||||
/* The text layer. Bootstrap styles the chrome; this styles scripture.
|
||||
Every selector here is a class render_books.py already emits, so restyling
|
||||
the text never means regenerating books/. */
|
||||
|
||||
/* Serif everywhere, chrome included. Overriding Bootstrap's own variable
|
||||
rather than setting font-family on elements means the navbar, modal and
|
||||
buttons all follow, and a typeface is named in exactly one place. System
|
||||
fonts on purpose: a webfont would be one more thing to keep cached for
|
||||
offline, and one more request before the text can be read. */
|
||||
:root {
|
||||
--bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
|
||||
"Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* Verse numbers. The markup is a <span>, not a <sup>, so the raise happens
|
||||
here -- and line-height:0 keeps the raised number from opening up the line
|
||||
it sits on. Not selectable, so copying a passage copies the words. */
|
||||
.v {
|
||||
font-size: .7em;
|
||||
font-weight: 700;
|
||||
vertical-align: super;
|
||||
line-height: 0;
|
||||
margin-right: .15em;
|
||||
color: var(--bs-secondary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* app.js turns these into real buttons, so they need a visible focus ring. */
|
||||
.v:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Chapter numbers are <h2>, which at heading size shouts over the text. */
|
||||
.chapter-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
color: var(--bs-secondary-color);
|
||||
margin: 2rem 0 .75rem;
|
||||
}
|
||||
|
||||
/* Poetry lines are paragraphs, so left alone each one carries a paragraph's
|
||||
bottom margin and a psalm falls apart into separate blocks. Close the gap,
|
||||
indent by the level the generator recorded, and hang the indent so a line
|
||||
too long for the screen still reads as one line. */
|
||||
.poetry { margin-bottom: 1rem; }
|
||||
.poetry .line {
|
||||
margin: 0;
|
||||
padding-left: 1.5em;
|
||||
text-indent: -1.5em;
|
||||
}
|
||||
.poetry .line[data-indent="2"] { padding-left: 3em; }
|
||||
.poetry .line[data-indent="3"] { padding-left: 4.5em; }
|
||||
|
||||
/* Descriptive titles: "A Psalm by David". */
|
||||
.d {
|
||||
font-style: italic;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
/* The notes are apparatus, not scripture, and should not read as it. */
|
||||
.footnotes {
|
||||
font-size: .875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.footnotes-title { font-size: 1rem; }
|
||||
.marker { font-weight: 700; }
|
||||
+52
-12
@@ -1,7 +1,14 @@
|
||||
// Verse modal wiring. The modal markup lives in base.njk and the bookmark
|
||||
// lives in the service worker, so all this file does is decide which verse
|
||||
// was clicked, fill the form, and show it -- Bootstrap handles opening,
|
||||
// dismissing and focus, and htmx carries the save.
|
||||
// Page startup and verse modal wiring.
|
||||
//
|
||||
// shell.html is static and ships with an empty #content, so the first thing
|
||||
// every load does is fetch the fragment its URL names and swap it in. That is
|
||||
// the same request a boosted link makes, so there is one path into #content
|
||||
// and nothing special about a first visit.
|
||||
//
|
||||
// The rest is the modal: the markup lives in shell.html and the bookmark lives
|
||||
// in the service worker, so all this file does is decide which verse was
|
||||
// clicked, fill the form, and show it -- Bootstrap handles opening, dismissing
|
||||
// and focus, and htmx carries the save.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -65,7 +72,7 @@
|
||||
if (!el || !window.bootstrap) return;
|
||||
var info = verseInfo(span);
|
||||
currentUrl = info.url;
|
||||
el.querySelector('.verse-dialog-ref').textContent = info.ref;
|
||||
document.getElementById('verse-dialog-ref').textContent = info.ref;
|
||||
el.querySelector('input[name="ref"]').value = info.ref;
|
||||
el.querySelector('input[name="href"]').value = info.href;
|
||||
status('');
|
||||
@@ -121,13 +128,46 @@
|
||||
}
|
||||
});
|
||||
|
||||
markVerses(document);
|
||||
// -- startup ------------------------------------------------------------
|
||||
|
||||
// The shell already carries the bookmark, so this is only for the case where
|
||||
// the worker started controlling the page after it was rendered.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(function () {
|
||||
document.body.dispatchEvent(new CustomEvent('bookmark-load'));
|
||||
}).catch(function () {});
|
||||
function fragmentUrl() {
|
||||
return (location.pathname.split('/').pop() || 'index.html') + location.search;
|
||||
}
|
||||
|
||||
// The content arrives after the document has been parsed, so the browser has
|
||||
// long given up on the fragment identifier by the time the verse exists.
|
||||
// Doing it here is what makes a link to GEN.html#v1_1 land on the verse.
|
||||
function scrollToHash() {
|
||||
if (!location.hash) return;
|
||||
var el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||
if (el) el.scrollIntoView();
|
||||
}
|
||||
|
||||
// api/bookmark has no origin server -- it is answered by the worker -- so
|
||||
// there is nothing to ask until the worker is controlling this page.
|
||||
// controller, not ready: ready can resolve a tick before clients.claim()
|
||||
// takes effect, and a request sent in that gap reaches the network and 404s.
|
||||
function whenControlled(fn) {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (navigator.serviceWorker.controller) return fn();
|
||||
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
|
||||
}
|
||||
|
||||
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
|
||||
// and it lands after htmx has processed the shell.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
htmx.ajax('GET', fragmentUrl(), { target: '#content', swap: 'innerHTML' })
|
||||
.then(scrollToHash);
|
||||
|
||||
// Registered from the page it is going to serve, not from a page of its
|
||||
// own: there is nothing to wait for and nothing to reload. This load is
|
||||
// already the real app -- the worker installs alongside it, and takes over
|
||||
// for the bookmark, the offline download and every request from then on.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('sw.js').catch(function () {});
|
||||
}
|
||||
whenControlled(function () {
|
||||
document.body.dispatchEvent(new CustomEvent('worker-ready'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,3 +0,0 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block body_class %}reading{% endblock %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{# Returned on its own by GET/POST/DELETE api/bookmark, and included in the
|
||||
shell so a normal navigation paints the bookmark without a second request.
|
||||
Both paths must produce the same element for the swap to be seamless.
|
||||
|
||||
outerHTML is the swap, so this element re-declares its own htmx attributes
|
||||
every time: the replacement is what receives the next event. hx-target is
|
||||
inherited, and the shell points it at #content, so "this" is not the
|
||||
default here -- it has to be said.
|
||||
|
||||
Empty state is d-none, not the hidden attribute: Bootstrap declares
|
||||
.d-flex after Reboot's [hidden] rule, both !important and at the same
|
||||
specificity, so the utility would win. #}
|
||||
<span class="bookmark navbar-text {% if mark %}d-flex{% else %}d-none{% endif %} align-items-center gap-2"
|
||||
id="bookmark"
|
||||
hx-get="api/bookmark" hx-trigger="bookmark-load from:body"
|
||||
hx-target="this" hx-swap="outerHTML">
|
||||
{%- if mark %}
|
||||
<span class="bookmark-label badge text-bg-secondary">Bookmark</span>
|
||||
{# Not boosted: a real navigation lets the browser scroll to the verse
|
||||
anchor, which a swap into #content would not do. #}
|
||||
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
|
||||
hx-boost="false">{{ mark.ref }}</a>
|
||||
<button type="button" class="bookmark-clear btn btn-sm btn-outline-light py-0"
|
||||
hx-delete="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">Clear</button>
|
||||
{%- endif %}
|
||||
</span>
|
||||
@@ -1,60 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>World English Bible Updated</title>
|
||||
<link rel="stylesheet" href="bootstrap.min.css">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
</head>
|
||||
<body class="bg-body-tertiary">
|
||||
<!-- Pages are fragments now: the shell is assembled by the service worker, so
|
||||
a first visit has nothing to render until the worker is installed and
|
||||
controlling. This page installs it and reloads into the real app. -->
|
||||
<main class="boot container py-5 text-center">
|
||||
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
|
||||
<p id="boot-status" class="text-body-secondary">Preparing the offline copy…</p>
|
||||
<noscript><p class="alert alert-warning">This app assembles its pages in the browser and needs JavaScript.</p></noscript>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var status = document.getElementById('boot-status');
|
||||
function fail(text) { status.textContent = text; }
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
fail('This browser has no service worker support, which this app needs.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Guards against a reload loop if the worker activates but never claims:
|
||||
// better to sit on this page with a message than to spin.
|
||||
var tries = 0;
|
||||
try { tries = parseInt(sessionStorage.getItem('boot:tries') || '0', 10); }
|
||||
catch (e) { tries = 0; }
|
||||
if (tries >= 2) {
|
||||
fail('The offline copy could not start. Try clearing this site’s data.');
|
||||
return;
|
||||
}
|
||||
try { sessionStorage.setItem('boot:tries', String(tries + 1)); } catch (e) {}
|
||||
|
||||
function go() {
|
||||
try { sessionStorage.removeItem('boot:tries'); } catch (e) {}
|
||||
location.reload();
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register('sw.js').then(function () {
|
||||
// controller is what actually matters: ready can resolve a tick before
|
||||
// clients.claim() takes effect, and reloading then lands here again.
|
||||
if (navigator.serviceWorker.controller) return go();
|
||||
navigator.serviceWorker.addEventListener('controllerchange', go);
|
||||
setTimeout(function () {
|
||||
if (navigator.serviceWorker.controller) go();
|
||||
else fail('The offline copy is taking longer than expected. Reload to retry.');
|
||||
}, 10000);
|
||||
}, function (err) {
|
||||
fail('Could not install the offline copy: ' + err.message);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block body_class %}browsing{% endblock %}
|
||||
{# Already on the index, so the nav entry marks the current page instead of
|
||||
offering a link to it. #}
|
||||
{% block navbar_nav %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="index.html">All books</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
/*! Browser bundle of nunjucks 3.2.4 */
|
||||
!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.nunjucks=n():t.nunjucks=n()}("undefined"!=typeof self?self:this,function(){return function(t){var n={};function i(r){if(n[r])return n[r].exports;var e=n[r]={i:r,l:!1,exports:{}};return t[r].call(e.exports,e,e.exports,i),e.l=!0,e.exports}return i.m=t,i.c=n,i.d=function(t,n,r){i.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},i.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(n,"a",n),n},i.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},i.p="",i(i.s=11)}([function(t,n,i){"use strict";var r=Array.prototype,e=Object.prototype,s={"&":"&",'"':""","'":"'","<":"<",">":">","\\":"\"},o=/[&"'<>\\]/g;function u(t,n){return e.hasOwnProperty.call(t,n)}function h(t){return s[t]}function f(t,n,i){var r,e,s;if(t instanceof Error&&(t=(e=t).name+": "+e.message),Object.setPrototypeOf?Object.setPrototypeOf(r=Error(t),f.prototype):Object.defineProperty(r=this,"message",{enumerable:!1,writable:!0,value:t}),Object.defineProperty(r,"name",{value:"Template render error"}),Error.captureStackTrace&&Error.captureStackTrace(r,this.constructor),e){var o=Object.getOwnPropertyDescriptor(e,"stack");(s=o&&(o.get||function(){return o.value}))||(s=function(){return e.stack})}else{var u=Error(t).stack;s=function(){return u}}return Object.defineProperty(r,"stack",{get:function(){return s.call(r)}}),Object.defineProperty(r,"cause",{value:e}),r.lineno=n,r.colno=i,r.firstUpdate=!0,r.Update=function(t){var n="("+(t||"unknown path")+")";return this.firstUpdate&&(this.lineno&&this.colno?n+=" [Line "+this.lineno+", Column "+this.colno+"]":this.lineno&&(n+=" [Line "+this.lineno+"]")),n+="\n ",this.firstUpdate&&(n+=" "),this.message=n+(this.message||""),this.firstUpdate=!1,this},r}function c(t){return"[object Function]"===e.toString.call(t)}function a(t){return"[object Array]"===e.toString.call(t)}function l(t){return"[object String]"===e.toString.call(t)}function v(t){return"[object Object]"===e.toString.call(t)}function p(t){var n,i=(n=t)?"string"==typeof n?n.split("."):[n]:[];return function(t){for(var n=t,r=0;r<i.length;r++){var e=i[r];if(!u(n,e))return;n=n[e]}return n}}function d(t){return Array.prototype.slice.call(t)}function m(t,n,i){return Array.prototype.indexOf.call(t||[],n,i)}function w(t){var n=[];for(var i in t)u(t,i)&&n.push(i);return n}(n=t.exports={}).hasOwnProp=u,n.t=function(t,i,r){if(r.Update||(r=new n.TemplateError(r)),r.Update(t),!i){var e=r;(r=Error(e.message)).name=e.name}return r},Object.setPrototypeOf?Object.setPrototypeOf(f.prototype,Error.prototype):f.prototype=Object.create(Error.prototype,{constructor:{value:f}}),n.TemplateError=f,n.escape=function(t){return t.replace(o,h)},n.isFunction=c,n.isArray=a,n.isString=l,n.isObject=v,n.getAttrGetter=p,n.groupBy=function(t,n,i){for(var r={},e=c(n)?n:p(n),s=0;s<t.length;s++){var o=t[s],u=e(o,s);if(void 0===u&&!0===i)throw new TypeError('groupby: attribute "'+n+'" resolved to undefined');(r[u]||(r[u]=[])).push(o)}return r},n.toArray=d,n.without=function(t){var n=[];if(!t)return n;for(var i=t.length,r=d(arguments).slice(1),e=-1;++e<i;)-1===m(r,t[e])&&n.push(t[e]);return n},n.repeat=function(t,n){for(var i="",r=0;r<n;r++)i+=t;return i},n.each=function(t,n,i){if(null!=t)if(r.forEach&&t.forEach===r.forEach)t.forEach(n,i);else if(t.length===+t.length)for(var e=0,s=t.length;e<s;e++)n.call(i,t[e],e,t)},n.map=function(t,n){var i=[];if(null==t)return i;if(r.map&&t.map===r.map)return t.map(n);for(var e=0;e<t.length;e++)i[i.length]=n(t[e],e);return t.length===+t.length&&(i.length=t.length),i},n.asyncIter=function(t,n,i){var r=-1;!function e(){++r<t.length?n(t[r],r,e,i):i()}()},n.asyncFor=function(t,n,i){var r=w(t||{}),e=r.length,s=-1;!function o(){var u=r[++s];s<e?n(u,t[u],s,e,o):i()}()},n.indexOf=m,n.keys=w,n.r=function(t){return w(t).map(function(n){return[n,t[n]]})},n.u=function(t){return w(t).map(function(n){return t[n]})},n.h=n.extend=function(t,n){return t=t||{},w(n).forEach(function(i){t[i]=n[i]}),t},n.inOperator=function(t,n){if(a(n)||l(n))return-1!==n.indexOf(t);if(v(n))return t in n;throw Error('Cannot use "in" operator to search for "'+t+'" in unexpected types.')}},function(t,n,i){"use strict";function r(t,n){for(var i=0;i<n.length;i++){var r=n[i];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,(e=r.key,void 0,"symbol"==typeof(s=function(t,n){if("object"!=typeof t||null===t)return t;var i=t[Symbol.toPrimitive];if(void 0!==i){var r=i.call(t,n||"default");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}(e,"string"))?s:s+""),r)}var e,s}function e(t,n,i){return n&&r(t.prototype,n),i&&r(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t}function s(t,n){t.prototype=OLine truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
{# Fallback for a fragment that names no template of its own. #}
|
||||
{% extends "base.njk" %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>World English Bible Updated</title>
|
||||
|
||||
<!-- Vendored, not from a CDN: the worker only caches same-origin responses.
|
||||
app.css comes second so its variable overrides win. -->
|
||||
<link rel="stylesheet" href="bootstrap.min.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Bible">
|
||||
|
||||
<!-- A history entry is a whole book and htmx keeps its cache in localStorage,
|
||||
so the cache is off and back/forward is an ordinary page load instead. -->
|
||||
<meta name="htmx-config" content='{"historyCacheSize":0,"refreshOnHistoryMiss":true}'>
|
||||
|
||||
<!-- htmx is not deferred: hx-boost must be in place before the first click.
|
||||
Deferred scripts run in order, so bootstrap is defined before app.js. -->
|
||||
<script src="htmx.min.js"></script>
|
||||
<script src="bootstrap.bundle.min.js" defer></script>
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
|
||||
<!-- This one file is every page. The server answers any navigation with it,
|
||||
and app.js puts the fragment the URL names into #content. -->
|
||||
<body hx-boost="true" hx-target="#content" hx-swap="innerHTML">
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="index.html">Bible</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#site-nav" aria-controls="site-nav"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="site-nav">
|
||||
<!-- Empty until the worker answers api/bookmark. -->
|
||||
<span id="bookmark" class="d-none"
|
||||
hx-get="api/bookmark" hx-trigger="worker-ready from:body"
|
||||
hx-target="this" hx-swap="outerHTML"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main id="content" class="container my-4"></main>
|
||||
|
||||
<!-- Opened by app.js when a verse number is clicked. -->
|
||||
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||
aria-labelledby="verse-dialog-ref" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="verse-dialog-ref"></h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="verse-dialog-status" class="form-text m-0"
|
||||
role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<form class="d-flex gap-2 m-0"
|
||||
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
|
||||
<input type="hidden" name="ref" value="">
|
||||
<input type="hidden" name="href" value="">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
data-action="copy">Copy link</button>
|
||||
<button class="btn btn-primary" type="submit">Set bookmark</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript><p class="container my-4">This app builds its pages in the browser
|
||||
and needs JavaScript.</p></noscript>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
{# The modal's status line. One template for both paths: the shell renders it
|
||||
empty, and the bookmark API re-renders it with oob set so a single response
|
||||
updates the header slot and this line together. #}
|
||||
<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"
|
||||
role="status" aria-live="polite"{% if oob %} hx-swap-oob="true"{% endif %}>{{ text }}</p>
|
||||
+221
-132
@@ -1,52 +1,93 @@
|
||||
// The app's renderer, and its backend. Hand-written: this file is the source,
|
||||
// served as-is apart from the two lines server.py stamps (see below).
|
||||
// The app's offline copy, and its backend. Hand-written: this file is the
|
||||
// source, served as-is apart from the three lines server.py stamps (see below).
|
||||
//
|
||||
// The files on disk are fragments: a
|
||||
// <title> and one root element. The shell around them lives in base.njk and
|
||||
// is applied here, per request, with nunjucks -- so changing the chrome means
|
||||
// editing one template rather than rebuilding every book.
|
||||
// The files on disk are fragments: a title element and one root element. The shell
|
||||
// around it is shell.html, a static document with an empty #content. This
|
||||
// worker does not assemble anything -- it answers every navigation with that
|
||||
// one file, and app.js asks for the fragment the URL names. So there is a
|
||||
// single way a page is built, online or off, first visit or hundredth.
|
||||
//
|
||||
// It is also the app's backend. The bookmark lives in IndexedDB here, not in
|
||||
// localStorage on the page, and is read and written over ./api/bookmark by
|
||||
// htmx exactly as if it were a server.
|
||||
//
|
||||
// STRATEGY decides what a same-origin GET does:
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// cache-first answer from the cache the moment there is a hit. Fastest
|
||||
// possible load, but a rebuild needs two reloads to appear.
|
||||
//
|
||||
// Either way every response that arrives is written back to the cache, so the
|
||||
// offline copy stays current, and any change on disk changes VERSION, dropping
|
||||
// the caches belonging to older versions.
|
||||
// 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 this survives offline
|
||||
// without being in ASSETS.
|
||||
importScripts('./nunjucks.min.js');
|
||||
|
||||
const STRATEGY = 'network-first';
|
||||
const NETWORK_TIMEOUT_MS = 3000;
|
||||
|
||||
// VERSION and ASSETS are stamped in by server.py from the files actually on
|
||||
// disk, so a template edit reaches installed devices with no build step: the
|
||||
// worker's bytes change, the browser sees a new worker, and the old cache is
|
||||
// dropped on activate. The values below are the standalone fallback.
|
||||
// The background download gets a longer leash than a page the reader is
|
||||
// waiting on: nothing is blocked on it, and giving up early on a slow link
|
||||
// only means fetching the same book again later.
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
// Few enough to leave the connection room for the page being read, more than
|
||||
// one so a slow response does not stall the queue. The old code asked for
|
||||
// every file at once, which is what made a weak connection collapse.
|
||||
const DOWNLOAD_AT_ONCE = 4;
|
||||
|
||||
// All three are stamped in by server.py from the files actually on disk, so an
|
||||
// edit reaches installed devices with no build step: the worker's bytes
|
||||
// change, the browser sees a new worker, and stale caches go on activate. The
|
||||
// values below are the standalone fallback.
|
||||
//
|
||||
// The two versions are deliberately separate, over the two directories the
|
||||
// server puts together. VERSION covers the hand-written chrome in assets/, so
|
||||
// editing the shell drops and refetches a few hundred kilobytes. The other
|
||||
// covers the generated pages in books/, which change only when
|
||||
// render_books.py runs -- so a change of chrome no longer costs every device
|
||||
// the whole corpus again.
|
||||
const VERSION = 'dev';
|
||||
const CACHE = 'bible-' + VERSION;
|
||||
const ASSETS = ["./"];
|
||||
const LIBRARY_VERSION = 'dev';
|
||||
const LIBRARY = [];
|
||||
|
||||
const SHELL_CACHE = 'bible-shell-' + VERSION;
|
||||
const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION;
|
||||
|
||||
// Resolved once, and the set is the test for which cache a response belongs
|
||||
// in. Membership, not a path pattern: the split is whichever directory the
|
||||
// file came from, which is a fact only the server has.
|
||||
const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href));
|
||||
|
||||
// The shell and what it needs to paint. index.html 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.
|
||||
const SHELL = './shell.html';
|
||||
const PRECACHE = [
|
||||
SHELL,
|
||||
'./index.html',
|
||||
'./app.js',
|
||||
'./app.css',
|
||||
'./htmx.min.js',
|
||||
'./bootstrap.min.css',
|
||||
'./bootstrap.bundle.min.js',
|
||||
'./manifest.webmanifest'
|
||||
];
|
||||
|
||||
function cacheFor(req) {
|
||||
return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE);
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(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;
|
||||
await Promise.all(ASSETS.map((url) =>
|
||||
await Promise.all(PRECACHE.map((url) =>
|
||||
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
|
||||
if (failed) console.warn('precache: ' + failed + ' of ' + ASSETS.length + ' failed');
|
||||
if (failed) console.warn('precache: ' + failed + ' of ' + PRECACHE.length + ' failed');
|
||||
await self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
@@ -55,7 +96,7 @@ self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys
|
||||
.filter((k) => k.startsWith('bible-') && k !== CACHE)
|
||||
.filter((k) => k.startsWith('bible-') && k !== SHELL_CACHE && k !== LIBRARY_CACHE)
|
||||
.map((k) => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
@@ -66,15 +107,25 @@ self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// The background download rides along on whatever the page is already
|
||||
// asking for. It gets no event of its own: activate cannot be used, because
|
||||
// waitUntil there holds back activation and every fetch with it, and a
|
||||
// worker is only kept alive while an event of its own is pending. So each
|
||||
// request the reader makes is what carries the download forward.
|
||||
keepFilling(event);
|
||||
|
||||
// Checked before the method guard: the bookmark API is the one thing here
|
||||
// that answers POST and DELETE.
|
||||
// that answers POST.
|
||||
if (isBookmarkApi(url)) return event.respondWith(bookmarkApi(event));
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
// A top-level navigation is the only request that gets the shell wrapped
|
||||
// around it. htmx's own requests are not navigations, so they receive the
|
||||
// bare fragment and swap it into the shell that is already on screen.
|
||||
if (req.mode === 'navigate') return event.respondWith(compose(event));
|
||||
// Every navigation gets the shell, whatever path it asked for. One URL to
|
||||
// cache, and it answers for a book that has never been opened as readily as
|
||||
// for one that has -- what is missing is then the fragment, which is a
|
||||
// message inside the app rather than a browser error page.
|
||||
if (req.mode === 'navigate') return event.respondWith(answer(SHELL, event));
|
||||
|
||||
if (isPage(url)) return event.respondWith(page(req, event));
|
||||
|
||||
event.respondWith(answer(req, event));
|
||||
});
|
||||
@@ -83,9 +134,9 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
// Aborted rather than raced, so a hung request is actually torn down instead
|
||||
// of leaking while the cache answers.
|
||||
async function fromNetwork(req) {
|
||||
async function fromNetwork(req, timeout) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(req, { signal: controller.signal });
|
||||
} finally {
|
||||
@@ -97,27 +148,19 @@ function cached(req) {
|
||||
return caches.match(req, { ignoreSearch: true });
|
||||
}
|
||||
|
||||
// Everything the worker reads goes through here, templates included, so a
|
||||
// .njk file is cached and served on exactly the same terms as a book.
|
||||
//
|
||||
// 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 bootstrap page.
|
||||
// internal fetch from a browser one and hand back the file itself rather than
|
||||
// the shell.
|
||||
async function answer(input, event) {
|
||||
const req = typeof input === 'string' ? new Request(input) : input;
|
||||
|
||||
if (STRATEGY === 'cache-first') {
|
||||
const hit = await cached(req);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fromNetwork(req);
|
||||
if (res && res.ok && res.type === 'basic') {
|
||||
const copy = res.clone();
|
||||
// Not awaited: the response should not wait on the cache write.
|
||||
const write = caches.open(CACHE).then((c) => c.put(req, copy));
|
||||
const write = cacheFor(req).then((c) => c.put(req, copy));
|
||||
if (event) event.waitUntil(write);
|
||||
else write.catch(() => {});
|
||||
}
|
||||
@@ -129,45 +172,105 @@ async function answer(input, event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function readText(input, event) {
|
||||
const res = await answer(input, event);
|
||||
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function htmlResponse(body, status) {
|
||||
return new Response(body, {
|
||||
status: status || 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// Composed on the fly from parts that are themselves cached; storing
|
||||
// the result too would pin a copy of the old shell.
|
||||
// Built here from live state rather than fetched; storing it would pin
|
||||
// a stale copy over the real thing.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- templates
|
||||
// ------------------------------------------------------------------- pages
|
||||
|
||||
// Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist in
|
||||
// a service worker. This one reads through answer(), so templates are offline
|
||||
// on the same terms as everything else.
|
||||
function CacheLoader() {}
|
||||
CacheLoader.prototype.async = true;
|
||||
CacheLoader.prototype.getSource = function (name, callback) {
|
||||
readText('./' + name).then(
|
||||
(src) => callback(null, { src: src, path: name, noCache: false }),
|
||||
(err) => callback(err));
|
||||
};
|
||||
function isPage(url) {
|
||||
return url.pathname.endsWith('.html');
|
||||
}
|
||||
|
||||
// Compiled templates are held in memory for the worker's lifetime. A rebuild
|
||||
// changes VERSION, which starts a new worker, so this never goes stale.
|
||||
const env = new nunjucks.Environment(new CacheLoader(), { autoescape: true });
|
||||
// A page that cannot be delivered becomes a fragment saying so, in the same
|
||||
// shape as a real one: a title and one root element. htmx only swaps a 2xx
|
||||
// body, so this is deliberately a 200 -- an error status would leave the
|
||||
// reader looking at the page they came from with no explanation.
|
||||
function notice(heading, text) {
|
||||
return htmlResponse(
|
||||
'<title>' + heading + '</title>\n' +
|
||||
'<div class="notice">\n' +
|
||||
' <h1>' + heading + '</h1>\n' +
|
||||
' <p>' + text + '</p>\n' +
|
||||
' <p><a href="index.html">All books</a></p>\n' +
|
||||
'</div>');
|
||||
}
|
||||
|
||||
function render(name, context) {
|
||||
return new Promise((resolve, reject) => {
|
||||
env.render(name, context, (err, out) => (err ? reject(err) : resolve(out)));
|
||||
});
|
||||
async function page(req, event) {
|
||||
let res;
|
||||
try {
|
||||
res = await answer(req, event);
|
||||
} catch (err) {
|
||||
return notice('Not available offline',
|
||||
'This page has not been opened on this device, so there is no copy ' +
|
||||
'stored here. Reconnect and try again, or open one you have read before.');
|
||||
}
|
||||
if (res.ok) return res;
|
||||
return notice('Not found', 'There is no page at this address.');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- library
|
||||
|
||||
// The background download of the whole corpus.
|
||||
//
|
||||
// There is no progress to keep: the cache is the record. Each run asks it what
|
||||
// is already there, which is what makes this survive the worker being shut
|
||||
// down mid-run, resume on the next visit, and stay honest when a page arrives
|
||||
// some other way -- reading a book puts it in the same cache.
|
||||
let filling = null; // the run in flight, so two do not start at once
|
||||
let complete = false; // a run got through the whole list; stop looking
|
||||
|
||||
function keepFilling(event) {
|
||||
if (complete) return;
|
||||
// navigator.onLine is only ever trustworthy when it says false, which is the
|
||||
// direction that matters: it stops a doomed run being started, and retried,
|
||||
// on every request while the device is plainly offline.
|
||||
if (!filling && self.navigator.onLine !== false) {
|
||||
filling = fillLibrary().catch(() => {}).finally(() => { filling = null; });
|
||||
}
|
||||
if (filling) event.waitUntil(filling);
|
||||
}
|
||||
|
||||
async function fillLibrary() {
|
||||
const cache = await caches.open(LIBRARY_CACHE);
|
||||
const have = new Set((await cache.keys()).map((req) => req.url));
|
||||
const missing = [];
|
||||
LIBRARY_URLS.forEach((url) => { if (!have.has(url)) missing.push(url); });
|
||||
if (!missing.length) { complete = true; return; }
|
||||
|
||||
let stopped = false;
|
||||
const runners = [];
|
||||
for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run());
|
||||
await Promise.all(runners);
|
||||
// Only after a run that was never cut short: anything else leaves pages
|
||||
// outstanding, and the next request should look again.
|
||||
if (!stopped) complete = true;
|
||||
|
||||
async function run() {
|
||||
while (missing.length && !stopped) {
|
||||
const url = missing.pop();
|
||||
try {
|
||||
const res = await fromNetwork(new Request(url), DOWNLOAD_TIMEOUT_MS);
|
||||
// A response that arrived but is not usable -- a page that has since
|
||||
// been removed, say -- is skipped rather than retried. The connection
|
||||
// is plainly fine, and stopping for it would strand everything after.
|
||||
if (res.ok && res.type === 'basic') await cache.put(url, res);
|
||||
} catch (err) {
|
||||
// Stop the whole run rather than grinding through a hundred certain
|
||||
// failures. The next request starts a fresh one, so reconnecting is
|
||||
// all it takes to carry on.
|
||||
stopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bookmark
|
||||
@@ -176,6 +279,11 @@ const DB_NAME = 'bible';
|
||||
const DB_STORE = 'kv';
|
||||
const BOOKMARK_KEY = 'bookmark';
|
||||
|
||||
// There is no way to clear a bookmark, so there is no empty state: a reader
|
||||
// who has never set one is treated as being at the beginning. Everything
|
||||
// downstream -- the navbar slot and the API -- can assume a mark.
|
||||
const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN.html#v1_1' };
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
@@ -201,25 +309,51 @@ async function withStore(mode, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Storage being unavailable degrades to "no bookmark" rather than a broken
|
||||
// page, the same way the old localStorage code did.
|
||||
// Storage being unavailable degrades to the default rather than a broken page,
|
||||
// which is the same fallback as never having set one.
|
||||
function getBookmark() {
|
||||
return withStore('readonly', (s) => s.get(BOOKMARK_KEY)).catch(() => null);
|
||||
return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
|
||||
.catch(() => null)
|
||||
.then((mark) => mark || DEFAULT_BOOKMARK);
|
||||
}
|
||||
|
||||
function isBookmarkApi(url) {
|
||||
return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark');
|
||||
}
|
||||
|
||||
async function bookmarkFragment(status) {
|
||||
const mark = await getBookmark();
|
||||
let html = await render('bookmark.njk', { mark: mark || null });
|
||||
if (status) {
|
||||
// Out of band, so one response updates both the navbar slot and the
|
||||
// modal's status line without the modal having to ask separately. Same
|
||||
// template the shell uses, so the two cannot drift apart.
|
||||
html += '\n' + await render('status.njk', { text: status, oob: true });
|
||||
function esc(text) {
|
||||
return String(text).replace(/[&<>"]/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
}
|
||||
|
||||
// Replaces the empty slot in shell.html, and then replaces itself on every
|
||||
// later read or write. The swap is outerHTML, so this markup has to re-declare
|
||||
// its own htmx attributes: the replacement is what receives the next event.
|
||||
// hx-target is inherited, and the shell points it at #content, so "this" is
|
||||
// not the default here -- it has to be said.
|
||||
function bookmarkHtml(mark) {
|
||||
return '<span class="navbar-text d-flex align-items-center gap-2"' +
|
||||
' id="bookmark" hx-get="api/bookmark"' +
|
||||
' hx-trigger="worker-ready from:body"' +
|
||||
' hx-target="this" hx-swap="outerHTML">' +
|
||||
// Not boosted: a full load lands on the shell with the fragment
|
||||
// identifier intact, which is what app.js scrolls to.
|
||||
'<a class="link-light text-truncate"' +
|
||||
' href="' + esc(mark.href) + '" hx-boost="false">' +
|
||||
esc(mark.ref) + '</a></span>';
|
||||
}
|
||||
|
||||
// Out of band, so one response updates both the navbar slot and the modal's
|
||||
// status line without the modal having to ask separately.
|
||||
function statusHtml(text) {
|
||||
return '<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"' +
|
||||
' role="status" aria-live="polite" hx-swap-oob="true">' +
|
||||
esc(text) + '</p>';
|
||||
}
|
||||
|
||||
async function bookmarkFragment(status) {
|
||||
let html = bookmarkHtml(await getBookmark());
|
||||
if (status) html += '\n' + statusHtml(status);
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -236,9 +370,6 @@ async function bookmarkApi(event) {
|
||||
await withStore('readwrite',
|
||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
||||
status = 'Bookmark set.';
|
||||
} else if (req.method === 'DELETE') {
|
||||
await withStore('readwrite', (s) => s.delete(BOOKMARK_KEY));
|
||||
status = 'Bookmark cleared.';
|
||||
} else if (req.method !== 'GET') {
|
||||
return htmlResponse('', 405);
|
||||
}
|
||||
@@ -247,45 +378,3 @@ async function bookmarkApi(event) {
|
||||
return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.'));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- composition
|
||||
|
||||
const TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
|
||||
const TEMPLATE_RE = /\sdata-template="([\w-]+)"/;
|
||||
|
||||
function fragmentPath(url) {
|
||||
const name = url.pathname.split('/').pop();
|
||||
return './' + (name || 'index.html');
|
||||
}
|
||||
|
||||
async function compose(event) {
|
||||
const url = new URL(event.request.url);
|
||||
let path = fragmentPath(url);
|
||||
|
||||
// The bootstrap page is a whole document already; wrapping it in the shell
|
||||
// would nest one inside the other.
|
||||
if (path === './boot.html') return answer(event.request, event);
|
||||
|
||||
let fragment;
|
||||
|
||||
try {
|
||||
fragment = await readText(path, event);
|
||||
} catch (err) {
|
||||
// Offline and never cached. The index is always precached, so falling
|
||||
// back to it keeps the app usable instead of showing a network error.
|
||||
if (path === './index.html') throw err;
|
||||
path = './index.html';
|
||||
fragment = await readText(path, event);
|
||||
}
|
||||
|
||||
// Already escaped by the renderer, hence the |safe on it in base.njk.
|
||||
const title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
|
||||
const body = fragment.replace(TITLE_RE, '');
|
||||
const template = (TEMPLATE_RE.exec(body) || ['', 'page'])[1] + '.njk';
|
||||
|
||||
return htmlResponse(await render(template, {
|
||||
title: title,
|
||||
content: body,
|
||||
mark: (await getBookmark()) || null
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<title>World English Bible Updated</title>
|
||||
<div class="index" data-template="index">
|
||||
<h1>World English Bible Updated</h1>
|
||||
<section class="book-group" data-section="ot">
|
||||
<h2>Old Testament</h2>
|
||||
<ul>
|
||||
|
||||
+17
-11
@@ -3,11 +3,11 @@
|
||||
|
||||
Reads the JSON produced by extract_bible.py and writes books/GEN.html and so
|
||||
on, plus an index. These are fragments, not documents: a <title> and one root
|
||||
element carrying data-template. The surrounding shell is assembled at request
|
||||
time by the service worker from the nunjucks templates in assets/, 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 templates without regenerating it.
|
||||
element. The document around them is assets/shell.html, which is static and the
|
||||
same for every page, 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 regenerating it.
|
||||
|
||||
This is the only build step left, and books/ is all it owns: everything
|
||||
hand-written lives in assets/ and is served straight from there. Run it when
|
||||
@@ -176,8 +176,8 @@ def render_book(book, translation, out_dir):
|
||||
code, name = book["code"], book["name"]
|
||||
out = []
|
||||
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.
|
||||
# A fragment: the title the shell will use, then one root element. The
|
||||
# class on it is what identifies the kind of page to CSS.
|
||||
out.append("<title>%s</title>\n" % esc(title))
|
||||
out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
|
||||
'data-name="%s" data-section="%s">\n'
|
||||
@@ -187,8 +187,15 @@ def render_book(book, translation, out_dir):
|
||||
out.append(' <header class="book-header">\n')
|
||||
out.append(" <h1>")
|
||||
if book["titles"]:
|
||||
for part in book["titles"]:
|
||||
out.append('<span class="%s">%s</span>' % (part["style"], esc(part["text"])))
|
||||
# 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>'
|
||||
% (part["style"], esc(part["text"]))
|
||||
for part in book["titles"]))
|
||||
else:
|
||||
out.append(esc(name))
|
||||
out.append("</h1>\n")
|
||||
@@ -226,8 +233,7 @@ SECTION_LABELS = [
|
||||
|
||||
def render_index(books, translation, out_dir):
|
||||
out = ["<title>%s</title>\n" % esc(translation["name"]),
|
||||
'<div class="index" data-template="index">\n <h1>%s</h1>\n'
|
||||
% esc(translation["name"])]
|
||||
'<div class="index" data-template="index">\n']
|
||||
for key, label in SECTION_LABELS:
|
||||
group = [b for b in books if b["section"] == key]
|
||||
if not group:
|
||||
|
||||
@@ -9,27 +9,30 @@ 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; the service worker
|
||||
assembles the shell around them. A browser asking for one directly, before any
|
||||
worker is installed, would get a bare fragment with no chrome, so navigations
|
||||
are answered with boot.html, which installs the worker and reloads into the
|
||||
real app. Requests the worker itself makes are ordinary fetches rather than
|
||||
navigations, so they pass straight through and get the fragment.
|
||||
The first is the shell. The pages here are fragments; shell.html is the one
|
||||
document that wraps them, and app.js fills it in from the URL. So every
|
||||
navigation is answered with shell.html, whatever path it asked for -- exactly
|
||||
what the service worker does once it is installed, which is why a first visit
|
||||
needs no special handling and no reload. Requests the worker and htmx make are
|
||||
ordinary fetches rather than navigations, so they pass straight through and get
|
||||
the fragment.
|
||||
|
||||
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. See stamp_worker().
|
||||
Doing it here rather than in a build step means editing the shell 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 pages that register it are served stale, a new version can never
|
||||
worker or the shell 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. That is not optional for
|
||||
the offline app: browsers only register a service worker in a secure context,
|
||||
so a device loading this over plain http:// from a LAN address installs the
|
||||
page but never gets an offline copy.
|
||||
Expects to sit behind something that terminates TLS. Without it the app still
|
||||
works -- the shell and the fragments are plain files -- but there is no offline
|
||||
copy: browsers only register a service worker in a secure context, and the
|
||||
bookmark lives in that worker, so over plain http:// from a LAN address the app
|
||||
reads online-only and the bookmark slot stays empty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,17 +50,18 @@ 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_EXTS = {".html", ".njk", ".webmanifest"}
|
||||
NO_CACHE_EXTS = {".html", ".webmanifest"}
|
||||
|
||||
# Served in place of any page a browser navigates to directly.
|
||||
BOOT_PAGE = "/boot.html"
|
||||
# Served in place of any page a browser navigates to.
|
||||
SHELL_PAGE = "/shell.html"
|
||||
|
||||
WORKER = "sw.js"
|
||||
|
||||
# The two lines stamp_worker() rewrites. They are real statements rather than
|
||||
# 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';"
|
||||
ASSETS_LINE = 'const ASSETS = ["./"];'
|
||||
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
|
||||
LIBRARY_LINE = "const LIBRARY = [];"
|
||||
|
||||
|
||||
def walk(roots):
|
||||
@@ -75,33 +79,49 @@ def walk(roots):
|
||||
return found
|
||||
|
||||
|
||||
def stamp_worker(roots):
|
||||
"""assets/sw.js with VERSION and ASSETS filled in from what is on disk.
|
||||
def digest(files):
|
||||
"""A short fingerprint of a set of files, by name, size and mtime.
|
||||
|
||||
The version is a digest of every file's name, size and mtime rather than
|
||||
its contents: it has to change whenever anything changes, and it is
|
||||
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 precache, which is harmless.
|
||||
changing it costs one needless refetch, which is harmless.
|
||||
"""
|
||||
files = walk(roots)
|
||||
digest = hashlib.sha256()
|
||||
assets = []
|
||||
out = hashlib.sha256()
|
||||
for rel in sorted(files):
|
||||
if rel == WORKER:
|
||||
continue # the browser revalidates the worker itself
|
||||
st = os.stat(files[rel])
|
||||
digest.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
|
||||
assets.append("./" + rel)
|
||||
assets.insert(0, "./") # a bare-root launch is its own cache entry
|
||||
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
|
||||
return out.hexdigest()[:12]
|
||||
|
||||
src = files.get(WORKER)
|
||||
|
||||
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 rest is what render_books.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.
|
||||
"""
|
||||
chrome = walk(roots[:1])
|
||||
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
|
||||
if src is None:
|
||||
return None
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
for line, value in ((VERSION_LINE, "const VERSION = '%s';" % digest.hexdigest()[:12]),
|
||||
(ASSETS_LINE, "const ASSETS = %s;" % json.dumps(assets, indent=2))):
|
||||
|
||||
for line, value in (
|
||||
(VERSION_LINE, "const VERSION = '%s';" % digest(chrome)),
|
||||
(LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)),
|
||||
(LIBRARY_LINE, "const LIBRARY = %s;"
|
||||
% json.dumps(sorted("./" + rel for rel in library), indent=2)),
|
||||
):
|
||||
if line not in body:
|
||||
raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line))
|
||||
body = body.replace(line, value, 1)
|
||||
@@ -112,7 +132,6 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
extensions_map = {
|
||||
**SimpleHTTPRequestHandler.extensions_map,
|
||||
".html": "text/html; charset=utf-8",
|
||||
".njk": "text/plain; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
@@ -139,13 +158,13 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return super().translate_path(path)
|
||||
|
||||
def send_head(self):
|
||||
# Sec-Fetch-Mode is the one signal that separates a browser typing in
|
||||
# a URL from the worker fetching a fragment: a request the worker
|
||||
# constructs is never in navigate mode. Browsers without the header
|
||||
# also have no service worker, and get the fragment -- there is
|
||||
# nothing better to give them.
|
||||
if self.headers.get("Sec-Fetch-Mode") == "navigate" and self.path != BOOT_PAGE:
|
||||
self.path = BOOT_PAGE
|
||||
# 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. Browsers without the header also have no
|
||||
# service worker, and get the fragment -- there is nothing better to
|
||||
# give them.
|
||||
if self.headers.get("Sec-Fetch-Mode") == "navigate":
|
||||
self.path = SHELL_PAGE
|
||||
|
||||
if posixpath.basename(self.path.split("?", 1)[0]) == WORKER:
|
||||
return self.send_worker()
|
||||
|
||||
Reference in new issue
Block a user