Better
This commit is contained in:
1 parent
ea193e1264
commit
2fea308b38
106 files changed
+1234
-878
No files matched your search
+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.'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user