doxa-maps/internal/generate-tree.mjs
Doxa Maps 2a692021c0 Boundary attribution travels with the boundaries
The India ADM1 boundary is Creative Commons Attribution (CC BY 2.5 IN,
sourced from DataMeet and the Election Commission of India), so the credit
has to reach whoever receives the data. It did not: 6.1 MB of boundary
GeoJSON shipped with no human-readable attribution, and a sidecar for one
file of eight.

data/boundaries/ATTRIBUTION now ships beside the data. Every licence value in
it is verbatim from the geoBoundaries API, queried per boundary -- CC BY 2.5
IN for India, Public Domain for China. No licence wording is ours.

★ IT TOOK TWO ALLOWLIST EDITS, and the first one silently did nothing. The
boundary staging is TWO-STAGE: a pair list gathers into the build pool, and a
separate path list decides what reaches the tree. I added the file to the pair
list, rebuilt, and it still did not ship. An entry in one stage of a two-stage
pipeline is not an entry.

WORLD-ADM0 IS CORRECTED, not closed. This register first guessed Natural
Earth, public domain. It is geoBoundaries gbOpen assembled per country: its
features carry geoBoundaries' own iso3/shapeGroup/shapeName, and the plugin's
data/adm0/PROVENANCE.json names the source and endpoint. That file also states
the real problem -- gbOpen passes each country's UPSTREAM licence through, so
one file carries up to 162 licences and up to 162 attribution duties -- and
its licences table is EMPTY. Which of the 162 are CC BY is unestablished.
Stated in ATTRIBUTION as an undischarged duty rather than papered over.

pplr's row is exp-shepherd's evidence: the PPLR ArcGIS org aggregating IMB,
Joshua Project, ROP and WCD identifiers, publishing no licence at all, and
WCD is a proprietary Brill database.
2026-09-28 01:55:03 +00:00

