diff --git a/assets/app.css b/assets/app.css new file mode 100644 index 0000000..fdadbed --- /dev/null +++ b/assets/app.css @@ -0,0 +1,68 @@ +/* The text layer. Bootstrap styles the chrome; this styles scripture. + Every selector here is a class render_books.py already emits, so restyling + the text never means regenerating books/. */ + +/* Serif everywhere, chrome included. Overriding Bootstrap's own variable + rather than setting font-family on elements means the navbar, modal and + buttons all follow, and a typeface is named in exactly one place. System + fonts on purpose: a webfont would be one more thing to keep cached for + offline, and one more request before the text can be read. */ +:root { + --bs-body-font-family: Georgia, "Iowan Old Style", "Palatino Linotype", + "Times New Roman", serif; +} + +/* Verse numbers. The markup is a , not a , so the raise happens + here -- and line-height:0 keeps the raised number from opening up the line + it sits on. Not selectable, so copying a passage copies the words. */ +.v { + font-size: .7em; + font-weight: 700; + vertical-align: super; + line-height: 0; + margin-right: .15em; + color: var(--bs-secondary-color); + user-select: none; +} + +/* app.js turns these into real buttons, so they need a visible focus ring. */ +.v:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; + border-radius: 2px; +} + +/* Chapter numbers are

, which at heading size shouts over the text. */ +.chapter-number { + font-size: 2rem; + font-weight: 400; + color: var(--bs-secondary-color); + margin: 2rem 0 .75rem; +} + +/* Poetry lines are paragraphs, so left alone each one carries a paragraph's + bottom margin and a psalm falls apart into separate blocks. Close the gap, + indent by the level the generator recorded, and hang the indent so a line + too long for the screen still reads as one line. */ +.poetry { margin-bottom: 1rem; } +.poetry .line { + margin: 0; + padding-left: 1.5em; + text-indent: -1.5em; +} +.poetry .line[data-indent="2"] { padding-left: 3em; } +.poetry .line[data-indent="3"] { padding-left: 4.5em; } + +/* Descriptive titles: "A Psalm by David". */ +.d { + font-style: italic; + color: var(--bs-secondary-color); +} + +/* The notes are apparatus, not scripture, and should not read as it. */ +.footnotes { + font-size: .875rem; + color: var(--bs-secondary-color); +} +.footnotes-title { font-size: 1rem; } +.marker { font-weight: 700; } diff --git a/assets/app.js b/assets/app.js index 3b8dcfd..01b7671 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,7 +1,14 @@ -// 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. +// Page startup and verse modal wiring. +// +// shell.html is static and ships with an empty #content, so the first thing +// every load does is fetch the fragment its URL names and swap it in. That is +// the same request a boosted link makes, so there is one path into #content +// and nothing special about a first visit. +// +// The rest is the modal: the markup lives in shell.html 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'; @@ -65,7 +72,7 @@ if (!el || !window.bootstrap) return; var info = verseInfo(span); currentUrl = info.url; - el.querySelector('.verse-dialog-ref').textContent = info.ref; + 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(''); @@ -121,13 +128,46 @@ } }); - markVerses(document); + // -- startup ------------------------------------------------------------ - // 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 () {}); + function fragmentUrl() { + return (location.pathname.split('/').pop() || 'index.html') + location.search; } + + // The content arrives after the document has been parsed, so the browser has + // long given up on the fragment identifier by the time the verse exists. + // Doing it here is what makes a link to GEN.html#v1_1 land on the verse. + 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 () { + htmx.ajax('GET', fragmentUrl(), { target: '#content', swap: 'innerHTML' }) + .then(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 + // for the bookmark, the offline download and every request from then on. + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js').catch(function () {}); + } + whenControlled(function () { + document.body.dispatchEvent(new CustomEvent('worker-ready')); + }); + }); })(); diff --git a/assets/book.njk b/assets/book.njk deleted file mode 100644 index 80b2e6e..0000000 --- a/assets/book.njk +++ /dev/null @@ -1,3 +0,0 @@ -{% extends "base.njk" %} -{% block body_class %}reading{% endblock %} -{% block content %}{{ content | safe }}{% endblock %} diff --git a/assets/bookmark.njk b/assets/bookmark.njk deleted file mode 100644 index 2483e36..0000000 --- a/assets/bookmark.njk +++ /dev/null @@ -1,26 +0,0 @@ -{# 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. #} - -{%- if mark %} - Bookmark - {# Not boosted: a real navigation lets the browser scroll to the verse - anchor, which a swap into #content would not do. #} - {{ mark.ref }} - -{%- endif %} - diff --git a/assets/boot.html b/assets/boot.html deleted file mode 100644 index 1cfbb17..0000000 --- a/assets/boot.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - -World English Bible Updated - - - - - -
- -

Preparing the offline copy…

