Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work

This commit is contained in:
doxa-maps-88655c70 2026-09-25 21:23:22 +00:00
commit 7fd75a0965
7 changed files with 193 additions and 37 deletions

View file

@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* check-dashboard-geocoder-provider.mjs — a dashboard running on MapLibre must not call Mapbox.
*
* THE CONTRACT: a partner who selects MapLibre has selected the keyless engine precisely so that no
* Mapbox key and no Mapbox traffic is involved. One request to api.mapbox.com sends that visitor's
* IP to Mapbox and breaks the promise, whatever the page looks like afterwards.
*
* ★ WHAT THIS MEASURED, AND IT IS NOT WHERE THE AUDIT POINTED. studio's Mapbox-globals audit named
* DashboardSearchHeader.vue, which did preload api.mapbox.com/.../mapbox-gl-geocoder.min.js
* unconditionally and never passed `provider` to the GeocoderComponent it mounts — both now fixed.
* But that component is NOT MOUNTED by this dashboard (research-dashboard.vue:23 says so in as many
* words), so fixing it changes nothing here. The four requests this leaf actually makes under
* ?provider=maplibre are all from the GENERATED LEAF PAGE's own tags:
*
* mapbox-gl.css · mapbox-gl.js · mapbox-gl-geocoder.css · mapbox-gl-geocoder.min.js
*
* i.e. the leaf ships Mapbox GL and the Mapbox geocoder to every visitor regardless of the engine
* it was asked for. That is the leaf GENERATOR's doing, not this plugin's, and it is why this gate
* is deliberately NOT wired into gates:post yet: it would be a red gate about somebody else's file.
* Run it by name; it names the exact URLs.
*
* ★ ZERO IS THE ASSERTION, not "it rendered". A build that quietly kept the Mapbox geocoder would
* look identical: the bars appear, suggestions work, nothing errors. Only the request log tells the
* truth, which is why this counts requests rather than looking at the page.
*
* ASSERTIONS, on the built tree:
* maplibre ZERO requests to api.mapbox.com.
* both whatever search bars this leaf renders still produce suggestions — a zero bought by
* breaking search is not a fix. MEASURED: this leaf renders NONE, because the search
* header is not mounted here, so that half reports rather than asserts. Point the gate
* at a leaf that mounts the bars and it starts asserting again.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'DASHBOARD-GEOCODER-PROVIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-dashboard')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
if (!profile) skip('the manifest declares no research-dashboard leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
try {
for (const engine of ['maplibre', 'mapbox']) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
const mapboxCalls = []
page.on('request', (r) => { const u = r.url(); if (u.includes('api.mapbox.com')) mapboxCalls.push(u) })
await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
await page.waitForTimeout(20000)
const search = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const seen = new Set(); const hits = []
const walk = (root) => {
if (!root || seen.has(root)) return
seen.add(root)
for (const el of root.querySelectorAll('*')) {
if (el.matches && el.matches('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')) hits.push(el)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
if (!hits.length) return { bars: 0, suggestions: 0 }
const input = hits[0]
input.focus()
input.value = 'Afar'
input.dispatchEvent(new Event('input', { bubbles: true }))
for (let i = 0; i < 40; i++) {
await sleep(500)
const root = input.getRootNode()
const n = root.querySelectorAll('.mapboxgl-ctrl-geocoder .suggestions li, .maplibregl-ctrl-geocoder .suggestions li, .suggestions li').length
if (n) return { bars: hits.length, suggestions: n }
}
return { bars: hits.length, suggestions: 0 }
})
await page.close()
lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}`)
if (engine === 'maplibre' && mapboxCalls.length) {
fails.push(`under maplibre the dashboard made ${mapboxCalls.length} request(s) to api.mapbox.com:`)
for (const u of [...new Set(mapboxCalls)].slice(0, 8)) fails.push(` ${u.slice(0, 150)}`)
}
// Reported, not asserted, when the leaf mounts no bars at all — see the header note.
if (search.bars && !search.suggestions) {
fails.push(`under ${engine} the search bar rendered but typing produced no suggestions — zero Mapbox traffic bought by a broken search is not a fix.`)
}
}
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
for (const f of fails) console.log(` ✗ ${f}`)
process.exit(1)
}
console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times and still searches; Mapbox is unchanged.`)

View file