1810 lines
109 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 { CDN } from '../library/constants/cdn.js'
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 { profileMetaFields, bundleStage } from './profile-meta.mjs'
import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs'
import { deriveComposedTiers } from './_composed-tiers.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 the host platform; 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 host platform's 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 = CDN.mapboxGlJs.url // the ONE registry the bundle's loader reads too (library/constants/cdn.js)
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(/\u0022/g, '&quot;') // \u0022, not a bare quote: template-literal-check reads a bare one as an open string
// ── 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" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>${esc(o.title)}</title>
<link rel="icon" href="data:,">
<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, integrity = '') {
// THE TREE'S FRONT PAGE (<tree>/index.html) — Driver 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" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<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}"${integrity ? ` integrity="${integrity}" crossorigin="anonymous"` : ''}></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 host platform's 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="${CDN.maplibreGlCss.url}" rel="stylesheet" integrity="${CDN.maplibreGlCss.integrity}" crossorigin="anonymous" />
<script src="${CDN.maplibreGlJs.url}" integrity="${CDN.maplibreGlJs.integrity}" crossorigin="anonymous"></script>`
: `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->`
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
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.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
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 || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
// element decides the same way from the token this page hands it, so the two cannot disagree.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
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.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
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. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
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 },
// A preset that OPENS on a lens with no GL map (upg-neighbor's flower-mesh is SVG) is still a
// map element: its view-switcher reaches the map lenses, so it keeps isMap (engine, token
// ladder). This tells the draw gates not to expect a canvas on first paint. Absent = true.
...(i.opensOnMap === false ? { opensOnMap: false } : {}),
}
})
}
// ── 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,
...profileMetaFields(dir, profileName), // stage / heavy / widget, shared with the dev manifest
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. It carries crossorigin + integrity because ensureEngineScript's <script> now
// does too (library/constants/cdn.js): a preload is reused only when its request mode matches. 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.
// The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too.
embed: {
iframe: iframeEmbed,
script: isMap && MAPBOX_GL_JS
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}" integrity="${CDN.mapboxGlJs.integrity}" crossorigin="anonymous">\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),
// Generator-internal, stripped with extraConfig at manifest assembly: the profile's OWN
// composes. The published manifest hoists composes to the BUNDLE, which is right for its
// consumers but useless for deriving a tier — a bundle's composite and its plain leaves would
// all inherit the same edges. The tier is a property of the profile that declares them.
composesOwn: readComposes(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 } : {}),
stage: bundleStage(profiles),
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) {
// ── DERIVE THE COMPOSITION TIER FROM THE EDGES, BEFORE ANYTHING READS THE MANIFEST ──────────
// Every consumer downstream — the gallery's capFor, the studio rail, check:composite-embed-knobs —
// trusts profile.type. Deriving here means a composite can no longer ship understated because its
// author forgot a meta.json, and a stale meta can no longer disagree with the structure in silence.
{
const { conflicts, unresolved } = deriveComposedTiers(bundles)
// FAILS rather than warns, now that the resolver understands all three vocabularies and the tree has
// ZERO unresolved refs. While five were outstanding a warning was right — they turned out to be
// correct data my resolver could not read, and failing would have blamed five authors for my gap.
// With the count at 0, a new unresolved ref is a genuinely broken edge and silently weakens a tier.
if (unresolved.length) {
console.error(`[tree] ${unresolved.length} composes ref(s) resolve to nothing — a broken edge silently weakens a tier:`)
for (const u of unresolved) console.error(` ${u}`)
console.error(' A ref may be "<bundle>/<profile>", a bare "<bundle>", or a registered element tag.')
process.exit(1)
}
if (conflicts.length) {
console.error('[tree] composition tier CONFLICT — a profile\'s meta.json disagrees with its own composes edges:')
for (const c of conflicts) console.error(` ${c}`)
console.error(' Fix the meta.json or the composes list; the generator will not choose between them.')
process.exit(1)
}
}
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, composesOwn, ...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.
// …with the studio bundle's integrity, written HERE: it used to be added only by fix:sri, so running
// `bun run tree` after fix:sri stripped it again (rega, 2026-09-25).
writeFileSync(join(root, 'index.html'), studioRootHtml(bakedToken, (bundles.find((b) => b.bundle === 'plugin-studio') || {}).integrity || ''))
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 Driver 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 (Driver 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'],
// COMPLETE India ADM1 (36 units, geoBoundaries gbOpen, CC BY 2.5 IN; source + licence in its meta),
// for maps that colour every state. The curated file above is UPG-100's 34-unit partition.
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full.geojson'), 'gb-IND-ADM1-full.geojson'],
// ★ AND THE ATTRIBUTION, because this pair list is what decides whether it TRAVELS. The India
// ADM1 boundary is Creative Commons Attribution, so the credit has to reach the recipient with
// the data; a file left in data/boundaries would never ship. Same trap as vendor/*/LICENSE.
[resolve(__dirname, '..', 'data', 'boundaries', 'ATTRIBUTION'), 'ATTRIBUTION'],
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full.meta.json'), 'gb-IND-ADM1-full.meta.json'],
// WEB copies (library/scripts/boundaries/build-web-boundaries.mjs): every feature and island kept, about
// a fifth of the bytes. The interactive maps fetch these; posters, print and build-time point-in-polygon
// keep the full files above (REPORT-WEB-BOUNDARIES.md).
[join(dataDir, 'adm0', 'world-adm0-web.geojson'), 'world-adm0-web.geojson'],
[join(dataDir, 'gb-IND-ADM1-web.geojson'), 'gb-IND-ADM1-web.geojson'],
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full-web.geojson'), 'gb-IND-ADM1-full-web.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',
'boundaries/gb-IND-ADM1-full.geojson', // all 36 states (Cities with Internet's NFHS layer)
'boundaries/gb-IND-ADM1-full.meta.json', // its source and licence, served beside it
// ★ SECOND STAGE. The pair list above only gathers into the build POOL; THIS list decides what
// reaches the tree. I added ATTRIBUTION to the pair list, ran the build, and it still did not ship
// — a two-stage pipeline needs the entry in both. The India ADM1 boundary is Creative Commons
// Attribution, so the credit must travel with the data.
'boundaries/ATTRIBUTION',
'boundaries/world-adm0-web.geojson', // the web copies the interactive maps fetch
'boundaries/gb-IND-ADM1-web.geojson',
'boundaries/gb-IND-ADM1-full-web.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',
// ★ AND THEIR LICENCES, because this list is what decides whether a notice TRAVELS. Both are MIT,
// which requires the permission notice to be included with the software. Tabulator's minified .js
// carried only a copyright line and its .css carried nothing; Vue's banner names MIT but omits the
// permission text. Shipping the code without the notice is the gap, and an allowlist that omits
// the LICENSE guarantees it stays open however carefully the file is vendored.
'vendor/tabulator/LICENSE',
// 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',
'vendor/vue/LICENSE',
]) {
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++
}
}
// people-groups/lists/*.json — the ?list= / profile-config `list` prop fetches the chosen list by name
// (frontier-500k, frontier, unreached, unengaged), so the whole folder ships. Missing, every non-default
// list 404'd on the built tree and drew 0 pins (go-int / rega at a71007f1).
const listsSrc = join(pubAssets, 'people-groups', 'lists')
if (existsSync(listsSrc)) {
const listsDest = join(treeAssets, 'people-groups', 'lists')
mkdirSync(listsDest, { recursive: true })
for (const f of readdirSync(listsSrc)) { if (!f.endsWith('.json')) continue; cpSync(join(listsSrc, f), join(listsDest, 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()