Files
bible/assets/js/render.js
T
2026-09-09 09:08:19 -04:00

151 lines
6.5 KiB
JavaScript

// 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; compile_bible.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/';
// All of them. Named here rather than in either caller because this file is
// the one that knows what a template is: sw.js precaches them from this list
// and both renderers load them from it, so there is one place to add the
// next one. A template missing from here is a template nunjucks would have
// to fetch mid-render, which is the one thing loadAll() exists to prevent.
var NAMES = [
'base.njk',
'page.njk',
'book.njk',
'index.njk',
'bookmark.njk',
'reference.njk',
'verses.njk',
'verse.njk',
'version.njk',
'toast.njk',
'notice.njk'
];
scope.TEMPLATE_URLS = NAMES.map(function (name) { return TEMPLATES + name; });
// 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 });
// Every template, in nunjucks' hands before the first render, because an
// include that has to wait for one does not come out whole.
//
// getTemplate answers from the loader's cache by calling straight back,
// and only reaches this loader -- and this loader's promise -- for a
// template it has never seen. Nunjucks compiles {% include %} into a
// waterfall, and the rest of the block it sits in is the waterfall's
// callback while the code after that block runs on regardless. Answered
// in the same tick the two happen in order and the output is whole;
// answered a tick later the render is finished and handed back first,
// and what comes back is missing the include and everything between it
// and the end of its block. The verse list, and then the </select> after
// it -- a picker with a chapter and no verses, on whichever navigation
// happened to be the first to need the template.
//
// Which is why it is all of them and not just the ones that get included:
// whether a template has been reached yet depends on which pages have
// been rendered, and in the worker on whether it has been restarted since.
// That is the whole shape of the bug -- intermittent, and never twice in a
// row. One pass over a few kilobytes already in the cache buys the
// question being settled.
//
// Failures resolve rather than reject: a template that cannot be read now
// will fail again in the render that actually needs it, where the error
// belongs to a request and can be answered with the bare fragment.
var loaded = null;
function loadAll() {
if (!loaded) {
loaded = Promise.all(NAMES.map(function (name) {
return new Promise(function (resolve) {
env.getTemplate(name, true, null, false, function () { resolve(); });
});
}));
}
return loaded;
}
function render(name, context) {
return loadAll().then(function () {
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 -- compile_bible.py escapes as it writes and
// notice.njk comes out of nunjucks -- which is what the |safe on it in
// base.njk is for.
//
// chrome is what the shell needs and the fragment cannot carry: the
// bookmark, the navbar picker's model, and the two versions the worker
// was built with. All three are null from boot.html, which has no worker
// to ask for any of them; the first two render as a hidden slot that
// fills itself if one ever appears, and the third renders as nothing,
// because no worker is the whole of what there is to report.
compose: function (fragment, chrome) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, '');
var name = (TEMPLATE_RE.exec(content) || ['', 'page'])[1] + '.njk';
return render(name, {
title: title,
content: content,
mark: (chrome && chrome.mark) || null,
reference: (chrome && chrome.reference) || null,
version: (chrome && chrome.version) || 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);