doxa-maps-cdn/app/pages/embed-test.vue
doxa-maps 5dea900183 Plugin Studio branch: the gallery and maps addresses are read per request, never baked
This branch sends readers to the Research Gallery (the public Plugin Studio
on its own subdomain) and loads the site's maps from that subdomain's /cdn.
Both addresses belong to the deployment:
  NUXT_PUBLIC_RESEARCH_GALLERY_URL  https://research.<host>/
  NUXT_PUBLIC_DX_MAPS_BASE          https://research.<host>/cdn/dx-maps

About 4,000 pages are prerendered, and a prerendered page carries the
build's runtime config, so an address read there is baked into the build.
One build could then serve only one deployment; the CDN branch failed that
way. So neither address is in nuxt.config any more:
- the server reads both from its environment per request
  (server/utils/dx-runtime.ts);
- GET /api/dx-runtime answers them, no-store. Every map (DoxaMapSlot via
  useDoxaMap, /research, /embed-test, and the static pages under public/)
  asks it first, then loads its bundle from that tree. The map's element is
  added only once the tree is known, and the map bundle's preload is added
  then, still while Mapbox downloads;
- every gallery link, the Research menu item, and the /regions and
  /countries index rules go to this site's /research-gallery?tab=&lang=,
  which 302s (no-store) to the gallery. It works without JavaScript, and a
  prerendered page names no host;
- old /js/doxa-maps and /embed/doxa-maps-build links are redirected per
  request by server/middleware/legacy-map-links.ts. They were build-time
  route rules.

No fallback host: the maps-dev and research defaults are gone. When a
setting is unset, /research-gallery answers 503 and each map says which
setting to set. Changing an address needs a restart, not a rebuild.
2026-09-29 05:09:37 +00:00

198 lines
8.8 KiB
Vue