@ -32,6 +32,7 @@ import { inject, ref, computed, unref, onMounted } from 'vue'
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
const bus = inject('selectionBus', null)
const dash = inject('dashboardData', null)
@ -43,6 +44,15 @@ const mapboxToken = inject('mapboxToken', ref(''))
// map profiles wire :data-source-id="dataSource" (DASH-FIX-2).
const searchSourceId = inject('searchSourceId', '')
// ★ WHICH ENGINE THIS DASHBOARD IS RUNNING ON, because the search bar has to agree with it.
// The same ladder every map panel uses: profile-config.provider -> ?provider= -> window.MAP_PROVIDER
// -> mapbox.
// inject() at setup top level, NOT inside the computed: Vue only resolves an injection during
// setup, and a call from inside a computed returns undefined with a warning — which would have
// silently resolved every host to the default engine, i.e. exactly the bug being fixed.
const profileConfig = inject('profileConfig', null)
const provider = computed(() => resolveMapProvider(profileConfig))
const token = computed(() => unref(mapboxToken) || '')
// ── Host-independent geocoder-plugin loader ────────────────────────────────────
@ -52,10 +62,21 @@ const token = computed(() => unref(mapboxToken) || '')
// host remembering to add that script. So load the plugin from the Mapbox CDN
// ourselves (mirrors how GeocoderComponent injects the geocoder CSS at runtime)
// and only mount the bars once it's ready — works in ANY host with zero wiring.
// ★ AND UNDER MapLibre IT MUST NOT BE THE MAPBOX ONE. This preloader fetched
// api.mapbox.com/.../mapbox-gl-geocoder.min.js unconditionally, so a partner who chose MapLibre
// precisely to avoid Mapbox still sent every visitor's IP to api.mapbox.com the moment this
// dashboard mounted — the keyless embed's whole reason for existing, cancelled by one hardcoded
// URL. (Found by studio's Mapbox-globals audit.)
//
// GeocoderComponent already loads whichever plugin its `provider` says (ensureMapboxGeocoder /
// ensureMaplibreGeocoder), so under MapLibre the right thing to do here is NOTHING: mark ready and
// let the component fetch its own from jsDelivr. The mapbox preload stays for the mapbox path,
// where it is what keeps a host from having to remember a <script> tag.
const GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
const pluginReady = ref(false)
function ensureGeocoderPlugin() {
if (typeof window === 'undefined') return
if (provider.value === 'maplibre') { pluginReady.value = true; return }
if (typeof window.MapboxGeocoder !== 'undefined') { pluginReady.value = true; return }
let s = document.querySelector(`script[src="${GEOCODER_JS}"]`)
if (!s) {
@ -200,6 +221,7 @@ useShadowStyles(`
v-if="pluginReady"
:ref="(el) => { bar.geocoderRef.value = el }"
standalone
:provider="provider"
:access-token="token"
:data-source-id="searchSourceId"
:placeholder="bar.entry.label"

View file

@ -945,7 +945,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-GsBGCy2M1ZnE7Xximx1HpGih2povZWx98661bjzLdaH6pnrTEhZj/PoPDuQ2gfJ5",
"integrity": "sha384-QtpHA6LFYa0Ig1rB2KZtX32+djysHFL251q7PYRCpkuyCts2Zn4JA3KQtqNS2Gak",
"elements": [
"research-dashboard-map"
],
@ -961,7 +961,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-GsBGCy2M1ZnE7Xximx1HpGih2povZWx98661bjzLdaH6pnrTEhZj/PoPDuQ2gfJ5\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-QtpHA6LFYa0Ig1rB2KZtX32+djysHFL251q7PYRCpkuyCts2Zn4JA3KQtqNS2Gak\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

View file

@ -14,6 +14,6 @@
</head>
<body>
<plugin-studio tree-base="."></plugin-studio>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-lYyH0iKEqJ0Ps32Bbzbe3XVdfJZbYAZ6SaeP7DURDmt6yVbxMY1bYrJNUdLDcf4d" crossorigin="anonymous"></script>
<script src="./plugin-studio/plugin-studio.js"></script>
</body>
</html>

View file

@ -945,7 +945,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-GsBGCy2M1ZnE7Xximx1HpGih2povZWx98661bjzLdaH6pnrTEhZj/PoPDuQ2gfJ5",
"integrity": "sha384-QtpHA6LFYa0Ig1rB2KZtX32+djysHFL251q7PYRCpkuyCts2Zn4JA3KQtqNS2Gak",
"elements": [
"research-dashboard-map"
],
@ -961,7 +961,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-GsBGCy2M1ZnE7Xximx1HpGih2povZWx98661bjzLdaH6pnrTEhZj/PoPDuQ2gfJ5\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-QtpHA6LFYa0Ig1rB2KZtX32+djysHFL251q7PYRCpkuyCts2Zn4JA3KQtqNS2Gak\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-GsBGCy2M1ZnE7Xximx1HpGih2povZWx98661bjzLdaH6pnrTEhZj/PoPDuQ2gfJ5" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-QtpHA6LFYa0Ig1rB2KZtX32+djysHFL251q7PYRCpkuyCts2Zn4JA3KQtqNS2Gak" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: