/** * generate-gallery.mjs — THE plugin gallery: one generator, two modes. * (No shebang on purpose: vite.config.js imports this module through Vite's config bundler, * which prepends a shim to every bundled file — a shebang below line 1 is a syntax error there.) * * renderGallery(model, { mode: 'build' }) public/__gallery/index.html — ARCHIVED beside the * root, written by `bun run gallery` (last step of * `bun run build`) from public//manifest.json. * renderGallery(model, { mode: 'dev' }) what `bun run dev` serves at /__gallery — the same * page over a manifest-shaped model of plugin-designs/ * (see internal/plugin-gallery/dev-plugin.mjs). * * ★ THIS IS NO LONGER THE ROOT PAGE (2026-09-22). In BOTH trees `/` now mounts , * the cap-5 developer studio; this gallery is the archived bundle index at /__gallery. The module * stays because it is load-bearing, not for the page alone: the dev plugin imports renderGallery() * and readBuildConfig(), and it computes the manifest-shaped model both trees render. * * public/ holds assets/ (data), / (the bundle folders + manifest.json), the generated root * page (the studio host, written by internal/generate-tree.mjs) and __gallery/ — this page, which * lists every bundle → profile → instance the manifest declares and * mounts the chosen one. It is GENERATED — never hand-edited — as a single static file: inline * CSS, inline vanilla JS, every path relative, so the same file works from any static host, * under a subpath (the website serves it at /embed/doxa-maps-build/index.html) and from file://. * * At runtime the page fetches /manifest.json so it stays correct when bundles change; a * compact copy of the manifest is baked in as the fallback (file://, or the fetch fails). Embed * snippets are re-derived from the manifest fields with the template generate-tree uses; this * script checks the derivation against every manifest snippet and bakes any that differ, so the * fallback is exact without carrying the snippets twice. * * The chosen map mounts exactly like its leaf page: same peer engine (Mapbox, or MapLibre for a * provider:'maplibre' instance), same profile-config (assetsBase, dataSource, instanceId or the * instance props), window.MAP_APP_API_URL, and the same token ladder — profile-config tk → * ?tk= on this page's URL → the paste-once localStorage token → baked token → window.MAP_TOKEN_URL * → the configured tokenUrl → same-origin /api/maps/token → ask once. * * Deep link: #//[/]. Actions: Open page (the leaf page), Copy embed * (the manifest's embed.iframe / embed.script). * * What mode 'dev' changes — and nothing else: the loader imports the bundle's dev entry * (scriptUrl = /plugin-designs//index.js, an ES module Vite serves) instead of adding an * IIFE script tag; the live model is fetched from the dev route ( = __plugin-gallery); and * the header carries the composability ladder with its "?" popovers (kind explanations + the * "Start here" primer). The popovers stay out of the built page on purpose: they add ~8 KB of * text to a 22 KB page and explain the source tree, which a visitor of the served root never sees. * * Usage: node internal/generate-gallery.mjs (`bun run gallery` — last step of `bun run build`) */ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs' import { join, resolve, dirname } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { PROFILE_TIER, TYPE_DISPLAY, detectProfileType } from './plugin-gallery/discover.mjs' import { TREE_NAME } from './_tree-name.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') // Composition tier → capability number (5 = app-shell/nested-mfe-spa … 1 = single-element). // The plugin studio is itself the nested-mfe-spa (capability 5) — the reserved app-shell key. // cap = 5 - PROFILE_TIER (nested-mfe-spa:0→5 · nested-elements-mfe:1→4 · monolithic:2→3 · // parameterized:3→2 · single:4→1). Unknown → 1 (single, the safe floor). export const CAP_OF = (type) => (PROFILE_TIER[type] != null ? 5 - PROFILE_TIER[type] : 1) // Build-mode: the manifest carries no composition tier, so read it from source the way the // dev discovery does (explicit .meta.json → an *.instances.json means parameterized → // single). Returns { "/": "" }. Pure best-effort: a missing tree // or unreadable folder just yields {} and every profile falls back to capability 1. export function readSourceTiers(appDir = join(ROOT, 'plugin-designs')) { const out = {} if (!existsSync(appDir)) return out let dirs = [] try { dirs = readdirSync(appDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { return out } for (const bundle of dirs) { const dir = join(appDir, bundle) let files = [] try { files = readdirSync(dir) } catch { continue } const hasInstances = files.some((f) => /\.instances\.json$/i.test(f)) for (const f of files) { if (!f.endsWith('.vue')) continue const profile = f.replace(/\.vue$/, '') try { const t = detectProfileType(dir, profile, '', [], hasInstances) out[bundle + '/' + profile] = t.type } catch { /* leave unlisted → capability 1 */ } } } return out } // Same fields the tree generator bakes into every leaf page (vite.doxa-maps-build-config.json). export function readBuildConfig() { let cfg = {} try { cfg = JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')) } catch { /* defaults */ } const str = (k) => (typeof cfg[k] === 'string' ? cfg[k].trim() : '') let tokenUrl = str('tokenUrl') if (!tokenUrl) { try { const src = JSON.parse(readFileSync(join(ROOT, 'library/api/sources.json'), 'utf8')) const v = src?._meta?.mapboxTokenUrl || src?.mapboxTokenUrl if (typeof v === 'string' && v.trim()) tokenUrl = v.trim() } catch { /* fall through */ } } return { name: str('name') || TREE_NAME, title: str('displayTitle') || TREE_NAME, tokenUrl: tokenUrl || 'window.MAP_TOKEN_URL', token: str('token'), apiBaseUrl: str('apiBaseUrl').replace(/\/+$/, ''), assetsBaseUrl: str('assetsBaseUrl').replace(/\/+$/, ''), qaWidget: cfg.qaWidget !== false, } } // ── Shared with the page (injected by source, so Node and the browser run the same code) ── // The embed snippets exactly as generate-tree writes them into the manifest: profile → iframe + // script, instance → iframe only. b/p/x are the compact records below. function snip(b, p, x) { var u = (x || p).u, tag = (x && x.e) || p.e || b.e[0] || b.b var r = { iframe: '' } if (!x) r.script = '' — inside an inline // script that would end the element early, so the escape is restored here (a no-op under node). const src = (fn) => fn.toString().replace(/<\/script/gi, '<\\/script') // ── mode 'dev' only: the composability ladder + its "?" popovers ──────────────────────────── // Vocabulary is the validated set from documentation/reference.md "Profile types": head words are // established terms (custom element, preset, multi-view, composite, app shell); the coined words // stay as a.k.a. Popover strings are HTML (innerHTML) — tags are escaped. const LADDER_CSS = String.raw`:root{--acc:#1a73e8}@media(prefers-color-scheme:dark){:root{--acc:#64b5f6}} .ladder{position:relative;margin-left:auto;display:flex;align-items:center;gap:6px;white-space:nowrap} .rung{display:inline-flex;align-items:center;gap:4px}.rname{font:500 12px/1 ui-monospace,Menlo,monospace} .arrow{color:var(--mut);font-size:12px;padding:0 1px} .q{width:18px;height:18px;padding:0;border-radius:50%;font:700 11px/1 system-ui,sans-serif;color:var(--mut);background:var(--bg);display:inline-grid;place-items:center} .start{font:600 10.5px/1 system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;color:var(--acc);background:var(--bg);border-radius:999px;padding:4px 9px;margin-right:4px} .q:hover,.q[aria-expanded=true],.start:hover,.start[aria-expanded=true]{color:var(--acc);border-color:var(--acc);background:var(--bg)} .explain{position:absolute;top:calc(100% + 8px);right:0;width:420px;max-width:calc(100vw - 32px);max-height:70vh;overflow:auto;z-index:40;background:var(--bg);border:1px solid var(--bd);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.14);padding:12px 14px;font-size:13px;line-height:1.45;text-align:left;white-space:normal} .explain h3{margin:0 0 5px;font-size:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.explain h4{margin:8px 0 3px;font-size:13px} .explain .tier{font:700 10px/1 system-ui,sans-serif;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)} .explain p{margin:0 0 6px}.explain .aka{font-size:11.5px;color:var(--mut);margin:-2px 0 6px} .explain .how{margin:0 0 6px;font-size:12.5px}.explain .how summary{cursor:pointer;color:var(--acc);font-weight:600;font-size:12px}.explain .how p{margin:6px 0 0;color:var(--mut)} .explain .ex{color:var(--mut);font-size:12px} @media(max-width:1280px){.ladder{display:none}} ` const LADDER_HTML = '' const LADDER_JS = String.raw` ` /** * The whole page from a manifest-shaped model (schema doxa-maps-manifest@1: bundles → profiles → * instances, with scriptUrl / iframeUrl / embed / integrity). Pure: no I/O, no globals. * * @param {object} model the manifest (build) or the dev model (dev-plugin.mjs galleryModel) * @param {object} opts mode 'build' | 'dev'; title; tree — the folder the live manifest is * fetched from (/manifest.json) and that relative refs resolve * against (build: the tree name; dev: the route prefix, refs absolute); * tokenUrl / token / apiBaseUrl / assetsBaseUrl / qaWidget — the build * config fields every leaf page bakes (readBuildConfig) */ export function renderGallery(model, opts = {}) { const { mode = 'build', title = TREE_NAME, tree = TREE_NAME, tokenUrl = 'window.MAP_TOKEN_URL', token = '', apiBaseUrl = '', assetsBaseUrl = '', qaWidget = true, tierMap = {} } = opts const dev = mode === 'dev' const baked = compact(model, true, tierMap) const json = (v) => JSON.stringify(v).replace(/ String(s).replace(/&/g, '&').replace(/ ${esc(title)}
${esc(title)}{{ countLabel }}${dev ? LADDER_HTML : ''}
{{ ttl }}{{ sub }}
Open page
Choose a map from the list.
${dev ? LADDER_JS : ''} ` } // ── CLI: the ARCHIVED gallery, public/__gallery/index.html ────────────────────────────────── // // ★ THE ROOT PAGE IS THE STUDIO NOW; THE GALLERY IS ARCHIVED BESIDE IT (2026-09-22). // DRIVER: "We need to back up and ARCHIVE the stuff that is old. Instead of editing it, just back // it up and archive it... 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 BUILT tree now behaves exactly like the DEV server (internal/plugin-gallery/dev-plugin.mjs, // which made this move first): `/` mounts , and the gallery stays reachable at the // explicit /__gallery for anyone who wants a bundle index. Identical routes in both trees is the // point — a developer who learns `/` in dev must not find a different product in the built tree. // // ARCHIVED, NOT DELETED — deliberately. This module is NOT dead code and must not be removed: // it also exports renderGallery()/readBuildConfig(), which the dev plugin imports, and it computes // the manifest-shaped model. Deleting it breaks `bun run dev` and `bun run build`. So the page it // writes simply MOVES one level down; nothing about how it is built changes. // // WHY `tree` GAINS A '../': the gallery's runtime refs (rel() and the two manifest fetches) are // RELATIVE to the page. Moving the page from public/index.html into public/__gallery/index.html // puts it one level deeper, so the same string must climb back out to reach public//. // Without this the archived page fetches /__gallery/doxa-maps/manifest.json and renders EMPTY — // the identical failure mode the studio hit in dev when its tree-base was missing. function main() { const CFG = readBuildConfig() const manifestPath = join(ROOT, 'public', CFG.name, 'manifest.json') if (!existsSync(manifestPath)) { console.error(`[generate-gallery] missing ${manifestPath} — run \`bun run tree\` first`); process.exit(1) } const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) const tierMap = readSourceTiers() // "/" → composition tier (the manifest carries none) const html = renderGallery(manifest, { mode: 'build', title: CFG.title, tree: '../' + CFG.name, tokenUrl: CFG.tokenUrl, token: CFG.token, apiBaseUrl: CFG.apiBaseUrl, assetsBaseUrl: CFG.assetsBaseUrl, qaWidget: CFG.qaWidget, tierMap }) const baked = compact(manifest, true, tierMap) const nProfiles = baked.reduce((n, b) => n + b.p.length, 0) const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0) const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0) // ARCHIVED OUT OF public/ (the agent decision, card BUILD-OUTPUT-IS-ONE-FOLDER): public/ holds only the // output folder + README.md, and the studio at /index.html is the front page. The last // generated copy is kept in the umbrella's internal-tracker/archive/. This still renders on request, into a // directory you name — never into public/. const oi = process.argv.indexOf('--out') const outDir = oi >= 0 && process.argv[oi + 1] ? resolve(process.argv[oi + 1]) : null if (!outDir) { console.log('[generate-gallery] ARCHIVED — not written. The gallery is no longer part of the build or of public/;') console.log(' the Plugin Studio (/index.html) is the front page. To render it anyway: bun run gallery -- --out ') return } if (outDir === resolve(ROOT, 'public') || outDir.startsWith(resolve(ROOT, 'public') + '/')) { console.error('[generate-gallery] refusing to write into public/ — it holds only the output folder.'); process.exit(1) } const dir = outDir mkdirSync(dir, { recursive: true }) writeFileSync(join(dir, 'index.html'), html) console.log(`[generate-gallery] ${dir}/index.html (ARCHIVED rendering) — ${(html.length / 1024).toFixed(1)} KB · ${baked.length} bundle(s) · ${nProfiles} profile(s) · ${nInstances} instance(s) · ${nOverrides} embed snippet(s) baked verbatim (the rest re-derive)`) } // Runs only as a script; importing it (the dev plugin does) has no side effects. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()