doxa-maps/internal/plugin-gallery/dev-plugin.mjs
doxa-maps-88655c70 50937cf766
Some checks are pending
build-gate / build (push) Waiting to run
Plugin studio embeds real elements, and a rooted URL is no longer double-prefixed
EVERY TAB FAILED TO LOAD. Introducing tree-base for the dev server prefixed it onto
URLs that were already rooted, producing /__plugin-gallery//plugin-designs/<b>/index.js
and a 404 on all 29 tabs. Both capability-5 shells had byte-identical resolve()
implementations, so both carried the same defect; both now return an already-absolute
ref untouched and join through exactly one slash. The built tree hands relative refs
and keeps its base, so this is one rule for both trees rather than a dev-only branch.

THE STUDIO NO LONGER FRAMES ANYTHING. Map elements were nested in iframes pointing at
their standalone leaf pages, to dodge a one-shot customElements.define collision. That
collision cannot occur here: 19 bundles claim 19 distinct tags, none shared, and two map
elements were proven to coexist in one document with the first unaffected by the second.
Every pane now mounts the plugin's real custom element, which is what makes a row both
reviewable and nestable.

Panes keep two lifetimes, and the difference is the WebGL context budget rather than
taste. Light rows stay mounted so switching back is instant and state survives. Map rows
are destroyed on deactivate, because a hidden map holds its GPU context: the browser
caps live contexts, then silently reclaims the OLDEST, so a map visited ten tabs ago
would go blank with no error anywhere. getContext never returns null and contextlost
never fires on the new canvas, which is what makes that failure so hard to attribute.

The dev server serves a leaf route for one element with no chrome, and passes the tree
base to nested shells so they discover the live tree instead of a baked fallback.
2026-09-22 14:02:20 +00:00

279 lines
18 KiB
JavaScript

