This commit is contained in:
mark committed 2026-09-08 08:24:21 -04:00
1 parent 9407c26d2d
commit bfb3c87558
5 files changed
+204 -129

No files matched your search

+25 -102
View File
@@ -1,99 +1,36 @@
// Page startup and verse modal wiring. // Page startup.
// //
// Pages arrive whole -- composed by the service worker, or by boot.html on the // 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. // visit before there is one -- so there is nothing here that builds a page.
// What is left is the four things a composed document cannot do for itself: // 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 // mark up the verse numbers as controls, put a swapped-in page where it
// where it belongs before it is painted, and tell the chrome when a worker has // belongs before it is painted, land the reference picker where the reader
// appeared to answer it. // asked to go, 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 // Two things used to live here and no longer do, both for the same reason:
// and verses off the book on the page. It is markup and htmx now: sw.js // they were reading the page to work out something the worker already knew.
// 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 // The reference picker was built by reading the chapters and verses off the
// worker, so all this file does for it is decide which verse was clicked, fill // book on screen. It is markup and htmx now: sw.js renders it from
// the form, and show it -- Bootstrap handles opening, dismissing and focus, // bible/verses.json and answers api/reference, and the address-bar update that
// and htmx carries the save. // a change event used to run through here is a response header on the answer.
// See templates/reference.njk.
//
// The verse dialog was built by scraping the book name out of article
// [data-name] and the chapter out of the enclosing section, then filling a
// form and showing it. sw.js answers api/verse with the whole of the dialog,
// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap
// opens it -- which is htmx's own bootstrap-modal example, with the click
// delegated because the trigger is one of 38,000 generated verse numbers and
// cannot carry attributes of its own. See templates/verse.njk.
//
// What is left of the dialog here is markVerses, and only because the numbers
// have to look and behave like controls before anyone can click one.
(function () { (function () {
'use strict'; 'use strict';
var currentUrl = '';
function modalEl() {
return document.getElementById('verse-dialog');
}
function status(text) {
var el = document.getElementById('verse-dialog-status');
if (el) el.textContent = text || '';
return el;
}
// navigator.clipboard only exists in a secure context, so over plain http
// this always falls through to a selectable field rather than failing.
function copyLink(url) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url).then(function () {
status('Link copied.');
}, function () { offerManualCopy(url); });
} else {
offerManualCopy(url);
}
}
function offerManualCopy(url) {
var el = status('Copy this link:');
if (!el) return;
var field = document.createElement('input');
field.type = 'text';
field.readOnly = true;
field.value = url;
field.className = 'verse-dialog-url form-control form-control-sm mt-2';
el.appendChild(field);
field.focus();
field.select();
}
// -- verse numbers ------------------------------------------------------ // -- verse numbers ------------------------------------------------------
function verseInfo(span) {
var article = document.querySelector('article.book');
var section = span.closest('.chapter');
var book = article
? (article.getAttribute('data-name') || article.getAttribute('data-book'))
: '';
var chapter = section ? section.getAttribute('data-chapter') : '';
var page = location.pathname.split('/').pop() || './';
return {
ref: book + ' ' + chapter + ':' + (span.textContent || '').trim(),
// Relative, so a saved bookmark survives a change of host or port.
href: page + '#' + span.id,
url: location.origin + location.pathname + '#' + span.id
};
}
function openFor(span) {
var el = modalEl();
if (!el || !window.bootstrap) return;
var info = verseInfo(span);
currentUrl = info.url;
document.getElementById('verse-dialog-ref').textContent = info.ref;
el.querySelector('input[name="ref"]').value = info.ref;
el.querySelector('input[name="href"]').value = info.href;
status('');
window.bootstrap.Modal.getOrCreateInstance(el).show();
}
function hideModal() {
var el = modalEl();
if (!el || !window.bootstrap) return;
var instance = window.bootstrap.Modal.getInstance(el);
if (instance) instance.hide();
}
// Applied here rather than baked into the HTML: without this script the // Applied here rather than baked into the HTML: without this script the
// numbers are plain text, and announcing them as buttons would lie. It also // numbers are plain text, and announcing them as buttons would lie. It also
// earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx // earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx
@@ -109,33 +46,19 @@
} }
// Delegated at the document, so it survives swaps and is bound only once. // Delegated at the document, so it survives swaps and is bound only once.
// Dismissal is not handled here: the close button carries data-bs-dismiss, // The verse numbers and the dialog's own buttons are not here: opening the
// and Bootstrap owns the backdrop and Escape. // dialog is an hx-on handler on #content, and everything inside it is
// markup that sw.js sends. This is the reset button and nothing else.
document.addEventListener('click', function (event) { document.addEventListener('click', function (event) {
if (!event.target.closest) return; if (!event.target.closest) return;
var verse = event.target.closest('.v');
if (verse && verse.id) { event.preventDefault(); openFor(verse); return; }
if (event.target.closest('[data-action="copy"]')) copyLink(currentUrl);
var reset = event.target.closest('#app-reset'); var reset = event.target.closest('#app-reset');
if (reset) resetApp(reset); if (reset) resetApp(reset);
}); });
document.addEventListener('keydown', function (event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
var el = document.activeElement;
if (el && el.classList && el.classList.contains('v') && el.id) {
event.preventDefault();
openFor(el);
}
});
document.body.addEventListener('htmx:afterSwap', function (event) { document.body.addEventListener('htmx:afterSwap', function (event) {
markVerses(event.target); markVerses(event.target);
var target = event.detail.target; var target = event.detail.target;
if (!target) return; if (!target) return;
// 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);
// Both selects answer into the verse list, so this is either of them. // Both selects answer into the verse list, so this is either of them.
if (target.id === 'reference-verse') return landOnReference(); if (target.id === 'reference-verse') return landOnReference();
if (target.id === 'content') { land(); showVersion(); } if (target.id === 'content') { land(); showVersion(); }
+1
View File
@@ -37,6 +37,7 @@
'bookmark.njk', 'bookmark.njk',
'reference.njk', 'reference.njk',
'verses.njk', 'verses.njk',
'verse.njk',
'version.njk', 'version.njk',
'status.njk', 'status.njk',
'notice.njk' 'notice.njk'
+64 -2
View File
@@ -12,6 +12,7 @@
// GET /api/bookmark the navbar slot // GET /api/bookmark the navbar slot
// POST /api/bookmark set the bookmark, and return the slot // POST /api/bookmark set the bookmark, and return the slot
// GET /api/reference the navbar's chapter and verse picker // GET /api/reference the navbar's chapter and verse picker
// GET /api/verse the dialog for one verse
// //
// Anything with a dot in its last segment is a file, not a route, and is // 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 // served from the cache or the network without going near the router. Pages
@@ -371,6 +372,40 @@ async function referenceOf(code, anchor) {
} }
} }
// ------------------------------------------------------------------- verse
// The verse dialog's model: what to call this verse, and the two addresses for
// it. Null for anything that is not a verse of the book on screen.
//
// Checked against verses.json rather than taken at its word. at arrives from
// the page, and a dialog offering to bookmark an anchor the corpus does not
// have is a dialog offering a bookmark that lands nowhere -- so it is read
// back through the same map the options were built from, and the label comes
// out of that map rather than out of the id. Which is what makes a verse
// written "19-27" read as 26:19-27 and not as whatever splitting an id on an
// underscore would produce.
//
// href is relative, so a bookmark survives a change of host or port; url is
// absolute, because the only thing anyone does with it is paste it somewhere
// else. Both are spelled from the address bar, which is the only record of
// which book is on screen.
async function verseOf(page, at) {
const code = bookOf(page);
const anchor = parseAnchor(at);
if (!page || !code || !anchor || !anchor.verse) return null;
const reference = await referenceOf(code, at);
if (!reference) return null;
const option = reference.verses.find((verse) => verse.id === at);
if (!option) return null;
return {
ref: code + ' ' + anchor.chapter + ':' + option.label,
href: code + '#' + at,
url: page.origin + page.pathname + '#' + at
};
}
const OFFLINE_NOTICE = ['Not available offline', const OFFLINE_NOTICE = ['Not available offline',
'This page has not been opened on this device, so there is no copy stored ' + '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.']; 'here. Reconnect and try again, or open one you have read before.'];
@@ -516,6 +551,24 @@ router.get('/api/reference', async (call) => {
}); });
}); });
// The dialog for one verse.
//
// at=v1_3 which verse, by the id of the element that is it -- the same
// spelling the picker's options use, read back the same way.
//
// The book is not in the URL, for the reason it is not in the picker's: this
// is chrome, it outlives the page under it, and HX-Current-URL is the address
// the page is actually showing.
//
// 204 for anything it cannot place. htmx swaps nothing on a 204, and the
// dialog opens on the swap, so a bad anchor is a click that does nothing
// rather than an empty dialog asking to bookmark it.
router.get('/api/verse', async (call) => {
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
if (!verse) return new Response('', { status: 204 });
return view.render('verse.njk', { verse: verse });
});
router.post('/api/bookmark', async (call) => { router.post('/api/bookmark', async (call) => {
try { try {
const form = await call.form(); const form = await call.form();
@@ -524,7 +577,16 @@ router.post('/api/bookmark', async (call) => {
if (!ref || !href) return new Response('', { status: 400 }); if (!ref || !href) return new Response('', { status: 400 });
await withStore('readwrite', await withStore('readwrite',
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY)); (s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
return bookmarkFragment('Bookmark set.'); // Only on the way out of a write that committed. base.njk closes the
// dialog on this, and a dialog that closed itself on a failure would take
// the line explaining the failure with it.
return new Response(await bookmarkFragment('Bookmark set.'), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'HX-Trigger-After-Settle': 'bookmark-saved'
}
});
} catch (err) { } catch (err) {
return bookmarkFragment('Could not save: storage unavailable.'); return bookmarkFragment('Could not save: storage unavailable.');
} }
@@ -596,7 +658,7 @@ const BOOKMARK_KEY = 'bookmark';
// who has never set one is treated as being at the beginning. Everything // who has never set one is treated as being at the beginning. Everything
// downstream can assume a mark -- bookmark.njk's empty branch is for the boot // 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. // page, which has no worker to ask, not for a reader without one.
const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN#v1_1' }; const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' };
function openDb() { function openDb() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+54 -25
View File
@@ -100,39 +100,68 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
{# The swap target. hx-target on <body> names this id, so every boosted {# The swap target. hx-target on <body> names this id, so every boosted
link lands here -- remove it and a click does nothing at all while a link lands here -- remove it and a click does nothing at all while a
full navigation still works. #} full navigation still works.
<div id="content" class="col-12 col-xl-8" hx-history-elt>
It is also where a verse click becomes a request. htmx's bootstrap-modal
example puts hx-get on the element that opens the dialog; the element
here is a verse number in a generated book, and there are 38,000 of
them -- an attribute apiece would cost more than the whole shell, and a
listener apiece more than the reading. So one handler sits on the
container they all swap into, and names the verse it found. Delegated,
so it survives every swap: this element is never replaced, only filled.
hx-vals cannot do this. htmx evaluates it as Function("return (...)")()
with nothing passed in, so an expression there can read the document but
not the click -- and which verse was clicked is the one thing that has
to travel.
Enter and space go through the same door rather than a second one:
markVerses() in app.js gives every verse role=button and tabindex, and
a button that answers the keyboard by being clicked is a button with one
behaviour instead of two. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt
hx-on:click="var verse = event.target.closest('.v[id]');
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
{ target: '#verse-dialog .modal-content',
swap: 'innerHTML' });"
hx-on:keydown="if (event.key !== 'Enter' &amp;&amp; event.key !== ' ') return;
if (!event.target.matches('.v[id]')) return;
event.preventDefault();
event.target.click();">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>
{% include "version.njk" %} {% include "version.njk" %}
</main> </main>
{# Chrome, so it is written once here instead of built in JavaScript on every {# The verse dialog: empty in the page, filled per request. Bootstrap owns
page. app.js only fills in the verse it was opened for and shows it. #} showing it, dismissing it, the backdrop and Escape; sw.js owns everything
inside, and templates/verse.njk is what lands in the modal-content below.
Opened by the arrival of the content rather than by data-bs-toggle on
whatever was clicked. The example puts that attribute on its trigger, which
is not available here for the same reason hx-get is not -- see #content
above -- and the swap is the honest signal anyway: there is nothing worth
showing until it is here. A verse the corpus does not have is answered 204,
which swaps nothing, which opens nothing.
after-swap and not after-settle: settling is the 20ms attribute pass and
this dialog has nothing to settle. Only the modal-content swap can reach
here -- the bookmark write targets the navbar slot, and the status line it
sends back rides in out of band, which fires its own event and not this one.
bookmark-saved comes from sw.js once the write has committed, and only then:
a dialog that closed itself on a failed write would take the message
explaining the failure with it. #}
<div class="modal fade" id="verse-dialog" tabindex="-1" <div class="modal fade" id="verse-dialog" tabindex="-1"
aria-labelledby="verse-dialog-ref" aria-hidden="true"> aria-labelledby="verse-dialog-ref" aria-hidden="true"
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
hx-on:bookmark-saved="var dialog = this;
setTimeout(function () {
bootstrap.Modal.getOrCreateInstance(dialog).hide();
}, 900);">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content"></div>
<div class="modal-header">
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref"></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
{% include "status.njk" %}
</div>
<div class="modal-footer">
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="">
<input type="hidden" name="href" value="">
<button class="btn btn-outline-secondary" type="button"
data-action="copy">Copy link</button>
<button class="btn btn-primary" type="submit">Set bookmark</button>
</form>
</div>
</div>
</div> </div>
</div> </div>
+60
View File
@@ -0,0 +1,60 @@
{# The verse dialog's contents: what this verse is called, a link to it, and
the one thing you can do with it.
Rendered by sw.js for the verse that was clicked and swapped into the empty
modal-content in base.njk -- the shape htmx's bootstrap-modal example uses.
Bootstrap owns showing, dismissing, the backdrop and Escape; everything
here is what is inside.
Context: verse (ref, href, url).
All three are built in the worker, from the book in the address bar and the
anchor that was clicked. app.js used to read them off the page -- the book
out of article[data-name], the chapter out of the enclosing section -- which
put the reference in two places and left the spelling of an anchor known to
both the file that generates them and the file that scrapes them back.
The link is a field rather than a line of text because it is the fallback
for the button beside it: navigator.clipboard exists only in a secure
context, so over plain http:// from a LAN address there is nothing to write
with and a selected field is the whole of what is left. Readonly, so it
reads as something to take rather than something to fill in. #}
<div class="modal-header">
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="visually-hidden" for="verse-dialog-url">Link to {{ verse.ref }}</label>
<input class="verse-dialog-url form-control form-control-sm" id="verse-dialog-url"
type="text" readonly value="{{ verse.url }}">
{% include "status.njk" %}
</div>
<div class="modal-footer">
{# The form is the bookmark write and nothing else. ref and href are hidden
because they are this dialog's whole subject: the reader chose them by
clicking a verse, and there is nothing here to edit. #}
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="{{ verse.ref }}">
<input type="hidden" name="href" value="{{ verse.href }}">
{# Selected first, written second: the selection is what makes the field
useful when the write is refused, and it costs nothing when it is not.
The status line is only told once the clipboard has actually taken it,
which is why the promise is answered rather than assumed. #}
<button class="btn btn-outline-secondary" type="button"
hx-on:click="var field = document.getElementById('verse-dialog-url');
var line = document.getElementById('verse-dialog-status');
field.focus();
field.select();
if (navigator.clipboard) {
navigator.clipboard.writeText(field.value).then(
function () { line.textContent = 'Link copied.'; },
function () { line.textContent = 'Copy the link above.'; });
} else {
line.textContent = document.execCommand('copy')
? 'Link copied.' : 'Copy the link above.';
}">Copy link</button>
<button class="btn btn-primary" type="submit">Set bookmark</button>
</form>
</div>