diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 337fe7cf3..cb5d45d1d 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -60,7 +60,19 @@ export function resolveDevAsset(rel, root = REPO_ROOT) { // 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('/') + // 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//[/]` instead serves a minimal page that mounts ONE element + // and nothing else — the dev equivalent of the built tree's //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) @@ -142,7 +154,112 @@ export function autoSpaDevPlugin(token = '') { 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() + // ── `/__leaf//[/]` — ONE element, nothing else ──────────── + // The dev stand-in for the built tree's //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([ + '', + `${bundle} · ${profile}`, + '', + '', + leafTok ? `` : '', + '`, + '', + `<${tag} profile-config='${pc}'>`, + ``, + '', + ].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([ + '', + '', + '', + 'doxa-maps plugin studio', + '', + // The token ladder's baked rung, exactly as the built leaves receive it, so maps paint + // in dev without anyone appending ?tk=. + devTok ? `` : "", + // 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. + '', + '', + // tree-base IS REQUIRED IN DEV — without it the studio mounts EMPTY. + // The studio builds its tab list at runtime by fetching `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. + '', + '', + '', + ].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() }