Better
This commit is contained in:
1 parent
ea193e1264
commit
2fea308b38
106 files changed
+1234
-878
No files matched your search
@@ -0,0 +1,128 @@
|
||||
<!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>
|
||||
<!-- Vendored rather than linked from a CDN: sw.js only caches same-origin
|
||||
responses, so a CDN stylesheet would leave the app unstyled offline. -->
|
||||
<link rel="stylesheet" href="css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
</head>
|
||||
<body class="bg-body-tertiary">
|
||||
|
||||
<!-- Served by server.py in place of any address a browser navigates to while
|
||||
no service worker is controlling this origin: a first visit, a cleared
|
||||
cache, a reload that bypassed the worker.
|
||||
|
||||
The pages on disk are fragments and the shell is a template, and it is the
|
||||
worker that puts them together. So this page has one job: register the
|
||||
worker and reload once it is running, which lands on the real app at the
|
||||
address that was asked for. It is only ever seen once per device. -->
|
||||
|
||||
<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 d-inline-block">This app builds its
|
||||
pages in the browser and needs JavaScript.</p></noscript>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function status(text) {
|
||||
document.getElementById('boot-status').textContent = text;
|
||||
}
|
||||
|
||||
// The reload lands on this page again if the worker somehow activates
|
||||
// without taking over the navigation. Two attempts is enough to cover a
|
||||
// browser that needed one, and stops a device that never will from
|
||||
// reloading forever.
|
||||
var tries = 0;
|
||||
try { tries = parseInt(sessionStorage.getItem('boot:tries') || '0', 10); }
|
||||
catch (err) { tries = 0; }
|
||||
|
||||
function go() {
|
||||
try { sessionStorage.removeItem('boot:tries'); } catch (err) {}
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (tries >= 2) return compose('The offline copy could not start.');
|
||||
try { sessionStorage.setItem('boot:tries', String(tries + 1)); } catch (err) {}
|
||||
|
||||
navigator.serviceWorker.register('sw.js').then(function (registration) {
|
||||
// Active already: this load raced the install and is simply not
|
||||
// controlled yet. Otherwise wait for the one installing to get there.
|
||||
if (registration.active) return go();
|
||||
var worker = registration.installing || registration.waiting;
|
||||
if (!worker) return compose('The offline copy did not install.');
|
||||
worker.addEventListener('statechange', function (event) {
|
||||
if (event.target.state === 'activated') go();
|
||||
});
|
||||
}, function (err) {
|
||||
compose('Could not install the offline copy: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Everything below is the path where there will never be a worker: a browser
|
||||
// without them, or -- much more likely -- the app served over plain http://
|
||||
// from a LAN address, which is not a secure context. Rather than spin here
|
||||
// forever, the page composes itself, using the same renderer and the same
|
||||
// templates the worker would have used. No offline copy and no bookmark, but
|
||||
// the text reads. Delete this if the app is only ever served over TLS.
|
||||
function compose(why) {
|
||||
console.warn(why);
|
||||
status('Loading…');
|
||||
script('js/nunjucks.min.js')
|
||||
.then(function () { return script('js/render.js'); })
|
||||
.then(function () {
|
||||
var view = createRenderer(readText);
|
||||
var page = location.pathname.split('/').pop();
|
||||
return readText('./' + page + location.search)
|
||||
.catch(function () {
|
||||
return view.notice('Not available',
|
||||
'This page could not be loaded. Check your connection and try again.');
|
||||
})
|
||||
// mark is null: the bookmark lives in the worker's IndexedDB, and
|
||||
// there is no worker to ask. bookmark.njk renders the slot hidden
|
||||
// and it fills itself the moment one appears.
|
||||
.then(function (fragment) { return view.compose(fragment, null); });
|
||||
})
|
||||
.then(become, function (err) { status('Could not start: ' + err.message); });
|
||||
}
|
||||
|
||||
function script(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var el = document.createElement('script');
|
||||
el.src = src;
|
||||
el.onload = function () { resolve(); };
|
||||
el.onerror = function () { reject(new Error('cannot load ' + src)); };
|
||||
document.head.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function readText(path) {
|
||||
return fetch(path, { credentials: 'same-origin' }).then(function (res) {
|
||||
if (!res.ok) throw new Error(path + ': ' + res.status);
|
||||
return res.text();
|
||||
});
|
||||
}
|
||||
|
||||
// Replaces this document in place: same URL, same history entry, no second
|
||||
// request. Written rather than assigned into the DOM because the shell
|
||||
// carries <script> tags, and scripts put into a page any other way never run.
|
||||
function become(html) {
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) start();
|
||||
else compose('This browser has no service worker support.');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,14 +1,16 @@
|
||||
// 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.
|
||||
// Pages arrive whole -- composed by the service worker, or by boot.html on the
|
||||
// visit before there is one -- so there is nothing here that builds a page.
|
||||
// What is left is the three things a composed document cannot do for itself:
|
||||
// mark up the verse numbers as controls, run the modal, and tell the bookmark
|
||||
// slot when a worker has appeared to answer it.
|
||||
//
|
||||
// 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.
|
||||
// The modal's markup is in templates/base.njk and the bookmark is in the
|
||||
// worker, so
|
||||
// all this file does for it 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';
|
||||
|
||||
@@ -58,7 +60,7 @@
|
||||
? (article.getAttribute('data-name') || article.getAttribute('data-book'))
|
||||
: '';
|
||||
var chapter = section ? section.getAttribute('data-chapter') : '';
|
||||
var page = location.pathname.split('/').pop() || 'index.html';
|
||||
var page = location.pathname.split('/').pop() || './';
|
||||
return {
|
||||
ref: book + ' ' + chapter + ':' + (span.textContent || '').trim(),
|
||||
// Relative, so a saved bookmark survives a change of host or port.
|
||||
@@ -130,13 +132,11 @@
|
||||
|
||||
// -- startup ------------------------------------------------------------
|
||||
|
||||
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.
|
||||
// A worker-composed page is an ordinary document and the browser scrolls to
|
||||
// the anchor itself. One composed by boot.html is not: it replaced a
|
||||
// document the browser had already finished with, so the fragment identifier
|
||||
// has been spent. Repeating it costs nothing in the first case and is what
|
||||
// makes a link to GEN.html#v1_1 land on the verse in the second.
|
||||
function scrollToHash() {
|
||||
if (!location.hash) return;
|
||||
var el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||
@@ -156,13 +156,14 @@
|
||||
// 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);
|
||||
markVerses();
|
||||
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.
|
||||
// composing, the bookmark and the offline download from the next
|
||||
// navigation on.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('sw.js').catch(function () {});
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/*! 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
|
||||
@@ -0,0 +1,80 @@
|
||||
// The renderer, shared by the two things that ever build a page: the service
|
||||
// worker, which composes every navigation once it is installed, and boot.html,
|
||||
// which composes the one navigation that arrives before there is a worker to
|
||||
// do it.
|
||||
//
|
||||
// Sharing it is the whole point. A shell only the worker could build is what
|
||||
// made a first visit show a spinner and reload; with the same templates and
|
||||
// the same code reachable from the page, the first visit is already the app
|
||||
// and the worker is what makes the second one fast and the hundredth one
|
||||
// offline.
|
||||
//
|
||||
// Loaded as a plain script by both -- importScripts in the worker, a <script>
|
||||
// tag in the page -- so it defines a global rather than exporting. Nunjucks
|
||||
// must already be loaded.
|
||||
(function (scope) {
|
||||
'use strict';
|
||||
|
||||
// A fragment is a <title> and one root element. The root's data-template
|
||||
// names the template that wraps it; render_books.py writes both.
|
||||
var TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
|
||||
var TEMPLATE_RE = /data-template="([a-z0-9_-]+)"/i;
|
||||
|
||||
// Templates live in one directory and are named by file within it, so a
|
||||
// template that extends or includes another names only the file.
|
||||
var TEMPLATES = './templates/';
|
||||
|
||||
// readText(path) -> Promise<string> is the only thing the two callers do
|
||||
// differently: the worker reads through its cache, the page over the network.
|
||||
scope.createRenderer = function (readText) {
|
||||
// Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist
|
||||
// in a service worker. This one is readText and nothing else.
|
||||
function Loader() {}
|
||||
Loader.prototype.async = true;
|
||||
Loader.prototype.getSource = function (name, callback) {
|
||||
readText(TEMPLATES + name).then(
|
||||
function (src) { callback(null, { src: src, path: name, noCache: false }); },
|
||||
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 });
|
||||
|
||||
function render(name, context) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
env.render(name, context, function (err, out) {
|
||||
return err ? reject(err) : resolve(out);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
render: render,
|
||||
|
||||
// A fragment becomes a whole document. page.njk is the fallback for one
|
||||
// that names no template of its own.
|
||||
//
|
||||
// content is already escaped -- render_books.py escapes as it writes and
|
||||
// notice.njk comes out of nunjucks -- which is what the |safe on it in
|
||||
// base.njk is for.
|
||||
compose: function (fragment, mark) {
|
||||
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
|
||||
var content = fragment.replace(TITLE_RE, '');
|
||||
var name = (TEMPLATE_RE.exec(content) || ['', 'page'])[1] + '.njk';
|
||||
return render(name, {
|
||||
title: title,
|
||||
content: content,
|
||||
mark: mark || null
|
||||
});
|
||||
},
|
||||
|
||||
// A page that could not be delivered, in the shape of a real fragment so
|
||||
// that it composes into a document and swaps into #content like one.
|
||||
notice: function (heading, text) {
|
||||
return render('notice.njk', { heading: heading, text: text });
|
||||
}
|
||||
};
|
||||
};
|
||||
})(self);
|
||||
@@ -0,0 +1,108 @@
|
||||
// A very small HTTP router, so the worker reads as a set of routes rather than
|
||||
// a chain of ifs unpicking a URL. The shape is lifted from the one in
|
||||
// htmx-serviceworker-todo, which is in turn a miniature of Hono or Express:
|
||||
// register a method and a path pattern, get the matching handler back.
|
||||
//
|
||||
// Patterns are matched segment by segment against a path relative to the
|
||||
// worker's own scope, so the app still works if it is ever served from a
|
||||
// subdirectory. A segment written as :name matches anything and lands in
|
||||
// params. Segment counts must be equal -- there are no wildcards, because
|
||||
// nothing here needs one.
|
||||
//
|
||||
// A handler returning a string gets it as an HTML response; returning a
|
||||
// Response passes it through untouched; returning nothing means "not mine
|
||||
// after all", and the caller falls back to serving the URL as a file.
|
||||
(function (scope) {
|
||||
'use strict';
|
||||
|
||||
function parse(pattern) {
|
||||
return pattern.split('/').map(function (part) {
|
||||
return part.charAt(0) === ':' ? { name: part.slice(1) } : { part: part };
|
||||
});
|
||||
}
|
||||
|
||||
// Everything a handler is given: the event it must attach cache writes to,
|
||||
// the URL, the path parameters, and the two questions every handler here
|
||||
// asks -- who is calling, and are they replacing the whole document.
|
||||
function Call(event, url, params) {
|
||||
this.event = event;
|
||||
this.request = event.request;
|
||||
this.url = url;
|
||||
this.params = params;
|
||||
// htmx stamps every request it makes; a navigation never carries it.
|
||||
this.htmx = this.request.headers.get('HX-Request') === 'true';
|
||||
this.navigate = this.request.mode === 'navigate';
|
||||
}
|
||||
|
||||
Call.prototype.form = function () {
|
||||
return this.request.formData();
|
||||
};
|
||||
|
||||
// base is the worker's scope path, e.g. "/" -- the prefix every pattern is
|
||||
// written without.
|
||||
function Router(base) {
|
||||
this.base = base;
|
||||
this.routes = [];
|
||||
}
|
||||
|
||||
Router.prototype.route = function (method, pattern, handler) {
|
||||
this.routes.push({ method: method, path: parse(pattern), handler: handler });
|
||||
return this;
|
||||
};
|
||||
|
||||
Router.prototype.get = function (pattern, handler) {
|
||||
return this.route('GET', pattern, handler);
|
||||
};
|
||||
|
||||
Router.prototype.post = function (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) {
|
||||
var path = url.pathname;
|
||||
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
|
||||
};
|
||||
|
||||
Router.prototype.find = function (method, path) {
|
||||
var segments = path.split('/');
|
||||
for (var i = 0; i < this.routes.length; i++) {
|
||||
var route = this.routes[i];
|
||||
if (route.method !== method || route.path.length !== segments.length) continue;
|
||||
var params = {};
|
||||
var ok = route.path.every(function (segment, n) {
|
||||
if (!segment.name) return segment.part === segments[n];
|
||||
params[segment.name] = decodeURIComponent(segments[n]);
|
||||
return true;
|
||||
});
|
||||
if (ok) return { handler: route.handler, params: params };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Returns a promise of a Response, or null when nothing matched -- null
|
||||
// being the signal to the caller that this URL is a file, not a route.
|
||||
Router.prototype.handle = function (event, url) {
|
||||
var found = this.find(event.request.method, this.relative(url));
|
||||
if (!found) return null;
|
||||
|
||||
return Promise.resolve(found.handler(new Call(event, url, found.params)))
|
||||
.then(function (result) {
|
||||
if (result instanceof Response) return result;
|
||||
if (typeof result !== 'string') return null;
|
||||
return new Response(result, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// Built here from live state -- the bookmark, the templates, the
|
||||
// cache -- so storing it would pin a stale copy over the real thing.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
scope.Router = Router;
|
||||
})(self);
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "Bible",
|
||||
"short_name": "Bible",
|
||||
"description": "World English Bible Updated",
|
||||
"start_url": "./index.html",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<!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>
|
||||
+162
-135
@@ -1,21 +1,42 @@
|
||||
// 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 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).
|
||||
//
|
||||
// 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 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.
|
||||
//
|
||||
// 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.
|
||||
// GET / the book list
|
||||
// GET /:page one book -- /GEN, /EXO
|
||||
// GET /api/bookmark the navbar slot
|
||||
// POST /api/bookmark set the bookmark, and return the slot
|
||||
//
|
||||
// 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 books/GEN.html, what the reader sees
|
||||
// is /GEN, and server.py resolves one to the other. books/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, which boot.html can load too. Normally it
|
||||
// does not: boot.html registers this worker and reloads into it. It composes in
|
||||
// the page when no worker is possible at all -- no secure context, or a browser
|
||||
// without them -- and then it does so from these same templates rather than
|
||||
// leaving a spinner up forever.
|
||||
//
|
||||
// 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.
|
||||
// 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
|
||||
@@ -25,6 +46,12 @@
|
||||
// visit picks up where it left off: the cache is the record of what has been
|
||||
// fetched, so there is no separate bookkeeping to lose.
|
||||
|
||||
// Imported scripts are stored with the registration, so these survive offline
|
||||
// without being in PRECACHE. importScripts rather than the fetch-and-eval
|
||||
// module loader the router came from: it is the one the platform supports in a
|
||||
// worker, and it costs no waterfall.
|
||||
importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js');
|
||||
|
||||
const NETWORK_TIMEOUT_MS = 3000;
|
||||
|
||||
// The background download gets a longer leash than a page the reader is
|
||||
@@ -43,11 +70,11 @@ const DOWNLOAD_AT_ONCE = 4;
|
||||
// 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.
|
||||
// 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 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 LIBRARY_VERSION = 'dev';
|
||||
const LIBRARY = [];
|
||||
@@ -60,19 +87,29 @@ const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION;
|
||||
// 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';
|
||||
// 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 = [
|
||||
SHELL,
|
||||
'./index.html',
|
||||
'./app.js',
|
||||
'./app.css',
|
||||
'./htmx.min.js',
|
||||
'./bootstrap.min.css',
|
||||
'./bootstrap.bundle.min.js',
|
||||
'./manifest.webmanifest'
|
||||
'./boot.html',
|
||||
'./',
|
||||
'./manifest.webmanifest',
|
||||
'./css/app.css',
|
||||
'./css/bootstrap.min.css',
|
||||
'./js/app.js',
|
||||
'./js/htmx.min.js',
|
||||
'./js/bootstrap.bundle.min.js',
|
||||
'./templates/base.njk',
|
||||
'./templates/page.njk',
|
||||
'./templates/book.njk',
|
||||
'./templates/index.njk',
|
||||
'./templates/bookmark.njk',
|
||||
'./templates/status.njk',
|
||||
'./templates/notice.njk'
|
||||
];
|
||||
|
||||
function cacheFor(req) {
|
||||
@@ -102,6 +139,13 @@ self.addEventListener('activate', (event) => {
|
||||
})());
|
||||
});
|
||||
|
||||
// A dot in the last segment means a file that exists on disk: a script, a
|
||||
// stylesheet, an icon, a template. Page URLs never have one, so this is the
|
||||
// whole boundary between what the router owns and what is simply served.
|
||||
function isFile(url) {
|
||||
return url.pathname.split('/').pop().indexOf('.') !== -1;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
@@ -114,19 +158,16 @@ self.addEventListener('fetch', (event) => {
|
||||
// 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.
|
||||
if (isBookmarkApi(url)) return event.respondWith(bookmarkApi(event));
|
||||
if (!isFile(url)) {
|
||||
const routed = router.handle(event, url);
|
||||
// A route that declines -- by returning nothing -- leaves the URL to be
|
||||
// served as a file, the same as one that never matched.
|
||||
if (routed) {
|
||||
return event.respondWith(routed.then((res) => res || answer(req, event)));
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
@@ -150,8 +191,8 @@ function cached(req) {
|
||||
|
||||
// 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 file itself rather than
|
||||
// the shell.
|
||||
// internal fetch from a browser one and hand back the fragment rather than
|
||||
// the boot page.
|
||||
async function answer(input, event) {
|
||||
const req = typeof input === 'string' ? new Request(input) : input;
|
||||
|
||||
@@ -172,51 +213,87 @@ async function answer(input, event) {
|
||||
}
|
||||
}
|
||||
|
||||
function htmlResponse(body, status) {
|
||||
return new Response(body, {
|
||||
status: status || 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// Built here from live state rather than fetched; storing it would pin
|
||||
// a stale copy over the real thing.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- pages
|
||||
// --------------------------------------------------------------- templates
|
||||
|
||||
function isPage(url) {
|
||||
return url.pathname.endsWith('.html');
|
||||
// Templates are read cache-first, alone among everything here. They are
|
||||
// precached on install, and editing one changes VERSION, which installs a new
|
||||
// worker with a new cache -- so a hit cannot be stale. Going to the network
|
||||
// for them would put six round trips in front of the first page every time the
|
||||
// browser has reaped this worker, which it does constantly.
|
||||
async function readTemplate(path) {
|
||||
const hit = await cached(path);
|
||||
return hit ? hit.text() : readText(path);
|
||||
}
|
||||
|
||||
// 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>');
|
||||
}
|
||||
const view = createRenderer(readTemplate);
|
||||
|
||||
async function page(req, event) {
|
||||
// ------------------------------------------------------------------ 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 OFFLINE_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.'];
|
||||
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 books/GEN.html.
|
||||
async function fragmentFor(path, event) {
|
||||
let res;
|
||||
try {
|
||||
res = await answer(req, event);
|
||||
res = await answer(path, 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.');
|
||||
return view.notice(...OFFLINE_NOTICE);
|
||||
}
|
||||
if (res.ok) return res;
|
||||
return notice('Not found', 'There is no page at this address.');
|
||||
return res.ok ? res.text() : view.notice(...MISSING_NOTICE);
|
||||
}
|
||||
|
||||
// One handler for both callers. htmx is swapping into a shell that is already
|
||||
// on screen and wants the fragment; a navigation has no shell yet and gets the
|
||||
// fragment composed into one.
|
||||
async function servePage(path, call) {
|
||||
const fragment = await fragmentFor(path, call.event);
|
||||
if (!call.navigate) return fragment;
|
||||
try {
|
||||
return await view.compose(fragment, await getBookmark());
|
||||
} catch (err) {
|
||||
// A template that will not render is a bug, not a condition to design for,
|
||||
// but it should not take the text down with it: the bare fragment has no
|
||||
// chrome and no styling, and it is still the page that was asked for.
|
||||
console.error('compose failed: ' + err.message);
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
// "/" before "/:page", because both are two segments and the first match wins.
|
||||
router.get('/', (call) => servePage('./', call));
|
||||
router.get('/:page', (call) => servePage('./' + call.params.page, call));
|
||||
|
||||
router.get('/api/bookmark', () => bookmarkFragment(''));
|
||||
|
||||
router.post('/api/bookmark', async (call) => {
|
||||
try {
|
||||
const form = await call.form();
|
||||
const ref = String(form.get('ref') || '').trim();
|
||||
const href = String(form.get('href') || '').trim();
|
||||
if (!ref || !href) return new Response('', { status: 400 });
|
||||
await withStore('readwrite',
|
||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
||||
return bookmarkFragment('Bookmark set.');
|
||||
} catch (err) {
|
||||
return bookmarkFragment('Could not save: storage unavailable.');
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- library
|
||||
|
||||
// The background download of the whole corpus.
|
||||
@@ -281,8 +358,9 @@ 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' };
|
||||
// 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: 'Genesis 1:1', href: 'GEN#v1_1' };
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -317,64 +395,13 @@ function getBookmark() {
|
||||
.then((mark) => mark || DEFAULT_BOOKMARK);
|
||||
}
|
||||
|
||||
function isBookmarkApi(url) {
|
||||
return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark');
|
||||
}
|
||||
|
||||
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);
|
||||
let html = await view.render('bookmark.njk', { mark: await getBookmark() });
|
||||
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 view.render('status.njk', { text: status, oob: true });
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
async function bookmarkApi(event) {
|
||||
const req = event.request;
|
||||
let status = '';
|
||||
|
||||
try {
|
||||
if (req.method === 'POST') {
|
||||
const form = await req.formData();
|
||||
const ref = String(form.get('ref') || '').trim();
|
||||
const href = String(form.get('href') || '').trim();
|
||||
if (!ref || !href) return htmlResponse('', 400);
|
||||
await withStore('readwrite',
|
||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
||||
status = 'Bookmark set.';
|
||||
} else if (req.method !== 'GET') {
|
||||
return htmlResponse('', 405);
|
||||
}
|
||||
return htmlResponse(await bookmarkFragment(status));
|
||||
} catch (err) {
|
||||
return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.'));
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,23 @@
|
||||
<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="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">
|
||||
{# History entries are whole books, and htmx keeps its history cache in
|
||||
localStorage -- a couple of them would blow the quota. Turned off: going
|
||||
back re-requests the fragment, which the worker answers from its cache. #}
|
||||
<meta name="htmx-config" content='{"historyCacheSize":0}'>
|
||||
<meta name="htmx-config"
|
||||
content='{"historyCacheSize":0,"refreshOnHistoryMiss":true}'>
|
||||
{# 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="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="htmx.min.js"></script>
|
||||
<script src="app.js" defer></script>
|
||||
<script src="js/htmx.min.js"></script>
|
||||
<script src="js/app.js" defer></script>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
@@ -43,7 +47,7 @@
|
||||
<nav class="site-header navbar navbar-expand-lg bg-dark sticky-top"
|
||||
data-bs-theme="dark">
|
||||
<div class="container-xl">
|
||||
<a class="navbar-brand" href="index.html">Bible</a>
|
||||
<a class="navbar-brand" href="./">Bible</a>
|
||||
<button class="navbar-toggler" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#site-nav"
|
||||
aria-controls="site-nav" aria-expanded="false"
|
||||
@@ -64,9 +68,7 @@
|
||||
the full width of a desktop window. #}
|
||||
<main class="container-xl my-4">
|
||||
<div class="row justify-content-center">
|
||||
<div id="content" class="col-12 col-xl-8 bg-body rounded-3 shadow-sm p-3 p-md-5">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block body_class %}reading{% endblock %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{# 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.
|
||||
|
||||
mark is null in one case only: boot.html composed this page, so there was
|
||||
no worker to ask. The slot is then hidden rather than absent and fetches
|
||||
itself once app.js reports a worker is controlling -- which is why the htmx
|
||||
attributes are on the empty state and not the filled one. A filled slot is
|
||||
only ever replaced by the modal's form, which names #bookmark itself.
|
||||
|
||||
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"
|
||||
{%- if not mark %}
|
||||
hx-get="api/bookmark" hx-trigger="worker-ready from:body"
|
||||
hx-target="this" hx-swap="outerHTML"
|
||||
{%- endif %}>
|
||||
{%- 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>
|
||||
{%- endif %}
|
||||
</span>
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block body_class %}browsing{% endblock %}
|
||||
{# Already on the index, so the nav entry marks the current page instead of
|
||||
offering a link to it. #}
|
||||
{% block navbar_nav %}
|
||||
{% endblock %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{# A page that could not be delivered, written in the same shape as a real
|
||||
one: a title and one root element naming the template that wraps it.
|
||||
|
||||
Sent with 200 on purpose. htmx only swaps a 2xx body, and an error status
|
||||
would leave the reader looking at the page they came from with nothing to
|
||||
say what happened. #}
|
||||
<title>{{ heading }}</title>
|
||||
<div class="notice" data-template="page">
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>{{ text }}</p>
|
||||
<p><a href="./">All books</a></p>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Fallback for a fragment that names no template of its own. #}
|
||||
{% extends "base.njk" %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{# 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>
|
||||
Reference in new issue
Block a user