Better
This commit is contained in:
1 parent
ea193e1264
commit
2fea308b38
106 files changed
+1234
-878
No files matched your search
@@ -0,0 +1,174 @@
|
||||
// Page startup and verse modal wiring.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
(function () {
|
||||
'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 ------------------------------------------------------
|
||||
|
||||
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
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// -- startup ------------------------------------------------------------
|
||||
|
||||
// A worker-composed page is an ordinary document and the browser scrolls to
|
||||
// the anchor itself. One composed by boot.html is not: it replaced a
|
||||
// document the browser had already finished with, so the fragment identifier
|
||||
// has been spent. Repeating it costs nothing in the first case and is what
|
||||
// makes a link to GEN.html#v1_1 land on the verse in the second.
|
||||
function scrollToHash() {
|
||||
if (!location.hash) return;
|
||||
var el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||
if (el) el.scrollIntoView();
|
||||
}
|
||||
|
||||
// api/bookmark has no origin server -- it is answered by the worker -- so
|
||||
// there is nothing to ask until the worker is controlling this page.
|
||||
// controller, not ready: ready can resolve a tick before clients.claim()
|
||||
// takes effect, and a request sent in that gap reaches the network and 404s.
|
||||
function whenControlled(fn) {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (navigator.serviceWorker.controller) return fn();
|
||||
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
|
||||
}
|
||||
|
||||
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
|
||||
// and it lands after htmx has processed the shell.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
markVerses();
|
||||
scrollToHash();
|
||||
|
||||
// Registered from the page it is going to serve, not from a page of its
|
||||
// own: there is nothing to wait for and nothing to reload. This load is
|
||||
// already the real app -- the worker installs alongside it, and takes over
|
||||
// composing, the bookmark and the offline download from the next
|
||||
// navigation on.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('sw.js').catch(function () {});
|
||||
}
|
||||
whenControlled(function () {
|
||||
document.body.dispatchEvent(new CustomEvent('worker-ready'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
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
+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
|
||||
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,80 @@
|
||||
// The renderer, shared by the two things that ever build a page: the service
|
||||
// worker, which composes every navigation once it is installed, and boot.html,
|
||||
// which composes the one navigation that arrives before there is a worker to
|
||||
// do it.
|
||||
//
|
||||
// Sharing it is the whole point. A shell only the worker could build is what
|
||||
// made a first visit show a spinner and reload; with the same templates and
|
||||
// the same code reachable from the page, the first visit is already the app
|
||||
// and the worker is what makes the second one fast and the hundredth one
|
||||
// offline.
|
||||
//
|
||||
// Loaded as a plain script by both -- importScripts in the worker, a <script>
|
||||
// tag in the page -- so it defines a global rather than exporting. Nunjucks
|
||||
// must already be loaded.
|
||||
(function (scope) {
|
||||
'use strict';
|
||||
|
||||
// A fragment is a <title> and one root element. The root's data-template
|
||||
// names the template that wraps it; render_books.py writes both.
|
||||
var TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
|
||||
var TEMPLATE_RE = /data-template="([a-z0-9_-]+)"/i;
|
||||
|
||||
// Templates live in one directory and are named by file within it, so a
|
||||
// template that extends or includes another names only the file.
|
||||
var TEMPLATES = './templates/';
|
||||
|
||||
// readText(path) -> Promise<string> is the only thing the two callers do
|
||||
// differently: the worker reads through its cache, the page over the network.
|
||||
scope.createRenderer = function (readText) {
|
||||
// Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist
|
||||
// in a service worker. This one is readText and nothing else.
|
||||
function Loader() {}
|
||||
Loader.prototype.async = true;
|
||||
Loader.prototype.getSource = function (name, callback) {
|
||||
readText(TEMPLATES + name).then(
|
||||
function (src) { callback(null, { src: src, path: name, noCache: false }); },
|
||||
function (err) { callback(err); });
|
||||
};
|
||||
|
||||
// Compiled templates are held for the lifetime of whatever loaded this. In
|
||||
// the worker an edit to any of them changes VERSION, which starts a new
|
||||
// worker, so they never go stale.
|
||||
var env = new nunjucks.Environment(new Loader(), { autoescape: true });
|
||||
|
||||
function render(name, context) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
env.render(name, context, function (err, out) {
|
||||
return err ? reject(err) : resolve(out);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
render: render,
|
||||
|
||||
// A fragment becomes a whole document. page.njk is the fallback for one
|
||||
// that names no template of its own.
|
||||
//
|
||||
// content is already escaped -- render_books.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) {
|
||||
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
|
||||
});
|
||||
},
|
||||
|
||||
// A page that could not be delivered, in the shape of a real fragment so
|
||||
// that it composes into a document and swaps into #content like one.
|
||||
notice: function (heading, text) {
|
||||
return render('notice.njk', { heading: heading, text: text });
|
||||
}
|
||||
};
|
||||
};
|
||||
})(self);
|
||||
@@ -0,0 +1,108 @@
|
||||
// A very small HTTP router, so the worker reads as a set of routes rather than
|
||||
// a chain of ifs unpicking a URL. The shape is lifted from the one in
|
||||
// htmx-serviceworker-todo, which is in turn a miniature of Hono or Express:
|
||||
// register a method and a path pattern, get the matching handler back.
|
||||
//
|
||||
// Patterns are matched segment by segment against a path relative to the
|
||||
// worker's own scope, so the app still works if it is ever served from a
|
||||
// subdirectory. A segment written as :name matches anything and lands in
|
||||
// params. Segment counts must be equal -- there are no wildcards, because
|
||||
// nothing here needs one.
|
||||
//
|
||||
// A handler returning a string gets it as an HTML response; returning a
|
||||
// Response passes it through untouched; returning nothing means "not mine
|
||||
// after all", and the caller falls back to serving the URL as a file.
|
||||
(function (scope) {
|
||||
'use strict';
|
||||
|
||||
function parse(pattern) {
|
||||
return pattern.split('/').map(function (part) {
|
||||
return part.charAt(0) === ':' ? { name: part.slice(1) } : { part: part };
|
||||
});
|
||||
}
|
||||
|
||||
// Everything a handler is given: the event it must attach cache writes to,
|
||||
// the URL, the path parameters, and the two questions every handler here
|
||||
// asks -- who is calling, and are they replacing the whole document.
|
||||
function Call(event, url, params) {
|
||||
this.event = event;
|
||||
this.request = event.request;
|
||||
this.url = url;
|
||||
this.params = params;
|
||||
// htmx stamps every request it makes; a navigation never carries it.
|
||||
this.htmx = this.request.headers.get('HX-Request') === 'true';
|
||||
this.navigate = this.request.mode === 'navigate';
|
||||
}
|
||||
|
||||
Call.prototype.form = function () {
|
||||
return this.request.formData();
|
||||
};
|
||||
|
||||
// base is the worker's scope path, e.g. "/" -- the prefix every pattern is
|
||||
// written without.
|
||||
function Router(base) {
|
||||
this.base = base;
|
||||
this.routes = [];
|
||||
}
|
||||
|
||||
Router.prototype.route = function (method, pattern, handler) {
|
||||
this.routes.push({ method: method, path: parse(pattern), handler: handler });
|
||||
return this;
|
||||
};
|
||||
|
||||
Router.prototype.get = function (pattern, handler) {
|
||||
return this.route('GET', pattern, handler);
|
||||
};
|
||||
|
||||
Router.prototype.post = function (pattern, handler) {
|
||||
return this.route('POST', pattern, handler);
|
||||
};
|
||||
|
||||
// The path a pattern is matched against: absolute, but with the scope
|
||||
// removed, so "/GEN" is "/GEN" whether the app sits at the origin root or
|
||||
// under /bible/.
|
||||
Router.prototype.relative = function (url) {
|
||||
var path = url.pathname;
|
||||
return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path;
|
||||
};
|
||||
|
||||
Router.prototype.find = function (method, path) {
|
||||
var segments = path.split('/');
|
||||
for (var i = 0; i < this.routes.length; i++) {
|
||||
var route = this.routes[i];
|
||||
if (route.method !== method || route.path.length !== segments.length) continue;
|
||||
var params = {};
|
||||
var ok = route.path.every(function (segment, n) {
|
||||
if (!segment.name) return segment.part === segments[n];
|
||||
params[segment.name] = decodeURIComponent(segments[n]);
|
||||
return true;
|
||||
});
|
||||
if (ok) return { handler: route.handler, params: params };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Returns a promise of a Response, or null when nothing matched -- null
|
||||
// being the signal to the caller that this URL is a file, not a route.
|
||||
Router.prototype.handle = function (event, url) {
|
||||
var found = this.find(event.request.method, this.relative(url));
|
||||
if (!found) return null;
|
||||
|
||||
return Promise.resolve(found.handler(new Call(event, url, found.params)))
|
||||
.then(function (result) {
|
||||
if (result instanceof Response) return result;
|
||||
if (typeof result !== 'string') return null;
|
||||
return new Response(result, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// Built here from live state -- the bookmark, the templates, the
|
||||
// cache -- so storing it would pin a stale copy over the real thing.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
scope.Router = Router;
|
||||
})(self);
|
||||
Reference in new issue
Block a user