This commit is contained in:
mark committed 2026-09-04 09:05:14 -04:00
1 parent ea193e1264
commit 2fea308b38
106 files changed
+1234 -878

No files matched your search

+162 -135
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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.'));
}
}