doxa-maps/internal/generate-gallery.mjs

457 lines
35 KiB
JavaScript

/**
* generate-gallery.mjs — THE plugin gallery: one generator, two modes.
* (No shebang on purpose: vite.config.js imports this module through Vite's config bundler,
* which prepends a shim to every bundled file — a shebang below line 1 is a syntax error there.)
*
* renderGallery(model, { mode: 'build' }) public/index.html — the root page of the served
* web-root, written by `bun run gallery` (last step of
* `bun run build`) from public/<tree>/manifest.json.
* renderGallery(model, { mode: 'dev' }) what `bun run dev` serves at / — the same page over a
* manifest-shaped model of app-profiles/ (see
* internal/plugin-gallery/dev-plugin.mjs).
*
* public/ holds exactly three things: assets/ (data), <tree>/ (the bundle folders + manifest.json)
* and this one page, which lists every bundle → profile → instance the manifest declares and
* mounts the chosen one. It is GENERATED — never hand-edited — as a single static file: inline
* CSS, inline vanilla JS, every path relative, so the same file works from any static host,
* under a subpath (the website serves it at /embed/doxa-maps-build/index.html) and from file://.
*
* At runtime the page fetches <tree>/manifest.json so it stays correct when bundles change; a
* compact copy of the manifest is baked in as the fallback (file://, or the fetch fails). Embed
* snippets are re-derived from the manifest fields with the template generate-tree uses; this
* script checks the derivation against every manifest snippet and bakes any that differ, so the
* fallback is exact without carrying the snippets twice.
*
* The chosen map mounts exactly like its leaf page: same peer engine (Mapbox, or MapLibre for a
* provider:'maplibre' instance), same profile-config (assetsBase, dataSource, instanceId or the
* instance props), window.MAP_APP_API_URL, and the same token ladder — profile-config tk →
* ?tk= on this page's URL → the paste-once localStorage token → baked token → window.MAP_TOKEN_URL
* → the configured tokenUrl → same-origin /api/maps/token → ask once.
*
* Deep link: #/<bundle>/<profile>[/<instance>]. Actions: Open page (the leaf page), Copy embed
* (the manifest's embed.iframe / embed.script).
*
* What mode 'dev' changes — and nothing else: the loader imports the bundle's dev entry
* (scriptUrl = /app-profiles/<bundle>/index.js, an ES module Vite serves) instead of adding an
* IIFE script tag; the live model is fetched from the dev route (<tree> = __plugin-gallery); and
* the header carries the composability ladder with its "?" popovers (kind explanations + the
* "Start here" primer). The popovers stay out of the built page on purpose: they add ~8 KB of
* text to a 22 KB page and explain the source tree, which a visitor of the served root never sees.
*
* Usage: node internal/generate-gallery.mjs (`bun run gallery` — last step of `bun run build`)
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Same fields the tree generator bakes into every leaf page (vite.doxa-maps-build-config.json).
export function readBuildConfig() {
let cfg = {}
try { cfg = JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')) } catch { /* defaults */ }
const str = (k) => (typeof cfg[k] === 'string' ? cfg[k].trim() : '')
let tokenUrl = str('tokenUrl')
if (!tokenUrl) {
try {
const src = JSON.parse(readFileSync(join(ROOT, 'library/api/sources.json'), 'utf8'))
const v = src?._meta?.mapboxTokenUrl || src?.mapboxTokenUrl
if (typeof v === 'string' && v.trim()) tokenUrl = v.trim()
} catch { /* fall through */ }
}
return {
name: str('name') || 'doxa-maps',
title: str('displayTitle') || 'doxa-maps',
tokenUrl: tokenUrl || 'window.MAP_TOKEN_URL',
token: str('token'),
apiBaseUrl: str('apiBaseUrl').replace(/\/+$/, ''),
assetsBaseUrl: str('assetsBaseUrl').replace(/\/+$/, ''),
qaWidget: cfg.qaWidget !== false,
}
}
// ── Shared with the page (injected by source, so Node and the browser run the same code) ──
// The embed snippets exactly as generate-tree writes them into the manifest: profile → iframe +
// script, instance → iframe only. b/p/x are the compact records below.
function snip(b, p, x) {
var u = (x || p).u, tag = (x && x.e) || p.e || b.e[0] || b.b
var r = { iframe: '<iframe src="' + u + '?utm_source=YOUR_SOURCE" width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>' }
if (!x) r.script = '<script src="' + b.s + '"' + (b.i ? ' integrity="' + b.i + '" crossorigin="anonymous"' : '') + '><\/script>\n<' + tag + " profile-config='" + JSON.stringify({ profile: p.p, tk: 'YOUR_MAPBOX_TOKEN', utmSource: 'YOUR_SOURCE' }) + "'></" + tag + '>'
return r
}
// Manifest → compact registry: [{b,s,i,e,p:[{p,e,u,d,em?,in?:[{id,t,a,pr,e,u,em?}]}]}].
// lean = drop every embed snippet the page can re-derive with snip() (the baked fallback);
// the live manifest keeps its snippets verbatim.
export function compact(m, lean) {
return (m.bundles || []).map(function (src) {
var b = { b: src.bundle, s: src.scriptUrl || '', i: src.integrity || '', e: src.elements || [], p: [] }
b.p = (src.profiles || []).map(function (sp) {
var p = { p: sp.profile, e: sp.element || '', u: sp.iframeUrl || '', d: sp.description || '' }
var em = sp.embed || {}, d = snip(b, p, null)
if (!lean || em.iframe !== d.iframe || em.script !== d.script) p.em = em
var ins = (sp.instances || []).map(function (sx) {
var x = { id: sx.id, t: sx.title || sx.id, a: sx.apiBaseUrl || '', pr: sx.props || {}, e: sx.element || '', u: sx.iframeUrl || '' }
var xe = sx.embed || {}, xd = snip(b, p, x)
if (!lean || xe.iframe !== xd.iframe) x.em = xe
return x
})
if (ins.length) p.in = ins
return p
})
return b
})
}
// Function source for the page. The dev server loads this module through Vite's config bundler,
// whose printer may turn the '<\/script>' literal above back into '</script>' — inside an inline
// script that would end the element early, so the escape is restored here (a no-op under node).
const src = (fn) => fn.toString().replace(/<\/script/gi, '<\\/script')
// ── mode 'dev' only: the composability ladder + its "?" popovers ────────────────────────────
// Vocabulary is the validated set from docs/reference.md "Profile types": head words are
// established terms (custom element, preset, multi-view, composite, app shell); the coined words
// stay as a.k.a. Popover strings are HTML (innerHTML) — tags are escaped.
const LADDER_CSS = String.raw`:root{--acc:#1a73e8}@media(prefers-color-scheme:dark){:root{--acc:#64b5f6}}
.ladder{position:relative;margin-left:auto;display:flex;align-items:center;gap:6px;white-space:nowrap}
.rung{display:inline-flex;align-items:center;gap:4px}.rname{font:500 12px/1 ui-monospace,Menlo,monospace}
.arrow{color:var(--mut);font-size:12px;padding:0 1px}
.q{width:18px;height:18px;padding:0;border-radius:50%;font:700 11px/1 system-ui,sans-serif;color:var(--mut);background:var(--bg);display:inline-grid;place-items:center}
.start{font:600 10.5px/1 system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;color:var(--acc);background:var(--bg);border-radius:999px;padding:4px 9px;margin-right:4px}
.q:hover,.q[aria-expanded=true],.start:hover,.start[aria-expanded=true]{color:var(--acc);border-color:var(--acc);background:var(--bg)}
.explain{position:absolute;top:calc(100% + 8px);right:0;width:420px;max-width:calc(100vw - 32px);max-height:70vh;overflow:auto;z-index:40;background:var(--bg);border:1px solid var(--bd);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.14);padding:12px 14px;font-size:13px;line-height:1.45;text-align:left;white-space:normal}
.explain h3{margin:0 0 5px;font-size:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.explain h4{margin:8px 0 3px;font-size:13px}
.explain .tier{font:700 10px/1 system-ui,sans-serif;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)}
.explain p{margin:0 0 6px}.explain .aka{font-size:11.5px;color:var(--mut);margin:-2px 0 6px}
.explain .how{margin:0 0 6px;font-size:12.5px}.explain .how summary{cursor:pointer;color:var(--acc);font-weight:600;font-size:12px}.explain .how p{margin:6px 0 0;color:var(--mut)}
.explain .ex{color:var(--mut);font-size:12px}
@media(max-width:1280px){.ladder{display:none}}
`
const LADDER_HTML = '<nav class="ladder" id="ladder" aria-label="Composability ladder"></nav>'
const LADDER_JS = String.raw`<script>
(function () {
var PRIMER = [
{ title: 'Element',
def: 'A custom HTML tag this repo invents, such as &lt;doxa-map&gt;: the browser learns it once, then you can paste it into any page as often as you like. Each pasted tag is one live copy, called an instance. (People also say &quot;web component&quot;.)',
how: 'The bundle index.js turns the shared ProfileLoader into an element class with defineCustomElement and registers the tag once with customElements.define.',
ex: '&lt;doxa-map profile-config=&#39;{&quot;profile&quot;:&quot;doxa-simple-map&quot;}&#39;&gt;&lt;/doxa-map&gt;' },
{ title: 'App profile (mounting file)',
def: 'One flat .vue file in app-profiles/&lt;bundle&gt;/ that defines a whole map app — the main component of that map. The settings written on the tag (profile-config) name which app profile to load, and the shared ProfileLoader picks it. In class-and-object terms: the element class is the class, each tag on a page is an instance, and the app profile is the screen that instance loads.',
how: 'Most repos need one such main file per app. doxa-maps builds hundreds of maps from one shared library, so every map gets its own app profile — the folder app-profiles/ is where they sit. The five kinds on this ladder are the five shapes those files come in.',
ex: 'app-profiles/doxa-simple-map/doxa-simple-map.vue' }
];
var LADDER = [
{ label: 'single-element', tier: 4, title: 'Single element', aka: 'single',
def: 'One app profile, one map: paste its tag into any page, once or many times, and set it up right there in profile-config. The smallest kind and the default — no preset table, no tabs, no nested maps.',
how: 'A flat .vue with no &lt;profile&gt;.meta.json beside it and no *.instances.json in the bundle folder. Machine key: single.',
ex: 'cities-internet/connectivity-map.vue → &lt;cities-internet-map&gt; · gospel-resources/resources-map.vue' },
{ label: 'preset-element', tier: 3, title: 'Preset element', aka: 'parameterized · prop-many-elements',
def: 'One app profile plus a small table of named presets (&lt;name&gt;.instances.json). Each row is one ready-made map with its settings baked in and gets its own embed page and sidebar entry — same code, same tag.',
how: 'The bundle folder holds &lt;name&gt;.instances.json ({&quot;instances&quot;:[{id,title,props}]}); the build writes one embed page per row at &lt;bundle&gt;/&lt;profile&gt;/&lt;id&gt;/index.html. Machine key: parameterized. Not this if the versions need different code — make another app profile.',
ex: 'doxa-simple-map.vue + simple-map.instances.json → Prayer / Engagement / Adoption' },
{ label: 'multi-view-element', tier: 2, title: 'Multi-view element', aka: 'monolithic · monolithic-mfe',
def: 'One app profile that holds several screens you switch between — tabs or steps — all written in one .vue and shown as one element, one screen at a time. Keeping them in one file is on purpose.',
how: 'Declared, never guessed: &lt;profile&gt;.meta.json {&quot;type&quot;:&quot;monolithic&quot;} (or &quot;multi-view-element&quot;); the .vue owns its own switcher. Not this if the pieces are other app profiles (composite) or all panels show at once (single).',
ex: 'doxa-research-map/research-map.vue — six tabs in one &lt;doxa-research-map&gt;' },
{ label: 'composite-element', tier: 1, title: 'Composite element', aka: 'nested-elements-mfe',
def: 'An app profile whose template places the maps of other app profiles together — stacked or side by side, such as a map and a poster wall — and ships the result as one new element. One tag embeds the whole set.',
how: 'Declared: &lt;profile&gt;.meta.json {&quot;type&quot;:&quot;nested-elements-mfe&quot;,&quot;composes&quot;:[…]} (or &quot;composite-element&quot;); the .vue creates each child tag with its own profile-config on the pane layout (layouts/pane-wrapper). Not this if the screens are written inside one file (multi-view).',
ex: 'my-upg-100-list/upg-100-mfe.vue — Map pane + Prayer Lists pane, one Share button' },
{ label: 'app-shell', tier: 0, title: 'App shell', aka: 'nested-mfe-spa · container application · root config · host',
def: 'The outer frame of a whole app — sidebar, header, navigation — that loads any of the other four kinds inside it and switches between them without a page reload. The only rung that is a page, not an element: what a visitor actually opens.',
how: 'Today both shells are HTML pages, not .vue app profiles: layouts/app-wrapper-material/index.html and this plugin gallery (internal/generate-gallery.mjs, one generator for the dev root and public/index.html). Machine key nested-mfe-spa (or app-shell) is reserved for a future .vue shell.',
ex: 'this plugin gallery · the Doxa Maps SPA' }
];
var ladder = document.getElementById('ladder'), openQ = null;
var pop = document.createElement('div'); pop.className = 'explain'; pop.hidden = true; pop.setAttribute('role', 'dialog');
function closePop() { pop.hidden = true; if (openQ) { openQ.setAttribute('aria-expanded', 'false'); openQ = null } }
function showPop(btn, html) { if (openQ === btn) { closePop(); return } closePop(); openQ = btn; btn.setAttribute('aria-expanded', 'true'); pop.innerHTML = html; pop.hidden = false }
function howBlock(t) { return '<details class="how"><summary>For builders — how to recognise it</summary><p>' + t + '</p></details>' }
function kindHtml(r) { return '<h3>' + r.title + ' <span class="tier">tier ' + r.tier + ' · ' + r.label + '</span></h3><p>' + r.def + '</p><div class="aka">a.k.a. ' + r.aka + '</div>' + howBlock(r.how) + '<div class="ex">e.g. ' + r.ex + '</div>' }
function primerHtml() { return '<h3>Start here <span class="tier">the two words every rung uses</span></h3>' + PRIMER.map(function (p) { return '<h4>' + p.title + '</h4><p>' + p.def + '</p>' + howBlock(p.how) + '<div class="ex">e.g. ' + p.ex + '</div>' }).join('') }
var start = document.createElement('button'); start.className = 'start'; start.type = 'button'; start.textContent = 'Start here'; start.setAttribute('aria-expanded', 'false');
start.setAttribute('aria-label', 'What is an element? What is an app profile?'); start.onclick = function (e) { e.stopPropagation(); showPop(start, primerHtml()) }; ladder.appendChild(start);
LADDER.forEach(function (r, i) {
if (i) { var a = document.createElement('span'); a.className = 'arrow'; a.textContent = '→'; ladder.appendChild(a) }
var rung = document.createElement('span'); rung.className = 'rung';
var nm = document.createElement('span'); nm.className = 'rname'; nm.textContent = r.label;
var q = document.createElement('button'); q.className = 'q'; q.type = 'button'; q.textContent = '?'; q.setAttribute('aria-label', 'What is a ' + r.title.toLowerCase() + '?'); q.setAttribute('aria-expanded', 'false');
q.onclick = function (e) { e.stopPropagation(); showPop(q, kindHtml(r)) };
rung.appendChild(nm); rung.appendChild(q); ladder.appendChild(rung);
});
ladder.appendChild(pop);
document.addEventListener('click', function (e) { if (!ladder.contains(e.target)) closePop() });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePop() });
})();
</script>
`
/**
* The whole page from a manifest-shaped model (schema doxa-maps-manifest@1: bundles → profiles →
* instances, with scriptUrl / iframeUrl / embed / integrity). Pure: no I/O, no globals.
*
* @param {object} model the manifest (build) or the dev model (dev-plugin.mjs galleryModel)
* @param {object} opts mode 'build' | 'dev'; title; tree — the folder the live manifest is
* fetched from (<tree>/manifest.json) and that relative refs resolve
* against (build: the tree name; dev: the route prefix, refs absolute);
* tokenUrl / token / apiBaseUrl / assetsBaseUrl / qaWidget — the build
* config fields every leaf page bakes (readBuildConfig)
*/
export function renderGallery(model, opts = {}) {
const { mode = 'build', title = 'doxa-maps', tree = 'doxa-maps', tokenUrl = 'window.MAP_TOKEN_URL', token = '', apiBaseUrl = '', assetsBaseUrl = '', qaWidget = true } = opts
const dev = mode === 'dev'
const baked = compact(model, true)
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c')
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
// The one line the two modes load a bundle with: the built IIFE (SRI-checked) vs the dev entry.
const loader = dev
? `function bundle(b) { return import(rel(b.s)) }`
: `function bundle(b) { return once(rel(b.s), 'js', b.i ? { integrity: b.i, crossOrigin: 'anonymous' } : {}) }`
return String.raw`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<link rel="icon" href="data:,">
<style>
:root{--bg:#fff;--bg2:#f6f7f9;--bg3:#eceef2;--bd:#e2e5ea;--fg:#15181d;--mut:#667085;--err:#d13c3c}
@media(prefers-color-scheme:dark){:root{--bg:#111318;--bg2:#181b21;--bg3:#232730;--bd:#2b3038;--fg:#eef0f3;--mut:#9aa3b2;--err:#ff7b7b}}
*{box-sizing:border-box}html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;height:100vh}
header{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--bd)}
header b{font-size:15px}.n{color:var(--mut);font-size:12px}
#menu{display:none;margin-left:auto}
button,.btn{font:inherit;font-size:12px;padding:5px 10px;border:1px solid var(--bd);border-radius:8px;background:var(--bg2);color:var(--fg);cursor:pointer;text-decoration:none;white-space:nowrap}
button:hover,.btn:hover{background:var(--bg3)}
.body{flex:1 1 auto;display:flex;min-height:0}
nav{flex:0 0 270px;overflow:auto;border-right:1px solid var(--bd);padding:6px 0 20px}
.gh{padding:12px 14px 4px;font-weight:600;font-size:13px;display:flex;justify-content:space-between;gap:8px}.gh span{color:var(--mut);font-weight:400}
.row{display:block;width:100%;text-align:left;border:0;border-radius:0;background:none;padding:6px 14px;font-size:13px;line-height:1.3;white-space:normal}
.row.in{padding-left:28px}.row code{display:block;font-size:11px;color:var(--mut)}
.row:hover{background:var(--bg2)}.row[aria-current=true]{background:var(--bg3);font-weight:600}
main{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;padding:10px 12px 12px;gap:8px}
.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.bar .t{flex:1 1 auto;min-width:0}.bar b{font-size:15px}.s{display:block;font-size:12px;color:var(--mut)}
code{font-family:ui-monospace,Menlo,monospace}
#embed{display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--bd);border-radius:8px;background:var(--bg2)}
#embed label{font-size:12px;color:var(--mut);display:flex;justify-content:space-between;align-items:center}
#embed textarea{width:100%;height:58px;font:11px/1.35 ui-monospace,Menlo,monospace;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--fg);padding:6px;resize:vertical}
#slot{flex:1 1 auto;position:relative;min-height:240px;border:1px solid var(--bd);border-radius:10px;overflow:hidden;background:var(--bg2)}
#slot>*{position:absolute;inset:0;width:100%;height:100%;display:block;border:0}
.hint{display:grid;place-items:center;padding:20px;text-align:center;color:var(--mut);font-size:13px}
.hint.err{color:var(--err)}
.hint input{font:12px ui-monospace,Menlo,monospace;padding:6px 8px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--fg);width:min(320px,70vw)}
[hidden]{display:none!important}
@media(max-width:700px){.body{flex-direction:column}nav{flex:0 0 auto;max-height:45vh;border-right:0;border-bottom:1px solid var(--bd)}#menu{display:inline-block}}
${dev ? LADDER_CSS : ''}</style>
</head>
<body>
<header><b>${esc(title)}</b><span class="n" id="count"></span>${dev ? LADDER_HTML : ''}<button id="menu" type="button" aria-controls="nav" aria-expanded="true">Maps</button></header>
<div class="body">
<nav id="nav" aria-label="Maps"></nav>
<main>
<div class="bar"><div class="t"><b id="ttl">Choose a map</b><span class="s" id="sub"></span></div><a class="btn" id="open" target="_blank" rel="noopener" hidden>Open page</a><button id="emb" type="button" hidden aria-expanded="false">Copy embed</button></div>
<div id="embed" hidden><label>iframe embed <button type="button" data-copy="ifr">Copy</button></label><textarea id="ifr" readonly></textarea><div id="scrw"><label>script embed <button type="button" data-copy="scr">Copy</button></label><textarea id="scr" readonly></textarea></div></div>
<div id="slot"><div class="hint">Choose a map from the list.</div></div>
</main>
</div>
<script>
(function () {
var TREE = ${json(tree)}, TOKEN_SRC = ${json(tokenUrl)}, BAKED_TOKEN = ${json(token)}, API_BASE = ${json(apiBaseUrl)}, ASSETS_BASE = ${json(assetsBaseUrl)}, QA = ${json(qaWidget)};
var DEFAULT_TOKEN_URL = '/api/maps/token';
var BAKED = ${json(baked)};
var snip = ${src(snip)};
var compact = ${src(compact)};
// Peer engines, the same tags every leaf page carries; loaded once, before the bundle.
var PEERS = {
mapbox: { css: ['https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css', 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'],
js: ['https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js', 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'] },
maplibre: { css: ['https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css'], js: ['https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js'] }
};
var $ = function (id) { return document.getElementById(id) };
var nav = $('nav'), slot = $('slot'), ttl = $('ttl'), sub = $('sub'), open = $('open'), embBtn = $('emb'), embed = $('embed'), ifr = $('ifr'), scr = $('scr'), scrw = $('scrw'), menu = $('menu'), count = $('count');
// THIS PAGE'S OWN DIRECTORY. A host may serve the folder at /…/doxa-maps-build (no trailing
// slash); the browser would then resolve every relative ref against the PARENT directory and
// drop the folder name, so bundles 404. Derive the directory explicitly and build refs from it.
var BASE = (function () {
var p = location.pathname || '/';
return /\.[a-z0-9]+$/i.test(p) ? p.replace(/[^/]*$/, '') : p.replace(/\/*$/, '') + '/';
})();
var ASSETS = ASSETS_BASE || new URL(BASE + 'assets', location.origin).href.replace(/\/+$/, '');
var REG = BAKED, active = null, seq = 0, loaded = {}, fetchedToken = '';
var narrow = function () { return window.matchMedia && window.matchMedia('(max-width:700px)').matches };
// Manifest refs are relative to the tree root; this page sits one level above it. Refs are
// anchored to BASE (above) so they resolve the same with or without a trailing slash.
function rel(u) { return /^([a-z]+:|\/)/i.test(u) ? u : BASE + TREE + '/' + u.replace(/^\.\//, '') }
function qp(name) { try { return new URLSearchParams(location.search).get(name) || '' } catch (e) { return '' } }
function hint(msg, err) { var d = document.createElement('div'); d.className = 'hint' + (err ? ' err' : ''); d.textContent = msg; slot.innerHTML = ''; slot.appendChild(d); return d }
function assign(t) { for (var i = 1; i < arguments.length; i++) { var s = arguments[i]; for (var k in s) if (Object.prototype.hasOwnProperty.call(s, k)) t[k] = s[k] } return t }
function once(url, kind, attrs) {
if (loaded[url]) return loaded[url];
loaded[url] = new Promise(function (ok, fail) {
var el = document.createElement(kind === 'css' ? 'link' : 'script');
if (kind === 'css') { el.rel = 'stylesheet'; el.href = url } else { el.src = url; assign(el, attrs || {}) }
el.onload = function () { ok() }; el.onerror = function () { delete loaded[url]; fail(new Error('failed to load ' + url)) };
document.head.appendChild(el);
});
return loaded[url];
}
function peer(provider) {
var p = PEERS[provider] || PEERS.mapbox, chain = Promise.resolve();
p.css.forEach(function (u) { once(u, 'css') });
p.js.forEach(function (u) { chain = chain.then(function () { return once(u, 'js') }) });
return chain;
}
${loader}
function row(key, kind, label, tag, extra) {
var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'row' + (extra || ''); btn.setAttribute('data-k', key); btn.setAttribute('data-kind', kind);
var l = document.createElement('span'); l.textContent = label; btn.appendChild(l);
var c = document.createElement('code'); c.textContent = '<' + tag + '>'; btn.appendChild(c);
btn.onclick = function () { location.hash = '#/' + key.split('/').map(encodeURIComponent).join('/') };
return btn;
}
function draw() {
nav.innerHTML = ''; var np = 0, ni = 0;
REG.forEach(function (b) {
var g = document.createElement('div'); g.className = 'grp';
var h = document.createElement('div'); h.className = 'gh'; var hn = document.createElement('span'); hn.textContent = b.b; h.appendChild(hn);
var hc = document.createElement('span'); hc.textContent = b.p.length; h.appendChild(hc); g.appendChild(h);
b.p.forEach(function (p) {
np++; g.appendChild(row(b.b + '/' + p.p, 'profile', p.p, p.e || b.e[0] || b.b));
(p.in || []).forEach(function (x) { ni++; g.appendChild(row(b.b + '/' + p.p + '/' + x.id, 'instance', x.t, x.e || p.e || b.e[0] || b.b, ' in')) });
});
nav.appendChild(g);
});
count.textContent = REG.length + ' bundles · ' + np + ' profiles · ' + ni + ' instances';
}
function find(parts) {
for (var i = 0; i < REG.length; i++) if (REG[i].b === parts[0]) {
var b = REG[i];
for (var j = 0; j < b.p.length; j++) if (b.p[j].p === parts[1]) {
var p = b.p[j];
if (parts.length < 3) return { b: b, p: p, x: null };
for (var k = 0; k < (p.in || []).length; k++) if (p.in[k].id === parts[2]) return { b: b, p: p, x: p.in[k] };
}
}
return null;
}
function select(key) {
var parts = key.split('/').map(function (s) { try { return decodeURIComponent(s) } catch (e) { return s } });
var hit = find(parts);
var rows = nav.querySelectorAll('.row');
for (var i = 0; i < rows.length; i++) rows[i].setAttribute('aria-current', rows[i].getAttribute('data-k') === parts.join('/') ? 'true' : 'false');
if (!hit) { ttl.textContent = 'No such map'; sub.textContent = key; open.hidden = embBtn.hidden = embed.hidden = true; hint('No such map: ' + key, true); return }
var b = hit.b, p = hit.p, x = hit.x, tag = (x && x.e) || p.e || b.e[0] || b.b;
ttl.textContent = x ? x.t : p.p; sub.textContent = b.b + ' · <' + tag + '>' + (p.d ? ' · ' + p.d : '');
open.href = rel((x || p).u); open.hidden = false; embBtn.hidden = false; embed.hidden = true; embBtn.setAttribute('aria-expanded', 'false');
var em = x ? (x.em || snip(b, p, x)) : (p.em || snip(b, p, null));
ifr.value = em.iframe || ''; scr.value = em.script || ''; scrw.hidden = !em.script;
if (narrow()) { nav.hidden = true; menu.setAttribute('aria-expanded', 'false') }
mount(b, p, x, tag);
}
// Same profile-config the leaf page bakes (profile page: profile + dataSource + instanceId;
// instance page: its props), plus the host-supplied share URL of that leaf page.
function mount(b, p, x, tag) {
var cfg = x ? assign({ assetsBase: ASSETS }, x.pr) : { profile: p.p, dataSource: 'pray-tools', instanceId: b.b + '-' + p.p, assetsBase: ASSETS };
if (QA) cfg.qa = true;
cfg.shareUrl = new URL(rel((x || p).u), location.href).href;
window.MAP_APP_API_URL = (x && x.a) || API_BASE;
var provider = cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox', my = ++seq;
hint(provider === 'maplibre' ? 'loading map…' : 'loading token…');
peer(provider).then(function () { return bundle(b) }).then(function () {
if (my !== seq) return;
resolveToken(cfg, provider === 'maplibre', function (token) {
if (my !== seq) return;
try {
var c = token ? assign({}, cfg, { tk: token }) : cfg;
var el = document.createElement(tag); el.setAttribute('profile-config', JSON.stringify(c));
slot.innerHTML = ''; slot.appendChild(el);
} catch (e) { hint(e && e.message ? e.message : String(e), true) }
});
}).catch(function (e) { if (my === seq) hint('Failed to load ' + b.b + ': ' + (e && e.message ? e.message : e), true) });
}
// Mapbox token resolution — the leaf pages' ladder, in precedence order:
// 1. prop (config.tk) 2. ?tk= on this page's URL (stored once for every page of the tree)
// 3. baked token — then, none found, a token ENDPOINT is fetched, first answer wins:
// 4. window.MAP_TOKEN_URL 5. configured TOKEN_SRC 6. same-origin /api/maps/token — else ask once.
function resolveToken(cfg, tokenFree, done) {
if (tokenFree) return done('');
var stored = ''; try { stored = localStorage.getItem('MAPS_STAGING_TK') || '' } catch (e) {}
var urlTk = qp('tk'); if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk) } catch (e) {} }
var directToken = cfg.tk || urlTk || stored || BAKED_TOKEN || fetchedToken;
if (directToken) return done(directToken);
function askToken(prefix) {
var d = hint('', true);
d.innerHTML = (prefix ? '<div></div>' : '') + '<div>map needs a Mapbox token — <input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)"> <button id="tkGo" type="button">use it</button></div>';
if (prefix) d.firstChild.textContent = prefix;
$('tkGo').onclick = function () { var v = ($('tkIn').value || '').trim(); if (!v) return; try { localStorage.setItem('MAPS_STAGING_TK', v) } catch (e) {} done(v) };
}
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u) }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function extractToken(data) { if (typeof data === 'string') return data.trim(); if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || ''; return '' }
function fetchToken(i, errors) {
if (i >= candidates.length) return askToken('Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')');
var url = candidates[i];
return fetch(url).then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
return (r.headers.get('content-type') || '').indexOf('json') !== -1 ? r.json() : r.text();
}).then(function (data) {
var tk = extractToken(data); if (!tk) throw new Error(url + ' → no token in response');
fetchedToken = tk; done(tk);
}).catch(function (e) { errors.push(e && e.message ? e.message : String(e)); return fetchToken(i + 1, errors) });
}
fetchToken(0, []);
}
function copy(text, btn) {
var ok = function () { var t = btn.textContent; btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = t }, 1200) };
var fallback = function () { var ta = btn.parentNode.nextSibling; ta.focus(); ta.select(); try { document.execCommand('copy'); ok() } catch (e) {} };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, fallback); else fallback();
}
embed.addEventListener('click', function (e) { var b = e.target.closest ? e.target.closest('[data-copy]') : null; if (b) copy($(b.getAttribute('data-copy')).value, b) });
embBtn.onclick = function () { embed.hidden = !embed.hidden; embBtn.setAttribute('aria-expanded', String(!embed.hidden)) };
menu.onclick = function () { nav.hidden = !nav.hidden; menu.setAttribute('aria-expanded', String(!nav.hidden)) };
try { window.matchMedia('(max-width:700px)').addEventListener('change', function (e) { if (!e.matches) { nav.hidden = false; menu.setAttribute('aria-expanded', 'true') } }) } catch (e) {}
function route() { var h = (location.hash || '').replace(/^#\/?/, ''); if (h) select(h) }
function init(m) {
if (m) { try { REG = compact(m, false) } catch (e) { REG = BAKED } }
draw(); window.addEventListener('hashchange', route); route();
}
// Live manifest first (the page stays right when bundles change); the baked copy is the fallback.
if (typeof fetch === 'function' && location.protocol !== 'file:') {
fetch(TREE + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null }).then(init, function () { init(null) });
} else init(null);
})();
</script>
${dev ? LADDER_JS : ''}</body>
</html>
`
}
// ── CLI: the built gallery, public/index.html ───────────────────────────────────────────────
function main() {
const CFG = readBuildConfig()
const manifestPath = join(ROOT, 'public', CFG.name, 'manifest.json')
if (!existsSync(manifestPath)) { console.error(`[generate-gallery] missing ${manifestPath} — run \`bun run tree\` first`); process.exit(1) }
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const html = renderGallery(manifest, { mode: 'build', title: CFG.title, tree: CFG.name, tokenUrl: CFG.tokenUrl, token: CFG.token, apiBaseUrl: CFG.apiBaseUrl, assetsBaseUrl: CFG.assetsBaseUrl, qaWidget: CFG.qaWidget })
const baked = compact(manifest, true)
const nProfiles = baked.reduce((n, b) => n + b.p.length, 0)
const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0)
const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0)
const out = join(ROOT, 'public', 'index.html')
writeFileSync(out, html)
console.log(`[generate-gallery] public/index.html — ${(html.length / 1024).toFixed(1)} KB · ${baked.length} bundle(s) · ${nProfiles} profile(s) · ${nInstances} instance(s) · ${nOverrides} embed snippet(s) baked verbatim (the rest re-derive)`)
}
// Runs only as a script; importing it (the dev plugin does) has no side effects.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()