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
This commit is contained in:
parent
60ad81bf5f
commit
9cefd6faa4
21 changed files with 403 additions and 459 deletions
|
|
@ -32,7 +32,7 @@ Full walkthrough + the migration story:
|
|||
|
||||
| Command | What | Writes to `public/`? |
|
||||
|---|---|---|
|
||||
| `bun run dev` | HMR playground at `localhost:5173` — iterate on a map in isolation | **No** |
|
||||
| `bun run dev` | HMR plugin gallery at `localhost:5173` — iterate on a map in isolation | **No** |
|
||||
| `bun run build` | every bundle → `public/doxa-maps/<bundle>/<bundle>.js` + generated pages | **Yes** |
|
||||
|
||||
To see a change on the served site: `bun run build` (or `BUNDLE=<name> bun run build:bundle`),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ embeds on partner sites piece by piece. *Same code, every screen.*
|
|||
<p align="center"><em>One build, same code: the SPA in a desktop browser and on a phone — many maps, one place.</em></p>
|
||||
|
||||
- **One repo, one library** — every map shares components. Not fifty folders of duplicated spaghetti.
|
||||
- **`bun run dev`** → a live playground of every map, each with a copy‑paste embed snippet.
|
||||
- **`bun run dev`** → a live plugin gallery of every map, each with a copy‑paste embed snippet.
|
||||
- **`bun run build`** → every map ships as its own self‑contained embeddable file (script tag or iframe — WordPress included), and the same build feeds the SPA and the mobile app.
|
||||
|
||||
**You don't write the code.** Point your AI agent at the example maps, describe what you want in plain English, and it builds a new one on the shared library.
|
||||
|
|
@ -212,9 +212,9 @@ You need [**bun**](https://bun.sh) installed (`bun.lock` is committed, so instal
|
|||
```bash
|
||||
bun install # download node_modules (one time)
|
||||
|
||||
bun run dev # the playground: http://localhost:5173 (or the next free port — read the terminal)
|
||||
bun run dev # the plugin gallery: http://localhost:5173 (or the next free port — read the terminal)
|
||||
# an auto-generated SPA of every map, grouped by bundle and composition tier,
|
||||
# each deep-linkable; `bun run smoke:playground` proves every one of them boots
|
||||
# each deep-linkable; `bun run smoke:gallery` proves every one of them boots
|
||||
|
||||
bun run build # the build step: every app-profiles/<map>/ becomes
|
||||
# public/doxa-maps/<map>/<map>.js + a standalone index.html per map
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
"type": "nested-elements-mfe",
|
||||
"label": "UPG-100 — Map + Prayer Lists",
|
||||
"composes": ["my-upg-100-list/upg-100-list", "my-upg-100-list/poster-wall"],
|
||||
"note": "Composite MFE built on the pane-wrapper layout (imitates the shell's 'upg-100-list' panes: map at [0,0], Prayer Lists at [1,0]). Makes this bundle MFE-class (tier 1) in the playground. See eco-dx (internal): PS001-composition-model."
|
||||
"note": "Composite MFE built on the pane-wrapper layout (imitates the shell's 'upg-100-list' panes: map at [0,0], Prayer Lists at [1,0]). Makes this bundle MFE-class (tier 1) in the plugin gallery. See eco-dx (internal): PS001-composition-model."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,14 +42,15 @@ this repo's own; the coined names stay as a.k.a.):
|
|||
| **preset-element** (parameterized) | `parameterized` | one app profile + a small table of named presets; each row is a ready-made map with its settings baked in, with its **own embed page** and sidebar entry — same code, same tag | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }` or a bare array; the build writes one embed page per row at `<bundle>/<profile>/<id>/index.html` |
|
||||
| **multi-view-element** (monolithic) | `monolithic` | one app profile holding several screens you switch between — tabs or steps — in one `.vue`, shown one screen at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` (or `"multi-view-element"`) |
|
||||
| **composite-element** (nested-elements-mfe) | `nested-elements-mfe` | an app profile whose template places other app profiles' maps together (stacked or side by side, e.g. a map + a poster wall) and ships the set as one new element — one tag embeds it all | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` (or `"composite-element"`) |
|
||||
| **app-shell** (nested-mfe-spa) | `nested-mfe-spa` | the outer frame — sidebar, header, navigation — that loads any of the other kinds and switches between them without a reload; the only rung that is a **page**, not an element | today an HTML page (`layouts/app-wrapper-material/index.html`, the dev playground); the key is reserved for a future `.vue` shell |
|
||||
| **app-shell** (nested-mfe-spa) | `nested-mfe-spa` | the outer frame — sidebar, header, navigation — that loads any of the other kinds and switches between them without a reload; the only rung that is a **page**, not an element | today an HTML page (`layouts/app-wrapper-material/index.html`, the plugin gallery); the key is reserved for a future `.vue` shell |
|
||||
|
||||
Composite kinds are always **declared** in `<profile>.meta.json` (`{ "type": …, "label": … }`, either spelling —
|
||||
see `TYPE_ALIASES` in `internal/playground/discover.mjs`) — the build never guesses them from source (tag-scanning
|
||||
see `TYPE_ALIASES` in `internal/plugin-gallery/discover.mjs`) — the build never guesses them from source (tag-scanning
|
||||
produced false positives). Discovery is one pure module,
|
||||
`internal/playground/discover.mjs`; `bun run dev` renders its result as the **auto-generated SPA** (sidebar
|
||||
grouped by bundle, most-composed first, each element deep-linkable at `#/<bundle>/<id>`), and
|
||||
`bun run smoke:playground` proves every element actually boots inside it.
|
||||
`internal/plugin-gallery/discover.mjs`; `bun run dev` renders its result as the **plugin gallery** (the same
|
||||
generated page the build writes to `public/index.html` — see "Plugin gallery" under Build scripts: a list grouped by
|
||||
bundle, every map deep-linkable at `#/<bundle>/<profile>[/<instance>]`), and `bun run smoke:gallery` proves every
|
||||
element actually boots inside it.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ built bundle re-points to different data/colors/language with **no rebuild**.
|
|||
any other host must point it at a copy of `public/assets/`.
|
||||
|
||||
- **Share ladder** (`library/utils/embedBase.js`) — what the Share button links and iframes:
|
||||
`shareUrl` (a whole url, e.g. the playground's own deep link) > the profile's **tree-relative**
|
||||
`shareUrl` (a whole url, e.g. the plugin gallery's own deep link) > the profile's **tree-relative**
|
||||
embed page (`<bundle>/<profile>/index.html`, the `embedPath` prop or `shareEmbedPath`) joined to an
|
||||
embed base — `embedBaseUrl` > `window.MAP_EMBED_BASE_URL` > the root the built bundle derives from
|
||||
its **own script url** (two segments above `<bundle>/<bundle>.js`, so a dropped tree works on any
|
||||
|
|
@ -150,12 +151,12 @@ each bundle registers a **distinct** tag.
|
|||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `bun run dev` | Vite dev server + HMR (default `http://localhost:5173`, or the next free port — read the terminal). `/` is the **auto-generated SPA** (playground) of every element, built from `internal/playground/{discover,render,dev-plugin}.mjs`; every bundle is also served at its own html path. Dev-only routes: `/__playground/registry.json` (the registry), `/assets/*` (served from `public/assets/`, else `app-profiles/*/data/`). **Writes nothing to `public/`** — no HMR across that boundary. |
|
||||
| `bun run smoke:playground` | Opens every element of the auto-generated SPA by deep link in Firefox and proves it boots: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. `--json <file>` writes the evidence; exit 1 on any failure. The pre-handoff gate. |
|
||||
| `bun run smoke:share` | The share round-trip gate (Firefox): opens a host page (`--playground` = every canvas element of the dev SPA; `--url <page> --tag <element>` = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. `--json <file>`; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). |
|
||||
| `bun run dev` | Vite dev server + HMR (default `http://localhost:5173`, or the next free port — read the terminal). `/` is the **plugin gallery** of every element — `internal/generate-gallery.mjs` in mode `dev` over `internal/plugin-gallery/{discover,dev-plugin}.mjs`; every bundle is also served at its own html path. Dev-only routes: `/__plugin-gallery/manifest.json` (the manifest-shaped model the page fetches), `/__plugin-gallery/registry.json` (the discovery registry the smoke tools read), `/assets/*` (served from `public/assets/`, else `app-profiles/*/data/`). **Writes nothing to `public/`** — no HMR across that boundary. |
|
||||
| `bun run smoke:gallery` | Opens every element of the plugin gallery by deep link in Firefox and proves it boots: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. `--json <file>` writes the evidence; exit 1 on any failure. The pre-handoff gate. |
|
||||
| `bun run smoke:share` | The share round-trip gate (Firefox): opens a host page (`--plugin-gallery` = every canvas element of the dev plugin gallery; `--url <page> --tag <element>` = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. `--json <file>`; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). |
|
||||
| `bun run check:public-hygiene [<dir>] [--strict]` | Proves a served folder is fit to publish: scans for local paths, internal folder names, dev-server URLs, JWT-shaped tokens, `.env`/token files, source maps, documentation and staging/preview pages, and prints an inventory (bundles, leaf pages, assets by folder). Under `--strict` any root entry other than `index.html`, `assets/` and `doxa-maps/` is a finding ("outside the drop-in contract"). Exit 1 on a finding. Run it on the website's drop-in before any public push. |
|
||||
| `bun run sync:dropin <dest> [--from-config \| --only a,b] [--prune]` / `bun run deploy:dev` | Syncs the built `public/` into a host checkout (e.g. the website's `public/embed/doxa-maps-build/`). **The drop-in contract** lives in ONE place — `vite.doxa-maps-build-config.json` → `dropin.include`, exactly three root entries: `index.html` (the root page generated by `internal/generate-spa.mjs`, listing every bundle/profile/instance and mounting the chosen one), `assets/` (data) and `doxa-maps/` (the bundle folders + `manifest.json`). `--from-config` copies only those, `--prune` removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; `--protect a,b` keeps host-owned files; `--dry-run` first; prints a copied/unchanged/deleted/pruned summary and the resulting top-level tree. `deploy:dev` = `build` → sync `--from-config --prune` into the website (`DOXA_DROPIN_DEST` overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. |
|
||||
| `bun run build` | `clean` → one `vite build` per `app-profiles/*/` folder → `tree` → `check:drop` → `check:parse` → `verify` → `spa` (writes `public/index.html` from the manifest). |
|
||||
| `bun run sync:dropin <dest> [--from-config \| --only a,b] [--prune]` / `bun run deploy:dev` | Syncs the built `public/` into a host checkout (e.g. the website's `public/embed/doxa-maps-build/`). **The drop-in contract** lives in ONE place — `vite.doxa-maps-build-config.json` → `dropin.include`, exactly three root entries: `index.html` (the built plugin gallery — the root page generated by `internal/generate-gallery.mjs`, listing every bundle/profile/instance and mounting the chosen one), `assets/` (data) and `doxa-maps/` (the bundle folders + `manifest.json`). `--from-config` copies only those, `--prune` removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. `dropin.exclude` (same file) lists built files inside the allowlist that no page fetches — the full people-groups CSV, the tables only internal scripts read, the table index; they stay in `public/` for the scripts, are skipped by `--from-config` (counted as "excluded (not shipped)") and removed from the destination if a copy is there. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; `--protect a,b` keeps host-owned files; `--dry-run` first; prints a copied/unchanged/deleted/pruned/excluded summary and the resulting top-level tree. `deploy:dev` = `build` → sync `--from-config --prune` into the website (`DOXA_DROPIN_DEST` overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. |
|
||||
| `bun run build` | `clean` → one `vite build` per `app-profiles/*/` folder → `tree` → `check:drop` → `check:parse` → `verify` → `gallery` (writes `public/index.html`, the built plugin gallery, from the manifest). |
|
||||
| `bun run build:bundle` | Builds ONE bundle (needs `BUNDLE=<name>`). |
|
||||
| `bun run build:watch` | Rebuilds into `public/` on every change (integration loop). |
|
||||
| `bun run clean` | Wipes + recreates the `public/<name>/` output tree. |
|
||||
|
|
@ -163,6 +164,19 @@ each bundle registers a **distinct** tag.
|
|||
| `bun run verify` | Data-integrity + shipped-bundle + network-policy guards (fast, no browser). |
|
||||
| `bun run verify:full` | + rendered posters and live apps in Firefox. |
|
||||
|
||||
**Plugin gallery.** One generator, two modes. `internal/generate-gallery.mjs` exports
|
||||
`renderGallery(model, { mode })`, a pure function from a manifest-shaped model (bundles → profiles →
|
||||
instances, with `scriptUrl` / `iframeUrl` / `embed` / `integrity`) to the whole page: the list, the stage,
|
||||
`#/<bundle>/<profile>[/<instance>]` deep links, Open page, Copy embed, the token ladder, lazy engine load,
|
||||
the phone layout and the baked fallback. Mode `build` (`bun run gallery`) renders
|
||||
`public/doxa-maps/manifest.json` into `public/index.html` — the root page of the served web-root and,
|
||||
through the drop-in, the website's `/doxa-maps` page. Mode `dev` is what `bun run dev` serves at `/`: the
|
||||
same page over `galleryModel(discoverSpaRegistry())` (`internal/plugin-gallery/dev-plugin.mjs`), differing
|
||||
only in the loader (it imports `/app-profiles/<bundle>/index.js` instead of adding the built IIFE), the live
|
||||
model route (`/__plugin-gallery/manifest.json`), the `/assets/*` dev route and the composability ladder's
|
||||
"?" popovers in the header — kept out of the built page to keep it lean. Nothing about the gallery is
|
||||
written twice.
|
||||
|
||||
**How a build works.** `vite.config.js` is multi-entry by discovery: at build time it
|
||||
registers every `app-profiles/` subfolder that (a) doesn't start with `_`/`.` and (b) has
|
||||
**both** `index.html` and `index.js`. One `vite build` builds **exactly one** bundle, selected
|
||||
|
|
|
|||
|
|
@ -30,4 +30,4 @@
|
|||
13. **The stack** (name the real products) — Vite · Bun · Vue web components · MapLibre/Mapbox ·
|
||||
Capacitor · Forgejo (source-control-as-CDN). No lock-in — un-closable primitives.
|
||||
14. **Try it / take it** — the repo, the docs (why → tutorial → how-to → reference → architecture),
|
||||
the live staging playground. Deep-dive: point your agent at docs/architecture/.
|
||||
the live plugin gallery. Deep-dive: point your agent at docs/architecture/.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-spa.mjs — write public/index.html, the root page of the served web-root.
|
||||
* generate-gallery.mjs — THE plugin gallery: one generator, two modes.
|
||||
* (No shebang on purpose: vite.config.js imports this module through Vite's config bundler,
|
||||
* which prepends a shim to every bundled file — a shebang below line 1 is a syntax error there.)
|
||||
*
|
||||
* renderGallery(model, { mode: 'build' }) public/index.html — the root page of the served
|
||||
* web-root, written by `bun run gallery` (last step of
|
||||
* `bun run build`) from public/<tree>/manifest.json.
|
||||
* renderGallery(model, { mode: 'dev' }) what `bun run dev` serves at / — the same page over a
|
||||
* manifest-shaped model of app-profiles/ (see
|
||||
* internal/plugin-gallery/dev-plugin.mjs).
|
||||
*
|
||||
* public/ holds exactly three things: assets/ (data), <tree>/ (the bundle folders + manifest.json)
|
||||
* and this one page, which lists every bundle → profile → instance the manifest declares and
|
||||
|
|
@ -23,16 +31,23 @@
|
|||
* Deep link: #/<bundle>/<profile>[/<instance>]. Actions: Open page (the leaf page), Copy embed
|
||||
* (the manifest's embed.iframe / embed.script).
|
||||
*
|
||||
* Usage: node internal/generate-spa.mjs (last step of `bun run build`)
|
||||
* What mode 'dev' changes — and nothing else: the loader imports the bundle's dev entry
|
||||
* (scriptUrl = /app-profiles/<bundle>/index.js, an ES module Vite serves) instead of adding an
|
||||
* IIFE script tag; the live model is fetched from the dev route (<tree> = __plugin-gallery); and
|
||||
* the header carries the composability ladder with its "?" popovers (kind explanations + the
|
||||
* "Start here" primer). The popovers stay out of the built page on purpose: they add ~8 KB of
|
||||
* text to a 22 KB page and explain the source tree, which a visitor of the served root never sees.
|
||||
*
|
||||
* Usage: node internal/generate-gallery.mjs (`bun run gallery` — last step of `bun run build`)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// Same fields the tree generator bakes into every leaf page (vite.doxa-maps-build-config.json).
|
||||
function readBuildConfig() {
|
||||
export function readBuildConfig() {
|
||||
let cfg = {}
|
||||
try { cfg = JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')) } catch { /* defaults */ }
|
||||
const str = (k) => (typeof cfg[k] === 'string' ? cfg[k].trim() : '')
|
||||
|
|
@ -67,7 +82,7 @@ function snip(b, p, x) {
|
|||
// Manifest → compact registry: [{b,s,i,e,p:[{p,e,u,d,em?,in?:[{id,t,a,pr,e,u,em?}]}]}].
|
||||
// lean = drop every embed snippet the page can re-derive with snip() (the baked fallback);
|
||||
// the live manifest keeps its snippets verbatim.
|
||||
function compact(m, lean) {
|
||||
export function compact(m, lean) {
|
||||
return (m.bundles || []).map(function (src) {
|
||||
var b = { b: src.bundle, s: src.scriptUrl || '', i: src.integrity || '', e: src.elements || [], p: [] }
|
||||
b.p = (src.profiles || []).map(function (sp) {
|
||||
|
|
@ -86,25 +101,117 @@ function compact(m, lean) {
|
|||
return b
|
||||
})
|
||||
}
|
||||
// Function source for the page. The dev server loads this module through Vite's config bundler,
|
||||
// whose printer may turn the '<\/script>' literal above back into '</script>' — inside an inline
|
||||
// script that would end the element early, so the escape is restored here (a no-op under node).
|
||||
const src = (fn) => fn.toString().replace(/<\/script/gi, '<\\/script')
|
||||
|
||||
const CFG = readBuildConfig()
|
||||
const TREE = CFG.name
|
||||
const manifestPath = join(ROOT, 'public', TREE, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) { console.error(`[generate-spa] missing ${manifestPath} — run \`bun run tree\` first`); process.exit(1) }
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const baked = compact(manifest, true)
|
||||
const nProfiles = baked.reduce((n, b) => n + b.p.length, 0)
|
||||
const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0)
|
||||
const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0)
|
||||
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c')
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
// ── mode 'dev' only: the composability ladder + its "?" popovers ────────────────────────────
|
||||
// Vocabulary is the validated set from docs/reference.md "Profile types": head words are
|
||||
// established terms (custom element, preset, multi-view, composite, app shell); the coined words
|
||||
// stay as a.k.a. Popover strings are HTML (innerHTML) — tags are escaped.
|
||||
const LADDER_CSS = String.raw`:root{--acc:#1a73e8}@media(prefers-color-scheme:dark){:root{--acc:#64b5f6}}
|
||||
.ladder{position:relative;margin-left:auto;display:flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.rung{display:inline-flex;align-items:center;gap:4px}.rname{font:500 12px/1 ui-monospace,Menlo,monospace}
|
||||
.arrow{color:var(--mut);font-size:12px;padding:0 1px}
|
||||
.q{width:18px;height:18px;padding:0;border-radius:50%;font:700 11px/1 system-ui,sans-serif;color:var(--mut);background:var(--bg);display:inline-grid;place-items:center}
|
||||
.start{font:600 10.5px/1 system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;color:var(--acc);background:var(--bg);border-radius:999px;padding:4px 9px;margin-right:4px}
|
||||
.q:hover,.q[aria-expanded=true],.start:hover,.start[aria-expanded=true]{color:var(--acc);border-color:var(--acc);background:var(--bg)}
|
||||
.explain{position:absolute;top:calc(100% + 8px);right:0;width:420px;max-width:calc(100vw - 32px);max-height:70vh;overflow:auto;z-index:40;background:var(--bg);border:1px solid var(--bd);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.14);padding:12px 14px;font-size:13px;line-height:1.45;text-align:left;white-space:normal}
|
||||
.explain h3{margin:0 0 5px;font-size:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.explain h4{margin:8px 0 3px;font-size:13px}
|
||||
.explain .tier{font:700 10px/1 system-ui,sans-serif;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)}
|
||||
.explain p{margin:0 0 6px}.explain .aka{font-size:11.5px;color:var(--mut);margin:-2px 0 6px}
|
||||
.explain .how{margin:0 0 6px;font-size:12.5px}.explain .how summary{cursor:pointer;color:var(--acc);font-weight:600;font-size:12px}.explain .how p{margin:6px 0 0;color:var(--mut)}
|
||||
.explain .ex{color:var(--mut);font-size:12px}
|
||||
@media(max-width:1280px){.ladder{display:none}}
|
||||
`
|
||||
const LADDER_HTML = '<nav class="ladder" id="ladder" aria-label="Composability ladder"></nav>'
|
||||
const LADDER_JS = String.raw`<script>
|
||||
(function () {
|
||||
var PRIMER = [
|
||||
{ title: 'Element',
|
||||
def: 'A custom HTML tag this repo invents, such as <doxa-map>: the browser learns it once, then you can paste it into any page as often as you like. Each pasted tag is one live copy, called an instance. (People also say "web component".)',
|
||||
how: 'The bundle index.js turns the shared ProfileLoader into an element class with defineCustomElement and registers the tag once with customElements.define.',
|
||||
ex: '<doxa-map profile-config='{"profile":"doxa-simple-map"}'></doxa-map>' },
|
||||
{ title: 'App profile (mounting file)',
|
||||
def: 'One flat .vue file in app-profiles/<bundle>/ that defines a whole map app — the main component of that map. The settings written on the tag (profile-config) name which app profile to load, and the shared ProfileLoader picks it. In class-and-object terms: the element class is the class, each tag on a page is an instance, and the app profile is the screen that instance loads.',
|
||||
how: 'Most repos need one such main file per app. doxa-maps builds hundreds of maps from one shared library, so every map gets its own app profile — the folder app-profiles/ is where they sit. The five kinds on this ladder are the five shapes those files come in.',
|
||||
ex: 'app-profiles/doxa-simple-map/doxa-simple-map.vue' }
|
||||
];
|
||||
var LADDER = [
|
||||
{ label: 'single-element', tier: 4, title: 'Single element', aka: 'single',
|
||||
def: 'One app profile, one map: paste its tag into any page, once or many times, and set it up right there in profile-config. The smallest kind and the default — no preset table, no tabs, no nested maps.',
|
||||
how: 'A flat .vue with no <profile>.meta.json beside it and no *.instances.json in the bundle folder. Machine key: single.',
|
||||
ex: 'cities-internet/connectivity-map.vue → <cities-internet-map> · gospel-resources/resources-map.vue' },
|
||||
{ label: 'preset-element', tier: 3, title: 'Preset element', aka: 'parameterized · prop-many-elements',
|
||||
def: 'One app profile plus a small table of named presets (<name>.instances.json). Each row is one ready-made map with its settings baked in and gets its own embed page and sidebar entry — same code, same tag.',
|
||||
how: 'The bundle folder holds <name>.instances.json ({"instances":[{id,title,props}]}); the build writes one embed page per row at <bundle>/<profile>/<id>/index.html. Machine key: parameterized. Not this if the versions need different code — make another app profile.',
|
||||
ex: 'doxa-simple-map.vue + simple-map.instances.json → Prayer / Engagement / Adoption' },
|
||||
{ label: 'multi-view-element', tier: 2, title: 'Multi-view element', aka: 'monolithic · monolithic-mfe',
|
||||
def: 'One app profile that holds several screens you switch between — tabs or steps — all written in one .vue and shown as one element, one screen at a time. Keeping them in one file is on purpose.',
|
||||
how: 'Declared, never guessed: <profile>.meta.json {"type":"monolithic"} (or "multi-view-element"); the .vue owns its own switcher. Not this if the pieces are other app profiles (composite) or all panels show at once (single).',
|
||||
ex: 'doxa-research-map/research-map.vue — six tabs in one <doxa-research-map>' },
|
||||
{ label: 'composite-element', tier: 1, title: 'Composite element', aka: 'nested-elements-mfe',
|
||||
def: 'An app profile whose template places the maps of other app profiles together — stacked or side by side, such as a map and a poster wall — and ships the result as one new element. One tag embeds the whole set.',
|
||||
how: 'Declared: <profile>.meta.json {"type":"nested-elements-mfe","composes":[…]} (or "composite-element"); the .vue creates each child tag with its own profile-config on the pane layout (layouts/pane-wrapper). Not this if the screens are written inside one file (multi-view).',
|
||||
ex: 'my-upg-100-list/upg-100-mfe.vue — Map pane + Prayer Lists pane, one Share button' },
|
||||
{ label: 'app-shell', tier: 0, title: 'App shell', aka: 'nested-mfe-spa · container application · root config · host',
|
||||
def: 'The outer frame of a whole app — sidebar, header, navigation — that loads any of the other four kinds inside it and switches between them without a page reload. The only rung that is a page, not an element: what a visitor actually opens.',
|
||||
how: 'Today both shells are HTML pages, not .vue app profiles: layouts/app-wrapper-material/index.html and this plugin gallery (internal/generate-gallery.mjs, one generator for the dev root and public/index.html). Machine key nested-mfe-spa (or app-shell) is reserved for a future .vue shell.',
|
||||
ex: 'this plugin gallery · the Doxa Maps SPA' }
|
||||
];
|
||||
var ladder = document.getElementById('ladder'), openQ = null;
|
||||
var pop = document.createElement('div'); pop.className = 'explain'; pop.hidden = true; pop.setAttribute('role', 'dialog');
|
||||
function closePop() { pop.hidden = true; if (openQ) { openQ.setAttribute('aria-expanded', 'false'); openQ = null } }
|
||||
function showPop(btn, html) { if (openQ === btn) { closePop(); return } closePop(); openQ = btn; btn.setAttribute('aria-expanded', 'true'); pop.innerHTML = html; pop.hidden = false }
|
||||
function howBlock(t) { return '<details class="how"><summary>For builders — how to recognise it</summary><p>' + t + '</p></details>' }
|
||||
function kindHtml(r) { return '<h3>' + r.title + ' <span class="tier">tier ' + r.tier + ' · ' + r.label + '</span></h3><p>' + r.def + '</p><div class="aka">a.k.a. ' + r.aka + '</div>' + howBlock(r.how) + '<div class="ex">e.g. ' + r.ex + '</div>' }
|
||||
function primerHtml() { return '<h3>Start here <span class="tier">the two words every rung uses</span></h3>' + PRIMER.map(function (p) { return '<h4>' + p.title + '</h4><p>' + p.def + '</p>' + howBlock(p.how) + '<div class="ex">e.g. ' + p.ex + '</div>' }).join('') }
|
||||
var start = document.createElement('button'); start.className = 'start'; start.type = 'button'; start.textContent = 'Start here'; start.setAttribute('aria-expanded', 'false');
|
||||
start.setAttribute('aria-label', 'What is an element? What is an app profile?'); start.onclick = function (e) { e.stopPropagation(); showPop(start, primerHtml()) }; ladder.appendChild(start);
|
||||
LADDER.forEach(function (r, i) {
|
||||
if (i) { var a = document.createElement('span'); a.className = 'arrow'; a.textContent = '→'; ladder.appendChild(a) }
|
||||
var rung = document.createElement('span'); rung.className = 'rung';
|
||||
var nm = document.createElement('span'); nm.className = 'rname'; nm.textContent = r.label;
|
||||
var q = document.createElement('button'); q.className = 'q'; q.type = 'button'; q.textContent = '?'; q.setAttribute('aria-label', 'What is a ' + r.title.toLowerCase() + '?'); q.setAttribute('aria-expanded', 'false');
|
||||
q.onclick = function (e) { e.stopPropagation(); showPop(q, kindHtml(r)) };
|
||||
rung.appendChild(nm); rung.appendChild(q); ladder.appendChild(rung);
|
||||
});
|
||||
ladder.appendChild(pop);
|
||||
document.addEventListener('click', function (e) { if (!ladder.contains(e.target)) closePop() });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePop() });
|
||||
})();
|
||||
</script>
|
||||
`
|
||||
|
||||
const html = String.raw`<!DOCTYPE html>
|
||||
/**
|
||||
* The whole page from a manifest-shaped model (schema doxa-maps-manifest@1: bundles → profiles →
|
||||
* instances, with scriptUrl / iframeUrl / embed / integrity). Pure: no I/O, no globals.
|
||||
*
|
||||
* @param {object} model the manifest (build) or the dev model (dev-plugin.mjs galleryModel)
|
||||
* @param {object} opts mode 'build' | 'dev'; title; tree — the folder the live manifest is
|
||||
* fetched from (<tree>/manifest.json) and that relative refs resolve
|
||||
* against (build: the tree name; dev: the route prefix, refs absolute);
|
||||
* tokenUrl / token / apiBaseUrl / assetsBaseUrl / qaWidget — the build
|
||||
* config fields every leaf page bakes (readBuildConfig)
|
||||
*/
|
||||
export function renderGallery(model, opts = {}) {
|
||||
const { mode = 'build', title = 'doxa-maps', tree = 'doxa-maps', tokenUrl = 'window.MAP_TOKEN_URL', token = '', apiBaseUrl = '', assetsBaseUrl = '', qaWidget = true } = opts
|
||||
const dev = mode === 'dev'
|
||||
const baked = compact(model, true)
|
||||
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c')
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
// The one line the two modes load a bundle with: the built IIFE (SRI-checked) vs the dev entry.
|
||||
const loader = dev
|
||||
? `function bundle(b) { return import(rel(b.s)) }`
|
||||
: `function bundle(b) { return once(rel(b.s), 'js', b.i ? { integrity: b.i, crossOrigin: 'anonymous' } : {}) }`
|
||||
|
||||
return String.raw`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(CFG.title)}</title>
|
||||
<title>${esc(title)}</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
:root{--bg:#fff;--bg2:#f6f7f9;--bg3:#eceef2;--bd:#e2e5ea;--fg:#15181d;--mut:#667085;--err:#d13c3c}
|
||||
|
|
@ -136,10 +243,10 @@ code{font-family:ui-monospace,Menlo,monospace}
|
|||
.hint input{font:12px ui-monospace,Menlo,monospace;padding:6px 8px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--fg);width:min(320px,70vw)}
|
||||
[hidden]{display:none!important}
|
||||
@media(max-width:700px){.body{flex-direction:column}nav{flex:0 0 auto;max-height:45vh;border-right:0;border-bottom:1px solid var(--bd)}#menu{display:inline-block}}
|
||||
</style>
|
||||
${dev ? LADDER_CSS : ''}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><b>${esc(CFG.title)}</b><span class="n" id="count"></span><button id="menu" type="button" aria-controls="nav" aria-expanded="true">Maps</button></header>
|
||||
<header><b>${esc(title)}</b><span class="n" id="count"></span>${dev ? LADDER_HTML : ''}<button id="menu" type="button" aria-controls="nav" aria-expanded="true">Maps</button></header>
|
||||
<div class="body">
|
||||
<nav id="nav" aria-label="Maps"></nav>
|
||||
<main>
|
||||
|
|
@ -150,11 +257,11 @@ code{font-family:ui-monospace,Menlo,monospace}
|
|||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var TREE = ${json(TREE)}, TOKEN_SRC = ${json(CFG.tokenUrl)}, BAKED_TOKEN = ${json(CFG.token)}, API_BASE = ${json(CFG.apiBaseUrl)}, ASSETS_BASE = ${json(CFG.assetsBaseUrl)}, QA = ${json(CFG.qaWidget)};
|
||||
var TREE = ${json(tree)}, TOKEN_SRC = ${json(tokenUrl)}, BAKED_TOKEN = ${json(token)}, API_BASE = ${json(apiBaseUrl)}, ASSETS_BASE = ${json(assetsBaseUrl)}, QA = ${json(qaWidget)};
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
var BAKED = ${json(baked)};
|
||||
var snip = ${snip.toString()};
|
||||
var compact = ${compact.toString()};
|
||||
var snip = ${src(snip)};
|
||||
var compact = ${src(compact)};
|
||||
// Peer engines, the same tags every leaf page carries; loaded once, before the bundle.
|
||||
var PEERS = {
|
||||
mapbox: { css: ['https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css', 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'],
|
||||
|
|
@ -189,7 +296,7 @@ code{font-family:ui-monospace,Menlo,monospace}
|
|||
p.js.forEach(function (u) { chain = chain.then(function () { return once(u, 'js') }) });
|
||||
return chain;
|
||||
}
|
||||
function bundle(b) { return once(rel(b.s), 'js', b.i ? { integrity: b.i, crossOrigin: 'anonymous' } : {}) }
|
||||
${loader}
|
||||
|
||||
function row(key, kind, label, tag, extra) {
|
||||
var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'row' + (extra || ''); btn.setAttribute('data-k', key); btn.setAttribute('data-kind', kind);
|
||||
|
|
@ -318,10 +425,25 @@ code{font-family:ui-monospace,Menlo,monospace}
|
|||
} else init(null);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
${dev ? LADDER_JS : ''}</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
const out = join(ROOT, 'public', 'index.html')
|
||||
writeFileSync(out, html)
|
||||
console.log(`[generate-spa] public/index.html — ${(html.length / 1024).toFixed(1)} KB · ${baked.length} bundle(s) · ${nProfiles} profile(s) · ${nInstances} instance(s) · ${nOverrides} embed snippet(s) baked verbatim (the rest re-derive)`)
|
||||
// ── CLI: the built gallery, public/index.html ───────────────────────────────────────────────
|
||||
function main() {
|
||||
const CFG = readBuildConfig()
|
||||
const manifestPath = join(ROOT, 'public', CFG.name, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) { console.error(`[generate-gallery] missing ${manifestPath} — run \`bun run tree\` first`); process.exit(1) }
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const html = renderGallery(manifest, { mode: 'build', title: CFG.title, tree: CFG.name, tokenUrl: CFG.tokenUrl, token: CFG.token, apiBaseUrl: CFG.apiBaseUrl, assetsBaseUrl: CFG.assetsBaseUrl, qaWidget: CFG.qaWidget })
|
||||
const baked = compact(manifest, true)
|
||||
const nProfiles = baked.reduce((n, b) => n + b.p.length, 0)
|
||||
const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0)
|
||||
const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0)
|
||||
const out = join(ROOT, 'public', 'index.html')
|
||||
writeFileSync(out, html)
|
||||
console.log(`[generate-gallery] public/index.html — ${(html.length / 1024).toFixed(1)} KB · ${baked.length} bundle(s) · ${nProfiles} profile(s) · ${nInstances} instance(s) · ${nOverrides} embed snippet(s) baked verbatim (the rest re-derive)`)
|
||||
}
|
||||
// Runs only as a script; importing it (the dev plugin does) has no side effects.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()
|
||||
|
|
@ -828,7 +828,7 @@ function main() {
|
|||
// above. Served paths are unchanged (public/assets/people-groups, /vendor),
|
||||
// so no runtime reference moves. public/ stays fully regenerable: rm -rf it
|
||||
// and `bun run build` rebuilds everything. The web-root holds only assets/,
|
||||
// <name>/ and the generated root page (internal/generate-spa.mjs): the
|
||||
// <name>/ and the generated root page (internal/generate-gallery.mjs): the
|
||||
// vendored external bundles (data/external-bundles) were consumed by the
|
||||
// optional layouts/ shell alone, so they are no longer copied; the frame
|
||||
// policy is the serving host's (docs/architecture/embeddability.md).
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* Phone widths (< 600) tap the sheet's drag strip first so the rows are visible.
|
||||
* Site: --url http://localhost:3001/pray --tag doxa-map
|
||||
* --url http://localhost:3001/research/ --tag doxa-research-map
|
||||
* Playground: --url http://localhost:5174/app-profiles/doxa-simple-map/index.html
|
||||
* Plugin gallery dev server: --url http://localhost:5174/app-profiles/doxa-simple-map/index.html
|
||||
*/
|
||||
import { launch, TELEMETRY } from './lib/browser.mjs'
|
||||
const args = process.argv.slice(2); const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* internal/lib/browser.mjs — helpers shared by the Playwright smoke gates
|
||||
* (playground-smoke.mjs, share-roundtrip-smoke.mjs).
|
||||
* (plugin-gallery-smoke.mjs, share-roundtrip-smoke.mjs).
|
||||
*
|
||||
* launch(browserName, { label }) Firefox by default (house rule: never Google Chrome;
|
||||
* Playwright's bundled Chromium is allowed via 'chromium').
|
||||
|
|
|
|||
|
|
@ -41,17 +41,17 @@
|
|||
* [--grep <substr>] [--browser chromium|firefox] [--threshold 50]
|
||||
* [--boot 5000] [--idle 15000] [--interact 15000] [--wait 25000]
|
||||
* [--profile] [--top 12] [--max-idle 0] [--viewport 1440x900] [--json <out.json>]
|
||||
* Default targets: the three playground deep links doxa-simple-map/prayer,
|
||||
* Default targets: the three plugin gallery deep links doxa-simple-map/prayer,
|
||||
* doxa-simple-map/engagement, doxa-research-map/research-map. --tag pairs with
|
||||
* --url by position (one for all, or none = report the mounted tag).
|
||||
* Exit: 0 every target's IDLE phase has ≤ --max-idle long frames (LoAF where supported,
|
||||
* else rAF-delta) · 1 any target over · 2 setup failure (playground unreachable /
|
||||
* else rAF-delta) · 1 any target over · 2 setup failure (plugin gallery unreachable /
|
||||
* no canvas within --wait)
|
||||
* CAVEATS (be honest with the numbers): headless Chromium renders WebGL through SwiftShader
|
||||
* (CPU) — GL back-pressure can land in the main thread, so absolute ms are pessimistic and
|
||||
* the rAF-delta count also catches GPU-bound frames; compare BEFORE vs AFTER on the same
|
||||
* machine and lean on `rendered` (frames the map drew) and LoAF `scripts` for causality.
|
||||
* The playground dev server compiles on first load, so boot numbers are dev numbers.
|
||||
* The plugin gallery dev server compiles on first load, so boot numbers are dev numbers.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* dev-plugin.mjs — the Vite dev-only plugin that serves the auto-generated SPA.
|
||||
*
|
||||
* Routes: GET / the playground (render.mjs over discover.mjs)
|
||||
* GET /__playground/registry.json the same registry, machine-readable (smoke tool)
|
||||
* GET /assets/<rel> shared asset root (see resolveDevAsset)
|
||||
* Wire-up: plugins: [vue(), cssInjectedByJsPlugin(), autoSpaDevPlugin(token)] in vite.config.js.
|
||||
* Verify: bun run smoke:playground (internal/playground-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 { renderAutoSpa } from './render.mjs'
|
||||
|
||||
// ─── DEV auto-SPA ────────────────────────────────────────────────────────────
|
||||
// `bun x vite` (dev) serves an auto-generated vertical-tabs SPA at `/` that
|
||||
// DISCOVERS every profile across app-profiles/*/ and lets you switch between
|
||||
// them. Each tab lazy-loads that profile's bundle entry (registering its custom
|
||||
// element) and renders it, with the dev Mapbox token (VITE_MAPBOX_TOKEN) injected
|
||||
// so maps just work. Dev-only (apply:'serve'); the production build is untouched.
|
||||
|
||||
// Shared asset root for the dev playground. 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
|
||||
}
|
||||
|
||||
export function autoSpaDevPlugin(token = '') {
|
||||
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) {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
return res.end(JSON.stringify({ error: 'asset not found', asset: rel, looked: ['public/assets/', 'app-profiles/*/data/'] }))
|
||||
}
|
||||
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 registry (same discovery the page uses) — consumed by
|
||||
// internal/playground-smoke.mjs and reusable by the build-emit step.
|
||||
if (path === '/__playground/registry.json') {
|
||||
let reg
|
||||
try { reg = discoverSpaRegistry() } catch (e) { res.statusCode = 500; return res.end(JSON.stringify({ error: String(e) })) }
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
return res.end(JSON.stringify(reg))
|
||||
}
|
||||
if (path !== '/' && path !== '/index.html') return next()
|
||||
let html
|
||||
try { html = renderAutoSpa(discoverSpaRegistry(), token) }
|
||||
catch { return next() }
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(html)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
/**
|
||||
* render.mjs — renders the auto-generated SPA (the playground) as ONE self-contained HTML
|
||||
* string from a registry (see discover.mjs) and a Mapbox token.
|
||||
*
|
||||
* It imitates layouts/app-wrapper-material on purpose (same --ui-* palette, header ladder,
|
||||
* sidebar .sep/.sect-hdr/.row/.badge, language selector + locale contract, deep links
|
||||
* #/<bundle>/<id>). Host contract it hands every element via profile-config: profile,
|
||||
* instance props, instanceId, tk, locale, assetsBase (defaults like the shell does).
|
||||
* Pure function of its inputs — reusable by the dev server and by a build-time emit.
|
||||
*/
|
||||
import { SWITCHER_LANGUAGES } from '../../layouts/app-wrapper-material/languages.js'
|
||||
|
||||
export function renderAutoSpa(reg, tk) {
|
||||
const data = JSON.stringify(reg).replace(/</g, '\\u003c')
|
||||
const tkj = JSON.stringify(tk || '')
|
||||
const langs = JSON.stringify(SWITCHER_LANGUAGES).replace(/</g, '\\u003c')
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/><title>Doxa Maps · Playground</title>
|
||||
<!-- Mapbox PEER — the same tags every generated leaf page and the shell carry. Some profiles
|
||||
(connectivity-map, resources-map, cities-map) take the engine as a page global
|
||||
(window.mapboxgl), so the host page must provide it. Mapbox stays on CDN by decision. -->
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<style>
|
||||
/* doxa / app-wrapper-material palette (Material-clean). Light default, dark via system. */
|
||||
:root{--ui-bg:#ffffff;--ui-bg-elevated:#f9f9f9;--ui-bg-accented:#f0f0f0;--ui-border:#e5e5e5;--ui-text:#0f0f0f;--ui-text-muted:#606060;--ui-primary:#1a73e8;
|
||||
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
--c-spa:#7c3aed;--c-mfe:#2563eb;--c-mono:#0e7490;--c-param:#047857;--c-single:#6b7280}
|
||||
@media (prefers-color-scheme:dark){:root{--ui-bg:#0f0f0f;--ui-bg-elevated:#181818;--ui-bg-accented:#272727;--ui-border:#303030;--ui-text:#f1f1f1;--ui-text-muted:#aaa;--ui-primary:#64b5f6;
|
||||
--c-spa:#a78bfa;--c-mfe:#60a5fa;--c-mono:#22d3ee;--c-param:#34d399;--c-single:#9ca3af}}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--ui-bg);color:var(--ui-text);font:14px/1.4 var(--sans);-webkit-font-smoothing:antialiased}
|
||||
#app{display:flex;flex-direction:column;height:100vh;min-height:0}
|
||||
/* header */
|
||||
header.hdr{flex:0 0 56px;height:56px;position:relative;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:14px;padding:0 20px;background:var(--ui-bg);border-bottom:1px solid var(--ui-border)}
|
||||
.brand{display:flex;align-items:baseline;gap:8px;white-space:nowrap}
|
||||
.brand b{font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
.brand span{font-size:12px;color:var(--ui-text-muted)}
|
||||
.legend{margin-left:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end}
|
||||
.legend .lt{font-size:11px;color:var(--ui-text-muted);margin-right:2px;white-space:nowrap}
|
||||
/* body */
|
||||
.body{flex:1 1 auto;display:flex;min-height:0}
|
||||
/* ── SIDEBAR — imitates app-wrapper-material exactly (.side / .sep / .sect-hdr / .row / .badge) ── */
|
||||
nav.side{width:284px;flex:0 0 284px;background:var(--ui-bg);border-right:1px solid var(--ui-border);
|
||||
overflow-y:auto;overflow-x:hidden;padding:8px 0 24px;scrollbar-width:thin}
|
||||
nav.side::-webkit-scrollbar{width:8px}
|
||||
nav.side::-webkit-scrollbar-thumb{background:var(--ui-border);border-radius:4px}
|
||||
.sep{height:1px;background:var(--ui-border);margin:8px 12px}
|
||||
.sect-hdr{padding:16px 24px 6px;font-size:14px;font-weight:600;color:var(--ui-text);display:flex;align-items:center;gap:8px}
|
||||
.row{display:flex;align-items:center;gap:12px;padding:8px 16px 8px 24px;min-height:40px;margin:0 8px;border-radius:10px;
|
||||
color:var(--ui-text);text-decoration:none;cursor:pointer;font-size:14px;border:0;background:none;width:calc(100% - 16px);
|
||||
text-align:left;font-family:inherit}
|
||||
.row:hover{background:var(--ui-bg-accented)}
|
||||
.row[aria-current="true"]{background:var(--ui-bg-accented);font-weight:600}
|
||||
/* one deviation from the shell: titles may wrap (up to 3 lines) so long names are never cut off */
|
||||
.row .lbl{flex:1;min-width:0;line-height:1.3;white-space:normal;overflow:hidden;
|
||||
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;word-break:break-word}
|
||||
.badge{flex:0 0 auto;font-size:10.5px;font-weight:600;letter-spacing:.01em;color:var(--ui-text-muted);background:var(--ui-bg-accented);
|
||||
padding:2px 7px;border-radius:999px;white-space:nowrap}
|
||||
.row[aria-current="true"] .badge{background:var(--ui-bg);}
|
||||
/* stage */
|
||||
main.stage{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;padding:14px 16px 16px;gap:10px}
|
||||
.bar{flex:0 0 auto;display:flex;align-items:center;gap:10px;min-height:28px}
|
||||
.bar .ttl{font-size:15px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bar .sub{font-size:12px;color:var(--ui-text-muted);white-space:nowrap}
|
||||
.slot{flex:1 1 auto;position:relative;min-height:0;border:1px solid var(--ui-border);border-radius:12px;background:var(--ui-bg-elevated);overflow:hidden}
|
||||
.slot>*{position:absolute;inset:0;width:100%;height:100%;display:block;border:0}
|
||||
.hint{position:absolute;inset:0;display:grid;place-items:center;color:var(--ui-text-muted);font-size:13px;padding:20px;text-align:center}
|
||||
:root{--shadow-2:0 4px 16px rgba(0,0,0,.14)}
|
||||
/* ── composability ladder (top-centre) with "?" explanations ── */
|
||||
.ladder{position:relative;justify-self:center;display:flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.rung{display:inline-flex;align-items:center;gap:4px;color:var(--ui-text)}
|
||||
.rung .rname{font:500 12px/1 var(--mono);letter-spacing:.01em}
|
||||
.arrow{color:var(--ui-text-muted);font-size:12px;padding:0 1px}
|
||||
.q{width:18px;height:18px;border-radius:50%;border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-muted);
|
||||
font:700 11px/1 var(--sans);cursor:pointer;display:inline-grid;place-items:center;padding:0;transition:color .15s,border-color .15s}
|
||||
.q:hover,.q[aria-expanded="true"]{color:var(--ui-primary);border-color:var(--ui-primary)}
|
||||
.explain{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:380px;max-width:calc(100vw - 32px);z-index:40;
|
||||
background:var(--ui-bg);border:1px solid var(--ui-border);border-radius:10px;box-shadow:var(--shadow-2);padding:12px 14px;font-size:13px;line-height:1.45;text-align:left;white-space:normal}
|
||||
.explain[hidden]{display:none}
|
||||
.explain h3{margin:0 0 5px;font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.explain .tier{font:700 10px/1 var(--sans);letter-spacing:.6px;text-transform:uppercase;color:var(--ui-text-muted)}
|
||||
.explain p{margin:0 0 6px}
|
||||
.explain h4{margin:8px 0 3px;font-size:13px;font-weight:600}
|
||||
.explain .aka{font-size:11.5px;color:var(--ui-text-muted);margin:-2px 0 6px}
|
||||
.explain .how{margin:0 0 6px;font-size:12.5px}
|
||||
.explain .how summary{cursor:pointer;color:var(--ui-primary);font-weight:600;font-size:12px}
|
||||
.explain .how p{margin:6px 0 0;color:var(--ui-text-muted)}
|
||||
.explain .ex{color:var(--ui-text-muted);font-size:12px}
|
||||
.explain{max-height:70vh;overflow:auto;width:420px}
|
||||
/* "Start here" primer chip — the two words every rung uses (element, app profile) */
|
||||
.start{font:600 10.5px/1 var(--sans);letter-spacing:.04em;text-transform:uppercase;color:var(--ui-primary);background:var(--ui-bg);
|
||||
border:1px solid var(--ui-border);border-radius:999px;padding:4px 9px;cursor:pointer;margin-right:4px;transition:border-color .15s}
|
||||
.start:hover,.start[aria-expanded="true"]{border-color:var(--ui-primary)}
|
||||
@media (max-width:1280px){.ladder{display:none}}
|
||||
/* ── language selector — imitates the shell (.langsel / .langtrigger / .langmenu) ── */
|
||||
.langsel{position:relative;justify-self:end}
|
||||
.langtrigger{display:flex;align-items:center;gap:7px;border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text);
|
||||
border-radius:8px;padding:6px 10px;font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;transition:background .15s}
|
||||
.langtrigger:hover{background:var(--ui-bg-accented)}
|
||||
.langtrigger .caret{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid var(--ui-text-muted);margin-left:1px}
|
||||
.langmenu{position:absolute;top:calc(100% + 6px);right:0;min-width:190px;z-index:40;list-style:none;margin:0;padding:6px;
|
||||
background:var(--ui-bg);border:1px solid var(--ui-border);border-radius:10px;box-shadow:var(--shadow-2);max-height:60vh;overflow-y:auto}
|
||||
.langmenu[hidden]{display:none}
|
||||
.langmenu li{margin:0}
|
||||
.langmenu button{display:flex;align-items:center;gap:9px;width:100%;border:0;background:none;color:var(--ui-text);font-family:inherit;
|
||||
padding:8px 10px;border-radius:7px;font-size:13px;cursor:pointer;text-align:left}
|
||||
.langmenu button:hover{background:var(--ui-bg-accented)}
|
||||
.langmenu button[aria-selected="true"]{color:var(--ui-primary);font-weight:600}
|
||||
[dir="rtl"] .flag{order:2}
|
||||
</style></head><body>
|
||||
<div id="app">
|
||||
<header class="hdr">
|
||||
<div class="brand"><b>Doxa Maps</b><span>playground</span></div>
|
||||
<nav class="ladder" id="ladder" aria-label="Composability ladder"></nav>
|
||||
<div class="langsel" id="langsel">
|
||||
<button class="langtrigger" id="langtrigger" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="flag" id="langflag"></span><span class="langname" id="langname"></span><span class="caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="langmenu" id="langmenu" role="listbox" hidden></ul>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav class="side" id="side" aria-label="Elements"></nav>
|
||||
<main class="stage">
|
||||
<div class="bar"><span class="ttl" id="ttl">Pick an element</span><span class="sub" id="sub"></span><span id="tchip"></span></div>
|
||||
<div class="slot" id="slot"><div class="hint">Choose an element from the left. Sections are bundles, ordered from most-composed to individual.</div></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const REG=${data},TK=${tkj},LANGS=${langs};
|
||||
// Sidebar badges: the reader-facing kind names (machine keys stay as-is — see discover.mjs TYPE_DISPLAY).
|
||||
const TL={'nested-mfe-spa':'Shell','nested-elements-mfe':'Composite','monolithic':'Multi-view','parameterized':'Preset','single':'Single'};
|
||||
const side=document.getElementById('side'),slot=document.getElementById('slot'),ttl=document.getElementById('ttl'),sub=document.getElementById('sub'),tchip=document.getElementById('tchip');
|
||||
let active=null;
|
||||
function badge(type){const s=document.createElement('span');s.className='badge';s.textContent=TL[type]||type;return s}
|
||||
function select(b,item,btn){
|
||||
cur={b,item,btn};
|
||||
// Deep link (imitates the shell's setHash): #/<bundle>/<id> survives reload and makes a view shareable.
|
||||
try{history.replaceState(null,'','#/'+encodeURIComponent(b.bundle)+'/'+encodeURIComponent(item.id))}catch(_){}
|
||||
if(active)active.setAttribute('aria-current','false');btn.setAttribute('aria-current','true');active=btn;
|
||||
ttl.textContent=item.label;sub.textContent=b.bundle+' · <'+b.tag+'>';tchip.innerHTML='';tchip.appendChild(badge(item.type));
|
||||
slot.innerHTML='<div class="hint">Loading '+item.label+'…</div>';
|
||||
import('/app-profiles/'+b.bundle+'/index.js').then(()=>{
|
||||
slot.innerHTML='';const el=document.createElement(b.tag);
|
||||
const cfg=Object.assign({profile:item.profile},item.props||{});
|
||||
if(!cfg.instanceId)cfg.instanceId=b.bundle+'-'+item.id;if(TK)cfg.tk=TK;cfg.locale=LOC;
|
||||
// ASSET BASE — imitates the shell (layouts/app-wrapper-material/index.html): data-driven
|
||||
// profiles (connectivity-map, resources-map, cities-map…) fetch tables/ and boundaries/
|
||||
// from assetsBase BEFORE creating their map. Unset, they default to ./assets relative to
|
||||
// the PAGE, the dev server answers HTML for the missing path, JSON.parse throws and no
|
||||
// map is ever created. autoSpaDevPlugin serves /assets/* from public/assets in dev.
|
||||
if(!cfg.assetsBase)cfg.assetsBase=new URL('assets',document.baseURI).href.replace(/\\/+$/,'');
|
||||
// SHARE URL — in the playground the shareable page IS this deep link (#/<bundle>/<id>); the
|
||||
// built-tree leaf pages do not exist under the dev server. ShareButton's ladder puts a
|
||||
// host-supplied WHOLE url first (library/utils/embedBase.js), so Share emits a working link.
|
||||
if(!cfg.shareUrl)cfg.shareUrl=location.origin+location.pathname+'#/'+encodeURIComponent(b.bundle)+'/'+encodeURIComponent(item.id);
|
||||
el.setAttribute('profile-config',JSON.stringify(cfg));slot.appendChild(el);
|
||||
}).catch(err=>{slot.innerHTML='<div class="hint">Failed to load: '+((err&&err.message)||err)+'</div>';});
|
||||
}
|
||||
// Same structure the shell's drawSide() emits: sep BETWEEN groups (never before the
|
||||
// first), a bold sect-hdr per bundle (no badge), then flat .row items with a trailing badge.
|
||||
// ── locale: imitates the shell — same storage key, same contract (profile-config.locale) ──
|
||||
const LKEY='doxa-maps:locale';
|
||||
const norm=t=>{const c=String(t||'').split('-')[0].toLowerCase();return LANGS.some(l=>l.code===c)?c:'en'};
|
||||
let LOC=(()=>{const q=new URLSearchParams(location.search).get('lang');if(q)return norm(q);try{const s=localStorage.getItem(LKEY);if(s)return norm(s)}catch(_){}return norm(navigator.language)})();
|
||||
let cur=null;
|
||||
const langsel=document.getElementById('langsel'),ltrig=document.getElementById('langtrigger'),lmenu=document.getElementById('langmenu');
|
||||
function openLang(){lmenu.hidden=false;ltrig.setAttribute('aria-expanded','true')}
|
||||
function closeLang(){lmenu.hidden=true;ltrig.setAttribute('aria-expanded','false')}
|
||||
function drawLang(){
|
||||
const L=LANGS.find(l=>l.code===LOC)||LANGS[0];
|
||||
document.getElementById('langflag').textContent=L.flag;document.getElementById('langname').textContent=L.nativeName;
|
||||
document.documentElement.dir=L.dir==='rtl'?'rtl':'ltr';
|
||||
lmenu.innerHTML=LANGS.map(l=>'<li><button type="button" role="option" data-code="'+l.code+'" aria-selected="'+(l.code===LOC)+'"><span class="flag">'+l.flag+'</span><span>'+l.nativeName+'</span></button></li>').join('');
|
||||
lmenu.querySelectorAll('button').forEach(o=>o.onclick=()=>{LOC=o.dataset.code;try{localStorage.setItem(LKEY,LOC)}catch(_){}closeLang();drawLang();if(cur)select(cur.b,cur.item,cur.btn);});
|
||||
}
|
||||
ltrig.onclick=e=>{e.stopPropagation();lmenu.hidden?openLang():closeLang()};
|
||||
drawLang();
|
||||
// ── the ladder: "Start here" primer + the five kinds of mounting files, with "?" explanations ──
|
||||
// Vocabulary is the validated set from eco-dx PS001-profile-types (public copy: docs/reference.md
|
||||
// "Profile types"): head words are established terms (custom element, preset, multi-view, composite,
|
||||
// app shell); the coined words stay as a.k.a. Strings are HTML (innerHTML) — tags are escaped.
|
||||
const PRIMER=[
|
||||
{title:'Element',
|
||||
def:'A custom HTML tag this repo invents, such as <doxa-map>: the browser learns it once, then you can paste it into any page as often as you like. Each pasted tag is one live copy, called an instance. (People also say "web component".)',
|
||||
how:'The bundle index.js turns the shared ProfileLoader into an element class with defineCustomElement and registers the tag once with customElements.define.',
|
||||
ex:'<doxa-map profile-config='{"profile":"doxa-simple-map"}'></doxa-map>'},
|
||||
{title:'App profile (mounting file)',
|
||||
def:'One flat .vue file in app-profiles/<bundle>/ that defines a whole map app — the main component of that map. The settings written on the tag (profile-config) name which app profile to load, and the shared ProfileLoader picks it. In class-and-object terms: the element class is the class, each tag on a page is an instance, and the app profile is the screen that instance loads.',
|
||||
how:'Most repos need one such main file per app. doxa-maps builds hundreds of maps from one shared library, so every map gets its own app profile — the folder app-profiles/ is where they sit. The five kinds on this ladder are the five shapes those files come in.',
|
||||
ex:'app-profiles/doxa-simple-map/doxa-simple-map.vue'},
|
||||
];
|
||||
const LADDER=[
|
||||
{key:'single',label:'single-element',tier:4,title:'Single element',aka:'single',
|
||||
def:'One app profile, one map: paste its tag into any page, once or many times, and set it up right there in profile-config. The smallest kind and the default — no preset table, no tabs, no nested maps.',
|
||||
how:'A flat .vue with no <profile>.meta.json beside it and no *.instances.json in the bundle folder. Machine key: single.',
|
||||
ex:'cities-internet/connectivity-map.vue → <cities-internet-map> · gospel-resources/resources-map.vue'},
|
||||
{key:'parameterized',label:'preset-element',tier:3,title:'Preset element',aka:'parameterized · prop-many-elements',
|
||||
def:'One app profile plus a small table of named presets (<name>.instances.json). Each row is one ready-made map with its settings baked in and gets its own embed page and sidebar entry — same code, same tag.',
|
||||
how:'The bundle folder holds <name>.instances.json ({"instances":[{id,title,props}]}); the build writes one embed page per row at <bundle>/<profile>/<id>/index.html. Machine key: parameterized. Not this if the versions need different code — make another app profile.',
|
||||
ex:'doxa-simple-map.vue + simple-map.instances.json → Prayer / Engagement / Adoption'},
|
||||
{key:'monolithic',label:'multi-view-element',tier:2,title:'Multi-view element',aka:'monolithic · monolithic-mfe',
|
||||
def:'One app profile that holds several screens you switch between — tabs or steps — all written in one .vue and shown as one element, one screen at a time. Keeping them in one file is on purpose.',
|
||||
how:'Declared, never guessed: <profile>.meta.json {"type":"monolithic"} (or "multi-view-element"); the .vue owns its own switcher. Not this if the pieces are other app profiles (composite) or all panels show at once (single).',
|
||||
ex:'doxa-research-map/research-map.vue — six tabs in one <doxa-research-map>'},
|
||||
{key:'nested-elements-mfe',label:'composite-element',tier:1,title:'Composite element',aka:'nested-elements-mfe',
|
||||
def:'An app profile whose template places the maps of other app profiles together — stacked or side by side, such as a map and a poster wall — and ships the result as one new element. One tag embeds the whole set.',
|
||||
how:'Declared: <profile>.meta.json {"type":"nested-elements-mfe","composes":[…]} (or "composite-element"); the .vue creates each child tag with its own profile-config on the pane layout (layouts/pane-wrapper). Not this if the screens are written inside one file (multi-view).',
|
||||
ex:'my-upg-100-list/upg-100-mfe.vue — Map pane + Prayer Lists pane, one Share button'},
|
||||
{key:'nested-mfe-spa',label:'app-shell',tier:0,title:'App shell',aka:'nested-mfe-spa · container application · root config · host',
|
||||
def:'The outer frame of a whole app — sidebar, header, navigation — that loads any of the other four kinds inside it and switches between them without a page reload. The only rung that is a page, not an element: what a visitor actually opens.',
|
||||
how:'Today both shells are HTML pages, not .vue app profiles: layouts/app-wrapper-material/index.html and this playground (internal/playground/render.mjs). Machine key nested-mfe-spa (or app-shell) is reserved for a future .vue shell.',
|
||||
ex:'this playground · the Doxa Maps SPA'},
|
||||
];
|
||||
const ladder=document.getElementById('ladder');
|
||||
const pop=document.createElement('div');pop.className='explain';pop.hidden=true;pop.setAttribute('role','dialog');
|
||||
let openQ=null;
|
||||
function closePop(){pop.hidden=true;if(openQ){openQ.setAttribute('aria-expanded','false');openQ=null}}
|
||||
function showPop(btn,html){if(openQ===btn){closePop();return}closePop();openQ=btn;btn.setAttribute('aria-expanded','true');pop.innerHTML=html;pop.hidden=false}
|
||||
const howBlock=t=>'<details class="how"><summary>For builders — how to recognise it</summary><p>'+t+'</p></details>';
|
||||
const kindHtml=r=>'<h3>'+r.title+' <span class="tier">tier '+r.tier+' · '+r.label+'</span></h3><p>'+r.def+'</p><div class="aka">a.k.a. '+r.aka+'</div>'+howBlock(r.how)+'<div class="ex">e.g. '+r.ex+'</div>';
|
||||
const primerHtml=()=>'<h3>Start here <span class="tier">the two words every rung uses</span></h3>'+PRIMER.map(p=>'<h4>'+p.title+'</h4><p>'+p.def+'</p>'+howBlock(p.how)+'<div class="ex">e.g. '+p.ex+'</div>').join('');
|
||||
const start=document.createElement('button');start.className='start';start.type='button';start.textContent='Start here';start.setAttribute('aria-expanded','false');
|
||||
start.setAttribute('aria-label','What is an element? What is an app profile?');start.onclick=e=>{e.stopPropagation();showPop(start,primerHtml())};ladder.appendChild(start);
|
||||
LADDER.forEach((r,i)=>{
|
||||
if(i){const a=document.createElement('span');a.className='arrow';a.textContent='→';ladder.appendChild(a)}
|
||||
const rung=document.createElement('span');rung.className='rung';
|
||||
const nm=document.createElement('span');nm.className='rname';nm.textContent=r.label;
|
||||
const q=document.createElement('button');q.className='q';q.type='button';q.textContent='?';q.setAttribute('aria-label','What is a '+r.title.toLowerCase()+'?');q.setAttribute('aria-expanded','false');
|
||||
q.onclick=e=>{e.stopPropagation();showPop(q,kindHtml(r))};
|
||||
rung.appendChild(nm);rung.appendChild(q);ladder.appendChild(rung);
|
||||
});
|
||||
ladder.appendChild(pop);
|
||||
document.addEventListener('click',e=>{if(!langsel.contains(e.target))closeLang();if(!ladder.contains(e.target))closePop()});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeLang();closePop()}});
|
||||
let firstGroup=true;
|
||||
for(const b of REG){
|
||||
if(!firstGroup){const s=document.createElement('div');s.className='sep';side.appendChild(s);}
|
||||
firstGroup=false;
|
||||
const h=document.createElement('div');h.className='sect-hdr';const hs=document.createElement('span');hs.textContent=b.bundle;h.appendChild(hs);side.appendChild(h);
|
||||
for(const item of b.items){const btn=document.createElement('button');btn.className='row';btn.setAttribute('aria-current','false');btn.dataset.id=b.bundle+'/'+item.id;
|
||||
const l=document.createElement('span');l.className='lbl';l.textContent=item.label;
|
||||
btn.appendChild(l);btn.appendChild(badge(item.type));btn.onclick=()=>select(b,item,btn);side.appendChild(btn);}
|
||||
}
|
||||
// Resolve the deep link (imitates the shell's profileFromHash): #/<bundle>/<id>, else the first element.
|
||||
const want=(location.hash||'').replace(/^#\\/?/,'').split('/').map(s=>{try{return decodeURIComponent(s)}catch(_){return s}});
|
||||
const wantId=(want[0]||'')+'/'+(want[1]||'');
|
||||
const target=[...side.querySelectorAll('button.row')].find(r=>r.dataset.id===wantId)||side.querySelector('button.row');
|
||||
if(target)target.click();
|
||||
</script></body></html>`
|
||||
}
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* playground-smoke.mjs — prove EVERY element in the auto-generated dev SPA
|
||||
* (the playground served by `bun x vite` at /) actually works.
|
||||
* plugin-gallery-smoke.mjs — prove EVERY element in the auto-generated dev SPA
|
||||
* (the plugin gallery served by `bun x vite` at /) actually works.
|
||||
*
|
||||
* WHY: the playground is the architecture demo and the pre-handoff gate — "all
|
||||
* WHY: the plugin gallery is the architecture demo and the pre-handoff gate — "all
|
||||
* the maps work in the auto-generated SPA" must be a checked fact, not a hope.
|
||||
* embed-smoke.mjs proves the BUILT pages boot; THIS proves each element boots
|
||||
* inside the playground shell: element defined, shadow DOM populated, no
|
||||
* inside the plugin gallery shell: element defined, shadow DOM populated, no
|
||||
* ProfileLoader error card, and (for map profiles) a WebGL canvas present.
|
||||
*
|
||||
* HOW: reads the playground's own registry (GET /__playground/registry.json —
|
||||
* HOW: reads the plugin gallery's own registry (GET /__plugin-gallery/registry.json —
|
||||
* the same discovery the page renders), then opens each element by DEEP LINK
|
||||
* (/#/<bundle>/<id>) in a FRESH page, one at a time (sequential by default so a
|
||||
* laptop's memory / WebGL-context budget is never blown), and audits the mount.
|
||||
* (/#/<bundle>/<profile>[/<instance>]) in a FRESH page, one at a time (sequential by
|
||||
* default so a laptop's memory / WebGL-context budget is never blown), and audits the mount.
|
||||
* Browser: Firefox by default (house rule: never Google Chrome; Playwright's
|
||||
* bundled Chromium is also allowed via --browser chromium when installed). If
|
||||
* Playwright's pinned Firefox revision is missing, falls back to any cached one.
|
||||
*
|
||||
* Usage: node internal/playground-smoke.mjs [--url http://localhost:5174]
|
||||
* Usage: node internal/plugin-gallery-smoke.mjs [--url http://localhost:5174]
|
||||
* [--grep <substr>] [--browser firefox|chromium] [--parallel 1]
|
||||
* [--wait 12000] [--json <out.json>]
|
||||
* Exit: 0 all pass · 1 any fail (list printed)
|
||||
|
|
@ -38,23 +38,24 @@ const JSON_OUT = argOf('--json', '')
|
|||
// browser launch (Firefox first; cached-Firefox fallback), TELEMETRY filter and the
|
||||
// shadow-piercing deepFind live in internal/lib/browser.mjs — shared with share-roundtrip-smoke.
|
||||
|
||||
// ── targets: every element the playground lists ─────────────────────────────
|
||||
const regRes = await fetch(`${BASE}/__playground/registry.json`).catch(() => null)
|
||||
if (!regRes || !regRes.ok) { console.error(`[playground-smoke] cannot read ${BASE}/__playground/registry.json — is \`bun x vite\` running?`); process.exit(2) }
|
||||
// ── targets: every element the plugin gallery lists ─────────────────────────
|
||||
const regRes = await fetch(`${BASE}/__plugin-gallery/registry.json`).catch(() => null)
|
||||
if (!regRes || !regRes.ok) { console.error(`[plugin-gallery-smoke] cannot read ${BASE}/__plugin-gallery/registry.json — is \`bun x vite\` running?`); process.exit(2) }
|
||||
const registry = await regRes.json()
|
||||
const targets = []
|
||||
for (const b of registry) for (const it of b.items) {
|
||||
const key = `${b.bundle}/${it.id}`
|
||||
// The gallery's deep-link key: <bundle>/<profile>, plus /<id> for a preset instance.
|
||||
const key = `${b.bundle}/${it.profile}${it.type === 'parameterized' ? `/${it.id}` : ''}`
|
||||
if (GREP && !key.includes(GREP) && !String(it.label).includes(GREP)) continue
|
||||
// A WebGL canvas is expected for map profiles and for composites (their maps live inside
|
||||
// NESTED custom elements — checked deep); lists/tables/charts/poster walls are DOM-only.
|
||||
const expectCanvas = (/map/i.test(it.profile) && !/list|table|chart|wall|dashboard/i.test(it.profile))
|
||||
|| it.type === 'nested-elements-mfe' || it.type === 'nested-mfe-spa'
|
||||
targets.push({ bundle: b.bundle, tag: b.tag, id: it.id, label: it.label, type: it.type, profile: it.profile, expectCanvas, url: `${BASE}/#/${encodeURIComponent(b.bundle)}/${encodeURIComponent(it.id)}` })
|
||||
targets.push({ bundle: b.bundle, tag: b.tag, id: it.id, key, label: it.label, type: it.type, profile: it.profile, expectCanvas, url: `${BASE}/#/${key.split('/').map(encodeURIComponent).join('/')}` })
|
||||
}
|
||||
console.log(`[playground-smoke] ${targets.length} element(s) from ${registry.length} bundle(s) @ ${BASE}`)
|
||||
console.log(`[plugin-gallery-smoke] ${targets.length} element(s) from ${registry.length} bundle(s) @ ${BASE}`)
|
||||
|
||||
const browser = await launch(BROWSER, { label: 'playground-smoke' })
|
||||
const browser = await launch(BROWSER, { label: 'plugin-gallery-smoke' })
|
||||
|
||||
async function check(t) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
|
|
@ -94,7 +95,7 @@ async function check(t) {
|
|||
const row = document.querySelector('button.row[aria-current="true"]')
|
||||
// Host contract: some profiles take the engine as a page global (window.mapboxgl).
|
||||
const hostHasMapbox = typeof window.mapboxgl !== 'undefined'
|
||||
return { mountedTag: el ? el.tagName.toLowerCase() : null, defined, children, errorCard: err ? err.textContent.trim().slice(0, 160) : null, statusText: st && st.textContent.trim() ? st.textContent.trim().slice(0, 160) : null, canvas, activeRow: row ? row.dataset.id : null, hostHasMapbox }
|
||||
return { mountedTag: el ? el.tagName.toLowerCase() : null, defined, children, errorCard: err ? err.textContent.trim().slice(0, 160) : null, statusText: st && st.textContent.trim() ? st.textContent.trim().slice(0, 160) : null, canvas, activeRow: row ? row.dataset.k : null, hostHasMapbox }
|
||||
}, t.tag)
|
||||
if (state.errorCard || (state.children > 0 && (state.canvas || !t.expectCanvas))) break
|
||||
await page.waitForTimeout(400)
|
||||
|
|
@ -103,7 +104,7 @@ async function check(t) {
|
|||
Object.assign(r, { defined: state?.defined, shadowChildren: state?.children ?? 0, errorCard: state?.errorCard, statusText: state?.statusText ?? null, canvas: state?.canvas, activeRow: state?.activeRow, mountedTag: state?.mountedTag })
|
||||
r.ms = Date.now() - t0
|
||||
const problems = []
|
||||
if (r.activeRow !== `${t.bundle}/${t.id}`) problems.push(`deep link resolved to ${r.activeRow}`)
|
||||
if (r.activeRow !== t.key) problems.push(`deep link resolved to ${r.activeRow}`)
|
||||
if (!r.mountedTag) problems.push('nothing mounted in #slot')
|
||||
else if (r.mountedTag !== t.tag) problems.push(`mounted <${r.mountedTag}>, expected <${t.tag}>`)
|
||||
if (!r.defined) problems.push(`custom element <${t.tag}> not defined`)
|
||||
|
|
@ -133,13 +134,13 @@ for (let i = 0; i < targets.length; i += PARALLEL) {
|
|||
for (const r of rs) {
|
||||
results.push(r)
|
||||
const warn = r.consoleErrors.length ? ` · ${r.consoleErrors.length} console error(s)` : ''
|
||||
console.log(`${r.pass ? '✓' : '✗'} ${r.bundle}/${r.id} (${r.type}) canvas:${r.canvas ? 'y' : 'n'} ${r.ms}ms${warn}${r.pass ? '' : '\n ' + r.why}`)
|
||||
console.log(`${r.pass ? '✓' : '✗'} ${r.key} (${r.type}) canvas:${r.canvas ? 'y' : 'n'} ${r.ms}ms${warn}${r.pass ? '' : '\n ' + r.why}`)
|
||||
}
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
const failed = results.filter(r => !r.pass)
|
||||
const noisy = results.filter(r => r.pass && r.consoleErrors.length)
|
||||
console.log(`\n[playground-smoke] ${results.length - failed.length}/${results.length} pass · ${failed.length} fail · ${noisy.length} pass-with-console-errors`)
|
||||
if (JSON_OUT) { writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2)); console.log(`[playground-smoke] report → ${JSON_OUT}`) }
|
||||
if (failed.length) { console.log('FAILED:'); failed.forEach(f => console.log(` ✗ ${f.bundle}/${f.id} — ${f.why}`)); process.exit(1) }
|
||||
console.log(`\n[plugin-gallery-smoke] ${results.length - failed.length}/${results.length} pass · ${failed.length} fail · ${noisy.length} pass-with-console-errors`)
|
||||
if (JSON_OUT) { writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2)); console.log(`[plugin-gallery-smoke] report → ${JSON_OUT}`) }
|
||||
if (failed.length) { console.log('FAILED:'); failed.forEach(f => console.log(` ✗ ${f.key} — ${f.why}`)); process.exit(1) }
|
||||
152
internal/plugin-gallery/dev-plugin.mjs
Normal file
152
internal/plugin-gallery/dev-plugin.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* 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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@
|
|||
* composite tier (monolithic / nested-elements-mfe / nested-mfe-spa).
|
||||
* <name>.instances.json [{id, title, props}] or {"instances": [...]} — marks the bundle's
|
||||
* profiles PARAMETERIZED and expands one element per instance.
|
||||
* Pure + side-effect free: used by the dev playground (dev-plugin.mjs) and reusable by the
|
||||
* build step that emits the SPA at the output root, and by any tool that needs the catalog.
|
||||
* Pure + side-effect free: the dev plugin (dev-plugin.mjs) turns its result into the
|
||||
* manifest-shaped model the plugin gallery renders; any tool that needs the catalog can use it.
|
||||
*/
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
|
@ -82,13 +82,15 @@ export function discoverSpaRegistry(root = REPO_ROOT) {
|
|||
const rawInst = instFile ? readJsonSafe(resolve(dir, instFile)) : null
|
||||
const instances = Array.isArray(rawInst) ? rawInst : (rawInst && Array.isArray(rawInst.instances) ? rawInst.instances : null)
|
||||
const hasInstances = !!(instances && instances.length)
|
||||
// The table's data API (its top-level apiBaseUrl), baked per instance as generate-tree does.
|
||||
const instApi = rawInst && !Array.isArray(rawInst) && typeof rawInst.apiBaseUrl === 'string' ? rawInst.apiBaseUrl : ''
|
||||
const items = []
|
||||
for (const profile of files.filter(f => f.endsWith('.vue')).map(f => f.replace(/\.vue$/, '')).sort()) {
|
||||
const t = detectProfileType(dir, profile, tag, allTags, hasInstances)
|
||||
if (t.type === 'parameterized' && hasInstances) {
|
||||
// Expand the parameterized table into one element PER preconfigured instance.
|
||||
for (const inst of instances) {
|
||||
items.push({ profile, id: inst.id || inst.title || profile, label: inst.title || inst.id || profile, type: 'parameterized', props: inst.props || {} })
|
||||
items.push({ profile, id: inst.id || inst.title || profile, label: inst.title || inst.id || profile, type: 'parameterized', props: inst.props || {}, apiBaseUrl: instApi })
|
||||
}
|
||||
} else {
|
||||
items.push({ profile, id: profile, label: t.label, type: t.type, props: null })
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* share-roundtrip-smoke.mjs — prove a map's Share button emits a URL that actually
|
||||
* reopens THE SAME MAP.
|
||||
*
|
||||
* WHY: Driver REQ-1 — share links must work wherever the tree is hosted: the playground,
|
||||
* WHY: REQ-1 — share links must work wherever the tree is hosted: the plugin gallery,
|
||||
* doxa.life, any CDN. A share URL is the one artefact a partner pastes into an email or
|
||||
* a website builder, and it fails SILENTLY: a hardcoded path 404s on a different host, a
|
||||
* dev server's SPA fallback answers 200 text/html for a path nobody serves, a host shell
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
* --tag pairs with --url by position (one --tag per --url); a single --tag applies
|
||||
* to every --url; with no --tag the tag the HOST page actually mounted is expected
|
||||
* on the reopened page (the usual case for a site with several map pages).
|
||||
* node internal/share-roundtrip-smoke.mjs --playground [http://localhost:5174]
|
||||
* [--grep <substr>] → every element with a canvas from GET <base>/__playground/
|
||||
* registry.json, opened by deep link <base>/#/<bundle>/<id>
|
||||
* node internal/share-roundtrip-smoke.mjs --plugin-gallery [http://localhost:5174]
|
||||
* [--grep <substr>] → every element with a canvas from GET <base>/__plugin-gallery/
|
||||
* registry.json, opened by deep link <base>/#/<bundle>/<profile>[/<instance>]
|
||||
* Exit: 0 all pass · 1 any fail (list printed) · 2 usage / no targets / registry unreachable
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
|
@ -60,12 +60,12 @@ const WAIT = Number(argOf('--wait', 12000))
|
|||
const JSON_OUT = argOf('--json', '')
|
||||
const BROWSER = argOf('--browser', 'firefox')
|
||||
const GREP = argOf('--grep', '')
|
||||
const PLAYGROUND = has('--playground')
|
||||
? String((() => { const v = args[args.indexOf('--playground') + 1]; return v && !v.startsWith('--') ? v : 'http://localhost:5174' })()).replace(/\/+$/, '')
|
||||
const GALLERY = has('--plugin-gallery')
|
||||
? String((() => { const v = args[args.indexOf('--plugin-gallery') + 1]; return v && !v.startsWith('--') ? v : 'http://localhost:5174' })()).replace(/\/+$/, '')
|
||||
: null
|
||||
|
||||
if (!URLS.length && !PLAYGROUND) {
|
||||
console.error(`[${TOOL}] usage: --url <host page URL> [--url ...] [--tag <tag>] | --playground [base] (see header)`)
|
||||
if (!URLS.length && !GALLERY) {
|
||||
console.error(`[${TOOL}] usage: --url <host page URL> [--url ...] [--tag <tag>] | --plugin-gallery [base] (see header)`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
|
|
@ -76,19 +76,20 @@ try { SHARE_TITLE = JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales/en/
|
|||
|
||||
// ── targets ───────────────────────────────────────────────────────────────────
|
||||
const targets = URLS.map((u, i) => ({ label: u, host: u, tag: tagFor(i) }))
|
||||
if (PLAYGROUND) {
|
||||
const regRes = await fetch(`${PLAYGROUND}/__playground/registry.json`).catch(() => null)
|
||||
if (!regRes || !regRes.ok) { console.error(`[${TOOL}] cannot read ${PLAYGROUND}/__playground/registry.json — is \`bun x vite\` running?`); process.exit(2) }
|
||||
if (GALLERY) {
|
||||
const regRes = await fetch(`${GALLERY}/__plugin-gallery/registry.json`).catch(() => null)
|
||||
if (!regRes || !regRes.ok) { console.error(`[${TOOL}] cannot read ${GALLERY}/__plugin-gallery/registry.json — is \`bun x vite\` running?`); process.exit(2) }
|
||||
const registry = await regRes.json()
|
||||
for (const b of registry) for (const it of b.items) {
|
||||
const key = `${b.bundle}/${it.id}`
|
||||
// The gallery's deep-link key: <bundle>/<profile>, plus /<id> for a preset instance.
|
||||
const key = `${b.bundle}/${it.profile}${it.type === 'parameterized' ? `/${it.id}` : ''}`
|
||||
if (GREP && !key.includes(GREP) && !String(it.label).includes(GREP)) continue
|
||||
// Same heuristic as playground-smoke: map profiles + composites carry a canvas (and a
|
||||
// Same heuristic as plugin-gallery-smoke: map profiles + composites carry a canvas (and a
|
||||
// Share button); lists/tables/charts/poster walls are DOM-only and have nothing to share.
|
||||
const hasCanvas = (/map/i.test(it.profile) && !/list|table|chart|wall|dashboard/i.test(it.profile))
|
||||
|| it.type === 'nested-elements-mfe' || it.type === 'nested-mfe-spa'
|
||||
if (!hasCanvas) continue
|
||||
targets.push({ label: key, host: `${PLAYGROUND}/#/${encodeURIComponent(b.bundle)}/${encodeURIComponent(it.id)}`, tag: b.tag, type: it.type, profile: it.profile })
|
||||
targets.push({ label: key, host: `${GALLERY}/#/${key.split('/').map(encodeURIComponent).join('/')}`, tag: b.tag, type: it.type, profile: it.profile })
|
||||
}
|
||||
}
|
||||
if (!targets.length) { console.error(`[${TOOL}] no targets`); process.exit(2) }
|
||||
|
|
@ -108,7 +109,7 @@ const PROBE_CANVAS = (deepFind) => {
|
|||
errorCard: err ? err.textContent.trim().slice(0, 160) : null,
|
||||
statusText: st && st.textContent.trim() ? st.textContent.trim().slice(0, 160) : null,
|
||||
title: document.title, url: location.href,
|
||||
playgroundShell: !!document.querySelector('#slot'), hostHasMapbox: typeof window.mapboxgl !== 'undefined'
|
||||
galleryShell: !!document.querySelector('#slot'), hostHasMapbox: typeof window.mapboxgl !== 'undefined'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +154,7 @@ async function check(t) {
|
|||
const r = {
|
||||
label: t.label, host: t.host, tag: t.tag, hostTag: null, shareUrl: null, status: null, contentType: null,
|
||||
resolvedUrl: null, iframeSnippetHasUrl: null, embedSnippet: null,
|
||||
reopened: { tag: null, canvas: false, url: null, title: null, playgroundShell: false, ...reSink },
|
||||
reopened: { tag: null, canvas: false, url: null, title: null, galleryShell: false, ...reSink },
|
||||
pass: false, why: '', ms: 0, hostErrors: hostSink
|
||||
}
|
||||
const problems = []
|
||||
|
|
@ -226,11 +227,11 @@ async function check(t) {
|
|||
wirePage(page2, reSink)
|
||||
try { await page2.goto(r.shareUrl, { waitUntil: 'load', timeout: 60000 }) } catch (e) { problems.push(`share URL opened but the page failed to load: ${String(e.message || e).slice(0, 160)}`); throw 'stop' }
|
||||
const rs = await pollCanvas(page2, WAIT)
|
||||
Object.assign(r.reopened, { tag: rs?.tag ?? null, canvas: !!rs?.canvas, url: rs?.url ?? null, title: rs?.title ?? null, playgroundShell: !!rs?.playgroundShell })
|
||||
Object.assign(r.reopened, { tag: rs?.tag ?? null, canvas: !!rs?.canvas, url: rs?.url ?? null, title: rs?.title ?? null, galleryShell: !!rs?.galleryShell })
|
||||
if (!rs?.canvas) { problems.push(`share URL opened but no map rendered (page title "${rs?.title ?? ''}")` + evidence(reSink, rs)); throw 'stop' }
|
||||
if (expectTag && rs.tag !== expectTag) {
|
||||
let why = `share URL reopened <${rs.tag}>, not <${expectTag}> — the link does not identify this map`
|
||||
if (rs.playgroundShell) why += ' (landed on the playground shell, which mounts its default first element)'
|
||||
if (rs.galleryShell) why += ' (landed on the plugin gallery shell, which mounts its default first element)'
|
||||
else if (rs.title) why += ` (page title "${rs.title}")`
|
||||
problems.push(why)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
* drops the WebGL layer from frames — pure white, or half white (a torn frame) —
|
||||
* in bursts of 1–8 frames even on a settled map. Evidence they are capture
|
||||
* artefacts, not real frames: they are rgb(255,255,255) while a covered canvas
|
||||
* shows the real container/overlay colour (rgb 248/249 in the playground, which
|
||||
* shows the real container/overlay colour (rgb 248/249 in the plugin gallery, which
|
||||
* the boot gap does show); Mapbox never paints half a frame; and the true-
|
||||
* composite screenshot stream never catches one (--steady 15000 ≈ 45–50 settled
|
||||
* samples per target: 0 white; 6–8 screenshots per legend click: 0 white, while
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
* run lasts ≥ min-run samples (screenshots: 1, every sample is a real composite) —
|
||||
* the user sees the container/overlay instead of the map. The two need different
|
||||
* fixes and are reported separately (`brightPainted`, `gapMs`, `coverAtBoot`).
|
||||
* FINDINGS 2026-09-16 (baseline, dev playground, Firefox): no 'day' frame was ever
|
||||
* FINDINGS 2026-09-16 (baseline, dev plugin gallery, Firefox): no 'day' frame was ever
|
||||
* painted on either surface in any run, including with the data race forced
|
||||
* (--delay-data 3000). The simple map's "light then dark" on load is its LIGHT
|
||||
* loading overlay `.dsm-loading{background:#f8f9fa}` (v-if="!appReady"), removed
|
||||
|
|
@ -100,12 +100,12 @@
|
|||
* [--video] [--video-step 80] [--video-min-run 10] [--keep-video] [--video-dir <dir>]
|
||||
* [--browser firefox|chromium] [--json <out.json>]
|
||||
* Exit: 0 no flash anywhere · 1 any flash (list printed) · 2 setup failure
|
||||
* (playground unreachable / no map canvas within --wait)
|
||||
* (plugin gallery unreachable / no map canvas within --wait)
|
||||
* CAVEATS (be honest with the numbers): the screenshot stream cannot see anything
|
||||
* shorter than one screenshot (~0.5 s) — a screenshot PASS means "no bright frame
|
||||
* was observed", not "none happened"; use --video for the fine timeline, and treat a
|
||||
* FAIL from either stream as proof. Camera moves (legend rows fly the map)
|
||||
* legitimately change the scene, which the +60 margin absorbs. The playground dev
|
||||
* legitimately change the scene, which the +60 margin absorbs. The plugin gallery dev
|
||||
* server compiles on first load, so boot timings are dev timings, and a warm Vite
|
||||
* cache makes the 2nd target boot faster than the 1st. Headless WebGL may render via
|
||||
* a software path; the numbers are what THIS browser painted.
|
||||
|
|
@ -229,7 +229,7 @@ const coverAt = (page) => deepEvaluate(page, (deepFind) => {
|
|||
return { covered: true, el: name, background: getComputedStyle(el).backgroundColor, text: (el.textContent || '').trim().slice(0, 40) }
|
||||
})
|
||||
// Phase marker (video mode): a fixed square in the viewport's bottom-left corner whose colour
|
||||
// changes at every phase start. Outside the map canvas in the playground; even if a layout put
|
||||
// changes at every phase start. Outside the map canvas in the plugin gallery; even if a layout put
|
||||
// it under the canvas box it is excluded by the core window.
|
||||
const MARKER_COLORS = ['rgb(255,0,0)', 'rgb(0,255,0)', 'rgb(0,0,255)']
|
||||
const setMarker = (page, i) => page.evaluate(([i, size, colors]) => {
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ const sizePresets = {
|
|||
/* The standalone embed page this map is shared/iframed as — resolved by
|
||||
utils/embedBase.js (its header documents the full ladder). In short:
|
||||
(1) profileConfig.shareUrl — a WHOLE url the HOST supplies wins (the dev
|
||||
playground passes its own #/<bundle>/<id> deep link);
|
||||
plugin gallery passes its own #/<bundle>/<profile>[/<instance>] deep link);
|
||||
(2) the `embedPath` prop — the PROFILE owns an ID→URL table and passes the
|
||||
active instance's page (parameterized profiles keyed by active tab id) —
|
||||
or the app-profile config (provided by ProfileLoader as 'profileConfig')
|
||||
|
|
@ -400,7 +400,7 @@ const sizePresets = {
|
|||
(`<bundle>/<profile>/index.html`) and join the embed base:
|
||||
profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL → the root the
|
||||
built bundle derives from its OWN script url — so a dropped tree shares
|
||||
working links on the playground, doxa.life or any CDN with no config.
|
||||
working links on the plugin gallery, doxa.life or any CDN with no config.
|
||||
Absolute urls pass through; a leading '/' is legacy root-relative;
|
||||
(3) the page's own location — each map builds to a self-contained page
|
||||
("Share shares THE MAP"), so with nothing configured the current page IS
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
|
||||
*
|
||||
* WHY: the built tree (<root>/<bundle>/<bundle>.js + <bundle>/<profile>/index.html …) is
|
||||
* dropped onto many hosts — the dev playground, doxa.life under /js/doxa-maps-build/,
|
||||
* dropped onto many hosts — the dev plugin gallery, doxa.life under /js/doxa-maps-build/,
|
||||
* any CDN subpath. Share links + embed snippets must point INTO that tree wherever it
|
||||
* lands, so profiles declare their embed page TREE-RELATIVE (`<bundle>/<profile>/index.html`
|
||||
* or `<bundle>/<profile>/<instance>/index.html`) and this module supplies the tree root.
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
* `document.baseURI` fallback), so in a built bundle it is `<treeRoot>/<bundle>/<bundle>.js`
|
||||
* and the root is two path segments up. It is trusted ONLY when the last two segments look
|
||||
* like `<name>/<name>.js` (the layout generate-tree.mjs emits). In dev (ESM) it is this
|
||||
* module's own URL (`…/library/utils/embedBase.js`) and is rejected — the playground passes
|
||||
* module's own URL (`…/library/utils/embedBase.js`) and is rejected — the plugin gallery passes
|
||||
* a whole `shareUrl` instead.
|
||||
*
|
||||
* Embed base ladder — resolveEmbedBase(profileConfig):
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
* 4. '' — none known; a tree-relative path resolves against the page
|
||||
*
|
||||
* Share URL ladder — resolveShareUrl({ shareUrl, embedPath, profileConfig }):
|
||||
* 1. shareUrl | profileConfig.shareUrl — a WHOLE url from the host wins (playground deep link)
|
||||
* 1. shareUrl | profileConfig.shareUrl — a WHOLE url from the host wins (plugin gallery deep link)
|
||||
* 2. embedPath | profileConfig.shareEmbedPath
|
||||
* absolute http(s) → passes through
|
||||
* leading '/' → legacy root-relative: location.origin + path
|
||||
|
|
@ -90,7 +90,7 @@ export function resolveShareUrl({ shareUrl, embedPath, profileConfig } = {}) {
|
|||
const cfg = profileConfig || null
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
|
||||
// (1) a whole url supplied by the host — the playground's own deep link, a CMS page, …
|
||||
// (1) a whole url supplied by the host — the plugin gallery's own deep link, a CMS page, …
|
||||
const whole = shareUrl || cfg?.shareUrl
|
||||
if (typeof whole === 'string' && whole.trim()) return whole.trim()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun x vite",
|
||||
"build": "bun run clean && bun run token && for d in app-profiles/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:drop && bun run check:parse && bun run verify && bun run spa",
|
||||
"build": "bun run clean && bun run token && for d in app-profiles/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:drop && bun run check:parse && bun run verify && bun run gallery",
|
||||
"build:watch": "bun x vite build --watch",
|
||||
"preview": "bun x vite preview",
|
||||
"clean": "node internal/clean.mjs",
|
||||
"build:bundle": "bun x vite build",
|
||||
"tree": "node internal/generate-tree.mjs",
|
||||
"spa": "node internal/generate-spa.mjs # writes public/index.html, the one root page, from public/doxa-maps/manifest.json",
|
||||
"gallery": "node internal/generate-gallery.mjs # writes public/index.html, the built plugin gallery (the one root page), from public/doxa-maps/manifest.json",
|
||||
"check:drop": "node internal/drop-portability-check.mjs",
|
||||
"check:parse": "node internal/inline-parse-check.mjs",
|
||||
"verify": "node library/scripts/research/verify-all.mjs",
|
||||
|
|
@ -35,7 +35,8 @@
|
|||
"token": "node internal/write-token.mjs",
|
||||
"smoke": "node internal/smoke.mjs",
|
||||
"smoke:embeds": "node internal/embed-smoke.mjs",
|
||||
"smoke:playground": "node internal/playground-smoke.mjs",
|
||||
"smoke:gallery": "node internal/plugin-gallery-smoke.mjs",
|
||||
"smoke:playground": "bun run smoke:gallery",
|
||||
"smoke:share": "node internal/share-roundtrip-smoke.mjs",
|
||||
"probe:legend-columns": "node internal/legend-columns-probe.mjs",
|
||||
"sync:dropin": "node internal/sync-dropin.mjs",
|
||||
|
|
|
|||
|
|
@ -20,10 +20,9 @@
|
|||
* paths under the dev server.
|
||||
*/
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
// The playground's language dropdown reuses the shell's list — one source of truth.
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
import { autoSpaDevPlugin } from './internal/playground/dev-plugin.mjs'
|
||||
import { autoSpaDevPlugin } from './internal/plugin-gallery/dev-plugin.mjs'
|
||||
import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shadow-css-comments.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
|
|
@ -64,7 +63,7 @@ function discoverBundles() {
|
|||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
// .env values (VITE_MAPBOX_TOKEN) are NOT on process.env inside this config —
|
||||
// load them explicitly so the dev playground can hand the token to each map.
|
||||
// load them explicitly so the dev plugin gallery can hand the token to each map.
|
||||
const env = loadEnv(mode, __dirname, '')
|
||||
const all = discoverBundles()
|
||||
const allNames = Object.keys(all)
|
||||
|
|
|
|||
Loading…
Reference in a new issue