Research Gallery: embed on someone else's page, in both modes
The gallery is going onto the DX-life research page, and nothing had tried it on a page it didn't generate. From a second origin, a bare <script> + <doxa-maps-spa> drew over the host's header, and 8 of 13 tabs showed an error card. Three causes, each fixed in the shell: - No :host box. The layout root is position:absolute, so it anchored to the viewport instead of the element. :host is now a positioned block with a default height the host can override. - A foreign page sets none of the globals a leaf page bakes. Every data call hit the host's own origin and got HTML back. The manifest now publishes a `runtime` block (apiBaseUrl + the public token every leaf already carries). The shell fills window.MAP_APP_API_URL / window.MAP_TOKEN only when the page hasn't set them, and forwards tk in profile-config. - Nested elements resolved assets to <tree>/../assets, a sibling pool the publisher doesn't ship (404 live). The shell now forwards an absolute <tree>/assets. This also fixes the iframe mode, where Cities with Internet and Gospel Resources were failing the same way. Also adds a `tab` attribute (plus ?tab=) to deep-link into one map, and a `tab-change` event for the host. check:gallery-embed drives every tab in both modes from a second origin. It passes here and fails on the previous tree (7 findings). EMBED.md documents the contract. The Traefik template now sends CORS for the whole tree: live manifest.json answers without Access-Control-Allow-Origin.
This commit is contained in:
parent
e7f84bb84d
commit
3a2cf0e8f2
12 changed files with 391 additions and 49 deletions
|
|
@ -63,7 +63,17 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
|
|||
| Surface | Paths | `frame-ancestors` |
|
||||
|---|---|---|
|
||||
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
|
||||
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html`, the SPA shell | **`'self'`** — preview only |
|
||||
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
|
||||
|
||||
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
other leaf. Its full embed contract (script and iframe) is in
|
||||
`plugin-designs/doxa-maps-spa/EMBED.md`.
|
||||
|
||||
**CORS is a separate requirement, and it covers the whole tree.** A script embed runs in the
|
||||
partner's document, so everything a composed surface `fetch()`es — `manifest.json` at the tree
|
||||
root, and `assets/` — needs `Access-Control-Allow-Origin: *`. Applying it only to the ≥2-segment
|
||||
embed pages leaves `manifest.json` uncovered. `bun run check:gallery-embed` proves the whole
|
||||
chain from a second origin.
|
||||
|
||||
`frame-ancestors` can ONLY be set by an HTTP response header (HTML `<meta>` cannot set it), so
|
||||
**the server/CDN owns this split — the bundler cannot enforce it.** Whoever serves the tree must
|
||||
|
|
|
|||
135
internal/check-research-gallery-embed.mjs
Normal file
135
internal/check-research-gallery-embed.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-research-gallery-embed.mjs — the cap-5 Research Gallery (<doxa-maps-spa>) must embed on
|
||||
* SOMEONE ELSE'S page, in both embed modes, with every nested map painting.
|
||||
*
|
||||
* WHAT THIS PROTECTS. The gallery is going onto the DX-life research page. Every other gate loads
|
||||
* the gallery on its OWN generated leaf page, which bakes the three things a nested map needs —
|
||||
* window.MAP_APP_API_URL, window.MAP_TOKEN and a correct assets path — and pins the tag
|
||||
* position:fixed. A foreign page supplies none of that, so those gates cannot see the embed failing.
|
||||
* Measured 2026-09-24 before the fix, from a second origin: the gallery drew OVER the host's header
|
||||
* (the element had no :host box, so the layout's position:absolute root anchored to the viewport),
|
||||
* and 8 of 13 tabs showed an error card because every data call hit the host's origin.
|
||||
*
|
||||
* HOW. Serves the built tree with internal/serve-strict.mjs (production parity: strict directories,
|
||||
* ACAO:*) on 127.0.0.1, and a stand-in host page on `localhost` — a DIFFERENT ORIGIN, so CORS is
|
||||
* genuinely exercised. The host page has its own header and footer around the embed. Then, per mode:
|
||||
* js — `<script src=…/doxa-maps-spa.js>` + a bare `<doxa-maps-spa>` tag, NO attributes: the
|
||||
* zero-config embed the docs promise.
|
||||
* iframe — an <iframe> at the gallery's leaf page.
|
||||
* and for every tab in the rail: select it, wait for it to mount, then assert.
|
||||
*
|
||||
* ASSERTIONS (a FAIL names the tab and the reason):
|
||||
* 1. js mode: the layout root sits INSIDE the element's box (not at the viewport origin).
|
||||
* 2. every tab reaches state 'ready' (never 'error', never stuck 'idle'/'loading').
|
||||
* 3. every CURATED tab paints a WebGL canvas — they are all maps or map composites.
|
||||
* Experimental tabs need only mount: some are cards or legacy frames with no canvas.
|
||||
* 4. zero failed requests to the TREE's own origin (a 404/CORS on our assets is our bug; third-
|
||||
* party tile/glyph hiccups are reported, not failed — they are not this repo's to fix).
|
||||
*
|
||||
* PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server).
|
||||
* Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id]
|
||||
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser)
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
const MODES = argOf('--mode', '') ? [argOf('--mode', '')] : ['js', 'iframe']
|
||||
const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null
|
||||
// ★ FREE PORTS, ASKED OF THE OS — never fixed ones. A stray server left on a fixed port by an
|
||||
// earlier crashed run answered in place of the tree under test, and this gate PASSED a known-broken
|
||||
// tree (caught by its own negative control, 2026-09-24): spawn failed with EADDRINUSE silently and
|
||||
// the old server served the fixed build. A port the OS just handed out cannot be someone else's.
|
||||
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const TREE_PORT = await freePort()
|
||||
const HOST_PORT = await freePort()
|
||||
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
|
||||
|
||||
if (!existsSync(resolve(ROOT, 'public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js'))) {
|
||||
console.log('SKIP check-research-gallery-embed: no built gallery at public/doxa-maps/doxa-maps-spa/')
|
||||
process.exit(2)
|
||||
}
|
||||
let chromium
|
||||
try { ({ chromium } = await import('playwright')) } catch {
|
||||
console.log('SKIP check-research-gallery-embed: playwright not installed'); process.exit(2)
|
||||
}
|
||||
|
||||
let mode = 'js'
|
||||
const hostPage = () => {
|
||||
const embed = mode === 'iframe'
|
||||
? `<iframe src="${TREE}/doxa-maps-spa/doxa-maps-spa/index.html" style="width:100%;height:760px;border:0"></iframe>`
|
||||
: `<script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa></doxa-maps-spa>`
|
||||
return `<!doctype html><html><head><meta charset=utf-8><title>host</title>
|
||||
<style>body{margin:0} header,footer{height:60px;background:#eee}</style></head>
|
||||
<body><header id=hdr>HOST HEADER</header><main style="max-width:1200px;margin:0 auto">${embed}</main><footer>HOST FOOTER</footer></body></html>`
|
||||
}
|
||||
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
|
||||
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(hostPage()) }).listen(HOST_PORT, 'localhost')
|
||||
const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} }
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
|
||||
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
|
||||
const fails = []
|
||||
try {
|
||||
for (mode of MODES) {
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
|
||||
const own = new Set(), foreign = new Set()
|
||||
const note = (url, why) => (url.startsWith(`http://127.0.0.1:${TREE_PORT}/`) ? own : foreign).add(why + ' ' + url.split('?')[0])
|
||||
page.on('response', r => { if (r.status() >= 400) note(r.url(), String(r.status())) })
|
||||
page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) })
|
||||
await page.goto(`http://localhost:${HOST_PORT}/`)
|
||||
const frame = mode === 'iframe' ? await (await page.waitForSelector('iframe')).contentFrame() : page.mainFrame()
|
||||
await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 30000 })
|
||||
|
||||
if (mode === 'js') {
|
||||
const g = await page.evaluate(() => {
|
||||
const s = document.querySelector('doxa-maps-spa'), v = s.shadowRoot.querySelector('.vtl')
|
||||
const a = s.getBoundingClientRect(), b = v.getBoundingClientRect()
|
||||
return { host: [a.top, a.left, a.width, a.height].map(Math.round), vtl: [b.top, b.left, b.width, b.height].map(Math.round) }
|
||||
})
|
||||
const inside = g.vtl[0] >= g.host[0] - 1 && g.vtl[1] >= g.host[1] - 1 && g.vtl[2] <= g.host[2] + 1 && g.vtl[3] <= g.host[3] + 1 && g.host[3] >= 400
|
||||
console.log(`${inside ? 'PASS' : 'FAIL'} [js] layout inside the host box — host ${g.host} layout ${g.vtl}`)
|
||||
if (!inside) fails.push(`[js] layout escaped the <doxa-maps-spa> box (host ${g.host}, layout ${g.vtl})`)
|
||||
}
|
||||
|
||||
const tabs = await frame.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => ({ id: t.id, discovered: !!t.discovered })))
|
||||
for (const { id, discovered } of tabs) {
|
||||
if (ONLY && !ONLY.includes(id)) continue
|
||||
await frame.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
|
||||
let v
|
||||
for (let k = 0; k < 50; k++) {
|
||||
await page.waitForTimeout(400)
|
||||
v = await frame.evaluate(id => {
|
||||
const vm = document.querySelector('doxa-maps-spa')._instance.proxy
|
||||
const i = vm.tabs.findIndex(x => x.id === id), t = vm.tabs[i]
|
||||
const el = vm._slots && vm._slots[i] && vm._slots[i].firstElementChild
|
||||
let canvas = 0
|
||||
const walk = r => { for (const n of r.querySelectorAll('*')) { if (n.tagName === 'CANVAS') canvas++; if (n.shadowRoot) walk(n.shadowRoot) } }
|
||||
if (el) { walk(el); if (el.shadowRoot) walk(el.shadowRoot) }
|
||||
return { state: t.state, error: t.error, canvas }
|
||||
}, id)
|
||||
if (v.state === 'error' || (v.state === 'ready' && (v.canvas || discovered && k > 5))) break
|
||||
}
|
||||
const needCanvas = !discovered
|
||||
const ok = v.state === 'ready' && (!needCanvas || v.canvas > 0)
|
||||
const why = v.state !== 'ready' ? `state ${v.state}${v.error ? ' — ' + v.error : ''}` : (needCanvas && !v.canvas ? 'mounted but painted no map canvas' : '')
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] ${id}${why ? ' — ' + why : ''}`)
|
||||
if (!ok) fails.push(`[${mode}] ${id}: ${why}`)
|
||||
}
|
||||
for (const f of own) { console.log(`FAIL [${mode}] tree request failed: ${f}`); fails.push(`[${mode}] ${f}`) }
|
||||
for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`)
|
||||
await page.close()
|
||||
}
|
||||
} finally { await browser.close(); cleanup() }
|
||||
|
||||
if (fails.length) {
|
||||
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin in ' + MODES.join(' + ') + ' mode')
|
||||
|
|
@ -1233,6 +1233,16 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
|
|||
embedBase: EMBED_BASE,
|
||||
bundleCount: bundles.length,
|
||||
profileCount: bundles.reduce((n, b) => n + b.profiles.length, 0),
|
||||
// THE PAGE GLOBALS A LEAF PAGE BAKES, published for embeds that have no leaf page. A composed
|
||||
// surface (the cap-5 Research Gallery) mounted by `<script>` on a FOREIGN page runs in that
|
||||
// page's document, which carries neither window.MAP_APP_API_URL nor window.MAP_TOKEN — measured
|
||||
// 2026-09-24: 8 of 13 gallery tabs errored with the host's HTML in place of API JSON. The shell
|
||||
// reads this block as its default (a host attribute still wins). ADDITIVE: every key is optional
|
||||
// and old consumers ignore it. The token is the same opt-in public pk. every leaf already bakes.
|
||||
runtime: {
|
||||
...(DEST_CONFIG.apiBaseUrl ? { apiBaseUrl: DEST_CONFIG.apiBaseUrl } : {}),
|
||||
...(bakedToken ? { token: bakedToken } : {}),
|
||||
},
|
||||
// `extraConfig` is a GENERATOR-INTERNAL carrier (<profile>.meta.json `config` → the leaf
|
||||
// page's BASE_CONFIG) and is stripped here so the published manifest shape is unchanged.
|
||||
// Consumers read the manifest as a contract; a new key would be a silent schema change.
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@
|
|||
"check:synced-css": "node internal/check-synced-css-copies.mjs",
|
||||
"check:composite-reg": "node internal/check-composite-registration.mjs",
|
||||
"check:filter-width": "node internal/check-filter-width-stable.mjs",
|
||||
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs # the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes — bare <script>+<doxa-maps-spa> and <iframe> — with the layout inside the host box and every curated tab painting a map. Starts its own servers on OS-assigned ports; needs only a built tree. --mode js|iframe, --only <tab,tab>. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
|
||||
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
|
||||
|
|
|
|||
78
plugin-designs/doxa-maps-spa/EMBED.md
Normal file
78
plugin-designs/doxa-maps-spa/EMBED.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Embedding the Research Gallery
|
||||
|
||||
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
|
||||
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
|
||||
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
|
||||
`https://YOUR-HOST/doxa-maps`.
|
||||
|
||||
## Script embed (recommended)
|
||||
|
||||
```html
|
||||
<script src="TREE/doxa-maps-spa/doxa-maps-spa.js"></script>
|
||||
<doxa-maps-spa></doxa-maps-spa>
|
||||
```
|
||||
|
||||
That is the whole embed. The element finds its tree from its own `<script>` URL, reads
|
||||
`TREE/manifest.json`, and takes the data API and public map token from the manifest's
|
||||
`runtime` block. It sizes itself as a block that is `85vh` tall (minimum 480px) and lays out
|
||||
inside its own box. To change the height, either:
|
||||
|
||||
```html
|
||||
<doxa-maps-spa style="height: 900px"></doxa-maps-spa>
|
||||
<!-- or, from your stylesheet -->
|
||||
<style> doxa-maps-spa { --doxa-maps-spa-height: 70vh; } </style>
|
||||
```
|
||||
|
||||
## iframe embed
|
||||
|
||||
```html
|
||||
<iframe src="TREE/doxa-maps-spa/doxa-maps-spa/index.html"
|
||||
style="width:100%; height:85vh; border:0" allow="fullscreen"></iframe>
|
||||
```
|
||||
|
||||
Add `?tab=<id>` to the `src` to open a specific map.
|
||||
|
||||
## Attributes (all optional)
|
||||
|
||||
| Attribute | Default | What it does |
|
||||
|---|---|---|
|
||||
| `tab` | device default (Big Picture on desktop, Simple Research on phones) | Tab id to open first. Changing it later switches tabs. `?tab=` on the page URL works too. |
|
||||
| `language` | parent page / saved choice | UI language pushed to every tab (`en`, `es`, `fr`, …). |
|
||||
| `api-base-url` | manifest `runtime.apiBaseUrl` | Data API. Only applied when the page has not already set `window.MAP_APP_API_URL`. |
|
||||
| `tk` | manifest `runtime.token` | Mapbox public token. Only fills `window.MAP_TOKEN` when the page has not set one. |
|
||||
| `token-url` | none | Token endpoint forwarded to each map. |
|
||||
| `tree-base` | derived from the script URL | Pin the tree root explicitly (needed only when the script is re-hosted apart from its tree). |
|
||||
| `heading` | `DX-Maps` | Shell title (shown only when the shell header is enabled). |
|
||||
|
||||
Tab ids: `dashboard-plus-grid` (Big Picture), `research-map-plus-grid` (Simple Research),
|
||||
`regions-dashboard`, `upg-100`, `prayer-map` (Prayer Progress), `cities-internet`,
|
||||
`gospel-resources`, and every Experimental bundle by its bundle name.
|
||||
|
||||
## Events
|
||||
|
||||
The element dispatches `tab-change` whenever the visible map changes, so the host page can
|
||||
mirror it into its own URL or analytics:
|
||||
|
||||
```js
|
||||
document.querySelector('doxa-maps-spa')
|
||||
.addEventListener('tab-change', (e) => console.log(e.detail[0].id, e.detail[0].label))
|
||||
```
|
||||
|
||||
## What the server must send
|
||||
|
||||
Both headers are **required** on a host other than the one serving the tree:
|
||||
|
||||
- **`Access-Control-Allow-Origin: *` on the whole tree**, including `manifest.json` at its root
|
||||
and `assets/`. The gallery and its maps `fetch()` these after mounting, and `fetch()` enforces
|
||||
CORS; a `<script>` tag alone does not. If `manifest.json` lacks the header, every tab shows
|
||||
"Could not load …".
|
||||
- **`Content-Security-Policy: frame-ancestors *`** on `doxa-maps-spa/doxa-maps-spa/index.html`,
|
||||
for the iframe embed only.
|
||||
|
||||
`serving/traefik-embed-headers.yaml` has a ready rule for both.
|
||||
|
||||
## Prove it
|
||||
|
||||
```sh
|
||||
bun run check:gallery-embed # serves the built tree + a foreign host page, drives every tab in both modes
|
||||
```
|
||||
|
|
@ -727,8 +727,21 @@ export default defineComponent({
|
|||
// effectiveLocale below, which prefers this prop, then profile-config, then the manual switcher.
|
||||
// NOTE: no `locale` PROP here on purpose — `locale` is a data() field (the manual switcher's
|
||||
// state); a same-named prop+data collides in Vue. The alias lives in profile-config instead.
|
||||
language: { type: String, default: '' }
|
||||
language: { type: String, default: '' },
|
||||
// ── THE EMBED CONTRACT (card DELEGATE-NEW-TAB-REGA-RESEARCH-GALLERY-CAP5-EMBEDDABILITY) ──
|
||||
// A host that drops <doxa-maps-spa> onto its own page (the DX-life research page) supplies NONE
|
||||
// of the page globals a generated leaf page bakes — window.MAP_APP_API_URL, window.MAP_TOKEN, a
|
||||
// correct assets path. Measured 2026-09-24 from a second origin: the shell mounted and 8 of 13
|
||||
// tabs painted an error card, every data call returning the HOST's HTML ("API returned text/html
|
||||
// … Server at may be down"). Each of these is therefore settable here, and each defaults from
|
||||
// the tree's own manifest.json (its `runtime` block), so a bare two-line embed needs no config.
|
||||
// See EMBED.md beside this file.
|
||||
apiBaseUrl: { type: String, default: '' },
|
||||
// Which tab opens first, by tab id (e.g. "upg-100", "gospel-resources"). Lets a host page deep-
|
||||
// link into one map of the gallery. Falls back to ?tab= on the page URL, then the device default.
|
||||
tab: { type: String, default: '' }
|
||||
},
|
||||
emits: ['tab-change'],
|
||||
setup() {
|
||||
// index.js provides the tree base it derived from the running <script> URL. Prop/attr override
|
||||
// it if present (resolved in `base` below).
|
||||
|
|
@ -764,6 +777,10 @@ export default defineComponent({
|
|||
// scriptUrls are resolved against THIS, not the document — see resolve(). Empty until a
|
||||
// manifest lands, which is exactly when there are no scriptUrls to resolve anyway.
|
||||
manifestBase: '',
|
||||
// The manifest's `runtime` block — the build's own apiBaseUrl + public map token, published so
|
||||
// an embed on a foreign page can default to them. {} until a manifest lands (or on an older
|
||||
// tree that predates the block), in which case only host-supplied values are forwarded.
|
||||
runtime: {},
|
||||
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
||||
locale: loadLocale(),
|
||||
localeOpen: false,
|
||||
|
|
@ -800,6 +817,24 @@ export default defineComponent({
|
|||
if (!raw) return '' // relative: leaf pages sit beside this bundle's folder
|
||||
return raw.replace(/\/+$/, '') + '/'
|
||||
},
|
||||
// The ABSOLUTE url of the tree's shared asset pool, forwarded to every nested element.
|
||||
//
|
||||
// ★ WHY THE SHELL MUST SAY IT. The pool ships INSIDE the tree (<tree>/assets/ — generate-tree's
|
||||
// assetsBaseFor), but a nested element given no absolute assetsBase falls to
|
||||
// library/utils/embedBase.js resolveAssetsBase, which climbs to <tree>/../assets — the web-root
|
||||
// SIBLING pool the publisher does not ship. Measured 2026-09-24: live
|
||||
// /plugin-assets/doxa-maps/assets/tables/… = 200, /plugin-assets/assets/tables/… = 404, and
|
||||
// locally the sibling copy lacks internet-by-country.json / gospel-by-country.json, so
|
||||
// Cities with Internet and Gospel Resources failed in BOTH embed modes. Fonts fared worse: a
|
||||
// raw './assets' resolved against the shell's own leaf page (…/doxa-maps-spa/doxa-maps-spa/assets).
|
||||
// An absolute value is ladder rung 1 there, so saying it here fixes every tab at once.
|
||||
// Precedence: an absolute host value, then the directory the manifest answered from (that IS the
|
||||
// tree root in both the built and dev trees), then nothing (children keep their own ladder).
|
||||
assetsBase() {
|
||||
const v = String(this.cfg.assetsBase || '').trim()
|
||||
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v.replace(/\/+$/, '')
|
||||
return this.manifestBase ? this.manifestBase.replace(/\/+$/, '') + '/assets' : ''
|
||||
},
|
||||
// The GROUPED rail model the shared VerticalTabsLayout paints, built from NAV_MODEL (the Driver's
|
||||
// ported sections/folder) projected over the REAL tabs. Each row's `key` = the tab id (stable),
|
||||
// which onSelect maps back to the tab index the mount machinery uses — so the sections are pure
|
||||
|
|
@ -883,7 +918,7 @@ export default defineComponent({
|
|||
this.watchWidth()
|
||||
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
|
||||
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
|
||||
this.select(this.firstVisibleIndex())
|
||||
this.select(this.initialIndex())
|
||||
// …then read the manifest in the background: it completes each curated tab (tag + scriptUrl) and
|
||||
// fills the Experimental folder. Deliberately AFTER select() and never awaited — the rail must
|
||||
// paint instantly. select() defers the actual mount until scriptUrl exists, and applyManifest
|
||||
|
|
@ -911,19 +946,13 @@ export default defineComponent({
|
|||
try {
|
||||
const slot = this._slots && this._slots[this.activeIndex]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute) {
|
||||
// Rebuild the SAME config the mount used, so a language switch cannot silently drop the
|
||||
// profile/instance/token and re-render a different view in the new language.
|
||||
const pc = { profile: t.profile || t.id, instanceId: 'dms-' + t.id }
|
||||
if (t.instance) pc.instanceId = t.instance
|
||||
if (loc) pc.locale = loc
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
|
||||
if (this.base) pc.treeBase = this.base
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
// Rebuild the SAME config the mount used (childConfig), so a language switch cannot silently
|
||||
// drop the profile/instance/token and re-render a different view in the new language.
|
||||
if (el && el.setAttribute) el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
|
||||
} catch { /* non-fatal: the tab keeps its previous language */ }
|
||||
}
|
||||
},
|
||||
// A host re-pointing the `tab` attribute after mount (e.g. from its own nav) switches the gallery.
|
||||
tab(id) { if (id) this.onSelect(id) }
|
||||
},
|
||||
methods: {
|
||||
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
|
||||
|
|
@ -1039,6 +1068,10 @@ export default defineComponent({
|
|||
// then append the discovered Experimental tabs — and finally (re)drive the active tab's mount,
|
||||
// which could not run before its scriptUrl existed.
|
||||
applyManifest(m) {
|
||||
// The build's published defaults for a foreign-page embed (apiBaseUrl + public token). Set the
|
||||
// page globals BEFORE select() below mounts anything — a nested map reads them at init/fetch.
|
||||
this.runtime = (m && m.runtime && typeof m.runtime === 'object') ? m.runtime : {}
|
||||
this.applyPageGlobals()
|
||||
const patch = resolveTabsFromManifest(m, TAB_DEFS)
|
||||
const activeId = this.activeKey
|
||||
// PRUNE rather than paint a dead row: a curated tab whose bundle is absent from this tree can
|
||||
|
|
@ -1060,8 +1093,29 @@ export default defineComponent({
|
|||
this.tabs = kept
|
||||
// Re-point activeIndex at the SAME tab id — pruning can shift indices under it, and activeIndex
|
||||
// is what every pane is keyed on. If that tab is gone, fall back to the first visible row.
|
||||
// A host-requested tab may be one only the manifest could supply (an Experimental bundle), so
|
||||
// it is honoured here as well as at mount.
|
||||
const want = this.requestedTab()
|
||||
const w = want ? this.tabs.findIndex(t => t.id === want) : -1
|
||||
const i = this.tabs.findIndex(t => t.id === activeId)
|
||||
this.select(i >= 0 ? i : this.firstVisibleIndex())
|
||||
if (w >= 0 && !this.isBigPictureDisabled(want) && !this._honouredTab) this.select(w)
|
||||
else this.select(i >= 0 ? i : this.firstVisibleIndex())
|
||||
this._honouredTab = true
|
||||
},
|
||||
// The tab a HOST asked to open first: the `tab` attribute, then profile-config.tab, then ?tab= on
|
||||
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked.
|
||||
requestedTab() {
|
||||
let q = ''
|
||||
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
|
||||
return String(this.tab || this.cfg.tab || q || '').trim()
|
||||
},
|
||||
// The index to open on mount: the requested tab when it exists and is enabled at this width,
|
||||
// otherwise the device default (firstVisibleIndex).
|
||||
initialIndex() {
|
||||
const want = this.requestedTab()
|
||||
const i = want ? this.tabs.findIndex(t => t.id === want) : -1
|
||||
if (i >= 0) this._honouredTab = true
|
||||
return (i >= 0 && !this.isBigPictureDisabled(want)) ? i : this.firstVisibleIndex()
|
||||
},
|
||||
// Switch UI language. Persist the choice and let the `effectiveLocale` WATCHER apply it — it
|
||||
// re-sets profile-config on the active tab's element, generically, with no per-tab special case.
|
||||
|
|
@ -1140,6 +1194,9 @@ export default defineComponent({
|
|||
}
|
||||
const t = this.tabs[i]
|
||||
if (!t) return
|
||||
// Tell the host which map is showing (a CustomEvent 'tab-change' on <doxa-maps-spa>, detail[0] =
|
||||
// { id, label }), so a host page can mirror it into its own URL or analytics.
|
||||
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: t.label }) }
|
||||
// Not yet completed by the manifest — applyManifest calls select() again once it lands.
|
||||
if (!t.scriptUrl) return
|
||||
// A heavy tab's slot only exists after its v-if subtree renders, so wait a tick before mounting
|
||||
|
|
@ -1166,6 +1223,43 @@ export default defineComponent({
|
|||
// deleted rather than kept "just in case": an unreachable second token path is one more thing to
|
||||
// keep in sync, and the ?locale= it emitted is precisely what produced the 404 URLs the Driver
|
||||
// saw (".../index.html?locale=en").
|
||||
// THE ONE profile-config every nested element receives — built here and nowhere else, so the mount
|
||||
// and the language watcher can never disagree about what a tab was told.
|
||||
childConfig(t) {
|
||||
// `profile` is the manifest's profile name, NOT the tab id: one tag can serve several profiles
|
||||
// (gospel-resources ships four views through <gospel-resources-map>), so the tab id would select
|
||||
// the wrong view — or none. instanceId stays tab-scoped so two panes of the same element never
|
||||
// collide; a preset instance replaces it only where this tree ships one.
|
||||
const pc = { profile: t.profile || t.id, instanceId: t.instance || ('dms-' + t.id) }
|
||||
if (this.effectiveLocale) pc.locale = this.effectiveLocale
|
||||
// TOKEN LADDER. Direct mounting shares THIS document, so a leaf page's baked token does not
|
||||
// apply — there is no leaf page. Host prop/config first, then the tree's published public token
|
||||
// (manifest runtime), and when neither exists the element keeps its own ladder (window.MAP_TOKEN
|
||||
// on the SPA's own leaf page, which bakes it).
|
||||
const tk = this.tk || this.cfg.tk || this.runtime.token
|
||||
if (tk) pc.tk = tk
|
||||
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
|
||||
// PASS THE TREE BASE DOWN — a nested cap-5 shell (or any element that fetches the manifest)
|
||||
// cannot guess it, and guessing is what produced the original 404 class.
|
||||
if (this.base) pc.treeBase = this.base
|
||||
if (this.assetsBase) pc.assetsBase = this.assetsBase
|
||||
return pc
|
||||
},
|
||||
// The PAGE GLOBALS a generated leaf page bakes and a foreign host page does not — set here, and
|
||||
// ONLY when the page has not set them itself: a host's own value wins, always.
|
||||
// window.MAP_APP_API_URL — the data API every nested map calls. library/api/apiBaseUrl.js reads
|
||||
// only this global (there is no profile-config path), so the shell cannot pass it any other way.
|
||||
// window.MAP_TOKEN — profile-config.tk (childConfig) reaches every element with a ProfileLoader,
|
||||
// but a BAKED composite mounts its map components directly and reads only this global.
|
||||
// Measured 2026-09-24: Big Picture (dashboard-plus-grid) logged "No Mapbox access token
|
||||
// provided" on a foreign page and painted no map, while every other tab took tk from config.
|
||||
applyPageGlobals() {
|
||||
if (typeof window === 'undefined') return
|
||||
const api = (this.apiBaseUrl || this.cfg.apiBaseUrl || this.runtime.apiBaseUrl || '').trim()
|
||||
if (api && !window.MAP_APP_API_URL) window.MAP_APP_API_URL = api.replace(/\/+$/, '')
|
||||
const tk = this.tk || this.cfg.tk || this.runtime.token
|
||||
if (tk && !window.MAP_TOKEN) window.MAP_TOKEN = tk
|
||||
},
|
||||
// Mount a tab's REAL custom element: inject the bundle's script once, wait for the tag to be
|
||||
// registered, then create it in the pane's empty slot. This is the ONE path for every tab.
|
||||
mountElement(i) {
|
||||
|
|
@ -1190,26 +1284,7 @@ export default defineComponent({
|
|||
// Remove the loading hint, insert the real element with its profile-config.
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(t.tag)
|
||||
// `profile` is the manifest's profile name, NOT the tab id: one tag can serve several
|
||||
// profiles (gospel-resources ships four views through <gospel-resources-map>), so the tab
|
||||
// id would select the wrong view — or none. instanceId stays tab-scoped so two panes of the
|
||||
// same element never collide.
|
||||
const pc = { profile: t.profile || t.id, instanceId: 'dms-' + t.id }
|
||||
// A preset instance, only where this tree actually ships one (see resolveTabsFromManifest).
|
||||
if (t.instance) pc.instanceId = t.instance
|
||||
// Honor the effective language on first mount too (not only on a later manual switch), so a
|
||||
// tab opened with an inherited `language` prop already renders in that language.
|
||||
if (this.effectiveLocale) pc.locale = this.effectiveLocale
|
||||
// TOKEN LADDER. Direct mounting shares THIS document, so a nested map reads the page's
|
||||
// window.MAP_TOKEN — the leaf pages' baked token no longer applies, because there are no
|
||||
// leaf pages. Forward whatever the host handed us; each element keeps its own full ladder
|
||||
// when we pass nothing.
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
|
||||
// PASS THE TREE BASE DOWN — a nested cap-5 shell (or any element that fetches the manifest)
|
||||
// cannot guess it, and guessing is what produced the original 404 class.
|
||||
if (this.base) pc.treeBase = this.base
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
|
||||
// ★ ALSO SET tree-base AS A PLAIN ATTRIBUTE, not only inside profile-config. Not every
|
||||
// element reads profile-config: an element that mounts its OWN SFC has no ProfileLoader
|
||||
// above it and therefore no provide('profileConfig'), so it takes tree-base as a prop
|
||||
|
|
@ -1247,6 +1322,20 @@ export default defineComponent({
|
|||
</script>
|
||||
|
||||
<style>
|
||||
/* ★ THE HOST BOX — what makes a bare <doxa-maps-spa> embeddable on someone else's page.
|
||||
The shared layout's root is `.vtl { position:absolute; inset:0 }`, and a custom element is
|
||||
display:INLINE with no positioning by default. So on any page that did not happen to wrap the tag
|
||||
in a positioned box, .vtl anchored to the nearest positioned ANCESTOR — usually the viewport —
|
||||
and the gallery painted over the host's own header. Measured 2026-09-24 on a foreign test page:
|
||||
the element's box sat at (59,100) 1200x760 while .vtl drew at (0,0) 1400x760, covering the site
|
||||
header. The generated leaf page never showed it because it pins the tag `position:fixed`.
|
||||
:host makes the element its OWN containing block with a real height, so the gallery always lives
|
||||
inside the box the host placed it in. The height is a DEFAULT, not a lock: a host's own
|
||||
style="height:…" (or any page rule) outranks :host, and --doxa-maps-spa-height tunes it without
|
||||
CSS specificity games. */
|
||||
:host { display: block; position: relative; height: var(--doxa-maps-spa-height, 85vh); min-height: 480px; }
|
||||
:host([hidden]) { display: none; }
|
||||
|
||||
/* The shell (top bar + rail + content frame) is the shared VerticalTabsLayout. Only the PANE-local
|
||||
styles for this SPA's own tab content remain here — panes fill the shell's content slot, and the
|
||||
element slot / loading hint each fill their pane.
|
||||
|
|
|
|||
14
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
14
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
8
public/doxa-maps/manifest.json
generated
8
public/doxa-maps/manifest.json
generated
|
|
@ -4,6 +4,10 @@
|
|||
"embedBase": ".",
|
||||
"bundleCount": 22,
|
||||
"profileCount": 35,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life",
|
||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||
},
|
||||
"bundles": [
|
||||
{
|
||||
"bundle": "all-people-groups",
|
||||
|
|
@ -298,7 +302,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8",
|
||||
"integrity": "sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -322,7 +326,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,8 +3,18 @@
|
|||
# Direct-embed pages (>=2 path segments under the tree) must be framable anywhere;
|
||||
# staging pages stay preview-only. frame-ancestors is header-only, so the proxy
|
||||
# owns this — the bundler can only document and template it.
|
||||
#
|
||||
# CORS (added 2026-09-24): the WHOLE tree must send Access-Control-Allow-Origin, not only the
|
||||
# embed pages. A composed surface (the cap-5 Research Gallery, cap-4 composites) mounted by
|
||||
# <script> on a partner page fetch()es manifest.json at the tree ROOT and assets/ after it mounts,
|
||||
# and fetch() enforces CORS. Measured live: manifest.json answered 200 with NO ACAO (it sits one
|
||||
# segment deep, outside the embed-pages rule), so every gallery tab would fail on doxa.life.
|
||||
http:
|
||||
middlewares:
|
||||
doxa-maps-cors:
|
||||
headers:
|
||||
accessControlAllowOriginList: ["*"]
|
||||
accessControlAllowMethods: ["GET", "HEAD", "OPTIONS"]
|
||||
doxa-maps-embed-framable:
|
||||
headers:
|
||||
customResponseHeaders:
|
||||
|
|
@ -14,5 +24,10 @@ http:
|
|||
doxa-maps-embed-pages:
|
||||
rule: "PathRegexp(`^/doxa-maps/[^/]+/[^/]+/`)"
|
||||
priority: 70 # above the site catch-all
|
||||
middlewares: [doxa-maps-embed-framable]
|
||||
middlewares: [doxa-maps-embed-framable, doxa-maps-cors]
|
||||
service: your-static-service
|
||||
doxa-maps-tree:
|
||||
rule: "PathPrefix(`/doxa-maps/`)"
|
||||
priority: 60 # below the embed-pages router, above the site catch-all
|
||||
middlewares: [doxa-maps-cors]
|
||||
service: your-static-service
|
||||
|
|
|
|||
Loading…
Reference in a new issue