<script setup lang="ts">
// /embed-test — proves that EVERY dx-maps plugin built for the web embeds on this site as JavaScript
// (no iframe). The list is read at runtime from the dx-maps tree's manifest.json, never typed here:
// section 1 is every profile whose stage is "production", section 2 every other profile. A profile whose
// manifest `targets` leaves out "web" (a desktop-only plugin) is skipped. So a plugin promoted, added or
// retired changes this page by itself. Each card loads its bundle's script from the same tree the rest of the site uses and adds
// the element the bundle defines, the way the manifest's embed.script snippet does.
//
// A map owns a WebGL context and a browser allows only a handful at once, so a card MOUNTS its
// element when it scrolls into view and REMOVES it when it scrolls far away. ?only=<bundle>/<profile>
// (comma-separated, or a bundle name) shows just those cards, for a single-plugin check.
import { mapboxTokenIfChosen } from '~/utils/mapProvider'
useHead({ title: 'Embed test', meta: [{ name: 'robots', content: 'noindex' }] })
interface Card {
key: string
bundle: string
profile: string
tag: string
script: string
integrity: string
description: string
production: boolean
locale: string // the language handed to the element (data-locale, for a probe)
state: 'idle' | 'loading' | 'mounted' | 'error'
error: string
}
const runtimeConfig = useRuntimeConfig()
const route = useRoute()
const { locale } = useI18n() // the site's language, handed to every plugin (it is in the path, not ?lang)
const cards = ref<Card[]>([])
const status = ref('Reading the manifest…')
const hosts = new Map<string, HTMLElement>()
let observer: IntersectionObserver | null = null
// The dx-maps tree: the deployment's NUXT_PUBLIC_DX_MAPS_BASE, asked from this site's server when the page
// mounts (loadDxRuntime), never read from the page's own config.
let tree = ''
function treeBase(): string {
return tree
}
const resolveUrl = (base: string, ref: string) =>
/^(https?:)?\/\//.test(ref) ? ref : ref.startsWith('/') ? new URL(ref, base).href : `${base}/${ref.replace(/^\.\//, '')}`
const scripts = new Map<string, Promise<void>>()
function loadScript(src: string, integrity: string): Promise<void> {
if (!scripts.has(src)) {
scripts.set(src, new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = src
if (integrity) { s.integrity = integrity; s.crossOrigin = 'anonymous' }
s.onload = () => resolve()
s.onerror = () => reject(new Error(`the script did not load: ${src}`))
document.head.appendChild(s)
}))
}
return scripts.get(src)!
}
// The element's config, in the site's CURRENT language: set on mount and again on every language switch,
// so a mounted plugin follows the switcher (profile-config.locale for the maps, the language attribute for
// the shells).
function applyLocale(el: Element, card: Card) {
const token = mapboxTokenIfChosen(runtimeConfig.public)
el.setAttribute('profile-config', JSON.stringify({
profile: card.profile,
assetsBase: `${treeBase()}/assets`,
locale: locale.value,
// MapLibre by default; Mapbox only when NUXT_PUBLIC_MAP_PROVIDER=mapbox and a token is set
...(token ? { tk: token } : { provider: 'maplibre' })
}))
el.setAttribute('language', locale.value)
card.locale = locale.value
}
watch(locale, () => {
for (const c of cards.value) {
const el = c.state === 'mounted' ? hosts.get(c.key)?.firstElementChild : null
if (el) applyLocale(el, c)
}
})
async function mount(card: Card) {
const host = hosts.get(card.key)
if (!host || card.state === 'mounted' || card.state === 'loading') return
card.state = 'loading'
try {
await loadScript(card.script, card.integrity)
if (!customElements.get(card.tag)) throw new Error(`<${card.tag}> was not defined by its bundle`)
const el = document.createElement(card.tag)
// shells (the Research Gallery, Plugin Studio) find their child maps through the tree's manifest
el.setAttribute('tree-base', treeBase())
applyLocale(el, card)
host.replaceChildren(el)
card.state = 'mounted'
} catch (e) {
card.state = 'error'
card.error = String((e as Error)?.message || e)
}
}
function unmount(card: Card) {
if (card.state !== 'mounted') return
hosts.get(card.key)?.replaceChildren()
card.state = 'idle'
}
onMounted(async () => {
try {
tree = await dxMapsBaseOrThrow()
} catch (e) {
status.value = String((e as Error)?.message || e)
return
}
const base = treeBase()
// Must be set before any bundle runs so the maps fetch from the configured prayer API.
const pray = (runtimeConfig.public as { prayBaseUrl?: string }).prayBaseUrl
if (pray) (window as unknown as { MAP_APP_API_URL?: string }).MAP_APP_API_URL = pray
let manifest: { bundles?: Array<Record<string, any>> }
try {
const r = await fetch(`${base}/manifest.json`, { cache: 'no-cache' })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
manifest = await r.json()
} catch (e) {
status.value = `The manifest did not load from ${base}/manifest.json (${(e as Error).message}).`
return
}
const only = String(route.query.only || '').split(',').map((s) => s.trim()).filter(Boolean)
const out: Card[] = []
for (const b of manifest.bundles || []) {
const defined: string[] = b.elements || []
for (const p of b.profiles || []) {
// desktop-only (or any non-web) plugins are not embeddable in a web page
const targets: string[] | undefined = p.targets || b.targets
if (Array.isArray(targets) && !targets.includes('web')) continue
const key = `${b.bundle}/${p.profile}`
if (only.length && !only.includes(key) && !only.includes(b.bundle)) continue
// the tag the bundle actually defines (a profile's own element name can name one it does not)
const tag = p.element && (!defined.length || defined.includes(p.element)) ? p.element : defined[0] || p.element
out.push({ key, bundle: b.bundle, profile: p.profile, tag, script: resolveUrl(base, b.scriptUrl || `${b.bundle}/${b.bundle}.js`),
integrity: b.integrity || '', description: p.description || '', production: (p.stage || b.stage) === 'production', locale: '',
state: 'idle', error: '' })
}
}
cards.value = [...out.filter((c) => c.production), ...out.filter((c) => !c.production)]
const prod = out.filter((c) => c.production).length
status.value = `${out.length} web plugins from ${base}/manifest.json (${prod} production, ${out.length - prod} other), each embedded as JavaScript.`
await nextTick()
observer = new IntersectionObserver((entries) => {
for (const en of entries) {
const card = cards.value.find((c) => c.key === (en.target as HTMLElement).dataset.card)
if (!card) continue
if (en.isIntersecting) mount(card)
else unmount(card)
}
}, { rootMargin: '600px 0px' })
document.querySelectorAll<HTMLElement>('[data-card]').forEach((n) => observer!.observe(n))
})
onBeforeUnmount(() => {
observer?.disconnect()
hosts.forEach((h) => h.replaceChildren())
})
const setHost = (key: string) => (el: unknown) => { if (el) hosts.set(key, el as HTMLElement); else hosts.delete(key) }
</script>
<template>
<div class="container page-content embed-test stack stack--lg">
<h1>Embed test</h1>
<p data-embed-status>{{ status }}</p>
<template v-for="(c, i) in cards" :key="c.key">
<h2 v-if="i === 0 && c.production" data-embed-section="production">1. Production</h2>
<h2 v-if="!c.production && (i === 0 || cards[i - 1].production)" data-embed-section="other">2. Every other web plugin</h2>
<section class="embed-card" :data-card="c.key" :data-state="c.state" :data-production="c.production" :data-locale="c.locale">
<header class="embed-card__head">
<strong>{{ c.bundle }} / {{ c.profile }}</strong>
<code>&lt;{{ c.tag }}&gt;</code>
<span class="embed-card__state">{{ c.state }}</span>
</header>
<p v-if="c.description" class="embed-card__desc">{{ c.description }}</p>
<p v-if="c.error" class="embed-card__error" role="alert">{{ c.error }}</p>
<div :ref="setHost(c.key)" class="embed-card__host" />
</section>
</template>
</div>
</template>
<style scoped>
.embed-card { border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; }
.embed-card__head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: baseline; padding: 10px 12px; background: #f3f3f1; }
.embed-card__state { margin-inline-start: auto; font-size: 12px; color: #6d766e; }
.embed-card__desc { margin: 0; padding: 6px 12px; font-size: 13px; color: #3b463d; }
.embed-card__error { margin: 0; padding: 6px 12px; color: #a0302a; }
.embed-card__host { position: relative; height: 560px; }
.embed-card__host :deep(> *) { display: block; width: 100%; height: 100%; }
@media (max-width: 600px) { .embed-card__host { height: 480px; } }
</style>