diff --git a/assets/js/app.js b/assets/js/app.js index 11a3455..a551a3b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2,15 +2,21 @@ // // 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. +// What is left is the four things a composed document cannot do for itself: +// mark up the verse numbers as controls, run the modal, put a swapped-in page +// where it belongs before it is painted, and tell the chrome when a worker has +// appeared to answer it. +// +// The reference picker used to be here as well, built by reading the chapters +// and verses off the book on the page. It is markup and htmx now: sw.js +// renders it from bible/verses.json and answers api/reference, and the scroll +// and the address-bar update that a change event used to run through here are +// response headers on the answer. See templates/reference.njk. // // 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. +// 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'; @@ -128,102 +134,16 @@ // Close the modal once a bookmark write has come back and swapped, leaving // the status line up long enough to be read. if (target.id === 'bookmark') return setTimeout(hideModal, 900); - if (target.id === 'content') { fillReference(); land(); } - }); - - // -- reference picker --------------------------------------------------- - - // The "GEN 1:1" in the navbar. Built from the book on the page rather than - // from a table shipped beside it: the chapters are the sections and a - // chapter's verses are the anchors inside it, so an option can never name a - // target that is not there to scroll to, and there is no second file to keep - // in step, cache, or version. - // - // It is not kept in step with the scroll position, on purpose. It says where - // you are going, not where you are, and it starts at 1:1 in every book. - - function byId(id) { - return document.getElementById(id); - } - - function fillOptions(select, items) { - select.textContent = ''; - for (var i = 0; i < items.length; i++) { - var option = document.createElement('option'); - // The value is the id of the element to land on, so nothing downstream - // has to know how a verse anchor is spelled. - option.value = items[i].value; - option.textContent = items[i].label; - select.appendChild(option); - } - } - - function fillVerses(chapterId) { - var section = byId(chapterId); - var verses = section ? section.querySelectorAll('.v') : []; - var items = []; - for (var i = 0; i < verses.length; i++) { - items.push({ value: verses[i].id, - label: (verses[i].textContent || '').trim() }); - } - fillOptions(byId('reference-verse'), items); - } - - function fillReference() { - var picker = byId('reference'); - if (!picker) return; - var book = document.querySelector('#content article.book'); - picker.classList.toggle('d-none', !book); - picker.classList.toggle('d-flex', !!book); - if (!book) return; - - byId('reference-book').textContent = book.getAttribute('data-book') || ''; - - var chapters = book.querySelectorAll('.chapter[data-chapter]'); - var items = []; - for (var i = 0; i < chapters.length; i++) { - items.push({ value: chapters[i].id, - label: chapters[i].getAttribute('data-chapter') }); - } - fillOptions(byId('reference-chapter'), items); - if (items.length) fillVerses(items[0].value); - } - - // The URL is the whole mechanism: name the anchor, then land on it. Nothing - // is stored anywhere else, so a reload or a shared link ends up in the same - // place. - // - // replaceState rather than assigning location.hash: spinning through a - // select should not stack one history entry per option, and assigning the - // hash hands the scroll to the browser, which Bootstrap has set to smooth. - function goTo(id) { - var target = byId(id); - if (!target) return; - history.replaceState(null, '', '#' + id); - jumpTo(target); - } - - // Delegated at the document, so it survives every swap and binds once. - // Changing the chapter rebuilds the verses and lands on the chapter heading, - // which is what 1 is directly below; changing the verse lands on the verse. - document.addEventListener('change', function (event) { - var target = event.target; - if (!target || !target.id) return; - if (target.id === 'reference-chapter') { - fillVerses(target.value); - goTo(target.value); - } else if (target.id === 'reference-verse') { - goTo(target.value); - } + if (target.id === 'content') land(); }); // A history restore does not go through the request pipeline -- htmx swaps // the element straight in and fires this instead -- so the work afterSwap // does has to be repeated here or a page reached by the back button arrives - // with dead verse numbers. + // with dead verse numbers. The picker listens for the same event itself, + // from the navbar, because a restore reaches nothing outside #content. document.body.addEventListener('htmx:historyRestore', function () { markVerses(); - fillReference(); land(); }); @@ -289,7 +209,6 @@ // and it lands after htmx has processed the shell. document.addEventListener('DOMContentLoaded', function () { markVerses(); - fillReference(); scrollToHash(); // Registered from the page it is going to serve, not from a page of its diff --git a/assets/js/render.js b/assets/js/render.js index cc18057..990ac69 100644 --- a/assets/js/render.js +++ b/assets/js/render.js @@ -59,14 +59,20 @@ // content is already escaped -- compile_bible.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) { + // + // chrome is what the shell needs and the fragment cannot carry: the + // bookmark, and the navbar picker's model. Both are null from boot.html, + // which has no worker to ask for either, and both render as a hidden + // slot that fills itself if one ever appears. + compose: function (fragment, chrome) { 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 + mark: (chrome && chrome.mark) || null, + reference: (chrome && chrome.reference) || null }); }, diff --git a/assets/js/router.js b/assets/js/router.js index ea00693..0797b5c 100644 --- a/assets/js/router.js +++ b/assets/js/router.js @@ -37,8 +37,20 @@ // out of the response itself. this.restore = this.request.headers.get('HX-History-Restore-Request') === 'true'; + // The address the page making this request is showing. htmx stamps it on + // everything it sends; a navigation carries nothing, because there is no + // page yet. It is how a handler answering the chrome -- which htmx never + // swaps, and which therefore outlives the page it was rendered for -- can + // still find out which page that is. + this.current = this.request.headers.get('HX-Current-URL'); } + // That address as a URL, resolved against this request's own so a relative + // one still works. Null when there was no page. + Call.prototype.currentUrl = function () { + return this.current ? new URL(this.current, this.url) : null; + }; + Call.prototype.form = function () { return this.request.formData(); }; diff --git a/assets/sw.js b/assets/sw.js index 06dd8b7..1060f65 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -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(); diff --git a/assets/templates/base.njk b/assets/templates/base.njk index 6c2c76f..968d6c4 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -1,7 +1,8 @@ {# The shell every page is wrapped in. Rendered by sw.js, not by the build, so editing this file changes every page without regenerating any of them. - Context: title, content (safe HTML fragment), mark (bookmark or null). + Context: title, content (safe HTML fragment), mark (bookmark or null), + reference (the navbar picker's model, or null). Bootstrap is the whole stylesheet. The fragments carry semantic USFM classes and no Bootstrap ones, so the body text is styled by Reboot; the @@ -76,29 +77,7 @@ data-bs-theme="dark">