Merge commit '56571a9bd72d6470514c34d71982f63e290125f3' into poster-design-plugin/catalogue

This commit is contained in:
Doxa Maps 2026-10-02 13:11:25 +00:00
commit 86ade3356d
6 changed files with 74 additions and 25 deletions

View file

@ -660,6 +660,26 @@ function assertNoDuplicateTabKeys(navModel) {
return dupes
}
// ★ FRESH BUNDLES AFTER A DEPLOY, AT THE SAME URLs (2026-10-02). A host that serves the tree with Last-Modified and no
// Cache-Control (the DX Life landing does) lets a browser run its cached copies for hours: measured, a typed-URL revisit
// after a deploy requested NONE of the gallery, the manifest or the bundles, so a returning reader ran the old files, or
// an old manifest against a new bundle. A version query cannot fix it here: loadScriptOnce keys its registry by the full
// href, so X.js?v= beside a composite's plain X.js would run the bundle twice. So the manifest is always revalidated, and
// when its fingerprint (every bundle's integrity) differs from this reader's last visit to this tree, every listed bundle
// gets ONE conditional request before any tab mounts: a 304 refreshes the cached copy, and a 200 is cancelled at its
// headers, which drops the stale entry. The plain <script> that follows (ours, or a composite's for its children) then
// runs the new file. An ordinary visit costs nothing extra.
const TREE_FP_KEY = 'dxm.treeFingerprint@'
function treeFingerprint(manifest) {
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
const s = bundles.map(b => (b && b.bundle) + ':' + ((b && b.integrity) || '')).join('|')
let h = 2166136261 // FNV-1a: a short, stable key, not a security hash
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
return (h >>> 0).toString(36) + '.' + s.length
}
function readTreeFp(root) { try { return localStorage.getItem(TREE_FP_KEY + root) } catch { return null } }
function writeTreeFp(root, fp) { try { localStorage.setItem(TREE_FP_KEY + root, fp) } catch { /* private mode: revalidate next time */ } }
// Turn a bundle id into a human label: "all-people-groups" → "All People Groups". Small, local and
// dependency-free; a bundle may override it by shipping a profile `title` in the manifest.
function titleize(id) {
@ -1324,17 +1344,24 @@ export default defineComponent({
const devFallback = '/public/' + __DOXA_TREE_NAME__ + '/manifest.json'
if (!candidates.some(c => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
const tried = []
// Every mount waits for this gate (see FRESH BUNDLES above): it opens once the manifest's bundles are known
// to be current, when no manifest can be read (fail-soft, as before), or after 8 s whatever happens.
let open
this._freshGate = new Promise(res => { open = res })
setTimeout(open, 8000)
const attempt = (i) => {
if (i >= candidates.length) {
// Every candidate failed. Record it on the tabs so the PANE says so — see mountTab().
this.manifestError = 'manifest.json not found (tried ' + tried.join(', ') + ')'
console.error('[doxa-maps-spa] ' + this.manifestError +
' — every tab except Big Picture is composed from it, so their panes cannot mount.')
open()
return
}
const { url, root: treeRoot } = candidates[i]
tried.push(url)
return fetch(url, { credentials: 'same-origin' })
// no-cache: always revalidated (a 304 when unchanged), so the manifest is never a stale copy.
return fetch(url, { credentials: 'same-origin', cache: 'no-cache' })
.then(r => { if (!r || !r.ok) throw new Error(url + ' → HTTP ' + (r ? r.status : '?')); return r.json() })
.then(m => {
if (!m) throw new Error(url + ' → empty')
@ -1344,11 +1371,32 @@ export default defineComponent({
// resolves '../' and rooted forms alike, so a caller-supplied treeBase, a built-tree
// path and the dev fallback all normalise here.
this.manifestBase = treeRoot || ''
this.revalidateBundles(m, treeRoot || '').then(open, open)
this.applyManifest(m)
})
.catch(() => attempt(i + 1))
}
try { attempt(0) } catch { /* fetch unavailable (non-browser): same fail-soft path */ }
try { attempt(0) } catch { open() /* fetch unavailable (non-browser): same fail-soft path */ }
},
// After a deploy (the manifest's fingerprint changed since this reader's last visit to this tree), ONE conditional
// request per listed bundle, so no stale cached copy is run (see FRESH BUNDLES). Never rejects.
revalidateBundles(m, root) {
const fp = treeFingerprint(m)
const prev = readTreeFp(root)
if (prev === fp) return Promise.resolve()
const urls = [...new Set(((m && m.bundles) || []).map(b => (b && b.scriptUrl) ? this.resolve(b.scriptUrl) : '').filter(Boolean))]
// Only a copy this browser already HOLDS can be stale, so the cache is asked first (only-if-cached never touches
// the network) and only those get the conditional request: a first visit with an empty cache costs nothing.
// only-if-cached needs a same-origin request; across origins, only a reader who has been here before is checked.
const sameOrigin = (u) => { try { return new URL(u, location.href).origin === location.origin } catch { return false } }
const held = (u) => (sameOrigin(u)
? fetch(u, { mode: 'same-origin', credentials: 'same-origin', cache: 'only-if-cached' })
.then(r => { if (r && r.body && r.body.cancel) r.body.cancel(); return !!(r && r.ok) }, () => false)
: Promise.resolve(prev !== null))
const revalidate = (u) => fetch(u, { credentials: 'same-origin', cache: 'no-cache' })
.then(r => { if (r && r.status === 200 && r.body && r.body.cancel) return r.body.cancel() })
const one = (u) => held(u).then(h => (h ? revalidate(u) : null)).catch(() => {})
return Promise.all(urls.map(one)).then(() => writeTreeFp(root, fp))
},
// Complete the curated tabs from the manifest, drop any whose bundle this tree does not ship,
// then append the discovered Experimental tabs — and finally (re)drive the active tab's mount,
@ -1592,7 +1640,8 @@ export default defineComponent({
// Gate the mount on the TAG being registered, NOT on the script's onload — onload fires even
// when a dev ES module then fails to execute (see loadScriptOnce), which would hand us an inert
// HTMLUnknownElement and an empty pane cheerfully reporting 'ready'.
loadScriptOnce(jsUrl).then(() => whenTagDefined(t.tag)).then(() => {
// …and on the freshness gate first, so a deploy's new bundle is the copy that runs (see FRESH BUNDLES).
Promise.resolve(this._freshGate).then(() => loadScriptOnce(jsUrl)).then(() => whenTagDefined(t.tag)).then(() => {
// Everything below is per-nested-plugin machinery; a throw here (a nested element that blows
// up in its connectedCallback, a serialise failure) must NOT crash the SPA — it lands in THIS
// tab's error pane. Hence the try/catch on top of the .catch() that handles a failed load.

File diff suppressed because one or more lines are too long

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-6LiR6vjB5GrQ6TfcAVuCU0pon7+MTANx5bWgB0a9RiWqYqBvFZ1DS9XX7/hg07iO" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-D/MCvAZhvdZ4CaVcpxKTxFLdT9GEuHidcKwBImnHSQfvUJg6mkAae/Y5JeUQYUjc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-6LiR6vjB5GrQ6TfcAVuCU0pon7+MTANx5bWgB0a9RiWqYqBvFZ1DS9XX7/hg07iO" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-D/MCvAZhvdZ4CaVcpxKTxFLdT9GEuHidcKwBImnHSQfvUJg6mkAae/Y5JeUQYUjc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -409,7 +409,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-6LiR6vjB5GrQ6TfcAVuCU0pon7+MTANx5bWgB0a9RiWqYqBvFZ1DS9XX7/hg07iO",
"integrity": "sha384-D/MCvAZhvdZ4CaVcpxKTxFLdT9GEuHidcKwBImnHSQfvUJg6mkAae/Y5JeUQYUjc",
"elements": [
"doxa-maps-spa"
],
@ -439,7 +439,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-6LiR6vjB5GrQ6TfcAVuCU0pon7+MTANx5bWgB0a9RiWqYqBvFZ1DS9XX7/hg07iO\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-D/MCvAZhvdZ4CaVcpxKTxFLdT9GEuHidcKwBImnHSQfvUJg6mkAae/Y5JeUQYUjc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{

View file

@ -409,7 +409,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-6LiR6vjB5GrQ6TfcAVuCU0pon7+MTANx5bWgB0a9RiWqYqBvFZ1DS9XX7/hg07iO",
"integrity": "sha384-D/MCvAZhvdZ4CaVcpxKTxFLdT9GEuHidcKwBImnHSQfvUJg6mkAae/Y5JeUQYUjc",
"elements": [
"doxa-maps-spa"
],
@ -439,7 +439,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-6LiR6vjB5GrQ6TfcAVuCU0pon7+MTANx5bWgB0a9RiWqYqBvFZ1DS9XX7/hg07iO\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-D/MCvAZhvdZ4CaVcpxKTxFLdT9GEuHidcKwBImnHSQfvUJg6mkAae/Y5JeUQYUjc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{