#!/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//.js). * It scans plugin-designs//*.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 * / * .js ← the IIFE bundle (already emitted by vite) * index.html ← bundle staging: lists this bundle's profiles * / * 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// plus // 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 { isWebBuilt } from './plugin-targets.mjs' 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, bundleVisibility, readProfileMeta, VISIBILITY } 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 //. 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) } } // EMBED_BASE_URL overrides the committed embedBaseUrl, the same way DOXA_SYNC_PATHS extends paths: // a host that builds its own tree (maps-dev's /cdn build inherits the container env) bakes absolute // snippet URLs without a committed config change. It is the base WITHOUT the tree name // (https://maps-dev.nuxtbase.app/cdn -> .../cdn//...). check-embed-targets-exist reads the same. const embedBaseUrl = ((process.env.EMBED_BASE_URL || '').trim() || (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 (.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 `/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 * `/` 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: /// = 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/ 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 ${esc(o.title)}

${esc(o.title.replace(' \u00b7 staging',''))}${o.scope === 'root' ? '' : ' / ' + esc(o.bundle || '')}

${esc(o.subtitle)}
loading\u2026
` } // ── 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 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 * /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 ${esc(DISPLAY_TITLE)} plugin studio ${bakedToken ? ` \n` : ''}${DEST_CONFIG.apiBaseUrl ? ` \n` : ''} ` } /** 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 * 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)} ${peerBlock}
${tokenFree ? 'loading…' : 'loading token…'}
${apiScript} ` } /** * Standalone profile page — the base parameterized profile (no preset props). * Lives at //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 ///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, // MERGE, never replace (go-int: a preset lost its profile's pinHint:false): the profile's // .meta.json `config` first, then the instance's props over it. The rule is stated in // documentation/PRESET-PLUGIN-STANDARD.md and asserted by check:preset-config-merge. config: { assetsBase: assetsBaseFor(4), ...(profile.extraConfig || {}), ...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 .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 = `` 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 = [] // .meta.json `visibility` values this generator does not recognise. Collected across the // whole scan and reported together below, so an author fixing three typos sees three, not one. const visibilityErrors = [] for (const entry of readdirSync(APP_PROFILES).sort()) { const dir = join(APP_PROFILES, entry) if (!isBundleDir(entry) || !statSync(dir).isDirectory()) continue // Not a web plugin (plugin-targets.mjs: targets without "web", e.g. Rudder): no leaf pages, no manifest // entry. desktop:build packages it. if (!isWebBuilt(dir)) 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 //