/** * 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/ 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//data/ 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) => `` const link = (...parts) => '/#/' + 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) for (const it of b.items) { if (!presets.has(it.profile)) presets.set(it.profile, []) 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, iframeUrl: link(b.bundle, profile), scriptUrl, embed: { iframe: iframe(`./${b.bundle}/${profile}/index.html`), script: `\n<${b.tag} profile-config='${JSON.stringify({ profile, tk: 'YOUR_MAPBOX_TOKEN', utmSource: 'YOUR_SOURCE' })}'>`, }, } 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 /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) } if (path !== '/' && path !== '/index.html') 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) }) }, } }