- -
- - - diff --git a/assets/index.njk b/assets/index.njk deleted file mode 100644 index 183cea0..0000000 --- a/assets/index.njk +++ /dev/null @@ -1,10 +0,0 @@ -{% 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 %} - -{% endblock %} -{% block content %}{{ content | safe }}{% endblock %} diff --git a/assets/nunjucks.min.js b/assets/nunjucks.min.js deleted file mode 100644 index 44d28d7..0000000 --- a/assets/nunjucks.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! 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;rt.length)o=e.slice(0,t.length),e.slice(o.length,f).forEach(function(t,i){i2?e-2:0),o=2;o0||!i)&&process.stdout.write(" ".repeat(n));var s=e===r.length-1?"":"\n";process.stdout.write(""+t+s)})}t.exports={Node:h,Root:a,NodeList:c,Value:f,Literal:l,Symbol:v,Group:p,Array:d,Pair:m,Dict:w,Output:V,Capture:D,TemplateData:U,If:y,IfAsync:g,InlineIf:k,For:E,AsyncEach:O,AsyncAll:x,Macro:j,Caller:T,Import:A,FromImport:N,FunCall:S,Filter:_,FilterAsync:L,KeywordArgs:F,Block:I,Super:C,Extends:R,Include:K,Set:M,Switch:P,Case:B,LookupVal:b,BinOp:G,In:W,Is:H,Or:J,And:z,Not:Y,Add:q,Concat:X,Sub:Q,Mul:Z,Div:tt,FloorDiv:nt,Mod:it,Pow:rt,Neg:et,Pos:st,Compare:ot,CompareOperand:ut,CallExtension:ht,CallExtensionAsync:ft,printNodes:function t(n,i){if(i=i||0,ct(n.typename+": ",i),n instanceof c)ct("\n"),n.children.forEach(function(n){t(n,i+2)});else if(n instanceof ht)ct(n.extName+"."+n.prop+"\n"),n.args&&t(n.args,i+2),n.contentArgs&&n.contentArgs.forEach(function(n){t(n,i+2)});else{var r=[],e=null;n.iterFields(function(t,n){t instanceof h?r.push([n,t]):(e=e||{})[n]=t}),e?ct(JSON.stringify(e,null,2)+"\n",null,!0):ct("\n"),r.forEach(function(n){var r=n[0],e=n[1];ct("["+r+"] =>",i+2),t(e,i+4)})}}}},function(t,n){},function(t,n,i){"use strict";function r(t,n){return(r=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t})(t,n)}var e=i(8),s=i(17),o=i(3),u=i(0).TemplateError,h=i(2).Frame,f={"==":"==","===":"===","!=":"!=","!==":"!==","<":"<",">":">","<=":"<=",">=":">="},c=function(t){var n,i;function e(){return t.apply(this,arguments)||this}i=t,(n=e).prototype=Object.create(i.prototype),n.prototype.constructor=n,r(n,i);var s=e.prototype;return s.init=function(t,n){this.templateName=t,this.codebuf=[],this.lastId=0,this.buffer=null,this.bufferStack=[],this.f="",this.inBlock=!1,this.throwOnUndefined=n},s.fail=function(t,n,i){throw void 0!==n&&(n+=1),void 0!==i&&(i+=1),new u(t,n,i)},s.a=function(){var t=this.v();return this.bufferStack.push(this.buffer),this.buffer=t,this.w("var "+this.buffer+' = "";'),t},s.b=function(){this.buffer=this.bufferStack.pop()},s.w=function(t){this.codebuf.push(t)},s.y=function(t){this.w(t+"\n")},s.g=function(){for(var t=this,n=arguments.length,i=Array(n),r=0;r0&&e.w(","),e.compile(t,n)}),r&&this.w(r)},s.L=function(t,n){this.assertType(t,o.Literal,o.Symbol,o.Group,o.Array,o.Dict,o.FunCall,o.Caller,o.Filter,o.LookupVal,o.Compare,o.InlineIf,o.In,o.Is,o.And,o.Or,o.Not,o.Add,o.Concat,o.Sub,o.Mul,o.Div,o.FloorDiv,o.Mod,o.Pow,o.Neg,o.Pos,o.Compare,o.NodeList),this.compile(t,n)},s.assertType=function(t){for(var n=arguments.length,i=Array(n>1?n-1:0),r=1;r0&&r.w(","),t){r.y("function(cb) {"),r.y("if(!cb) { cb = function(err) { if(err) { throw err; }}}");var e=r.a();r.T(function(){r.compile(t,n),r.y("cb(null, "+e+");")}),r.b(),r.y("return "+e+";"),r.y("}")}else r.w("null")}),i){var h=this.v();this.y(", "+this.A(h)),this.y(this.buffer+" += runtime.suppressValue("+h+", "+u+" && env.opts.autoescape);"),this.j()}else this.w(")"),this.w(", "+u+" && env.opts.autoescape);\n")},s.compileCallExtensionAsync=function(t,n){this.compileCallExtension(t,n,!0)},s.compileNodeList=function(t,n){this.S(t,n)},s.compileLiteral=function(t){if("string"==typeof t.value){var n=t.value.replace(/\\/g,"\\\\");n=(n=(n=(n=(n=n.replace(/"/g,'\\"')).replace(/\n/g,"\\n")).replace(/\r/g,"\\r")).replace(/\t/g,"\\t")).replace(/\u2028/g,"\\u2028"),this.w('"'+n+'"')}else null===t.value?this.w("null"):this.w(t.value.toString())},s.compileSymbol=function(t,n){var i=t.value,r=n.lookup(i);r?this.w(r):this.w('runtime.contextOrFrameLookup(context, frame, "'+i+'")')},s.compileGroup=function(t,n){this._(t,n,"(",")")},s.compileArray=function(t,n){this._(t,n,"[","]")},s.compileDict=function(t,n){this._(t,n,"{","}")},s.compilePair=function(t,n){var i=t.key,r=t.value;i instanceof o.Symbol?i=new o.Literal(i.lineno,i.colno,i.value):i instanceof o.Literal&&"string"==typeof i.value||this.fail("compilePair: Dict keys must be strings or names",i.lineno,i.colno),this.compile(i,n),this.w(": "),this.L(r,n)},s.compileInlineIf=function(t,n){this.w("("),this.compile(t.cond,n),this.w("?"),this.compile(t.body,n),this.w(":"),null!==t.else_?this.compile(t.else_,n):this.w('""'),this.w(")")},s.compileIn=function(t,n){this.w("runtime.inOperator("),this.compile(t.left,n),this.w(","),this.compile(t.right,n),this.w(")")},s.compileIs=function(t,n){var i=t.right.name?t.right.name.value:t.right.value;this.w('env.getTest("'+i+'").call(context, '),this.compile(t.left,n),t.right.args&&(this.w(","),this.compile(t.right.args,n)),this.w(") === true")},s.F=function(t,n,i){this.compile(t.left,n),this.w(i),this.compile(t.right,n)},s.compileOr=function(t,n){return this.F(t,n," || ")},s.compileAnd=function(t,n){return this.F(t,n," && ")},s.compileAdd=function(t,n){return this.F(t,n," + ")},s.compileConcat=function(t,n){return this.F(t,n,' + "" + ')},s.compileSub=function(t,n){return this.F(t,n," - ")},s.compileMul=function(t,n){return this.F(t,n," * ")},s.compileDiv=function(t,n){return this.F(t,n," / ")},s.compileMod=function(t,n){return this.F(t,n," % ")},s.compileNot=function(t,n){this.w("!"),this.compile(t.target,n)},s.compileFloorDiv=function(t,n){this.w("Math.floor("),this.compile(t.left,n),this.w(" / "),this.compile(t.right,n),this.w(")")},s.compilePow=function(t,n){this.w("Math.pow("),this.compile(t.left,n),this.w(", "),this.compile(t.right,n),this.w(")")},s.compileNeg=function(t,n){this.w("-"),this.compile(t.target,n)},s.compilePos=function(t,n){this.w("+"),this.compile(t.target,n)},s.compileCompare=function(t,n){var i=this;this.compile(t.expr,n),t.ops.forEach(function(t){i.w(" "+f[t.type]+" "),i.compile(t.expr,n)})},s.compileLookupVal=function(t,n){this.w("runtime.memberLookup(("),this.L(t.target,n),this.w("),"),this.L(t.val,n),this.w(")")},s.I=function(t){switch(t.typename){case"Symbol":return t.value;case"FunCall":return"the return value of ("+this.I(t.name)+")";case"LookupVal":return this.I(t.target)+'["'+this.I(t.val)+'"]';case"Literal":return t.value.toString();default:return"--expression--"}},s.compileFunCall=function(t,n){this.w("(lineno = "+t.lineno+", colno = "+t.colno+", "),this.w("runtime.callWrap("),this.L(t.name,n),this.w(', "'+this.I(t.name).replace(/"/g,'\\"')+'", context, '),this._(t.args,n,"[","])"),this.w(")")},s.compileFilter=function(t,n){var i=t.name;this.assertType(i,o.Symbol),this.w('env.getFilter("'+i.value+'").call(context, '),this._(t.args,n),this.w(")")},s.compileFilterAsync=function(t,n){var i=t.name,r=t.symbol.value;this.assertType(i,o.Symbol),n.set(r,r),this.w('env.getFilter("'+i.value+'").call(context, '),this._(t.args,n),this.y(", "+this.A(r)),this.j()},s.compileKeywordArgs=function(t,n){this.w("runtime.makeKeywordArgs("),this.compileDict(t,n),this.w(")")},s.compileSet=function(t,n){var i=this,r=[];t.targets.forEach(function(t){var e=t.value,s=n.lookup(e);null!==s&&void 0!==s||(s=i.v(),i.y("var "+s+";")),r.push(s)}),t.value?(this.w(r.join(" = ")+" = "),this.L(t.value,n),this.y(";")):(this.w(r.join(" = ")+" = "),this.compile(t.body,n),this.y(";")),t.targets.forEach(function(t,n){var e=r[n],s=t.value;i.y('frame.set("'+s+'", '+e+", true);"),i.y("if(frame.topLevel) {"),i.y('context.setVariable("'+s+'", '+e+");"),i.y("}"),"_"!==s.charAt(0)&&(i.y("if(frame.topLevel) {"),i.y('context.addExport("'+s+'", '+e+");"),i.y("}"))})},s.compileSwitch=function(t,n){var i=this;this.w("switch ("),this.compile(t.expr,n),this.w(") {"),t.cases.forEach(function(t,r){i.w("case "),i.compile(t.cond,n),i.w(": "),i.compile(t.body,n),t.body.children.length&&i.y("break;")}),t.default&&(this.w("default:"),this.compile(t.default,n)),this.w("}")},s.compileIf=function(t,n,i){var r=this;this.w("if("),this.L(t.cond,n),this.y(") {"),this.T(function(){r.compile(t.body,n),i&&r.w("cb()")}),t.else_?(this.y("}\nelse {"),this.T(function(){r.compile(t.else_,n),i&&r.w("cb()")})):i&&(this.y("}\nelse {"),this.w("cb()")),this.y("}")},s.compileIfAsync=function(t,n){this.w("(function(cb) {"),this.compileIf(t,n,!0),this.w("})("+this.A()),this.j()},s.C=function(t,n,i,r){var e=this;[{name:"index",val:i+" + 1"},{name:"index0",val:i},{name:"revindex",val:r+" - "+i},{name:"revindex0",val:r+" - "+i+" - 1"},{name:"first",val:i+" === 0"},{name:"last",val:i+" === "+r+" - 1"},{name:"length",val:r}].forEach(function(t){e.y('frame.set("loop.'+t.name+'", '+t.val+");")})},s.compileFor=function(t,n){var i=this,r=this.v(),e=this.v(),s=this.v();if(n=n.push(),this.y("frame = frame.push();"),this.w("var "+s+" = "),this.L(t.arr,n),this.y(";"),this.w("if("+s+") {"),this.y(s+" = runtime.fromIterator("+s+");"),t.name instanceof o.Array){this.y("var "+r+";"),this.y("if(runtime.isArray("+s+")) {"),this.y("var "+e+" = "+s+".length;"),this.y("for("+r+"=0; "+r+" < "+s+".length; "+r+"++) {"),t.name.children.forEach(function(e,o){var u=i.v();i.y("var "+u+" = "+s+"["+r+"]["+o+"];"),i.y('frame.set("'+e+'", '+s+"["+r+"]["+o+"]);"),n.set(t.name.children[o].value,u)}),this.C(t,s,r,e),this.T(function(){i.compile(t.body,n)}),this.y("}"),this.y("} else {");var u=t.name.children,h=u[0],f=u[1],c=this.v(),a=this.v();n.set(h.value,c),n.set(f.value,a),this.y(r+" = -1;"),this.y("var "+e+" = runtime.keys("+s+").length;"),this.y("for(var "+c+" in "+s+") {"),this.y(r+"++;"),this.y("var "+a+" = "+s+"["+c+"];"),this.y('frame.set("'+h.value+'", '+c+");"),this.y('frame.set("'+f.value+'", '+a+");"),this.C(t,s,r,e),this.T(function(){i.compile(t.body,n)}),this.y("}"),this.y("}")}else{var l=this.v();n.set(t.name.value,l),this.y("var "+e+" = "+s+".length;"),this.y("for(var "+r+"=0; "+r+" < "+s+".length; "+r+"++) {"),this.y("var "+l+" = "+s+"["+r+"];"),this.y('frame.set("'+t.name.value+'", '+l+");"),this.C(t,s,r,e),this.T(function(){i.compile(t.body,n)}),this.y("}")}this.y("}"),t.else_&&(this.y("if (!"+e+") {"),this.compile(t.else_,n),this.y("}")),this.y("frame = frame.pop();")},s.R=function(t,n,i){var r=this,e=this.v(),s=this.v(),u=this.v(),h=i?"asyncAll":"asyncEach";if(n=n.push(),this.y("frame = frame.push();"),this.w("var "+u+" = runtime.fromIterator("),this.L(t.arr,n),this.y(");"),t.name instanceof o.Array){var f=t.name.children.length;this.w("runtime."+h+"("+u+", "+f+", function("),t.name.children.forEach(function(t){r.w(t.value+",")}),this.w(e+","+s+",next) {"),t.name.children.forEach(function(t){var i=t.value;n.set(i,i),r.y('frame.set("'+i+'", '+i+");")})}else{var c=t.name.value;this.y("runtime."+h+"("+u+", 1, function("+c+", "+e+", "+s+",next) {"),this.y('frame.set("'+c+'", '+c+");"),n.set(c,c)}this.C(t,u,e,s),this.T(function(){var s;i&&(s=r.a()),r.compile(t.body,n),r.y("next("+e+(s?","+s:"")+");"),i&&r.b()});var a=this.v();this.y("}, "+this.A(a)),this.j(),i&&this.y(this.buffer+" += "+a+";"),t.else_&&(this.y("if (!"+u+".length) {"),this.compile(t.else_,n),this.y("}")),this.y("frame = frame.pop();")},s.compileAsyncEach=function(t,n){this.R(t,n)},s.compileAsyncAll=function(t,n){this.R(t,n,!0)},s.K=function(t,n){var i=this,r=[],e=null,s="macro_"+this.v(),u=void 0!==n;t.args.children.forEach(function(n,s){s===t.args.children.length-1&&n instanceof o.Dict?e=n:(i.assertType(n,o.Symbol),r.push(n))});var f,c=[].concat(r.map(function(t){return"l_"+t.value}),["kwargs"]),a=r.map(function(t){return'"'+t.value+'"'}),l=(e&&e.children||[]).map(function(t){return'"'+t.key.value+'"'});f=u?n.push(!0):new h,this.g("var "+s+" = runtime.makeMacro(","["+a.join(", ")+"], ","["+l.join(", ")+"], ","function ("+c.join(", ")+") {","var callerFrame = frame;","frame = "+(u?"frame.push(true);":"new runtime.Frame();"),"kwargs = kwargs || {};",'if (Object.prototype.hasOwnProperty.call(kwargs, "caller")) {','frame.set("caller", kwargs.caller); }'),r.forEach(function(t){i.y('frame.set("'+t.value+'", l_'+t.value+");"),f.set(t.value,"l_"+t.value)}),e&&e.children.forEach(function(t){var n=t.key.value;i.w('frame.set("'+n+'", '),i.w('Object.prototype.hasOwnProperty.call(kwargs, "'+n+'")'),i.w(' ? kwargs["'+n+'"] : '),i.L(t.value,f),i.w(");")});var v=this.a();return this.T(function(){i.compile(t.body,f)}),this.y("frame = "+(u?"frame.pop();":"callerFrame;")),this.y("return new runtime.SafeString("+v+");"),this.y("});"),this.b(),s},s.compileMacro=function(t,n){var i=this.K(t),r=t.name.value;n.set(r,i),n.parent?this.y('frame.set("'+r+'", '+i+");"):("_"!==t.name.value.charAt(0)&&this.y('context.addExport("'+r+'");'),this.y('context.setVariable("'+r+'", '+i+");"))},s.compileCaller=function(t,n){this.w("(function (){");var i=this.K(t,n);this.w("return "+i+";})()")},s.M=function(t,n,i,r){var e=this.v(),s=this.N(),o=this.A(e),u=i?"true":"false",h=r?"true":"false";return this.w("env.getTemplate("),this.L(t.template,n),this.y(", "+u+", "+s+", "+h+", "+o),e},s.compileImport=function(t,n){var i=t.target.value,r=this.M(t,n,!1,!1);this.j(),this.y(r+".getExported("+(t.withContext?"context.getVariables(), frame, ":"")+this.A(r)),this.j(),n.set(i,r),n.parent?this.y('frame.set("'+i+'", '+r+");"):this.y('context.setVariable("'+i+'", '+r+");")},s.compileFromImport=function(t,n){var i=this,r=this.M(t,n,!1,!1);this.j(),this.y(r+".getExported("+(t.withContext?"context.getVariables(), frame, ":"")+this.A(r)),this.j(),t.names.children.forEach(function(t){var e,s,u=i.v();t instanceof o.Pair?(e=t.key.value,s=t.value.value):s=e=t.value,i.y("if(Object.prototype.hasOwnProperty.call("+r+', "'+e+'")) {'),i.y("var "+u+" = "+r+"."+e+";"),i.y("} else {"),i.y("cb(new Error(\"cannot import '"+e+"'\")); return;"),i.y("}"),n.set(s,u),n.parent?i.y('frame.set("'+s+'", '+u+");"):i.y('context.setVariable("'+s+'", '+u+");")})},s.compileBlock=function(t){var n=this.v();this.inBlock||this.w('(parentTemplate ? function(e, c, f, r, cb) { cb(""); } : '),this.w('context.getBlock("'+t.name.value+'")'),this.inBlock||this.w(")"),this.y("(env, context, frame, runtime, "+this.A(n)),this.y(this.buffer+" += "+n+";"),this.j()},s.compileSuper=function(t,n){var i=t.blockName.value,r=t.symbol.value,e=this.A(r);this.y('context.getSuper(env, "'+i+'", b_'+i+", frame, runtime, "+e),this.y(r+" = runtime.markSafe("+r+");"),this.j(),n.set(r,r)},s.compileExtends=function(t,n){var i=this.v(),r=this.M(t,n,!0,!1);this.y("parentTemplate = "+r),this.y("for(var "+i+" in parentTemplate.blocks) {"),this.y("context.addBlock("+i+", parentTemplate.blocks["+i+"]);"),this.y("}"),this.j()},s.compileInclude=function(t,n){this.y("var tasks = [];"),this.y("tasks.push("),this.y("function(callback) {");var i=this.M(t,n,!1,t.ignoreMissing);this.y("callback(null,"+i+");});"),this.y("});");var r=this.v();this.y("tasks.push("),this.y("function(template, callback){"),this.y("template.render(context.getVariables(), frame, "+this.A(r)),this.y("callback(null,"+r+");});"),this.y("});"),this.y("tasks.push("),this.y("function(result, callback){"),this.y(this.buffer+" += result;"),this.y("callback(null);"),this.y("});"),this.y("env.waterfall(tasks, function(){"),this.j()},s.compileTemplateData=function(t,n){this.compileLiteral(t,n)},s.compileCapture=function(t,n){var i=this,r=this.buffer;this.buffer="output",this.y("(function() {"),this.y('var output = "";'),this.T(function(){i.compile(t.body,n)}),this.y("return output;"),this.y("})()"),this.buffer=r},s.compileOutput=function(t,n){var i=this;t.children.forEach(function(r){r instanceof o.TemplateData?r.value&&(i.w(i.buffer+" += "),i.compileLiteral(r,n),i.y(";")):(i.w(i.buffer+" += runtime.suppressValue("),i.throwOnUndefined&&i.w("runtime.ensureDefined("),i.compile(r,n),i.throwOnUndefined&&i.w(","+t.lineno+","+t.colno+")"),i.w(", env.opts.autoescape);\n"))})},s.compileRoot=function(t,n){var i=this;n&&this.fail("compileRoot: root node can't have frame"),n=new h,this.k(t,"root"),this.y("var parentTemplate = null;"),this.S(t,n),this.y("if(parentTemplate) {"),this.y("parentTemplate.rootRenderFunc(env, context, frame, runtime, cb);"),this.y("} else {"),this.y("cb(null, "+this.buffer+");"),this.y("}"),this.O(!0),this.inBlock=!0;var r=[],e=t.findAll(o.Block);e.forEach(function(t,n){var e=t.name.value;if(-1!==r.indexOf(e))throw Error('Block "'+e+'" defined more than once.');r.push(e),i.k(t,"b_"+e);var s=new h;i.y("var frame = frame.push(true);"),i.compile(t.body,s),i.O()}),this.y("return {"),e.forEach(function(t,n){var r="b_"+t.name.value;i.y(r+": "+r+",")}),this.y("root: root\n};")},s.compile=function(t,n){var i=this["compile"+t.typename];i?i.call(this,t,n):this.fail("compile: Cannot compile node: "+t.typename,t.lineno,t.colno)},s.getCode=function(){return this.codebuf.join("")},e}(i(1).Obj);t.exports={compile:function(t,n,i,r,o){void 0===o&&(o={});var u=new c(r,o.throwOnUndefined),h=(i||[]).map(function(t){return t.preprocess}).filter(function(t){return!!t}).reduce(function(t,n){return n(t)},t);return u.compile(s.transform(e.parse(h,i,o),n,r)),u.getCode()},Compiler:c}},function(t,n,i){"use strict";function r(t,n){return(r=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t})(t,n)}var e=i(4),s=i(1).EmitterObj;t.exports=function(t){var n,i;function s(){return t.apply(this,arguments)||this}i=t,(n=s).prototype=Object.create(i.prototype),n.prototype.constructor=n,r(n,i);var o=s.prototype;return o.resolve=function(t,n){return e.resolve(e.dirname(t),n)},o.isRelative=function(t){return 0===t.indexOf("./")||0===t.indexOf("../")},s}(s)},function(t,n,i){"use strict";function r(t,n){t.prototype=Object.create(n.prototype),t.prototype.constructor=t,e(t,n)}function e(t,n){return(e=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t})(t,n)}var s=i(12),o=i(15),u=i(0),h=i(5),f=i(18),c=i(10),a=c.FileSystemLoader,l=c.WebLoader,v=c.PrecompiledLoader,p=i(20),d=i(21),m=i(1),w=m.Obj,b=m.EmitterObj,y=i(2),g=y.handleError,k=y.Frame,E=i(22);function O(t,n,i){s(function(){t(n,i)})}var x={type:"code",obj:{root:function(t,n,i,r,e){try{e(null,"")}catch(t){e(g(t,null,null))}}}},j=function(t){function n(){return t.apply(this,arguments)||this}r(n,t);var i=n.prototype;return i.init=function(t,n){var i=this;n=this.opts=n||{},this.opts.dev=!!n.dev,this.opts.autoescape=null==n.autoescape||n.autoescape,this.opts.throwOnUndefined=!!n.throwOnUndefined,this.opts.trimBlocks=!!n.trimBlocks,this.opts.lstripBlocks=!!n.lstripBlocks,this.loaders=[],t?this.loaders=u.isArray(t)?t:[t]:a?this.loaders=[new a("views")]:l&&(this.loaders=[new l("/views")]),"undefined"!=typeof window&&window.nunjucksPrecompiled&&this.loaders.unshift(new v(window.nunjucksPrecompiled)),this.P(),this.globals=d(),this.filters={},this.tests={},this.asyncFilters=[],this.extensions={},this.extensionsList=[],u.r(f).forEach(function(t){var n=t[0],r=t[1];return i.addFilter(n,r)}),u.r(p).forEach(function(t){var n=t[0],r=t[1];return i.addTest(n,r)})},i.P=function(){var t=this;this.loaders.forEach(function(n){n.cache={},"function"==typeof n.on&&(n.on("update",function(i,r){n.cache[i]=null,t.emit("update",i,r,n)}),n.on("load",function(i,r){t.emit("load",i,r,n)}))})},i.invalidateCache=function(){this.loaders.forEach(function(t){t.cache={}})},i.addExtension=function(t,n){return n.__name=t,this.extensions[t]=n,this.extensionsList.push(n),this},i.removeExtension=function(t){var n=this.getExtension(t);n&&(this.extensionsList=u.without(this.extensionsList,n),delete this.extensions[t])},i.getExtension=function(t){return this.extensions[t]},i.hasExtension=function(t){return!!this.extensions[t]},i.addGlobal=function(t,n){return this.globals[t]=n,this},i.getGlobal=function(t){if(void 0===this.globals[t])throw Error("global not found: "+t);return this.globals[t]},i.addFilter=function(t,n,i){var r=n;return i&&this.asyncFilters.push(t),this.filters[t]=r,this},i.getFilter=function(t){if(!this.filters[t])throw Error("filter not found: "+t);return this.filters[t]},i.addTest=function(t,n){return this.tests[t]=n,this},i.getTest=function(t){if(!this.tests[t])throw Error("test not found: "+t);return this.tests[t]},i.resolveTemplate=function(t,n,i){return!(!t.isRelative||!n)&&t.isRelative(i)&&t.resolve?t.resolve(n,i):i},i.getTemplate=function(t,n,i,r,e){var s,o=this,h=this,f=null;if(t&&t.raw&&(t=t.raw),u.isFunction(i)&&(e=i,i=null,n=n||!1),u.isFunction(n)&&(e=n,n=!1),t instanceof A)f=t;else{if("string"!=typeof t)throw Error("template names must be a string: "+t);for(var c=0;c0&&!this.skip(e.TOKEN_COMMA)&&this.fail("parseFrom: expected comma",t.lineno,t.colno);var u=this.parsePrimary();if("_"===u.value.charAt(0)&&this.fail("parseFrom: names starting with an underscore cannot be imported",u.lineno,u.colno),this.skipSymbol("as")){var h=this.parsePrimary();r.addChild(new s.Pair(u.lineno,u.colno,u,h))}else r.addChild(u);i=this.parseWithContext()}return new s.FromImport(t.lineno,t.colno,n,r,i)},h.parseBlock=function(){var t=this.peekToken();this.skipSymbol("block")||this.fail("parseBlock: expected block",t.lineno,t.colno);var n=new s.Block(t.lineno,t.colno);n.name=this.parsePrimary(),n.name instanceof s.Symbol||this.fail("parseBlock: variable name expected",t.lineno,t.colno),this.advanceAfterBlockEnd(t.value),n.body=this.parseUntilBlocks("endblock"),this.skipSymbol("endblock"),this.skipSymbol(n.name.value);var i=this.peekToken();return i||this.fail("parseBlock: expected endblock, got end of file"),this.advanceAfterBlockEnd(i.value),n},h.parseExtends=function(){var t=this.peekToken();this.skipSymbol("extends")||this.fail("parseTemplateRef: expected extends");var n=new s.Extends(t.lineno,t.colno);return n.template=this.parseExpression(),this.advanceAfterBlockEnd(t.value),n},h.parseInclude=function(){var t=this.peekToken();this.skipSymbol("include")||this.fail("parseInclude: expected include");var n=new s.Include(t.lineno,t.colno);return n.template=this.parseExpression(),this.skipSymbol("ignore")&&this.skipSymbol("missing")&&(n.ignoreMissing=!0),this.advanceAfterBlockEnd(t.value),n},h.parseIf=function(){var t,n=this.peekToken();this.skipSymbol("if")||this.skipSymbol("elif")||this.skipSymbol("elseif")?t=new s.If(n.lineno,n.colno):this.skipSymbol("ifAsync")?t=new s.IfAsync(n.lineno,n.colno):this.fail("parseIf: expected if, elif, or elseif",n.lineno,n.colno),t.cond=this.parseExpression(),this.advanceAfterBlockEnd(n.value),t.body=this.parseUntilBlocks("elif","elseif","else","endif");var i=this.peekToken();switch(i&&i.value){case"elseif":case"elif":t.else_=this.parseIf();break;case"else":this.advanceAfterBlockEnd(),t.else_=this.parseUntilBlocks("endif"),this.advanceAfterBlockEnd();break;case"endif":t.else_=null,this.advanceAfterBlockEnd();break;default:this.fail("parseIf: expected elif, else, or endif, got end of file")}return t},h.parseSet=function(){var t=this.peekToken();this.skipSymbol("set")||this.fail("parseSet: expected set",t.lineno,t.colno);for(var n,i=new s.Set(t.lineno,t.colno,[]);(n=this.parsePrimary())&&(i.targets.push(n),this.skip(e.TOKEN_COMMA)););return this.skipValue(e.TOKEN_OPERATOR,"=")?(i.value=this.parseExpression(),this.advanceAfterBlockEnd(t.value)):this.skip(e.TOKEN_BLOCK_END)?(i.body=new s.Capture(t.lineno,t.colno,this.parseUntilBlocks("endset")),i.value=null,this.advanceAfterBlockEnd()):this.fail("parseSet: expected = or block end in set tag",t.lineno,t.colno),i},h.parseSwitch=function(){var t=this.peekToken();this.skipSymbol("switch")||this.skipSymbol("case")||this.skipSymbol("default")||this.fail('parseSwitch: expected "switch," "case" or "default"',t.lineno,t.colno);var n=this.parseExpression();this.advanceAfterBlockEnd("switch"),this.parseUntilBlocks("case","default","endswitch");var i,r=this.peekToken(),e=[];do{this.skipSymbol("case");var o=this.parseExpression();this.advanceAfterBlockEnd("switch");var u=this.parseUntilBlocks("case","default","endswitch");e.push(new s.Case(r.line,r.col,o,u)),r=this.peekToken()}while(r&&"case"===r.value);switch(r.value){case"default":this.advanceAfterBlockEnd(),i=this.parseUntilBlocks("endswitch"),this.advanceAfterBlockEnd();break;case"endswitch":this.advanceAfterBlockEnd();break;default:this.fail('parseSwitch: expected "case," "default" or "endswitch," got EOF.')}return new s.Switch(t.lineno,t.colno,n,e,i)},h.parseStatement=function(){var t=this.peekToken();if(t.type!==e.TOKEN_SYMBOL&&this.fail("tag name expected",t.lineno,t.colno),this.breakOnBlocks&&-1!==u.indexOf(this.breakOnBlocks,t.value))return null;switch(t.value){case"raw":return this.parseRaw();case"verbatim":return this.parseRaw("verbatim");case"if":case"ifAsync":return this.parseIf();case"for":case"asyncEach":case"asyncAll":return this.parseFor();case"block":return this.parseBlock();case"extends":return this.parseExtends();case"include":return this.parseInclude();case"set":return this.parseSet();case"macro":return this.parseMacro();case"call":return this.parseCall();case"import":return this.parseImport();case"from":return this.parseFrom();case"filter":return this.parseFilterStatement();case"switch":return this.parseSwitch();default:if(this.extensions.length)for(var n=0;n0;){var h=o[0],f=o[1],c=o[2];c===t?r+=1:c===n&&(r-=1),0===r?(e+=f,this.tokens.backN(h.length-f.length)):e+=h}return new s.Output(u.lineno,u.colno,[new s.TemplateData(u.lineno,u.colno,e)])},h.parsePostfix=function(t){for(var n,i=this.peekToken();i;){if(i.type===e.TOKEN_LEFT_PAREN)t=new s.FunCall(i.lineno,i.colno,t,this.parseSignature());else if(i.type===e.TOKEN_LEFT_BRACKET)(n=this.parseAggregate()).children.length>1&&this.fail("invalid index"),t=new s.LookupVal(i.lineno,i.colno,t,n.children[0]);else{if(i.type!==e.TOKEN_OPERATOR||"."!==i.value)break;this.nextToken();var r=this.nextToken();r.type!==e.TOKEN_SYMBOL&&this.fail("expected name as lookup value, got "+r.value,r.lineno,r.colno),n=new s.Literal(r.lineno,r.colno,r.value),t=new s.LookupVal(i.lineno,i.colno,t,n)}i=this.peekToken()}return t},h.parseExpression=function(){return this.parseInlineIf()},h.parseInlineIf=function(){var t=this.parseOr();if(this.skipSymbol("if")){var n=this.parseOr(),i=t;(t=new s.InlineIf(t.lineno,t.colno)).body=i,t.cond=n,this.skipSymbol("else")?t.else_=this.parseOr():t.else_=null}return t},h.parseOr=function(){for(var t=this.parseAnd();this.skipSymbol("or");){var n=this.parseAnd();t=new s.Or(t.lineno,t.colno,t,n)}return t},h.parseAnd=function(){for(var t=this.parseNot();this.skipSymbol("and");){var n=this.parseNot();t=new s.And(t.lineno,t.colno,t,n)}return t},h.parseNot=function(){var t=this.peekToken();return this.skipSymbol("not")?new s.Not(t.lineno,t.colno,this.parseNot()):this.parseIn()},h.parseIn=function(){for(var t=this.parseIs();;){var n=this.nextToken();if(!n)break;var i=n.type===e.TOKEN_SYMBOL&&"not"===n.value;if(i||this.pushToken(n),!this.skipSymbol("in")){i&&this.pushToken(n);break}var r=this.parseIs();t=new s.In(t.lineno,t.colno,t,r),i&&(t=new s.Not(t.lineno,t.colno,t))}return t},h.parseIs=function(){var t=this.parseCompare();if(this.skipSymbol("is")){var n=this.skipSymbol("not"),i=this.parseCompare();t=new s.Is(t.lineno,t.colno,t,i),n&&(t=new s.Not(t.lineno,t.colno,t))}return t},h.parseCompare=function(){for(var t=["==","===","!=","!==","<",">","<=",">="],n=this.parseConcat(),i=[];;){var r=this.nextToken();if(!r)break;if(-1===t.indexOf(r.value)){this.pushToken(r);break}i.push(new s.CompareOperand(r.lineno,r.colno,this.parseConcat(),r.value))}return i.length?new s.Compare(i[0].lineno,i[0].colno,n,i):n},h.parseConcat=function(){for(var t=this.parseAdd();this.skipValue(e.TOKEN_TILDE,"~");){var n=this.parseAdd();t=new s.Concat(t.lineno,t.colno,t,n)}return t},h.parseAdd=function(){for(var t=this.parseSub();this.skipValue(e.TOKEN_OPERATOR,"+");){var n=this.parseSub();t=new s.Add(t.lineno,t.colno,t,n)}return t},h.parseSub=function(){for(var t=this.parseMul();this.skipValue(e.TOKEN_OPERATOR,"-");){var n=this.parseMul();t=new s.Sub(t.lineno,t.colno,t,n)}return t},h.parseMul=function(){for(var t=this.parseDiv();this.skipValue(e.TOKEN_OPERATOR,"*");){var n=this.parseDiv();t=new s.Mul(t.lineno,t.colno,t,n)}return t},h.parseDiv=function(){for(var t=this.parseFloorDiv();this.skipValue(e.TOKEN_OPERATOR,"/");){var n=this.parseFloorDiv();t=new s.Div(t.lineno,t.colno,t,n)}return t},h.parseFloorDiv=function(){for(var t=this.parseMod();this.skipValue(e.TOKEN_OPERATOR,"//");){var n=this.parseMod();t=new s.FloorDiv(t.lineno,t.colno,t,n)}return t},h.parseMod=function(){for(var t=this.parsePow();this.skipValue(e.TOKEN_OPERATOR,"%");){var n=this.parsePow();t=new s.Mod(t.lineno,t.colno,t,n)}return t},h.parsePow=function(){for(var t=this.parseUnary();this.skipValue(e.TOKEN_OPERATOR,"**");){var n=this.parseUnary();t=new s.Pow(t.lineno,t.colno,t,n)}return t},h.parseUnary=function(t){var n,i=this.peekToken();return n=this.skipValue(e.TOKEN_OPERATOR,"-")?new s.Neg(i.lineno,i.colno,this.parseUnary(!0)):this.skipValue(e.TOKEN_OPERATOR,"+")?new s.Pos(i.lineno,i.colno,this.parseUnary(!0)):this.parsePrimary(),t||(n=this.parseFilter(n)),n},h.parsePrimary=function(t){var n,i=this.nextToken(),r=null;if(i?i.type===e.TOKEN_STRING?n=i.value:i.type===e.TOKEN_INT?n=parseInt(i.value,10):i.type===e.TOKEN_FLOAT?n=parseFloat(i.value):i.type===e.TOKEN_BOOLEAN?"true"===i.value?n=!0:"false"===i.value?n=!1:this.fail("invalid boolean: "+i.value,i.lineno,i.colno):i.type===e.TOKEN_NONE?n=null:i.type===e.TOKEN_REGEX&&(n=RegExp(i.value.body,i.value.flags)):this.fail("expected expression, got end of file"),void 0!==n?r=new s.Literal(i.lineno,i.colno,n):i.type===e.TOKEN_SYMBOL?r=new s.Symbol(i.lineno,i.colno,i.value):(this.pushToken(i),r=this.parseAggregate()),t||(r=this.parsePostfix(r)),r)return r;throw this.error("unexpected token: "+i.value,i.lineno,i.colno)},h.parseFilterName=function(){for(var t=this.expect(e.TOKEN_SYMBOL),n=t.value;this.skipValue(e.TOKEN_OPERATOR,".");)n+="."+this.expect(e.TOKEN_SYMBOL).value;return new s.Symbol(t.lineno,t.colno,n)},h.parseFilterArgs=function(t){return this.peekToken().type===e.TOKEN_LEFT_PAREN?this.parsePostfix(t).args.children:[]},h.parseFilter=function(t){for(;this.skip(e.TOKEN_PIPE);){var n=this.parseFilterName();t=new s.Filter(n.lineno,n.colno,n,new s.NodeList(n.lineno,n.colno,[t].concat(this.parseFilterArgs(t))))}return t},h.parseFilterStatement=function(){var t=this.peekToken();this.skipSymbol("filter")||this.fail("parseFilterStatement: expected filter");var n=this.parseFilterName(),i=this.parseFilterArgs(n);this.advanceAfterBlockEnd(t.value);var r=new s.Capture(n.lineno,n.colno,this.parseUntilBlocks("endfilter"));this.advanceAfterBlockEnd();var e=new s.Filter(n.lineno,n.colno,n,new s.NodeList(n.lineno,n.colno,[r].concat(i)));return new s.Output(n.lineno,n.colno,[e])},h.parseAggregate=function(){var t,n=this.nextToken();switch(n.type){case e.TOKEN_LEFT_PAREN:t=new s.Group(n.lineno,n.colno);break;case e.TOKEN_LEFT_BRACKET:t=new s.Array(n.lineno,n.colno);break;case e.TOKEN_LEFT_CURLY:t=new s.Dict(n.lineno,n.colno);break;default:return null}for(;;){var i=this.peekToken().type;if(i===e.TOKEN_RIGHT_PAREN||i===e.TOKEN_RIGHT_BRACKET||i===e.TOKEN_RIGHT_CURLY){this.nextToken();break}if(t.children.length>0&&(this.skip(e.TOKEN_COMMA)||this.fail("parseAggregate: expected comma after expression",n.lineno,n.colno)),t instanceof s.Dict){var r=this.parsePrimary();this.skip(e.TOKEN_COLON)||this.fail("parseAggregate: expected colon after dict key",n.lineno,n.colno);var o=this.parseExpression();t.addChild(new s.Pair(r.lineno,r.colno,r,o))}else{var u=this.parseExpression();t.addChild(u)}}return t},h.parseSignature=function(t,n){var i=this.peekToken();if(!n&&i.type!==e.TOKEN_LEFT_PAREN){if(t)return null;this.fail("expected arguments",i.lineno,i.colno)}i.type===e.TOKEN_LEFT_PAREN&&(i=this.nextToken());for(var r=new s.NodeList(i.lineno,i.colno),o=new s.KeywordArgs(i.lineno,i.colno),u=!1;;){if(i=this.peekToken(),!n&&i.type===e.TOKEN_RIGHT_PAREN){this.nextToken();break}if(n&&i.type===e.TOKEN_BLOCK_END)break;if(u&&!this.skip(e.TOKEN_COMMA))this.fail("parseSignature: expected comma after expression",i.lineno,i.colno);else{var h=this.parseExpression();this.skipValue(e.TOKEN_OPERATOR,"=")?o.addChild(new s.Pair(h.lineno,h.colno,h,this.parseExpression())):r.addChild(h)}u=!0}return o.children.length&&r.addChild(o),r},h.parseUntilBlocks=function(){for(var t=this.breakOnBlocks,n=arguments.length,i=Array(n),r=0;r=!".indexOf(e)){this.forward();var h,f=["==","===","!=","!==","<=",">=","//","**"],a=e+this.current();switch(-1!==r.indexOf(f,a)&&(this.forward(),e=a,-1!==r.indexOf(f,a+this.current())&&(e=a+this.current(),this.forward())),e){case"(":h="left-paren";break;case")":h="right-paren";break;case"[":h="left-bracket";break;case"]":h="right-bracket";break;case"{":h="left-curly";break;case"}":h="right-curly";break;case",":h="comma";break;case":":h="colon";break;case"~":h="tilde";break;case"|":h="pipe";break;default:h="operator"}return c(h,e,n,i)}if((t=this.W(" \n\t\r ()[]{}%*-+~/#,:|.<>=!")).match(/^[-+]?[0-9]+$/))return"."===this.current()?(this.forward(),c("float",t+"."+this.$("0123456789"),n,i)):c("int",t,n,i);if(t.match(/^(true|false)$/))return c("boolean",t,n,i);if("none"===t)return c("none",t,n,i);if("null"===t)return c("none",t,n,i);if(t)return c("symbol",t,n,i);throw Error("Unexpected value while parsing: "+t)}var l,v=this.tags.BLOCK_START.charAt(0)+this.tags.VARIABLE_START.charAt(0)+this.tags.COMMENT_START.charAt(0)+this.tags.COMMENT_END.charAt(0);if(this.isFinished())return null;if((t=this.G(this.tags.BLOCK_START+"-"))||(t=this.G(this.tags.BLOCK_START)))return this.in_code=!0,c("block-start",t,n,i);if((t=this.G(this.tags.VARIABLE_START+"-"))||(t=this.G(this.tags.VARIABLE_START)))return this.in_code=!0,c("variable-start",t,n,i);t="";var p=!1;for(this.H(this.tags.COMMENT_START)&&(p=!0,t=this.G(this.tags.COMMENT_START));null!==(l=this.W(v));){if(t+=l,(this.H(this.tags.BLOCK_START)||this.H(this.tags.VARIABLE_START)||this.H(this.tags.COMMENT_START))&&!p){if(this.lstripBlocks&&this.H(this.tags.BLOCK_START)&&this.colno>0&&this.colno<=t.length){var d=t.slice(-this.colno);if(/^\s+$/.test(d)&&!(t=t.slice(0,-this.colno)).length)return this.nextToken()}break}if(this.H(this.tags.COMMENT_END)){if(!p)throw Error("unexpected end of comment");t+=this.G(this.tags.COMMENT_END);break}t+=this.current(),this.forward()}if(null===l&&p)throw Error("expected end of comment, got end of file");return c(p?"comment":"data",t,n,i)},n.U=function(t){this.forward();for(var n="";!this.isFinished()&&this.current()!==t;){var i=this.current();if("\\"===i){switch(this.forward(),this.current()){case"n":n+="\n";break;case"t":n+="\t";break;case"r":n+="\r";break;default:n+=this.current()}this.forward()}else n+=i,this.forward()}return this.forward(),n},n.H=function(t){return this.index+t.length>this.len?null:this.str.slice(this.index,this.index+t.length)===t},n.G=function(t){return this.H(t)?(this.forwardN(t.length),t):null},n.W=function(t){return this.J(!0,t||"")},n.$=function(t){return this.J(!1,t)},n.J=function(t,n){if(this.isFinished())return null;var i=n.indexOf(this.current());if(t&&-1===i||!t&&-1!==i){var r=this.current();this.forward();for(var e=n.indexOf(this.current());(t&&-1===e||!t&&-1!==e)&&!this.isFinished();)r+=this.current(),this.forward(),e=n.indexOf(this.current());return r}return""},n.D=function(t){var n=this.currentStr().match(t);return n?(this.forwardN(n[0].length),n):null},n.isFinished=function(){return this.index>=this.len},n.forwardN=function(t){for(var n=0;no){for(var n=0,i=e.length-s;n0&&o.length>e&&!o.warned){o.warned=!0;var u=Error("Possible EventEmitter memory leak detected. "+o.length+" "+n+" listeners added. Use emitter.setMaxListeners() to increase limit");u.name="MaxListenersExceededWarning",u.emitter=t,u.type=n,u.count=o.length,console&&console.warn&&console.warn(u)}return t}function l(t,n,i){var r={fired:!1,wrapFn:void 0,target:t,type:n,listener:i},e=function(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}.bind(r);return e.listener=i,r.wrapFn=e,e}function v(t,n,i){var r=t.z;if(void 0===r)return[];var e=r[n];return void 0===e?[]:"function"==typeof e?i?[e.listener||e]:[e]:i?function(t){for(var n=Array(t.length),i=0;i0&&(o=n[0]),o instanceof Error)throw o;var u=Error("Unhandled error."+(o?" ("+o.message+")":""));throw u.context=o,u}var h=e[t];if(void 0===h)return!1;if("function"==typeof h)s(h,this,n);else{var f=h.length,c=d(h,f);for(i=0;i=0;s--)if(i[s]===n||i[s].listener===n){o=i[s].listener,e=s;break}if(e<0)return this;0===e?i.shift():function(t,n){for(;n+1=0;r--)this.removeListener(t,n[r]);return this},u.prototype.listeners=function(t){return v(this,t,!0)},u.prototype.rawListeners=function(t){return v(this,t,!1)},u.listenerCount=function(t,n){return"function"==typeof t.listenerCount?t.listenerCount(n):p.call(t,n)},u.prototype.listenerCount=p,u.prototype.eventNames=function(){return this.Y>0?r(this.z):[]}},function(t,n,i){"use strict";var r=i(3),e=i(0),s=0;function o(){return"hole_"+s++}function u(t,n){for(var i=null,r=0;r=n)return t;var i=n-t.length,o=r.repeat(" ",i/2-i%2),u=r.repeat(" ",i/2);return e.copySafeness(t,o+t+u)},n.default=function(t,n,i){return i?t||n:void 0!==t?t:n},n.dictsort=function(t,n,i){if(!r.isObject(t))throw new r.TemplateError("dictsort filter: val must be an object");var e,s=[];for(var o in t)s.push([o,t[o]]);if(void 0===i||"key"===i)e=0;else{if("value"!==i)throw new r.TemplateError("dictsort filter: You can only sort by either key or value");e=1}return s.sort(function(t,i){var s=t[e],o=i[e];return n||(r.isString(s)&&(s=s.toUpperCase()),r.isString(o)&&(o=o.toUpperCase())),s>o?1:s===o?0:-1}),s},n.dump=function(t,n){return JSON.stringify(t,null,n)},n.escape=function(t){return t instanceof e.SafeString?t:(t=null===t||void 0===t?"":t,e.markSafe(r.escape(t.toString())))},n.safe=function(t){return t instanceof e.SafeString?t:(t=null===t||void 0===t?"":t,e.markSafe(t.toString()))},n.first=function(t){return t[0]},n.forceescape=function(t){return t=null===t||void 0===t?"":t,e.markSafe(r.escape(t.toString()))},n.groupby=function(t,n){return r.groupBy(t,n,this.env.opts.throwOnUndefined)},n.indent=function(t,n,i){if(""===(t=s(t,"")))return"";n=n||4;var o=t.split("\n"),u=r.repeat(" ",n),h=o.map(function(t,n){return 0!==n||i?""+u+t:t}).join("\n");return e.copySafeness(t,h)},n.join=function(t,n,i){return n=n||"",i&&(t=r.map(t,function(t){return t[i]})),t.join(n)},n.last=function(t){return t[t.length-1]},n.length=function(t){var n=s(t,"");return void 0!==n?"function"==typeof Map&&n instanceof Map||"function"==typeof Set&&n instanceof Set?n.size:!r.isObject(n)||n instanceof e.SafeString?n.length:r.keys(n).length:0},n.list=h,n.lower=function(t){return(t=s(t,"")).toLowerCase()},n.nl2br=function(t){return null===t||void 0===t?"":e.copySafeness(t,t.replace(/\r\n|\n/g,"
\n"))},n.random=function(t){return t[Math.floor(Math.random()*t.length)]},n.reject=f(!1),n.rejectattr=function(t,n){return t.filter(function(t){return!t[n]})},n.select=f(!0),n.selectattr=function(t,n){return t.filter(function(t){return!!t[n]})},n.replace=function(t,n,i,r){var s=t;if(n instanceof RegExp)return t.replace(n,i);void 0===r&&(r=-1);var o="";if("number"==typeof n)n=""+n;else if("string"!=typeof n)return t;if("number"==typeof t&&(t=""+t),"string"!=typeof t&&!(t instanceof e.SafeString))return t;if(""===n)return o=i+t.split("").join(i)+i,e.copySafeness(t,o);var u=t.indexOf(n);if(0===r||-1===u)return t;for(var h=0,f=0;u>-1&&(-1===r||f=e&&c.push(i),s.push(c)}return s},n.sum=function(t,n,i){return void 0===i&&(i=0),n&&(t=r.map(t,function(t){return t[n]})),i+t.reduce(function(t,n){return t+n},0)},n.sort=e.makeMacro(["value","reverse","case_sensitive","attribute"],[],function(t,n,i,e){var s=this,o=r.map(t,function(t){return t}),u=r.getAttrGetter(e);return o.sort(function(t,o){var h=e?u(t):t,f=e?u(o):o;if(s.env.opts.throwOnUndefined&&e&&(void 0===h||void 0===f))throw new TypeError('sort: attribute "'+e+'" resolved to undefined');return!i&&r.isString(h)&&r.isString(f)&&(h=h.toLowerCase(),f=f.toLowerCase()),hf?n?-1:1:0}),o}),n.string=function(t){return e.copySafeness(t,t)},n.striptags=function(t,n){var i=c((t=s(t,"")).replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>|/gi,"")),r="";return r=n?i.replace(/^ +| +$/gm,"").replace(/ +/g," ").replace(/(\r\n)/g,"\n").replace(/\n\n\n+/g,"\n\n"):i.replace(/\s+/gi," "),e.copySafeness(t,r)},n.title=function(t){var n=(t=s(t,"")).split(" ").map(function(t){return u(t)});return e.copySafeness(t,n.join(" "))},n.trim=c,n.truncate=function(t,n,i,r){var o=t;if(t=s(t,""),n=n||255,t.length<=n)return t;if(i)t=t.substring(0,n);else{var u=t.lastIndexOf(" ",n);-1===u&&(u=n),t=t.substring(0,u)}return t+=void 0!==r&&null!==r?r:"...",e.copySafeness(o,t)},n.upper=function(t){return(t=s(t,"")).toUpperCase()},n.urlencode=function(t){var n=encodeURIComponent;return r.isString(t)?n(t):(r.isArray(t)?t:r.r(t)).map(function(t){var i=t[0],r=t[1];return n(i)+"="+n(r)}).join("&")};var a=/^(?:\(|<|<)?(.*?)(?:\.|,|\)|\n|>)?$/,l=/^[\w.!#$%&'*+\-\/=?\^`{|}~]+@[a-z\d\-]+(\.[a-z\d\-]+)+$/i,v=/^https?:\/\/.*$/,p=/^www\./,d=/\.(?:org|net|com)(?:\:|\/|$)/;n.urlize=function(t,n,i){o(n)&&(n=1/0);var r=!0===i?' rel="nofollow"':"";return t.split(/(\s+)/).filter(function(t){return t&&t.length}).map(function(t){var i=t.match(a),e=i?i[1]:t,s=e.substr(0,n);return v.test(e)?'"+s+"":p.test(e)?'"+s+"":l.test(e)?''+e+"":d.test(e)?'"+s+"":t}).join("")},n.wordcount=function(t){var n=(t=s(t,""))?t.match(/\w+/g):null;return n?n.length:null},n.float=function(t,n){var i=parseFloat(t);return o(i)?n:i};var m=e.makeMacro(["value","default","base"],[],function(t,n,i){void 0===i&&(i=10);var r=parseInt(t,i);return o(r)?n:r});n.int=m,n.d=n.default,n.e=n.escape},function(t,n,i){"use strict";function r(t,n){return(r=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t})(t,n)}var e=function(t){var n,i;function e(n){var i;return(i=t.call(this)||this).precompiled=n||{},i}return i=t,(n=e).prototype=Object.create(i.prototype),n.prototype.constructor=n,r(n,i),e.prototype.getSource=function(t){return this.precompiled[t]?{src:{type:"code",obj:this.precompiled[t]},path:t}:null},e}(i(6));t.exports={PrecompiledLoader:e}},function(t,n,i){"use strict";var r=i(2).SafeString;n.callable=function(t){return"function"==typeof t},n.defined=function(t){return void 0!==t},n.divisibleby=function(t,n){return t%n==0},n.escaped=function(t){return t instanceof r},n.equalto=function(t,n){return t===n},n.eq=n.equalto,n.sameas=n.equalto,n.even=function(t){return t%2==0},n.falsy=function(t){return!t},n.ge=function(t,n){return t>=n},n.greaterthan=function(t,n){return t>n},n.gt=n.greaterthan,n.le=function(t,n){return t<=n},n.lessthan=function(t,n){return t0)for(var e=t;en;s+=i)r.push(s);return r},cycler:function(){return t=Array.prototype.slice.call(arguments),n=-1,{current:null,reset:function(){n=-1,this.current=null},next:function(){return++n>=t.length&&(n=0),this.current=t[n],this.current}};var t,n},joiner:function(t){return function(t){t=t||",";var n=!0;return function(){var i=n?"":t;return n=!1,i}}(t)}}}},function(t,n,i){var r=i(4);t.exports=function(t,n){function i(t,n){if(this.name=t,this.path=t,this.defaultEngine=n.defaultEngine,this.ext=r.extname(t),!this.ext&&!this.defaultEngine)throw Error("No default engine was specified and no extension was provided.");this.ext||(this.name+=this.ext=("."!==this.defaultEngine[0]?".":"")+this.defaultEngine)}return i.prototype.render=function(n,i){t.render(this.name,n,i)},n.set("view",i),n.set("nunjucksEnv",t),t}},function(t,n,i){"use strict";var r=i(4),e=i(4),s=i(0).t,o=i(5),u=i(7).Environment,h=i(24);function f(t,n){return!!Array.isArray(n)&&n.some(function(n){return t.match(n)})}function c(t,n){(n=n||{}).isString=!0;var i=n.env||new u([]),r=n.wrapper||h;if(!n.name)throw Error('the "name" option is required when compiling a string');return r([a(t,n.name,i)],n)}function a(t,n,i){var r,e=(i=i||new u([])).asyncFilters,h=i.extensionsList;n=n.replace(/\\/g,"/");try{r=o.compile(t,e,h,n,i.opts)}catch(t){throw s(n,!1,t)}return{name:n,template:r}}t.exports={precompile:function(t,n){var i=(n=n||{}).env||new u([]),s=n.wrapper||h;if(n.isString)return c(t,n);var o=r.existsSync(t)&&r.statSync(t),l=[],v=[];if(o.isFile())l.push(a(r.readFileSync(t,"utf-8"),n.name||t,i));else if(o.isDirectory()){!function i(s){r.readdirSync(s).forEach(function(o){var u=e.join(s,o),h=u.substr(e.join(t,"/").length),c=r.statSync(u);c&&c.isDirectory()?f(h+="/",n.exclude)||i(u):f(h,n.include)&&v.push(u)})}(t);for(var p=0;p=this.length||t<0)throw Error("KeyError");return this.splice(t,1)},append:function(t){return this.push(t)},remove:function(t){for(var n=0;nt.length||e>0&&o>=r||e<0&&o<=r);o+=e)s.push(i.memberLookup(t,o));return s}.apply(this,arguments):(t=t||{},r.isArray(t)&&l(v,n)?v[n].bind(t):r.isObject(t)&&l(p,n)?p[n].bind(t):f.apply(this,arguments))},function(){i.contextOrFrameLookup=h,i.memberLookup=f,e&&(e.prototype.assertType=t),s&&(s.prototype.parseAggregate=n)}}}])}); diff --git a/assets/page.njk b/assets/page.njk deleted file mode 100644 index 694ddb1..0000000 --- a/assets/page.njk +++ /dev/null @@ -1,3 +0,0 @@ -{# Fallback for a fragment that names no template of its own. #} -{% extends "base.njk" %} -{% block content %}{{ content | safe }}{% endblock %} diff --git a/assets/shell.html b/assets/shell.html new file mode 100644 index 0000000..200f785 --- /dev/null +++ b/assets/shell.html @@ -0,0 +1,86 @@ + + + + + +World English Bible Updated + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + diff --git a/assets/status.njk b/assets/status.njk deleted file mode 100644 index 30326bc..0000000 --- a/assets/status.njk +++ /dev/null @@ -1,5 +0,0 @@ -{# 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. #} -

{{ text }}

diff --git a/assets/sw.js b/assets/sw.js index a0ad43b..a55c42e 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -1,52 +1,93 @@ -// 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 app's offline copy, and its backend. Hand-written: this file is the +// source, served as-is apart from the three lines server.py stamps (see below). // -// The files on disk are fragments: a -// 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. +// The files on disk are fragments: a title element and one root element. The shell +// around it is shell.html, a static document with an empty #content. This +// worker does not assemble anything -- it answers every navigation with that +// one file, and app.js asks for the fragment the URL names. So there is a +// single way a page is built, online or off, first visit or hundredth. // // 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: +// Every same-origin GET is 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. Every response that arrives is written back to the +// cache, so a book that has been read once stays readable offline. // -// 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. +// The whole corpus is kept offline, but not by making anyone wait for it and +// not by telling them about it. Install caches the shell alone -- a handful of +// small files -- and the app is usable the moment that is done. The books are +// then fetched a few at a time in the background while the reader reads, with +// nothing on screen to say so. Close the tab half way through and the next +// visit picks up where it left off: the cache is the record of what has been +// fetched, so there is no separate bookkeeping to lose. -// 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. +// The background download gets a longer leash than a page the reader is +// waiting on: nothing is blocked on it, and giving up early on a slow link +// only means fetching the same book again later. +const DOWNLOAD_TIMEOUT_MS = 30000; + +// Few enough to leave the connection room for the page being read, more than +// one so a slow response does not stall the queue. The old code asked for +// every file at once, which is what made a weak connection collapse. +const DOWNLOAD_AT_ONCE = 4; + +// All three are stamped in by server.py from the files actually on disk, so an +// edit reaches installed devices with no build step: the worker's bytes +// change, the browser sees a new worker, and stale caches go on activate. The +// values below are the standalone fallback. +// +// The two versions are deliberately separate, over the two directories the +// server puts together. VERSION covers the hand-written chrome in assets/, so +// editing the shell drops and refetches a few hundred kilobytes. The other +// covers the generated pages in books/, which change only when +// render_books.py runs -- so a change of chrome no longer costs every device +// the whole corpus again. const VERSION = 'dev'; -const CACHE = 'bible-' + VERSION; -const ASSETS = ["./"]; +const LIBRARY_VERSION = 'dev'; +const LIBRARY = []; + +const SHELL_CACHE = 'bible-shell-' + VERSION; +const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION; + +// Resolved once, and the set is the test for which cache a response belongs +// in. Membership, not a path pattern: the split is whichever directory the +// file came from, which is a fact only the server has. +const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href)); + +// The shell and what it needs to paint. index.html is in here as well as in +// the library -- it is the one page the app falls back to, so it should not +// have to wait for the download to reach it. +const SHELL = './shell.html'; +const PRECACHE = [ + SHELL, + './index.html', + './app.js', + './app.css', + './htmx.min.js', + './bootstrap.min.css', + './bootstrap.bundle.min.js', + './manifest.webmanifest' +]; + +function cacheFor(req) { + return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE); +} self.addEventListener('install', (event) => { event.waitUntil((async () => { - const cache = await caches.open(CACHE); + const cache = await caches.open(SHELL_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) => + await Promise.all(PRECACHE.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; }))); - if (failed) console.warn('precache: ' + failed + ' of ' + ASSETS.length + ' failed'); + if (failed) console.warn('precache: ' + failed + ' of ' + PRECACHE.length + ' failed'); await self.skipWaiting(); })()); }); @@ -55,7 +96,7 @@ self.addEventListener('activate', (event) => { event.waitUntil((async () => { const keys = await caches.keys(); await Promise.all(keys - .filter((k) => k.startsWith('bible-') && k !== CACHE) + .filter((k) => k.startsWith('bible-') && k !== SHELL_CACHE && k !== LIBRARY_CACHE) .map((k) => caches.delete(k))); await self.clients.claim(); })()); @@ -66,15 +107,25 @@ self.addEventListener('fetch', (event) => { const url = new URL(req.url); if (url.origin !== location.origin) return; + // The background download rides along on whatever the page is already + // asking for. It gets no event of its own: activate cannot be used, because + // waitUntil there holds back activation and every fetch with it, and a + // worker is only kept alive while an event of its own is pending. So each + // request the reader makes is what carries the download forward. + keepFilling(event); + // Checked before the method guard: the bookmark API is the one thing here - // that answers POST and DELETE. + // that answers POST. 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)); + // Every navigation gets the shell, whatever path it asked for. One URL to + // cache, and it answers for a book that has never been opened as readily as + // for one that has -- what is missing is then the fragment, which is a + // message inside the app rather than a browser error page. + if (req.mode === 'navigate') return event.respondWith(answer(SHELL, event)); + + if (isPage(url)) return event.respondWith(page(req, event)); event.respondWith(answer(req, event)); }); @@ -83,9 +134,9 @@ self.addEventListener('fetch', (event) => { // Aborted rather than raced, so a hung request is actually torn down instead // of leaking while the cache answers. -async function fromNetwork(req) { +async function fromNetwork(req, timeout) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS); try { return await fetch(req, { signal: controller.signal }); } finally { @@ -97,27 +148,19 @@ 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. +// internal fetch from a browser one and hand back the file itself rather than +// the shell. 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)); + const write = cacheFor(req).then((c) => c.put(req, copy)); if (event) event.waitUntil(write); else write.catch(() => {}); } @@ -129,45 +172,105 @@ async function answer(input, event) { } } -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. + // Built here from live state rather than fetched; storing it would pin + // a stale copy over the real thing. 'Cache-Control': 'no-store' } }); } -// --------------------------------------------------------------- templates +// ------------------------------------------------------------------- pages -// 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)); -}; +function isPage(url) { + return url.pathname.endsWith('.html'); +} -// 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 }); +// A page that cannot be delivered becomes a fragment saying so, in the same +// shape as a real one: a title and one root element. htmx only swaps a 2xx +// body, so this is deliberately a 200 -- an error status would leave the +// reader looking at the page they came from with no explanation. +function notice(heading, text) { + return htmlResponse( + '<title>' + heading + '\n' + + '
\n' + + '

' + heading + '

\n' + + '

' + text + '

\n' + + '

All books

\n' + + '
'); +} -function render(name, context) { - return new Promise((resolve, reject) => { - env.render(name, context, (err, out) => (err ? reject(err) : resolve(out))); - }); +async function page(req, event) { + let res; + try { + res = await answer(req, event); + } catch (err) { + return notice('Not available offline', + 'This page has not been opened on this device, so there is no copy ' + + 'stored here. Reconnect and try again, or open one you have read before.'); + } + if (res.ok) return res; + return notice('Not found', 'There is no page at this address.'); +} + +// ----------------------------------------------------------------- library + +// The background download of the whole corpus. +// +// There is no progress to keep: the cache is the record. Each run asks it what +// is already there, which is what makes this survive the worker being shut +// down mid-run, resume on the next visit, and stay honest when a page arrives +// some other way -- reading a book puts it in the same cache. +let filling = null; // the run in flight, so two do not start at once +let complete = false; // a run got through the whole list; stop looking + +function keepFilling(event) { + if (complete) return; + // navigator.onLine is only ever trustworthy when it says false, which is the + // direction that matters: it stops a doomed run being started, and retried, + // on every request while the device is plainly offline. + if (!filling && self.navigator.onLine !== false) { + filling = fillLibrary().catch(() => {}).finally(() => { filling = null; }); + } + if (filling) event.waitUntil(filling); +} + +async function fillLibrary() { + const cache = await caches.open(LIBRARY_CACHE); + const have = new Set((await cache.keys()).map((req) => req.url)); + const missing = []; + LIBRARY_URLS.forEach((url) => { if (!have.has(url)) missing.push(url); }); + if (!missing.length) { complete = true; return; } + + let stopped = false; + const runners = []; + for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run()); + await Promise.all(runners); + // Only after a run that was never cut short: anything else leaves pages + // outstanding, and the next request should look again. + if (!stopped) complete = true; + + async function run() { + while (missing.length && !stopped) { + const url = missing.pop(); + try { + const res = await fromNetwork(new Request(url), DOWNLOAD_TIMEOUT_MS); + // A response that arrived but is not usable -- a page that has since + // been removed, say -- is skipped rather than retried. The connection + // is plainly fine, and stopping for it would strand everything after. + if (res.ok && res.type === 'basic') await cache.put(url, res); + } catch (err) { + // Stop the whole run rather than grinding through a hundred certain + // failures. The next request starts a fresh one, so reconnecting is + // all it takes to carry on. + stopped = true; + } + } + } } // ---------------------------------------------------------------- bookmark @@ -176,6 +279,11 @@ const DB_NAME = 'bible'; const DB_STORE = 'kv'; const BOOKMARK_KEY = 'bookmark'; +// There is no way to clear a bookmark, so there is no empty state: a reader +// who has never set one is treated as being at the beginning. Everything +// downstream -- the navbar slot and the API -- can assume a mark. +const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN.html#v1_1' }; + function openDb() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); @@ -201,25 +309,51 @@ async function withStore(mode, fn) { } } -// Storage being unavailable degrades to "no bookmark" rather than a broken -// page, the same way the old localStorage code did. +// Storage being unavailable degrades to the default rather than a broken page, +// which is the same fallback as never having set one. function getBookmark() { - return withStore('readonly', (s) => s.get(BOOKMARK_KEY)).catch(() => null); + return withStore('readonly', (s) => s.get(BOOKMARK_KEY)) + .catch(() => null) + .then((mark) => mark || DEFAULT_BOOKMARK); } function isBookmarkApi(url) { return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark'); } +function esc(text) { + return String(text).replace(/[&<>"]/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +} + +// Replaces the empty slot in shell.html, and then replaces itself on every +// later read or write. The swap is outerHTML, so this markup has to re-declare +// its own htmx attributes: 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. +function bookmarkHtml(mark) { + return '' + + // Not boosted: a full load lands on the shell with the fragment + // identifier intact, which is what app.js scrolls to. + '' + + esc(mark.ref) + ''; +} + +// Out of band, so one response updates both the navbar slot and the modal's +// status line without the modal having to ask separately. +function statusHtml(text) { + return '

' + + esc(text) + '

'; +} + 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 }); - } + let html = bookmarkHtml(await getBookmark()); + if (status) html += '\n' + statusHtml(status); return html; } @@ -236,9 +370,6 @@ async function bookmarkApi(event) { 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); } @@ -247,45 +378,3 @@ async function bookmarkApi(event) { return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.')); } } - -// ------------------------------------------------------------- composition - -const TITLE_RE = /([\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 - })); -} diff --git a/books/AMO.html b/books/AMO.html index a071340..5708833 100644 --- a/books/AMO.html +++ b/books/AMO.html @@ -1,7 +1,7 @@ <title>Amos — World English Bible Updated
-

The Book ofAmos

+

The Book of Amos