doxa-maps/internal/plugin-gallery/dev-plugin.mjs
swift-river542 9cefd6faa4 Plugin gallery: one generator for the dev server and the build
- internal/playground → internal/plugin-gallery; playground-smoke → plugin-gallery-smoke;
  generate-spa → generate-gallery (scripts: gallery, smoke:gallery; smoke:playground stays as alias)
- generate-gallery exports renderGallery(model, { mode }) — the dev plugin serves the same page
  at / from the discovered registry (dev mode imports the dev entries, ladder popovers on);
  the build writes public/index.html from the manifest (byte-identical to before)
- Docs and probes use the gallery vocabulary
2026-09-16 20:04:18 -05:00

152 lines
8.9 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 app-profiles/*/.
// 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 app-profiles/<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, 'app-profiles')
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>`
const link = (...parts) => '/#/' + parts.map(encodeURIComponent).join('/')
const bundles = [...reg].sort((a, b) => a.bundle.localeCompare(b.bundle)).map((b) => {
const scriptUrl = `/app-profiles/${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: `<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: 'app-profiles/*/*.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/', 'app-profiles/*/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)
})
},
}
}