Move verse selector to htmx
This commit is contained in:
1 parent
dd0021ce15
commit
ba3b588ac5
9 files changed
+390
-131
No files matched your search
+16
-97
@@ -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
|
||||
|
||||
+8
-2
@@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
+175
-5
@@ -11,6 +11,7 @@
|
||||
// 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();
|
||||
|
||||
@@ -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">
|
||||
<div class="container-xl">
|
||||
<a class="navbar-brand" href="./">Bible</a>
|
||||
{# The reference picker: which book you are in, and where in it to go.
|
||||
Outside the collapse deliberately -- it is the main way of moving around
|
||||
a book now that the chapter list is gone from the page, and a chapter
|
||||
picker behind a hamburger is no use on a phone.
|
||||
|
||||
Empty here and filled by app.js from the book on the page. The verse
|
||||
list has to be rebuilt in the browser every time the chapter changes, so
|
||||
there is nothing for a template to usefully render first.
|
||||
|
||||
d-none for the empty state rather than the hidden attribute: Bootstrap
|
||||
declares .d-flex after Reboot's [hidden] rule, both !important and at
|
||||
the same specificity, so the utility would win. #}
|
||||
<div class="reference navbar-text d-none align-items-center gap-1 ms-3"
|
||||
id="reference">
|
||||
<span class="reference-book text-light fw-semibold" id="reference-book"></span>
|
||||
<label class="visually-hidden" for="reference-chapter">Chapter</label>
|
||||
<select class="reference-chapter form-select form-select-sm"
|
||||
id="reference-chapter"></select>
|
||||
<span class="reference-sep text-light">:</span>
|
||||
<label class="visually-hidden" for="reference-verse">Verse</label>
|
||||
<select class="reference-verse form-select form-select-sm"
|
||||
id="reference-verse"></select>
|
||||
</div>
|
||||
{% include "reference.njk" %}
|
||||
<button class="navbar-toggler" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#site-nav"
|
||||
aria-controls="site-nav" aria-expanded="false"
|
||||
@@ -129,7 +108,7 @@
|
||||
</main>
|
||||
|
||||
{# Chrome, so it is written once here instead of built in JavaScript on every
|
||||
page. app.js only fills in the reference and shows it. #}
|
||||
page. app.js only fills in the verse it was opened for and shows it. #}
|
||||
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||
aria-labelledby="verse-dialog-ref" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{# The navbar's reference picker: which book you are in, and where in it to go.
|
||||
|
||||
Context: reference (or null), oob.
|
||||
|
||||
reference is built by sw.js from bible/verses.json, which compile_bible.py
|
||||
writes from the same tree it renders the books from -- so an option can
|
||||
never name an anchor that is not in the page it lands on.
|
||||
|
||||
It used to be built in the browser, by app.js, reading the chapters and
|
||||
verses off the book already on screen. That was free, and it only worked
|
||||
where there was a document to read: this shell is composed in a service
|
||||
worker, which has no DOM, before any document exists. What the picker needs
|
||||
is a fact on disk now, and everything below is markup and htmx.
|
||||
|
||||
Outside the collapse deliberately -- it is the main way of moving around a
|
||||
book now that the chapter list is gone from the page, and a chapter picker
|
||||
behind a hamburger is no use on a phone.
|
||||
|
||||
d-none for the empty state rather than the hidden attribute: Bootstrap
|
||||
declares .d-flex after Reboot's [hidden] rule, both !important and at the
|
||||
same specificity, so the utility would win.
|
||||
|
||||
Two things bring it back into step with the page, because the navbar is
|
||||
never the swap target -- every boosted link lands in #content:
|
||||
|
||||
a navigation sw.js appends this template to the fragment with oob set,
|
||||
the same way a bookmark write carries the modal's status
|
||||
line, so one response moves both.
|
||||
a restore htmx swaps the history element straight in and processes
|
||||
no out-of-band elements at all, so the picker asks for
|
||||
itself instead. worker-ready is in the same trigger for
|
||||
the empty state below.
|
||||
|
||||
Neither carries the book: the request does, in HX-Current-URL, which is the
|
||||
address the page is actually showing. An address baked into this attribute
|
||||
is the one thing here that can go stale, because the picker outlives the
|
||||
page it was rendered for. #}
|
||||
<div class="reference navbar-text {% if reference %}d-flex{% else %}d-none{% endif %} align-items-center gap-1 ms-3"
|
||||
id="reference"
|
||||
{%- if oob %} hx-swap-oob="true"{% endif %}
|
||||
hx-get="api/reference"
|
||||
hx-trigger="worker-ready from:body, htmx:historyRestore from:body"
|
||||
hx-target="this" hx-swap="outerHTML">
|
||||
{%- if reference %}
|
||||
<span class="reference-book text-light fw-semibold"
|
||||
id="reference-book">{{ reference.book }}</span>
|
||||
<label class="visually-hidden" for="reference-chapter">Chapter</label>
|
||||
{# The value is the id of the element to land on, so nothing downstream has
|
||||
to know how a verse anchor is spelled -- including the worker, which
|
||||
reads the chapter back out of the id it sent.
|
||||
|
||||
hx-target is spelled out on both selects because the one on the div above
|
||||
is "this", and an inherited "this" would resolve to the select. #}
|
||||
<select class="reference-chapter form-select form-select-sm"
|
||||
id="reference-chapter" name="chapter"
|
||||
hx-get="api/reference" hx-target="#reference-verse"
|
||||
hx-swap="innerHTML">
|
||||
{%- for chapter in reference.chapters %}
|
||||
<option value="{{ chapter.id }}"
|
||||
{%- if chapter.id == reference.chapter %} selected{% endif %}>{{ chapter.label }}</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<span class="reference-sep text-light">:</span>
|
||||
<label class="visually-hidden" for="reference-verse">Verse</label>
|
||||
{# Answers with its own options again rather than nothing, so the response
|
||||
has something to hang the scroll on -- and so the option the reader chose
|
||||
comes back marked selected instead of being reset by the swap. #}
|
||||
<select class="reference-verse form-select form-select-sm"
|
||||
id="reference-verse" name="verse"
|
||||
hx-get="api/reference" hx-target="this" hx-swap="innerHTML">
|
||||
{% include "verses.njk" %}
|
||||
</select>
|
||||
{%- endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
{# The verse select's options. Rendered into the picker by reference.njk, and
|
||||
returned on their own by api/reference after a chapter or verse change.
|
||||
Both paths must produce the same options for the swap to be seamless. #}
|
||||
{% for verse in reference.verses -%}
|
||||
<option value="{{ verse.id }}"
|
||||
{%- if verse.id == reference.verse %} selected{% endif %}>{{ verse.label }}</option>
|
||||
{% endfor -%}
|
||||
@@ -53,8 +53,10 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
# Revalidate every time: these are what carry an update to an installed app.
|
||||
NO_CACHE_NAMES = {"sw.js"}
|
||||
# .njk is in here because the worker reads templates at runtime: a stale one
|
||||
# would outlive the version that replaced it.
|
||||
NO_CACHE_EXTS = {".html", ".njk", ".webmanifest"}
|
||||
# would outlive the version that replaced it. .json is verses.json, which
|
||||
# compile_bible.py generates beside the pages and which describes them, so it
|
||||
# goes stale exactly when they do.
|
||||
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
|
||||
|
||||
# Served in place of any page a browser navigates to.
|
||||
BOOT_PAGE = "/boot.html"
|
||||
|
||||
@@ -45,6 +45,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -777,6 +778,91 @@ def render_index(books, translation, out_dir):
|
||||
return path
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# WRITING -- the reference map
|
||||
# ==========================================================================
|
||||
|
||||
# bible/verses.json: every book's chapters, and every chapter's verse labels.
|
||||
#
|
||||
# The navbar's reference picker is built from this, by the service worker. It
|
||||
# used to be built in the browser by reading the book already on the page --
|
||||
# the chapters were the sections and a chapter's verses were the anchors inside
|
||||
# it -- which was free but only worked where there was a document to read. A
|
||||
# worker has no DOM, and the picker now lives in a shell the worker composes
|
||||
# before any document exists, so what the picker needs has to be a fact on
|
||||
# disk rather than something recovered from markup.
|
||||
#
|
||||
# Written here, from the same tree the fragments are rendered from, so the two
|
||||
# cannot disagree: every option this file produces names an anchor render_book
|
||||
# wrote, because both come from the same verse nodes.
|
||||
#
|
||||
# The encoding is compact on purpose. A chapter whose verses run 1..n is
|
||||
# written 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:
|
||||
#
|
||||
# {"GEN": [31, 25, 24, ...], "SIR": [..., ["1", ..., "19-27", "28", "29"]]}
|
||||
#
|
||||
# That is 6 KB for 38,000 verses against 180 KB of spelled-out labels, which is
|
||||
# the difference between a file the worker can parse every time it wakes and
|
||||
# one it would have to find somewhere to keep.
|
||||
#
|
||||
# Chapter numbers are not in the file at all: they are the array index plus
|
||||
# one. render_verses checks that, rather than assuming it.
|
||||
|
||||
VERSES_FILE = "verses.json"
|
||||
|
||||
|
||||
def chapter_verses(chapter):
|
||||
"""The verse labels of one chapter, in document order.
|
||||
|
||||
Blocks only. Footnotes hang inside the chapter section too, but a note's
|
||||
content never carries a verse node -- and if one ever did, it would be a
|
||||
citation rather than a place in the text.
|
||||
"""
|
||||
labels = []
|
||||
|
||||
def walk(nodes):
|
||||
for node in nodes:
|
||||
if node["t"] == "verse":
|
||||
labels.append(node["label"])
|
||||
elif "c" in node:
|
||||
walk(node["c"])
|
||||
|
||||
for block in chapter["blocks"]:
|
||||
walk(block.get("content", []))
|
||||
return labels
|
||||
|
||||
|
||||
def render_verses(books, out_dir):
|
||||
"""Write the reference map. Returns its path and the verses in it.
|
||||
|
||||
See the note above for the encoding.
|
||||
"""
|
||||
out = {}
|
||||
total = 0
|
||||
for book in books:
|
||||
numbers = [c["number"] for c in book["chapters"]]
|
||||
if numbers != list(range(1, len(numbers) + 1)):
|
||||
# The map addresses chapters by position, so a book that skips or
|
||||
# repeats one would silently mislabel every option after the gap.
|
||||
# Better to stop the build than to ship a picker that lies.
|
||||
raise RuntimeError("%s: chapters are not 1..%d (%s)"
|
||||
% (book["code"], len(numbers), numbers))
|
||||
chapters = []
|
||||
for chapter in book["chapters"]:
|
||||
labels = chapter_verses(chapter)
|
||||
plain = [str(i + 1) for i in range(len(labels))]
|
||||
chapters.append(len(labels) if labels == plain else labels)
|
||||
total += len(labels)
|
||||
out[book["code"]] = chapters
|
||||
|
||||
path = os.path.join(out_dir, VERSES_FILE)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, separators=(",", ":"), ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
return path, total
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Entry point
|
||||
# ==========================================================================
|
||||
@@ -797,6 +883,7 @@ def main(argv=None):
|
||||
for book in data["books"]:
|
||||
total += os.path.getsize(render_book(book, data["translation"], args.out))
|
||||
render_index(data["books"], data["translation"], args.out)
|
||||
verses, count = render_verses(data["books"], args.out)
|
||||
|
||||
chapters = sum(len(b["chapters"]) for b in data["books"])
|
||||
notes = sum(len(c["notes"]) for b in data["books"] for c in b["chapters"])
|
||||
@@ -807,6 +894,9 @@ def main(argv=None):
|
||||
print("skipped %d (contents pages)" % len(skipped), file=sys.stderr)
|
||||
print("html %.1f MB total, %.0f KB average"
|
||||
% (total / 1e6, total / len(data["books"]) / 1e3), file=sys.stderr)
|
||||
print("verses %d in %s (%.1f KB)"
|
||||
% (count, os.path.basename(verses), os.path.getsize(verses) / 1e3),
|
||||
file=sys.stderr)
|
||||
print("wrote %s/" % args.out.rstrip("/"), file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
Reference in new issue
Block a user