152 lines
8.9 KiB
JavaScript
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 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>`
|
|
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: `<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)
|
|
}
|
|
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)
|
|
})
|
|
},
|
|
}
|
|
}
|