Move verse selector to htmx
This commit is contained in:
1 parent
dd0021ce15
commit
ba3b588ac5
9 files changed
+394
-135
No files matched your search
+179
-9
@@ -7,10 +7,11 @@
|
||||
// write the bookmark in IndexedDB. htmx on the page talks to it exactly as it
|
||||
// would to an origin.
|
||||
//
|
||||
// GET / the book list
|
||||
// GET /:page one book -- /GEN, /EXO
|
||||
// GET /api/bookmark the navbar slot
|
||||
// POST /api/bookmark set the bookmark, and return the slot
|
||||
// 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
|
||||
// GET /api/reference the navbar's chapter and verse picker
|
||||
//
|
||||
// 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
|
||||
@@ -108,6 +109,8 @@ const PRECACHE = [
|
||||
'./templates/book.njk',
|
||||
'./templates/index.njk',
|
||||
'./templates/bookmark.njk',
|
||||
'./templates/reference.njk',
|
||||
'./templates/verses.njk',
|
||||
'./templates/status.njk',
|
||||
'./templates/notice.njk'
|
||||
];
|
||||
@@ -226,6 +229,9 @@ function pageRequest(url) {
|
||||
return new Request(url, { cache: 'no-cache' });
|
||||
}
|
||||
|
||||
// Used for verses.json as well, which is generated beside the pages and
|
||||
// versions with them, and would go stale the same way for the same reason.
|
||||
|
||||
async function readText(input, event) {
|
||||
const res = await answer(input, event);
|
||||
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
|
||||
@@ -252,6 +258,106 @@ const view = createRenderer(readTemplate);
|
||||
// if the app is ever served from a subdirectory.
|
||||
const router = new Router(new URL('./', location.href).pathname);
|
||||
|
||||
// ---------------------------------------------------------------- reference
|
||||
|
||||
// bible/verses.json: every book's chapters, and every chapter's verse labels.
|
||||
// compile_bible.py writes it from the same tree it renders the books from, so
|
||||
// every option built here names an anchor that is in the page it lands on.
|
||||
//
|
||||
// A chapter whose verses run 1..n is stored as the number n; only the eight in
|
||||
// the corpus with a gap or a range -- Sirach 26 ends "19-27", 28, 29 -- spell
|
||||
// their labels out. 6 KB for 38,000 verses rather than 180.
|
||||
const VERSES_URL = './verses.json';
|
||||
|
||||
let verses = null;
|
||||
|
||||
// Held for the life of this worker, and only for that. Regenerating the corpus
|
||||
// changes LIBRARY_VERSION, which is a new worker with a new cache, so a copy
|
||||
// held here cannot outlive the pages it describes.
|
||||
//
|
||||
// It lives in the library cache, so a device that has never been online long
|
||||
// enough to fetch it has no picker rather than no page -- see referenceOf.
|
||||
function readVerses() {
|
||||
if (!verses) {
|
||||
verses = readText(pageRequest(VERSES_URL))
|
||||
.then(JSON.parse)
|
||||
.catch((err) => { verses = null; throw err; });
|
||||
}
|
||||
return verses;
|
||||
}
|
||||
|
||||
// "ch3" or "v3_16" -- the id of the element an option lands on, which is also
|
||||
// where the chapter it belongs to is written down. Options are built here, so
|
||||
// this is only ever reading back what this file sent; a URL is still a URL,
|
||||
// and anything else is null.
|
||||
function parseAnchor(id) {
|
||||
const chapter = /^ch(\d+)$/.exec(id || '');
|
||||
if (chapter) return { chapter: +chapter[1], verse: null };
|
||||
const verse = /^v(\d+)_[0-9-]+$/.exec(id || '');
|
||||
if (verse) return { chapter: +verse[1], verse: id };
|
||||
return null;
|
||||
}
|
||||
|
||||
function verseLabels(chapters, number) {
|
||||
const entry = chapters[number - 1];
|
||||
if (typeof entry !== 'number') return entry || [];
|
||||
const out = [];
|
||||
for (let i = 1; i <= entry; i++) out.push(String(i));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The picker's model for one book, or null if that is not a book with verses
|
||||
// in it -- the book list, a notice, and the two front-matter books that have
|
||||
// no verse anchors to offer.
|
||||
//
|
||||
// anchor says which chapter to show the verses of, and which verse to mark
|
||||
// selected. Null starts at 1:1, which is where every book starts: this says
|
||||
// where you are going, not where you are, and the address bar is the only
|
||||
// record of the latter.
|
||||
function referenceFor(map, code, anchor) {
|
||||
const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
|
||||
? map[code] : null;
|
||||
if (!chapters || !chapters.length) return null;
|
||||
|
||||
// The front matter and the glossary are books with chapters and no verses.
|
||||
// A picker over them would be a chapter select beside an empty one, so they
|
||||
// get none, the same as the book list.
|
||||
if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const at = parseAnchor(anchor) || { chapter: 1, verse: null };
|
||||
const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1;
|
||||
|
||||
return {
|
||||
book: code,
|
||||
chapter: 'ch' + number,
|
||||
chapters: chapters.map((_, i) => ({ id: 'ch' + (i + 1), label: String(i + 1) })),
|
||||
verse: at.verse,
|
||||
verses: verseLabels(chapters, number)
|
||||
.map((label) => ({ id: 'v' + number + '_' + label, label: label }))
|
||||
};
|
||||
}
|
||||
|
||||
// The book a page URL is showing: "GEN" for /GEN, "" for the book list.
|
||||
function bookOf(url) {
|
||||
return url ? router.relative(url).slice(1) : '';
|
||||
}
|
||||
|
||||
// Never throws. The map is fetched over the network first and answered from
|
||||
// the library cache when that fails, so the one case this swallows is a device
|
||||
// that has gone offline before the background download reached verses.json:
|
||||
// the pages it has read still read, and the picker is hidden until a later
|
||||
// navigation finds the map. The same degradation the bookmark makes.
|
||||
async function referenceOf(code, anchor) {
|
||||
if (!code) return null;
|
||||
try {
|
||||
return referenceFor(await readVerses(), code, anchor);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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.'];
|
||||
@@ -273,11 +379,24 @@ async function fragmentFor(path, event) {
|
||||
// One handler for every caller. htmx swapping a link into a shell already on
|
||||
// screen wants the bare fragment; a navigation has no shell yet, and a history
|
||||
// restore is rebuilding one, so both get the fragment composed into a document.
|
||||
async function servePage(path, call) {
|
||||
//
|
||||
// code is the book the path names, or "" for the book list -- the one thing
|
||||
// the picker needs and the path already knows.
|
||||
async function servePage(path, code, call) {
|
||||
const fragment = await fragmentFor(path, call.event);
|
||||
if (!call.navigate && !call.restore) return fragment;
|
||||
const reference = await referenceOf(code, null);
|
||||
if (!call.navigate && !call.restore) {
|
||||
// The picker is in the navbar and every boosted link targets #content, so
|
||||
// a swap never reaches it. It rides along out of band instead, the way a
|
||||
// bookmark write carries the modal's status line: one response, both
|
||||
// halves of the page. Sent even when it is null -- that is the hidden
|
||||
// state, and arriving at the book list has to put the picker away.
|
||||
return fragment + '\n'
|
||||
+ await view.render('reference.njk', { reference: reference, oob: true });
|
||||
}
|
||||
try {
|
||||
return await view.compose(fragment, await getBookmark());
|
||||
return await view.compose(fragment, { mark: await getBookmark(),
|
||||
reference: reference });
|
||||
} 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
|
||||
@@ -288,11 +407,62 @@ async function servePage(path, call) {
|
||||
}
|
||||
|
||||
// "/" 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('/', (call) => servePage('./', '', call));
|
||||
router.get('/:page', (call) =>
|
||||
servePage('./' + call.params.page, call.params.page, call));
|
||||
|
||||
router.get('/api/bookmark', () => bookmarkFragment(''));
|
||||
|
||||
// The picker, and the two moves it makes.
|
||||
//
|
||||
// no parameters the whole picker, for whichever book is on screen. Asked
|
||||
// for by the picker itself, after a history restore -- which
|
||||
// swaps the history element straight in and processes no
|
||||
// out-of-band elements -- and when a worker first appears
|
||||
// under a page boot.html composed.
|
||||
// chapter=ch3 the verses of that chapter, and land on the chapter.
|
||||
// verse=v3_16 the same verses, that one selected, and land on the verse.
|
||||
//
|
||||
// The book is not in the URL, deliberately: it comes from HX-Current-URL,
|
||||
// which is the address the page is showing now rather than the address it was
|
||||
// showing when the picker was last rendered. The picker sits in the navbar and
|
||||
// outlives the page under it, so anything baked into its attributes is the one
|
||||
// thing here that can go stale.
|
||||
//
|
||||
// What the response headers are doing is the whole of what app.js used to do
|
||||
// on a change event, and htmx has a name for each part of it:
|
||||
//
|
||||
// settle:0 swap the options now. The default waits 20ms, and the point
|
||||
// of this request is a scroll the reader is waiting on.
|
||||
// show:#id:top scroll the named anchor to the top -- htmx's scrollBehavior
|
||||
// is "instant", which is what this app wants everywhere.
|
||||
// Reboot sets scroll-behavior: smooth, so saying nothing here
|
||||
// would animate.
|
||||
// HX-Replace-Url put the anchor in the address bar without stacking a
|
||||
// history entry per option spun past.
|
||||
router.get('/api/reference', async (call) => {
|
||||
const params = call.url.searchParams;
|
||||
const anchor = params.get('verse') || params.get('chapter');
|
||||
const page = call.currentUrl();
|
||||
const reference = await referenceOf(bookOf(page), anchor);
|
||||
|
||||
if (!anchor) return view.render('reference.njk', { reference: reference });
|
||||
// The options were rendered from this map, so failing to parse them back
|
||||
// means the map moved under an open page: leave the picker alone rather
|
||||
// than swapping in options for a book that is no longer on screen.
|
||||
if (!reference) return new Response('', { status: 204 });
|
||||
|
||||
const target = reference.verse || reference.chapter;
|
||||
return new Response(await view.render('verses.njk', { reference: reference }), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'HX-Reswap': 'innerHTML settle:0 show:#' + target + ':top',
|
||||
'HX-Replace-Url': page.pathname + page.search + '#' + target
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/api/bookmark', async (call) => {
|
||||
try {
|
||||
const form = await call.form();
|
||||
|
||||
Reference in new issue
Block a user