/**
* dev-plugin.mjs — the Vite dev-only plugin that serves the plugin gallery.
*
* Routes: GET / the plugin gallery: internal/generate-gallery.mjs
* renderGallery(model, { mode: 'dev' }) over
* galleryModel(discoverSpaRegistry())
* GET /__plugin-gallery/manifest.json that model, manifest-shaped (the page fetches it live)
* GET /__plugin-gallery/registry.json the discovery registry, machine-readable (smoke tools)
* GET /assets/<rel> shared asset root (see resolveDevAsset)
* Wire-up: plugins: [vue(), cssInjectedByJsPlugin(), autoSpaDevPlugin(token)] in vite.config.js.
* Verify: bun run smoke:gallery (internal/plugin-gallery-smoke.mjs — every element must boot)
*/
import { resolve, extname } from 'node:path'
import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs'
import { REPO_ROOT, discoverSpaRegistry } from './discover.mjs'
import { readBuildConfig, renderGallery } from '../generate-gallery.mjs'
// ─── DEV plugin gallery ──────────────────────────────────────────────────────
// `bun x vite` (dev) serves the plugin gallery at `/` — the same page `bun run build`
// writes to public/index.html — over every profile DISCOVERED across plugin-designs/*/.
// Choosing a map lazy-imports that bundle's dev entry (registering its custom element)
// and mounts it, with the dev Mapbox token (VITE_MAPBOX_TOKEN) as the baked rung of
// the token ladder so maps just work. Dev-only (apply:'serve'); the build is untouched.
// Shared asset root for the dev plugin gallery. Data-driven profiles (connectivity-map,
// resources-map, cities-map, poster walls, tables) fetch `assetsBase + '/tables/*.json'`
// and `'/boundaries/*.geojson'`. publicDir is false ON PURPOSE (a bundle build must never
// inline public/), so dev has to serve the asset tree explicitly, the way the built site
// does: public/assets first (exactly what generate-tree ships), else the per-bundle source
// of truth plugin-designs/<bundle>/data/<rel> when public/ has been cleaned.
const DEV_ASSET_MIME = {
'.json': 'application/json; charset=utf-8', '.geojson': 'application/geo+json; charset=utf-8',
'.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.csv': 'text/csv; charset=utf-8',
'.txt': 'text/plain; charset=utf-8', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml',
'.png': 'image/png', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.woff2': 'font/woff2', '.woff': 'font/woff', '.pdf': 'application/pdf',
}
export function resolveDevAsset(rel, root = REPO_ROOT) {
const isFile = (p) => existsSync(p) && statSync(p).isFile()
const shipped = resolve(root, 'public', 'assets', rel)
if (isFile(shipped)) return shipped
const profiles = resolve(root, 'plugin-designs')
for (const name of readdirSync(profiles)) {
if (name.startsWith('_') || name.startsWith('.')) continue
const cand = resolve(profiles, name, 'data', rel)
if (isFile(cand)) return cand
}
return null
}
// The manifest-shaped model (schema doxa-maps-manifest@1: bundles → profiles → instances)
// the gallery renders, from the discovery registry. Bundles and profiles are ordered
// alphabetically, as generate-tree orders the manifest; a preset profile keeps its own
// row above its instances, exactly as the built page lists it.
// scriptUrl the bundle's dev entry — the page imports it (no IIFE exists under the dev server)
// iframeUrl this gallery's own deep link — the only page under the dev server that shows
// exactly this map (the built leaf pages do not exist here); Open page and the
// maps' Share button use it, so a shared link reopens the same map
// embed the snippets the build will write (generate-tree's template, tree-relative;
// integrity only exists after a build), so Copy embed previews what ships
export function galleryModel(reg) {
const iframe = (u) => `<iframe src="${u}?utm_source=YOUR_SOURCE" width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>`
// A REAL DEV LEAF, not a hash deep-link into the gallery.
//
// THIS USED TO BE `'/#/' + parts.join('/')` — a hash route into the gallery PAGE. That was
// coherent while `/` WAS the gallery, but the studio nests map profiles in a lazy iframe pointed
// at exactly this URL (plugin-studio.vue:25, mode 'iframe'), so every map tab was iframing the
// gallery. The Driver saw the staging blurb — "One shared core, one build, many paste-anywhere
// maps..." — rendered INSIDE each plugin pane, and reported that the studio "is still loading
// HTMLs for each plugin with this text." It was: the gallery, recursively, once per map tab.
//
// `/__leaf/<bundle>/<profile>[/<instance>]` instead serves a minimal page that mounts ONE element
// and nothing else — the dev equivalent of the built tree's <bundle>/<profile>/index.html, which
// is what this URL means in production. Same contract, so the studio behaves identically in both.
const link = (...parts) => '/__leaf/' + parts.map(encodeURIComponent).join('/')
const bundles = [...reg].sort((a, b) => a.bundle.localeCompare(b.bundle)).map((b) => {
const scriptUrl = `/plugin-designs/${b.bundle}/index.js`
const presets = new Map() // profile → its preset instances (empty for every other kind)
const typeOf = new Map() // profile → composition tier machine key (single … nested-mfe-spa)
for (const it of b.items) {
if (!presets.has(it.profile)) presets.set(it.profile, [])
if (!typeOf.has(it.profile)) typeOf.set(it.profile, it.type)
if (it.type === 'parameterized') presets.get(it.profile).push(it)
}
const profiles = [...presets.keys()].sort().map((profile) => {
const p = {
profile, file: `${profile}.vue`, description: '', element: b.tag,
type: typeOf.get(profile) || 'single',
iframeUrl: link(b.bundle, profile), scriptUrl,
embed: {
iframe: iframe(`./${b.bundle}/${profile}/index.html`),
script: `<script src="./${b.bundle}/${b.bundle}.js"></script>\n<${b.tag} profile-config='${JSON.stringify({ profile, tk: 'YOUR_MAPBOX_TOKEN', utmSource: 'YOUR_SOURCE' })}'></${b.tag}>`,
},
}
const instances = presets.get(profile).map((it) => ({
id: it.id, title: it.label, apiBaseUrl: it.apiBaseUrl || '',
props: { profile, ...(it.props || {}) }, element: b.tag,
iframeUrl: link(b.bundle, profile, it.id),
embed: { iframe: iframe(`./${b.bundle}/${profile}/${it.id}/index.html`) },
}))
if (instances.length) p.instances = instances
return p
})
return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], profiles }
})
return {
schema: 'doxa-maps-manifest@1', generatedFrom: 'plugin-designs/*/*.vue (dev discovery)', embedBase: '.',
bundleCount: bundles.length, profileCount: bundles.reduce((n, b) => n + b.profiles.length, 0), bundles,
}
}
export function autoSpaDevPlugin(token = '') {
// One generator for both galleries (internal/generate-gallery.mjs), driven by the same build
// config the built page bakes. What dev changes: the model comes from discovery, the live
// model route stands in for <tree>/manifest.json, shared assets come from this server's
// /assets route, and the .env token is the baked rung of the token ladder.
const page = () => {
const cfg = readBuildConfig()
return renderGallery(galleryModel(discoverSpaRegistry()), {
mode: 'dev', title: cfg.title, tree: '__plugin-gallery', tokenUrl: cfg.tokenUrl, token: token || cfg.token,
apiBaseUrl: cfg.apiBaseUrl, assetsBaseUrl: '', qaWidget: cfg.qaWidget,
})
}
const json = (res, body, status = 200) => {
res.statusCode = status
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache')
res.end(JSON.stringify(body))
}
return {
name: 'doxa-auto-spa',
apply: 'serve',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const path = (req.url || '').split('?')[0]
// /assets/* — see resolveDevAsset. A miss is a REAL 404, never Vite's HTML
// fallback: HTML-for-JSON made three maps die silently at JSON.parse before
// they ever created a map, with no console error and no error card.
if (path.startsWith('/assets/')) {
let rel = ''
try { rel = decodeURIComponent(path.slice('/assets/'.length)) } catch { rel = '' }
if (!rel || rel.split('/').includes('..')) { res.statusCode = 400; return res.end('bad asset path') }
const file = resolveDevAsset(rel)
if (!file) return json(res, { error: 'asset not found', asset: rel, looked: ['public/assets/', 'plugin-designs/*/data/'] }, 404)
res.statusCode = 200
res.setHeader('Content-Type', DEV_ASSET_MIME[extname(file).toLowerCase()] || 'application/octet-stream')
res.setHeader('Cache-Control', 'no-cache')
return res.end(readFileSync(file))
}
// Machine-readable, the same discovery the page renders: the registry (kinds + items,
// read by the smoke tools) and the manifest-shaped model (what the page fetches live).
if (path === '/__plugin-gallery/registry.json' || path === '/__plugin-gallery/manifest.json') {
let reg
try { reg = discoverSpaRegistry() } catch (e) { return json(res, { error: String(e) }, 500) }
return json(res, path.endsWith('/manifest.json') ? galleryModel(reg) : reg)
}
// ── `/__leaf/<bundle>/<profile>[/<instance>]` — ONE element, nothing else ────────────
// The dev stand-in for the built tree's <bundle>/<profile>/index.html. The studio's iframe
// panes point here (see the link() note above); without it they fell through to Vite's SPA
// fallback and rendered `/` — i.e. the whole studio/gallery — inside every map tab.
// Deliberately minimal: no chrome, no prose, no gallery. Just the tag, its bundle entry, and
// the baked token, so what mounts here is the element and only the element.
if (path.startsWith('/__leaf/')) {
const parts = path.slice('/__leaf/'.length).split('/').filter(Boolean).map(s => {
try { return decodeURIComponent(s) } catch { return '' }
})
const [bundle, profile, instance] = parts
// Path traversal guard: these land in a filesystem-ish URL below.
if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) {
res.statusCode = 400; return res.end('bad leaf path')
}
let reg
try { reg = discoverSpaRegistry() } catch (e) { return json(res, { error: String(e) }, 500) }
const entry = reg.find(b => b.bundle === bundle)
if (!entry) return json(res, { error: 'unknown bundle', bundle }, 404)
const tag = entry.tag
let leafTok = token
if (!leafTok) { try { leafTok = (readBuildConfig() || {}).token || '' } catch { leafTok = '' } }
// profile-config carries the same shape the built leaves pass, so a profile/instance
// renders the SAME view here as in production rather than the bundle's default.
const pc = JSON.stringify(instance ? { profile, instanceId: instance } : { profile })
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache')
return res.end([
'<!DOCTYPE html>',
`<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}</title>`,
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
'<link rel="icon" href="data:,">',
leafTok ? `<script>window.MAP_TOKEN=${JSON.stringify(leafTok)};</script>` : '',
'<style>html,body{margin:0;padding:0;height:100%;background:#f3f3f1;}',
`${tag}{display:block;width:100%;height:100vh;}</style>`,
'</head><body>',
// tree-base ON THE LEAF TOO — cap-5 shells nested here fetch a manifest of their own.
// doxa-maps-spa (like plugin-studio) discovers its tabs from the tree's manifest.json and
// resolves it RELATIVE to its own page when no base is given. On a /__leaf/ URL that
// resolved to /__leaf/manifest.json and 400'd. Harmless (both shells carry a baked
// fallback list) but it silently degrades the tab list to the fallback instead of the
// live 19-bundle tree. Passing the base makes a nested shell see exactly what the top
// level sees. Elements that ignore tree-base simply drop an unknown attribute.
`<${tag} tree-base="/__plugin-gallery" profile-config='${pc}'></${tag}>`,
`<script type="module" src="/plugin-designs/${bundle}/index.js"></script>`,
'</body></html>',
].filter(Boolean).join('\n'))
}
// ── `/` MOUNTS THE PLUGIN STUDIO, NOT THE STAGING GALLERY ────────────────────────────
// DRIVER DIRECTION 2026-09-22: "when I run dev server on the dx maps it should just open up
// a plugin studio that gives you access to all the plugins in one place, without a lot of
// text garb that takes up so much space" — and, separately, "we do not need the extra
// generated staging environment because the plugin studio is replacing these index html."
//
// WHY THIS ROUTE HAD TO EXIST AT ALL. The dev server previously served ONLY the gallery at
// `/`, and had no route for a bundle leaf — so `bun run dev` could not show the cap-5 studio
// by any URL. The Driver opened `/`, saw plugins wrapped in per-bundle frames behind a wall
// of explanatory prose, and reasonably concluded "none of the elements are working". They
// were working; the page was a CHOOSER presenting itself as the product.
//
// WHY MOUNT THE TAG RATHER THAN REDIRECT TO A LEAF: plugin-studio is a real cap-5
// defineCustomElement element, so the honest dev surface is the element itself, loaded
// through its own index.js — the same path the built bundle takes. That keeps HMR working
// (Vite transforms the .vue on demand) and keeps registerStrategies/profileModules/Pinia
// intact, which baking or iframing would bypass.
//
// The gallery is NOT deleted — it is still generated for the built tree and is still
// load-bearing for the manifest. It simply stops being what a developer lands on. It stays
// reachable at an explicit URL for anyone who wants the bundle index.
if (path === '/' || path === '/index.html') {
// Same resolution order the gallery uses: the .env/VITE_MAPBOX_TOKEN value passed into
// autoSpaDevPlugin(token), else the build config's baked token. readBuildConfig() is
// called HERE rather than reusing page()'s `cfg` — that const lives inside the page()
// arrow function, so it is not in scope in this middleware (an earlier version referenced
// it and the dev server 500'd with "cfg is not defined").
let devTok = token
if (!devTok) { try { devTok = (readBuildConfig() || {}).token || '' } catch { devTok = '' } }
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache')
return res.end([
'<!DOCTYPE html>',
'<html lang="en"><head><meta charset="UTF-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
'<title>doxa-maps plugin studio</title>',
'<link rel="icon" href="data:,">',
// The token ladder's baked rung, exactly as the built leaves receive it, so maps paint
// in dev without anyone appending ?tk=.
devTok ? `<script>window.MAP_TOKEN=${JSON.stringify(devTok)};</script>` : "",
// No chrome, no prose: the element fills the viewport. "Without a lot of text garb
// that takes up so much space" is a layout requirement, so the host page contributes
// ZERO text — every pixel belongs to the studio element.
'<style>html,body{margin:0;padding:0;height:100%;background:#f3f3f1;}',
'plugin-studio{display:block;width:100%;height:100vh;}</style>',
'</head><body>',
// tree-base IS REQUIRED IN DEV — without it the studio mounts EMPTY.
// The studio builds its tab list at runtime by fetching `<base>manifest.json`, and with
// no base it falls back to '../manifest.json' RELATIVE TO ITS SCRIPT URL. In dev the
// script is /plugin-designs/plugin-studio/index.js, so that resolves to
// /plugin-designs/manifest.json — which does not exist. The shell then renders with zero
// rows: 31 shadow descendants, no nested plugin tags. (Measured exactly that before this
// line existed; the symptom reads as "no elements in the studio", not as a failed fetch.)
// The dev server's manifest lives on its own route instead, serving the SAME
// manifest-shaped model the built tree bakes (19 bundles / 30 profiles), so pointing the
// studio at it gives dev the same tab list production has.
'<plugin-studio tree-base="/__plugin-gallery"></plugin-studio>',
'<script type="module" src="/plugin-designs/plugin-studio/index.js"></script>',
'</body></html>',
].filter(Boolean).join('\n'))
}
// The staging gallery, kept but DEMOTED to an explicit opt-in URL.
if (path !== '/__gallery' && path !== '/__gallery/') return next()
let html
try { html = page() }
catch (e) { console.error(`[plugin-gallery] ${e && e.stack ? e.stack : e}`); return next() }
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(html)
})
},
}
}