doxa-maps/internal/generate-tree.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

1704 lines
100 KiB
JavaScript

#!/usr/bin/env node
/**
* generate-tree.mjs — build the self-contained doxa-maps/ output tree.
*
* Runs AFTER the per-bundle vite builds (which emit app/doxa-maps/<bundle>/<bundle>.js).
* It scans plugin-designs/<bundle>/*.vue, pulls the OPTIONAL JSDoc header tags from the
* top of each profile, reads the custom-element tag(s) each bundle registers in its
* index.js, then WRITES, into the doxa-maps/ tree:
*
* doxa-maps/
* index.html ← staging root: every bundle, tab strip, links out
* manifest.json ← machine-readable index of all bundles + profiles
* <bundle>/
* <bundle>.js ← the IIFE bundle (already emitted by vite)
* index.html ← bundle staging: lists this bundle's profiles
* <profile>/
* index.html ← standalone embeddable profile page
*
* Every generated HTML page is SELF-CONTAINED: no fetch() for the manifest, no
* dev-server staging.js dependency. The bundle list + profile list are inlined at
* build time. The profile page loads Mapbox CDN + the parent bundle IIFE and mounts
* the web component; the Mapbox token is fetched at runtime from window.MAP_TOKEN_URL
* (configured via tokenUrl in vite.doxa-maps-build-config.json).
*
* Destinations & name: both come from vite.doxa-maps-build-config.json — `name` sets the output
* folder name (default "doxa-maps", independent of this bundler folder's own name),
* `paths` lists extra folders to copy the whole tree into. The tree is always written
* to ./app/<name>/ plus <path>/<name>/ for every entry in `paths`.
*
* Run standalone: bun generate-tree.mjs (expects vite build already ran)
* Wired into: bun run build (runs after the per-bundle vite builds)
*/
import {
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
cpSync, rmSync, mkdtempSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
// Bake the composition tier (capability machine-key) INTO the manifest so ANY consumer —
// the built gallery AND the plugin-studio cap-5 element — can group by capability 5→1
// without a separate tierMap. Previously the manifest carried no `type`, so a manifest-fed
// consumer (plugin-studio) mis-tiered every profile as cap-1 (single). Single source of truth.
import { detectProfileType } from './plugin-gallery/discover.mjs'
import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs'
import { syncAppShells } from './sync-app-shells.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const APP_PROFILES = resolve(__dirname, '..', 'plugin-designs')
// The single build-config for this bundler (name, paths, embedBaseUrl, tokenUrl).
const BUILD_CONFIG_FILE = resolve(__dirname, '..', 'vite.doxa-maps-build-config.json')
const SOURCES = resolve(__dirname, '..', 'library/api/sources.json')
// The single build-config (vite.doxa-maps-build-config.json): `name` = output folder
// name (decoupled from this bundler folder, which stays doxa-maps); `paths` = extra
// copy destinations; `embedBaseUrl` = absolute base for embed snippets; `tokenUrl` =
// the token endpoint baked into every page. One file, read once here.
function readBuildConfig() {
try {
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
const name = (typeof cfg.name === 'string' && cfg.name.trim()) ? cfg.name.trim() : CONFIGURED_TREE_NAME
const paths = Array.isArray(cfg.paths) ? cfg.paths.filter((p) => typeof p === 'string' && p.trim()) : []
// Local, NON-committed sync override: `DOXA_SYNC_PATHS=/abs/dir[,/abs/dir2]` ALSO copies the
// built tree into extra destinations (e.g. a local doxa-life checkout's public folder) without
// hardcoding a machine-specific path into this shared config. Each dest gets <dir>/<name>/.
if (process.env.DOXA_SYNC_PATHS) {
for (const p of process.env.DOXA_SYNC_PATHS.split(',').map((s) => s.trim()).filter(Boolean)) {
if (!paths.includes(p)) paths.push(p)
}
}
const embedBaseUrl = (typeof cfg.embedBaseUrl === 'string' ? cfg.embedBaseUrl.trim() : '').replace(/\/+$/, '')
const tokenUrl = typeof cfg.tokenUrl === 'string' ? cfg.tokenUrl.trim() : ''
// OPT-IN baked token (token ladder rung 3): a literal Mapbox token inlined into
// every generated page. Empty by default — prefer tokenUrl (a URL, not a secret).
// Only set this when you deliberately want the token to ship inside the folder.
const token = typeof cfg.token === 'string' ? cfg.token.trim() : ''
// Where the SHARED asset tree (previews, posters, boundaries) is served from.
// Vite no longer copies public/ into every bundle (publicDir:false), so pages
// must be told where the one shared copy lives. Empty = derive a RELATIVE path
// per page depth (keeps a dropped folder self-contained under one web-root);
// set it ABSOLUTE (https://maps.doxa.life/assets) when bundles are embedded on
// partner sites or loaded by the mobile app, where relative cannot resolve.
const assetsBaseUrl = (typeof cfg.assetsBaseUrl === 'string' ? cfg.assetsBaseUrl.trim() : '').replace(/\/+$/, '')
// The data API every generated page points at, baked as window.MAP_APP_API_URL before the
// bundle loads — the HOT-SWAPPABLE backend seam. Empty = same-origin. Set it to the live API
// now (https://pray.doxa.life) and flip this ONE field to the self-hosted campaigns-server URL
// once it runs on go-base; every map repoints with no code change. A per-instance apiBaseUrl
// in a *.instances.json still overrides this for that instance.
const apiBaseUrl = (typeof cfg.apiBaseUrl === 'string' ? cfg.apiBaseUrl.trim() : '').replace(/\/+$/, '')
// GATED profiles: bundle folder names withheld from the generated tree, staging pages and
// manifest (governance — e.g. key-cities). Their .js still BUILDS via vite; they simply never
// appear on any public surface the generator emits. convert.py gates the plugin index too.
const gatedProfiles = Array.isArray(cfg.gatedProfiles) ? cfg.gatedProfiles.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : []
// QA corner widget baked into generated embed pages (the go-base plugin surface). Default true.
// Partner embeds use the copy-embed SNIPPET with their own config, so they never get this.
const qaWidget = cfg.qaWidget !== false
// Ship the 42 MB print-resolution posters/ inside the tree? DEFAULT FALSE. The wall renders
// from previews/, so the tree stays visually complete without them; bundling both pushed the
// tree to 74 MB and the catalog server refused the pack. Turn on only for a fully offline drop.
const bundlePosters = cfg.bundlePosters === true
// Ship the poster SOURCES (<id>.html, what the browser rasterises) without the
// 40.9 MB of rasters. bundlePosters is a single boolean over a directory holding
// both, so it could not express this — and "neither" is what left export and print
// preview 404ing on a default drop. Implied by bundlePosters:true.
const posterSources = cfg.posterSources === true || bundlePosters
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget, bundlePosters, posterSources }
} catch {
return { name: CONFIGURED_TREE_NAME, paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true, bundlePosters: false, posterSources: false }
}
}
const DEST_CONFIG = readBuildConfig()
const TREE_NAME = DEST_CONFIG.name
/**
* Where a generated page should fetch shared assets from.
*
* TREE-RELATIVE, NOT WEB-ROOT-RELATIVE (BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE,
* 2026-09-20). Assets live INSIDE the tree at `<TREE_NAME>/assets/`, so a page nested
* `depth` levels below the web-root is `depth - 1` levels below the TREE ROOT and climbs
* that many times. The tree is then the complete shipping unit: whoever rsyncs ONLY
* `<TREE_NAME>/` still gets the data, and the folder resolves identically under file://,
* a foreign subpath, Cloudflare-Drop or Railway.
*
* The previous form climbed `depth` times to a SIBLING `assets/` at the web-root. That is
* correct only when the publisher also ships that sibling — ours ships the tree alone, so
* every data fetch 404'd (all-people-groups, gospel-resources, cities-internet, upg-100).
* A leaf could never reach a shared folder ABOVE the tree once the tree moved.
*
* An absolute `assetsBaseUrl` still always wins — the only form that works for a bundle
* embedded on a partner site or loaded inside the mobile app, where there is no tree to
* climb within (the page is on a foreign origin).
*
* @param {number} depth levels from the web-root: <tree>/<bundle>/<profile>/ = 3
*/
function assetsBaseFor(depth) {
// depth is measured from the WEB-ROOT; the tree root is one level in, hence depth - 1.
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(Math.max(0, depth - 1))}assets`
}
// design note 2026-07-11: the staging TOP must not mention deployment orgs (doxa/GO/...). The visible
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
// (TREE_NAME stays in URLs/paths where it is a folder name, not branding).
const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim())
? DEST_CONFIG.displayTitle.trim() : TREE_NAME
// Base URL every intra-tree ref (manifest iframeUrl/scriptUrl) is built against.
// With embedBaseUrl set (e.g. "https://doxa.life/js"), refs are ABSOLUTE — the
// build-time override for known hosts / partner embeds. WITHOUT it, refs are
// RELATIVE to the tree root (".", i.e. "./bundle/profile/…") so the whole folder
// is POSITION-INDEPENDENT: file://, Cloudflare-Drop, a foreign subpath, or Railway
// all behave identically (drop-portability gate, internal/drop-portability-check.mjs).
// A leading-slash "/doxa-maps/…" was the Drop breakage — any subpath host resolves
// it against the host origin, not the tree, and every iframe/script 404s.
const EMBED_BASE = DEST_CONFIG.embedBaseUrl
? `${DEST_CONFIG.embedBaseUrl}/${TREE_NAME}`
: '.'
const DEFAULT_ROOT = resolve(__dirname, '..', 'public', TREE_NAME) // ./public/<name>
const HEADER_BYTES = 8192 // only scan the top of each file (header lives there)
const TAG_RE = /^\s*\*\s*@([\w-]+)\s+(.+?)\s*$/gm // JSDoc `* @tag value` lines
const DEFINE_RE = /customElements\.define\(\s*['"]([^'"]+)['"]/g // registered element tags
// SCAFFOLDS are source DX copies to start a plugin, not plugins: their SOURCE ships, their placeholder
// pages must not be published. Same by-name rule as internal/plugin-gallery/discover.mjs, so the built
// manifest lists exactly what the dev manifest lists (a blank template-map-a/b in the built studio was
// the only difference, found 2026-09-25). Not gatedProfiles: that list is governance (private data).
const SCAFFOLD_BUNDLES = ['template-bundle']
// ★ THE MAPBOX ENGINE URL, read from the ONE place that loads it (useMapInstance.js ENGINES.mapbox.js),
// so the embed snippet's preload can never point at a different version than the one the bundle
// requests — a mismatched preload is not used, and the engine would download twice.
const MAPBOX_GL_JS = (() => {
try {
const src = readFileSync(resolve(__dirname, '..', 'library', 'composables', 'useMapInstance.js'), 'utf8')
const m = src.match(/mapbox:\s*\{[^}]*?js:\s*'([^']+mapbox-gl\.js)'/)
if (m) return m[1]
} catch { /* fall through */ }
console.warn('[generate-tree] could not read the Mapbox engine URL from useMapInstance.js; embed snippets get no preload')
return ''
})()
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
// ★ CUSTOM-ELEMENT NAME RULE (HTML spec): lowercase, must contain a hyphen, no spaces.
// @element is NOT a comment — the build copies it VERBATIM into manifest.json and consumers feed
// it straight to document.querySelector(). Anything else is a downstream SyntaxError.
const VALID_TAG_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/
/** Pull the optional JSDoc header tags from the top of a .vue/.js file. */
function extractHeaderTags(filePath) {
const head = readFileSync(filePath, 'utf8').slice(0, HEADER_BYTES)
const tags = {}
for (const m of head.matchAll(TAG_RE)) {
const key = m[1]
if (!(key in tags)) tags[key] = m[2] // first occurrence wins
}
// ★ VALIDATE @element AT THE ONE CHOKE POINT EVERY CONSUMER READS FROM.
// Hit for real 2026-09-20: big-picture.vue declared
// @element bp-big-picture (this composite) + bp-research-header + people-groups-grid (nested)
// i.e. documentation where a SELECTOR was expected. generate-tree copied it verbatim into
// manifest.json; embed-smoke fed it to querySelector; the resulting SyntaxError was reported by a
// catch-all as "mount timeout", so a DATA DEFECT IN A COMMENT masqueraded as a BROKEN PLUGIN —
// on the one bundle that is tab-1 of the cap-5 SPA. It had been failing that suite for as long as
// the suite existed, and nothing pointed at the real cause.
// THE GENERAL RULE: any annotation a BUILD copies into a MACHINE-READ artifact is an INTERFACE,
// not a comment. If a human can write prose there and the build accepts it, the build is missing
// a validator. Fail loudly and name the file, the value and the rule.
if (typeof tags.element === 'string' && !VALID_TAG_RE.test(tags.element)) {
console.error(`\n[generate-tree] INVALID @element in ${filePath}`)
console.error(` got: ${JSON.stringify(tags.element)}`)
console.error(' expected: ONE bare custom-element tag — lowercase, containing a hyphen, nothing else')
console.error(' (e.g. "bp-big-picture"). It is copied verbatim into manifest.json and')
console.error(' passed to document.querySelector() by embedders and the smoke suites.')
console.error(' fix: put the tag alone on the @element line; document what it nests in the')
console.error(' description above it, or in @composes.')
process.exit(1)
}
return tags
}
// A leaf DRAWS A MAP iff its .vue references a map engine. This is the signal that a page
// needs a map peer (Mapbox/MapLibre) + the Mapbox token ladder; a leaf without it is a grid,
// card, chart-table or data-list and must render TOKEN-FREE with NO peer (see mapPageHtml).
// UNLIKE composite-TIER classification (which the codebase refuses to infer from source, see
// discover.detectProfileType), this is an unambiguous POSITIVE dependency check, not a
// heuristic tag guess: a file that imports useMapInstance / touches window.mapboxgl / loads a
// mapbox-gl|maplibre-gl peer unquestionably intends to draw tiles. Whole-file scan (not just
// the header) because these references live in the <script setup> body, not the JSDoc header.
const MAP_ENGINE_RE = /useMapInstance|useMapProvider|mapbox-gl|maplibre-gl|window\.mapboxgl|window\.maplibregl/
function isMapLeaf(filePath) {
try { return MAP_ENGINE_RE.test(readFileSync(filePath, 'utf8')) }
catch { return false }
}
/**
* Does a profile own a map ANYWHERE beneath its folder, not just in its own root .vue?
*
* WHY THIS EXISTS (measured 2026-09-22). isMapLeaf scans ONE FLAT FILE. That is correct for a
* simple leaf, and wrong for a profile that splits itself into components. The case that proved
* it: `research-dashboard.vue` has ZERO engine references (grep count 0) and is the only .vue in
* its folder — yet the profile owns a real Mapbox map at
* `src/components/dashboards/DashboardMapPanel.vue`, one directory deeper. So every existing
* resolution step reported map=False and the generator emitted TOKEN_FREE for it.
*
* THE MEASURED CONSEQUENCE, and why it is worth fixing rather than documenting. A TOKEN_FREE leaf
* calls `mount('')`, so the `tk: token` branch never runs and the element's profile-config carries
* no token. The map then survives only on the leaf's `window.MAP_TOKEN = BAKED_TOKEN` fallback —
* which the generated page itself guards with `if (BAKED_TOKEN && !window.MAP_TOKEN)`, i.e. it
* YIELDS to any pre-existing global. A negative control confirmed the asymmetry: blanking
* window.MAP_TOKEN broke `dashboard-plus-grid` ALONE; its eight siblings were untouched because
* they inject `tk` directly. One bundle was a single rung short of every other.
*
* BOUNDED ON PURPOSE. Only `src/` is walked, and only .vue/.js/.mjs are read. A profile's map
* lives in its component tree; walking the whole folder would drag in data/, fixtures and built
* output for no signal and real cost on every build.
*/
const NESTED_SCAN_DIRS = ['src']
const NESTED_SCAN_EXT = /\.(vue|js|mjs)$/
function ownsAMapDeeply(profileDir) {
for (const sub of NESTED_SCAN_DIRS) {
const root = join(profileDir, sub)
if (!existsSync(root)) continue
const stack = [root]
while (stack.length) {
const cur = stack.pop()
let entries
try { entries = readdirSync(cur, { withFileTypes: true }) } catch { continue }
for (const e of entries) {
const p = join(cur, e.name)
if (e.isDirectory()) { stack.push(p); continue }
if (NESTED_SCAN_EXT.test(e.name) && isMapLeaf(p)) return true
}
}
}
return false
}
/**
* Does THIS PROFILE reach a map through its own import graph?
*
* ★ PER PROFILE, NOT PER BUNDLE (BUG-FALSE-ISMAP-BAKES-403KB-MAPBOX-INTO-MAPLESS-LEAVES, fixed
* 2026-09-25). ownsAMapDeeply() answers "is there a map ANYWHERE under this bundle's src/", and the
* single-leaf branch used it for every profile — so a map-less sibling inherited map-ness from the
* bundle: regions-dashboard/regions-list shows a list and no map, but src/RegionCountryMap.vue made
* it isMap=true. Its leaf then baked the ~400 KB Mapbox engine it never uses, and since 4a6886d2 its
* embed snippet also preloaded it. This follows the RELATIVE imports reachable from the profile's
* own .vue (bounded to the bundle folder, .vue/.js/.mjs), so research-dashboard — whose map sits
* several imports deep in src/components/dashboards/DashboardMapPanel.vue — still resolves true.
* An explicit boolean `isMap` in <profile>.meta.json wins over the scan.
*/
const RELATIVE_IMPORT_RE = /(?:import[^'"`;]*?from\s*|import\s*\(\s*|import\s+)['"`](\.{1,2}\/[^'"`]+)['"`]/g
function profileReachesAMap(dir, file) {
const seen = new Set()
const stack = [join(dir, file)]
while (stack.length) {
const f = stack.pop()
if (seen.has(f)) continue
seen.add(f)
let src
try { src = readFileSync(f, 'utf8') } catch { continue }
if (MAP_ENGINE_RE.test(src)) return true
for (const m of src.matchAll(RELATIVE_IMPORT_RE)) {
const base = resolve(dirname(f), m[1])
if (!base.startsWith(dir)) continue // stay inside this bundle
for (const cand of [base, base + '.vue', base + '.js', base + '.mjs', join(base, 'index.js')]) {
if (NESTED_SCAN_EXT.test(cand) && existsSync(cand) && statSync(cand).isFile()) { stack.push(cand); break }
}
}
}
return false
}
function metaIsMap(dir, profileName) {
try {
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
return typeof meta?.isMap === 'boolean' ? meta.isMap : null
} catch { return null }
}
/**
* Does a cap-4 composite (`nested-elements-mfe`) compose a child that paints a map?
*
* A cap-4 composite mounts its children as custom elements in ITS OWN document, so it
* needs the map engine + token that the child would otherwise have got from its own
* iframe leaf page. Map-ness is therefore INHERITED from the composed children.
*
* Resolution order, most-declared to most-forgiving:
* 1. meta.json `composes: ["<bundle>/<profile>", …]` — the author's explicit statement of
* what this composite nests. Each entry is resolved to a sibling .vue and scanned.
* 2. the composite's OWN source — a composite may still touch a map engine directly.
* 3. ANY OTHER profile in the same bundle. A cap-4 composite composes siblings from its
* own bundle by construction (one download serves every pane), so if the bundle ships
* a map at all, a composite in it can mount one. This is the SAFE default: a spurious
* peer on a genuinely map-free composite costs one cached script, whereas a MISSING
* peer costs the whole view (no canvas is ever created) — so the failure modes are
* deliberately asymmetric.
*
* @param {string} dir the bundle's plugin-designs directory
* @param {string} profileName the composite profile (e.g. 'upg-100-mfe')
* @param {string} file the composite's own .vue filename
*/
function composesAMapLeaf(dir, profileName, file) {
// 1. explicit `composes` from <profile>.meta.json
try {
const metaPath = join(dir, `${profileName}.meta.json`)
if (existsSync(metaPath)) {
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
const composes = Array.isArray(meta?.composes) ? meta.composes : []
// Entries are "<bundle>/<profile>" or a bare "<profile>".
//
// CORRECTED 2026-09-22 — this previously did `.split('/').pop()`, DISCARDING the bundle
// segment, then looked for the .vue inside THIS bundle's dir. For a composite that nests a
// child from ANOTHER bundle that lookup silently missed: the file does not exist there, so
// isMapLeaf returned false and the composite was classified map-free. Measured case:
// dashboard-plus-grid composes "research-dashboard", which lives in its own sibling bundle
// directory, not in dashboard-plus-grid/.
//
// Two fixes, and BOTH were needed — neither alone is sufficient:
// (a) resolve cross-bundle: try the sibling bundle dir before falling back to this one;
// (b) scan the resolved profile DEEPLY (ownsAMapDeeply), because research-dashboard's own
// .vue has zero engine refs while its map sits in src/components/dashboards/.
// Fixing only (a) still yields map=false — verified by simulation before writing this.
const bundlesRoot = dirname(dir)
const resolveChild = (entry) => {
const parts = String(entry).split('/').filter(Boolean)
const profile = parts[parts.length - 1]
const bundle = parts.length > 1 ? parts[parts.length - 2] : null
// Candidate profile DIRECTORIES, most-specific first.
const dirs = []
if (bundle) dirs.push(join(bundlesRoot, bundle))
dirs.push(dir) // same-bundle sibling (the historical assumption)
dirs.push(join(bundlesRoot, profile))// a bare entry naming a bundle of the same name
for (const d of dirs) {
if (!existsSync(d)) continue
if (isMapLeaf(join(d, `${profile}.vue`))) return true
if (ownsAMapDeeply(d)) return true
}
return false
}
const named = composes.map((c) => String(c)).filter(Boolean)
if (named.length) {
return named.some(resolveChild)
}
}
} catch (_) { /* malformed meta → fall through to the source scans */ }
// 2. the composite's own source — flat file first, then its own component tree. The deep scan
// is what catches a profile whose root .vue is a pure layout and whose map lives in
// src/components/ (the research-dashboard shape).
if (isMapLeaf(join(dir, file))) return true
if (ownsAMapDeeply(dir)) return true
// 3. any sibling profile in the same bundle
try {
return readdirSync(dir)
.filter((f) => f.endsWith('.vue') && f !== file)
.some((f) => isMapLeaf(join(dir, f)))
} catch { return false }
}
/**
* The RAW `composes` array a profile declares in its own <profile>.meta.json, or null when
* the profile declares none / the file is absent / malformed. This is the same source
* composesAMapLeaf reads (see its point 1) but returns the author's data itself rather than
* a map-ness boolean derived from it — the manifest needs the edges, not just the verdict.
*/
function readComposes(dir, profileName) {
try {
const metaPath = join(dir, `${profileName}.meta.json`)
if (!existsSync(metaPath)) return null
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
const composes = Array.isArray(meta?.composes) ? meta.composes.map((c) => String(c)).filter(Boolean) : []
return composes.length ? composes : null
} catch { return null }
}
/**
* Extra profile-config keys a profile declares in its own <profile>.meta.json under
* `config`, merged into the standalone leaf page's BASE_CONFIG. Returns {} when absent.
*
* WHY THIS EXISTS (BUG-DOTLESS-MAPS-SHOW-TAP-A-DOT-HINT): the shared PinHint is mounted
* by ProfileLoader for every profile and opts out via profile-config `pinHint:false`
* (see library/components/hints/PinHint.vue). But the standalone leaf's config was a
* HARDCODED object here, so no profile could actually set that key — the documented
* opt-out existed with no channel to reach it, which is why PinHint's own header could
* record "an opt-out no profile has ever used". cities-internet/connectivity-map and
* gospel-resources/resources-map shade COUNTRY POLYGONS and render no dots at all, yet
* both showed "Tap a coloured dot" (MEASURED live, 2026-09-23) — a hint pointing at
* something the reader cannot see makes a working map look broken.
*
* Declarative on purpose: the generator stays free of a per-profile special case, and
* the same channel serves any future profile-config key without another edit here.
* `profile`/`tk`/`assetsBase`/`instanceId` are generator-owned and cannot be overridden.
*/
const RESERVED_CONFIG_KEYS = new Set(['profile', 'tk', 'assetsBase', 'instanceId', 'dataSource'])
function readProfileConfig(dir, profileName) {
try {
const metaPath = join(dir, `${profileName}.meta.json`)
if (!existsSync(metaPath)) return {}
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
const cfg = meta?.config
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return {}
const out = {}
for (const [k, v] of Object.entries(cfg)) {
if (RESERVED_CONFIG_KEYS.has(k)) {
console.error(`[generate-tree] ${profileName}.meta.json: ignoring reserved config key "${k}"`)
continue
}
out[k] = v
}
return out
} catch { return {} }
}
/** Element tag names a bundle registers, read from its index.js (order preserved). */
function extractElements(indexJsPath) {
if (!existsSync(indexJsPath)) return []
const src = readFileSync(indexJsPath, 'utf8')
const tags = []
for (const m of src.matchAll(DEFINE_RE)) if (!tags.includes(m[1])) tags.push(m[1])
return tags
}
/**
* Mapbox token source baked into every generated page. Returns a plain string that is
* either a JS global name ("window.MAP_TOKEN_URL") or a literal URL. Resolved SAFELY as
* DATA in the page — never eval'd — so a literal URL can't produce a SyntaxError.
* Precedence: vite.doxa-maps-build-config.json `tokenUrl` (the drop-anywhere absolute
* URL — the ONE place a developer sets their token endpoint) → sources.json
* `mapboxTokenUrl` → "window.MAP_TOKEN_URL". This single value flows into every
* profile + instance index.html across the tree.
*/
function readTokenUrlExpr() {
if (DEST_CONFIG.tokenUrl) return DEST_CONFIG.tokenUrl
try {
const src = JSON.parse(readFileSync(SOURCES, 'utf8'))
const v = src?._meta?.mapboxTokenUrl || src?.mapboxTokenUrl
if (typeof v === 'string' && v.trim()) return v.trim()
} catch (_) { /* fall through */ }
return 'window.MAP_TOKEN_URL'
}
// ── HTML escaping ────────────────────────────────────────────────────────────
const esc = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
// ── Shared theme (dark, gem frames) ──────────────────────────────────────────
const BASE_CSS = `
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787; --cy:#46d4ff; --vi:#b9a6f5; --gold:#ffc24b; --grn:#5ee0a0;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background:
radial-gradient(1100px 480px at 82% -12%, rgba(70,212,255,.10), transparent 60%),
radial-gradient(820px 460px at -8% 112%, rgba(185,166,245,.10), transparent 60%),
var(--bg);
color: var(--ink); font-family: var(--sans); min-height: 100%; -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
code { font-family: var(--mono); background:#1f2937; padding:1px 5px; border-radius:4px; color:var(--gold); }
header.forge { padding: 22px 28px 6px; }
header.forge h1 { font: 800 21px/1.2 var(--sans); letter-spacing:-.02em; }
header.forge h1 .ac { color: var(--cy); }
header.forge .sub { margin-top: 6px; font: 13px/1.55 var(--sans); color: var(--mut); max-width: 78ch; }
header.forge .crumb { margin-top: 10px; font: 12px/1 var(--mono); color: var(--dim); }
header.forge .crumb a { color: var(--cy); }
#count { display:block; margin-top:10px; color:var(--cy); font:12px/1 var(--mono); }
/* tab strip */
.tabstrip { position: sticky; top: 0; z-index: 5; display:flex; gap:10px;
padding: 12px 28px; overflow-x:auto; scrollbar-width:thin;
background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px);
border-bottom:1px solid var(--surf2); }
.tabstrip::-webkit-scrollbar { height:8px; }
.tabstrip::-webkit-scrollbar-thumb { background:#243349; border-radius:8px; }
.tab { flex:0 0 auto; text-align:left; cursor:pointer; min-width:198px; display:block;
background:var(--surf); border:1px solid var(--line); border-radius:12px; padding:11px 14px; color:inherit; transition:.15s; }
.tab:hover { border-color:#33455f; transform:translateY(-1px); }
.tab.active { border-color:var(--gem,var(--cy));
background: color-mix(in srgb, var(--gem,var(--cy)) 12%, var(--surf));
box-shadow: 0 0 0 1px var(--gem,var(--cy)) inset, 0 6px 20px rgba(0,0,0,.3); }
.tab .tname { font:700 13px/1.1 var(--sans); display:flex; align-items:center; gap:8px; }
.tab .dot { width:9px; height:9px; border-radius:50%; background:var(--gem,var(--cy)); box-shadow:0 0 8px var(--gem,var(--cy)); }
.tab .ttags { margin-top:6px; display:flex; flex-wrap:wrap; gap:4px; }
.tab .etag { font:9px/1 var(--mono); color:var(--vi); border:1px solid rgba(185,166,245,.4); border-radius:999px; padding:3px 7px; }
.tab .tfile { margin-top:6px; font:10px/1 var(--mono); color:var(--dim); }
/* gem grid */
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap:18px; padding: 18px 28px 28px; }
.gem { --gem:var(--cy); --gem-rgb:70,212,255; position:relative; display:flex; flex-direction:column;
padding:12px; background:var(--surf); border:1px solid var(--gem); border-radius:14px;
box-shadow:0 0 26px rgba(var(--gem-rgb),.14); transition:.2s; }
.gem:hover { transform: translateY(-2px); box-shadow:0 0 34px rgba(var(--gem-rgb),.22); }
.gem .ghead { display:flex; align-items:center; gap:10px; padding:4px 6px 10px; }
.gem .gtag { font:800 14px/1 var(--mono); color:var(--gem); }
.gem .gkind { font:9px/1 var(--mono); color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:3px 8px; }
.gem .gbody { border:1px solid var(--gem); border-radius:10px; background:#0d1622; padding:14px; min-height:96px; }
.gem .gbody .desc { font:12px/1.5 var(--sans); color:var(--mut); }
.gem .gbody .meta { margin-top:8px; font:10px/1.4 var(--mono); color:var(--dim); }
.gem .gcta { margin-top:12px; display:flex; gap:8px; }
.gem .gcta a { font:11px/1 var(--mono); color:var(--gem); border:1px solid var(--gem); border-radius:8px; padding:8px 12px; }
.gem .gcta a:hover { background: color-mix(in srgb, var(--gem) 14%, transparent); }
.empty { color:var(--dim); font:13px/1.6 var(--mono); padding: 30px 28px; }
.foot { padding:14px 28px 20px; font:11.5px/1.5 var(--mono); color:var(--dim); border-top:1px solid var(--surf2); }
.foot .scr { color: var(--vi); }
/* profile page: full-bleed map */
.mapwrap { position:fixed; inset:0; }
.mapwrap .bar { position:absolute; top:0; left:0; right:0; z-index:10; display:flex; align-items:center; gap:10px;
padding:10px 16px; background: color-mix(in srgb, var(--bg) 80%, transparent); backdrop-filter: blur(8px);
border-bottom:1px solid var(--surf2); font:12px/1 var(--mono); color:var(--mut); }
.mapwrap .bar a { color: var(--cy); }
.mapwrap .bar .tag { color: var(--gem, var(--cy)); font-weight:700; }
.mapwrap .slot { position:absolute; inset:0; }
.mapwrap .slot > * { position:absolute; inset:0; width:100%; height:100%; display:block; }
.mapwrap .err { position:absolute; inset:0; display:grid; place-items:center; padding:24px; text-align:center;
color:#f87171; font:13px/1.6 var(--mono); }
`
const GEMS = ['#46d4ff', '#b9a6f5', '#5ee0a0', '#ffc24b', '#f472b6', '#f59e0b', '#34d399']
const hexRgb = (h) => { const n = parseInt(h.slice(1), 16); return (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) }
// ── The "doxa-maps" staging shell ────────────────────────────────────────────
// ONE staging design, emitted at every tree level (root + each bundle). It is a
// thin HTML shell that loads the shared, org-agnostic renderer (staging-forge.js,
// inlined at build time below) with a `window.FORGE` config telling it which tree
// level it is + where the manifest is. The renderer reads the semantic-tree
// manifest and draws `bundle → profile → instance` tabs, live map tiles, an
// architecture showcase, a desktop/mobile viewport toggle, and seamless
// scroll-to-hide of the tree. NO org / bundle / profile names are hardcoded here.
const FORGE_CSS = readForgeAsset('internal/staging-forge.css')
const FORGE_JS = readForgeAsset('internal/staging-forge.js')
// Built pages inline this as a CLASSIC script — import.meta is a parse error there.
.replace(/\/\* DEV-HMR-START[\s\S]*?DEV-HMR-END \*\//, '/* dev-hmr stripped for built pages */')
// `rel` is repo-root-relative (this script lives in internal/, one level down).
function readForgeAsset(rel) {
try { return readFileSync(resolve(__dirname, '..', rel), 'utf8') }
catch { return '' }
}
/**
* The shared doxa-maps staging page. Self-contained: CSS + renderer are
* INLINED (no external fetch that could 404 on a static host), except the
* manifest which the renderer fetches relative to the page.
*
* @param {object} o
* @param {'root'|'bundle'} o.scope
* @param {string} o.title
* @param {string} o.subtitle
* @param {string=} o.bundle - when scope==='bundle'
* @param {string=} o.crumbHref - back link (bundle scope)
* @param {string=} o.crumbLabel
*/
function forgeHtml(o) {
const forgeConfig = {
mode: 'built',
scope: o.scope,
// ONE manifest at the tree root is the single source of truth. Root page
// reads ./manifest.json; a bundle page (one level deeper) reads ../manifest.json.
manifestUrl: o.scope === 'root' ? './manifest.json' : '../manifest.json',
bundle: o.bundle || null,
// The tree links (open/view) in staging-forge are relative to the ROOT tree.
// A bundle page sits one level in, so it must prefix root-relative hrefs with '../'.
hrefBase: o.scope === 'root' ? './' : '../',
}
const crumb = o.crumbHref
? `<a class="ext" href="${esc(o.crumbHref)}">← ${esc(o.crumbLabel || 'back')}</a>`
: ''
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(o.title)}</title>
<link rel="icon" href="data:,">
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<style>${FORGE_CSS}</style>
</head>
<body>
<header class="top">
<h1>${esc(o.title.replace(' \u00b7 staging',''))}<span class="ac">${o.scope === 'root' ? '' : ' / ' + esc(o.bundle || '')}</span></h1>
<div class="sub">${esc(o.subtitle)}</div>
</header>
<div class="strip" id="frameInfo">loading\u2026</div>
<div id="tokenBar" class="hidden"></div>
<div id="shell">
<nav id="treeNav" aria-label="semantic tree"></nav>
<main id="stage"></main>
</div>
<script>window.FORGE = ${JSON.stringify(forgeConfig).replace(/</g,'\\u003c')};
/* file:// contract: manifest inlined at build \u2014 no fetch needed to render. */
window.FORGE.manifest = ${JSON.stringify(o.inlineManifest || null).replace(/</g,'\\u003c')};</script>
<script>${FORGE_JS}</script>
</body>
</html>
`
}
// ── Page templates ───────────────────────────────────────────────────────────
/** Root staging page — the doxa-maps showing ALL bundles (scope='root'). */
function rootHtml(bundles, manifest) {
return forgeHtml({
inlineManifest: manifest,
scope: 'root',
title: `${DISPLAY_TITLE} · staging`,
subtitle: 'one shared core → many self-contained, paste-anywhere map bundles',
})
}
/**
* THE WEB-ROOT PAGE — a thin host that mounts <plugin-studio> and nothing else.
*
* DRIVER DIRECTION 2026-09-22: "We just need a functional plugin studio with all our plugins
* working. We do not need these old staging index.html that get in the way of everything."
* The dev server made this move first (internal/plugin-gallery/dev-plugin.mjs); this is the same
* page for the BUILT tree, so `/` is the studio in BOTH trees and a developer who learns one
* does not meet a different product in the other.
*
* ★ WHY THE HOST MARKUP RATHER THAN A REDIRECT to the built leaf at
* <tree>/plugin-studio/plugin-studio/index.html: a redirect makes `/` a hop that then shows a
* DEEPER url, so the studio's own address stops being the root. Mounting the tag here makes `/`
* BE the studio. It is also the honest cap-5 surface: the studio is a real custom element, so
* the root page uses the same deployment method (direct-JS) any host site would.
*
* ★ WHY A CLASSIC <script>, NOT type="module" — THIS IS THE LOAD-BEARING DETAIL.
* index.js derives the tree root from `document.currentScript.src` (two levels up).
* document.currentScript is non-null ONLY during SYNCHRONOUS classic-script evaluation; for a
* module it is ALWAYS null. With a module tag the derivation silently returns '', the studio
* falls back to '../manifest.json' → /manifest.json → 404, and it renders with ZERO tabs — a
* blank shell that reads as "no elements in the studio", not as a failed fetch. (The dev server
* can use a module tag only because it passes tree-base explicitly.) The built leaf pages have
* always used a classic tag; this page matches them.
* tree-base is ALSO passed explicitly — belt and braces, and it makes the contract readable
* rather than implied by a regex two files away.
*
* ★ NO PROSE, NO CHROME: "without a lot of text garb that takes up so much space" is a layout
* requirement, so this page contributes ZERO text — every pixel belongs to the element.
* Material LIGHT only (#f3f3f1 surface): no dark-mode block. The page it replaces carried a
* prefers-color-scheme:dark rule, which is how the root could go dark against a light product.
*
* ★ window.MAP_APP_API_URL IS NOT OPTIONAL HERE — it is baked for the same reason every leaf page
* bakes it. library/api/apiBaseUrl.js getApiBaseUrl() reads ONLY this global (or the build-time
* VITE_API_BASE_URL); there is no profile-config path. Nested elements the studio mounts are
* REAL elements that fetch their own data, so without this they fall back to a same-origin URL
* that does not exist and every data-backed tab fails.
* MEASURED, not reasoned: the first version of this page omitted it, and activating the
* all-people-groups tab logged `[rest-api] Failed to load from pray-tools` while the IDENTICAL
* element on its own leaf page loaded fine. The element still MOUNTED and was `defined` — which
* is precisely why this was nearly missed. "The custom element mounted" is a WEAKER claim than
* "the plugin works"; a mounted element with no data is the failure that looks like success.
*/
function studioRootHtml(bakedToken) {
// THE TREE'S FRONT PAGE (<tree>/index.html) — the agent decision, card
// BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE: the output is ONE folder, and opening it opens the
// studio. It used to sit one level ABOVE the tree (public/index.html) with the staging-forge chooser
// at <tree>/index.html; both are replaced by this. Relative paths, so the folder stays
// position-independent (drop-portability): a domain root, a foreign subpath, or file://.
const src = `./plugin-studio/plugin-studio.js`
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(DISPLAY_TITLE)} plugin studio</title>
<link rel="icon" href="data:,">
${bakedToken ? ` <script>window.MAP_TOKEN=${JSON.stringify(bakedToken)};</script>\n` : ''}${DEST_CONFIG.apiBaseUrl ? ` <script>window.MAP_APP_API_URL = ${JSON.stringify(DEST_CONFIG.apiBaseUrl)};</script>\n` : ''} <style>
html, body { margin: 0; padding: 0; height: 100%; background: #f3f3f1; }
plugin-studio { display: block; width: 100%; height: 100vh; }
</style>
</head>
<body>
<plugin-studio tree-base="."></plugin-studio>
<script src="${src}"></script>
</body>
</html>
`
}
/** Bundle staging page — the doxa-maps showing THIS bundle's profiles (scope='bundle'). */
function bundleHtml(b, index, manifest) {
return forgeHtml({
inlineManifest: manifest,
scope: 'bundle',
bundle: b.bundle,
title: `${DISPLAY_TITLE}`,
subtitle: `${b.bundle} — one bundle, its profiles as paste-anywhere maps`,
crumbHref: '../index.html',
crumbLabel: 'all bundles',
})
}
/**
* Shared standalone map page — a BARE, full-page map (the shareable leaf of the tree).
* Per the design: profile/instance pages are NOT staging — no header, no tabstrip, no
* chrome. The map custom element fills the viewport so the browser URL (location.href)
* shares THE MAP directly. Loads Mapbox CDN + the parent bundle IIFE, resolves the
* token via the ladder below, then mounts the web component with `config`.
* Params:
* opts.title page <title>
* opts.tag custom-element tag
* opts.config profile-config object (tk added at runtime; a hardcoded config.tk
* is the ladder's top "prop" rung)
* opts.jsSrc relative URL to the bundle IIFE (../ for profile, ../../ for instance)
* opts.apiBaseUrl optional — baked as window.MAP_APP_API_URL before the bundle loads
* opts.tokenUrlExpr sources.json mapboxTokenUrl value (data-safe URL or global name)
* opts.bakedToken opt-in literal token baked from build-config (ladder rung 3)
*/
function mapPageHtml(opts) {
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity, shellTokenFree, isMap, scrollPage, isComposite } = opts
const apiScript = apiBaseUrl
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
: ''
// Bake the QA corner widget onto the go-base embed surface (opt-out via build-config qaWidget:false).
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
// instance whose config says provider:'maplibre' gets the MapLibre peer (token-
// free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts
// immediately. Default stays Mapbox, byte-for-byte unchanged.
const isMapLibre = !!(config && config.provider === 'maplibre')
// DOES THIS LEAF ACTUALLY DRAW A MAP? (isMap, computed per-profile at scan time — a
// leaf whose .vue references a map engine: useMapInstance/useMapProvider, mapbox-gl,
// maplibre-gl, or window.mapboxgl/maplibregl. See scanBundles → isMapLeaf.)
// A leaf that is NOT a map (a grid, a card, a chart table, a data list) must render
// TOKEN-FREE and load NO map peer at all — otherwise a tokenless load hits the token
// ladder, fetch-fails on the token endpoint (e.g. CORS on doxa.life/api/maps/token)
// and NEVER creates the element: the styled #slot stays empty, so maps/dashboards look
// broken to a first-time viewer. This is the CORRECT signal that supersedes the legacy
// provider:'maplibre' hack authors used to force non-maps token-free (people-groups-grid,
// doxa-vision-card, big-picture). `isMap` defaults to true when unspecified so any
// caller that has not yet computed it keeps the original map-gated behaviour.
const drawsMap = isMap === undefined ? true : !!isMap
const notAMap = !drawsMap
// SHELL PAGES ARE TOKEN-FREE. A cap-5 app-shell (nested-mfe-spa, e.g. <plugin-studio>) is
// NOT a map — it is chrome that nests OTHER elements. Its nested MAP elements each render in
// their own iframe leaf page, which runs its OWN token ladder. So the shell page itself must
// NOT gate element creation on a Mapbox token: without this, a tokenless load fetch-fails on
// the token endpoint (e.g. CORS on doxa.life/api/maps/token) and NEVER creates the element —
// the styled ladder never appears. Token-free ⇒ mount immediately + load NO Mapbox peer.
// shellOnly WINS over provider: a cap-5 app-shell paints no map itself, so it needs no peer
// AT ALL — even if an instance carries provider:'maplibre' (a legacy way authors forced
// token-free before shellTokenFree existed). Correct signal = the composition tier, not a
// map provider that happens to be set on the shell's config. `notAMap` generalises this to
// EVERY non-map leaf (cap-4 composites + cap-1/2/3 non-map leaves), not just cap-5 shells.
const shellOnly = !!shellTokenFree || notAMap
const tokenFree = isMapLibre || shellOnly
const peerBlock = shellOnly
? `<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->`
: isMapLibre
? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element
uses window.maplibregl directly (same versions as @map/composables/useMapInstance). -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js"></script>`
: `<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>`
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(title)}</title>
<link rel="icon" href="data:,">
${peerBlock}
<style>
${scrollPage
? `/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
scrolls PAST one stacked section down to the next (BUG-DASHBOARD-CANT-SCROLL-DOWN). This is the
OPPOSITE of the fixed-viewport map/single-element leaf below: NO position:fixed, NO inset:0,
height:auto so the page can grow. See StackLayout.vue's "THE LAYOUT OWNS THE SCROLL" contract. */
html { height: 100%; }
html, body { margin: 0; background: #0a0e15; }
body { min-height: 100%; overflow-y: auto; }
/* MIN-HEIGHT IN VIEWPORT UNITS, NOT PERCENT (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT).
min-height:100% here resolved against BODY's computed height, which is auto (body only has
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */
#slot, ${esc(tag)} { position: static; inset: auto; width: 100%; min-height: 100dvh; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }`
: `/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, ${esc(tag)} { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }`}
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">${tokenFree ? 'loading…' : 'loading token…'}</div>
${apiScript}
<!-- Bundle IIFE — registers the <${esc(tag)}> custom element. -->
<script src="${esc(jsSrc)}"${integrity ? ` integrity="${esc(integrity)}" crossorigin="anonymous"` : ''}></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = ${JSON.stringify(tokenFree)};
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = ${JSON.stringify(!!isComposite && !isMapLibre)};
var TOKEN_SRC = ${JSON.stringify(tokenUrlExpr)};
var BAKED_TOKEN = ${JSON.stringify(bakedToken || '')};
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement(${JSON.stringify(tag)});
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
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 = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
if (NESTS_MAPS && !directToken) {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
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 fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>
`
}
/**
* Standalone profile page — the base parameterized profile (no preset props).
* Lives at <bundle>/<profile>/index.html → bundle IIFE is one level up (../).
*/
function profileHtml(b, profile, tokenUrlExpr, bakedToken) {
const tag = b.elements[0] || b.bundle
return mapPageHtml({
title: `${profile.profile} · ${b.bundle}`,
tag,
// Profile-declared keys first, so the generator-owned ones always win (they are
// also filtered as reserved at read time — belt and braces, since a leaf whose
// `profile` or `assetsBase` got overwritten would silently render the wrong thing).
config: { ...(profile.extraConfig || {}), profile: profile.profile, dataSource: 'pray-tools', instanceId: `${b.bundle}-${profile.profile}`, assetsBase: assetsBaseFor(3) },
jsSrc: `../${b.bundle}.js`,
apiBaseUrl: DEST_CONFIG.apiBaseUrl,
tokenUrlExpr,
bakedToken,
integrity: b.integrity,
// A cap-5 app-shell (nested-mfe-spa) is chrome, not a map → token-free (see mapPageHtml).
shellTokenFree: profile.type === 'nested-mfe-spa',
isComposite: profile.type === 'nested-elements-mfe',
// Whether this leaf actually draws map tiles (computed in scanBundles → isMapLeaf). A
// non-map leaf renders token-free with no map peer; only a real map runs the token ladder.
isMap: profile.isMap,
// A cap-4 composite (nested-elements-mfe) is a StackLayout SCROLL-STACK: its leaf must let the
// DOCUMENT own the scroll (height:auto, no position:fixed) so the page grows and a reader scrolls
// PAST one section to the next. A fixed-viewport leaf traps a 100vh dashboard and nothing scrolls
// (BUG-DASHBOARD-CANT-SCROLL-DOWN). Cap-5 (nested-mfe-spa) stays fixed — it is a tabs shell, not a
// scroll-stack; any scroll-stack it hosts is its OWN iframe leaf, which gets this flag itself.
scrollPage: profile.type === 'nested-elements-mfe',
})
}
/**
* Preconfigured-instance page — one preset render of a parameterized profile.
* Lives at <bundle>/<profile>/<instance>/index.html → bundle IIFE is two levels up
* (../../). The instance's preset props + apiBaseUrl are baked in.
*/
function instanceHtml(b, profile, inst, tokenUrlExpr, bakedToken) {
const tag = inst.element || b.elements[0] || b.bundle
return mapPageHtml({
title: `${inst.title} · ${b.bundle}`,
tag,
config: { assetsBase: assetsBaseFor(4), ...inst.props },
jsSrc: `../../${b.bundle}.js`,
apiBaseUrl: inst.apiBaseUrl || DEST_CONFIG.apiBaseUrl,
tokenUrlExpr,
bakedToken,
integrity: b.integrity,
// Instance pages inherit the profile's shell-ness (an app-shell preset is still token-free).
shellTokenFree: profile.type === 'nested-mfe-spa',
isComposite: profile.type === 'nested-elements-mfe',
// Instance pages inherit the profile's map-ness: a preset of a non-map leaf is still a
// non-map (token-free, no peer); a preset of a real map still runs the token ladder.
isMap: profile.isMap,
// A preset of a cap-4 composite scroll-stack is still a scroll page (see profileHtml).
scrollPage: profile.type === 'nested-elements-mfe',
})
}
/**
* Read preconfigured instances declared in a `*.instances.json` next to a profile's
* .vue. Returns [] when absent. Each instance becomes a nested standalone page. The
* file shape: { profile, apiBaseUrl?, instances: [{ id, title?, props }] } where
* `props` are merged into the profile-config (minus tk, which is fetched at runtime).
*/
function readInstances(dir, profileName, bundle, tag) {
// Accept either <profileName>.instances.json or any *.instances.json in the folder.
let file = join(dir, `${profileName}.instances.json`)
if (!existsSync(file)) {
const alt = readdirSync(dir).find((f) => f.endsWith('.instances.json'))
if (!alt) return []
file = join(dir, alt)
}
let cfg
try {
cfg = JSON.parse(readFileSync(file, 'utf8'))
} catch (e) {
console.error(`[generate-tree] bad instances json ${file}: ${e.message}`)
return []
}
const apiBaseUrl = typeof cfg.apiBaseUrl === 'string' ? cfg.apiBaseUrl : ''
const list = Array.isArray(cfg.instances) ? cfg.instances : []
return list
.filter((i) => i && typeof i.id === 'string' && i.id.trim())
.map((i) => {
const id = i.id.trim()
const iframeUrl = `${EMBED_BASE}/${bundle}/${profileName}/${id}/index.html`
const iframeEmbed =
`<iframe src="${iframeUrl}?utm_source=YOUR_SOURCE" width="100%" height="600" ` +
`style="border:none;border-radius:12px;" allowfullscreen></iframe>`
return {
id,
title: typeof i.title === 'string' ? i.title : id,
apiBaseUrl,
// profile-config props (tk added at runtime); profile name always included.
props: { profile: profileName, ...(i.props && typeof i.props === 'object' ? i.props : {}) },
element: tag,
iframeUrl,
embed: { iframe: iframeEmbed },
}
})
}
// ── Scan plugin-designs → bundle records ───────────────────────────────────────
function scanBundles() {
if (!existsSync(APP_PROFILES)) {
console.error(`[generate-tree] no plugin-designs/ at ${APP_PROFILES}`)
process.exit(1)
}
const PROTOTYPE_RE = /template|example|starter|sample|demo|clone/i
const bundles = []
for (const entry of readdirSync(APP_PROFILES).sort()) {
const dir = join(APP_PROFILES, entry)
if (!isBundleDir(entry) || !statSync(dir).isDirectory()) continue
const vueFiles = readdirSync(dir).filter((f) => f.endsWith('.vue')).sort()
if (vueFiles.length === 0) continue
// The custom-element tag this bundle registers (first one). Used in the
// <script>-style embed snippet below.
const bundleElements = extractElements(join(dir, 'index.js'))
const tag = bundleElements[0] || entry
// SUBRESOURCE INTEGRITY (sha384) of the built bundle IIFE. Vite has already
// emitted it to DEFAULT_ROOT by the time generate-tree runs, so we can hash it
// HERE and thread the value into every <script> tag we generate (standalone
// pages + copy-paste embed snippets). A tampered / CDN-swapped bundle then fails
// the browser's integrity check and is REFUSED. crossorigin="anonymous" is
// required for the check to run on cross-origin (CDN) loads. Missing .js → null
// → tags omit integrity (graceful, e.g. `bun run tree` before a build).
let integrity = null
try {
integrity = 'sha384-' + createHash('sha384').update(
readFileSync(join(DEFAULT_ROOT, entry, entry + '.js'))
).digest('base64')
} catch { integrity = null }
const profiles = vueFiles.map((file) => {
const tags = extractHeaderTags(join(dir, file))
const { description, file: fileTag, ...rest } = tags
const profileName = file.replace(/\.vue$/, '')
// Absolute-or-root-relative URLs so the snippets are paste-anywhere.
// iframeUrl → the standalone profile page; scriptUrl → the bundle IIFE.
const iframeUrl = `${EMBED_BASE}/${entry}/${profileName}/index.html`
const scriptUrl = `${EMBED_BASE}/${entry}/${entry}.js`
// Two ready-to-paste embed forms (mirrors ShareButton's iframe approach +
// the direct web-component approach). profileConfig token comes from the
// host at runtime (window.MAP_TOKEN_URL); the tk placeholder is a reminder.
// ?utm_source teaches TRAFFIC ATTRIBUTION in every snippet (same pattern as
// the YOUR_MAPBOX_TOKEN placeholder): the partner replaces YOUR_SOURCE and
// every people-group click-through from their embed carries it (the popup
// inherits the page URL's utm_source — see PeopleGroupDetail.utmSource()).
const iframeEmbed =
`<iframe src="${iframeUrl}?utm_source=YOUR_SOURCE" width="100%" height="600" ` +
`style="border:none;border-radius:12px;" allowfullscreen></iframe>`
// ★ THE SNIPPET MUST CARRY assetsBase + apiBaseUrl (BUG-EMBED-SCRIPT-SNIPPET-OMITS-…).
// MEASURED 2026-09-20: pasting the old snippet onto a FOREIGN HOST PAGE mounted the element
// (defined + shadow root + full height) and rendered NO MAP — two 404s, because relative asset
// and API paths resolve against the HOST's document, not the bundle's.
// THE ASYMMETRY, and why deploy-1 never had this problem: an IFRAME carries its OWN document
// base, so the leaf page's relative paths resolve; a DIRECT-JS embed executes in the host's
// document, so they do not. Same bundle, same snippet, different containing document.
// Emit the CONFIGURED values when the build sets them (assetsBaseUrl / apiBaseUrl), otherwise a
// PLACEHOLDER in the established style of YOUR_MAPBOX_TOKEN / YOUR_SOURCE — so the snippet stays
// honest for a same-root drop and becomes CORRECT for a partner who fills them in.
// ★ assetsBase goes in the PROFILE-CONFIG; the API base does NOT — it is a WINDOW GLOBAL.
// Verified in library/api/apiBaseUrl.js: getApiBaseUrl() reads ONLY window.MAP_APP_API_URL or
// the build-time VITE_API_BASE_URL. There is no profile-config path, so emitting an
// `apiBaseUrl` key here would look correct and do NOTHING (measured: the data call still 404'd).
// Same modular pattern as window.MAP_TOKEN_URL — a host-settable global, no rebuild needed.
const embedCfg = {
profile: profileName,
tk: 'YOUR_MAPBOX_TOKEN',
utmSource: 'YOUR_SOURCE',
assetsBase: DEST_CONFIG.assetsBaseUrl || 'YOUR_TREE_URL/assets',
}
const apiBase = DEST_CONFIG.apiBaseUrl || 'YOUR_API_BASE_URL'
const scriptEmbed =
`<script>window.MAP_APP_API_URL=${JSON.stringify(apiBase)};<\/script>\n` +
`<script src="${scriptUrl}"${integrity ? ` integrity="${integrity}" crossorigin="anonymous"` : ''}><\/script>\n` +
`<${tag} profile-config='${JSON.stringify(embedCfg)}'></${tag}>`
// Preconfigured instances: ONE parameterized profile → N preset render
// targets, declared in <something>.instances.json next to the .vue. Each
// instance emits its own nested page (<profile>/<instance>/index.html) with
// the preset props baked in. Distinct from tabs (many components → one el);
// this splits one profile into many named, directly-embeddable variants.
const instances = readInstances(dir, profileName, entry, tag)
// Composition tier (capability machine-key), baked into the manifest so consumers group
// by capability without a side tierMap. Explicit <profile>.meta.json {"type"} wins; else
// an *.instances.json → parameterized; else single. (detectProfileType, same as the gallery.)
const { type: profileType } = detectProfileType(dir, profileName, tag, [], instances.length > 0)
// Does this page need a map ENGINE? Not "does this .vue call mapbox itself" — the
// question the peer/token gate actually asks is "will a map be drawn IN THIS DOCUMENT".
// The two composite tiers answer it DIFFERENTLY, and conflating them was
// BUG-UPG100MFE-MAP-TAB-MOUNTS-NOTHING (2026-09-20):
//
// cap-5 nested-mfe-spa nests children as IFRAMES. Each child is its own leaf
// PAGE with its own peer + its own token ladder, so the
// shell document itself draws nothing and needs nothing.
// → non-map, always.
//
// cap-4 nested-elements-mfe nests children as CUSTOM ELEMENTS IN THIS SAME DOCUMENT
// AND REALM (deliberately — one bundle download serves
// every pane; see upg-100-mfe.vue "Why nested ELEMENTS").
// There is no inner page to run a token ladder or load a
// peer, so if ANY composed child paints a map, THIS page
// must ship the engine and resolve a token. Marking it a
// non-map emitted "NO map peer" → window.mapboxgl was
// undefined → useMapInstance bailed at its
// "mapboxgl is not available" guard and the map pane's
// container was never created. The canvas was not hidden
// or unsized; it never existed.
//
// So a cap-4 composite INHERITS map-ness from the siblings it composes. That is
// derived from meta.json `composes` (and falls back to scanning the bundle's own
// profiles), never from a per-leaf special case — the next author who composes a map
// gets a working peer without knowing this rule exists.
// A NON-composite profile is `|| ownsAMapDeeply(dir)` for the same reason a composite is:
// a profile whose root .vue is a pure layout can still own a map in its own component
// tree. research-dashboard is exactly that — zero engine refs in research-dashboard.vue,
// a real Mapbox map in src/components/dashboards/DashboardMapPanel.vue — so the flat scan
// alone marked its standalone leaf TOKEN_FREE and the page mounted with no token.
//
// The deep scan is bounded to src/ and to .vue/.js/.mjs, and it was negative-tested across
// every bundle before being wired in: research-dashboard is the ONLY profile whose
// classification it changes (flat=false → deep=true). Every other map bundle already
// matched flat, and no genuinely map-free profile was flipped.
const isMap = profileType === 'nested-mfe-spa'
? false
: profileType === 'nested-elements-mfe'
? composesAMapLeaf(dir, profileName, file)
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
return {
profile: profileName,
file,
description: description || fileTag || '',
element: tag,
type: profileType,
isMap,
iframeUrl,
scriptUrl,
// ★ ENGINE PRELOAD, map snippets only (measured 2026-09-25, cold, 10 Mbps). A partner page that
// pastes this snippet has no leaf page to fetch mapbox-gl.js, so the bundle requested it only
// after it had downloaded, run and mounted: doxa-simple-map's engine started at 1.06 s, right
// after its bundle (0.99 s); regions-mfe's at 2.67 s. A preload lets the ~480 KB engine download
// alongside the bundle. NO crossorigin attribute, on purpose: ensureEngineScript injects a plain
// <script src>, and a preload is only reused when its request mode matches — with crossorigin it
// would download twice. Mapbox only: a non-map profile gets none, and a partner switching to
// provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests.
embed: {
iframe: iframeEmbed,
script: isMap && MAPBOX_GL_JS
? `<!-- Mapbox engine, fetched early; remove this line if you set "provider":"maplibre" -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}">\n` + scriptEmbed
: scriptEmbed,
},
...(instances.length ? { instances } : {}),
// Extra profile-config keys from <profile>.meta.json `config` (e.g. pinHint:false).
// GENERATOR-INTERNAL: stripped before the manifest is written (see writeManifestAndHtml)
// so the published manifest shape is unchanged.
extraConfig: readProfileConfig(dir, profileName),
...rest,
}
})
// Representative-profile ordering (mirrors the app-shell SCAN): prototype-named
// profiles sort LAST so the real production profile represents the bundle.
profiles.sort((a, b) =>
(PROTOTYPE_RE.test(a.profile) ? 1 : 0) - (PROTOTYPE_RE.test(b.profile) ? 1 : 0))
// Bundle-level composition edges: the union of every profile's own <profile>.meta.json
// `composes` (most bundles carry exactly one profile that declares it; my-upg-100-list is
// the one bundle with several profiles, only one of which — upg-100-mfe — composes). Union,
// not "first wins", so a future bundle with more than one composing profile still publishes
// every edge. Read straight from vueFiles (not the `profiles` array above) so this stays the
// ONE place `composes` is published — at the bundle level only, matching the manifest shape.
// Omitted entirely when no profile in the bundle declares composes (no empty array).
const bundleComposes = vueFiles.flatMap((file) => readComposes(dir, file.replace(/\.vue$/, '')) || [])
const composesUnique = [...new Set(bundleComposes)]
bundles.push({
bundle: entry,
// Absolute-or-root-relative URL to the bundle IIFE (paste-anywhere).
scriptUrl: `${EMBED_BASE}/${entry}/${entry}.js`,
output: `${TREE_NAME}/${entry}/${entry}.js`,
integrity,
elements: bundleElements,
...(composesUnique.length ? { composes: composesUnique } : {}),
profiles,
})
}
return bundles
}
// ── Write the tree into a root, then fan out to all destinations ─────────────
function readDestinations() {
const dests = [DEFAULT_ROOT]
// Each entry in vite.doxa-maps-build-config.json `paths` is a folder that should CONTAIN
// the <name>/ tree (so the full path is <entry>/<name>/).
for (const p of DEST_CONFIG.paths) {
dests.push(resolve(__dirname, '..', p, TREE_NAME)) // paths are relative to the BUNDLER ROOT (this script lives in internal/)
}
return dests
}
function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
const manifest = {
schema: 'doxa-maps-manifest@1',
generatedFrom: 'plugin-designs/*/*.vue',
// The base URL all embed/script/iframe URLs below are built against. Set
// embedBaseUrl in vite.doxa-maps-build-config.json to make these absolute; empty here
// means they are root-relative (served from the site root).
embedBase: EMBED_BASE,
bundleCount: bundles.length,
profileCount: bundles.reduce((n, b) => n + b.profiles.length, 0),
// THE PAGE GLOBALS A LEAF PAGE BAKES, published for embeds that have no leaf page. A composed
// surface (the cap-5 Research Gallery) mounted by `<script>` on a FOREIGN page runs in that
// page's document, which carries neither window.MAP_APP_API_URL nor window.MAP_TOKEN — measured
// 2026-09-24: 8 of 13 gallery tabs errored with the host's HTML in place of API JSON. The shell
// reads this block as its default (a host attribute still wins). ADDITIVE: every key is optional
// and old consumers ignore it. The token is the same opt-in public pk. every leaf already bakes.
runtime: {
...(DEST_CONFIG.apiBaseUrl ? { apiBaseUrl: DEST_CONFIG.apiBaseUrl } : {}),
...(bakedToken ? { token: bakedToken } : {}),
},
// `extraConfig` is a GENERATOR-INTERNAL carrier (<profile>.meta.json `config` → the leaf
// page's BASE_CONFIG) and is stripped here so the published manifest shape is unchanged.
// Consumers read the manifest as a contract; a new key would be a silent schema change.
bundles: bundles.map((b) => ({
...b,
profiles: b.profiles.map(({ extraConfig, ...p }) => p),
})),
}
mkdirSync(root, { recursive: true })
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
// ★ A CORS-READABLE COPY for the Research Gallery, byte-identical, written in the SAME step so the
// two can never diverge. The live static layer sends Access-Control-Allow-Origin on paths inside a
// folder of the tree but not on the root manifest (measured 2026-09-25), so a partner page's
// script embed of <doxa-maps-spa> reads this copy. See doxa-maps-spa.vue loadManifest.
if (existsSync(join(root, 'doxa-maps-spa'))) {
writeFileSync(join(root, 'doxa-maps-spa', 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
}
// THE FRONT PAGE IS THE STUDIO (see studioRootHtml). The staging-forge chooser that used to be
// written here (rootHtml) is retired with it; nothing is written above the tree any more.
writeFileSync(join(root, 'index.html'), studioRootHtml(bakedToken))
bundles.forEach((b) => {
const bundleDir = join(root, b.bundle)
mkdirSync(bundleDir, { recursive: true })
// ── THE 19 BUNDLE CHOOSERS ARE NO LONGER EMITTED (2026-09-22) ─────────────────────────────
// <tree>/<bundle>/index.html used to be a "staging-forge" chooser page listing that bundle's
// profiles. MEASURED before removal: referenced by manifest.json exactly ZERO times, while the
// 28 profile leaves one level deeper are referenced 28 times by embed.iframe (the IFRAME
// deployment method — a shipped deliverable). So the choosers were reachable only by typing
// their URL, mounted NO element, and returned a confident HTTP 200.
//
// ★ WHY THAT 200 WAS EXPENSIVE, not merely useless: probing a chooser is INDISTINGUISHABLE
// from probing a broken feature. It cost the agent a session ("none of the elements are
// working" — they were; the page was a chooser presenting itself as the product) and cost an
// agent a false "the grid is completely inert" diagnosis. A page that answers 200 with no
// element is a lying instrument, and deleting it removes a whole class of wrong answer.
//
// bundleHtml() is KEPT (unused here) on purpose: it is the scope='bundle' half of forgeHtml,
// still exercised by the archived gallery's model, and removing it would be a second,
// unrelated change riding along with this one.
for (const p of b.profiles) {
const profDir = join(bundleDir, p.profile)
mkdirSync(profDir, { recursive: true })
writeFileSync(join(profDir, 'index.html'), profileHtml(b, p, tokenUrlExpr, bakedToken))
// Preconfigured instances → one nested page each, props baked in.
for (const inst of (p.instances || [])) {
const instDir = join(profDir, inst.id)
mkdirSync(instDir, { recursive: true })
writeFileSync(join(instDir, 'index.html'), instanceHtml(b, p, inst, tokenUrlExpr, bakedToken))
}
}
})
}
function publicReadme(bundles) {
const profiles = bundles.reduce((n, b) => n + b.profiles.length, 0)
return `# public/ — the built output
This folder is GENERATED by \`bun run build\`; do not edit it by hand. It holds exactly one thing:
- **\`${TREE_NAME}/\`** — the output folder: ${bundles.length} plugin bundles and ${profiles} pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- \`${TREE_NAME}/index.html\` — the front page: the Plugin Studio, every plugin in one place.
- \`${TREE_NAME}/<plugin>/<plugin>.js\` — a plugin's bundle (a custom element; load it with a \`<script>\` tag).
- \`${TREE_NAME}/<plugin>/<page>/index.html\` — a ready-made page for one plugin view (embed it in an \`<iframe>\`).
- \`${TREE_NAME}/manifest.json\` — every plugin, page and embed snippet, machine-readable.
- \`${TREE_NAME}/assets/\` — the data, fonts and images the plugins fetch at runtime.
The folder's name comes from \`name\` in \`vite.doxa-maps-build-config.json\`. Sources live in
\`plugin-designs/\`, \`library/\` and \`data/\`; \`rm -rf public/ && bun run build\` recreates all of this.
`
}
function main() {
const bundles = scanBundles()
// SUBRESOURCE INTEGRITY is computed inside scanBundles() (each bundle record carries
// `integrity`), so it is available to the embed snippets AND the generated <script> tags.
const tokenUrlExpr = readTokenUrlExpr()
const bakedToken = DEST_CONFIG.token // opt-in literal token (ladder rung 3); '' by default
// 1. Fill in manifest + HTML on the DEFAULT root (where vite already emitted the .js).
writeManifestAndHtml(DEFAULT_ROOT, bundles, tokenUrlExpr, bakedToken)
// 2. Copy the completed default tree to every extra destination.
const dests = readDestinations()
// ── RUNTIME ASSETS: previews + boundaries ────────────────────────────────
// These are deliberately NOT bundled. The build is a single-file IIFE with
// `inlineDynamicImports: true`, so anything imported — even via a dynamic
// import() — is inlined. That took the upg-100-list bundle to 6 MB: 1.8 MB of
// poster previews plus 2.74 MB of boundary coordinates, all of it blocking the
// map, the list view and the poster wall behind one parse.
// Serving them as real files instead keeps the bundle at ~0.4 MB, lets the
// browser cache them, and lets the shell become interactive immediately.
// They must be COPIED here or a clean build drops them and the wall falls back
// to text placeholders while the map fails to resolve its boundaries.
{
// STAGED IN A TEMP DIRECTORY, NOT public/assets (the agent decision, card BUILD-OUTPUT-IS-ONE-FOLDER:
// public/ holds only the output folder + README.md). This step still gathers the sources
// (data/, plugin previews, the upg-100 boundaries) into one pool and copies the fetched
// allowlist into <tree>/assets below; the pool itself is a build intermediate. The web-root
// sibling it used to be was also a trap: it made a sibling-relative asset path resolve locally
// while it 404'd live (library/utils/embedBase.js records that incident).
const pubAssets = mkdtempSync(join(tmpdir(), `${TREE_NAME}-assets-`))
// …and removed even if this script dies part-way: an 'exit' handler runs on a crash too. A pool left
// behind by a failed build is ~50 MB in /tmp, and on a full disk (2026-09-25) that is what fails the NEXT build.
const dropPool = () => { try { rmSync(pubAssets, { recursive: true, force: true }) } catch { /* already gone */ } }
process.once('exit', dropPool)
// EVERY profile's previews, not just the 100-list's. This was hardcoded to
// my-upg-100-list, so country-posters shipped with zero thumbnails and its
// whole wall fell back to "poster not generated yet" placeholders even though
// 174 WebPs existed on disk. Any future poster app is picked up automatically.
// Filenames are already app-prefixed (country-*.webp vs the zone keys), so one
// flat folder cannot collide.
{
const profilesRoot = resolve(__dirname, '..', 'plugin-designs')
const d = join(pubAssets, 'previews')
let copied = 0
for (const name of readdirSync(profilesRoot)) {
if (name.startsWith('_') || name.startsWith('.')) continue
const src = join(profilesRoot, name, 'assets', 'previews')
if (!existsSync(src)) continue
mkdirSync(d, { recursive: true })
cpSync(src, d, { recursive: true })
copied += readdirSync(src).length
}
if (copied) console.log(`[generate-tree] gathered ${copied} poster previews`)
}
const bDir = join(pubAssets, 'boundaries')
mkdirSync(bDir, { recursive: true })
const dataDir = resolve(__dirname, '..', 'plugin-designs', 'my-upg-100-list', 'data')
for (const [from, to] of [
[join(dataDir, 'adm0', 'world-adm0.geojson'), 'world-adm0.geojson'],
[join(dataDir, 'gb-IND-ADM1.geojson'), 'gb-IND-ADM1.geojson'],
[join(dataDir, 'gb-CHN-ADM1.geojson'), 'gb-CHN-ADM1.geojson'],
]) if (existsSync(from)) cpSync(from, join(bDir, to))
console.log('[generate-tree] gathered boundary geojson')
// DATA LIVES IN data/, THE WEB-ROOT IS GENERATED (the D1 reorganization):
// source-of-truth folders that used to sit ONLY inside public/ now live in
// data/ and are copied into the served tree here, exactly like boundaries
// above. Served paths are unchanged (public/assets/people-groups, /vendor),
// so no runtime reference moves. public/ stays fully regenerable: rm -rf it
// and `bun run build` rebuilds everything. The web-root holds only assets/,
// <name>/ and the generated root page (internal/generate-gallery.mjs): the
// vendored external bundles (data/external-bundles) were consumed by the
// optional layouts/ shell alone, so they are no longer copied; the frame
// policy is the serving host's (documentation/architecture/embeddability.md).
const repoData = resolve(__dirname, '..', 'data')
for (const [srcName, destRel] of [
['people-groups', join(pubAssets, 'people-groups')],
['vendor', join(pubAssets, 'vendor')],
['posters', join(pubAssets, 'posters')],
['tables', join(pubAssets, 'tables')],
// pplr — the PPLR data maps' derived datasets (plugin-designs/pplr-data-maps/scripts/build-data.mjs)
['pplr', join(pubAssets, 'pplr')],
// fonts/ + images/ — vendored faces and images the leaves fetch (see the staging notes
// below). They used to live ONLY in public/assets; they are sources, so they live in data/.
['fonts', join(pubAssets, 'fonts')],
['images', join(pubAssets, 'images')],
]) {
const src = join(repoData, srcName)
if (!existsSync(src)) continue
rmSync(destRel, { recursive: true, force: true })
mkdirSync(destRel, { recursive: true })
cpSync(src, destRel, { recursive: true })
}
console.log('[generate-tree] gathered data/ sources into the build pool (people-groups, vendor, posters, tables, pplr, fonts, images)')
// ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ──────
// BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy
// above is a SIBLING of the tree; a publisher that rsyncs only `<TREE_NAME>/`
// (ours does) never carries it, so every data fetch 404'd live and four elements
// rendered a black page. Pages now resolve assets TREE-RELATIVE (assetsBaseFor),
// so the data must live at `<TREE_NAME>/assets/` for the tree to be genuinely
// self-contained and position-independent.
//
// ONLY WHAT IS FETCHED AT RUNTIME. This is an allowlist, not a mirror: the
// web-root assets/ is 94 MB, dominated by posters (42 MB) and previews (10 MB)
// that no leaf fetches over HTTP, plus the 16 MB people_groups.csv that nothing
// reads (already in dropin.exclude). Copying all of it would bloat every deploy.
// Each entry below is traceable to a literal fetch() in a .vue — add a row here
// when an element starts fetching something new, or it will 404 in the tree.
const treeAssets = join(DEFAULT_ROOT, 'assets')
rmSync(treeAssets, { recursive: true, force: true })
let staged = 0
for (const rel of [
// boundaries — choropleth geometry
// gospel-resources/resources-map.vue, cities-internet/connectivity-map.vue → world-adm0
// my-upg-100-list/upg-100-list.vue → all three (adm0 + IND/CHN adm1)
'boundaries/world-adm0.geojson',
'boundaries/gb-IND-ADM1.geojson',
'boundaries/gb-CHN-ADM1.geojson',
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json',
// …and its compact cut (build-points.mjs): what all-people-groups actually fetches, 0.74 MB.
'people-groups/people-groups-points.json',
// pplr-data-maps — fetched through dataUrl() (src/utils/pplrData.js). pg-names.json loads
// only when a pin's detail card opens.
'pplr/pg-points.json',
'pplr/pg-names.json',
'pplr/coverage.json',
'pplr/rop-tree.json',
'pplr/pplr-geoid-family.json',
'pplr/rosetta-sample.json',
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
'vendor/tabulator/tabulator.min.js',
'vendor/tabulator/tabulator.min.css',
// vendor/vue — the GALLERY SHELL's own Vue (generate-gallery.mjs loadVue()), not a bundle's.
//
// ★ ADDED 2026-09-22, and it had never been staged. generate-gallery.mjs:542 requests
// `ASSETS + '/vendor/vue/vue.global.prod.js'` and this list shipped only vendor/tabulator,
// so that request 404'd on every build there has ever been. It survived because loadVue()
// degrades in three rungs — self-hosted, then the jsDelivr CDN, then a plain-DOM render —
// and on any networked machine rung 2 silently covers for rung 1. The only symptom was a
// 404 in `bun run smoke`, a gate nobody was running.
//
// WHY IT MATTERS ANYWAY: the comment above loadVue() promises "self-hosted first (portable /
// offline / no-CDN)". Measured in a browser with no outbound network, the gallery fell all
// the way through to the plain-DOM degrade — usable, but not the Vue shell, and the offline
// claim was false. A tree dropped on a foreign host or opened over file:// is exactly the
// case the self-hosted rung exists for.
'vendor/vue/vue.global.prod.js',
]) {
const from = join(pubAssets, rel)
if (!existsSync(from)) { console.warn(`[generate-tree] WARN missing tree asset: assets/${rel}`); continue }
const to = join(treeAssets, rel)
mkdirSync(dirname(to), { recursive: true })
cpSync(from, to)
staged++
}
// tables/*.json — the data-tables + map layers fetch these by name; ship the whole
// (small, ~14 MB total on disk but only the fetched ones matter) folder MINUS the
// internal-only files dropin.exclude already names, so a new table works with no
// build change. Kept as a directory copy because the fetch name is dynamic
// (`${assetsBase}/tables/${file}.json`).
const TABLES_SKIP = new Set(['open-doors.json', 'platform-reachability.json', 'index.json'])
const tablesSrc = join(pubAssets, 'tables')
if (existsSync(tablesSrc)) {
const tablesDest = join(treeAssets, 'tables')
mkdirSync(tablesDest, { recursive: true })
for (const f of readdirSync(tablesSrc)) {
if (TABLES_SKIP.has(f)) continue
cpSync(join(tablesSrc, f), join(tablesDest, f))
staged++
}
}
// previews/ + posters/ — the poster walls fetch these PER CARD, lazily, by id
// (`${assetsBase}/previews/<id>.webp`, `${assetsBase}/posters/<id>.webp`), so the
// name is dynamic and the whole folder ships. They are the bulk of the tree
// (previews ~10 MB, posters ~42 MB) but both are really fetched and dropping
// either is a VISIBLE regression, not a saving:
// previews → the my-upg-100-list / country-posters wall thumbnails. Without
// them every card falls back to a text placeholder (the reported
// "UPG-100 poster preview unavailable").
// posters → the full-size download behind the wall's export button, which
// fails closed with "poster not found (404)" if absent.
// Excluded on purpose instead: assets/people_groups.csv (16 MB, fetched by
// nothing) and the internal-only tables above.
// POSTERS ARE OPT-IN (bundlePosters, default false). MEASURED 2026-09-20: staging both dirs
// made the tree 74 MB, which git-packed to 57 MiB and the catalog server REJECTED outright
// ("remote end hung up"), so nothing could publish at all. The split that resolves it comes
// from the components themselves: PosterWall renders the wall from previews/<id>.webp
// (line 80) and only fetches posters/<id>.webp ON DEMAND behind the export button (line 159).
// So previews (10 MB, 378 files) are what RENDER and must ship; full posters (42 MB) are
// print-resolution originals needed only when a user actually exports one. Shipping previews
// keeps every element visually complete in a dropped folder — the self-contained promise —
// while an absolute assetsBaseUrl serves the originals on demand (the same escape hatch used
// for partner-site and mobile embeds). Set bundlePosters:true to ship them anyway for a
// fully offline drop, accepting the size.
// ★ fonts/ MUST be staged (measured 2026-09-24). A leaf page resolves its assetsBase to
// <tree>/assets, so a vendored @font-face at assets/fonts/... is fetched from INSIDE the
// tree — and this staging step is an allowlist, so a dir absent from it 404s. The regions
// clone vendors Bebas Neue (public/assets/fonts/bebas-neue, OFL) because DX-life serves
// theirs without CORS; with fonts/ unstaged the face registered, requested
// <tree>/assets/fonts/..., failed, and every heading silently rendered on the fallback.
// document.fonts reported the face in 'error' status while a computed-style diff of the
// two pages said 0 differences — both sides agreeing on a FALLBACK is not a match, and a
// font that 404s is invisible to every check that reads font-family rather than asking
// the engine whether the family is usable.
// Small and self-contained: a woff2 pair is ~22KB, and the tree's self-contained promise
// is exactly why it has to travel with it.
// ★ images/ MUST be staged for the same reason fonts/ must (see the note above). The
// regions clone copies DX-life's worldmap.svg watermark, which their UupgsList sets inline
// as a background-image on its root (UupgsList.vue:451-454, background-size 60% /
// background-position top / min-height 400px). A leaf resolves assetsBase to <tree>/assets,
// so the URL is fetched from INSIDE the tree; with images/ absent from this allowlist it
// 404s and the watermark is simply not there, with no error anywhere — the background just
// does not paint, which is even quieter than the font failure was.
// ★ posters/ CARRIES TWO VERY DIFFERENT PAYLOADS and bundlePosters is one boolean,
// so it could only ever ship both or neither:
// <id>.webp 378 files, 40.9 MB — the print-resolution rasters. This is the weight
// that pushed the tree to 80MB and got the push rejected.
// <id>.html 28 files, the poster SOURCE the browser rasterises when no .webp is
// present (library/packet/poster-raster.js). ~0.3 MB each.
// "Neither" is what a default drop got, and it is why BOTH full-size paths 404'd:
// export fell back to fetching <id>.html and that was not shipped either, so the
// fallback had nothing to fetch. posterSources ships the sources WITHOUT the rasters,
// which is the only combination the old boolean could not express.
// Default stays FALSE — 28 sources are ~27.9 MB raw, real weight that needs a
// deployment decision, not a silent default. This makes the choice available.
const posterExts = DEST_CONFIG.bundlePosters ? null // everything
: (DEST_CONFIG.posterSources ? ['.html'] : []) // sources only / nothing
const assetDirs = (posterExts === null || posterExts.length)
? ['previews', 'posters', 'fonts', 'images']
: ['previews', 'fonts', 'images']
for (const dirName of assetDirs) {
const src = join(pubAssets, dirName)
if (!existsSync(src)) { console.warn(`[generate-tree] WARN missing tree asset dir: assets/${dirName}`); continue }
if (dirName === 'posters' && posterExts !== null) {
// Selective copy: only the extensions this deployment asked for.
mkdirSync(join(treeAssets, dirName), { recursive: true })
let n = 0
for (const f of readdirSync(src)) {
if (!posterExts.some((e) => f.endsWith(e))) continue
cpSync(join(src, f), join(treeAssets, dirName, f))
n += 1
}
staged += n
continue
}
cpSync(src, join(treeAssets, dirName), { recursive: true })
staged += readdirSync(src).length
}
if (DEST_CONFIG.bundlePosters) {
console.log('[generate-tree] posters/ bundled in full (bundlePosters:true) — rasters + sources')
} else if (DEST_CONFIG.posterSources) {
console.log('[generate-tree] posters/*.html bundled (posterSources:true) — no rasters; export and print RASTERISE in the browser')
} else {
console.log('[generate-tree] posters/ NOT bundled — the wall renders from previews; export and print need posterSources:true, bundlePosters:true, or an absolute assetsBaseUrl')
}
console.log(`[generate-tree] staged ${staged} runtime asset(s) → ${TREE_NAME}/assets (tree is self-contained)`)
rmSync(pubAssets, { recursive: true, force: true })
}
// public/README.md — the one other entry public/ holds: what this folder is, in plain words.
writeFileSync(join(DEFAULT_ROOT, '..', 'README.md'), publicReadme(bundles))
for (const dest of dests) {
if (dest === DEFAULT_ROOT) continue
rmSync(dest, { recursive: true, force: true })
mkdirSync(dirname(dest), { recursive: true })
cpSync(DEFAULT_ROOT, dest, { recursive: true })
console.log(`[generate-tree] copied ${TREE_NAME}/ → ${dest}`)
}
// The native app shells load THIS folder; keep their paths in step with the tree's name.
for (const r of syncAppShells()) if (r.changed) console.log(`[generate-tree] app shell ${r.file}: ${r.notes.join('; ')}`)
console.log(`[generate-tree] ${bundles.length} bundle(s), ` +
`${bundles.reduce((n, b) => n + b.profiles.length, 0)} profile(s) → ${dests.length} destination(s)`)
}
main()