Fix
This commit is contained in:
1 parent
844f86cda0
commit
77bf8d9f2e
109 files changed
+934
-3325
No files matched your search
+66
-127
@@ -1,107 +1,28 @@
|
||||
// Bookmark UI. Deliberately small and dependency-free: the state is one
|
||||
// verse reference, so it lives in localStorage rather than IndexedDB --
|
||||
// localStorage reads synchronously, which lets the index page paint the
|
||||
// bookmark link on first paint instead of flashing an empty slot.
|
||||
// Verse modal wiring. The modal markup lives in base.njk and the bookmark
|
||||
// lives in the service worker, so all this file does 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';
|
||||
|
||||
var KEY = 'bible:bookmark';
|
||||
var dialog = null;
|
||||
var current = null;
|
||||
var currentUrl = '';
|
||||
|
||||
// localStorage throws (not returns null) in some private-browsing modes,
|
||||
// so every access is guarded and the feature degrades to a no-op.
|
||||
function readBookmark() {
|
||||
try { return JSON.parse(localStorage.getItem(KEY) || 'null'); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function writeBookmark(value) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(value)); return true; }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
|
||||
// -- index page ---------------------------------------------------------
|
||||
|
||||
function renderBookmarkLink() {
|
||||
var slot = document.getElementById('bookmark');
|
||||
if (!slot) return;
|
||||
var mark = readBookmark();
|
||||
slot.textContent = '';
|
||||
if (!mark || !mark.href) { slot.hidden = true; return; }
|
||||
var label = document.createElement('span');
|
||||
label.className = 'bookmark-label';
|
||||
label.textContent = 'Bookmark';
|
||||
var link = document.createElement('a');
|
||||
link.className = 'bookmark-link';
|
||||
link.href = mark.href;
|
||||
link.textContent = mark.ref;
|
||||
slot.appendChild(label);
|
||||
slot.appendChild(document.createTextNode(' '));
|
||||
slot.appendChild(link);
|
||||
slot.hidden = false;
|
||||
}
|
||||
|
||||
// -- dialog -------------------------------------------------------------
|
||||
|
||||
function buildDialog() {
|
||||
var d = document.createElement('dialog');
|
||||
d.id = 'verse-dialog';
|
||||
d.className = 'verse-dialog';
|
||||
d.innerHTML =
|
||||
'<h2 class="verse-dialog-ref"></h2>' +
|
||||
'<p class="verse-dialog-note" hidden>Currently bookmarked.</p>' +
|
||||
'<div class="verse-dialog-actions">' +
|
||||
'<button type="button" data-action="bookmark">Set bookmark</button>' +
|
||||
'<button type="button" data-action="copy">Copy link</button>' +
|
||||
'<button type="button" data-action="close">Close</button>' +
|
||||
'</div>' +
|
||||
'<p class="verse-dialog-status" role="status" aria-live="polite"></p>';
|
||||
document.body.appendChild(d);
|
||||
d.addEventListener('click', onDialogClick);
|
||||
return d;
|
||||
function modalEl() {
|
||||
return document.getElementById('verse-dialog');
|
||||
}
|
||||
|
||||
function status(text) {
|
||||
var el = dialog.querySelector('.verse-dialog-status');
|
||||
el.textContent = text || '';
|
||||
var el = document.getElementById('verse-dialog-status');
|
||||
if (el) el.textContent = text || '';
|
||||
return el;
|
||||
}
|
||||
|
||||
function closeSoon() {
|
||||
setTimeout(function () { if (dialog.open) dialog.close(); }, 900);
|
||||
}
|
||||
|
||||
function onDialogClick(event) {
|
||||
// A click landing on the dialog itself is the backdrop, not the content.
|
||||
if (event.target === dialog) { dialog.close(); return; }
|
||||
|
||||
var button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
var action = button.getAttribute('data-action');
|
||||
|
||||
if (action === 'close') {
|
||||
dialog.close();
|
||||
} else if (action === 'bookmark') {
|
||||
var saved = writeBookmark({
|
||||
ref: current.ref, href: current.href, savedAt: Date.now()
|
||||
});
|
||||
status(saved ? 'Bookmark set.' : 'Could not save: storage unavailable.');
|
||||
renderBookmarkLink();
|
||||
dialog.querySelector('.verse-dialog-note').hidden = !saved;
|
||||
if (saved) closeSoon();
|
||||
} else if (action === 'copy') {
|
||||
copyLink(current.url);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.');
|
||||
closeSoon();
|
||||
}, function () { offerManualCopy(url); });
|
||||
} else {
|
||||
offerManualCopy(url);
|
||||
@@ -110,12 +31,12 @@
|
||||
|
||||
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';
|
||||
el.appendChild(document.createTextNode(' '));
|
||||
field.className = 'verse-dialog-url form-control form-control-sm mt-2';
|
||||
el.appendChild(field);
|
||||
field.focus();
|
||||
field.select();
|
||||
@@ -140,55 +61,73 @@
|
||||
}
|
||||
|
||||
function openFor(span) {
|
||||
if (!dialog) dialog = buildDialog();
|
||||
current = verseInfo(span);
|
||||
dialog.querySelector('.verse-dialog-ref').textContent = current.ref;
|
||||
var mark = readBookmark();
|
||||
dialog.querySelector('.verse-dialog-note').hidden =
|
||||
!(mark && mark.href === current.href);
|
||||
var el = modalEl();
|
||||
if (!el || !window.bootstrap) return;
|
||||
var info = verseInfo(span);
|
||||
currentUrl = info.url;
|
||||
el.querySelector('.verse-dialog-ref').textContent = info.ref;
|
||||
el.querySelector('input[name="ref"]').value = info.ref;
|
||||
el.querySelector('input[name="href"]').value = info.href;
|
||||
status('');
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
window.bootstrap.Modal.getOrCreateInstance(el).show();
|
||||
}
|
||||
|
||||
function initVerses() {
|
||||
var verses = document.querySelectorAll('.chapter .v');
|
||||
if (!verses.length) return;
|
||||
// Applied here rather than baked into the HTML: without this script the
|
||||
// numbers are plain text, and announcing them as buttons would lie.
|
||||
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
|
||||
// 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
|
||||
// swap, because the new verses arrive bare.
|
||||
function markVerses(root) {
|
||||
var verses = (root || document).querySelectorAll('.chapter .v');
|
||||
for (var i = 0; i < verses.length; i++) {
|
||||
verses[i].setAttribute('role', 'button');
|
||||
verses[i].setAttribute('tabindex', '0');
|
||||
verses[i].setAttribute('aria-label',
|
||||
'Verse ' + (verses[i].textContent || '').trim() + ', options');
|
||||
}
|
||||
document.addEventListener('click', function (event) {
|
||||
var verse = event.target.closest ? event.target.closest('.v') : null;
|
||||
if (verse && verse.id) { event.preventDefault(); openFor(verse); }
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
renderBookmarkLink();
|
||||
initVerses();
|
||||
}
|
||||
|
||||
// Another tab changing the bookmark updates this one's index link.
|
||||
window.addEventListener('storage', function (event) {
|
||||
if (event.key === KEY) renderBookmarkLink();
|
||||
// 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,
|
||||
// and Bootstrap owns the backdrop and Escape.
|
||||
document.addEventListener('click', function (event) {
|
||||
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);
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Close the modal once a bookmark write has come back and swapped, leaving
|
||||
// the status line up long enough to be read.
|
||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||
markVerses(event.target);
|
||||
if (event.detail.target && event.detail.target.id === 'bookmark') {
|
||||
setTimeout(hideModal, 900);
|
||||
}
|
||||
});
|
||||
|
||||
markVerses(document);
|
||||
|
||||
// The shell already carries the bookmark, so this is only for the case where
|
||||
// the worker started controlling the page after it was rendered.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(function () {
|
||||
document.body.dispatchEvent(new CustomEvent('bookmark-load'));
|
||||
}).catch(function () {});
|
||||
}
|
||||
})();
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{# 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).
|
||||
|
||||
Bootstrap is the whole stylesheet. The fragments carry semantic USFM
|
||||
classes and no Bootstrap ones, so the body text is styled by Reboot; the
|
||||
chrome here is what uses the framework. #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{% block title %}{{ title | safe }}{% endblock %}</title>
|
||||
{# Vendored rather than linked from a CDN: sw.js only caches same-origin
|
||||
responses, so a CDN stylesheet would leave the app unstyled offline. #}
|
||||
<link rel="stylesheet" href="bootstrap.min.css">
|
||||
{# History entries are whole books, and htmx keeps its history cache in
|
||||
localStorage -- a couple of them would blow the quota. Turned off: going
|
||||
back re-requests the fragment, which the worker answers from its cache. #}
|
||||
<meta name="htmx-config" content='{"historyCacheSize":0}'>
|
||||
{# The bundle carries Popper, which the navbar collapse and the modal do not
|
||||
need -- but a dropdown or tooltip added later would, and this is one file
|
||||
in the precache either way. Deferred scripts run in order, so `bootstrap`
|
||||
is defined by the time app.js runs. #}
|
||||
<script src="bootstrap.bundle.min.js" defer></script>
|
||||
{# htmx is not deferred: hx-boost has to be in place before the first click. #}
|
||||
<script src="htmx.min.js"></script>
|
||||
<script src="app.js" defer></script>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Bible">
|
||||
</head>
|
||||
{# Boosted links swap only the fragment, so the navbar, the modal and the
|
||||
scripts above survive navigation and never reload. #}
|
||||
<body class="{% block body_class %}{% endblock %} bg-body-tertiary"
|
||||
hx-boost="true" hx-target="#content" hx-swap="innerHTML show:top">
|
||||
|
||||
<nav class="site-header navbar navbar-expand-lg bg-dark sticky-top"
|
||||
data-bs-theme="dark">
|
||||
<div class="container-xl">
|
||||
<a class="navbar-brand" href="index.html">Bible</a>
|
||||
<button class="navbar-toggler" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#site-nav"
|
||||
aria-controls="site-nav" aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="site-nav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% block navbar_nav %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
{% include "bookmark.njk" %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{# col-xl-8 keeps a line of verse near a readable measure instead of running
|
||||
the full width of a desktop window. #}
|
||||
<main class="container-xl my-4">
|
||||
<div class="row justify-content-center">
|
||||
<div id="content" class="col-12 col-xl-8 bg-body rounded-3 shadow-sm p-3 p-md-5">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</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. #}
|
||||
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||
aria-labelledby="verse-dialog-ref" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block body_class %}reading{% endblock %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{# Returned on its own by GET/POST/DELETE api/bookmark, and included in the
|
||||
shell so a normal navigation paints the bookmark without a second request.
|
||||
Both paths must produce the same element for the swap to be seamless.
|
||||
|
||||
outerHTML is the swap, so this element re-declares its own htmx attributes
|
||||
every time: 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.
|
||||
|
||||
Empty state is d-none, not the hidden attribute: Bootstrap declares
|
||||
.d-flex after Reboot's [hidden] rule, both !important and at the same
|
||||
specificity, so the utility would win. #}
|
||||
<span class="bookmark navbar-text {% if mark %}d-flex{% else %}d-none{% endif %} align-items-center gap-2"
|
||||
id="bookmark"
|
||||
hx-get="api/bookmark" hx-trigger="bookmark-load from:body"
|
||||
hx-target="this" hx-swap="outerHTML">
|
||||
{%- if mark %}
|
||||
<span class="bookmark-label badge text-bg-secondary">Bookmark</span>
|
||||
{# Not boosted: a real navigation lets the browser scroll to the verse
|
||||
anchor, which a swap into #content would not do. #}
|
||||
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
|
||||
hx-boost="false">{{ mark.ref }}</a>
|
||||
<button type="button" class="bookmark-clear btn btn-sm btn-outline-light py-0"
|
||||
hx-delete="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">Clear</button>
|
||||
{%- endif %}
|
||||
</span>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>World English Bible Updated</title>
|
||||
<link rel="stylesheet" href="bootstrap.min.css">
|
||||
<meta name="theme-color" content="#1a202c">
|
||||
</head>
|
||||
<body class="bg-body-tertiary">
|
||||
<!-- Pages are fragments now: the shell is assembled by the service worker, so
|
||||
a first visit has nothing to render until the worker is installed and
|
||||
controlling. This page installs it and reloads into the real app. -->
|
||||
<main class="boot container py-5 text-center">
|
||||
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
|
||||
<p id="boot-status" class="text-body-secondary">Preparing the offline copy…</p>
|
||||
<noscript><p class="alert alert-warning">This app assembles its pages in the browser and needs JavaScript.</p></noscript>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var status = document.getElementById('boot-status');
|
||||
function fail(text) { status.textContent = text; }
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
fail('This browser has no service worker support, which this app needs.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Guards against a reload loop if the worker activates but never claims:
|
||||
// better to sit on this page with a message than to spin.
|
||||
var tries = 0;
|
||||
try { tries = parseInt(sessionStorage.getItem('boot:tries') || '0', 10); }
|
||||
catch (e) { tries = 0; }
|
||||
if (tries >= 2) {
|
||||
fail('The offline copy could not start. Try clearing this site’s data.');
|
||||
return;
|
||||
}
|
||||
try { sessionStorage.setItem('boot:tries', String(tries + 1)); } catch (e) {}
|
||||
|
||||
function go() {
|
||||
try { sessionStorage.removeItem('boot:tries'); } catch (e) {}
|
||||
location.reload();
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register('sw.js').then(function () {
|
||||
// controller is what actually matters: ready can resolve a tick before
|
||||
// clients.claim() takes effect, and reloading then lands here again.
|
||||
if (navigator.serviceWorker.controller) return go();
|
||||
navigator.serviceWorker.addEventListener('controllerchange', go);
|
||||
setTimeout(function () {
|
||||
if (navigator.serviceWorker.controller) go();
|
||||
else fail('The offline copy is taking longer than expected. Reload to retry.');
|
||||
}, 10000);
|
||||
}, function (err) {
|
||||
fail('Could not install the offline copy: ' + err.message);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,(t,e)=>`#${CSS.escape(e)}`)),t),s=t=>null==t?`${t}`:Object.prototype.toString.call(t).match(/\s([a-z]+)/i)[1].toLowerCase(),o=t=>{t.dispatchEvent(new Event(i))},r=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),a=t=>r(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,l=t=>{if(!r(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",()=>{for(const t of p)t()}),p.push(e)):e()},_=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,b=(t,e,n=!0)=>{if(!n)return void _(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),_(t))};e.addEventListener(i,a),setTimeout(()=>{r||o(e)},s)},v=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,A=/::\d+$/,E={};let T=1;const C={mouseenter:"mouseover",mouseleave:"mouseout"},O=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function x(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function k(t){const e=x(t);return t.uidEvent=e,E[e]=E[e]||{},E[e]}function L(t,e,i=null){return Object.values(t).find(t=>t.callable===e&&t.delegationSelector===i)}function S(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return O.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=S(e,i,n);if(e in C){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=k(t),c=l[a]||(l[a]={}),h=L(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=x(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function $(t,e,i,n,s){const o=L(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&$(t,e,i,r.callable,r.delegationSelector)}function N(t){return t=t.replace(w,""),C[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=k(t),c=l[rLine truncated
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toStrinLine truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 698 B |
Binary file not shown.
|
After Width: | Height: | Size: 775 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,10 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block body_class %}browsing{% endblock %}
|
||||
{# Already on the index, so the nav entry marks the current page instead of
|
||||
offering a link to it. #}
|
||||
{% block navbar_nav %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="index.html">All books</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Bible",
|
||||
"short_name": "Bible",
|
||||
"description": "World English Bible Updated",
|
||||
"start_url": "./index.html",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#1a202c",
|
||||
"theme_color": "#1a202c",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/*! Browser bundle of nunjucks 3.2.4 */
|
||||
!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.nunjucks=n():t.nunjucks=n()}("undefined"!=typeof self?self:this,function(){return function(t){var n={};function i(r){if(n[r])return n[r].exports;var e=n[r]={i:r,l:!1,exports:{}};return t[r].call(e.exports,e,e.exports,i),e.l=!0,e.exports}return i.m=t,i.c=n,i.d=function(t,n,r){i.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},i.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(n,"a",n),n},i.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},i.p="",i(i.s=11)}([function(t,n,i){"use strict";var r=Array.prototype,e=Object.prototype,s={"&":"&",'"':""","'":"'","<":"<",">":">","\\":"\"},o=/[&"'<>\\]/g;function u(t,n){return e.hasOwnProperty.call(t,n)}function h(t){return s[t]}function f(t,n,i){var r,e,s;if(t instanceof Error&&(t=(e=t).name+": "+e.message),Object.setPrototypeOf?Object.setPrototypeOf(r=Error(t),f.prototype):Object.defineProperty(r=this,"message",{enumerable:!1,writable:!0,value:t}),Object.defineProperty(r,"name",{value:"Template render error"}),Error.captureStackTrace&&Error.captureStackTrace(r,this.constructor),e){var o=Object.getOwnPropertyDescriptor(e,"stack");(s=o&&(o.get||function(){return o.value}))||(s=function(){return e.stack})}else{var u=Error(t).stack;s=function(){return u}}return Object.defineProperty(r,"stack",{get:function(){return s.call(r)}}),Object.defineProperty(r,"cause",{value:e}),r.lineno=n,r.colno=i,r.firstUpdate=!0,r.Update=function(t){var n="("+(t||"unknown path")+")";return this.firstUpdate&&(this.lineno&&this.colno?n+=" [Line "+this.lineno+", Column "+this.colno+"]":this.lineno&&(n+=" [Line "+this.lineno+"]")),n+="\n ",this.firstUpdate&&(n+=" "),this.message=n+(this.message||""),this.firstUpdate=!1,this},r}function c(t){return"[object Function]"===e.toString.call(t)}function a(t){return"[object Array]"===e.toString.call(t)}function l(t){return"[object String]"===e.toString.call(t)}function v(t){return"[object Object]"===e.toString.call(t)}function p(t){var n,i=(n=t)?"string"==typeof n?n.split("."):[n]:[];return function(t){for(var n=t,r=0;r<i.length;r++){var e=i[r];if(!u(n,e))return;n=n[e]}return n}}function d(t){return Array.prototype.slice.call(t)}function m(t,n,i){return Array.prototype.indexOf.call(t||[],n,i)}function w(t){var n=[];for(var i in t)u(t,i)&&n.push(i);return n}(n=t.exports={}).hasOwnProp=u,n.t=function(t,i,r){if(r.Update||(r=new n.TemplateError(r)),r.Update(t),!i){var e=r;(r=Error(e.message)).name=e.name}return r},Object.setPrototypeOf?Object.setPrototypeOf(f.prototype,Error.prototype):f.prototype=Object.create(Error.prototype,{constructor:{value:f}}),n.TemplateError=f,n.escape=function(t){return t.replace(o,h)},n.isFunction=c,n.isArray=a,n.isString=l,n.isObject=v,n.getAttrGetter=p,n.groupBy=function(t,n,i){for(var r={},e=c(n)?n:p(n),s=0;s<t.length;s++){var o=t[s],u=e(o,s);if(void 0===u&&!0===i)throw new TypeError('groupby: attribute "'+n+'" resolved to undefined');(r[u]||(r[u]=[])).push(o)}return r},n.toArray=d,n.without=function(t){var n=[];if(!t)return n;for(var i=t.length,r=d(arguments).slice(1),e=-1;++e<i;)-1===m(r,t[e])&&n.push(t[e]);return n},n.repeat=function(t,n){for(var i="",r=0;r<n;r++)i+=t;return i},n.each=function(t,n,i){if(null!=t)if(r.forEach&&t.forEach===r.forEach)t.forEach(n,i);else if(t.length===+t.length)for(var e=0,s=t.length;e<s;e++)n.call(i,t[e],e,t)},n.map=function(t,n){var i=[];if(null==t)return i;if(r.map&&t.map===r.map)return t.map(n);for(var e=0;e<t.length;e++)i[i.length]=n(t[e],e);return t.length===+t.length&&(i.length=t.length),i},n.asyncIter=function(t,n,i){var r=-1;!function e(){++r<t.length?n(t[r],r,e,i):i()}()},n.asyncFor=function(t,n,i){var r=w(t||{}),e=r.length,s=-1;!function o(){var u=r[++s];s<e?n(u,t[u],s,e,o):i()}()},n.indexOf=m,n.keys=w,n.r=function(t){return w(t).map(function(n){return[n,t[n]]})},n.u=function(t){return w(t).map(function(n){return t[n]})},n.h=n.extend=function(t,n){return t=t||{},w(n).forEach(function(i){t[i]=n[i]}),t},n.inOperator=function(t,n){if(a(n)||l(n))return-1!==n.indexOf(t);if(v(n))return t in n;throw Error('Cannot use "in" operator to search for "'+t+'" in unexpected types.')}},function(t,n,i){"use strict";function r(t,n){for(var i=0;i<n.length;i++){var r=n[i];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,(e=r.key,void 0,"symbol"==typeof(s=function(t,n){if("object"!=typeof t||null===t)return t;var i=t[Symbol.toPrimitive];if(void 0!==i){var r=i.call(t,n||"default");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}(e,"string"))?s:s+""),r)}var e,s}function e(t,n,i){return n&&r(t.prototype,n),i&&r(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t}function s(t,n){t.prototype=OLine truncated
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Fallback for a fragment that names no template of its own. #}
|
||||
{% extends "base.njk" %}
|
||||
{% block content %}{{ content | safe }}{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{# The modal's status line. One template for both paths: the shell renders it
|
||||
empty, and the bookmark API re-renders it with oob set so a single response
|
||||
updates the header slot and this line together. #}
|
||||
<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"
|
||||
role="status" aria-live="polite"{% if oob %} hx-swap-oob="true"{% endif %}>{{ text }}</p>
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
// The app's renderer, and its backend. Hand-written: this file is the source,
|
||||
// served as-is apart from the two lines server.py stamps (see below).
|
||||
//
|
||||
// The files on disk are fragments: a
|
||||
// <title> and one root element. The shell around them lives in base.njk and
|
||||
// is applied here, per request, with nunjucks -- so changing the chrome means
|
||||
// editing one template rather than rebuilding every book.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// STRATEGY decides what a same-origin GET does:
|
||||
//
|
||||
// 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.
|
||||
// cache-first answer from the cache the moment there is a hit. Fastest
|
||||
// possible load, but a rebuild needs two reloads to appear.
|
||||
//
|
||||
// Either way every response that arrives is written back to the cache, so the
|
||||
// offline copy stays current, and any change on disk changes VERSION, dropping
|
||||
// the caches belonging to older versions.
|
||||
|
||||
// Imported scripts are stored with the registration, so this survives offline
|
||||
// without being in ASSETS.
|
||||
importScripts('./nunjucks.min.js');
|
||||
|
||||
const STRATEGY = 'network-first';
|
||||
const NETWORK_TIMEOUT_MS = 3000;
|
||||
|
||||
// VERSION and ASSETS are stamped in by server.py from the files actually on
|
||||
// disk, so a template edit reaches installed devices with no build step: the
|
||||
// worker's bytes change, the browser sees a new worker, and the old cache is
|
||||
// dropped on activate. The values below are the standalone fallback.
|
||||
const VERSION = 'dev';
|
||||
const CACHE = 'bible-' + VERSION;
|
||||
const ASSETS = ["./"];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// Added one at a time rather than addAll(): addAll is atomic, so a single
|
||||
// failed asset would leave the app with no offline copy at all.
|
||||
let failed = 0;
|
||||
await Promise.all(ASSETS.map((url) =>
|
||||
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
|
||||
if (failed) console.warn('precache: ' + failed + ' of ' + ASSETS.length + ' failed');
|
||||
await self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys
|
||||
.filter((k) => k.startsWith('bible-') && k !== CACHE)
|
||||
.map((k) => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// Checked before the method guard: the bookmark API is the one thing here
|
||||
// that answers POST and DELETE.
|
||||
if (isBookmarkApi(url)) return event.respondWith(bookmarkApi(event));
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
// A top-level navigation is the only request that gets the shell wrapped
|
||||
// around it. htmx's own requests are not navigations, so they receive the
|
||||
// bare fragment and swap it into the shell that is already on screen.
|
||||
if (req.mode === 'navigate') return event.respondWith(compose(event));
|
||||
|
||||
event.respondWith(answer(req, event));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- fetching
|
||||
|
||||
// Aborted rather than raced, so a hung request is actually torn down instead
|
||||
// of leaking while the cache answers.
|
||||
async function fromNetwork(req) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(req, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function cached(req) {
|
||||
return caches.match(req, { ignoreSearch: true });
|
||||
}
|
||||
|
||||
// Everything the worker reads goes through here, templates included, so a
|
||||
// .njk file is cached and served on exactly the same terms as a book.
|
||||
//
|
||||
// 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 fragment rather than
|
||||
// the bootstrap page.
|
||||
async function answer(input, event) {
|
||||
const req = typeof input === 'string' ? new Request(input) : input;
|
||||
|
||||
if (STRATEGY === 'cache-first') {
|
||||
const hit = await cached(req);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fromNetwork(req);
|
||||
if (res && res.ok && res.type === 'basic') {
|
||||
const copy = res.clone();
|
||||
// Not awaited: the response should not wait on the cache write.
|
||||
const write = caches.open(CACHE).then((c) => c.put(req, copy));
|
||||
if (event) event.waitUntil(write);
|
||||
else write.catch(() => {});
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
const hit = await cached(req);
|
||||
if (hit) return hit;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function htmlResponse(body, status) {
|
||||
return new Response(body, {
|
||||
status: status || 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// Composed on the fly from parts that are themselves cached; storing
|
||||
// the result too would pin a copy of the old shell.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- templates
|
||||
|
||||
// Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist in
|
||||
// a service worker. This one reads through answer(), so templates are offline
|
||||
// on the same terms as everything else.
|
||||
function CacheLoader() {}
|
||||
CacheLoader.prototype.async = true;
|
||||
CacheLoader.prototype.getSource = function (name, callback) {
|
||||
readText('./' + name).then(
|
||||
(src) => callback(null, { src: src, path: name, noCache: false }),
|
||||
(err) => callback(err));
|
||||
};
|
||||
|
||||
// Compiled templates are held in memory for the worker's lifetime. A rebuild
|
||||
// changes VERSION, which starts a new worker, so this never goes stale.
|
||||
const env = new nunjucks.Environment(new CacheLoader(), { autoescape: true });
|
||||
|
||||
function render(name, context) {
|
||||
return new Promise((resolve, reject) => {
|
||||
env.render(name, context, (err, out) => (err ? reject(err) : resolve(out)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bookmark
|
||||
|
||||
const DB_NAME = 'bible';
|
||||
const DB_STORE = 'kv';
|
||||
const BOOKMARK_KEY = 'bookmark';
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(DB_STORE);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Resolved on the transaction rather than the request, so the value is only
|
||||
// reported once the write it belongs to has actually committed.
|
||||
async function withStore(mode, fn) {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(DB_STORE, mode);
|
||||
const req = fn(tx.objectStore(DB_STORE));
|
||||
tx.oncomplete = () => resolve(req ? req.result : undefined);
|
||||
tx.onabort = tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Storage being unavailable degrades to "no bookmark" rather than a broken
|
||||
// page, the same way the old localStorage code did.
|
||||
function getBookmark() {
|
||||
return withStore('readonly', (s) => s.get(BOOKMARK_KEY)).catch(() => null);
|
||||
}
|
||||
|
||||
function isBookmarkApi(url) {
|
||||
return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark');
|
||||
}
|
||||
|
||||
async function bookmarkFragment(status) {
|
||||
const mark = await getBookmark();
|
||||
let html = await render('bookmark.njk', { mark: mark || null });
|
||||
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 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 === 'DELETE') {
|
||||
await withStore('readwrite', (s) => s.delete(BOOKMARK_KEY));
|
||||
status = 'Bookmark cleared.';
|
||||
} else if (req.method !== 'GET') {
|
||||
return htmlResponse('', 405);
|
||||
}
|
||||
return htmlResponse(await bookmarkFragment(status));
|
||||
} catch (err) {
|
||||
return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.'));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- composition
|
||||
|
||||
const TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
|
||||
const TEMPLATE_RE = /\sdata-template="([\w-]+)"/;
|
||||
|
||||
function fragmentPath(url) {
|
||||
const name = url.pathname.split('/').pop();
|
||||
return './' + (name || 'index.html');
|
||||
}
|
||||
|
||||
async function compose(event) {
|
||||
const url = new URL(event.request.url);
|
||||
let path = fragmentPath(url);
|
||||
|
||||
// The bootstrap page is a whole document already; wrapping it in the shell
|
||||
// would nest one inside the other.
|
||||
if (path === './boot.html') return answer(event.request, event);
|
||||
|
||||
let fragment;
|
||||
|
||||
try {
|
||||
fragment = await readText(path, event);
|
||||
} catch (err) {
|
||||
// Offline and never cached. The index is always precached, so falling
|
||||
// back to it keeps the app usable instead of showing a network error.
|
||||
if (path === './index.html') throw err;
|
||||
path = './index.html';
|
||||
fragment = await readText(path, event);
|
||||
}
|
||||
|
||||
// Already escaped by the renderer, hence the |safe on it in base.njk.
|
||||
const title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
|
||||
const body = fragment.replace(TITLE_RE, '');
|
||||
const template = (TEMPLATE_RE.exec(body) || ['', 'page'])[1] + '.njk';
|
||||
|
||||
return htmlResponse(await render(template, {
|
||||
title: title,
|
||||
content: body,
|
||||
mark: (await getBookmark()) || null
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user